diff --git a/.config/nextest.toml b/.config/nextest.toml index b72ae458c9..2ef6d6f215 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -19,10 +19,29 @@ gpu = { max-threads = 4 } filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))' test-group = 'gpu' -# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its -# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure -# at the end of the log. The default profile's GPU override still applies. +# CI (`--profile ci`, .github/workflows/ci.yml). Collect all failures in one integration run +# instead of exposing one regression per rebuild. A hanging test still terminates, and every +# failure is repeated at the end of the log. The default profile's GPU override still applies. [profile.ci] -fail-fast = true +fail-fast = false slow-timeout = { period = "60s", terminate-after = 10 } failure-output = "immediate-final" + +# Keep phase timings for the remaining expensive Windows GPU regressions visible. This does +# not relax their timeout, isolate failures differently or remove any test cases. +# Page eviction and budget borrowing redraw multi-effect documents. On the Windows +# CPU-backed adapter it uses all GPU slots, avoiding oversubscription without skipping work. +[[profile.ci.overrides]] +platform = 'cfg(target_os = "windows")' +filter = 'package(photocraft-gpu) & test(/pages_are_evicted_under_the_budget|layer_pages_that_just_miss_borrow_the_effect_map_share/)' +threads-required = 4 +success-output = "immediate" + +[[profile.ci.overrides]] +platform = 'cfg(target_os = "windows")' +filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)' +success-output = "immediate" +# These multi-page effect regressions saturate the Windows software GPU. Reserve two +# scheduling slots so four independent devices do not compete for the same CPU renderer. +# All cases and the 60-second warning remain unchanged. +threads-required = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e76c76ba1..c15bc9542b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,26 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: + full_sweep: + description: "Run every testkit sweep case (runtime comparison and integration validation)" + type: boolean + default: false + color_range: + description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)" + type: boolean + default: false + selection_combining: + description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)" + type: boolean + default: false + brush_tip: + description: "Compare cached round-tip rasterization on 24 MP" + type: boolean + default: false + brush_tip_base: + description: "Baseline commit for the round-tip comparison" + type: string + default: "main" selection_morphology: description: "Compare selection morphology on 36 MP (Linux, exact outputs)" type: boolean @@ -22,6 +42,10 @@ on: fuzz_seconds: description: "Seconds per fuzz target" default: "600" + dry_mixer: + description: "Compare the live Mixer Brush against the origin/main merge base on 24 MP (Linux)" + type: boolean + default: false # The token is only used to check out the code: no job writes to the repository. permissions: @@ -30,6 +54,7 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + PHOTOCRAFT_FULL_SWEEP: ${{ inputs.full_sweep && '1' || '0' }} # No test or fuzz run may print a page: the engine refuses to spool, and if something ever # bypasses that, lp fails loudly on a printer that does not exist. PHOTOCRAFT_NO_PRINT: "1" @@ -133,6 +158,7 @@ jobs: # Cargo.toml's dev profile already keeps line tables only. dev-debug: "" env: | + PHOTOCRAFT_FULL_SWEEP=${{ inputs.full_sweep && '1' || '0' }} PHOTOCRAFT_NO_PRINT=1 LPDEST=photocraft-no-such-printer timeout-minutes: 100 @@ -424,6 +450,36 @@ jobs: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts + color-range-performance: + if: inputs.color_range == true + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: color-range-perf + - name: Compare exact outputs and release timings on 24 MP + run: | + set -o pipefail + cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: color-range-performance + path: color-range-perf.txt + selection-morphology: if: inputs.selection_morphology == true runs-on: ubuntu-latest @@ -459,7 +515,8 @@ jobs: path: selection-morphology-perf.txt selection-morphology-quick-perf: - if: inputs.selection_morphology == true + if: inputs.selection_morphology == true || inputs.color_range == true || inputs.selection_combining == true || inputs.brush_tip == true || inputs.dry_mixer == true + name: quick performance suite # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by # bevel_bench, so its required GPU rows cannot be measured there. runs-on: macos-15 @@ -481,3 +538,128 @@ jobs: path: | target/perf/*.json target/perf/summary.md + + selection-combining: + if: inputs.selection_combining == true + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: selection-combining-perf + - name: Compare exact outputs and release timings on 36 MP + run: | + set -o pipefail + cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: selection-combining-performance + path: selection-combining-perf.txt + + brush-tip: + if: inputs.brush_tip == true + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + RAYON_NUM_THREADS: "4" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + path: after + - uses: actions/checkout@v7 + with: + persist-credentials: false + ref: ${{ inputs.brush_tip_base }} + path: before + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + workspaces: | + before -> target + after -> target + key: brush-tip-release + - name: Build the same benchmark against both versions + run: | + cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs + cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench + cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench + - name: Compare round and transformed tips on 24 MP + run: | + set -o pipefail + for size in 200 2000; do + for angle in 0 0.7; do + for version in before after; do + echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt + "$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt + done + done + done + - uses: actions/upload-artifact@v7 + if: always() + with: + name: brush-tip-performance + path: brush-tip.txt + dry-mixer: + if: inputs.dry_mixer == true + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + RAYON_NUM_THREADS: "4" + CARGO_PROFILE_RELEASE_LTO: "false" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Pin baseline to the merge base with origin/main + id: baseline + run: | + git remote add upstream https://github.com/storytold/photocraft.git + git fetch --no-tags upstream main + base=$(git merge-base HEAD upstream/main) + echo "sha=$base" >> "$GITHUB_OUTPUT" + git worktree add --detach "$RUNNER_TEMP/photocraft-baseline" "$base" + mkdir -p "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples" + cp crates/engine/examples/bench_dry_mixer.rs "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples/bench_dry_mixer.rs" + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + - name: Benchmark baseline and candidate on this runner + run: | + set -o pipefail + echo "baseline=${{ steps.baseline.outputs.sha }}" | tee dry-mixer-perf.txt + echo "candidate=${GITHUB_SHA}" | tee -a dry-mixer-perf.txt + CARGO_TARGET_DIR="$RUNNER_TEMP/target-baseline" cargo run --release --manifest-path "$RUNNER_TEMP/photocraft-baseline/Cargo.toml" -p photocraft-engine --example bench_dry_mixer | tee baseline-mixer.txt + CARGO_TARGET_DIR="$RUNNER_TEMP/target-candidate" cargo run --release -p photocraft-engine --example bench_dry_mixer | tee candidate-mixer.txt + cat baseline-mixer.txt candidate-mixer.txt >> dry-mixer-perf.txt + python3 - <<'PY' + import re + def fingerprints(path): + rows = open(path).readlines() + result = {} + for row in rows: + match = re.search(r'wet=\s*(\d+)% sample_all=\s*(\w+)\s+size=\s*(\d+).*blake3=(\w+)', row) + if match: + result[match.group(1, 2, 3)] = match.group(4) + return result + before, after = fingerprints('baseline-mixer.txt'), fingerprints('candidate-mixer.txt') + assert len(before) == 12 and before == after, 'baseline/candidate mixer output or state differs' + PY + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: dry-mixer-performance + path: dry-mixer-perf.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b78fac526..8298b49f3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} @@ -41,3 +42,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 27111f3b94..fe092e498a 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: photocraft app-dir: PhotoCraft diff --git a/AGENTS.md b/AGENTS.md index e1ffe55928..32074b4e45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md: guide for AI agents and contributors -PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`. +PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`. ## 1. Orientation (5 minutes) @@ -49,7 +49,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t ### Never crash (outranks feature work) -People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`. +People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. - **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`. - **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else. @@ -62,7 +62,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file, 1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`). 2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths. -3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts. +3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts. 4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing. 5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded. 6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent. @@ -80,7 +80,7 @@ Priorities: important infrastructure first, then low-hanging parity, then the lo dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md` (menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap, update the parity doc and gap entry it belongs to, with the date, and bump their status line - and revision history (craftrules `standards/progress-docs.md`). + and revision history. 1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows, the performance scenarios that are over budget or not measurable yet, and the count of settings that do nothing. Its numbers are measured; prefer them to estimates. @@ -130,7 +130,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still - `docs/parity-checklist.md`: generated Photoshop menu coverage. - `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit). - `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`. -- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics. - `plan/` (local, gitignored): research, parity plan, execution plan, estimates. - `log/` (local, gitignored): the dev log. @@ -150,11 +149,13 @@ If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown P username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 83c583649f..ffbf025415 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -23,22 +23,29 @@ next to them. | `crates/ui-egui/src/i18n/cs.tsv` | Czech 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/hu.tsv` | Hungarian 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/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/ar.tsv` | Arabic 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) | +| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and 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/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and 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/da.tsv` | Danish UI translations | @pechnikov2026-star and 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/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and 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) | | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.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/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `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/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn 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 | @@ -90,8 +97,12 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 | +| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 | | `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | | `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` | +| `docs/images/multi-channel-editing/{curves,curves-choices,levels,drawing,mask-view-before,mask-view-after}.png` | Selected colour channel editing (synthetic gradient) | @hidoba | Original synthetic gradient rendered with the offscreen `snapshot` example; adjacent JSON scripts | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` | +| `docs/images/multi-channel-editing/great-wave.jpg`, `rgb.png`, `cmyk.png`, `lab.png`, `gallery-channel-preview-{before,after}.png` | *The Great Wave off Kanagawa*, c. 1830–1832, and PhotoCraft channel-selection captures | Katsushika Hokusai (artwork); PhotoCraft contributors (UI) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg), Metropolitan Museum reproduction; adjacent JSON capture scripts | Public domain artwork (PD-Art), adjacent `LICENSE-PUBLIC-DOMAIN.txt`; UI/scripts MIT OR Apache-2.0 | Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in [`docs/images/SOURCES.md`](docs/images/SOURCES.md)): @@ -110,7 +121,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta ## Test data (not committed, not shipped) -`corpus/` is gitignored and no test fixtures are committed to the repository. +`corpus/` is gitignored and no test fixtures are committed to the repository, except the small +synthetic parser fixtures listed under *Synthetic parser test fixtures* below. `cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs` and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence next to the files: @@ -129,3 +141,18 @@ next to the files: | `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 | Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. + +## Synthetic parser test fixtures + +| Path | Title | Author | Source | License | +|---|---|---|---|---| +| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 and version-12 parser fixtures | PhotoCraft contributors (version-12 files: @yoriswind) | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 | + +## Embedded-vector editor source + +The external SVG watcher, engine/UI handoff and bounded SVG rendering in this change +are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in +[PhotoCraft #3409](https://github.com/storytold/photocraft/pull/3409), commit +`897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS +extension and current-main integration build on that source. Clipboard and unrelated +Shape Tools changes from that branch are not included. diff --git a/Cargo.lock b/Cargo.lock index 8f4fd3a185..ab0417b79b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,7 +323,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -650,7 +650,7 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -1350,17 +1350,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1383,17 +1383,28 @@ dependencies = [ [[package]] name = "craft-jpeg" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pcraft-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b8f7d93b00dfb8f172938f3d2d796ae62dcfe123#b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree 0.21.1", "tempfile", @@ -1404,7 +1415,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1425,13 +1436,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -2096,7 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3297,7 +3309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3343,15 +3355,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -3887,6 +3890,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + [[package]] name = "maybe-owned" version = "0.3.4" @@ -4139,6 +4148,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -4551,6 +4569,16 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4774,7 +4802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4961,10 +4989,12 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.17.0" +version = "0.20.1" dependencies = [ "arboard", "async-io", + "base64 0.22.1", + "blake3", "chrono", "clap", "core-graphics2", @@ -4993,9 +5023,12 @@ dependencies = [ "photocraft-raster", "photocraft-tablet", "photocraft-ui-egui", + "plist", "resvg", "rfd", "ron", + "roxmltree 0.21.1", + "rustix 1.1.5", "serde", "serde_json", "tempfile", @@ -5011,7 +5044,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.17.0" +version = "0.20.1" dependencies = [ "crc32fast", "flate2", @@ -5021,8 +5054,9 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.17.0" +version = "0.20.1" dependencies = [ + "criterion", "photocraft-color", "photocraft-geom", "photocraft-raster", @@ -5035,7 +5069,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.17.0" +version = "0.20.1" dependencies = [ "base64 0.23.1", "cap-std", @@ -5046,7 +5080,9 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", + "photocraft-geom", "photocraft-io", + "photocraft-psd", "photocraft-raster", "photocraft-raw", "rmcp", @@ -5060,7 +5096,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.17.0" +version = "0.20.1" dependencies = [ "clap", "clap_complete", @@ -5079,7 +5115,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.17.0" +version = "0.20.1" dependencies = [ "moxcms", "rayon", @@ -5087,7 +5123,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.17.0" +version = "0.20.1" dependencies = [ "craft-jpeg", "exr", @@ -5096,6 +5132,7 @@ dependencies = [ "image", "image-webp", "jpeg-encoder", + "jxl-oxide", "photocraft-heif", "png 0.18.1", "proptest", @@ -5105,11 +5142,12 @@ dependencies = [ "tiff", "zune-core 0.5.3", "zune-jpeg 0.5.15", + "zune-jpegxl", ] [[package]] name = "photocraft-color" -version = "0.17.0" +version = "0.20.1" dependencies = [ "photocraft-cms", "serde", @@ -5117,7 +5155,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.17.0" +version = "0.20.1" dependencies = [ "criterion", "photocraft-cms", @@ -5133,7 +5171,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.17.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5144,8 +5182,9 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.17.0" +version = "0.20.1" dependencies = [ + "base64 0.22.1", "blake3", "chrono", "craft-print", @@ -5185,9 +5224,10 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.17.0" +version = "0.20.1" dependencies = [ "blake3", + "craft-pcraft-format", "crc32fast", "exacl", "flate2", @@ -5208,14 +5248,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.17.0" +version = "0.20.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.17.0" +version = "0.20.1" dependencies = [ "bytemuck", "half", @@ -5235,19 +5275,20 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.17.0" +version = "0.20.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.17.0" +version = "0.20.1" dependencies = [ "blake3", "craft-docpath", "crc32fast", "flate2", + "imagesize", "miniz_oxide 0.9.1", "photocraft-affinity", "photocraft-algo", @@ -5277,31 +5318,32 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.17.0" +version = "0.20.1" [[package]] name = "photocraft-ops" -version = "0.17.0" +version = "0.20.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.17.0" +version = "0.20.1" dependencies = [ "photocraft-cms", "photocraft-color", "photocraft-geom", "photocraft-raster", "rayon", + "rustc-hash 2.1.3", "serde", "serde_json", ] [[package]] name = "photocraft-plugins" -version = "0.17.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5317,8 +5359,9 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.17.0" +version = "0.20.1" dependencies = [ + "encoding_rs", "flate2", "photocraft-psd", "proptest", @@ -5328,18 +5371,22 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.17.0" +version = "0.20.1" dependencies = [ + "crc32fast", + "lz4_flex", "photocraft-color", "photocraft-geom", "proptest", "rayon", "serde", + "sysinfo", + "winsafe", ] [[package]] name = "photocraft-raw" -version = "0.17.0" +version = "0.20.1" dependencies = [ "flate2", "image", @@ -5354,7 +5401,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.17.0" +version = "0.20.1" dependencies = [ "block2 0.6.2", "objc2 0.6.5", @@ -5366,7 +5413,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.17.0" +version = "0.20.1" dependencies = [ "memory-stats", "serde_json", @@ -5374,7 +5421,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.17.0" +version = "0.20.1" dependencies = [ "flate2", "parley", @@ -5389,7 +5436,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.17.0" +version = "0.20.1" dependencies = [ "base64 0.23.1", "craft-print", @@ -5434,7 +5481,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.17.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5445,7 +5492,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.17.0" +version = "0.20.1" dependencies = [ "eframe", "egui", @@ -5467,7 +5514,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.17.0" +version = "0.20.1" dependencies = [ "flate2", "js-sys", @@ -5899,7 +5946,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6434,7 +6481,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6503,7 +6550,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6637,6 +6684,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7323,6 +7381,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sysinfo" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -7381,10 +7453,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8653,7 +8725,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8906,6 +8978,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", @@ -9183,7 +9256,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.17.0" +version = "0.20.1" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 720b16776b..e01bcf175a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.17.0" +version = "0.20.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } # internal photocraft-geom = { path = "crates/geom" } photocraft-color = { path = "crates/color" } @@ -39,10 +39,12 @@ photocraft-tablet = { path = "crates/tablet" } photocraft-ipp = { path = "crates/ipp" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" @@ -52,6 +54,7 @@ clap_mangen = { version = "0.3.3", features = ["env"] } arc-swap = "1" bytemuck = { version = "1", features = ["derive"] } flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] } +encoding_rs = { version = "0.8", default-features = false, features = ["alloc"] } image = { version = "0.25", default-features = false } # Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg. zune-core = "0.5" @@ -67,6 +70,7 @@ 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" +rustc-hash = "2.1.3" criterion = { version = "0.8", default-features = false } getrandom = "0.3" # Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump). diff --git a/NOTICE b/NOTICE index 6250865673..9ab6b0a1cf 100644 --- a/NOTICE +++ b/NOTICE @@ -23,6 +23,37 @@ https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDP BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts ship with the package as OFL-.txt. +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs. It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies. diff --git a/README.md b/README.md index a22f1aee26..b1b453a318 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,8 @@ The Flatpak prints through the host's CUPS socket (File › Print talks IPP to i The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. +To use the bundled CLI, run `./photocraft--linux-x86_64.AppImage --cli --help`, or `--cli mcp` to start the MCP server on stdio. CLI launches skip desktop integration. A symlink named `photocraft-cli` also selects the CLI; see [`docs/development.md`](docs/development.md#headless-cli). + On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself: @@ -394,13 +396,22 @@ stand on its own. | | App | What it's for | Code | Learn more | |:-:|---|---|---|---| -| | **PhotoCraft** | **Image editing: layers, masks, type and real PSD files · you are here** | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | +| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index 829ff75af8..16943f6767 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,8 +122,10 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-11-02 | Load Actions: Mac Roman step names with é ä ö ü Ä or ° read as Mac Roman instead of windows-1252 or Cyrillic (#10774): windows-1252's rare 0x80–0x9F letters and a lone Cyrillic letter in a Latin word no longer count as common text, the degree sign does, and the file's own Unicode text settles a word-initial rare letter and breaks exact ties toward windows-1252 or Mac Roman | +| 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). | | 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-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized (`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 | | 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster | @@ -143,4 +145,4 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | 2026-10-10 | minor | Readiness by audience with hours per tier | | 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added | | 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation | -| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` | +| 2026-10-10 | major | First version | diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 4d494c8a82..f8b900e49a 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -83,11 +83,11 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), ActionsError> { let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; - let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); + let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || matches!(bytes.get(..4), Some([0, 0, 0, 12 | 16])); if !is_atn { let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?; let (steps, set) = load_actions(&text, name)?; diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 91b99ef33b..c3583bf83d 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -55,6 +55,14 @@ pub(crate) struct ExportArgs { /// Compress TIFF output: none (the default), lzw or zip #[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)] pub(crate) tiff_compression: Option, + /// TIFF Save Transparency: on keeps the transparency (ahead of any alpha or spot channels), + /// off composites the image over white. Without it: kept unless the document has alpha or + /// spot channels + #[arg(long, value_name = "on|off", value_parser = on_off)] + pub(crate) tiff_transparency: Option, + /// Also write a TIFF image pyramid: half-size copies after the full image + #[arg(long)] + pub(crate) tiff_pyramid: bool, /// 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 #[arg(long, value_name = "24|32", value_parser = tga_bits)] @@ -74,6 +82,10 @@ pub(crate) struct ExportArgs { /// and CMYK shrink) or progressive #[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)] pub(crate) jpeg_format: Option, + /// Request the flattened document at its canvas size in .pcraft output as + /// composite/full.png; it may be omitted. Check the manifest's full_composite member. + #[arg(long)] + pub(crate) pcraft_full_composite: bool, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -91,6 +103,9 @@ pub(crate) struct ConvertArgs { /// Allow an that is the file, replacing the original #[arg(long)] pub(crate) in_place: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, #[command(flatten)] pub(crate) export: ExportArgs, } @@ -102,6 +117,9 @@ pub(crate) struct InfoArgs { /// One line of JSON instead of pretty-printed #[arg(long)] pub(crate) compact: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, } /// Open a file (or start a new document), run engine commands in order, save the result @@ -130,6 +148,9 @@ pub(crate) struct RunArgs { /// Allow an --out that is the , replacing the original #[arg(long)] pub(crate) in_place: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, #[command(flatten)] pub(crate) export: ExportArgs, /// `--cmd` and `--params` values in command-line order, set by [`parse`]. @@ -169,6 +190,25 @@ pub(crate) struct BatchArgs { /// originals; --in-place allows it #[arg(long)] pub(crate) in_place: bool, + /// Include images in subfolders; every result goes straight into --out, so a later file of + /// the same name replaces an earlier one + #[arg(long)] + pub(crate) recursive: bool, + /// Name each result with Batch tokens such as {name}, {serial}, {date}, and {ext} + #[arg(long, value_name = "TEMPLATE")] + pub(crate) naming: Option, + /// First serial number used by --naming + #[arg(long, value_name = "1-4294967295", value_parser = clap::value_parser!(u32).range(1..))] + pub(crate) start_serial: Option, + /// Stop after the first failed input + #[arg(long)] + pub(crate) stop_on_error: bool, + /// Write failed inputs and reasons to this text file + #[arg(long, value_name = "FILE")] + pub(crate) error_log: Option, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, #[command(flatten)] pub(crate) export: ExportArgs, } @@ -252,6 +292,8 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + #[error("expected on or off")] + OnOff, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, @@ -306,6 +348,14 @@ fn tiff_compression(s: &str) -> Result { } } +fn on_off(s: &str) -> Result { + match s { + "on" => Ok(true), + "off" => Ok(false), + _ => Err(ValueError::OnOff), + } +} + fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index bdaf249bc9..347f829443 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -58,32 +58,77 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> if !a.in_place && same_dir(in_dir, out_dir) { return Err(CliError::OutIsIn(out_dir.to_path_buf())); } - std::fs::create_dir_all(out_dir)?; - let mut inputs: Vec = std::fs::read_dir(in_dir) - .map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })? - .flatten() - .map(|e| e.path()) - .filter(|p| is_input(p)) - .collect(); - inputs.sort(); + let inputs: Vec = if a.recursive { + photocraft_engine::file_cmds::list_images_tree(&in_dir.to_string_lossy(), &out_dir.to_string_lossy(), "photocraft-cli batch")? + .into_iter() + .filter(|input| is_input(Path::new(&input.path))) + .collect() + } else { + let mut images = Vec::new(); + let entries = std::fs::read_dir(in_dir).map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?; + for entry in entries { + let path = entry.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?.path(); + if is_input(&path) { + images.push(photocraft_engine::file_cmds::BatchInput { path: path.to_string_lossy().into_owned(), rel_dir: String::new() }); + } + } + // The tree listing already holds the engine's order (each folder's files, then its + // subfolders), which `{serial}` numbers; only the flat listing needs sorting. + images.sort_by(|a, b| a.path.cmp(&b.path)); + images + }; let (mut ok, mut failed) = (0, 0); - let mut written = photocraft_engine::file_cmds::OutputClaims::default(); // `--format .jpg` names outputs `.jpg`, as `--format jpg` does (#490). let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f)); - for input in &inputs { - let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into()); - let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); - let target = out_dir.join(format!("{stem}.{ext}")); - let target_text = target.to_string_lossy(); + let named = photocraft_engine::file_cmds::plan_batch_names( + &inputs, + &out_dir.to_string_lossy(), + format.unwrap_or("same"), + &json!({"naming": a.naming.as_deref().unwrap_or(""), "startingSerial": a.start_serial.unwrap_or(1)}), + ) + .map_err(|error| CliError::Naming(error.to_string()))?; + let targets: Vec = match named { + Some(paths) => paths.into_iter().map(PathBuf::from).collect(), + None => inputs + .iter() + .map(|input| { + let path = Path::new(&input.path); + let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.path), str::to_owned); + let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + out_dir.join(format!("{stem}.{ext}")) + }) + .collect(), + }; + let plan: Vec<_> = inputs.iter().zip(&targets).map(|(input, target)| (input.path.clone(), target.to_string_lossy().into_owned())).collect(); + photocraft_engine::file_cmds::validate_flat_outputs(&plan).map_err(|error| CliError::Naming(error.to_string()))?; + if let Some(log) = &a.error_log { + let same = |path: &Path| photocraft_engine::file_cmds::same_destination(log, path); + if inputs.iter().any(|input| same(Path::new(&input.path))) || targets.iter().any(|target| same(target)) { + return Err(CliError::BatchOption("--error-log must not replace an input or batch output".into())); + } + } + std::fs::create_dir_all(out_dir)?; + let mut failures = Vec::new(); + for (input, target) in inputs.iter().zip(targets) { + let input = Path::new(&input.path); + // The selected format encodes the bytes, whatever extension `--naming` gives the file. + // Without --format, a file PhotoCraft only opens (.kra, raw, …) is saved as PSD, the + // same format `--naming`'s {ext} plans. + let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.to_string_lossy()), str::to_owned); let r = (|| -> Result> { - written.check(&target_text).map_err(CliError::OutputClaimed)?; + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } let mut h = Headless::trusted_local(); // Steps that play another action find it in the set; the session's playback stack // stops an action that calls itself. h.session.actions.list = set.clone(); - h.open(input)?; + h.open_with_approval(input, a.approve_high_memory)?; for (id, p) in &actions { - if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) { + if photocraft_engine::actions_cmds::shell_view_command(id) + || photocraft_engine::actions_cmds::shell_save_command(id) + || photocraft_engine::actions_cmds::noop_step(&h.session, id, p) + { continue; } let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?; @@ -98,17 +143,31 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> match r { Ok(ws) => { ok += 1; - written.record(&target_text, &input.to_string_lossy()); let _ = writeln!(out, "ok {} -> {}", input.display(), target.display()); warn_all(err, &ws); } Err(e) => { failed += 1; let _ = writeln!(err, "FAIL {}: {e}", input.display()); + failures.push((input.to_string_lossy().into_owned(), e.to_string())); + if a.stop_on_error { + break; + } } } } - let _ = writeln!(out, "{ok} succeeded, {failed} failed"); + let skipped = inputs.len().saturating_sub(ok + failed); + if skipped > 0 { + let _ = writeln!(err, "--stop-on-error: {skipped} remaining input(s) skipped"); + let _ = writeln!(out, "{ok} succeeded, {failed} failed, {skipped} skipped"); + } else { + let _ = writeln!(out, "{ok} succeeded, {failed} failed"); + } + // After the summary, so a log that can't be written doesn't hide the counts. + if let Some(path) = &a.error_log { + let bytes = photocraft_engine::file_cmds::encode_batch_error_log(failures.iter().map(|(file, error)| (file.as_str(), error.as_str()))); + photocraft_format::atomic_write(path, &bytes)?; + } if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) } } diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 9d730b4635..4c6678bfb1 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -30,7 +30,8 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write let (input, output) = (&a.input, &a.output); let opts = ExportOptions::from(&a.export); refuse_overwriting_input(a.in_place, input, output)?; - let mut o = files::open(input)?; + let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() }; + let mut o = files::open_with_options(input, &import_opts)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); @@ -43,7 +44,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result { let file = &a.file; let mut h = Headless::trusted_local(); - let opened = h.open(file)?; + let opened = h.open_with_approval(file, a.approve_high_memory)?; let mut doc = h.inspect(None)?; let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); if let Some(st) = h.session.active() { @@ -86,13 +87,18 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> if let Some(out) = &a.out { refuse_overwriting_input(a.in_place, file, out)?; } - let o = h.open(file)?; + let o = h.open_with_approval(file, a.approve_high_memory)?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); warn_all(err, &ws); reported_fonts.extend(ws); } let cmds = command_list(a)?; for (id, params) in cmds { + // A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802). + if photocraft_engine::actions_cmds::noop_step(&h.session, &id, ¶ms) { + print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?; + continue; + } let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?; if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { return Err(CliError::NestedFailure { id, message }); diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs index 3e4aa3caef..55e8df48e2 100644 --- a/apps/photocraft-cli/src/error.rs +++ b/apps/photocraft-cli/src/error.rs @@ -32,9 +32,10 @@ pub(crate) enum CliError { OutIsInput(PathBuf), #[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())] OutIsIn(PathBuf), - /// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]). + #[error("--naming: {0}")] + Naming(String), #[error("{0}")] - OutputClaimed(String), + BatchOption(String), #[error("{0} file(s) failed")] FilesFailed(usize), } diff --git a/apps/photocraft-cli/src/main.rs b/apps/photocraft-cli/src/main.rs index ea5598f99d..6e21c2ac44 100644 --- a/apps/photocraft-cli/src/main.rs +++ b/apps/photocraft-cli/src/main.rs @@ -1,6 +1,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { + // May re-execute this process with glibc tuned to return freed document memory (#8743). + photocraft_engine::malloc_tuning::apply(); // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap, // not panic before anything is printed (issue #1108). let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 34101c9ec0..ac37ec0537 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -8,7 +8,13 @@ use crate::{Result, args::ExportArgs}; impl From<&ExportArgs> for ExportOptions { fn from(a: &ExportArgs) -> Self { // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() }; + let mut o = ExportOptions { + tiff_layers: a.tiff_layers, + tiff_transparency: a.tiff_transparency, + tga_bits: a.tga_bits, + pcraft_full_composite: a.pcraft_full_composite, + ..Default::default() + }; if let Some(q) = a.quality { o.encode.jpeg_quality = q; // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. @@ -19,6 +25,7 @@ impl From<&ExportArgs> for ExportOptions { if let Some(c) = a.tiff_compression { o.encode.tiff_compression = c; } + o.encode.tiff_pyramid = a.tiff_pyramid; if let Some(f) = a.jpeg_format { o.encode.jpeg_format = f; } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 2c06a488fd..386eafe62c 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -434,9 +434,25 @@ fn batch_applies_actions_to_directory() { write_png(&inp.join("two.png"), 9, 9, 2); std::fs::write(inp.join("notes.txt"), "ignored").unwrap(); let actions = d.join("actions.json"); + let log = d.join("errors.txt"); std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap(); - let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout; + let out = ok(bin() + .args(["batch", "--actions"]) + .arg(&actions) + .arg("--in") + .arg(&inp) + .arg("--out") + .arg(&outp) + .args(["--format", "pcraft", "--error-log"]) + .arg(&log)) + .stdout; assert!(out.contains("2 succeeded, 0 failed"), "{out}"); + let clean_log = std::fs::read(&log).unwrap(); + assert!(clean_log.starts_with(&[0xff, 0xfe])); + let (pairs, remainder) = clean_log[2..].as_chunks::<2>(); + assert!(remainder.is_empty()); + let body = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::>()).unwrap(); + assert_eq!(body, "Batch Start\r\n\r\nBatch End\r\n"); for n in ["one", "two"] { let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout; let v: Value = serde_json::from_str(&info).unwrap(); @@ -450,6 +466,127 @@ fn batch_applies_actions_to_directory() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn batch_recursive_naming_stop_and_error_log() { + let root = tmp("batch-recursive-options"); + let input = root.join("in"); + std::fs::create_dir_all(input.join("sub")).unwrap(); + for file in ["a.png", "sub/a.png", "sub/c.png"] { + write_png(&input.join(file), 8, 6, 3); + } + std::fs::write(input.join("sub/b.png"), b"not a png").unwrap(); + let actions = root.join("actions.json"); + std::fs::write(&actions, r#"[["image.imageRotation.90cw", {}]]"#).unwrap(); + let output = root.join("out"); + let log = root.join("errors.txt"); + let run = |out: &Path, extra: &[&str]| { + bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap() + }; + let result = run(&output, &["--recursive", "--naming", "{name}-{serial:2}.{ext}", "--start-serial", "5", "--error-log", &log.to_string_lossy()]); + let stdout = String::from_utf8_lossy(&result.stdout); + assert!(!result.status.success(), "{stdout}"); + assert!(stdout.contains("3 succeeded, 1 failed"), "{stdout}"); + for file in ["a-05.png", "a-06.png", "c-08.png"] { + assert!(output.join(file).is_file(), "{file}: {stdout}"); + } + let bytes = std::fs::read(log).unwrap(); + assert!(bytes.starts_with(&[0xff, 0xfe])); + let (pairs, remainder) = bytes[2..].as_chunks::<2>(); + assert!(remainder.is_empty()); + let errors = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::>()).unwrap(); + assert!(errors.contains("Batch Start\r\n") && errors.contains("b.png") && errors.ends_with("Batch End\r\n"), "{errors}"); + + let stopped = root.join("stopped"); + let result = run(&stopped, &["--recursive", "--stop-on-error"]); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!(!result.status.success(), "{stdout}"); + assert!(stdout.contains("2 succeeded, 1 failed, 1 skipped"), "{stdout}"); + assert!(stderr.contains("--stop-on-error"), "{stderr}"); + assert!(!stopped.join("c.png").exists()); + + let collision = root.join("collision"); + let alias = collision.join("../collision/a.png"); + let result = run(&collision, &["--recursive", "--error-log", &alias.to_string_lossy()]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("--error-log")); + assert!(!collision.join("a.png").exists()); +} + +/// `{serial}` numbers a recursive batch in the engine's order (a folder's files before its +/// subfolders), `--format` encodes the bytes whatever extension `--naming` writes, and an +/// `--error-log` that can't be written still leaves the summary printed. +#[test] +fn batch_naming_keeps_the_tree_order_and_the_selected_encoder() { + let root = tmp("batch-naming-order"); + let input = root.join("in"); + std::fs::create_dir_all(input.join("sub")).unwrap(); + write_png(&input.join("z.png"), 8, 6, 3); + write_png(&input.join("sub/a.png"), 5, 4, 3); + let actions = root.join("actions.json"); + std::fs::write(&actions, "[]").unwrap(); + let run = |out: &Path, extra: &[&str]| { + bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap() + }; + let png_width = |path: &Path| { + let bytes = std::fs::read(path).unwrap(); + u32::from_be_bytes(bytes[16..20].try_into().unwrap()) + }; + let ordered = root.join("ordered"); + let result = run(&ordered, &["--recursive", "--naming", "{serial:2}.{ext}"]); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + assert_eq!(png_width(&ordered.join("01.png")), 8, "the root file comes first"); + assert_eq!(png_width(&ordered.join("02.png")), 5); + + let jpeg = root.join("jpeg"); + let result = run(&jpeg, &["--format", "jpg", "--naming", "{name}_{serial}"]); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + assert!(std::fs::read(jpeg.join("z_001")).unwrap().starts_with(&[0xff, 0xd8]), "JPEG bytes without an extension"); + let png = root.join("png"); + let result = run(&png, &["--format", "png", "--naming", "{name}.jpg"]); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + assert!(std::fs::read(png.join("z.jpg")).unwrap().starts_with(b"\x89PNG"), "PNG bytes under a .jpg name"); + + let log = root.join("log-is-a-folder"); + std::fs::create_dir_all(&log).unwrap(); + let result = run(&root.join("logged"), &["--error-log", &log.to_string_lossy()]); + let stdout = String::from_utf8_lossy(&result.stdout); + assert!(!result.status.success(), "{stdout}"); + assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}"); + std::fs::remove_dir_all(root).unwrap(); +} + +/// Without --format, a file PhotoCraft only opens is saved as PSD, with or without --naming: +/// the encoder gets the format the planned name uses, not the input's import-only extension. +#[test] +fn batch_saves_import_only_inputs_as_psd() { + let root = tmp("batch-import-only"); + let input = root.join("in"); + std::fs::create_dir_all(&input).unwrap(); + std::fs::write( + input.join("logo.xpm"), + "/* XPM */ +static char *logo[] = { +\"2 2 1 1\", +\"a c #FF0000\", +\"aa\", +\"aa\" +}; +", + ) + .unwrap(); + let actions = root.join("actions.json"); + std::fs::write(&actions, "[]").unwrap(); + for (name, extra) in [("plain", &[][..]), ("named", &["--naming", "{name}.{ext}"][..])] { + let out = root.join(name); + let result = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out).args(extra).output().unwrap(); + assert!(result.status.success(), "{name}: {}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); + assert!(std::fs::read(out.join("logo.psd")).unwrap().starts_with(b"8BPS"), "{name}: PSD bytes"); + assert!(!out.join("logo.xpm").exists(), "{name}"); + } + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn batch_skips_recorded_view_and_save_steps() { let d = tmp("batch-shell-steps"); @@ -804,6 +941,33 @@ 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"); } +#[test] +fn batch_naming_rejects_unread_input_alias_through_a_missing_directory() { + let d = tmp("batch-missing-directory-alias"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + write_png(&input.join("b.png"), 8, 4, 7); + let original = std::fs::read(input.join("b.png")).unwrap(); + let actions = d.join("actions.json"); + std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let output = bin() + .arg("batch") + .arg("--actions") + .arg(&actions) + .arg("--in") + .arg(&input) + .arg("--out") + .arg(input.join("missing/..")) + .args(["--in-place", "--naming", "b.{ext}"]) + .output() + .unwrap(); + assert!(!output.status.success(), "{}", String::from_utf8_lossy(&output.stdout)); + assert!(String::from_utf8_lossy(&output.stderr).contains("replace an input")); + assert_eq!(std::fs::read(input.join("b.png")).unwrap(), original); + assert!(!input.join("missing").exists()); +} + /// #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] @@ -944,9 +1108,9 @@ fn mcp_bridge_takes_a_port_or_an_address() { let _ = child.wait(); } -/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420). +/// Photoshop Batch processes every input in order, replacing earlier flat results with the same name. #[test] -fn batch_fails_inputs_that_share_an_output_name() { +fn batch_later_input_replaces_a_same_named_output() { let d = tmp("batch-collide"); let input = d.join("in"); std::fs::create_dir_all(&input).unwrap(); @@ -961,15 +1125,10 @@ fn batch_fails_inputs_that_share_an_output_name() { let out_dir = d.join("out"); let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap(); let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); - assert!( - !o.status.success(), - "a lost result must fail the run -{out} -{err}" - ); - assert!(out.contains("1 succeeded, 2 failed"), "{out}"); - assert_eq!(err.matches("not written").count(), 2, "{err}"); - assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1); + assert!(o.status.success(), "{out}{err}"); + assert!(out.contains("3 succeeded, 0 failed"), "{out}"); + assert!(out_dir.join("a.png").is_file()); + assert!(std::fs::read_dir(&out_dir).unwrap().count() <= 2); // Keeping each file's format, every input gets its own output. let out_dir = d.join("same"); let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout; @@ -1025,6 +1184,26 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn pcraft_output_has_no_full_composite_unless_asked() { + let d = tmp("pcraft-full-composite"); + let full = |path: &std::path::Path| photocraft_format::read_full_composite(&std::fs::read(path).unwrap()).unwrap().is_some(); + for (name, flag, expect) in [("plain.pcraft", None, false), ("full.pcraft", Some("--pcraft-full-composite"), true)] { + let out = d.join(name); + let mut cmd = bin(); + cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]); + if let Some(flag) = flag { + cmd.arg(flag); + } + ok(cmd.arg("--out").arg(&out)); + assert_eq!(full(&out), expect, "{name}"); + } + let converted = d.join("converted.pcraft"); + ok(bin().arg("convert").arg(d.join("plain.pcraft")).arg(&converted).arg("--pcraft-full-composite")); + assert!(full(&converted)); + std::fs::remove_dir_all(d).unwrap(); +} + /// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in /// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path /// takes one, and text (`--filter`) refuses it with a usage error. @@ -1090,6 +1269,20 @@ fn batch_plays_a_photoshop_actions_file() { assert!(!o.status.success()); } +/// A version 12 Actions file (Photoshop 5, often saved on classic Mac OS without an extension) is +/// recognised by its version word like version 16, not read as a JSON actions list. +#[test] +fn extensionless_version_12_actions_file_is_read_as_atn() { + let d = tmp("atn-v12"); + let path = d.join("Legacy Actions"); + std::fs::write(&path, include_bytes!("../../../../crates/psd/tests/fixtures/atn/v12-commands.atn")).unwrap(); + let (steps, set, warnings) = photocraft_cli::load_actions_file(&path, None).unwrap(); + assert_eq!(set.len(), 1); + assert_eq!(steps, vec![("actions.play".to_string(), json!({"action": set[0].name}))]); + assert!(warnings.is_empty(), "{warnings:?}"); + std::fs::remove_dir_all(d).unwrap(); +} + /// What `photocraft_cli::run` returned and wrote, in-process. struct Invoke { code: i32, @@ -1421,3 +1614,110 @@ fn control_token_env_vars_are_the_flags_fallbacks() { let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", "")); assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}"); } + +/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744), +/// so a script that deselects after Reveal Selection still finishes and saves. +#[test] +fn run_skips_deselect_with_nothing_selected() { + let folder = tmp("deselect-run"); + let native = folder.join("out.pcraft").to_string_lossy().to_string(); + let Invoke { code, stdout, stderr } = invoke(&[ + "run", + "--new", + r#"{"width":16,"height":16}"#, + "--cmd", + "select.rect", + "--params", + r#"{"x":0,"y":0,"width":8,"height":16}"#, + "--cmd", + "layer.new.layer", + "--cmd", + "layer.layerMask.revealSelection", + "--cmd", + "select.deselect", + "--cmd", + "layer.new.layer", + "--params", + r#"{"name":"after"}"#, + "--out", + &native, + ]); + assert_eq!(code, 0, "{stderr}"); + let results: Vec = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(results.len(), 5, "every command reports a result: {stdout}"); + assert_eq!(results[3], json!({"command": "select.deselect", "result": null})); + let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}"); + + // Deselect with a selection still deselects: Inverse afterwards finds nothing to invert. + let Invoke { code, stderr, .. } = invoke(&[ + "run", + "--new", + r#"{"width":16,"height":16}"#, + "--cmd", + "select.all", + "--cmd", + "select.deselect", + "--cmd", + "select.inverse", + "--out", + &native, + ]); + assert_eq!(code, 1, "{stderr}"); + assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}"); + let _ = std::fs::remove_dir_all(&folder); +} + +/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the +/// same action does in the Actions panel (#6744). +#[test] +fn batch_skips_deselect_with_nothing_selected() { + let dir = tmp("deselect-batch"); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); + assert_eq!(code, 0, "{stderr}"); + std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap(); + let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]); + assert_eq!(code, 0, "{stdout}{stderr}"); + assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}"); + let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]); + assert_eq!(code, 0, "{stderr}"); + let info: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// TIFF Options from the command line (#8827): `--tiff-compression`, `--tiff-transparency on|off` +/// and `--tiff-pyramid`; a bad value is a usage error that writes nothing. +#[test] +fn tiff_options_choose_compression_transparency_and_pyramid() { + let d = tmp("tiff-options"); + let a = d.join("a.png"); + write_png(&a, 200, 100, 3); + // (flags, compression tag, alpha, directories) + for (flags, compression, alpha, dirs) in [ + (&[][..], 1, true, 1), + (&["--tiff-compression", "lzw"][..], 5, true, 1), + (&["--tiff-transparency", "off", "--tiff-compression", "zip"][..], 8, false, 1), + (&["--tiff-transparency", "on", "--tiff-pyramid"][..], 1, true, 3), + ] { + let out = d.join("a.tif"); + ok(bin().arg("convert").arg(&a).arg(&out).args(flags)); + let bytes = std::fs::read(&out).unwrap(); + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), dirs, "{flags:?}"); + assert!(info.pages.iter().all(|p| p.compression == compression), "{flags:?}: {:?}", info.pages); + assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{flags:?}"); + } + for (flag, bad, expected) in [("--tiff-transparency", "maybe", "expected on or off"), ("--tiff-compression", "jpeg", "expected none, lzw or zip")] { + let out = d.join("bad.tif"); + let o = bin().arg("convert").arg(&a).arg(&out).args([flag, bad]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{flag} {bad}"); + assert!(err.contains(flag) && err.contains(expected), "{flag} {bad}: {err}"); + assert!(!out.exists(), "{flag} {bad} wrote a file"); + } +} diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs index 65092cdee4..2729f74251 100644 --- a/apps/photocraft-cli/tests/it/main.rs +++ b/apps/photocraft-cli/tests/it/main.rs @@ -1,6 +1,8 @@ //! The crate's integration tests, linked into one test binary. mod cli; +#[cfg(all(target_os = "linux", target_env = "gnu"))] +mod malloc_tuning; mod man_and_completions; mod orientation; diff --git a/apps/photocraft-cli/tests/it/malloc_tuning.rs b/apps/photocraft-cli/tests/it/malloc_tuning.rs new file mode 100644 index 0000000000..a7513a714a --- /dev/null +++ b/apps/photocraft-cli/tests/it/malloc_tuning.rs @@ -0,0 +1,41 @@ +//! The binary re-executes itself with glibc's trim threshold set (#8743), keeping its arguments +//! and a value the user set. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +use photocraft_engine::malloc_tuning::{TRIM_THRESHOLD, TRIM_THRESHOLD_VAR}; + +/// The environment of a running `serve` once it answered a request, as `NAME=value` entries. +fn serve_environ(set: Option<&str>) -> Vec { + let mut cmd = Command::new(crate::binary()); + for var in [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX", "GLIBC_TUNABLES"] { + cmd.env_remove(var); + } + if let Some(v) = set { + cmd.env(TRIM_THRESHOLD_VAR, v); + } + let mut child = cmd.arg("serve").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, r#"{{"id":1,"method":"session.list"}}"#).unwrap(); + let mut reply = String::new(); + BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap(); + assert!(reply.contains(r#""id":1"#), "serve answered after the re-exec: {reply}"); + // The same process: `exec` keeps the PID, so this is the re-executed program's environment. + let environ = std::fs::read(format!("/proc/{}/environ", child.id())).unwrap(); + drop(stdin); + child.wait().unwrap(); + environ.split(|&b| b == 0).map(|e| String::from_utf8_lossy(e).into_owned()).collect() +} + +#[test] +fn sets_the_trim_threshold() { + let env = serve_environ(None); + assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}={TRIM_THRESHOLD}")), "{env:?}"); +} + +#[test] +fn keeps_a_value_the_user_set() { + let env = serve_environ(Some("999999")); + assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}=999999")), "{env:?}"); +} diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index f82051dbf9..024888c65d 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -103,7 +103,7 @@ pub fn start() { } let unsaved = Arc::new(AtomicBool::new(false)); guard_unload(unsaved.clone()); - Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets })) + Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, ui_language: "" })) }), ) .await; @@ -224,7 +224,10 @@ fn load_font_manifest(served: Served, ctx: egui::Context) { served_fonts::add_fonts(&fonts); // Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets // the Arabic font before typing); the rest is fetched when text or a font menu needs it. - served_fonts::request_for_languages(browser_languages().iter().map(String::as_str)); + // Also the interface language's: `serve_fonts` asked for it once already, maybe before + // this manifest arrived, when no fallback was registered yet. + let ui_language = photocraft_ui_egui::i18n::current().code(); + served_fonts::request_for_languages(browser_languages().iter().map(String::as_str).chain([ui_language])); ctx.request_repaint(); }); } @@ -270,6 +273,8 @@ struct WebShell { served: Served, database: Option, presets: crate::preset_bridge::Bridge, + /// The interface language whose served script font was last asked for. + ui_language: &'static str, } impl WebShell { @@ -294,6 +299,12 @@ impl WebShell { /// Fetches the served families asked for (picked in a font menu, or needed by a layout) and /// installs the files that arrived. fn serve_fonts(&mut self, ctx: &egui::Context) { + // The interface language's script font draws its labels (Arabic, Japanese…). + let ui_language = photocraft_ui_egui::i18n::current().code(); + if ui_language != self.ui_language { + self.ui_language = ui_language; + served_fonts::request_for_languages([ui_language]); + } let requested = served_fonts::take_requests(); let (fetch, arrived) = { let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner); @@ -320,7 +331,7 @@ impl WebShell { } }); } - served_fonts::install(&mut self.app, arrived); + served_fonts::install(&mut self.app, ctx, arrived); } } diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 5de916c250..b064f00376 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -34,11 +34,14 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +blake3 = { version = "1.8", default-features = false, features = ["pure"] } chrono = { workspace = true } clap = { workspace = true } thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "=3.27.0" +roxmltree = "0.21" craft-update = { workspace = true } rfd = { workspace = true } arboard = { version = "3.7", features = ["wayland-data-control"] } @@ -69,6 +72,15 @@ muda = { version = "=0.21.2", default-features = false } # Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version. dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } +[target.'cfg(unix)'.dependencies] +rustix = { version = "=1.1.5", features = ["fs"] } +# Reads CFBundleExecutable from a chosen macOS application bundle's Info.plist (XML or binary) for +# external vector editing. Already in Cargo.lock (craft-update); built on every Unix so Linux CI +# compiles and tests the bundle checks. The event-stream reader (behind plist's "unstable" +# feature) lets us cap nesting: building a `Value`, or serde's ignored fields, recurses per level. +# Pinned exactly because that API may change in a minor release: review the diff before bumping. +plist = { version = "=1.10.3", features = ["enable_unstable_features_that_may_break_with_minor_version_bumps"] } + # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } @@ -88,7 +100,7 @@ futures-lite = "=2.6.1" async-io = "=2.6.0" # Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't # re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly. -winit = { version = "=0.30.13", default-features = false, features = ["x11"] } +winit = { version = "=0.30.13", default-features = false, features = ["wayland-csd-adwaita-notitle", "x11"] } # Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`, # GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles), @@ -110,3 +122,7 @@ harness = false [build-dependencies] # Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" + +[dev-dependencies] +# Opt-in native save-back acceptance transports embedded sources through engine JSON. +base64 = "=0.22.1" diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index ef1fa438c0..4d5330d58c 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -390,7 +390,7 @@ fn stage_copy(from: &Path, to: &Path) -> std::io::Result { fn remove_stale_staging(folder: &Path) { let Ok(entries) = std::fs::read_dir(folder) else { return }; for entry in entries.flatten() { - if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) { + if entry.file_name().to_str().is_none_or(|n| side_of(n, STAGED).is_none()) { continue; } let removed = match entry.file_type() { @@ -491,14 +491,31 @@ pub fn portable_marker(exe_dir: &Path) -> Option { PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) } +/// Probe names tried before giving up on a portable data folder. +const WRITE_PROBE_ATTEMPTS: u32 = 64; + /// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only). +/// +/// The probe is created exclusively (`create_new`), so an entry already at its name (a link +/// someone else placed there, say) is never opened, written through or removed: the next name +/// is tried instead. Only a probe this call created is removed. fn ensure_writable(dir: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dir)?; - let probe = dir.join(format!(".write-test-{}", std::process::id())); - std::fs::write(&probe, b"")?; - // A leftover probe is harmless; don't fail portable mode over it. - let _ = std::fs::remove_file(&probe); - Ok(()) + let pid = std::process::id(); + for attempt in 0..WRITE_PROBE_ATTEMPTS { + let probe = dir.join(format!(".write-test-{pid}-{attempt}")); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) { + Ok(file) => { + drop(file); + // A leftover probe is harmless; don't fail portable mode over it. + let _ = std::fs::remove_file(&probe); + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every write-test name is already taken")) } #[cfg(test)] diff --git a/apps/photocraft/src/app_dirs/tests.rs b/apps/photocraft/src/app_dirs/tests.rs index c54299fb58..b7f750ba37 100644 --- a/apps/photocraft/src/app_dirs/tests.rs +++ b/apps/photocraft/src/app_dirs/tests.rs @@ -443,3 +443,28 @@ fn missing_exe_dir_or_env_never_panics() { let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None); assert_eq!(d.dir, None, "empty variables are ignored"); } + +#[cfg(unix)] +#[test] +fn the_write_probe_never_follows_an_entry_already_at_its_name() { + let root = temp("probe-link"); + let data = root.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let victim = root.join("victim.txt"); + std::fs::write(&victim, b"keep me").unwrap(); + // Every name the probe tries, except the last, is a link to a file outside the folder. + let pid = std::process::id(); + for attempt in 0..WRITE_PROBE_ATTEMPTS - 1 { + std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{attempt}"))).unwrap(); + } + ensure_writable(&data).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep me"); + // The links are left alone, and the probe that was created is gone. + assert!(std::fs::symlink_metadata(data.join(format!(".write-test-{pid}-0"))).unwrap().file_type().is_symlink()); + assert!(!data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1)).exists()); + // With every name taken the folder is reported unwritable, still without touching the file. + std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1))).unwrap(); + assert!(ensure_writable(&data).is_err()); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep me"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs index e20e70206a..4fb8765e52 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -15,7 +15,7 @@ //! be stamped on the shortcut, or the taskbar would stop matching the two. /// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render -/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes. +/// on Apple's icon grid; elsewhere the 256 px hicolor render of the full tile. pub fn window_icon() -> egui::IconData { match eframe::icon_data::from_png_bytes(PNG) { Ok(icon) => icon, @@ -108,6 +108,16 @@ mod tests { assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank"); } + #[test] + fn windows_and_linux_icons_keep_the_round_corners() { + // rx=112 of 512 leaves (10, 10) of a 256 px render transparent; the old crop filled it (#4478). + let png = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png"); + let icon = eframe::icon_data::from_png_bytes(png).unwrap(); + assert_eq!(icon.width, 256); + assert_eq!(icon.rgba[(10 * 256 + 10) * 4 + 3], 0, "corner is transparent"); + assert_eq!(icon.rgba[(128 * 256 + 2) * 4 + 3], 255, "the tile is full bleed"); + } + /// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image. #[test] fn exe_icon_has_every_shell_size() { diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index d73984eb8a..4834327b8e 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -16,15 +16,10 @@ use photocraft_ui_egui::ControlRequest; use photocraft_ui_egui::control::REPLY_DEADLINE; use serde_json::{Value, json}; -pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver { +/// Start serving a listener already bound during startup, before a window is opened. +pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); - let listener = match TcpListener::bind(("127.0.0.1", port)) { - Ok(l) => l, - Err(e) => { - eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}"); - return rx; - } - }; + let port = listener.local_addr().map_or(0, |a| a.port()); eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port)); std::thread::spawn(move || { let limiter = ConnectionLimiter::new(MAX_CONNECTIONS); @@ -152,6 +147,30 @@ mod tests { TcpStream::connect(&logged).unwrap(); } + #[test] + fn a_prebound_listener_authenticates_and_dispatches_requests() { + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let requests = start(listener, TOKEN.into(), egui::Context::default()); + let mut stream = TcpStream::connect(address).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut replies = BufReader::new(stream.try_clone().unwrap()); + writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap(); + let mut line = String::new(); + replies.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["result"]["authenticated"], true); + writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap(); + let request = requests.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(request.method, "test.bound"); + request.reply.send(json!({"ok": true, "result": "served"})).unwrap(); + line.clear(); + replies.read_line(&mut line).unwrap(); + let reply: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(reply["id"], 2); + assert_eq!(reply["result"], "served"); + } + /// #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/external_svg.rs b/apps/photocraft/src/external_svg.rs new file mode 100644 index 0000000000..1d4f552573 --- /dev/null +++ b/apps/photocraft/src/external_svg.rs @@ -0,0 +1,1433 @@ +//! Explicit desktop SVG editing. Clipboard/source data never supplies a command or program path. +//! +//! The owned temporary SVG is watched off the UI thread. Closing the PhotoCraft session stops +//! watching and removes its temporary directory; it does not terminate the external editor. + +use photocraft_ui_egui::{ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave}; + +/// Start an explicit native editor session; artwork cannot supply executable paths. +pub(crate) fn open(editor: ExternalSvgEditor, source: photocraft_ui_egui::ExternalVectorSource) -> Result, String> { + native::open(editor, source) +} + +mod native { + use super::{ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave}; + use std::fs::{Metadata, OpenOptions}; + use std::io::{Read, Write}; + use std::path::Path; + use std::path::PathBuf; + use std::process::Child; + use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TrySendError}; + use std::thread; + use std::time::{Duration, Instant, SystemTime}; + use tempfile::TempDir; + + const MAX_BYTES: usize = 16 * 1024 * 1024; + const POLL_INTERVAL: Duration = Duration::from_millis(200); + const SAVE_SETTLE: Duration = Duration::from_millis(600); + /// The file changed while being read. The watcher retries it; it is never shown to the user. + const SAVE_IN_PROGRESS: &str = "The SVG is still being saved"; + + /// Resolve the chosen editor and move conversion, launch and watching off the UI thread. + pub(super) fn open(editor: ExternalSvgEditor, source: photocraft_ui_egui::ExternalVectorSource) -> Result, String> { + // A Flatpak payload belongs to the caller's sandbox when executed directly. Let the + // desktop launch the actual editor and grant it just this writable working file. + #[cfg(target_os = "linux")] + let portal = matches!(&editor, ExternalSvgEditor::VectorCraft); + #[cfg(not(target_os = "linux"))] + let portal = false; + let chosen_vectorcraft = matches!(&editor, ExternalSvgEditor::VectorCraft); + let chosen_bundle = matches!(&editor, ExternalSvgEditor::Program(path) if cfg!(target_os = "macos") && Path::new(path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("app")) && Path::new(path).is_dir()); + let executable = if portal && source.extension == "svg" { None } else { Some(resolve(editor)?) }; + // Only VectorCraft's own working copy of an AI is regenerated (it opens foreign AI + // read-only); Illustrator and other editors get the original bytes. + let regenerate_ai = chosen_vectorcraft || executable.as_ref().is_some_and(|resolved| is_vectorcraft(resolved, chosen_bundle)); + // Display conversion is always performed by VectorCraft's CLI, even when the user + // chooses a different interactive editor. A VectorCraft build with an adjacent CLI keeps + // its matching CLI; for a macOS bundle that is the bundle's own `Contents/MacOS`. + let converter = if source.extension == "svg" { None } else { Some(resolve_converter(executable.as_ref().map(|e| e.program.as_path()))?) }; + if source.bytes.is_empty() || source.bytes.len() > MAX_BYTES || !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") { + return Err("Unsupported embedded vector source or source exceeds 16 MiB".into()); + } + // Conversion and editor startup run off the UI thread. Dropping this session cancels + // conversion; it never kills the interactive editor or changes the parent document. + let (stop, stopped) = mpsc::channel(); + let (send, updates) = mpsc::sync_channel(2); + thread::Builder::new() + .name("photocraft-vector-editor".into()) + .spawn(move || { + let prepared = match converter.as_deref() { + Some(executable) => super::vector_conversion::prepare(executable, &source, &stopped, regenerate_ai), + None => OwnedSvg::create(&source.bytes).map(|owned| (owned, source.bytes.as_ref().clone())), + }; + match prepared { + Ok((owned, initial)) => { + if stopped.try_recv().is_ok() { + return; + } + #[cfg(target_os = "linux")] + if portal { + match super::portal::open(&owned, &stopped) { + Ok(()) => watch(owned, initial, None, stopped, send, (POLL_INTERVAL, SAVE_SETTLE), converter.as_deref().map(|e| (e, &source))), + Err(error) => { + let _ = send.try_send(Err(error)); + } + } + return; + } + if let Some(executable) = executable.as_ref() { + match executable.launch(&owned.file) { + Ok(child) => { + watch(owned, initial, Some(child), stopped, send, (POLL_INTERVAL, SAVE_SETTLE), converter.as_deref().map(|e| (e, &source))) + } + Err(error) => { + let _ = send.try_send(Err(format!("Could not open the vector editor: {error}"))); + } + } + } + } + Err(error) => { + let _ = send.try_send(Err(error)); + } + } + }) + .map_err(|e| format!("Cannot start vector editor session: {e}"))?; + Ok(Box::new(Session { stop, updates, finished: false })) + } + + /// The editor-choice dialog's label for a user-selected program, named by every + /// "not found" error so the user can find the control. + const OTHER_APPLICATION: &str = "Other application"; + + /// A validated editor and how to start it. + #[derive(Clone, Debug, PartialEq, Eq)] + pub(super) struct Resolved { + /// The canonical executable file. It is run directly unless `bundle` is set, and its + /// directory is where an adjacent `vectorcraft-cli` is looked for. + pub(super) program: PathBuf, + /// A canonical macOS application bundle opened through LaunchServices + /// (`/usr/bin/open -a`), so a Cocoa app receives the file as an open-document event + /// instead of an argument it would ignore. + pub(super) bundle: Option, + } + + impl Resolved { + /// Run `program` itself with the working file as its only argument. + fn direct(program: PathBuf) -> Self { + Self { program, bundle: None } + } + + /// The process that opens `file`: no shell, no PATH lookup, one file argument. + fn command(&self, file: &Path) -> std::process::Command { + match &self.bundle { + Some(bundle) => { + let mut command = std::process::Command::new("/usr/bin/open"); + // Both paths are absolute, so neither can be read as an `open` option. + command.arg("-a").arg(bundle).arg(file); + command + } + None => { + let mut command = std::process::Command::new(&self.program); + command.arg(file); + command + } + } + } + + /// Start the editor on `file`. For a bundle the child is `open`, which exits once + /// LaunchServices has delivered the file; the watcher only reaps it. + fn launch(&self, file: &Path) -> std::io::Result { + self.command(file).spawn() + } + } + + /// Whether a chosen program is VectorCraft: a macOS bundle `resolve_program` identified by + /// its bundle id (it then runs the executable directly), a known VectorCraft installation, + /// or an executable named as VectorCraft is (`vectorcraft`/`vectorcraft.exe`, an AppImage + /// `vectorcraft--….AppImage`, the Flatpak launcher `ai.storyteller.vectorcraft`). + /// A neighbouring CLI alone doesn't make an editor VectorCraft. + fn is_vectorcraft(resolved: &Resolved, chosen_bundle: bool) -> bool { + resolved.bundle.is_none() + && (chosen_bundle + || resolved.program.file_name().is_some_and(|name| { + let name = name.to_string_lossy().to_ascii_lowercase(); + let stem = name.strip_suffix(".exe").unwrap_or(&name); + stem == "vectorcraft" || stem == "ai.storyteller.vectorcraft" || (stem.starts_with("vectorcraft-") && !stem.starts_with("vectorcraft-cli")) + }) + || vectorcraft_programs().contains(&resolved.program)) + } + + /// True when `program`'s directory holds a regular (not linked) `vectorcraft-cli`. + fn has_adjacent_cli(program: &Path) -> bool { + let name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }; + program.parent().is_some_and(|directory| std::fs::symlink_metadata(directory.join(name)).is_ok_and(|m| m.is_file() && !m.file_type().is_symlink())) + } + + /// The executable whose adjacent `vectorcraft-cli` converts AI/PDF/EPS previews. A selected + /// build that carries its own CLI keeps it (for a macOS bundle, `Contents/MacOS` of that same + /// bundle). Otherwise the first known VectorCraft installation that has one is used; document + /// metadata never participates in discovery. + fn resolve_converter(selected: Option<&Path>) -> Result { + if let Some(editor) = selected + && has_adjacent_cli(editor) + { + return Ok(editor.to_path_buf()); + } + vectorcraft_programs() + .into_iter() + .find(|program| has_adjacent_cli(program)) + .ok_or_else(|| "AI/PDF/EPS editing also needs an installed VectorCraft CLI to validate display previews".into()) + } + + /// A known VectorCraft location: a bare executable, or a macOS application bundle. + #[cfg(unix)] + #[derive(Debug, PartialEq, Eq)] + enum Install { + Executable(PathBuf), + Bundle(PathBuf), + } + + /// Fixed VectorCraft locations in search order. On macOS the standard application folders + /// come first; the Unix install prefixes apply everywhere. Nothing is searched recursively. + #[cfg(unix)] + fn vectorcraft_installs(home: Option<&Path>, macos: bool) -> Vec { + let mut installs = vec![]; + if macos { + installs.push(Install::Bundle(PathBuf::from("/Applications/VectorCraft.app"))); + if let Some(home) = home { + installs.push(Install::Bundle(home.join("Applications/VectorCraft.app"))); + } + } + if let Some(home) = home { + installs.push(Install::Executable(home.join(".local/share/flatpak/app/ai.storyteller.vectorcraft/current/active/files/bin/vectorcraft"))); + } + installs.push(Install::Executable(PathBuf::from("/usr/local/bin/vectorcraft"))); + installs.push(Install::Executable(PathBuf::from("/usr/bin/vectorcraft"))); + if let Some(home) = home { + installs.push(Install::Executable(home.join(".local/bin/vectorcraft"))); + } + installs + } + + /// Every valid known VectorCraft executable, in search order. + #[cfg(unix)] + fn vectorcraft_programs() -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + vectorcraft_installs(home.as_deref(), cfg!(target_os = "macos")) + .into_iter() + .filter_map(|install| match install { + Install::Executable(path) => validate_executable(&path).ok(), + // VectorCraft opens the files named on its command line and does not handle + // macOS open-document events, so its bundle executable is run directly. + Install::Bundle(bundle) => bundle_executable(&bundle).ok().map(|(_, program)| program), + }) + .collect() + } + + /// Discover only known installations or an explicitly chosen executable or application. + #[cfg(unix)] + fn resolve(editor: ExternalSvgEditor) -> Result { + match editor { + ExternalSvgEditor::Program(path) => resolve_program(Path::new(&path), cfg!(target_os = "macos")), + ExternalSvgEditor::VectorCraft => vectorcraft_programs() + .into_iter() + .next() + .map(Resolved::direct) + .ok_or_else(|| format!("VectorCraft was not found. Choose its executable with {OTHER_APPLICATION}")), + ExternalSvgEditor::Illustrator if cfg!(target_os = "macos") => newest_illustrator(Path::new("/Applications")), + ExternalSvgEditor::Illustrator => Err(format!("Choose a local executable with {OTHER_APPLICATION}")), + } + } + + /// A user-selected program: on macOS an application bundle (`.app`, which the system file + /// chooser returns as a directory) opens through LaunchServices; anything else must be an + /// executable regular file. + #[cfg(unix)] + fn resolve_program(path: &Path, macos_bundles: bool) -> Result { + if macos_bundles && path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("app")) && path.is_dir() { + let (bundle, program, vectorcraft) = bundle_executable_and_identity(path)?; + // Unlike Cocoa editors, VectorCraft consumes file arguments, not LaunchServices + // open-document events. Identify the app by its bundle id, not the presence of a + // CLI beside an unrelated user-selected editor. SVG editing needs no CLI at all. + return Ok(if vectorcraft { Resolved::direct(program) } else { Resolved { program, bundle: Some(bundle) } }); + } + validate_executable(path).map(Resolved::direct) + } + + /// The newest Adobe Illustrator bundle under `applications`, opened through LaunchServices. + #[cfg(unix)] + fn newest_illustrator(applications: &Path) -> Result { + illustrator_bundles(applications) + .into_iter() + .rev() + .find_map(|bundle| bundle_executable(&bundle).ok()) + .map(|(bundle, program)| Resolved { program, bundle: Some(bundle) }) + .ok_or_else(|| format!("Illustrator was not found. Choose its application with {OTHER_APPLICATION}")) + } + + /// Adobe installs `Adobe Illustrator /Adobe Illustrator .app` in + /// /Applications. Look at most one folder deep (512 entries, then 64 per folder), never + /// recursively, and sort by name so the last entry is the highest version. + #[cfg(unix)] + fn illustrator_bundles(applications: &Path) -> Vec { + let is_illustrator = |name: &std::ffi::OsStr| name.to_str().is_some_and(|name| name.starts_with("Adobe Illustrator")); + let is_app = |path: &Path| path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("app")); + let mut bundles = vec![]; + let Ok(entries) = std::fs::read_dir(applications) else { return bundles }; + for entry in entries.take(512).flatten() { + if !is_illustrator(&entry.file_name()) { + continue; + } + let path = entry.path(); + if is_app(&path) { + bundles.push(path); + continue; + } + let Ok(inner) = std::fs::read_dir(&path) else { continue }; + bundles.extend(inner.take(64).flatten().filter(|app| is_illustrator(&app.file_name())).map(|app| app.path()).filter(|path| is_app(path))); + } + bundles.sort(); + bundles + } + + /// Info.plist files are a few kilobytes; anything larger is not a real application. + #[cfg(unix)] + const MAX_INFO_PLIST_BYTES: u64 = 1024 * 1024; + + /// Resolve an application bundle to its canonical path and its executable, + /// `Contents/MacOS/`. The bundle must be a directory named `*.app`, its + /// Info.plist (XML or binary) a bounded regular file, and the executable a plain file name + /// that resolves to an executable regular file inside that same `Contents/MacOS`. + #[cfg(unix)] + fn bundle_executable(bundle: &Path) -> Result<(PathBuf, PathBuf), String> { + bundle_executable_and_identity(bundle).map(|(bundle, program, _)| (bundle, program)) + } + + /// Validate the bundle and identify VectorCraft from its declared application identifier. + /// A neighbouring converter alone does not identify an arbitrary chosen editor. + #[cfg(unix)] + fn bundle_executable_and_identity(bundle: &Path) -> Result<(PathBuf, PathBuf, bool), String> { + if !bundle.is_absolute() { + return Err("Choose an absolute application path".into()); + } + let bundle = bundle.canonicalize().map_err(|e| format!("Cannot resolve the chosen application: {e}"))?; + let named_app = bundle.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("app")); + if !named_app || !std::fs::symlink_metadata(&bundle).is_ok_and(|m| m.is_dir()) { + return Err("Choose an application bundle (.app) or an executable file".into()); + } + let mut options = OpenOptions::new(); + options.read(true); + no_follow(&mut options); + let info = options.open(bundle.join("Contents/Info.plist")).map_err(|e| format!("Cannot read the application's Info.plist: {e}"))?; + let metadata = info.metadata().map_err(|e| format!("Cannot inspect the application's Info.plist: {e}"))?; + if !metadata.is_file() || metadata.len() > MAX_INFO_PLIST_BYTES { + return Err("The application's Info.plist must be a regular file of at most 1 MiB".into()); + } + let mut bytes = Vec::new(); + info.take(MAX_INFO_PLIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("Cannot read the application's Info.plist: {e}"))?; + if bytes.len() as u64 > MAX_INFO_PLIST_BYTES { + return Err("The application's Info.plist must be a regular file of at most 1 MiB".into()); + } + let name = bundle_executable_name(&bytes)?; + if name.is_empty() || name.len() > 255 || name == "." || name == ".." || name.contains(['/', '\\', '\0']) { + return Err("The application's Info.plist names an invalid executable".into()); + } + let directory = bundle.join("Contents/MacOS"); + let program = validate_executable(&directory.join(&name))?; + let directory = directory.canonicalize().map_err(|e| format!("Cannot resolve the application's executable folder: {e}"))?; + if !directory.starts_with(&bundle) || program.parent() != Some(directory.as_path()) { + return Err("The application's executable must be inside its bundle".into()); + } + let vectorcraft = bundle_string_property(&bytes, "CFBundleIdentifier")?.as_deref() == Some("ai.storyteller.vectorcraft"); + Ok((bundle, program, vectorcraft)) + } + + /// The top-level `CFBundleExecutable` string of an Info.plist. Events are streamed with a + /// nesting limit instead of building a value tree, so hostile nesting cannot exhaust the stack. + #[cfg(unix)] + fn bundle_executable_name(info_plist: &[u8]) -> Result { + bundle_string_property(info_plist, "CFBundleExecutable")?.ok_or_else(|| "The application's Info.plist names no executable".into()) + } + + /// Read one top-level string without constructing a recursive property-list value. + #[cfg(unix)] + fn bundle_string_property(info_plist: &[u8], key: &str) -> Result, String> { + use plist::stream::{Event, Reader}; + const MAX_DEPTH: usize = 32; + let invalid = || "The application's Info.plist is not a valid property list".to_string(); + let mut depth = 0usize; + // At depth 1 (the root dictionary) entries alternate between key and value. + let mut expecting_key = true; + let mut wanted = false; + for event in Reader::new(std::io::Cursor::new(info_plist)) { + let event = event.map_err(|_| invalid())?; + match event { + Event::StartArray(_) | Event::StartDictionary(_) => { + if depth == 0 && !matches!(event, Event::StartDictionary(_)) { + return Err(invalid()); + } + if depth == 1 && wanted { + return Err(format!("The application's {key} is not a string")); + } + depth = depth.checked_add(1).filter(|d| *d <= MAX_DEPTH).ok_or_else(invalid)?; + } + Event::EndCollection => { + depth = depth.checked_sub(1).ok_or_else(invalid)?; + if depth == 0 { + break; + } + if depth == 1 { + expecting_key = true; + } + } + Event::String(text) if depth == 1 && expecting_key => { + wanted = text == key; + expecting_key = false; + } + Event::String(text) if depth == 1 && wanted => return Ok(Some(text.into_owned())), + _ if depth == 1 && wanted => return Err(format!("The application's {key} is not a string")), + _ if depth == 1 => expecting_key = true, + _ if depth == 0 => return Err(invalid()), + _ => {} + } + } + Ok(None) + } + + /// Canonicalize an absolute user-selected path and require an executable regular file. + #[cfg(unix)] + fn validate_executable(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + if !path.is_absolute() { + return Err("Choose an absolute executable path".into()); + } + let path = path.canonicalize().map_err(|e| format!("Cannot resolve the chosen editor: {e}"))?; + let metadata = std::fs::symlink_metadata(&path).map_err(|e| format!("Cannot read the chosen editor: {e}"))?; + ensure_regular(&metadata)?; + if metadata.permissions().mode() & 0o111 == 0 { + return Err("The chosen file is not executable".into()); + } + Ok(path) + } + + /// Probe only fixed Program Files / LocalAppData install locations (one Adobe directory + /// level, at most 64 entries) or validate an explicitly chosen executable. + #[cfg(windows)] + fn resolve_editor(editor: ExternalSvgEditor) -> Result { + match editor { + ExternalSvgEditor::Program(path) => validate_executable(Path::new(&path)), + ExternalSvgEditor::VectorCraft => { + let mut candidates = vec![]; + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(root) = std::env::var_os(variable) { + candidates.push(PathBuf::from(root).join("VectorCraft/vectorcraft.exe")); + } + } + if let Some(root) = std::env::var_os("LOCALAPPDATA") { + candidates.push(PathBuf::from(root).join("Programs/VectorCraft/vectorcraft.exe")); + } + candidates + .into_iter() + .find_map(|path| validate_executable(&path).ok()) + .ok_or_else(|| format!("VectorCraft was not found. Choose its executable with {OTHER_APPLICATION}")) + } + ExternalSvgEditor::Illustrator => { + let mut candidates = vec![]; + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + let Some(root) = std::env::var_os(variable) else { continue }; + let adobe = PathBuf::from(root).join("Adobe"); + // One known installation level only; never search the user's disks recursively. + let Ok(entries) = std::fs::read_dir(&adobe) else { continue }; + for entry in entries.take(64).flatten() { + if !entry.file_name().to_string_lossy().starts_with("Adobe Illustrator") { + continue; + } + candidates.push(entry.path().join("Support Files/Contents/Windows/Illustrator.exe")); + } + } + candidates.sort(); + candidates + .into_iter() + .rev() + .find_map(|path| validate_executable(&path).ok()) + .ok_or_else(|| format!("Illustrator was not found. Choose its executable with {OTHER_APPLICATION}")) + } + } + } + + /// Windows programs are always run directly with the working file as their argument. + #[cfg(windows)] + fn resolve(editor: ExternalSvgEditor) -> Result { + resolve_editor(editor).map(Resolved::direct) + } + + /// The known VectorCraft executable, if one is installed. + #[cfg(windows)] + fn vectorcraft_programs() -> Vec { + resolve_editor(ExternalSvgEditor::VectorCraft).ok().into_iter().collect() + } + + /// Require an absolute `.exe` that is a regular file, not a link or reparse point, and + /// return its canonical path; scripts and command lines are rejected. + #[cfg(windows)] + fn validate_executable(path: &Path) -> Result { + if !path.is_absolute() || !path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) { + return Err("Choose an absolute Windows .exe path; scripts and command lines are not supported".into()); + } + let metadata = std::fs::symlink_metadata(path).map_err(|e| format!("Cannot read the chosen editor: {e}"))?; + ensure_regular(&metadata)?; + path.canonicalize().map_err(|e| format!("Cannot resolve the chosen editor: {e}")) + } + + pub(super) struct OwnedSvg { + directory: TempDir, + pub(super) root: PathBuf, + pub(super) file: PathBuf, + } + + impl OwnedSvg { + /// Create a private working file from bounded source bytes. + pub(super) fn create(svg: &[u8]) -> Result { + Self::create_format(svg, "svg") + } + + /// Whitelisted extensions preserve the format; artwork cannot select a path. + pub(super) fn create_format(svg: &[u8], extension: &str) -> Result { + if !matches!(extension, "svg" | "ai" | "pdf" | "eps") { + return Err("Unsupported vector working-copy format".into()); + } + if svg.is_empty() || svg.len() > MAX_BYTES { + return Err("The SVG must contain between 1 byte and 16 MiB".into()); + } + let mut builder = tempfile::Builder::new(); + builder.prefix("photocraft-svg-edit-"); + // Flatpak's /tmp has no host pathname that the document portal can export. + // The app-private cache does; TempDir still creates a private random directory + // and removes it when syncing stops. Only Artwork.svg is shared with the editor. + #[cfg(target_os = "linux")] + let cache = if Path::new("/.flatpak-info").is_file() { + let cache = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or("A sandbox-private cache directory is required for vector editing")?; + std::fs::create_dir_all(&cache).map_err(|e| format!("Cannot create vector editing cache: {e}"))?; + Some(cache) + } else { + None + }; + #[cfg(not(target_os = "linux"))] + let cache: Option = None; + let directory = match cache { + Some(cache) => builder.tempdir_in(cache), + None => builder.tempdir(), + } + .map_err(|e| format!("Cannot create the SVG editing directory: {e}"))?; + let root = directory.path().canonicalize().map_err(|e| format!("Cannot resolve the SVG editing directory: {e}"))?; + let file = root.join(format!("Artwork.{extension}")); + let mut output = OpenOptions::new().write(true).create_new(true).open(&file).map_err(|e| format!("Cannot create the SVG editing file: {e}"))?; + output.write_all(svg).and_then(|()| output.sync_all()).map_err(|e| format!("Cannot write the SVG editing file: {e}"))?; + Ok(Self { directory, root, file }) + } + + /// Validate the working directory and capture a regular file identity for save settling. + pub(super) fn stamp(&self) -> Result { + let directory_metadata = std::fs::symlink_metadata(&self.root).map_err(|e| format!("Cannot inspect the SVG editing directory: {e}"))?; + if !directory_metadata.is_dir() || is_redirect(&directory_metadata) || self.directory.path().canonicalize().ok().as_ref() != Some(&self.root) { + return Err("The SVG editing directory was replaced or redirected".into()); + } + let metadata = std::fs::symlink_metadata(&self.file).map_err(|e| format!("Cannot inspect the edited SVG: {e}"))?; + ensure_regular(&metadata)?; + Stamp::from_metadata(&metadata) + } + + /// Read one settled snapshot, retaining byte limits during concurrent file growth. + pub(super) fn read(&self, expected: &Stamp) -> Result, String> { + if &self.stamp()? != expected { + return Err(SAVE_IN_PROGRESS.into()); + } + let mut options = OpenOptions::new(); + options.read(true); + no_follow(&mut options); + let file = options.open(&self.file).map_err(|e| format!("Cannot read the edited SVG: {e}"))?; + let metadata = file.metadata().map_err(|e| format!("Cannot inspect the edited SVG handle: {e}"))?; + ensure_regular(&metadata)?; + if &Stamp::from_metadata(&metadata)? != expected { + return Err(SAVE_IN_PROGRESS.into()); + } + let size = usize::try_from(metadata.len()).map_err(|_| "The edited SVG is too large")?; + let mut bytes = Vec::new(); + bytes.try_reserve_exact(size).map_err(|_| "Not enough memory to read the edited SVG")?; + // Also bound reads if a concurrent writer grows the file after the metadata check. + file.take(MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("Cannot read the edited SVG: {e}"))?; + if bytes.is_empty() || bytes.len() > MAX_BYTES { + return Err("The edited SVG must contain between 1 byte and 16 MiB".into()); + } + if bytes.len() != size || &self.stamp()? != expected { + return Err(SAVE_IN_PROGRESS.into()); + } + Ok(bytes) + } + /// Share a checked writable handle, never a sandbox-private pathname, with the desktop. + #[cfg(target_os = "linux")] + pub(super) fn share_handle(&self) -> Result { + let expected = self.stamp()?; + let mut options = OpenOptions::new(); + options.read(true).write(true); + no_follow(&mut options); + let file = options.open(&self.file).map_err(|e| format!("Cannot share vector working file: {e}"))?; + let metadata = file.metadata().map_err(|e| e.to_string())?; + ensure_regular(&metadata)?; + if Stamp::from_metadata(&metadata)? != expected || self.stamp()? != expected { + return Err("The vector working file changed before sharing".into()); + } + Ok(file) + } + + /// Write normalized SVG through a checked, no-follow handle before editor launch. + #[cfg(all(test, unix))] + pub(super) fn overwrite(&self, bytes: &[u8]) -> Result<(), String> { + if bytes.is_empty() || bytes.len() > MAX_BYTES { + return Err("Prepared SVG exceeds the supported byte limit".into()); + } + self.stamp()?; + let mut options = OpenOptions::new(); + options.write(true); + no_follow(&mut options); + let mut file = options.open(&self.file).map_err(|e| format!("Cannot open the prepared SVG: {e}"))?; + ensure_regular(&file.metadata().map_err(|e| format!("Cannot inspect the prepared SVG: {e}"))?)?; + file.set_len(0).and_then(|()| file.write_all(bytes)).and_then(|()| file.sync_all()).map_err(|e| format!("Cannot write the prepared SVG: {e}")) + } + } + + /// Prevent redirected opens and avoid blocking on a substituted Unix FIFO. + fn no_follow(options: &mut OpenOptions) { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Open the reparse point itself, so replacing the file with a symlink cannot + // make this access follow it between the metadata check and opening the handle. + options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Do not follow a replaced symlink or block opening a substituted FIFO/device. + options.custom_flags((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32); + } + } + + /// Reject anything but a regular file, including symlinks and Windows reparse points. + fn ensure_regular(metadata: &Metadata) -> Result<(), String> { + if !metadata.is_file() || is_redirect(metadata) { + return Err("The SVG or editor must be a regular file, not a link or reparse point".into()); + } + Ok(()) + } + + /// True for a symlink, or on Windows any reparse point, whose target could be swapped. + /// Expects `symlink_metadata`, which describes the link itself rather than its target. + fn is_redirect(metadata: &Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0000_0400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT + } + #[cfg(not(windows))] + { + false + } + } + + #[derive(Clone, PartialEq, Eq)] + pub(super) struct Stamp { + modified: SystemTime, + length: u64, + #[cfg(unix)] + identity: (u64, u64), + } + + impl Stamp { + /// Reject unsupported file sizes before capturing save time, length and native identity. + fn from_metadata(metadata: &Metadata) -> Result { + if metadata.len() == 0 || metadata.len() > MAX_BYTES as u64 { + return Err("The edited SVG must contain between 1 byte and 16 MiB".into()); + } + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + Ok(Self { + modified: metadata.modified().map_err(|e| format!("Cannot inspect the SVG save time: {e}"))?, + length: metadata.len(), + #[cfg(unix)] + identity: (metadata.dev(), metadata.ino()), + }) + } + } + + pub(super) struct Session { + stop: Sender<()>, + updates: Receiver>, + finished: bool, + } + + impl ExternalSvgSession for Session { + /// Like [`Self::poll_vector`], keeping only the saved source bytes. + fn poll(&mut self) -> Option, String>> { + self.poll_vector().map(|result| result.map(|saved| saved.bytes)) + } + + /// Take one queued save or error without blocking; a closed queue marks the session finished. + fn poll_vector(&mut self) -> Option> { + match self.updates.try_recv() { + Ok(update) => Some(update), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => { + self.finished = true; + None + } + } + } + + /// True once a poll has seen the watcher thread exit and drop its sender. + fn finished(&self) -> bool { + self.finished + } + } + + impl Drop for Session { + /// Signal the worker to stop without waiting for it. + fn drop(&mut self) { + // No thread join or editor termination on the UI thread. The worker owns TempDir + // and drops it after this bounded wakeup, including when the update receiver closes. + let _ = self.stop.send(()); + } + } + + /// Watch an already-written working file with test timings, without conversion. + #[cfg(test)] + fn start(owned: OwnedSvg, initial: Vec, child: Option, interval: Duration, settle: Duration) -> Result { + let (stop, stopped) = mpsc::channel(); + // At most two bounded updates await the UI, never an unbounded save queue. + let (send, updates) = mpsc::sync_channel(2); + thread::Builder::new() + .name("photocraft-svg-editor".into()) + .spawn(move || watch(owned, initial, child, stopped, send, (interval, settle), None)) + .map_err(|e| format!("Cannot watch SVG editor saves: {e}"))?; + Ok(Session { stop, updates, finished: false }) + } + + /// Watch a native working file with short test timings, converting each save's preview + /// with `editor`'s adjacent CLI as a real session would. + #[cfg(test)] + pub(super) fn start_native_test( + owned: OwnedSvg, + initial: Vec, + editor: PathBuf, + source: photocraft_ui_egui::ExternalVectorSource, + ) -> Result { + let (stop, stopped) = mpsc::channel(); + let (send, updates) = mpsc::sync_channel(2); + thread::Builder::new() + .name("native-vector-test-watcher".into()) + .spawn(move || watch(owned, initial, None, stopped, send, (Duration::from_millis(30), Duration::from_millis(100)), Some((&editor, &source)))) + .map_err(|e| e.to_string())?; + Ok(Session { stop, updates, finished: false }) + } + + #[derive(Debug, PartialEq, Eq)] + enum Delivery { + Sent, + Changed, + Stopped, + } + + /// Keep either a converted save or its error under queue backpressure, without + /// re-running conversion. A newer file supersedes either result; cancellation never waits. + fn deliver( + owned: &OwnedSvg, + stamp: &Stamp, + send: &SyncSender>, + stopped: &Receiver<()>, + mut update: Result, + interval: Duration, + ) -> Delivery { + loop { + if owned.stamp().ok().as_ref() != Some(stamp) { + return Delivery::Changed; + } + match send.try_send(update) { + Ok(()) => return Delivery::Sent, + Err(TrySendError::Full(value)) => { + update = value; + if !matches!(stopped.recv_timeout(interval), Err(mpsc::RecvTimeoutError::Timeout)) { + return Delivery::Stopped; + } + } + Err(TrySendError::Disconnected(_)) => return Delivery::Stopped, + } + } + } + + /// Worker loop: poll the working file each `interval`, read it only after its stamp stays + /// unchanged for `settle`, and deliver changed bytes with an optional converted SVG preview + /// for non-SVG sources. Repeated identical errors and known-failed sources are not re-sent. + /// Returns when stopped or the UI drops its receiver, dropping the private working copy. + fn watch( + owned: OwnedSvg, + mut last_bytes: Vec, + mut child: Option, + stopped: Receiver<()>, + send: SyncSender>, + timing: (Duration, Duration), + converter: Option<(&Path, &photocraft_ui_egui::ExternalVectorSource)>, + ) { + let (interval, settle) = timing; + // The editor may save before the watcher starts. First settled read must compare bytes. + let mut last_stamp: Option = None; + let mut pending: Option<(Stamp, Instant)> = None; + let mut last_error: Option = None; + let mut failed_source: Option<[u8; 32]> = None; + while let Err(mpsc::RecvTimeoutError::Timeout) = stopped.recv_timeout(interval) { + if child.as_mut().is_some_and(|process| process.try_wait().ok().flatten().is_some()) { + child = None; + } + let candidate = match owned.stamp() { + Ok(stamp) => { + if last_stamp.as_ref() == Some(&stamp) { + continue; + } + match &pending { + Some((old, since)) if old == &stamp && since.elapsed() >= settle => owned.read(&stamp), + Some((old, _)) if old == &stamp => continue, + _ => { + pending = Some((stamp, Instant::now())); + continue; + } + } + } + Err(error) => { + pending = None; + Err(error) + } + }; + match candidate { + Ok(bytes) => { + if bytes == last_bytes || failed_source.is_some_and(|hash| hash == *blake3::hash(&bytes).as_bytes()) { + last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone()); + continue; + } + let preview = match converter.filter(|(_, source)| source.extension != "svg") { + Some((editor, source)) => { + let snapshot = photocraft_ui_egui::ExternalVectorSource { + bytes: std::sync::Arc::new(bytes.clone()), + extension: source.extension.clone(), + size: source.size, + }; + match super::vector_conversion::render_preview(editor, &snapshot, &stopped) { + Ok(svg) => Some(svg), + Err(error) => { + let Some((stamp, _)) = pending.as_ref() else { continue }; + if last_error.as_ref() == Some(&error) { + failed_source = Some(*blake3::hash(&bytes).as_bytes()); + last_stamp = Some(stamp.clone()); + } else { + match deliver(&owned, stamp, &send, &stopped, Err(error.clone()), interval) { + Delivery::Sent => { + last_error = Some(error); + failed_source = Some(*blake3::hash(&bytes).as_bytes()); + last_stamp = Some(stamp.clone()); + } + Delivery::Changed => {} + Delivery::Stopped => return, + } + } + continue; + } + } + } + None => None, + }; + let Some((stamp, _)) = pending.as_ref() else { continue }; + let update = Ok(ExternalVectorSave { bytes: bytes.clone(), preview_svg: preview }); + match deliver(&owned, stamp, &send, &stopped, update, interval) { + Delivery::Sent => { + last_error = None; + failed_source = None; + last_bytes = bytes; + last_stamp = Some(stamp.clone()); + } + Delivery::Changed => {} + Delivery::Stopped => return, + } + } + Err(error) => { + if !read_failed(error, &mut pending, &mut last_stamp, &mut last_error, &send) { + break; + } + } + } + } + } + + /// Handle a failed stamp or read. A save that changed during the read waits to settle + /// again without a user-visible error. Other errors are reported once until they change. + /// Returns false when the UI has dropped its receiver. + fn read_failed( + error: String, + pending: &mut Option<(Stamp, Instant)>, + last_stamp: &mut Option, + last_error: &mut Option, + send: &SyncSender>, + ) -> bool { + *last_stamp = None; + if error == SAVE_IN_PROGRESS { + *pending = None; + return true; + } + if last_error.as_ref() == Some(&error) { + return true; + } + match send.try_send(Err(error.clone())) { + Ok(()) => *last_error = Some(error), + Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => return false, + } + true + } + + #[cfg(test)] + mod tests { + use super::*; + + /// Only VectorCraft gets a regenerated AI working copy: Illustrator, another program or a + /// bundle opened through LaunchServices gets the original. + #[test] + fn only_vectorcraft_editors_get_a_regenerated_ai() { + let root = std::env::temp_dir(); + let program = |name: &str| Resolved::direct(root.join(name)); + assert!(is_vectorcraft(&program(if cfg!(windows) { "vectorcraft.exe" } else { "vectorcraft" }), false)); + assert!(!is_vectorcraft(&program(if cfg!(windows) { "Illustrator.exe" } else { "Illustrator" }), false)); + assert!(is_vectorcraft(&program("vectorcraft-1.2.0-linux-x86_64.AppImage"), false)); + assert!(is_vectorcraft(&program("ai.storyteller.vectorcraft"), false)); + assert!(!is_vectorcraft(&program(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }), false)); + assert!(!is_vectorcraft(&program("uninstall-vectorcraft.exe"), false)); + assert!(is_vectorcraft(&program("Renamed"), true), "a bundle identified as VectorCraft runs directly"); + let cocoa = Resolved { program: root.join("Editor"), bundle: Some(root.join("Editor.app")) }; + assert!(!is_vectorcraft(&cocoa, true)); + } + + #[test] + fn conversion_errors_wait_for_queue_capacity_without_disappearing() { + let owned = OwnedSvg::create(b"").unwrap(); + let stamp = owned.stamp().unwrap(); + let (send, updates) = mpsc::sync_channel(1); + send.send(Ok(ExternalVectorSave { bytes: b"previous save".to_vec(), preview_svg: None })).unwrap(); + let (_stop, stopped) = mpsc::channel(); + let worker = thread::spawn(move || deliver(&owned, &stamp, &send, &stopped, Err("native preview failed".into()), Duration::from_millis(2))); + thread::sleep(Duration::from_millis(30)); + assert_eq!(updates.recv_timeout(Duration::from_secs(2)).unwrap().unwrap().bytes, b"previous save"); + assert_eq!(updates.recv_timeout(Duration::from_secs(2)).unwrap().unwrap_err(), "native preview failed"); + assert_eq!(worker.join().unwrap(), Delivery::Sent); + } + + #[test] + fn explicitly_selected_vector_build_keeps_its_adjacent_cli() { + let directory = tempfile::tempdir().unwrap(); + let editor = directory.path().join("custom-vectorcraft"); + let cli = directory.path().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }); + std::fs::write(&cli, b"trusted test fixture").unwrap(); + assert_eq!(resolve_converter(Some(&editor)).unwrap(), editor); + } + + /// A synthetic `/.app` whose Info.plist is `info` and whose + /// `Contents/MacOS/` is an executable script. Returns the bundle path. + #[cfg(unix)] + fn fake_bundle(root: &Path, name: &str, executable: &str, info: &[u8]) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bundle = root.join(format!("{name}.app")); + std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + std::fs::write(bundle.join("Contents/Info.plist"), info).unwrap(); + let program = bundle.join("Contents/MacOS").join(executable); + std::fs::write(&program, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + bundle + } + + #[cfg(unix)] + fn xml_info(executable: &str) -> Vec { + format!( + r#"CFBundleNameEditorCFBundleDocumentTypesCFBundleTypeNameSVGCFBundleExecutable{executable}"# + ) + .into_bytes() + } + + #[cfg(unix)] + #[test] + fn macos_bundle_selection_opens_the_bundle_with_launch_services() { + let directory = tempfile::tempdir().unwrap(); + let bundle = fake_bundle(directory.path(), "Editor", "Editor Binary", &xml_info("Editor Binary")); + let resolved = resolve_program(&bundle, true).unwrap(); + let canonical = bundle.canonicalize().unwrap(); + assert_eq!(resolved.bundle.as_deref(), Some(canonical.as_path())); + assert_eq!(resolved.program, canonical.join("Contents/MacOS/Editor Binary")); + let file = directory.path().join("Artwork.svg"); + let command = resolved.command(&file); + assert_eq!(command.get_program(), "/usr/bin/open"); + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, [std::ffi::OsStr::new("-a"), canonical.as_os_str(), file.as_os_str()]); + // Outside macOS a directory is never an editor, bundle or not. + assert!(resolve_program(&bundle, false).is_err()); + // A plain executable keeps running directly with the file as its only argument. + let program = canonical.join("Contents/MacOS/Editor Binary"); + let direct = resolve_program(&program, true).unwrap(); + assert_eq!(direct, Resolved::direct(program.clone())); + let command = direct.command(&file); + assert_eq!(command.get_program(), program.as_os_str()); + assert_eq!(command.get_args().collect::>(), [file.as_os_str()]); + } + + /// A selected VectorCraft bundle must receive the watched file as argv, including + /// SVG-only installations without a CLI. Other applications retain open-document + /// events even if someone placed a vectorcraft-cli file beside their executable. + #[cfg(unix)] + #[test] + fn manually_selected_vectorcraft_bundle_uses_argv_without_misclassifying_other_apps() { + let directory = tempfile::tempdir().unwrap(); + for binary in [false, true] { + let mut info = plist::Dictionary::new(); + info.insert("CFBundleExecutable".into(), plist::Value::String("VectorCraft".into())); + info.insert("CFBundleIdentifier".into(), plist::Value::String("ai.storyteller.vectorcraft".into())); + let mut bytes = Vec::new(); + if binary { + plist::Value::Dictionary(info).to_writer_binary(&mut bytes).unwrap(); + } else { + plist::Value::Dictionary(info).to_writer_xml(&mut bytes).unwrap(); + } + let bundle = fake_bundle(directory.path(), if binary { "Renamed" } else { "VectorCraft" }, "VectorCraft", &bytes); + let resolved = resolve_program(&bundle, true).unwrap(); + assert!(resolved.bundle.is_none(), "VectorCraft must receive file arguments"); + assert!(!has_adjacent_cli(&resolved.program), "SVG editing works without the converter"); + let file = directory.path().join("Artwork.svg"); + let command = resolved.command(&file); + assert_eq!(command.get_program(), resolved.program.as_os_str()); + assert_eq!(command.get_args().collect::>(), [file.as_os_str()]); + } + let other = fake_bundle(directory.path(), "Other", "Other", &xml_info("Other")); + std::fs::write(other.join("Contents/MacOS/vectorcraft-cli"), b"#!/bin/sh\n").unwrap(); + let resolved = resolve_program(&other, true).unwrap(); + assert!(has_adjacent_cli(&resolved.program)); + assert!(resolved.bundle.is_some(), "an adjacent CLI is not the application's identity"); + assert_eq!(resolved.command(&directory.path().join("Artwork.svg")).get_program(), "/usr/bin/open"); + } + + #[cfg(unix)] + #[test] + fn macos_bundle_reads_binary_info_plists() { + let mut info = plist::Dictionary::new(); + info.insert("CFBundleExecutable".into(), plist::Value::String("VectorCraft".into())); + let mut bytes = Vec::new(); + plist::Value::Dictionary(info).to_writer_binary(&mut bytes).unwrap(); + assert!(bytes.starts_with(b"bplist00")); + let directory = tempfile::tempdir().unwrap(); + let bundle = fake_bundle(directory.path(), "VectorCraft", "VectorCraft", &bytes); + let (canonical, program) = bundle_executable(&bundle).unwrap(); + assert_eq!(program, canonical.join("Contents/MacOS/VectorCraft")); + } + + #[cfg(unix)] + #[test] + fn macos_bundle_rejects_escapes_links_and_malformed_info_plists() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + // The executable name is a file name inside Contents/MacOS, never a path. + for name in ["../../../outside", "sub/dir", ".", ".."] { + let bundle = fake_bundle(root, "Traversal", "Traversal", &xml_info(name)); + assert!(bundle_executable(&bundle).is_err(), "{name}"); + } + // A link from inside the bundle to an executable elsewhere is not the bundle's program. + let outside = root.join("outside-tool"); + std::fs::write(&outside, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o755)).unwrap(); + let bundle = fake_bundle(root, "Linked", "Real", &xml_info("Escape")); + std::os::unix::fs::symlink(&outside, bundle.join("Contents/MacOS/Escape")).unwrap(); + assert!(bundle_executable(&bundle).unwrap_err().contains("inside its bundle")); + // Contents/MacOS itself redirected out of the bundle. + let bundle = fake_bundle(root, "LinkedDir", "Tool", &xml_info("Tool")); + std::fs::remove_dir_all(bundle.join("Contents/MacOS")).unwrap(); + std::fs::create_dir(root.join("elsewhere")).unwrap(); + std::fs::copy(&outside, root.join("elsewhere/Tool")).unwrap(); + std::os::unix::fs::symlink(root.join("elsewhere"), bundle.join("Contents/MacOS")).unwrap(); + assert!(bundle_executable(&bundle).unwrap_err().contains("inside its bundle")); + // A linked Info.plist is not followed. + let bundle = fake_bundle(root, "LinkedInfo", "Tool", &xml_info("Tool")); + let real_info = root.join("Info.plist"); + std::fs::write(&real_info, xml_info("Tool")).unwrap(); + std::fs::remove_file(bundle.join("Contents/Info.plist")).unwrap(); + std::os::unix::fs::symlink(&real_info, bundle.join("Contents/Info.plist")).unwrap(); + assert!(bundle_executable(&bundle).is_err()); + // The named executable must be executable. + let bundle = fake_bundle(root, "NotExecutable", "Tool", &xml_info("Tool")); + std::fs::set_permissions(bundle.join("Contents/MacOS/Tool"), std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(bundle_executable(&bundle).is_err()); + // Oversized, non-dictionary, deeply nested and non-string entries fail without panicking. + let huge = vec![b' '; MAX_INFO_PLIST_BYTES as usize + 1]; + assert!(bundle_executable(&fake_bundle(root, "Huge", "Tool", &huge)).unwrap_err().contains("1 MiB")); + let deep = format!( + "Deep{}{}CFBundleExecutableTool", + "".repeat(100_000), + "".repeat(100_000) + ); + assert!(bundle_executable_name(deep.as_bytes()).is_err()); + assert!(bundle_executable_name(b"Tool").is_err()); + assert!(bundle_executable_name(b"CFBundleExecutable").is_err()); + assert!(bundle_executable_name(b"CFBundleExecutable1").is_err()); + assert!(bundle_executable_name(b"CFBundleNameTool").is_err()); + assert!(bundle_executable_name(b"not a plist").is_err()); + // A nested CFBundleExecutable key does not name the bundle's executable. + let nested = b"OtherCFBundleExecutableWrongCFBundleExecutableRight"; + assert_eq!(bundle_executable_name(nested).unwrap(), "Right"); + // A file or a non-.app directory is not a bundle. + assert!(bundle_executable(&outside).is_err()); + assert!(bundle_executable(&root.join("elsewhere")).is_err()); + assert!(bundle_executable(Path::new("relative/Editor.app")).is_err()); + } + + #[cfg(unix)] + #[test] + fn macos_vectorcraft_discovery_checks_standard_application_folders_first() { + let home = Path::new("/home/synthetic"); + let mac = vectorcraft_installs(Some(home), true); + assert_eq!(mac[0], Install::Bundle(PathBuf::from("/Applications/VectorCraft.app"))); + assert_eq!(mac[1], Install::Bundle(home.join("Applications/VectorCraft.app"))); + let other = vectorcraft_installs(Some(home), false); + assert!(other.iter().all(|install| matches!(install, Install::Executable(_))), "no bundles outside macOS"); + assert_eq!(&mac[2..], other.as_slice(), "the Unix locations keep their order"); + assert_eq!( + other, + [ + Install::Executable(home.join(".local/share/flatpak/app/ai.storyteller.vectorcraft/current/active/files/bin/vectorcraft")), + Install::Executable(PathBuf::from("/usr/local/bin/vectorcraft")), + Install::Executable(PathBuf::from("/usr/bin/vectorcraft")), + Install::Executable(home.join(".local/bin/vectorcraft")), + ] + ); + assert_eq!(vectorcraft_installs(None, true)[0], Install::Bundle(PathBuf::from("/Applications/VectorCraft.app"))); + } + + #[cfg(unix)] + #[test] + fn bundled_vectorcraft_cli_is_found_inside_the_same_bundle() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let bundle = fake_bundle(directory.path(), "VectorCraft", "VectorCraft", &xml_info("VectorCraft")); + let (_, program) = bundle_executable(&bundle).unwrap(); + assert!(!has_adjacent_cli(&program), "no CLI yet"); + // A CLI beside the bundle directory is not inside the bundle. + std::fs::write(directory.path().join("vectorcraft-cli"), b"#!/bin/sh\n").unwrap(); + assert!(!has_adjacent_cli(&program)); + let cli = bundle.join("Contents/MacOS/vectorcraft-cli"); + std::fs::write(&cli, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(resolve_converter(Some(&program)).unwrap(), program); + } + + #[cfg(unix)] + #[test] + fn macos_illustrator_discovery_is_one_level_deep_and_prefers_the_newest() { + let directory = tempfile::tempdir().unwrap(); + let applications = directory.path(); + for version in ["2024", "2025"] { + let folder = applications.join(format!("Adobe Illustrator {version}")); + std::fs::create_dir(&folder).unwrap(); + fake_bundle(&folder, &format!("Adobe Illustrator {version}"), "Adobe Illustrator", &xml_info("Adobe Illustrator")); + } + // Broken newer install, unrelated apps and deeper nesting are skipped. + std::fs::create_dir_all(applications.join("Adobe Illustrator 2026/Adobe Illustrator 2026.app")).unwrap(); + fake_bundle(applications, "Other Editor", "Other", &xml_info("Other")); + std::fs::create_dir_all(applications.join("Adobe Illustrator 2027/Deeper")).unwrap(); + fake_bundle(&applications.join("Adobe Illustrator 2027/Deeper"), "Adobe Illustrator 2027", "Adobe Illustrator", &xml_info("Adobe Illustrator")); + let found = illustrator_bundles(applications); + assert_eq!(found.len(), 3, "{found:?}"); + assert!(found.iter().all(|path| path.file_name().unwrap().to_str().unwrap().starts_with("Adobe Illustrator"))); + let newest = newest_illustrator(applications).unwrap(); + let expected = applications.join("Adobe Illustrator 2025/Adobe Illustrator 2025.app").canonicalize().unwrap(); + assert_eq!(newest.bundle, Some(expected.clone())); + assert_eq!(newest.program, expected.join("Contents/MacOS/Adobe Illustrator")); + assert!(newest_illustrator(&applications.join("missing")).unwrap_err().contains(OTHER_APPLICATION)); + } + + #[test] + fn missing_editor_errors_name_the_dialog_option() { + let dialog = include_str!("../../../crates/ui-egui/src/smart_svg_ui.rs"); + assert!(dialog.contains(&format!("(Editor::Program, \"{OTHER_APPLICATION}\")")), "the dialog label changed"); + let source = include_str!("external_svg.rs"); + assert!(!source.contains(concat!("Other ", "editor")), "errors must name the dialog's option"); + #[cfg(target_os = "linux")] + assert!(resolve(ExternalSvgEditor::Illustrator).unwrap_err().contains(OTHER_APPLICATION)); + } + + const EMPTY_SAVE: &str = "The edited SVG must contain between 1 byte and 16 MiB"; + + fn update(session: &mut Session, deadline: Instant) -> Result, String> { + loop { + assert!(Instant::now() < deadline, "timed out waiting for an SVG save"); + if let Some(update) = session.poll() { + return update; + } + thread::sleep(Duration::from_millis(10)); + } + } + + fn expect_saved(session: &mut Session, expected: &[u8], deadline: Instant) { + loop { + match update(session, deadline) { + Ok(bytes) => { + assert_eq!(bytes, expected, "unexpected SVG save contents"); + return; + } + // A save that changes during a read is retried by the watcher, never shown. + Err(error) => assert!(error == EMPTY_SAVE, "unexpected SVG watcher error: {error}"), + } + } + } + + fn expect_empty_error(session: &mut Session, deadline: Instant) { + match update(session, deadline) { + Err(error) if error == EMPTY_SAVE => {} + other => panic!("expected the held-empty SVG error, got {other:?}"), + } + } + + fn queued_session(updates: Vec, String>>) -> Session { + let (stop, _stopped) = mpsc::channel(); + let (send, receive) = mpsc::channel(); + for update in updates { + send.send(update.map(|bytes| ExternalVectorSave { bytes, preview_svg: None })).unwrap(); + } + Session { stop, updates: receive, finished: false } + } + + #[test] + fn finished_session_reports_queued_error_before_releasing_capacity() { + let mut session = queued_session(vec![Err("Editor selection cancelled".into())]); + assert!(!session.finished()); + assert!(session.poll().unwrap().is_err()); + assert!(!session.finished()); + assert!(session.poll().is_none()); + assert!(session.finished()); + } + + #[test] + fn saves_debounce_repeat_and_recover_after_invalid_file() { + let initial = b"".to_vec(); + let owned = OwnedSvg::create(&initial).unwrap(); + let file = owned.file.clone(); + let root = owned.root.clone(); + let settle = Duration::from_millis(100); + let deadline = Instant::now() + Duration::from_secs(3); + let mut session = start(owned, initial, None, Duration::from_millis(10), settle).unwrap(); + let save_started = Instant::now(); + let first = b"first complete"; + std::fs::write(&file, first).unwrap(); + expect_saved(&mut session, first, deadline); + assert!(save_started.elapsed() >= settle, "a changed save must settle before delivery"); + assert!(session.poll().is_none()); + std::fs::write(&file, b"").unwrap(); + expect_empty_error(&mut session, deadline); + std::fs::write(&file, b"second complete").unwrap(); + expect_saved(&mut session, b"second complete", deadline); + assert!(session.poll().is_none()); + drop(session); + while root.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(!root.exists(), "worker must clean its owned temporary SVG"); + } + + #[test] + fn exact_save_wait_skips_only_expected_queued_transient_errors() { + let bytes = b"complete"; + let mut session = queued_session(vec![Err(EMPTY_SAVE.into()), Ok(bytes.to_vec())]); + expect_saved(&mut session, bytes, Instant::now() + Duration::from_secs(3)); + assert!(session.poll().is_none()); + } + + #[test] + #[should_panic(expected = "unexpected SVG watcher error")] + fn exact_save_wait_rejects_unexpected_errors() { + let mut session = queued_session(vec![Err("Cannot read the edited SVG: permission denied".into()), Ok(b"".to_vec())]); + expect_saved(&mut session, b"", Instant::now() + Duration::from_secs(3)); + } + + #[test] + #[should_panic(expected = "unexpected SVG watcher error")] + fn exact_save_wait_rejects_a_reported_save_in_progress() { + let mut session = queued_session(vec![Err(SAVE_IN_PROGRESS.into()), Ok(b"".to_vec())]); + expect_saved(&mut session, b"", Instant::now() + Duration::from_secs(3)); + } + + /// A save that changes during a read must settle again without a UI message, while + /// other read errors are still reported once and dropped receivers stop the watcher. + #[test] + fn changing_save_retries_silently_but_other_read_errors_report_once() { + let owned = OwnedSvg::create(b"").unwrap(); + let stamp = owned.stamp().unwrap(); + let (send, updates) = mpsc::sync_channel(2); + let mut pending = Some((stamp.clone(), Instant::now())); + let mut last_stamp = Some(stamp.clone()); + let mut last_error = Some(EMPTY_SAVE.to_string()); + assert!(read_failed(SAVE_IN_PROGRESS.into(), &mut pending, &mut last_stamp, &mut last_error, &send)); + assert!(pending.is_none(), "the changed save must settle again before it is read"); + assert!(last_stamp.is_none(), "the next settled stamp must be read"); + assert_eq!(last_error.as_deref(), Some(EMPTY_SAVE), "a retry neither reports nor forgets the shown error"); + assert!(updates.try_recv().is_err(), "a save in progress is not a user-visible error"); + + let denied = "Cannot read the edited SVG: permission denied".to_string(); + pending = Some((stamp.clone(), Instant::now())); + assert!(read_failed(denied.clone(), &mut pending, &mut last_stamp, &mut last_error, &send)); + assert!(pending.is_some(), "other read errors keep their existing settle state"); + assert_eq!(updates.try_recv().unwrap().unwrap_err(), denied); + assert!(read_failed(denied.clone(), &mut pending, &mut last_stamp, &mut last_error, &send)); + assert!(updates.try_recv().is_err(), "a repeated error is reported once"); + + let full = || Ok(ExternalVectorSave { bytes: b"".to_vec(), preview_svg: None }); + send.try_send(full()).unwrap(); + send.try_send(full()).unwrap(); + let gone = "Cannot inspect the edited SVG: not found".to_string(); + assert!(read_failed(gone.clone(), &mut pending, &mut last_stamp, &mut last_error, &send)); + assert_eq!(last_error.as_deref(), Some(denied.as_str()), "an error the full queue refused is retried later"); + drop(updates); + assert!(!read_failed(gone, &mut pending, &mut last_stamp, &mut last_error, &send), "a dropped UI ends the watcher"); + } + + /// Saves that replace the file while it is being read deliver only complete SVGs, never + /// a transient error, and the final stable save always arrives. + #[cfg(unix)] + #[test] + fn saves_replaced_during_reads_deliver_the_final_save_without_transient_errors() { + let initial = b"".to_vec(); + let owned = OwnedSvg::create(&initial).unwrap(); + let file = owned.file.clone(); + let mut session = start(owned, initial, None, Duration::from_millis(1), Duration::ZERO).unwrap(); + let final_svg = b"final".to_vec(); + let expected = final_svg.clone(); + let writer = thread::spawn(move || { + let next = file.with_extension("next"); + for i in 0..300usize { + let body = "x".repeat((i * 7919) % 262_144); + std::fs::write(&next, format!("{body}")).unwrap(); + std::fs::rename(&next, &file).unwrap(); + thread::sleep(Duration::from_micros((i as u64 * 37) % 900)); + } + std::fs::write(&next, &final_svg).unwrap(); + std::fs::rename(&next, &file).unwrap(); + }); + let deadline = Instant::now() + Duration::from_secs(20); + let mut deliveries = 0usize; + loop { + match update(&mut session, deadline) { + Ok(bytes) => { + deliveries += 1; + assert!(bytes.starts_with(b"") && bytes.ends_with(b""), "a torn save was delivered"); + // Only the writer's last save has this content. + if bytes == expected { + break; + } + } + Err(error) => panic!("unexpected SVG watcher error during replacement saves: {error}"), + } + } + writer.join().unwrap(); + assert!(deliveries >= 1); + thread::sleep(Duration::from_millis(50)); + assert!(session.poll().is_none(), "a stable save is delivered once"); + } + + #[test] + fn oversize_file_is_rejected_before_allocating() { + let owned = OwnedSvg::create(b"").unwrap(); + std::fs::File::create(&owned.file).unwrap().set_len(MAX_BYTES as u64 + 1).unwrap(); + assert!(owned.stamp().is_err()); + assert!(OwnedSvg::create(&[]).is_err()); + } + + #[cfg(unix)] + #[test] + fn atomic_replacement_with_equal_size_and_mtime_is_a_new_save() { + let initial = b"one".to_vec(); + let owned = OwnedSvg::create(&initial).unwrap(); + let file = owned.file.clone(); + let original_stamp = owned.stamp().unwrap(); + let modified = std::fs::metadata(&file).unwrap().modified().unwrap(); + let mut session = start(owned, initial, None, Duration::from_millis(10), Duration::from_millis(40)).unwrap(); + let replacement = file.with_extension("replacement"); + let saved = b"two"; + std::fs::write(&replacement, saved).unwrap(); + std::fs::File::options().write(true).open(&replacement).unwrap().set_modified(modified).unwrap(); + std::fs::rename(&replacement, &file).unwrap(); + let replaced_stamp = Stamp::from_metadata(&std::fs::metadata(&file).unwrap()).unwrap(); + assert_eq!(original_stamp.modified, replaced_stamp.modified); + assert_eq!(original_stamp.length, replaced_stamp.length); + assert!(original_stamp != replaced_stamp, "file identity must distinguish equal-time atomic saves"); + expect_saved(&mut session, saved, Instant::now() + Duration::from_secs(3)); + } + + #[cfg(unix)] + #[test] + fn unix_saved_file_rejects_symlinks_and_fifos_without_blocking() { + let owned = OwnedSvg::create(b"").unwrap(); + let stamp = owned.stamp().unwrap(); + let target = owned.root.join("private.svg"); + std::fs::write(&target, b"private contents").unwrap(); + std::fs::remove_file(&owned.file).unwrap(); + std::os::unix::fs::symlink(&target, &owned.file).unwrap(); + assert!(owned.stamp().is_err()); + assert!(owned.read(&stamp).is_err()); + assert!(owned.overwrite(b"").is_err()); + std::fs::remove_file(&owned.file).unwrap(); + rustix::fs::mkfifoat(rustix::fs::CWD, &owned.file, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR).unwrap(); + assert!(owned.stamp().is_err()); + let began = Instant::now(); + assert!(owned.read(&stamp).is_err()); + assert!(began.elapsed() < Duration::from_secs(1)); + assert_eq!(std::fs::read(&target).unwrap(), b"private contents"); + } + + #[cfg(windows)] + #[test] + fn script_and_command_line_are_not_editor_executables() { + assert!(validate_executable(Path::new("cmd.exe /c start")).is_err()); + assert!(validate_executable(Path::new("C:/editor.cmd")).is_err()); + assert!(validate_executable(Path::new("editor.exe")).is_err()); + } + + #[cfg(windows)] + #[test] + fn replacement_symlink_is_rejected_without_reading_target() { + let owned = OwnedSvg::create(b"").unwrap(); + let target = owned.root.join("other.svg"); + std::fs::write(&target, b"private target").unwrap(); + std::fs::remove_file(&owned.file).unwrap(); + // Symlink creation requires Developer Mode or elevation. Other tests run everywhere. + if std::os::windows::fs::symlink_file(&target, &owned.file).is_err() { + return; + } + assert!(owned.stamp().is_err()); + let _ = std::fs::remove_file(&owned.file); + } + } +} + +#[path = "vector_conversion.rs"] +mod vector_conversion; + +#[cfg(target_os = "linux")] +#[path = "external_svg_portal.rs"] +mod portal; + +#[cfg(test)] +#[path = "native_saveback_tests.rs"] +mod native_saveback_tests; diff --git a/apps/photocraft/src/external_svg_portal.rs b/apps/photocraft/src/external_svg_portal.rs new file mode 100644 index 0000000000..0edef3e8ce --- /dev/null +++ b/apps/photocraft/src/external_svg_portal.rs @@ -0,0 +1,193 @@ +//! Launch a sandboxed editor through the desktop, exporting only the writable working SVG. +//! Never execute another Flatpak's payload inside PhotoCraft or grant access to its home. + +use super::native::OwnedSvg; +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::time::{Duration, Instant}; +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{Fd, OwnedObjectPath, OwnedValue, Value}; + +/// Ask the desktop (xdg-desktop-portal OpenURI) to let the user pick an editor for the working +/// file, on a fresh session-bus connection with a 5-second method timeout. Returns once the +/// portal answers; `stop` or a 120-second deadline cancels the wait. +pub(super) fn open(owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> { + let connection = + zbus::blocking::connection::Builder::session().map_err(|e| e.to_string())?.method_timeout(Duration::from_secs(5)).build().map_err(|e| e.to_string())?; + open_on(&connection, owned, stop) +} + +/// [`open`] on an existing connection: share a checked writable handle (never a pathname) with +/// `OpenFile`, always asking the user to choose, and wait for the named request's response. +fn open_on(connection: &Connection, owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> { + use futures_lite::StreamExt; + // Retain the checked handle for the portal call. Writable document-portal access also + // supports atomic saves. The watcher keeps using PhotoCraft's original owned pathname. + let file = owned.share_handle()?; + let unique = connection.unique_name().ok_or("No desktop connection name")?.as_str().trim_start_matches(':').replace('.', "_"); + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let token = format!("photocraft_vector_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}"); + let request = Proxy::new(connection, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + // Subscribe before launching: the portal can finish before OpenFile returns. + let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?; + let portal = Proxy::new(connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.OpenURI") + .map_err(|e| e.to_string())?; + // SVG's desktop default may be a viewer or browser. Always let the user select an editor. + let options = HashMap::from([("handle_token", Value::from(token.as_str())), ("writable", Value::from(true)), ("ask", Value::from(true))]); + let handle: OwnedObjectPath = + portal.call("OpenFile", &("", Fd::from(&file), options)).map_err(|e| format!("Cannot open the desktop editor chooser: {e}"))?; + if handle.as_str() != path { + let returned = + Proxy::new(connection, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + let _: Result<(), _> = returned.call("Close", &()); + return Err("The desktop portal does not support named requests".into()); + } + let deadline = Instant::now() + Duration::from_secs(120); + loop { + if !matches!(stop.try_recv(), Err(TryRecvError::Empty)) || Instant::now() >= deadline { + let _: Result<(), _> = request.call("Close", &()); + return Err("Vector editor selection cancelled or timed out".into()); + } + let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async { + async_io::Timer::after(Duration::from_millis(50)).await; + None + })); + if let Some(reply) = next { + let reply = reply.ok_or("The desktop editor chooser closed without an answer")?; + let (code, _): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; + return match code { + 0 => Ok(()), + 1 => Err("Vector editor selection cancelled".into()), + _ => Err("The desktop could not open the vector editor".into()), + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, Read, Seek, Write}; + use std::process::{Child, Command, Stdio}; + use std::sync::mpsc; + + struct Bus(Child); + impl Drop for Bus { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + fn bus() -> (Bus, Connection, Connection) { + let guard = crate::CHILD_SPAWNS.read().unwrap(); + let mut child = Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(Stdio::piped()).spawn().unwrap(); + drop(guard); + let mut address = String::new(); + std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut address).unwrap(); + let server = zbus::blocking::connection::Builder::address(address.trim()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap(); + let client = zbus::blocking::connection::Builder::address(address.trim()).unwrap().method_timeout(Duration::from_secs(2)).build().unwrap(); + (Bus(child), server, client) + } + + /// A real D-Bus FD transfer carries only the owned SVG, with explicit writable/chooser + /// options. An immediate response cannot race subscription; writing that FD updates the + /// original watched path. A rejected chooser never succeeds or launches a fallback. + #[test] + fn shares_a_writable_working_copy_and_handles_chooser_responses() { + for code in [0u32, 1, 2] { + let (_bus, server, client) = bus(); + let owned = OwnedSvg::create(b"").unwrap(); + let messages = zbus::blocking::MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + for message in messages { + let message = message.unwrap(); + let header = message.header(); + if header.member().map(|m| m.as_str()) != Some("OpenFile") { + continue; + } + let (parent, fd, options): (String, zbus::zvariant::OwnedFd, HashMap) = message.body().deserialize().unwrap(); + assert!(parent.is_empty()); + assert!(bool::try_from(options["ask"].try_clone().unwrap()).unwrap()); + assert!(bool::try_from(options["writable"].try_clone().unwrap()).unwrap()); + let mut file = std::fs::File::from(std::os::fd::OwnedFd::from(fd)); + let mut bytes = String::new(); + file.read_to_string(&mut bytes).unwrap(); + assert_eq!(bytes, ""); + if code == 0 { + file.rewind().unwrap(); + file.write_all(b"").unwrap(); + file.sync_all().unwrap(); + } + let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap(); + let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_"); + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap(); + server + .emit_signal( + header.sender().cloned().map(zbus::names::BusName::from), + path.as_str(), + "org.freedesktop.portal.Request", + "Response", + &(code, HashMap::>::new()), + ) + .unwrap(); + // Keep the owner alive until the client has consumed its response. + return server; + } + panic!("OpenFile was not called"); + }); + let (_stop, stopped) = mpsc::channel(); + assert_eq!(open_on(&client, &owned, &stopped).is_ok(), code == 0); + let _server = worker.join().unwrap(); + assert_eq!(std::fs::read(&owned.file).unwrap(), if code == 0 { b"" } else { b"" }); + } + } + + #[test] + fn refuses_redirected_or_nonregular_files_before_contacting_the_portal() { + let (_bus, _server, client) = bus(); + let owned = OwnedSvg::create(b"").unwrap(); + let other = tempfile::NamedTempFile::new().unwrap(); + std::fs::remove_file(&owned.file).unwrap(); + std::os::unix::fs::symlink(other.path(), &owned.file).unwrap(); + let (_stop, stopped) = mpsc::channel(); + assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file")); + std::fs::remove_file(&owned.file).unwrap(); + rustix::fs::mkfifoat(rustix::fs::CWD, &owned.file, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR).unwrap(); + assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file")); + } + #[test] + fn stopping_sync_closes_an_unanswered_desktop_request() { + let (_bus, server, client) = bus(); + let owned = OwnedSvg::create(b"").unwrap(); + let messages = zbus::blocking::MessageIterator::from(&server); + let (stop, stopped) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let mut pending_stop = Some(stop); + for message in messages { + let message = message.unwrap(); + let header = message.header(); + match header.member().map(|member| member.as_str()) { + Some("OpenFile") => { + let (_, _, options): (String, zbus::zvariant::OwnedFd, HashMap) = message.body().deserialize().unwrap(); + let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap(); + let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_"); + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap(); + pending_stop.take().unwrap().send(()).unwrap(); + } + Some("Close") => { + server.reply(&header, &()).unwrap(); + return; + } + _ => {} + } + } + panic!("The desktop request was not closed"); + }); + assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("cancelled")); + worker.join().unwrap(); + } +} diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 56d721e495..6fddbe9bf1 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -535,18 +535,20 @@ impl Sentinel { /// file, read before the window opens; `Auto` when the file or value is missing or unknown. #[cfg(any(target_os = "linux", test))] pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer { + read_performance(path).map(|performance| performance.linux_display_server).unwrap_or_default() +} + +/// The preferences file's Performance section, read as the app reads it: a value of the wrong +/// type keeps its default without resetting the others. `None` without a file or section. +fn read_performance(path: Option<&Path>) -> Option { let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); - v.get("performance") - .and_then(|p| serde_json::from_value::(p.clone()).ok()) - .map(|performance| performance.linux_display_server) - .unwrap_or_default() + v.get("performance").cloned().map(photocraft_engine::prefs::read_section) } /// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window -/// opens; empty (automatic) when the file or value is missing. +/// opens; empty (automatic) when the file or value is missing or not text. pub fn read_gpu_adapter(path: Option<&Path>) -> String { - let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); - v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect() + read_performance(path).map(|performance| performance.gpu_adapter.chars().take(256).collect()).unwrap_or_default() } /// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless diff --git a/apps/photocraft/src/gpu_startup/tests.rs b/apps/photocraft/src/gpu_startup/tests.rs index ee1f0ce1e6..0fabe6c116 100644 --- a/apps/photocraft/src/gpu_startup/tests.rs +++ b/apps/photocraft/src/gpu_startup/tests.rs @@ -37,6 +37,8 @@ fn display_server_preference_is_read_before_the_window_opens() { // Unknown values, other types, broken JSON and a missing file all start as Auto. assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto); assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto); + // A bad value beside it resets only itself, as when the app loads the file (#6611). + assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11","cacheLevels":"four"}}"#), LinuxDisplayServer::X11); assert_eq!(read("{not json"), LinuxDisplayServer::Auto); assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto); assert_eq!(read_display_server(None), LinuxDisplayServer::Auto); @@ -210,6 +212,8 @@ fn gpu_adapter_is_read_before_the_window_opens() { }; assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia"); assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), ""); + // A bad value beside it resets only itself, as when the app loads the file (#6611). + assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia","cacheLevels":"four"}}"#), "nvidia"); assert_eq!(read(r#"{"performance":{}}"#), ""); assert_eq!(read("not json"), ""); assert_eq!(read_gpu_adapter(None), ""); diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 6a0cc8f052..f5f014a3c1 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -315,22 +315,31 @@ pub struct AppLogger { filter: Filter, stderr: bool, sink: Mutex, + /// The file [`AppLogger::attach_dir`] opened, so a failure can tell the user what to attach. + path: Mutex>, /// The lock that marks the attached log file as in use (see [`rotate`]); held until exit. owner: Mutex>, } impl AppLogger { pub fn new(filter: Filter, stderr: bool) -> AppLogger { - AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) } + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), path: Mutex::new(None), owner: Mutex::new(None) } } /// Rotate the logs in `dir` and send records to the fresh file; returns its path. pub fn attach_dir(&self, dir: &Path) -> Result { let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + *self.path.lock().unwrap_or_else(PoisonError::into_inner) = Some(path.clone()); *self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner; Ok(path) } + + /// The log file records are written to, once [`AppLogger::attach_dir`] opened one: `None` when + /// the file could not be created (standard error is the only sink, see [`install`]). + pub fn path(&self) -> Option { + self.path.lock().unwrap_or_else(PoisonError::into_inner).clone() + } } impl log::Log for AppLogger { @@ -379,6 +388,21 @@ mod tests { d } + /// The startup-failure dialog names the log file, so the logger has to remember it (#7643). + /// A start that couldn't create one keeps `None` and the dialog leaves the line out. + #[test] + fn path_names_the_attached_log_file() { + let _spawns = crate::no_child_spawns(); + let dir = temp_dir("path"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + assert_eq!(logger.path(), None, "nothing is written before a file is attached"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(logger.path(), Some(path)); + drop(logger); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() { let f = Filter::parse(DEFAULT_FILTER); diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index d1a3b68d3b..75eb0ea83a 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -49,6 +49,8 @@ mod mac_menu; #[cfg(target_os = "macos")] mod mac_window; // Pure logic is tested on every platform; only Linux runs the check. +#[cfg(not(target_arch = "wasm32"))] +mod external_svg; #[cfg(any(target_os = "linux", test))] mod linux_libs; mod logging; @@ -66,6 +68,7 @@ mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). #[cfg(any(not(target_os = "macos"), test))] mod single_instance; +mod startup_report; // Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button). mod tablet; mod ui_state; @@ -75,12 +78,11 @@ mod window_frame; use photocraft_engine::Session; use photocraft_ui_egui::PhotocraftApp; -/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon. -const APP_ID: &str = "ai.storyteller.photocraft"; +use photocraft_ui_egui::os_window::APP_ID; -/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption -/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS -/// keeps its traffic lights over the integrated title strip. +/// Windows and Linux can use the app's title bar, with its own caption buttons and edge resizing +/// (`photocraft_ui_egui::titlebar`); the preference below selects the mode. macOS always keeps its +/// system traffic lights over the integrated title strip. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); /// Whether this start draws its own title bar: Windows and Linux do, unless Preferences › @@ -285,12 +287,19 @@ fn control_setup( cli_port: Option, auth: photocraft_automation::args::ControlArgs, automation: &photocraft_automation::args::AutomationArgs, -) -> Option<(u16, String, photocraft_automation::workspace::AuthorizedWorkspace)> { +) -> Option<(std::net::TcpListener, String, photocraft_automation::workspace::AuthorizedWorkspace)> { let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref())); if let Some(warning) = &warning { eprint!("{warning}"); } let port = port?; + let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => listener, + Err(error) => { + eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {error}"); + std::process::exit(2); + } + }; let (supplied, token_file) = (auth.control_token, auth.control_token_file); let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { Ok(token) => token, @@ -313,7 +322,7 @@ fn control_setup( std::process::exit(2); } }; - Some((port, token, workspace)) + Some((listener, token, workspace)) } /// Record this start's backend in the GPU startup marker, if the marker is locked. @@ -331,11 +340,14 @@ fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup } fn main() -> eframe::Result { - // First, so the panic hook and every start-up warning are recorded (`logging`). + // Before anything starts a thread: may re-execute this process with glibc tuned to return + // the memory of closed documents (#8743). + photocraft_engine::malloc_tuning::apply(); + // Then, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); #[cfg(not(target_arch = "wasm32"))] - if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(done) = craft_update::self_update::finish(&photocraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { if let Err(e) = done { eprintln!("PhotoCraft update helper failed: {e}"); std::process::exit(1); @@ -385,12 +397,14 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] let _ = (new_instance, &launch_options); + // Bind and authenticate the authorized control listener before display/GPU initialization. + let control = control_setup(control_port, auth, &automation); + #[cfg(target_os = "linux")] let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok()); #[cfg(target_os = "linux")] let session = linux_display_session(); - let control = control_setup(control_port, auth, &automation); // Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that // launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -473,6 +487,10 @@ fn main() -> eframe::Result { log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan); let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu; let keep_marker = gpu_startup::keep_marker_after_error(&plan, os); + // For the failure dialog below: the plan itself moves into the window closure. With + // `WGPU_BACKEND` set (`plan.env`) wgpu picks the backend, so name that rather than `auto`. + let started_renderer = plan.env.clone().unwrap_or_else(|| plan.backend.name().to_string()); + let started_note = plan.reason.clone(); let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let created_in_callback = app_created.clone(); let started_sentinel = sentinel.clone(); @@ -599,8 +617,8 @@ fn main() -> eframe::Result { log::warn!("couldn't remember GPU backend {}: {e}", b.name()); } }); - if let Some((port, token, _)) = control { - let rx = control_server::start(port, token, cc.egui_ctx.clone()); + if let Some((listener, token, _)) = control { + let rx = control_server::start(listener, token, cc.egui_ctx.clone()); app = app.with_control(rx); } #[cfg(target_os = "macos")] @@ -632,7 +650,14 @@ fn main() -> eframe::Result { // Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the // precise crosshair for painting tools while it is toggled on (#1758). app.services.caps_lock = caps_lock::service(cc); - app.services.screen_pick = screen_color::service(cc, app.services.is_wayland); + // Pick through the portal on a Wayland desktop even when the window went through + // Xwayland (a pen, or the X11 display-server preference): Xwayland's root window has + // no desktop pixels to read (#8977). + #[cfg(target_os = "linux")] + let portal = screen_color::uses_portal(native_session); + #[cfg(not(target_os = "linux"))] + let portal = false; + app.services.screen_pick = screen_color::service(cc, portal); // Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader // write into the stylus feed. The monitor goes in here, not before the event loop: // AppKit's shared application only exists once winit created it (#759). @@ -710,6 +735,16 @@ fn main() -> eframe::Result { } } } + // A launch that fails before a window exists leaves nothing on screen, and a desktop launch + // has no console for the `Err` above: log it, then say why in a native dialog with the log + // file to attach (#7643). A terminal or automation launch can read standard error or the + // exit status instead, so it keeps the silent exit. + if let Err(error) = &result { + log::error!("PhotoCraft could not start: {error}"); + if startup_report::needs_dialog(control_port.is_some()) { + startup_report::show(&error.to_string(), &started_renderer, started_note.as_deref(), logger.and_then(logging::AppLogger::path).as_deref()); + } + } result } @@ -911,9 +946,12 @@ mod tests { let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("prefs.json"); - assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default"); + let default_custom = super::CUSTOM_TITLEBAR; + assert_eq!(super::custom_titlebar(None), default_custom, "no preferences file: the platform default"); std::fs::write(&file, "not json").unwrap(); - assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default"); + assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an unreadable file: the platform default"); + std::fs::write(&file, r#"{"interface":{}}"#).unwrap(); + assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an older preference file: the platform default"); std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap(); assert!(!super::custom_titlebar(Some(&file))); assert_eq!(super::native_options(false).viewport.decorations, Some(true)); diff --git a/apps/photocraft/src/native_saveback_tests.rs b/apps/photocraft/src/native_saveback_tests.rs new file mode 100644 index 0000000000..70d0e1586e --- /dev/null +++ b/apps/photocraft/src/native_saveback_tests.rs @@ -0,0 +1,222 @@ +//! Opt-in local acceptance. All artwork paths come from the test environment; originals are read-only. + +use super::{native, vector_conversion}; +use base64::Engine as _; +use photocraft_doc::{Document, LayerContent, LayerId, SmartSource}; +use photocraft_ui_egui::{ExternalSvgSession, ExternalVectorSource}; +use serde_json::{Value, json}; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; + +/// Exercise the real CLI's same-format Save, the native watcher and PhotoCraft's transaction. +#[test] +#[ignore = "set VECTORCRAFT_TEST_EDITOR and optionally PHOTO_NATIVE_LOGO_PSD/PHOTO_NATIVE_CONTAINER"] +#[allow(clippy::cognitive_complexity, reason = "one opt-in walk through a real editor, step by step")] +fn original_format_real_editor_save_watcher_and_psd_roundtrip() { + let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR"); + let editor = Path::new(&editor); + let mut photo = photocraft_engine::Session::new(); + let vector_path = std::env::var_os("PHOTO_NATIVE_VECTOR"); + let vector_original = vector_path.as_ref().map(|path| std::fs::read(path).unwrap()); + let original_path = std::env::var_os("PHOTO_NATIVE_LOGO_PSD"); + let original = original_path.as_ref().map(|path| std::fs::read(path).unwrap()); + let mut parent_before = None; + let id; + if let Some(bytes) = &original { + photo.add_document(photocraft_io::import("Logo.psd", bytes).unwrap().document, None); + if let Ok(name) = std::env::var("PHOTO_NATIVE_CONTAINER") { + parent_before = Some(photo.active().unwrap().doc.clone()); + let layer = find_source(&photo.active().unwrap().doc, &name).expect("nested logo container"); + photo.execute("layer.smartObjects.editContents", json!({"layer":layer.0})).unwrap(); + } + let name = std::env::var("PHOTO_NATIVE_SOURCE").unwrap_or("Vector Smart Object.ai".into()); + id = find_source(&photo.active().unwrap().doc, &name).expect("visible embedded vector"); + } else { + photo.execute("file.new", json!({"width":32,"height":24})).unwrap(); + let svg = br#""#; + let placed = photocraft_engine::file_cmds::place_bytes(&mut photo, "Artwork.svg", svg.to_vec(), None, &json!({"smartObject":true})).unwrap(); + id = LayerId(placed["layer"].as_u64().unwrap()); + // Generate native sources with our writer, or use a read-only local vector fixture. + let (format, bytes) = if let Some(bytes) = &vector_original { + let extension = Path::new(vector_path.as_ref().unwrap()).extension().unwrap().to_str().unwrap().to_ascii_lowercase(); + (extension, bytes.clone()) + } else { + let owned = native::OwnedSvg::create(svg).unwrap(); + let format = std::env::var("PHOTO_NATIVE_FORMAT").unwrap_or("ai".into()); + let path = owned.root.join(format!("Input.{format}")); + run_commands( + editor, + &owned.root, + vec![ + ("document.open", json!({"path":owned.file})), + ("document.export", json!({"path":path,"format":format,"preserveEditing":true,"useArtboards":true})), + ], + ); + (format, std::fs::read(path).unwrap()) + }; + let state = photo.active_mut().unwrap(); + let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") }; + sm.source = SmartSource::Embedded { file_name: format!("Artwork.{format}"), bytes: Arc::new(bytes) }; + } + let handoff = photo.execute("layer.smartObjects.exportSvg", json!({"layer":id.0})).unwrap(); + let source = ExternalVectorSource { + bytes: Arc::new(base64::engine::general_purpose::STANDARD.decode(handoff["sourceBase64"].as_str().unwrap()).unwrap()), + extension: handoff["extension"].as_str().unwrap().into(), + size: serde_json::from_value(handoff["sourceSize"].clone()).unwrap(), + }; + let before = photo.active().unwrap().doc.clone(); + let (_send, stop) = mpsc::channel(); + let (owned, initial) = vector_conversion::prepare(editor, &source, &stop, true).unwrap(); + assert_eq!(owned.file.extension().unwrap().to_str().unwrap(), source.extension); + assert!(photo.active().unwrap().doc == before, "preparation must not change the embedded original"); + let file = owned.file.clone(); + let root = owned.root.clone(); + let mut watcher = native::start_native_test(owned, initial.clone(), editor.to_path_buf(), source.clone()).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert!(watcher.poll_vector().is_none(), "opening must not accept the regenerated private AI working copy"); + // A small visible edit, ordinary Save to the already-open file, no conversion to SVG. + run_commands( + editor, + &root, + vec![ + ("document.open", json!({"path":file})), + ("paint.setFill", json!({"color":"#00ff00"})), + ("shape.rectangle", json!({"x":2,"y":2,"width":3,"height":2})), + ("file.save", json!({})), + ], + ); + let saved = std::fs::read(&file).unwrap(); + eprintln!("Native save: format={}, bytes={}", source.extension, saved.len()); + assert!(saved != initial, "Save did not update the native working file"); + assert!(photocraft_io::vector_preview::is_native_source(&saved)); + assert!(!photocraft_io::svg::is_svg(&saved)); + let deadline = Instant::now() + Duration::from_secs(90); + let snapshot = loop { + assert!(Instant::now() < deadline, "saved native vector never reached watcher"); + if let Some(result) = watcher.poll_vector() { + break result.unwrap(); + } + std::thread::sleep(Duration::from_millis(25)); + }; + assert!(snapshot.bytes == saved, "watcher snapshot differs from saved native bytes"); + let params = json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"extension":source.extension,"sourceBase64":base64::engine::general_purpose::STANDARD.encode(&saved),"previewSvg":String::from_utf8(snapshot.preview_svg.unwrap()).unwrap()}); + assert_eq!(photo.execute("layer.smartObjects.updateVectorSource", params).unwrap()["updated"], true); + let after = photo.active().unwrap().doc.clone(); + let LayerContent::Smart(previous) = &before.layer(id).unwrap().content else { panic!("smart") }; + let LayerContent::Smart(changed) = &after.layer(id).unwrap().content else { panic!("smart") }; + assert_eq!(changed.transform, previous.transform); + assert_eq!(changed.perspective, previous.perspective); + assert_eq!(changed.warp, previous.warp); + assert_eq!(changed.smart_filters, previous.smart_filters); + assert!(embedded(&after, changed).unwrap().1 == saved, "native source was replaced with different bytes"); + assert!(photo.undo()); + assert!(photo.active().unwrap().doc == before, "Undo did not restore original source state"); + assert!(photo.redo()); + assert!(photo.active().unwrap().doc == after, "Redo did not restore saved native source state"); + let output = photocraft_io::export(&after, "Roundtrip.psd", &Default::default()).unwrap(); + let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document; + let layer = find_source(&reopened, &embedded(&after, changed).unwrap().0).unwrap(); + let LayerContent::Smart(sm) = &reopened.layer(layer).unwrap().content else { panic!("smart") }; + assert!(embedded(&reopened, sm).unwrap().1 == saved, "PSD reopen did not retain the saved native bytes"); + assert!(sm.vector_preview == changed.vector_preview, "PSD did not retain the source-bound preview"); + assert!( + sm.transform.m.iter().zip(changed.transform.m).all(|(a, b)| (a - b).abs() < 1e-9), + "PSD corner reconstruction changed placement beyond floating-point tolerance" + ); + assert!(photocraft_engine::smart_cmds::render(&reopened, sm).unwrap().as_ref() == changed.cache.as_ref(), "reopened native preview changed appearance"); + if let Some(parent_before) = parent_before { + photo.execute("layer.smartObjects.saveContents", json!({})).unwrap(); + photo.set_active(0); + let parent_after = photo.active().unwrap().doc.clone(); + assert!(photo.undo()); + assert!(photo.active().unwrap().doc == parent_before, "parent Undo changed original content"); + assert!(photo.redo()); + assert!(photo.active().unwrap().doc == parent_after, "parent Redo changed saved content"); + let output = photocraft_io::export(&parent_after, "Roundtrip.psd", &Default::default()).unwrap(); + let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document; + assert!(contains_source(&reopened, &saved, 0), "parent PSD lost its nested native vector"); + } + if let Some(path) = vector_path { + assert!(std::fs::read(path).unwrap() == vector_original.unwrap(), "original vector changed") + } + if let Some(path) = original_path { + assert!(std::fs::read(path).unwrap() == original.unwrap(), "original PSD changed") + } +} + +fn run_commands(editor: &Path, root: &Path, commands: Vec<(&str, Value)>) { + let cli = editor.parent().unwrap().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }); + let output_path = root.join("TestResponses.jsonl"); + let output = std::fs::File::create(&output_path).unwrap(); + let mut child = Command::new(cli) + .args(["mcp", "--headless", "--automation-read-root"]) + .arg(root) + .arg("--automation-write-root") + .arg(root) + .stdin(Stdio::piped()) + .stdout(output) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let count = commands.len(); + for (index, (command, params)) in commands.into_iter().enumerate() { + writeln!( + input, + "{}", + json!({"jsonrpc":"2.0","id":index+1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}) + ) + .unwrap(); + } + drop(input); + let deadline = Instant::now() + Duration::from_secs(90); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success()); + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("editor test timed out") + } + std::thread::sleep(Duration::from_millis(25)); + } + let responses = std::fs::read_to_string(output_path).unwrap(); + for id in 1..=count { + let response = responses.lines().filter_map(|line| serde_json::from_str::(line).ok()).find(|r| r["id"] == id).unwrap(); + assert!(response.get("error").is_none() && response["result"]["isError"] == false, "editor command {id} failed"); + } +} + +fn embedded<'a>(doc: &'a Document, sm: &'a photocraft_doc::SmartObject) -> Option<(String, &'a [u8])> { + match &sm.source { + SmartSource::Embedded { file_name, bytes } => Some((file_name.clone(), bytes)), + SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path), + } +} +fn find_source(doc: &Document, name: &str) -> Option { + doc.walk().into_iter().find_map(|(path, _, layer)| { + if !doc.shown_at(&path) { + return None; + } + let LayerContent::Smart(sm) = &layer.content else { return None }; + embedded(doc, sm).filter(|(file, _)| file == name).map(|_| layer.id) + }) +} + +fn contains_source(doc: &Document, saved: &[u8], depth: usize) -> bool { + if depth > 4 { + return false; + } + doc.walk().into_iter().any(|(_, _, layer)| { + let LayerContent::Smart(sm) = &layer.content else { return false }; + let Some((name, bytes)) = embedded(doc, sm) else { return false }; + bytes == saved + || ((bytes.starts_with(b"8BPS") || photocraft_format::is_pcraft(bytes)) + && photocraft_io::import(&name, bytes).is_ok_and(|child| contains_source(&child.document, saved, depth + 1))) + }) +} diff --git a/apps/photocraft/src/screen_color.rs b/apps/photocraft/src/screen_color.rs index ec8cd6378c..6a5ae643c5 100644 --- a/apps/photocraft/src/screen_color.rs +++ b/apps/photocraft/src/screen_color.rs @@ -38,6 +38,12 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option bool { + desktop == crate::linux_libs::DisplaySession::Wayland +} #[derive(Clone, Copy)] #[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))] struct Placement { @@ -480,6 +486,15 @@ mod tests { let offset = (y * first.width + x) * 4; assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]); } + #[cfg(target_os = "linux")] + #[test] + fn a_wayland_desktop_picks_through_the_portal_even_with_an_xwayland_window() { + use crate::linux_libs::DisplaySession; + // A pen or the X11 display-server preference opens the window through Xwayland, but the + // session is still Wayland: the X11 grab fails there with BadMatch (#8977). + assert!(uses_portal(DisplaySession::Wayland)); + assert!(!uses_portal(DisplaySession::X11)); + } #[test] fn screen_capture_matches_negative_and_hidpi_monitor_geometry() { let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }]; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index c4350141fb..7741a8d8a3 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenRaster", &["ora"]), ("PNG", &["png"]), ("JPEG", &["jpg", "jpeg"]), + ("JPEG XL", &["jxl"]), ("WebP", &["webp"]), ("TIFF", &["tif", "tiff"]), ("Targa", &["tga"]), @@ -360,9 +361,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string()) } -/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389). -fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> { - photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) +/// [`write_atomic`] for an encoded save, reporting the fraction written (a background save's +/// progress, #3389). A PSD/PSB is serialized straight into the temporary file, so the whole file +/// is never held in memory next to its model (#6607). +fn write_encoded_atomic(path: &Path, encoded: &photocraft_io::Encoded, progress: &dyn Fn(f32)) -> Result<(), String> { + match encoded { + photocraft_io::Encoded::Bytes(bytes) => photocraft_format::atomic_write_progress(path, bytes, progress), + photocraft_io::Encoded::Psd(_) => { + // Only the progress bar's scale: a model whose length can't be worked out fails the + // write the same way, through the atomic write's "not changed" error. + let len = encoded.encoded_len().unwrap_or(0); + photocraft_format::atomic_write_stream_progress(path, len, progress, &mut |w| encoded.write_to(w)) + } + } + .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 @@ -412,26 +424,26 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i const ENCODE_SHARE: f32 = 0.85; /// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017): -/// encode, then write through `write`. Cancelling while encoding leaves the target untouched; -/// once writing starts the save can't be cancelled. The encoder's progress fills the first +/// encode, then write through `write` (a PSD/PSB is serialized as it is written). Cancelling +/// while encoding leaves the target untouched; once writing starts the save can't be cancelled. The encoder's progress fills the first /// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389). fn save_through( doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl, - write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>, + write: impl Fn(&str, &photocraft_io::Encoded, &dyn Fn(f32)) -> Result<(), String>, ) -> Result, String> { ctl.progress(0.0, "Encoding"); let opts = export_options(settings); let cancelled = || ctl.check().is_err(); let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, ""); let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress); - let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?; + let r = crate::crash_guard::guard("Save", || photocraft_io::encode_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?; ctl.commit()?; ctl.progress(ENCODE_SHARE, "Writing"); // The last bit is the flush and rename after the last chunk: 100% only once they're done. - write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?; + write(path, &r.encoded, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?; ctl.progress(1.0, ""); Ok(r.warnings) } @@ -442,7 +454,7 @@ fn save_file( settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl, ) -> Result, String> { - save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress)) + save_through(doc, path, settings, ctl, |p, encoded, progress| write_encoded_atomic(Path::new(p), encoded, progress)) } type SharedRecovery = Rc>>; @@ -496,6 +508,11 @@ fn guarded_clipboard_get(mut read: impl FnMut() -> Result Result>, String> + 'static) -> photocraft_ui_egui::ClipboardGetSvgFn { + 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))) } @@ -615,6 +632,18 @@ fn clipboard_text(read: Result) -> Result } } +/// Keep SVG source for Paste As without accepting an unbounded clipboard string as a new copy. +fn svg_clipboard_source(text: Option) -> Result>, String> { + let Some(text) = text else { return Ok(None) }; + if !photocraft_io::svg::is_svg(text.as_bytes()) { + return Ok(None); + } + if text.len() > 16 << 20 { + return Err("SVG clipboard data exceeds the 16 MiB limit".into()); + } + Ok(Some(text.into_bytes())) +} + /// The shell command that starts this install under XWayland, where winit delivers file drops /// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland /// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH. @@ -634,12 +663,6 @@ fn shell_word(s: &str) -> String { if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) } } -/// The release-check transport, or `None` inside Flatpak: `flatpak update` owns updates there. -/// The sandbox has network (for the `--control` server), which does not change this. -fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option { - flatpak_id.is_none().then(crate::updates::service) -} - pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); let clip_unreadable: Rc> = Rc::default(); @@ -662,7 +685,7 @@ pub fn native(automation: Option) -> }); let automation_save = automation.clone().map(|workspace| { std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| { - save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string())) + save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write_stream(p, &mut |w| encoded.write_to(w)).map_err(|e| e.to_string())) }) as photocraft_ui_egui::SaveFileFn }); let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step; @@ -704,7 +727,7 @@ pub fn native(automation: Option) -> inbox: None, open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))), beep: Some(platform_beep), - check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()), + check_updates: Some(crate::updates::service()), clipboard_set_image: Some({ let clip = clip.clone(); guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| { @@ -716,6 +739,7 @@ pub fn native(automation: Option) -> cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string()) }) }), + open_external_svg: Some(Box::new(crate::external_svg::open)), clipboard_get_image: Some({ let clip = clip.clone(); let unreadable = clip_unreadable.clone(); @@ -752,6 +776,17 @@ pub fn native(automation: Option) -> ) }) }), + clipboard_get_svg: Some({ + let clip = clip.clone(); + guarded_clipboard_svg(move || { + let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; + let cb = match slot.as_mut() { + Some(c) => c, + None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?), + }; + svg_clipboard_source(clipboard_text(cb.get_text())?) + }) + }), clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), diff --git a/apps/photocraft/src/services/tests.rs b/apps/photocraft/src/services/tests.rs index c71074572e..fb2188cdab 100644 --- a/apps/photocraft/src/services/tests.rs +++ b/apps/photocraft/src/services/tests.rs @@ -4,6 +4,41 @@ use photocraft_format::list_recovery; use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; +/// #6607: a PSD/PSB save streams the file model into the temporary file, which then holds +/// exactly the exported bytes; a serializer error halfway leaves the old file and no +/// temporary file behind. +#[test] +fn a_psd_save_streams_the_exported_bytes_and_keeps_the_old_file_on_error() { + let dir = temp("stream"); + let mut doc = Document::new("streamed", photocraft_geom::Size::new(64, 48), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + let px: Vec = (0..64 * 48 * fmt.bytes_per_pixel()).map(|i| (i * 7 % 251) as u8).collect(); + doc.layers + .push(photocraft_doc::Layer::new("A", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::from_interleaved(fmt, doc.bounds(), &px)))); + let opts = photocraft_io::ExportOptions::default(); + for name in ["a.psd", "a.psb"] { + let path = dir.join(name); + std::fs::write(&path, b"old").unwrap(); + let want = photocraft_io::export(&doc, name, &opts).unwrap().bytes; + let r = photocraft_io::encode_with(&doc, name, &opts, &photocraft_raster::Interrupt::NONE).unwrap(); + let photocraft_io::Encoded::Psd(mut file) = r.encoded else { panic!("{name}: not streamed") }; + let seen = std::cell::RefCell::new(Vec::new()); + write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file.clone()), &|f| seen.borrow_mut().push(f)).unwrap(); + assert!(std::fs::read(&path).unwrap() == want, "{name}: the saved file differs from the export"); + assert_eq!(seen.borrow().last().copied(), Some(1.0), "{name}"); + + let info = file.layer_info.as_mut().unwrap(); + let layer = info.layers[0].clone(); + info.layers = vec![layer; 32768]; + let e = write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file), &|_| {}).unwrap_err(); + assert!(e.contains("32767") && e.contains("not changed"), "{name}: {e}"); + assert!(std::fs::read(&path).unwrap() == want, "{name}: a failed save replaced the file"); + let temps: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().ends_with(".tmp")).collect(); + assert!(temps.is_empty(), "{name}: left behind {temps:?}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// #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] @@ -182,9 +217,8 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() { } #[test] -fn flatpak_builds_have_no_update_transport() { - assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none()); - assert!(update_transport(None).is_some()); +fn native_services_provide_update_transport() { + assert!(native(None).check_updates.is_some()); } #[test] @@ -253,7 +287,7 @@ fn every_save_dialog_leads_with_its_own_extension() { app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap(); let _ = invoke(&mut app, "file.export.quickExportAsPng"); } - for format in ["gif", "png8", "png24", "jpeg", "wbmp"] { + for format in ["gif", "png8", "png24", "jpeg", "webp", "wbmp"] { dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format})); } app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); @@ -266,13 +300,13 @@ fn every_save_dialog_leads_with_its_own_extension() { let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect(); // The extension each save above should suggest, in order. let want = [ - "psd", // Save As untitled - "pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng - "psd", // Save a Copy - "png jpg webp tif tga", // Export As - "png jpg gif webp", // Quick Export - "gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices - "csv pcpresets", // Measurement Log, Export Presets + "psd", // Save As untitled + "pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng + "psd", // Save a Copy + "png jpg webp tif tga", // Export As + "png jpg gif webp", // Quick Export + "gif png png jpg webp wbmp html", // Save for Web: gif png8 png24 jpeg webp wbmp, then with slices + "csv pcpresets", // Measurement Log, Export Presets ] .join(" "); assert_eq!(exts, want.split(' ').collect::>()); @@ -313,10 +347,15 @@ fn save_as_offers_only_writable_formats_including_ico() { let subtype = name.rsplit('.').next().unwrap_or_default(); assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered"); } - // File › Open lists every file Save As writes, also in that format's own type (PDF is written - // but not read yet). + // File Open lists readable Save As formats in their own type. Export-only formats must + // stay absent from both Open filters rather than advertising an unavailable decoder. for (name, exts) in SAVE_FILTERS { for ext in exts.iter().filter(|e| **e != "pdf") { + if photocraft_codecs::from_extension(ext).is_some_and(|format| !format.caps().read) { + assert!(!OPEN_EXTS.contains(ext), "{name}: export-only .{ext} must not appear in All Formats"); + assert!(!OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: export-only .{ext} must not appear in an Open type"); + continue; + } assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}"); assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}"); } @@ -705,6 +744,33 @@ fn from_text(text: &str) -> Option<(u32, u32, Vec)> { clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(text.to_string()))).expect("no read fails") } +#[test] +fn svg_clipboard_source_preserves_markup_and_bounds_its_size() { + let bytes = svg_clipboard_source(Some(HALF_RED.into())).unwrap().unwrap(); + assert_eq!(bytes, HALF_RED.as_bytes()); + for text in [None, Some("plain text".into()), Some("

text

".into())] { + assert_eq!(svg_clipboard_source(text), Ok(None)); + } + let mut oversized = HALF_RED.to_string(); + oversized.extend(std::iter::repeat_n(' ', (16 << 20) + 1)); + assert!(svg_clipboard_source(Some(oversized)).is_err()); +} + +#[test] +fn a_panicking_svg_clipboard_read_is_reported_and_can_be_retried() { + let panics = Rc::new(Cell::new(true)); + let fail = panics.clone(); + let mut get = guarded_clipboard_svg(move || { + if fail.get() { + panic!("SVG clipboard reader bug"); + } + Ok(Some(HALF_RED.as_bytes().to_vec())) + }); + assert!(get().unwrap_err().contains("Paste failed")); + panics.set(false); + assert_eq!(get().unwrap().unwrap(), HALF_RED.as_bytes()); +} + fn pixel(img: &(u32, u32, Vec), x: u32, y: u32) -> [u8; 4] { let at = ((y * img.0 + x) * 4) as usize; img.2[at..at + 4].try_into().unwrap() @@ -882,3 +948,22 @@ fn a_panicking_clipboard_read_is_reported_not_a_crash() { let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug")); assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed")); } + +/// #8827: what TIFF Options sets in `ExportSettings` reaches the file the app writes: the +/// compression, Save Transparency and Save Image Pyramid. +#[test] +fn tiff_options_reach_the_written_file() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let settings = photocraft_ui_egui::ExportSettings { + tiff_compression: photocraft_codecs::TiffCompression::Lzw, + tiff_transparency: Some(false), + tiff_pyramid: true, + ..Default::default() + }; + let bytes = photocraft_io::export(&doc, "t.tif", &export_options(&settings)).unwrap().bytes; + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.iter().map(|p| (p.width, p.compression)).collect::>(), [(200, 5), (100, 5), (50, 5)]); + assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha()); +} diff --git a/apps/photocraft/src/startup_report.rs b/apps/photocraft/src/startup_report.rs new file mode 100644 index 0000000000..554b99dea0 --- /dev/null +++ b/apps/photocraft/src/startup_report.rs @@ -0,0 +1,104 @@ +//! Telling the user why a launch failed when there is no window to say it in (#7643). +//! +//! A desktop launch has no console — release builds are GUI-subsystem apps on Windows — so the +//! `Err` that ends `main` used to vanish and PhotoCraft "just closed". This shows one native +//! dialog with the error, the renderer that was tried and the log file to attach. The text is +//! English diagnostics, like Help › System Info, so a bug report reads the same in every language. +//! +//! Terminal and automation launches keep the old behaviour: they can read standard error or the +//! exit status, and a modal dialog would leave a script waiting. + +use std::io::IsTerminal; +use std::path::Path; + +/// Whether a failed launch needs the dialog: no terminal under standard error, no control client +/// (an agent launch reads the exit status instead of clicking OK), and a screen to put it on. +pub fn dialog_wanted(stderr_is_terminal: bool, automation: bool, display: bool) -> bool { + !stderr_is_terminal && !automation && display +} + +/// Whether this launch asks for the dialog (see [`dialog_wanted`]). +pub fn needs_dialog(automation: bool) -> bool { + dialog_wanted(std::io::stderr().is_terminal(), automation, has_display()) +} + +/// Whether there is a screen for a dialog: always on Windows and macOS; on Linux a headless box +/// (a CI runner, an SSH session — `startup_without_display` removes both variables) has none. +fn has_display() -> bool { + #[cfg(target_os = "linux")] + { + ["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty())) + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + +/// The dialog's text: the error, the renderer that was tried and why it wasn't the preference, +/// the version and, when one was opened, the log file. +pub fn text(error: &str, renderer: &str, note: Option<&str>, version: &str, log: Option<&Path>) -> String { + let mut text = format!("PhotoCraft could not start.\n\n{error}\n\nRenderer: {renderer}"); + if let Some(note) = note { + text.push_str(&format!("\nStartup: {note}")); + } + text.push_str(&format!("\nVersion: {version}")); + if let Some(path) = log { + text.push_str(&format!("\nLog file: {}", path.display())); + } + text +} + +/// Show [`text`] in a native error dialog. Called after the event loop returned, so nothing is +/// repainting and the launch was about to fail anyway. +pub fn show(error: &str, renderer: &str, note: Option<&str>, log: Option<&Path>) { + let version = photocraft_engine::build_info::long_version(); + let body = text(error, renderer, note, &version, log); + log::info!("showing the startup-failure dialog"); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(photocraft_ui_egui::i18n::t("PhotoCraft could not start")) + .set_description(body) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_desktop_launch_asks_the_user_and_a_script_does_not() { + assert!(dialog_wanted(false, false, true), "no console: the error would vanish"); + assert!(!dialog_wanted(true, false, true), "a terminal shows the error itself"); + assert!(!dialog_wanted(false, true, true), "an automation client reads the exit status"); + assert!(!dialog_wanted(false, false, false), "a headless launch has no screen for a dialog"); + } + + #[test] + fn the_text_names_the_error_renderer_version_and_log() { + let log = Path::new("/home/me/.config/photocraft/logs/photocraft.log"); + let body = text("no adapter for Vulkan", "dx12", None, "0.15.1 (abc1234)", Some(log)); + for part in [ + "could not start", + "no adapter for Vulkan", + "Renderer: dx12", + "Version: 0.15.1 (abc1234)", + "Log file: /home/me/.config/photocraft/logs/photocraft.log", + ] { + assert!(body.contains(part), "{part:?} missing from {body:?}"); + } + assert!(!body.contains("Startup:"), "no fallback to explain: {body:?}"); + // A start whose log file could not be created says so by leaving the line out. + assert!(!text("boom", "cpu", None, "0.15.1", None).contains("Log file:")); + } + + /// A crash fallback is the confusing case: the app picks a renderer the user never chose, so + /// the dialog has to say why as well as which. + #[test] + fn a_fallback_that_was_not_the_preference_is_explained() { + let note = "the previous start didn't finish on vulkan; using dx12"; + let body = text("device lost", "dx12", Some(note), "0.15.1", None); + assert!(body.contains(&format!("Renderer: dx12\nStartup: {note}\n")), "{body:?}"); + } +} diff --git a/apps/photocraft/src/updates.rs b/apps/photocraft/src/updates.rs index 40ac9ae016..a11a7b63c9 100644 --- a/apps/photocraft/src/updates.rs +++ b/apps/photocraft/src/updates.rs @@ -22,6 +22,6 @@ pub fn service() -> CheckFn { fn check() -> CheckResult { let http = craft_update::Http::new("PhotoCraft/Update")?; - let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?; + let found = craft_update::check_for_update(&photocraft_ui_egui::UPDATE_APP, &http, photocraft_engine::build_info::VERSION)?; Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url })) } diff --git a/apps/photocraft/src/vector_conversion.rs b/apps/photocraft/src/vector_conversion.rs new file mode 100644 index 0000000000..ce473a1b95 --- /dev/null +++ b/apps/photocraft/src/vector_conversion.rs @@ -0,0 +1,342 @@ +//! Bounded display previews for native vector editing. The working source retains its format. +//! No document metadata supplies a process, command line or output path. + +use super::native::OwnedSvg; +use photocraft_ui_egui::ExternalVectorSource; +use std::io::{Read, Seek, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +/// Validate the source using a private preview, then expose only an original-format copy. +/// `regenerate_ai` is true only when the interactive editor is VectorCraft: other editors +/// (Illustrator, a chosen program) get the original AI bytes. +pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, regenerate_ai: bool) -> Result<(OwnedSvg, Vec), String> { + render_preview(editor, source, stop)?; + // Foreign AI opens read-only for ordinary Save in VectorCraft. Regenerate only this + // private AI working copy with editable AI data; never weaken that source-file protection. + // Comparing the watcher with this initial copy prevents an open-only/no-op replacement. + let regenerate = regenerate_ai && source.extension == "ai"; + let initial = if regenerate { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() }; + if regenerate { + let prepared = ExternalVectorSource { bytes: std::sync::Arc::new(initial.clone()), extension: source.extension.clone(), size: source.size }; + render_preview(editor, &prepared, stop)?; + } + let owned = OwnedSvg::create_format(&initial, &source.extension)?; + Ok((owned, initial)) +} + +/// Render a stable source snapshot inside private MCP roots; never rewrite the editor's file. +pub(super) fn render_preview(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result, String> { + let converted = if source.extension == "svg" { source.bytes.as_ref().clone() } else { run_export(editor, source, stop, "svg")? }; + source_svg(&converted, source.size) +} + +/// Convert `source` to `format` (`svg` or `ai`) with the `vectorcraft-cli` next to `editor`, +/// confined to a private temporary read/write root. Errors on a format/magic mismatch, a +/// missing or linked CLI, a failed open/export reply, cancellation via `stop`, or the +/// 60-second deadline; the child process is killed and reaped on any failure. +fn run_export(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, format: &str) -> Result, String> { + if !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") { + return Err("Unsupported embedded vector source type".into()); + } + if !matches!(format, "svg" | "ai") + || !photocraft_io::vector_preview::is_native_source(&source.bytes) + || (source.extension == "pdf" && !source.bytes.starts_with(b"%PDF-")) + || (source.extension == "eps" && source.bytes.starts_with(b"%PDF-")) + { + return Err("The saved bytes do not match the original vector format".into()); + } + let owned = OwnedSvg::create_format(&source.bytes, format)?; + let cli_name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" }; + let cli = editor.parent().ok_or("The editor has no installation directory")?.join(cli_name); + // Selecting an editor explicitly also selects its adjacent VectorCraft CLI; PATH and the + // embedded file name never participate in executable discovery. + let metadata = + std::fs::symlink_metadata(&cli).map_err(|_| "Embedded AI/PDF/EPS editing needs vectorcraft-cli next to the selected VectorCraft executable")?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("VectorCraft CLI must be a regular file".into()); + } + let input = owned.root.join(format!("Source.{}", source.extension)); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&input).map_err(|e| format!("Cannot create vector input: {e}"))?; + file.write_all(&source.bytes).map_err(|e| format!("Cannot write vector input: {e}"))?; + drop(file); + let commands = export_commands(&input, &owned.file, format)?; + // MCP's headless roots apply to every thread and file reader, including references in + // hostile embedded artwork. Ordinary `convert` does not establish those restrictions. + let responses_path = owned.root.join("Responses.jsonl"); + let mut responses_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&responses_path) + .map_err(|e| format!("Cannot create conversion response file: {e}"))?; + let responses_output = responses_file.try_clone().map_err(|e| format!("Cannot prepare conversion response handle: {e}"))?; + let mut child = Command::new(cli) + .args(["mcp", "--headless", "--automation-read-root"]) + .arg(&owned.root) + .arg("--automation-write-root") + .arg(&owned.root) + .current_dir(&owned.root) + .stdin(Stdio::piped()) + .stdout(Stdio::from(responses_output)) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Cannot start VectorCraft conversion: {e}"))?; + let result = (|| { + let mut stdin = child.stdin.take().ok_or("VectorCraft conversion input is unavailable")?; + for (id, command, params) in commands { + let request = serde_json::json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}); + writeln!(stdin, "{request}").map_err(|e| format!("Cannot send vector conversion request: {e}"))?; + } + drop(stdin); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if stop.try_recv().is_ok() { + return Err("Vector editing cancelled".into()); + } + if let Some(status) = child.try_wait().map_err(|e| format!("Cannot wait for vector conversion: {e}"))? { + if !status.success() { + return Err("VectorCraft could not convert the embedded vector".into()); + } + break; + } + if Instant::now() >= deadline { + return Err("VectorCraft conversion timed out".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + let mut responses = String::new(); + // Read the retained handle, never reopen a pathname an input processor could replace. + responses_file.rewind().map_err(|e| format!("Cannot seek conversion responses: {e}"))?; + responses_file.take(1024 * 1024 + 1).read_to_string(&mut responses).map_err(|e| format!("Invalid conversion responses: {e}"))?; + validate_responses(&responses)?; + let converted = owned.read(&owned.stamp()?)?; + if format == "ai" && !converted.starts_with(b"%PDF-") { + return Err("VectorCraft did not save a PDF-compatible AI working copy".into()); + } + Ok(converted) + })(); + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + result +} + +/// Build requests only from UTF-8 paths: serializing a non-UTF-8 `Path` inside `json!` panics. +#[cfg(all(test, unix))] +fn conversion_commands(input: &Path, output: &Path) -> Result<[(u8, &'static str, serde_json::Value); 2], String> { + export_commands(input, output, "svg") +} + +/// The open (id 1) and export (id 2) requests that [`validate_responses`] checks; AI exports +/// preserve editing data. Non-UTF-8 paths return errors because `json!` would panic on them. +fn export_commands(input: &Path, output: &Path, format: &str) -> Result<[(u8, &'static str, serde_json::Value); 2], String> { + let input = input.to_str().ok_or("The temporary vector input path is not UTF-8")?; + let output = output.to_str().ok_or("The temporary vector output path is not UTF-8")?; + Ok([ + (1, "document.open", serde_json::json!({"path": input})), + (2, "document.export", serde_json::json!({"path": output, "format": format, "preserveEditing": format == "ai"})), + ]) +} + +/// Keep the original PSD source-pixel frame: PDF points and EPS bounds need not equal the +/// placed object's stored pixel dimensions. The nested viewport scales vector geometry once. +fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result, String> { + let text = std::str::from_utf8(bytes).map_err(|_| "Converted SVG is not UTF-8")?; + photocraft_io::svg::import_svg_bounded_with_work("Artwork.svg", bytes, 96_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?; + let doc = roxmltree::Document::parse(text).map_err(|e| format!("Invalid converted SVG: {e}"))?; + let root = doc.root_element(); + if root.tag_name().name() != "svg" { + return Err("VectorCraft did not return SVG".into()); + } + let Some((w, h)) = size else { + return Ok(bytes.to_vec()); + }; + if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 || w > 16384.0 || h > 16384.0 || w * h > 24_000_000.0 { + return Err("The original vector source dimensions exceed the editing limit".into()); + } + // The nested root keeps its own width/height, so the outer viewBox must use the same CSS + // pixels: `612pt` is 816 user units at SVG's 96 dpi. Other units are rejected below. + let dimension = |key| { + let value = root.attribute(key)?; + let (number, scale) = value.strip_suffix("pt").map_or((value, 1.0), |number| (number, 4.0 / 3.0)); + number.parse::().ok().map(|v| v * scale).filter(|v| v.is_finite() && *v > 0.0) + }; + let (iw, ih) = dimension("width").zip(dimension("height")).ok_or("Converted SVG has no finite dimensions")?; + let inner = text.get(root.range()).ok_or("Invalid SVG root range")?; + let result = + format!(r#"{inner}"#) + .into_bytes(); + if result.len() > 16 * 1024 * 1024 { + return Err("Converted SVG exceeds 16 MiB".into()); + } + Ok(result) +} + +/// Require successful replies for both opening the source and exporting the SVG. +fn validate_responses(text: &str) -> Result<(), String> { + if text.len() > 1024 * 1024 { + return Err("VectorCraft conversion responses exceeded the limit".into()); + } + let replies: Vec = + text.lines().map(serde_json::from_str).collect::>().map_err(|_| "Invalid VectorCraft conversion response")?; + for id in [1, 2] { + let reply = replies.iter().find(|r| r.get("id").and_then(serde_json::Value::as_u64) == Some(id)).ok_or("Missing VectorCraft conversion response")?; + if reply.get("error").is_some() || reply.get("result").and_then(|r| r.get("isError")).and_then(serde_json::Value::as_bool) != Some(false) { + return Err("VectorCraft could not open or export the embedded vector; original contents were retained".into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn non_utf8_temporary_paths_return_errors_instead_of_panicking() { + use std::os::unix::ffi::OsStrExt; + let invalid = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/vector-\xff")); + let valid = Path::new("/tmp/Artwork.svg"); + assert!(conversion_commands(invalid, valid).is_err()); + assert!(conversion_commands(valid, invalid).is_err()); + let commands = conversion_commands(valid, valid).unwrap(); + assert_eq!(commands[0].2["path"], "/tmp/Artwork.svg"); + assert_eq!(commands[1].2["format"], "svg"); + assert_eq!(commands[1].2["preserveEditing"], false); + } + + #[test] + fn converted_vectors_use_the_psd_source_pixel_frame() { + let svg = br#""#; + let normalized = source_svg(svg, Some((16.0, 12.0))).unwrap(); + let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &normalized, 24_000_000).unwrap(); + assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12)); + assert_eq!(source_svg(svg, None).unwrap(), svg); + let fractional = source_svg(svg, Some((16.3, 12.2))).unwrap(); + let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &fractional, 24_000_000).unwrap(); + assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12)); + for size in [(f64::NAN, 1.0), (1.0, f64::INFINITY), (0.0, 1.0), (16384.0, 16384.0)] { + assert!(source_svg(svg, Some(size)).is_err()); + } + assert!(source_svg(b"%PDF-1.6", None).is_err()); + } + + #[test] + fn point_sized_converted_vectors_fill_the_frame_without_cropping() { + // 12pt × 9pt is 16 × 12 CSS pixels: red on the left half, blue on the right half, + // with and without a root viewBox. Misreading pt as px would scale by 4/3 and crop. + for svg in [ + r#""#, + r#""#, + ] { + let normalized = source_svg(svg.as_bytes(), Some((16.0, 12.0))).unwrap(); + let raster = photocraft_io::svg::rasterize_bounded(&normalized, 1.0, 24_000_000).unwrap(); + assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12)); + let (x0, y0) = (raster.rect.x0, raster.rect.y0); + let is = |x: i32, y: i32, channel: usize| { + let px = raster.get(x0 + x, y0 + y); + px[3] > 0.99 && px[channel] > 0.99 && px[2 - channel] < 0.01 + }; + for y in [0, 6, 11] { + assert!(is(0, y, 0) && is(6, y, 0), "left half must be red: {svg}"); + assert!(is(9, y, 2) && is(15, y, 2), "right half must be blue and uncropped: {svg}"); + } + } + assert!(source_svg(br#""#, Some((16.0, 12.0))).is_err()); + } + + /// A stand-in `vectorcraft-cli` that logs every request beside itself and answers the open + /// and export requests, writing a small SVG (or, for `ai`, a regenerated PDF) to the export path. + #[cfg(unix)] + const FAKE_CLI: &str = r#"#!/bin/sh +log="$(dirname "$0")/requests.log" +while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + case "$line" in + *document.export*) + out=$(printf '%s' "$line" | sed -n 's/.*"path":"\([^"]*\)".*/\1/p') + case "$line" in + *'"format":"ai"'*) printf '%%PDF-1.7 regenerated' > "$out" ;; + *) printf '' > "$out" ;; + esac ;; + esac +done +printf '{"id":1,"result":{"isError":false}}\n{"id":2,"result":{"isError":false}}\n' +"#; + + /// Illustrator or another chosen editor gets the original AI; only VectorCraft's own working + /// copy is regenerated (VectorCraft opens a foreign AI read-only). + #[cfg(unix)] + #[test] + fn ai_working_copy_is_regenerated_only_for_vectorcraft() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let editor = directory.path().join("Editor"); + std::fs::write(&editor, b"#!/bin/sh\n").unwrap(); + let cli = directory.path().join("vectorcraft-cli"); + std::fs::write(&cli, FAKE_CLI).unwrap(); + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap(); + let original = b"%PDF-1.7\noriginal AI".to_vec(); + let source = ExternalVectorSource { bytes: std::sync::Arc::new(original.clone()), extension: "ai".into(), size: None }; + let (_send, stop) = std::sync::mpsc::channel(); + let (owned, initial) = prepare(&editor, &source, &stop, false).unwrap(); + assert_eq!(initial, original); + assert_eq!(std::fs::read(&owned.file).unwrap(), original, "the other editor opens the original AI"); + let log = std::fs::read_to_string(directory.path().join("requests.log")).unwrap(); + assert!(log.contains(r#""format":"svg""#), "the preview is still converted: {log}"); + assert!(!log.contains(r#""format":"ai""#), "no AI re-export for another editor: {log}"); + let (_owned, regenerated) = prepare(&editor, &source, &stop, true).unwrap(); + assert_eq!(regenerated, b"%PDF-1.7 regenerated"); + } + + #[test] + fn conversion_requires_successful_open_and_export_replies() { + let good = r#"{"id":1,"result":{"isError":false}} +{"id":2,"result":{"isError":false}}"#; + assert!(validate_responses(good).is_ok()); + for bad in [ + "", + r#"{"id":2,"result":{"isError":false}}"#, + r#"{"id":1,"result":{"isError":true}} +{"id":2,"result":{"isError":false}}"#, + r#"{"id":1,"error":{"message":"denied"}}"#, + ] { + assert!(validate_responses(bad).is_err()); + } + } +} + +#[cfg(test)] +mod live_vectorcraft_tests { + use super::*; + use std::sync::{Arc, mpsc}; + + /// Opt-in executable test uses only our own PostScript, never user artwork or Adobe assets. + #[test] + #[ignore = "set VECTORCRAFT_TEST_EDITOR to a local VectorCraft executable"] + fn real_vectorcraft_converts_eps_and_rejects_failed_open_without_blank_replacement() { + let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR"); + let editor = Path::new(&editor); + let (_send, stop) = mpsc::channel(); + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n0 0 1 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n"; + let source = ExternalVectorSource { bytes: Arc::new(eps.to_vec()), extension: "eps".into(), size: Some((16.0, 12.0)) }; + let (owned, initial) = prepare(editor, &source, &stop, true).unwrap(); + assert_eq!(initial, *source.bytes); + assert_eq!(owned.file.extension().unwrap(), "eps"); + assert_eq!(std::fs::read(&owned.file).unwrap(), *source.bytes); + let svg = render_preview(editor, &source, &stop).unwrap(); + assert!(owned.file.exists()); + let raster = photocraft_io::svg::rasterize_bounded(&svg, 1.0, 24_000_000).unwrap(); + assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12)); + let invalid = ExternalVectorSource { bytes: Arc::new(b"%PDF-1.6\nnot a PDF".to_vec()), extension: "pdf".into(), size: None }; + assert!(prepare(editor, &invalid, &stop, true).is_err()); + let root = owned.root.clone(); + drop(owned); + assert!(!root.exists()); + } +} diff --git a/apps/photocraft/tests/it/control_exit_code.rs b/apps/photocraft/tests/it/control_exit_code.rs index 4be624c5d2..98c3243133 100644 --- a/apps/photocraft/tests/it/control_exit_code.rs +++ b/apps/photocraft/tests/it/control_exit_code.rs @@ -2,8 +2,7 @@ //! status 0 believes the control server is running when it is not. //! //! These tests run the real binary as a subprocess and assert the exit code, -//! covering both failure paths in `main.rs`: control authentication and the -//! automation workspace. +//! covering control authentication, the automation workspace and listener binding. /// A syntactically valid 64-hex-char token, so the workspace test reaches the /// workspace check instead of failing on token validation first. @@ -28,7 +27,7 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run { let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let output = std::process::Command::new(crate::binary()) + let mut child = std::process::Command::new(crate::binary()) .args(args) .env("PHOTOCRAFT_CONFIG_DIR", &dir) // Isolate from the host environment so the test is reproducible. @@ -41,12 +40,38 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run { .env_remove("WAYLAND_DISPLAY") .env_remove("WAYLAND_SOCKET") .envs(vars.iter().copied()) - .output() + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() .expect("failed to run photocraft binary"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let timed_out = loop { + if child.try_wait().unwrap().is_some() { + break false; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + break true; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let output = child.wait_with_output().unwrap(); + let gpu_started = dir.join("gpu-starting.json").exists(); std::fs::remove_dir_all(&dir).ok(); + assert!(!timed_out, "PhotoCraft did not exit: {}", String::from_utf8_lossy(&output.stderr)); + assert!(!gpu_started, "control setup errors must exit before GPU startup"); Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() } } +#[test] +fn an_occupied_control_port_exits_with_status_2() { + let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = held.local_addr().unwrap().port().to_string(); + let Run { status, stderr } = run_photocraft(&["--control", &port, "--control-token", VALID_TOKEN]); + assert_eq!(status.code(), Some(2), "occupied control port must exit 2, stderr: {stderr}"); + assert!(stderr.contains(&format!("failed to bind 127.0.0.1:{port}")), "stderr: {stderr}"); +} + #[test] fn invalid_control_token_exits_with_status_2() { // `validate_token` rejects a token that is not exactly 64 hex characters. diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 8ddb66f093..b229aadee4 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -17,14 +17,13 @@ Exactly three colours: ## Geometry A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS -renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders crop 22 units off -each side so the figure reads at 16–48 px. +renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders use the whole tile, +as the other Crafting Apps do. ## Provenance The owner's original drawing, made in ArtCraft (2880 px, engraving style, keyed to the palette), -vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source PNG stays in -craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.txt`. +vectorised. License: see `LICENSE.txt`. ## Files diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.photocraft.png index a28597fc9b..9f65c1fec1 100644 Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.photocraft.png index ec4cb49675..02e1b9886a 100644 Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.photocraft.png index 249b94787b..4b321d2bf5 100644 Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png index db2eb42d8a..b707a2fa91 100644 Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.photocraft.png index ad50ecb450..3694573349 100644 Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.photocraft.png index 341185fdd8..d2ed2d865e 100644 Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.photocraft.png index e54f75c920..ba0bee419e 100644 Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png index e093144cf2..b5cbfd4fa0 100644 Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png differ diff --git a/assets/app-icon/photocraft.ico b/assets/app-icon/photocraft.ico index 88c8a0adad..7d4d947f11 100644 Binary files a/assets/app-icon/photocraft.ico and b/assets/app-icon/photocraft.ico differ diff --git a/assets/icons/crop-overlay-diagonal.svg b/assets/icons/crop-overlay-diagonal.svg new file mode 100644 index 0000000000..429dfe5d5d --- /dev/null +++ b/assets/icons/crop-overlay-diagonal.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-golden-ratio.svg b/assets/icons/crop-overlay-golden-ratio.svg new file mode 100644 index 0000000000..3ebe4c14b8 --- /dev/null +++ b/assets/icons/crop-overlay-golden-ratio.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-golden-spiral.svg b/assets/icons/crop-overlay-golden-spiral.svg new file mode 100644 index 0000000000..4e365613b7 --- /dev/null +++ b/assets/icons/crop-overlay-golden-spiral.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-grid.svg b/assets/icons/crop-overlay-grid.svg new file mode 100644 index 0000000000..aebe804016 --- /dev/null +++ b/assets/icons/crop-overlay-grid.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-thirds.svg b/assets/icons/crop-overlay-thirds.svg new file mode 100644 index 0000000000..3893cfd386 --- /dev/null +++ b/assets/icons/crop-overlay-thirds.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/crop-overlay-triangle.svg b/assets/icons/crop-overlay-triangle.svg new file mode 100644 index 0000000000..70c915d38c --- /dev/null +++ b/assets/icons/crop-overlay-triangle.svg @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/assets/icons/path-op-combine.svg b/assets/icons/path-op-combine.svg new file mode 100644 index 0000000000..4c60a71e25 --- /dev/null +++ b/assets/icons/path-op-combine.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-exclude.svg b/assets/icons/path-op-exclude.svg new file mode 100644 index 0000000000..e2bea5b413 --- /dev/null +++ b/assets/icons/path-op-exclude.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-intersect.svg b/assets/icons/path-op-intersect.svg new file mode 100644 index 0000000000..a6bca9787e --- /dev/null +++ b/assets/icons/path-op-intersect.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/path-op-merge.svg b/assets/icons/path-op-merge.svg new file mode 100644 index 0000000000..32dbe69ffe --- /dev/null +++ b/assets/icons/path-op-merge.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-new-layer.svg b/assets/icons/path-op-new-layer.svg new file mode 100644 index 0000000000..21b26eac87 --- /dev/null +++ b/assets/icons/path-op-new-layer.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-subtract.svg b/assets/icons/path-op-subtract.svg new file mode 100644 index 0000000000..c461ac44ad --- /dev/null +++ b/assets/icons/path-op-subtract.svg @@ -0,0 +1,14 @@ + + + + diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 40ecbdd127..8a0e40a5a2 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -8,9 +8,14 @@ tiles/.zst blobs/.zst thumb.png composite/preview.png +composite/full.png save.lock ``` +`thumb.png` (at most 256 px on the longer side), `composite/preview.png` (at most 1024 px) and `composite/full.png` (the canvas size) are the flattened document as 8-bit RGBA PNGs, whatever the document's depth. The manifest names each one it holds, under `thumbnail`, `composite` and `full_composite`. + +A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^26 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed. + `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. diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index e1a2cc23fa..13ab740a86 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -1,11 +1,23 @@ # Raster formats -`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. +`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868. ## Layered TIFF A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +## JPEG XL + +Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare +codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded +natively. Grayscale with alpha is expanded to RGBA at the original bit depth +to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's +incorrect default 8-bit alpha header is corrected to explicitly describe +16-bit alpha, with an independent decoder oracle checking alpha values. +The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path +converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings. +JPEG XL opening remains under review in #2868. + ## WebP Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side. diff --git a/contributors/people.toml b/contributors/people.toml index ac36371f38..08609975e8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -107,7 +104,9 @@ display_name = "Ran Geerts" sync_github_name = false [people.vangel76] -sync_github_name = true +real_name = "Valentin Klein" +display_name = "vangel76" +sync_github_name = false [people.FinnNk] sync_github_name = true @@ -205,6 +204,9 @@ sync_github_name = true [people.fjamesprice] sync_github_name = true +[people.mockydevs] +real_name = "Don Omondi" + [people.ha1des] sync_github_name = true @@ -265,6 +267,11 @@ sync_github_name = false [people.drunkeninja42] real_name = "Kushagra Sharma" +[people.hidoba] +real_name = "Vlad Grankovsky" +display_name = "Vlad Grankovsky" +sync_github_name = false + [people.WebWork-rrs] display_name = "Ratul Paul" @@ -282,3 +289,22 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true + +[people.Yuki-init] +display_name = "DC@yuki.himejima" +sync_github_name = false + +[people.sn-o-w] +real_name = "Cristian Silaghi" +sync_github_name = false + +[people.regexer] +sync_github_name = true + +[people.SimoneRecchia] +real_name = "Simone Recchia" +display_name = "Simone Recchia" +sync_github_name = false diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs index 732feeef49..a93ec84462 100644 --- a/crates/affinity/src/access.rs +++ b/crates/affinity/src/access.rs @@ -23,10 +23,7 @@ impl Stream { /// 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, - } + self.field(id, tag)?.array_len() } /// A child object (shared, linked or inline); `None` for null or missing. diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 96fdbd706c..569ab65c03 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -20,6 +20,11 @@ const MAX_ENTRIES: usize = 1 << 22; /// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry /// and as its map key), and tables can point many names at the same bytes. const MAX_NAME_BYTES: usize = 32 << 20; +/// File and directory records parsed across every allocation table. Each table walks its own +/// records, and tables may start inside one another and share a long suffix, so the per-table +/// counts do not bound the work: a few thousand overlapping tables could reparse one large run +/// of records each. Real files hold a few hundred saves of a few thousand entries. +const MAX_TABLE_RECORDS: usize = 1 << 25; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -175,6 +180,7 @@ impl<'a> Archive<'a> { let mut seen = HashSet::new(); let mut next = header.fat_offset; let mut tables = 0usize; + let mut records_left = MAX_TABLE_RECORDS; while next != 0 { if !seen.insert(next) { return Err(Error::Malformed("allocation tables form a cycle")); @@ -199,6 +205,9 @@ impl<'a> Archive<'a> { c.take(8)?; let dirs = c.u16()?; c.take(1)?; + // Charge the declared records before walking them. + let declared = usize::try_from(files).unwrap_or(usize::MAX).saturating_add(usize::from(dirs)); + records_left = records_left.checked_sub(declared).ok_or(Error::Limit("too many allocation records"))?; for _ in 0..files { let id = c.u32()?; let flag = c.u8()?; diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index fa45cf6213..f834e50598 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -153,8 +153,8 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { let fields: Vec<&Value> = s.positional(c).collect(); // The corner arrays are scanned for every occurrence of a shared curve, so charge // them like records before they are walked. - if let Some(Value::Array(idx)) = fields.get(1) - && let Err(reason) = r.geometry_budget.records(idx.len()) + if let Some(len) = fields.get(1).and_then(|v| v.array_len()) + && let Err(reason) = r.geometry_budget.records(len) { r.warn(reason); return None; @@ -170,17 +170,25 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { Value::Bool(b) => *b, _ => return None, }; - let Value::Array(records) = fields.next()? else { return None }; - seen = seen.checked_add(records.len())?; + let records = fields.next()?; + let len = records.array_len()?; + seen = seen.checked_add(len)?; if seen > MAX_NODES { r.warn("curves with more than four million nodes"); return None; } - if let Err(reason) = r.geometry_budget.records(records.len()) { + // Charge the subpath itself as well as its records: an empty subpath adds no records + // or points, but a shared curve full of them is still walked for every occurrence. + if let Err(reason) = r.geometry_budget.records(len.saturating_add(1)) { r.warn(reason); return None; } - let anchors = anchors(records)?; + let anchors = match records { + Value::Array(a) => anchors(a.iter().map(record)), + Value::Records { size: 18, data } => anchors(data.as_chunks::<18>().0.iter().map(record18)), + // Records of another width are no curve nodes. + _ => anchors(std::iter::repeat_n(None, len)), + }?; let radii = if index == 0 { corners.as_ref() } else { None }; if let Some(sp) = subpath(&anchors, closed, radii) { path.subpaths.push(sp); @@ -202,22 +210,24 @@ struct Anchor { fn record(v: &Value) -> Option<(Point, u8)> { let Value::Bytes(b) = v else { return None }; - if b.len() != 18 { - return None; - } - let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?); - let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?); + record18(b.as_slice().try_into().ok()?) +} + +/// One 18-byte curve node: f64 x, f64 y, u8 kind, u8 role. +fn record18(b: &[u8; 18]) -> Option<(Point, u8)> { + let x = f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]); + let y = f64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]); if !x.is_finite() || !y.is_finite() { return None; } - Some((Point { x, y }, *b.get(17)?)) + Some((Point { x, y }, b[17])) } -fn anchors(records: &[Value]) -> Option> { +fn anchors(records: impl ExactSizeIterator>) -> Option> { let mut out: Vec = Vec::with_capacity(records.len() / 3 + 1); let mut pending_in = None; - for (i, v) in records.iter().enumerate() { - let (p, role) = record(v)?; + for (i, v) in records.enumerate() { + let (p, role) = v?; match role { 0 => { out.push(Anchor { p, inh: pending_in.take(), out: None, record: i }); diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index c59afa1cac..3d74333111 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -300,6 +300,9 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; + // The parsed stream owns copies of everything it needs: do not hold the inflated bytes + // (hundreds of MB for a large vector document) while the model is built. + drop(doc); Reader::new(&s, &mut archive, limits).document() } diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 1847fad90f..2f1fb86e2d 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -125,15 +125,26 @@ pub struct Stroke { /// Gradient stops per gradient. const MAX_STOPS: usize = 1024; +/// Dash and gap lengths per stroke. Affinity's editor offers three dash/gap pairs; a longer +/// pattern would be copied into every shape that shares its style. +const MAX_DASHES: usize = 64; -fn floats(v: &Value) -> Option> { +/// The first `n` components of a float vector or packed `f32` record. Colours and gradient +/// positions use a few leading components; a shared colour with a huge record is decoded for +/// every occurrence, so the rest is neither converted nor copied. +fn floats(v: &Value, n: usize) -> Option> { match v { - Value::Floats(f) => Some(f.clone()), - Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()), + Value::Floats(f) => Some(f.iter().take(n).copied().collect()), + Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b, n)), _ => None, } } +/// Up to `n` little-endian `f32` samples widened to `f64`. +fn f32_samples(b: &[u8], n: usize) -> Vec { + b.as_chunks::<4>().0.iter().take(n).map(|c| f64::from(f32::from_le_bytes(*c))).collect() +} + /// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB /// equivalent, else its base colour, which in real files hold plain colours. Colours are shared /// objects, so one can link to itself, and with both links followed at every level a colour read @@ -149,26 +160,27 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option { let s = r.s; let class = s.class(id)?; - let col = || -> Option> { - let v = floats(s.field(id, b"_col")?)?; + // `n` is how many components the model reads; only those are decoded and checked. + let col = |n: usize| -> Option> { + let v = floats(s.field(id, b"_col")?, n)?; v.iter().all(|f| f.is_finite()).then_some(v) }; Some(match &class.0.to_be_bytes() { b"RGBA" => { - let v = col()?; + let v = col(4)?; Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) } } b"HSLA" => { - let v = col()?; + let v = col(4)?; let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?); Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) } } b"CMYK" => { - let v = col()?; + let v = col(5)?; Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) } } b"GRAY" => { - let v = col()?; + let v = col(2)?; Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) } } b"LABA" => { @@ -240,7 +252,11 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(), + // A position is (offset, bias): later components are not decoded. + Some(Value::Array(a)) => a.iter().filter_map(|v| floats(v, 2)).take(MAX_STOPS).collect(), + Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => { + data.chunks_exact(usize::from(*size)).map(|b| f32_samples(b, 2)).take(MAX_STOPS).collect() + } _ => Vec::new(), }; let mut stops = Vec::new(); @@ -266,23 +282,52 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option Option { - let items = r.s.objs(id, list); - let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); - items.get(i).or_else(|| items.first()).copied() +/// The active entry of a paint list (the `index`-th object, else the first) and how many +/// objects the list holds. Every raw slot, null ones included, is charged before the list is +/// walked: a shared layer's lists are read again for each occurrence. A list beyond the +/// document's work budget reads as empty. +fn current(r: &mut Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> (Option, usize) { + let s = r.s; + let Some(Value::Array(items)) = s.field(id, list) else { return (None, 0) }; + if r.charge_slots(items.len()).is_err() { + r.warn("fill and stroke lists beyond the document work budget (left empty)"); + return (None, 0); + } + let want = s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); + let (mut first, mut chosen, mut count) = (None, None, 0usize); + for o in items.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }) { + first = first.or(Some(o)); + if count == want { + chosen = Some(o); + } + count += 1; + } + (chosen.or(first), count) } /// The fills of `list` (artboard backgrounds use `BFFl`). pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec { - let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil")); - d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect() + fills_counted(r, id, local, list).0 +} + +/// [`fills`], and how many entries `list` holds. +fn fills_counted(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> (Vec, usize) { + let (d, count) = current(r, id, list, b"BFCr"); + let d = d.or_else(|| r.s.obj(id, b"BFil")); + (d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect(), count) } /// A node's fill and stroke. `local` maps the node's space to document pixels. pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec, Vec) { let s = r.s; - let fills = fills(r, id, local, b"BFFl"); - if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 { + let (fills, fill_count) = fills_counted(r, id, local, b"BFFl"); + let (pen, style, pen_count) = if s.field(id, b"LIFl").is_some() { + let (pen, pens) = current(r, id, b"LIFl", b"LICr"); + (pen, current(r, id, b"LILn", b"LICr").0, pens) + } else { + (s.obj(id, b"PFil"), s.obj(id, b"LSty"), 0) + }; + if fill_count > 1 || pen_count > 1 { r.warn("several fills or strokes on one object (only the active one is imported)"); } if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) { @@ -291,11 +336,6 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec stroke(r, pen, style, local, behind), @@ -333,6 +373,17 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) _ => Align::Center, }; let dash = match s.field(lsty, b"Patn") { + // Each occurrence of a shared style keeps its own scaled copy: refuse long patterns + // before scanning or copying them. + Some(Value::Array(a)) if a.len() > MAX_DASHES => { + r.warn("dash patterns longer than 64 entries (imported as solid strokes)"); + None + } + // The copies are retained across the document: charge them like other shared slots. + Some(Value::Array(a)) if r.charge_slots(a.len()).is_err() => { + r.warn("dash patterns beyond the document work budget (imported as solid strokes)"); + None + } Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { @@ -348,3 +399,19 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) } Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_components_a_schema_reads_are_decoded() { + // A 64 KiB record decodes to the four components asked for, not sixteen thousand. + let record = Value::Bytes(1.0f32.to_le_bytes().repeat(16_383)); + assert_eq!(floats(&record, 4), Some(vec![1.0; 4])); + assert_eq!(floats(&Value::Floats(vec![0.5; 6]), 2), Some(vec![0.5; 2])); + // Fewer components than asked for are returned as they are, for the caller to refuse. + assert_eq!(floats(&Value::Floats(vec![0.5; 3]), 5), Some(vec![0.5; 3])); + assert_eq!(floats(&Value::Bytes(vec![0; 3]), 4), None); + } +} diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 226c66a459..b0a8c2daab 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -187,12 +187,12 @@ fn plane( Some(Value::Array(a)) => a, _ => &[], }; - let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) { - Some(Value::Array(a)) => a.as_slice(), + // Tile references in order. Null slots are skipped, but every slot walked (null or not) is + // charged below, so a shared bitmap's long null prefix is not rescanned for free. + let mut tiles: &[Value] = match s.field(bitmap, &tag(b"Idx", c)) { + Some(Value::Array(a)) => a, _ => &[], - } - .iter() - .filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }); + }; // The scan below runs for every occurrence of a shared bitmap, whatever it produces. r.charge_slots(states.len().min(tw.saturating_mul(th)))?; let row = (w as usize) * bps; @@ -206,7 +206,14 @@ fn plane( 0 | 1 => None, 2 => Some(0xFF), 4 => { - let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) }; + let Some(at) = tiles.iter().position(|v| matches!(v, Value::Obj(Some(_)))) else { + r.charge_slots(tiles.len())?; + return Err(Error::Malformed("missing pixel tile")); + }; + r.charge_slots(at + 1)?; + let Some(Value::Obj(Some(blck))) = tiles.get(at) else { return Err(Error::Malformed("missing pixel tile")) }; + let blck = *blck; + tiles = tiles.get(at + 1..).unwrap_or_default(); let (ox, oy) = (tx * TILE, ty * TILE); if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { continue; diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index c7751da885..a79f7eac03 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -12,9 +12,10 @@ const KAPPA: f64 = 0.552_284_749_830_793_4; const MAX_SIDES: i64 = 10_000; /// The last value of a tag: a shape changed from one kind to another keeps a stale first block. +/// Indexed: a shared shape is read again for every occurrence, and rescanning a large field +/// list each time multiplied into work far beyond the file's size. fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { - let t = Tag::of(tag); - r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v) + r.s.object(id)?.get_last(Tag::of(tag)) } fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { @@ -51,12 +52,14 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let types = match last(r, shape, b"CTyp") { - Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + // Only the four corners are read: a longer array is not scanned or copied, which + // would otherwise repeat for every occurrence of a shared shape. + let types: Vec> = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), _ => Vec::new(), }; - let radii = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + let radii: Vec = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), _ => Vec::new(), }; // Corners follow the first one (top left) unless they were unlocked; then each has diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index 7c7d47017b..74f240b873 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -53,6 +53,23 @@ pub enum Value { /// `None` is a null object. Obj(Option), Array(Vec), + /// An array of fixed-size records (`0x2c`, curve nodes) held in one buffer: `size` bytes each. + /// Counts as one value per record against [`MAX_VALUES`], like the array of `Bytes` it replaces. + Records { + size: u16, + data: Vec, + }, +} + +impl Value { + /// Slot count of an array field (including null slots); `None` for a scalar. + pub fn array_len(&self) -> Option { + match self { + Value::Array(a) => Some(a.len()), + Value::Records { size, data } => Some(data.len() / usize::from(*size).max(1)), + _ => None, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -73,39 +90,72 @@ 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, + /// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence, + /// index of its last occurrence) sorted by tag, so `get` and `get_last` are a binary search. + /// 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. Smaller objects have no index (and no per-object hash map to build): + /// scanning a handful of fields is cheaper. + index: Vec<(Tag, u32, u32)>, +} + +/// Objects with at most this many fields are scanned instead of indexed. +const SCAN: usize = 12; + +fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32, u32)> { + if fields.len() <= SCAN { + return Vec::new(); + } + // Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) puts + // each tag's occurrences together in stream order: the first and last of each run keep + // first-match semantics for `get` and last-match semantics for `get_last`. + let mut index: Vec<(Tag, u32, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i, i)).collect(); + index.sort_unstable(); + index.dedup_by(|later, kept| { + let same = later.0 == kept.0; + if same { + kept.2 = later.2; + } + same + }); + index } 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); - } - } + let index = build_index(&fields); Object { class, kind, chain, fields, index } } pub fn get(&self, tag: Tag) -> Option<&Value> { - self.index.get(&tag).map(|&i| &self.fields[i].1) + if tag.0 == 0 { + return None; + } + if self.fields.len() <= SCAN { + return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v); + } + let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?; + self.index.get(at).and_then(|&(_, i, _)| self.fields.get(i as usize)).map(|(_, v)| v) + } + + /// The last field with `tag`, for objects that may repeat a tag (a shape changed from one + /// kind to another keeps a stale first block). Indexed like [`Object::get`], so repeated + /// lookups on a large shared object do not rescan its fields. + pub fn get_last(&self, tag: Tag) -> Option<&Value> { + if tag.0 == 0 { + return None; + } + if self.fields.len() <= SCAN { + return self.fields.iter().rev().find(|(t, _)| *t == tag).map(|(_, v)| v); + } + let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?; + self.index.get(at).and_then(|&(_, _, i)| self.fields.get(i as usize)).map(|(_, v)| v) } /// 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.index = build_index(&fields); self.fields = fields; } } @@ -336,11 +386,13 @@ impl Parser<'_> { if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { return Err(Error::Malformed("array longer than its data")); } - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Bytes(self.c.take(size)?.to_vec())); - } - Ok(Value::Array(v)) + // Still one value per record against the budget, but a single allocation. + self.spend(n)?; + let bytes = self.c.take(n * size)?; + let mut data = Vec::new(); + data.try_reserve_exact(bytes.len()).map_err(|_| Error::Limit("not enough memory for the document stream"))?; + data.extend_from_slice(bytes); + Ok(Value::Records { size: size as u16, data }) } 0x32 => { let n = self.count(1)?; diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index af788d60de..a8a1187da5 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -297,6 +297,42 @@ mod archive { assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle")); } + #[test] + fn allocation_records_are_charged_before_they_are_walked() { + let d = doc(); + let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None); + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + // The table's file count sits after its tag, next, date and 24 reserved bytes. + let files = fat + 4 + 8 + 8 + 24; + file[files..files + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records")); + // A count within the budget still reaches the records (and here fails on missing data). + file[files..files + 4].copy_from_slice(&1_000_000u32.to_le_bytes()); + assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_)))); + } + + #[test] + fn allocation_records_are_charged_across_every_table() { + let d = doc(); + let mut file = synth::container(&[("doc.dat", &d, Method::Stored), ("other.dat", &d, Method::Stored)], None); + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + // Chain a copy of the (valid, two-record) table after it; the copy is the last table. + let second = file.len(); + let table = file[fat..].to_vec(); + file.extend(&table); + file[fat + 4..fat + 12].copy_from_slice(&(second as u64).to_le_bytes()); + let files = second + 4 + 8 + 8 + 24; + // Each count alone is within the archive budget of 2^25; together they exceed it by one. + file[files..files + 4].copy_from_slice(&((1u32 << 25) - 1).to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records")); + // Exactly the budget is allowed: the second table is walked (and fails on missing data). + file[files..files + 4].copy_from_slice(&((1u32 << 25) - 2).to_le_bytes()); + assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_)))); + // Directory records count too: one more (after the file count and 8 bytes) is over. + file[files + 12..files + 14].copy_from_slice(&1u16.to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records")); + } + #[test] fn hostile_streams_fail_cleanly() { let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]); @@ -416,6 +452,20 @@ mod shapes { rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap() } + #[test] + fn empty_subpaths_of_a_shared_curve_are_charged_for_every_occurrence() { + // One curve of 100k empty subpaths, placed 100 times: no records and no points, but + // ten million subpaths walked unless each one is charged. + let mut data = vec![F::U8(0), F::U32(100_000)]; + for _ in 0..100_000 { + data.extend([F::Bool(false), F::Records(18, Vec::new())]); + } + let curve = F::Def(5, vec![tag(b"PCrv")], vec![(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(data))]))]); + let group = F::Def(6, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared((0..100).map(|_| F::Ref(5)).collect()))]); + let d = read(vec![curve, group], 6); + assert!(d.warnings.iter().any(|w| w.contains("curve geometry budget exhausted")), "{:?}", d.warnings); + } + fn path(document: &Document) -> &Path { match &document.spreads[0].nodes[0].kind { Kind::Shape { path, .. } => path, @@ -757,3 +807,54 @@ mod spot_colours { assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings); } } + +/// Object lookup and record arrays against the structures they replaced. +mod stream_storage { + use crate::stream::{self, Kind, Object, Tag, Value}; + use crate::synth::{self, F, tag}; + + /// The per-object map `Object::get` used before: first occurrence of each real tag. + fn reference(fields: &[(Tag, Value)], t: Tag) -> Option<&Value> { + let mut index = std::collections::HashMap::new(); + for (i, (ft, _)) in fields.iter().enumerate() { + if ft.0 != 0 { + index.entry(*ft).or_insert(i); + } + } + index.get(&t).map(|&i| &fields[i].1) + } + + #[test] + fn lookup_keeps_first_match_for_every_object_size() { + for n in 0..60u32 { + // Tags repeat (n % 7 distinct tags) and every fifth field is positional (tag 0). + let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect(); + let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone()); + for t in 0..120 { + assert_eq!(object.get(Tag(t)), reference(&fields, Tag(t)), "{n} fields, tag {t}"); + } + } + } + + #[test] + fn last_lookup_keeps_last_match_for_every_object_size() { + for n in 0..60u32 { + let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect(); + let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone()); + for t in 0..120 { + let expected = if t == 0 { None } else { fields.iter().rev().find(|(k, _)| *k == Tag(t)).map(|(_, v)| v) }; + assert_eq!(object.get_last(Tag(t)), expected, "{n} fields, tag {t}"); + } + } + } + + #[test] + fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() { + let records: Vec = (0..18 * 5).map(|i| i as u8).collect(); + let bytes = synth::stream(&[(tag(b"Nods"), F::Records(18, records.chunks(18).map(<[u8]>::to_vec).collect()))]); + let s = stream::parse(&bytes).unwrap(); + let root = s.object(s.root).unwrap(); + assert_eq!(root.get(Tag::of(b"Nods")), Some(&Value::Records { size: 18, data: records })); + assert_eq!(root.get(Tag::of(b"Nods")).and_then(Value::array_len), Some(5)); + } +} diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index eb52cda436..96c11b4805 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -55,7 +55,7 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { _ => bounds.x0, }; if !artistic { - if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) { + if s.objs(frame, b"ColW").len() > 1 || s.array_len(frame, b"ColW").is_some_and(|n| n > 1) { r.warn("text frames with several columns (imported as one column)"); } if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) { diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml index af50c1d20e..767c8044d9 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -23,3 +23,8 @@ rayon = { workspace = true } [dev-dependencies] photocraft-testkit = { workspace = true } serde_json = { workspace = true } +criterion = { workspace = true } + +[[bench]] +name = "blur" +harness = false diff --git a/crates/algo/benches/blur.rs b/crates/algo/benches/blur.rs new file mode 100644 index 0000000000..3165b40cd0 --- /dev/null +++ b/crates/algo/benches/blur.rs @@ -0,0 +1,33 @@ +//! `cargo bench -p photocraft-algo` +use criterion::{Criterion, criterion_group, criterion_main}; +use photocraft_algo::{FilterParams, apply}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn textured(n: u32) -> Surface { + let r = Rect::new(0, 0, n as i32, n as i32); + let mut s = Surface::new(PixelFormat::RGBA8); + let bytes: Vec = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect(); + s.write_interleaved(r, &bytes); + s +} + +/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including +/// the variance-matched kernels below radius 1 (#8544). +fn gaussian_direct(c: &mut Criterion) { + let n = 2048; + let s = textured(n); + let area = Rect::new(0, 0, n as i32, n as i32); + for radius in [0.3f32, 0.8, 3.0] { + let p = FilterParams::GaussianBlur { radius }; + c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None))); + } +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(10); + targets = gaussian_direct +} +criterion_main!(benches); diff --git a/crates/algo/examples/bench_photo.rs b/crates/algo/examples/bench_photo.rs index dc0354886d..11d29e9afd 100644 --- a/crates/algo/examples/bench_photo.rs +++ b/crates/algo/examples/bench_photo.rs @@ -122,6 +122,24 @@ fn main() { }; wideangle::apply(&s, r, &p, Interp::Bicubic) }); + // The mesh solve alone, on the dialog's 800 x 533 proxy. + let proxy = Rect::new(0, 0, 800, 533); + let solve_case = |constraints: Vec| { + let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints, ..Default::default() }; + wideangle::solve(&p, proxy) + }; + time("wide angle solve: 800x533 proxy, H + V", &only, || { + solve_case(vec![ + Constraint { a: [60.0, 80.0], b: [740.0, 80.0], orientation: Orientation::Horizontal }, + Constraint { a: [100.0, 60.0], b: [100.0, 470.0], orientation: Orientation::Vertical }, + ]) + }); + time("wide angle solve: 800x533 proxy, 2 free", &only, || { + solve_case(vec![ + Constraint { a: [60.0, 80.0], b: [740.0, 95.0], orientation: Orientation::Free }, + Constraint { a: [100.0, 60.0], b: [110.0, 470.0], orientation: Orientation::Free }, + ]) + }); time("hdr: merge 3 exposures + local adaptation", &only, || { let shots: Vec> = [0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect(); diff --git a/crates/algo/examples/bench_stack_entropy.rs b/crates/algo/examples/bench_stack_entropy.rs new file mode 100644 index 0000000000..b496b69d3f --- /dev/null +++ b/crates/algo/examples/bench_stack_entropy.rs @@ -0,0 +1,104 @@ +//! Stack Mode Entropy timings and exact output hashes, including lookup preparation. +//! Run the same example on both revisions: `cargo run --release -p photocraft-algo --example +//! bench_stack_entropy -- [--quick] [--reps N] [--threads N] [--json path]`. +//! Full cases use 24/36 MP; quick cases use 1/16 as many pixels. The deep-stack fallback is +//! deliberately small. Input generation, output hashing and deallocation are outside timings. + +use std::path::PathBuf; +use std::time::Instant; + +use photocraft_algo::stack::{Stat, combine}; +use photocraft_testkit::perf::{self, Summary}; +use serde_json::json; + +struct Options { + quick: bool, + reps: usize, + threads: Option, + json: Option, +} + +fn options() -> Result { + let mut o = Options { quick: false, reps: 5, threads: None, json: None }; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--quick" => o.quick = true, + "--reps" => o.reps = args.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "invalid --reps")?, + "--threads" => o.threads = Some(args.next().ok_or("--threads needs an integer")?.parse().map_err(|_| "invalid --threads")?), + "--json" => o.json = Some(args.next().ok_or("--json needs a path")?.into()), + _ => return Err(format!("unknown option: {arg}")), + } + } + if !(1..=25).contains(&o.reps) || o.threads.is_some_and(|n| !(1..=64).contains(&n)) { + return Err("expected 1..=25 repetitions and 1..=64 threads".into()); + } + Ok(o) +} + +fn benchmark(o: &Options) -> Result<(), String> { + let mut rows = Vec::new(); + let cases = [ + ("3 layers opaque 24 MP", 3, 24_000_000, false), + ("7 layers varying alpha 24 MP", 7, 24_000_000, true), + ("3 layers opaque 36 MP", 3, 36_000_000, false), + ("257 layers varying alpha", 257, 8192, true), + ]; + for (name, count, pixels, masked) in cases { + let pixels = if o.quick && pixels > 8192 { pixels / 16 } else { pixels }; + let layers: Vec> = (0..count) + .map(|k| { + (0..pixels) + .map(|i| { + [ + ((i * 31 + k * 17) % 256) as f32 / 255.0, + ((i / 7 + k * 29) % 32) as f32 / 31.0, + ((i + k * 13) % 1024) as f32 / 255.0 - 0.5, + if masked && k > i % (count + 1) { 0.0 } else { 1.0 }, + ] + }) + .collect() + }) + .collect(); + // Warm the worker pool and input pages before collecting samples. + std::hint::black_box(combine(&layers, Stat::Entropy)); + let mut samples = Vec::with_capacity(o.reps); + let mut hash = 0u64; + for rep in 0..o.reps { + let start = Instant::now(); + let out = std::hint::black_box(combine(&layers, Stat::Entropy)); + samples.push(start.elapsed().as_secs_f64() * 1000.0); + if rep == 0 { + hash = out.iter().flatten().fold(0xcbf29ce484222325, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x100000001b3)); + } + } + let p50 = Summary::of(&samples).map_or(0.0, |s| s.p50); + println!("{name}: {pixels} pixels, {p50:.3} ms p50, hash {hash:016x}, samples_ms={samples:?}"); + let mut row = perf::row(name, &samples, perf::process_peak_rss_bytes(), None); + row["output_hash"] = json!(format!("{hash:016x}")); + row["pixels"] = json!(pixels); + rows.push(row); + } + if let Some(path) = &o.json { + let report = perf::report("bench_stack_entropy", json!({"quick": o.quick, "threads": o.threads}), rows, None); + std::fs::write(path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?; + } + Ok(()) +} + +fn run() -> Result<(), String> { + let o = options()?; + #[cfg(not(target_arch = "wasm32"))] + if let Some(threads) = o.threads { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().map_err(|e| e.to_string())?; + return pool.install(|| benchmark(&o)); + } + benchmark(&o) +} + +fn main() { + if let Err(e) = run() { + eprintln!("{e}"); + std::process::exit(1); + } +} diff --git a/crates/algo/src/artistic.rs b/crates/algo/src/artistic.rs index 4266091451..cb817f2935 100644 --- a/crates/algo/src/artistic.rs +++ b/crates/algo/src/artistic.rs @@ -236,6 +236,19 @@ impl GalleryEffect { } } + /// A copy whose stored numeric values are held to their declared ranges (a non-finite value + /// becomes the default). A project file stores `values` as plain numbers, so unlike + /// [`Self::set`] nothing has clamped them; geometry sized from them (reach) needs it. + pub(crate) fn clamped(&self) -> GalleryEffect { + let mut out = self.clone(); + for p in self.filter.params() { + if let (GalleryParamKind::Range { min, max, default }, Some(v)) = (&p.kind, out.values.get_mut(p.key)) { + *v = if v.is_finite() { v.clamp(*min, *max) } else { *default }; + } + } + out + } + /// A bool value. pub fn flag(&self, key: &str) -> bool { self.get(key) != 0.0 diff --git a/crates/algo/src/artistic_fx.rs b/crates/algo/src/artistic_fx.rs index ca346a1f05..a2c98e3c39 100644 --- a/crates/algo/src/artistic_fx.rs +++ b/crates/algo/src/artistic_fx.rs @@ -17,6 +17,7 @@ use photocraft_geom::Rect; use crate::Ctx; use crate::artistic::GalleryEffect; use crate::artistic::GalleryFilter as F; +use crate::cutout; use crate::fxutil::{box_blur_n, cell_point, gauss_blur_n, gauss_box_radii, rgba, set_rgba}; use crate::image::Image; use crate::noise::hash01; @@ -406,10 +407,13 @@ fn highlights(p: Px, l: f32, area: f32, intensity: f32) -> Px { /// Pixels an effect reads around an output pixel. pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 { + // Stored values come from a project file unclamped; keep the arithmetic below inside i32. + let e = &e.clamped(); let g = |k: &str| e.get(k); match e.filter { F::ColoredPencil => 2, - F::Cutout => gs(cutout_sigma(e)), + // Traced over the whole image before tiling (see [`Prepared`]). + F::Cutout => 0, F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2, F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0, F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3, @@ -451,8 +455,8 @@ 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")) +fn cutout_settings(e: &GalleryEffect) -> cutout::Settings { + cutout::Settings { levels: e.get("numberOfLevels").round().clamp(2.0, 8.0) as usize, simplicity: e.get("edgeSimplicity"), fidelity: 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 { @@ -526,7 +530,7 @@ fn glass_cell(e: &GalleryEffect) -> f32 { /// Total reach of a stack of effects. pub(crate) fn reach(effects: &[GalleryEffect]) -> i32 { - effects.iter().map(effect_reach).sum() + effects.iter().map(effect_reach).fold(0, i32::saturating_add) } /// Runs a stack of effects over `out` (the source covers `out` grown by [`reach`]). @@ -538,9 +542,60 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx) return src.crop(out); } let mut win = Win { r, w, h, c: src.data.chunks_exact(n).map(|px| rgb(rgba(ctx, px))).collect() }; - for e in effects { - apply(e, &mut win, ctx); + // The reach that sized `src` comes from the clamped values, so the pixels must use them too. + for e in effects.iter().map(GalleryEffect::clamped) { + apply(&e, &mut win, ctx); } + finish(&win, src, out, ctx) +} + +/// Whether a stack needs the whole image at once (Cutout traces it). +pub(crate) fn is_global(effects: &[GalleryEffect]) -> bool { + effects.iter().any(|e| e.filter == F::Cutout) +} + +/// A stack with a Cutout, prepared once for all tiles: the effects up to the last Cutout run +/// over the whole image and it is traced; each tile then only fills in the traced shapes and +/// runs the effects after it. +pub(crate) struct Prepared { + traced: cutout::Traced, + rest: Vec, +} + +impl Prepared { + /// `None` for a stack that has no Cutout (it runs tile by tile as usual). + pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option { + // Held to the declared ranges once here (see `run`), so the traced shapes and the tiles agree. + let effects = &effects.iter().map(GalleryEffect::clamped).collect::>(); + let last = effects.iter().rposition(|e| e.filter == F::Cutout)?; + let r = src.rect; + let (w, h) = (r.width() as usize, r.height() as usize); + let mut win = Win { r, w, h, c: src.data.chunks_exact(src.ch).map(|px| rgb(rgba(ctx, px))).collect() }; + for e in effects.get(..last).unwrap_or_default() { + apply(e, &mut win, ctx); + } + let traced = cutout::trace(&win.c, r, &cutout_settings(effects.get(last)?)); + Some(Prepared { traced, rest: effects.get(last + 1..).unwrap_or_default().to_vec() }) + } + + /// The stack's result over `out` (`src` is the image it was prepared from). + pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec { + let r = out.inflate(reach(&self.rest)).intersect(&src.rect); + let (w, h) = (r.width() as usize, r.height() as usize); + if w == 0 || h == 0 { + return src.crop(out); + } + let mut win = Win { r, w, h, c: self.traced.render(r) }; + for e in &self.rest { + apply(e, &mut win, ctx); + } + finish(&win, src, out, ctx) + } +} + +/// `out` of the source with its colour from `win` (alpha kept). +fn finish(win: &Win, src: &Image, out: Rect, ctx: &Ctx) -> Vec { + let n = src.ch; let mut res = src.crop(out); let ow = out.width() as usize; for (i, px) in res.chunks_exact_mut(n).enumerate() { @@ -585,16 +640,7 @@ fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool { win.c[i] = mix(paper, map(win.c[i], |v| v * 0.88), cov); } } - F::Cutout => { - blur3(&mut win.c, w, h, cutout_sigma(e)); - let levels = g("numberOfLevels").round(); - for p in &mut win.c { - // Flat tones per luminance level, keeping each region's hue. - let l = lum(p).max(1e-4); - let k = posterize(l, levels) / l; - *p = map(*p, |v| posterize(clamp01(v * k), levels * 2.0)); - } - } + F::Cutout => win.c = cutout::trace(&win.c, win.r, &cutout_settings(e)).render(win.r), F::DryBrush | F::Fresco => { let fresco = e.filter == F::Fresco; kuwahara(win, 1 + g("brushSize") as usize); @@ -1344,6 +1390,8 @@ mod tests { let fp = FilterParams::FilterGallery { effects: vec![e.clone()] }; apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b) }; + // Cutout has its own test below: its tone bands are of the channel mean, which this + // pattern keeps nearly constant. for f in [G::RoughPastels, G::AngledStrokes] { let base = run(&GalleryEffect::new(f), 256); for prm in f.params() { @@ -1361,6 +1409,123 @@ mod tests { } } + #[test] + fn cutout_responds_to_every_slider() { + // A radial gradient (curved band edges, tones to quantize) with a 10 px square in it. + let rows: Vec> = (0..96) + .map(|y| { + (0..96) + .map(|x| { + if (70..80).contains(&x) && (14..24).contains(&y) { + return 0.05; + } + let d = (((x as f32 - 40.0).powi(2) + (y as f32 - 52.0).powi(2)).sqrt() / 60.0).min(1.0); + 1.0 - d + }) + .collect() + }) + .collect(); + let base = cutout_on(&rows, &[]); + for (key, v) in + [("numberOfLevels", 2.0), ("numberOfLevels", 8.0), ("edgeSimplicity", 0.0), ("edgeSimplicity", 10.0), ("edgeFidelity", 1.0), ("edgeFidelity", 3.0)] + { + assert_ne!(cutout_on(&rows, &[(key, v)]), base, "cutout {key} = {v} changed nothing"); + } + } + + /// Runs one Cutout over a grey image given as rows of values; returns the red channel. + fn cutout_on(rows: &[Vec], set: &[(&str, f32)]) -> Vec> { + let (w, h) = (rows[0].len() as i32, rows.len() as i32); + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + for (y, row) in rows.iter().enumerate() { + for (x, v) in row.iter().enumerate() { + s.write_pixel(x as i32, y as i32, &[*v, *v, *v, 1.0]); + } + } + let mut e = GalleryEffect::new(crate::GalleryFilter::Cutout); + for (k, v) in set { + e.set(k, *v); + } + let b = Rect::new(0, 0, w, h); + let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b); + out.chunks(4 * w as usize).map(|r| r.chunks(4).map(|p| p[0]).collect()).collect() + } + + #[test] + fn cutout_makes_flat_shapes_with_clean_edges() { + // It used to posterize every pixel after a blur: noise survived as speckle, edges were + // ragged and smooth gradients banded instead of becoming flat paper. + let noisy: Vec> = (0..32).map(|y| (0..64).map(|x| if x < 32 { 0.2 } else { 0.8 } + (hash01(x, y, 0, 7) - 0.5) * 0.16).collect()).collect(); + let out = cutout_on(&noisy, &[]); + for (x0, x1) in [(2, 28), (36, 62)] { + let vals: Vec = (2..30).flat_map(|y| out[y][x0..x1].to_vec()).collect(); + let spread = vals.iter().fold(f32::MIN, |a, &v| a.max(v)) - vals.iter().fold(f32::MAX, |a, &v| a.min(v)); + assert!(spread < 1e-4, "a noisy area should be one flat tone, spread {spread}"); + } + // The cut is an anti-aliased pixel or two, not a blur. + for y in 0..32 { + let (lo, hi) = (out[y][16], out[y][48]); + let soft = out[y].iter().filter(|&&v| (v - lo).abs() > 0.01 && (v - hi).abs() > 0.01).count(); + assert!(soft <= 2, "row {y}: {soft} edge pixels"); + } + // A grey ramp becomes `numberOfLevels` equal-width bands, each its own mean tone, as in the + // reference application (0–255 over 256 px at 4 levels: steps at 64, 128 and 192, the + // upper bands 96, 160 and 224). + let ramp: Vec> = (0..64).map(|_| (0..256).map(|x| x as f32 / 255.0).collect()).collect(); + let row = &cutout_on(&ramp, &[("numberOfLevels", 4.0), ("edgeSimplicity", 0.0), ("edgeFidelity", 3.0)])[32]; + for (x, want) in [(100, 95.5), (160, 159.5), (230, 223.5)] { + assert!((row[x] * 255.0 - want).abs() < 0.6, "band at {x}: {} vs {want}", row[x] * 255.0); + } + for step in [64usize, 128, 192] { + assert!(row[step - 2] < row[step + 1] - 0.2, "a step at {step}: {} / {}", row[step - 2], row[step + 1]); + } + // Edge Simplicity drops small shapes by their size alone (measured in the reference + // application: 6 px squares stay at 0–2 and 4 px go; 12 px stay at 4 and 8 px go). + let square = |n: usize| -> Vec> { + (0..48).map(|y| (0..48).map(|x| if (20..20 + n).contains(&x) && (20..20 + n).contains(&y) { 0.9 } else { 0.2 }).collect()).collect() + }; + for (n, simplicity, kept) in [(6, 0.0, true), (4, 0.0, false), (6, 2.0, true), (12, 4.0, true), (8, 4.0, false), (12, 10.0, false)] { + let out = cutout_on(&square(n), &[("edgeSimplicity", simplicity)]); + let c = 20 + n / 2; + assert_eq!(out[c][c] - out[2][2] > 0.5, kept, "{n} px square at simplicity {simplicity}"); + } + } + + #[test] + fn cutout_keeps_the_tone_of_surviving_islands_at_low_fidelity() { + for (size, simplicity) in [(6, 0.0), (6, 2.0), (8, 0.0), (12, 4.0)] { + let rows: Vec> = + (0..32).map(|y| (0..32).map(|x| if (12..12 + size).contains(&x) && (12..12 + size).contains(&y) { 0.9 } else { 0.2 }).collect()).collect(); + let reference = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", 2.0)]); + let centre = 12 + size / 2; + for fidelity in [1.0, 1.5, 2.0] { + let out = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", fidelity)]); + assert!( + (out[centre][centre] - reference[centre][centre]).abs() < 0.02, + "{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {} vs {}", + out[centre][centre], + reference[centre][centre] + ); + assert!(out[centre][centre] - out[2][2] > 0.5, "the island keeps its contrast"); + } + } + } + + #[test] + fn a_stack_around_a_cutout_is_tile_independent() { + // Effects before a Cutout are traced with it over the whole image; effects after it run + // per tile on the filled-in shapes. + use crate::GalleryFilter as G; + let b = Rect::new(0, 0, 56, 40); + let s = pattern(SampleType::F32); + let fp = + FilterParams::FilterGallery { effects: vec![GalleryEffect::new(G::Spatter), GalleryEffect::new(G::Cutout), GalleryEffect::new(G::GlowingEdges)] }; + let a = apply_tiled(&s, &fp, b, b, None, 256, Some(b)).read_region(b); + let t = apply_tiled(&s, &fp, b, b, None, 16, Some(b)).read_region(b); + let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(worst < 1e-3, "differs across tilings by {worst}"); + } + /// Flat greys keep their tone through every pattern and Contrast, black and white stay /// solid, and Contrast makes the screen crisper. #[test] diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index bd762d073f..7ca9d34f99 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -1,7 +1,7 @@ //! Blurs: Gaussian, box, motion, radial, surface. use photocraft_geom::Rect; -use photocraft_raster::Interrupt; +use photocraft_raster::{Interrupt, scratch}; use crate::image::{Edge, Image, premultiply, unpremultiply}; use crate::photo_util::{par_map, par_rows}; @@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec { k.into_iter().map(|v| v / s).collect() } +/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole +/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a +/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the +/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4% +/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05. +pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec { + if !(0.05..1.0).contains(&radius) { + return gaussian_kernel(radius); + } + let variance = |k: &[f32]| { + let r = (k.len() / 2) as f32; + k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() + }; + // Sampled at `width` with one fixed half-width for every radius below 1: its variance then + // rises steadily with `width` (the search can't settle on a step where taps are added), and + // the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the + // half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too. + let half = 3; + let sampled = |width: f32| { + let k: Vec = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect(); + let s: f32 = k.iter().sum(); + k.into_iter().map(|v| v / s).collect::>() + }; + let target = radius * radius; + // The sampled kernel's variance only falls short of sigma², so the width lies above `radius`. + let (mut lo, mut hi) = (radius, radius + 1.0); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + if variance(&sampled(mid)) < target { + lo = mid; + } else { + hi = mid; + } + } + sampled(0.5 * (lo + hi)) +} + /// Separable convolution of `src` (premultiplied internally) for `out`. /// `src` must cover `out` grown by the kernel radii. pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec { @@ -41,7 +78,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo // Premultiplied copy of the needed source window. let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry); let ww = win.width() as usize; - let mut p = vec![0.0f32; ww * win.height() as usize * n]; + let mut p = scratch::Buf::new(ww * win.height() as usize * n, 0.0f32); for y in win.y0..win.y1 { for x in win.x0..win.x1 { let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n; @@ -52,7 +89,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo } premultiply(&mut p, n, alpha); // Horizontal pass: (ow × th). - let mut tmp = vec![0.0f32; ow * th * n]; + let mut tmp = scratch::Buf::new(ow * th * n, 0.0f32); for ty in 0..th { let row = &p[ty * ww * n..(ty + 1) * ww * n]; let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n]; @@ -76,7 +113,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo } } // Vertical pass. - let mut res = vec![0.0f32; ow * oh * n]; + let mut res = scratch::take(ow * oh * n, 0.0f32); for oy in 0..oh { let d = &mut res[oy * ow * n..(oy + 1) * ow * n]; for (i, kv) in ky.iter().enumerate() { @@ -107,13 +144,12 @@ fn conv_row(src: &[f32], dst: &mut [f32], kernel: &[f32]) { pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { // Exact kernel for small radii; beyond that three box passes approximate the Gaussian with - // cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). - match box_widths(&FilterParams::GaussianBlur { radius }) { - Some(boxes) => box_passes(src, out, ctx.alpha, &boxes), - None => { - let k = gaussian_kernel(radius); - conv_sep(src, out, &k, &k, ctx.alpha) - } + // cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). Built + // from the description other devices run (`separable`), so they blur the same (#5772). + match crate::separable::Separable::of(&FilterParams::GaussianBlur { radius }) { + Some(crate::separable::Separable::Boxes(boxes)) => box_passes(src, out, ctx.alpha, &boxes), + Some(crate::separable::Separable::Kernel(k)) => conv_sep(src, out, &k, &k, ctx.alpha), + None => src.crop(out), } } @@ -146,11 +182,27 @@ pub(crate) fn box_widths(params: &FilterParams) -> Option> { } /// Scratch buffers of one worker for [`box_blur`]. -#[derive(Default)] struct Scratch { line: Vec, next: Vec, acc: Vec, + /// The source row being read (horizontal passes only). + row: Vec, +} + +impl Scratch { + /// Buffers with room for lines of `line` floats (and the shorter ones after the first pass). + fn new(line: usize, row: usize) -> Self { + Scratch { line: scratch::take_cap(line), next: scratch::take_cap(line), acc: Vec::new(), row: scratch::take_cap(row) } + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + scratch::give(std::mem::take(&mut self.line)); + scratch::give(std::mem::take(&mut self.next)); + scratch::give(std::mem::take(&mut self.row)); + } } /// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's @@ -216,7 +268,7 @@ fn box_pass_px(src: &[f32], dst: &mut [f32], r: usize) { /// `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 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() { @@ -270,21 +322,21 @@ pub(crate) fn box_blur( // Horizontal passes over `used`'s rows only (the repeated rows above and below it would just // repeat their results), for the output columns. Each row is extended to the passes' reach by // repeating its end pixels. - let mut h = vec![0.0f32; used.height() as usize * ow * n]; + let mut h = scratch::Buf::new(used.height() as usize * ow * n, 0.0f32); let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize); par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| { - let (mut row, mut s) = (Vec::new(), Scratch::default()); + let mut s = Scratch::new((used.width() as usize + left + right) * n, used.width() as usize * n); for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() { if ctl.cancelled() { return; } let y = used.y0 + (task * ROWS_PER_TASK + k) as i32; - read(Rect::new(used.x0, y, used.x1, y + 1), &mut row); - premultiply(&mut row, n, alpha); - let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return }; + read(Rect::new(used.x0, y, used.x1, y + 1), &mut s.row); + premultiply(&mut s.row, n, alpha); + let (Some(first), Some(last)) = (s.row.get(..n), s.row.get(s.row.len().saturating_sub(n)..)) else { return }; s.line.clear(); (0..left).for_each(|_| s.line.extend_from_slice(first)); - s.line.extend_from_slice(&row); + s.line.extend_from_slice(&s.row); (0..right).for_each(|_| s.line.extend_from_slice(last)); line_passes(dst, n, &radii, &mut s, ctl); } @@ -300,11 +352,11 @@ pub(crate) fn box_blur( } let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n); let row = |y: usize| &h[y * stride + sx..y * stride + sx + w]; - let mut s = Scratch::default(); + let mut s = Scratch::new((rows + top + bottom) * w, 0); (0..top).for_each(|_| s.line.extend_from_slice(row(0))); (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]; + let mut res = scratch::take(oh * w, 0.0f32); line_passes(&mut res, w, &radii, &mut s, ctl); if ctl.cancelled() { return (t, Vec::new()); @@ -331,7 +383,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec buf.extend_from_slice(src.row(r)); }; let ow = out.width() as usize * n; - let mut res = vec![0.0f32; ow * out.height() as usize]; + let mut res = scratch::take(ow * out.height() as usize, 0.0f32); if res.is_empty() { return res; } @@ -340,6 +392,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) { dst[x0..x0 + w].copy_from_slice(s); } + scratch::give(data); } res } diff --git a/crates/algo/src/blur/tests.rs b/crates/algo/src/blur/tests.rs index 32256bc954..74cf7ab20c 100644 --- a/crates/algo/src/blur/tests.rs +++ b/crates/algo/src/blur/tests.rs @@ -2,6 +2,76 @@ use super::*; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_raster::Surface; +/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel +/// path) visibly softens a hard edge, more with each step, as the kernel predicts. +#[test] +fn small_gaussian_radii_soften_a_rendered_edge() { + let canvas = Rect::new(0, 0, 20, 4); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]); + let mut last = 0.0f32; + for radius in [0.1f32, 0.2, 0.3] { + let p = FilterParams::GaussianBlur { radius }; + assert!(box_widths(&p).is_none(), "the direct-kernel path"); + let out = crate::apply(&s, &p, canvas, canvas, None); + // The last black pixel takes the white side's share of the kernel. + let got = out.pixel(9, 1)[0]; + let k = gaussian_blur_kernel(radius); + let want: f32 = k[k.len() / 2 + 1..].iter().sum(); + assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}"); + assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}"); + last = got; + } +} + +/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly +/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged. +#[test] +fn small_gaussian_radii_blur_smoothly() { + let spread = |k: &[f32]| { + let r = (k.len() / 2) as f32; + k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::() + }; + let mut last = 0.0f32; + for step in 1..=20 { + let radius = step as f32 * 0.05; + let k = gaussian_blur_kernel(radius); + assert!((k.iter().sum::() - 1.0).abs() < 1e-5, "normalised at {radius}"); + if radius < 1.0 { + assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k)); + } + // A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre. + let c = k.len() / 2; + let edge: f32 = k[c + 1..].iter().sum(); + assert!(edge > last, "radius {radius}: edge {edge} after {last}"); + if radius > 0.1 { + assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last); + } + last = edge; + } + assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs"); + // No step where the variance-matched range hands over to plain sampling. + let edge = |r: f32| { + let k = gaussian_blur_kernel(r); + k[k.len() / 2 + 1..].iter().sum::() + }; + assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0)); + // No dip where a per-radius width used to add taps (1/3 and 2/3). + for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] { + assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1); + } + // Exact variance right up to the hand-over, past where `gaussian_kernel` changes width. + for radius in [0.98f32, 0.995, 0.998, 0.9995] { + let k = gaussian_blur_kernel(radius); + assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k)); + } + for radius in [1.0, 1.5, 3.0, 4.0] { + assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}"); + } + assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]); +} + #[test] fn radial_blur_samples_large_arcs_beyond_64_intervals() { let bounds = Rect::new(0, 0, 1000, 1000); @@ -134,7 +204,7 @@ fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> 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 s = Scratch::new(0, 0); + s.line.extend_from_slice(&line); let mut dst = vec![0.0f32; len * w]; line_passes(&mut dst, w, &radii, &mut s, ctl); dst diff --git a/crates/algo/src/cutout/curves.rs b/crates/algo/src/cutout/curves.rs new file mode 100644 index 0000000000..76e65d1082 --- /dev/null +++ b/crates/algo/src/cutout/curves.rs @@ -0,0 +1,324 @@ +//! Outlines: each chain fitted once into a straight-sided polyline, with its corners kept and its +//! runs simplified (Douglas–Peucker). + +use super::{Pt, Settings}; + +/// `a - b`. +fn sub(a: Pt, b: Pt) -> Pt { + [a[0] - b[0], a[1] - b[1]] +} + +/// Length of `a`. +fn len(a: Pt) -> f32 { + (a[0] * a[0] + a[1] * a[1]).sqrt() +} + +/// Fits one chain: corners kept, runs simplified to straight segments. Its ends stay where they are, so +/// chains meeting at a junction still meet. +pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec { + let n = pts.len(); + if n < 3 { + return pts.to_vec(); + } + let original = pts; + let rounded = round_off(pts, closed, s.rounding()); + let pts = &rounded[..]; + let at = |i: isize| pts[i.rem_euclid(n as isize) as usize]; + // Turn at each point, measured over a few cracks either side. + const K: isize = 3; + let turn = |i: isize| { + let (a, b) = (sub(at(i), at(i - K)), sub(at(i + K), at(i))); + let (la, lb) = (len(a), len(b)); + if la < 1e-6 || lb < 1e-6 { 0.0 } else { ((a[0] * b[0] + a[1] * b[1]) / (la * lb)).clamp(-1.0, 1.0).acos() } + }; + let span = if closed { 0..n as isize } else { K..(n as isize - K) }; + let turns: Vec<(isize, f32)> = span.clone().map(|i| (i, turn(i))).collect(); + let limit = s.corner_turn(); + let mut corners: Vec = Vec::new(); + for &(i, t) in &turns { + if t <= limit { + continue; + } + // Only the sharpest point of a bend. + let sharpest = (-K..=K).all(|o| { + let j = i + o; + let in_span = closed || span.contains(&j); + !in_span || o == 0 || { + let tj = turn(j); + tj < t || (tj == t && o > 0) + } + }); + if sharpest { + corners.push(i.rem_euclid(n as isize) as usize); + } + } + // Square a corner back where the pixels cut it: where the straight runs either side meet. + let mut p = pts.to_vec(); + for &i in &corners { + let ii = i as isize; + if let Some(q) = intersect(at(ii - K), at(ii - 1), at(ii + 1), at(ii + K)) + && len(sub(q, at(ii))) <= 1.0 + { + p[i] = q; + } + } + let mut keep: Vec = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() }; + keep.sort_unstable(); + keep.dedup(); + let mut is_corner = vec![false; n]; + for &k in &keep { + is_corner[k] = true; + } + if closed && keep.len() < 2 { + // A smooth loop: split it at two far-apart points (not corners) to simplify each half. + let a = keep.first().copied().unwrap_or(0); + let far = (0..n).max_by(|&i, &j| len(sub(p[i], p[a])).total_cmp(&len(sub(p[j], p[a])))).unwrap_or(n / 2); + keep = if a == far { vec![a, (a + n / 2) % n] } else { vec![a.min(far), a.max(far)] }; + } + // Douglas–Peucker between consecutive kept points. + let tol = s.tolerance(); + let mut verts: Vec = Vec::new(); + let pieces = if closed { keep.len() } else { keep.len() - 1 }; + for k in 0..pieces { + let a = keep[k]; + let b = if k + 1 < keep.len() { keep[k + 1] } else { keep[0] + n }; + verts.push(a); + let idx: Vec = (a..=b).collect(); + let mut marks = vec![false; idx.len()]; + simplify(&idx.iter().map(|&i| p[i % n]).collect::>(), 0, idx.len() - 1, tol, &mut marks); + verts.extend(idx.iter().zip(&marks).skip(1).take(idx.len().saturating_sub(2)).filter(|m| *m.1).map(|(&i, _)| i % n)); + } + if !closed { + verts.push(n - 1); + } + let v: Vec = verts.iter().map(|&i| p[i]).collect(); + let corner: Vec = verts.iter().map(|&i| is_corner[i]).collect(); + let _ = &corner; + let mut out = v.clone(); + if closed { + out.extend(v.first().copied()); + } + if closed { + let before = signed_area(original); + let after = signed_area(&out); + // Region merging decides which islands survive. Rounding and simplification must not + // erase one (or reverse its hole): keep the original shared outline if half its area + // would be lost. Close it explicitly because the fill consumes consecutive segments. + if before * after <= 0.0 || after.abs() < before.abs() * 0.5 { + let mut out = original.to_vec(); + out.extend(original.first().copied()); + return out; + } + } + out +} + +/// Signed loop area, relative to its first point to avoid cancellation far from the origin. +fn signed_area(pts: &[Pt]) -> f64 { + let Some(origin) = pts.first() else { return 0.0 }; + pts.iter() + .zip(pts.iter().cycle().skip(1)) + .map(|(a, b)| { + let (ax, ay) = (f64::from(a[0]) - f64::from(origin[0]), f64::from(a[1]) - f64::from(origin[1])); + let (bx, by) = (f64::from(b[0]) - f64::from(origin[0]), f64::from(b[1]) - f64::from(origin[1])); + ax * by - bx * ay + }) + .sum::() + * 0.5 +} + +/// Two passes of a moving average over `r` points either side; an open chain's ends (junctions) +/// stay put, and the window shrinks towards them so they don't pull the outline. +fn round_off(pts: &[Pt], closed: bool, r: usize) -> Vec { + let n = pts.len(); + let mut p = pts.to_vec(); + if r == 0 || n < 3 { + return p; + } + for _ in 0..2 { + let src = p.clone(); + for (i, q) in p.iter_mut().enumerate() { + let rr = if closed { r.min((n - 1) / 2) } else { r.min(i).min(n - 1 - i) }; + if rr == 0 { + continue; + } + let (mut sx, mut sy) = (0.0f32, 0.0f32); + for o in -(rr as isize)..=rr as isize { + let s = src[(i as isize + o).rem_euclid(n as isize) as usize]; + sx += s[0]; + sy += s[1]; + } + let k = (2 * rr + 1) as f32; + *q = [sx / k, sy / k]; + } + } + p +} + +/// Where the lines through `a0`–`a1` and `b0`–`b1` cross, if they aren't parallel. +fn intersect(a0: Pt, a1: Pt, b0: Pt, b1: Pt) -> Option { + let (da, db) = (sub(a1, a0), sub(b1, b0)); + let den = da[0] * db[1] - da[1] * db[0]; + if den.abs() < 1e-6 { + return None; + } + let t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / den; + Some([a0[0] + da[0] * t, a0[1] + da[1] * t]) +} + +/// Marks the points of `p[a..=b]` that Douglas–Peucker keeps at tolerance `tol`. +fn simplify(p: &[Pt], a: usize, b: usize, tol: f32, marks: &mut [bool]) { + let mut stack = vec![(a, b)]; + while let Some((a, b)) = stack.pop() { + if b <= a + 1 { + continue; + } + let (pa, d) = (p[a], sub(p[b], p[a])); + let l = len(d); + let mut best = (a, 0.0f32); + for (i, q) in p.iter().enumerate().take(b).skip(a + 1) { + let e = sub(*q, pa); + let dist = if l < 1e-6 { len(e) } else { (e[0] * d[1] - e[1] * d[0]).abs() / l }; + if dist > best.1 { + best = (i, dist); + } + } + if best.1 > tol { + marks[best.0] = true; + stack.push((a, best.0)); + stack.push((best.0, b)); + } + } +} + +/// Bounding box `[x0, y0, x1, y1]` of `pts`. +pub(super) fn bounds(pts: &[Pt]) -> [f32; 4] { + pts.iter().fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(fidelity: f32) -> Settings { + Settings { levels: 4, simplicity: 2.0, fidelity } + } + + /// The outline of a square from (2, 2) to (12, 12), one point per unit, starting at a corner. + fn square() -> Vec { + let mut p = Vec::new(); + for i in 0..10 { + p.push([2.0 + i as f32, 2.0]); + } + for i in 0..10 { + p.push([12.0, 2.0 + i as f32]); + } + for i in 0..10 { + p.push([12.0 - i as f32, 12.0]); + } + for i in 0..10 { + p.push([2.0, 12.0 - i as f32]); + } + p + } + + fn close(a: Pt, b: Pt) -> bool { + len(sub(a, b)) < 1e-3 + } + + #[test] + fn douglas_peucker_keeps_only_points_off_the_line() { + // An L, slightly ragged: only its corner is further from the end-to-end line than the + // tolerance, and the two arms are straight within it. + let wobble = |i: usize| 0.05 * (i % 2) as f32; + let p: Vec = (0..9).map(|i| if i <= 4 { [i as f32, wobble(i)] } else { [4.0 + wobble(i), (i - 4) as f32] }).collect(); + let mut marks = vec![false; p.len()]; + simplify(&p, 0, 8, 0.5, &mut marks); + assert_eq!(marks.iter().filter(|m| **m).count(), 1, "{marks:?}"); + assert!(marks[4]); + let mut marks = vec![false; p.len()]; + simplify(&p, 0, 8, 3.0, &mut marks); + assert!(marks.iter().all(|m| !m), "under the tolerance nothing is kept"); + } + + #[test] + fn lines_cross_where_they_should() { + let q = intersect([0.0, 0.0], [1.0, 1.0], [0.0, 2.0], [1.0, 1.0]); + assert!(q.is_some_and(|q| close(q, [1.0, 1.0])), "{q:?}"); + assert_eq!(intersect([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 1.0]), None); + } + + #[test] + fn rounding_off_keeps_an_open_chains_ends() { + let p: Vec = (0..12).map(|i| [i as f32, if i % 2 == 0 { 0.0 } else { 1.0 }]).collect(); + let r = round_off(&p, false, 3); + assert_eq!((r[0], r[11]), (p[0], p[11])); + assert!(r[5][1] > 0.2 && r[5][1] < 0.8, "the zigzag is averaged out: {:?}", r[5]); + assert_eq!(round_off(&p, false, 0), p); + } + + #[test] + fn a_square_keeps_its_four_corners_and_straight_sides() { + let out = fit(&square(), true, &settings(2.0)); + for c in [[2.0, 2.0], [12.0, 2.0], [12.0, 12.0], [2.0, 12.0]] { + assert!(out.iter().any(|p| close(*p, c)), "corner {c:?} lost: {out:?}"); + } + let on_side = |p: &Pt| [p[0] - 2.0, p[0] - 12.0, p[1] - 2.0, p[1] - 12.0].iter().any(|d| d.abs() < 1e-3); + assert!(out.iter().all(on_side), "{out:?}"); + assert!(out.len() < 10, "the sides are simplified to their ends: {out:?}"); + } + + #[test] + fn a_rounded_corner_is_squared_back() { + // The pixels cut the corner at (12, 2): the outline goes (11, 2), (12, 3) instead. + let mut p = square(); + p[10] = [11.5, 2.5]; + let out = fit(&p, true, &settings(2.0)); + assert!(out.iter().any(|q| close(*q, [12.0, 2.0])), "{out:?}"); + } + + #[test] + fn an_open_chain_ends_where_it_started_at_any_fidelity() { + let p: Vec = (0..30).map(|i| [i as f32, (i as f32 / 5.0).sin() * 4.0 + 0.3 * (i % 3) as f32]).collect(); + for f in [1.0, 2.0, 3.0] { + let out = fit(&p, false, &settings(f)); + assert_eq!((out.first(), out.last()), (p.first(), p.last()), "fidelity {f}"); + } + } + + #[test] + fn fitting_keeps_small_islands_and_their_winding() { + use super::super::edges::follow_edges; + let (w, h) = (32, 32); + for size in [2, 3, 4, 6] { + let reg: Vec = (0..w * h).map(|i| u32::from((12..12 + size).contains(&(i % w)) && (12..12 + size).contains(&(i / w)))).collect(); + let colours = [[0.2; 3], [0.9; 3]]; + let c: Vec<_> = reg.iter().map(|&r| colours[r as usize]).collect(); + let (chains, _) = follow_edges(®, &colours, &c, w, h); + let (pts, closed) = chains.iter().find(|(_, closed)| *closed).unwrap(); + assert!(*closed); + let area = |p: &[Pt]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64).sum::() / 2.0; + let original = area(pts); + for simplicity in [0.0, 2.0] { + let s = Settings { simplicity, ..settings(2.0) }; + if size * size < s.min_area() as usize { + continue; + } + for fidelity in [1.0, 1.5, 2.0, 3.0] { + let out = fit(pts, true, &Settings { fidelity, ..s }); + let fitted = area(&out); + assert!( + fitted * original > 0.0 && fitted.abs() >= original.abs() * 0.5, + "{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {original} -> {fitted}" + ); + assert_eq!(out.first(), out.last(), "fallbacks must close the loop for the fill"); + } + } + } + } + + #[test] + fn bounds_cover_every_point() { + assert_eq!(bounds(&[[1.0, 5.0], [-2.0, 3.0], [4.0, 4.0]]), [-2.0, 3.0, 4.0, 5.0]); + } +} diff --git a/crates/algo/src/cutout/edges.rs b/crates/algo/src/cutout/edges.rs new file mode 100644 index 0000000000..8b0a39c41b --- /dev/null +++ b/crates/algo/src/cutout/edges.rs @@ -0,0 +1,210 @@ +//! Edges: the cracks between regions, linked into chains from junction to junction, each crack +//! placed where the anti-aliasing says the edge is. + +use super::{OUT, Pt, Px}; +use crate::par; + +/// A chain's points, and whether it is closed. +pub(super) type Chain = (Vec, bool); +/// A pixel, or a lattice point (the corner at a pixel's top left). +type Cell = (i64, i64); + +/// A step along the lattice, y down. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Dir { + E, + W, + S, + N, +} + +/// Links the cracks between regions into chains. Each chain is its points (the junctions it runs +/// between, if any, and one sub-pixel point per crack) and whether it is closed; its sides are +/// the regions on its left and right (y down). +pub(super) fn follow_edges(reg: &[u32], colours: &[Px], c: &[Px], w: usize, h: usize) -> (Vec, Vec<(u32, u32)>) { + let (wi, hi) = (w as i64, h as i64); + let at = |x: i64, y: i64| -> u32 { if x < 0 || y < 0 || x >= wi || y >= hi { OUT } else { reg[y as usize * w + x as usize] } }; + let hcount = w * (h + 1); + // Horizontal crack (x, y)–(x+1, y) is `y * w + x`; vertical (x, y)–(x, y+1) follows them. + let hc = |x: i64, y: i64| (y as usize) * w + x as usize; + let vc = |x: i64, y: i64| hcount + (y as usize) * (w + 1) + x as usize; + let mut visited = vec![false; hcount + (w + 1) * h]; + // The crack leaving lattice point (x, y) in `d`: its id, the pixels on its left and right, + // and the point it reaches. + let step = |x: i64, y: i64, d: Dir| -> Option<(usize, Cell, Cell, Cell)> { + match d { + Dir::E if x < wi => Some((hc(x, y), (x, y - 1), (x, y), (x + 1, y))), + Dir::W if x > 0 => Some((hc(x - 1, y), (x - 1, y), (x - 1, y - 1), (x - 1, y))), + Dir::S if y < hi => Some((vc(x, y), (x, y), (x - 1, y), (x, y + 1))), + Dir::N if y > 0 => Some((vc(x, y - 1), (x - 1, y - 1), (x, y - 1), (x, y - 1))), + _ => None, + } + }; + // Which cracks separate two regions, and which lattice points are junctions, worked out + // once (in parallel) before the walk. + let mut is_edge = vec![false; visited.len()]; + let (hb, vb) = is_edge.split_at_mut(hcount); + par::chunks_mut(hb, w, true, |y, row| { + for (x, b) in row.iter_mut().enumerate() { + *b = at(x as i64, y as i64 - 1) != at(x as i64, y as i64); + } + }); + par::chunks_mut(vb, w + 1, true, |y, row| { + for (x, b) in row.iter_mut().enumerate() { + *b = at(x as i64 - 1, y as i64) != at(x as i64, y as i64); + } + }); + let boundary = |x: i64, y: i64, d: Dir| step(x, y, d).filter(|s| is_edge[s.0]); + let mut is_junction = vec![false; (w + 1) * (h + 1)]; + par::chunks_mut(&mut is_junction, w + 1, true, |y, row| { + let y = y as i64; + for (x, j) in row.iter_mut().enumerate() { + let x = x as i64; + let deg = [Dir::E, Dir::W, Dir::S, Dir::N].iter().filter(|&&d| boundary(x, y, d).is_some()).count(); + *j = deg >= 3 || (deg > 0 && (x == 0 || x == wi) && (y == 0 || y == hi)); + } + }); + let junction = |x: i64, y: i64| is_junction[y as usize * (w + 1) + x as usize]; + // One point per crack: its middle, moved along the normal from left to right pixel by what + // their colours say about where the edge really is. + let crack_point = |l: (i64, i64), r: (i64, i64)| -> Pt { + let mid = [(l.0 + r.0 + 1) as f32 / 2.0, (l.1 + r.1 + 1) as f32 / 2.0]; + let (n0, n1) = ((r.0 - l.0) as f32, (r.1 - l.1) as f32); + let (ra, rb) = (at(l.0, l.1), at(r.0, r.1)); + let (Some(ca), Some(cb)) = (colours.get(ra as usize), colours.get(rb as usize)) else { return mid }; + let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]]; + let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if dd < 1e-4 { + return mid; + } + // How far each pixel's colour is from its own region's towards the other's (0–1). + let frac = |p: (i64, i64)| { + let q = c[p.1 as usize * w + p.0 as usize]; + ((q[0] - ca[0]) * d[0] + (q[1] - ca[1]) * d[1] + (q[2] - ca[2]) * d[2]) / dd + }; + let (fl, fr) = (frac(l).clamp(0.0, 1.0), frac(r).clamp(0.0, 1.0)); + let off = ((1.0 - fr) - fl).clamp(-0.5, 0.5); + [mid[0] + n0 * off, mid[1] + n1 * off] + }; + let mut chains = Vec::new(); + let mut sides = Vec::new(); + let mut walk = |x: i64, y: i64, first: Dir, visited: &mut Vec| { + let open = junction(x, y); + let mut pts = if open { vec![[x as f32, y as f32]] } else { Vec::new() }; + let (mut cx, mut cy, mut d) = (x, y, first); + let mut side = (OUT, OUT); + let mut closed = !open; + while let Some((id, l, r, to)) = boundary(cx, cy, d) { + if visited[id] { + break; + } + visited[id] = true; + if pts.len() <= usize::from(open) { + side = (at(l.0, l.1), at(r.0, r.1)); + } + pts.push(crack_point(l, r)); + (cx, cy) = to; + if junction(cx, cy) { + pts.push([cx as f32, cy as f32]); + closed = false; + break; + } + let back = match d { + Dir::E => Dir::W, + Dir::W => Dir::E, + Dir::S => Dir::N, + Dir::N => Dir::S, + }; + let Some(next) = [Dir::E, Dir::S, Dir::W, Dir::N].into_iter().find(|&nd| nd != back && boundary(cx, cy, nd).is_some_and(|b| !visited[b.0])) else { + break; + }; + d = next; + } + if pts.len() >= 2 { + chains.push((pts, closed)); + sides.push(side); + } + }; + for y in 0..=hi { + for x in 0..=wi { + if junction(x, y) { + for d in [Dir::E, Dir::S, Dir::W, Dir::N] { + if boundary(x, y, d).is_some_and(|b| !visited[b.0]) { + walk(x, y, d, &mut visited); + } + } + } + } + } + // What is left are closed loops with no junction (an island inside one other region). + for y in 0..=hi { + for x in 0..=wi { + for d in [Dir::E, Dir::S] { + if boundary(x, y, d).is_some_and(|b| !visited[b.0]) { + walk(x, y, d, &mut visited); + } + } + } + } + (chains, sides) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Regions drawn as digits, one per pixel, with grey `colours` (region `k` is `k / 4`). + fn regions(rows: &[&str]) -> (Vec, Vec, usize, usize) { + let reg: Vec = rows.iter().flat_map(|r| r.bytes()).map(|b| u32::from(b - b'0')).collect(); + let n = reg.iter().max().map_or(0, |m| m + 1); + (reg, (0..n).map(|k| [k as f32 / 4.0; 3]).collect(), rows[0].len(), rows.len()) + } + + /// The chain whose sides are regions `a` and `b`, either way round. + fn between(chains: &[Chain], sides: &[(u32, u32)], a: u32, b: u32) -> Vec { + chains.iter().zip(sides).filter(|(_, s)| **s == (a, b) || **s == (b, a)).map(|(c, _)| c.clone()).collect() + } + + #[test] + fn the_edge_between_two_regions_is_one_chain_between_junctions() { + let (reg, colours, w, h) = regions(&["000111", "000111", "000111", "000111"]); + let c: Vec = reg.iter().map(|&r| colours[r as usize]).collect(); + let (chains, sides) = follow_edges(®, &colours, &c, w, h); + let inner = between(&chains, &sides, 0, 1); + assert_eq!(inner.len(), 1, "{chains:?}"); + let (pts, closed) = &inner[0]; + assert!(!closed); + // From junction to junction (where it meets the canvas edge), one point per crack. + let ends = [pts[0], pts[pts.len() - 1]]; + assert!(ends.contains(&[3.0, 0.0]) && ends.contains(&[3.0, 4.0]), "{pts:?}"); + assert_eq!(pts.len(), 2 + 4); + assert!(pts.iter().all(|p| p[0] == 3.0), "flat colours: the edge is on the crack: {pts:?}"); + // Every crack between two regions, the canvas edge included, is in exactly one chain. + let cracks: usize = chains.iter().map(|(p, closed)| p.len() - if *closed { 0 } else { 2 }).sum(); + assert_eq!(cracks, 4 + 2 * (w + h)); + } + + #[test] + fn an_island_is_one_closed_chain() { + let (reg, colours, w, h) = regions(&["00000", "01100", "01100", "00000"]); + let c: Vec = reg.iter().map(|&r| colours[r as usize]).collect(); + let (chains, sides) = follow_edges(®, &colours, &c, w, h); + let island = between(&chains, &sides, 0, 1); + assert_eq!(island.len(), 1); + assert!(island[0].1, "closed"); + assert_eq!(island[0].0.len(), 8, "one point per crack around the 2x2 island"); + } + + #[test] + fn an_anti_aliased_edge_is_placed_inside_its_pixel() { + // Region 0 is black and region 1 white; the last column of region 0 is half white, so + // the edge runs down the middle of that column. + let (reg, _, w, h) = regions(&["000111", "000111", "000111"]); + let colours = vec![[0.0; 3], [1.0; 3]]; + let c: Vec = reg.iter().enumerate().map(|(i, &r)| if i % w == 2 { [0.5; 3] } else { colours[r as usize] }).collect(); + let (chains, sides) = follow_edges(®, &colours, &c, w, h); + let inner = between(&chains, &sides, 0, 1); + let pts = &inner[0].0; + assert!(pts[1..pts.len() - 1].iter().all(|p| (p[0] - 2.5).abs() < 1e-5), "{pts:?}"); + } +} diff --git a/crates/algo/src/cutout/fill.rs b/crates/algo/src/cutout/fill.rs new file mode 100644 index 0000000000..f2e96973ae --- /dev/null +++ b/crates/algo/src/cutout/fill.rs @@ -0,0 +1,119 @@ +//! Fill: exact-area scan conversion of the traced shapes, so edges are anti-aliased and shapes +//! that share an edge sum to full coverage. + +use super::Pt; + +/// Accumulates the signed area of segment `a`–`b` into `acc` (rows of `stride` cells, `h` rows +/// visible, columns `0..stride - 2` visible): the part left of the tile is drawn down its left +/// edge (it still winds the pixels to its right), the part right of it is dropped. +pub(super) fn line_clipped(acc: &mut [f32], stride: usize, h: usize, a: Pt, b: Pt) { + if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] { + return; + } + let wf = (stride - 2) as f32; + // At most the two ends and two crossings; on the stack, as this runs for every segment drawn. + let mut buf = [0.0f32, 1.0, 0.0, 0.0]; + let mut n = 2; + for x in [0.0, wf] { + let t = (x - a[0]) / (b[0] - a[0]); + if t > 0.0 && t < 1.0 { + buf[n] = t; + n += 1; + } + } + let cuts = &mut buf[..n]; + cuts.sort_by(f32::total_cmp); + for t in cuts.windows(2) { + let p = [a[0] + (b[0] - a[0]) * t[0], a[1] + (b[1] - a[1]) * t[0]]; + let q = [a[0] + (b[0] - a[0]) * t[1], a[1] + (b[1] - a[1]) * t[1]]; + let mx = (p[0] + q[0]) / 2.0; + if mx >= wf { + continue; + } + if mx < 0.0 { + line(acc, stride, h, [0.0, p[1]], [0.0, q[1]]); + } else { + line(acc, stride, h, [p[0].clamp(0.0, wf), p[1]], [q[0].clamp(0.0, wf), q[1]]); + } + } +} + +/// Signed-area accumulation of one segment inside the tile (the scanline method of font +/// rasterisers): after a running sum along each row, a cell holds the coverage winding. +fn line(acc: &mut [f32], stride: usize, h: usize, p0: Pt, p1: Pt) { + if p0[1] == p1[1] { + return; + } + let (dir, p0, p1) = if p0[1] < p1[1] { (1.0f32, p0, p1) } else { (-1.0, p1, p0) }; + let dxdy = (p1[0] - p0[0]) / (p1[1] - p0[1]); + let mut x = p0[0]; + let ystart = p0[1].max(0.0); + if p0[1] < 0.0 { + x -= p0[1] * dxdy; + } + let yend = p1[1].min(h as f32); + if ystart >= yend { + return; + } + let max_x = (stride - 1) as f32; + for y in (ystart as usize)..(yend.ceil() as usize).min(h) { + let ls = y * stride; + let dy = ((y + 1) as f32).min(p1[1]) - (y as f32).max(p0[1]); + let xnext = x + dxdy * dy; + let d = dy * dir; + let (x0, x1) = if x < xnext { (x, xnext) } else { (xnext, x) }; + let (x0, x1) = (x0.clamp(0.0, max_x - 1.0), x1.clamp(0.0, max_x - 1.0)); + let x0f = x0.floor(); + let x0i = x0f as usize; + let x1c = x1.ceil(); + let x1i = x1c as usize; + if x1i <= x0i + 1 { + let xmf = 0.5 * (x0 + x1) - x0f; + acc[ls + x0i] += d - d * xmf; + acc[ls + x0i + 1] += d * xmf; + } else { + let sx = (x1 - x0).recip(); + let x0r = x0 - x0f; + let a0 = 0.5 * sx * (1.0 - x0r) * (1.0 - x0r); + let x1r = x1 - x1c + 1.0; + let am = 0.5 * sx * x1r * x1r; + acc[ls + x0i] += d * a0; + if x1i == x0i + 2 { + acc[ls + x0i + 1] += d * (1.0 - a0 - am); + } else { + let a1 = sx * (1.5 - x0r); + acc[ls + x0i + 1] += d * (a1 - a0); + for xi in x0i + 2..x1i - 1 { + acc[ls + xi] += d * sx; + } + let a2 = a1 + (x1i - x0i - 3) as f32 * sx; + acc[ls + x1i - 1] += d * (1.0 - a2 - am); + } + acc[ls + x1i] += d * am; + } + x = xnext; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_square_covers_its_area_exactly() { + let mut acc = vec![0.0f32; 12 * 10]; + let sq = [[2.25, 2.5], [7.75, 2.5], [7.75, 6.5], [2.25, 6.5], [2.25, 2.5]]; + for s in sq.windows(2) { + line_clipped(&mut acc, 12, 10, s[0], s[1]); + } + let mut total = 0.0; + for y in 0..10 { + let mut sum = 0.0; + for x in 0..10 { + sum += acc[y * 12 + x]; + total += sum.abs().min(1.0); + } + } + assert!((total - 5.5 * 4.0).abs() < 1e-4, "{total}"); + } +} diff --git a/crates/algo/src/cutout/mod.rs b/crates/algo/src/cutout/mod.rs new file mode 100644 index 0000000000..b802b40767 --- /dev/null +++ b/crates/algo/src/cutout/mod.rs @@ -0,0 +1,367 @@ +//! Cutout (Filter Gallery › Artistic): the image as flat sheets of cut paper. +//! +//! What it does follows the reference application, measured on grey ramps, hue sweeps, squares of +//! known size and a portrait: +//! +//! 1. **Tone bands**: each pixel's band is the plain mean of its three channels, cut into Number of +//! Levels equal steps (four levels: below 64, 128, 192 and the rest, on 0–255). Colours of +//! different hue but the same mean share a band. +//! 2. **Regions**: 4-connected areas of one band. Regions smaller than the Edge Simplicity area +//! merge, smallest first, into the neighbour they share most border with. The area depends on +//! the slider alone, not on the image size. Each region is filled with the mean colour of its own +//! pixels, so a region mixing hues comes out as their average. +//! 3. **Edges**: the cracks between regions are linked into chains from junction to junction +//! (where three regions meet, or the canvas edge). Each crack is moved along its normal to where +//! the pixels on either side say the edge is (an anti-aliased pixel is part one colour, part the +//! other). A chain is fitted once, so two neighbouring shapes share one and the same edge. +//! 4. **Outlines**: straight-sided polygons. Sharp bends stay corners; the runs between are +//! simplified (Douglas–Peucker) to a tolerance that grows with Edge Simplicity and shrinks with +//! Edge Fidelity. +//! 5. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased +//! and the coverages of shapes that share an edge sum to one: no seams. +//! +//! Tracing reads the whole image and runs once; filling runs per tile and gives the same pixels +//! whatever the tiling. +//! +//! Each stage is its own module: [`regions`] (1–2), [`edges`] (3), [`curves`] (4) and [`fill`] +//! (5); [`trace`] runs them in turn. + +use photocraft_geom::Rect; + +use crate::par; + +mod curves; +mod edges; +mod fill; +mod regions; +mod util; + +use curves::{bounds, fit}; +use edges::follow_edges; +use fill::line_clipped; +use regions::{components, merge_regions}; + +type Px = [f32; 3]; +type Pt = [f32; 2]; + +/// The tone band of a pixel: the mean of its three channels in `levels` equal steps. +fn band(p: Px, levels: usize) -> u8 { + let v = (p[0] + p[1] + p[2]) / 3.0; + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + ((v * levels as f32) as usize).min(levels - 1) as u8 +} + +/// Region id of the area outside the image. +const OUT: u32 = u32::MAX; + +/// The filter's three sliders. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Settings { + /// Number of Levels (2–8): how many tone bands. + pub levels: usize, + /// Edge Simplicity (0–10): smaller shapes merge and outlines simplify more. + pub simplicity: f32, + /// Edge Fidelity (1–3): more corners are kept. + pub fidelity: f32, +} + +/// Linear interpolation in a per-step table over Edge Simplicity 0–10. +fn by_simplicity(table: &[f32; 11], simplicity: f32) -> f32 { + let s = if simplicity.is_finite() { simplicity.clamp(0.0, 10.0) } else { 0.0 }; + let i = (s.floor() as usize).min(9); + let t = s - i as f32; + table[i] + (table[i + 1] - table[i]) * t +} + +impl Settings { + /// Regions with fewer pixels merge into a neighbour. Measured in the reference application + /// with squares of 2–96 px: the smallest that survive are 6 px wide at Simplicity 0–2, 12 px + /// at 4, 48 px at 6 and 96 px at 7, the same on a 256 px and a 600 px canvas. + fn min_area(&self) -> u32 { + const AREA: [f32; 11] = [25.0, 25.0, 25.0, 50.0, 100.0, 400.0, 1500.0, 5000.0, 10000.0, 16000.0, 30000.0]; + by_simplicity(&AREA, self.simplicity).round() as u32 + } + /// Douglas–Peucker tolerance of the straight outlines, in pixels: coarser with more Edge + /// Simplicity, finer with more Edge Fidelity. + fn tolerance(&self) -> f32 { + const TOL: [f32; 11] = [0.5, 0.5, 0.6, 0.8, 1.0, 1.5, 2.5, 4.0, 7.0, 12.0, 20.0]; + by_simplicity(&TOL, self.simplicity) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0)) + } + /// Below Edge Fidelity 2 the outline is rounded off before it is fitted: points averaged with + /// this many neighbours either side (3 at fidelity 1). + fn rounding(&self) -> usize { + ((2.0 - self.fidelity.clamp(1.0, 3.0)) * 3.0).round().max(0.0) as usize + } + /// Turns sharper than this stay corners. + fn corner_turn(&self) -> f32 { + (100.0 - 22.5 * self.fidelity.clamp(1.0, 3.0)).to_radians() + } +} + +/// A traced image: the shapes and their colours, ready to fill any tile. +pub(crate) struct Traced { + /// Document position of the traced window's top-left pixel; outlines are relative to it. + x0: i32, + y0: i32, + /// Fitted edges (polylines), each shared by the one or two regions it separates. + chains: Vec>, + /// Per chain, its bounding box `[x0, y0, x1, y1]`. + chain_boxes: Vec<[f32; 4]>, + regions: Vec, +} + +/// One shape: its flat colour, bounding box and outline. +struct Region { + colour: Px, + bbox: [f32; 4], + /// The chains around the region, reversed where the region is on their right. + edges: Vec<(u32, bool)>, +} + +/// Traces `c` (sRGB colour of every pixel of `r`, row-major). +pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced { + let (w, h) = (r.width() as usize, r.height() as usize); + let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new() }; + if w == 0 || h == 0 || c.len() != w * h { + return empty; + } + // A non-finite sample (32-bit documents can hold them) would make its region's mean colour, + // and so its edges and fill, non-finite: count it as 0 from here on. + let clean = |v: f32| if v.is_finite() { v } else { 0.0 }; + let owned: Vec; + let c: &[Px] = if c.iter().flatten().all(|v| v.is_finite()) { + c + } else { + owned = c.iter().map(|p| p.map(clean)).collect(); + &owned + }; + let levels = s.levels.clamp(2, 8); + let mut labels = vec![0u8; w * h]; + par::chunks_mut(&mut labels, w, true, |y, row| { + for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) { + *o = band(*p, levels); + } + }); + let (comp, n) = components(&labels, w, h); + let (reg, colours) = merge_regions(&comp, n, c, w, h, s); + let (chains, sides) = follow_edges(®, &colours, c, w, h); + let chains: Vec> = par::map(chains.len(), true, |i| fit(&chains[i].0, chains[i].1, s)); + let chain_boxes: Vec<[f32; 4]> = chains.iter().map(|ch| bounds(ch)).collect(); + let mut regions: Vec = + colours.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect(); + for (k, &(left, right)) in sides.iter().enumerate() { + for (side, rev) in [(left, false), (right, true)] { + if let Some(g) = regions.get_mut(side as usize) { + g.edges.push((k as u32, rev)); + let b = chain_boxes[k]; + g.bbox = [g.bbox[0].min(b[0]), g.bbox[1].min(b[1]), g.bbox[2].max(b[2]), g.bbox[3].max(b[3])]; + } + } + } + Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions } +} + +impl Traced { + /// The cut paper over `r` (document coordinates): sRGB colour per pixel, row-major. + pub(crate) fn render(&self, r: Rect) -> Vec { + let (w, h) = (r.width() as usize, r.height() as usize); + let mut col = vec![[0.0f32; 3]; w * h]; + if w == 0 || h == 0 { + return col; + } + let mut weight = vec![0.0f32; w * h]; + let stride = w + 2; + let mut acc = vec![0.0f32; stride * h]; + let (ox, oy) = ((self.x0 - r.x0) as f32, (self.y0 - r.y0) as f32); + let (wf, hf) = (w as f32, h as f32); + for g in &self.regions { + let b = [g.bbox[0] + ox, g.bbox[1] + oy, g.bbox[2] + ox, g.bbox[3] + oy]; + if b[2] <= 0.0 || b[0] >= wf || b[3] <= 0.0 || b[1] >= hf { + continue; + } + for &(k, rev) in &g.edges { + let (Some(pts), Some(cb)) = (self.chains.get(k as usize), self.chain_boxes.get(k as usize)) else { continue }; + // Chains left of the tile still count (they wind the rows they cross); chains to + // its right or above or below it don't. + if cb[0] + ox >= wf || cb[3] + oy <= 0.0 || cb[1] + oy >= hf { + continue; + } + for seg in pts.windows(2) { + let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) }; + line_clipped(&mut acc, stride, h, [a[0] + ox, a[1] + oy], [z[0] + ox, z[1] + oy]); + } + } + let y0 = b[1].floor().max(0.0) as usize; + let y1 = (b[3].ceil().max(0.0) as usize).min(h); + let xe = ((b[2].ceil().max(0.0) as usize) + 1).min(w); + for y in y0..y1 { + let row = &mut acc[y * stride..(y + 1) * stride]; + let mut sum = 0.0f32; + for (x, v) in row.iter().enumerate().take(xe) { + sum += v; + let cov = sum.abs().min(1.0); + if cov > 0.0 { + let i = y * w + x; + let paint = g.colour; + col[i] = [col[i][0] + paint[0] * cov, col[i][1] + paint[1] * cov, col[i][2] + paint[2] * cov]; + weight[i] += cov; + } + } + row.fill(0.0); + } + } + for (p, &wt) in col.iter_mut().zip(&weight) { + if wt > 1e-6 { + *p = [p[0] / wt, p[1] / wt, p[2] / wt]; + } + } + col + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dist2(a: Px, b: Px) -> f32 { + (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2) + } + + fn settings(levels: usize, simplicity: f32, fidelity: f32) -> Settings { + Settings { levels, simplicity, fidelity } + } + + /// Total coverage of every pixel: each pixel is covered exactly once by the shapes. + fn coverage(t: &Traced, r: Rect) -> Vec { + let (w, h) = (r.width() as usize, r.height() as usize); + let mut weight = vec![0.0f32; w * h]; + let stride = w + 2; + let mut acc = vec![0.0f32; stride * h]; + for g in &t.regions { + for &(k, rev) in &g.edges { + for seg in t.chains[k as usize].windows(2) { + let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) }; + line_clipped(&mut acc, stride, h, a, z); + } + } + for y in 0..h { + let mut sum = 0.0; + for x in 0..w { + sum += acc[y * stride + x]; + weight[y * w + x] += sum.abs().min(1.0); + } + acc[y * stride..(y + 1) * stride].fill(0.0); + } + } + weight + } + + #[test] + fn shapes_cover_every_pixel_once_with_no_seams() { + // Blobs of four colours touching each other and the canvas edge, with T-junctions, + // saddles and holes. + let (w, h) = (48usize, 36usize); + let pal = [[0.1, 0.1, 0.1], [0.9, 0.2, 0.2], [0.2, 0.7, 0.3], [0.95, 0.95, 0.9]]; + let c: Vec = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let k = ((x / 7.0).sin() + (y / 5.0).cos() + (x * y / 90.0).sin()).abs() * 1.6; + pal[(k as usize).min(3)] + }) + .collect(); + let r = Rect::new(0, 0, w as i32, h as i32); + for fidelity in [1.0, 3.0] { + let t = trace(&c, r, &settings(4, 2.0, fidelity)); + assert!(t.regions.len() > 4); + let cov = coverage(&t, r); + let worst = cov.iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max); + assert!(worst < 1e-3, "coverage off by {worst}"); + } + } + + #[test] + fn edge_fidelity_changes_the_outlines_a_little() { + // In the reference application Edge Fidelity nudges the outlines: on a portrait 1.9% of + // pixels change from fidelity 1 to 2 and 1.2% from 2 to 3. Large smooth shapes here. + let (w, h) = (160usize, 120usize); + let pal = [[0.1, 0.1, 0.1], [0.9, 0.3, 0.2], [0.3, 0.6, 0.9]]; + let c: Vec = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let k = ((x / 17.0).sin() * 1.3 + (y / 13.0).cos() + (x * y / 900.0).sin()).abs(); + pal[(k as usize).min(2)] + }) + .collect(); + let r = Rect::new(0, 0, w as i32, h as i32); + let out = |f: f32| trace(&c, r, &settings(3, 2.0, f)).render(r); + let changed = |a: &[Px], b: &[Px]| a.iter().zip(b).filter(|(p, q)| dist2(**p, **q) > 0.01).count() as f32 / a.len() as f32; + let (loose, normal, tight) = (out(1.0), out(2.0), out(3.0)); + for (name, a, b) in [("1 vs 2", &loose, &normal), ("2 vs 3", &normal, &tight)] { + let f = changed(a, b); + assert!(f > 0.002 && f < 0.06, "fidelity {name}: {f}"); + } + } + + #[test] + fn empty_images_stop_before_the_transpose_and_row_passes() { + for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] { + let t = trace(&[], r, &settings(4, 2.0, 1.0)); + assert!(t.regions.is_empty()); + assert!(t.chains.is_empty()); + } + } + + #[test] + fn filling_does_not_depend_on_the_tiling() { + let (w, h) = (40usize, 30usize); + let c: Vec = (0..w * h).map(|i| if ((i % w) / 6 + (i / w) / 5) % 3 == 0 { [0.8, 0.3, 0.1] } else { [0.1, 0.3, 0.7] }).collect(); + let r = Rect::new(10, 20, 10 + w as i32, 20 + h as i32); + let t = trace(&c, r, &settings(3, 1.0, 2.0)); + let whole = t.render(r); + for tile in [Rect::new(10, 20, 23, 31), Rect::new(23, 31, 50, 50), Rect::new(37, 20, 50, 33)] { + let part = t.render(tile); + for y in tile.y0..tile.y1 { + for x in tile.x0..tile.x1 { + let a = whole[((y - r.y0) as usize) * w + (x - r.x0) as usize]; + let b = part[((y - tile.y0) as usize) * tile.width() as usize + (x - tile.x0) as usize]; + assert!(dist2(a, b) < 1e-8, "({x}, {y}): {a:?} vs {b:?}"); + } + } + } + } + + /// A NaN or infinite sample must not reach a region's colour, its edges or the fill. + #[test] + fn non_finite_samples_give_finite_covered_output() { + let (w, h) = (24usize, 16usize); + let mut c: Vec = (0..w * h).map(|i| if i % w < 12 { [0.1, 0.1, 0.1] } else { [0.9, 0.8, 0.7] }).collect(); + c[3 * w + 4] = [f32::NAN, 0.5, 0.5]; + c[8 * w + 15] = [f32::INFINITY, f32::NEG_INFINITY, 0.2]; + c[12 * w + 12] = [f32::NAN; 3]; + let r = Rect::new(0, 0, w as i32, h as i32); + for simplicity in [0.0, 4.0] { + let t = trace(&c, r, &settings(4, simplicity, 2.0)); + assert!(t.regions.iter().all(|g| g.colour.iter().all(|v| v.is_finite())), "region colours finite"); + assert!(t.chains.iter().flatten().flatten().all(|v| v.is_finite()), "outlines finite"); + let out = t.render(r); + assert!(out.iter().flatten().all(|v| v.is_finite()), "pixels finite"); + let worst = coverage(&t, r).iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max); + assert!(worst < 1e-3, "coverage off by {worst}"); + } + } + + #[test] + fn noise_merges_into_neighbours_and_a_six_pixel_square_survives_at_simplicity_0() { + let (w, h) = (32usize, 32usize); + let c: Vec = (0..w * h) + .map(|i| { + // Noise inside one band (1/6 to 2/6 at six levels). + let v = 0.2 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06; + if (11..17).contains(&(i % w)) && (11..17).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] } + }) + .collect(); + let r = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(trace(&c, r, &settings(6, 0.0, 2.0)).regions.len(), 2, "the speck stays at simplicity 0"); + assert_eq!(trace(&c, r, &settings(6, 10.0, 2.0)).regions.len(), 1, "the speck merges at simplicity 10"); + } +} diff --git a/crates/algo/src/cutout/regions.rs b/crates/algo/src/cutout/regions.rs new file mode 100644 index 0000000000..803077c03a --- /dev/null +++ b/crates/algo/src/cutout/regions.rs @@ -0,0 +1,180 @@ +//! Regions: connected areas of one tone band, and the merge of small ones into their neighbours. + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap}; +use std::hash::BuildHasherDefault; + +use super::util::{FxHasher, find}; +use super::{Px, Settings}; + +/// 4-connected components of equal labels, numbered in raster order of their first pixel. +pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec, usize) { + let mut comp = vec![0u32; w * h]; + let mut parent: Vec = Vec::new(); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let up = (y > 0 && labels[i - w] == labels[i]).then(|| comp[i - w]); + let left = (x > 0 && labels[i - 1] == labels[i]).then(|| comp[i - 1]); + comp[i] = match (up, left) { + (Some(a), Some(b)) => { + let (ra, rb) = (find(&mut parent, a), find(&mut parent, b)); + let (lo, hi) = (ra.min(rb), ra.max(rb)); + parent[hi as usize] = lo; + lo + } + (Some(a), None) | (None, Some(a)) => a, + (None, None) => { + parent.push(parent.len() as u32); + parent.len() as u32 - 1 + } + }; + } + } + let mut index = vec![u32::MAX; parent.len()]; + let mut n = 0u32; + for c in comp.iter_mut() { + let root = find(&mut parent, *c) as usize; + if index[root] == u32::MAX { + index[root] = n; + n += 1; + } + *c = index[root]; + } + (comp, n as usize) +} + +/// Merges small and sliver components into their neighbours. Returns each pixel's region and +/// each region's mean colour. +pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec, Vec) { + let mut area = vec![0u32; n]; + let mut sum = vec![[0.0f64; 3]; n]; + for (&k, p) in comp.iter().zip(c) { + let k = k as usize; + area[k] += 1; + sum[k] = [sum[k][0] + f64::from(p[0]), sum[k][1] + f64::from(p[1]), sum[k][2] + f64::from(p[2])]; + } + // Shared border lengths, in cracks. + let mut shared: HashMap> = HashMap::default(); + for y in 0..h { + for x in 0..w { + let a = comp[y * w + x]; + for (nx, ny) in [(x + 1, y), (x, y + 1)] { + if nx < w && ny < h { + let b = comp[ny * w + nx]; + if a != b { + *shared.entry((u64::from(a.min(b)) << 32) | u64::from(a.max(b))).or_insert(0) += 1; + } + } + } + } + } + let mut adj: Vec> = vec![Vec::new(); n]; + let mut pairs: Vec<(u64, u32)> = shared.into_iter().collect(); + pairs.sort_unstable(); + for (key, len) in pairs { + let (a, b) = ((key >> 32) as u32, key as u32); + adj[a as usize].push((b, len)); + adj[b as usize].push((a, len)); + } + let min_area = s.min_area(); + let mergeable = |a: u32| a < min_area; + let mut parent: Vec = (0..n as u32).collect(); + let mut heap: BinaryHeap> = (0..n).filter(|&i| mergeable(area[i])).map(|i| Reverse((area[i], i as u32))).collect(); + while let Some(Reverse((a, id))) = heap.pop() { + let i = id as usize; + if find(&mut parent, id) != id || area[i] != a || !mergeable(a) { + continue; + } + // The neighbours as they are now, with their total shared border. + let mut nb: Vec<(u32, u32)> = std::mem::take(&mut adj[i]).into_iter().map(|(k, len)| (find(&mut parent, k), len)).filter(|&(k, _)| k != id).collect(); + nb.sort_unstable(); + nb.dedup_by(|b, a| { + if a.0 == b.0 { + a.1 += b.1; + true + } else { + false + } + }); + let Some(&(t, _)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else { + adj[i] = nb; + continue; + }; + let ti = t as usize; + parent[i] = t; + area[ti] += area[i]; + sum[ti] = [sum[ti][0] + sum[i][0], sum[ti][1] + sum[i][1], sum[ti][2] + sum[i][2]]; + // Appended as they are; resolved and summed when `t` itself merges. + adj[ti].extend(nb.into_iter().filter(|&(k, _)| k != t)); + if mergeable(area[ti]) { + heap.push(Reverse((area[ti], t))); + } + } + let mut index = vec![u32::MAX; n]; + let mut colours = Vec::new(); + let mut reg = vec![0u32; comp.len()]; + for (r, &k) in reg.iter_mut().zip(comp) { + let root = find(&mut parent, k) as usize; + if index[root] == u32::MAX { + index[root] = colours.len() as u32; + let a = f64::from(area[root].max(1)); + colours.push([(sum[root][0] / a) as f32, (sum[root][1] / a) as f32, (sum[root][2] / a) as f32]); + } + *r = index[root]; + } + (reg, colours) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(simplicity: f32) -> Settings { + Settings { levels: 4, simplicity, fidelity: 2.0 } + } + + /// Labels drawn as text, one character per pixel. + fn labels(rows: &[&str]) -> (Vec, usize, usize) { + (rows.iter().flat_map(|r| r.bytes()).collect(), rows[0].len(), rows.len()) + } + + #[test] + fn components_are_four_connected_and_numbered_in_raster_order() { + let (l, w, h) = labels(&["aab", "abb", "bba"]); + let (comp, n) = components(&l, w, h); + // The top-left `a`s, the `b`s (one area), then the lone `a` (touching the first only + // diagonally), in the order their first pixels come. + assert_eq!(n, 3); + assert_eq!(comp, vec![0, 0, 1, 0, 1, 1, 1, 1, 2]); + // A U shape: both arms are found as separate runs first and joined further down. + let (l, w, h) = labels(&["a.a", "a.a", "aaa"]); + let (comp, n) = components(&l, w, h); + assert_eq!(n, 2); + assert_eq!(comp[0], comp[2]); + } + + /// Regions from labels, with one flat colour per label. + fn merged(rows: &[&str], simplicity: f32) -> (Vec, Vec) { + let (l, w, h) = labels(rows); + let c: Vec = l.iter().map(|&b| [f32::from(b - b'a') / 4.0; 3]).collect(); + let (comp, n) = components(&l, w, h); + merge_regions(&comp, n, &c, w, h, &settings(simplicity)) + } + + #[test] + fn small_regions_merge_into_the_neighbour_they_share_most_border_with() { + let rows = ["aaaaaaaaaa", "aaaaaaaaaa", "aacaaaaaaa", "aaccbbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb"]; + // At Edge Simplicity 0 a region needs 25 pixels: the three `c`s go into the `a`s, which + // they share six cracks with (one with the `b`s); the 30 `b`s stay. + let (reg, colours) = merged(&rows, 0.0); + assert_eq!(colours.len(), 2); + assert_eq!(reg[2 * 10 + 2], reg[0]); + assert_ne!(reg[3 * 10 + 4], reg[0]); + // A region's colour is the mean of its pixels: 47 `a`s (0.0) and 3 `c`s (0.5). + let a = colours[reg[0] as usize][0]; + assert!((a - 1.5 / 50.0).abs() < 1e-6, "{a}"); + // At 4 a region needs 100 pixels, so the `b`s merge too. + assert_eq!(merged(&rows, 4.0).1.len(), 1); + } +} diff --git a/crates/algo/src/cutout/util.rs b/crates/algo/src/cutout/util.rs new file mode 100644 index 0000000000..551f909c37 --- /dev/null +++ b/crates/algo/src/cutout/util.rs @@ -0,0 +1,46 @@ +//! Small helpers shared by the Cutout stages. + +use std::hash::Hasher; + +/// Union-find root of `i`, halving the path on the way. +pub(super) fn find(parent: &mut [u32], mut i: u32) -> u32 { + while parent[i as usize] != i { + let p = parent[i as usize]; + parent[i as usize] = parent[p as usize]; + i = p; + } + i +} + +/// A fast deterministic hasher for the region adjacency map. +#[derive(Default)] +pub(super) struct FxHasher(u64); + +impl Hasher for FxHasher { + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.write_u64(u64::from(b)); + } + } + fn write_u64(&mut self, v: u64) { + self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x517c_c1b7_2722_0a95); + } + fn finish(&self) -> u64 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_returns_the_root_and_shortens_the_path() { + // 4 -> 3 -> 2 -> 1 -> 0. + let mut parent = vec![0, 0, 1, 2, 3]; + assert_eq!(find(&mut parent, 4), 0); + assert_eq!(find(&mut parent, 0), 0); + assert!(parent[4] < 3, "the path was halved: {parent:?}"); + assert!(parent.iter().all(|&p| p <= 2)); + } +} diff --git a/crates/algo/src/denoise.rs b/crates/algo/src/denoise.rs index 07f4745635..57260050bd 100644 --- a/crates/algo/src/denoise.rs +++ b/crates/algo/src/denoise.rs @@ -28,28 +28,57 @@ pub(crate) fn reach() -> f32 { (2 * MAX_LUMA_R + 2 * MAX_CHROMA_R + 6) as f32 } -/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`. -fn guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { - let len = w * h; - // Interleave the four box-filtered moments to run one pass. - let mut m = vec![0.0f32; len * 4]; - for i in 0..len { - m[i * 4] = g[i]; - m[i * 4 + 1] = p[i]; - m[i * 4 + 2] = g[i] * g[i]; - m[i * 4 + 3] = g[i] * p[i]; +/// Shared first and second moments of a guide (two filtered channels). +fn guide_moments(g: &[f32], w: usize, h: usize, r: usize) -> Vec { + let mut moments = Vec::with_capacity(g.len() * 2); + for &v in g { + moments.extend_from_slice(&[v, v * v]); } - box_blur_n(&mut m, w, h, 4, r); - let mut ab = vec![0.0f32; len * 2]; - for i in 0..len { - let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]); - let var = (mgg - mg * mg).max(0.0); - let a = (mgp - mg * mp) / (var + eps); - ab[i * 2] = a; - ab[i * 2 + 1] = mp - a * mg; + box_blur_n(&mut moments, w, h, 2, r); + moments +} + +/// Self-guided luminance: mean(p) = mean(g), mean(g*p) = mean(g*g). +fn guided_luma(g: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { + let mut ab = guide_moments(g, w, h, r); + for pair in ab.as_chunks_mut::<2>().0.iter_mut() { + let mean = pair[0]; + let covariance = pair[1] - mean * mean; + // Keep the original unclamped numerator, including roundoff below zero. + let a = covariance / (covariance.max(0.0) + eps); + pair[0] = a; + pair[1] = mean - a * mean; } box_blur_n(&mut ab, w, h, 2, r); - (0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect() + g.iter().zip(ab.as_chunks::<2>().0.iter()).map(|(&v, pair)| pair[0] * v + pair[1]).collect() +} + +/// Chroma planes share the cleaned guide's mean and regularised variance. +/// Two guide channels plus two moments and two coefficients per chroma plane: +/// ten filtered channels, versus twelve for two independent guided filters. +fn guided_chroma(g: &[f32], planes: [&mut [f32]; 2], w: usize, h: usize, r: usize, eps: f32, amount: f32) { + let mut stats = guide_moments(g, w, h, r); + for pair in stats.as_chunks_mut::<2>().0.iter_mut() { + pair[1] = (pair[1] - pair[0] * pair[0]).max(0.0) + eps; + } + for plane in planes { + let mut ab = Vec::with_capacity(g.len() * 2); + for (&guide, &p) in g.iter().zip(plane.iter()) { + ab.extend_from_slice(&[p, guide * p]); + } + box_blur_n(&mut ab, w, h, 2, r); + for (pair, guide) in ab.as_chunks_mut::<2>().0.iter_mut().zip(stats.as_chunks::<2>().0.iter()) { + let mean = pair[0]; + let a = (pair[1] - guide[0] * mean) / guide[1]; + pair[0] = a; + pair[1] = mean - a * guide[0]; + } + box_blur_n(&mut ab, w, h, 2, r); + for ((v, &guide), pair) in plane.iter_mut().zip(g).zip(ab.as_chunks::<2>().0.iter()) { + let filtered = pair[0] * guide + pair[1]; + *v += (filtered - *v) * amount; + } + } } pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec) -> Vec { @@ -125,7 +154,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec let r = (1.0 + spec.strength.clamp(0.0, 10.0) * 0.4).round() as usize; let keep = spec.preserve.clamp(0.0, 100.0) / 100.0; let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - keep); - let d = guided(&yv, &yv, ww, wh, r.min(MAX_LUMA_R), eps); + let d = guided_luma(&yv, ww, wh, r.min(MAX_LUMA_R), eps); let k = s.sqrt(); for (v, dv) in yv.iter_mut().zip(d) { *v += (dv - *v) * k; @@ -135,12 +164,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec if col > 0.0 && model != Model::Gray { let r = (1.0 + col * 8.0).round() as usize; let eps = (0.01 + 0.05 * col).powi(2); - for plane in [&mut c1, &mut c2] { - let d = guided(&yv, plane, ww, wh, r.min(MAX_CHROMA_R), eps); - for (v, dv) in plane.iter_mut().zip(d) { - *v += (dv - *v) * col; - } - } + guided_chroma(&yv, [&mut c1, &mut c2], ww, wh, r.min(MAX_CHROMA_R), eps, col); } // Sharpen Details: unsharp mask on luma. if sh > 0.0 { @@ -178,3 +202,106 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec } res } + +#[cfg(test)] +mod tests { + use super::*; + + /// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`. + fn reference_guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { + let len = w * h; + // Interleave the four box-filtered moments to run one pass. + let mut m = vec![0.0f32; len * 4]; + for i in 0..len { + m[i * 4] = g[i]; + m[i * 4 + 1] = p[i]; + m[i * 4 + 2] = g[i] * g[i]; + m[i * 4 + 3] = g[i] * p[i]; + } + box_blur_n(&mut m, w, h, 4, r); + let mut ab = vec![0.0f32; len * 2]; + for i in 0..len { + let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]); + let var = (mgg - mg * mg).max(0.0); + let a = (mgp - mg * mp) / (var + eps); + ab[i * 2] = a; + ab[i * 2 + 1] = mp - a * mg; + } + box_blur_n(&mut ab, w, h, 2, r); + (0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect() + } + + #[test] + fn shared_statistics_match_independent_filters_bit_for_bit() { + for (w, h) in [(1, 1), (1, 19), (23, 1), (7, 5), (33, 17)] { + let len = w * h; + for flat in [false, true] { + let g: Vec = (0..len).map(|i| if flat { 0.1 } else { ((i * 37 % 101) as f32 - 20.0) / 63.0 }).collect(); + let p: Vec = (0..len).map(|i| (i * 13 % 71) as f32 / 70.0 - 0.5).collect(); + let q: Vec = (0..len).map(|i| (i * 29 % 83) as f32 / 41.0 - 1.0).collect(); + for r in [0, 1, MAX_LUMA_R, MAX_CHROMA_R, 25] { + for eps in [0.000001, 0.0036] { + let expected = reference_guided(&g, &g, w, h, r, eps); + let actual = guided_luma(&g, w, h, r, eps); + assert_bits(&actual, &expected); + for amount in [0.01, 0.45, 1.0] { + let mut actual_p = p.clone(); + let mut actual_q = q.clone(); + guided_chroma(&g, [&mut actual_p, &mut actual_q], w, h, r, eps, amount); + for (original, actual) in [(&p, &actual_p), (&q, &actual_q)] { + let filtered = reference_guided(&g, original, w, h, r, eps); + let expected: Vec = original.iter().zip(filtered).map(|(&v, d)| v + (d - v) * amount).collect(); + assert_bits(actual, &expected); + } + } + } + } + } + } + } + + #[test] + fn reduce_noise_guides_both_chroma_planes_by_the_cleaned_luma() { + // Caller wiring: Lab keeps its planes as they are, so each output plane is compared with + // the independent filters run on the planes in order (luma by itself, chroma by the + // cleaned luma). + let (w, h) = (19, 13); + let rect = Rect::new(0, 0, w as i32, h as i32); + let mut src = Image::new(rect, 3); + for (i, px) in src.data.as_chunks_mut::<3>().0.iter_mut().enumerate() { + px[0] = (i * 37 % 101) as f32 / 100.0; + px[1] = (i * 13 % 71) as f32 / 70.0 - 0.5; + px[2] = (i * 29 % 83) as f32 / 41.0 - 1.0; + } + let ctx = Ctx { bounds: rect, mode: ColorMode::Lab, alpha: false }; + let spec = DenoiseSpec { strength: 6.0, preserve: 30.0, color: 50.0, sharpen: 0.0, jpeg: false }; + let out = reduce_noise(&src, rect, &ctx, &spec); + + let plane = |c: usize| -> Vec { src.data.as_chunks::<3>().0.iter().map(|px| px[c]).collect() }; + let (luma, chroma) = (plane(0), [plane(1), plane(2)]); + let s = spec.strength / 10.0; + let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - spec.preserve / 100.0); + let smooth = reference_guided(&luma, &luma, w, h, (1.0 + spec.strength * 0.4).round() as usize, eps); + let cleaned: Vec = luma.iter().zip(&smooth).map(|(&v, &d)| v + (d - v) * s.sqrt()).collect(); + let col = spec.color / 100.0; + let chroma_r = (1.0 + col * 8.0).round() as usize; + let chroma_eps = (0.01 + 0.05 * col).powi(2); + let mut expected = vec![cleaned]; + for p in &chroma { + let filtered = reference_guided(&expected[0], p, w, h, chroma_r, chroma_eps); + expected.push(p.iter().zip(filtered).map(|(&v, f)| v + (f - v) * col).collect()); + } + for (c, expected) in expected.iter().enumerate() { + let actual: Vec = out.as_chunks::<3>().0.iter().map(|px| px[c]).collect(); + assert_bits(&actual, expected); + assert!(actual.iter().zip(&plane(c)).any(|(a, o)| (a - o).abs() > 1e-3), "plane {c} was filtered"); + } + } + + fn assert_bits(actual: &[f32], expected: &[f32]) { + assert_eq!(actual.len(), expected.len()); + for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(actual.to_bits(), expected.to_bits(), "pixel {i}: {actual} != {expected}"); + } + } +} diff --git a/crates/algo/src/image.rs b/crates/algo/src/image.rs index 6a17e08b01..c521f04348 100644 --- a/crates/algo/src/image.rs +++ b/crates/algo/src/image.rs @@ -1,7 +1,7 @@ //! Interleaved `f32` image regions and sampling helpers. use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Surface, scratch}; /// How samples outside an image are read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -14,8 +14,9 @@ pub enum Edge { Wrap, } -/// A rectangle of interleaved, normalized samples. -#[derive(Clone, Debug, PartialEq)] +/// A rectangle of interleaved, normalized samples. The samples come from, and go back to, the +/// scratch pool. +#[derive(Debug, PartialEq)] pub struct Image { pub rect: Rect, /// Channels per pixel (alpha last when the surface has alpha). @@ -23,6 +24,20 @@ pub struct Image { pub data: Vec, } +impl Clone for Image { + fn clone(&self) -> Self { + let mut data = scratch::take(self.data.len(), 0.0f32); + data.copy_from_slice(&self.data); + Image { rect: self.rect, ch: self.ch, data } + } +} + +impl Drop for Image { + fn drop(&mut self) { + scratch::give(std::mem::take(&mut self.data)); + } +} + impl Image { /// Reads `rect` from a surface. pub fn read(s: &Surface, rect: Rect) -> Self { @@ -53,9 +68,15 @@ impl Image { img } + /// The samples, taken out of the image (it can't be destructured: it returns them to the + /// pool on drop). + pub fn into_data(mut self) -> Vec { + std::mem::take(&mut self.data) + } + /// New zeroed image. pub fn new(rect: Rect, ch: usize) -> Self { - Image { rect, ch, data: vec![0.0; rect.width() as usize * rect.height() as usize * ch] } + Image { rect, ch, data: scratch::take(rect.width() as usize * rect.height() as usize * ch, 0.0) } } #[inline] diff --git a/crates/algo/src/lens_flare.rs b/crates/algo/src/lens_flare.rs new file mode 100644 index 0000000000..71c6975bbc --- /dev/null +++ b/crates/algo/src/lens_flare.rs @@ -0,0 +1,275 @@ +//! Filter › Render › Lens Flare. +//! +//! A flare is built from elements placed along its axis: the line from the flare centre (t = 0) +//! through the centre of the image (t = 1), as in a real lens, where the internal reflections line +//! up with the optical centre. Each lens has its own set, measured from Photoshop's output +//! (`lens_flare_data.rs`): +//! +//! - radial elements: a colour profile by distance around a point on the axis (the core, glows, +//! rings and the reflection discs, with their coloured rims); +//! - streaks: straight lines centred on the axis (Movie Prime's horizontal and diagonal streaks, the +//! 105 mm Prime's spike); +//! - rays: thin one-pixel rays out of the centre, drawn without antialiasing as Photoshop does. +//! +//! Sizes are in units of 1/800 of the image width: Photoshop sizes a flare by the width alone, the +//! same in landscape, square and portrait images. Brightness changes the shape of the elements, not +//! just their light (the core grows, the glows widen, at 300 % most lenses wash out to white), so +//! each lens is measured at several brightnesses; between two the flare blends them. The light is +//! screened onto the pixels. + +use photocraft_geom::Rect; + +use crate::LensType; +use crate::fxutil::{rgba, set_rgba, smoothstep}; +use crate::image::Image; +use crate::{Ctx, lens_flare_data as data}; + +/// A colour profile by distance (in flare units) around a point on the axis; zero outside the +/// first and last knot, linear between knots. +pub(crate) struct Radial { + pub t: f32, + /// Offset across the axis, in flare units. + pub perp: f32, + pub knots: &'static [(f32, [f32; 3])], +} + +/// A straight streak centred on the axis: full strength along `half · (1 − taper)`, fading to +/// nothing at `half`; a Gaussian `width` across. +pub(crate) struct Streak { + pub t: f32, + pub perp: f32, + /// Degrees, clockwise from the +x axis (image y points down). + pub angle: f32, + pub half: f32, + pub taper: f32, + pub width: f32, + pub rgb: [f32; 3], +} + +/// A one-pixel ray out of the flare centre from radius `r0` to `r1`, fading in and out over +/// `fade` of its length. +pub(crate) struct Ray { + pub angle: f32, + pub r0: f32, + pub r1: f32, + pub fade: f32, + pub rgb: [f32; 3], +} + +/// A lens's flare at one measured brightness (percent). +pub(crate) struct Level { + pub brightness: f32, + pub radial: &'static [Radial], + pub streaks: &'static [Streak], + pub rays: &'static [Ray], +} + +fn levels(lens: LensType) -> &'static [Level] { + match lens { + LensType::Zoom => data::ZOOM, + LensType::Prime35 => data::PRIME35, + LensType::Prime105 => data::PRIME105, + LensType::MoviePrime => data::MOVIE_PRIME, + } +} + +/// The two measured levels around `brightness` and the weight of the second (levels are sorted). +fn bracket(levels: &[Level], brightness: f32) -> Option<(&Level, &Level, f32)> { + let first = levels.first()?; + let last = levels.last()?; + if brightness <= first.brightness { + return Some((first, first, 0.0)); + } + let i = levels.partition_point(|l| l.brightness < brightness); + let Some(hi) = levels.get(i) else { return Some((last, last, 0.0)) }; + let Some(lo) = i.checked_sub(1).and_then(|j| levels.get(j)) else { return Some((hi, hi, 0.0)) }; + let span = hi.brightness - lo.brightness; + Some((lo, hi, if span > 0.0 { (brightness - lo.brightness) / span } else { 0.0 })) +} + +/// The profile at distance `d`; `None` outside it. +fn profile(knots: &[(f32, [f32; 3])], d: f32) -> Option<[f32; 3]> { + let (first, last) = (knots.first()?, knots.last()?); + if d < first.0 || d > last.0 { + return None; + } + // The first knot at or beyond `d` (binary search: profiles have up to a few hundred knots). + let i = knots.partition_point(|k| k.0 < d); + let Some(hi) = knots.get(i) else { return Some(last.1) }; + let Some(lo) = i.checked_sub(1).and_then(|j| knots.get(j)) else { return Some(hi.1) }; + let span = hi.0 - lo.0; + let f = if span > 0.0 { (d - lo.0) / span } else { 0.0 }; + Some(std::array::from_fn(|k| lo.1[k] + (hi.1[k] - lo.1[k]) * f)) +} + +/// Flare geometry in document pixels, and the output tile it is drawn into. +struct Geometry { + centre: (f32, f32), + /// Axis: image centre minus flare centre. + axis: (f32, f32), + /// Unit normal to the axis. + normal: (f32, f32), + /// Pixels per flare unit. + unit: f32, + /// Output tile: origin and size. + origin: (f32, f32), + size: (usize, usize), +} + +impl Geometry { + fn point(&self, t: f32, perp: f32) -> (f32, f32) { + (self.centre.0 + t * self.axis.0 + perp * self.unit * self.normal.0, self.centre.1 + t * self.axis.1 + perp * self.unit * self.normal.1) + } +} + +/// Adds `weight` times the light of one level into `light` (the output tile, row-major). +fn add_level(level: &Level, weight: f32, g: &Geometry, light: &mut [[f32; 3]]) { + if weight <= 0.0 { + return; + } + let (w, h) = g.size; + let (ox, oy) = g.origin; + // Radial elements, each only over the pixels it can reach. + for e in level.radial { + let Some(reach) = e.knots.last().map(|k| k.0 * g.unit) else { continue }; + let p = g.point(e.t, e.perp); + let x0 = ((p.0 - reach - ox).floor().max(0.0) as usize).min(w); + let x1 = ((p.0 + reach - ox).ceil().max(0.0) as usize).min(w); + let y0 = ((p.1 - reach - oy).floor().max(0.0) as usize).min(h); + let y1 = ((p.1 + reach - oy).ceil().max(0.0) as usize).min(h); + for y in y0..y1 { + let fy = oy + y as f32 + 0.5 - p.1; + for x in x0..x1 { + let fx = ox + x as f32 + 0.5 - p.0; + if let Some(v) = profile(e.knots, fx.hypot(fy) / g.unit) + && let Some(px) = light.get_mut(y * w + x) + { + for (p, v) in px.iter_mut().zip(v) { + *p += weight * v; + } + } + } + } + } + + for s in level.streaks { + let p = g.point(s.t, s.perp); + let (sin, cos) = s.angle.to_radians().sin_cos(); + let half = s.half.max(1e-3); + let taper = s.taper.clamp(0.01, 1.0); + let width = s.width.max(1e-3); + for (i, px) in light.iter_mut().enumerate() { + let fx = ox + (i % w) as f32 + 0.5 - p.0; + let fy = oy + (i / w) as f32 + 0.5 - p.1; + let across = (-fx * sin + fy * cos) / g.unit / width; + // NaN comes from overflowing geometry and must not reach the pixels. + if across.is_nan() || across.abs() > 4.0 { + continue; + } + let along = ((fx * cos + fy * sin) / g.unit).abs(); + let k = weight * (1.0 - smoothstep(half * (1.0 - taper), half, along)) * (-across * across).exp(); + for (p, c) in px.iter_mut().zip(s.rgb) { + *p += k * c; + } + } + } + + // Rays: walk each ray in short steps and light every pixel it crosses once. + for r in level.rays { + let (sin, cos) = r.angle.to_radians().sin_cos(); + let (r0, r1) = (r.r0 * g.unit, r.r1 * g.unit); + let length = (r1 - r0).max(1e-6); + let fade = r.fade.max(1e-3); + // Skip a ray whose segment misses this tile: none of its samples can land inside it. + let (sx, sy) = (g.centre.0 + r0 * cos, g.centre.1 + r0 * sin); + let (ex, ey) = (g.centre.0 + r1 * cos, g.centre.1 + r1 * sin); + if sx.max(ex) < ox - 1.0 || sx.min(ex) > ox + w as f32 + 1.0 || sy.max(ey) < oy - 1.0 || sy.min(ey) > oy + h as f32 + 1.0 { + continue; + } + let steps = ((r1 - r0) / 0.35).ceil().clamp(0.0, 1e6) as usize; + let mut last = None; + for i in 0..steps { + let rad = r0 + i as f32 * 0.35; + let (px, py) = ((g.centre.0 + rad * cos).floor(), (g.centre.1 + rad * sin).floor()); + if last == Some((px, py)) { + continue; + } + last = Some((px, py)); + let (lx, ly) = (px - ox, py - oy); + if lx < 0.0 || ly < 0.0 || lx >= w as f32 || ly >= h as f32 { + continue; + } + let pos = (rad - r0) / length; + let env = weight * ((pos.min(1.0 - pos)) / fade).clamp(0.0, 1.0); + if let Some(v) = light.get_mut(ly as usize * w + lx as usize) { + for (p, c) in v.iter_mut().zip(r.rgb) { + *p += env * c; + } + } + } + } +} + +pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec { + let n = src.ch; + let b = ctx.bounds; + let (bw, bh) = (b.width() as f32, b.height() as f32); + let c = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh); + let mid = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0); + let axis = (mid.0 - c.0, mid.1 - c.1); + let len = axis.0.hypot(axis.1); + // A centre so far off the canvas that the geometry overflows f32 (e.g. 1e300 from a command) + // lights nothing here; without this, NaN would reach every pixel through the streaks. + if ![c.0, c.1, len].iter().all(|v| v.is_finite()) { + return src.crop(out); + } + // At the image centre the axis has no direction; the offsets across it are tiny anyway. + let normal = if len > 1e-6 { (-axis.1 / len, axis.0 / len) } else { (0.0, 1.0) }; + let (w, h) = (out.width() as usize, out.height() as usize); + let g = Geometry { centre: c, axis, normal, unit: (bw / 800.0).max(1e-6), origin: (out.x0 as f32, out.y0 as f32), size: (w, h) }; + + let mut light = vec![[0.0f32; 3]; w * h]; + if let Some((lo, hi, f)) = bracket(levels(lens), brightness.clamp(10.0, 300.0)) { + add_level(lo, 1.0 - f, &g, &mut light); + if !std::ptr::eq(lo, hi) { + add_level(hi, f, &g, &mut light); + } + } + + let mut res = src.crop(out); + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let Some(f) = light.get(i) else { break }; + let mut c = rgba(ctx, px); + for k in 0..3 { + // `clamp` keeps NaN; a non-finite amount adds no light. + let a = if f[k].is_finite() { f[k].clamp(0.0, 1.0) } else { 0.0 }; + // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit value + // already above white is left as is rather than pulled down towards it. + c[k] += a * (1.0 - c[k]).max(0.0); + } + set_rgba(ctx, px, c); + } + res +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brightness_between_levels_blends_the_two_around_it() { + let lv = levels(LensType::Zoom); + let (a, b) = (&lv[0], &lv[1]); + let mid = (a.brightness + b.brightness) / 2.0; + let (lo, hi, f) = bracket(lv, mid).unwrap(); + assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, b) && (f - 0.5).abs() < 1e-6); + // Exactly on a level, and outside the measured range, one level is used as is. + let (_, hi, f) = bracket(lv, b.brightness).unwrap(); + assert!(std::ptr::eq(hi, b) && f == 1.0); + let (lo, hi, f) = bracket(lv, 1.0).unwrap(); + assert!(std::ptr::eq(lo, a) && std::ptr::eq(hi, a) && f == 0.0); + let last = lv.last().unwrap(); + let (lo, _, f) = bracket(lv, 1000.0).unwrap(); + assert!(std::ptr::eq(lo, last) && f == 0.0); + } +} diff --git a/crates/algo/src/lens_flare_data.rs b/crates/algo/src/lens_flare_data.rs new file mode 100644 index 0000000000..ce961f422f --- /dev/null +++ b/crates/algo/src/lens_flare_data.rs @@ -0,0 +1,14532 @@ +//! Lens Flare element tables, measured from Photoshop 27's Lens Flare output: one flare per lens +//! and brightness on black (1024 × 768). Distances are in flare units (1/800 of the image width); +//! see `lens_flare.rs`. Generated; regenerate rather than edit by hand. + +// Measured values, not constants: some happen to lie close to π/2, 1/π, log10(e) and the like. +#![allow(clippy::approx_constant)] + +use super::lens_flare::{Level, Radial, Ray, Streak}; + +const ZOOM_10_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [1.516, 1.414, 1.415]), + (0.8, [1.544, 1.444, 1.444]), + (2.4, [1.315, 1.105, 1.109]), + (4.0, [1.043, 0.7296, 0.7354]), + (4.8, [0.9226, 0.574, 0.5814]), + (5.6, [0.8159, 0.4383, 0.4452]), + (6.4, [0.7169, 0.3207, 0.3302]), + (7.2, [0.6416, 0.2356, 0.2432]), + (8.0, [0.5759, 0.1689, 0.1773]), + (8.8, [0.5388, 0.1407, 0.146]), + (9.6, [0.5073, 0.1246, 0.1308]), + (12.8, [0.4109, 0.09, 0.0964]), + (17.6, [0.2858, 0.0465, 0.0507]), + (19.2, [0.2513, 0.0337, 0.037]), + (23.2, [0.164, 0.0077, 0.011]), + (24.0, [0.149, 0.005, 0.0069]), + (26.4, [0.1056, -0.0084, -0.0062]), + (30.4, [0.0504, -0.0158, -0.016]), + (32.0, [0.0319, -0.0187, -0.017]), + (33.6, [0.0151, -0.0231, -0.0211]), + (34.4, [0.0073, -0.0233, -0.0237]), + (36.0, [-0.0083, -0.0231, -0.0243]), + (36.8, [-0.0153, -0.0253, -0.0242]), + (38.4, [-0.0274, -0.03, -0.0287]), + (40.0, [-0.0372, -0.0304, -0.0314]), + (41.6, [-0.051, -0.0303, -0.0313]), + (43.2, [-0.0596, -0.0302, -0.0312]), + (44.8, [-0.0645, -0.0301, -0.0311]), + (47.2, [-0.0664, -0.03, -0.031]), + (98.4, [-0.0529, -0.0264, -0.0272]), + (212.8, [-0.0329, -0.0179, -0.0181]), + (309.6, [-0.0133, -0.0068, -0.005]), + (326.4, [-0.0096, -0.0047, -0.0052]), + (384.8, [0.002, 0.0017, 0.0021]), + (432.0, [0.0089, 0.0052, 0.0059]), + (466.4, [0.0106, 0.005, 0.0057]), + (491.2, [-0.0013, -0.0017, -0.0042]), + (507.2, [-0.0053, -0.0029, -0.0046]), + (520.0, [-0.0042, -0.0007, -0.0025]), + (547.2, [0.0022, 0.0015, 0.0014]), + (570.4, [0.0003, -0.0012, 0.0013]), + (585.6, [0.0001, 0.0005, 0.0054]), + (596.8, [0.0009, 0.0003, 0.0]), + (600.8, [-0.0014, -0.0003, -0.0023]), + (604.8, [0.006, 0.0011, -0.0025]), + (606.4, [0.0071, 0.0005, -0.0028]), + (609.6, [-0.0005, 0.0001, -0.0024]), + (611.2, [-0.0006, 0.0003, -0.0018]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [-0.7096, -0.7016, -0.7011]), + (0.8, [-0.6774, -0.656, -0.6547]), + (1.6, [-0.6326, -0.5852, -0.5845]), + (2.4, [-0.5775, -0.5013, -0.5013]), + (3.2, [-0.5041, -0.4018, -0.4037]), + (4.0, [-0.4459, -0.3219, -0.323]), + (4.8, [-0.3859, -0.2444, -0.2467]), + (5.6, [-0.3358, -0.1822, -0.1842]), + (7.2, [-0.2519, -0.0844, -0.0871]), + (8.0, [-0.2283, -0.0601, -0.0628]), + (8.8, [-0.2087, -0.0458, -0.0469]), + (12.8, [-0.1518, -0.0243, -0.0268]), + (13.6, [-0.1412, -0.022, -0.0235]), + (17.6, [-0.0939, -0.0051, -0.0063]), + (19.2, [-0.0786, 0.0006, 0.0008]), + (23.2, [-0.0379, 0.0136, 0.0121]), + (24.0, [-0.0317, 0.013, 0.0139]), + (26.4, [-0.0116, 0.0204, 0.02]), + (30.4, [0.0133, 0.0234, 0.0245]), + (33.6, [0.029, 0.0271, 0.0268]), + (36.0, [0.0395, 0.0265, 0.0281]), + (38.4, [0.048, 0.0302, 0.0303]), + (40.0, [0.0517, 0.03, 0.0312]), + (42.4, [0.0616, 0.0299, 0.0311]), + (44.8, [0.0656, 0.0298, 0.0309]), + (104.0, [0.0512, 0.0257, 0.0266]), + (212.0, [0.0325, 0.0178, 0.0181]), + (309.6, [0.013, 0.0067, 0.005]), + (325.6, [0.0095, 0.0047, 0.0053]), + (382.4, [-0.0016, -0.0015, -0.0017]), + (429.6, [-0.0086, -0.005, -0.0056]), + (465.6, [-0.0105, -0.0049, -0.0055]), + (489.6, [0.0009, 0.0015, 0.0042]), + (506.4, [0.0053, 0.0029, 0.0047]), + (519.2, [0.0041, 0.0008, 0.0026]), + (546.4, [-0.0022, -0.0014, -0.0013]), + (569.6, [-0.0003, 0.0012, -0.0011]), + (584.8, [-0.0001, -0.0005, -0.0052]), + (596.0, [-0.0009, -0.0003, 0.0002]), + (599.2, [0.0017, 0.0006, 0.0017]), + (604.8, [-0.0072, -0.0008, 0.0027]), + (606.4, [-0.0055, -0.0002, 0.0029]), + (608.8, [0.0006, -0.0001, 0.0024]), + (610.4, [0.0006, -0.0003, 0.0017]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0043, 0.004, 0.004]), + (24.501, [0.0041, 0.004, 0.004]), + (26.082, [0.0084, 0.004, 0.004]), + (28.453, [0.0084, 0.004, 0.0041]), + (29.243, [0.0067, 0.0041, 0.0034]), + (30.034, [0.001, 0.0035, 0.0004]), + (30.824, [0.0001, 0.0006, 0.0]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.0119, 0.004, 0.0]), + (13.538, [0.0121, 0.004, 0.0]), + (14.334, [0.0107, 0.0037, 0.0]), + (15.131, [0.0027, 0.0004, 0.0]), + (15.927, [-0.0002, -0.0001, 0.0]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.0219, 0.0573, 0.0447]), + (0.76482, [0.0137, 0.0367, 0.0262]), + (1.5296, [0.013, 0.0299, 0.0218]), + (2.2945, [0.004, 0.0086, 0.0039]), + (3.0593, [-0.0002, 0.0007, -0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.0394, 0.0916, 0.0717]), + (0.79661, [0.0247, 0.0659, 0.0464]), + (1.5932, [0.0246, 0.0554, 0.0414]), + (2.3898, [0.0101, 0.0347, 0.024]), + (3.1864, [0.0082, 0.0201, 0.0175]), + (3.983, [0.0041, 0.0156, 0.0133]), + (5.5762, [0.0037, 0.0087, 0.0082]), + (6.3728, [-0.0005, 0.0042, 0.0048]), + (7.1694, [0.0001, 0.004, 0.004]), + (7.9661, [0.0, -0.0003, 0.0004]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.0032, 0.0039, 0.035]), + (6.3762, [0.003, 0.004, 0.0331]), + (12.752, [0.0032, 0.0044, 0.0273]), + (28.693, [0.003, 0.005, 0.0026]), + (33.475, [0.0035, 0.0049, -0.0025]), + (39.054, [0.0036, 0.0046, -0.0033]), + (53.401, [0.0039, 0.0044, 0.004]), + (54.198, [0.0041, 0.0005, 0.0003]), + (54.995, [0.0003, 0.0001, 0.0001]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0044, -0.0001, -0.0104]), + (12.734, [0.0045, -0.0003, -0.0077]), + (29.448, [0.0045, -0.0005, 0.0015]), + (31.04, [0.0002, -0.0005, 0.0021]), + (41.387, [0.0, -0.0003, 0.0018]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [0.0047, 0.0004, -0.0219]), + (11.937, [0.0042, 0.0, -0.018]), + (13.529, [0.0045, -0.0001, -0.0166]), + (15.916, [0.0004, -0.0002, -0.0146]), + (29.445, [0.0005, -0.0007, 0.0007]), + (35.016, [0.0003, -0.0005, 0.0049]), + (38.995, [0.0002, -0.0004, 0.0054]), + (47.749, [0.0001, -0.0002, 0.0017]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0001, 0.0078, 0.0382]), + (3.1792, [0.0, 0.0078, 0.035]), + (13.512, [0.0, 0.0078, 0.0109]), + (18.28, [0.0001, 0.0078, 0.0058]), + (21.46, [0.0, 0.008, 0.0048]), + (23.844, [-0.0001, 0.0, 0.0004]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0039, 0.0039, 0.0039]), + (74.31, [0.0037, 0.0039, 0.0037]), + (75.109, [0.0046, 0.0039, 0.0037]), + (75.908, [0.0086, 0.0039, 0.0038]), + (77.507, [0.0085, 0.0038, 0.0044]), + (78.306, [0.0045, 0.0046, 0.0007]), + (79.105, [0.0043, 0.0006, -0.0003]), + (79.904, [0.0008, -0.0002, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0, 0.0, -0.0001]), + (143.85, [0.0, 0.0, -0.0002]), + (145.45, [0.0, 0.0, 0.0042]), + (148.64, [0.0, 0.0, 0.0042]), + (150.24, [0.0, 0.0, -0.0002]), + (151.84, [-0.0003, 0.0, 0.0]), + (153.44, [0.0043, 0.0, 0.0]), + (156.64, [0.0038, 0.0, 0.0]), + (157.43, [0.0003, 0.0, 0.0]), + ], + }, +]; +const ZOOM_10_STREAKS: &[Streak] = &[]; +const ZOOM_10_RAYS: &[Ray] = &[]; +const ZOOM_50_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.764, 0.8405, 0.8238]), + (0.8, [0.7491, 0.8266, 0.8133]), + (1.6, [0.7522, 0.8271, 0.8122]), + (4.8, [0.7053, 0.7462, 0.7327]), + (7.2, [0.6707, 0.6725, 0.6597]), + (11.2, [0.5886, 0.5434, 0.5325]), + (14.4, [0.5151, 0.4343, 0.4226]), + (20.0, [0.3846, 0.2712, 0.255]), + (20.8, [0.3685, 0.2506, 0.24]), + (21.6, [0.3529, 0.229, 0.2159]), + (23.2, [0.3182, 0.1922, 0.186]), + (24.0, [0.3004, 0.1709, 0.1599]), + (24.8, [0.2898, 0.1597, 0.1501]), + (25.6, [0.2697, 0.1402, 0.1343]), + (27.2, [0.243, 0.1156, 0.1018]), + (28.8, [0.217, 0.091, 0.082]), + (30.4, [0.2024, 0.0752, 0.0638]), + (32.0, [0.189, 0.0583, 0.0509]), + (33.6, [0.1772, 0.0519, 0.0433]), + (39.2, [0.1453, 0.0388, 0.0282]), + (40.8, [0.1352, 0.0366, 0.0261]), + (42.4, [0.1216, 0.0317, 0.0185]), + (45.6, [0.104, 0.0259, 0.011]), + (48.8, [0.0868, 0.0199, 0.0063]), + (50.4, [0.0777, 0.0136, 0.004]), + (52.0, [0.0732, 0.0118, 0.0024]), + (53.6, [0.0646, 0.0101, -0.002]), + (55.2, [0.0608, 0.0054, -0.0031]), + (56.8, [0.0583, 0.0042, -0.004]), + (59.2, [0.05, 0.0024, -0.0113]), + (60.8, [0.0448, -0.0018, -0.0112]), + (62.4, [0.038, -0.0019, -0.0118]), + (63.2, [0.0397, -0.0026, -0.0123]), + (64.8, [0.0522, 0.0, -0.0106]), + (66.4, [0.0692, -0.0007, -0.0092]), + (67.2, [0.0727, -0.0012, -0.0086]), + (68.0, [0.0694, -0.0014, -0.0085]), + (68.8, [0.0574, -0.0031, -0.0105]), + (69.6, [0.0503, -0.0082, -0.0109]), + (72.0, [0.0129, -0.0124, -0.0101]), + (74.4, [0.007, -0.0139, -0.0097]), + (75.2, [0.0045, -0.0166, -0.0092]), + (76.8, [0.0007, -0.0171, -0.0123]), + (78.4, [-0.0072, -0.0176, -0.0153]), + (79.2, [-0.0069, -0.0178, -0.015]), + (82.4, [-0.0147, -0.0178, -0.0126]), + (85.6, [-0.0177, -0.0174, -0.0091]), + (87.2, [-0.0217, -0.0176, -0.0081]), + (92.0, [-0.0206, -0.0188, -0.0077]), + (93.6, [-0.0202, -0.0225, -0.0081]), + (99.2, [-0.0191, -0.0235, -0.0079]), + (100.8, [-0.0224, -0.0236, -0.0114]), + (108.8, [-0.0222, -0.0244, -0.0131]), + (120.0, [-0.0251, -0.0268, -0.0217]), + (124.0, [-0.0259, -0.027, -0.0231]), + (124.8, [-0.0261, -0.0303, -0.0233]), + (128.8, [-0.0264, -0.0296, -0.0227]), + (129.6, [-0.03, -0.0294, -0.026]), + (132.8, [-0.0303, -0.0285, -0.0245]), + (138.4, [-0.0305, -0.0275, -0.0228]), + (157.6, [-0.0293, -0.029, -0.0285]), + (198.4, [-0.0275, -0.0276, -0.0239]), + (242.4, [-0.0242, -0.023, -0.0209]), + (289.6, [-0.0179, -0.0157, -0.013]), + (308.8, [-0.0138, -0.012, -0.0059]), + (325.6, [-0.0111, -0.0095, -0.0077]), + (343.2, [-0.0081, -0.0067, -0.0029]), + (399.2, [0.0021, 0.0014, 0.0047]), + (432.8, [0.0051, 0.0047, 0.0078]), + (464.8, [0.0054, 0.0062, 0.0092]), + (472.8, [0.0052, 0.0053, 0.0065]), + (490.4, [-0.0031, -0.0019, -0.0023]), + (507.2, [-0.0098, -0.007, -0.0039]), + (522.4, [-0.0109, -0.0078, -0.0055]), + (552.8, [-0.0004, 0.0016, -0.002]), + (568.8, [0.0046, 0.0037, -0.0036]), + (573.6, [0.0073, 0.0054, -0.0014]), + (582.4, [0.0135, 0.01, 0.0083]), + (587.2, [0.0171, 0.0118, 0.0108]), + (593.6, [0.0163, 0.0093, 0.0067]), + (596.8, [0.0178, 0.0073, 0.009]), + (599.2, [0.0208, 0.0112, 0.0088]), + (600.8, [0.0239, 0.0166, 0.0069]), + (603.2, [0.0299, 0.0236, -0.0002]), + (605.6, [0.0333, 0.0213, -0.0074]), + (607.2, [0.0259, 0.0141, -0.0111]), + (611.2, [-0.0043, -0.0042, -0.0105]), + (612.8, [-0.0091, -0.0059, -0.0088]), + (614.4, [-0.0111, -0.0065, -0.0073]), + (617.6, [-0.0114, -0.0064, -0.005]), + (624.0, [-0.005, -0.0025, -0.0015]), + (635.2, [0.0011, 0.0, -0.0018]), + (640.0, [-0.0014, -0.0014, -0.0007]), + (644.0, [0.0006, 0.0007, 0.0033]), + (650.4, [0.0006, 0.001, 0.0037]), + (653.6, [-0.0005, 0.0003, 0.0018]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.2288, 0.1657, 0.1816]), + (1.6, [0.2265, 0.1649, 0.1839]), + (8.0, [0.205, 0.1203, 0.1415]), + (14.4, [0.1714, 0.0728, 0.0934]), + (20.0, [0.139, 0.0374, 0.0557]), + (23.2, [0.1236, 0.02, 0.0406]), + (24.0, [0.1187, 0.0153, 0.0339]), + (24.8, [0.1175, 0.0138, 0.0322]), + (25.6, [0.1125, 0.0095, 0.0293]), + (27.2, [0.1051, 0.0066, 0.023]), + (28.8, [0.0952, 0.0036, 0.0211]), + (29.6, [0.093, 0.0048, 0.0207]), + (32.0, [0.0847, 0.0015, 0.0189]), + (38.4, [0.0706, 0.0063, 0.0254]), + (44.0, [0.0645, 0.0125, 0.0302]), + (54.4, [0.0574, 0.0191, 0.0356]), + (59.2, [0.0436, 0.0224, 0.0364]), + (62.4, [0.0426, 0.0228, 0.0347]), + (64.0, [0.0479, 0.0239, 0.0329]), + (67.2, [0.0513, 0.0257, 0.03]), + (72.0, [0.0315, 0.0247, 0.0269]), + (80.8, [0.0308, 0.0262, 0.0232]), + (88.8, [0.0294, 0.0257, 0.0159]), + (100.8, [0.0258, 0.0273, 0.0153]), + (108.8, [0.0257, 0.0279, 0.017]), + (118.4, [0.0282, 0.0301, 0.0249]), + (126.4, [0.0299, 0.0296, 0.0276]), + (137.6, [0.0301, 0.027, 0.0228]), + (157.6, [0.0288, 0.0285, 0.0286]), + (200.8, [0.0269, 0.0269, 0.0238]), + (243.2, [0.0236, 0.0224, 0.021]), + (276.0, [0.0195, 0.0174, 0.0158]), + (293.6, [0.017, 0.0149, 0.0122]), + (308.8, [0.0135, 0.0117, 0.0062]), + (325.6, [0.0109, 0.0092, 0.008]), + (343.2, [0.0079, 0.0065, 0.0033]), + (399.2, [-0.0022, -0.0016, -0.0043]), + (432.8, [-0.0051, -0.0047, -0.0073]), + (464.0, [-0.0054, -0.0061, -0.0086]), + (472.0, [-0.0052, -0.0054, -0.006]), + (489.6, [0.0031, 0.0018, 0.0027]), + (506.4, [0.0097, 0.0069, 0.0043]), + (521.6, [0.0108, 0.0078, 0.0059]), + (552.0, [0.0004, -0.0015, 0.0024]), + (567.2, [-0.0044, -0.0035, 0.0043]), + (572.8, [-0.0072, -0.0053, 0.0019]), + (581.6, [-0.0134, -0.0099, -0.0078]), + (586.4, [-0.017, -0.0118, -0.0104]), + (592.8, [-0.0163, -0.0092, -0.0065]), + (596.0, [-0.0179, -0.0073, -0.0088]), + (598.4, [-0.0212, -0.0114, -0.0089]), + (600.0, [-0.0244, -0.0168, -0.0072]), + (602.4, [-0.0303, -0.0242, 0.0002]), + (604.8, [-0.0336, -0.0214, 0.0076]), + (605.6, [-0.0308, -0.0178, 0.0096]), + (607.2, [-0.0186, -0.0095, 0.0118]), + (610.4, [0.0045, 0.0043, 0.0103]), + (612.0, [0.0093, 0.0059, 0.0087]), + (613.6, [0.0111, 0.0065, 0.0072]), + (616.8, [0.0113, 0.0064, 0.0049]), + (623.2, [0.0049, 0.0024, 0.0016]), + (634.4, [-0.0011, -0.0001, 0.0018]), + (639.2, [0.0014, 0.0014, 0.0006]), + (643.2, [-0.0006, -0.0008, -0.0033]), + (649.6, [-0.0006, -0.001, -0.0038]), + (652.8, [0.0005, -0.0002, -0.0017]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0215, 0.0055, 0.0001]), + (0.79967, [0.0189, 0.0031, -0.0017]), + (2.399, [0.0218, 0.0059, 0.0011]), + (7.9967, [0.0202, 0.004, -0.0005]), + (117.55, [0.0202, 0.0045, -0.0001]), + (118.35, [0.0166, 0.0045, 0.0]), + (119.15, [0.0162, 0.0045, -0.0001]), + (271.89, [0.0165, 0.0043, -0.0001]), + (273.49, [0.0119, 0.0043, -0.0002]), + (274.29, [0.0118, 0.0045, -0.0002]), + (275.08, [0.0078, 0.0037, -0.0002]), + (275.88, [0.0085, -0.0001, -0.0002]), + (276.68, [0.0042, 0.0005, -0.0002]), + (277.48, [0.0044, 0.0004, -0.0002]), + (278.28, [0.0003, 0.0004, -0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0001, -0.0019, 0.0023]), + (0.79036, [0.0041, 0.0046, 0.004]), + (1.5807, [0.0031, 0.0044, 0.0053]), + (2.3711, [0.0044, 0.0039, 0.0047]), + (3.9518, [0.0028, 0.0037, 0.0043]), + (4.7422, [0.0041, 0.0029, 0.0045]), + (7.1133, [0.0025, 0.0026, 0.0034]), + (11.065, [0.0032, 0.003, 0.0033]), + (15.017, [0.0033, 0.0101, 0.0039]), + (17.388, [0.0031, 0.0138, 0.0078]), + (18.969, [0.0071, 0.0173, 0.009]), + (22.13, [0.0076, 0.0241, 0.0142]), + (22.921, [0.0105, 0.0264, 0.0158]), + (26.872, [0.0454, 0.0401, 0.0202]), + (27.663, [0.0475, 0.0426, 0.0226]), + (29.243, [0.037, 0.0458, 0.0242]), + (30.034, [0.0277, 0.035, 0.0202]), + (30.824, [0.0126, 0.0113, 0.007]), + (31.615, [0.0027, 0.0008, 0.0008]), + (32.405, [0.0003, 0.0002, 0.0006]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [-0.0012, 0.018, 0.0484]), + (0.79858, [0.0024, 0.022, 0.0555]), + (2.3957, [0.0034, 0.0229, 0.0578]), + (16.77, [0.0021, 0.0216, 0.0539]), + (22.36, [0.0018, 0.0126, 0.0352]), + (28.749, [0.0005, 0.0042, 0.0172]), + (43.123, [0.0, 0.0038, 0.016]), + (46.318, [0.0, 0.0016, 0.0074]), + (50.311, [-0.0001, 0.0001, 0.0011]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.0784, 0.0331, -0.0088]), + (1.5927, [0.0783, 0.0333, -0.0087]), + (3.9818, [0.0783, 0.0344, -0.0042]), + (7.9635, [0.078, 0.0351, -0.0017]), + (11.149, [0.0864, 0.0351, -0.0013]), + (13.538, [0.0843, 0.0359, -0.0011]), + (14.334, [0.0731, 0.0309, -0.0012]), + (15.131, [0.0234, 0.0096, -0.0011]), + (15.927, [-0.0005, -0.0001, -0.0012]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.1203, 0.3013, 0.2305]), + (0.76482, [0.0735, 0.1859, 0.1399]), + (1.5296, [0.0635, 0.1554, 0.1184]), + (2.2945, [0.0211, 0.0513, 0.0384]), + (3.0593, [0.0008, 0.0025, 0.0019]), + (3.8241, [0.0, -0.0007, 0.0]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.1746, 0.4112, 0.3272]), + (0.79661, [0.126, 0.3027, 0.2427]), + (1.5932, [0.1117, 0.266, 0.2161]), + (2.3898, [0.0648, 0.1607, 0.1396]), + (3.1864, [0.0436, 0.1066, 0.0988]), + (3.983, [0.0327, 0.0824, 0.0743]), + (4.7796, [0.0261, 0.068, 0.0636]), + (5.5762, [0.02, 0.0485, 0.0466]), + (7.1694, [0.0084, 0.0228, 0.0229]), + (7.9661, [0.0035, 0.0137, 0.0137]), + (8.7627, [0.0008, 0.0061, 0.0052]), + (9.5593, [0.0002, 0.0002, 0.0029]), + (12.746, [0.0002, 0.0002, 0.0019]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.0335, 0.0136, 0.1475]), + (6.3762, [0.0331, 0.0145, 0.1391]), + (9.5643, [0.0318, 0.0146, 0.1287]), + (11.158, [0.0351, 0.0177, 0.1221]), + (12.752, [0.0305, 0.0158, 0.1145]), + (19.129, [0.0216, 0.0135, 0.0756]), + (22.317, [0.0248, 0.014, 0.0507]), + (26.302, [0.0242, 0.0135, 0.0202]), + (27.099, [0.0278, 0.0134, 0.0149]), + (28.693, [0.0289, 0.0151, 0.0047]), + (30.287, [0.0293, 0.0169, -0.0041]), + (31.881, [0.034, 0.0165, -0.0115]), + (34.272, [0.0347, 0.0159, -0.0192]), + (35.069, [0.0383, 0.0165, -0.0209]), + (35.866, [0.0387, 0.0194, -0.022]), + (39.054, [0.0421, 0.0186, -0.0217]), + (39.851, [0.0419, 0.0183, -0.0207]), + (40.648, [0.0423, 0.0209, -0.0195]), + (41.445, [0.0459, 0.0226, -0.0179]), + (43.039, [0.0456, 0.0225, -0.0143]), + (44.633, [0.0499, 0.0223, -0.0105]), + (46.227, [0.0494, 0.0267, -0.0068]), + (47.025, [0.0528, 0.0263, -0.0051]), + (48.619, [0.053, 0.0265, -0.0017]), + (49.416, [0.0567, 0.0268, -0.0002]), + (50.213, [0.0568, 0.0306, 0.0011]), + (53.401, [0.0626, 0.0314, 0.0051]), + (54.198, [0.0567, 0.0309, 0.0018]), + (54.995, [0.0161, 0.0083, 0.0018]), + (55.792, [-0.0014, -0.0006, 0.0021]), + (56.589, [0.0003, 0.0006, 0.0022]), + (60.574, [0.0003, 0.0001, 0.0019]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0428, 0.0237, -0.0481]), + (1.5918, [0.0417, 0.0231, -0.0475]), + (3.1836, [0.0452, 0.0255, -0.0466]), + (4.7754, [0.0417, 0.0232, -0.0456]), + (7.1631, [0.042, 0.0229, -0.0435]), + (13.53, [0.0429, 0.0224, -0.0348]), + (19.897, [0.0454, 0.0229, -0.0196]), + (25.469, [0.0443, 0.0232, -0.0036]), + (29.448, [0.044, 0.0231, 0.0049]), + (30.244, [0.0319, 0.0175, 0.0062]), + (31.04, [0.0073, 0.0031, 0.0071]), + (31.836, [0.0, -0.0008, 0.008]), + (37.407, [-0.0004, -0.0001, 0.0098]), + (46.162, [0.0002, 0.0, 0.0022]), + (47.754, [-0.0024, -0.0017, 0.001]), + (48.55, [-0.0011, -0.0007, 0.0007]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [0.0291, 0.0217, -0.1021]), + (7.1624, [0.0298, 0.0215, -0.0963]), + (13.529, [0.0328, 0.0211, -0.0783]), + (14.325, [0.0283, 0.0181, -0.076]), + (15.916, [-0.0016, -0.0014, -0.0699]), + (16.712, [-0.003, -0.0021, -0.0668]), + (22.283, [0.0011, -0.0013, -0.0396]), + (29.445, [0.0013, -0.0011, -0.0016]), + (31.833, [0.0009, -0.0006, 0.0077]), + (35.016, [0.0007, 0.0, 0.0159]), + (37.403, [0.0009, 0.0008, 0.0187]), + (39.791, [0.0012, 0.0013, 0.0183]), + (49.341, [0.0017, 0.0011, 0.0032]), + (50.932, [0.0044, 0.0023, 0.0012]), + (53.32, [0.0024, 0.0008, -0.0014]), + (58.891, [-0.0015, -0.001, -0.0023]), + (62.074, [0.0, 0.0001, -0.0019]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0186, 0.0742, 0.1998]), + (0.7948, [0.0177, 0.0694, 0.1966]), + (1.5896, [0.0154, 0.0689, 0.1959]), + (3.1792, [0.0158, 0.0679, 0.1882]), + (7.948, [0.0152, 0.0604, 0.1315]), + (13.512, [0.0153, 0.0553, 0.0771]), + (15.896, [0.0173, 0.055, 0.0621]), + (16.691, [0.0154, 0.0539, 0.0568]), + (21.46, [0.0146, 0.0531, 0.0419]), + (22.254, [0.0105, 0.038, 0.0298]), + (23.049, [0.0028, 0.0112, 0.0111]), + (23.844, [0.0002, 0.0012, 0.0031]), + (24.639, [-0.0005, -0.0002, 0.0018]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0038, 0.0038, 0.0039]), + (32.76, [0.0038, 0.0034, 0.0039]), + (33.56, [0.0038, 0.0079, 0.0038]), + (39.952, [0.0038, 0.0075, 0.0038]), + (41.55, [0.0038, 0.0123, 0.0038]), + (46.344, [0.0038, 0.011, 0.0038]), + (47.942, [0.004, 0.0161, 0.0039]), + (48.741, [0.0034, 0.0155, 0.004]), + (49.54, [0.0078, 0.0157, 0.004]), + (51.937, [0.0077, 0.015, 0.0041]), + (53.535, [0.0078, 0.02, 0.0042]), + (56.732, [0.0078, 0.0193, 0.0041]), + (58.33, [0.0077, 0.0242, 0.0041]), + (59.928, [0.0072, 0.0235, 0.0041]), + (60.727, [0.0117, 0.0236, 0.0041]), + (61.526, [0.0117, 0.023, 0.0041]), + (63.124, [0.0117, 0.028, 0.0041]), + (66.32, [0.0117, 0.0273, 0.0038]), + (67.119, [0.0117, 0.0318, 0.0037]), + (69.516, [0.0118, 0.0314, 0.0034]), + (70.315, [0.0161, 0.0309, 0.0034]), + (71.114, [0.0155, 0.0353, 0.0033]), + (72.712, [0.0151, 0.0352, 0.0032]), + (73.511, [0.0192, 0.035, 0.0032]), + (74.31, [0.0283, 0.0358, 0.0032]), + (75.908, [0.0416, 0.0434, 0.0033]), + (76.707, [0.0501, 0.0429, 0.0032]), + (77.507, [0.054, 0.0431, 0.004]), + (78.306, [0.0511, 0.043, 0.0004]), + (79.904, [0.0171, -0.0004, -0.0002]), + (80.703, [0.0122, 0.0001, -0.0002]), + (81.502, [0.0029, 0.0, -0.0001]), + (82.301, [-0.0006, 0.0, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0004, -0.0007, -0.0002]), + (142.25, [-0.0001, 0.0001, -0.0004]), + (143.05, [0.0008, 0.0001, 0.0035]), + (146.25, [0.0143, -0.0001, 0.0308]), + (147.05, [0.0166, 0.0038, 0.0356]), + (147.85, [0.0196, 0.0094, 0.0352]), + (150.24, [0.0195, 0.0282, 0.0254]), + (151.04, [0.0222, 0.0316, 0.0199]), + (151.84, [0.0255, 0.0315, 0.0137]), + (154.24, [0.0355, 0.023, 0.0042]), + (155.04, [0.0347, 0.0184, 0.0006]), + (155.84, [0.0289, 0.0131, 0.0]), + (159.03, [0.0022, 0.0002, 0.0]), + (159.83, [-0.0002, 0.0, 0.0]), + ], + }, +]; +const ZOOM_50_STREAKS: &[Streak] = &[]; +const ZOOM_50_RAYS: &[Ray] = &[ + Ray { angle: 0.19, r0: 6.25, r1: 235.94, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 7.25, r0: 6.25, r1: 129.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 16.4, r0: 20.312, r1: 298.44, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 43.18, r0: 7.0312, r1: 288.28, fade: 0.05, rgb: [0.0176, 0.0157, 0.0157] }, + Ray { angle: 62.98, r0: 6.25, r1: 198.44, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 64.75, r0: 70.312, r1: 266.41, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 70.16, r0: 14.062, r1: 144.53, fade: 0.05, rgb: [0.0167, 0.0196, 0.0196] }, + Ray { angle: 71.94, r0: 77.344, r1: 210.94, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.1, r0: 57.031, r1: 259.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 81.01, r0: 28.125, r1: 279.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 86.35, r0: 6.25, r1: 158.59, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 91.69, r0: 6.25, r1: 128.91, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 93.37, r0: 65.625, r1: 303.91, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 104.33, r0: 28.906, r1: 262.5, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 109.69, r0: 29.688, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.83, r0: 71.875, r1: 172.66, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 118.75, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 129.16, r0: 65.625, r1: 159.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 131.19, r0: 28.906, r1: 222.66, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 138.3, r0: 56.25, r1: 175.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 145.39, r0: 69.531, r1: 207.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 147.3, r0: 73.438, r1: 221.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 148.82, r0: 69.531, r1: 192.19, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 152.63, r0: 62.5, r1: 181.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 155.88, r0: 19.531, r1: 256.25, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 161.64, r0: 14.062, r1: 235.16, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 163.13, r0: 38.281, r1: 244.53, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 168.91, r0: 35.938, r1: 238.28, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 172.13, r0: 35.156, r1: 143.75, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 180.0, r0: 25.0, r1: 88.281, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 186.52, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 193.85, r0: 28.906, r1: 200.0, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 208.12, r0: 60.938, r1: 253.12, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 213.33, r0: 29.688, r1: 278.12, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 217.21, r0: 38.281, r1: 246.88, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 220.48, r0: 52.344, r1: 221.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 224.42, r0: 93.75, r1: 177.34, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 225.87, r0: 70.312, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0098, 0.0118] }, + Ray { angle: 229.2, r0: 64.844, r1: 160.16, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 234.41, r0: 14.062, r1: 167.97, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 240.32, r0: 37.5, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 241.65, r0: 84.375, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 243.7, r0: 62.5, r1: 178.91, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 245.58, r0: 63.281, r1: 207.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 250.9, r0: 44.531, r1: 206.25, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 252.81, r0: 67.188, r1: 148.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 254.57, r0: 64.062, r1: 153.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 259.88, r0: 6.25, r1: 186.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 267.07, r0: 62.5, r1: 137.5, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 268.95, r0: 51.562, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 283.24, r0: 67.188, r1: 175.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 284.76, r0: 74.219, r1: 200.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 288.62, r0: 46.094, r1: 204.69, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 291.97, r0: 24.219, r1: 99.219, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 295.68, r0: 27.344, r1: 216.41, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 299.14, r0: 64.844, r1: 223.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 302.89, r0: 6.25, r1: 232.03, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 317.07, r0: 42.188, r1: 129.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 319.1, r0: 75.781, r1: 197.66, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 320.58, r0: 39.844, r1: 241.41, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 322.59, r0: 6.25, r1: 287.5, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 340.62, r0: 6.25, r1: 230.47, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 345.72, r0: 25.781, r1: 192.19, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 352.82, r0: 6.25, r1: 120.31, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, +]; +const ZOOM_75_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.6341, 0.8079, 0.8109]), + (2.4, [0.6422, 0.7918, 0.7924]), + (4.0, [0.6328, 0.776, 0.7774]), + (9.6, [0.5947, 0.6729, 0.6755]), + (17.6, [0.4894, 0.4854, 0.4893]), + (19.2, [0.4614, 0.4415, 0.4464]), + (24.0, [0.3872, 0.3338, 0.3344]), + (25.6, [0.3658, 0.2968, 0.3052]), + (26.4, [0.3531, 0.2849, 0.2871]), + (27.2, [0.3388, 0.2605, 0.2707]), + (28.0, [0.331, 0.2534, 0.2583]), + (30.4, [0.306, 0.2117, 0.2137]), + (32.0, [0.2911, 0.1892, 0.1959]), + (36.0, [0.2638, 0.1552, 0.1488]), + (37.6, [0.2561, 0.1462, 0.133]), + (40.0, [0.2352, 0.1234, 0.1087]), + (41.6, [0.2221, 0.1167, 0.0974]), + (44.0, [0.2109, 0.0977, 0.0794]), + (45.6, [0.1968, 0.0873, 0.0705]), + (48.8, [0.1823, 0.0699, 0.0514]), + (53.6, [0.1564, 0.0553, 0.0407]), + (56.0, [0.1425, 0.0524, 0.0319]), + (59.2, [0.1297, 0.0469, 0.0286]), + (60.8, [0.1264, 0.0409, 0.0286]), + (61.6, [0.1239, 0.0408, 0.0289]), + (62.4, [0.124, 0.0405, 0.026]), + (63.2, [0.1323, 0.0411, 0.025]), + (64.8, [0.1558, 0.0451, 0.0282]), + (66.4, [0.1897, 0.0512, 0.0327]), + (67.2, [0.1961, 0.0516, 0.031]), + (68.0, [0.1957, 0.049, 0.0301]), + (68.8, [0.1709, 0.0442, 0.0303]), + (69.6, [0.1535, 0.0398, 0.0261]), + (70.4, [0.1275, 0.0351, 0.0239]), + (72.0, [0.0918, 0.0273, 0.0209]), + (74.4, [0.0805, 0.0251, 0.0185]), + (76.0, [0.0738, 0.0181, 0.0174]), + (78.4, [0.0639, 0.0161, 0.0116]), + (83.2, [0.0461, 0.0117, 0.0157]), + (84.0, [0.0418, 0.0091, 0.0125]), + (84.8, [0.0366, 0.0082, 0.0124]), + (88.0, [0.0278, 0.0073, 0.0159]), + (89.6, [0.024, 0.0026, 0.0169]), + (91.2, [0.0208, 0.0022, 0.0138]), + (92.8, [0.0156, -0.0026, 0.0099]), + (95.2, [0.016, -0.0037, 0.0104]), + (97.6, [0.0196, -0.004, 0.0116]), + (99.2, [0.0195, -0.0076, 0.013]), + (100.8, [0.0195, -0.0076, 0.0063]), + (101.6, [0.0224, -0.0106, 0.0072]), + (106.4, [0.0252, -0.0111, 0.007]), + (107.2, [0.0192, -0.0114, 0.0065]), + (108.0, [0.0184, -0.0116, 0.0064]), + (108.8, [0.0189, -0.0119, 0.0029]), + (109.6, [0.0154, -0.0118, 0.0013]), + (111.2, [0.0145, -0.0163, -0.0011]), + (112.8, [0.014, -0.0169, -0.0034]), + (113.6, [0.0103, -0.0173, -0.0045]), + (114.4, [0.0103, -0.0176, -0.0056]), + (116.0, [0.0058, -0.0182, -0.0077]), + (116.8, [0.0059, -0.0186, -0.0081]), + (118.4, [0.0052, -0.0191, -0.0135]), + (119.2, [0.0049, -0.0188, -0.0138]), + (120.8, [0.0042, -0.0229, -0.0153]), + (123.2, [0.0038, -0.0228, -0.0167]), + (124.0, [0.0004, -0.0229, -0.0171]), + (126.4, [-0.0008, -0.0228, -0.0173]), + (127.2, [-0.0011, -0.0228, -0.0206]), + (129.6, [-0.0017, -0.0215, -0.0203]), + (131.2, [-0.0022, -0.0246, -0.0193]), + (132.0, [-0.0019, -0.0241, -0.0188]), + (133.6, [-0.0061, -0.0233, -0.0176]), + (136.0, [-0.006, -0.0222, -0.0153]), + (137.6, [-0.0061, -0.0218, -0.0185]), + (140.8, [-0.0064, -0.022, -0.0193]), + (141.6, [-0.0056, -0.0255, -0.0195]), + (143.2, [-0.0094, -0.0254, -0.0201]), + (146.4, [-0.009, -0.0257, -0.0208]), + (148.0, [-0.009, -0.0259, -0.0254]), + (152.0, [-0.0085, -0.0265, -0.0275]), + (152.8, [-0.0113, -0.027, -0.0281]), + (153.6, [-0.0125, -0.0304, -0.0289]), + (157.6, [-0.0122, -0.0305, -0.0302]), + (159.2, [-0.0121, -0.0305, -0.0339]), + (163.2, [-0.0117, -0.0301, -0.0326]), + (164.0, [-0.0152, -0.0303, -0.0324]), + (165.6, [-0.0149, -0.0308, -0.0321]), + (166.4, [-0.0148, -0.0335, -0.0319]), + (170.4, [-0.0146, -0.0328, -0.0306]), + (171.2, [-0.0146, -0.0327, -0.0339]), + (175.2, [-0.0143, -0.0323, -0.0329]), + (176.0, [-0.0176, -0.0321, -0.0327]), + (179.2, [-0.0171, -0.0314, -0.032]), + (180.8, [-0.017, -0.0353, -0.0316]), + (184.0, [-0.0169, -0.0344, -0.0304]), + (184.8, [-0.0169, -0.0343, -0.0334]), + (188.0, [-0.0162, -0.034, -0.0328]), + (189.6, [-0.0202, -0.0337, -0.0324]), + (195.2, [-0.0198, -0.0323, -0.031]), + (196.8, [-0.0198, -0.0361, -0.0306]), + (199.2, [-0.0198, -0.0354, -0.0295]), + (200.8, [-0.0198, -0.0352, -0.0333]), + (203.2, [-0.0197, -0.0348, -0.0322]), + (204.0, [-0.0233, -0.0346, -0.0321]), + (214.4, [-0.0223, -0.0324, -0.03]), + (216.0, [-0.0223, -0.0363, -0.0297]), + (217.6, [-0.0222, -0.0355, -0.0289]), + (219.2, [-0.0219, -0.0352, -0.0327]), + (220.8, [-0.0214, -0.0349, -0.032]), + (221.6, [-0.0248, -0.0347, -0.0318]), + (239.2, [-0.0221, -0.0303, -0.0286]), + (240.0, [-0.022, -0.0334, -0.0284]), + (241.6, [-0.0218, -0.0335, -0.0275]), + (243.2, [-0.0215, -0.0332, -0.0312]), + (244.0, [-0.0213, -0.0331, -0.0305]), + (244.8, [-0.0249, -0.0328, -0.0304]), + (289.6, [-0.0165, -0.0207, -0.0157]), + (309.6, [-0.0118, -0.0141, -0.0049]), + (324.8, [-0.0088, -0.0105, -0.0058]), + (343.2, [-0.0053, -0.006, 0.0005]), + (399.2, [0.0033, 0.0039, 0.0088]), + (432.8, [0.0037, 0.007, 0.0107]), + (467.2, [0.0009, 0.0087, 0.0104]), + (472.8, [0.0006, 0.0072, 0.0077]), + (491.2, [-0.0085, -0.0055, -0.0041]), + (503.2, [-0.0135, -0.0112, -0.0064]), + (512.8, [-0.013, -0.0106, -0.0055]), + (543.2, [-0.0076, -0.0019, -0.0047]), + (556.0, [-0.0016, -0.0003, -0.0042]), + (566.4, [0.0065, 0.0027, -0.0043]), + (571.2, [0.0115, 0.0052, -0.0023]), + (584.8, [0.0285, 0.0179, 0.013]), + (588.0, [0.0299, 0.0192, 0.0131]), + (593.6, [0.0285, 0.0172, 0.0083]), + (596.8, [0.0315, 0.0172, 0.012]), + (598.4, [0.0348, 0.0197, 0.0138]), + (599.2, [0.0369, 0.0241, 0.0134]), + (600.8, [0.0396, 0.0332, 0.0075]), + (603.2, [0.043, 0.037, -0.005]), + (604.0, [0.0417, 0.0355, -0.009]), + (605.6, [0.0395, 0.0276, -0.0148]), + (607.2, [0.0323, 0.0188, -0.0173]), + (610.4, [0.004, -0.0009, -0.015]), + (612.0, [-0.0083, -0.0072, -0.014]), + (613.6, [-0.0138, -0.0104, -0.013]), + (615.2, [-0.0159, -0.0112, -0.0112]), + (617.6, [-0.0156, -0.0095, -0.0076]), + (620.8, [-0.0116, -0.0052, -0.0039]), + (624.0, [-0.0083, -0.0034, -0.0019]), + (628.8, [-0.0049, -0.005, -0.0043]), + (632.0, [0.0007, 0.0009, -0.0027]), + (633.6, [0.002, 0.0015, -0.0029]), + (636.0, [0.0021, 0.0008, -0.0026]), + (640.8, [-0.002, -0.0024, -0.0001]), + (645.6, [-0.0005, 0.0003, 0.005]), + (648.0, [-0.0017, -0.0013, 0.0043]), + (652.0, [-0.0005, 0.001, 0.0044]), + (655.2, [0.0, 0.0004, 0.0007]), + (658.4, [0.0006, 0.001, -0.0002]), + (660.8, [-0.0006, -0.0011, -0.0028]), + (666.4, [-0.0002, -0.0005, -0.0018]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.3342, 0.1947, 0.2028]), + (0.8, [0.3282, 0.1888, 0.1988]), + (1.6, [0.3285, 0.1951, 0.2053]), + (3.2, [0.3243, 0.1912, 0.2029]), + (8.8, [0.3139, 0.171, 0.1825]), + (21.6, [0.2699, 0.1024, 0.1142]), + (24.0, [0.2564, 0.0924, 0.1015]), + (27.2, [0.2402, 0.0788, 0.09]), + (28.0, [0.2368, 0.0791, 0.0881]), + (30.4, [0.2206, 0.0719, 0.0794]), + (32.8, [0.2021, 0.0633, 0.0743]), + (36.0, [0.1764, 0.0467, 0.0661]), + (39.2, [0.1593, 0.0315, 0.061]), + (44.8, [0.1308, 0.0301, 0.0575]), + (48.8, [0.1168, 0.0319, 0.059]), + (58.4, [0.1091, 0.0426, 0.0673]), + (60.8, [0.1016, 0.0436, 0.0689]), + (62.4, [0.0972, 0.0441, 0.0658]), + (64.0, [0.1005, 0.0458, 0.0633]), + (66.4, [0.1161, 0.0486, 0.0603]), + (67.2, [0.116, 0.0492, 0.0591]), + (69.6, [0.0995, 0.0464, 0.0569]), + (72.0, [0.0764, 0.0446, 0.0567]), + (76.0, [0.0767, 0.0459, 0.0585]), + (80.8, [0.0768, 0.0486, 0.0563]), + (87.2, [0.0742, 0.048, 0.0453]), + (94.4, [0.0667, 0.0505, 0.0418]), + (104.0, [0.0358, 0.05, 0.0367]), + (107.2, [0.0324, 0.0503, 0.0371]), + (109.6, [0.0319, 0.051, 0.0378]), + (116.8, [0.0333, 0.0534, 0.0479]), + (124.8, [0.0354, 0.0539, 0.0531]), + (130.4, [0.037, 0.0523, 0.052]), + (137.6, [0.0373, 0.0488, 0.0461]), + (148.0, [0.0362, 0.0488, 0.0484]), + (157.6, [0.0354, 0.0496, 0.0546]), + (200.8, [0.0311, 0.0424, 0.0406]), + (241.6, [0.0252, 0.033, 0.0326]), + (290.4, [0.016, 0.0199, 0.0157]), + (309.6, [0.0115, 0.0136, 0.0054]), + (324.8, [0.0085, 0.0101, 0.0064]), + (344.0, [0.0049, 0.0055, -0.0001]), + (380.8, [-0.0015, -0.0014, -0.0057]), + (408.0, [-0.0038, -0.005, -0.0085]), + (466.4, [-0.001, -0.0087, -0.0097]), + (472.8, [-0.0004, -0.0068, -0.0064]), + (490.4, [0.0083, 0.0054, 0.0048]), + (503.2, [0.0133, 0.0113, 0.0069]), + (514.4, [0.0125, 0.0097, 0.0061]), + (542.4, [0.0075, 0.0019, 0.0053]), + (555.2, [0.0015, 0.0003, 0.0048]), + (568.0, [-0.0088, -0.0038, 0.0043]), + (572.8, [-0.0144, -0.0069, 0.0011]), + (583.2, [-0.0274, -0.0172, -0.012]), + (587.2, [-0.0297, -0.0191, -0.0126]), + (593.6, [-0.0286, -0.0172, -0.0079]), + (596.0, [-0.0316, -0.0173, -0.0118]), + (597.6, [-0.035, -0.02, -0.0138]), + (598.4, [-0.0376, -0.0247, -0.0136]), + (600.0, [-0.0402, -0.0335, -0.0074]), + (602.4, [-0.0431, -0.0373, 0.0051]), + (604.0, [-0.0414, -0.0315, 0.0124]), + (605.6, [-0.0372, -0.0234, 0.0165]), + (608.0, [-0.0188, -0.0076, 0.0163]), + (610.4, [0.0036, 0.005, 0.0143]), + (612.0, [0.0119, 0.0093, 0.0135]), + (614.4, [0.016, 0.0112, 0.011]), + (619.2, [0.0124, 0.0058, 0.0045]), + (623.2, [0.0081, 0.0033, 0.0019]), + (628.0, [0.0047, 0.0049, 0.0044]), + (631.2, [-0.0009, -0.001, 0.0027]), + (632.8, [-0.002, -0.0015, 0.003]), + (635.2, [-0.002, -0.0007, 0.0025]), + (639.2, [0.002, 0.0024, 0.0007]), + (644.8, [0.0006, -0.0002, -0.005]), + (647.2, [0.0017, 0.0013, -0.0043]), + (651.2, [0.0006, -0.001, -0.0044]), + (654.4, [-0.0001, -0.0004, -0.0006]), + (656.8, [-0.0009, -0.0012, -0.0001]), + (660.0, [0.0006, 0.0011, 0.0029]), + (665.6, [0.0002, 0.0004, 0.0017]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0334, 0.0136, 0.0008]), + (0.79967, [0.0306, 0.011, -0.002]), + (1.5993, [0.036, 0.0154, 0.003]), + (3.1987, [0.033, 0.014, 0.0008]), + (6.3973, [0.032, 0.013, -0.0001]), + (116.75, [0.0321, 0.0125, 0.0001]), + (117.55, [0.0293, 0.0128, 0.0001]), + (118.35, [0.0273, 0.0092, 0.0001]), + (119.15, [0.0241, 0.0087, 0.0001]), + (271.89, [0.0242, 0.0085, -0.0002]), + (273.49, [0.0186, 0.0087, -0.0002]), + (274.29, [0.0159, 0.0048, -0.0002]), + (276.68, [0.0083, 0.0038, -0.0002]), + (277.48, [0.0053, 0.0002, -0.0002]), + (279.08, [0.0001, 0.0006, -0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [-0.0024, -0.0025, -0.0085]), + (0.79036, [0.0068, 0.0042, 0.0053]), + (1.5807, [0.0044, 0.0055, 0.0075]), + (2.3711, [0.0045, 0.0034, 0.004]), + (3.1615, [0.0037, 0.0023, 0.003]), + (4.7422, [0.0038, 0.0031, 0.0035]), + (5.5325, [0.0037, 0.0042, 0.0058]), + (6.3229, [0.0049, 0.0038, 0.0052]), + (8.694, [0.0037, 0.0022, 0.003]), + (9.4844, [0.0044, 0.0048, 0.0043]), + (11.855, [0.0031, 0.0073, 0.0028]), + (12.646, [0.0047, 0.0105, 0.0047]), + (14.227, [0.0037, 0.0141, 0.0076]), + (16.598, [0.0046, 0.0165, 0.0102]), + (21.34, [0.0082, 0.0301, 0.0173]), + (22.921, [0.0134, 0.036, 0.0203]), + (26.872, [0.0535, 0.057, 0.0265]), + (27.663, [0.0562, 0.0606, 0.0287]), + (28.453, [0.0535, 0.063, 0.0303]), + (29.243, [0.0452, 0.0644, 0.031]), + (30.034, [0.034, 0.0511, 0.026]), + (30.824, [0.0166, 0.0181, 0.011]), + (31.615, [0.0042, 0.0018, 0.0018]), + (32.405, [0.0007, 0.0001, 0.0007]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [-0.0022, 0.0283, 0.0756]), + (0.79858, [0.0031, 0.0347, 0.0823]), + (2.3957, [0.0049, 0.0361, 0.0866]), + (16.77, [0.0023, 0.034, 0.08]), + (21.562, [0.0031, 0.0244, 0.0607]), + (25.555, [0.0011, 0.0156, 0.0445]), + (27.95, [0.0006, 0.0086, 0.0311]), + (31.943, [0.0003, 0.008, 0.0277]), + (43.123, [-0.0003, 0.0075, 0.027]), + (44.721, [-0.0003, 0.0047, 0.0171]), + (46.318, [-0.0003, 0.003, 0.0113]), + (50.311, [-0.0003, 0.0002, 0.0016]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1167, 0.051, -0.0139]), + (0.79635, [0.1128, 0.0517, -0.0125]), + (1.5927, [0.1139, 0.0508, -0.0138]), + (3.9818, [0.1134, 0.0532, -0.0066]), + (7.1672, [0.1126, 0.0542, -0.0034]), + (11.149, [0.1265, 0.0583, -0.0021]), + (13.538, [0.1239, 0.059, -0.0018]), + (14.334, [0.1061, 0.0495, -0.0019]), + (15.131, [0.037, 0.016, -0.0017]), + (15.927, [-0.0002, -0.0006, -0.0018]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.1797, 0.446, 0.3393]), + (0.76482, [0.11, 0.2762, 0.2075]), + (1.5296, [0.0929, 0.2308, 0.1773]), + (2.2945, [0.0296, 0.0774, 0.0606]), + (3.0593, [0.0012, 0.0038, 0.0035]), + (3.8241, [-0.0001, -0.0012, -0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.2472, 0.558, 0.4641]), + (0.79661, [0.1777, 0.4198, 0.3538]), + (1.5932, [0.1563, 0.3731, 0.3178]), + (2.3898, [0.0885, 0.236, 0.212]), + (3.1864, [0.0588, 0.1568, 0.1473]), + (3.983, [0.0467, 0.1223, 0.1149]), + (4.7796, [0.0388, 0.0977, 0.0937]), + (6.3728, [0.017, 0.05, 0.0504]), + (7.1694, [0.0126, 0.0332, 0.035]), + (7.9661, [0.0062, 0.02, 0.0228]), + (8.7627, [0.0024, 0.0082, 0.0107]), + (9.5593, [0.0007, 0.0002, 0.0045]), + (15.135, [0.0002, 0.0003, 0.0018]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.0783, 0.0383, 0.2114]), + (6.3762, [0.0756, 0.0384, 0.1997]), + (9.5643, [0.0717, 0.0378, 0.1845]), + (11.158, [0.0753, 0.0416, 0.175]), + (11.955, [0.0709, 0.0398, 0.1697]), + (16.738, [0.054, 0.0344, 0.1318]), + (22.317, [0.0539, 0.0304, 0.0715]), + (23.911, [0.0517, 0.0348, 0.0531]), + (24.708, [0.0514, 0.0344, 0.0444]), + (25.505, [0.0548, 0.0349, 0.036]), + (27.099, [0.056, 0.0342, 0.0202]), + (29.49, [0.0598, 0.0375, -0.001]), + (31.881, [0.0642, 0.0363, -0.0173]), + (32.678, [0.0642, 0.0389, -0.0217]), + (34.272, [0.0679, 0.0381, -0.0282]), + (35.866, [0.0691, 0.037, -0.0321]), + (36.663, [0.071, 0.0394, -0.0329]), + (39.054, [0.0723, 0.0371, -0.0317]), + (39.851, [0.0718, 0.0389, -0.0302]), + (40.648, [0.0752, 0.0402, -0.0283]), + (41.445, [0.0744, 0.0397, -0.0261]), + (42.242, [0.0777, 0.0391, -0.0238]), + (43.836, [0.0787, 0.0425, -0.0184]), + (45.43, [0.0807, 0.0419, -0.0131]), + (46.227, [0.083, 0.0449, -0.0105]), + (47.822, [0.0852, 0.0437, -0.0056]), + (48.619, [0.0848, 0.0458, -0.0032]), + (49.416, [0.0885, 0.0477, -0.0012]), + (51.01, [0.0882, 0.0474, 0.0023]), + (51.807, [0.0884, 0.051, 0.0037]), + (53.401, [0.0939, 0.0518, 0.006]), + (54.198, [0.0862, 0.0471, 0.003]), + (54.995, [0.0291, 0.014, 0.003]), + (55.792, [-0.0016, -0.0002, 0.0032]), + (56.589, [0.0009, 0.001, 0.0033]), + (65.356, [0.0003, 0.0003, 0.0019]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0531, 0.032, -0.0688]), + (1.5918, [0.0526, 0.0329, -0.0689]), + (3.1836, [0.0584, 0.0363, -0.0677]), + (4.7754, [0.0534, 0.0332, -0.0662]), + (6.3672, [0.0531, 0.0327, -0.0642]), + (12.734, [0.0568, 0.0329, -0.0527]), + (19.897, [0.062, 0.0348, -0.0285]), + (25.469, [0.0613, 0.0353, -0.0052]), + (29.448, [0.0603, 0.0343, 0.0066]), + (30.244, [0.0434, 0.0244, 0.0084]), + (31.04, [0.0094, 0.0031, 0.0094]), + (31.836, [-0.0025, -0.0039, 0.0111]), + (34.224, [-0.0024, -0.0031, 0.0134]), + (37.407, [-0.0027, -0.0024, 0.0134]), + (46.162, [-0.0003, -0.0006, 0.0028]), + (47.754, [-0.0043, -0.0028, 0.001]), + (50.141, [-0.0009, -0.0009, -0.0001]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [0.0252, 0.0216, -0.1506]), + (0.79582, [0.0232, 0.0183, -0.1476]), + (7.1624, [0.0253, 0.0183, -0.1391]), + (10.346, [0.0273, 0.0184, -0.1284]), + (12.733, [0.0314, 0.0206, -0.1167]), + (13.529, [0.0329, 0.0192, -0.1132]), + (14.325, [0.0262, 0.0158, -0.1099]), + (15.121, [0.0028, 0.0019, -0.1057]), + (15.916, [-0.0176, -0.0129, -0.1014]), + (16.712, [-0.0192, -0.0144, -0.0964]), + (19.1, [-0.0154, -0.0134, -0.0819]), + (22.283, [-0.0097, -0.0108, -0.057]), + (26.262, [-0.0078, -0.0105, -0.0246]), + (29.445, [-0.0078, -0.0107, -0.0025]), + (31.833, [-0.0076, -0.0094, 0.0108]), + (35.016, [-0.0066, -0.0076, 0.0222]), + (37.403, [-0.0052, -0.0051, 0.0261]), + (40.587, [-0.0031, -0.0029, 0.0245]), + (49.341, [0.0012, -0.0004, 0.0041]), + (50.932, [0.0056, 0.0021, 0.0012]), + (53.32, [0.0031, 0.0008, -0.0024]), + (58.891, [-0.0021, -0.0014, -0.0033]), + (60.482, [0.0005, 0.0, -0.003]), + (66.053, [-0.0003, -0.0003, -0.002]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0317, 0.099, 0.2988]), + (0.7948, [0.0265, 0.098, 0.2906]), + (3.1792, [0.0251, 0.0943, 0.2775]), + (9.5376, [0.0225, 0.0853, 0.1705]), + (11.922, [0.0225, 0.0832, 0.135]), + (13.512, [0.0227, 0.0816, 0.1157]), + (15.896, [0.0252, 0.0813, 0.0927]), + (17.486, [0.0217, 0.0782, 0.0803]), + (21.46, [0.0215, 0.0773, 0.0641]), + (22.254, [0.0154, 0.0548, 0.0467]), + (23.049, [0.0039, 0.015, 0.0167]), + (23.844, [0.0008, 0.0013, 0.0038]), + (24.639, [-0.0002, -0.0007, 0.0026]), + (27.818, [-0.0004, -0.0005, 0.0018]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0038, 0.0038, 0.0039]), + (27.167, [0.0038, 0.0035, 0.0039]), + (27.966, [0.0038, 0.0082, 0.0039]), + (33.56, [0.0038, 0.0081, 0.0038]), + (34.359, [0.0038, 0.0123, 0.0038]), + (38.354, [0.0038, 0.0111, 0.0038]), + (39.153, [0.0038, 0.0153, 0.0038]), + (41.55, [0.0039, 0.0156, 0.0038]), + (42.349, [0.0083, 0.0157, 0.0038]), + (43.148, [0.0076, 0.0153, 0.0038]), + (43.947, [0.0077, 0.0199, 0.0037]), + (47.143, [0.0078, 0.019, 0.0039]), + (47.942, [0.0077, 0.0233, 0.0039]), + (51.138, [0.0089, 0.0232, 0.0042]), + (51.937, [0.0123, 0.0276, 0.0042]), + (54.334, [0.0117, 0.0269, 0.0042]), + (55.933, [0.0116, 0.0318, 0.0042]), + (58.33, [0.0109, 0.0324, 0.0042]), + (59.129, [0.0145, 0.036, 0.0042]), + (59.928, [0.0158, 0.0351, 0.0042]), + (61.526, [0.0156, 0.0361, 0.0042]), + (62.325, [0.0156, 0.0399, 0.0042]), + (63.923, [0.0155, 0.0387, 0.004]), + (65.521, [0.015, 0.0435, 0.0039]), + (66.32, [0.0192, 0.0431, 0.0037]), + (67.119, [0.0196, 0.0425, 0.0037]), + (67.918, [0.0195, 0.0468, 0.0035]), + (69.516, [0.0196, 0.0464, 0.0033]), + (71.114, [0.0196, 0.0512, 0.0031]), + (71.913, [0.0196, 0.0507, 0.0031]), + (72.712, [0.0229, 0.0506, 0.003]), + (73.511, [0.0296, 0.0546, 0.003]), + (75.109, [0.0511, 0.0584, 0.003]), + (76.707, [0.0735, 0.067, 0.0029]), + (77.507, [0.0773, 0.066, 0.0038]), + (78.306, [0.0752, 0.0666, 0.0001]), + (79.105, [0.048, 0.0281, -0.0006]), + (79.904, [0.0265, 0.0028, -0.0002]), + (80.703, [0.0178, 0.0009, -0.0002]), + (81.502, [0.0057, -0.0002, -0.0001]), + (82.301, [-0.0007, 0.0001, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [-0.0004, 0.0008, -0.0002]), + (142.25, [-0.0002, 0.0001, -0.0003]), + (143.05, [0.0019, 0.0001, 0.0062]), + (146.25, [0.0235, 0.0001, 0.0472]), + (147.05, [0.0288, 0.0069, 0.0547]), + (148.64, [0.0305, 0.0232, 0.0486]), + (150.24, [0.0305, 0.041, 0.0379]), + (151.04, [0.0343, 0.0471, 0.0307]), + (151.84, [0.0385, 0.0466, 0.0202]), + (154.24, [0.0537, 0.0351, 0.0059]), + (155.04, [0.0532, 0.0283, 0.0014]), + (155.84, [0.044, 0.0191, -0.0001]), + (159.03, [0.0038, 0.0008, 0.0]), + (159.83, [-0.0003, -0.0001, 0.0]), + ], + }, +]; +const ZOOM_75_STREAKS: &[Streak] = &[]; +const ZOOM_75_RAYS: &[Ray] = &[ + Ray { angle: 0.19, r0: 6.25, r1: 236.72, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 7.39, r0: 6.25, r1: 116.41, fade: 0.05, rgb: [0.0118, 0.0167, 0.0157] }, + Ray { angle: 16.39, r0: 15.625, r1: 368.75, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 35.96, r0: 24.219, r1: 232.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 43.1, r0: 9.375, r1: 260.16, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 55.85, r0: 46.094, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 62.98, r0: 8.5938, r1: 220.31, fade: 0.05, rgb: [0.0235, 0.0294, 0.0275] }, + Ray { angle: 64.75, r0: 57.031, r1: 321.88, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 70.21, r0: 14.062, r1: 139.84, fade: 0.05, rgb: [0.0196, 0.0196, 0.0216] }, + Ray { angle: 71.94, r0: 67.188, r1: 241.41, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 79.1, r0: 41.406, r1: 310.94, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 81.03, r0: 56.25, r1: 341.41, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 86.31, r0: 6.25, r1: 159.38, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 91.51, r0: 6.25, r1: 115.62, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 93.43, r0: 6.25, r1: 377.34, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 104.33, r0: 26.562, r1: 317.19, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 109.8, r0: 13.281, r1: 177.34, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 116.78, r0: 61.719, r1: 282.81, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 118.75, r0: 18.75, r1: 214.06, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 129.2, r0: 64.844, r1: 370.31, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 131.18, r0: 28.906, r1: 254.69, fade: 0.05, rgb: [0.0235, 0.0275, 0.0255] }, + Ray { angle: 136.35, r0: 68.75, r1: 194.53, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 138.23, r0: 55.469, r1: 282.81, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 145.46, r0: 66.406, r1: 284.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 147.33, r0: 36.719, r1: 256.25, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 148.78, r0: 68.75, r1: 214.06, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 152.55, r0: 53.125, r1: 264.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 156.0, r0: 21.094, r1: 256.25, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 161.68, r0: 14.062, r1: 246.88, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 163.2, r0: 17.188, r1: 244.53, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 168.8, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 172.33, r0: 36.719, r1: 136.72, fade: 0.05, rgb: [0.0216, 0.0255, 0.0235] }, + Ray { angle: 186.58, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 193.77, r0: 24.219, r1: 221.88, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 200.86, r0: 72.656, r1: 184.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 202.85, r0: 57.031, r1: 250.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 207.96, r0: 26.562, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 213.23, r0: 17.188, r1: 279.69, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 217.09, r0: 37.5, r1: 293.75, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 220.48, r0: 42.969, r1: 286.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 224.36, r0: 16.406, r1: 213.28, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 225.93, r0: 75.781, r1: 178.91, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 227.35, r0: 91.406, r1: 164.84, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 229.4, r0: 35.938, r1: 161.72, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 234.32, r0: 10.938, r1: 123.44, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 234.61, r0: 19.531, r1: 171.88, fade: 0.05, rgb: [0.0235, 0.0294, 0.0294] }, + Ray { angle: 240.32, r0: 38.281, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 241.64, r0: 74.219, r1: 217.19, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 243.57, r0: 62.5, r1: 190.62, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 245.54, r0: 57.812, r1: 214.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 250.77, r0: 27.344, r1: 206.25, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 252.65, r0: 63.281, r1: 146.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 254.51, r0: 60.938, r1: 154.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 259.94, r0: 26.562, r1: 197.66, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 263.3, r0: 64.062, r1: 193.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 265.12, r0: 75.0, r1: 192.19, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 266.85, r0: 6.25, r1: 128.12, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 269.01, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 276.16, r0: 6.25, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 283.26, r0: 28.906, r1: 188.28, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 284.82, r0: 71.094, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 288.66, r0: 43.75, r1: 205.47, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 295.72, r0: 26.562, r1: 216.41, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 299.12, r0: 39.844, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 302.95, r0: 6.25, r1: 232.03, fade: 0.05, rgb: [0.0235, 0.0294, 0.0275] }, + Ray { angle: 306.27, r0: 67.188, r1: 229.69, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 310.14, r0: 60.156, r1: 251.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 317.34, r0: 34.375, r1: 115.62, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 319.22, r0: 69.531, r1: 274.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 320.71, r0: 6.25, r1: 282.03, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 322.47, r0: 6.25, r1: 320.31, fade: 0.05, rgb: [0.0196, 0.0216, 0.0235] }, + Ray { angle: 340.66, r0: 6.25, r1: 267.97, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 345.8, r0: 25.781, r1: 210.94, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 352.82, r0: 6.25, r1: 102.34, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, +]; +const ZOOM_100_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.3108, 0.6442, 0.7116]), + (0.8, [0.3108, 0.6592, 0.7231]), + (3.2, [0.3153, 0.6598, 0.7214]), + (5.6, [0.3139, 0.6507, 0.714]), + (8.8, [0.3033, 0.6181, 0.6903]), + (11.2, [0.2893, 0.5927, 0.6604]), + (12.0, [0.2903, 0.5773, 0.6488]), + (15.2, [0.2701, 0.5311, 0.6032]), + (22.4, [0.2142, 0.4049, 0.4756]), + (27.2, [0.1942, 0.3371, 0.3951]), + (28.8, [0.1886, 0.3131, 0.3724]), + (31.2, [0.184, 0.2885, 0.3438]), + (33.6, [0.1924, 0.2802, 0.3216]), + (38.4, [0.203, 0.2495, 0.276]), + (46.4, [0.1963, 0.2034, 0.2076]), + (52.0, [0.1878, 0.1735, 0.1683]), + (55.2, [0.1842, 0.1612, 0.1478]), + (56.8, [0.1822, 0.1496, 0.1377]), + (60.0, [0.176, 0.1313, 0.1213]), + (62.4, [0.1751, 0.12, 0.1156]), + (63.2, [0.1832, 0.1216, 0.1158]), + (64.8, [0.2365, 0.1309, 0.1187]), + (65.6, [0.256, 0.1338, 0.1208]), + (66.4, [0.2877, 0.1409, 0.1221]), + (67.2, [0.3035, 0.142, 0.1224]), + (68.0, [0.3, 0.1403, 0.1214]), + (69.6, [0.2549, 0.1208, 0.1139]), + (72.0, [0.1833, 0.0994, 0.1043]), + (72.8, [0.1807, 0.0962, 0.1041]), + (73.6, [0.1833, 0.0901, 0.103]), + (75.2, [0.1836, 0.0857, 0.0989]), + (76.8, [0.1798, 0.0825, 0.0971]), + (79.2, [0.1723, 0.0709, 0.0883]), + (81.6, [0.1687, 0.065, 0.0878]), + (83.2, [0.1627, 0.0603, 0.0825]), + (85.6, [0.1606, 0.0528, 0.0841]), + (87.2, [0.1537, 0.0468, 0.0809]), + (89.6, [0.1608, 0.0403, 0.0763]), + (90.4, [0.1602, 0.0344, 0.073]), + (94.4, [0.1613, 0.0241, 0.061]), + (95.2, [0.1611, 0.0209, 0.0597]), + (98.4, [0.1585, 0.0168, 0.0564]), + (100.0, [0.1605, 0.0115, 0.0549]), + (102.4, [0.1583, 0.0101, 0.0524]), + (104.0, [0.1585, 0.0013, 0.0519]), + (105.6, [0.1564, 0.0005, 0.0525]), + (107.2, [0.1577, -0.0006, 0.0456]), + (108.8, [0.1573, -0.0058, 0.0446]), + (110.4, [0.1522, -0.0081, 0.0416]), + (111.2, [0.1483, -0.009, 0.0369]), + (112.8, [0.1431, -0.0157, 0.0336]), + (114.4, [0.1355, -0.0146, 0.0296]), + (115.2, [0.1342, -0.0141, 0.0235]), + (116.8, [0.1324, -0.0129, 0.0208]), + (117.6, [0.1276, -0.0152, 0.0196]), + (118.4, [0.129, -0.0142, 0.0191]), + (120.0, [0.1275, -0.0124, 0.0138]), + (120.8, [0.1283, -0.0114, 0.0136]), + (121.6, [0.1237, -0.0103, 0.013]), + (122.4, [0.1235, -0.0123, 0.0125]), + (123.2, [0.1224, -0.0112, 0.0127]), + (124.0, [0.1245, -0.0131, 0.0096]), + (125.6, [0.1205, -0.0105, 0.0055]), + (127.2, [0.12, -0.0104, 0.0059]), + (128.8, [0.1225, -0.006, 0.0033]), + (131.2, [0.1229, 0.001, 0.006]), + (132.0, [0.1242, 0.0, 0.0075]), + (133.6, [0.1237, 0.0046, 0.006]), + (136.0, [0.1228, 0.0117, 0.0095]), + (136.8, [0.1227, 0.0111, 0.0109]), + (138.4, [0.1233, 0.0134, 0.0073]), + (140.0, [0.1255, 0.016, 0.0069]), + (141.6, [0.1241, 0.0182, 0.007]), + (142.4, [0.1228, 0.0168, 0.0036]), + (146.4, [0.1201, 0.024, 0.0023]), + (147.2, [0.1168, 0.0221, -0.0012]), + (148.0, [0.1159, 0.0226, -0.0021]), + (151.2, [0.1152, 0.0269, -0.0035]), + (152.0, [0.1116, 0.0276, -0.0068]), + (152.8, [0.1112, 0.0251, -0.0089]), + (156.0, [0.1093, 0.0288, -0.0114]), + (157.6, [0.1064, 0.0307, -0.0156]), + (158.4, [0.106, 0.0286, -0.0151]), + (161.6, [0.1014, 0.0282, -0.0137]), + (162.4, [0.1017, 0.0284, -0.017]), + (164.0, [0.101, 0.0245, -0.0163]), + (166.4, [0.097, 0.0245, -0.0148]), + (168.0, [0.097, 0.0242, -0.0183]), + (168.8, [0.0966, 0.0238, -0.0175]), + (169.6, [0.0966, 0.0203, -0.0174]), + (172.0, [0.0926, 0.02, -0.0158]), + (172.8, [0.0923, 0.0199, -0.0186]), + (174.4, [0.0919, 0.0202, -0.0183]), + (176.0, [0.0886, 0.0161, -0.0177]), + (177.6, [0.0882, 0.0157, -0.0166]), + (178.4, [0.0883, 0.0157, -0.0197]), + (180.0, [0.0889, 0.0157, -0.0191]), + (181.6, [0.0852, 0.0113, -0.0184]), + (183.2, [0.0861, 0.0114, -0.0173]), + (184.0, [0.0864, 0.0112, -0.0203]), + (185.6, [0.0875, 0.0108, -0.0197]), + (186.4, [0.0844, 0.0114, -0.0194]), + (188.0, [0.0853, 0.0072, -0.0188]), + (188.8, [0.0857, 0.0075, -0.0176]), + (190.4, [0.0869, 0.0073, -0.0207]), + (192.8, [0.0857, 0.0077, -0.0191]), + (193.6, [0.0863, 0.0042, -0.0189]), + (195.2, [0.0876, 0.0039, -0.0185]), + (196.0, [0.0884, 0.0039, -0.0213]), + (199.2, [0.0886, 0.004, -0.0193]), + (200.8, [0.0903, 0.0001, -0.0181]), + (202.4, [0.0926, 0.0006, -0.0214]), + (203.2, [0.0902, 0.0007, -0.0206]), + (206.4, [0.094, 0.0011, -0.0195]), + (207.2, [0.0949, -0.0026, -0.0185]), + (208.0, [0.0966, -0.0022, -0.0214]), + (208.8, [0.0946, -0.0021, -0.0216]), + (213.6, [0.1012, -0.0018, -0.0188]), + (214.4, [0.1024, -0.0051, -0.0214]), + (220.0, [0.0992, -0.0033, -0.0196]), + (220.8, [0.0985, -0.0034, -0.0196]), + (221.6, [0.0947, -0.0066, -0.0226]), + (227.2, [0.0918, -0.0046, -0.0191]), + (228.0, [0.088, -0.0034, -0.0215]), + (228.8, [0.0878, -0.0061, -0.0225]), + (233.6, [0.0846, -0.0054, -0.0204]), + (234.4, [0.0806, -0.0051, -0.0195]), + (236.0, [0.0794, -0.004, -0.0229]), + (237.6, [0.078, -0.0075, -0.0217]), + (240.8, [0.076, -0.0063, -0.0202]), + (242.4, [0.0708, -0.0058, -0.0188]), + (243.2, [0.0704, -0.0057, -0.0218]), + (244.0, [0.0696, -0.0051, -0.0217]), + (245.6, [0.0685, -0.0084, -0.0206]), + (248.0, [0.067, -0.007, -0.0188]), + (250.4, [0.0614, -0.0062, -0.0165]), + (252.0, [0.0598, -0.0057, -0.0193]), + (252.8, [0.059, -0.0051, -0.0181]), + (254.4, [0.0575, -0.0085, -0.0169]), + (256.0, [0.0563, -0.0073, -0.0155]), + (256.8, [0.0518, -0.007, -0.0148]), + (259.2, [0.0496, -0.0059, -0.0125]), + (260.0, [0.0488, -0.0055, -0.0155]), + (263.2, [0.0459, -0.0038, -0.0128]), + (264.0, [0.0459, -0.0071, -0.0122]), + (265.6, [0.0405, -0.0059, -0.0111]), + (268.0, [0.0386, -0.0048, -0.0091]), + (269.6, [0.0372, -0.0041, -0.0122]), + (272.8, [0.0345, -0.0023, -0.0098]), + (274.4, [0.029, -0.0056, -0.0089]), + (277.6, [0.0267, -0.0037, -0.0065]), + (279.2, [0.0253, -0.0029, -0.0095]), + (282.4, [0.0232, -0.0013, -0.007]), + (283.2, [0.0189, -0.0012, -0.0065]), + (284.0, [0.0185, -0.0002, -0.0059]), + (284.8, [0.0179, -0.003, -0.0054]), + (285.6, [0.0174, -0.0033, -0.0048]), + (288.8, [0.0154, -0.002, -0.0027]), + (289.6, [0.0149, -0.0017, -0.0057]), + (292.0, [0.0137, -0.0007, -0.0034]), + (293.6, [0.0093, -0.0001, -0.0022]), + (296.0, [0.0092, 0.0016, -0.0001]), + (297.6, [0.0089, -0.0014, 0.0015]), + (300.0, [0.0082, 0.0005, 0.0047]), + (301.6, [0.0074, 0.0013, 0.003]), + (303.2, [0.0068, 0.0019, 0.0052]), + (304.8, [0.0023, 0.0023, 0.007]), + (309.6, [0.0008, 0.0048, 0.0108]), + (311.2, [0.0005, 0.0019, 0.0116]), + (313.6, [-0.0001, 0.0037, 0.0126]), + (314.4, [-0.0004, 0.0041, 0.0091]), + (316.8, [-0.0009, 0.0049, 0.0096]), + (317.6, [-0.005, 0.0051, 0.0095]), + (326.4, [-0.0086, 0.0073, 0.009]), + (327.2, [-0.0089, 0.0039, 0.0094]), + (328.8, [-0.0095, 0.0044, 0.0106]), + (329.6, [-0.0097, 0.0046, 0.0077]), + (332.0, [-0.0105, 0.0051, 0.0087]), + (332.8, [-0.014, 0.0054, 0.0091]), + (347.2, [-0.0159, 0.0094, 0.0147]), + (348.8, [-0.016, 0.006, 0.0148]), + (349.6, [-0.0161, 0.0064, 0.0155]), + (351.2, [-0.0157, 0.0067, 0.0121]), + (352.8, [-0.0195, 0.007, 0.0125]), + (390.4, [-0.0211, 0.0089, 0.0167]), + (433.6, [-0.0211, 0.0029, 0.0149]), + (465.6, [-0.0208, -0.0041, 0.0106]), + (473.6, [-0.0166, -0.0057, 0.0061]), + (487.2, [-0.0142, -0.0106, -0.0028]), + (500.0, [-0.0157, -0.0142, -0.0042]), + (518.4, [-0.0166, -0.0122, -0.0029]), + (538.4, [-0.0158, -0.0054, -0.0066]), + (548.0, [-0.0102, -0.0011, -0.0071]), + (557.6, [-0.0004, 0.0042, -0.0067]), + (571.2, [0.0203, 0.0158, -0.0042]), + (584.0, [0.0454, 0.029, 0.0088]), + (588.0, [0.0515, 0.03, 0.0094]), + (593.6, [0.0528, 0.0266, 0.0047]), + (597.6, [0.059, 0.026, 0.0124]), + (598.4, [0.0608, 0.028, 0.0126]), + (600.0, [0.066, 0.0377, 0.0122]), + (601.6, [0.0646, 0.0431, 0.0049]), + (603.2, [0.0616, 0.0456, -0.0071]), + (605.6, [0.057, 0.0386, -0.0182]), + (607.2, [0.045, 0.0259, -0.0212]), + (608.8, [0.0247, 0.0107, -0.0222]), + (611.2, [-0.0044, -0.0058, -0.0206]), + (612.8, [-0.0147, -0.0094, -0.0183]), + (615.2, [-0.02, -0.0103, -0.015]), + (618.4, [-0.0211, -0.0118, -0.0106]), + (623.2, [-0.017, -0.0086, -0.0022]), + (628.8, [-0.0098, -0.005, -0.006]), + (632.0, [-0.0004, 0.0001, -0.0045]), + (633.6, [0.0024, 0.0014, -0.0027]), + (636.0, [0.0015, 0.0, -0.0015]), + (639.2, [-0.0036, -0.0013, -0.0018]), + (640.8, [-0.0049, -0.0019, -0.0002]), + (644.0, [-0.0014, 0.0005, 0.0059]), + (645.6, [-0.0005, 0.0014, 0.0067]), + (648.8, [-0.0015, 0.0003, 0.0049]), + (652.8, [0.0012, 0.0016, 0.0052]), + (656.0, [0.0007, -0.0006, 0.0]), + (658.4, [0.0021, 0.0001, 0.0]), + (661.6, [-0.0004, -0.002, -0.0036]), + (668.0, [0.0, 0.0003, -0.0017]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.6563, 0.3322, 0.2884]), + (0.8, [0.6349, 0.3164, 0.2761]), + (4.8, [0.633, 0.3106, 0.2708]), + (11.2, [0.6249, 0.2917, 0.2462]), + (22.4, [0.5906, 0.2397, 0.1951]), + (29.6, [0.5311, 0.2033, 0.1714]), + (36.8, [0.4362, 0.1448, 0.1429]), + (44.8, [0.3513, 0.107, 0.1218]), + (52.8, [0.2864, 0.079, 0.1111]), + (57.6, [0.2514, 0.0769, 0.1099]), + (59.2, [0.2404, 0.0793, 0.1105]), + (62.4, [0.2236, 0.0793, 0.1057]), + (67.2, [0.2328, 0.0905, 0.0988]), + (72.0, [0.1618, 0.0854, 0.0946]), + (72.8, [0.1566, 0.0863, 0.0952]), + (76.0, [0.144, 0.0914, 0.097]), + (81.6, [0.1301, 0.1006, 0.0999]), + (88.0, [0.1123, 0.1097, 0.0931]), + (92.8, [0.0879, 0.1177, 0.0976]), + (101.6, [0.0538, 0.128, 0.0997]), + (107.2, [0.0359, 0.1309, 0.0958]), + (109.6, [0.0294, 0.1327, 0.0979]), + (112.0, [0.0309, 0.136, 0.1018]), + (116.8, [0.0338, 0.1328, 0.1087]), + (120.8, [0.0282, 0.1281, 0.1123]), + (127.2, [0.0143, 0.1156, 0.1126]), + (137.6, [-0.0061, 0.0855, 0.0989]), + (140.8, [-0.0128, 0.08, 0.0993]), + (148.0, [-0.0127, 0.0664, 0.0999]), + (158.4, [-0.0105, 0.053, 0.1056]), + (180.0, [-0.0085, 0.0553, 0.0938]), + (190.4, [-0.0145, 0.0552, 0.0871]), + (196.8, [-0.0209, 0.0546, 0.0833]), + (215.2, [-0.0462, 0.0512, 0.0727]), + (228.0, [-0.0412, 0.0471, 0.0659]), + (242.4, [-0.0316, 0.0408, 0.059]), + (272.8, [-0.0062, 0.0262, 0.0337]), + (292.0, [0.0061, 0.0162, 0.0196]), + (300.0, [0.0071, 0.0111, 0.0122]), + (305.6, [0.0091, 0.0091, 0.0045]), + (313.6, [0.0114, 0.004, -0.0005]), + (325.6, [0.0157, 0.001, -0.0001]), + (340.8, [0.0184, -0.0039, -0.0083]), + (350.4, [0.0195, -0.0065, -0.0114]), + (386.4, [0.0204, -0.009, -0.0154]), + (432.8, [0.0204, -0.0029, -0.0139]), + (464.8, [0.0201, 0.004, -0.0096]), + (472.8, [0.016, 0.0056, -0.0052]), + (486.4, [0.0136, 0.0104, 0.0037]), + (499.2, [0.0151, 0.014, 0.005]), + (517.6, [0.0161, 0.012, 0.0036]), + (537.6, [0.0153, 0.0053, 0.0073]), + (547.2, [0.0098, 0.001, 0.0078]), + (556.8, [0.0001, -0.0042, 0.0074]), + (570.4, [-0.0204, -0.0157, 0.0049]), + (584.0, [-0.0468, -0.0294, -0.0086]), + (587.2, [-0.0515, -0.03, -0.0088]), + (592.8, [-0.0529, -0.0266, -0.0042]), + (596.0, [-0.0575, -0.0254, -0.0109]), + (597.6, [-0.0613, -0.0285, -0.0126]), + (599.2, [-0.0666, -0.0381, -0.0124]), + (600.0, [-0.0659, -0.0412, -0.0096]), + (602.4, [-0.0617, -0.0461, 0.0073]), + (604.8, [-0.057, -0.0385, 0.0183]), + (606.4, [-0.0446, -0.0254, 0.0212]), + (608.0, [-0.0242, -0.0102, 0.0222]), + (609.6, [-0.0036, 0.0018, 0.0214]), + (611.2, [0.0115, 0.0085, 0.0193]), + (612.8, [0.0174, 0.0098, 0.017]), + (614.4, [0.02, 0.0103, 0.0149]), + (617.6, [0.021, 0.0118, 0.0104]), + (622.4, [0.0167, 0.0085, 0.0022]), + (628.0, [0.0095, 0.0049, 0.0061]), + (631.2, [0.0, -0.0003, 0.0044]), + (632.8, [-0.0025, -0.0013, 0.0027]), + (635.2, [-0.0014, 0.0001, 0.0015]), + (639.2, [0.0047, 0.0018, 0.0012]), + (644.0, [0.0006, -0.0012, -0.0067]), + (648.0, [0.0015, -0.0003, -0.0049]), + (652.0, [-0.0012, -0.0016, -0.0051]), + (655.2, [-0.0008, 0.0006, 0.0001]), + (657.6, [-0.0021, 0.0, 0.0001]), + (660.8, [0.0003, 0.002, 0.0037]), + (667.2, [0.0, -0.0004, 0.0017]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0468, 0.0233, 0.0022]), + (0.79967, [0.0463, 0.0201, -0.0023]), + (1.5993, [0.0521, 0.0262, 0.0031]), + (2.399, [0.0522, 0.0274, 0.0041]), + (3.1987, [0.0501, 0.0238, 0.0014]), + (5.5977, [0.0485, 0.0221, -0.0002]), + (10.396, [0.0519, 0.0249, 0.0015]), + (12.795, [0.0505, 0.0232, -0.0001]), + (43.182, [0.0485, 0.0227, 0.0003]), + (59.175, [0.0488, 0.0218, -0.0004]), + (75.169, [0.0504, 0.0243, 0.0016]), + (115.95, [0.0497, 0.0236, 0.0005]), + (116.75, [0.0487, 0.0238, 0.0005]), + (119.15, [0.0367, 0.0166, 0.0006]), + (119.95, [0.035, 0.0167, 0.0005]), + (271.09, [0.0346, 0.0161, -0.0001]), + (271.89, [0.0335, 0.0166, -0.0001]), + (273.49, [0.0263, 0.012, -0.0001]), + (274.29, [0.0238, 0.012, -0.0002]), + (275.08, [0.0196, 0.008, -0.0001]), + (275.88, [0.0164, 0.0086, -0.0002]), + (276.68, [0.0127, 0.0045, -0.0002]), + (277.48, [0.0085, 0.0045, -0.0002]), + (278.28, [0.005, 0.0006, -0.0002]), + (279.08, [0.0013, 0.0005, -0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0036, -0.0107, -0.0054]), + (0.79036, [0.0068, 0.0049, 0.0059]), + (3.1615, [0.0047, 0.0015, 0.0017]), + (5.5325, [0.0048, 0.004, 0.0044]), + (7.9036, [0.0051, 0.0029, 0.0024]), + (11.855, [0.0041, 0.0081, 0.0049]), + (12.646, [0.0058, 0.0121, 0.0062]), + (15.017, [0.0064, 0.0172, 0.007]), + (16.598, [0.0073, 0.0205, 0.01]), + (22.13, [0.0128, 0.0395, 0.022]), + (22.921, [0.0165, 0.0426, 0.0235]), + (23.711, [0.0235, 0.0474, 0.0242]), + (26.082, [0.0493, 0.0646, 0.03]), + (26.872, [0.0554, 0.0697, 0.0319]), + (27.663, [0.0582, 0.074, 0.0344]), + (28.453, [0.0553, 0.0758, 0.0367]), + (29.243, [0.0481, 0.0772, 0.0383]), + (30.034, [0.0375, 0.0625, 0.0309]), + (30.824, [0.0191, 0.0239, 0.0117]), + (31.615, [0.0056, 0.0024, 0.0011]), + (32.405, [0.0017, -0.0002, -0.0007]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [0.002, 0.0462, 0.0896]), + (0.79858, [0.003, 0.0481, 0.1048]), + (2.3957, [0.0053, 0.05, 0.1097]), + (16.77, [0.0023, 0.0461, 0.1009]), + (17.569, [0.0021, 0.0456, 0.0989]), + (19.965, [0.0015, 0.0368, 0.0824]), + (20.763, [0.0027, 0.0359, 0.0791]), + (25.555, [0.0002, 0.0231, 0.0568]), + (27.95, [-0.0002, 0.0141, 0.0388]), + (31.943, [-0.0002, 0.0127, 0.0346]), + (43.123, [-0.0008, 0.0126, 0.034]), + (44.721, [-0.0008, 0.0075, 0.0219]), + (46.318, [-0.0009, 0.0043, 0.0146]), + (51.109, [-0.0011, -0.0008, 0.0005]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1416, 0.0584, -0.0167]), + (0.79635, [0.1414, 0.0615, -0.0108]), + (1.5927, [0.1418, 0.0596, -0.0145]), + (2.3891, [0.1415, 0.0612, -0.0096]), + (3.9818, [0.1418, 0.0632, -0.004]), + (7.1672, [0.1412, 0.0647, -0.0001]), + (8.7599, [0.1465, 0.0669, 0.0005]), + (10.353, [0.1519, 0.0717, 0.0058]), + (11.945, [0.1573, 0.0734, 0.0058]), + (13.538, [0.1537, 0.0713, 0.0058]), + (14.334, [0.1326, 0.0608, 0.0022]), + (15.131, [0.0507, 0.0204, -0.0014]), + (15.927, [0.0021, 0.0006, -0.0018]), + (16.723, [0.0005, 0.0003, -0.0017]), + (19.909, [0.0002, 0.0019, 0.0015]), + (23.094, [0.0002, 0.0018, 0.0014]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.23, 0.5705, 0.4333]), + (0.76482, [0.1405, 0.3521, 0.2642]), + (1.5296, [0.119, 0.298, 0.2248]), + (2.2945, [0.0389, 0.1004, 0.0772]), + (3.0593, [0.0034, 0.0052, 0.0048]), + (3.8241, [-0.0002, -0.0017, -0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.2965, 0.6513, 0.5792]), + (0.79661, [0.2181, 0.4985, 0.4419]), + (1.5932, [0.1881, 0.4529, 0.4008]), + (2.3898, [0.1131, 0.2913, 0.2716]), + (3.1864, [0.0754, 0.1964, 0.1898]), + (3.983, [0.0609, 0.155, 0.1498]), + (6.3728, [0.0242, 0.0638, 0.0649]), + (7.1694, [0.016, 0.0422, 0.0468]), + (7.9661, [0.0082, 0.0235, 0.0324]), + (8.7627, [0.0038, 0.0124, 0.0153]), + (9.5593, [0.0011, 0.0001, 0.0068]), + (17.525, [0.001, 0.0009, 0.0018]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.19, 0.1329, 0.3261]), + (6.3762, [0.1813, 0.1305, 0.3087]), + (9.5643, [0.1706, 0.1269, 0.2857]), + (11.158, [0.1713, 0.13, 0.2712]), + (11.955, [0.1631, 0.1259, 0.2633]), + (14.346, [0.1437, 0.1188, 0.2366]), + (18.332, [0.1201, 0.1066, 0.1824]), + (19.926, [0.1173, 0.1071, 0.1565]), + (23.911, [0.0996, 0.0995, 0.0885]), + (26.302, [0.0971, 0.0978, 0.051]), + (28.693, [0.0948, 0.096, 0.0179]), + (30.287, [0.0929, 0.0917, -0.0009]), + (31.881, [0.0919, 0.091, -0.0167]), + (33.475, [0.0926, 0.0864, -0.0289]), + (35.069, [0.0943, 0.0849, -0.0375]), + (37.46, [0.0954, 0.0809, -0.0427]), + (39.054, [0.0946, 0.0749, -0.0416]), + (39.851, [0.0948, 0.0754, -0.04]), + (41.445, [0.0968, 0.0715, -0.0351]), + (47.822, [0.1101, 0.0677, -0.0085]), + (50.213, [0.1168, 0.0701, -0.0002]), + (53.401, [0.1288, 0.0743, 0.0068]), + (54.198, [0.1155, 0.0699, 0.0046]), + (54.995, [0.0433, 0.0241, 0.0041]), + (55.792, [-0.0023, 0.0001, 0.0044]), + (56.589, [0.0004, 0.0018, 0.0046]), + (68.544, [0.0005, 0.0007, 0.0019]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [0.0355, 0.0118, -0.1092]), + (0.7959, [0.0379, 0.0144, -0.1084]), + (1.5918, [0.0375, 0.0142, -0.1087]), + (3.1836, [0.0448, 0.0192, -0.1067]), + (4.7754, [0.0395, 0.0157, -0.1041]), + (6.3672, [0.0398, 0.0152, -0.1013]), + (10.347, [0.0457, 0.0166, -0.092]), + (13.53, [0.0511, 0.0185, -0.0806]), + (20.693, [0.0663, 0.0248, -0.0425]), + (25.469, [0.0709, 0.0285, -0.0125]), + (29.448, [0.0742, 0.0296, 0.0053]), + (30.244, [0.0508, 0.0184, 0.008]), + (31.04, [0.0062, -0.0104, 0.0102]), + (31.836, [-0.0087, -0.0179, 0.0123]), + (34.224, [-0.007, -0.0151, 0.0165]), + (37.407, [-0.0063, -0.012, 0.0176]), + (41.387, [-0.0048, -0.0084, 0.0128]), + (46.162, [-0.0017, -0.0034, 0.0038]), + (47.754, [-0.0059, -0.0049, 0.0015]), + (48.55, [-0.0035, -0.003, 0.001]), + (51.733, [-0.0017, -0.0011, -0.0005]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-0.0214, -0.0245, -0.2278]), + (3.1833, [-0.0213, -0.0238, -0.2263]), + (5.5707, [-0.0172, -0.0224, -0.2205]), + (7.1624, [-0.0168, -0.0235, -0.2149]), + (10.346, [-0.0093, -0.0202, -0.1985]), + (12.733, [0.0003, -0.0157, -0.1815]), + (13.529, [0.0034, -0.0164, -0.1759]), + (14.325, [-0.0042, -0.0227, -0.1708]), + (15.121, [-0.0323, -0.0431, -0.1645]), + (15.916, [-0.0572, -0.0609, -0.158]), + (16.712, [-0.0581, -0.0624, -0.1509]), + (19.895, [-0.0429, -0.0549, -0.1199]), + (22.283, [-0.0323, -0.0502, -0.0912]), + (26.262, [-0.0207, -0.0452, -0.0434]), + (28.649, [-0.0157, -0.0419, -0.0183]), + (31.833, [-0.0105, -0.0357, 0.0087]), + (35.016, [-0.0069, -0.0288, 0.0263]), + (37.403, [-0.005, -0.0226, 0.0327]), + (40.587, [-0.0013, -0.0149, 0.0316]), + (42.178, [0.0013, -0.0106, 0.0279]), + (46.157, [0.0023, -0.0039, 0.0146]), + (49.341, [0.0035, -0.0013, 0.0049]), + (50.932, [0.0089, 0.0027, 0.0012]), + (53.32, [0.0045, 0.0011, -0.0037]), + (58.891, [-0.0022, -0.0022, -0.0044]), + (60.482, [0.001, -0.0001, -0.0041]), + (70.032, [-0.0003, -0.0006, -0.0019]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0359, 0.1304, 0.3987]), + (0.7948, [0.034, 0.1334, 0.3815]), + (1.5896, [0.0331, 0.1332, 0.3843]), + (3.1792, [0.0317, 0.1304, 0.3675]), + (9.5376, [0.0294, 0.1132, 0.2281]), + (13.512, [0.0319, 0.1089, 0.1566]), + (15.896, [0.0347, 0.1086, 0.1262]), + (16.691, [0.0315, 0.1071, 0.1171]), + (19.075, [0.0316, 0.1053, 0.0989]), + (21.46, [0.0308, 0.1037, 0.0878]), + (22.254, [0.0209, 0.0723, 0.0629]), + (23.049, [0.0056, 0.0203, 0.0233]), + (23.844, [0.0005, 0.0019, 0.007]), + (25.434, [-0.0012, -0.0005, 0.0033]), + (30.202, [-0.0004, -0.0003, 0.0019]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0036, 0.0037, 0.0039]), + (23.172, [0.0036, 0.0032, 0.0039]), + (23.971, [0.0036, 0.0079, 0.0039]), + (27.966, [0.0036, 0.0074, 0.0039]), + (29.564, [0.0036, 0.0123, 0.0039]), + (32.76, [0.0036, 0.0112, 0.0038]), + (33.56, [0.0037, 0.0157, 0.0038]), + (34.359, [0.0033, 0.0156, 0.0038]), + (35.158, [0.0079, 0.0156, 0.0038]), + (36.756, [0.0076, 0.0152, 0.0038]), + (37.555, [0.0076, 0.0197, 0.0038]), + (39.952, [0.0076, 0.0192, 0.0038]), + (41.55, [0.0077, 0.024, 0.0037]), + (42.349, [0.0072, 0.0233, 0.0038]), + (43.148, [0.0117, 0.0233, 0.0037]), + (43.947, [0.0115, 0.024, 0.0038]), + (44.746, [0.0116, 0.028, 0.0038]), + (46.344, [0.0116, 0.0266, 0.0037]), + (47.942, [0.0117, 0.0317, 0.0039]), + (48.741, [0.0112, 0.0314, 0.004]), + (49.54, [0.0155, 0.0309, 0.0041]), + (50.339, [0.0155, 0.0352, 0.0042]), + (51.937, [0.0154, 0.0346, 0.0042]), + (53.535, [0.0157, 0.0398, 0.0043]), + (54.334, [0.015, 0.0386, 0.0043]), + (55.933, [0.0199, 0.0436, 0.0043]), + (56.732, [0.0192, 0.0427, 0.0043]), + (58.33, [0.0193, 0.0477, 0.0042]), + (59.129, [0.0195, 0.0464, 0.0042]), + (59.928, [0.0188, 0.0484, 0.0042]), + (60.727, [0.0234, 0.0517, 0.0043]), + (61.526, [0.0233, 0.0503, 0.0042]), + (63.124, [0.0233, 0.0556, 0.0042]), + (63.923, [0.0235, 0.0539, 0.0041]), + (64.722, [0.0227, 0.0582, 0.004]), + (65.521, [0.0268, 0.0588, 0.0039]), + (66.32, [0.0273, 0.0585, 0.0037]), + (67.119, [0.0272, 0.0632, 0.0036]), + (67.918, [0.0272, 0.062, 0.0034]), + (69.516, [0.0274, 0.067, 0.0031]), + (70.315, [0.0317, 0.0659, 0.0031]), + (71.114, [0.031, 0.0705, 0.003]), + (72.712, [0.0301, 0.0725, 0.0029]), + (73.511, [0.0401, 0.0751, 0.0029]), + (74.31, [0.0623, 0.0795, 0.0028]), + (75.109, [0.074, 0.0862, 0.0028]), + (75.908, [0.088, 0.0888, 0.0029]), + (76.707, [0.1016, 0.0967, 0.0028]), + (77.507, [0.109, 0.0978, 0.0036]), + (78.306, [0.1027, 0.0961, 0.0001]), + (79.105, [0.0683, 0.047, -0.0007]), + (79.904, [0.0375, 0.0061, -0.0002]), + (80.703, [0.0265, 0.0049, -0.0002]), + (81.502, [0.0086, 0.0004, -0.0001]), + (82.301, [-0.0012, 0.0, 0.0]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0006, 0.0031, -0.0003]), + (0.79916, [0.0004, -0.0007, -0.0004]), + (19.18, [0.0007, 0.0007, 0.0002]), + (142.25, [-0.0002, 0.0001, -0.0005]), + (143.05, [0.0038, 0.0001, 0.0091]), + (146.25, [0.0344, 0.0001, 0.0632]), + (147.05, [0.0432, 0.0092, 0.0746]), + (147.85, [0.0436, 0.0216, 0.0731]), + (150.24, [0.044, 0.0576, 0.0546]), + (151.04, [0.0513, 0.067, 0.045]), + (151.84, [0.0553, 0.0665, 0.0296]), + (154.24, [0.0737, 0.0509, 0.01]), + (155.04, [0.0721, 0.0405, 0.0027]), + (155.84, [0.0591, 0.0283, -0.0001]), + (159.03, [0.0053, 0.0019, 0.0]), + (159.83, [-0.0004, -0.0002, 0.0]), + ], + }, +]; +const ZOOM_100_STREAKS: &[Streak] = &[]; +const ZOOM_100_RAYS: &[Ray] = &[ + Ray { angle: 0.23, r0: 17.188, r1: 391.41, fade: 0.05, rgb: [0.0216, 0.0235, 0.0216] }, + Ray { angle: 7.19, r0: 6.25, r1: 107.03, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 15.13, r0: 6.25, r1: 93.75, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 16.46, r0: 85.938, r1: 439.06, fade: 0.05, rgb: [0.0255, 0.0275, 0.0275] }, + Ray { angle: 17.45, r0: 14.844, r1: 130.47, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 24.29, r0: 21.094, r1: 119.53, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 35.97, r0: 71.875, r1: 333.59, fade: 0.05, rgb: [0.0098, 0.0098, 0.0118] }, + Ray { angle: 38.66, r0: 24.219, r1: 89.062, fade: 0.05, rgb: [0.0118, 0.0245, 0.0235] }, + Ray { angle: 43.16, r0: 12.5, r1: 393.75, fade: 0.05, rgb: [0.0314, 0.0314, 0.0353] }, + Ray { angle: 53.39, r0: 13.281, r1: 120.31, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 55.79, r0: 46.875, r1: 152.34, fade: 0.05, rgb: [0.0098, 0.0118, 0.0098] }, + Ray { angle: 62.91, r0: 17.188, r1: 240.62, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 64.75, r0: 61.719, r1: 375.78, fade: 0.05, rgb: [0.0216, 0.0235, 0.0216] }, + Ray { angle: 70.1, r0: 14.062, r1: 132.03, fade: 0.05, rgb: [0.0176, 0.0265, 0.0255] }, + Ray { angle: 71.94, r0: 66.406, r1: 268.75, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 73.88, r0: 77.344, r1: 228.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.16, r0: 42.969, r1: 362.5, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 81.03, r0: 55.469, r1: 402.34, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 86.33, r0: 16.406, r1: 159.38, fade: 0.05, rgb: [0.0235, 0.0353, 0.0353] }, + Ray { angle: 91.74, r0: 6.25, r1: 100.78, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 93.37, r0: 6.25, r1: 404.69, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 104.35, r0: 26.562, r1: 370.31, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 109.69, r0: 91.406, r1: 182.81, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 110.89, r0: 14.062, r1: 137.5, fade: 0.05, rgb: [0.0157, 0.0255, 0.0275] }, + Ray { angle: 112.95, r0: 66.406, r1: 310.16, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.83, r0: 59.375, r1: 324.22, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 118.75, r0: 17.969, r1: 232.03, fade: 0.05, rgb: [0.0216, 0.0235, 0.0275] }, + Ray { angle: 129.16, r0: 64.844, r1: 357.03, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 131.16, r0: 29.688, r1: 285.94, fade: 0.05, rgb: [0.0255, 0.0275, 0.0294] }, + Ray { angle: 135.32, r0: 14.062, r1: 119.53, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 136.28, r0: 110.94, r1: 324.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 138.19, r0: 46.094, r1: 314.06, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 145.43, r0: 46.094, r1: 284.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 147.42, r0: 14.844, r1: 278.12, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 148.7, r0: 81.25, r1: 232.81, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 152.63, r0: 39.062, r1: 263.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 155.92, r0: 18.75, r1: 256.25, fade: 0.05, rgb: [0.0353, 0.0392, 0.0392] }, + Ray { angle: 161.66, r0: 14.844, r1: 246.88, fade: 0.05, rgb: [0.0333, 0.0392, 0.0392] }, + Ray { angle: 163.17, r0: 18.75, r1: 244.53, fade: 0.05, rgb: [0.0304, 0.0353, 0.0353] }, + Ray { angle: 163.42, r0: 74.219, r1: 175.78, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 168.82, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 172.26, r0: 35.938, r1: 128.91, fade: 0.05, rgb: [0.0186, 0.0255, 0.0255] }, + Ray { angle: 183.2, r0: 69.531, r1: 206.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 186.51, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 193.74, r0: 6.25, r1: 240.62, fade: 0.05, rgb: [0.0216, 0.0235, 0.0275] }, + Ray { angle: 201.0, r0: 72.656, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 202.78, r0: 70.312, r1: 250.0, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 208.02, r0: 11.719, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 213.31, r0: 24.219, r1: 279.69, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 217.14, r0: 38.281, r1: 293.75, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 218.24, r0: 21.875, r1: 136.72, fade: 0.05, rgb: [0.0069, 0.0098, 0.0108] }, + Ray { angle: 220.48, r0: 47.656, r1: 300.78, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 224.33, r0: 31.25, r1: 249.22, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 226.01, r0: 60.156, r1: 227.34, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 227.42, r0: 78.125, r1: 263.28, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 229.38, r0: 49.219, r1: 162.5, fade: 0.05, rgb: [0.0157, 0.0167, 0.0196] }, + Ray { angle: 232.04, r0: 47.656, r1: 133.59, fade: 0.05, rgb: [0.0196, 0.0255, 0.0255] }, + Ray { angle: 234.58, r0: 23.438, r1: 175.78, fade: 0.05, rgb: [0.0235, 0.0314, 0.0353] }, + Ray { angle: 240.27, r0: 12.5, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0176, 0.0157] }, + Ray { angle: 241.7, r0: 75.781, r1: 221.09, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 243.57, r0: 62.5, r1: 200.78, fade: 0.05, rgb: [0.0235, 0.0235, 0.0275] }, + Ray { angle: 245.61, r0: 56.25, r1: 214.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 250.58, r0: 27.344, r1: 152.34, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 250.85, r0: 28.125, r1: 206.25, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 252.54, r0: 63.281, r1: 142.97, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 254.64, r0: 60.938, r1: 154.69, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 256.16, r0: 81.25, r1: 193.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 259.94, r0: 28.906, r1: 197.66, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 263.21, r0: 86.719, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 265.17, r0: 85.938, r1: 193.75, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 266.91, r0: 47.656, r1: 117.19, fade: 0.05, rgb: [0.0118, 0.0137, 0.0157] }, + Ray { angle: 269.02, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 276.19, r0: 14.844, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 283.24, r0: 57.812, r1: 198.44, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 284.74, r0: 98.438, r1: 201.56, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 288.62, r0: 81.25, r1: 205.47, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 295.78, r0: 27.344, r1: 216.41, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 297.53, r0: 92.969, r1: 107.81, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 299.12, r0: 83.594, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 301.07, r0: 87.5, r1: 220.31, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 302.89, r0: 35.938, r1: 232.03, fade: 0.05, rgb: [0.0255, 0.0304, 0.0314] }, + Ray { angle: 306.25, r0: 96.094, r1: 219.53, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 310.14, r0: 50.0, r1: 255.47, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 317.22, r0: 51.562, r1: 106.25, fade: 0.05, rgb: [0.0118, 0.0196, 0.0196] }, + Ray { angle: 319.11, r0: 67.969, r1: 297.66, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 320.67, r0: 37.5, r1: 307.81, fade: 0.05, rgb: [0.0235, 0.0235, 0.0275] }, + Ray { angle: 322.54, r0: 9.375, r1: 321.09, fade: 0.05, rgb: [0.0196, 0.0235, 0.0255] }, + Ray { angle: 324.44, r0: 75.0, r1: 157.03, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 340.57, r0: 12.5, r1: 304.69, fade: 0.05, rgb: [0.0314, 0.0314, 0.0353] }, + Ray { angle: 345.9, r0: 23.438, r1: 228.91, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, +]; +const ZOOM_125_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.2682, 0.5198, 0.6493]), + (0.8, [0.2901, 0.5275, 0.6554]), + (1.6, [0.2799, 0.5261, 0.6544]), + (5.6, [0.2707, 0.5232, 0.6565]), + (10.4, [0.2554, 0.5004, 0.64]), + (15.2, [0.2351, 0.4638, 0.6029]), + (22.4, [0.2074, 0.3841, 0.5262]), + (29.6, [0.209, 0.3165, 0.4518]), + (32.0, [0.2173, 0.3142, 0.4382]), + (36.8, [0.2534, 0.3174, 0.4106]), + (44.0, [0.2785, 0.3014, 0.3718]), + (52.8, [0.2977, 0.2708, 0.3168]), + (58.4, [0.2959, 0.2482, 0.2857]), + (62.4, [0.3022, 0.2333, 0.2663]), + (63.2, [0.3137, 0.2348, 0.2634]), + (65.6, [0.3826, 0.2514, 0.2619]), + (66.4, [0.4138, 0.2552, 0.26]), + (67.2, [0.4269, 0.2566, 0.2585]), + (68.0, [0.4285, 0.255, 0.2544]), + (72.0, [0.3137, 0.2073, 0.2302]), + (75.2, [0.3129, 0.1977, 0.2115]), + (80.0, [0.3086, 0.1787, 0.1933]), + (86.4, [0.2963, 0.1488, 0.1876]), + (88.8, [0.2896, 0.1414, 0.1796]), + (91.2, [0.2912, 0.1289, 0.1736]), + (92.8, [0.2957, 0.1208, 0.1639]), + (96.0, [0.2955, 0.1117, 0.1594]), + (97.6, [0.2964, 0.1018, 0.1551]), + (100.8, [0.2908, 0.0934, 0.1515]), + (104.8, [0.2843, 0.0836, 0.1407]), + (107.2, [0.2796, 0.0831, 0.1366]), + (109.6, [0.2755, 0.0786, 0.1297]), + (111.2, [0.2793, 0.0754, 0.1256]), + (113.6, [0.2746, 0.0745, 0.1171]), + (114.4, [0.2718, 0.0752, 0.1095]), + (115.2, [0.2687, 0.0768, 0.1066]), + (116.8, [0.2658, 0.0729, 0.102]), + (122.4, [0.2523, 0.0759, 0.0848]), + (124.0, [0.2509, 0.0794, 0.0848]), + (125.6, [0.2477, 0.076, 0.081]), + (128.0, [0.2449, 0.0783, 0.0797]), + (130.4, [0.2346, 0.0847, 0.0768]), + (134.4, [0.2194, 0.0923, 0.0808]), + (137.6, [0.2194, 0.0932, 0.0813]), + (141.6, [0.2123, 0.0955, 0.0734]), + (145.6, [0.2077, 0.0944, 0.0698]), + (147.2, [0.2045, 0.0974, 0.0654]), + (148.8, [0.2023, 0.0919, 0.0656]), + (150.4, [0.1985, 0.0943, 0.0571]), + (154.4, [0.1872, 0.0914, 0.0503]), + (155.2, [0.1822, 0.092, 0.0463]), + (157.6, [0.1773, 0.0921, 0.045]), + (158.4, [0.177, 0.0921, 0.0419]), + (160.0, [0.1732, 0.0916, 0.0427]), + (161.6, [0.1707, 0.0887, 0.0395]), + (163.2, [0.165, 0.0882, 0.0407]), + (164.0, [0.165, 0.0853, 0.0378]), + (166.4, [0.1576, 0.0859, 0.0391]), + (167.2, [0.158, 0.0826, 0.0362]), + (168.8, [0.1534, 0.0828, 0.0375]), + (170.4, [0.1529, 0.0794, 0.0345]), + (171.2, [0.1493, 0.0797, 0.0353]), + (172.0, [0.1493, 0.0799, 0.0363]), + (173.6, [0.1484, 0.0766, 0.0336]), + (175.2, [0.15, 0.0771, 0.0352]), + (176.0, [0.1508, 0.0771, 0.0323]), + (176.8, [0.1483, 0.0736, 0.0324]), + (178.4, [0.1509, 0.0736, 0.0337]), + (179.2, [0.1496, 0.0741, 0.0309]), + (180.0, [0.1486, 0.0708, 0.0312]), + (181.6, [0.1488, 0.071, 0.0317]), + (182.4, [0.1454, 0.0716, 0.0294]), + (184.8, [0.1447, 0.0687, 0.0314]), + (185.6, [0.1421, 0.0693, 0.0286]), + (187.2, [0.143, 0.066, 0.0302]), + (188.8, [0.1392, 0.0671, 0.0278]), + (191.2, [0.1367, 0.0653, 0.0294]), + (192.0, [0.1371, 0.0657, 0.0271]), + (194.4, [0.1344, 0.0641, 0.0291]), + (195.2, [0.1348, 0.0644, 0.0265]), + (197.6, [0.1325, 0.0628, 0.0282]), + (198.4, [0.1333, 0.0641, 0.0258]), + (200.0, [0.1313, 0.0658, 0.0275]), + (200.8, [0.1312, 0.0632, 0.0282]), + (201.6, [0.1319, 0.0635, 0.0253]), + (203.2, [0.1301, 0.0656, 0.0267]), + (204.0, [0.1304, 0.0636, 0.0281]), + (204.8, [0.131, 0.0632, 0.0258]), + (207.2, [0.1298, 0.0661, 0.0271]), + (208.0, [0.1304, 0.0634, 0.0256]), + (210.4, [0.1293, 0.0668, 0.0264]), + (212.0, [0.131, 0.0647, 0.0239]), + (214.4, [0.1307, 0.0687, 0.026]), + (215.2, [0.1317, 0.0667, 0.0233]), + (216.0, [0.1291, 0.0668, 0.0247]), + (217.6, [0.1314, 0.0692, 0.0263]), + (218.4, [0.133, 0.0709, 0.0246]), + (219.2, [0.1309, 0.0686, 0.0235]), + (221.6, [0.1346, 0.0723, 0.0256]), + (222.4, [0.1324, 0.0727, 0.0229]), + (223.2, [0.1338, 0.0709, 0.0242]), + (224.8, [0.1368, 0.0736, 0.0258]), + (225.6, [0.1359, 0.0754, 0.0232]), + (227.2, [0.1373, 0.0741, 0.0242]), + (228.8, [0.1383, 0.0767, 0.0251]), + (229.6, [0.137, 0.0785, 0.0224]), + (232.0, [0.1405, 0.079, 0.0251]), + (233.6, [0.1407, 0.0819, 0.0222]), + (236.0, [0.1413, 0.0822, 0.0244]), + (236.8, [0.1422, 0.0831, 0.0214]), + (240.0, [0.1432, 0.0852, 0.024]), + (240.8, [0.1449, 0.0861, 0.0214]), + (243.2, [0.1446, 0.0864, 0.0245]), + (244.8, [0.1472, 0.0895, 0.0223]), + (246.4, [0.1489, 0.0925, 0.0246]), + (247.2, [0.147, 0.0902, 0.0261]), + (248.8, [0.1454, 0.0927, 0.0242]), + (251.2, [0.1395, 0.0945, 0.0283]), + (252.8, [0.1378, 0.0954, 0.0266]), + (255.2, [0.1314, 0.0985, 0.031]), + (256.0, [0.13, 0.0962, 0.0308]), + (256.8, [0.1297, 0.0978, 0.0293]), + (259.2, [0.1229, 0.1018, 0.0336]), + (260.0, [0.1216, 0.0996, 0.0339]), + (260.8, [0.1211, 0.1004, 0.0319]), + (261.6, [0.1166, 0.1018, 0.0336]), + (264.0, [0.1133, 0.1057, 0.037]), + (264.8, [0.1129, 0.1036, 0.0343]), + (265.6, [0.1081, 0.1033, 0.0349]), + (268.0, [0.1051, 0.1018, 0.0381]), + (268.8, [0.1043, 0.0989, 0.0357]), + (269.6, [0.0996, 0.0963, 0.0356]), + (272.0, [0.0962, 0.0941, 0.0383]), + (273.6, [0.0905, 0.0907, 0.0357]), + (276.8, [0.087, 0.0869, 0.0396]), + (277.6, [0.0824, 0.0868, 0.037]), + (279.2, [0.0806, 0.0817, 0.0382]), + (280.8, [0.0792, 0.0808, 0.0398]), + (281.6, [0.0755, 0.0802, 0.0405]), + (282.4, [0.0736, 0.0801, 0.0378]), + (284.8, [0.0718, 0.0754, 0.0407]), + (286.4, [0.0665, 0.0743, 0.0408]), + (288.8, [0.0648, 0.0695, 0.042]), + (289.6, [0.0645, 0.0698, 0.0429]), + (290.4, [0.0606, 0.0694, 0.0443]), + (292.0, [0.0599, 0.069, 0.0424]), + (294.4, [0.059, 0.066, 0.0462]), + (295.2, [0.0559, 0.0662, 0.0479]), + (296.8, [0.0562, 0.0665, 0.0466]), + (298.4, [0.0562, 0.0667, 0.0499]), + (299.2, [0.0523, 0.0635, 0.0513]), + (303.2, [0.0497, 0.064, 0.0575]), + (304.0, [0.0454, 0.0615, 0.0591]), + (304.8, [0.0453, 0.0597, 0.0609]), + (308.0, [0.0427, 0.0595, 0.0614]), + (308.8, [0.0389, 0.0601, 0.0621]), + (310.4, [0.0388, 0.0569, 0.0633]), + (312.8, [0.0381, 0.0584, 0.0609]), + (313.6, [0.0341, 0.0584, 0.061]), + (314.4, [0.034, 0.059, 0.061]), + (316.0, [0.0326, 0.0548, 0.0606]), + (316.8, [0.0318, 0.0547, 0.0568]), + (320.8, [0.0255, 0.052, 0.0536]), + (321.6, [0.0245, 0.0487, 0.0521]), + (322.4, [0.0241, 0.0489, 0.0485]), + (324.0, [0.0191, 0.0479, 0.048]), + (326.4, [0.0176, 0.0471, 0.0478]), + (327.2, [0.0169, 0.0437, 0.0484]), + (328.0, [0.0165, 0.0425, 0.0453]), + (328.8, [0.0125, 0.0422, 0.046]), + (332.8, [0.0103, 0.0405, 0.0492]), + (333.6, [0.0096, 0.0368, 0.0464]), + (334.4, [0.0061, 0.0364, 0.0469]), + (339.2, [0.0052, 0.035, 0.0494]), + (340.0, [0.0013, 0.0323, 0.0475]), + (344.8, [0.0006, 0.029, 0.0501]), + (346.4, [-0.0034, 0.028, 0.0466]), + (347.2, [-0.0032, 0.0243, 0.0471]), + (351.2, [-0.0033, 0.0231, 0.0471]), + (352.0, [-0.0068, 0.0228, 0.0471]), + (352.8, [-0.0069, 0.0225, 0.0439]), + (353.6, [-0.0069, 0.0225, 0.0444]), + (354.4, [-0.007, 0.0189, 0.0443]), + (356.8, [-0.007, 0.0184, 0.0444]), + (358.4, [-0.0105, 0.018, 0.045]), + (360.0, [-0.0102, 0.0175, 0.0415]), + (364.0, [-0.01, 0.0138, 0.0421]), + (364.8, [-0.013, 0.0137, 0.0422]), + (368.0, [-0.0123, 0.0131, 0.0396]), + (369.6, [-0.012, 0.0097, 0.0397]), + (371.2, [-0.0122, 0.0099, 0.0399]), + (372.0, [-0.0147, 0.0098, 0.04]), + (376.8, [-0.0131, 0.0097, 0.0375]), + (378.4, [-0.0124, 0.0063, 0.0377]), + (379.2, [-0.0154, 0.0065, 0.0378]), + (381.6, [-0.0145, 0.0065, 0.0381]), + (382.4, [-0.0142, 0.0065, 0.035]), + (385.6, [-0.013, 0.0064, 0.0355]), + (387.2, [-0.0156, 0.0033, 0.0356]), + (390.4, [-0.0142, 0.0036, 0.0362]), + (392.0, [-0.0137, 0.0036, 0.0329]), + (394.4, [-0.0127, 0.0035, 0.0332]), + (396.0, [-0.0155, 0.0038, 0.0332]), + (396.8, [-0.0149, 0.0008, 0.0332]), + (397.6, [-0.0147, 0.0005, 0.0332]), + (400.0, [-0.0139, 0.0005, 0.0334]), + (400.8, [-0.0137, 0.0005, 0.0303]), + (403.2, [-0.0125, 0.0004, 0.0298]), + (404.0, [-0.015, 0.0003, 0.0296]), + (407.2, [-0.0146, 0.0001, 0.029]), + (408.0, [-0.0144, -0.0033, 0.0289]), + (410.4, [-0.0137, -0.0031, 0.0283]), + (411.2, [-0.0135, -0.0032, 0.025]), + (413.6, [-0.0126, -0.0034, 0.025]), + (414.4, [-0.0158, -0.0035, 0.0248]), + (419.2, [-0.0144, -0.0036, 0.024]), + (420.0, [-0.0142, -0.0066, 0.0239]), + (422.4, [-0.0136, -0.0075, 0.0239]), + (424.0, [-0.0133, -0.0077, 0.0198]), + (425.6, [-0.0136, -0.0079, 0.0197]), + (426.4, [-0.0164, -0.008, 0.0196]), + (433.6, [-0.0144, -0.0089, 0.0178]), + (434.4, [-0.0143, -0.0094, 0.0176]), + (435.2, [-0.0142, -0.0131, 0.0173]), + (436.0, [-0.014, -0.0126, 0.0171]), + (436.8, [-0.0139, -0.0129, 0.0167]), + (437.6, [-0.0138, -0.013, 0.0129]), + (438.4, [-0.0137, -0.0131, 0.013]), + (439.2, [-0.014, -0.0132, 0.0127]), + (440.0, [-0.0172, -0.0133, 0.0124]), + (452.8, [-0.0145, -0.0142, 0.0087]), + (453.6, [-0.0144, -0.0145, 0.0085]), + (454.4, [-0.0143, -0.0181, 0.0085]), + (456.0, [-0.014, -0.0177, 0.0041]), + (456.8, [-0.0139, -0.0178, 0.0044]), + (457.6, [-0.0171, -0.0179, 0.004]), + (465.6, [-0.0165, -0.0187, 0.0015]), + (472.0, [-0.0155, -0.0199, -0.0036]), + (478.4, [-0.0191, -0.0235, -0.0093]), + (487.2, [-0.0234, -0.0238, -0.0149]), + (494.4, [-0.028, -0.023, -0.0158]), + (503.2, [-0.0323, -0.0206, -0.0138]), + (519.2, [-0.0298, -0.0131, -0.0055]), + (532.0, [-0.0215, -0.0067, -0.0011]), + (541.6, [-0.013, -0.0024, -0.0008]), + (552.8, [-0.0015, 0.0041, -0.0049]), + (560.0, [0.006, 0.0099, -0.0103]), + (565.6, [0.0132, 0.015, -0.0142]), + (569.6, [0.0198, 0.0186, -0.0146]), + (574.4, [0.0295, 0.0229, -0.0118]), + (582.4, [0.0501, 0.0296, 0.0]), + (587.2, [0.061, 0.0312, 0.0045]), + (593.6, [0.0644, 0.0258, 0.0025]), + (596.8, [0.0674, 0.0235, 0.0098]), + (598.4, [0.0717, 0.0257, 0.0133]), + (600.0, [0.0776, 0.0364, 0.0134]), + (601.6, [0.0768, 0.0441, 0.0076]), + (603.2, [0.0755, 0.0489, -0.0019]), + (604.8, [0.0753, 0.0459, -0.0105]), + (606.4, [0.0688, 0.0367, -0.0168]), + (608.0, [0.0514, 0.0229, -0.0215]), + (610.4, [0.0187, 0.004, -0.0259]), + (611.2, [0.0089, -0.0008, -0.0265]), + (612.0, [0.0016, -0.0036, -0.0266]), + (613.6, [-0.0069, -0.0066, -0.0255]), + (615.2, [-0.0106, -0.0075, -0.0219]), + (618.4, [-0.0089, -0.0064, -0.0094]), + (620.8, [-0.0089, -0.0053, -0.0047]), + (624.0, [-0.012, -0.005, -0.0036]), + (628.0, [-0.0146, -0.0054, -0.0088]), + (630.4, [-0.0127, -0.0036, -0.0109]), + (636.8, [0.002, 0.0016, 0.0]), + (638.4, [0.0017, 0.0009, 0.0005]), + (642.4, [-0.0052, -0.0027, 0.002]), + (645.6, [-0.0038, -0.0022, 0.0077]), + (648.0, [0.0016, -0.0013, 0.0107]), + (649.6, [0.0022, -0.001, 0.0092]), + (652.8, [-0.0027, -0.0007, 0.0051]), + (656.0, [0.0035, 0.0017, 0.0042]), + (659.2, [-0.0016, 0.0006, -0.0031]), + (662.4, [0.0016, 0.0004, -0.0031]), + (664.8, [-0.0026, -0.0014, -0.0055]), + (667.2, [0.0007, -0.0007, -0.0029]), + (669.6, [-0.0023, -0.0009, -0.0028]), + (671.2, [-0.0014, -0.0004, -0.0017]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.6284, 0.4495, 0.3403]), + (0.8, [0.6645, 0.447, 0.3431]), + (9.6, [0.6821, 0.4406, 0.3293]), + (21.6, [0.6861, 0.4055, 0.2954]), + (30.4, [0.6289, 0.3672, 0.2709]), + (37.6, [0.539, 0.2937, 0.2389]), + (47.2, [0.4453, 0.2355, 0.2112]), + (53.6, [0.3872, 0.2122, 0.2006]), + (62.4, [0.3261, 0.1874, 0.1902]), + (67.2, [0.3239, 0.1828, 0.1811]), + (72.0, [0.2497, 0.1616, 0.1764]), + (76.8, [0.2283, 0.1603, 0.1794]), + (82.4, [0.2046, 0.172, 0.1818]), + (88.8, [0.1939, 0.1879, 0.1789]), + (96.0, [0.156, 0.202, 0.1829]), + (105.6, [0.1299, 0.2129, 0.1814]), + (108.8, [0.1229, 0.2103, 0.1817]), + (112.0, [0.1125, 0.2075, 0.1885]), + (120.8, [0.1001, 0.1919, 0.2022]), + (128.8, [0.088, 0.1722, 0.1979]), + (135.2, [0.0903, 0.1543, 0.1852]), + (138.4, [0.0853, 0.1464, 0.1809]), + (147.2, [0.0684, 0.1324, 0.1809]), + (157.6, [0.071, 0.1176, 0.1856]), + (160.0, [0.0719, 0.118, 0.185]), + (173.6, [0.0723, 0.1132, 0.1734]), + (179.2, [0.0641, 0.1124, 0.1687]), + (189.6, [0.0621, 0.1071, 0.159]), + (199.2, [0.0563, 0.0985, 0.1492]), + (215.2, [0.0388, 0.0777, 0.1325]), + (246.4, [-0.0148, 0.0243, 0.1006]), + (264.8, [0.0051, -0.0057, 0.0718]), + (280.8, [0.023, 0.0057, 0.0547]), + (287.2, [0.0274, 0.0089, 0.0465]), + (295.2, [0.0292, 0.0081, 0.0355]), + (299.2, [0.029, 0.0066, 0.0273]), + (305.6, [0.0331, 0.0068, 0.0122]), + (311.2, [0.0349, 0.0048, 0.007]), + (315.2, [0.0364, 0.0032, 0.0067]), + (326.4, [0.044, 0.0081, 0.0119]), + (341.6, [0.0488, 0.0123, -0.0011]), + (356.8, [0.0493, 0.0164, -0.0041]), + (368.0, [0.0462, 0.0182, -0.0071]), + (400.8, [0.0319, 0.0146, -0.0135]), + (414.4, [0.026, 0.0147, -0.0119]), + (437.6, [0.0205, 0.0163, -0.0082]), + (463.2, [0.0156, 0.0179, -0.0014]), + (469.6, [0.0144, 0.0189, 0.0028]), + (480.8, [0.0197, 0.0233, 0.0126]), + (492.0, [0.0261, 0.0227, 0.0166]), + (504.0, [0.0315, 0.0194, 0.0135]), + (518.4, [0.0289, 0.0126, 0.0062]), + (531.2, [0.0207, 0.0064, 0.0019]), + (540.8, [0.0123, 0.0021, 0.0015]), + (552.0, [0.0009, -0.0043, 0.0057]), + (559.2, [-0.0064, -0.0101, 0.011]), + (568.8, [-0.0201, -0.0186, 0.0153]), + (573.6, [-0.0297, -0.0229, 0.0125]), + (581.6, [-0.0501, -0.0296, 0.0007]), + (586.4, [-0.0611, -0.0312, -0.0039]), + (592.8, [-0.0644, -0.0258, -0.002]), + (596.0, [-0.0676, -0.0236, -0.0096]), + (597.6, [-0.0724, -0.0262, -0.0132]), + (599.2, [-0.0782, -0.0371, -0.0137]), + (600.0, [-0.0776, -0.0411, -0.0114]), + (602.4, [-0.0757, -0.0494, 0.0021]), + (604.0, [-0.0758, -0.046, 0.0107]), + (604.8, [-0.0733, -0.0414, 0.0142]), + (606.4, [-0.061, -0.0301, 0.0196]), + (609.6, [-0.0179, -0.0036, 0.0261]), + (611.2, [-0.0009, 0.0039, 0.0266]), + (612.8, [0.0072, 0.0066, 0.0253]), + (614.4, [0.0105, 0.0074, 0.0215]), + (617.6, [0.0086, 0.0062, 0.009]), + (620.0, [0.0088, 0.0051, 0.0046]), + (623.2, [0.012, 0.005, 0.0037]), + (626.4, [0.0143, 0.0054, 0.0077]), + (629.6, [0.0124, 0.0034, 0.0109]), + (633.6, [0.0025, -0.0008, 0.0027]), + (636.0, [-0.0022, -0.0017, 0.0]), + (637.6, [-0.0016, -0.0009, -0.0004]), + (641.6, [0.0052, 0.0027, -0.0022]), + (644.8, [0.0036, 0.0021, -0.008]), + (647.2, [-0.0019, 0.0012, -0.0107]), + (648.8, [-0.0021, 0.001, -0.009]), + (651.2, [0.0026, 0.001, -0.0052]), + (652.0, [0.0025, 0.0006, -0.0052]), + (653.6, [-0.0012, -0.0008, -0.0058]), + (655.2, [-0.0035, -0.0018, -0.0039]), + (658.4, [0.0015, -0.0006, 0.0032]), + (660.8, [-0.0016, -0.0006, 0.0029]), + (664.0, [0.0026, 0.0014, 0.0056]), + (666.4, [-0.0008, 0.0006, 0.0029]), + (668.8, [0.0024, 0.0009, 0.0029]), + (670.4, [0.0012, 0.0004, 0.0016]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.0468, 0.0227, 0.0022]), + (0.79967, [0.0514, 0.0212, -0.0033]), + (1.5993, [0.0539, 0.0277, 0.0046]), + (4.798, [0.0521, 0.0244, 0.0003]), + (6.3973, [0.0525, 0.0255, 0.0015]), + (7.197, [0.0522, 0.0239, 0.0003]), + (10.396, [0.0557, 0.0275, 0.0029]), + (12.795, [0.0542, 0.0259, 0.0008]), + (27.988, [0.0522, 0.0246, 0.0005]), + (30.387, [0.0534, 0.0258, 0.0019]), + (59.175, [0.0518, 0.0234, -0.0001]), + (75.169, [0.0538, 0.0267, 0.0026]), + (114.35, [0.0533, 0.0257, 0.0015]), + (116.75, [0.0524, 0.0258, 0.0016]), + (119.15, [0.0414, 0.019, 0.0014]), + (119.95, [0.0394, 0.0191, 0.0015]), + (271.09, [0.0383, 0.0174, 0.0004]), + (271.89, [0.0361, 0.018, 0.0004]), + (276.68, [0.0136, 0.0082, 0.0002]), + (279.08, [0.0031, 0.0008, 0.0003]), + (279.88, [0.0012, 0.0013, 0.0002]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0033, -0.0132, -0.0096]), + (0.79036, [0.0027, 0.006, 0.0052]), + (2.3711, [0.0026, 0.0038, 0.0034]), + (5.5325, [0.0042, 0.0066, 0.0058]), + (7.9036, [0.0038, 0.005, 0.0037]), + (11.065, [0.0039, 0.0083, 0.0035]), + (11.855, [0.0036, 0.0098, 0.0043]), + (12.646, [0.0062, 0.0116, 0.0057]), + (15.807, [0.0056, 0.0172, 0.0079]), + (20.549, [0.0089, 0.032, 0.017]), + (22.921, [0.0131, 0.0402, 0.0213]), + (23.711, [0.018, 0.043, 0.0221]), + (26.082, [0.0362, 0.0586, 0.0266]), + (27.663, [0.0434, 0.066, 0.0301]), + (29.243, [0.0355, 0.0701, 0.0333]), + (30.034, [0.0277, 0.0575, 0.0269]), + (30.824, [0.0145, 0.0217, 0.0114]), + (31.615, [0.0049, 0.0025, 0.0018]), + (32.405, [0.0014, -0.0002, -0.0006]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [0.0006, 0.0416, 0.0902]), + (0.79858, [0.0028, 0.0479, 0.1026]), + (2.3957, [0.0036, 0.0481, 0.1051]), + (16.77, [0.0023, 0.0471, 0.098]), + (26.353, [0.0007, 0.0221, 0.0509]), + (27.95, [0.0005, 0.015, 0.0373]), + (31.145, [0.0009, 0.0146, 0.034]), + (35.936, [-0.0009, 0.0136, 0.0329]), + (43.123, [-0.0002, 0.0135, 0.033]), + (44.721, [-0.0003, 0.0087, 0.0215]), + (46.318, [-0.0002, 0.0053, 0.0146]), + (51.109, [-0.0003, -0.0002, 0.0007]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1487, 0.0636, -0.0151]), + (0.79635, [0.1384, 0.0644, -0.0109]), + (1.5927, [0.1415, 0.0646, -0.0141]), + (3.9818, [0.1422, 0.0685, -0.0035]), + (7.1672, [0.142, 0.0703, 0.0003]), + (8.7599, [0.1476, 0.0719, 0.0012]), + (10.353, [0.1539, 0.076, 0.0061]), + (11.945, [0.1581, 0.0769, 0.006]), + (13.538, [0.1536, 0.0761, 0.0054]), + (14.334, [0.1321, 0.0645, 0.0023]), + (15.131, [0.0522, 0.0248, -0.0006]), + (15.927, [0.0019, 0.0004, -0.0013]), + (17.52, [-0.0001, -0.0002, -0.0013]), + (19.112, [-0.0002, 0.0014, 0.0017]), + (22.298, [0.0003, 0.0023, 0.003]), + (31.058, [0.0008, 0.0016, 0.0005]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.2441, 0.6242, 0.4724]), + (0.76482, [0.1522, 0.3913, 0.2897]), + (1.5296, [0.127, 0.3249, 0.246]), + (2.2945, [0.0437, 0.1118, 0.0815]), + (3.0593, [0.0037, 0.0069, 0.0061]), + (3.8241, [-0.0007, -0.0023, -0.0011]), + (4.5889, [0.0002, -0.0004, 0.0002]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.2921, 0.671, 0.6166]), + (0.79661, [0.2265, 0.5314, 0.491]), + (1.5932, [0.1996, 0.4838, 0.4456]), + (2.3898, [0.1184, 0.3213, 0.307]), + (3.1864, [0.0804, 0.2213, 0.2194]), + (3.983, [0.0662, 0.1735, 0.17]), + (4.7796, [0.0513, 0.1407, 0.1418]), + (5.5762, [0.0413, 0.1066, 0.1116]), + (6.3728, [0.0249, 0.0711, 0.0776]), + (7.1694, [0.018, 0.048, 0.056]), + (7.9661, [0.0102, 0.0284, 0.0358]), + (8.7627, [0.0052, 0.0128, 0.0223]), + (9.5593, [0.002, 0.0014, 0.0105]), + (18.322, [0.0019, 0.001, 0.0023]), + (19.915, [-0.0013, -0.0005, 0.0016]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.4333, 0.3028, 0.5255]), + (3.1881, [0.4314, 0.3001, 0.5178]), + (6.3762, [0.4207, 0.2928, 0.4969]), + (11.158, [0.3968, 0.278, 0.4383]), + (11.955, [0.3846, 0.2711, 0.4258]), + (17.535, [0.3149, 0.228, 0.3184]), + (23.114, [0.2517, 0.193, 0.1768]), + (24.708, [0.2385, 0.1828, 0.137]), + (27.099, [0.2204, 0.1717, 0.0823]), + (28.693, [0.2076, 0.1639, 0.0495]), + (30.287, [0.1968, 0.1526, 0.0207]), + (31.881, [0.1857, 0.1447, -0.004]), + (33.475, [0.1775, 0.1358, -0.0234]), + (35.069, [0.1702, 0.1268, -0.0377]), + (37.46, [0.161, 0.1157, -0.0486]), + (39.851, [0.1513, 0.1054, -0.0481]), + (42.242, [0.1462, 0.1003, -0.0398]), + (47.025, [0.141, 0.0907, -0.0151]), + (50.213, [0.1454, 0.089, -0.0006]), + (51.807, [0.1453, 0.0897, 0.0045]), + (53.401, [0.1511, 0.0919, 0.0086]), + (54.198, [0.1422, 0.0859, 0.0073]), + (54.995, [0.0584, 0.0368, 0.0065]), + (55.792, [0.002, 0.0083, 0.0069]), + (56.589, [0.005, 0.0084, 0.0072]), + (62.168, [0.0033, 0.0045, 0.0048]), + (81.297, [0.0009, 0.0018, 0.0011]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [-0.0408, -0.0358, -0.1754]), + (0.7959, [-0.0348, -0.0337, -0.1719]), + (1.5918, [-0.0355, -0.0336, -0.1721]), + (3.1836, [-0.0263, -0.0276, -0.1694]), + (4.7754, [-0.0321, -0.0306, -0.1659]), + (6.3672, [-0.0298, -0.0304, -0.1611]), + (8.7549, [-0.0241, -0.0278, -0.153]), + (12.734, [-0.0093, -0.0197, -0.1333]), + (16.714, [0.0085, -0.0097, -0.1044]), + (21.489, [0.029, 0.0025, -0.0622]), + (25.469, [0.0409, 0.0109, -0.0253]), + (29.448, [0.0528, 0.017, 0.0018]), + (30.244, [0.029, 0.0037, 0.006]), + (31.04, [-0.0217, -0.0245, 0.0093]), + (31.836, [-0.0355, -0.0324, 0.0129]), + (35.019, [-0.0279, -0.0248, 0.0213]), + (37.407, [-0.0229, -0.0197, 0.0228]), + (39.795, [-0.0193, -0.0166, 0.0207]), + (45.366, [-0.0083, -0.008, 0.0077]), + (46.162, [-0.0074, -0.0072, 0.0061]), + (47.754, [-0.0101, -0.0079, 0.0029]), + (48.55, [-0.0062, -0.0058, 0.0019]), + (51.733, [-0.003, -0.0027, -0.0004]), + (52.529, [-0.0006, -0.0006, 0.0001]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-0.1721, -0.142, -0.3734]), + (0.79582, [-0.1751, -0.1397, -0.3732]), + (7.1624, [-0.1657, -0.1332, -0.3506]), + (10.346, [-0.154, -0.124, -0.3246]), + (13.529, [-0.132, -0.1112, -0.2895]), + (14.325, [-0.1379, -0.1167, -0.2814]), + (15.121, [-0.168, -0.1351, -0.2712]), + (15.916, [-0.1922, -0.1507, -0.2607]), + (16.712, [-0.1889, -0.1496, -0.2494]), + (19.895, [-0.1597, -0.1297, -0.2004]), + (23.875, [-0.1239, -0.1091, -0.1277]), + (27.854, [-0.0957, -0.0925, -0.0588]), + (29.445, [-0.0863, -0.0853, -0.0356]), + (31.833, [-0.0726, -0.0734, -0.0057]), + (33.424, [-0.0635, -0.0656, 0.0104]), + (35.016, [-0.0552, -0.058, 0.0226]), + (36.608, [-0.047, -0.0505, 0.0312]), + (38.995, [-0.0358, -0.0396, 0.0365]), + (41.383, [-0.0257, -0.0312, 0.0338]), + (42.178, [-0.0217, -0.0285, 0.0317]), + (45.362, [-0.0131, -0.0211, 0.0196]), + (49.341, [-0.0055, -0.0134, 0.0039]), + (50.932, [0.0025, -0.0071, -0.0009]), + (53.32, [-0.0019, -0.0077, -0.0065]), + (58.095, [-0.007, -0.0093, -0.0082]), + (58.891, [-0.0071, -0.0085, -0.0076]), + (60.482, [-0.0022, -0.0052, -0.0068]), + (78.786, [-0.0007, -0.0019, -0.0009]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0479, 0.1573, 0.4578]), + (0.7948, [0.0448, 0.1508, 0.4521]), + (3.1792, [0.0417, 0.1482, 0.4324]), + (9.5376, [0.0385, 0.1288, 0.2707]), + (13.512, [0.0419, 0.1258, 0.1876]), + (15.101, [0.044, 0.1257, 0.1612]), + (17.486, [0.0391, 0.1239, 0.1325]), + (18.28, [0.0398, 0.1238, 0.1243]), + (21.46, [0.0379, 0.1199, 0.1034]), + (22.254, [0.0274, 0.0862, 0.0768]), + (23.049, [0.0103, 0.0275, 0.0317]), + (23.844, [0.0041, 0.0042, 0.0102]), + (24.639, [0.0022, 0.0005, 0.0068]), + (25.434, [0.0013, 0.0, 0.0057]), + (36.561, [0.001, 0.0005, 0.0017]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0037, 0.0041, 0.0042]), + (20.775, [0.0037, 0.0035, 0.0041]), + (21.574, [0.0038, 0.0078, 0.004]), + (25.569, [0.0037, 0.0076, 0.0041]), + (26.368, [0.0037, 0.0115, 0.0041]), + (29.564, [0.0037, 0.0114, 0.004]), + (30.363, [0.0039, 0.0154, 0.004]), + (31.162, [0.0032, 0.0158, 0.004]), + (31.961, [0.0067, 0.0158, 0.004]), + (33.56, [0.0076, 0.0164, 0.004]), + (34.359, [0.0076, 0.0201, 0.004]), + (35.957, [0.0076, 0.0193, 0.0039]), + (37.555, [0.0077, 0.024, 0.0039]), + (38.354, [0.0073, 0.0237, 0.0039]), + (39.153, [0.011, 0.0233, 0.0038]), + (39.952, [0.0116, 0.0274, 0.0039]), + (42.349, [0.0116, 0.0279, 0.0038]), + (43.148, [0.0116, 0.0318, 0.0038]), + (44.746, [0.0115, 0.0316, 0.0039]), + (45.545, [0.0156, 0.0354, 0.0038]), + (47.143, [0.0156, 0.0351, 0.004]), + (47.942, [0.0156, 0.039, 0.004]), + (49.54, [0.0153, 0.0397, 0.0043]), + (50.339, [0.0168, 0.044, 0.0044]), + (51.138, [0.02, 0.043, 0.0045]), + (52.736, [0.0194, 0.0478, 0.0046]), + (53.535, [0.0197, 0.0466, 0.0046]), + (54.334, [0.0192, 0.0502, 0.0044]), + (55.933, [0.0238, 0.051, 0.0045]), + (56.732, [0.0234, 0.0553, 0.0045]), + (57.531, [0.0235, 0.0539, 0.0045]), + (59.129, [0.0231, 0.0585, 0.0044]), + (59.928, [0.0272, 0.0588, 0.0044]), + (60.727, [0.0275, 0.0629, 0.0044]), + (61.526, [0.0274, 0.0616, 0.0044]), + (62.325, [0.0275, 0.066, 0.0043]), + (63.124, [0.0269, 0.0655, 0.0044]), + (64.722, [0.0315, 0.0697, 0.0042]), + (67.119, [0.031, 0.075, 0.0036]), + (67.918, [0.0349, 0.0779, 0.0034]), + (68.717, [0.0353, 0.0777, 0.0033]), + (71.114, [0.0355, 0.0853, 0.0029]), + (71.913, [0.0395, 0.0857, 0.0029]), + (72.712, [0.0375, 0.0891, 0.0029]), + (73.511, [0.052, 0.09, 0.0029]), + (74.31, [0.0696, 0.0986, 0.003]), + (75.109, [0.0886, 0.1021, 0.003]), + (75.908, [0.1032, 0.1097, 0.0031]), + (76.707, [0.124, 0.1146, 0.0029]), + (77.507, [0.1298, 0.1155, 0.0036]), + (78.306, [0.124, 0.1156, 0.0005]), + (79.105, [0.0819, 0.0595, -0.0004]), + (79.904, [0.0492, 0.0079, -0.0001]), + (80.703, [0.0308, 0.0057, 0.0]), + (81.502, [0.0133, 0.0007, 0.0001]), + (82.301, [-0.0012, 0.0002, 0.0001]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0003, -0.0004, -0.0006]), + (1.5983, [0.0001, 0.001, -0.0007]), + (142.25, [0.0003, 0.0002, -0.0006]), + (143.05, [0.0068, 0.0003, 0.0141]), + (146.25, [0.0405, 0.0003, 0.079]), + (147.05, [0.0506, 0.0126, 0.0915]), + (147.85, [0.0516, 0.029, 0.0895]), + (148.64, [0.0512, 0.0411, 0.0821]), + (150.24, [0.0519, 0.0726, 0.0674]), + (151.04, [0.0651, 0.0823, 0.0535]), + (151.84, [0.0685, 0.0809, 0.0341]), + (153.44, [0.0845, 0.0663, 0.0189]), + (154.24, [0.0905, 0.0622, 0.0109]), + (155.04, [0.0878, 0.0475, 0.0024]), + (155.84, [0.0725, 0.0329, 0.0]), + (158.23, [0.0252, 0.0094, 0.0001]), + (159.03, [0.0056, 0.0015, 0.0]), + (159.83, [-0.0003, 0.0001, 0.0]), + ], + }, +]; +const ZOOM_125_STREAKS: &[Streak] = &[]; +const ZOOM_125_RAYS: &[Ray] = &[ + Ray { angle: 0.2, r0: 20.312, r1: 449.22, fade: 0.05, rgb: [0.0255, 0.0275, 0.0255] }, + Ray { angle: 8.06, r0: 14.062, r1: 128.91, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 15.36, r0: 17.188, r1: 158.59, fade: 0.05, rgb: [0.0147, 0.0235, 0.0235] }, + Ray { angle: 16.43, r0: 128.91, r1: 509.38, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 17.21, r0: 17.188, r1: 204.69, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 24.39, r0: 24.219, r1: 189.84, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 29.69, r0: 19.531, r1: 86.719, fade: 0.05, rgb: [0.0078, 0.0196, 0.0196] }, + Ray { angle: 34.95, r0: 32.812, r1: 141.41, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 35.96, r0: 110.94, r1: 333.59, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 38.74, r0: 32.812, r1: 150.78, fade: 0.05, rgb: [0.0176, 0.0275, 0.0275] }, + Ray { angle: 43.16, r0: 20.312, r1: 445.31, fade: 0.05, rgb: [0.0235, 0.0314, 0.0275] }, + Ray { angle: 53.33, r0: 17.188, r1: 189.84, fade: 0.05, rgb: [0.0157, 0.0255, 0.0245] }, + Ray { angle: 55.85, r0: 54.688, r1: 152.34, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 59.55, r0: 141.41, r1: 325.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 62.98, r0: 18.75, r1: 263.28, fade: 0.05, rgb: [0.0275, 0.0353, 0.0353] }, + Ray { angle: 64.81, r0: 63.281, r1: 431.25, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 70.17, r0: 18.75, r1: 125.78, fade: 0.05, rgb: [0.0118, 0.0206, 0.0206] }, + Ray { angle: 71.99, r0: 66.406, r1: 297.66, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 73.89, r0: 139.06, r1: 246.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.05, r0: 39.062, r1: 411.72, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 80.95, r0: 60.938, r1: 409.38, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 86.33, r0: 20.312, r1: 160.16, fade: 0.05, rgb: [0.0196, 0.0333, 0.0314] }, + Ray { angle: 93.42, r0: 10.938, r1: 404.69, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 104.36, r0: 28.125, r1: 417.19, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 109.71, r0: 119.53, r1: 189.84, fade: 0.05, rgb: [0.0137, 0.0176, 0.0157] }, + Ray { angle: 110.66, r0: 15.625, r1: 212.5, fade: 0.05, rgb: [0.0196, 0.0275, 0.0275] }, + Ray { angle: 110.92, r0: 15.625, r1: 178.12, fade: 0.05, rgb: [0.0059, 0.0078, 0.0098] }, + Ray { angle: 112.93, r0: 61.719, r1: 439.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.83, r0: 55.469, r1: 367.19, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 118.66, r0: 19.531, r1: 249.22, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 129.19, r0: 66.406, r1: 369.53, fade: 0.05, rgb: [0.0118, 0.0137, 0.0118] }, + Ray { angle: 131.14, r0: 28.906, r1: 317.97, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 135.54, r0: 25.0, r1: 144.53, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 136.26, r0: 160.94, r1: 324.22, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 138.23, r0: 55.469, r1: 314.06, fade: 0.05, rgb: [0.0088, 0.0118, 0.0118] }, + Ray { angle: 141.63, r0: 138.28, r1: 242.19, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 145.39, r0: 61.719, r1: 284.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 147.31, r0: 15.625, r1: 278.12, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 148.73, r0: 94.531, r1: 249.22, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 152.52, r0: 40.625, r1: 264.06, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 153.55, r0: 85.938, r1: 213.28, fade: 0.05, rgb: [0.0157, 0.0196, 0.0186] }, + Ray { angle: 155.92, r0: 19.531, r1: 256.25, fade: 0.05, rgb: [0.0275, 0.0353, 0.0373] }, + Ray { angle: 157.11, r0: 27.344, r1: 113.28, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 161.69, r0: 16.406, r1: 246.09, fade: 0.05, rgb: [0.0235, 0.0314, 0.0314] }, + Ray { angle: 163.07, r0: 18.75, r1: 244.53, fade: 0.05, rgb: [0.0196, 0.0265, 0.0255] }, + Ray { angle: 168.84, r0: 10.938, r1: 238.28, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 169.44, r0: 9.375, r1: 189.06, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 171.26, r0: 106.25, r1: 216.41, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 172.5, r0: 35.938, r1: 121.09, fade: 0.05, rgb: [0.0118, 0.0235, 0.0235] }, + Ray { angle: 176.63, r0: 32.031, r1: 225.0, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 183.13, r0: 146.09, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 186.48, r0: 35.938, r1: 235.16, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 193.74, r0: 18.75, r1: 240.62, fade: 0.05, rgb: [0.0176, 0.0235, 0.0255] }, + Ray { angle: 200.96, r0: 71.875, r1: 203.91, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 202.8, r0: 103.12, r1: 253.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 208.01, r0: 99.219, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 213.33, r0: 28.125, r1: 280.47, fade: 0.05, rgb: [0.0275, 0.0392, 0.0363] }, + Ray { angle: 217.04, r0: 42.188, r1: 292.97, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 218.19, r0: 25.0, r1: 212.5, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 220.48, r0: 49.219, r1: 300.78, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 224.23, r0: 39.062, r1: 279.69, fade: 0.05, rgb: [0.0137, 0.0157, 0.0196] }, + Ray { angle: 226.08, r0: 59.375, r1: 244.53, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 227.35, r0: 79.688, r1: 242.19, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 229.33, r0: 35.938, r1: 164.06, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 232.3, r0: 53.906, r1: 207.03, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 234.54, r0: 27.344, r1: 180.47, fade: 0.05, rgb: [0.0176, 0.0275, 0.0294] }, + Ray { angle: 240.39, r0: 71.875, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 241.67, r0: 72.656, r1: 221.88, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 243.75, r0: 61.719, r1: 210.94, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 245.57, r0: 63.281, r1: 214.06, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 250.54, r0: 39.844, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0137, 0.0118] }, + Ray { angle: 250.85, r0: 41.406, r1: 206.25, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 252.72, r0: 63.281, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 254.48, r0: 65.625, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 256.14, r0: 82.812, r1: 200.78, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 259.97, r0: 43.75, r1: 197.66, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 263.25, r0: 114.84, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 264.26, r0: 10.938, r1: 146.88, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 265.21, r0: 124.22, r1: 195.31, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 267.03, r0: 64.062, r1: 107.03, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 269.05, r0: 10.156, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0196, 0.0216] }, + Ray { angle: 276.22, r0: 36.719, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 283.35, r0: 66.406, r1: 200.0, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 284.81, r0: 115.62, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 285.74, r0: 40.625, r1: 165.62, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 287.59, r0: 50.0, r1: 146.09, fade: 0.05, rgb: [0.0176, 0.0196, 0.0216] }, + Ray { angle: 288.62, r0: 103.12, r1: 205.47, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 295.82, r0: 28.906, r1: 216.41, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 299.12, r0: 112.5, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 300.15, r0: 31.25, r1: 146.88, fade: 0.05, rgb: [0.0157, 0.0255, 0.0255] }, + Ray { angle: 302.91, r0: 36.719, r1: 232.03, fade: 0.05, rgb: [0.0216, 0.0275, 0.0255] }, + Ray { angle: 305.78, r0: 55.469, r1: 161.72, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 306.3, r0: 164.06, r1: 242.19, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 310.11, r0: 43.75, r1: 254.69, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 319.11, r0: 67.969, r1: 297.66, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 320.67, r0: 50.781, r1: 307.81, fade: 0.05, rgb: [0.0225, 0.0275, 0.0275] }, + Ray { angle: 322.54, r0: 45.312, r1: 321.09, fade: 0.05, rgb: [0.0225, 0.0275, 0.0275] }, + Ray { angle: 324.28, r0: 75.0, r1: 154.69, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 340.57, r0: 27.344, r1: 339.84, fade: 0.05, rgb: [0.0333, 0.0392, 0.0392] }, + Ray { angle: 345.77, r0: 23.438, r1: 246.09, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, +]; +const ZOOM_150_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.2242, 0.4596, 0.5721]), + (4.0, [0.225, 0.4574, 0.5696]), + (10.4, [0.2163, 0.4499, 0.5631]), + (20.0, [0.2003, 0.4111, 0.5416]), + (23.2, [0.2023, 0.3977, 0.5295]), + (28.0, [0.2138, 0.383, 0.5104]), + (30.4, [0.2229, 0.3789, 0.5019]), + (39.2, [0.2652, 0.4068, 0.4869]), + (42.4, [0.2838, 0.408, 0.48]), + (52.0, [0.3365, 0.3925, 0.4514]), + (60.0, [0.3557, 0.3879, 0.4246]), + (62.4, [0.3691, 0.3852, 0.4185]), + (64.0, [0.389, 0.3894, 0.4179]), + (67.2, [0.4477, 0.404, 0.4131]), + (68.0, [0.4503, 0.4016, 0.4097]), + (72.0, [0.4057, 0.3649, 0.3844]), + (84.0, [0.4189, 0.3189, 0.3324]), + (91.2, [0.4249, 0.2774, 0.3036]), + (96.0, [0.4258, 0.2609, 0.2921]), + (104.0, [0.4384, 0.2594, 0.2747]), + (109.6, [0.4308, 0.2588, 0.2637]), + (112.0, [0.427, 0.2539, 0.2514]), + (117.6, [0.4244, 0.2512, 0.2359]), + (129.6, [0.4052, 0.2523, 0.2219]), + (134.4, [0.3879, 0.2407, 0.2232]), + (140.8, [0.3808, 0.2274, 0.2194]), + (144.0, [0.3786, 0.2173, 0.2134]), + (153.6, [0.346, 0.1891, 0.195]), + (159.2, [0.3433, 0.1773, 0.1838]), + (164.0, [0.3285, 0.1674, 0.1764]), + (171.2, [0.315, 0.171, 0.1685]), + (174.4, [0.3049, 0.1677, 0.1671]), + (175.2, [0.305, 0.1699, 0.1612]), + (179.2, [0.2945, 0.169, 0.1577]), + (184.8, [0.2853, 0.1609, 0.1535]), + (192.0, [0.2848, 0.1572, 0.1511]), + (198.4, [0.2763, 0.1528, 0.1469]), + (211.2, [0.2783, 0.1488, 0.1392]), + (242.4, [0.2722, 0.1657, 0.1266]), + (248.0, [0.2719, 0.1715, 0.1303]), + (264.0, [0.2258, 0.1817, 0.1364]), + (270.4, [0.2085, 0.1695, 0.1381]), + (279.2, [0.1821, 0.1532, 0.1334]), + (290.4, [0.155, 0.139, 0.1346]), + (292.0, [0.1542, 0.1405, 0.1332]), + (296.8, [0.1473, 0.1377, 0.138]), + (300.8, [0.1353, 0.137, 0.1443]), + (304.8, [0.126, 0.1275, 0.1503]), + (305.6, [0.1211, 0.1264, 0.1487]), + (307.2, [0.1184, 0.1266, 0.1501]), + (308.0, [0.1146, 0.1234, 0.1467]), + (312.8, [0.1057, 0.1236, 0.1422]), + (316.0, [0.0946, 0.1193, 0.1349]), + (325.6, [0.0656, 0.0983, 0.1025]), + (330.4, [0.0514, 0.0856, 0.099]), + (332.0, [0.0493, 0.0841, 0.0957]), + (332.8, [0.045, 0.0828, 0.0959]), + (334.4, [0.0435, 0.0783, 0.0934]), + (336.0, [0.0377, 0.0765, 0.0939]), + (336.8, [0.0368, 0.0722, 0.0945]), + (337.6, [0.0365, 0.0717, 0.0914]), + (339.2, [0.031, 0.0706, 0.0923]), + (340.8, [0.0292, 0.0658, 0.0896]), + (341.6, [0.0251, 0.0655, 0.0903]), + (343.2, [0.0242, 0.0631, 0.0906]), + (344.8, [0.0196, 0.0601, 0.0869]), + (346.4, [0.019, 0.0579, 0.0861]), + (347.2, [0.0154, 0.0538, 0.0824]), + (349.6, [0.0141, 0.0526, 0.082]), + (350.4, [0.0103, 0.0494, 0.0783]), + (352.8, [0.0087, 0.0475, 0.0779]), + (353.6, [0.0055, 0.0465, 0.0747]), + (354.4, [0.0057, 0.0433, 0.0739]), + (356.0, [0.0046, 0.0428, 0.0736]), + (356.8, [0.0018, 0.0426, 0.0726]), + (358.4, [0.0023, 0.0385, 0.0701]), + (360.0, [-0.0011, 0.0378, 0.0696]), + (360.8, [-0.0007, 0.0377, 0.0664]), + (361.6, [-0.0005, 0.0343, 0.0667]), + (363.2, [-0.0034, 0.0338, 0.067]), + (364.0, [-0.0029, 0.0338, 0.0639]), + (365.6, [-0.0033, 0.0301, 0.0635]), + (366.4, [-0.0052, 0.0303, 0.0637]), + (368.8, [-0.0043, 0.0287, 0.0608]), + (369.6, [-0.0064, 0.0268, 0.0607]), + (372.0, [-0.0045, 0.0275, 0.0583]), + (372.8, [-0.007, 0.0245, 0.0583]), + (375.2, [-0.0046, 0.0243, 0.0556]), + (376.0, [-0.0071, 0.0247, 0.0561]), + (377.6, [-0.0059, 0.0216, 0.0567]), + (378.4, [-0.0047, 0.022, 0.0536]), + (380.0, [-0.0066, 0.0228, 0.054]), + (381.6, [-0.0045, 0.0197, 0.0537]), + (383.2, [-0.006, 0.0203, 0.052]), + (385.6, [-0.0028, 0.0181, 0.0515]), + (386.4, [-0.0046, 0.0188, 0.0498]), + (388.8, [-0.0026, 0.0191, 0.0512]), + (389.6, [-0.0028, 0.0171, 0.0484]), + (390.4, [-0.0044, 0.0178, 0.0482]), + (392.8, [-0.0013, 0.0193, 0.0494]), + (394.4, [-0.0031, 0.0167, 0.0465]), + (396.8, [-0.0005, 0.0182, 0.0476]), + (397.6, [-0.0025, 0.018, 0.0449]), + (398.4, [-0.0016, 0.016, 0.0445]), + (400.0, [0.0003, 0.0173, 0.0446]), + (401.6, [-0.0011, 0.0185, 0.043]), + (402.4, [0.0003, 0.0161, 0.0422]), + (404.0, [0.0026, 0.0174, 0.0425]), + (405.6, [0.0012, 0.0188, 0.0413]), + (407.2, [0.0033, 0.0166, 0.0404]), + (408.0, [0.0049, 0.0177, 0.0405]), + (409.6, [0.0033, 0.0189, 0.0407]), + (410.4, [0.0048, 0.0199, 0.0378]), + (412.0, [0.0072, 0.0176, 0.0385]), + (413.6, [0.0056, 0.0194, 0.0393]), + (415.2, [0.0083, 0.021, 0.0361]), + (416.8, [0.01, 0.019, 0.037]), + (417.6, [0.0087, 0.0204, 0.0374]), + (418.4, [0.0106, 0.0211, 0.0377]), + (419.2, [0.0118, 0.022, 0.0348]), + (420.8, [0.0141, 0.0234, 0.0358]), + (421.6, [0.0122, 0.0212, 0.0362]), + (424.8, [0.0182, 0.0257, 0.0352]), + (425.6, [0.0164, 0.0271, 0.0355]), + (426.4, [0.0172, 0.0248, 0.036]), + (429.6, [0.0224, 0.0287, 0.0349]), + (430.4, [0.0205, 0.03, 0.0352]), + (432.0, [0.0238, 0.028, 0.0362]), + (433.6, [0.027, 0.0304, 0.0334]), + (435.2, [0.026, 0.0322, 0.0348]), + (437.6, [0.0304, 0.0323, 0.0359]), + (438.4, [0.0322, 0.0334, 0.0329]), + (440.0, [0.0313, 0.0355, 0.0342]), + (442.4, [0.0355, 0.0368, 0.036]), + (443.2, [0.0371, 0.0361, 0.0337]), + (444.0, [0.0355, 0.0378, 0.0336]), + (448.0, [0.0424, 0.0417, 0.0367]), + (448.8, [0.0421, 0.0406, 0.0341]), + (449.6, [0.0413, 0.0424, 0.0347]), + (453.6, [0.0482, 0.0476, 0.038]), + (454.4, [0.0461, 0.0454, 0.0352]), + (459.2, [0.0513, 0.0522, 0.0399]), + (460.8, [0.0517, 0.0502, 0.0382]), + (464.0, [0.0549, 0.054, 0.0411]), + (465.6, [0.0525, 0.055, 0.0405]), + (466.4, [0.0541, 0.0548, 0.0388]), + (467.2, [0.0553, 0.0518, 0.0399]), + (470.4, [0.058, 0.0492, 0.0408]), + (473.6, [0.0534, 0.0393, 0.0386]), + (474.4, [0.0517, 0.034, 0.0387]), + (476.8, [0.0454, 0.0279, 0.0397]), + (479.2, [0.0428, 0.0225, 0.0379]), + (481.6, [0.0409, 0.0157, 0.0395]), + (483.2, [0.0374, 0.0141, 0.04]), + (488.0, [0.0345, 0.0086, 0.0394]), + (488.8, [0.0332, 0.0053, 0.0395]), + (490.4, [0.0295, 0.0042, 0.0399]), + (495.2, [0.0245, -0.0001, 0.0384]), + (497.6, [0.0197, -0.0046, 0.0385]), + (504.0, [0.0161, -0.0081, 0.0347]), + (504.8, [0.0129, -0.0092, 0.0344]), + (508.8, [0.0115, -0.0125, 0.0323]), + (509.6, [0.0109, -0.0129, 0.0291]), + (512.0, [0.0088, -0.0145, 0.0272]), + (512.8, [0.0059, -0.015, 0.0264]), + (518.4, [0.0007, -0.0196, 0.0208]), + (519.2, [-0.0005, -0.0199, 0.0164]), + (520.0, [-0.0015, -0.0202, 0.0162]), + (521.6, [-0.0039, -0.0209, 0.0137]), + (522.4, [-0.0077, -0.0212, 0.0125]), + (530.4, [-0.0215, -0.0262, -0.0021]), + (532.8, [-0.029, -0.0274, -0.0058]), + (540.0, [-0.0413, -0.0284, -0.0156]), + (540.8, [-0.0424, -0.0312, -0.0165]), + (544.0, [-0.045, -0.0301, -0.0206]), + (544.8, [-0.0481, -0.0298, -0.0215]), + (552.0, [-0.0481, -0.0241, -0.0273]), + (558.4, [-0.0437, -0.0165, -0.0298]), + (559.2, [-0.0438, -0.0156, -0.0332]), + (560.8, [-0.0427, -0.0135, -0.0334]), + (568.8, [-0.0289, -0.0008, -0.0349]), + (578.4, [0.0008, 0.0152, -0.0238]), + (585.6, [0.0225, 0.0229, -0.0139]), + (588.0, [0.0263, 0.0241, -0.0122]), + (593.6, [0.0259, 0.0208, -0.0121]), + (595.2, [0.0258, 0.0201, -0.0092]), + (597.6, [0.0287, 0.0198, -0.0004]), + (598.4, [0.031, 0.0223, 0.0029]), + (600.0, [0.0394, 0.0334, 0.0101]), + (601.6, [0.0494, 0.0433, 0.0125]), + (603.2, [0.0621, 0.054, 0.0119]), + (605.6, [0.0825, 0.0578, 0.0076]), + (606.4, [0.0842, 0.0568, 0.0056]), + (608.0, [0.0763, 0.0491, 0.001]), + (610.4, [0.0542, 0.0352, -0.003]), + (612.0, [0.0378, 0.0266, -0.0037]), + (613.6, [0.0269, 0.0209, -0.0034]), + (615.2, [0.0189, 0.0166, -0.002]), + (617.6, [0.0146, 0.0115, 0.0006]), + (620.8, [0.0165, 0.0059, 0.0016]), + (624.0, [0.0157, 0.0008, -0.0009]), + (630.4, [-0.0043, -0.0039, -0.01]), + (632.0, [-0.0087, -0.0036, -0.0105]), + (634.4, [-0.0127, -0.0022, -0.0079]), + (636.8, [-0.0114, -0.0004, -0.0031]), + (640.8, [0.003, 0.0001, 0.0053]), + (642.4, [0.0065, -0.0012, 0.0078]), + (644.0, [0.0038, -0.0029, 0.0093]), + (646.4, [-0.0067, -0.0052, 0.0089]), + (648.0, [-0.0101, -0.0054, 0.0087]), + (648.8, [-0.0092, -0.005, 0.009]), + (651.2, [-0.0002, -0.0026, 0.0096]), + (652.8, [0.0035, -0.0011, 0.0082]), + (654.4, [0.0022, -0.0002, 0.0049]), + (656.8, [-0.0043, 0.0004, -0.0009]), + (657.6, [-0.0043, 0.0006, -0.0018]), + (660.8, [0.0027, 0.0012, -0.0033]), + (664.0, [-0.0044, -0.0017, -0.0073]), + (664.8, [-0.0045, -0.0019, -0.0068]), + (667.2, [0.0014, -0.0006, -0.0034]), + (668.0, [0.0017, -0.0006, -0.0031]), + (670.4, [-0.0049, -0.0018, -0.0031]), + (671.2, [-0.0042, -0.0015, -0.0019]), + (672.8, [0.0012, -0.0003, 0.0005]), + (673.6, [0.0015, -0.0002, 0.0005]), + (675.2, [-0.0023, -0.0005, -0.0005]), + (676.0, [-0.0022, -0.0002, -0.0001]), + (677.6, [0.0025, 0.0008, 0.0014]), + (679.2, [0.0006, 0.0001, 0.0003]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.7093, 0.5088, 0.4152]), + (0.8, [0.7273, 0.5147, 0.4289]), + (18.4, [0.7396, 0.5086, 0.4163]), + (26.4, [0.7248, 0.4948, 0.4058]), + (29.6, [0.709, 0.4827, 0.4018]), + (38.4, [0.6551, 0.4105, 0.3776]), + (43.2, [0.6158, 0.3849, 0.3665]), + (52.0, [0.5447, 0.353, 0.3511]), + (63.2, [0.4739, 0.3113, 0.3333]), + (67.2, [0.4613, 0.3051, 0.3276]), + (71.2, [0.4136, 0.2895, 0.3247]), + (84.8, [0.3446, 0.2839, 0.3273]), + (93.6, [0.3063, 0.3038, 0.3308]), + (104.8, [0.2546, 0.285, 0.323]), + (116.8, [0.2275, 0.2624, 0.3274]), + (129.6, [0.2007, 0.2378, 0.3094]), + (132.0, [0.2002, 0.2353, 0.304]), + (135.2, [0.1999, 0.2344, 0.2958]), + (138.4, [0.1926, 0.2342, 0.2897]), + (144.8, [0.1795, 0.2383, 0.2858]), + (154.4, [0.18, 0.2432, 0.2809]), + (160.0, [0.1679, 0.2447, 0.2794]), + (165.6, [0.1642, 0.2433, 0.2742]), + (176.0, [0.16, 0.2195, 0.263]), + (182.4, [0.1576, 0.2127, 0.2559]), + (196.8, [0.1321, 0.1985, 0.2368]), + (207.2, [0.1118, 0.1831, 0.2225]), + (214.4, [0.1019, 0.1714, 0.2128]), + (246.4, [0.0432, 0.0996, 0.1659]), + (262.4, [0.0601, 0.0637, 0.1317]), + (279.2, [0.0797, 0.0683, 0.1081]), + (290.4, [0.0868, 0.0667, 0.0925]), + (296.0, [0.0867, 0.061, 0.0809]), + (300.0, [0.0892, 0.0575, 0.0688]), + (302.4, [0.0929, 0.0578, 0.0607]), + (305.6, [0.097, 0.0599, 0.0541]), + (313.6, [0.1044, 0.0539, 0.0534]), + (324.0, [0.1215, 0.0628, 0.0725]), + (327.2, [0.1265, 0.066, 0.0744]), + (339.2, [0.1393, 0.077, 0.068]), + (344.0, [0.1418, 0.0783, 0.0652]), + (355.2, [0.1453, 0.0839, 0.0669]), + (371.2, [0.1359, 0.0843, 0.0627]), + (384.0, [0.1197, 0.0792, 0.057]), + (396.8, [0.103, 0.0699, 0.0502]), + (420.0, [0.0672, 0.0456, 0.0386]), + (441.6, [0.0266, 0.0152, 0.0235]), + (449.6, [0.0112, 0.0027, 0.016]), + (460.8, [-0.0057, -0.0135, 0.0047]), + (465.6, [-0.0112, -0.018, 0.0007]), + (468.8, [-0.0147, -0.0163, -0.0007]), + (469.6, [-0.0172, -0.015, -0.0011]), + (470.4, [-0.017, -0.0133, -0.0002]), + (471.2, [-0.0169, -0.0113, -0.0027]), + (472.0, [-0.016, -0.0076, -0.0025]), + (475.2, [-0.0092, 0.0004, -0.0034]), + (476.8, [-0.0095, 0.0044, -0.0032]), + (477.6, [-0.0086, 0.0063, -0.0059]), + (479.2, [-0.0073, 0.0105, -0.0068]), + (480.0, [-0.0068, 0.0093, -0.0071]), + (484.0, [-0.0058, 0.0142, -0.0082]), + (485.6, [-0.0048, 0.0159, -0.0109]), + (488.0, [-0.0017, 0.0169, -0.0114]), + (488.8, [-0.0034, 0.0173, -0.0117]), + (491.2, [-0.001, 0.0198, -0.011]), + (492.0, [0.0002, 0.0205, -0.0134]), + (495.2, [0.0042, 0.0242, -0.0144]), + (496.0, [0.0027, 0.0226, -0.0144]), + (499.2, [0.0051, 0.0245, -0.0122]), + (500.0, [0.0056, 0.0249, -0.0146]), + (501.6, [0.0067, 0.0258, -0.0147]), + (502.4, [0.0078, 0.0262, -0.0144]), + (504.0, [0.006, 0.028, -0.0141]), + (504.8, [0.0059, 0.0257, -0.0139]), + (507.2, [0.0073, 0.0266, -0.0118]), + (509.6, [0.0088, 0.0281, -0.0125]), + (514.4, [0.0111, 0.0319, -0.0074]), + (515.2, [0.0119, 0.0293, -0.0066]), + (520.0, [0.019, 0.0313, -0.0024]), + (520.8, [0.0167, 0.0317, -0.0013]), + (525.6, [0.0248, 0.0348, 0.006]), + (526.4, [0.0265, 0.0319, 0.0075]), + (528.0, [0.0295, 0.032, 0.011]), + (528.8, [0.0312, 0.0327, 0.009]), + (530.4, [0.0355, 0.0334, 0.0115]), + (531.2, [0.0343, 0.0339, 0.0128]), + (538.4, [0.0474, 0.0367, 0.0229]), + (539.2, [0.0487, 0.0337, 0.0239]), + (540.8, [0.0507, 0.0338, 0.026]), + (542.4, [0.053, 0.0336, 0.0242]), + (544.8, [0.0509, 0.0325, 0.0267]), + (552.0, [0.0513, 0.0265, 0.0319]), + (555.2, [0.0495, 0.0227, 0.0335]), + (556.8, [0.0481, 0.0174, 0.0355]), + (557.6, [0.0472, 0.0159, 0.0324]), + (558.4, [0.0477, 0.015, 0.0333]), + (559.2, [0.0431, 0.0139, 0.0333]), + (565.6, [0.0339, 0.0045, 0.0358]), + (570.4, [0.0223, -0.0038, 0.0339]), + (578.4, [-0.0034, -0.0166, 0.0231]), + (584.8, [-0.0224, -0.0232, 0.0145]), + (588.8, [-0.0269, -0.0241, 0.0126]), + (593.6, [-0.0252, -0.0204, 0.0117]), + (596.8, [-0.0287, -0.02, 0.0006]), + (597.6, [-0.0312, -0.0228, -0.0031]), + (599.2, [-0.0401, -0.034, -0.0107]), + (600.8, [-0.0505, -0.0441, -0.0128]), + (602.4, [-0.0631, -0.0548, -0.0122]), + (604.0, [-0.078, -0.058, -0.0092]), + (604.8, [-0.0833, -0.0584, -0.0077]), + (605.6, [-0.0845, -0.0571, -0.0056]), + (607.2, [-0.0762, -0.049, -0.0008]), + (609.6, [-0.0536, -0.0351, 0.003]), + (611.2, [-0.0372, -0.0264, 0.0037]), + (612.8, [-0.0264, -0.0208, 0.0032]), + (614.4, [-0.0188, -0.0166, 0.0017]), + (616.8, [-0.0149, -0.0116, -0.0008]), + (619.2, [-0.0162, -0.0073, -0.0018]), + (621.6, [-0.0174, -0.0033, -0.0008]), + (623.2, [-0.0157, -0.0009, 0.001]), + (629.6, [0.0045, 0.0036, 0.0101]), + (632.8, [0.0117, 0.0024, 0.0089]), + (634.4, [0.0128, 0.0012, 0.0061]), + (636.0, [0.0109, 0.0001, 0.0027]), + (640.8, [-0.0058, 0.0004, -0.0069]), + (641.6, [-0.0067, 0.0011, -0.0081]), + (643.2, [-0.0035, 0.0029, -0.0095]), + (645.6, [0.0071, 0.0051, -0.009]), + (647.2, [0.0099, 0.0052, -0.0089]), + (648.0, [0.0088, 0.0047, -0.0092]), + (651.2, [-0.0027, 0.0015, -0.0092]), + (652.0, [-0.0037, 0.0009, -0.0082]), + (653.6, [-0.002, 0.0, -0.0047]), + (656.0, [0.0042, -0.0005, 0.001]), + (660.0, [-0.0027, -0.0013, 0.0035]), + (662.4, [0.003, 0.0011, 0.0071]), + (664.0, [0.0043, 0.0017, 0.0067]), + (666.4, [-0.0017, 0.0005, 0.0034]), + (667.2, [-0.0016, 0.0005, 0.0031]), + (669.6, [0.0049, 0.0017, 0.003]), + (672.0, [-0.0015, 0.0002, -0.0006]), + (674.4, [0.0024, 0.0004, 0.0005]), + (676.8, [-0.0028, -0.0009, -0.0014]), + (678.4, [-0.0003, -0.0001, -0.0001]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.048, 0.0259, 0.0035]), + (0.79967, [0.0474, 0.0251, 0.0005]), + (1.5993, [0.052, 0.0301, 0.0064]), + (2.399, [0.0511, 0.0311, 0.0052]), + (3.1987, [0.0497, 0.0271, 0.0038]), + (7.197, [0.0503, 0.028, 0.0038]), + (9.596, [0.0532, 0.0321, 0.0063]), + (18.392, [0.0507, 0.0285, 0.0035]), + (32.786, [0.0506, 0.0287, 0.0044]), + (58.376, [0.0499, 0.0263, 0.0026]), + (75.169, [0.0507, 0.0285, 0.0042]), + (109.55, [0.0515, 0.0281, 0.0035]), + (115.95, [0.051, 0.028, 0.0035]), + (119.95, [0.0406, 0.0215, 0.0036]), + (271.09, [0.0386, 0.0196, 0.0018]), + (271.89, [0.0364, 0.0198, 0.0019]), + (276.68, [0.0156, 0.0076, 0.0017]), + (278.28, [0.0078, 0.0062, 0.0018]), + (279.08, [0.0049, 0.0028, 0.0017]), + (279.88, [0.0028, 0.0033, 0.0018]), + (327.06, [0.0015, 0.0019, 0.0007]), + (392.64, [0.0036, 0.0028, 0.0008]), + (479.0, [0.0016, 0.0019, 0.0003]), + ], + }, + Radial { + t: -0.30073, + perp: 0.16052, + knots: &[ + (0.0, [0.0048, -0.0079, -0.0033]), + (0.79036, [0.0013, 0.0016, 0.0008]), + (4.7422, [0.0043, 0.0017, 0.0021]), + (8.694, [0.0033, 0.0041, 0.0006]), + (11.855, [0.004, 0.0056, 0.0036]), + (16.598, [0.004, 0.0116, 0.0057]), + (22.921, [0.0067, 0.0246, 0.0124]), + (27.663, [0.0198, 0.0402, 0.0174]), + (29.243, [0.0168, 0.0426, 0.0193]), + (30.034, [0.0133, 0.0353, 0.0156]), + (30.824, [0.0072, 0.0135, 0.0061]), + (31.615, [0.0025, 0.0016, 0.0007]), + (32.405, [0.0014, -0.0001, -0.0003]), + ], + }, + Radial { + t: 0.5675, + perp: -0.14036, + knots: &[ + (0.0, [-0.0034, 0.0337, 0.0648]), + (0.79858, [0.0013, 0.0372, 0.0726]), + (2.3957, [0.0017, 0.0396, 0.075]), + (3.1943, [0.0014, 0.038, 0.0742]), + (6.3886, [0.003, 0.0397, 0.0755]), + (10.382, [0.0009, 0.0383, 0.0742]), + (16.77, [0.0012, 0.0384, 0.072]), + (17.569, [0.0008, 0.0374, 0.0707]), + (19.965, [0.0009, 0.0302, 0.0593]), + (24.756, [0.0004, 0.0221, 0.0442]), + (26.353, [0.0001, 0.018, 0.0375]), + (27.95, [-0.0002, 0.0122, 0.027]), + (34.339, [-0.0012, 0.011, 0.0241]), + (43.123, [-0.0005, 0.0108, 0.0245]), + (44.721, [-0.0002, 0.0075, 0.0153]), + (46.318, [-0.0002, 0.0052, 0.01]), + (50.311, [0.0, 0.0009, 0.0008]), + ], + }, + Radial { + t: 0.8008, + perp: -0.19394, + knots: &[ + (0.0, [0.1041, 0.0564, -0.0135]), + (0.79635, [0.1059, 0.0584, -0.0049]), + (1.5927, [0.1068, 0.0573, -0.0088]), + (2.3891, [0.1085, 0.0584, -0.004]), + (3.9818, [0.1077, 0.0604, 0.0005]), + (6.3708, [0.1076, 0.0625, 0.0036]), + (10.353, [0.1178, 0.0683, 0.009]), + (11.945, [0.1206, 0.0693, 0.0087]), + (13.538, [0.1182, 0.068, 0.0079]), + (14.334, [0.1013, 0.058, 0.0065]), + (15.131, [0.0428, 0.0237, 0.0032]), + (15.927, [0.0017, 0.0009, 0.0009]), + (21.501, [0.0017, 0.0028, 0.0046]), + (31.058, [0.0013, 0.0017, 0.0018]), + ], + }, + Radial { + t: 0.98924, + perp: 0.12742, + knots: &[ + (0.0, [0.2107, 0.5799, 0.4155]), + (0.76482, [0.1357, 0.3628, 0.2637]), + (1.5296, [0.1085, 0.3035, 0.2176]), + (2.2945, [0.0387, 0.1042, 0.0735]), + (3.0593, [0.0031, 0.007, 0.0056]), + (3.8241, [-0.0005, -0.0026, -0.0027]), + (4.5889, [0.0001, -0.001, -0.001]), + ], + }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.268, 0.5905, 0.5674]), + (0.79661, [0.2003, 0.4823, 0.4597]), + (1.5932, [0.1779, 0.4442, 0.4219]), + (2.3898, [0.1086, 0.3072, 0.2988]), + (3.1864, [0.0724, 0.2131, 0.2159]), + (3.983, [0.0591, 0.1686, 0.171]), + (6.3728, [0.024, 0.0698, 0.0798]), + (7.1694, [0.0164, 0.0482, 0.0569]), + (7.9661, [0.0093, 0.029, 0.0409]), + (8.7627, [0.0045, 0.0137, 0.0233]), + (9.5593, [0.0021, 0.0022, 0.0129]), + (10.356, [0.0007, 0.0021, 0.0122]), + (17.525, [0.0018, 0.0027, 0.0035]), + (20.712, [-0.0019, -0.0001, 0.0015]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [0.7687, 0.5785, 0.764]), + (3.1881, [0.7621, 0.5742, 0.7545]), + (6.3762, [0.7402, 0.5567, 0.7245]), + (11.955, [0.6619, 0.5042, 0.6232]), + (17.535, [0.5353, 0.4164, 0.4711]), + (23.114, [0.4196, 0.3373, 0.2775]), + (26.302, [0.3654, 0.301, 0.1744]), + (28.693, [0.3276, 0.2718, 0.1071]), + (31.084, [0.2927, 0.2422, 0.0506]), + (32.678, [0.2727, 0.2239, 0.0204]), + (34.272, [0.2544, 0.2062, -0.0035]), + (35.866, [0.2375, 0.1891, -0.021]), + (37.46, [0.2236, 0.175, -0.0314]), + (39.851, [0.203, 0.1546, -0.0355]), + (42.242, [0.1867, 0.1382, -0.0301]), + (45.43, [0.1701, 0.1226, -0.0158]), + (47.822, [0.1641, 0.1117, -0.0048]), + (51.01, [0.1635, 0.1068, 0.0077]), + (53.401, [0.1691, 0.108, 0.0136]), + (54.198, [0.1569, 0.1006, 0.0121]), + (54.995, [0.0764, 0.0536, 0.0116]), + (55.792, [0.0203, 0.0205, 0.0118]), + (62.965, [0.0142, 0.0129, 0.0073]), + (71.732, [0.0087, 0.0084, 0.0031]), + (97.237, [0.0019, 0.0011, -0.0002]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [-0.1492, -0.1186, -0.2434]), + (1.5918, [-0.1477, -0.1212, -0.2439]), + (3.1836, [-0.1381, -0.115, -0.2398]), + (4.7754, [-0.1396, -0.118, -0.2348]), + (6.3672, [-0.1357, -0.1155, -0.2288]), + (9.5508, [-0.1201, -0.1043, -0.2123]), + (11.938, [-0.1038, -0.0925, -0.1954]), + (15.918, [-0.0726, -0.0708, -0.157]), + (20.693, [-0.0349, -0.0447, -0.1008]), + (25.469, [-0.0075, -0.0238, -0.0427]), + (27.06, [0.001, -0.0193, -0.0277]), + (27.856, [0.0075, -0.0151, -0.0202]), + (29.448, [0.0146, -0.0091, -0.0078]), + (30.244, [-0.0085, -0.0212, -0.0022]), + (31.04, [-0.0521, -0.0484, 0.0024]), + (31.836, [-0.0639, -0.055, 0.0071]), + (35.019, [-0.0488, -0.0408, 0.0194]), + (37.407, [-0.0398, -0.0317, 0.0227]), + (41.387, [-0.0275, -0.0211, 0.0183]), + (45.366, [-0.0158, -0.0113, 0.0081]), + (47.754, [-0.0139, -0.0099, 0.0034]), + (49.346, [-0.0082, -0.0058, 0.0014]), + (52.529, [-0.0021, -0.0014, 0.0007]), + (54.121, [-0.0019, -0.0013, -0.0006]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-0.4275, -0.3328, -0.5483]), + (0.79582, [-0.4164, -0.3294, -0.5526]), + (5.5707, [-0.403, -0.3186, -0.5324]), + (8.754, [-0.3849, -0.3047, -0.5026]), + (11.141, [-0.3599, -0.2881, -0.4712]), + (13.529, [-0.3266, -0.2669, -0.4322]), + (14.325, [-0.3275, -0.2687, -0.4202]), + (15.916, [-0.3639, -0.2938, -0.3906]), + (19.895, [-0.3016, -0.2477, -0.3048]), + (23.875, [-0.2413, -0.2083, -0.2052]), + (27.058, [-0.2017, -0.1834, -0.1305]), + (29.445, [-0.1737, -0.1622, -0.0819]), + (31.833, [-0.1463, -0.1396, -0.0416]), + (33.424, [-0.1298, -0.1255, -0.0194]), + (35.812, [-0.1063, -0.1047, 0.0051]), + (37.403, [-0.0922, -0.0916, 0.016]), + (38.995, [-0.0798, -0.08, 0.0217]), + (41.383, [-0.0622, -0.0642, 0.0217]), + (43.77, [-0.0482, -0.0508, 0.0155]), + (46.157, [-0.0369, -0.041, 0.0068]), + (49.341, [-0.0266, -0.0306, -0.0041]), + (50.932, [-0.0173, -0.023, -0.0083]), + (53.32, [-0.0213, -0.0219, -0.013]), + (58.095, [-0.0243, -0.0223, -0.014]), + (60.482, [-0.0167, -0.0166, -0.0116]), + (70.828, [-0.0104, -0.0104, -0.0044]), + (79.582, [-0.0066, -0.0059, -0.0016]), + (98.681, [-0.0019, -0.0014, -0.0001]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.0588, 0.1636, 0.4885]), + (0.7948, [0.0604, 0.1646, 0.4725]), + (3.1792, [0.0582, 0.1607, 0.4559]), + (3.974, [0.0595, 0.1583, 0.4327]), + (4.7688, [0.0576, 0.155, 0.4179]), + (9.5376, [0.049, 0.1385, 0.2909]), + (12.717, [0.051, 0.1373, 0.2216]), + (15.101, [0.0536, 0.1363, 0.1771]), + (15.896, [0.0537, 0.1377, 0.1656]), + (17.486, [0.0486, 0.1356, 0.1435]), + (18.28, [0.0493, 0.1344, 0.1343]), + (21.46, [0.0472, 0.1305, 0.1122]), + (22.254, [0.0351, 0.0942, 0.0849]), + (23.049, [0.0164, 0.0344, 0.0381]), + (23.844, [0.0084, 0.0065, 0.0146]), + (24.639, [0.0053, 0.002, 0.0103]), + (25.434, [0.0039, 0.0014, 0.009]), + (27.818, [0.0023, 0.0017, 0.0075]), + (40.535, [0.0017, 0.0009, 0.0018]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0045, 0.0047, 0.0044]), + (18.378, [0.0046, 0.0047, 0.0045]), + (19.976, [0.0045, 0.0087, 0.0045]), + (23.172, [0.0045, 0.0083, 0.0044]), + (23.971, [0.0045, 0.0121, 0.0045]), + (26.368, [0.0044, 0.0122, 0.0045]), + (27.966, [0.0042, 0.0167, 0.0044]), + (29.564, [0.0084, 0.0162, 0.0043]), + (32.76, [0.0083, 0.0188, 0.0042]), + (33.56, [0.0082, 0.0217, 0.0043]), + (34.359, [0.0086, 0.0221, 0.0042]), + (35.158, [0.0119, 0.0219, 0.0041]), + (36.756, [0.0121, 0.0256, 0.0042]), + (39.952, [0.0125, 0.028, 0.004]), + (40.751, [0.0146, 0.0296, 0.004]), + (44.746, [0.0149, 0.0359, 0.0042]), + (45.545, [0.0179, 0.0388, 0.004]), + (48.741, [0.0181, 0.0434, 0.0045]), + (49.54, [0.0212, 0.0469, 0.0046]), + (52.736, [0.0214, 0.053, 0.005]), + (54.334, [0.0252, 0.056, 0.0049]), + (55.933, [0.0251, 0.0586, 0.0048]), + (57.531, [0.0282, 0.0628, 0.005]), + (59.928, [0.0285, 0.0687, 0.0048]), + (60.727, [0.0315, 0.0706, 0.0048]), + (66.32, [0.0348, 0.0836, 0.0039]), + (67.119, [0.0377, 0.0867, 0.0037]), + (69.516, [0.0393, 0.0929, 0.0031]), + (70.315, [0.0425, 0.0942, 0.003]), + (72.712, [0.0438, 0.1007, 0.0029]), + (75.109, [0.0982, 0.1194, 0.0034]), + (76.707, [0.1403, 0.1283, 0.0034]), + (77.507, [0.1461, 0.134, 0.0039]), + (78.306, [0.1414, 0.13, 0.001]), + (79.904, [0.0541, 0.0103, 0.0003]), + (81.502, [0.0157, 0.0025, 0.0006]), + (82.301, [-0.0001, 0.0005, 0.0006]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0031, -0.0012, -0.001]), + (0.79916, [0.0034, 0.0021, -0.0007]), + (7.9916, [0.0015, 0.0019, -0.0004]), + (19.18, [0.0033, 0.0032, 0.0001]), + (29.569, [0.0007, 0.0011, 0.0001]), + (141.45, [0.0005, 0.0005, -0.0002]), + (142.25, [0.0007, 0.0005, 0.0018]), + (143.05, [0.0089, 0.0006, 0.0181]), + (146.25, [0.0507, 0.0019, 0.0961]), + (147.05, [0.0646, 0.0177, 0.1092]), + (147.85, [0.0646, 0.0354, 0.106]), + (149.44, [0.0641, 0.0688, 0.0854]), + (150.24, [0.0671, 0.0888, 0.0803]), + (151.04, [0.0782, 0.0999, 0.0644]), + (151.84, [0.082, 0.0981, 0.0429]), + (154.24, [0.1079, 0.076, 0.0143]), + (155.04, [0.1048, 0.0597, 0.004]), + (155.84, [0.0847, 0.0409, 0.0002]), + (156.64, [0.0656, 0.0328, 0.0004]), + (159.03, [0.0057, 0.0022, 0.0001]), + (159.83, [0.0, 0.0003, 0.0001]), + ], + }, +]; +const ZOOM_150_STREAKS: &[Streak] = &[]; +const ZOOM_150_RAYS: &[Ray] = &[ + Ray { angle: 0.26, r0: 35.938, r1: 518.75, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 8.22, r0: 74.219, r1: 186.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 15.49, r0: 21.875, r1: 221.88, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 16.41, r0: 129.69, r1: 578.91, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 17.32, r0: 59.375, r1: 277.34, fade: 0.05, rgb: [0.0176, 0.0255, 0.0235] }, + Ray { angle: 24.5, r0: 40.625, r1: 258.59, fade: 0.05, rgb: [0.0127, 0.0196, 0.0176] }, + Ray { angle: 29.61, r0: 32.812, r1: 135.94, fade: 0.05, rgb: [0.0059, 0.0157, 0.0118] }, + Ray { angle: 34.95, r0: 100.78, r1: 202.34, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 35.98, r0: 118.75, r1: 527.34, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 38.93, r0: 39.062, r1: 212.5, fade: 0.05, rgb: [0.0078, 0.0216, 0.0176] }, + Ray { angle: 43.12, r0: 30.469, r1: 486.72, fade: 0.05, rgb: [0.0196, 0.0235, 0.0196] }, + Ray { angle: 53.28, r0: 44.531, r1: 260.16, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 62.91, r0: 35.156, r1: 283.59, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 64.81, r0: 66.406, r1: 446.88, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 70.06, r0: 37.5, r1: 117.97, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 71.94, r0: 78.125, r1: 325.0, fade: 0.05, rgb: [0.0118, 0.0137, 0.0118] }, + Ray { angle: 73.8, r0: 155.47, r1: 264.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 79.1, r0: 73.438, r1: 411.72, fade: 0.05, rgb: [0.0216, 0.0235, 0.0235] }, + Ray { angle: 80.97, r0: 66.406, r1: 409.38, fade: 0.05, rgb: [0.0196, 0.0235, 0.0206] }, + Ray { angle: 86.35, r0: 28.125, r1: 159.38, fade: 0.05, rgb: [0.0098, 0.0235, 0.0196] }, + Ray { angle: 92.47, r0: 20.312, r1: 178.91, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 93.39, r0: 117.97, r1: 404.69, fade: 0.05, rgb: [0.0255, 0.0275, 0.0275] }, + Ray { angle: 104.37, r0: 50.0, r1: 417.19, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 109.6, r0: 119.53, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 110.66, r0: 27.344, r1: 285.94, fade: 0.05, rgb: [0.0186, 0.0294, 0.0255] }, + Ray { angle: 112.97, r0: 139.84, r1: 439.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 116.69, r0: 67.969, r1: 408.59, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 118.78, r0: 43.75, r1: 267.19, fade: 0.05, rgb: [0.0157, 0.0235, 0.0196] }, + Ray { angle: 129.12, r0: 77.344, r1: 371.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 131.03, r0: 47.656, r1: 353.12, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 135.41, r0: 47.656, r1: 196.88, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 136.28, r0: 141.41, r1: 324.22, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 138.17, r0: 74.219, r1: 314.06, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 145.43, r0: 72.656, r1: 284.38, fade: 0.05, rgb: [0.0098, 0.0127, 0.0118] }, + Ray { angle: 147.38, r0: 54.688, r1: 278.12, fade: 0.05, rgb: [0.0078, 0.0137, 0.0098] }, + Ray { angle: 148.84, r0: 135.94, r1: 269.53, fade: 0.05, rgb: [0.0118, 0.0147, 0.0137] }, + Ray { angle: 149.99, r0: 58.594, r1: 132.03, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 152.57, r0: 64.062, r1: 263.28, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 153.61, r0: 100.78, r1: 260.94, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 155.92, r0: 25.0, r1: 256.25, fade: 0.05, rgb: [0.0196, 0.0255, 0.0235] }, + Ray { angle: 156.99, r0: 29.688, r1: 167.19, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 161.68, r0: 21.875, r1: 246.88, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 163.07, r0: 73.438, r1: 244.53, fade: 0.05, rgb: [0.0235, 0.0294, 0.0294] }, + Ray { angle: 168.83, r0: 27.344, r1: 238.28, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 169.09, r0: 26.562, r1: 165.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 169.35, r0: 24.219, r1: 238.28, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 171.23, r0: 133.59, r1: 232.81, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 172.33, r0: 42.969, r1: 113.28, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 176.44, r0: 70.312, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 183.1, r0: 144.53, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 186.51, r0: 45.312, r1: 235.16, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 193.72, r0: 26.562, r1: 240.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 194.6, r0: 124.22, r1: 189.84, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 200.85, r0: 74.219, r1: 215.62, fade: 0.05, rgb: [0.0078, 0.0088, 0.0078] }, + Ray { angle: 202.7, r0: 119.53, r1: 253.91, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 206.99, r0: 39.062, r1: 121.09, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 208.03, r0: 114.06, r1: 264.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 209.13, r0: 60.156, r1: 155.47, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 213.3, r0: 33.594, r1: 279.69, fade: 0.05, rgb: [0.0216, 0.0294, 0.0275] }, + Ray { angle: 217.06, r0: 90.625, r1: 292.97, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 218.09, r0: 85.156, r1: 286.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 220.45, r0: 71.875, r1: 300.78, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 224.21, r0: 116.41, r1: 279.69, fade: 0.05, rgb: [0.0157, 0.0206, 0.0196] }, + Ray { angle: 225.0, r0: 25.0, r1: 153.91, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 225.99, r0: 99.219, r1: 262.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 227.35, r0: 92.188, r1: 264.84, fade: 0.05, rgb: [0.0078, 0.0088, 0.0078] }, + Ray { angle: 229.33, r0: 53.125, r1: 163.28, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 232.21, r0: 59.375, r1: 246.88, fade: 0.05, rgb: [0.0137, 0.0235, 0.0196] }, + Ray { angle: 234.55, r0: 37.5, r1: 182.03, fade: 0.05, rgb: [0.0118, 0.0235, 0.0216] }, + Ray { angle: 240.32, r0: 72.656, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 241.75, r0: 80.469, r1: 221.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 243.65, r0: 65.625, r1: 217.19, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 245.61, r0: 67.969, r1: 214.06, fade: 0.05, rgb: [0.0098, 0.0157, 0.0147] }, + Ray { angle: 250.9, r0: 56.25, r1: 206.25, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 254.7, r0: 88.281, r1: 149.22, fade: 0.05, rgb: [0.0078, 0.0118, 0.0078] }, + Ray { angle: 259.82, r0: 49.219, r1: 197.66, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 263.29, r0: 128.91, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 264.18, r0: 103.12, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 269.05, r0: 43.75, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 276.15, r0: 71.094, r1: 196.09, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 283.38, r0: 85.938, r1: 200.0, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 284.81, r0: 139.06, r1: 201.56, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 285.69, r0: 60.156, r1: 202.34, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 287.51, r0: 62.5, r1: 204.69, fade: 0.05, rgb: [0.0176, 0.0255, 0.0235] }, + Ray { angle: 288.62, r0: 103.91, r1: 205.47, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 295.82, r0: 42.188, r1: 216.41, fade: 0.05, rgb: [0.0157, 0.0225, 0.0196] }, + Ray { angle: 297.53, r0: 198.44, r1: 213.28, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 299.16, r0: 139.84, r1: 223.44, fade: 0.05, rgb: [0.0118, 0.0127, 0.0137] }, + Ray { angle: 299.88, r0: 35.156, r1: 208.59, fade: 0.05, rgb: [0.0118, 0.0216, 0.0196] }, + Ray { angle: 302.95, r0: 42.969, r1: 232.03, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 303.92, r0: 118.75, r1: 235.16, fade: 0.05, rgb: [0.0147, 0.0176, 0.0167] }, + Ray { angle: 305.74, r0: 76.562, r1: 227.34, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 306.33, r0: 189.06, r1: 242.19, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 310.14, r0: 73.438, r1: 255.47, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 319.2, r0: 78.125, r1: 298.44, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 320.63, r0: 59.375, r1: 307.81, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 322.58, r0: 60.938, r1: 321.09, fade: 0.05, rgb: [0.0196, 0.0255, 0.0235] }, + Ray { angle: 337.64, r0: 49.219, r1: 136.72, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 340.61, r0: 32.031, r1: 376.56, fade: 0.05, rgb: [0.0275, 0.0353, 0.0314] }, + Ray { angle: 344.88, r0: 25.781, r1: 169.53, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 345.83, r0: 75.781, r1: 263.28, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, +]; +const ZOOM_200_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.3625, 0.3726, 0.4423]), + (0.8, [0.3691, 0.3775, 0.4453]), + (39.2, [0.3683, 0.3739, 0.4442]), + (69.6, [0.3817, 0.3856, 0.4472]), + (110.4, [0.4177, 0.4213, 0.4617]), + (149.6, [0.4662, 0.4729, 0.4832]), + (182.4, [0.5306, 0.5391, 0.5134]), + (216.8, [0.6266, 0.651, 0.5531]), + (233.6, [0.6799, 0.6727, 0.5738]), + (239.2, [0.6988, 0.6662, 0.5814]), + (259.2, [0.7592, 0.6409, 0.5601]), + (266.4, [0.7581, 0.6298, 0.5527]), + (286.4, [0.7018, 0.5604, 0.5248]), + (298.4, [0.6811, 0.545, 0.5285]), + (304.8, [0.6329, 0.5083, 0.5229]), + (315.2, [0.5491, 0.464, 0.4571]), + (327.2, [0.4265, 0.3719, 0.3772]), + (341.6, [0.2907, 0.2651, 0.3093]), + (348.0, [0.2412, 0.227, 0.2806]), + (356.8, [0.1877, 0.1811, 0.243]), + (364.8, [0.153, 0.1509, 0.217]), + (372.8, [0.1328, 0.1304, 0.198]), + (384.0, [0.1226, 0.1175, 0.1771]), + (393.6, [0.1215, 0.1177, 0.1681]), + (403.2, [0.1301, 0.1295, 0.1601]), + (412.0, [0.1471, 0.1447, 0.1701]), + (417.6, [0.1632, 0.159, 0.1761]), + (427.2, [0.2038, 0.1946, 0.194]), + (441.6, [0.2811, 0.2572, 0.2256]), + (461.6, [0.4147, 0.3507, 0.2815]), + (467.2, [0.4568, 0.3592, 0.2962]), + (470.4, [0.4751, 0.3357, 0.3009]), + (474.4, [0.4432, 0.2695, 0.3114]), + (478.4, [0.4136, 0.2159, 0.329]), + (482.4, [0.4001, 0.1827, 0.3485]), + (484.8, [0.3998, 0.1706, 0.355]), + (491.2, [0.3992, 0.1517, 0.3688]), + (494.4, [0.3979, 0.1504, 0.3717]), + (497.6, [0.3973, 0.1479, 0.3682]), + (506.4, [0.3905, 0.155, 0.3519]), + (515.2, [0.3696, 0.1647, 0.3186]), + (521.6, [0.3476, 0.1706, 0.2849]), + (525.6, [0.3305, 0.1705, 0.2573]), + (531.2, [0.297, 0.1675, 0.2153]), + (538.4, [0.243, 0.1549, 0.1593]), + (547.2, [0.1635, 0.134, 0.0893]), + (552.8, [0.0994, 0.1086, 0.0377]), + (556.0, [0.0567, 0.0926, 0.0091]), + (556.8, [0.0439, 0.0886, 0.0016]), + (557.6, [0.0331, 0.0849, -0.0083]), + (558.4, [0.0217, 0.0779, -0.015]), + (559.2, [0.0065, 0.0732, -0.0243]), + (560.8, [-0.0185, 0.0616, -0.041]), + (563.2, [-0.0611, 0.0413, -0.0688]), + (566.4, [-0.1265, 0.0088, -0.1066]), + (567.2, [-0.1418, -0.0022, -0.1164]), + (568.0, [-0.1592, -0.0095, -0.1272]), + (571.2, [-0.2288, -0.0476, -0.1628]), + (573.6, [-0.2833, -0.0792, -0.1872]), + (579.2, [-0.4091, -0.1594, -0.2315]), + (587.2, [-0.5751, -0.2766, -0.2762]), + (592.0, [-0.684, -0.354, -0.3165]), + (594.4, [-0.7244, -0.3841, -0.3191]), + (596.0, [-0.7356, -0.3922, -0.3034]), + (596.8, [-0.7359, -0.3972, -0.2895]), + (597.6, [-0.7299, -0.3991, -0.2646]), + (598.4, [-0.7076, -0.3841, -0.2309]), + (599.2, [-0.6684, -0.356, -0.1805]), + (600.0, [-0.6161, -0.3178, -0.1257]), + (601.6, [-0.4868, -0.2199, -0.0243]), + (602.4, [-0.4085, -0.1604, 0.0155]), + (603.2, [-0.3233, -0.0985, 0.0528]), + (604.0, [-0.2429, -0.0358, 0.0829]), + (604.8, [-0.149, 0.0226, 0.1111]), + (605.6, [-0.056, 0.0758, 0.1241]), + (606.4, [0.029, 0.1183, 0.1378]), + (607.2, [0.1047, 0.1543, 0.1493]), + (608.0, [0.1721, 0.1749, 0.158]), + (608.8, [0.2285, 0.1958, 0.157]), + (610.4, [0.2956, 0.2205, 0.1619]), + (611.2, [0.316, 0.2231, 0.162]), + (612.0, [0.3299, 0.2289, 0.1558]), + (614.4, [0.3545, 0.243, 0.1515]), + (615.2, [0.3629, 0.2389, 0.1446]), + (617.6, [0.3535, 0.2354, 0.1306]), + (620.0, [0.3318, 0.2091, 0.1127]), + (621.6, [0.315, 0.1935, 0.1055]), + (622.4, [0.2954, 0.1754, 0.093]), + (625.6, [0.2333, 0.1166, 0.0585]), + (628.0, [0.19, 0.0897, 0.046]), + (628.8, [0.1703, 0.0793, 0.0326]), + (630.4, [0.134, 0.0595, 0.0199]), + (631.2, [0.1184, 0.0527, 0.0156]), + (632.0, [0.1027, 0.0471, 0.0157]), + (632.8, [0.0946, 0.0428, 0.008]), + (633.6, [0.0861, 0.0297, 0.0045]), + (634.4, [0.0761, 0.0243, 0.0032]), + (635.2, [0.0602, 0.0198, 0.0039]), + (636.8, [0.0437, 0.0128, 0.0139]), + (637.6, [0.0364, 0.0119, 0.0206]), + (640.0, [0.0232, 0.0266, 0.0405]), + (640.8, [0.0206, 0.0335, 0.0518]), + (641.6, [0.0193, 0.0313, 0.0617]), + (642.4, [0.0184, 0.0357, 0.0692]), + (643.2, [0.0266, 0.0402, 0.0737]), + (644.0, [0.0338, 0.0443, 0.074]), + (645.6, [0.0447, 0.0527, 0.0829]), + (647.2, [0.0372, 0.0515, 0.0774]), + (648.0, [0.0284, 0.0508, 0.07]), + (649.6, [0.0022, 0.0506, 0.0597]), + (650.4, [-0.0067, 0.0395, 0.0538]), + (651.2, [-0.0149, 0.0319, 0.0483]), + (652.0, [-0.0216, 0.0269, 0.0468]), + (652.8, [-0.0258, 0.024, 0.0453]), + (653.6, [-0.024, 0.0232, 0.0442]), + (654.4, [-0.0169, 0.028, 0.0433]), + (656.0, [0.004, 0.0271, 0.0441]), + (656.8, [0.0176, 0.0295, 0.0356]), + (657.6, [0.0237, 0.0325, 0.0309]), + (659.2, [0.0466, 0.0369, 0.02]), + (660.0, [0.0551, 0.0289, 0.0171]), + (660.8, [0.0635, 0.0226, 0.0109]), + (661.6, [0.0605, 0.0158, 0.0059]), + (662.4, [0.063, 0.0095, 0.0025]), + (663.2, [0.0677, 0.0046, 0.002]), + (664.0, [0.0748, 0.0027, 0.0048]), + (664.8, [0.0861, 0.005, 0.0028]), + (667.2, [0.0811, 0.0191, 0.0233]), + (668.8, [0.0692, 0.0253, 0.035]), + (669.6, [0.0651, 0.0166, 0.0282]), + (670.4, [0.0535, 0.014, 0.0267]), + (671.2, [0.0455, 0.0121, 0.0254]), + (673.6, [0.0313, 0.0136, 0.0249]), + (674.4, [0.0219, 0.0085, 0.0175]), + (676.0, [0.0158, 0.0125, 0.0158]), + (677.6, [0.003, 0.0103, 0.0076]), + (678.4, [0.0006, 0.0083, 0.0028]), + (679.2, [0.0002, 0.0085, 0.0044]), + (680.0, [0.0052, 0.0084, 0.0077]), + (681.6, [0.0169, 0.0228, 0.0187]), + (682.4, [0.0179, 0.0278, 0.0228]), + (683.2, [0.0147, 0.0306, 0.0254]), + (684.0, [0.0063, 0.0347, 0.0294]), + (684.8, [0.0091, 0.0435, 0.0273]), + (686.4, [0.0204, 0.0328, 0.0317]), + (687.2, [0.0222, 0.0281, 0.0356]), + (688.0, [0.0196, 0.0229, 0.0391]), + (688.8, [0.0093, 0.0181, 0.0432]), + (689.6, [0.0105, 0.0154, 0.0494]), + (690.4, [0.0113, 0.0142, 0.0367]), + (691.2, [0.0113, 0.0193, 0.0254]), + (692.8, [0.0108, 0.0054, 0.0031]), + (693.6, [0.0159, -0.0041, -0.0119]), + (694.4, [0.0607, -0.0115, -0.0256]), + (695.2, [0.0829, 0.0035, -0.0052]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.6333, 0.6162, 0.5536]), + (38.4, [0.6351, 0.6207, 0.5559]), + (69.6, [0.6218, 0.6092, 0.553]), + (114.4, [0.582, 0.5696, 0.5368]), + (153.6, [0.5323, 0.5166, 0.5148]), + (177.6, [0.4858, 0.4694, 0.4928]), + (194.4, [0.4443, 0.4233, 0.4744]), + (216.0, [0.3822, 0.3496, 0.4493]), + (232.8, [0.3295, 0.2717, 0.4287]), + (235.2, [0.3217, 0.2662, 0.4258]), + (266.4, [0.2249, 0.2072, 0.3548]), + (285.6, [0.2111, 0.2158, 0.3183]), + (288.8, [0.2079, 0.2131, 0.3104]), + (296.0, [0.1873, 0.1979, 0.2855]), + (300.8, [0.1984, 0.1973, 0.2636]), + (304.8, [0.2159, 0.2129, 0.2599]), + (314.4, [0.259, 0.2251, 0.2915]), + (327.2, [0.3476, 0.2852, 0.336]), + (340.8, [0.4316, 0.3482, 0.3603]), + (347.2, [0.4613, 0.3707, 0.3703]), + (360.0, [0.4963, 0.4002, 0.3852]), + (374.4, [0.494, 0.4009, 0.3832]), + (386.4, [0.4682, 0.3836, 0.3695]), + (396.0, [0.4403, 0.3582, 0.3557]), + (402.4, [0.417, 0.3355, 0.3441]), + (418.4, [0.3375, 0.264, 0.2862]), + (429.6, [0.2597, 0.1982, 0.2389]), + (440.8, [0.1718, 0.1253, 0.1886]), + (452.0, [0.0718, 0.0465, 0.1342]), + (457.6, [0.0204, 0.0099, 0.1061]), + (460.8, [-0.0073, -0.0083, 0.0908]), + (463.2, [-0.0299, -0.0194, 0.0785]), + (465.6, [-0.0515, -0.0262, 0.0686]), + (466.4, [-0.0626, -0.0288, 0.0638]), + (468.8, [-0.0845, -0.0204, 0.0539]), + (469.6, [-0.0879, -0.0132, 0.0524]), + (470.4, [-0.0881, -0.0037, 0.0472]), + (472.0, [-0.0798, 0.022, 0.0406]), + (473.6, [-0.0676, 0.0449, 0.0343]), + (474.4, [-0.0598, 0.0569, 0.0283]), + (475.2, [-0.0567, 0.0652, 0.0249]), + (476.0, [-0.0516, 0.0751, 0.0181]), + (477.6, [-0.0462, 0.0908, 0.0088]), + (478.4, [-0.044, 0.099, 0.0023]), + (479.2, [-0.0434, 0.1033, -0.0018]), + (480.0, [-0.0409, 0.1088, -0.0089]), + (480.8, [-0.0423, 0.113, -0.0135]), + (481.6, [-0.0399, 0.1164, -0.0189]), + (482.4, [-0.0429, 0.1205, -0.024]), + (483.2, [-0.0422, 0.1217, -0.0276]), + (485.6, [-0.0501, 0.1273, -0.0408]), + (488.8, [-0.0563, 0.1316, -0.0525]), + (489.6, [-0.0594, 0.1306, -0.0567]), + (491.2, [-0.0622, 0.1325, -0.0596]), + (492.8, [-0.062, 0.1305, -0.0631]), + (493.6, [-0.065, 0.1282, -0.0669]), + (494.4, [-0.0641, 0.1275, -0.0678]), + (497.6, [-0.0712, 0.1234, -0.0707]), + (501.6, [-0.078, 0.1116, -0.0715]), + (504.8, [-0.0827, 0.1039, -0.0697]), + (508.8, [-0.0827, 0.095, -0.0647]), + (514.4, [-0.0784, 0.0776, -0.0529]), + (519.2, [-0.0721, 0.0647, -0.0371]), + (520.0, [-0.0691, 0.063, -0.0329]), + (520.8, [-0.07, 0.0631, -0.0308]), + (521.6, [-0.0663, 0.0595, -0.0254]), + (522.4, [-0.0664, 0.0584, -0.0202]), + (525.6, [-0.0563, 0.0555, -0.0076]), + (527.2, [-0.0508, 0.0514, 0.0034]), + (528.0, [-0.0463, 0.0521, 0.0056]), + (528.8, [-0.0451, 0.0494, 0.0103]), + (530.4, [-0.0355, 0.0508, 0.0197]), + (531.2, [-0.0325, 0.0489, 0.0248]), + (532.8, [-0.021, 0.0515, 0.0334]), + (533.6, [-0.0187, 0.0488, 0.0395]), + (534.4, [-0.0127, 0.0496, 0.0439]), + (536.0, [-0.0052, 0.0496, 0.0558]), + (536.8, [0.0008, 0.0499, 0.0588]), + (538.4, [0.0098, 0.0502, 0.0705]), + (539.2, [0.0166, 0.0499, 0.073]), + (540.0, [0.0193, 0.0508, 0.0795]), + (540.8, [0.0259, 0.0531, 0.0862]), + (541.6, [0.0305, 0.0516, 0.0899]), + (548.8, [0.0881, 0.0641, 0.137]), + (551.2, [0.1133, 0.0746, 0.1558]), + (552.0, [0.1209, 0.0748, 0.1612]), + (557.6, [0.19, 0.0965, 0.2059]), + (563.2, [0.2771, 0.1343, 0.2608]), + (568.8, [0.3844, 0.1874, 0.3193]), + (572.8, [0.4663, 0.233, 0.3532]), + (576.8, [0.551, 0.2859, 0.3823]), + (586.4, [0.7353, 0.4119, 0.4223]), + (591.2, [0.8376, 0.4824, 0.4573]), + (594.4, [0.8812, 0.5129, 0.4523]), + (596.0, [0.8836, 0.522, 0.422]), + (596.8, [0.8734, 0.5188, 0.3962]), + (597.6, [0.8473, 0.5018, 0.36]), + (599.2, [0.7513, 0.4335, 0.2469]), + (600.0, [0.6856, 0.3825, 0.1952]), + (600.8, [0.6151, 0.3298, 0.1443]), + (601.6, [0.5357, 0.2694, 0.1018]), + (602.4, [0.4489, 0.2065, 0.0647]), + (603.2, [0.3649, 0.1402, 0.0347]), + (604.0, [0.2697, 0.0811, 0.0065]), + (604.8, [0.1772, 0.0287, -0.0089]), + (605.6, [0.0898, -0.0129, -0.0223]), + (606.4, [0.0133, -0.0493, -0.0336]), + (607.2, [-0.0537, -0.0719, -0.0425]), + (608.0, [-0.1102, -0.0927, -0.044]), + (608.8, [-0.1491, -0.1074, -0.047]), + (609.6, [-0.1778, -0.1165, -0.0485]), + (610.4, [-0.1972, -0.1229, -0.0483]), + (611.2, [-0.2107, -0.1282, -0.0461]), + (613.6, [-0.2379, -0.1438, -0.0413]), + (614.4, [-0.2482, -0.141, -0.0383]), + (616.0, [-0.2441, -0.1386, -0.0302]), + (616.8, [-0.2385, -0.1382, -0.0235]), + (620.0, [-0.2119, -0.1059, -0.0056]), + (620.8, [-0.2049, -0.0997, -0.0044]), + (621.6, [-0.1867, -0.0831, 0.0064]), + (623.2, [-0.1545, -0.0545, 0.0224]), + (624.0, [-0.145, -0.0401, 0.0307]), + (624.8, [-0.129, -0.0292, 0.0362]), + (626.4, [-0.0989, -0.0118, 0.0438]), + (627.2, [-0.0884, -0.0022, 0.049]), + (628.0, [-0.0692, 0.0085, 0.0588]), + (628.8, [-0.0507, 0.0157, 0.0655]), + (630.4, [-0.0181, 0.0303, 0.0751]), + (631.2, [-0.0072, 0.0357, 0.0725]), + (632.0, [0.0008, 0.0396, 0.0789]), + (632.8, [0.0095, 0.0493, 0.0821]), + (634.4, [0.0314, 0.0589, 0.0819]), + (636.0, [0.0475, 0.0656, 0.0673]), + (636.8, [0.0547, 0.0617, 0.0604]), + (637.6, [0.0546, 0.0576, 0.053]), + (638.4, [0.0597, 0.0524, 0.0462]), + (639.2, [0.0633, 0.0464, 0.0358]), + (640.0, [0.0657, 0.0392, 0.0245]), + (640.8, [0.0667, 0.0381, 0.0149]), + (641.6, [0.0624, 0.0337, 0.0078]), + (643.2, [0.0472, 0.0252, -0.0012]), + (644.0, [0.0426, 0.0224, -0.007]), + (644.8, [0.0342, 0.0142, -0.009]), + (645.6, [0.0368, 0.0147, -0.0075]), + (646.4, [0.042, 0.0147, -0.0023]), + (648.0, [0.0639, 0.0168, 0.0056]), + (648.8, [0.0735, 0.0141, 0.0114]), + (649.6, [0.0823, 0.0243, 0.0173]), + (650.4, [0.0903, 0.0313, 0.0226]), + (651.2, [0.0967, 0.0361, 0.0198]), + (652.0, [0.1003, 0.0386, 0.0211]), + (652.8, [0.0933, 0.039, 0.0222]), + (653.6, [0.0858, 0.031, 0.0231]), + (654.4, [0.0761, 0.0319, 0.0237]), + (655.2, [0.0641, 0.031, 0.021]), + (656.0, [0.0502, 0.0284, 0.0275]), + (658.4, [0.0182, 0.0198, 0.0433]), + (659.2, [0.0099, 0.0265, 0.0426]), + (660.0, [0.0007, 0.033, 0.0485]), + (660.8, [0.0015, 0.0397, 0.0533]), + (662.4, [-0.0063, 0.0505, 0.0564]), + (663.2, [-0.0139, 0.0479, 0.0532]), + (664.0, [-0.0271, 0.045, 0.0509]), + (665.6, [-0.0254, 0.0353, 0.0372]), + (666.4, [-0.022, 0.0309, 0.0304]), + (668.0, [-0.0097, 0.0249, 0.019]), + (668.8, [-0.0079, 0.0307, 0.0229]), + (669.6, [0.0025, 0.0332, 0.0244]), + (670.4, [0.0102, 0.0349, 0.0256]), + (671.2, [0.0163, 0.0353, 0.026]), + (672.8, [0.0237, 0.0329, 0.0259]), + (673.6, [0.029, 0.034, 0.0295]), + (675.2, [0.0356, 0.0302, 0.0314]), + (676.8, [0.0487, 0.0329, 0.0402]), + (677.6, [0.0469, 0.0348, 0.0447]), + (678.4, [0.046, 0.034, 0.0382]), + (679.2, [0.0405, 0.0296, 0.0346]), + (680.8, [0.0294, 0.0152, 0.0235]), + (681.6, [0.0291, 0.0108, 0.0197]), + (683.2, [0.0364, 0.0034, 0.0125]), + (684.0, [0.033, -0.0068, 0.0119]), + (685.6, [0.0219, 0.003, 0.0068]), + (686.4, [0.0208, 0.0079, 0.0029]), + (688.0, [0.0297, 0.0176, -0.0049]), + (688.8, [0.0282, 0.02, -0.0121]), + (689.6, [0.0275, 0.0207, 0.0003]), + (690.4, [0.0274, 0.0123, 0.0112]), + (692.0, [0.0278, 0.0269, 0.0338]), + (692.8, [0.016, 0.0363, 0.049]), + (693.6, [-0.039, 0.0428, 0.0618]), + (694.4, [0.0327, 0.0014, -0.002]), + (695.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.49534, + perp: 0.077, + knots: &[ + (0.0, [0.002, 0.0091, 0.0027]), + (0.79967, [-0.0043, 0.0051, -0.0002]), + (1.5993, [-0.0027, 0.006, 0.0003]), + (195.92, [-0.0045, 0.004, -0.0007]), + (271.89, [-0.0069, 0.0018, -0.0017]), + (278.28, [-0.0077, -0.0002, -0.0018]), + (328.66, [-0.0096, -0.0025, -0.0031]), + (372.64, [-0.0076, -0.001, -0.0028]), + (391.84, [-0.0028, 0.0021, -0.0007]), + (398.23, [-0.0046, 0.0012, 0.0007]), + (405.43, [-0.0048, 0.0014, -0.0019]), + (410.23, [-0.0038, 0.0024, -0.0014]), + (467.0, [-0.0071, -0.0006, -0.0027]), + (513.39, [-0.0132, -0.0055, -0.0061]), + (546.17, [-0.0156, -0.0062, -0.0079]), + (565.36, [-0.0139, -0.0042, -0.0087]), + (632.54, [-0.0132, -0.0065, -0.0071]), + (654.93, [-0.0095, -0.0047, -0.0049]), + (683.71, [-0.0015, -0.0007, -0.0016]), + ], + }, + Radial { t: 0.8008, perp: -0.19394, knots: &[(0.0, [0.0024, 0.0016, 0.0012]), (3.1854, [0.0019, 0.0013, 0.0009])] }, + Radial { + t: 1.271, + perp: 0.29732, + knots: &[ + (0.0, [0.0089, 0.0922, 0.0416]), + (0.79661, [0.0086, 0.0811, 0.0382]), + (1.5932, [0.0087, 0.0745, 0.0338]), + (3.1864, [0.0086, 0.0397, 0.024]), + (3.983, [0.0095, 0.0312, 0.0214]), + (5.5762, [0.0094, 0.0206, 0.0178]), + (6.3728, [0.0087, 0.013, 0.016]), + (8.7627, [0.0071, 0.0033, 0.011]), + (9.5593, [0.0066, 0.0018, 0.0107]), + (15.135, [0.0061, 0.0031, 0.0053]), + (19.119, [0.0013, 0.0, 0.0014]), + ], + }, + Radial { + t: 1.4494, + perp: 0.20085, + knots: &[ + (0.0, [1.516, 1.313, 1.143]), + (5.5792, [1.463, 1.274, 1.101]), + (9.5643, [1.354, 1.196, 1.017]), + (15.143, [1.114, 1.022, 0.835]), + (23.114, [0.7349, 0.7341, 0.4867]), + (25.505, [0.6323, 0.6548, 0.3927]), + (28.693, [0.5081, 0.5531, 0.2848]), + (31.881, [0.3964, 0.4529, 0.197]), + (34.272, [0.3278, 0.385, 0.1459]), + (36.663, [0.272, 0.3236, 0.107]), + (38.257, [0.2409, 0.2883, 0.088]), + (39.851, [0.2128, 0.2551, 0.0735]), + (43.039, [0.1675, 0.2001, 0.0557]), + (45.43, [0.1429, 0.1679, 0.0491]), + (47.822, [0.126, 0.1434, 0.0454]), + (51.01, [0.1135, 0.122, 0.0424]), + (54.198, [0.105, 0.1076, 0.0399]), + (55.792, [0.0698, 0.071, 0.0382]), + (62.965, [0.0417, 0.0463, 0.0269]), + (70.138, [0.0267, 0.0322, 0.0178]), + (75.717, [0.0186, 0.0238, 0.0131]), + (96.44, [0.0033, 0.0049, 0.0026]), + (109.19, [-0.0027, -0.0019, -0.0016]), + (129.92, [-0.002, -0.0013, -0.001]), + ], + }, + Radial { + t: 1.4098, + perp: 0.0, + knots: &[ + (0.0, [-0.5004, -0.3986, -0.343]), + (0.7959, [-0.49, -0.3992, -0.3318]), + (5.5713, [-0.4694, -0.3848, -0.3193]), + (10.347, [-0.4158, -0.3466, -0.2849]), + (14.326, [-0.3525, -0.2988, -0.2377]), + (19.102, [-0.2684, -0.2363, -0.1671]), + (24.673, [-0.1868, -0.1744, -0.0871]), + (27.856, [-0.1438, -0.1435, -0.055]), + (29.448, [-0.1249, -0.1269, -0.0428]), + (31.04, [-0.1172, -0.1247, -0.0321]), + (32.632, [-0.1013, -0.1115, -0.0217]), + (35.019, [-0.0781, -0.0891, -0.0086]), + (36.611, [-0.066, -0.0743, -0.0027]), + (39.795, [-0.0496, -0.0548, 0.0031]), + (42.978, [-0.0349, -0.037, 0.0038]), + (46.958, [-0.0247, -0.0221, 0.0021]), + (49.346, [-0.0151, -0.0119, 0.001]), + (52.529, [-0.0067, -0.0043, 0.0002]), + (54.917, [-0.0015, -0.0007, -0.0002]), + ], + }, + Radial { + t: 1.4716, + perp: 0.0, + knots: &[ + (0.0, [-1.06, -0.9091, -0.8607]), + (3.9791, [-1.045, -0.8977, -0.8451]), + (9.5498, [-0.9612, -0.8348, -0.7743]), + (15.916, [-0.7813, -0.7181, -0.6324]), + (23.875, [-0.5137, -0.5119, -0.3796]), + (26.262, [-0.4463, -0.4611, -0.3106]), + (29.445, [-0.3599, -0.3905, -0.2324]), + (32.629, [-0.2831, -0.3205, -0.1695]), + (35.812, [-0.2203, -0.2571, -0.1208]), + (38.995, [-0.1725, -0.2026, -0.0865]), + (42.178, [-0.1352, -0.158, -0.0677]), + (46.157, [-0.1055, -0.1181, -0.0567]), + (50.137, [-0.0859, -0.0906, -0.051]), + (51.728, [-0.0897, -0.087, -0.049]), + (54.116, [-0.0879, -0.0817, -0.0459]), + (58.095, [-0.0713, -0.0714, -0.0408]), + (60.482, [-0.0575, -0.0597, -0.0365]), + (68.44, [-0.0371, -0.0413, -0.0237]), + (77.194, [-0.0221, -0.0264, -0.0145]), + (89.132, [-0.0114, -0.0136, -0.0078]), + (104.25, [-0.0019, -0.0019, -0.0014]), + ], + }, + Radial { + t: 1.6516, + perp: -0.17774, + knots: &[ + (0.0, [0.073, 0.1045, 0.2472]), + (3.1792, [0.0716, 0.1035, 0.2363]), + (5.5636, [0.0688, 0.0979, 0.2094]), + (8.7428, [0.0575, 0.0888, 0.1721]), + (10.332, [0.0548, 0.0853, 0.1547]), + (11.922, [0.057, 0.0893, 0.1385]), + (14.306, [0.0594, 0.0899, 0.1158]), + (17.486, [0.051, 0.0891, 0.0854]), + (19.075, [0.048, 0.0871, 0.0741]), + (21.46, [0.0444, 0.0803, 0.0624]), + (22.254, [0.0386, 0.0605, 0.0497]), + (23.049, [0.0305, 0.0303, 0.0286]), + (23.844, [0.0249, 0.0131, 0.0171]), + (25.434, [0.0189, 0.0081, 0.0137]), + (27.818, [0.0139, 0.0069, 0.0122]), + (34.971, [0.0107, 0.0056, 0.007]), + (44.509, [0.0031, 0.0015, 0.0013]), + ], + }, + Radial { + t: 2.0025, + perp: 0.1057, + knots: &[ + (0.0, [0.0077, 0.0082, 0.0022]), + (0.79904, [0.0044, 0.0068, 0.0031]), + (1.5981, [0.004, 0.0074, 0.0026]), + (15.981, [0.0039, 0.0049, 0.0036]), + (23.971, [0.0035, 0.0098, 0.0036]), + (25.569, [0.0071, 0.0108, 0.0036]), + (35.158, [0.0087, 0.0187, 0.0032]), + (46.344, [0.0135, 0.0315, 0.0031]), + (61.526, [0.0241, 0.0574, 0.0039]), + (71.913, [0.0337, 0.0795, 0.0028]), + (72.712, [0.0348, 0.0812, 0.0029]), + (73.511, [0.0446, 0.0855, 0.0032]), + (76.707, [0.0989, 0.1053, 0.0039]), + (77.507, [0.1042, 0.1074, 0.0044]), + (78.306, [0.0997, 0.1065, 0.0027]), + (79.105, [0.0703, 0.0572, 0.0022]), + (79.904, [0.0424, 0.0117, 0.0024]), + (81.502, [0.0149, 0.005, 0.0026]), + (82.301, [0.0046, 0.0026, 0.0026]), + (86.296, [0.0019, 0.0027, -0.0014]), + (89.492, [-0.0003, -0.0004, -0.0036]), + (93.487, [-0.004, -0.0034, -0.003]), + (95.884, [-0.0057, -0.0032, -0.0025]), + (102.28, [-0.003, -0.0017, -0.002]), + (135.84, [-0.0018, -0.001, -0.001]), + ], + }, + Radial { + t: 2.311, + perp: 0.22759, + knots: &[ + (0.0, [0.0023, 0.0095, 0.002]), + (3.1966, [0.0028, 0.0077, 0.0006]), + (3.9958, [0.0031, 0.0093, 0.0006]), + (12.787, [0.0034, 0.0095, 0.001]), + (19.18, [0.0086, 0.0116, 0.0008]), + (23.176, [0.0042, 0.0064, 0.0003]), + (27.971, [0.0032, 0.0047, 0.0011]), + (77.519, [0.0029, 0.0024, 0.0017]), + (113.48, [0.0002, 0.0002, 0.001]), + (136.66, [-0.0003, 0.0004, 0.0005]), + (141.45, [0.0013, 0.0015, 0.0007]), + (142.25, [0.0024, 0.0015, 0.0027]), + (143.05, [0.0102, 0.0015, 0.0194]), + (143.85, [0.0222, 0.0015, 0.0388]), + (144.65, [0.0301, 0.0016, 0.0578]), + (145.45, [0.041, 0.0016, 0.0762]), + (146.25, [0.0529, 0.0044, 0.0963]), + (147.05, [0.0648, 0.0197, 0.1066]), + (147.85, [0.0638, 0.0373, 0.1031]), + (149.44, [0.0616, 0.0721, 0.0837]), + (150.24, [0.0674, 0.0906, 0.0765]), + (151.04, [0.0739, 0.0999, 0.0626]), + (151.84, [0.0782, 0.0968, 0.0413]), + (154.24, [0.1045, 0.0755, 0.014]), + (155.04, [0.1004, 0.0603, 0.004]), + (155.84, [0.081, 0.0422, 0.0005]), + (156.64, [0.0631, 0.034, 0.0006]), + (159.03, [0.0051, 0.003, 0.0003]), + (159.83, [-0.0005, 0.0004, 0.0001]), + ], + }, +]; +const ZOOM_200_STREAKS: &[Streak] = &[]; +const ZOOM_200_RAYS: &[Ray] = &[]; +const ZOOM_300_RADIAL: &[Radial] = &[ + Radial { + t: 0.0016474, + perp: 0.19143, + knots: &[ + (0.0, [0.4969, 0.4969, 0.4969]), + (371.2, [0.4954, 0.4954, 0.4954]), + (487.2, [0.504, 0.504, 0.504]), + (539.2, [0.494, 0.494, 0.494]), + (568.0, [0.5075, 0.5075, 0.5075]), + (604.8, [0.5451, 0.5451, 0.5451]), + (624.0, [0.4924, 0.4924, 0.4924]), + (631.2, [0.5038, 0.5038, 0.5038]), + (680.0, [0.5024, 0.5024, 0.5024]), + (684.0, [0.4947, 0.4947, 0.4947]), + (689.6, [0.5146, 0.5146, 0.5146]), + (692.0, [0.491, 0.491, 0.491]), + (694.4, [0.421, 0.421, 0.421]), + (695.2, [0.5488, 0.5488, 0.5488]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 0.0064224, + perp: 0.067591, + knots: &[ + (0.0, [0.5024, 0.5024, 0.5024]), + (372.8, [0.504, 0.504, 0.504]), + (486.4, [0.4956, 0.4956, 0.4956]), + (538.4, [0.5056, 0.5056, 0.5056]), + (568.0, [0.4916, 0.4916, 0.4916]), + (604.0, [0.4548, 0.4548, 0.4548]), + (611.2, [0.469, 0.469, 0.469]), + (622.4, [0.5073, 0.5073, 0.5073]), + (629.6, [0.4962, 0.4962, 0.4962]), + (678.4, [0.4975, 0.4975, 0.4975]), + (683.2, [0.5052, 0.5052, 0.5052]), + (688.8, [0.4856, 0.4856, 0.4856]), + (691.2, [0.5117, 0.5117, 0.5117]), + (693.6, [0.5835, 0.5835, 0.5835]), + (694.4, [0.2167, 0.2167, 0.2167]), + (695.2, [0.0, 0.0, 0.0]), + ], + }, +]; +const ZOOM_300_STREAKS: &[Streak] = &[]; +const ZOOM_300_RAYS: &[Ray] = &[]; +pub(super) const ZOOM: &[Level] = &[ + Level { brightness: 10.0, radial: ZOOM_10_RADIAL, streaks: ZOOM_10_STREAKS, rays: ZOOM_10_RAYS }, + Level { brightness: 50.0, radial: ZOOM_50_RADIAL, streaks: ZOOM_50_STREAKS, rays: ZOOM_50_RAYS }, + Level { brightness: 75.0, radial: ZOOM_75_RADIAL, streaks: ZOOM_75_STREAKS, rays: ZOOM_75_RAYS }, + Level { brightness: 100.0, radial: ZOOM_100_RADIAL, streaks: ZOOM_100_STREAKS, rays: ZOOM_100_RAYS }, + Level { brightness: 125.0, radial: ZOOM_125_RADIAL, streaks: ZOOM_125_STREAKS, rays: ZOOM_125_RAYS }, + Level { brightness: 150.0, radial: ZOOM_150_RADIAL, streaks: ZOOM_150_STREAKS, rays: ZOOM_150_RAYS }, + Level { brightness: 200.0, radial: ZOOM_200_RADIAL, streaks: ZOOM_200_STREAKS, rays: ZOOM_200_RAYS }, + Level { brightness: 300.0, radial: ZOOM_300_RADIAL, streaks: ZOOM_300_STREAKS, rays: ZOOM_300_RAYS }, +]; + +const PRIME35_10_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.4306, 0.4221, 0.4191]), + (0.8, [0.3931, 0.3809, 0.3715]), + (1.6, [0.3745, 0.3477, 0.3356]), + (3.2, [0.2997, 0.2508, 0.2307]), + (4.8, [0.2312, 0.168, 0.1416]), + (6.4, [0.1839, 0.1101, 0.0847]), + (7.2, [0.1635, 0.0878, 0.0616]), + (8.0, [0.1476, 0.0712, 0.0456]), + (8.8, [0.1388, 0.0606, 0.0373]), + (9.6, [0.1349, 0.0595, 0.0376]), + (10.4, [0.1271, 0.0544, 0.0377]), + (11.2, [0.1222, 0.0506, 0.0338]), + (14.4, [0.101, 0.0395, 0.0282]), + (19.2, [0.0742, 0.0238, 0.0176]), + (20.0, [0.0711, 0.0209, 0.0187]), + (23.2, [0.0526, 0.0137, 0.0134]), + (26.4, [0.039, 0.0088, 0.0097]), + (28.8, [0.0318, 0.0056, 0.0098]), + (33.6, [0.0178, 0.0043, 0.0069]), + (41.6, [0.0028, 0.003, 0.0052]), + (45.6, [0.0013, 0.003, 0.0053]), + (132.0, [0.0015, 0.0047, 0.0061]), + (143.2, [0.0015, 0.0072, 0.0101]), + (157.6, [0.0015, 0.0116, 0.0185]), + (167.2, [0.0015, 0.0128, 0.0205]), + (235.2, [0.0014, 0.0079, 0.0126]), + (275.2, [0.0014, 0.0017, 0.0032]), + (301.6, [0.0013, 0.0004, 0.0015]), + (311.2, [0.0012, -0.0022, -0.0006]), + (331.2, [0.0029, 0.0003, 0.001]), + (343.2, [0.0005, -0.0008, -0.0009]), + (497.6, [0.001, -0.0067, -0.0109]), + (526.4, [0.0009, -0.0034, -0.0095]), + (596.0, [0.0002, -0.0088, -0.0194]), + (600.0, [0.0, -0.0072, -0.0171]), + (608.0, [-0.0003, -0.0001, -0.0027]), + (611.2, [0.0001, 0.001, 0.0004]), + (626.4, [0.0, -0.0001, -0.0031]), + (640.8, [0.0, 0.0, 0.0019]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.5493, 0.5456, 0.5422]), + (0.8, [0.4942, 0.4678, 0.4573]), + (1.6, [0.477, 0.44, 0.4255]), + (3.2, [0.3855, 0.3195, 0.2949]), + (4.8, [0.3029, 0.2169, 0.1839]), + (6.4, [0.245, 0.1448, 0.1129]), + (7.2, [0.2166, 0.114, 0.0817]), + (8.0, [0.1972, 0.0942, 0.0614]), + (8.8, [0.1853, 0.0809, 0.0517]), + (9.6, [0.1805, 0.079, 0.0498]), + (10.4, [0.1694, 0.072, 0.049]), + (12.8, [0.1485, 0.0569, 0.0379]), + (14.4, [0.1348, 0.0505, 0.0342]), + (15.2, [0.1298, 0.0468, 0.0291]), + (16.0, [0.1231, 0.0438, 0.0291]), + (16.8, [0.1164, 0.0401, 0.0248]), + (17.6, [0.1098, 0.0348, 0.023]), + (18.4, [0.1037, 0.0332, 0.0215]), + (19.2, [0.0993, 0.0291, 0.0162]), + (20.0, [0.0952, 0.0251, 0.0178]), + (23.2, [0.0713, 0.015, 0.0098]), + (25.6, [0.0587, 0.0084, 0.0055]), + (27.2, [0.0515, 0.0067, 0.0021]), + (28.8, [0.0446, 0.0023, 0.0028]), + (30.4, [0.0381, 0.0021, -0.0006]), + (32.0, [0.0322, 0.0022, -0.0004]), + (32.8, [0.029, -0.0005, 0.0004]), + (34.4, [0.0249, -0.0003, -0.003]), + (36.8, [0.0173, 0.0001, -0.0028]), + (37.6, [0.0144, -0.0027, -0.0023]), + (39.2, [0.0118, -0.0028, -0.0054]), + (42.4, [0.0023, -0.003, -0.0052]), + (45.6, [-0.0019, -0.003, -0.0053]), + (132.0, [-0.0015, -0.0047, -0.0061]), + (143.2, [-0.0015, -0.0071, -0.0099]), + (157.6, [-0.0015, -0.0115, -0.0184]), + (167.2, [-0.0015, -0.0128, -0.0205]), + (236.0, [-0.0014, -0.0078, -0.0126]), + (275.2, [-0.0014, -0.0017, -0.0033]), + (301.6, [-0.0013, -0.0005, -0.0015]), + (311.2, [-0.0011, 0.0022, 0.0006]), + (331.2, [-0.003, -0.0003, -0.001]), + (343.2, [-0.0005, 0.0007, 0.0009]), + (498.4, [-0.001, 0.0067, 0.0109]), + (527.2, [-0.0009, 0.0034, 0.0096]), + (596.8, [-0.0002, 0.0088, 0.0195]), + (600.8, [0.0, 0.0074, 0.0173]), + (608.8, [0.0003, 0.0002, 0.0029]), + (612.0, [-0.0001, -0.0009, -0.0004]), + (620.8, [-0.0001, -0.0004, 0.0004]), + (627.2, [0.0, 0.0001, 0.0031]), + (641.6, [0.0, 0.0001, -0.0019]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.004, 0.0039, 0.0039]), + (31.962, [0.0043, 0.0039, 0.004]), + (33.56, [0.0002, 0.0039, 0.004]), + (49.542, [-0.0003, 0.0036, 0.0036]), + (151.82, [0.0, 0.0039, 0.0039]), + (152.62, [0.0, 0.0038, 0.0038]), + (153.42, [0.0, 0.001, 0.0009]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[(0.0, [0.0, 0.0001, 0.008]), (15.867, [0.0, 0.0001, 0.0081]), (33.32, [0.0, 0.0001, 0.0041]), (35.7, [0.0, 0.0001, 0.0014])], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[(0.0, [0.0005, 0.0005, 0.0005]), (4.7082, [0.0012, 0.0005, 0.0011]), (11.771, [0.0034, 0.0039, 0.0037]), (16.479, [0.0017, 0.0018, 0.0018])], + }, +]; +const PRIME35_10_STREAKS: &[Streak] = &[]; +const PRIME35_10_RAYS: &[Ray] = &[]; +const PRIME35_50_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.45, 1.46, 1.46]), + (0.8, [1.405, 1.415, 1.396]), + (1.6, [1.41, 1.428, 1.402]), + (4.8, [1.331, 1.368, 1.26]), + (8.0, [1.228, 1.236, 1.083]), + (11.2, [1.106, 1.055, 0.8968]), + (16.0, [0.8984, 0.7326, 0.6134]), + (19.2, [0.7595, 0.5209, 0.442]), + (20.8, [0.6893, 0.4211, 0.3628]), + (22.4, [0.62, 0.3296, 0.2944]), + (24.0, [0.5594, 0.2464, 0.2283]), + (24.8, [0.5323, 0.2069, 0.1995]), + (26.4, [0.4768, 0.1425, 0.1461]), + (27.2, [0.4472, 0.1167, 0.1206]), + (28.0, [0.4167, 0.0898, 0.1001]), + (28.8, [0.3959, 0.0704, 0.0851]), + (30.4, [0.3633, 0.0403, 0.0585]), + (31.2, [0.3483, 0.0263, 0.0498]), + (32.0, [0.334, 0.0158, 0.0398]), + (32.8, [0.3205, 0.0113, 0.0333]), + (34.4, [0.3001, 0.0067, 0.0314]), + (35.2, [0.2915, 0.0053, 0.0288]), + (36.8, [0.2738, 0.0059, 0.0286]), + (38.4, [0.2556, 0.0017, 0.0249]), + (40.8, [0.23, 0.0017, 0.0228]), + (42.4, [0.2088, -0.0025, 0.0143]), + (44.8, [0.1842, -0.0027, 0.0104]), + (46.4, [0.1704, -0.0067, 0.009]), + (48.0, [0.1551, -0.0067, 0.0084]), + (49.6, [0.141, -0.0138, 0.0045]), + (50.4, [0.1347, -0.0162, 0.0049]), + (52.0, [0.1265, -0.0164, 0.0036]), + (52.8, [0.1193, -0.0161, 0.0006]), + (55.2, [0.1011, -0.0206, 0.0007]), + (56.8, [0.0899, -0.0201, -0.0007]), + (59.2, [0.066, -0.0206, -0.0101]), + (60.8, [0.0561, -0.0257, -0.0107]), + (62.4, [0.0482, -0.0257, -0.0148]), + (63.2, [0.0544, -0.0252, -0.0163]), + (64.8, [0.0817, -0.0193, -0.0153]), + (66.4, [0.1104, -0.0179, -0.0151]), + (67.2, [0.1173, -0.0178, -0.0151]), + (68.0, [0.1069, -0.0183, -0.0167]), + (68.8, [0.0863, -0.0223, -0.0196]), + (70.4, [0.0404, -0.0325, -0.0207]), + (71.2, [0.0174, -0.035, -0.0204]), + (72.0, [-0.0008, -0.0349, -0.0201]), + (72.8, [-0.0077, -0.0348, -0.0199]), + (73.6, [-0.0112, -0.0348, -0.0198]), + (75.2, [-0.018, -0.0396, -0.0199]), + (76.8, [-0.025, -0.0398, -0.0255]), + (78.4, [-0.0354, -0.0395, -0.0307]), + (87.2, [-0.0578, -0.0373, -0.0285]), + (92.0, [-0.0572, -0.0363, -0.0273]), + (94.4, [-0.0568, -0.0415, -0.0266]), + (99.2, [-0.0563, -0.0403, -0.025]), + (100.8, [-0.0616, -0.0399, -0.0294]), + (108.8, [-0.0603, -0.0374, -0.0248]), + (123.2, [-0.0597, -0.0352, -0.0312]), + (124.8, [-0.0596, -0.0402, -0.0313]), + (128.0, [-0.0593, -0.0402, -0.0312]), + (129.6, [-0.0642, -0.0398, -0.036]), + (131.2, [-0.0651, -0.0392, -0.0365]), + (141.6, [-0.064, -0.0289, -0.0241]), + (156.8, [-0.0617, -0.01, 0.0052]), + (161.6, [-0.0612, -0.0056, 0.0118]), + (165.6, [-0.0609, -0.0032, 0.0143]), + (175.2, [-0.0605, -0.0012, 0.0141]), + (185.6, [-0.0598, 0.0014, 0.0172]), + (193.6, [-0.0592, 0.0002, 0.0156]), + (203.2, [-0.0586, -0.004, 0.0102]), + (232.8, [-0.0572, -0.024, -0.0138]), + (251.2, [-0.0566, -0.0248, -0.014]), + (277.6, [-0.055, -0.0401, -0.0319]), + (300.8, [-0.054, -0.0429, -0.0349]), + (311.2, [-0.0542, -0.0484, -0.0393]), + (328.0, [-0.0469, -0.0418, -0.0334]), + (342.4, [-0.0532, -0.0461, -0.0388]), + (371.2, [-0.0474, -0.0468, -0.0406]), + (408.8, [-0.0454, -0.049, -0.0421]), + (430.4, [-0.0448, -0.0485, -0.044]), + (464.8, [-0.0377, -0.0445, -0.0426]), + (482.4, [-0.0276, -0.0357, -0.0381]), + (491.2, [-0.0254, -0.032, -0.0366]), + (501.6, [-0.0167, -0.0253, -0.0329]), + (518.4, [-0.0056, -0.006, -0.0208]), + (526.4, [0.0009, 0.0079, -0.0112]), + (531.2, [0.0034, 0.014, -0.0073]), + (580.0, [0.0134, 0.0112, -0.0159]), + (596.0, [0.0151, 0.0101, -0.0227]), + (598.4, [0.0167, 0.0122, -0.0191]), + (601.6, [0.0221, 0.018, -0.0021]), + (603.2, [0.0233, 0.0187, 0.004]), + (605.6, [0.02, 0.0148, 0.0045]), + (609.6, [0.0036, 0.0, -0.0045]), + (611.2, [-0.0027, -0.0023, -0.0048]), + (612.8, [-0.0057, -0.0018, -0.0032]), + (614.4, [-0.0048, -0.0017, -0.0029]), + (617.6, [-0.0015, -0.0047, -0.008]), + (619.2, [-0.002, -0.0037, -0.008]), + (622.4, [-0.0012, 0.0014, -0.004]), + (624.8, [0.0011, 0.0009, -0.0067]), + (628.0, [-0.0005, -0.0006, -0.0091]), + (632.8, [-0.0008, -0.0011, -0.0032]), + (636.0, [0.0006, 0.0011, 0.0059]), + (639.2, [0.0008, 0.0008, 0.0075]), + (648.0, [-0.0001, -0.0001, -0.0009]), + (652.8, [-0.0003, -0.0002, -0.0017]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.4095, -0.4275, -0.4124]), + (0.8, [-0.4189, -0.4246, -0.4124]), + (2.4, [-0.4081, -0.4236, -0.4019]), + (4.8, [-0.3877, -0.408, -0.3656]), + (8.0, [-0.3548, -0.3649, -0.3099]), + (11.2, [-0.313, -0.3021, -0.2479]), + (15.2, [-0.2546, -0.2106, -0.1686]), + (20.0, [-0.1837, -0.1018, -0.0831]), + (20.8, [-0.1735, -0.0884, -0.0722]), + (21.6, [-0.1606, -0.0708, -0.059]), + (23.2, [-0.1385, -0.0421, -0.039]), + (24.0, [-0.1282, -0.0269, -0.0264]), + (25.6, [-0.1099, -0.0017, -0.0097]), + (27.2, [-0.0908, 0.0162, 0.0084]), + (28.0, [-0.0798, 0.0239, 0.014]), + (28.8, [-0.074, 0.0307, 0.0187]), + (29.6, [-0.0691, 0.0343, 0.0231]), + (32.0, [-0.0538, 0.0469, 0.0314]), + (32.8, [-0.0495, 0.0472, 0.0337]), + (40.8, [-0.0224, 0.0451, 0.0311]), + (42.4, [-0.0151, 0.046, 0.0341]), + (48.0, [0.0011, 0.0446, 0.0332]), + (49.6, [0.0055, 0.0467, 0.034]), + (56.0, [0.0194, 0.0467, 0.0329]), + (57.6, [0.0228, 0.0456, 0.0338]), + (59.2, [0.0291, 0.0456, 0.0361]), + (62.4, [0.035, 0.0467, 0.0369]), + (63.2, [0.0321, 0.0464, 0.0372]), + (66.4, [0.0098, 0.0425, 0.0357]), + (67.2, [0.0063, 0.0423, 0.0352]), + (68.0, [0.0105, 0.042, 0.0362]), + (71.2, [0.0434, 0.0478, 0.037]), + (72.8, [0.0514, 0.0472, 0.0363]), + (75.2, [0.054, 0.0486, 0.0358]), + (78.4, [0.0595, 0.0477, 0.039]), + (87.2, [0.0662, 0.0453, 0.0365]), + (99.2, [0.0637, 0.0443, 0.0321]), + (100.8, [0.0659, 0.0438, 0.0334]), + (107.2, [0.0645, 0.0418, 0.0289]), + (120.0, [0.064, 0.0398, 0.0348]), + (132.0, [0.0651, 0.039, 0.0363]), + (142.4, [0.0639, 0.0284, 0.0233]), + (156.0, [0.0619, 0.0112, -0.0035]), + (161.6, [0.0612, 0.0059, -0.0114]), + (165.6, [0.061, 0.0034, -0.014]), + (174.4, [0.0606, 0.0015, -0.0138]), + (186.4, [0.0597, -0.0014, -0.0172]), + (194.4, [0.0592, -0.0001, -0.0155]), + (204.0, [0.0585, 0.0041, -0.01]), + (233.6, [0.0572, 0.0242, 0.014]), + (252.0, [0.0566, 0.025, 0.0142]), + (277.6, [0.055, 0.04, 0.0317]), + (300.8, [0.054, 0.0429, 0.0348]), + (311.2, [0.0543, 0.0484, 0.0393]), + (328.0, [0.0468, 0.0418, 0.0334]), + (343.2, [0.0532, 0.0462, 0.0389]), + (372.0, [0.0473, 0.0468, 0.0406]), + (408.8, [0.0454, 0.049, 0.0421]), + (431.2, [0.0448, 0.0485, 0.044]), + (464.0, [0.0381, 0.0449, 0.0427]), + (483.2, [0.0275, 0.0356, 0.0381]), + (492.0, [0.0254, 0.032, 0.0366]), + (499.2, [0.0185, 0.0281, 0.0345]), + (516.8, [0.0071, 0.0091, 0.0228]), + (528.0, [-0.0014, -0.0093, 0.0103]), + (532.0, [-0.0034, -0.014, 0.0073]), + (580.8, [-0.0134, -0.0112, 0.016]), + (596.8, [-0.0151, -0.0101, 0.0228]), + (599.2, [-0.0165, -0.0119, 0.0198]), + (602.4, [-0.0219, -0.0178, 0.0029]), + (604.0, [-0.0233, -0.0188, -0.0035]), + (605.6, [-0.0222, -0.0172, -0.0055]), + (607.2, [-0.0182, -0.0127, -0.0032]), + (609.6, [-0.0082, -0.003, 0.0029]), + (611.2, [-0.0009, 0.0013, 0.0049]), + (613.6, [0.0055, 0.0019, 0.0033]), + (615.2, [0.005, 0.0016, 0.0027]), + (619.2, [0.0016, 0.0046, 0.0083]), + (623.2, [0.0014, -0.0013, 0.0039]), + (626.4, [-0.0013, -0.0005, 0.0075]), + (628.8, [0.0003, 0.0006, 0.009]), + (633.6, [0.0007, 0.0011, 0.0034]), + (636.8, [-0.0006, -0.0011, -0.0055]), + (640.0, [-0.0008, -0.0008, -0.0074]), + (648.8, [0.0001, 0.0001, 0.0009]), + (653.6, [0.0003, 0.0003, 0.0018]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.0594, 0.0666, 0.0353]), + (4.7944, [0.0546, 0.0661, 0.035]), + (6.3925, [0.0507, 0.066, 0.035]), + (11.187, [0.0506, 0.0641, 0.035]), + (12.785, [0.0467, 0.0599, 0.035]), + (17.579, [0.0452, 0.06, 0.035]), + (19.177, [0.0408, 0.0599, 0.035]), + (23.972, [0.0401, 0.0595, 0.0351]), + (25.57, [0.0361, 0.0554, 0.0351]), + (31.962, [0.0356, 0.0556, 0.0359]), + (33.56, [0.0312, 0.0556, 0.0361]), + (39.953, [0.0309, 0.055, 0.0359]), + (41.551, [0.0262, 0.0507, 0.0356]), + (49.542, [0.0229, 0.0493, 0.0339]), + (51.14, [0.0184, 0.0494, 0.0338]), + (60.728, [0.0188, 0.051, 0.0348]), + (62.327, [0.0149, 0.047, 0.0352]), + (75.112, [0.0159, 0.0469, 0.0351]), + (76.71, [0.0116, 0.0469, 0.0351]), + (111.87, [0.0118, 0.047, 0.0352]), + (112.67, [0.0121, 0.047, 0.0352]), + (119.06, [0.0315, 0.047, 0.0353]), + (119.86, [0.0317, 0.047, 0.0353]), + (127.05, [0.0117, 0.047, 0.0352]), + (147.03, [0.0118, 0.0473, 0.0353]), + (147.83, [0.0119, 0.0458, 0.035]), + (149.42, [0.0119, 0.0391, 0.0296]), + (151.02, [0.0077, 0.0327, 0.0243]), + (152.62, [0.0078, 0.0263, 0.0196]), + (154.22, [0.0038, 0.0198, 0.0147]), + (155.82, [0.0037, 0.0133, 0.0095]), + (156.62, [0.0008, 0.0095, 0.0067]), + (159.01, [0.0001, 0.0001, -0.0002]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.0636, 0.0794]), + (7.9639, [-0.0001, 0.0635, 0.0793]), + (17.52, [-0.0001, 0.051, 0.0637]), + (23.095, [-0.0001, 0.0378, 0.0477]), + (31.059, [-0.0001, 0.0123, 0.0155]), + (35.041, [-0.0001, 0.0065, 0.0082]), + (44.598, [-0.0001, 0.0013, 0.0016]), + (63.711, [-0.0001, 0.0017, 0.002]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0076, 0.0082, 0.0701]), + (15.867, [0.0078, 0.0083, 0.0704]), + (17.454, [0.0065, 0.007, 0.0603]), + (19.04, [0.0061, 0.0066, 0.0557]), + (28.56, [0.0043, 0.0047, 0.0393]), + (30.94, [0.0039, 0.0043, 0.0327]), + (33.32, [0.004, 0.0043, 0.0242]), + (36.494, [0.0, 0.0004, 0.0119]), + (38.081, [0.0, 0.0003, 0.0056]), + (39.667, [0.0, 0.0003, 0.0016]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0004, 0.0044, 0.0047]), + (0.7847, [0.0049, 0.0032, 0.0027]), + (3.1388, [0.007, 0.0049, 0.0051]), + (4.7082, [0.0084, 0.0069, 0.0066]), + (7.847, [0.0147, 0.0141, 0.0144]), + (11.771, [0.017, 0.0189, 0.0175]), + (13.34, [0.016, 0.0177, 0.0163]), + (21.187, [0.0013, 0.0017, 0.0015]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0389, 0.0148, -0.0008]), + (3.9865, [0.0378, 0.0151, -0.0007]), + (7.1757, [0.0366, 0.0125, -0.0005]), + (9.5675, [0.0318, 0.0113, -0.0003]), + (20.73, [0.0191, 0.0043, 0.0004]), + (31.892, [0.006, 0.0005, 0.0002]), + (39.067, [0.0017, 0.0002, 0.0001]), + ], + }, +]; +const PRIME35_50_STREAKS: &[Streak] = &[]; +const PRIME35_50_RAYS: &[Ray] = &[ + Ray { angle: 71.57, r0: 6.25, r1: 82.812, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 104.33, r0: 48.438, r1: 264.06, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 118.75, r0: 15.625, r1: 196.09, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 147.3, r0: 13.281, r1: 223.44, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 161.64, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0353, 0.0353, 0.0353] }, + Ray { angle: 161.93, r0: 6.25, r1: 134.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 168.91, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 183.28, r0: 20.312, r1: 189.06, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 225.87, r0: 31.25, r1: 159.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 240.32, r0: 18.75, r1: 224.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 247.15, r0: 6.25, r1: 83.594, fade: 0.05, rgb: [0.0127, 0.0157, 0.0157] }, + Ray { angle: 251.07, r0: 6.25, r1: 84.375, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 254.58, r0: 25.0, r1: 157.03, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 268.96, r0: 6.25, r1: 195.31, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 283.24, r0: 38.281, r1: 178.91, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 297.53, r0: 12.5, r1: 109.38, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, +]; +const PRIME35_75_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.438, 1.379, 1.367]), + (0.8, [1.37, 1.369, 1.365]), + (1.6, [1.392, 1.373, 1.364]), + (3.2, [1.378, 1.374, 1.348]), + (6.4, [1.342, 1.372, 1.264]), + (10.4, [1.269, 1.266, 1.121]), + (15.2, [1.151, 1.04, 0.9127]), + (19.2, [1.03, 0.8126, 0.7253]), + (24.8, [0.8532, 0.5055, 0.4852]), + (28.0, [0.7566, 0.3624, 0.3754]), + (30.4, [0.6922, 0.2859, 0.3005]), + (33.6, [0.6149, 0.2093, 0.2311]), + (36.8, [0.5455, 0.1487, 0.1662]), + (38.4, [0.5141, 0.121, 0.1415]), + (39.2, [0.4955, 0.1021, 0.1263]), + (40.0, [0.4786, 0.0896, 0.1134]), + (41.6, [0.4461, 0.0787, 0.0952]), + (43.2, [0.422, 0.0585, 0.0782]), + (44.8, [0.3932, 0.0431, 0.0604]), + (46.4, [0.371, 0.0265, 0.0502]), + (48.0, [0.3515, 0.0156, 0.0363]), + (48.8, [0.3407, 0.0135, 0.0309]), + (49.6, [0.3326, 0.0136, 0.03]), + (51.2, [0.3178, 0.009, 0.0256]), + (53.6, [0.2948, 0.0039, 0.0252]), + (56.0, [0.2727, 0.0052, 0.0154]), + (58.4, [0.252, 0.006, 0.0166]), + (59.2, [0.2448, 0.0027, 0.0171]), + (60.8, [0.2325, -0.0033, 0.0179]), + (61.6, [0.2272, -0.0027, 0.0162]), + (62.4, [0.2277, -0.0019, 0.0111]), + (63.2, [0.2409, 0.0006, 0.0084]), + (64.0, [0.2645, 0.0052, 0.0088]), + (65.6, [0.3272, 0.0169, 0.0149]), + (66.4, [0.3561, 0.0235, 0.0156]), + (67.2, [0.3684, 0.026, 0.0135]), + (68.0, [0.3551, 0.0223, 0.0121]), + (68.8, [0.319, 0.0151, 0.0107]), + (70.4, [0.2275, -0.0009, 0.002]), + (71.2, [0.1855, -0.0079, 0.001]), + (72.0, [0.1522, -0.0123, -0.0018]), + (72.8, [0.1373, -0.0125, -0.0031]), + (73.6, [0.1313, -0.0125, -0.003]), + (74.4, [0.126, -0.0138, -0.0026]), + (76.0, [0.116, -0.0226, -0.0043]), + (77.6, [0.1054, -0.0236, -0.0126]), + (80.0, [0.0915, -0.0228, -0.0134]), + (82.4, [0.0781, -0.0279, -0.0127]), + (84.8, [0.0596, -0.0326, -0.0231]), + (88.0, [0.0441, -0.0312, -0.0214]), + (89.6, [0.037, -0.0358, -0.0208]), + (91.2, [0.0282, -0.0353, -0.026]), + (92.8, [0.0185, -0.0398, -0.0308]), + (94.4, [0.0177, -0.0391, -0.0296]), + (97.6, [0.0066, -0.038, -0.0279]), + (99.2, [-0.0019, -0.0426, -0.027]), + (100.8, [-0.0092, -0.0438, -0.0359]), + (102.4, [-0.0128, -0.0468, -0.0342]), + (104.8, [-0.0197, -0.0451, -0.0306]), + (106.4, [-0.0218, -0.0441, -0.0282]), + (107.2, [-0.0284, -0.0437, -0.0272]), + (109.6, [-0.0354, -0.0433, -0.0329]), + (111.2, [-0.0366, -0.0481, -0.0336]), + (112.8, [-0.038, -0.0477, -0.0346]), + (116.0, [-0.0484, -0.0468, -0.0371]), + (118.4, [-0.0485, -0.0461, -0.0446]), + (119.2, [-0.0485, -0.0462, -0.0452]), + (120.8, [-0.0484, -0.0513, -0.0462]), + (122.4, [-0.0482, -0.0509, -0.0467]), + (124.8, [-0.0539, -0.0499, -0.047]), + (126.4, [-0.0536, -0.0492, -0.048]), + (128.0, [-0.0534, -0.0485, -0.0529]), + (129.6, [-0.0531, -0.0485, -0.0525]), + (131.2, [-0.0528, -0.0527, -0.0516]), + (132.0, [-0.0529, -0.0519, -0.0508]), + (133.6, [-0.0582, -0.0497, -0.0485]), + (136.0, [-0.0579, -0.0452, -0.0443]), + (137.6, [-0.0575, -0.0417, -0.0458]), + (140.0, [-0.0569, -0.0358, -0.0389]), + (141.6, [-0.0571, -0.0368, -0.034]), + (142.4, [-0.0602, -0.0357, -0.0315]), + (143.2, [-0.0623, -0.0335, -0.0289]), + (146.4, [-0.0614, -0.0247, -0.0182]), + (148.0, [-0.061, -0.0203, -0.0181]), + (152.0, [-0.0602, -0.0094, -0.003]), + (153.6, [-0.065, -0.0105, 0.0039]), + (157.6, [-0.0635, -0.0017, 0.0183]), + (159.2, [-0.0631, 0.0016, 0.0173]), + (162.4, [-0.0624, 0.0073, 0.0242]), + (164.8, [-0.0681, 0.0103, 0.0275]), + (166.4, [-0.0679, 0.0063, 0.0288]), + (169.6, [-0.0676, 0.008, 0.0299]), + (172.0, [-0.0674, 0.009, 0.0242]), + (174.4, [-0.0672, 0.0102, 0.0251]), + (176.8, [-0.073, 0.0117, 0.0266]), + (179.2, [-0.0726, 0.0131, 0.0288]), + (180.8, [-0.0723, 0.0087, 0.0303]), + (183.2, [-0.0719, 0.0099, 0.0321]), + (185.6, [-0.0716, 0.0108, 0.0271]), + (188.0, [-0.0718, 0.0111, 0.0275]), + (189.6, [-0.0771, 0.0111, 0.0275]), + (194.4, [-0.0764, 0.0101, 0.026]), + (195.2, [-0.0763, 0.0093, 0.0257]), + (196.8, [-0.0761, 0.003, 0.0247]), + (199.2, [-0.0758, 0.0019, 0.0227]), + (200.8, [-0.0756, 0.001, 0.0159]), + (202.4, [-0.0754, 0.0, 0.0146]), + (204.8, [-0.081, -0.0014, 0.0127]), + (213.6, [-0.0798, -0.0079, 0.0039]), + (214.4, [-0.0797, -0.009, 0.003]), + (216.0, [-0.0795, -0.016, 0.0011]), + (217.6, [-0.0793, -0.0178, -0.0014]), + (219.2, [-0.0791, -0.0195, -0.009]), + (222.4, [-0.085, -0.023, -0.0132]), + (231.2, [-0.0843, -0.0306, -0.022]), + (239.2, [-0.0836, -0.0328, -0.0232]), + (240.8, [-0.0834, -0.0379, -0.0226]), + (241.6, [-0.0834, -0.0375, -0.0226]), + (243.2, [-0.0835, -0.0371, -0.0275]), + (245.6, [-0.0889, -0.0367, -0.0263]), + (252.8, [-0.0886, -0.0374, -0.0261]), + (278.4, [-0.0858, -0.0547, -0.0452]), + (300.8, [-0.0843, -0.0571, -0.0476]), + (311.2, [-0.0846, -0.0634, -0.0526]), + (328.0, [-0.0761, -0.0561, -0.0455]), + (344.0, [-0.0832, -0.0611, -0.0519]), + (368.8, [-0.0779, -0.0619, -0.0535]), + (423.2, [-0.0731, -0.0639, -0.0563]), + (463.2, [-0.0637, -0.0581, -0.0543]), + (483.2, [-0.0487, -0.045, -0.0457]), + (490.4, [-0.0458, -0.0405, -0.0431]), + (500.0, [-0.035, -0.0311, -0.0381]), + (516.8, [-0.0217, -0.0044, -0.0202]), + (526.4, [-0.0128, 0.0183, -0.0043]), + (531.2, [-0.0092, 0.0274, 0.0017]), + (576.8, [0.0045, 0.0292, 0.001]), + (596.8, [0.0071, 0.0298, -0.0035]), + (598.4, [0.0102, 0.0313, -0.0006]), + (601.6, [0.0268, 0.0339, 0.0119]), + (604.0, [0.0405, 0.031, 0.0189]), + (605.6, [0.0449, 0.0264, 0.0184]), + (607.2, [0.0421, 0.0193, 0.0132]), + (608.0, [0.0377, 0.015, 0.0093]), + (609.6, [0.0242, 0.0062, 0.0008]), + (611.2, [0.0078, -0.001, -0.0051]), + (612.8, [-0.0058, -0.0046, -0.0069]), + (613.6, [-0.0103, -0.0051, -0.0068]), + (615.2, [-0.0139, -0.0049, -0.0062]), + (620.0, [-0.003, -0.0041, -0.0102]), + (624.8, [-0.0018, 0.0018, -0.0033]), + (628.0, [0.003, 0.001, -0.005]), + (630.4, [0.0007, -0.0017, -0.0058]), + (632.8, [-0.0018, -0.0009, -0.001]), + (639.2, [0.0016, 0.0012, 0.0071]), + (644.8, [0.0001, -0.0001, 0.0016]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.3934, -0.3751, -0.3685]), + (3.2, [-0.3842, -0.3746, -0.3617]), + (6.4, [-0.3726, -0.3753, -0.3347]), + (9.6, [-0.3526, -0.3472, -0.2979]), + (14.4, [-0.3128, -0.2729, -0.2247]), + (24.0, [-0.2112, -0.0822, -0.0744]), + (27.2, [-0.1763, -0.0287, -0.0351]), + (29.6, [-0.1541, -0.0011, -0.0092]), + (31.2, [-0.1402, 0.0132, 0.0055]), + (32.8, [-0.127, 0.0263, 0.0156]), + (36.8, [-0.0975, 0.0513, 0.0425]), + (37.6, [-0.0934, 0.0542, 0.0457]), + (39.2, [-0.0809, 0.0663, 0.0543]), + (40.0, [-0.0753, 0.0703, 0.0589]), + (41.6, [-0.0643, 0.0721, 0.0641]), + (44.0, [-0.0529, 0.0808, 0.0719]), + (45.6, [-0.0423, 0.0855, 0.0754]), + (48.0, [-0.0337, 0.0905, 0.0806]), + (53.6, [-0.0157, 0.0919, 0.0811]), + (56.8, [-0.0068, 0.0891, 0.083]), + (60.8, [0.0028, 0.0899, 0.0796]), + (62.4, [0.0051, 0.0885, 0.0815]), + (63.2, [-0.0007, 0.0867, 0.0818]), + (64.0, [-0.0097, 0.0846, 0.0809]), + (66.4, [-0.0452, 0.0762, 0.0766]), + (67.2, [-0.0511, 0.0744, 0.0777]), + (68.0, [-0.0457, 0.0754, 0.0768]), + (68.8, [-0.0323, 0.0775, 0.0767]), + (71.2, [0.0174, 0.0852, 0.0794]), + (72.0, [0.0279, 0.086, 0.08]), + (72.8, [0.0331, 0.0858, 0.0805]), + (76.0, [0.0384, 0.088, 0.079]), + (78.4, [0.0432, 0.0872, 0.0819]), + (82.4, [0.0503, 0.0873, 0.0796]), + (84.8, [0.0565, 0.0886, 0.0832]), + (89.6, [0.0628, 0.0877, 0.0798]), + (92.8, [0.069, 0.0878, 0.0824]), + (99.2, [0.0742, 0.0863, 0.0768]), + (100.8, [0.0762, 0.0858, 0.0794]), + (105.6, [0.078, 0.0839, 0.0722]), + (107.2, [0.0817, 0.0829, 0.0701]), + (118.4, [0.0881, 0.0817, 0.0808]), + (124.8, [0.0896, 0.0814, 0.0828]), + (131.2, [0.0884, 0.0807, 0.0834]), + (137.6, [0.089, 0.0701, 0.0744]), + (142.4, [0.0894, 0.0603, 0.06]), + (148.0, [0.0885, 0.0449, 0.0428]), + (152.0, [0.0871, 0.0335, 0.027]), + (157.6, [0.0871, 0.022, 0.0054]), + (162.4, [0.0857, 0.0129, -0.0039]), + (164.8, [0.088, 0.0092, -0.0073]), + (169.6, [0.0873, 0.0079, -0.0101]), + (172.0, [0.0872, 0.0069, -0.0079]), + (174.4, [0.0866, 0.0057, -0.009]), + (179.2, [0.0883, 0.0024, -0.0128]), + (180.8, [0.0881, 0.0036, -0.0142]), + (183.2, [0.0877, 0.0021, -0.0164]), + (194.4, [0.0882, 0.0014, -0.0145]), + (199.2, [0.0876, 0.0057, -0.0118]), + (200.8, [0.0873, 0.0065, -0.0084]), + (214.4, [0.0876, 0.0159, 0.0043]), + (216.0, [0.0874, 0.0196, 0.0063]), + (224.0, [0.0889, 0.0283, 0.0187]), + (232.0, [0.0882, 0.0348, 0.0262]), + (240.8, [0.0874, 0.038, 0.0267]), + (252.8, [0.0887, 0.0372, 0.0259]), + (272.0, [0.0865, 0.0513, 0.0414]), + (281.6, [0.0856, 0.0556, 0.0462]), + (300.8, [0.0843, 0.0571, 0.0476]), + (312.0, [0.0844, 0.0635, 0.0526]), + (328.0, [0.076, 0.0561, 0.0455]), + (343.2, [0.0833, 0.0609, 0.0517]), + (369.6, [0.0779, 0.0619, 0.0535]), + (430.4, [0.0727, 0.0632, 0.0568]), + (465.6, [0.0629, 0.0575, 0.054]), + (484.0, [0.0486, 0.0449, 0.0456]), + (491.2, [0.0457, 0.0404, 0.0431]), + (495.2, [0.0409, 0.037, 0.0416]), + (505.6, [0.0315, 0.0242, 0.0339]), + (517.6, [0.0217, 0.0043, 0.0201]), + (527.2, [0.0127, -0.0184, 0.0043]), + (532.8, [0.0089, -0.0279, -0.0018]), + (577.6, [-0.0045, -0.0292, -0.001]), + (597.6, [-0.007, -0.0298, 0.0037]), + (600.0, [-0.0128, -0.0321, -0.0015]), + (602.4, [-0.0258, -0.0339, -0.0113]), + (604.8, [-0.0397, -0.0314, -0.0186]), + (606.4, [-0.0448, -0.027, -0.0187]), + (608.0, [-0.0428, -0.0201, -0.014]), + (608.8, [-0.0389, -0.016, -0.0102]), + (610.4, [-0.026, -0.0072, -0.0017]), + (612.0, [-0.0096, 0.0004, 0.0046]), + (613.6, [0.0045, 0.0044, 0.0068]), + (614.4, [0.0094, 0.005, 0.0068]), + (616.0, [0.0138, 0.0049, 0.0062]), + (617.6, [0.0117, 0.0044, 0.0064]), + (620.0, [0.0045, 0.0043, 0.0093]), + (621.6, [0.0025, 0.0038, 0.01]), + (624.8, [0.0028, -0.0005, 0.0047]), + (626.4, [0.0007, -0.0024, 0.0027]), + (628.8, [-0.003, -0.0012, 0.0046]), + (631.2, [-0.0011, 0.0016, 0.0059]), + (633.6, [0.0017, 0.001, 0.0013]), + (640.8, [-0.002, -0.0015, -0.0071]), + (645.6, [-0.0002, 0.0002, -0.0017]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.0834, 0.1014, 0.0566]), + (0.79906, [0.0852, 0.0995, 0.0541]), + (3.1962, [0.0853, 0.1016, 0.0546]), + (7.9906, [0.0779, 0.1008, 0.0545]), + (8.7896, [0.0771, 0.098, 0.0545]), + (15.182, [0.0719, 0.0975, 0.0545]), + (16.78, [0.0677, 0.0968, 0.0545]), + (18.378, [0.0677, 0.0928, 0.0546]), + (19.976, [0.0667, 0.0925, 0.0546]), + (20.776, [0.0633, 0.0924, 0.0546]), + (23.972, [0.0617, 0.0918, 0.0546]), + (25.57, [0.0574, 0.0914, 0.0547]), + (27.967, [0.0569, 0.0873, 0.0549]), + (31.163, [0.0535, 0.0874, 0.0557]), + (34.36, [0.0538, 0.0878, 0.0562]), + (35.958, [0.0497, 0.0878, 0.0564]), + (39.953, [0.0484, 0.0833, 0.0559]), + (41.551, [0.0435, 0.0827, 0.0554]), + (46.345, [0.0404, 0.0809, 0.0537]), + (47.944, [0.0351, 0.0805, 0.0533]), + (51.14, [0.034, 0.08, 0.0528]), + (52.738, [0.0336, 0.0756, 0.0528]), + (55.135, [0.0293, 0.076, 0.0527]), + (60.728, [0.0307, 0.078, 0.0543]), + (62.327, [0.0267, 0.0787, 0.0549]), + (65.523, [0.0272, 0.0797, 0.0557]), + (68.719, [0.0275, 0.0794, 0.0554]), + (70.317, [0.0273, 0.0747, 0.0552]), + (71.915, [0.0234, 0.0745, 0.0549]), + (81.504, [0.0237, 0.0743, 0.0547]), + (83.102, [0.0195, 0.0743, 0.0547]), + (111.87, [0.0195, 0.0745, 0.0549]), + (112.67, [0.021, 0.0745, 0.0549]), + (119.06, [0.0504, 0.0784, 0.0549]), + (119.86, [0.0515, 0.0784, 0.0549]), + (127.05, [0.0202, 0.0744, 0.0548]), + (147.03, [0.0197, 0.0744, 0.0551]), + (148.62, [0.0195, 0.0666, 0.0495]), + (149.42, [0.0164, 0.0612, 0.0454]), + (155.02, [0.0071, 0.0264, 0.0191]), + (155.82, [0.0041, 0.021, 0.0151]), + (159.01, [0.0001, 0.0011, 0.0004]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.096, 0.1202]), + (9.5566, [-0.0002, 0.0926, 0.1166]), + (18.317, [-0.0001, 0.0735, 0.0949]), + (23.095, [-0.0002, 0.0571, 0.0759]), + (28.67, [-0.0002, 0.0286, 0.0382]), + (31.059, [-0.0002, 0.0184, 0.0244]), + (32.652, [-0.0002, 0.014, 0.0185]), + (35.837, [-0.0002, 0.0085, 0.0112]), + (40.616, [-0.0002, 0.0038, 0.0048]), + (44.598, [-0.0002, 0.0016, 0.0019]), + (66.896, [-0.0002, 0.0017, 0.0019]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0148, 0.0168, 0.1051]), + (1.5867, [0.0156, 0.0169, 0.1076]), + (15.867, [0.0157, 0.0168, 0.1059]), + (17.454, [0.0135, 0.0144, 0.0916]), + (19.04, [0.0123, 0.0133, 0.0844]), + (26.974, [0.0092, 0.0102, 0.0646]), + (30.94, [0.0078, 0.0087, 0.0513]), + (38.081, [0.0, 0.0007, 0.0105]), + (40.461, [0.0002, 0.0006, 0.0015]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0056, 0.005, 0.002]), + (0.7847, [0.0088, 0.0078, 0.0064]), + (1.5694, [0.0082, 0.0058, 0.0061]), + (4.7082, [0.0132, 0.01, 0.0106]), + (7.847, [0.0214, 0.0207, 0.0204]), + (11.771, [0.0247, 0.0265, 0.0253]), + (12.555, [0.0232, 0.0242, 0.0232]), + (13.34, [0.0231, 0.0247, 0.0236]), + (18.048, [0.0092, 0.01, 0.0096]), + (21.972, [0.0018, 0.0012, 0.0014]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0555, 0.0267, -0.0013]), + (0.7973, [0.0562, 0.0239, -0.0012]), + (3.1892, [0.0561, 0.0238, -0.0012]), + (11.162, [0.0476, 0.0194, -0.0003]), + (23.919, [0.0242, 0.0093, 0.0008]), + (27.905, [0.0185, 0.0046, 0.0006]), + (31.892, [0.0123, 0.0029, 0.0003]), + (37.473, [0.005, 0.0005, 0.0002]), + (43.851, [0.0015, 0.0005, 0.0002]), + ], + }, +]; +const PRIME35_75_STREAKS: &[Streak] = &[]; +const PRIME35_75_RAYS: &[Ray] = &[ + Ray { angle: 6.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 8.37, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0186, 0.0186] }, + Ray { angle: 9.3, r0: 52.344, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0176, 0.0157] }, + Ray { angle: 10.73, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 11.64, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 37.2, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 38.24, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0137, 0.0176] }, + Ray { angle: 39.53, r0: 56.25, r1: 96.875, fade: 0.05, rgb: [0.0137, 0.0137, 0.0157] }, + Ray { angle: 41.61, r0: 26.562, r1: 85.938, fade: 0.05, rgb: [0.0137, 0.0176, 0.0157] }, + Ray { angle: 67.69, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 68.69, r0: 6.25, r1: 81.25, fade: 0.05, rgb: [0.0176, 0.0216, 0.0235] }, + Ray { angle: 69.77, r0: 6.25, r1: 36.719, fade: 0.05, rgb: [0.0216, 0.0304, 0.0324] }, + Ray { angle: 71.23, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0176, 0.0196] }, + Ray { angle: 72.12, r0: 6.25, r1: 95.312, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 97.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 98.74, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 99.71, r0: 50.0, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 101.09, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0147, 0.0176] }, + Ray { angle: 102.15, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 104.33, r0: 50.0, r1: 317.19, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 118.75, r0: 17.188, r1: 214.06, fade: 0.05, rgb: [0.0314, 0.0314, 0.0314] }, + Ray { angle: 127.41, r0: 6.25, r1: 96.094, fade: 0.05, rgb: [0.0118, 0.0147, 0.0176] }, + Ray { angle: 127.67, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 128.66, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0137, 0.0176, 0.0206] }, + Ray { angle: 129.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0147, 0.0176] }, + Ray { angle: 131.8, r0: 6.25, r1: 95.312, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 147.33, r0: 13.281, r1: 257.81, fade: 0.05, rgb: [0.0275, 0.0314, 0.0294] }, + Ray { angle: 157.61, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 158.68, r0: 6.25, r1: 91.406, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 159.74, r0: 6.25, r1: 32.812, fade: 0.05, rgb: [0.0216, 0.0373, 0.0373] }, + Ray { angle: 161.68, r0: 6.25, r1: 246.88, fade: 0.05, rgb: [0.051, 0.051, 0.051] }, + Ray { angle: 168.81, r0: 11.719, r1: 238.28, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 183.23, r0: 21.094, r1: 234.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 187.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0157, 0.0176] }, + Ray { angle: 188.85, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 189.71, r0: 51.562, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0157, 0.0167] }, + Ray { angle: 191.14, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0186] }, + Ray { angle: 192.15, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0176, 0.0196, 0.0235] }, + Ray { angle: 217.67, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 218.73, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0118, 0.0157, 0.0176] }, + Ray { angle: 220.0, r0: 49.219, r1: 99.219, fade: 0.05, rgb: [0.0098, 0.0098, 0.0137] }, + Ray { angle: 221.96, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 225.93, r0: 30.469, r1: 210.94, fade: 0.05, rgb: [0.0235, 0.0275, 0.0235] }, + Ray { angle: 240.05, r0: 18.75, r1: 137.5, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 240.36, r0: 17.188, r1: 224.22, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 247.23, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, + Ray { angle: 249.3, r0: 6.25, r1: 33.594, fade: 0.05, rgb: [0.0176, 0.0284, 0.0275] }, + Ray { angle: 250.73, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 251.57, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0176, 0.0196, 0.0216] }, + Ray { angle: 254.52, r0: 41.406, r1: 157.03, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 269.0, r0: 8.5938, r1: 195.31, fade: 0.05, rgb: [0.0353, 0.0373, 0.0353] }, + Ray { angle: 276.16, r0: 97.656, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 276.97, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 278.33, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0167, 0.0176] }, + Ray { angle: 279.33, r0: 51.562, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0167, 0.0157] }, + Ray { angle: 280.73, r0: 44.531, r1: 97.656, fade: 0.05, rgb: [0.0157, 0.0157, 0.0137] }, + Ray { angle: 281.65, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0157, 0.0167] }, + Ray { angle: 283.26, r0: 50.0, r1: 189.06, fade: 0.05, rgb: [0.0294, 0.0314, 0.0314] }, + Ray { angle: 297.59, r0: 14.844, r1: 185.16, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 307.25, r0: 30.469, r1: 96.875, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 308.3, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0118, 0.0157, 0.0176] }, + Ray { angle: 309.62, r0: 52.344, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 311.76, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0157, 0.0176, 0.0196] }, + Ray { angle: 337.25, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 338.3, r0: 6.25, r1: 89.062, fade: 0.05, rgb: [0.0157, 0.0196, 0.0216] }, + Ray { angle: 339.3, r0: 60.938, r1: 99.219, fade: 0.05, rgb: [0.0167, 0.0176, 0.0176] }, + Ray { angle: 340.85, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0176, 0.0176, 0.0216] }, + Ray { angle: 341.68, r0: 6.25, r1: 96.875, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, +]; +const PRIME35_100_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.283, 1.225, 1.21]), + (0.8, [1.31, 1.302, 1.306]), + (1.6, [1.302, 1.281, 1.275]), + (5.6, [1.305, 1.288, 1.255]), + (10.4, [1.276, 1.276, 1.159]), + (16.0, [1.207, 1.1, 0.997]), + (24.8, [1.044, 0.7192, 0.7071]), + (28.8, [0.9663, 0.5735, 0.5935]), + (31.2, [0.9222, 0.5104, 0.5389]), + (36.0, [0.8373, 0.4128, 0.4378]), + (39.2, [0.7806, 0.3522, 0.38]), + (44.8, [0.6827, 0.2563, 0.2854]), + (48.0, [0.6315, 0.212, 0.2406]), + (52.0, [0.5698, 0.1612, 0.1896]), + (53.6, [0.5441, 0.1449, 0.1705]), + (55.2, [0.5204, 0.1322, 0.157]), + (57.6, [0.4894, 0.1055, 0.1349]), + (59.2, [0.4678, 0.0978, 0.1237]), + (60.8, [0.4505, 0.0845, 0.11]), + (62.4, [0.4413, 0.0758, 0.1006]), + (63.2, [0.4594, 0.0809, 0.0991]), + (66.4, [0.6287, 0.1213, 0.1015]), + (67.2, [0.6495, 0.1267, 0.1016]), + (68.0, [0.6286, 0.1217, 0.0988]), + (70.4, [0.4634, 0.0762, 0.0799]), + (71.2, [0.4065, 0.0639, 0.0732]), + (72.0, [0.3606, 0.0565, 0.0705]), + (72.8, [0.3404, 0.0511, 0.0704]), + (73.6, [0.3351, 0.0447, 0.0699]), + (74.4, [0.3298, 0.0424, 0.0686]), + (76.8, [0.3101, 0.0417, 0.064]), + (78.4, [0.2974, 0.0327, 0.0564]), + (79.2, [0.289, 0.0314, 0.0535]), + (80.8, [0.2794, 0.0314, 0.0537]), + (81.6, [0.2736, 0.0286, 0.0526]), + (83.2, [0.2581, 0.0248, 0.0433]), + (85.6, [0.2445, 0.0191, 0.0412]), + (86.4, [0.2363, 0.016, 0.0378]), + (88.8, [0.2215, 0.0159, 0.0319]), + (90.4, [0.2111, 0.0079, 0.0291]), + (91.2, [0.2044, 0.006, 0.0275]), + (92.8, [0.1937, 0.0068, 0.0264]), + (93.6, [0.1891, 0.007, 0.0228]), + (95.2, [0.1796, 0.0029, 0.0179]), + (97.6, [0.164, 0.0043, 0.0193]), + (98.4, [0.1573, 0.004, 0.0171]), + (100.0, [0.1531, 0.0008, 0.018]), + (101.6, [0.1437, 0.0028, 0.0208]), + (102.4, [0.1385, 0.0033, 0.0195]), + (104.0, [0.1313, -0.0056, 0.0205]), + (105.6, [0.1228, -0.0046, 0.0227]), + (107.2, [0.1187, -0.0033, 0.0165]), + (108.0, [0.1157, -0.0058, 0.0164]), + (109.6, [0.1032, -0.0063, 0.0163]), + (111.2, [0.0953, -0.0053, 0.0101]), + (112.8, [0.0873, -0.0144, 0.0083]), + (113.6, [0.0816, -0.0152, 0.0073]), + (115.2, [0.0769, -0.0143, -0.0043]), + (116.0, [0.077, -0.014, -0.0067]), + (116.8, [0.0742, -0.0155, -0.0077]), + (117.6, [0.0675, -0.0184, -0.0085]), + (118.4, [0.065, -0.0186, -0.0095]), + (120.0, [0.06, -0.0175, -0.0163]), + (121.6, [0.0497, -0.0179, -0.017]), + (122.4, [0.0456, -0.0208, -0.017]), + (124.0, [0.0427, -0.0255, -0.021]), + (125.6, [0.034, -0.0249, -0.0274]), + (127.2, [0.0287, -0.0286, -0.0275]), + (128.8, [0.0273, -0.0279, -0.0318]), + (130.4, [0.0229, -0.0264, -0.0312]), + (132.0, [0.0224, -0.029, -0.0294]), + (133.6, [0.017, -0.0266, -0.0315]), + (136.0, [0.0104, -0.0201, -0.0239]), + (137.6, [0.0082, -0.0198, -0.0214]), + (138.4, [0.0049, -0.0166, -0.0198]), + (140.8, [-0.0016, -0.0064, -0.008]), + (141.6, [-0.0024, -0.0052, -0.0048]), + (142.4, [-0.0047, -0.0047, -0.004]), + (143.2, [-0.0075, -0.0013, -0.0011]), + (146.4, [-0.0077, 0.0137, 0.0169]), + (148.0, [-0.0123, 0.0166, 0.0217]), + (151.2, [-0.0125, 0.033, 0.0423]), + (152.8, [-0.0165, 0.0359, 0.0482]), + (155.2, [-0.0151, 0.0468, 0.0651]), + (156.0, [-0.0168, 0.0505, 0.0698]), + (156.8, [-0.0197, 0.0538, 0.0717]), + (158.4, [-0.0194, 0.0546, 0.0774]), + (160.0, [-0.0189, 0.06, 0.0848]), + (160.8, [-0.0211, 0.0626, 0.088]), + (161.6, [-0.024, 0.0652, 0.0899]), + (162.4, [-0.024, 0.0673, 0.0891]), + (164.8, [-0.0236, 0.0671, 0.0939]), + (166.4, [-0.0288, 0.07, 0.096]), + (168.0, [-0.029, 0.0721, 0.092]), + (169.6, [-0.0288, 0.0685, 0.0927]), + (171.2, [-0.0339, 0.069, 0.0933]), + (173.6, [-0.0341, 0.071, 0.0885]), + (175.2, [-0.0347, 0.0684, 0.0897]), + (176.0, [-0.038, 0.0674, 0.0907]), + (180.0, [-0.0387, 0.0719, 0.0901]), + (181.6, [-0.0438, 0.0682, 0.0922]), + (183.2, [-0.0436, 0.0696, 0.0928]), + (184.8, [-0.0431, 0.0707, 0.0891]), + (186.4, [-0.0473, 0.0708, 0.09]), + (188.8, [-0.048, 0.0657, 0.0898]), + (190.4, [-0.0476, 0.0655, 0.0842]), + (192.0, [-0.0522, 0.0651, 0.0836]), + (194.4, [-0.0525, 0.0579, 0.0821]), + (196.0, [-0.0521, 0.057, 0.0756]), + (199.2, [-0.0573, 0.0544, 0.072]), + (200.8, [-0.0568, 0.0478, 0.0698]), + (202.4, [-0.0573, 0.0462, 0.0625]), + (204.0, [-0.0621, 0.045, 0.0607]), + (206.4, [-0.0613, 0.0419, 0.0578]), + (207.2, [-0.0612, 0.0376, 0.0562]), + (208.8, [-0.0647, 0.035, 0.0489]), + (209.6, [-0.0666, 0.0343, 0.048]), + (212.8, [-0.0656, 0.0309, 0.0438]), + (214.4, [-0.0664, 0.0241, 0.0381]), + (215.2, [-0.0698, 0.0224, 0.0345]), + (220.0, [-0.0705, 0.015, 0.0263]), + (222.4, [-0.076, 0.0057, 0.0165]), + (227.2, [-0.0772, -0.0008, 0.0091]), + (228.8, [-0.0812, -0.0057, 0.0019]), + (229.6, [-0.0812, -0.009, 0.0013]), + (232.8, [-0.0807, -0.0106, -0.0008]), + (234.4, [-0.0845, -0.0109, -0.0014]), + (236.0, [-0.0861, -0.0114, -0.0066]), + (237.6, [-0.0859, -0.0164, -0.006]), + (240.8, [-0.086, -0.0138, -0.0031]), + (242.4, [-0.0911, -0.0123, -0.0021]), + (244.0, [-0.0907, -0.011, -0.0056]), + (245.6, [-0.0905, -0.015, -0.0036]), + (248.0, [-0.0907, -0.0134, -0.0013]), + (250.4, [-0.0959, -0.0124, -0.0005]), + (252.0, [-0.0958, -0.0124, -0.0059]), + (253.6, [-0.0957, -0.0147, -0.0063]), + (254.4, [-0.0954, -0.0191, -0.0068]), + (257.6, [-0.1003, -0.0216, -0.0095]), + (259.2, [-0.0997, -0.0229, -0.0118]), + (260.0, [-0.0994, -0.0235, -0.0164]), + (260.8, [-0.0991, -0.0241, -0.0182]), + (262.4, [-0.0986, -0.0255, -0.0195]), + (264.8, [-0.101, -0.0326, -0.0214]), + (265.6, [-0.1039, -0.0331, -0.022]), + (268.0, [-0.1032, -0.0346, -0.024]), + (269.6, [-0.1029, -0.0355, -0.0304]), + (272.8, [-0.1027, -0.0373, -0.0337]), + (274.4, [-0.108, -0.0433, -0.0352]), + (277.6, [-0.1072, -0.0433, -0.038]), + (279.2, [-0.107, -0.0432, -0.0446]), + (282.4, [-0.1069, -0.0422, -0.0455]), + (284.0, [-0.1121, -0.0418, -0.0459]), + (285.6, [-0.1117, -0.0464, -0.0461]), + (288.8, [-0.1114, -0.0467, -0.0468]), + (289.6, [-0.1113, -0.0468, -0.0513]), + (290.4, [-0.1112, -0.0469, -0.052]), + (296.0, [-0.1163, -0.048, -0.0521]), + (297.6, [-0.1162, -0.0541, -0.0522]), + (300.0, [-0.1161, -0.0544, -0.0524]), + (301.6, [-0.1162, -0.0552, -0.0589]), + (304.0, [-0.1166, -0.0576, -0.0603]), + (304.8, [-0.1218, -0.0586, -0.061]), + (309.6, [-0.1226, -0.0647, -0.0655]), + (311.2, [-0.1221, -0.0713, -0.0662]), + (313.6, [-0.1205, -0.0714, -0.0654]), + (314.4, [-0.1198, -0.0711, -0.0697]), + (315.2, [-0.119, -0.0707, -0.0701]), + (316.8, [-0.1167, -0.0696, -0.0686]), + (318.4, [-0.1206, -0.0683, -0.0672]), + (326.4, [-0.1149, -0.0639, -0.0628]), + (328.0, [-0.115, -0.0695, -0.0629]), + (328.8, [-0.1152, -0.0695, -0.0625]), + (330.4, [-0.1159, -0.0699, -0.0688]), + (332.0, [-0.1167, -0.0706, -0.0694]), + (333.6, [-0.1233, -0.0716, -0.0704]), + (338.4, [-0.1269, -0.0746, -0.0738]), + (347.2, [-0.127, -0.0771, -0.0767]), + (348.8, [-0.1265, -0.0825, -0.0769]), + (349.6, [-0.1263, -0.0824, -0.0767]), + (351.2, [-0.1255, -0.0826, -0.0824]), + (352.8, [-0.1305, -0.0827, -0.0822]), + (432.0, [-0.1174, -0.0923, -0.0877]), + (460.8, [-0.1057, -0.0884, -0.0847]), + (472.8, [-0.093, -0.0827, -0.0779]), + (482.4, [-0.0803, -0.0742, -0.0704]), + (491.2, [-0.0743, -0.0671, -0.0657]), + (499.2, [-0.0622, -0.0572, -0.0593]), + (516.8, [-0.0419, -0.0239, -0.0357]), + (528.0, [-0.0265, 0.0054, -0.0132]), + (532.0, [-0.0223, 0.0133, -0.0073]), + (569.6, [-0.0032, 0.0277, 0.0061]), + (583.2, [0.0038, 0.0302, 0.0087]), + (596.0, [0.0125, 0.0306, 0.0084]), + (597.6, [0.0156, 0.0308, 0.0093]), + (598.4, [0.0195, 0.0314, 0.0106]), + (603.2, [0.0568, 0.0411, 0.0248]), + (604.8, [0.0654, 0.0383, 0.0254]), + (606.4, [0.0695, 0.0307, 0.0217]), + (608.0, [0.0665, 0.0205, 0.0144]), + (609.6, [0.0538, 0.0101, 0.006]), + (612.0, [0.0201, -0.0006, -0.0019]), + (613.6, [-0.0031, -0.0025, -0.0023]), + (614.4, [-0.0121, -0.0022, -0.0016]), + (615.2, [-0.0183, -0.0015, -0.0009]), + (616.0, [-0.0211, -0.0007, -0.0004]), + (617.6, [-0.0177, -0.0004, -0.0011]), + (620.0, [-0.0069, -0.0043, -0.0074]), + (622.4, [-0.0047, -0.007, -0.0118]), + (624.8, [-0.0062, -0.0023, -0.0058]), + (627.2, [0.004, 0.0041, 0.001]), + (628.8, [0.0071, 0.004, 0.0]), + (631.2, [0.0007, -0.0015, -0.0051]), + (632.8, [-0.0012, -0.0019, -0.0033]), + (635.2, [-0.0006, 0.0017, 0.004]), + (639.2, [0.0, -0.0013, 0.0016]), + (641.6, [0.0013, 0.0018, 0.0054]), + (643.2, [0.0023, 0.0016, 0.0031]), + (644.8, [0.0004, -0.0001, 0.0007]), + (650.4, [-0.0009, -0.0012, -0.0017]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.3178, -0.3098, -0.3125]), + (0.8, [-0.3018, -0.2804, -0.2781]), + (1.6, [-0.304, -0.2858, -0.2832]), + (4.8, [-0.3087, -0.2886, -0.2773]), + (10.4, [-0.3046, -0.2844, -0.2383]), + (16.0, [-0.2826, -0.2212, -0.182]), + (25.6, [-0.2221, -0.0755, -0.0742]), + (26.4, [-0.2171, -0.0677, -0.0661]), + (28.8, [-0.2012, -0.0349, -0.0437]), + (29.6, [-0.1962, -0.029, -0.036]), + (31.2, [-0.1866, -0.0137, -0.0251]), + (32.8, [-0.1778, -0.0048, -0.0134]), + (33.6, [-0.173, 0.0003, -0.0098]), + (36.0, [-0.1577, 0.0187, 0.009]), + (42.4, [-0.1182, 0.0578, 0.0476]), + (46.4, [-0.0941, 0.0777, 0.0692]), + (53.6, [-0.0555, 0.1085, 0.0996]), + (60.0, [-0.026, 0.1244, 0.117]), + (62.4, [-0.0192, 0.1301, 0.1217]), + (63.2, [-0.0256, 0.1277, 0.1219]), + (66.4, [-0.0902, 0.1115, 0.12]), + (67.2, [-0.0991, 0.1083, 0.1195]), + (68.0, [-0.0914, 0.1105, 0.1199]), + (71.2, [-0.0079, 0.1313, 0.129]), + (72.0, [0.0093, 0.1331, 0.1299]), + (72.8, [0.0148, 0.1349, 0.1293]), + (74.4, [0.0174, 0.1378, 0.1298]), + (78.4, [0.0287, 0.1403, 0.1334]), + (83.2, [0.0424, 0.1414, 0.1367]), + (85.6, [0.0466, 0.1428, 0.1368]), + (87.2, [0.0526, 0.1434, 0.1382]), + (89.6, [0.0547, 0.1432, 0.1382]), + (98.4, [0.0744, 0.141, 0.1374]), + (105.6, [0.0823, 0.1382, 0.1249]), + (111.2, [0.0899, 0.1342, 0.1287]), + (113.6, [0.0951, 0.1373, 0.1305]), + (115.2, [0.0965, 0.1365, 0.1363]), + (120.0, [0.1008, 0.1358, 0.1431]), + (127.2, [0.1098, 0.1351, 0.1449]), + (133.6, [0.1117, 0.1293, 0.1425]), + (138.4, [0.1145, 0.1162, 0.1276]), + (143.2, [0.118, 0.0973, 0.1052]), + (148.0, [0.1186, 0.0758, 0.0784]), + (151.2, [0.1177, 0.0586, 0.0568]), + (156.0, [0.1175, 0.0374, 0.0249]), + (161.6, [0.1186, 0.0185, 0.0007]), + (165.6, [0.1187, 0.0105, -0.0081]), + (168.0, [0.1193, 0.0064, -0.0085]), + (171.2, [0.121, 0.0057, -0.0106]), + (173.6, [0.1208, 0.0036, -0.0093]), + (176.0, [0.1216, 0.0037, -0.0116]), + (180.0, [0.1206, -0.0015, -0.0151]), + (182.4, [0.1224, -0.0019, -0.0184]), + (185.6, [0.1218, -0.0045, -0.019]), + (188.0, [0.1228, -0.0027, -0.02]), + (192.0, [0.1236, -0.0029, -0.0173]), + (194.4, [0.1232, 0.0007, -0.0163]), + (199.2, [0.1239, 0.0034, -0.0098]), + (200.8, [0.1237, 0.0068, -0.0081]), + (202.4, [0.1235, 0.008, -0.0042]), + (205.6, [0.1244, 0.0103, -0.0006]), + (208.0, [0.1241, 0.0149, 0.0036]), + (212.8, [0.1248, 0.0189, 0.01]), + (220.0, [0.125, 0.0308, 0.0233]), + (222.4, [0.1272, 0.037, 0.0299]), + (229.6, [0.1283, 0.0478, 0.0414]), + (236.0, [0.1294, 0.0502, 0.0458]), + (237.6, [0.1291, 0.0517, 0.0452]), + (250.4, [0.1315, 0.0437, 0.0354]), + (252.8, [0.1312, 0.0435, 0.037]), + (258.4, [0.1314, 0.049, 0.0406]), + (275.2, [0.1314, 0.0626, 0.059]), + (280.0, [0.1304, 0.0625, 0.0643]), + (296.0, [0.132, 0.0637, 0.0679]), + (300.0, [0.1318, 0.0661, 0.0682]), + (311.2, [0.134, 0.0779, 0.0779]), + (313.6, [0.1325, 0.0793, 0.0777]), + (324.0, [0.1237, 0.0729, 0.0714]), + (328.8, [0.123, 0.0734, 0.0706]), + (343.2, [0.132, 0.0806, 0.0799]), + (428.8, [0.1178, 0.0925, 0.0877]), + (464.0, [0.104, 0.0878, 0.0839]), + (482.4, [0.081, 0.0748, 0.0709]), + (492.0, [0.0743, 0.0671, 0.0656]), + (502.4, [0.0595, 0.0536, 0.0567]), + (512.0, [0.0483, 0.0354, 0.0437]), + (519.2, [0.0397, 0.0203, 0.0329]), + (529.6, [0.0255, -0.0075, 0.0115]), + (532.8, [0.0222, -0.0133, 0.0073]), + (570.4, [0.0031, -0.0277, -0.0061]), + (584.0, [-0.0038, -0.0303, -0.0087]), + (596.8, [-0.0124, -0.0307, -0.0084]), + (598.4, [-0.0152, -0.0308, -0.0092]), + (599.2, [-0.0186, -0.0313, -0.0103]), + (604.0, [-0.0558, -0.0411, -0.0245]), + (605.6, [-0.0648, -0.0389, -0.0256]), + (607.2, [-0.0694, -0.0318, -0.0223]), + (608.8, [-0.0673, -0.0217, -0.0154]), + (610.4, [-0.0557, -0.0112, -0.0069]), + (612.8, [-0.0229, 0.0001, 0.0015]), + (614.4, [0.0008, 0.0024, 0.0023]), + (615.2, [0.0103, 0.0022, 0.0017]), + (616.0, [0.0172, 0.0016, 0.0009]), + (616.8, [0.0207, 0.0008, 0.0004]), + (618.4, [0.0184, 0.0002, 0.0008]), + (621.6, [0.0052, 0.0056, 0.0092]), + (623.2, [0.0045, 0.007, 0.0117]), + (625.6, [0.0063, 0.0028, 0.0063]), + (626.4, [0.0044, 0.0003, 0.0034]), + (628.0, [-0.0033, -0.0039, -0.0009]), + (629.6, [-0.0073, -0.0044, -0.0005]), + (632.0, [-0.0012, 0.0012, 0.0049]), + (633.6, [0.0011, 0.002, 0.0037]), + (636.8, [0.0007, -0.0019, -0.0047]), + (640.0, [-0.0001, 0.0014, -0.0013]), + (642.4, [-0.0012, -0.0016, -0.0053]), + (644.0, [-0.0024, -0.0018, -0.0034]), + (645.6, [-0.0007, -0.0001, -0.0008]), + (648.0, [0.0002, -0.0001, -0.0009]), + (651.2, [0.0009, 0.0012, 0.0018]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.1191, 0.143, 0.0733]), + (3.1962, [0.1105, 0.1403, 0.0737]), + (5.5934, [0.1112, 0.1382, 0.0737]), + (9.5887, [0.1039, 0.1352, 0.0736]), + (14.383, [0.1024, 0.1305, 0.0736]), + (23.972, [0.0878, 0.1251, 0.0738]), + (25.57, [0.0841, 0.1211, 0.0739]), + (27.967, [0.0838, 0.1209, 0.0743]), + (29.565, [0.0795, 0.1205, 0.0748]), + (31.962, [0.0801, 0.1205, 0.0756]), + (33.56, [0.0759, 0.1201, 0.0758]), + (35.958, [0.0763, 0.12, 0.0761]), + (39.953, [0.0712, 0.119, 0.0755]), + (41.551, [0.0659, 0.1142, 0.0748]), + (44.747, [0.0622, 0.112, 0.0732]), + (46.345, [0.0568, 0.1114, 0.0726]), + (49.542, [0.0547, 0.1109, 0.0717]), + (51.14, [0.0497, 0.1066, 0.0715]), + (55.135, [0.0494, 0.1064, 0.0713]), + (56.733, [0.0451, 0.1066, 0.0716]), + (60.728, [0.0462, 0.1082, 0.0733]), + (62.327, [0.0425, 0.1048, 0.074]), + (67.92, [0.043, 0.1061, 0.0749]), + (69.518, [0.0391, 0.1057, 0.0746]), + (75.112, [0.0394, 0.1048, 0.0737]), + (76.71, [0.0352, 0.1006, 0.0738]), + (85.499, [0.0355, 0.101, 0.0739]), + (87.097, [0.0314, 0.101, 0.0739]), + (111.87, [0.0314, 0.1009, 0.074]), + (115.06, [0.0489, 0.1049, 0.0737]), + (116.66, [0.0589, 0.1055, 0.0781]), + (119.06, [0.0727, 0.1088, 0.0779]), + (119.86, [0.0739, 0.1088, 0.0779]), + (127.05, [0.0325, 0.1009, 0.0738]), + (147.03, [0.0317, 0.1014, 0.0737]), + (147.83, [0.0312, 0.0963, 0.0706]), + (158.21, [0.0011, 0.0086, 0.0057]), + (159.01, [0.0001, 0.002, 0.0007]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0007, 0.1338, 0.1558]), + (8.7602, [-0.0004, 0.1283, 0.1516]), + (18.317, [-0.0003, 0.1009, 0.1217]), + (23.095, [-0.0004, 0.0793, 0.0954]), + (29.466, [-0.0002, 0.0377, 0.0458]), + (31.059, [-0.0004, 0.0295, 0.0353]), + (32.652, [-0.0003, 0.0226, 0.0269]), + (34.245, [-0.0003, 0.0171, 0.0213]), + (38.227, [-0.0004, 0.009, 0.0109]), + (44.598, [-0.0002, 0.0024, 0.0029]), + (59.729, [-0.0003, 0.0036, 0.0041]), + (72.471, [-0.0004, 0.0015, 0.0019]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0232, 0.0313, 0.1277]), + (0.79334, [0.0246, 0.0331, 0.1315]), + (15.867, [0.0265, 0.0318, 0.1287]), + (17.454, [0.0225, 0.0276, 0.1121]), + (19.04, [0.0212, 0.0256, 0.1038]), + (28.56, [0.0153, 0.0182, 0.0739]), + (30.94, [0.0138, 0.0162, 0.0619]), + (33.32, [0.0108, 0.0122, 0.0481]), + (36.494, [0.0056, 0.0068, 0.0237]), + (39.667, [-0.0002, 0.0011, 0.0058]), + (41.254, [0.0, 0.0011, 0.001]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0079, 0.0109, 0.0109]), + (0.7847, [0.0094, 0.0073, 0.0081]), + (3.1388, [0.0132, 0.0087, 0.0101]), + (3.9235, [0.0163, 0.0109, 0.0121]), + (4.7082, [0.0165, 0.0127, 0.0136]), + (5.4929, [0.0215, 0.0161, 0.0174]), + (7.0623, [0.0245, 0.0214, 0.0223]), + (7.847, [0.028, 0.0267, 0.0272]), + (8.6317, [0.0283, 0.0273, 0.0277]), + (9.4165, [0.0315, 0.0312, 0.0312]), + (11.771, [0.0328, 0.0349, 0.0339]), + (12.555, [0.0309, 0.0321, 0.0314]), + (13.34, [0.0308, 0.0326, 0.0315]), + (19.618, [0.0075, 0.0067, 0.0073]), + (21.972, [0.0017, 0.0009, 0.0018]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0836, 0.0339, -0.0014]), + (1.5946, [0.0778, 0.0342, -0.0011]), + (5.5811, [0.0753, 0.0337, -0.001]), + (12.757, [0.0578, 0.0292, 0.0002]), + (18.338, [0.0449, 0.0229, 0.001]), + (20.73, [0.0433, 0.0205, 0.0011]), + (24.716, [0.0355, 0.0163, 0.0013]), + (30.297, [0.0224, 0.0095, 0.0009]), + (35.878, [0.0129, 0.0041, 0.0006]), + (43.054, [0.0036, 0.0017, 0.0007]), + (54.216, [0.0003, 0.0011, 0.0006]), + (71.757, [0.0016, 0.0019, 0.0017]), + (90.094, [-0.0011, 0.0, -0.0007]), + (102.05, [0.0004, 0.0019, 0.0013]), + ], + }, +]; +const PRIME35_100_STREAKS: &[Streak] = &[]; +const PRIME35_100_RAYS: &[Ray] = &[ + Ray { angle: 7.32, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0275] }, + Ray { angle: 8.05, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 9.3, r0: 6.25, r1: 37.5, fade: 0.05, rgb: [0.0118, 0.0216, 0.0255] }, + Ray { angle: 10.56, r0: 6.25, r1: 60.938, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 11.86, r0: 33.594, r1: 113.28, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 17.4, r0: 18.75, r1: 132.03, fade: 0.05, rgb: [0.0314, 0.0431, 0.0431] }, + Ray { angle: 17.65, r0: 18.75, r1: 128.12, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 24.03, r0: 16.406, r1: 120.31, fade: 0.05, rgb: [0.0098, 0.0137, 0.0157] }, + Ray { angle: 24.29, r0: 15.625, r1: 120.31, fade: 0.05, rgb: [0.0216, 0.0314, 0.0314] }, + Ray { angle: 37.26, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0167, 0.0196] }, + Ray { angle: 38.3, r0: 6.25, r1: 84.375, fade: 0.05, rgb: [0.0216, 0.0392, 0.0392] }, + Ray { angle: 38.56, r0: 6.25, r1: 86.719, fade: 0.05, rgb: [0.0225, 0.0373, 0.0392] }, + Ray { angle: 39.41, r0: 7.8125, r1: 40.625, fade: 0.05, rgb: [0.0137, 0.0275, 0.0294] }, + Ray { angle: 40.48, r0: 8.5938, r1: 35.938, fade: 0.05, rgb: [0.0078, 0.0127, 0.0147] }, + Ray { angle: 41.49, r0: 43.75, r1: 94.531, fade: 0.05, rgb: [0.0098, 0.0206, 0.0196] }, + Ray { angle: 41.76, r0: 35.938, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 53.14, r0: 14.844, r1: 120.31, fade: 0.05, rgb: [0.0078, 0.0127, 0.0127] }, + Ray { angle: 53.39, r0: 14.844, r1: 121.09, fade: 0.05, rgb: [0.0255, 0.0392, 0.0392] }, + Ray { angle: 67.69, r0: 8.5938, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 68.57, r0: 8.5938, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 69.89, r0: 76.562, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 71.17, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 71.87, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0275] }, + Ray { angle: 97.72, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0196, 0.0255, 0.0275] }, + Ray { angle: 98.42, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0235, 0.0255] }, + Ray { angle: 99.71, r0: 79.688, r1: 114.06, fade: 0.05, rgb: [0.0196, 0.0235, 0.0196] }, + Ray { angle: 100.98, r0: 79.688, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 102.24, r0: 33.594, r1: 113.28, fade: 0.05, rgb: [0.0176, 0.0216, 0.0196] }, + Ray { angle: 104.36, r0: 50.781, r1: 372.66, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 110.57, r0: 13.281, r1: 125.78, fade: 0.05, rgb: [0.0118, 0.0176, 0.0196] }, + Ray { angle: 110.89, r0: 14.062, r1: 138.28, fade: 0.05, rgb: [0.0324, 0.05, 0.051] }, + Ray { angle: 118.75, r0: 17.188, r1: 232.81, fade: 0.05, rgb: [0.0333, 0.0392, 0.0392] }, + Ray { angle: 127.69, r0: 8.5938, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0235, 0.0235] }, + Ray { angle: 128.59, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0118, 0.0157, 0.0196] }, + Ray { angle: 129.71, r0: 7.8125, r1: 37.5, fade: 0.05, rgb: [0.0118, 0.0196, 0.0255] }, + Ray { angle: 130.84, r0: 7.0312, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 132.02, r0: 38.281, r1: 110.94, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 147.42, r0: 14.844, r1: 278.12, fade: 0.05, rgb: [0.0333, 0.0353, 0.0353] }, + Ray { angle: 157.63, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 158.52, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 159.79, r0: 78.125, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0225, 0.0206] }, + Ray { angle: 161.67, r0: 6.25, r1: 246.88, fade: 0.05, rgb: [0.0608, 0.0647, 0.0627] }, + Ray { angle: 168.47, r0: 12.5, r1: 141.41, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 168.81, r0: 12.5, r1: 238.28, fade: 0.05, rgb: [0.0333, 0.0373, 0.0353] }, + Ray { angle: 183.22, r0: 21.875, r1: 234.38, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 187.67, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0255] }, + Ray { angle: 188.46, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0255, 0.0255] }, + Ray { angle: 189.71, r0: 68.75, r1: 114.06, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 190.96, r0: 6.25, r1: 59.375, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 192.24, r0: 28.125, r1: 113.28, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 217.72, r0: 13.281, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0216, 0.0216] }, + Ray { angle: 218.64, r0: 7.8125, r1: 78.125, fade: 0.05, rgb: [0.0137, 0.0216, 0.0216] }, + Ray { angle: 219.8, r0: 6.25, r1: 39.062, fade: 0.05, rgb: [0.0118, 0.0216, 0.0255] }, + Ray { angle: 220.92, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0098, 0.0147, 0.0196] }, + Ray { angle: 222.04, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0176, 0.0196] }, + Ray { angle: 226.01, r0: 30.469, r1: 228.12, fade: 0.05, rgb: [0.0235, 0.0294, 0.0275] }, + Ray { angle: 240.27, r0: 17.969, r1: 224.22, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 247.12, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0245, 0.0245] }, + Ray { angle: 247.4, r0: 6.25, r1: 210.94, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 248.21, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0196, 0.0216] }, + Ray { angle: 249.48, r0: 83.594, r1: 114.06, fade: 0.05, rgb: [0.0363, 0.0392, 0.0392] }, + Ray { angle: 250.75, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 251.44, r0: 28.906, r1: 112.5, fade: 0.05, rgb: [0.0196, 0.0265, 0.0245] }, + Ray { angle: 254.64, r0: 38.281, r1: 155.47, fade: 0.05, rgb: [0.0235, 0.0314, 0.0275] }, + Ray { angle: 254.89, r0: 25.781, r1: 144.53, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 269.02, r0: 10.938, r1: 195.31, fade: 0.05, rgb: [0.0373, 0.0431, 0.0431] }, + Ray { angle: 276.16, r0: 113.28, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 277.32, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0176, 0.0275, 0.0275] }, + Ray { angle: 278.06, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 279.33, r0: 68.75, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 280.59, r0: 80.469, r1: 113.28, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 281.85, r0: 6.25, r1: 65.625, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 283.24, r0: 30.469, r1: 199.22, fade: 0.05, rgb: [0.0275, 0.0314, 0.0314] }, + Ray { angle: 297.53, r0: 21.094, r1: 196.88, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 305.53, r0: 47.656, r1: 99.219, fade: 0.05, rgb: [0.0118, 0.0216, 0.0235] }, + Ray { angle: 307.46, r0: 9.375, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 308.33, r0: 7.8125, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 309.42, r0: 6.25, r1: 39.062, fade: 0.05, rgb: [0.0098, 0.0137, 0.0216] }, + Ray { angle: 310.6, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0137, 0.0157] }, + Ray { angle: 311.8, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0196, 0.0216] }, + Ray { angle: 337.28, r0: 8.5938, r1: 113.28, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 338.21, r0: 7.0312, r1: 114.06, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 339.49, r0: 78.125, r1: 114.06, fade: 0.05, rgb: [0.0353, 0.0373, 0.0392] }, + Ray { angle: 340.81, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 341.56, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0176, 0.0275, 0.0275] }, +]; +const PRIME35_125_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [1.07, 1.084, 1.069]), + (0.8, [1.135, 1.127, 1.117]), + (1.6, [1.113, 1.113, 1.101]), + (7.2, [1.131, 1.117, 1.088]), + (12.8, [1.12, 1.102, 1.016]), + (16.0, [1.101, 1.036, 0.9582]), + (27.2, [1.012, 0.7168, 0.7255]), + (30.4, [0.9818, 0.6473, 0.6681]), + (41.6, [0.8687, 0.4798, 0.5065]), + (51.2, [0.7621, 0.3521, 0.3864]), + (55.2, [0.7145, 0.3047, 0.3411]), + (61.6, [0.644, 0.2443, 0.278]), + (62.4, [0.643, 0.2379, 0.2696]), + (63.2, [0.6602, 0.2379, 0.2629]), + (66.4, [0.8143, 0.2649, 0.2486]), + (67.2, [0.8342, 0.2651, 0.2445]), + (68.0, [0.815, 0.2566, 0.2365]), + (68.8, [0.7675, 0.2406, 0.2283]), + (71.2, [0.5926, 0.1809, 0.2023]), + (72.0, [0.546, 0.1622, 0.1964]), + (72.8, [0.5273, 0.1539, 0.1892]), + (75.2, [0.5025, 0.1411, 0.1713]), + (76.8, [0.4903, 0.1298, 0.1663]), + (80.0, [0.4631, 0.1226, 0.1522]), + (81.6, [0.4498, 0.1142, 0.1497]), + (86.4, [0.4171, 0.1028, 0.1389]), + (88.8, [0.399, 0.1027, 0.1299]), + (91.2, [0.3829, 0.0937, 0.1243]), + (92.8, [0.3746, 0.0891, 0.116]), + (96.0, [0.3506, 0.0848, 0.1119]), + (97.6, [0.3426, 0.0763, 0.1088]), + (100.8, [0.3214, 0.0738, 0.1082]), + (104.0, [0.305, 0.0722, 0.1016]), + (107.2, [0.286, 0.0703, 0.1019]), + (111.2, [0.264, 0.0581, 0.0966]), + (113.6, [0.2488, 0.0553, 0.089]), + (115.2, [0.239, 0.055, 0.0784]), + (116.8, [0.2311, 0.0466, 0.074]), + (122.4, [0.1999, 0.0383, 0.055]), + (124.0, [0.1939, 0.0376, 0.0546]), + (125.6, [0.1849, 0.0299, 0.0506]), + (128.0, [0.1756, 0.0265, 0.0464]), + (130.4, [0.1618, 0.0298, 0.0393]), + (132.0, [0.1564, 0.0334, 0.041]), + (133.6, [0.1477, 0.0331, 0.0402]), + (136.0, [0.139, 0.0324, 0.0439]), + (136.8, [0.1368, 0.0333, 0.0477]), + (138.4, [0.1301, 0.0405, 0.0523]), + (140.0, [0.1216, 0.0443, 0.0601]), + (141.6, [0.1182, 0.0521, 0.0653]), + (142.4, [0.1168, 0.0536, 0.0704]), + (147.2, [0.0929, 0.0772, 0.0959]), + (148.8, [0.0879, 0.0783, 0.1078]), + (150.4, [0.0852, 0.0889, 0.1114]), + (151.2, [0.0842, 0.0913, 0.118]), + (152.8, [0.0752, 0.1012, 0.1277]), + (153.6, [0.0749, 0.1055, 0.135]), + (155.2, [0.0662, 0.1115, 0.1445]), + (157.6, [0.0614, 0.1207, 0.16]), + (159.2, [0.061, 0.1279, 0.1661]), + (160.8, [0.0565, 0.1301, 0.171]), + (162.4, [0.0482, 0.136, 0.1758]), + (164.8, [0.0449, 0.138, 0.1774]), + (166.4, [0.0368, 0.1403, 0.1789]), + (168.8, [0.0308, 0.1399, 0.1767]), + (173.6, [0.0237, 0.1342, 0.1683]), + (176.8, [0.0178, 0.1333, 0.1664]), + (178.4, [0.0173, 0.135, 0.1681]), + (180.0, [0.013, 0.1328, 0.1661]), + (181.6, [0.0125, 0.1341, 0.1665]), + (182.4, [0.0096, 0.1343, 0.1644]), + (184.0, [0.0093, 0.1312, 0.1658]), + (185.6, [0.0049, 0.1325, 0.1623]), + (187.2, [0.005, 0.1282, 0.1622]), + (188.8, [0.0007, 0.1284, 0.1572]), + (190.4, [0.0001, 0.1238, 0.1561]), + (191.2, [-0.0029, 0.1226, 0.1538]), + (192.8, [-0.003, 0.1212, 0.1492]), + (194.4, [-0.0072, 0.1154, 0.146]), + (196.0, [-0.0074, 0.1142, 0.1402]), + (197.6, [-0.0119, 0.1076, 0.1365]), + (199.2, [-0.0119, 0.1063, 0.1309]), + (200.0, [-0.0148, 0.1049, 0.1298]), + (204.8, [-0.0198, 0.0898, 0.115]), + (206.4, [-0.0235, 0.0883, 0.1108]), + (208.8, [-0.0242, 0.0806, 0.1018]), + (210.4, [-0.0284, 0.0789, 0.0993]), + (212.0, [-0.029, 0.072, 0.0917]), + (212.8, [-0.0323, 0.0712, 0.0906]), + (214.4, [-0.0321, 0.0689, 0.0866]), + (216.0, [-0.0361, 0.0617, 0.08]), + (218.4, [-0.0368, 0.0576, 0.0728]), + (219.2, [-0.0398, 0.0531, 0.0686]), + (221.6, [-0.0411, 0.0482, 0.0628]), + (223.2, [-0.0448, 0.041, 0.0561]), + (224.8, [-0.0444, 0.039, 0.0531]), + (226.4, [-0.0487, 0.0356, 0.046]), + (227.2, [-0.0478, 0.0319, 0.0456]), + (228.8, [-0.0489, 0.0314, 0.0429]), + (229.6, [-0.0516, 0.031, 0.039]), + (231.2, [-0.0503, 0.0259, 0.0382]), + (233.6, [-0.0532, 0.027, 0.0331]), + (235.2, [-0.0528, 0.0233, 0.0343]), + (236.8, [-0.0567, 0.0259, 0.0316]), + (238.4, [-0.0556, 0.0268, 0.0339]), + (239.2, [-0.0569, 0.025, 0.0352]), + (240.0, [-0.0598, 0.0268, 0.0357]), + (240.8, [-0.0588, 0.0286, 0.0338]), + (242.4, [-0.0588, 0.031, 0.0372]), + (243.2, [-0.0619, 0.0293, 0.0388]), + (244.8, [-0.0613, 0.0334, 0.0373]), + (246.4, [-0.062, 0.0365, 0.0407]), + (247.2, [-0.0651, 0.035, 0.0421]), + (248.8, [-0.0639, 0.037, 0.0399]), + (249.6, [-0.0637, 0.0383, 0.0412]), + (250.4, [-0.0662, 0.0393, 0.0422]), + (252.0, [-0.0667, 0.0361, 0.0422]), + (252.8, [-0.0664, 0.0373, 0.0388]), + (254.4, [-0.0699, 0.0385, 0.0392]), + (256.0, [-0.068, 0.0347, 0.0372]), + (256.8, [-0.0673, 0.0345, 0.0331]), + (258.4, [-0.0705, 0.0345, 0.0331]), + (259.2, [-0.0688, 0.0341, 0.0326]), + (260.8, [-0.0674, 0.0287, 0.0272]), + (262.4, [-0.0707, 0.029, 0.027]), + (264.0, [-0.0686, 0.0285, 0.0265]), + (264.8, [-0.068, 0.025, 0.0231]), + (266.4, [-0.0715, 0.0236, 0.0217]), + (268.0, [-0.0697, 0.0234, 0.0216]), + (269.6, [-0.072, 0.0182, 0.0167]), + (272.8, [-0.0706, 0.0194, 0.0147]), + (273.6, [-0.0741, 0.0175, 0.0105]), + (274.4, [-0.0738, 0.0148, 0.0106]), + (276.8, [-0.0719, 0.0171, 0.0104]), + (278.4, [-0.0756, 0.0166, 0.0052]), + (279.2, [-0.0745, 0.0138, 0.0059]), + (281.6, [-0.075, 0.0172, 0.0064]), + (282.4, [-0.0774, 0.0181, 0.0026]), + (283.2, [-0.0762, 0.018, 0.0026]), + (284.0, [-0.0758, 0.015, 0.0034]), + (285.6, [-0.0761, 0.0178, 0.0046]), + (286.4, [-0.0795, 0.0192, 0.0038]), + (287.2, [-0.0785, 0.0203, 0.0005]), + (288.0, [-0.0779, 0.0212, 0.0017]), + (288.8, [-0.0775, 0.0179, 0.0025]), + (290.4, [-0.0798, 0.0207, 0.004]), + (291.2, [-0.081, 0.0221, 0.0038]), + (292.0, [-0.0801, 0.0233, 0.0]), + (292.8, [-0.0795, 0.0247, 0.0011]), + (294.4, [-0.0789, 0.0214, 0.0025]), + (295.2, [-0.083, 0.0221, 0.0032]), + (296.0, [-0.0827, 0.0223, 0.0026]), + (296.8, [-0.082, 0.0227, -0.0009]), + (298.4, [-0.0807, 0.0233, 0.0012]), + (299.2, [-0.0836, 0.019, 0.0021]), + (300.8, [-0.0844, 0.0187, 0.0026]), + (301.6, [-0.0839, 0.0185, -0.0015]), + (303.2, [-0.0826, 0.0175, -0.0008]), + (304.0, [-0.0865, 0.0153, -0.0008]), + (304.8, [-0.0878, 0.0097, -0.0009]), + (305.6, [-0.0872, 0.0089, -0.0014]), + (306.4, [-0.087, 0.0074, -0.0068]), + (308.0, [-0.0862, 0.0046, -0.0076]), + (309.6, [-0.0906, 0.0022, -0.0076]), + (310.4, [-0.0895, -0.0031, -0.0069]), + (311.2, [-0.0881, -0.0029, -0.0069]), + (312.0, [-0.0867, -0.0025, -0.0103]), + (312.8, [-0.0845, -0.0017, -0.0085]), + (314.4, [-0.0862, 0.0013, -0.0044]), + (316.0, [-0.0816, -0.0006, 0.001]), + (316.8, [-0.0793, 0.0018, -0.0012]), + (317.6, [-0.0765, 0.0039, 0.0009]), + (319.2, [-0.0775, 0.0081, 0.0065]), + (320.8, [-0.0729, 0.0122, 0.0119]), + (321.6, [-0.0708, 0.0089, 0.0146]), + (322.4, [-0.0688, 0.0105, 0.012]), + (323.2, [-0.0671, 0.0123, 0.0129]), + (324.0, [-0.0703, 0.0139, 0.0135]), + (326.4, [-0.0666, 0.0185, 0.0145]), + (327.2, [-0.0658, 0.0174, 0.0151]), + (328.0, [-0.0646, 0.0148, 0.011]), + (329.6, [-0.0697, 0.0168, 0.0089]), + (332.8, [-0.0709, 0.0189, 0.0061]), + (333.6, [-0.0713, 0.0155, 0.0015]), + (334.4, [-0.0768, 0.0132, -0.0021]), + (339.2, [-0.0804, 0.0141, -0.0085]), + (340.0, [-0.0848, 0.0131, -0.0144]), + (340.8, [-0.0861, 0.0089, -0.0153]), + (345.6, [-0.0832, 0.0061, -0.0176]), + (346.4, [-0.0866, 0.0064, -0.0228]), + (347.2, [-0.0861, 0.0019, -0.0234]), + (348.0, [-0.0856, 0.0, -0.0233]), + (351.2, [-0.0834, -0.0025, -0.0236]), + (352.0, [-0.0865, -0.0031, -0.0232]), + (352.8, [-0.0883, -0.0037, -0.0281]), + (353.6, [-0.088, -0.0037, -0.0289]), + (354.4, [-0.0878, -0.0084, -0.0288]), + (355.2, [-0.0876, -0.0105, -0.0289]), + (357.6, [-0.0869, -0.0121, -0.0289]), + (358.4, [-0.0915, -0.0128, -0.0286]), + (360.0, [-0.0913, -0.0139, -0.0344]), + (360.8, [-0.091, -0.0141, -0.034]), + (362.4, [-0.0907, -0.021, -0.0339]), + (364.0, [-0.0899, -0.0219, -0.0337]), + (364.8, [-0.094, -0.0223, -0.0337]), + (366.4, [-0.0952, -0.0233, -0.0336]), + (367.2, [-0.0952, -0.0237, -0.0386]), + (368.8, [-0.0952, -0.0243, -0.0383]), + (369.6, [-0.095, -0.0295, -0.0381]), + (371.2, [-0.0948, -0.0308, -0.0378]), + (372.0, [-0.0996, -0.0312, -0.0376]), + (373.6, [-0.1003, -0.0319, -0.0368]), + (375.2, [-0.1003, -0.0325, -0.0424]), + (377.6, [-0.1005, -0.0336, -0.0414]), + (378.4, [-0.1, -0.0386, -0.0411]), + (380.0, [-0.1059, -0.039, -0.0405]), + (381.6, [-0.1061, -0.0394, -0.0394]), + (383.2, [-0.1063, -0.0396, -0.0445]), + (386.4, [-0.1064, -0.0396, -0.0427]), + (387.2, [-0.1113, -0.0445, -0.0423]), + (390.4, [-0.1128, -0.0454, -0.0403]), + (391.2, [-0.113, -0.0454, -0.041]), + (392.0, [-0.1131, -0.0454, -0.0453]), + (395.2, [-0.1135, -0.0451, -0.0486]), + (396.0, [-0.1185, -0.0446, -0.0494]), + (397.6, [-0.1187, -0.0502, -0.0511]), + (400.0, [-0.1189, -0.0492, -0.0531]), + (401.6, [-0.1189, -0.0487, -0.0606]), + (403.2, [-0.1186, -0.0481, -0.0618]), + (404.8, [-0.1242, -0.0476, -0.0631]), + (407.2, [-0.1238, -0.0463, -0.065]), + (408.8, [-0.1236, -0.0522, -0.0663]), + (410.4, [-0.1235, -0.0542, -0.0673]), + (411.2, [-0.1236, -0.0552, -0.0727]), + (412.0, [-0.1235, -0.0562, -0.0741]), + (413.6, [-0.1232, -0.0582, -0.0752]), + (415.2, [-0.1293, -0.0602, -0.0764]), + (419.2, [-0.1299, -0.0646, -0.0793]), + (420.8, [-0.1303, -0.0719, -0.0805]), + (422.4, [-0.1307, -0.0732, -0.0812]), + (424.0, [-0.1309, -0.0747, -0.0879]), + (425.6, [-0.1312, -0.0759, -0.0887]), + (426.4, [-0.1361, -0.0765, -0.0891]), + (434.4, [-0.1356, -0.0811, -0.0923]), + (436.0, [-0.135, -0.087, -0.0927]), + (438.4, [-0.1341, -0.0876, -0.0981]), + (440.8, [-0.1383, -0.0885, -0.0985]), + (453.6, [-0.1314, -0.0925, -0.1005]), + (454.4, [-0.1308, -0.0977, -0.1002]), + (456.0, [-0.1298, -0.0988, -0.1064]), + (456.8, [-0.1287, -0.0989, -0.106]), + (458.4, [-0.1338, -0.0993, -0.106]), + (464.8, [-0.128, -0.1, -0.105]), + (481.6, [-0.1024, -0.0953, -0.0937]), + (494.4, [-0.0879, -0.09, -0.0859]), + (503.2, [-0.0724, -0.0812, -0.0773]), + (518.4, [-0.0488, -0.0592, -0.0573]), + (532.0, [-0.0275, -0.0323, -0.0346]), + (565.6, [0.0086, -0.0151, -0.0162]), + (596.8, [0.0344, -0.0058, -0.005]), + (599.2, [0.0436, -0.0003, 0.0012]), + (602.4, [0.067, 0.0104, 0.012]), + (604.8, [0.0749, 0.011, 0.0118]), + (606.4, [0.073, 0.0087, 0.0087]), + (608.0, [0.0653, 0.0061, 0.0058]), + (609.6, [0.0508, 0.0036, 0.0039]), + (611.2, [0.0311, 0.0018, 0.0034]), + (613.6, [0.0021, 0.0023, 0.0037]), + (615.2, [-0.01, 0.0032, 0.0037]), + (616.0, [-0.013, 0.003, 0.0033]), + (617.6, [-0.0136, 0.0016, 0.0011]), + (621.6, [-0.0062, -0.0035, -0.006]), + (624.0, [-0.0023, -0.0041, -0.0071]), + (628.0, [-0.0075, -0.0024, -0.0053]), + (630.4, [-0.0009, -0.0022, -0.0043]), + (632.8, [0.007, 0.0022, 0.0011]), + (634.4, [0.0093, 0.005, 0.005]), + (635.2, [0.008, 0.005, 0.0055]), + (637.6, [-0.0023, -0.0006, 0.0014]), + (639.2, [-0.0054, -0.0024, 0.0]), + (641.6, [-0.0009, 0.0004, 0.0032]), + (643.2, [0.0027, 0.0009, 0.003]), + (645.6, [0.0014, 0.0012, 0.0017]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [-0.1438, -0.1016, -0.088]), + (0.8, [-0.115, -0.1185, -0.1069]), + (6.4, [-0.1314, -0.1172, -0.1015]), + (8.0, [-0.1322, -0.1162, -0.0966]), + (12.8, [-0.1338, -0.1097, -0.0717]), + (16.0, [-0.129, -0.0855, -0.0504]), + (20.0, [-0.1225, -0.0453, -0.0198]), + (24.0, [-0.1129, -0.0017, 0.0105]), + (25.6, [-0.1095, 0.0138, 0.0234]), + (27.2, [-0.1047, 0.03, 0.0332]), + (29.6, [-0.0978, 0.0493, 0.0483]), + (36.8, [-0.077, 0.0885, 0.0861]), + (39.2, [-0.0698, 0.0982, 0.0963]), + (51.2, [-0.0243, 0.1557, 0.1492]), + (62.4, [0.0194, 0.1937, 0.1882]), + (63.2, [0.0119, 0.193, 0.1903]), + (64.8, [-0.0147, 0.1882, 0.1923]), + (66.4, [-0.0461, 0.1818, 0.1943]), + (67.2, [-0.054, 0.1817, 0.1954]), + (68.0, [-0.0476, 0.1842, 0.1985]), + (70.4, [0.0125, 0.2042, 0.207]), + (71.2, [0.0376, 0.2116, 0.2096]), + (72.0, [0.0536, 0.2179, 0.2113]), + (72.8, [0.0591, 0.2206, 0.214]), + (82.4, [0.0884, 0.2319, 0.226]), + (97.6, [0.121, 0.2361, 0.2301]), + (107.2, [0.1357, 0.2277, 0.216]), + (122.4, [0.1622, 0.2296, 0.233]), + (128.0, [0.1678, 0.2289, 0.2322]), + (133.6, [0.1754, 0.2196, 0.2281]), + (136.8, [0.1772, 0.2119, 0.217]), + (141.6, [0.1811, 0.1885, 0.1927]), + (150.4, [0.1895, 0.1367, 0.131]), + (156.8, [0.1936, 0.0972, 0.0767]), + (160.0, [0.1942, 0.0824, 0.0586]), + (162.4, [0.197, 0.0727, 0.0492]), + (165.6, [0.1986, 0.0645, 0.0415]), + (168.8, [0.2013, 0.0602, 0.0393]), + (173.6, [0.2019, 0.0588, 0.0406]), + (184.0, [0.2021, 0.0496, 0.03]), + (187.2, [0.2025, 0.0486, 0.0293]), + (188.8, [0.204, 0.0475, 0.0313]), + (193.6, [0.2034, 0.0519, 0.0348]), + (210.4, [0.2055, 0.0729, 0.0639]), + (227.2, [0.205, 0.1009, 0.0989]), + (233.6, [0.2027, 0.1014, 0.1037]), + (239.2, [0.2009, 0.0965, 0.0979]), + (251.2, [0.197, 0.0733, 0.0779]), + (254.4, [0.1952, 0.0698, 0.0774]), + (275.2, [0.179, 0.0741, 0.087]), + (283.2, [0.1747, 0.0673, 0.0877]), + (293.6, [0.1693, 0.0532, 0.0808]), + (298.4, [0.1673, 0.0514, 0.0774]), + (303.2, [0.1654, 0.0529, 0.0752]), + (311.2, [0.1631, 0.066, 0.0771]), + (314.4, [0.1573, 0.0623, 0.0719]), + (322.4, [0.1362, 0.0451, 0.0467]), + (328.0, [0.128, 0.0355, 0.0443]), + (332.8, [0.1295, 0.0325, 0.0487]), + (340.8, [0.1368, 0.033, 0.0623]), + (347.2, [0.1335, 0.0374, 0.0665]), + (370.4, [0.1303, 0.0577, 0.0694]), + (384.0, [0.1337, 0.0632, 0.0677]), + (392.0, [0.1366, 0.065, 0.0635]), + (408.0, [0.1394, 0.0624, 0.0811]), + (426.4, [0.143, 0.0841, 0.0968]), + (440.8, [0.1423, 0.0923, 0.1024]), + (456.8, [0.1334, 0.0989, 0.1063]), + (467.2, [0.126, 0.1001, 0.1045]), + (482.4, [0.1023, 0.0953, 0.0937]), + (495.2, [0.0878, 0.09, 0.0858]), + (504.0, [0.0722, 0.0811, 0.0772]), + (519.2, [0.0487, 0.0592, 0.0572]), + (532.8, [0.0274, 0.0323, 0.0346]), + (567.2, [-0.0095, 0.0147, 0.0158]), + (597.6, [-0.0344, 0.0059, 0.005]), + (600.0, [-0.0427, 0.0008, -0.0006]), + (603.2, [-0.0661, -0.0102, -0.0118]), + (605.6, [-0.0749, -0.0112, -0.0121]), + (608.0, [-0.0709, -0.0077, -0.0076]), + (609.6, [-0.0606, -0.0052, -0.005]), + (612.0, [-0.0335, -0.002, -0.0035]), + (614.4, [-0.0041, -0.0023, -0.0038]), + (616.0, [0.0089, -0.0032, -0.0038]), + (616.8, [0.0123, -0.0032, -0.0035]), + (619.2, [0.0126, -0.0008, 0.0001]), + (622.4, [0.0064, 0.0032, 0.0057]), + (624.8, [0.0023, 0.0041, 0.007]), + (626.4, [0.0032, 0.003, 0.0064]), + (628.0, [0.0068, 0.0023, 0.0054]), + (629.6, [0.0065, 0.0025, 0.0051]), + (632.0, [-0.0016, 0.0014, 0.0032]), + (635.2, [-0.0094, -0.0049, -0.0048]), + (636.8, [-0.0059, -0.0041, -0.0051]), + (638.4, [0.0015, 0.0001, -0.0018]), + (640.0, [0.0052, 0.0023, 0.0001]), + (641.6, [0.0037, 0.0009, -0.0018]), + (644.0, [-0.0027, -0.001, -0.0032]), + (646.4, [-0.0017, -0.0012, -0.0017]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.1346, 0.1521, 0.0797]), + (3.1962, [0.1329, 0.1555, 0.0808]), + (4.7944, [0.1271, 0.1528, 0.0807]), + (14.383, [0.1118, 0.1472, 0.0809]), + (20.776, [0.1003, 0.1389, 0.0815]), + (25.57, [0.0921, 0.1381, 0.0821]), + (27.168, [0.0915, 0.1341, 0.0825]), + (28.766, [0.0877, 0.1346, 0.0831]), + (32.761, [0.089, 0.1329, 0.0847]), + (36.757, [0.0857, 0.1309, 0.0853]), + (47.144, [0.0671, 0.1228, 0.082]), + (48.743, [0.0617, 0.1221, 0.0815]), + (51.14, [0.0605, 0.1216, 0.0814]), + (52.738, [0.0561, 0.1215, 0.0813]), + (55.934, [0.0554, 0.1196, 0.0815]), + (57.532, [0.0518, 0.1189, 0.0819]), + (60.728, [0.0529, 0.1204, 0.0837]), + (62.327, [0.0492, 0.1211, 0.0847]), + (64.724, [0.0503, 0.123, 0.0859]), + (66.322, [0.0502, 0.1195, 0.0862]), + (67.92, [0.0465, 0.1189, 0.086]), + (71.915, [0.0462, 0.1183, 0.0853]), + (73.513, [0.0423, 0.1181, 0.0851]), + (78.308, [0.0426, 0.1187, 0.0854]), + (79.906, [0.0406, 0.1149, 0.0858]), + (88.696, [0.0394, 0.1162, 0.0869]), + (90.294, [0.0359, 0.1163, 0.087]), + (111.07, [0.037, 0.1188, 0.0888]), + (111.87, [0.0375, 0.1189, 0.0889]), + (112.67, [0.0413, 0.119, 0.089]), + (118.26, [0.0816, 0.127, 0.0932]), + (119.86, [0.0841, 0.1272, 0.0934]), + (121.46, [0.0788, 0.1276, 0.0934]), + (127.05, [0.0389, 0.1199, 0.0899]), + (146.23, [0.0384, 0.1225, 0.0917]), + (147.03, [0.0387, 0.1213, 0.0915]), + (147.83, [0.0372, 0.1148, 0.0864]), + (156.62, [0.0081, 0.0268, 0.0187]), + (158.21, [0.0013, 0.0082, 0.0068]), + (159.01, [0.0003, 0.0011, 0.0011]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0011, 0.1392, 0.1715]), + (8.7602, [-0.0008, 0.1357, 0.1695]), + (16.724, [-0.0008, 0.1149, 0.1432]), + (22.299, [-0.0006, 0.0897, 0.1128]), + (24.688, [-0.0009, 0.0759, 0.0945]), + (29.466, [-0.0009, 0.0415, 0.0529]), + (31.059, [-0.0008, 0.0321, 0.041]), + (32.652, [-0.0009, 0.0257, 0.0315]), + (35.837, [-0.001, 0.0146, 0.0187]), + (39.819, [-0.0008, 0.0067, 0.0089]), + (45.394, [-0.0009, 0.0025, 0.0033]), + (60.525, [-0.001, 0.0047, 0.0055]), + (84.417, [-0.0009, -0.0018, -0.0017]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0224, 0.034, 0.1339]), + (1.5867, [0.0251, 0.0339, 0.1363]), + (2.38, [0.0233, 0.0338, 0.1352]), + (15.074, [0.0265, 0.0342, 0.1355]), + (15.867, [0.0263, 0.0341, 0.1327]), + (17.454, [0.0224, 0.0297, 0.117]), + (20.627, [0.02, 0.0262, 0.1029]), + (28.56, [0.0152, 0.0194, 0.0789]), + (31.734, [0.0128, 0.0157, 0.0616]), + (34.114, [0.0086, 0.0102, 0.0434]), + (34.907, [0.0068, 0.0087, 0.0397]), + (36.494, [0.0059, 0.0086, 0.0278]), + (38.081, [0.0032, 0.0046, 0.0154]), + (39.667, [0.0007, 0.0023, 0.0059]), + (41.254, [0.0006, 0.002, 0.0018]), + (49.981, [0.0008, 0.0017, 0.0019]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0133, 0.0084, 0.01]), + (0.7847, [0.0144, 0.0098, 0.0089]), + (1.5694, [0.0136, 0.0104, 0.0102]), + (3.1388, [0.0161, 0.0115, 0.0114]), + (4.7082, [0.021, 0.0153, 0.0158]), + (5.4929, [0.026, 0.0199, 0.0204]), + (7.0623, [0.0305, 0.0265, 0.0264]), + (7.847, [0.0352, 0.0324, 0.0323]), + (8.6317, [0.0349, 0.0336, 0.0337]), + (9.4165, [0.0399, 0.0383, 0.0379]), + (10.201, [0.0387, 0.0394, 0.0385]), + (11.771, [0.0423, 0.0441, 0.0425]), + (12.555, [0.0399, 0.0403, 0.0392]), + (13.34, [0.0398, 0.0411, 0.0399]), + (14.125, [0.0345, 0.036, 0.035]), + (14.909, [0.0327, 0.0338, 0.033]), + (15.694, [0.0265, 0.0276, 0.0272]), + (19.618, [0.0092, 0.0077, 0.0084]), + (22.756, [0.0016, 0.0, 0.0005]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.0972, 0.0538, 0.0089]), + (0.7973, [0.0992, 0.0501, 0.0087]), + (5.5811, [0.0945, 0.0498, 0.0096]), + (8.7703, [0.0842, 0.0464, 0.0097]), + (14.351, [0.0729, 0.0394, 0.0106]), + (24.716, [0.0429, 0.0305, 0.0107]), + (27.108, [0.0413, 0.0273, 0.0104]), + (33.486, [0.0262, 0.0177, 0.0094]), + (39.865, [0.0145, 0.0135, 0.0091]), + (43.851, [0.0094, 0.0121, 0.0089]), + (47.838, [0.0063, 0.0114, 0.0086]), + (58.203, [0.0044, 0.01, 0.0078]), + (71.757, [0.0052, 0.0089, 0.0078]), + (92.486, [0.0013, 0.0053, 0.0041]), + (98.865, [0.0025, 0.0067, 0.0052]), + (112.42, [0.0017, 0.0042, 0.0032]), + ], + }, +]; +const PRIME35_125_STREAKS: &[Streak] = &[]; +const PRIME35_125_RAYS: &[Ray] = &[ + Ray { angle: 7.19, r0: 10.938, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0196] }, + Ray { angle: 8.29, r0: 10.156, r1: 67.188, fade: 0.05, rgb: [0.0078, 0.0157, 0.0167] }, + Ray { angle: 9.4, r0: 89.844, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 10.51, r0: 10.156, r1: 70.312, fade: 0.05, rgb: [0.0078, 0.0137, 0.0147] }, + Ray { angle: 11.64, r0: 31.25, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 17.21, r0: 19.531, r1: 205.47, fade: 0.05, rgb: [0.0353, 0.0471, 0.0431] }, + Ray { angle: 24.39, r0: 19.531, r1: 191.41, fade: 0.05, rgb: [0.0216, 0.0314, 0.0294] }, + Ray { angle: 37.13, r0: 10.938, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0137, 0.0157] }, + Ray { angle: 38.24, r0: 10.938, r1: 124.22, fade: 0.05, rgb: [0.0137, 0.0284, 0.0275] }, + Ray { angle: 38.74, r0: 10.938, r1: 152.34, fade: 0.05, rgb: [0.0157, 0.0314, 0.0304] }, + Ray { angle: 40.46, r0: 82.031, r1: 125.0, fade: 0.05, rgb: [0.0176, 0.0235, 0.0225] }, + Ray { angle: 41.8, r0: 38.281, r1: 134.38, fade: 0.05, rgb: [0.0108, 0.0196, 0.0216] }, + Ray { angle: 53.33, r0: 17.188, r1: 190.62, fade: 0.05, rgb: [0.0235, 0.0412, 0.0412] }, + Ray { angle: 67.39, r0: 14.844, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0176, 0.0176] }, + Ray { angle: 68.57, r0: 11.719, r1: 72.656, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 69.79, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0216, 0.0255, 0.0255] }, + Ray { angle: 70.95, r0: 12.5, r1: 65.625, fade: 0.05, rgb: [0.0078, 0.0176, 0.0176] }, + Ray { angle: 72.11, r0: 14.062, r1: 125.78, fade: 0.05, rgb: [0.0118, 0.0225, 0.0206] }, + Ray { angle: 72.36, r0: 32.031, r1: 121.88, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 81.91, r0: 73.438, r1: 161.72, fade: 0.05, rgb: [0.0118, 0.0157, 0.0127] }, + Ray { angle: 97.57, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0216] }, + Ray { angle: 98.67, r0: 10.156, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 99.76, r0: 93.75, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0235, 0.0216] }, + Ray { angle: 100.84, r0: 10.156, r1: 71.094, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 102.0, r0: 34.375, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 104.36, r0: 50.0, r1: 417.19, fade: 0.05, rgb: [0.0275, 0.0275, 0.0275] }, + Ray { angle: 110.66, r0: 15.625, r1: 212.5, fade: 0.05, rgb: [0.0373, 0.0529, 0.051] }, + Ray { angle: 118.66, r0: 17.969, r1: 250.78, fade: 0.05, rgb: [0.0294, 0.0392, 0.0392] }, + Ray { angle: 127.45, r0: 12.5, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0196, 0.0196] }, + Ray { angle: 128.46, r0: 10.938, r1: 75.0, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 129.76, r0: 51.562, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 130.78, r0: 13.281, r1: 65.625, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 132.04, r0: 39.062, r1: 125.78, fade: 0.05, rgb: [0.0137, 0.0216, 0.0196] }, + Ray { angle: 147.31, r0: 21.094, r1: 278.12, fade: 0.05, rgb: [0.0294, 0.0382, 0.0353] }, + Ray { angle: 157.35, r0: 31.25, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0235, 0.0216] }, + Ray { angle: 158.51, r0: 80.469, r1: 125.78, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 159.77, r0: 85.156, r1: 126.56, fade: 0.05, rgb: [0.0255, 0.0392, 0.0373] }, + Ray { angle: 161.69, r0: 13.281, r1: 246.09, fade: 0.05, rgb: [0.0412, 0.0608, 0.0588] }, + Ray { angle: 168.82, r0: 18.75, r1: 238.28, fade: 0.05, rgb: [0.0314, 0.0392, 0.0353] }, + Ray { angle: 183.11, r0: 25.0, r1: 234.38, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 187.57, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0216] }, + Ray { angle: 188.67, r0: 8.5938, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 189.78, r0: 90.625, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, + Ray { angle: 190.89, r0: 8.5938, r1: 66.406, fade: 0.05, rgb: [0.0078, 0.0157, 0.0176] }, + Ray { angle: 192.0, r0: 9.375, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0196] }, + Ray { angle: 217.5, r0: 26.562, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0196] }, + Ray { angle: 218.52, r0: 13.281, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 219.81, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0235, 0.0235] }, + Ray { angle: 220.84, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0147, 0.0137] }, + Ray { angle: 222.14, r0: 11.719, r1: 127.34, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 226.08, r0: 31.25, r1: 244.53, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 240.39, r0: 20.312, r1: 224.22, fade: 0.05, rgb: [0.0196, 0.0275, 0.0235] }, + Ray { angle: 247.07, r0: 12.5, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0235, 0.0216] }, + Ray { angle: 248.21, r0: 77.344, r1: 125.78, fade: 0.05, rgb: [0.0176, 0.0216, 0.0216] }, + Ray { angle: 249.45, r0: 82.812, r1: 127.34, fade: 0.05, rgb: [0.0206, 0.0255, 0.0235] }, + Ray { angle: 250.53, r0: 12.5, r1: 73.438, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 251.72, r0: 45.312, r1: 127.34, fade: 0.05, rgb: [0.0157, 0.0235, 0.0235] }, + Ray { angle: 254.49, r0: 42.188, r1: 153.91, fade: 0.05, rgb: [0.0235, 0.0275, 0.0275] }, + Ray { angle: 269.03, r0: 13.281, r1: 195.31, fade: 0.05, rgb: [0.0333, 0.0412, 0.0412] }, + Ray { angle: 276.17, r0: 120.31, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 277.19, r0: 10.938, r1: 125.78, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 278.34, r0: 10.156, r1: 64.844, fade: 0.05, rgb: [0.0059, 0.0157, 0.0157] }, + Ray { angle: 279.45, r0: 89.844, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0225, 0.0216] }, + Ray { angle: 280.54, r0: 9.375, r1: 68.75, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 281.68, r0: 10.156, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 283.35, r0: 57.812, r1: 200.0, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 297.69, r0: 25.0, r1: 207.03, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 305.78, r0: 64.062, r1: 164.06, fade: 0.05, rgb: [0.0216, 0.0275, 0.0275] }, + Ray { angle: 307.17, r0: 16.406, r1: 126.56, fade: 0.05, rgb: [0.0069, 0.0137, 0.0157] }, + Ray { angle: 308.24, r0: 11.719, r1: 125.78, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 309.5, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 310.59, r0: 11.719, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0127, 0.0137] }, + Ray { angle: 311.83, r0: 11.719, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0167, 0.0167] }, + Ray { angle: 337.07, r0: 24.219, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0235, 0.0216] }, + Ray { angle: 338.21, r0: 12.5, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0157, 0.0176] }, + Ray { angle: 339.49, r0: 78.906, r1: 127.34, fade: 0.05, rgb: [0.0216, 0.0255, 0.0255] }, + Ray { angle: 340.6, r0: 11.719, r1: 64.844, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 341.79, r0: 13.281, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0216, 0.0216] }, +]; +const PRIME35_150_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.8968, 0.6706, 0.7055]), + (0.8, [0.9194, 0.7841, 0.8374]), + (1.6, [0.9126, 0.756, 0.8061]), + (7.2, [0.9116, 0.7654, 0.8157]), + (14.4, [0.9049, 0.7674, 0.793]), + (30.4, [0.8732, 0.5997, 0.6894]), + (44.0, [0.828, 0.5033, 0.6074]), + (62.4, [0.7407, 0.37, 0.4829]), + (63.2, [0.7474, 0.3695, 0.4803]), + (67.2, [0.8341, 0.3885, 0.464]), + (68.0, [0.8246, 0.3803, 0.4579]), + (72.0, [0.6843, 0.3062, 0.4215]), + (72.8, [0.6721, 0.2995, 0.4174]), + (76.8, [0.6484, 0.2721, 0.3921]), + (87.2, [0.5851, 0.2208, 0.3384]), + (96.0, [0.5344, 0.19, 0.3074]), + (98.4, [0.5206, 0.1875, 0.2974]), + (109.6, [0.4667, 0.1694, 0.2857]), + (112.0, [0.4541, 0.1593, 0.2732]), + (115.2, [0.4362, 0.1536, 0.2703]), + (119.2, [0.4169, 0.1471, 0.2537]), + (127.2, [0.3803, 0.1397, 0.2411]), + (129.6, [0.3691, 0.138, 0.232]), + (132.0, [0.3569, 0.1314, 0.2302]), + (134.4, [0.3456, 0.1332, 0.2295]), + (140.0, [0.3198, 0.1463, 0.2401]), + (143.2, [0.3088, 0.1589, 0.2509]), + (147.2, [0.2877, 0.1777, 0.2676]), + (149.6, [0.2786, 0.1834, 0.2801]), + (155.2, [0.2524, 0.2117, 0.3044]), + (159.2, [0.2405, 0.2263, 0.3188]), + (164.8, [0.2156, 0.2363, 0.3244]), + (171.2, [0.1918, 0.2339, 0.3114]), + (173.6, [0.1798, 0.2252, 0.3064]), + (175.2, [0.1763, 0.2247, 0.2983]), + (178.4, [0.1618, 0.2215, 0.2951]), + (188.0, [0.1339, 0.2125, 0.2795]), + (192.0, [0.1244, 0.2031, 0.2672]), + (194.4, [0.1119, 0.197, 0.2583]), + (218.4, [0.0598, 0.1272, 0.1707]), + (224.0, [0.0497, 0.1168, 0.1544]), + (228.0, [0.045, 0.1126, 0.1481]), + (231.2, [0.0435, 0.1165, 0.1454]), + (234.4, [0.0394, 0.1211, 0.1484]), + (235.2, [0.0405, 0.1218, 0.149]), + (244.8, [0.036, 0.1566, 0.1734]), + (248.0, [0.0376, 0.17, 0.1848]), + (248.8, [0.036, 0.1746, 0.1871]), + (251.2, [0.0357, 0.1823, 0.192]), + (252.8, [0.0384, 0.1848, 0.1947]), + (253.6, [0.0369, 0.1881, 0.1959]), + (264.0, [0.0485, 0.2095, 0.1996]), + (264.8, [0.0465, 0.2112, 0.2016]), + (268.0, [0.0491, 0.2155, 0.2046]), + (269.6, [0.0474, 0.2196, 0.205]), + (270.4, [0.0496, 0.2205, 0.2074]), + (276.8, [0.0476, 0.2314, 0.2103]), + (280.0, [0.049, 0.2401, 0.2153]), + (280.8, [0.0466, 0.2436, 0.2171]), + (282.4, [0.0483, 0.2455, 0.218]), + (283.2, [0.0455, 0.2495, 0.2203]), + (284.8, [0.0473, 0.2525, 0.2209]), + (285.6, [0.0444, 0.2551, 0.2238]), + (286.4, [0.0454, 0.2581, 0.2227]), + (289.6, [0.0455, 0.2633, 0.2273]), + (290.4, [0.0424, 0.2616, 0.2305]), + (291.2, [0.0426, 0.2642, 0.2323]), + (294.4, [0.0422, 0.2688, 0.2317]), + (296.0, [0.0387, 0.2712, 0.2341]), + (297.6, [0.0401, 0.2743, 0.2322]), + (298.4, [0.0375, 0.2772, 0.2344]), + (300.0, [0.0405, 0.2775, 0.2336]), + (300.8, [0.0373, 0.2804, 0.2347]), + (301.6, [0.0377, 0.2819, 0.2369]), + (302.4, [0.0394, 0.2795, 0.2357]), + (304.0, [0.0359, 0.2816, 0.2347]), + (304.8, [0.0379, 0.2791, 0.2352]), + (306.4, [0.0341, 0.2764, 0.2306]), + (307.2, [0.0359, 0.2766, 0.2313]), + (308.8, [0.0328, 0.2706, 0.2262]), + (310.4, [0.0364, 0.2725, 0.2286]), + (311.2, [0.0347, 0.2694, 0.2263]), + (312.8, [0.0417, 0.274, 0.2334]), + (314.4, [0.0434, 0.2764, 0.2357]), + (315.2, [0.0474, 0.2802, 0.2408]), + (316.8, [0.0491, 0.2891, 0.2466]), + (318.4, [0.0562, 0.2934, 0.2534]), + (319.2, [0.055, 0.2981, 0.256]), + (320.8, [0.0623, 0.3016, 0.2576]), + (321.6, [0.0614, 0.3063, 0.2612]), + (323.2, [0.0663, 0.315, 0.2613]), + (324.8, [0.0657, 0.3176, 0.267]), + (326.4, [0.0697, 0.3231, 0.2655]), + (328.0, [0.0659, 0.3233, 0.2685]), + (329.6, [0.066, 0.3273, 0.2662]), + (330.4, [0.0612, 0.3239, 0.2672]), + (332.0, [0.0602, 0.3259, 0.2624]), + (332.8, [0.0545, 0.3272, 0.2629]), + (335.2, [0.0488, 0.3227, 0.2571]), + (336.0, [0.0426, 0.3234, 0.2572]), + (337.6, [0.0406, 0.3178, 0.2533]), + (339.2, [0.0334, 0.3185, 0.2522]), + (340.8, [0.0335, 0.3136, 0.2491]), + (341.6, [0.0292, 0.3143, 0.2486]), + (344.0, [0.0299, 0.3114, 0.2493]), + (344.8, [0.0264, 0.3123, 0.2479]), + (346.4, [0.029, 0.3149, 0.2483]), + (348.0, [0.0257, 0.3108, 0.2422]), + (349.6, [0.0289, 0.3116, 0.2429]), + (351.2, [0.0258, 0.3066, 0.237]), + (352.8, [0.0285, 0.3072, 0.2378]), + (353.6, [0.025, 0.3078, 0.2354]), + (356.0, [0.0277, 0.303, 0.233]), + (356.8, [0.0239, 0.3032, 0.2332]), + (359.2, [0.0261, 0.2986, 0.2288]), + (360.0, [0.0223, 0.2988, 0.2295]), + (362.4, [0.0249, 0.2942, 0.2252]), + (363.2, [0.0208, 0.2945, 0.2261]), + (365.6, [0.0228, 0.2902, 0.222]), + (366.4, [0.0188, 0.29, 0.2224]), + (368.8, [0.0211, 0.2908, 0.2187]), + (369.6, [0.0172, 0.2861, 0.2193]), + (372.0, [0.019, 0.2846, 0.2155]), + (372.8, [0.0156, 0.2816, 0.2164]), + (374.4, [0.0153, 0.2771, 0.2177]), + (375.2, [0.0165, 0.2762, 0.2134]), + (376.8, [0.0122, 0.2745, 0.2109]), + (379.2, [0.0137, 0.2665, 0.2019]), + (380.0, [0.0098, 0.2656, 0.2011]), + (381.6, [0.0098, 0.258, 0.199]), + (382.4, [0.0097, 0.2569, 0.1928]), + (383.2, [0.0048, 0.2558, 0.1913]), + (385.6, [0.0046, 0.2475, 0.1882]), + (387.2, [-0.0011, 0.2457, 0.1812]), + (389.6, [-0.0013, 0.2384, 0.1765]), + (390.4, [-0.0064, 0.237, 0.1725]), + (392.8, [-0.0071, 0.2348, 0.171]), + (394.4, [-0.0129, 0.2274, 0.1637]), + (396.8, [-0.0125, 0.2256, 0.1625]), + (397.6, [-0.0164, 0.2254, 0.1596]), + (398.4, [-0.0183, 0.2198, 0.1553]), + (400.8, [-0.0177, 0.2151, 0.1543]), + (401.6, [-0.0225, 0.2144, 0.1526]), + (402.4, [-0.0226, 0.2098, 0.1477]), + (404.8, [-0.0226, 0.2026, 0.147]), + (405.6, [-0.0271, 0.2011, 0.1459]), + (406.4, [-0.0269, 0.1996, 0.1408]), + (408.8, [-0.0277, 0.1891, 0.1403]), + (409.6, [-0.032, 0.1873, 0.1407]), + (412.8, [-0.0325, 0.1751, 0.1295]), + (413.6, [-0.0372, 0.1734, 0.1277]), + (416.0, [-0.0382, 0.1685, 0.1164]), + (416.8, [-0.0387, 0.162, 0.1143]), + (417.6, [-0.044, 0.1599, 0.1123]), + (420.8, [-0.0461, 0.1543, 0.0993]), + (421.6, [-0.0514, 0.148, 0.0974]), + (423.2, [-0.0531, 0.1444, 0.0939]), + (424.0, [-0.0538, 0.1431, 0.0871]), + (424.8, [-0.0538, 0.1418, 0.0848]), + (426.4, [-0.0606, 0.1372, 0.0816]), + (428.0, [-0.0614, 0.1314, 0.0781]), + (428.8, [-0.0619, 0.1297, 0.0717]), + (429.6, [-0.0619, 0.128, 0.0703]), + (431.2, [-0.0684, 0.1243, 0.0676]), + (432.8, [-0.0688, 0.1159, 0.0652]), + (433.6, [-0.0685, 0.1136, 0.0592]), + (435.2, [-0.0743, 0.1092, 0.0567]), + (436.0, [-0.074, 0.1077, 0.0558]), + (437.6, [-0.0737, 0.0975, 0.0542]), + (438.4, [-0.0729, 0.0959, 0.0487]), + (439.2, [-0.0746, 0.0938, 0.047]), + (440.0, [-0.0779, 0.0919, 0.0463]), + (442.4, [-0.0762, 0.0851, 0.044]), + (443.2, [-0.075, 0.0786, 0.0399]), + (444.0, [-0.0778, 0.077, 0.0359]), + (444.8, [-0.0804, 0.0751, 0.0354]), + (448.0, [-0.0775, 0.067, 0.0326]), + (448.8, [-0.0778, 0.0603, 0.0278]), + (449.6, [-0.082, 0.0585, 0.024]), + (453.6, [-0.0777, 0.0511, 0.012]), + (454.4, [-0.082, 0.0445, 0.0044]), + (455.2, [-0.0819, 0.0421, 0.0008]), + (459.2, [-0.0769, 0.0356, -0.0123]), + (460.0, [-0.0809, 0.0329, -0.0201]), + (460.8, [-0.0793, 0.0262, -0.0249]), + (464.8, [-0.0727, 0.0192, -0.0405]), + (465.6, [-0.0761, 0.0181, -0.0469]), + (466.4, [-0.0737, 0.0157, -0.0562]), + (467.2, [-0.0712, 0.0065, -0.0603]), + (470.4, [-0.0621, -0.0183, -0.0775]), + (471.2, [-0.0668, -0.0237, -0.0806]), + (472.8, [-0.0682, -0.0347, -0.0972]), + (473.6, [-0.0688, -0.0414, -0.1009]), + (474.4, [-0.0691, -0.0535, -0.1044]), + (476.0, [-0.0691, -0.0643, -0.1115]), + (477.6, [-0.079, -0.0738, -0.1182]), + (479.2, [-0.0795, -0.0833, -0.1341]), + (480.0, [-0.0796, -0.0872, -0.1375]), + (481.6, [-0.0802, -0.1056, -0.1445]), + (482.4, [-0.0804, -0.1103, -0.1479]), + (483.2, [-0.0885, -0.1151, -0.1515]), + (484.0, [-0.0909, -0.1194, -0.1547]), + (484.8, [-0.092, -0.124, -0.1577]), + (486.4, [-0.0939, -0.133, -0.1737]), + (488.0, [-0.0957, -0.1411, -0.1802]), + (488.8, [-0.0959, -0.1524, -0.1832]), + (489.6, [-0.0996, -0.1582, -0.1864]), + (490.4, [-0.1064, -0.1621, -0.1893]), + (492.0, [-0.107, -0.1693, -0.1945]), + (493.6, [-0.106, -0.1756, -0.2088]), + (496.0, [-0.1035, -0.1837, -0.2158]), + (497.6, [-0.1111, -0.1981, -0.2201]), + (500.0, [-0.1085, -0.2054, -0.2255]), + (501.6, [-0.1072, -0.2099, -0.2387]), + (504.0, [-0.1049, -0.2158, -0.2436]), + (504.8, [-0.1117, -0.217, -0.2452]), + (506.4, [-0.1104, -0.2301, -0.2477]), + (508.8, [-0.1069, -0.2351, -0.2516]), + (509.6, [-0.1057, -0.2368, -0.2604]), + (512.0, [-0.1016, -0.2414, -0.27]), + (513.6, [-0.1086, -0.2445, -0.2749]), + (515.2, [-0.106, -0.2471, -0.2795]), + (516.8, [-0.1035, -0.2602, -0.2842]), + (518.4, [-0.1013, -0.2655, -0.2886]), + (520.0, [-0.0996, -0.2709, -0.3022]), + (521.6, [-0.0991, -0.2746, -0.3054]), + (522.4, [-0.1044, -0.2761, -0.3067]), + (526.4, [-0.0966, -0.2793, -0.3103]), + (528.8, [-0.091, -0.2906, -0.3103]), + (531.2, [-0.085, -0.2897, -0.3206]), + (532.8, [-0.0907, -0.2903, -0.3214]), + (539.2, [-0.0783, -0.2944, -0.3258]), + (541.6, [-0.0737, -0.3059, -0.3269]), + (544.0, [-0.0698, -0.3075, -0.339]), + (544.8, [-0.0768, -0.3081, -0.3394]), + (545.6, [-0.0765, -0.3087, -0.3399]), + (556.8, [-0.0572, -0.3154, -0.346]), + (558.4, [-0.0547, -0.3264, -0.3483]), + (559.2, [-0.0529, -0.3268, -0.3566]), + (560.8, [-0.0617, -0.3276, -0.3587]), + (573.6, [-0.0433, -0.3331, -0.3645]), + (591.2, [-0.0222, -0.3397, -0.371]), + (598.4, [-0.0107, -0.3355, -0.3687]), + (600.0, [-0.0052, -0.3229, -0.356]), + (601.6, [0.0011, -0.3008, -0.3317]), + (604.0, [0.0098, -0.2485, -0.2719]), + (606.4, [0.0119, -0.1787, -0.1908]), + (609.6, [0.0063, -0.0851, -0.0794]), + (611.2, [0.0055, -0.0457, -0.0314]), + (612.0, [0.0062, -0.0279, -0.0102]), + (612.8, [0.0077, -0.0111, 0.0094]), + (614.4, [0.0121, 0.0194, 0.0441]), + (616.0, [0.0157, 0.0451, 0.0694]), + (616.8, [0.016, 0.0548, 0.0754]), + (617.6, [0.015, 0.0612, 0.0754]), + (618.4, [0.0126, 0.0628, 0.0692]), + (619.2, [0.0091, 0.0591, 0.0576]), + (620.0, [0.0049, 0.0503, 0.0424]), + (620.8, [0.0006, 0.0379, 0.0259]), + (622.4, [-0.0064, 0.0101, -0.0034]), + (623.2, [-0.0086, -0.0014, -0.0137]), + (624.0, [-0.0098, -0.0094, -0.0207]), + (625.6, [-0.01, -0.0164, -0.0272]), + (627.2, [-0.008, -0.0173, -0.0269]), + (628.8, [-0.0044, -0.0161, -0.0214]), + (630.4, [-0.0003, -0.0104, -0.0105]), + (632.0, [0.0029, -0.0008, 0.0017]), + (633.6, [0.0041, 0.0053, 0.0082]), + (634.4, [0.004, 0.0057, 0.0092]), + (637.6, [0.0025, 0.0037, 0.0103]), + (641.6, [0.0022, 0.0025, 0.0049]), + (644.8, [-0.0026, -0.0024, 0.0001]), + (646.4, [-0.0039, -0.0029, -0.001]), + (649.6, [0.0015, -0.0007, -0.0014]), + (651.2, [0.0022, 0.0016, -0.0004]), + (652.8, [-0.0008, 0.0011, -0.0023]), + (654.4, [-0.0035, -0.0021, -0.0042]), + (657.6, [-0.0014, -0.0011, -0.0033]), + (661.6, [0.0007, 0.0008, 0.002]), + (670.4, [0.0014, 0.0008, 0.0018]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.0775, 0.282, 0.2368]), + (0.8, [0.0881, 0.2347, 0.1824]), + (1.6, [0.085, 0.2383, 0.1892]), + (7.2, [0.0884, 0.2346, 0.1844]), + (14.4, [0.0925, 0.2294, 0.1893]), + (31.2, [0.1047, 0.2831, 0.2187]), + (44.8, [0.1195, 0.3069, 0.2392]), + (62.4, [0.1488, 0.3442, 0.2742]), + (64.0, [0.1402, 0.3428, 0.2754]), + (66.4, [0.1165, 0.3356, 0.2793]), + (67.2, [0.1129, 0.3348, 0.2789]), + (68.0, [0.1161, 0.3372, 0.2811]), + (72.0, [0.1684, 0.3629, 0.2925]), + (77.6, [0.1814, 0.3727, 0.3009]), + (97.6, [0.2185, 0.3841, 0.3181]), + (107.2, [0.2293, 0.3759, 0.3034]), + (120.0, [0.2469, 0.3709, 0.3083]), + (132.0, [0.2592, 0.3577, 0.2996]), + (136.0, [0.2626, 0.3456, 0.2898]), + (140.8, [0.2673, 0.3223, 0.2681]), + (147.2, [0.2744, 0.2823, 0.2299]), + (156.8, [0.2819, 0.2183, 0.1616]), + (160.8, [0.2857, 0.1981, 0.1417]), + (164.8, [0.2897, 0.1835, 0.1314]), + (171.2, [0.2942, 0.1733, 0.1297]), + (175.2, [0.2985, 0.1713, 0.1312]), + (188.0, [0.3053, 0.1597, 0.1239]), + (196.8, [0.3094, 0.1653, 0.1361]), + (220.8, [0.3127, 0.1921, 0.1772]), + (228.8, [0.3072, 0.186, 0.1788]), + (233.6, [0.3026, 0.1724, 0.1708]), + (237.6, [0.2968, 0.1575, 0.1583]), + (248.0, [0.2796, 0.0997, 0.1087]), + (252.8, [0.2704, 0.077, 0.0905]), + (264.0, [0.2409, 0.0361, 0.0657]), + (274.4, [0.2223, 0.0038, 0.0415]), + (279.2, [0.2152, -0.0133, 0.03]), + (284.8, [0.2074, -0.0363, 0.0147]), + (292.0, [0.1995, -0.0584, -0.0073]), + (301.6, [0.19, -0.0859, -0.0243]), + (304.8, [0.1865, -0.0893, -0.0273]), + (309.6, [0.1816, -0.0866, -0.0271]), + (312.0, [0.1752, -0.0899, -0.032]), + (315.2, [0.162, -0.1021, -0.0467]), + (316.8, [0.155, -0.1113, -0.0568]), + (319.2, [0.1451, -0.1237, -0.0671]), + (324.0, [0.1283, -0.149, -0.0826]), + (327.2, [0.1221, -0.1602, -0.0903]), + (330.4, [0.1218, -0.1673, -0.0941]), + (333.6, [0.1276, -0.1703, -0.0942]), + (340.0, [0.1394, -0.1694, -0.0917]), + (342.4, [0.1401, -0.1694, -0.0923]), + (344.8, [0.1375, -0.1704, -0.0953]), + (358.4, [0.1243, -0.1739, -0.0907]), + (369.6, [0.1159, -0.1735, -0.0934]), + (375.2, [0.1136, -0.1666, -0.0966]), + (380.0, [0.1106, -0.16, -0.0876]), + (395.2, [0.1188, -0.1369, -0.0654]), + (398.4, [0.1196, -0.1347, -0.0626]), + (407.2, [0.121, -0.1163, -0.0543]), + (410.4, [0.1221, -0.1078, -0.0539]), + (424.8, [0.1323, -0.0757, -0.015]), + (429.6, [0.1365, -0.066, -0.0041]), + (436.0, [0.1409, -0.0494, 0.0066]), + (449.6, [0.1357, -0.0123, 0.0251]), + (460.0, [0.1268, 0.0083, 0.0605]), + (461.6, [0.1261, 0.0134, 0.0687]), + (465.6, [0.1181, 0.0203, 0.0847]), + (467.2, [0.1167, 0.0231, 0.0958]), + (471.2, [0.1037, 0.0543, 0.1171]), + (474.4, [0.1081, 0.0766, 0.1368]), + (475.2, [0.1084, 0.0854, 0.1401]), + (480.8, [0.115, 0.1195, 0.1693]), + (483.2, [0.1158, 0.1384, 0.1797]), + (484.8, [0.1224, 0.1474, 0.1864]), + (489.6, [0.1277, 0.1769, 0.2111]), + (491.2, [0.1337, 0.1861, 0.2172]), + (494.4, [0.1334, 0.1995, 0.2324]), + (499.2, [0.1338, 0.2205, 0.2458]), + (503.2, [0.1298, 0.2317, 0.2603]), + (504.8, [0.1279, 0.2356, 0.2634]), + (506.4, [0.1311, 0.2415, 0.2663]), + (509.6, [0.1264, 0.251, 0.271]), + (512.8, [0.1213, 0.2573, 0.2859]), + (514.4, [0.124, 0.2603, 0.2908]), + (516.0, [0.1215, 0.2633, 0.2954]), + (521.6, [0.1138, 0.2848, 0.3158]), + (524.0, [0.1148, 0.2893, 0.3197]), + (532.0, [0.0968, 0.2976, 0.3286]), + (534.4, [0.0972, 0.2988, 0.3298]), + (540.8, [0.0845, 0.3034, 0.3342]), + (542.4, [0.0814, 0.3099, 0.335]), + (544.8, [0.077, 0.3115, 0.343]), + (545.6, [0.0806, 0.3121, 0.3434]), + (546.4, [0.0803, 0.3127, 0.3439]), + (557.6, [0.061, 0.3193, 0.35]), + (559.2, [0.0585, 0.3264, 0.3515]), + (560.0, [0.0569, 0.3269, 0.3565]), + (561.6, [0.0613, 0.3276, 0.3588]), + (562.4, [0.0606, 0.328, 0.3592]), + (597.6, [0.0143, 0.34, 0.3723]), + (600.8, [0.0055, 0.3248, 0.358]), + (603.2, [-0.004, 0.2894, 0.3189]), + (605.6, [-0.0115, 0.2316, 0.2522]), + (607.2, [-0.0123, 0.184, 0.1971]), + (609.6, [-0.0082, 0.1119, 0.1119]), + (612.0, [-0.0057, 0.0498, 0.0363]), + (613.6, [-0.0076, 0.0147, -0.0052]), + (615.2, [-0.0118, -0.0162, -0.0405]), + (616.8, [-0.0158, -0.0425, -0.0673]), + (617.6, [-0.0163, -0.0529, -0.0744]), + (618.4, [-0.0156, -0.06, -0.0758]), + (619.2, [-0.0135, -0.0628, -0.0709]), + (620.0, [-0.0102, -0.0602, -0.0603]), + (620.8, [-0.0061, -0.0525, -0.0458]), + (621.6, [-0.0018, -0.0407, -0.0293]), + (623.2, [0.0055, -0.0128, 0.0008]), + (624.0, [0.0079, -0.0007, 0.0118]), + (624.8, [0.0094, 0.0081, 0.0195]), + (625.6, [0.0099, 0.0135, 0.0243]), + (626.4, [0.0098, 0.0162, 0.0269]), + (628.8, [0.0065, 0.0172, 0.0255]), + (631.2, [0.0005, 0.0113, 0.012]), + (633.6, [-0.0038, -0.0023, -0.005]), + (634.4, [-0.0043, -0.0049, -0.0077]), + (636.0, [-0.0039, -0.005, -0.0095]), + (638.4, [-0.0027, -0.0036, -0.0102]), + (642.4, [-0.0025, -0.0025, -0.005]), + (647.2, [0.0038, 0.003, 0.001]), + (649.6, [0.0005, 0.0016, 0.0021]), + (652.0, [-0.0026, -0.0013, 0.0005]), + (653.6, [0.0002, -0.0013, 0.0021]), + (656.0, [0.0035, 0.0029, 0.0044]), + (659.2, [0.0006, 0.0006, 0.0028]), + (662.4, [-0.0008, -0.0008, -0.0019]), + (671.2, [-0.0014, -0.0008, -0.0019]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.1471, 0.1581, 0.074]), + (0.79906, [0.1409, 0.1551, 0.0745]), + (3.9953, [0.1325, 0.1545, 0.0776]), + (16.78, [0.111, 0.1397, 0.0777]), + (23.972, [0.0987, 0.1409, 0.0783]), + (25.57, [0.0952, 0.1377, 0.0789]), + (30.364, [0.0883, 0.1349, 0.0805]), + (37.556, [0.0835, 0.1317, 0.0822]), + (47.944, [0.0631, 0.1219, 0.0786]), + (58.331, [0.0524, 0.1231, 0.0803]), + (64.724, [0.0511, 0.1239, 0.0849]), + (66.322, [0.0475, 0.1244, 0.0853]), + (75.112, [0.044, 0.1208, 0.0848]), + (76.71, [0.0407, 0.1212, 0.0852]), + (81.504, [0.0417, 0.1236, 0.0871]), + (83.102, [0.038, 0.1205, 0.0877]), + (89.495, [0.039, 0.1224, 0.0894]), + (91.892, [0.0357, 0.123, 0.09]), + (111.87, [0.0391, 0.1293, 0.0946]), + (119.06, [0.092, 0.1419, 0.1032]), + (119.86, [0.093, 0.1419, 0.1033]), + (123.85, [0.0662, 0.1359, 0.1009]), + (127.05, [0.0414, 0.133, 0.0975]), + (146.23, [0.0433, 0.1404, 0.1037]), + (147.03, [0.0437, 0.1385, 0.1024]), + (147.83, [0.0414, 0.1309, 0.0977]), + (155.02, [0.0155, 0.048, 0.0358]), + (158.21, [0.0019, 0.0098, 0.0071]), + (159.01, [0.0004, 0.0011, 0.0004]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0038, 0.1134, 0.127]), + (0.79639, [0.0, 0.1391, 0.1646]), + (1.5928, [-0.0019, 0.1334, 0.1553]), + (7.1675, [-0.0014, 0.135, 0.1559]), + (12.742, [-0.0016, 0.1247, 0.1437]), + (18.317, [-0.0014, 0.1071, 0.1241]), + (22.299, [-0.0015, 0.0886, 0.1017]), + (25.484, [-0.0015, 0.0677, 0.0801]), + (29.466, [-0.0017, 0.0407, 0.0484]), + (32.652, [-0.0015, 0.0243, 0.029]), + (35.837, [-0.0019, 0.013, 0.0164]), + (38.227, [-0.0016, 0.0077, 0.0098]), + (43.005, [-0.0018, 0.0015, 0.0026]), + (46.19, [-0.0021, 0.0006, 0.0017]), + (58.933, [-0.0018, 0.0031, 0.0041]), + (83.621, [-0.0016, -0.0037, -0.0032]), + (103.53, [-0.0003, -0.0017, -0.0012]), + (122.64, [-0.0007, -0.0023, -0.002]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0169, 0.032, 0.1134]), + (0.79334, [0.0156, 0.0249, 0.1021]), + (1.5867, [0.0158, 0.0262, 0.1078]), + (2.38, [0.0166, 0.0269, 0.1045]), + (3.1734, [0.0153, 0.0262, 0.1064]), + (14.28, [0.0187, 0.027, 0.1062]), + (15.867, [0.0206, 0.0294, 0.1067]), + (17.454, [0.0169, 0.0251, 0.0936]), + (21.42, [0.0147, 0.0219, 0.0803]), + (28.56, [0.0113, 0.0169, 0.0633]), + (30.94, [0.0096, 0.0141, 0.0536]), + (38.874, [0.0018, 0.0052, 0.0095]), + (41.254, [0.0004, 0.0027, 0.0026]), + (63.468, [0.0003, 0.0018, 0.0016]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [0.0117, 0.0111, 0.0102]), + (0.7847, [0.0112, 0.0079, 0.0087]), + (3.1388, [0.0154, 0.0109, 0.0139]), + (4.7082, [0.021, 0.0154, 0.0179]), + (5.4929, [0.0263, 0.0199, 0.0232]), + (6.2776, [0.0275, 0.0238, 0.0269]), + (9.4165, [0.0429, 0.0412, 0.0436]), + (10.201, [0.0426, 0.043, 0.0443]), + (11.771, [0.0473, 0.0496, 0.0506]), + (12.555, [0.045, 0.046, 0.0472]), + (13.34, [0.0451, 0.0471, 0.0477]), + (14.125, [0.0385, 0.0405, 0.0414]), + (14.909, [0.0363, 0.0376, 0.0386]), + (15.694, [0.0285, 0.0299, 0.0318]), + (18.048, [0.015, 0.0152, 0.0172]), + (19.618, [0.0075, 0.0074, 0.009]), + (21.187, [0.0022, 0.0018, 0.0038]), + (24.326, [-0.0025, -0.0026, -0.0013]), + (29.819, [-0.0019, -0.0008, -0.0004]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.1197, 0.0826, 0.0247]), + (0.7973, [0.1175, 0.073, 0.0192]), + (3.1892, [0.1165, 0.0728, 0.0205]), + (6.3784, [0.1093, 0.0701, 0.0213]), + (15.946, [0.0832, 0.0602, 0.0234]), + (19.932, [0.0689, 0.0542, 0.0238]), + (25.513, [0.058, 0.0454, 0.0238]), + (29.5, [0.045, 0.0438, 0.0231]), + (32.689, [0.037, 0.0387, 0.0223]), + (37.473, [0.0289, 0.032, 0.0214]), + (44.649, [0.0165, 0.0273, 0.0211]), + (49.432, [0.0122, 0.0258, 0.0205]), + (57.405, [0.0099, 0.0236, 0.0192]), + (71.757, [0.0105, 0.0211, 0.0177]), + (90.094, [0.0057, 0.0149, 0.0118]), + (99.662, [0.0068, 0.0151, 0.0117]), + (112.42, [0.0047, 0.0102, 0.0085]), + ], + }, +]; +const PRIME35_150_STREAKS: &[Streak] = &[]; +const PRIME35_150_RAYS: &[Ray] = &[ + Ray { angle: 7.1, r0: 50.0, r1: 139.84, fade: 0.05, rgb: [0.0088, 0.0157, 0.0157] }, + Ray { angle: 8.42, r0: 75.0, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 9.44, r0: 92.969, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 10.47, r0: 91.406, r1: 139.06, fade: 0.05, rgb: [0.0186, 0.0314, 0.0275] }, + Ray { angle: 11.74, r0: 46.094, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 17.32, r0: 28.125, r1: 277.34, fade: 0.05, rgb: [0.0284, 0.0412, 0.0353] }, + Ray { angle: 24.5, r0: 29.688, r1: 260.16, fade: 0.05, rgb: [0.0196, 0.0294, 0.0235] }, + Ray { angle: 37.17, r0: 71.094, r1: 138.28, fade: 0.05, rgb: [0.0118, 0.0255, 0.0176] }, + Ray { angle: 38.55, r0: 16.406, r1: 139.06, fade: 0.05, rgb: [0.0078, 0.0216, 0.0176] }, + Ray { angle: 38.93, r0: 17.188, r1: 213.28, fade: 0.05, rgb: [0.0137, 0.0275, 0.0235] }, + Ray { angle: 40.54, r0: 82.031, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0314, 0.0265] }, + Ray { angle: 41.83, r0: 48.438, r1: 137.5, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 53.28, r0: 28.125, r1: 260.94, fade: 0.05, rgb: [0.0216, 0.0353, 0.0353] }, + Ray { angle: 67.46, r0: 46.875, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 68.72, r0: 88.281, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0225, 0.0176] }, + Ray { angle: 69.77, r0: 93.75, r1: 139.84, fade: 0.05, rgb: [0.0157, 0.0235, 0.0225] }, + Ray { angle: 70.9, r0: 85.156, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 71.94, r0: 30.469, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 82.03, r0: 77.344, r1: 225.0, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 97.43, r0: 47.656, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 98.72, r0: 65.625, r1: 141.41, fade: 0.05, rgb: [0.0098, 0.0137, 0.0127] }, + Ray { angle: 99.75, r0: 100.0, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0186, 0.0157] }, + Ray { angle: 100.77, r0: 88.281, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 102.02, r0: 52.344, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0196, 0.0157] }, + Ray { angle: 104.36, r0: 52.344, r1: 417.19, fade: 0.05, rgb: [0.0196, 0.0235, 0.0235] }, + Ray { angle: 110.66, r0: 25.0, r1: 285.94, fade: 0.05, rgb: [0.0314, 0.0451, 0.0412] }, + Ray { angle: 118.76, r0: 27.344, r1: 267.97, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 127.5, r0: 60.156, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0196, 0.0176] }, + Ray { angle: 128.7, r0: 97.656, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 129.58, r0: 95.312, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 130.73, r0: 81.25, r1: 139.06, fade: 0.05, rgb: [0.0196, 0.0294, 0.0275] }, + Ray { angle: 132.16, r0: 62.5, r1: 139.06, fade: 0.05, rgb: [0.0137, 0.0275, 0.0235] }, + Ray { angle: 147.38, r0: 34.375, r1: 278.12, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 157.39, r0: 44.531, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0147, 0.0137] }, + Ray { angle: 158.71, r0: 71.094, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 159.75, r0: 91.406, r1: 139.06, fade: 0.05, rgb: [0.0118, 0.0167, 0.0167] }, + Ray { angle: 161.69, r0: 17.969, r1: 246.09, fade: 0.05, rgb: [0.0275, 0.0471, 0.0431] }, + Ray { angle: 168.82, r0: 32.812, r1: 238.28, fade: 0.05, rgb: [0.0235, 0.0314, 0.0275] }, + Ray { angle: 183.11, r0: 45.312, r1: 234.38, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 187.45, r0: 47.656, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 188.74, r0: 85.938, r1: 141.41, fade: 0.05, rgb: [0.0127, 0.0206, 0.0176] }, + Ray { angle: 189.76, r0: 103.91, r1: 139.84, fade: 0.05, rgb: [0.0157, 0.0216, 0.0216] }, + Ray { angle: 190.78, r0: 88.281, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0206, 0.0176] }, + Ray { angle: 192.07, r0: 39.844, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 217.53, r0: 48.438, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0147] }, + Ray { angle: 218.74, r0: 22.656, r1: 77.344, fade: 0.05, rgb: [0.002, 0.0078, 0.0078] }, + Ray { angle: 219.61, r0: 73.438, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 220.84, r0: 70.312, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 222.21, r0: 42.969, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 225.99, r0: 36.719, r1: 262.5, fade: 0.05, rgb: [0.0137, 0.0225, 0.0196] }, + Ray { angle: 240.32, r0: 35.156, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 247.11, r0: 33.594, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0176, 0.0147] }, + Ray { angle: 247.37, r0: 24.219, r1: 137.5, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 248.42, r0: 82.812, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0186, 0.0157] }, + Ray { angle: 249.45, r0: 96.875, r1: 139.84, fade: 0.05, rgb: [0.0216, 0.0324, 0.0275] }, + Ray { angle: 250.54, r0: 95.312, r1: 141.41, fade: 0.05, rgb: [0.0235, 0.0343, 0.0294] }, + Ray { angle: 251.59, r0: 42.969, r1: 141.41, fade: 0.05, rgb: [0.0078, 0.0176, 0.0157] }, + Ray { angle: 254.7, r0: 43.75, r1: 152.34, fade: 0.05, rgb: [0.0098, 0.0196, 0.0157] }, + Ray { angle: 269.04, r0: 21.094, r1: 195.31, fade: 0.05, rgb: [0.0235, 0.0314, 0.0284] }, + Ray { angle: 276.11, r0: 127.34, r1: 196.09, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 277.13, r0: 42.188, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 278.42, r0: 83.594, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 279.46, r0: 100.78, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0196, 0.0157] }, + Ray { angle: 280.47, r0: 90.625, r1: 139.06, fade: 0.05, rgb: [0.0176, 0.0235, 0.0196] }, + Ray { angle: 281.75, r0: 73.438, r1: 139.84, fade: 0.05, rgb: [0.0206, 0.0324, 0.0294] }, + Ray { angle: 283.38, r0: 60.938, r1: 200.0, fade: 0.05, rgb: [0.0196, 0.0294, 0.0275] }, + Ray { angle: 297.53, r0: 72.656, r1: 216.41, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 305.74, r0: 65.625, r1: 228.91, fade: 0.05, rgb: [0.0176, 0.0265, 0.0235] }, + Ray { angle: 307.34, r0: 71.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 308.48, r0: 94.531, r1: 140.62, fade: 0.05, rgb: [0.0127, 0.0176, 0.0167] }, + Ray { angle: 309.42, r0: 95.312, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 310.59, r0: 76.562, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 311.95, r0: 48.438, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0196, 0.0157] }, + Ray { angle: 337.12, r0: 47.656, r1: 139.84, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 338.44, r0: 85.156, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 339.49, r0: 96.875, r1: 139.84, fade: 0.05, rgb: [0.0216, 0.0333, 0.0284] }, + Ray { angle: 340.57, r0: 89.062, r1: 141.41, fade: 0.05, rgb: [0.0216, 0.0314, 0.0294] }, + Ray { angle: 341.68, r0: 45.312, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, +]; +const PRIME35_200_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.7154, 0.8569, 0.7967]), + (0.8, [0.7338, 0.8943, 0.8334]), + (1.6, [0.7292, 0.8846, 0.8226]), + (62.4, [0.7382, 0.9051, 0.8391]), + (100.8, [0.7537, 0.9389, 0.8685]), + (184.0, [0.8203, 1.086, 0.9897]), + (216.8, [0.8395, 1.112, 1.02]), + (223.2, [0.8488, 1.103, 1.04]), + (236.0, [0.8995, 1.202, 1.158]), + (249.6, [0.9794, 1.377, 1.293]), + (260.0, [1.073, 1.524, 1.395]), + (276.0, [1.114, 1.721, 1.534]), + (290.4, [1.119, 1.842, 1.613]), + (321.6, [1.108, 1.983, 1.706]), + (331.2, [1.095, 2.003, 1.708]), + (351.2, [1.022, 1.941, 1.633]), + (366.4, [0.9518, 1.845, 1.546]), + (388.0, [0.814, 1.621, 1.354]), + (413.6, [0.6226, 1.264, 1.057]), + (438.4, [0.407, 0.8514, 0.7121]), + (448.0, [0.3122, 0.6636, 0.5554]), + (457.6, [0.212, 0.4414, 0.3756]), + (460.0, [0.1878, 0.3733, 0.3243]), + (463.2, [0.1483, 0.2808, 0.2404]), + (465.6, [0.1187, 0.1927, 0.1711]), + (466.4, [0.1035, 0.1533, 0.1409]), + (467.2, [0.0885, 0.1188, 0.1159]), + (468.0, [0.0674, 0.076, 0.0831]), + (468.8, [0.052, 0.0383, 0.0557]), + (469.6, [0.0307, 0.0022, 0.0251]), + (470.4, [0.0146, -0.0416, -0.008]), + (471.2, [-0.0064, -0.0757, -0.0429]), + (472.0, [-0.0232, -0.1194, -0.0791]), + (472.8, [-0.0424, -0.1549, -0.1089]), + (473.6, [-0.0605, -0.1916, -0.1471]), + (474.4, [-0.0801, -0.233, -0.1765]), + (477.6, [-0.1524, -0.3986, -0.3124]), + (482.4, [-0.2722, -0.6528, -0.5018]), + (484.8, [-0.3368, -0.7803, -0.6111]), + (492.8, [-0.5721, -1.221, -0.9667]), + (496.8, [-0.6695, -1.437, -1.143]), + (520.8, [-1.138, -2.711, -2.172]), + (525.6, [-1.218, -2.905, -2.33]), + (538.4, [-1.343, -3.177, -2.56]), + (567.2, [-1.594, -3.676, -2.969]), + (596.8, [-1.773, -4.012, -3.261]), + (599.2, [-1.754, -3.951, -3.217]), + (600.8, [-1.698, -3.816, -3.118]), + (602.4, [-1.607, -3.604, -2.961]), + (604.0, [-1.482, -3.317, -2.735]), + (605.6, [-1.315, -2.961, -2.455]), + (608.0, [-1.007, -2.324, -1.937]), + (612.0, [-0.4248, -1.106, -0.9541]), + (613.6, [-0.2004, -0.6281, -0.5549]), + (614.4, [-0.0926, -0.3993, -0.3655]), + (615.2, [0.0061, -0.186, -0.1882]), + (616.0, [0.0895, 0.013, -0.0177]), + (616.8, [0.1631, 0.1932, 0.1339]), + (617.6, [0.2257, 0.3522, 0.2665]), + (618.4, [0.2749, 0.4814, 0.378]), + (619.2, [0.3157, 0.5899, 0.4733]), + (620.0, [0.3411, 0.6701, 0.5437]), + (620.8, [0.3535, 0.7192, 0.5886]), + (621.6, [0.3537, 0.7344, 0.6058]), + (622.4, [0.3391, 0.7145, 0.594]), + (623.2, [0.3171, 0.6691, 0.5626]), + (624.0, [0.2842, 0.5962, 0.509]), + (624.8, [0.2426, 0.4999, 0.4361]), + (628.0, [0.0402, 0.0324, 0.0784]), + (628.8, [-0.0043, -0.0727, -0.0089]), + (629.6, [-0.0406, -0.1614, -0.0717]), + (630.4, [-0.0667, -0.2229, -0.1167]), + (631.2, [-0.0818, -0.2621, -0.1426]), + (632.0, [-0.088, -0.2752, -0.1491]), + (632.8, [-0.075, -0.2637, -0.1424]), + (633.6, [-0.0573, -0.2379, -0.1199]), + (634.4, [-0.0376, -0.189, -0.0868]), + (635.2, [-0.0215, -0.1329, -0.0491]), + (636.0, [0.0033, -0.0768, -0.0132]), + (636.8, [0.0239, -0.0253, 0.0203]), + (637.6, [0.0391, 0.0169, 0.0432]), + (638.4, [0.0483, 0.0527, 0.056]), + (639.2, [0.0519, 0.0726, 0.0573]), + (640.0, [0.0504, 0.08, 0.0474]), + (640.8, [0.0464, 0.0781, 0.0408]), + (641.6, [0.0411, 0.0658, 0.0309]), + (642.4, [0.0344, 0.0625, 0.0214]), + (643.2, [0.0396, 0.0627, 0.0135]), + (644.0, [0.0434, 0.067, 0.0056]), + (645.6, [0.0487, 0.0807, 0.0144]), + (646.4, [0.0459, 0.0822, 0.016]), + (647.2, [0.0408, 0.0794, 0.0149]), + (648.0, [0.0347, 0.0733, 0.0095]), + (649.6, [0.0202, 0.0588, 0.0082]), + (650.4, [0.023, 0.049, 0.0092]), + (651.2, [0.0256, 0.0464, 0.015]), + (652.0, [0.0285, 0.0508, 0.0269]), + (652.8, [0.0308, 0.0603, 0.0451]), + (653.6, [0.0313, 0.0706, 0.0597]), + (654.4, [0.0355, 0.078, 0.0707]), + (655.2, [0.0356, 0.0767, 0.0776]), + (656.0, [0.0359, 0.0706, 0.0816]), + (656.8, [0.0375, 0.0647, 0.0789]), + (657.6, [0.035, 0.0611, 0.081]), + (659.2, [0.0489, 0.0642, 0.0894]), + (660.0, [0.0538, 0.0613, 0.0918]), + (660.8, [0.0558, 0.0563, 0.0888]), + (661.6, [0.0493, 0.0441, 0.0766]), + (663.2, [0.0486, 0.0082, 0.0391]), + (664.0, [0.0538, -0.0068, 0.0222]), + (664.8, [0.0628, -0.0088, 0.0077]), + (665.6, [0.068, -0.0014, 0.0127]), + (667.2, [0.0757, 0.0255, 0.0283]), + (668.0, [0.0752, 0.0298, 0.0306]), + (668.8, [0.0744, 0.0256, 0.0275]), + (669.6, [0.075, 0.0114, 0.0153]), + (670.4, [0.0703, 0.0056, 0.0105]), + (672.8, [0.0476, -0.0142, -0.0068]), + (674.4, [0.0356, -0.0242, -0.0133]), + (675.2, [0.0373, -0.0141, -0.0035]), + (676.0, [0.035, -0.0017, 0.0079]), + (676.8, [0.029, 0.0078, 0.0176]), + (677.6, [0.0214, 0.0107, 0.0223]), + (679.2, [0.0095, 0.0035, 0.023]), + (680.0, [0.0048, 0.0007, 0.027]), + (680.8, [-0.0012, 0.0091, 0.0279]), + (682.4, [-0.0179, 0.0253, 0.0293]), + (683.2, [-0.0254, 0.0413, 0.0349]), + (684.0, [-0.036, 0.0601, 0.0418]), + (684.8, [-0.0344, 0.0721, 0.0397]), + (687.2, [-0.0293, 0.0521, 0.0336]), + (688.8, [-0.028, 0.0469, 0.0352]), + (689.6, [-0.0112, 0.0488, 0.0397]), + (690.4, [0.0052, 0.0485, 0.0346]), + (691.2, [0.02, 0.0399, 0.0247]), + (692.0, [0.0347, 0.0224, 0.0153]), + (692.8, [0.0495, 0.0013, 0.0073]), + (693.6, [0.0663, -0.0223, -0.0014]), + (694.4, [0.0966, -0.0412, -0.0045]), + (695.2, [0.136, -0.0662, -0.0113]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.2737, 0.1155, 0.1799]), + (0.8, [0.2699, 0.1131, 0.1737]), + (11.2, [0.2675, 0.1035, 0.1681]), + (24.0, [0.2654, 0.0978, 0.1645]), + (63.2, [0.2615, 0.0943, 0.1603]), + (91.2, [0.2513, 0.0726, 0.1411]), + (108.0, [0.2422, 0.0517, 0.1236]), + (130.4, [0.2279, 0.019, 0.0962]), + (141.6, [0.2187, -0.002, 0.079]), + (169.6, [0.1921, -0.0601, 0.032]), + (180.0, [0.1832, -0.0785, 0.0166]), + (194.4, [0.1734, -0.0954, 0.0]), + (216.0, [0.1618, -0.1112, -0.0174]), + (218.4, [0.1597, -0.1122, -0.0214]), + (220.8, [0.1563, -0.1171, -0.0287]), + (223.2, [0.1512, -0.1279, -0.0401]), + (226.4, [0.1414, -0.1535, -0.0623]), + (231.2, [0.122, -0.2077, -0.1075]), + (236.0, [0.0995, -0.2714, -0.161]), + (245.6, [0.0457, -0.4226, -0.2798]), + (250.4, [0.0136, -0.5039, -0.3423]), + (252.8, [-0.0061, -0.5452, -0.3736]), + (260.0, [-0.0751, -0.6688, -0.4689]), + (267.2, [-0.1259, -0.7852, -0.5593]), + (278.4, [-0.1851, -0.9472, -0.6846]), + (284.8, [-0.2133, -1.024, -0.7428]), + (289.6, [-0.2268, -1.071, -0.7813]), + (303.2, [-0.2601, -1.177, -0.8641]), + (310.4, [-0.2792, -1.223, -0.9027]), + (321.6, [-0.3229, -1.309, -0.9761]), + (326.4, [-0.3369, -1.337, -0.995]), + (332.0, [-0.3422, -1.358, -1.009]), + (353.6, [-0.3313, -1.343, -0.9884]), + (372.0, [-0.2942, -1.26, -0.9208]), + (384.8, [-0.2462, -1.155, -0.8435]), + (396.0, [-0.2001, -1.043, -0.752]), + (411.2, [-0.1266, -0.8622, -0.6071]), + (420.0, [-0.0784, -0.744, -0.5174]), + (432.8, [0.0041, -0.5564, -0.3694]), + (436.8, [0.0266, -0.4979, -0.3235]), + (443.2, [0.0701, -0.3928, -0.2362]), + (448.0, [0.1071, -0.3071, -0.1697]), + (454.4, [0.1571, -0.1798, -0.0641]), + (458.4, [0.1909, -0.0895, 0.0018]), + (459.2, [0.1963, -0.0691, 0.0153]), + (460.8, [0.2105, -0.0283, 0.049]), + (462.4, [0.2258, 0.0126, 0.0857]), + (464.0, [0.2419, 0.0584, 0.1254]), + (464.8, [0.2505, 0.083, 0.1462]), + (466.4, [0.2666, 0.1421, 0.1896]), + (468.0, [0.2934, 0.212, 0.2409]), + (470.4, [0.3443, 0.3237, 0.325]), + (474.4, [0.4272, 0.5097, 0.4886]), + (483.2, [0.6195, 0.9528, 0.8273]), + (492.0, [0.8546, 1.42, 1.203]), + (497.6, [0.9847, 1.712, 1.438]), + (521.6, [1.406, 2.942, 2.422]), + (526.4, [1.477, 3.127, 2.571]), + (539.2, [1.579, 3.378, 2.776]), + (568.0, [1.779, 3.834, 3.142]), + (598.4, [1.915, 4.136, 3.391]), + (601.6, [1.844, 3.954, 3.259]), + (603.2, [1.752, 3.746, 3.106]), + (604.8, [1.629, 3.467, 2.885]), + (606.4, [1.465, 3.114, 2.608]), + (608.8, [1.157, 2.482, 2.097]), + (614.4, [0.3432, 0.7828, 0.7084]), + (615.2, [0.2337, 0.5523, 0.5174]), + (616.0, [0.1329, 0.3316, 0.3363]), + (616.8, [0.0424, 0.1291, 0.1607]), + (617.6, [-0.0334, -0.0553, 0.0052]), + (618.4, [-0.0985, -0.2192, -0.1316]), + (619.2, [-0.151, -0.3577, -0.248]), + (620.0, [-0.1975, -0.472, -0.3508]), + (620.8, [-0.226, -0.5586, -0.4266]), + (621.6, [-0.2411, -0.6147, -0.4772]), + (622.4, [-0.2438, -0.637, -0.5002]), + (623.2, [-0.2356, -0.6277, -0.4977]), + (624.0, [-0.216, -0.5886, -0.4714]), + (624.8, [-0.1853, -0.5215, -0.4224]), + (625.6, [-0.1454, -0.4298, -0.3533]), + (626.4, [-0.1015, -0.319, -0.2684]), + (627.2, [-0.0508, -0.2069, -0.1845]), + (628.8, [0.0523, 0.0314, -0.002]), + (629.6, [0.0941, 0.1398, 0.0855]), + (630.4, [0.1323, 0.2316, 0.1512]), + (631.2, [0.1607, 0.2955, 0.2003]), + (632.0, [0.1783, 0.3401, 0.2304]), + (632.8, [0.1856, 0.3588, 0.2411]), + (633.6, [0.1731, 0.3523, 0.2332]), + (634.4, [0.1563, 0.3271, 0.2136]), + (635.2, [0.1367, 0.2809, 0.1823]), + (636.0, [0.1188, 0.2256, 0.1451]), + (636.8, [0.0927, 0.1691, 0.1083]), + (637.6, [0.071, 0.1162, 0.0697]), + (638.4, [0.0546, 0.0714, 0.0448]), + (639.2, [0.0441, 0.0299, 0.0297]), + (640.0, [0.0353, 0.007, 0.0258]), + (640.8, [0.0359, -0.003, 0.0331]), + (641.6, [0.0395, -0.0028, 0.0371]), + (642.4, [0.0446, 0.0058, 0.0467]), + (643.2, [0.0507, 0.0093, 0.0565]), + (644.0, [0.0444, 0.01, 0.0647]), + (644.8, [0.0404, 0.0066, 0.072]), + (645.6, [0.0371, 0.0001, 0.066]), + (647.2, [0.0323, -0.014, 0.0599]), + (648.0, [0.0371, -0.012, 0.0604]), + (648.8, [0.043, -0.0066, 0.0645]), + (649.6, [0.0496, 0.0008, 0.0618]), + (650.4, [0.0568, 0.008, 0.0635]), + (651.2, [0.0526, 0.0139, 0.0632]), + (652.0, [0.0501, 0.0179, 0.0586]), + (652.8, [0.0472, 0.0149, 0.048]), + (653.6, [0.0447, 0.0062, 0.0263]), + (654.4, [0.0437, -0.0043, 0.011]), + (655.2, [0.036, -0.0125, -0.0009]), + (656.0, [0.0355, -0.0175, -0.0086]), + (656.8, [0.0354, -0.0118, -0.0131]), + (658.4, [0.0337, -0.0014, -0.0165]), + (659.2, [0.0264, -0.0008, -0.0203]), + (660.8, [0.0141, -0.0059, -0.0282]), + (661.6, [0.0113, -0.0024, -0.0311]), + (662.4, [0.013, 0.008, -0.0207]), + (663.2, [0.015, 0.0242, -0.0045]), + (664.0, [0.0147, 0.043, 0.0154]), + (664.8, [0.0105, 0.0586, 0.0334]), + (665.6, [0.0022, 0.06, 0.0472]), + (666.4, [-0.0077, 0.0548, 0.0438]), + (667.2, [-0.0133, 0.0415, 0.0356]), + (668.0, [-0.0166, 0.0278, 0.0279]), + (668.8, [-0.0166, 0.0213, 0.0243]), + (669.6, [-0.0157, 0.024, 0.0264]), + (670.4, [-0.016, 0.0341, 0.0345]), + (671.2, [-0.0167, 0.0403, 0.0395]), + (673.6, [0.0059, 0.0597, 0.0568]), + (675.2, [0.0163, 0.0692, 0.0624]), + (676.0, [0.0137, 0.0598, 0.0529]), + (676.8, [0.0151, 0.0475, 0.0416]), + (677.6, [0.0204, 0.037, 0.0312]), + (678.4, [0.0279, 0.0324, 0.0253]), + (679.2, [0.0352, 0.035, 0.0243]), + (680.0, [0.0364, 0.0392, 0.0243]), + (680.8, [0.0412, 0.041, 0.0164]), + (681.6, [0.0468, 0.0318, 0.0153]), + (683.2, [0.0631, 0.0165, 0.0145]), + (684.0, [0.0708, 0.0019, 0.0097]), + (684.8, [0.0796, -0.0171, 0.0027]), + (685.6, [0.0778, -0.0311, 0.0006]), + (686.4, [0.0768, -0.0325, 0.0007]), + (688.0, [0.073, -0.0179, 0.0054]), + (689.6, [0.0702, -0.0117, 0.0047]), + (690.4, [0.0543, -0.013, 0.0006]), + (691.2, [0.0374, -0.014, -0.0009]), + (692.0, [0.0224, -0.0069, 0.0083]), + (692.8, [0.0077, 0.0046, 0.0182]), + (693.6, [-0.007, 0.0251, 0.0261]), + (694.4, [-0.0229, 0.0489, 0.0353]), + (695.2, [-0.0528, 0.0682, 0.0392]), + (696.0, [-0.0921, 0.0915, 0.0446]), + (696.8, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.9013, + perp: 0.0, + knots: &[ + (0.0, [0.0447, 0.0359, -0.016]), + (0.79906, [0.0379, 0.0316, -0.0115]), + (3.9953, [0.0337, 0.0301, -0.0108]), + (19.177, [0.0225, 0.0238, -0.0091]), + (25.57, [0.0173, 0.0247, -0.0069]), + (37.556, [0.0156, 0.024, -0.0014]), + (47.944, [0.0073, 0.0237, -0.0006]), + (57.532, [0.0029, 0.0252, 0.0038]), + (59.929, [0.0017, 0.027, 0.0056]), + (65.523, [0.0032, 0.0315, 0.0101]), + (78.308, [0.003, 0.0376, 0.016]), + (85.499, [0.0064, 0.0491, 0.0251]), + (92.691, [0.0085, 0.055, 0.0314]), + (93.49, [0.0071, 0.0558, 0.0319]), + (111.07, [0.014, 0.0719, 0.0448]), + (112.67, [0.0188, 0.0741, 0.046]), + (118.26, [0.0488, 0.0854, 0.0551]), + (119.86, [0.0526, 0.088, 0.0564]), + (127.05, [0.0207, 0.0863, 0.0563]), + (140.63, [0.0266, 0.0996, 0.0671]), + (146.23, [0.0309, 0.1105, 0.0757]), + (147.03, [0.0308, 0.1085, 0.0743]), + (148.62, [0.0271, 0.0946, 0.0644]), + (159.01, [-0.0018, -0.0038, -0.0029]), + (159.81, [-0.0015, -0.0052, -0.0041]), + (259.69, [-0.0007, -0.0019, -0.0016]), + ], + }, + Radial { + t: -1.0759, + perp: 0.0, + knots: &[ + (0.0, [-0.0012, -0.0025, -0.0024]), + (20.706, [-0.0018, -0.0017, -0.0037]), + (44.598, [-0.0032, -0.0081, -0.0063]), + (55.747, [-0.0038, -0.0087, -0.007]), + (82.028, [-0.0028, -0.0065, -0.0051]), + (99.548, [-0.0012, -0.0028, -0.0023]), + (122.64, [-0.0009, -0.0023, -0.0017]), + ], + }, + Radial { + t: 0.6924, + perp: 0.0, + knots: &[ + (0.0, [0.0016, 0.0043, 0.0036]), + (2.38, [0.0012, 0.0026, 0.0023]), + (3.1734, [0.0014, 0.0039, 0.0031]), + (77.748, [0.0009, 0.0023, 0.0019]), + (84.888, [-0.0005, -0.0025, -0.0015]), + ], + }, + Radial { + t: 1.6362, + perp: 0.0, + knots: &[ + (0.0, [-0.002, -0.0027, 0.0039]), + (1.5694, [-0.0013, 0.0003, 0.0026]), + (4.7082, [0.0025, 0.0035, 0.0053]), + (9.4165, [0.0126, 0.0191, 0.0191]), + (10.201, [0.0126, 0.0196, 0.0193]), + (11.771, [0.0157, 0.0259, 0.0235]), + (13.34, [0.0152, 0.0247, 0.0222]), + (15.694, [0.0079, 0.0131, 0.0123]), + (18.048, [0.0023, 0.0039, 0.0055]), + (20.402, [-0.0014, -0.0019, -0.0004]), + (21.972, [-0.0034, -0.005, -0.0021]), + (26.68, [-0.0052, -0.0062, -0.0046]), + (28.249, [-0.0036, -0.003, -0.0017]), + (32.173, [-0.0017, -0.0012, -0.0001]), + ], + }, + Radial { + t: 2.0727, + perp: 0.0, + knots: &[ + (0.0, [0.1135, 0.13, 0.0738]), + (0.7973, [0.1183, 0.1323, 0.077]), + (3.1892, [0.121, 0.1329, 0.0753]), + (7.1757, [0.1155, 0.1315, 0.0754]), + (25.513, [0.0773, 0.1109, 0.0745]), + (30.297, [0.0667, 0.1024, 0.0731]), + (39.067, [0.0516, 0.0924, 0.0705]), + (51.027, [0.0383, 0.0834, 0.0654]), + (78.135, [0.0266, 0.0619, 0.0496]), + (86.108, [0.028, 0.0564, 0.0447]), + (112.42, [0.0149, 0.0349, 0.0278]), + ], + }, +]; +const PRIME35_200_STREAKS: &[Streak] = &[]; +const PRIME35_200_RAYS: &[Ray] = &[]; +const PRIME35_300_RADIAL: &[Radial] = &[ + Radial { + t: -0.0000358, + perp: 0.0, + knots: &[ + (0.0, [0.5003, 0.5003, 0.5003]), + (688.8, [0.5036, 0.5036, 0.5036]), + (692.0, [0.4955, 0.4955, 0.4955]), + (695.2, [0.4741, 0.4741, 0.4741]), + (696.0, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0050188, + perp: 0.0, + knots: &[ + (0.0, [0.4997, 0.4997, 0.4997]), + (688.8, [0.4963, 0.4963, 0.4963]), + (692.0, [0.5006, 0.5006, 0.5006]), + (696.0, [0.5245, 0.5245, 0.5245]), + (696.8, [0.0, 0.0, 0.0]), + ], + }, +]; +const PRIME35_300_STREAKS: &[Streak] = &[]; +const PRIME35_300_RAYS: &[Ray] = &[]; +pub(super) const PRIME35: &[Level] = &[ + Level { brightness: 10.0, radial: PRIME35_10_RADIAL, streaks: PRIME35_10_STREAKS, rays: PRIME35_10_RAYS }, + Level { brightness: 50.0, radial: PRIME35_50_RADIAL, streaks: PRIME35_50_STREAKS, rays: PRIME35_50_RAYS }, + Level { brightness: 75.0, radial: PRIME35_75_RADIAL, streaks: PRIME35_75_STREAKS, rays: PRIME35_75_RAYS }, + Level { brightness: 100.0, radial: PRIME35_100_RADIAL, streaks: PRIME35_100_STREAKS, rays: PRIME35_100_RAYS }, + Level { brightness: 125.0, radial: PRIME35_125_RADIAL, streaks: PRIME35_125_STREAKS, rays: PRIME35_125_RAYS }, + Level { brightness: 150.0, radial: PRIME35_150_RADIAL, streaks: PRIME35_150_STREAKS, rays: PRIME35_150_RAYS }, + Level { brightness: 200.0, radial: PRIME35_200_RADIAL, streaks: PRIME35_200_STREAKS, rays: PRIME35_200_RAYS }, + Level { brightness: 300.0, radial: PRIME35_300_RADIAL, streaks: PRIME35_300_STREAKS, rays: PRIME35_300_RAYS }, +]; + +const PRIME105_10_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.742, 0.7144, 0.7037]), + (0.8, [0.7034, 0.6788, 0.6594]), + (1.6, [0.6247, 0.587, 0.5597]), + (3.2, [0.4345, 0.3763, 0.3429]), + (4.0, [0.3632, 0.2983, 0.2645]), + (4.8, [0.3107, 0.2534, 0.2171]), + (5.6, [0.2863, 0.2331, 0.1986]), + (6.4, [0.2655, 0.2199, 0.1912]), + (8.0, [0.2349, 0.1946, 0.1647]), + (8.8, [0.2164, 0.1864, 0.1557]), + (9.6, [0.1996, 0.1718, 0.1457]), + (12.0, [0.1554, 0.1358, 0.1158]), + (14.4, [0.1134, 0.1079, 0.098]), + (15.2, [0.1078, 0.1007, 0.0926]), + (16.0, [0.0974, 0.0983, 0.0835]), + (17.6, [0.0831, 0.0817, 0.0702]), + (18.4, [0.0748, 0.0764, 0.0622]), + (19.2, [0.0682, 0.0673, 0.0558]), + (20.8, [0.0538, 0.0573, 0.0452]), + (24.0, [0.0328, 0.037, 0.0279]), + (25.6, [0.0271, 0.0309, 0.0221]), + (26.4, [0.0206, 0.0268, 0.0186]), + (28.0, [0.018, 0.0201, 0.0129]), + (28.8, [0.0134, 0.0188, 0.0119]), + (32.8, [0.0065, 0.0117, 0.0054]), + (34.4, [0.0049, 0.0082, 0.0049]), + (35.2, [0.0024, 0.0081, 0.0027]), + (36.8, [0.0018, 0.0043, 0.0013]), + (37.6, [-0.0007, 0.0046, 0.0011]), + (39.2, [-0.0011, 0.0007, -0.0024]), + (40.8, [-0.0049, 0.0004, -0.0023]), + (42.4, [-0.0045, -0.0028, -0.006]), + (44.0, [-0.0084, -0.0039, -0.0058]), + (44.8, [-0.0082, -0.0063, -0.0057]), + (46.4, [-0.0085, -0.0062, -0.0095]), + (47.2, [-0.0111, -0.0067, -0.0093]), + (48.8, [-0.0117, -0.01, -0.0094]), + (49.6, [-0.0117, -0.0099, -0.0091]), + (51.2, [-0.0123, -0.0098, -0.0129]), + (52.8, [-0.0153, -0.0134, -0.0128]), + (76.0, [-0.0158, -0.0143, -0.0128]), + (77.6, [-0.012, -0.0143, -0.0128]), + (80.0, [-0.0157, -0.0143, -0.0128]), + (99.2, [-0.0155, -0.0145, -0.0125]), + (209.6, [-0.0099, -0.0093, -0.0077]), + (368.8, [0.0014, 0.0015, 0.0013]), + (615.2, [-0.0022, -0.0024, -0.0021]), + (661.6, [-0.0005, -0.0002, -0.0005]), + (685.6, [-0.0025, -0.0018, -0.0011]), + (699.2, [0.0025, 0.0029, 0.0021]), + (702.4, [0.0012, 0.0015, 0.0008]), + (706.4, [-0.0047, -0.0043, -0.0037]), + (708.8, [-0.0062, -0.0059, -0.0048]), + (717.6, [-0.0011, -0.0014, -0.0012]), + (747.2, [-0.0017, -0.0016, -0.0014]), + (753.6, [-0.004, -0.0078, -0.0052]), + (756.8, [-0.0058, -0.0128, -0.0112]), + (760.0, [-0.0064, -0.015, -0.0156]), + (764.8, [-0.0049, -0.0101, -0.013]), + (772.0, [0.0045, 0.0136, 0.0033]), + (776.0, [0.0044, 0.018, 0.0085]), + (780.0, [0.0022, 0.011, 0.0101]), + (784.0, [-0.0013, -0.0023, 0.0026]), + (788.0, [-0.0028, -0.0085, -0.0031]), + (791.2, [-0.003, -0.0089, -0.0035]), + (808.8, [-0.0005, -0.0017, -0.0009]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.1004, 0.0924, 0.0911]), + (0.8, [0.0985, 0.0898, 0.0916]), + (1.6, [0.093, 0.0846, 0.0841]), + (2.4, [0.0756, 0.0643, 0.064]), + (4.0, [0.0589, 0.046, 0.0453]), + (4.8, [0.0499, 0.0398, 0.0389]), + (12.0, [0.0324, 0.0267, 0.028]), + (12.8, [0.0301, 0.0272, 0.0269]), + (13.6, [0.0299, 0.0247, 0.027]), + (14.4, [0.0273, 0.025, 0.0257]), + (19.2, [0.0226, 0.0194, 0.0202]), + (26.4, [0.0168, 0.0152, 0.015]), + (47.2, [0.0131, 0.0123, 0.0115]), + (128.8, [0.0121, 0.0115, 0.0097]), + (328.0, [-0.0022, -0.0019, -0.0019]), + (385.6, [-0.0046, -0.0043, -0.0037]), + (512.0, [-0.0011, -0.0011, -0.001]), + (625.6, [-0.0004, 0.0001, 0.0]), + (659.2, [-0.0022, -0.0022, -0.0017]), + (687.2, [-0.0003, -0.0006, -0.0011]), + (700.0, [-0.0052, -0.0053, -0.0042]), + (703.2, [-0.004, -0.0039, -0.0029]), + (707.2, [0.002, 0.002, 0.0016]), + (709.6, [0.0035, 0.0035, 0.0027]), + (718.4, [-0.0023, -0.0012, -0.001]), + (745.6, [-0.0021, -0.0019, -0.0015]), + (752.0, [-0.0007, 0.0016, -0.0001]), + (754.4, [-0.0016, -0.0021, -0.0031]), + (759.2, [-0.0057, -0.0154, -0.0118]), + (763.2, [-0.0076, -0.0205, -0.0184]), + (765.6, [-0.0061, -0.0167, -0.0169]), + (768.0, [-0.0038, -0.0068, -0.0114]), + (772.8, [0.0034, 0.0168, 0.0055]), + (774.4, [0.0051, 0.0212, 0.0089]), + (776.8, [0.0076, 0.0239, 0.0132]), + (778.4, [0.0073, 0.0218, 0.0132]), + (780.8, [0.0043, 0.0137, 0.0104]), + (784.0, [-0.0027, -0.0063, -0.0011]), + (785.6, [-0.0048, -0.0122, -0.0062]), + (792.0, [-0.002, -0.0054, -0.0007]), + (800.8, [-0.0022, -0.0057, -0.0018]), + (809.6, [-0.0002, -0.0016, -0.0008]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0022, 0.0021, 0.0013]), + (783.44, [0.0019, 0.0016, 0.0015]), + (818.62, [0.0032, 0.0019, 0.0018]), + (831.41, [0.0034, 0.0027, 0.003]), + (833.01, [0.0046, 0.0067, 0.0061]), + (834.61, [0.0067, 0.0131, 0.0105]), + (838.61, [0.0121, 0.0297, 0.0257]), + (841.8, [0.0135, 0.0346, 0.0328]), + (843.4, [0.0131, 0.0332, 0.033]), + (845.0, [0.0111, 0.0271, 0.0302]), + (848.2, [0.0044, 0.0044, 0.0155]), + (852.2, [-0.0078, -0.0297, -0.0082]), + (853.79, [-0.0103, -0.0373, -0.0146]), + (856.19, [-0.012, -0.0418, -0.0214]), + (857.79, [-0.0108, -0.0384, -0.0223]), + (860.19, [-0.0065, -0.0253, -0.0203]), + (862.59, [0.0006, -0.0032, -0.0095]), + (864.19, [0.005, 0.0114, 0.0008]), + (864.99, [0.0064, 0.0156, 0.0045]), + (865.79, [0.0064, 0.017, 0.0058]), + (875.38, [0.0042, 0.0126, 0.0031]), + (884.97, [0.0035, 0.0086, 0.0033]), + (890.57, [-0.0001, 0.0011, 0.0005]), + ], + }, + Radial { t: 1.4995, perp: 0.0, knots: &[(0.0, [0.0059, 0.0035, 0.0001]), (3.9983, [0.0044, 0.0035, 0.0002]), (9.5958, [0.0016, 0.0002, 0.0001])] }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0061, -0.0048, 0.0002]), + (0.79862, [-0.0042, -0.0034, 0.0002]), + (3.1945, [-0.0056, -0.0046, 0.0002]), + (9.5834, [-0.0019, -0.0003, 0.0001]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.045, 0.037, 0.0168]), + (8.7134, [0.044, 0.036, 0.0157]), + (9.5055, [0.0252, 0.021, 0.0098]), + (10.298, [0.0024, 0.0024, 0.0006]), + (11.09, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.0354, 0.0523, 0.0753]), + (1.5834, [0.0212, 0.0362, 0.0602]), + (2.375, [0.0109, 0.0251, 0.0499]), + (3.1667, [0.0071, 0.0213, 0.0457]), + (6.3335, [0.0064, 0.0184, 0.0446]), + (7.9168, [0.0021, 0.007, 0.0365]), + (8.7085, [0.0021, 0.0061, 0.0341]), + (14.25, [0.0002, 0.0007, 0.0002]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.0228, 0.015, 0.0]), + (0.78746, [0.0226, 0.0148, 0.0]), + (1.5749, [0.0176, 0.0085, 0.0]), + (2.3624, [0.0086, 0.0045, 0.0]), + (3.9373, [0.004, -0.0002, 0.0]), + (5.5122, [-0.0002, 0.0, 0.0]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[(0.0, [0.0, 0.004, 0.0079]), (3.9837, [0.0, 0.0044, 0.0078]), (4.7804, [0.0, 0.0007, 0.0037]), (5.5771, [0.0, -0.0001, -0.0001])], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0066, 0.0063, -0.0002]), + (0.78812, [0.0054, 0.0041, -0.0003]), + (5.5169, [0.0046, 0.0018, -0.0002]), + (8.6693, [0.0019, 0.0004, -0.0002]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.0655, 0.0664, 0.0674]), + (0.79199, [0.0544, 0.0561, 0.0594]), + (1.584, [0.0488, 0.052, 0.0531]), + (2.376, [0.0389, 0.0388, 0.0395]), + (3.96, [0.0161, 0.0167, 0.0177]), + (4.752, [0.0078, 0.0083, 0.0093]), + (6.336, [-0.001, -0.0007, 0.0]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[(0.0, [0.0037, 0.0038, 0.0039]), (29.204, [0.004, 0.004, 0.004]), (29.993, [0.0033, 0.0033, 0.0034]), (30.783, [-0.0002, -0.0002, -0.0002])], + }, + Radial { t: 0.94074, perp: 0.0, knots: &[(0.0, [0.0, 0.0141, 0.0177]), (0.7998, [0.0, 0.0085, 0.0088]), (2.3994, [0.0, -0.0002, -0.0001])] }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.0317, 0.0222, 0.0]), + (0.77016, [0.0268, 0.0197, -0.0001]), + (1.5403, [0.0214, 0.0114, -0.0001]), + (2.3105, [0.0106, 0.0072, -0.0001]), + (3.8508, [0.0063, 0.0023, -0.0001]), + (5.3911, [0.0023, 0.0018, 0.0]), + (7.7016, [0.0018, 0.0007, 0.0]), + ], + }, + Radial { t: 2.9016, perp: 0.0, knots: &[(0.0, [0.0044, 0.0011, 0.0005]), (46.298, [0.0042, 0.0004, 0.0002]), (47.895, [-0.0003, 0.0003, 0.0001])] }, +]; +const PRIME105_10_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_10_RAYS: &[Ray] = &[ + Ray { angle: 96.82, r0: 6.25, r1: 72.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 97.07, r0: 6.25, r1: 75.0, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 97.32, r0: 6.25, r1: 94.531, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 275.48, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 275.73, r0: 6.25, r1: 64.062, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 275.99, r0: 6.25, r1: 64.844, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, +]; +const PRIME105_50_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.7558, 0.858, 0.8891]), + (0.8, [0.9807, 0.9598, 0.8998]), + (1.6, [0.8986, 0.918, 0.8699]), + (2.4, [0.897, 0.9062, 0.8456]), + (4.8, [0.8267, 0.8432, 0.7661]), + (11.2, [0.7145, 0.731, 0.6539]), + (18.4, [0.5606, 0.5738, 0.5065]), + (23.2, [0.4652, 0.4709, 0.4194]), + (25.6, [0.4199, 0.4182, 0.3743]), + (28.8, [0.3696, 0.3598, 0.326]), + (40.8, [0.2569, 0.2164, 0.184]), + (44.8, [0.2227, 0.1771, 0.1437]), + (51.2, [0.1783, 0.1212, 0.0776]), + (55.2, [0.1578, 0.093, 0.0427]), + (58.4, [0.147, 0.0771, 0.0198]), + (60.8, [0.1453, 0.0707, 0.0092]), + (65.6, [0.1467, 0.0642, -0.0073]), + (70.4, [0.1508, 0.0587, -0.021]), + (75.2, [0.1574, 0.0564, -0.0289]), + (76.0, [0.1622, 0.056, -0.0297]), + (77.6, [0.1902, 0.0555, -0.0313]), + (78.4, [0.189, 0.0555, -0.032]), + (80.0, [0.1665, 0.0555, -0.0342]), + (80.8, [0.1661, 0.0555, -0.0353]), + (89.6, [0.1775, 0.0519, -0.0486]), + (100.0, [0.195, 0.0533, -0.059]), + (111.2, [0.2083, 0.0526, -0.068]), + (124.8, [0.2196, 0.0552, -0.0735]), + (139.2, [0.2053, 0.0467, -0.0764]), + (152.8, [0.1792, 0.0355, -0.0738]), + (160.8, [0.155, 0.024, -0.0736]), + (170.4, [0.1192, 0.0072, -0.0732]), + (179.2, [0.0817, -0.0094, -0.0709]), + (186.4, [0.0469, -0.0248, -0.0686]), + (195.2, [0.0001, -0.0456, -0.0658]), + (201.6, [-0.0359, -0.0616, -0.0638]), + (234.4, [-0.2321, -0.1483, -0.0515]), + (247.2, [-0.299, -0.1764, -0.0459]), + (260.0, [-0.347, -0.1943, -0.0396]), + (272.0, [-0.3683, -0.1983, -0.033]), + (288.0, [-0.3668, -0.1881, -0.0237]), + (320.8, [-0.3221, -0.1471, -0.0061]), + (346.4, [-0.2729, -0.11, 0.0055]), + (373.6, [-0.2078, -0.0697, 0.0143]), + (396.8, [-0.142, -0.0359, 0.0178]), + (421.6, [-0.0604, -0.0008, 0.0177]), + (438.4, [0.0027, 0.0232, 0.0155]), + (469.6, [0.1302, 0.066, 0.0076]), + (480.8, [0.1724, 0.0798, 0.0039]), + (490.4, [0.1985, 0.0842, 0.0003]), + (500.0, [0.2125, 0.0853, -0.0027]), + (512.0, [0.2109, 0.0808, -0.006]), + (524.8, [0.1886, 0.0684, -0.0079]), + (537.6, [0.1508, 0.0517, -0.0085]), + (557.6, [0.0794, 0.0249, -0.0064]), + (568.8, [0.0408, 0.0129, -0.0042]), + (576.8, [0.0161, 0.0062, -0.0024]), + (586.4, [-0.0088, -0.0001, -0.001]), + (591.2, [-0.0173, -0.0018, -0.0006]), + (595.2, [-0.0217, -0.0024, -0.0009]), + (600.8, [-0.0225, -0.0017, -0.0018]), + (608.0, [-0.0158, 0.0017, -0.0035]), + (620.0, [0.0066, 0.008, -0.0072]), + (626.4, [0.0201, 0.0099, -0.009]), + (630.4, [0.0268, 0.0088, -0.0099]), + (633.6, [0.033, 0.011, -0.0096]), + (636.8, [0.037, 0.0118, -0.0091]), + (641.6, [0.039, 0.0117, -0.0081]), + (648.8, [0.0359, 0.0093, -0.0058]), + (654.4, [0.0288, 0.006, -0.0039]), + (661.6, [0.0133, -0.0011, -0.0024]), + (670.4, [-0.0136, -0.0139, -0.0038]), + (675.2, [-0.0261, -0.0174, -0.0034]), + (680.0, [-0.0338, -0.0174, -0.0028]), + (685.6, [-0.0356, -0.0159, -0.0028]), + (690.4, [-0.0288, -0.0116, -0.0026]), + (700.0, [-0.004, 0.0031, 0.0005]), + (702.4, [-0.002, 0.0015, -0.0019]), + (706.4, [-0.0066, -0.0082, -0.0113]), + (708.0, [-0.0049, -0.0089, -0.0136]), + (709.6, [0.0032, -0.0058, -0.014]), + (710.4, [0.0104, -0.0027, -0.0135]), + (713.6, [0.0461, 0.0119, -0.0076]), + (714.4, [0.051, 0.0143, -0.006]), + (715.2, [0.0523, 0.0153, -0.0048]), + (716.0, [0.0496, 0.0145, -0.004]), + (716.8, [0.0427, 0.0115, -0.0037]), + (719.2, [0.0127, -0.0001, -0.0045]), + (720.0, [0.0075, -0.0008, -0.0046]), + (720.8, [0.0064, 0.0003, -0.0044]), + (722.4, [0.0118, 0.004, -0.0036]), + (723.2, [0.0146, 0.0046, -0.0035]), + (724.0, [0.0146, 0.0038, -0.0038]), + (724.8, [0.0109, 0.002, -0.0041]), + (727.2, [-0.0124, -0.0052, -0.0042]), + (728.0, [-0.0153, -0.0064, -0.0041]), + (730.4, [-0.0086, -0.0065, -0.0047]), + (733.6, [-0.0074, -0.0042, -0.0046]), + (736.8, [-0.0025, -0.0024, -0.0048]), + (740.0, [-0.0051, -0.0031, -0.0048]), + (743.2, [-0.0052, -0.004, -0.005]), + (746.4, [-0.0105, -0.0096, -0.0074]), + (749.6, [-0.0149, -0.0196, -0.0126]), + (752.0, [-0.0155, -0.0244, -0.0166]), + (755.2, [-0.019, -0.0337, -0.0234]), + (756.8, [-0.0208, -0.0396, -0.0295]), + (759.2, [-0.0212, -0.0417, -0.0377]), + (760.8, [-0.0188, -0.0384, -0.0403]), + (763.2, [-0.017, -0.0326, -0.04]), + (765.6, [-0.0139, -0.0215, -0.0341]), + (769.6, [-0.0053, 0.0026, -0.0159]), + (772.0, [0.0001, 0.0186, 0.0007]), + (773.6, [0.0032, 0.0268, 0.0098]), + (775.2, [0.0054, 0.033, 0.0164]), + (777.6, [0.0059, 0.0332, 0.022]), + (780.0, [0.0029, 0.0232, 0.0192]), + (780.8, [-0.0006, 0.0167, 0.0156]), + (784.0, [-0.0151, -0.0179, -0.0061]), + (787.2, [-0.0252, -0.0432, -0.017]), + (788.8, [-0.0285, -0.0512, -0.0203]), + (791.2, [-0.0309, -0.0563, -0.0219]), + (792.8, [-0.029, -0.054, -0.0207]), + (798.4, [-0.0186, -0.0353, -0.0118]), + (804.8, [-0.0126, -0.0277, -0.0059]), + (807.2, [-0.0092, -0.0227, -0.0036]), + (808.8, [-0.0118, -0.0221, -0.0059]), + (810.4, [-0.0058, -0.011, -0.0051]), + (812.0, [0.0042, 0.0056, 0.0006]), + (812.8, [0.0076, 0.0087, 0.0029]), + (813.6, [0.0062, 0.006, 0.003]), + (814.4, [0.0024, 0.0006, 0.001]), + (815.2, [0.0003, -0.0022, -0.0007]), + (816.0, [0.0004, -0.0017, -0.0009]), + (817.6, [0.003, 0.0031, 0.0012]), + (819.2, [-0.0001, -0.0008, -0.0002]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [-0.0451, -0.0148, 0.0454]), + (0.8, [0.0273, 0.0309, 0.0595]), + (1.6, [0.0224, 0.0242, 0.0562]), + (2.4, [0.0182, 0.0245, 0.0527]), + (4.0, [0.0126, 0.0197, 0.0477]), + (5.6, [0.0096, 0.0157, 0.0438]), + (6.4, [0.0105, 0.0163, 0.0429]), + (9.6, [-0.0014, 0.0088, 0.0377]), + (13.6, [-0.013, 0.0033, 0.0331]), + (24.0, [-0.0441, -0.0185, 0.0189]), + (28.8, [-0.0576, -0.0269, 0.0151]), + (38.4, [-0.0751, -0.0336, 0.0135]), + (45.6, [-0.0837, -0.0343, 0.018]), + (55.2, [-0.0909, -0.0319, 0.0286]), + (64.0, [-0.1048, -0.0308, 0.0381]), + (77.6, [-0.1309, -0.033, 0.0518]), + (80.8, [-0.1415, -0.0339, 0.0547]), + (91.2, [-0.1649, -0.038, 0.0633]), + (103.2, [-0.192, -0.0452, 0.0701]), + (116.0, [-0.2141, -0.0522, 0.0738]), + (128.8, [-0.2236, -0.0558, 0.074]), + (140.8, [-0.213, -0.0512, 0.0729]), + (151.2, [-0.1931, -0.0427, 0.0708]), + (161.6, [-0.1635, -0.0296, 0.0685]), + (171.2, [-0.1284, -0.0144, 0.0657]), + (180.8, [-0.0858, 0.0043, 0.0629]), + (190.4, [-0.0365, 0.0263, 0.0598]), + (199.2, [0.0131, 0.0484, 0.057]), + (236.8, [0.2424, 0.1497, 0.0427]), + (251.2, [0.3156, 0.18, 0.0362]), + (264.0, [0.358, 0.1947, 0.0295]), + (279.2, [0.3747, 0.1942, 0.0209]), + (294.4, [0.3638, 0.1798, 0.0119]), + (324.8, [0.3193, 0.14, -0.0043]), + (351.2, [0.2656, 0.1008, -0.016]), + (376.8, [0.2021, 0.0623, -0.0239]), + (401.6, [0.1289, 0.0256, -0.0272]), + (424.0, [0.0528, -0.0064, -0.0267]), + (440.0, [-0.0086, -0.0295, -0.0245]), + (470.4, [-0.1347, -0.0718, -0.0166]), + (481.6, [-0.1776, -0.0858, -0.0128]), + (492.0, [-0.2059, -0.0905, -0.0088]), + (501.6, [-0.2192, -0.0914, -0.0059]), + (512.8, [-0.2172, -0.087, -0.0027]), + (525.6, [-0.1948, -0.0745, -0.0007]), + (538.4, [-0.1566, -0.0576, -0.0001]), + (559.2, [-0.0811, -0.0294, -0.0023]), + (570.4, [-0.0421, -0.0173, -0.0046]), + (578.4, [-0.0172, -0.0105, -0.0064]), + (588.0, [0.0076, -0.0044, -0.0078]), + (592.0, [0.0147, -0.003, -0.0081]), + (596.8, [0.02, -0.0022, -0.0078]), + (603.2, [0.0198, -0.0035, -0.0067]), + (609.6, [0.013, -0.0067, -0.005]), + (620.8, [-0.0083, -0.0126, -0.0015]), + (628.0, [-0.0235, -0.0148, 0.0005]), + (631.2, [-0.0286, -0.0135, 0.0013]), + (635.2, [-0.0362, -0.0159, 0.0009]), + (642.4, [-0.0414, -0.0164, -0.0004]), + (649.6, [-0.0384, -0.014, -0.0027]), + (655.2, [-0.0313, -0.0107, -0.0047]), + (663.2, [-0.0137, -0.0026, -0.0063]), + (672.0, [0.0139, 0.0103, -0.0049]), + (676.8, [0.0262, 0.0135, -0.0054]), + (680.8, [0.0328, 0.0137, -0.0059]), + (687.2, [0.0342, 0.0116, -0.0059]), + (693.6, [0.0229, 0.0042, -0.0068]), + (700.8, [0.0038, -0.007, -0.0094]), + (703.2, [0.0015, -0.0056, -0.0071]), + (707.2, [0.0059, 0.0041, 0.0024]), + (708.8, [0.0042, 0.0049, 0.0047]), + (710.4, [-0.0029, 0.0021, 0.0052]), + (711.2, [-0.0096, -0.0009, 0.0046]), + (714.4, [-0.0476, -0.0165, -0.0008]), + (715.2, [-0.0548, -0.0198, -0.0027]), + (716.0, [-0.0588, -0.022, -0.0042]), + (716.8, [-0.0589, -0.0224, -0.0053]), + (717.6, [-0.0547, -0.0208, -0.0059]), + (718.4, [-0.0465, -0.0173, -0.006]), + (720.0, [-0.0251, -0.0086, -0.0053]), + (720.8, [-0.0174, -0.0064, -0.0049]), + (721.6, [-0.0138, -0.0065, -0.0049]), + (722.4, [-0.0141, -0.0082, -0.0053]), + (724.8, [-0.0228, -0.0119, -0.006]), + (725.6, [-0.0218, -0.0108, -0.0057]), + (726.4, [-0.0168, -0.0087, -0.0053]), + (728.0, [0.0002, -0.0036, -0.0052]), + (728.8, [0.006, -0.0018, -0.0054]), + (729.6, [0.0073, -0.001, -0.0054]), + (732.0, [-0.0002, -0.0011, -0.0046]), + (732.8, [-0.0008, -0.0014, -0.0043]), + (734.4, [-0.0013, -0.0031, -0.0045]), + (738.4, [-0.0085, -0.0068, -0.005]), + (741.6, [-0.0073, -0.0081, -0.0066]), + (744.8, [-0.0094, -0.0094, -0.0082]), + (747.2, [-0.0059, -0.0063, -0.0078]), + (748.8, [-0.0055, -0.0019, -0.0052]), + (751.2, [-0.0006, 0.0068, 0.0001]), + (752.0, [0.0001, 0.0075, 0.001]), + (752.8, [-0.0014, 0.0047, -0.0004]), + (754.4, [-0.0061, -0.0059, -0.007]), + (755.2, [-0.0082, -0.0127, -0.0115]), + (756.8, [-0.0153, -0.0309, -0.0245]), + (759.2, [-0.0253, -0.0517, -0.0386]), + (761.6, [-0.0332, -0.0664, -0.0472]), + (763.2, [-0.0358, -0.0691, -0.0505]), + (764.8, [-0.034, -0.0635, -0.0481]), + (766.4, [-0.0281, -0.0496, -0.0423]), + (768.8, [-0.0143, -0.0187, -0.0281]), + (772.0, [0.0096, 0.0331, -0.0016]), + (773.6, [0.0178, 0.0521, 0.0085]), + (775.2, [0.0225, 0.0645, 0.0186]), + (776.8, [0.0227, 0.0677, 0.0273]), + (778.4, [0.02, 0.0606, 0.0298]), + (780.0, [0.012, 0.0424, 0.0282]), + (780.8, [0.0062, 0.0298, 0.025]), + (783.2, [-0.0156, -0.0191, 0.0041]), + (784.8, [-0.0304, -0.0524, -0.0167]), + (785.6, [-0.0345, -0.062, -0.0234]), + (786.4, [-0.0354, -0.0647, -0.026]), + (789.6, [-0.0285, -0.0485, -0.019]), + (793.6, [-0.0216, -0.0372, -0.015]), + (796.0, [-0.0189, -0.0346, -0.0131]), + (800.8, [-0.019, -0.036, -0.0109]), + (804.0, [-0.0157, -0.0313, -0.0085]), + (806.4, [-0.0101, -0.0242, -0.0047]), + (808.8, [-0.0113, -0.024, -0.0063]), + (809.6, [-0.0091, -0.0201, -0.0059]), + (811.2, [-0.0006, -0.004, -0.0014]), + (812.0, [0.0027, 0.0008, 0.0006]), + (814.4, [-0.0007, -0.0027, -0.0011]), + (815.2, [0.0009, 0.0001, -0.0001]), + (816.8, [0.0018, 0.0022, 0.001]), + (819.2, [0.0006, -0.0008, -0.0008]), + (821.6, [0.0024, 0.0027, 0.0012]), + (822.4, [0.0004, -0.0004, -0.0003]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0231, 0.0139, 0.0076]), + (0.79943, [0.0243, 0.0172, 0.0098]), + (1.5989, [0.0248, 0.0166, 0.0077]), + (2.3983, [0.0249, 0.0167, 0.0094]), + (3.1977, [0.0244, 0.0153, 0.0066]), + (5.596, [0.0253, 0.0171, 0.0099]), + (7.9943, [0.0237, 0.0156, 0.0076]), + (28.78, [0.0257, 0.0171, 0.0099]), + (120.71, [0.027, 0.0163, 0.008]), + (155.89, [0.0253, 0.0153, 0.0079]), + (194.26, [0.0208, 0.0132, 0.008]), + (276.6, [0.0032, 0.0049, 0.0085]), + (312.58, [-0.0004, 0.0034, 0.0088]), + (405.31, [0.0015, 0.0052, 0.0096]), + (556.41, [0.0095, 0.008, 0.0092]), + (656.33, [0.0049, 0.0063, 0.0093]), + (713.09, [0.0055, 0.0065, 0.0092]), + (753.07, [0.0035, 0.0056, 0.0093]), + (773.05, [0.0047, 0.0063, 0.0094]), + (783.44, [0.0028, 0.0044, 0.0082]), + (793.84, [0.0095, 0.0087, 0.0102]), + (811.42, [0.0114, 0.0093, 0.0099]), + (817.82, [0.0139, 0.0111, 0.0105]), + (825.81, [0.0188, 0.0161, 0.0147]), + (830.61, [0.0187, 0.0149, 0.0132]), + (831.41, [0.0189, 0.0166, 0.0142]), + (833.01, [0.0231, 0.0289, 0.0222]), + (834.61, [0.0301, 0.048, 0.0348]), + (836.21, [0.0386, 0.0696, 0.0512]), + (838.61, [0.0502, 0.0961, 0.0736]), + (840.2, [0.0544, 0.1054, 0.0843]), + (841.8, [0.0567, 0.1085, 0.0906]), + (843.4, [0.0553, 0.1023, 0.0905]), + (845.0, [0.05, 0.0862, 0.0832]), + (847.4, [0.0323, 0.0437, 0.0623]), + (849.0, [0.018, 0.0106, 0.0433]), + (851.4, [-0.0049, -0.0441, 0.0089]), + (852.2, [-0.0108, -0.0591, -0.0019]), + (853.79, [-0.0207, -0.0835, -0.0214]), + (854.59, [-0.0235, -0.0917, -0.0301]), + (856.19, [-0.0253, -0.0995, -0.0448]), + (857.79, [-0.0223, -0.0912, -0.0505]), + (859.39, [-0.014, -0.0689, -0.0487]), + (860.19, [-0.0067, -0.0522, -0.044]), + (861.79, [0.0143, -0.0065, -0.0239]), + (863.39, [0.0376, 0.0451, 0.0062]), + (864.19, [0.0473, 0.0684, 0.0213]), + (864.99, [0.0542, 0.0858, 0.0324]), + (865.79, [0.0572, 0.0944, 0.0372]), + (869.78, [0.0594, 0.0997, 0.0392]), + (872.18, [0.0566, 0.0969, 0.0381]), + (876.98, [0.0443, 0.0784, 0.0278]), + (883.37, [0.0324, 0.0633, 0.0165]), + (886.57, [0.0236, 0.0506, 0.0096]), + (888.17, [0.0237, 0.0484, 0.0105]), + (888.97, [0.0235, 0.0448, 0.0113]), + (889.77, [0.0191, 0.0354, 0.0106]), + (891.37, [0.0052, 0.0095, 0.0044]), + (892.97, [-0.004, -0.0046, -0.0012]), + (894.57, [-0.0029, -0.0027, -0.0019]), + (895.37, [0.0003, 0.0013, -0.0001]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.0463, 0.1169, 0.01]), + (0.79965, [0.0545, 0.1287, 0.0135]), + (2.399, [0.0497, 0.1221, 0.0118]), + (6.3972, [0.0367, 0.1004, 0.0084]), + (12.794, [0.0205, 0.0603, 0.0031]), + (16.793, [0.0139, 0.0372, 0.0019]), + (20.791, [0.0116, 0.0212, 0.0008]), + (27.188, [0.0114, 0.009, -0.0005]), + (33.585, [0.0111, 0.0053, -0.0007]), + (78.366, [0.0114, 0.0061, -0.0001]), + (79.965, [0.0037, 0.002, -0.0001]), + (80.765, [0.003, 0.0019, -0.0001]), + (158.33, [0.0046, 0.0022, -0.0004]), + (159.93, [-0.0036, -0.0019, -0.0004]), + (472.6, [-0.0033, -0.0019, -0.0005]), + (579.75, [-0.0062, -0.0038, -0.001]), + (617.33, [-0.0019, -0.0016, -0.0011]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [0.0014, 0.0172, 0.0169]), + (2.3966, [-0.0024, 0.0133, 0.0137]), + (3.1955, [-0.0011, 0.0139, 0.014]), + (8.7876, [-0.0022, 0.0136, 0.0139]), + (60.714, [0.0002, 0.0153, 0.0152]), + (62.312, [0.0001, 0.0145, 0.0144]), + (63.91, [0.0001, 0.0013, 0.0011]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [0.001, 0.1936, 0.028]), + (0.79862, [0.0017, 0.1937, 0.0284]), + (2.3959, [-0.0035, 0.1821, 0.0277]), + (3.9931, [-0.0051, 0.1697, 0.0246]), + (10.382, [-0.0018, 0.0896, 0.009]), + (13.577, [-0.0003, 0.0603, 0.0048]), + (19.167, [0.0006, 0.0289, 0.0021]), + (24.757, [0.0003, 0.0078, -0.0004]), + (28.75, [0.0006, 0.0013, -0.0012]), + (32.743, [0.0008, -0.0003, -0.0012]), + (71.876, [0.0019, 0.0004, -0.0004]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0006, 0.0111, 0.0038]), + (23.962, [-0.0016, 0.0121, 0.0041]), + (25.559, [-0.0015, 0.0075, 0.0039]), + (27.955, [-0.0013, 0.005, 0.0035]), + (29.553, [-0.001, 0.0012, 0.0001]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [-0.002, 0.0136, 0.0089]), + (1.5824, [-0.001, 0.0148, 0.0083]), + (4.7472, [-0.0022, 0.0132, 0.0061]), + (14.242, [-0.0024, 0.0132, 0.0062]), + (17.406, [-0.0019, 0.0202, 0.0103]), + (18.989, [-0.0031, 0.0208, 0.0098]), + (19.78, [-0.0023, 0.0193, 0.0099]), + (20.571, [-0.002, 0.0135, 0.0066]), + (21.362, [-0.0017, 0.0036, 0.0013]), + (22.154, [-0.002, -0.0011, -0.0006]), + (23.736, [-0.0017, -0.0016, -0.001]), + ], + }, + Radial { t: 0.39897, perp: 0.0, knots: &[(0.0, [0.0035, 0.0017, 0.0004]), (4.6745, [0.0009, 0.0006, -0.0003]), (10.907, [0.0017, 0.0009, 0.0])] }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.2423, 0.1995, 0.1016]), + (0.79212, [0.2299, 0.1921, 0.0981]), + (7.9212, [0.2245, 0.1853, 0.0915]), + (8.7134, [0.2286, 0.1893, 0.0929]), + (9.5055, [0.1373, 0.1145, 0.0555]), + (10.298, [0.0134, 0.0112, 0.0054]), + (11.09, [-0.0006, -0.0007, -0.0006]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.0183, 0.0208, 0.0024]), + (0.79613, [0.0202, 0.0221, 0.0036]), + (42.195, [0.0183, 0.0235, 0.0047]), + (43.787, [0.0208, 0.025, 0.0062]), + (45.379, [0.0179, 0.0236, 0.0048]), + (50.952, [0.0145, 0.0222, 0.0033]), + (63.69, [0.0145, 0.0222, 0.004]), + (64.486, [0.0134, 0.0208, 0.0037]), + (66.078, [0.0044, 0.0063, 0.0004]), + (66.875, [0.0017, 0.0014, -0.0003]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.2163, 0.2893, 0.3745]), + (0.79168, [0.1675, 0.2473, 0.3422]), + (1.5834, [0.1323, 0.2065, 0.3079]), + (2.375, [0.0887, 0.1635, 0.2702]), + (3.1667, [0.0651, 0.1419, 0.2522]), + (3.9584, [0.0613, 0.1373, 0.2487]), + (4.7501, [0.0613, 0.1377, 0.249]), + (6.3335, [0.0552, 0.1244, 0.2422]), + (7.9168, [0.0188, 0.044, 0.198]), + (8.7085, [0.0159, 0.0368, 0.179]), + (11.875, [0.007, 0.0165, 0.0766]), + (12.667, [0.0042, 0.0097, 0.0513]), + (14.25, [0.0012, 0.0025, 0.0028]), + (15.834, [0.0007, 0.0015, 0.0011]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.1553, 0.1199, 0.0165]), + (0.78746, [0.1544, 0.1209, 0.015]), + (1.5749, [0.1149, 0.0925, 0.0088]), + (2.3624, [0.083, 0.0673, 0.0043]), + (3.1498, [0.0574, 0.0446, 0.0023]), + (3.9373, [0.0362, 0.0282, 0.0002]), + (5.5122, [0.0179, 0.0125, 0.0002]), + (7.0871, [0.0082, 0.0061, 0.0]), + (8.662, [0.0029, 0.0013, -0.0001]), + (11.812, [0.0019, 0.001, 0.0]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0041, 0.0417, 0.0555]), + (1.5935, [0.0041, 0.0441, 0.0562]), + (3.9837, [0.0045, 0.0429, 0.0555]), + (4.7804, [0.0007, 0.0183, 0.0246]), + (5.5771, [0.0, -0.0001, -0.0003]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0209, -0.1582, -0.0133]), + (0.78812, [0.023, -0.1589, -0.013]), + (1.5762, [0.0218, -0.1593, -0.0137]), + (4.7287, [0.0178, -0.1469, -0.0118]), + (8.6693, [0.0103, -0.1193, -0.0095]), + (14.186, [0.0035, -0.0739, -0.0057]), + (18.915, [0.0012, -0.0411, -0.003]), + (22.067, [0.0011, -0.0231, -0.001]), + (26.008, [0.0014, -0.0083, 0.0003]), + (29.949, [0.0013, -0.0015, 0.0011]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3088, 0.3142, 0.3163]), + (0.79199, [0.2437, 0.259, 0.2886]), + (1.584, [0.2259, 0.2395, 0.2578]), + (2.376, [0.1773, 0.1859, 0.1995]), + (3.168, [0.1325, 0.1405, 0.1452]), + (3.96, [0.0885, 0.0947, 0.0977]), + (4.752, [0.0529, 0.0571, 0.0569]), + (6.336, [0.0157, 0.0166, 0.0113]), + (7.1279, [0.0072, 0.008, 0.0021]), + (15.048, [0.0126, 0.0125, 0.0044]), + (30.096, [0.0125, 0.0128, 0.0046]), + (30.888, [0.0078, 0.0081, 0.0031]), + (31.68, [0.0, 0.0003, -0.0001]), + ], + }, + Radial { t: 2.5968, perp: 0.0, knots: &[(0.0, [-0.0023, -0.0007, 0.0003]), (0.79014, [0.0006, -0.0003, 0.0])] }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.0043, 0.0034, 0.0032]), + (16.575, [0.0039, 0.0029, 0.0032]), + (18.154, [0.0038, 0.0076, 0.0079]), + (20.522, [0.0038, 0.0068, 0.007]), + (22.1, [0.0039, 0.0115, 0.0118]), + (23.679, [0.004, 0.011, 0.0113]), + (25.257, [0.0041, 0.0156, 0.0158]), + (26.836, [0.0032, 0.0146, 0.0148]), + (28.415, [0.0033, 0.0191, 0.0193]), + (29.993, [0.0039, 0.0197, 0.0199]), + (30.783, [0.0033, 0.0155, 0.0157]), + (31.572, [-0.0003, 0.0027, 0.0028]), + (32.361, [-0.0001, -0.0009, -0.0007]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[(0.0, [0.0, 0.0, 0.0]), (2.3556, [0.0, 0.0, 0.0]), (3.1407, [-0.0028, -0.0015, 0.0]), (19.63, [-0.0022, -0.0012, 0.0])], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0085, 0.0843, 0.0959]), + (0.7998, [0.0025, 0.0488, 0.0552]), + (1.5996, [-0.0001, 0.0246, 0.0274]), + (2.3994, [-0.0001, -0.0017, -0.0018]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0077, -0.0271, -0.0028]), + (3.1726, [-0.0075, -0.0259, -0.0027]), + (11.104, [-0.0029, -0.0093, -0.0013]), + (12.691, [-0.0024, -0.0061, 0.0028]), + (15.07, [-0.0015, -0.0015, 0.0027]), + (15.863, [-0.0012, -0.0002, 0.0002]), + (17.45, [-0.0009, -0.0083, -0.0006]), + (24.588, [-0.0006, -0.0015, 0.0001]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.1823, 0.0785, 0.0089]), + (0.77016, [0.1487, 0.0606, 0.0018]), + (1.5403, [0.1057, 0.0279, -0.0088]), + (2.3105, [0.0788, 0.0061, -0.0129]), + (3.0807, [0.0551, -0.0113, -0.0142]), + (3.8508, [0.0344, -0.024, -0.0157]), + (5.3911, [0.0163, -0.0293, -0.0122]), + (6.1613, [0.01, -0.0266, -0.0103]), + (6.9315, [0.0068, -0.0235, -0.0084]), + (8.4718, [0.0016, -0.0183, -0.0052]), + (9.242, [0.0, -0.0132, -0.0038]), + (10.782, [0.0, -0.0056, -0.0019]), + (12.323, [0.0, -0.0015, -0.0007]), + ], + }, + Radial { t: 1.6547, perp: 0.0, knots: &[(0.0, [-0.0001, 0.0043, 0.0012]), (3.1458, [0.0, 0.0014, 0.0006])] }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0281, 0.0172, 0.0198]), + (11.175, [0.0279, 0.0182, 0.0206]), + (39.912, [0.0278, 0.0157, 0.0187]), + (46.298, [0.0295, 0.0163, 0.0194]), + (47.096, [0.0151, 0.0079, 0.0111]), + (47.895, [-0.0028, -0.0025, 0.0006]), + (48.693, [-0.0053, -0.0035, -0.0003]), + (59.868, [-0.0034, -0.0031, 0.0]), + (62.263, [-0.0043, -0.0048, -0.0004]), + (67.052, [-0.0018, -0.0011, 0.0002]), + ], + }, +]; +const PRIME105_50_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_50_RAYS: &[Ray] = &[ + Ray { angle: 0.01, r0: 6.25, r1: 100.78, fade: 0.05, rgb: [0.0196, 0.0196, 0.0176] }, + Ray { angle: 0.29, r0: 6.25, r1: 100.78, fade: 0.05, rgb: [0.0196, 0.0196, 0.0176] }, + Ray { angle: 3.08, r0: 6.25, r1: 25.781, fade: 0.05, rgb: [0.0157, 0.0118, 0.0108] }, + Ray { angle: 45.93, r0: 14.844, r1: 151.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0098] }, + Ray { angle: 60.36, r0: 6.25, r1: 225.0, fade: 0.05, rgb: [0.0118, 0.0098, 0.0118] }, + Ray { angle: 74.62, r0: 40.625, r1: 156.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 88.96, r0: 6.25, r1: 214.84, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 96.05, r0: 6.25, r1: 171.88, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 96.59, r0: 6.25, r1: 164.84, fade: 0.05, rgb: [0.0216, 0.0196, 0.0196] }, + Ray { angle: 97.28, r0: 6.25, r1: 110.16, fade: 0.05, rgb: [0.0176, 0.0186, 0.0196] }, + Ray { angle: 97.83, r0: 6.25, r1: 165.62, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 103.18, r0: 61.719, r1: 176.56, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 104.31, r0: 78.906, r1: 264.84, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 118.66, r0: 6.25, r1: 196.09, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 147.11, r0: 12.5, r1: 224.22, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 161.45, r0: 6.25, r1: 235.16, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 168.49, r0: 6.25, r1: 147.66, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 168.76, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 183.12, r0: 6.25, r1: 47.656, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 225.7, r0: 49.219, r1: 151.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0098] }, + Ray { angle: 240.25, r0: 6.25, r1: 224.22, fade: 0.05, rgb: [0.0098, 0.0098, 0.0108] }, + Ray { angle: 254.51, r0: 21.094, r1: 156.25, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 268.94, r0: 96.875, r1: 195.31, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 269.71, r0: 6.25, r1: 103.12, fade: 0.05, rgb: [0.0255, 0.0412, 0.0176] }, + Ray { angle: 269.99, r0: 6.25, r1: 103.12, fade: 0.05, rgb: [0.0255, 0.0412, 0.0176] }, + Ray { angle: 270.41, r0: 6.25, r1: 110.94, fade: 0.05, rgb: [0.0196, 0.0294, 0.0098] }, + Ray { angle: 275.17, r0: 6.25, r1: 164.06, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 275.45, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 275.93, r0: 6.25, r1: 167.19, fade: 0.05, rgb: [0.0216, 0.0216, 0.0196] }, + Ray { angle: 276.37, r0: 6.25, r1: 105.47, fade: 0.05, rgb: [0.0255, 0.0275, 0.0265] }, + Ray { angle: 283.25, r0: 56.25, r1: 177.34, fade: 0.05, rgb: [0.0078, 0.0059, 0.0059] }, + Ray { angle: 284.37, r0: 92.188, r1: 201.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 298.81, r0: 10.156, r1: 195.31, fade: 0.05, rgb: [0.0127, 0.0118, 0.0137] }, + Ray { angle: 327.28, r0: 11.719, r1: 223.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 341.56, r0: 6.25, r1: 235.94, fade: 0.05, rgb: [0.0235, 0.0235, 0.0216] }, + Ray { angle: 348.81, r0: 6.25, r1: 299.22, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, +]; +const PRIME105_75_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.4236, 0.6668, 0.9755]), + (0.8, [0.7098, 0.8339, 0.9616]), + (1.6, [0.6471, 0.7915, 0.9629]), + (3.2, [0.6572, 0.8017, 0.9414]), + (9.6, [0.6265, 0.7842, 0.8838]), + (15.2, [0.5821, 0.7373, 0.819]), + (21.6, [0.4875, 0.6473, 0.7279]), + (27.2, [0.4003, 0.5516, 0.641]), + (35.2, [0.3058, 0.4456, 0.5471]), + (40.8, [0.2566, 0.3827, 0.4845]), + (48.8, [0.2006, 0.2936, 0.3867]), + (55.2, [0.1618, 0.2338, 0.2961]), + (57.6, [0.1509, 0.2213, 0.2613]), + (61.6, [0.151, 0.2007, 0.2216]), + (67.2, [0.1602, 0.1723, 0.1552]), + (72.0, [0.1715, 0.1672, 0.1049]), + (75.2, [0.1816, 0.1623, 0.0769]), + (76.0, [0.1905, 0.1625, 0.07]), + (77.6, [0.2261, 0.1617, 0.0594]), + (78.4, [0.2274, 0.1599, 0.0545]), + (80.0, [0.2029, 0.1544, 0.0441]), + (84.8, [0.2204, 0.152, 0.0135]), + (88.8, [0.2372, 0.1523, -0.0155]), + (92.0, [0.2557, 0.156, -0.0305]), + (96.0, [0.2812, 0.1598, -0.0445]), + (104.0, [0.3285, 0.17, -0.0584]), + (108.8, [0.3564, 0.1764, -0.0739]), + (112.0, [0.3732, 0.1827, -0.08]), + (124.8, [0.4333, 0.1986, -0.0917]), + (130.4, [0.4466, 0.2037, -0.0923]), + (131.2, [0.4426, 0.1988, -0.0974]), + (136.8, [0.44, 0.193, -0.105]), + (148.8, [0.418, 0.1749, -0.1097]), + (161.6, [0.3671, 0.1422, -0.114]), + (176.8, [0.268, 0.0811, -0.1244]), + (184.8, [0.2042, 0.0442, -0.1228]), + (193.6, [0.1255, 0.0014, -0.1221]), + (198.4, [0.0778, -0.0227, -0.1227]), + (206.4, [-0.0024, -0.0687, -0.12]), + (229.6, [-0.2545, -0.1987, -0.108]), + (245.6, [-0.4094, -0.2791, -0.0998]), + (259.2, [-0.4997, -0.3252, -0.0908]), + (265.6, [-0.5209, -0.3359, -0.086]), + (272.8, [-0.5298, -0.3397, -0.079]), + (280.8, [-0.5259, -0.3371, -0.0726]), + (288.8, [-0.5065, -0.3262, -0.0658]), + (300.8, [-0.466, -0.3019, -0.0526]), + (317.6, [-0.4116, -0.2686, -0.039]), + (347.2, [-0.3065, -0.1963, -0.0122]), + (367.2, [-0.2364, -0.1469, 0.0016]), + (386.4, [-0.1683, -0.0999, 0.0109]), + (414.4, [-0.0638, -0.0307, 0.0175]), + (430.4, [0.0006, 0.0105, 0.0187]), + (449.6, [0.0821, 0.0617, 0.0184]), + (481.6, [0.2266, 0.1462, 0.0135]), + (490.4, [0.2535, 0.1572, 0.0104]), + (499.2, [0.268, 0.1624, 0.0075]), + (506.4, [0.2703, 0.1624, 0.0049]), + (514.4, [0.2633, 0.1591, 0.0031]), + (522.4, [0.2468, 0.1493, 0.002]), + (529.6, [0.2246, 0.1363, 0.0014]), + (546.4, [0.1508, 0.0942, 0.0017]), + (574.4, [0.0016, 0.0066, 0.0032]), + (580.8, [-0.0302, -0.0115, 0.0029]), + (584.8, [-0.0472, -0.0211, 0.0025]), + (588.8, [-0.0606, -0.0286, 0.0017]), + (595.2, [-0.0725, -0.036, -0.001]), + (599.2, [-0.0721, -0.0365, -0.0036]), + (604.0, [-0.064, -0.0327, -0.007]), + (609.6, [-0.0481, -0.024, -0.0105]), + (616.0, [-0.0257, -0.0112, -0.0138]), + (624.8, [0.0028, 0.0057, -0.0155]), + (632.8, [0.0181, 0.0128, -0.015]), + (636.8, [0.0223, 0.0147, -0.0137]), + (641.6, [0.0219, 0.0125, -0.0124]), + (649.6, [0.0135, 0.0023, -0.0102]), + (655.2, [0.0015, -0.0079, -0.0094]), + (665.6, [-0.029, -0.0309, -0.0117]), + (670.4, [-0.0404, -0.0396, -0.0138]), + (674.4, [-0.0445, -0.0417, -0.0136]), + (678.4, [-0.0423, -0.0389, -0.0128]), + (686.4, [-0.031, -0.0268, -0.0113]), + (689.6, [-0.0243, -0.0187, -0.01]), + (692.8, [-0.0136, -0.0076, -0.0075]), + (696.0, [-0.0003, 0.0031, -0.0044]), + (699.2, [0.0113, 0.0094, -0.0027]), + (700.8, [0.0131, 0.0088, -0.004]), + (702.4, [0.012, 0.0056, -0.0073]), + (706.4, [-0.0024, -0.0092, -0.0194]), + (708.0, [-0.0052, -0.011, -0.0211]), + (708.8, [-0.0044, -0.0099, -0.0208]), + (709.6, [-0.0008, -0.0064, -0.0194]), + (710.4, [0.0058, -0.0007, -0.0171]), + (713.6, [0.0448, 0.0306, -0.0053]), + (714.4, [0.051, 0.0336, -0.0038]), + (715.2, [0.0542, 0.0325, -0.0033]), + (716.0, [0.054, 0.0281, -0.0037]), + (716.8, [0.0494, 0.0211, -0.0049]), + (717.6, [0.0404, 0.0131, -0.0061]), + (719.2, [0.0137, 0.0001, -0.0071]), + (720.0, [0.002, -0.0033, -0.0068]), + (720.8, [-0.0047, -0.004, -0.0063]), + (721.6, [-0.0063, -0.0034, -0.006]), + (722.4, [-0.0035, -0.0019, -0.0061]), + (724.0, [0.0069, 0.001, -0.0081]), + (724.8, [0.0087, 0.0007, -0.0096]), + (725.6, [0.0054, -0.0016, -0.0108]), + (728.0, [-0.0185, -0.0139, -0.0122]), + (728.8, [-0.0221, -0.0165, -0.012]), + (730.4, [-0.0183, -0.0167, -0.0117]), + (732.0, [-0.0105, -0.0129, -0.0117]), + (732.8, [-0.0092, -0.0112, -0.0115]), + (736.0, [-0.0131, -0.0083, -0.0083]), + (738.4, [-0.0053, -0.0059, -0.0101]), + (739.2, [-0.0059, -0.0058, -0.0103]), + (741.6, [-0.0135, -0.0103, -0.0097]), + (744.0, [-0.0105, -0.0128, -0.0125]), + (746.4, [-0.0197, -0.0202, -0.0156]), + (748.8, [-0.0171, -0.0292, -0.0214]), + (751.2, [-0.0235, -0.0364, -0.0244]), + (755.2, [-0.0255, -0.0466, -0.0316]), + (756.8, [-0.0293, -0.0521, -0.0374]), + (758.4, [-0.029, -0.0545, -0.0437]), + (759.2, [-0.0287, -0.054, -0.047]), + (760.8, [-0.0261, -0.0476, -0.0508]), + (763.2, [-0.022, -0.0398, -0.0522]), + (765.6, [-0.0176, -0.0276, -0.0493]), + (769.6, [-0.0068, -0.0067, -0.0307]), + (771.2, [-0.0001, 0.0042, -0.0199]), + (772.8, [0.004, 0.0133, -0.0081]), + (774.4, [0.0082, 0.0223, 0.0008]), + (776.0, [0.0105, 0.0281, 0.0085]), + (777.6, [0.0091, 0.0288, 0.0147]), + (779.2, [0.0067, 0.0237, 0.0144]), + (780.0, [0.0048, 0.019, 0.0126]), + (780.8, [0.002, 0.0124, 0.008]), + (784.0, [-0.0161, -0.0286, -0.0184]), + (785.6, [-0.0212, -0.0434, -0.0271]), + (788.0, [-0.0279, -0.0616, -0.0355]), + (789.6, [-0.0304, -0.0702, -0.0388]), + (792.8, [-0.0342, -0.0795, -0.0396]), + (794.4, [-0.0318, -0.0758, -0.0378]), + (799.2, [-0.0183, -0.0499, -0.025]), + (800.8, [-0.0166, -0.0434, -0.0208]), + (803.2, [-0.0067, -0.0318, -0.0106]), + (804.0, [-0.0062, -0.0318, -0.0089]), + (805.6, [-0.009, -0.0347, -0.0073]), + (808.0, [-0.0066, -0.0307, -0.0072]), + (809.6, [-0.0088, -0.0322, -0.0127]), + (810.4, [-0.0065, -0.0265, -0.0135]), + (811.2, [-0.0043, -0.0167, -0.0097]), + (812.0, [0.0, -0.0019, -0.0026]), + (812.8, [0.0061, 0.011, 0.0019]), + (813.6, [0.0084, 0.0158, 0.0052]), + (814.4, [0.0072, 0.0094, 0.0045]), + (815.2, [0.0016, -0.002, -0.0003]), + (816.0, [0.0, -0.0051, -0.0027]), + (816.8, [0.002, -0.0005, -0.0015]), + (817.6, [0.0008, 0.0017, -0.0002]), + (818.4, [0.0029, 0.0041, 0.0008]), + (820.8, [0.0002, -0.0007, -0.0017]), + (821.6, [0.0024, 0.0029, 0.001]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.1961, 0.1121, 0.0052]), + (0.8, [0.3227, 0.1833, 0.0097]), + (1.6, [0.302, 0.1696, 0.0099]), + (2.4, [0.2898, 0.1649, 0.0098]), + (3.2, [0.288, 0.1612, 0.0101]), + (6.4, [0.2765, 0.1524, 0.0022]), + (10.4, [0.2399, 0.1215, -0.0085]), + (16.8, [0.1962, 0.0881, -0.0207]), + (21.6, [0.1736, 0.0706, -0.0273]), + (29.6, [0.1411, 0.0447, -0.0333]), + (36.0, [0.1152, 0.0272, -0.0357]), + (40.0, [0.1006, 0.018, -0.0328]), + (42.4, [0.0915, 0.0136, -0.0299]), + (47.2, [0.0744, 0.0076, -0.0195]), + (50.4, [0.0643, 0.0055, -0.0103]), + (55.2, [0.0502, 0.0031, 0.0058]), + (60.8, [0.0266, -0.0014, 0.0278]), + (67.2, [-0.0079, -0.0096, 0.0518]), + (74.4, [-0.0504, -0.0249, 0.0751]), + (77.6, [-0.0657, -0.0317, 0.0838]), + (80.0, [-0.085, -0.0359, 0.0903]), + (92.0, [-0.1603, -0.0626, 0.1225]), + (108.8, [-0.2795, -0.1043, 0.1473]), + (114.4, [-0.3155, -0.1178, 0.1518]), + (123.2, [-0.3637, -0.1349, 0.1521]), + (130.4, [-0.3924, -0.149, 0.1471]), + (132.0, [-0.39, -0.1451, 0.1518]), + (136.8, [-0.3904, -0.1426, 0.1551]), + (146.4, [-0.3826, -0.1357, 0.1531]), + (153.6, [-0.3672, -0.1266, 0.151]), + (161.6, [-0.3391, -0.1108, 0.1474]), + (176.0, [-0.2545, -0.0608, 0.1483]), + (188.8, [-0.1563, -0.0084, 0.1403]), + (200.8, [-0.0459, 0.0484, 0.1323]), + (207.2, [0.0183, 0.0823, 0.1274]), + (232.0, [0.2831, 0.2158, 0.1067]), + (245.6, [0.4138, 0.2818, 0.0957]), + (252.8, [0.4684, 0.3089, 0.0895]), + (259.2, [0.5053, 0.3271, 0.0835]), + (265.6, [0.5275, 0.3374, 0.0768]), + (272.0, [0.5366, 0.341, 0.0697]), + (280.0, [0.5336, 0.338, 0.0611]), + (288.8, [0.5131, 0.3256, 0.0519]), + (305.6, [0.454, 0.2894, 0.032]), + (338.4, [0.3381, 0.2123, 0.0004]), + (360.8, [0.2578, 0.156, -0.017]), + (381.6, [0.1834, 0.1041, -0.0287]), + (410.4, [0.0756, 0.0321, -0.037]), + (428.8, [0.0015, -0.0154, -0.0387]), + (450.4, [-0.0911, -0.0737, -0.0384]), + (479.2, [-0.2242, -0.1524, -0.0342]), + (489.6, [-0.2608, -0.1692, -0.0308]), + (500.0, [-0.2799, -0.1759, -0.0273]), + (507.2, [-0.2824, -0.1761, -0.0247]), + (515.2, [-0.2754, -0.1727, -0.0227]), + (523.2, [-0.2589, -0.1628, -0.0216]), + (530.4, [-0.2364, -0.1498, -0.0209]), + (547.2, [-0.1617, -0.1071, -0.0212]), + (576.0, [-0.0058, -0.0156, -0.0227]), + (581.6, [0.0222, 0.0004, -0.0224]), + (585.6, [0.0396, 0.0101, -0.022]), + (589.6, [0.0534, 0.0179, -0.0212]), + (596.0, [0.066, 0.0256, -0.0186]), + (600.0, [0.0661, 0.0265, -0.0159]), + (604.8, [0.0583, 0.0228, -0.0125]), + (610.4, [0.0426, 0.0142, -0.009]), + (616.8, [0.02, 0.0014, -0.0056]), + (621.6, [0.0031, -0.0091, -0.0045]), + (626.4, [-0.0112, -0.017, -0.0037]), + (633.6, [-0.0247, -0.023, -0.004]), + (637.6, [-0.0294, -0.0253, -0.0054]), + (642.4, [-0.0292, -0.0232, -0.0067]), + (650.4, [-0.021, -0.0131, -0.0089]), + (656.8, [-0.007, -0.0012, -0.0098]), + (663.2, [0.0109, 0.0128, -0.0091]), + (666.4, [0.0218, 0.0205, -0.0075]), + (671.2, [0.0335, 0.0295, -0.0055]), + (675.2, [0.0382, 0.0322, -0.0054]), + (679.2, [0.0366, 0.0297, -0.0062]), + (687.2, [0.0251, 0.0176, -0.0077]), + (690.4, [0.0184, 0.0097, -0.009]), + (693.6, [0.0081, -0.0013, -0.0114]), + (696.8, [-0.005, -0.0122, -0.0145]), + (700.0, [-0.0172, -0.019, -0.0164]), + (701.6, [-0.0194, -0.0186, -0.0153]), + (703.2, [-0.0185, -0.0156, -0.0122]), + (707.2, [-0.005, -0.0011, 0.0001]), + (708.8, [-0.0022, 0.001, 0.0021]), + (709.6, [-0.0026, 0.0001, 0.0018]), + (710.4, [-0.0053, -0.0027, 0.0005]), + (711.2, [-0.0109, -0.0078, -0.0017]), + (712.0, [-0.0193, -0.0149, -0.0045]), + (714.4, [-0.0516, -0.041, -0.0142]), + (715.2, [-0.0601, -0.0468, -0.0165]), + (716.0, [-0.0657, -0.0487, -0.0177]), + (716.8, [-0.0681, -0.0465, -0.0178]), + (717.6, [-0.0667, -0.0412, -0.0171]), + (718.4, [-0.0606, -0.0337, -0.0158]), + (719.2, [-0.0503, -0.0258, -0.0146]), + (720.8, [-0.0231, -0.0141, -0.014]), + (721.6, [-0.0131, -0.0118, -0.0144]), + (722.4, [-0.0083, -0.0119, -0.0149]), + (723.2, [-0.0086, -0.0131, -0.0151]), + (725.6, [-0.0237, -0.0182, -0.0124]), + (726.4, [-0.0242, -0.0175, -0.0109]), + (727.2, [-0.0193, -0.0147, -0.0098]), + (728.8, [-0.0026, -0.0067, -0.0086]), + (729.6, [0.0034, -0.0034, -0.0087]), + (730.4, [0.0053, -0.0016, -0.0089]), + (732.0, [-0.0012, -0.003, -0.0089]), + (733.6, [-0.0091, -0.0075, -0.0089]), + (735.2, [-0.0102, -0.0116, -0.0103]), + (736.8, [-0.0076, -0.013, -0.0124]), + (737.6, [-0.0087, -0.0138, -0.0125]), + (740.0, [-0.0179, -0.0176, -0.0114]), + (742.4, [-0.0124, -0.0167, -0.0133]), + (743.2, [-0.0125, -0.0159, -0.0139]), + (744.8, [-0.0167, -0.0158, -0.0136]), + (745.6, [-0.0164, -0.0157, -0.013]), + (747.2, [-0.0101, -0.0127, -0.0119]), + (750.4, [-0.01, 0.0002, -0.0048]), + (752.0, [-0.0046, 0.0047, -0.0024]), + (752.8, [-0.0049, 0.0013, -0.005]), + (754.4, [-0.0126, -0.0122, -0.014]), + (755.2, [-0.0168, -0.0208, -0.0195]), + (756.8, [-0.0244, -0.0437, -0.0358]), + (759.2, [-0.034, -0.0685, -0.0532]), + (761.6, [-0.0401, -0.0858, -0.0632]), + (763.2, [-0.0415, -0.0904, -0.0657]), + (764.8, [-0.0382, -0.0824, -0.0623]), + (766.4, [-0.031, -0.0658, -0.0549]), + (768.8, [-0.0161, -0.0289, -0.0399]), + (772.0, [0.0107, 0.0322, -0.013]), + (772.8, [0.016, 0.0451, -0.0066]), + (774.4, [0.0242, 0.0646, 0.0042]), + (776.0, [0.0264, 0.0721, 0.015]), + (776.8, [0.026, 0.0725, 0.0199]), + (777.6, [0.0249, 0.0698, 0.0217]), + (778.4, [0.0225, 0.0632, 0.0213]), + (780.0, [0.0141, 0.0419, 0.0196]), + (780.8, [0.0081, 0.0274, 0.0166]), + (782.4, [-0.0072, -0.0105, 0.0025]), + (783.2, [-0.0147, -0.029, -0.0071]), + (784.8, [-0.0307, -0.0688, -0.0325]), + (785.6, [-0.036, -0.0809, -0.0405]), + (786.4, [-0.0376, -0.0849, -0.0436]), + (788.8, [-0.0302, -0.0701, -0.0371]), + (792.0, [-0.0253, -0.0579, -0.0314]), + (800.0, [-0.0206, -0.0524, -0.0252]), + (801.6, [-0.0185, -0.0479, -0.0227]), + (804.0, [-0.0112, -0.0381, -0.015]), + (807.2, [-0.0057, -0.0285, -0.0058]), + (808.0, [-0.0059, -0.0297, -0.0064]), + (809.6, [-0.0084, -0.0353, -0.0123]), + (810.4, [-0.0063, -0.028, -0.0109]), + (812.0, [0.0019, -0.0016, -0.0007]), + (812.8, [0.0024, -0.0016, -0.0011]), + (813.6, [0.0041, 0.0028, 0.0005]), + (814.4, [0.0004, -0.005, -0.0022]), + (816.0, [0.0, -0.0021, -0.002]), + (816.8, [0.0029, 0.004, 0.0007]), + (820.0, [0.0011, -0.0002, -0.001]), + (821.6, [0.0024, 0.004, 0.0017]), + (822.4, [0.0017, 0.0009, -0.0008]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0374, 0.0293, 0.017]), + (0.79943, [0.0427, 0.0249, 0.0174]), + (1.5989, [0.0394, 0.0257, 0.0156]), + (3.1977, [0.0391, 0.0231, 0.0132]), + (4.7966, [0.0396, 0.0245, 0.0138]), + (6.3955, [0.0381, 0.023, 0.0132]), + (8.7938, [0.0393, 0.0246, 0.0149]), + (10.393, [0.0386, 0.0236, 0.0122]), + (12.791, [0.0389, 0.0249, 0.0142]), + (16.788, [0.0389, 0.0249, 0.0139]), + (18.387, [0.0401, 0.0265, 0.0162]), + (27.181, [0.0403, 0.0259, 0.0146]), + (40.771, [0.0418, 0.0279, 0.017]), + (72.748, [0.0433, 0.0288, 0.0163]), + (87.938, [0.0465, 0.0311, 0.017]), + (88.737, [0.041, 0.0258, 0.012]), + (89.537, [0.0432, 0.0279, 0.014]), + (92.734, [0.0447, 0.0294, 0.0148]), + (116.72, [0.0484, 0.0307, 0.0151]), + (127.11, [0.0495, 0.0313, 0.0153]), + (127.91, [0.0463, 0.0282, 0.0123]), + (128.71, [0.0481, 0.03, 0.014]), + (134.3, [0.0487, 0.0307, 0.0147]), + (155.89, [0.0481, 0.0302, 0.015]), + (193.46, [0.0411, 0.0266, 0.0155]), + (274.21, [0.0093, 0.0107, 0.0164]), + (308.58, [0.0027, 0.0074, 0.017]), + (555.61, [0.016, 0.0158, 0.0184]), + (656.33, [0.0095, 0.012, 0.0182]), + (702.7, [0.0111, 0.0128, 0.0179]), + (749.07, [0.0095, 0.0114, 0.0178]), + (773.05, [0.0113, 0.0129, 0.0181]), + (783.44, [0.0087, 0.0101, 0.0164]), + (793.84, [0.0164, 0.0167, 0.0198]), + (809.03, [0.0206, 0.0205, 0.0195]), + (817.82, [0.0254, 0.0243, 0.0199]), + (826.61, [0.0322, 0.0328, 0.0253]), + (830.61, [0.0303, 0.0313, 0.0245]), + (831.41, [0.0308, 0.0335, 0.0257]), + (833.01, [0.0366, 0.0481, 0.035]), + (834.61, [0.0453, 0.0702, 0.0499]), + (836.21, [0.0554, 0.0959, 0.069]), + (838.61, [0.0674, 0.126, 0.095]), + (840.2, [0.0708, 0.1358, 0.1077]), + (841.8, [0.0706, 0.1381, 0.1147]), + (843.4, [0.0667, 0.1301, 0.1152]), + (845.0, [0.0583, 0.1108, 0.1085]), + (847.4, [0.0381, 0.0627, 0.0875]), + (849.0, [0.0227, 0.0267, 0.0687]), + (851.4, [-0.0062, -0.0338, 0.0335]), + (853.0, [-0.0205, -0.0662, 0.0101]), + (854.59, [-0.0297, -0.0882, -0.0107]), + (856.19, [-0.0325, -0.0973, -0.0279]), + (856.99, [-0.0315, -0.0955, -0.0333]), + (857.79, [-0.0281, -0.0886, -0.0357]), + (859.39, [-0.0173, -0.064, -0.0355]), + (860.19, [-0.0095, -0.0451, -0.0311]), + (860.99, [0.0008, -0.02, -0.0213]), + (861.79, [0.0115, 0.0063, -0.0078]), + (862.59, [0.0237, 0.0353, 0.009]), + (863.39, [0.0373, 0.0671, 0.029]), + (864.19, [0.049, 0.095, 0.047]), + (864.99, [0.0575, 0.1162, 0.0605]), + (865.79, [0.0608, 0.1262, 0.0671]), + (869.78, [0.0612, 0.1335, 0.0713]), + (873.78, [0.0632, 0.1383, 0.0674]), + (876.98, [0.0531, 0.1226, 0.058]), + (880.18, [0.0415, 0.1014, 0.0477]), + (880.98, [0.04, 0.0962, 0.0441]), + (882.57, [0.0294, 0.0802, 0.0327]), + (884.17, [0.0198, 0.0694, 0.0212]), + (885.77, [0.0187, 0.0665, 0.0135]), + (886.57, [0.0186, 0.0655, 0.012]), + (887.37, [0.0181, 0.0656, 0.0125]), + (888.97, [0.0186, 0.0681, 0.0189]), + (889.77, [0.0181, 0.0624, 0.0206]), + (890.57, [0.0143, 0.0485, 0.019]), + (891.37, [0.0075, 0.0277, 0.0125]), + (892.17, [0.0047, 0.0161, 0.0073]), + (892.97, [-0.0025, -0.0035, -0.0009]), + (894.57, [-0.0053, -0.0099, -0.0048]), + (895.37, [-0.0021, -0.0015, -0.0023]), + (896.16, [0.0006, 0.0025, -0.0003]), + (896.96, [0.0008, 0.0031, 0.0008]), + (898.56, [0.0004, -0.0007, -0.0004]), + (900.96, [-0.0005, -0.0026, -0.0018]), + (901.76, [0.002, 0.0015, -0.0005]), + (902.56, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.0876, 0.2114, 0.0268]), + (0.79965, [0.0759, 0.2127, 0.0253]), + (1.5993, [0.0793, 0.2087, 0.0248]), + (4.7979, [0.0681, 0.1868, 0.0215]), + (11.195, [0.0443, 0.1225, 0.0114]), + (15.993, [0.0323, 0.0758, 0.0067]), + (18.392, [0.0284, 0.0563, 0.005]), + (20.791, [0.0267, 0.0405, 0.0036]), + (23.19, [0.0268, 0.0307, 0.0026]), + (26.389, [0.0268, 0.0213, 0.0017]), + (29.587, [0.0271, 0.016, 0.0014]), + (33.585, [0.0271, 0.0132, 0.0011]), + (78.366, [0.0281, 0.0142, 0.0023]), + (79.166, [0.0231, 0.0116, 0.0022]), + (79.965, [0.0136, 0.0068, 0.0023]), + (80.765, [0.0117, 0.0059, 0.0022]), + (158.33, [0.0123, 0.0062, 0.0017]), + (159.93, [-0.0031, -0.0017, 0.0017]), + (160.73, [-0.0034, -0.0018, 0.0017]), + (482.19, [-0.0037, -0.0022, 0.0012]), + (545.36, [-0.0065, -0.0035, 0.0011]), + (584.55, [-0.0122, -0.0067, -0.0003]), + (598.94, [-0.0109, -0.0073, -0.0016]), + (610.94, [-0.0052, -0.0031, -0.0014]), + (611.74, [-0.004, -0.0013, -0.0002]), + (613.33, [-0.0041, -0.0028, -0.0007]), + (614.93, [-0.0009, 0.0002, 0.0017]), + (616.53, [-0.0029, -0.0015, -0.0004]), + (617.33, [-0.0004, 0.0004, 0.0018]), + (618.13, [-0.0004, 0.0005, 0.0013]), + (618.93, [-0.0033, -0.002, -0.0012]), + (629.33, [-0.0026, -0.0018, -0.0018]), + (638.92, [0.0008, 0.0012, 0.0006]), + (639.72, [-0.0003, -0.0003, -0.0003]), + (640.52, [0.0034, 0.0023, 0.0026]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (7.7937, [0.0, 0.0, 0.0]), + (8.5731, [-0.0262, -0.0345, -0.0554]), + (9.3524, [0.0027, 0.0005, -0.0015]), + (10.132, [0.0014, -0.0011, -0.0021]), + (10.911, [0.0025, 0.0011, -0.0012]), + (18.705, [0.0016, 0.0009, 0.0001]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [-0.0055, 0.0167, 0.0146]), + (0.79887, [-0.0027, 0.0185, 0.0173]), + (1.5977, [-0.0033, 0.0168, 0.0166]), + (3.9944, [-0.0032, 0.0185, 0.0184]), + (4.7932, [-0.0008, 0.0192, 0.019]), + (7.9887, [-0.0023, 0.0178, 0.0178]), + (16.776, [-0.0005, 0.0199, 0.02]), + (19.972, [-0.0016, 0.0196, 0.0196]), + (27.96, [0.0005, 0.0208, 0.0204]), + (60.714, [0.0021, 0.0229, 0.022]), + (62.312, [0.0019, 0.0213, 0.0205]), + (63.111, [0.0016, 0.014, 0.0127]), + (63.91, [0.0018, 0.0039, 0.0026]), + (64.709, [0.0017, 0.001, 0.0]), + (92.669, [0.0016, 0.001, 0.0004]), + (93.468, [-0.0018, -0.0021, -0.0026]), + (94.267, [0.0002, -0.0002, -0.0007]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0168, 0.3203, 0.076]), + (0.79862, [-0.0193, 0.3224, 0.0719]), + (1.5972, [-0.0204, 0.3125, 0.0704]), + (3.1945, [-0.0267, 0.2929, 0.0633]), + (3.9931, [-0.0248, 0.282, 0.0608]), + (5.5903, [-0.0207, 0.2537, 0.0519]), + (9.5834, [-0.0118, 0.1688, 0.0263]), + (11.181, [-0.0087, 0.1379, 0.0183]), + (13.577, [-0.0059, 0.1031, 0.0113]), + (16.771, [-0.0042, 0.0706, 0.0076]), + (21.563, [-0.0032, 0.0325, 0.0031]), + (24.757, [-0.0033, 0.014, 0.0001]), + (28.75, [-0.0027, 0.0029, -0.0016]), + (34.341, [-0.0019, -0.0007, -0.0018]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [0.0028, 0.0219, 0.0083]), + (2.3962, [-0.0025, 0.018, 0.0076]), + (21.566, [-0.0029, 0.0182, 0.0078]), + (23.962, [-0.0031, 0.0207, 0.0102]), + (25.559, [-0.003, 0.0119, 0.0083]), + (27.157, [-0.0027, 0.0089, 0.0071]), + (29.553, [-0.0019, 0.0019, 0.0018]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [-0.0045, 0.0154, 0.0079]), + (0.7912, [-0.0024, 0.0155, 0.0078]), + (1.5824, [-0.0017, 0.0176, 0.0109]), + (9.4944, [-0.0038, 0.0147, 0.0062]), + (13.45, [-0.0024, 0.016, 0.0081]), + (15.033, [-0.0025, 0.0178, 0.008]), + (17.406, [-0.0019, 0.0247, 0.0129]), + (18.989, [-0.0034, 0.0257, 0.013]), + (19.78, [-0.0028, 0.0241, 0.0128]), + (20.571, [-0.0017, 0.0162, 0.0077]), + (21.362, [-0.0022, 0.0047, 0.0031]), + (22.154, [-0.0021, 0.0, 0.0003]), + (22.945, [-0.0018, -0.0013, -0.0005]), + ], + }, + Radial { + t: 0.39897, + perp: 0.0, + knots: &[ + (0.0, [-0.0029, 0.0019, -0.0011]), + (0.77909, [0.0038, 0.0032, 0.0024]), + (1.5582, [0.0024, 0.0018, -0.0009]), + (2.3373, [0.0018, 0.0022, -0.0012]), + (4.6745, [0.0028, 0.0022, 0.0004]), + (28.826, [0.0032, 0.002, -0.0001]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.3289, 0.2775, 0.1415]), + (0.79212, [0.3424, 0.2892, 0.1477]), + (1.5842, [0.3451, 0.2903, 0.1456]), + (7.9212, [0.3329, 0.2784, 0.1365]), + (8.7134, [0.34, 0.2838, 0.1394]), + (9.5055, [0.2019, 0.1687, 0.0826]), + (10.298, [0.0192, 0.0161, 0.0072]), + (11.09, [-0.0008, -0.0008, -0.0008]), + (11.882, [0.0028, 0.0023, 0.0011]), + (12.674, [0.0018, 0.0014, 0.0007]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.029, 0.0388, 0.0106]), + (1.5923, [0.0321, 0.0408, 0.0116]), + (42.195, [0.029, 0.0386, 0.0114]), + (43.787, [0.0332, 0.0408, 0.0137]), + (45.379, [0.0283, 0.0382, 0.0111]), + (50.952, [0.0232, 0.0359, 0.0086]), + (52.544, [0.025, 0.037, 0.0096]), + (63.69, [0.0229, 0.035, 0.0089]), + (64.486, [0.0215, 0.0327, 0.0086]), + (66.078, [0.0066, 0.0099, 0.0025]), + (66.875, [0.002, 0.0022, 0.0005]), + (67.671, [0.0015, 0.0011, 0.0004]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.285, 0.3834, 0.5035]), + (0.79168, [0.255, 0.3605, 0.4789]), + (2.375, [0.1305, 0.2419, 0.3886]), + (3.1667, [0.0984, 0.2125, 0.3659]), + (3.9584, [0.0913, 0.2049, 0.3619]), + (4.7501, [0.0927, 0.2068, 0.3614]), + (5.5418, [0.0892, 0.1997, 0.3586]), + (6.3335, [0.0842, 0.1861, 0.3518]), + (7.9168, [0.0287, 0.0658, 0.2917]), + (8.7085, [0.0242, 0.0547, 0.2632]), + (11.875, [0.0102, 0.0232, 0.1132]), + (12.667, [0.0059, 0.0132, 0.0751]), + (14.25, [0.0014, 0.003, 0.0025]), + (15.834, [0.0009, 0.0018, 0.0012]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[(0.0, [0.0, 0.0, 0.0]), (13.514, [0.0, 0.0, 0.0]), (14.309, [0.0206, 0.0213, 0.0173]), (15.104, [0.001, 0.0004, 0.0003])], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.1954, 0.1632, 0.0255]), + (0.78746, [0.2024, 0.1712, 0.0245]), + (1.5749, [0.1544, 0.1283, 0.0179]), + (2.3624, [0.1135, 0.0947, 0.0092]), + (3.1498, [0.0772, 0.0645, 0.0064]), + (3.9373, [0.0546, 0.0443, 0.0043]), + (4.7247, [0.0378, 0.0307, 0.0024]), + (5.5122, [0.0287, 0.021, 0.0001]), + (6.2997, [0.0201, 0.0153, 0.0001]), + (8.662, [0.0067, 0.0043, -0.0004]), + (10.237, [0.0046, 0.0025, 0.0002]), + (23.624, [0.0031, 0.0013, -0.0002]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0065, 0.0601, 0.0741]), + (0.79673, [0.0071, 0.0643, 0.0798]), + (2.3902, [0.0088, 0.0654, 0.0829]), + (3.9837, [0.0072, 0.0625, 0.0785]), + (4.7804, [0.0033, 0.0279, 0.0362]), + (5.5771, [0.001, 0.0012, 0.0015]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0444, -0.2736, -0.0393]), + (0.78812, [0.0415, -0.2769, -0.0371]), + (2.3644, [0.0408, -0.2724, -0.0349]), + (4.7287, [0.0345, -0.2539, -0.0315]), + (8.6693, [0.0224, -0.2055, -0.0234]), + (12.61, [0.0125, -0.1487, -0.0156]), + (16.551, [0.0071, -0.0983, -0.0105]), + (20.491, [0.0047, -0.0556, -0.0054]), + (23.644, [0.004, -0.0294, -0.0019]), + (26.008, [0.004, -0.016, -0.0004]), + (29.949, [0.0034, -0.0039, 0.0012]), + (33.889, [0.0026, -0.0001, 0.0012]), + (37.83, [0.0018, 0.0006, 0.0007]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3568, 0.3864, 0.4484]), + (0.79199, [0.3339, 0.3567, 0.419]), + (1.584, [0.2988, 0.324, 0.3702]), + (2.376, [0.2393, 0.2557, 0.2866]), + (3.168, [0.1819, 0.1924, 0.2114]), + (3.96, [0.122, 0.132, 0.1437]), + (4.752, [0.078, 0.083, 0.085]), + (5.544, [0.0522, 0.0547, 0.0495]), + (6.336, [0.0251, 0.0264, 0.0178]), + (7.1279, [0.0132, 0.0143, 0.0054]), + (9.5039, [0.0172, 0.0177, 0.007]), + (15.048, [0.0206, 0.0206, 0.0085]), + (20.592, [0.0191, 0.0192, 0.0077]), + (30.096, [0.0208, 0.0212, 0.0087]), + (30.888, [0.0139, 0.0143, 0.0064]), + (31.68, [0.0014, 0.0017, 0.0005]), + ], + }, + Radial { t: 2.5968, perp: 0.0, knots: &[(0.0, [-0.0007, -0.0007, -0.0001]), (4.7409, [0.0019, 0.0016, 0.0005]), (11.852, [0.0009, 0.0014, 0.0002])] }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.0048, 0.0042, 0.0044]), + (13.418, [0.0041, 0.0035, 0.0033]), + (14.997, [0.004, 0.0082, 0.0081]), + (16.575, [0.004, 0.0075, 0.0073]), + (18.154, [0.004, 0.0122, 0.012]), + (19.732, [0.004, 0.0119, 0.0117]), + (20.522, [0.0039, 0.0154, 0.0153]), + (22.1, [0.0041, 0.0153, 0.0152]), + (22.89, [0.0044, 0.0189, 0.0189]), + (23.679, [0.0041, 0.0197, 0.0196]), + (25.257, [0.0087, 0.0241, 0.024]), + (26.047, [0.0072, 0.0226, 0.0226]), + (26.836, [0.0068, 0.0259, 0.0259]), + (29.993, [0.0079, 0.0289, 0.0318]), + (30.783, [0.007, 0.0243, 0.0246]), + (31.572, [0.0008, 0.0065, 0.0066]), + (32.361, [-0.0001, -0.0009, -0.0008]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (2.3556, [0.0, 0.0, 0.0]), + (3.1407, [-0.005, -0.0011, 0.0019]), + (3.9259, [-0.0095, -0.0059, 0.0012]), + (4.7111, [-0.0074, -0.0033, 0.0013]), + (6.2815, [-0.0061, -0.002, 0.001]), + (10.993, [-0.0064, -0.0033, -0.0003]), + (19.63, [-0.0041, -0.0022, 0.0001]), + ], + }, + Radial { t: 0.94074, perp: 0.0, knots: &[(0.0, [0.0121, 0.1415, 0.1429]), (0.7998, [0.007, 0.0728, 0.0826]), (2.3994, [0.001, -0.0004, -0.0002])] }, + Radial { t: 0.87489, perp: 0.0, knots: &[(0.0, [-0.0041, -0.0027, -0.0033]), (0.78535, [0.0027, 0.0014, 0.0016]), (1.5707, [0.0005, -0.0001, -0.0003])] }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0115, -0.0418, -0.0037]), + (2.3795, [-0.0083, -0.0406, -0.0059]), + (4.759, [-0.0067, -0.0383, -0.0046]), + (9.5179, [-0.0036, -0.0218, -0.0028]), + (13.484, [-0.0017, -0.0082, 0.0061]), + (15.863, [-0.0008, -0.0016, 0.0021]), + (17.45, [-0.0001, -0.0128, -0.0007]), + (18.243, [-0.0001, -0.0136, -0.0005]), + (23.002, [0.0001, -0.0044, 0.0002]), + (26.174, [0.0, -0.0013, 0.0005]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.2676, 0.0886, -0.0116]), + (0.77016, [0.2119, 0.0752, -0.0138]), + (1.5403, [0.1631, 0.0277, -0.0249]), + (2.3105, [0.1179, -0.0033, -0.0315]), + (3.0807, [0.083, -0.0234, -0.0329]), + (3.8508, [0.0565, -0.0398, -0.0315]), + (4.621, [0.042, -0.0432, -0.0304]), + (5.3911, [0.0318, -0.0446, -0.0288]), + (6.1613, [0.0204, -0.0428, -0.0251]), + (6.9315, [0.0148, -0.0366, -0.0209]), + (9.242, [0.0038, -0.0212, -0.0099]), + (10.782, [0.0011, -0.0095, -0.0046]), + (12.323, [0.0002, -0.003, -0.0016]), + ], + }, + Radial { t: 1.6547, perp: 0.0, knots: &[(0.0, [-0.0001, 0.0063, 0.0027]), (3.9322, [0.0001, 0.0019, 0.0007])] }, + Radial { t: 2.6079, perp: 0.0, knots: &[(0.0, [-0.0014, 0.0, 0.0006]), (8.696, [-0.0024, -0.0025, -0.0007]), (11.858, [-0.0012, -0.0013, -0.0003])] }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0396, 0.0213, 0.027]), + (0.79824, [0.0424, 0.0219, 0.0279]), + (39.912, [0.0439, 0.0236, 0.0273]), + (46.298, [0.047, 0.0245, 0.0282]), + (47.096, [0.0254, 0.0125, 0.0161]), + (47.895, [-0.0039, -0.0033, 0.0002]), + (48.693, [-0.0073, -0.0062, -0.0026]), + (53.482, [-0.0048, -0.0052, -0.0014]), + (59.868, [-0.0043, -0.0048, -0.0014]), + (62.263, [-0.006, -0.0076, -0.0021]), + (63.859, [-0.0036, -0.004, -0.0011]), + (67.052, [-0.0017, -0.0013, -0.0005]), + ], + }, +]; +const PRIME105_75_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_75_RAYS: &[Ray] = &[ + Ray { angle: 0.22, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0255, 0.0216, 0.0196] }, + Ray { angle: 0.47, r0: 6.25, r1: 121.09, fade: 0.05, rgb: [0.0235, 0.0216, 0.0176] }, + Ray { angle: 7.59, r0: 6.25, r1: 121.88, fade: 0.05, rgb: [0.0157, 0.0157, 0.0137] }, + Ray { angle: 7.9, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0216, 0.0176, 0.0176] }, + Ray { angle: 8.21, r0: 6.25, r1: 125.78, fade: 0.05, rgb: [0.0176, 0.0137, 0.0137] }, + Ray { angle: 45.99, r0: 18.75, r1: 209.38, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 60.39, r0: 6.25, r1: 258.59, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 74.52, r0: 42.969, r1: 157.81, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 89.02, r0: 10.938, r1: 266.41, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 96.11, r0: 6.25, r1: 350.0, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 96.51, r0: 6.25, r1: 193.75, fade: 0.05, rgb: [0.0255, 0.0255, 0.0235] }, + Ray { angle: 97.18, r0: 6.25, r1: 114.84, fade: 0.05, rgb: [0.0216, 0.0206, 0.0216] }, + Ray { angle: 97.66, r0: 107.81, r1: 196.88, fade: 0.05, rgb: [0.0098, 0.0098, 0.0118] }, + Ray { angle: 103.23, r0: 86.719, r1: 192.97, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 104.32, r0: 24.219, r1: 320.31, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 118.66, r0: 16.406, r1: 214.06, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 147.22, r0: 12.5, r1: 257.81, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 161.5, r0: 14.062, r1: 246.88, fade: 0.05, rgb: [0.0255, 0.0255, 0.0255] }, + Ray { angle: 161.79, r0: 14.844, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 168.69, r0: 6.25, r1: 238.28, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 183.08, r0: 6.25, r1: 66.406, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 225.78, r0: 20.312, r1: 208.59, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 240.26, r0: 10.938, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 254.46, r0: 42.188, r1: 154.69, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 268.98, r0: 105.47, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 269.68, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0333, 0.0451, 0.0235] }, + Ray { angle: 269.99, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0333, 0.0451, 0.0235] }, + Ray { angle: 270.38, r0: 6.25, r1: 117.97, fade: 0.05, rgb: [0.0216, 0.0363, 0.0098] }, + Ray { angle: 275.22, r0: 6.25, r1: 194.53, fade: 0.05, rgb: [0.0147, 0.0157, 0.0167] }, + Ray { angle: 275.47, r0: 6.25, r1: 193.75, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 276.04, r0: 6.25, r1: 116.41, fade: 0.05, rgb: [0.0353, 0.0333, 0.0333] }, + Ray { angle: 276.48, r0: 6.25, r1: 170.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0157] }, + Ray { angle: 276.93, r0: 6.25, r1: 194.53, fade: 0.05, rgb: [0.0127, 0.0118, 0.0137] }, + Ray { angle: 283.3, r0: 85.156, r1: 187.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 284.37, r0: 21.875, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0118, 0.0137] }, + Ray { angle: 291.05, r0: 12.5, r1: 61.719, fade: 0.05, rgb: [0.0118, 0.0157, 0.0137] }, + Ray { angle: 298.81, r0: 18.75, r1: 213.28, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 327.33, r0: 24.219, r1: 259.38, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 341.59, r0: 14.062, r1: 274.22, fade: 0.05, rgb: [0.0275, 0.0294, 0.0275] }, + Ray { angle: 348.73, r0: 6.25, r1: 369.53, fade: 0.05, rgb: [0.0216, 0.0216, 0.0216] }, +]; +const PRIME105_100_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.1723, 0.4255, 0.7155]), + (0.8, [0.2437, 0.438, 0.6829]), + (3.2, [0.2673, 0.4555, 0.6837]), + (10.4, [0.2552, 0.4548, 0.6555]), + (18.4, [0.2075, 0.4121, 0.5979]), + (23.2, [0.1619, 0.3731, 0.5534]), + (28.8, [0.1087, 0.3104, 0.5011]), + (32.0, [0.0869, 0.2824, 0.4753]), + (37.6, [0.0412, 0.2296, 0.4287]), + (42.4, [0.0116, 0.1917, 0.3933]), + (44.8, [0.0028, 0.1779, 0.3768]), + (48.0, [-0.0132, 0.1549, 0.3566]), + (49.6, [-0.0142, 0.1434, 0.3443]), + (53.6, [-0.0239, 0.1239, 0.319]), + (60.8, [-0.0171, 0.1007, 0.2728]), + (64.0, [-0.0065, 0.1001, 0.2542]), + (67.2, [0.0099, 0.0959, 0.2304]), + (70.4, [0.027, 0.0975, 0.2065]), + (74.4, [0.0479, 0.0917, 0.1813]), + (75.2, [0.0543, 0.0907, 0.176]), + (76.0, [0.0648, 0.0912, 0.171]), + (77.6, [0.1084, 0.0952, 0.1608]), + (78.4, [0.1114, 0.0957, 0.1545]), + (79.2, [0.0963, 0.0951, 0.148]), + (80.0, [0.0861, 0.095, 0.141]), + (80.8, [0.0876, 0.0963, 0.135]), + (88.8, [0.1367, 0.1066, 0.0824]), + (92.0, [0.1589, 0.1119, 0.0684]), + (96.0, [0.1897, 0.1229, 0.0491]), + (100.8, [0.2301, 0.1401, 0.0371]), + (107.2, [0.2827, 0.1631, 0.0148]), + (110.4, [0.3097, 0.1746, 0.008]), + (117.6, [0.3739, 0.2174, -0.0077]), + (120.0, [0.3944, 0.2316, -0.0073]), + (124.8, [0.4369, 0.2567, -0.0011]), + (128.8, [0.4689, 0.2761, 0.001]), + (136.0, [0.5221, 0.3087, 0.0104]), + (144.0, [0.5731, 0.3434, 0.0147]), + (154.4, [0.6301, 0.3771, 0.0296]), + (163.2, [0.6572, 0.3955, 0.0342]), + (166.4, [0.6648, 0.4021, 0.0388]), + (176.8, [0.6752, 0.4135, 0.05]), + (185.6, [0.6659, 0.4021, 0.0628]), + (194.4, [0.638, 0.3838, 0.0694]), + (208.8, [0.5531, 0.3328, 0.0837]), + (225.6, [0.4004, 0.2615, 0.1042]), + (236.0, [0.282, 0.2035, 0.1163]), + (237.6, [0.2574, 0.1891, 0.1122]), + (248.8, [0.1083, 0.1084, 0.1097]), + (250.4, [0.0829, 0.0925, 0.1049]), + (252.8, [0.0506, 0.0733, 0.1031]), + (260.0, [-0.038, 0.0177, 0.1011]), + (265.6, [-0.1019, -0.0238, 0.0999]), + (274.4, [-0.1949, -0.0817, 0.0952]), + (285.6, [-0.2953, -0.1488, 0.0906]), + (296.0, [-0.3716, -0.2002, 0.0878]), + (306.4, [-0.4245, -0.2361, 0.082]), + (319.2, [-0.4638, -0.2633, 0.07]), + (332.8, [-0.4818, -0.2755, 0.0538]), + (351.2, [-0.4704, -0.2741, 0.0259]), + (368.0, [-0.4325, -0.2583, -0.0061]), + (399.2, [-0.3295, -0.2156, -0.0696]), + (416.8, [-0.268, -0.1913, -0.0994]), + (438.4, [-0.1885, -0.1574, -0.1259]), + (452.0, [-0.1367, -0.132, -0.1365]), + (466.4, [-0.0772, -0.0985, -0.1372]), + (484.8, [-0.001, -0.0484, -0.1265]), + (494.4, [0.0336, -0.026, -0.118]), + (505.6, [0.0693, -0.0003, -0.1052]), + (515.2, [0.0957, 0.0215, -0.092]), + (523.2, [0.1133, 0.0359, -0.0806]), + (535.2, [0.1337, 0.0551, -0.062]), + (545.6, [0.1455, 0.0695, -0.0445]), + (562.4, [0.1525, 0.0842, -0.0163]), + (582.4, [0.1443, 0.0918, 0.0163]), + (592.8, [0.1326, 0.0906, 0.0312]), + (603.2, [0.1167, 0.0852, 0.043]), + (612.0, [0.1016, 0.0793, 0.0504]), + (620.0, [0.0857, 0.0716, 0.0536]), + (630.4, [0.0622, 0.0563, 0.051]), + (643.2, [0.0368, 0.0438, 0.0451]), + (655.2, [0.0202, 0.0337, 0.0332]), + (666.4, [0.01, 0.0242, 0.0183]), + (679.2, [0.0078, 0.0179, 0.0011]), + (683.2, [0.0075, 0.0164, -0.0034]), + (689.6, [0.0033, 0.0115, -0.0107]), + (701.6, [-0.0051, -0.0005, -0.0206]), + (709.6, [-0.0174, -0.0163, -0.0271]), + (727.2, [-0.0432, -0.0494, -0.0354]), + (734.4, [-0.0512, -0.0585, -0.0373]), + (740.8, [-0.0518, -0.0598, -0.0377]), + (745.6, [-0.048, -0.0561, -0.0365]), + (751.2, [-0.0371, -0.0452, -0.0317]), + (756.8, [-0.0262, -0.0419, -0.0327]), + (759.2, [-0.0197, -0.038, -0.0321]), + (762.4, [-0.0079, -0.0284, -0.0283]), + (764.8, [0.0032, -0.0172, -0.023]), + (768.0, [0.0199, 0.0017, -0.0139]), + (772.0, [0.0425, 0.0283, -0.0004]), + (774.4, [0.0531, 0.0409, 0.0073]), + (776.8, [0.0606, 0.0473, 0.0136]), + (779.2, [0.0646, 0.0468, 0.0167]), + (780.8, [0.0639, 0.042, 0.0162]), + (782.4, [0.0572, 0.0325, 0.0124]), + (784.8, [0.0365, 0.0183, 0.0081]), + (787.2, [0.0152, 0.018, 0.013]), + (788.8, [0.0085, 0.0214, 0.0154]), + (789.6, [0.0086, 0.0241, 0.0161]), + (791.2, [0.0129, 0.0292, 0.0158]), + (792.8, [0.0156, 0.0302, 0.0127]), + (794.4, [0.0115, 0.0239, 0.0069]), + (797.6, [-0.0054, 0.0028, -0.0068]), + (799.2, [-0.0092, -0.0046, -0.0121]), + (800.8, [-0.0099, -0.009, -0.0155]), + (805.6, [-0.0105, -0.0158, -0.025]), + (808.0, [-0.0159, -0.0191, -0.0299]), + (810.4, [-0.0158, -0.0133, -0.0254]), + (814.4, [-0.0054, 0.0071, -0.0032]), + (815.2, [-0.0051, 0.0097, -0.0004]), + (816.0, [-0.0055, 0.0096, -0.0001]), + (817.6, [-0.0074, 0.0015, -0.0061]), + (818.4, [-0.0097, -0.0063, -0.0111]), + (820.0, [-0.0179, -0.0278, -0.0232]), + (821.6, [-0.0244, -0.0468, -0.0314]), + (823.2, [-0.0282, -0.0586, -0.0352]), + (824.0, [-0.0273, -0.0598, -0.0346]), + (824.8, [-0.0246, -0.0566, -0.0328]), + (825.6, [-0.0209, -0.0497, -0.0303]), + (828.0, [-0.0084, -0.0202, -0.0232]), + (829.6, [-0.005, -0.0083, -0.0195]), + (831.2, [-0.0011, 0.006, -0.0123]), + (833.6, [0.0022, 0.0175, -0.0052]), + (834.4, [0.0029, 0.0176, -0.0045]), + (836.8, [-0.0047, 0.0027, -0.0086]), + (840.8, [-0.0143, -0.0175, -0.0177]), + (841.6, [-0.0154, -0.0194, -0.0187]), + (844.8, [-0.0131, -0.0152, -0.017]), + (849.6, [-0.0123, -0.014, -0.0154]), + (853.6, [-0.015, -0.0166, -0.0148]), + (860.0, [-0.0157, -0.0174, -0.0135]), + (862.4, [-0.0174, -0.0199, -0.0142]), + (864.8, [-0.016, -0.019, -0.0137]), + (869.6, [-0.0188, -0.0204, -0.0139]), + (871.2, [-0.019, -0.0213, -0.0145]), + (873.6, [-0.0156, -0.0188, -0.0125]), + (875.2, [-0.0163, -0.0195, -0.0149]), + (876.8, [-0.0148, -0.0164, -0.0147]), + (878.4, [-0.0131, -0.0163, -0.0151]), + (880.8, [-0.0092, -0.0113, -0.0101]), + (882.4, [-0.0086, -0.0069, -0.0093]), + (884.8, [-0.006, -0.0052, -0.0089]), + (887.2, [-0.0015, 0.003, 0.0011]), + (888.8, [0.0038, 0.0077, 0.0026]), + (889.6, [0.0026, 0.0055, 0.0006]), + (890.4, [-0.0018, 0.0018, -0.0011]), + (892.0, [-0.0013, 0.0038, 0.0003]), + (894.4, [-0.0004, 0.0025, -0.0003]), + (896.0, [-0.004, -0.0005, -0.001]), + (896.8, [-0.0035, 0.0002, -0.0003]), + (897.6, [-0.0002, 0.001, 0.0004]), + (898.4, [-0.0002, -0.0004, -0.0003]), + (899.2, [-0.0034, -0.0025, -0.0009]), + (900.0, [-0.0026, -0.0012, -0.0003]), + (900.8, [0.0011, 0.0009, 0.0005]), + (901.6, [0.0015, 0.0002, 0.0002]), + (902.4, [-0.0016, -0.0015, -0.0008]), + (904.0, [0.0025, 0.002, 0.0008]), + (904.8, [0.0014, 0.0007, 0.0003]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.7996, 0.5774, 0.2793]), + (0.8, [0.6929, 0.5025, 0.2761]), + (2.4, [0.6811, 0.4962, 0.279]), + (20.0, [0.6316, 0.4589, 0.2715]), + (38.4, [0.5463, 0.406, 0.2592]), + (54.4, [0.4521, 0.3494, 0.2551]), + (64.0, [0.3777, 0.3097, 0.2587]), + (76.8, [0.2577, 0.2532, 0.2698]), + (85.6, [0.167, 0.208, 0.2757]), + (93.6, [0.0836, 0.1606, 0.2761]), + (104.8, [-0.0369, 0.089, 0.2664]), + (110.4, [-0.0978, 0.0507, 0.2607]), + (117.6, [-0.1728, -0.0037, 0.2531]), + (126.4, [-0.2618, -0.0664, 0.2315]), + (135.2, [-0.3434, -0.1238, 0.2066]), + (144.0, [-0.4151, -0.1765, 0.1818]), + (152.8, [-0.4771, -0.2223, 0.1553]), + (160.0, [-0.5151, -0.2513, 0.1354]), + (168.8, [-0.5482, -0.2788, 0.1112]), + (177.6, [-0.5636, -0.2941, 0.0882]), + (187.2, [-0.5614, -0.2975, 0.0628]), + (197.6, [-0.5342, -0.285, 0.0378]), + (207.2, [-0.4859, -0.2585, 0.0145]), + (216.0, [-0.4227, -0.2298, -0.006]), + (226.4, [-0.327, -0.1907, -0.0288]), + (236.8, [-0.2145, -0.1411, -0.0519]), + (238.4, [-0.1905, -0.1271, -0.0492]), + (240.8, [-0.1591, -0.1108, -0.0505]), + (249.6, [-0.0479, -0.0543, -0.0602]), + (251.2, [-0.0237, -0.0394, -0.0572]), + (253.6, [0.0075, -0.0214, -0.0575]), + (257.6, [0.0557, 0.0073, -0.059]), + (265.6, [0.1441, 0.0615, -0.0628]), + (276.8, [0.2547, 0.129, -0.0668]), + (288.8, [0.3517, 0.1916, -0.0716]), + (297.6, [0.4078, 0.2275, -0.0741]), + (306.4, [0.4475, 0.2537, -0.0739]), + (315.2, [0.4745, 0.2712, -0.0706]), + (328.0, [0.494, 0.2817, -0.0629]), + (346.4, [0.4863, 0.2784, -0.0453]), + (367.2, [0.4375, 0.2542, -0.0149]), + (400.0, [0.3186, 0.1992, 0.0425]), + (417.6, [0.252, 0.1705, 0.0684]), + (439.2, [0.1673, 0.1321, 0.0913]), + (452.8, [0.1126, 0.1042, 0.0997]), + (467.2, [0.0521, 0.0699, 0.1001]), + (484.0, [-0.0191, 0.0233, 0.0907]), + (494.4, [-0.0576, -0.0015, 0.0818]), + (506.4, [-0.0967, -0.0296, 0.068]), + (516.0, [-0.1234, -0.0516, 0.0548]), + (524.0, [-0.1412, -0.0661, 0.0433]), + (534.4, [-0.1595, -0.0831, 0.0271]), + (545.6, [-0.1731, -0.0991, 0.0082]), + (562.4, [-0.1806, -0.1143, -0.0202]), + (582.4, [-0.1726, -0.1221, -0.0531]), + (603.2, [-0.1448, -0.1157, -0.0805]), + (620.8, [-0.1117, -0.1011, -0.0919]), + (631.2, [-0.0876, -0.0856, -0.0893]), + (644.0, [-0.0615, -0.0724, -0.0832]), + (656.0, [-0.0441, -0.0619, -0.0708]), + (667.2, [-0.0332, -0.052, -0.0555]), + (684.0, [-0.0301, -0.0436, -0.0331]), + (696.0, [-0.0215, -0.0333, -0.0196]), + (702.4, [-0.0168, -0.026, -0.015]), + (710.4, [-0.0044, -0.0098, -0.0081]), + (728.0, [0.0222, 0.0241, 0.0007]), + (735.2, [0.0307, 0.0337, 0.0029]), + (741.6, [0.0317, 0.0355, 0.0034]), + (746.4, [0.0281, 0.032, 0.0025]), + (752.0, [0.0174, 0.0212, -0.0022]), + (757.6, [0.0066, 0.0179, -0.0012]), + (760.0, [0.0001, 0.0141, -0.0017]), + (763.2, [-0.0116, 0.0045, -0.0055]), + (765.6, [-0.0226, -0.0065, -0.0106]), + (772.8, [-0.0623, -0.0526, -0.0335]), + (775.2, [-0.0732, -0.0652, -0.0412]), + (778.4, [-0.0829, -0.0728, -0.049]), + (780.8, [-0.0857, -0.0702, -0.0511]), + (783.2, [-0.0796, -0.0587, -0.0472]), + (785.6, [-0.0604, -0.0441, -0.0422]), + (788.0, [-0.0386, -0.043, -0.0468]), + (789.6, [-0.0304, -0.046, -0.0495]), + (790.4, [-0.0296, -0.0486, -0.0503]), + (792.8, [-0.0357, -0.0561, -0.0497]), + (794.4, [-0.0369, -0.0553, -0.0459]), + (796.0, [-0.0304, -0.0471, -0.0398]), + (799.2, [-0.0129, -0.026, -0.0266]), + (800.8, [-0.0099, -0.0192, -0.0217]), + (806.4, [-0.0115, -0.0109, -0.01]), + (808.8, [-0.0058, -0.0064, -0.0042]), + (810.4, [-0.0034, -0.0083, -0.0055]), + (812.0, [-0.0051, -0.0134, -0.0109]), + (814.4, [-0.0123, -0.0262, -0.0249]), + (816.0, [-0.0146, -0.0342, -0.0334]), + (817.6, [-0.0144, -0.0335, -0.033]), + (819.2, [-0.0124, -0.0227, -0.0249]), + (821.6, [-0.0007, 0.0091, -0.0072]), + (822.4, [0.003, 0.0187, -0.003]), + (824.0, [0.0077, 0.0315, 0.0008]), + (824.8, [0.0077, 0.0343, 0.0005]), + (825.6, [0.0055, 0.0325, -0.0014]), + (826.4, [0.0022, 0.0269, -0.004]), + (828.8, [-0.0108, -0.0035, -0.0122]), + (829.6, [-0.0132, -0.0103, -0.0136]), + (832.8, [-0.0198, -0.0333, -0.022]), + (834.4, [-0.0215, -0.0393, -0.0261]), + (835.2, [-0.0218, -0.0385, -0.0266]), + (838.4, [-0.0171, -0.0269, -0.0235]), + (841.6, [-0.0121, -0.0126, -0.0161]), + (843.2, [-0.01, -0.0091, -0.014]), + (846.4, [-0.0114, -0.013, -0.0152]), + (862.4, [-0.0154, -0.0174, -0.0128]), + (872.0, [-0.0188, -0.0205, -0.0142]), + (874.4, [-0.014, -0.0167, -0.0111]), + (876.0, [-0.0155, -0.0183, -0.0138]), + (878.4, [-0.0154, -0.017, -0.0154]), + (880.8, [-0.0093, -0.0118, -0.0098]), + (881.6, [-0.0084, -0.0109, -0.0094]), + (882.4, [-0.0106, -0.0115, -0.0105]), + (884.8, [-0.0078, -0.0074, -0.0099]), + (887.2, [-0.001, 0.0018, -0.0022]), + (888.8, [0.0023, 0.0058, 0.0007]), + (890.4, [0.0001, 0.0061, 0.0013]), + (892.0, [0.0011, 0.006, 0.0012]), + (895.2, [-0.0046, -0.0008, -0.0009]), + (897.6, [-0.0036, -0.0031, -0.0016]), + (899.2, [-0.0029, -0.0015, -0.0006]), + (900.8, [0.002, 0.0021, 0.001]), + (902.4, [0.0013, 0.0023, 0.0006]), + (903.2, [0.0034, 0.004, 0.0016]), + (904.0, [0.0034, 0.003, 0.0012]), + (904.8, [0.0002, 0.0, -0.0005]), + (906.4, [-0.0017, -0.002, -0.0009]), + (908.0, [-0.0024, -0.0021, -0.0005]), + (908.8, [-0.0004, -0.0001, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0482, 0.034, 0.024]), + (0.79943, [0.0512, 0.0428, 0.028]), + (2.3983, [0.05, 0.0401, 0.0268]), + (23.983, [0.0508, 0.0439, 0.0294]), + (50.364, [0.056, 0.0493, 0.034]), + (135.1, [0.0721, 0.0588, 0.0349]), + (181.47, [0.073, 0.0599, 0.0371]), + (195.86, [0.0709, 0.0592, 0.0386]), + (196.66, [0.0667, 0.0552, 0.0345]), + (197.46, [0.069, 0.0574, 0.0372]), + (211.05, [0.0655, 0.0558, 0.0385]), + (212.65, [0.0623, 0.0528, 0.0357]), + (213.45, [0.0637, 0.0544, 0.0376]), + (236.63, [0.0546, 0.0493, 0.0392]), + (300.59, [0.0253, 0.0302, 0.0406]), + (329.37, [0.0185, 0.0257, 0.0408]), + (362.94, [0.016, 0.0244, 0.0406]), + (493.25, [0.0224, 0.0268, 0.0366]), + (591.58, [0.0272, 0.0298, 0.0371]), + (689.11, [0.0241, 0.0284, 0.0379]), + (807.43, [0.0191, 0.0236, 0.0338]), + (863.39, [0.0196, 0.0255, 0.0351]), + (887.37, [0.0196, 0.0246, 0.033]), + (893.77, [0.0194, 0.0257, 0.0356]), + (899.36, [0.0192, 0.0222, 0.0318]), + (900.96, [0.0179, 0.0192, 0.0307]), + (904.96, [0.0243, 0.0278, 0.0331]), + (916.15, [0.0234, 0.0279, 0.0299]), + (920.95, [0.0281, 0.0315, 0.0284]), + (925.74, [0.0297, 0.0333, 0.0261]), + (929.74, [0.0328, 0.0379, 0.0271]), + (931.34, [0.0313, 0.0376, 0.0271]), + (935.34, [0.0345, 0.0379, 0.0266]), + (937.74, [0.037, 0.0411, 0.0286]), + (940.13, [0.029, 0.035, 0.0239]), + (941.73, [0.0315, 0.0377, 0.029]), + (944.13, [0.0297, 0.033, 0.0307]), + (946.53, [0.0193, 0.0254, 0.0222]), + (947.33, [0.018, 0.0229, 0.0207]), + (948.13, [0.0198, 0.0214, 0.021]), + (948.93, [0.0171, 0.0165, 0.0198]), + (950.53, [0.0147, 0.0133, 0.02]), + (951.33, [0.0125, 0.0105, 0.0174]), + (953.72, [0.0005, -0.0064, 0.0005]), + (954.52, [-0.0025, -0.0113, -0.0031]), + (955.32, [-0.004, -0.0132, -0.0034]), + (956.12, [-0.0039, -0.0126, -0.0023]), + (957.72, [0.0018, -0.0075, 0.0003]), + (959.32, [0.0029, -0.0059, 0.0006]), + (962.52, [0.0074, 0.0027, 0.0024]), + (963.32, [0.0074, 0.0033, 0.0024]), + (964.92, [0.0022, 0.001, 0.0008]), + (966.51, [0.0011, -0.0002, -0.0001]), + (968.11, [-0.0036, -0.0031, -0.0008]), + (971.31, [-0.0011, -0.0002, 0.0004]), + (972.11, [0.0019, 0.0022, 0.0015]), + (972.91, [0.0025, 0.0023, 0.0011]), + (976.11, [-0.0017, -0.0012, -0.0001]), + (978.51, [0.0012, 0.0008, 0.001]), + (980.9, [0.0002, 0.0012, 0.0007]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.1748, 0.357, 0.067]), + (0.79965, [0.1634, 0.3465, 0.0676]), + (1.5993, [0.1644, 0.3453, 0.0654]), + (2.399, [0.1597, 0.3394, 0.0642]), + (4.7979, [0.1442, 0.3123, 0.0579]), + (8.7962, [0.1147, 0.2509, 0.0421]), + (14.394, [0.0794, 0.1632, 0.0238]), + (17.592, [0.0638, 0.1165, 0.014]), + (19.991, [0.055, 0.0874, 0.0077]), + (21.591, [0.051, 0.0722, 0.0049]), + (23.99, [0.0466, 0.0548, 0.001]), + (26.389, [0.0434, 0.0425, -0.0014]), + (29.587, [0.0403, 0.032, -0.0033]), + (32.786, [0.038, 0.0268, -0.0036]), + (37.584, [0.0365, 0.0246, -0.0023]), + (78.366, [0.0402, 0.0271, -0.0006]), + (79.965, [0.0224, 0.0159, -0.0005]), + (80.765, [0.018, 0.0127, -0.0006]), + (158.33, [0.0229, 0.015, 0.0004]), + (159.93, [0.0028, 0.0022, 0.0004]), + (160.73, [-0.0001, 0.0, 0.0004]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0411, 0.3218, 0.1762]), + (0.77937, [0.045, 0.3135, 0.1746]), + (2.3381, [0.0425, 0.3074, 0.1698]), + (3.1175, [0.042, 0.2945, 0.1617]), + (3.8968, [0.0377, 0.2704, 0.1452]), + (4.6762, [0.0341, 0.2268, 0.1179]), + (5.4556, [0.0234, 0.1621, 0.0845]), + (6.235, [0.0156, 0.0874, 0.0412]), + (7.0143, [0.0085, 0.0415, 0.0188]), + (7.7937, [0.0056, 0.0199, 0.0069]), + (8.5731, [0.0052, 0.0016, -0.003]), + (9.3524, [0.0034, -0.0035, -0.0057]), + (14.808, [0.0024, 0.0029, 0.0006]), + (17.925, [0.0007, 0.0012, 0.0001]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [0.0002, 0.0251, 0.0218]), + (0.79887, [0.0019, 0.0242, 0.0206]), + (3.9944, [0.0027, 0.0258, 0.0229]), + (7.1898, [0.0012, 0.0245, 0.0218]), + (27.96, [0.0033, 0.0267, 0.0237]), + (60.714, [0.0024, 0.0267, 0.0244]), + (62.312, [0.0024, 0.0235, 0.0208]), + (63.91, [0.0023, 0.0051, 0.0035]), + (64.709, [0.0022, 0.0016, 0.0]), + (91.071, [0.0019, 0.0013, 0.0004]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [0.0969, 0.5109, 0.1902]), + (0.79862, [0.0915, 0.5172, 0.1886]), + (1.5972, [0.0866, 0.5078, 0.1859]), + (3.9931, [0.0756, 0.464, 0.1653]), + (6.3889, [0.0681, 0.3941, 0.1315]), + (9.5834, [0.0535, 0.2837, 0.0795]), + (11.181, [0.0465, 0.2336, 0.0594]), + (12.778, [0.0408, 0.1933, 0.0452]), + (14.375, [0.0363, 0.1612, 0.036]), + (17.57, [0.0291, 0.1106, 0.0244]), + (21.563, [0.0213, 0.0598, 0.0117]), + (24.757, [0.0158, 0.0293, 0.0033]), + (26.354, [0.0139, 0.0194, 0.0004]), + (27.952, [0.0124, 0.0128, -0.0012]), + (30.347, [0.0108, 0.0074, -0.0022]), + (35.139, [0.0097, 0.0049, -0.0014]), + (52.709, [0.0098, 0.0065, 0.0017]), + (85.452, [0.006, 0.0035, 0.0012]), + (151.74, [0.0019, 0.0015, 0.0005]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0013, 0.0232, 0.0084]), + (0.79873, [0.0019, 0.021, 0.0081]), + (1.5975, [0.0026, 0.023, 0.0089]), + (4.7924, [0.0031, 0.0223, 0.0093]), + (19.968, [0.0023, 0.0222, 0.0098]), + (23.962, [0.0017, 0.0249, 0.0125]), + (25.559, [-0.0005, 0.017, 0.0105]), + (27.955, [-0.0004, 0.011, 0.0078]), + (30.352, [-0.0005, 0.0018, 0.0013]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [0.0001, 0.0165, 0.0084]), + (0.7912, [0.0008, 0.0178, 0.0102]), + (2.3736, [-0.0005, 0.016, 0.0073]), + (4.7472, [-0.0014, 0.0151, 0.0063]), + (9.4944, [0.0003, 0.0161, 0.0079]), + (13.45, [-0.0008, 0.0159, 0.0077]), + (18.989, [-0.0004, 0.0261, 0.0131]), + (19.78, [-0.0006, 0.0229, 0.0114]), + (21.362, [-0.0028, 0.0043, 0.0023]), + (22.154, [-0.0032, -0.0002, -0.0007]), + (22.945, [-0.0029, -0.0023, -0.0015]), + (38.769, [-0.0017, -0.0019, -0.0008]), + ], + }, + Radial { + t: 0.39897, + perp: 0.0, + knots: &[ + (0.0, [-0.0023, -0.0023, -0.0048]), + (0.77909, [0.0015, -0.0008, -0.0014]), + (3.1164, [0.0023, 0.0024, 0.0013]), + (5.4536, [0.0016, 0.0009, 0.0006]), + (17.919, [0.0029, 0.0024, 0.0007]), + (27.268, [0.002, 0.0011, -0.0003]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.4465, 0.3627, 0.1844]), + (1.5842, [0.4356, 0.3631, 0.183]), + (7.1291, [0.4386, 0.3649, 0.1829]), + (8.7134, [0.429, 0.3577, 0.1801]), + (9.5055, [0.234, 0.1969, 0.0995]), + (10.298, [0.0214, 0.018, 0.0095]), + (11.09, [-0.0003, -0.0002, 0.0]), + (11.882, [0.0022, 0.0018, 0.0009]), + (12.674, [0.0013, 0.0011, 0.0005]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.0396, 0.0545, 0.025]), + (0.79613, [0.035, 0.0522, 0.0239]), + (42.195, [0.0343, 0.054, 0.0202]), + (43.787, [0.0364, 0.0552, 0.0212]), + (45.379, [0.0334, 0.0534, 0.0192]), + (50.952, [0.0304, 0.0516, 0.0176]), + (58.117, [0.0315, 0.0527, 0.0186]), + (63.69, [0.0301, 0.0503, 0.0175]), + (64.486, [0.0285, 0.0482, 0.017]), + (65.282, [0.0178, 0.0321, 0.0116]), + (66.078, [0.0052, 0.0101, 0.0037]), + (66.875, [0.0013, 0.0011, 0.0011]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.4091, 0.5462, 0.6575]), + (0.79168, [0.3216, 0.4623, 0.5971]), + (1.5834, [0.2646, 0.4064, 0.5594]), + (2.375, [0.1832, 0.3361, 0.5102]), + (3.1667, [0.1385, 0.2956, 0.482]), + (3.9584, [0.1299, 0.2844, 0.4752]), + (5.5418, [0.1228, 0.2706, 0.4685]), + (6.3335, [0.1116, 0.2491, 0.4571]), + (7.9168, [0.0408, 0.0943, 0.3805]), + (8.7085, [0.0322, 0.0746, 0.3399]), + (12.667, [0.0069, 0.0158, 0.0979]), + (13.459, [0.0041, 0.0089, 0.0498]), + (14.25, [0.0019, 0.0037, 0.0063]), + (15.042, [0.0011, 0.0022, 0.0009]), + (15.834, [0.0009, 0.0016, 0.0011]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[ + (0.0, [0.3209, 0.2539, 0.09]), + (0.79493, [0.2395, 0.2176, 0.0695]), + (1.5899, [0.2186, 0.1736, 0.0548]), + (2.3848, [0.1598, 0.1258, 0.0301]), + (3.1797, [0.1083, 0.0736, 0.016]), + (3.9746, [0.0738, 0.0424, 0.0067]), + (4.7696, [0.0462, 0.021, 0.0007]), + (5.5645, [0.0284, 0.01, 0.0]), + (6.3594, [0.0158, 0.0033, -0.0014]), + (7.1543, [0.0072, 0.0024, 0.0007]), + (7.9493, [0.0013, -0.001, 0.0008]), + (8.7442, [-0.0009, -0.0021, 0.0017]), + (9.5391, [-0.0034, -0.0072, -0.0005]), + (10.334, [-0.0043, -0.0091, -0.0007]), + (11.924, [-0.003, -0.002, 0.0024]), + (13.514, [-0.0004, 0.001, 0.0041]), + (14.309, [-0.0004, 0.007, 0.0064]), + (15.899, [0.0004, 0.0055, 0.0058]), + (16.693, [-0.0003, 0.0015, 0.0028]), + (17.488, [-0.0007, 0.0, 0.0013]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.2133, 0.1779, 0.0435]), + (0.78746, [0.2155, 0.1802, 0.0343]), + (1.5749, [0.1763, 0.1445, 0.0245]), + (2.3624, [0.1287, 0.1069, 0.0164]), + (3.1498, [0.0937, 0.0766, 0.0106]), + (3.9373, [0.064, 0.0516, 0.0066]), + (4.7247, [0.0427, 0.0352, 0.0039]), + (5.5122, [0.0321, 0.0244, 0.0021]), + (6.2997, [0.0211, 0.0168, 0.0011]), + (7.8746, [0.0104, 0.0083, -0.001]), + (10.237, [0.003, 0.0019, -0.0006]), + (23.624, [0.0035, 0.0025, -0.0003]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0091, 0.0723, 0.0975]), + (0.79673, [0.0111, 0.0803, 0.0996]), + (3.1869, [0.0093, 0.0766, 0.0971]), + (3.9837, [0.008, 0.0675, 0.0882]), + (4.7804, [0.0051, 0.0331, 0.0443]), + (5.5771, [0.0007, 0.0042, 0.0056]), + (6.3739, [-0.0006, -0.0008, 0.0001]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [-0.0497, -0.468, -0.1182]), + (0.78812, [-0.0471, -0.4632, -0.1162]), + (1.5762, [-0.0487, -0.4623, -0.1169]), + (3.1525, [-0.048, -0.4485, -0.111]), + (5.5169, [-0.0495, -0.415, -0.0997]), + (7.8812, [-0.0496, -0.3667, -0.0844]), + (12.61, [-0.0401, -0.252, -0.0525]), + (16.551, [-0.0305, -0.1665, -0.0333]), + (20.491, [-0.0203, -0.0942, -0.0161]), + (22.067, [-0.0159, -0.0698, -0.0097]), + (23.644, [-0.012, -0.0494, -0.0046]), + (25.22, [-0.0085, -0.0331, -0.001]), + (26.796, [-0.0054, -0.0209, 0.002]), + (29.949, [-0.0015, -0.0061, 0.0046]), + (31.525, [-0.0004, -0.0022, 0.0047]), + (34.677, [0.001, 0.0015, 0.0042]), + (40.982, [0.0015, 0.0019, 0.0019]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3805, 0.4182, 0.5362]), + (0.79199, [0.3595, 0.4081, 0.5204]), + (1.584, [0.3287, 0.3672, 0.4591]), + (2.376, [0.2577, 0.2877, 0.3567]), + (3.168, [0.2109, 0.2317, 0.268]), + (3.96, [0.1566, 0.1701, 0.1889]), + (4.752, [0.1134, 0.1188, 0.1219]), + (5.544, [0.0734, 0.0764, 0.0718]), + (6.336, [0.04, 0.0414, 0.0322]), + (7.1279, [0.0248, 0.0255, 0.0156]), + (14.256, [0.0282, 0.0282, 0.0162]), + (30.096, [0.027, 0.0273, 0.0158]), + (30.888, [0.0168, 0.017, 0.0111]), + (31.68, [0.0026, 0.0028, 0.0024]), + (32.472, [0.0001, 0.0004, 0.0003]), + ], + }, + Radial { t: 2.5968, perp: 0.0, knots: &[(0.0, [0.0006, 0.0013, 0.0]), (11.852, [0.0003, 0.0015, 0.0003])] }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.0064, 0.0033, 0.0064]), + (0.7893, [0.0032, 0.0036, 0.0045]), + (11.05, [0.004, 0.0033, 0.0045]), + (12.629, [0.004, 0.0078, 0.009]), + (14.207, [0.004, 0.0082, 0.0093]), + (14.997, [0.004, 0.0115, 0.0126]), + (18.154, [0.0038, 0.0153, 0.0164]), + (19.732, [0.0081, 0.0193, 0.0203]), + (21.311, [0.0075, 0.0231, 0.0243]), + (22.89, [0.0078, 0.0273, 0.0241]), + (24.468, [0.0122, 0.0315, 0.0285]), + (26.047, [0.0117, 0.0353, 0.0326]), + (27.625, [0.0155, 0.0391, 0.0375]), + (29.993, [0.0157, 0.0428, 0.0443]), + (30.783, [0.0121, 0.0341, 0.0338]), + (31.572, [0.0025, 0.0096, 0.0098]), + (32.361, [-0.0003, -0.0005, -0.0003]), + ], + }, + Radial { + t: -1.5241, + perp: 0.0, + knots: &[ + (0.0, [-0.0172, 0.1197, 0.1516]), + (0.75, [-0.0209, 0.108, 0.123]), + (1.5, [-0.0273, 0.0548, 0.0719]), + (2.25, [-0.0278, 0.0032, 0.0097]), + (3.0, [-0.0174, 0.0029, 0.0036]), + (3.75, [-0.0262, -0.0047, -0.0013]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0117, 0.0453, 0.0214]), + (0.78519, [0.0058, 0.0357, 0.0206]), + (1.5704, [0.0043, 0.0211, 0.0138]), + (2.3556, [0.0022, 0.0209, 0.0125]), + (3.1407, [0.0021, 0.0155, 0.0097]), + (5.4963, [0.0012, 0.0061, 0.0046]), + (7.0667, [0.0013, 0.0025, 0.0027]), + (10.993, [0.0038, -0.0006, -0.0002]), + (14.133, [0.0047, 0.0039, 0.0021]), + (19.63, [0.0027, 0.0023, 0.0013]), + ], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0092, 0.1292, 0.1522]), + (0.7998, [0.0101, 0.0897, 0.0987]), + (1.5996, [0.0039, 0.0445, 0.05]), + (2.3994, [0.0008, 0.0056, 0.0061]), + (3.1992, [0.0003, 0.0021, 0.0023]), + (3.999, [0.0004, 0.0012, 0.0012]), + ], + }, + Radial { + t: 0.87489, + perp: 0.0, + knots: &[ + (0.0, [0.0005, -0.0014, -0.0024]), + (0.78535, [-0.0004, -0.0016, 0.0]), + (3.1414, [0.0011, 0.0008, 0.0017]), + (7.0682, [-0.0009, -0.0024, -0.0017]), + (7.8535, [-0.0004, -0.001, -0.0008]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0396, -0.0876, -0.0174]), + (3.1726, [-0.0376, -0.0806, -0.0169]), + (6.3453, [-0.0311, -0.0691, -0.014]), + (10.311, [-0.023, -0.0473, -0.0079]), + (13.484, [-0.0178, -0.0264, 0.0082]), + (15.07, [-0.0151, -0.0189, 0.0055]), + (15.863, [-0.014, -0.0186, 0.0019]), + (17.45, [-0.0116, -0.0309, -0.0052]), + (18.243, [-0.0102, -0.0301, -0.005]), + (22.208, [-0.0064, -0.0155, -0.0018]), + (24.588, [-0.0047, -0.0094, -0.0003]), + (30.933, [-0.0017, -0.0016, 0.0011]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.2874, 0.085, -0.0345]), + (0.77016, [0.2325, 0.0609, -0.0424]), + (1.5403, [0.1602, 0.0051, -0.0653]), + (2.3105, [0.1219, -0.0227, -0.0678]), + (3.0807, [0.0787, -0.0543, -0.0732]), + (3.8508, [0.0502, -0.0711, -0.0711]), + (4.621, [0.0288, -0.0768, -0.0677]), + (5.3911, [0.0163, -0.0774, -0.0616]), + (6.1613, [0.0069, -0.0711, -0.054]), + (7.7016, [-0.0006, -0.052, -0.0376]), + (9.242, [-0.0042, -0.0324, -0.0213]), + (10.012, [-0.0038, -0.0232, -0.015]), + (11.552, [-0.0019, -0.009, -0.0057]), + (12.323, [-0.0008, -0.0047, -0.0034]), + ], + }, + Radial { t: 1.6547, perp: 0.0, knots: &[(0.0, [0.0013, 0.0078, 0.0045]), (5.5051, [0.0001, 0.0016, 0.0013])] }, + Radial { t: 2.6079, perp: 0.0, knots: &[(0.0, [-0.0014, -0.0023, -0.0008]), (7.1149, [-0.0005, -0.0017, -0.0004])] }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0625, 0.0414, 0.0381]), + (0.79824, [0.0622, 0.0376, 0.0383]), + (9.5789, [0.0624, 0.0372, 0.0376]), + (45.5, [0.0642, 0.0365, 0.0429]), + (46.298, [0.0633, 0.036, 0.0423]), + (47.096, [0.0356, 0.0204, 0.0238]), + (47.895, [-0.0002, -0.0027, 0.0002]), + (48.693, [-0.0058, -0.0063, -0.0033]), + (59.07, [-0.0038, -0.005, -0.0026]), + (62.263, [-0.005, -0.0071, -0.0033]), + (65.456, [-0.0018, -0.0017, -0.0014]), + ], + }, +]; +const PRIME105_100_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_100_RAYS: &[Ray] = &[ + Ray { angle: 0.29, r0: 9.375, r1: 139.84, fade: 0.05, rgb: [0.0235, 0.0216, 0.0176] }, + Ray { angle: 0.59, r0: 9.375, r1: 130.47, fade: 0.05, rgb: [0.0176, 0.0137, 0.0137] }, + Ray { angle: 3.06, r0: 249.22, r1: 299.22, fade: 0.05, rgb: [0.0039, 0.0059, 0.0059] }, + Ray { angle: 7.3, r0: 8.5938, r1: 89.844, fade: 0.05, rgb: [0.0176, 0.0157, 0.0157] }, + Ray { angle: 7.56, r0: 8.5938, r1: 139.06, fade: 0.05, rgb: [0.0167, 0.0137, 0.0137] }, + Ray { angle: 8.33, r0: 10.938, r1: 139.84, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 17.47, r0: 16.406, r1: 125.78, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 17.73, r0: 17.188, r1: 132.81, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 24.56, r0: 21.094, r1: 121.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 39.0, r0: 14.844, r1: 89.062, fade: 0.05, rgb: [0.0157, 0.0118, 0.0108] }, + Ray { angle: 46.07, r0: 23.438, r1: 225.78, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 53.69, r0: 17.969, r1: 125.78, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 53.95, r0: 17.969, r1: 92.188, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 60.35, r0: 21.875, r1: 296.09, fade: 0.05, rgb: [0.0127, 0.0137, 0.0118] }, + Ray { angle: 74.59, r0: 86.719, r1: 164.06, fade: 0.05, rgb: [0.0088, 0.0078, 0.0098] }, + Ray { angle: 89.03, r0: 12.5, r1: 316.41, fade: 0.05, rgb: [0.0196, 0.0216, 0.0196] }, + Ray { angle: 95.88, r0: 7.8125, r1: 221.09, fade: 0.05, rgb: [0.0196, 0.0196, 0.0176] }, + Ray { angle: 96.22, r0: 7.8125, r1: 404.69, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 96.9, r0: 7.8125, r1: 100.78, fade: 0.05, rgb: [0.0216, 0.0196, 0.0196] }, + Ray { angle: 97.31, r0: 8.5938, r1: 207.81, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 97.66, r0: 8.5938, r1: 222.66, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 103.24, r0: 95.312, r1: 199.22, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 104.34, r0: 24.219, r1: 373.44, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 110.72, r0: 21.094, r1: 138.28, fade: 0.05, rgb: [0.0216, 0.0216, 0.0196] }, + Ray { angle: 118.66, r0: 18.75, r1: 231.25, fade: 0.05, rgb: [0.0176, 0.0157, 0.0157] }, + Ray { angle: 125.37, r0: 39.844, r1: 106.25, fade: 0.05, rgb: [0.0059, 0.0069, 0.0078] }, + Ray { angle: 147.3, r0: 22.656, r1: 278.12, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 161.56, r0: 15.625, r1: 246.88, fade: 0.05, rgb: [0.0275, 0.0275, 0.0255] }, + Ray { angle: 168.42, r0: 30.469, r1: 168.75, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 168.69, r0: 30.469, r1: 238.28, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 183.13, r0: 71.094, r1: 231.25, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 197.88, r0: 16.406, r1: 131.25, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 198.16, r0: 16.406, r1: 100.78, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 204.8, r0: 19.531, r1: 121.09, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 219.44, r0: 17.969, r1: 89.062, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 225.6, r0: 21.875, r1: 128.12, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 225.87, r0: 21.875, r1: 227.34, fade: 0.05, rgb: [0.0118, 0.0098, 0.0098] }, + Ray { angle: 233.36, r0: 21.094, r1: 120.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 240.18, r0: 19.531, r1: 225.0, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 254.66, r0: 42.188, r1: 152.34, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 268.98, r0: 107.81, r1: 195.31, fade: 0.05, rgb: [0.0157, 0.0157, 0.0196] }, + Ray { angle: 269.73, r0: 11.719, r1: 128.12, fade: 0.05, rgb: [0.0314, 0.0412, 0.0196] }, + Ray { angle: 269.99, r0: 11.719, r1: 128.12, fade: 0.05, rgb: [0.0314, 0.0412, 0.0196] }, + Ray { angle: 270.24, r0: 10.156, r1: 142.97, fade: 0.05, rgb: [0.0216, 0.0333, 0.0118] }, + Ray { angle: 275.32, r0: 8.5938, r1: 196.09, fade: 0.05, rgb: [0.0137, 0.0157, 0.0137] }, + Ray { angle: 275.63, r0: 8.5938, r1: 181.25, fade: 0.05, rgb: [0.0196, 0.0176, 0.0176] }, + Ray { angle: 276.07, r0: 8.5938, r1: 196.09, fade: 0.05, rgb: [0.0235, 0.0235, 0.0235] }, + Ray { angle: 276.37, r0: 8.5938, r1: 196.09, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 277.18, r0: 101.56, r1: 196.09, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 283.3, r0: 76.562, r1: 197.66, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 284.4, r0: 22.656, r1: 201.56, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 290.89, r0: 15.625, r1: 137.5, fade: 0.05, rgb: [0.0196, 0.0176, 0.0157] }, + Ray { angle: 297.64, r0: 160.94, r1: 207.03, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 298.8, r0: 21.875, r1: 222.66, fade: 0.05, rgb: [0.0176, 0.0176, 0.0176] }, + Ray { angle: 327.45, r0: 27.344, r1: 288.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 341.59, r0: 16.406, r1: 311.72, fade: 0.05, rgb: [0.0275, 0.0294, 0.0275] }, + Ray { angle: 348.76, r0: 32.031, r1: 438.28, fade: 0.05, rgb: [0.0255, 0.0255, 0.0255] }, +]; +const PRIME105_125_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.4665, 0.4864, 0.5486]), + (0.8, [0.4652, 0.4948, 0.5513]), + (10.4, [0.474, 0.5028, 0.5494]), + (18.4, [0.4524, 0.4967, 0.5349]), + (43.2, [0.3331, 0.3987, 0.4624]), + (55.2, [0.2931, 0.3506, 0.4225]), + (60.0, [0.2886, 0.3369, 0.4085]), + (65.6, [0.2907, 0.3314, 0.3903]), + (74.4, [0.3071, 0.3299, 0.3622]), + (76.0, [0.3162, 0.3292, 0.3566]), + (77.6, [0.3387, 0.3318, 0.351]), + (78.4, [0.3395, 0.332, 0.3484]), + (80.0, [0.3203, 0.3297, 0.3409]), + (80.8, [0.3198, 0.3278, 0.3372]), + (92.8, [0.3548, 0.3242, 0.286]), + (108.8, [0.4019, 0.332, 0.2219]), + (114.4, [0.415, 0.338, 0.2046]), + (126.4, [0.4502, 0.3455, 0.1828]), + (134.4, [0.4591, 0.3516, 0.1712]), + (144.0, [0.4528, 0.3576, 0.1646]), + (153.6, [0.4355, 0.3511, 0.1679]), + (167.2, [0.3838, 0.3245, 0.1678]), + (182.4, [0.2948, 0.268, 0.1627]), + (192.8, [0.2245, 0.2106, 0.1578]), + (204.8, [0.1331, 0.133, 0.1556]), + (212.0, [0.0697, 0.0808, 0.1512]), + (218.4, [0.014, 0.0308, 0.1543]), + (228.0, [-0.0785, -0.0487, 0.1506]), + (235.2, [-0.1469, -0.1018, 0.1437]), + (238.4, [-0.1754, -0.1219, 0.1435]), + (245.6, [-0.2361, -0.1568, 0.1409]), + (252.8, [-0.2806, -0.1846, 0.138]), + (258.4, [-0.3055, -0.2008, 0.134]), + (264.8, [-0.3187, -0.2047, 0.1318]), + (272.8, [-0.3145, -0.1969, 0.1257]), + (279.2, [-0.2888, -0.179, 0.1182]), + (288.0, [-0.2321, -0.1407, 0.1117]), + (295.2, [-0.1771, -0.1002, 0.1067]), + (304.0, [-0.1141, -0.0594, 0.088]), + (311.2, [-0.0657, -0.0212, 0.0798]), + (314.4, [-0.0468, -0.0052, 0.0774]), + (321.6, [-0.0082, 0.0254, 0.068]), + (332.8, [0.0504, 0.0767, 0.0538]), + (340.8, [0.081, 0.1036, 0.0411]), + (353.6, [0.123, 0.1372, 0.0309]), + (361.6, [0.14, 0.1531, 0.0244]), + (368.8, [0.151, 0.1622, 0.0173]), + (380.8, [0.1541, 0.1631, 0.0008]), + (384.0, [0.1563, 0.1613, -0.0006]), + (396.8, [0.156, 0.1374, -0.0087]), + (406.4, [0.149, 0.115, -0.0152]), + (409.6, [0.1418, 0.1062, -0.0183]), + (420.0, [0.1131, 0.0798, -0.0193]), + (430.4, [0.0773, 0.0485, -0.0226]), + (444.8, [0.0305, 0.0094, -0.0221]), + (451.2, [0.0095, -0.0109, -0.0249]), + (461.6, [-0.0171, -0.0429, -0.0261]), + (468.0, [-0.0305, -0.0577, -0.0245]), + (480.0, [-0.0541, -0.0756, -0.0225]), + (484.8, [-0.0668, -0.0863, -0.0258]), + (492.0, [-0.0804, -0.0971, -0.0244]), + (500.8, [-0.0933, -0.1028, -0.0208]), + (505.6, [-0.1029, -0.1084, -0.0243]), + (516.8, [-0.1156, -0.1087, -0.0244]), + (527.2, [-0.1253, -0.1114, -0.0308]), + (543.2, [-0.1215, -0.0985, -0.0376]), + (551.2, [-0.1112, -0.0854, -0.0385]), + (559.2, [-0.0939, -0.0675, -0.0379]), + (569.6, [-0.0634, -0.0398, -0.0367]), + (576.8, [-0.0383, -0.0186, -0.0358]), + (582.4, [-0.0154, 0.0002, -0.0338]), + (595.2, [0.0449, 0.0504, -0.0215]), + (603.2, [0.0774, 0.0755, -0.0154]), + (612.0, [0.1019, 0.0928, -0.0118]), + (619.2, [0.11, 0.096, -0.0101]), + (626.4, [0.1069, 0.0874, -0.0106]), + (630.4, [0.0979, 0.0744, -0.0131]), + (633.6, [0.0928, 0.0688, -0.0127]), + (639.2, [0.0772, 0.0513, -0.0137]), + (644.8, [0.0573, 0.0305, -0.015]), + (652.0, [0.0254, -0.0006, -0.0177]), + (664.8, [-0.045, -0.0631, -0.0283]), + (671.2, [-0.0762, -0.0886, -0.0338]), + (676.8, [-0.095, -0.098, -0.0366]), + (687.2, [-0.1143, -0.096, -0.0442]), + (690.4, [-0.1125, -0.0904, -0.0457]), + (695.2, [-0.1002, -0.0741, -0.0447]), + (700.8, [-0.073, -0.0491, -0.0432]), + (705.6, [-0.0495, -0.037, -0.0524]), + (707.2, [-0.0336, -0.0289, -0.0541]), + (708.8, [-0.0102, -0.0157, -0.0531]), + (711.2, [0.037, 0.0118, -0.0469]), + (712.0, [0.0517, 0.0199, -0.0447]), + (712.8, [0.063, 0.0256, -0.0428]), + (713.6, [0.0705, 0.0291, -0.0411]), + (715.2, [0.0754, 0.0302, -0.0385]), + (716.8, [0.0685, 0.0257, -0.0372]), + (718.4, [0.0525, 0.0159, -0.0368]), + (723.2, [-0.0123, -0.0254, -0.0388]), + (725.6, [-0.0398, -0.0404, -0.0402]), + (727.2, [-0.0506, -0.0455, -0.0403]), + (729.6, [-0.0559, -0.0465, -0.0386]), + (734.4, [-0.0554, -0.0385, -0.0329]), + (736.8, [-0.0491, -0.0351, -0.033]), + (739.2, [-0.0459, -0.036, -0.035]), + (744.8, [-0.0504, -0.0445, -0.0434]), + (750.4, [-0.0628, -0.0632, -0.0521]), + (756.8, [-0.0668, -0.0789, -0.0598]), + (759.2, [-0.0659, -0.0791, -0.0699]), + (762.4, [-0.0626, -0.0732, -0.0787]), + (765.6, [-0.0547, -0.0542, -0.0805]), + (769.6, [-0.0391, -0.0249, -0.0674]), + (772.0, [-0.0268, -0.0032, -0.0499]), + (774.4, [-0.0175, 0.0135, -0.0359]), + (775.2, [-0.0152, 0.0174, -0.0313]), + (777.6, [-0.0099, 0.022, -0.0153]), + (778.4, [-0.0095, 0.0208, -0.0133]), + (780.0, [-0.0128, 0.013, -0.0144]), + (780.8, [-0.0159, 0.0057, -0.0185]), + (781.6, [-0.02, -0.005, -0.0254]), + (784.0, [-0.0294, -0.0305, -0.042]), + (785.6, [-0.0331, -0.0415, -0.0487]), + (788.8, [-0.0376, -0.0543, -0.0547]), + (792.0, [-0.0368, -0.0588, -0.0561]), + (795.2, [-0.0373, -0.0586, -0.0561]), + (799.2, [-0.0332, -0.0506, -0.0507]), + (801.6, [-0.0278, -0.0435, -0.0441]), + (803.2, [-0.0267, -0.042, -0.0392]), + (806.4, [-0.0212, -0.0351, -0.0248]), + (808.8, [-0.0074, -0.0226, -0.0094]), + (811.2, [0.0028, -0.0072, 0.0001]), + (812.0, [0.0086, -0.0028, 0.0025]), + (812.8, [0.0117, 0.0001, 0.0031]), + (813.6, [0.0106, 0.0005, 0.0016]), + (815.2, [-0.0014, -0.0059, -0.0036]), + (816.0, [-0.006, -0.0061, -0.0039]), + (816.8, [-0.0023, 0.001, -0.0014]), + (817.6, [0.0068, 0.0099, 0.0022]), + (818.4, [0.0085, 0.011, 0.0026]), + (819.2, [-0.002, 0.0032, -0.001]), + (820.0, [-0.0052, -0.0013, -0.0021]), + (820.8, [0.0032, 0.0026, 0.0012]), + (821.6, [-0.0025, 0.0009, 0.0013]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.4669, 0.4406, 0.3897]), + (0.8, [0.4529, 0.4326, 0.3854]), + (14.4, [0.4433, 0.4205, 0.381]), + (28.0, [0.4222, 0.3965, 0.3632]), + (45.6, [0.3763, 0.3496, 0.3384]), + (55.2, [0.3452, 0.3262, 0.3326]), + (68.8, [0.2846, 0.291, 0.3334]), + (78.4, [0.2358, 0.257, 0.3313]), + (93.6, [0.1416, 0.203, 0.3352]), + (104.8, [0.0713, 0.1568, 0.3282]), + (112.0, [0.0302, 0.1308, 0.3255]), + (124.0, [-0.0318, 0.0926, 0.3114]), + (130.4, [-0.0572, 0.0723, 0.3011]), + (135.2, [-0.0693, 0.0597, 0.293]), + (139.2, [-0.0763, 0.0491, 0.286]), + (148.0, [-0.0824, 0.0317, 0.2704]), + (153.6, [-0.0793, 0.0252, 0.2568]), + (158.4, [-0.0724, 0.0232, 0.2459]), + (167.2, [-0.0529, 0.0262, 0.2284]), + (174.4, [-0.0276, 0.0363, 0.2168]), + (183.2, [0.015, 0.0585, 0.2018]), + (194.4, [0.0752, 0.1013, 0.1832]), + (205.6, [0.1449, 0.1584, 0.1657]), + (219.2, [0.2467, 0.2406, 0.143]), + (232.0, [0.3513, 0.3254, 0.124]), + (237.6, [0.3962, 0.3554, 0.1164]), + (250.4, [0.4765, 0.3978, 0.0981]), + (256.8, [0.5029, 0.4084, 0.0904]), + (262.4, [0.5129, 0.4089, 0.0829]), + (271.2, [0.5024, 0.3932, 0.0741]), + (280.0, [0.4612, 0.3565, 0.0656]), + (288.0, [0.3983, 0.3086, 0.0619]), + (297.6, [0.3084, 0.2422, 0.0571]), + (304.8, [0.2468, 0.1962, 0.0577]), + (312.8, [0.1821, 0.1435, 0.0543]), + (328.0, [0.0827, 0.0564, 0.0539]), + (330.4, [0.0668, 0.0417, 0.0517]), + (336.0, [0.0339, 0.0117, 0.0501]), + (340.8, [0.01, -0.0098, 0.0507]), + (346.4, [-0.016, -0.0325, 0.0494]), + (356.0, [-0.0557, -0.0681, 0.046]), + (362.4, [-0.0761, -0.0877, 0.0441]), + (368.8, [-0.092, -0.1039, 0.0429]), + (373.6, [-0.0982, -0.1101, 0.0455]), + (377.6, [-0.1054, -0.1161, 0.0441]), + (381.6, [-0.1091, -0.1166, 0.046]), + (390.4, [-0.1207, -0.1128, 0.0414]), + (400.0, [-0.1243, -0.0987, 0.0407]), + (404.0, [-0.1257, -0.0933, 0.037]), + (412.0, [-0.1152, -0.0785, 0.0356]), + (422.4, [-0.0915, -0.0605, 0.029]), + (445.6, [-0.0282, -0.0101, 0.0147]), + (452.0, [-0.0106, 0.0067, 0.0127]), + (457.6, [0.0001, 0.0203, 0.0069]), + (463.2, [0.0115, 0.0347, 0.0038]), + (468.8, [0.0203, 0.0444, -0.001]), + (480.8, [0.0381, 0.0563, -0.0097]), + (486.4, [0.0498, 0.0657, -0.0091]), + (492.8, [0.0586, 0.072, -0.0139]), + (501.6, [0.0678, 0.0739, -0.0217]), + (506.4, [0.0751, 0.0772, -0.0204]), + (517.6, [0.0833, 0.073, -0.0253]), + (524.8, [0.0891, 0.0734, -0.0227]), + (533.6, [0.0882, 0.0666, -0.0223]), + (542.4, [0.0819, 0.0559, -0.0211]), + (551.2, [0.069, 0.0396, -0.0226]), + (557.6, [0.0544, 0.0243, -0.0245]), + (570.4, [0.0146, -0.0125, -0.0282]), + (576.8, [-0.0095, -0.0331, -0.0305]), + (581.6, [-0.0301, -0.05, -0.0322]), + (595.2, [-0.0961, -0.1055, -0.0468]), + (604.0, [-0.1335, -0.1346, -0.054]), + (613.6, [-0.1609, -0.1538, -0.058]), + (620.8, [-0.1686, -0.1565, -0.0596]), + (627.2, [-0.166, -0.1488, -0.0592]), + (631.2, [-0.1573, -0.1358, -0.0568]), + (634.4, [-0.1522, -0.1302, -0.0571]), + (645.6, [-0.1172, -0.092, -0.0549]), + (653.6, [-0.0814, -0.0571, -0.0519]), + (666.4, [-0.0103, 0.0059, -0.041]), + (672.0, [0.017, 0.0283, -0.0361]), + (675.2, [0.029, 0.0359, -0.0343]), + (677.6, [0.0363, 0.0386, -0.0332]), + (688.0, [0.0556, 0.0363, -0.0257]), + (691.2, [0.054, 0.0308, -0.0241]), + (696.0, [0.0415, 0.0144, -0.0251]), + (701.6, [0.0142, -0.0109, -0.0268]), + (704.8, [-0.0001, -0.0193, -0.0214]), + (706.4, [-0.0095, -0.0235, -0.0177]), + (708.0, [-0.0252, -0.0316, -0.0157]), + (709.6, [-0.0487, -0.0449, -0.0167]), + (713.6, [-0.1293, -0.0913, -0.0276]), + (715.2, [-0.1476, -0.1001, -0.0313]), + (716.8, [-0.1518, -0.1009, -0.0337]), + (719.2, [-0.137, -0.0915, -0.0352]), + (724.0, [-0.0766, -0.0517, -0.0337]), + (726.4, [-0.0491, -0.036, -0.0324]), + (728.0, [-0.0375, -0.0301, -0.0321]), + (729.6, [-0.0329, -0.0283, -0.0328]), + (732.0, [-0.0327, -0.0301, -0.035]), + (734.4, [-0.0353, -0.0352, -0.0384]), + (738.4, [-0.0463, -0.0423, -0.0395]), + (740.8, [-0.0507, -0.0424, -0.0379]), + (744.0, [-0.0508, -0.0416, -0.036]), + (747.2, [-0.0494, -0.0373, -0.0316]), + (751.2, [-0.0398, -0.0217, -0.0253]), + (752.0, [-0.0394, -0.0214, -0.0258]), + (752.8, [-0.0414, -0.0256, -0.029]), + (754.4, [-0.0487, -0.0403, -0.0396]), + (755.2, [-0.0536, -0.0502, -0.0466]), + (756.8, [-0.0647, -0.0747, -0.0656]), + (759.2, [-0.0766, -0.1026, -0.0842]), + (761.6, [-0.0823, -0.118, -0.0925]), + (763.2, [-0.082, -0.1175, -0.0944]), + (765.6, [-0.0733, -0.1007, -0.0877]), + (767.2, [-0.0614, -0.077, -0.078]), + (769.6, [-0.042, -0.0338, -0.063]), + (772.0, [-0.0156, 0.0166, -0.0455]), + (773.6, [-0.0043, 0.0417, -0.0366]), + (775.2, [0.0044, 0.0599, -0.0254]), + (776.8, [0.0091, 0.0675, -0.0135]), + (777.6, [0.0091, 0.0668, -0.0109]), + (778.4, [0.0066, 0.0605, -0.0109]), + (780.0, [-0.0022, 0.0392, -0.0088]), + (780.8, [-0.0078, 0.0253, -0.0099]), + (782.4, [-0.0222, -0.0105, -0.0195]), + (783.2, [-0.0294, -0.0282, -0.0274]), + (784.0, [-0.0388, -0.0505, -0.0408]), + (784.8, [-0.046, -0.0686, -0.0528]), + (785.6, [-0.0506, -0.0801, -0.0612]), + (786.4, [-0.0516, -0.0832, -0.0638]), + (788.8, [-0.0422, -0.0662, -0.0544]), + (791.2, [-0.0336, -0.0519, -0.0489]), + (794.4, [-0.028, -0.0428, -0.0465]), + (796.0, [-0.0277, -0.0418, -0.0467]), + (800.0, [-0.0321, -0.0457, -0.0468]), + (802.4, [-0.0315, -0.0457, -0.045]), + (804.8, [-0.0282, -0.043, -0.0394]), + (806.4, [-0.0259, -0.041, -0.0336]), + (807.2, [-0.0216, -0.0377, -0.0288]), + (808.0, [-0.0154, -0.0321, -0.0222]), + (809.6, [-0.0072, -0.0199, -0.0094]), + (812.0, [0.013, -0.0018, 0.0051]), + (812.8, [0.0105, -0.0022, 0.0053]), + (813.6, [0.0069, -0.0058, 0.0031]), + (814.4, [0.0011, -0.0079, 0.0003]), + (815.2, [0.001, -0.0057, -0.0002]), + (816.0, [0.0001, -0.0004, -0.0017]), + (816.8, [0.002, 0.0037, -0.0038]), + (818.4, [-0.0022, 0.0002, -0.0077]), + (819.2, [-0.0063, -0.0023, -0.0063]), + (820.0, [-0.0037, -0.0003, -0.0011]), + (820.8, [0.004, 0.0046, 0.0053]), + (821.6, [0.0048, 0.0094, 0.0075]), + (822.4, [0.0003, 0.0006, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0912, 0.0817, 0.0672]), + (0.79943, [0.0789, 0.0732, 0.0614]), + (1.5989, [0.084, 0.0758, 0.0643]), + (3.1977, [0.0817, 0.0734, 0.0619]), + (11.192, [0.0818, 0.0733, 0.0619]), + (28.78, [0.084, 0.078, 0.0659]), + (73.548, [0.0853, 0.0813, 0.0692]), + (127.91, [0.0891, 0.0829, 0.0682]), + (183.07, [0.0849, 0.0811, 0.0698]), + (203.06, [0.0795, 0.0769, 0.07]), + (267.01, [0.0523, 0.0578, 0.0713]), + (291.79, [0.0444, 0.0527, 0.0716]), + (306.18, [0.0427, 0.0515, 0.072]), + (408.51, [0.0525, 0.0598, 0.0726]), + (560.4, [0.0535, 0.0601, 0.0731]), + (625.96, [0.0571, 0.0635, 0.0732]), + (681.92, [0.0634, 0.0686, 0.0743]), + (753.07, [0.0627, 0.067, 0.0742]), + (782.65, [0.0664, 0.0693, 0.0734]), + (794.64, [0.0814, 0.0787, 0.0765]), + (814.62, [0.0985, 0.085, 0.0775]), + (826.61, [0.1069, 0.093, 0.082]), + (830.61, [0.1069, 0.0915, 0.0816]), + (831.41, [0.1081, 0.0942, 0.0834]), + (833.01, [0.1153, 0.1104, 0.0943]), + (836.21, [0.1365, 0.1603, 0.1287]), + (838.61, [0.1486, 0.1909, 0.1541]), + (841.0, [0.1524, 0.2035, 0.1721]), + (843.4, [0.1467, 0.1922, 0.1768]), + (845.0, [0.1355, 0.1689, 0.1722]), + (848.2, [0.1001, 0.094, 0.1472]), + (851.4, [0.0524, 0.0026, 0.1084]), + (852.2, [0.0423, -0.0177, 0.0976]), + (853.79, [0.0257, -0.0514, 0.0775]), + (854.59, [0.0192, -0.0638, 0.0674]), + (855.39, [0.0137, -0.073, 0.057]), + (856.19, [0.0101, -0.0784, 0.0463]), + (856.99, [0.0089, -0.0785, 0.0379]), + (857.79, [0.0098, -0.073, 0.033]), + (859.39, [0.0176, -0.0494, 0.0266]), + (860.19, [0.0248, -0.0314, 0.0283]), + (861.79, [0.0455, 0.0191, 0.0464]), + (862.59, [0.0567, 0.0473, 0.0614]), + (863.39, [0.0692, 0.0779, 0.0803]), + (864.19, [0.0791, 0.1037, 0.0974]), + (864.99, [0.0863, 0.1225, 0.1104]), + (865.79, [0.0885, 0.1297, 0.1156]), + (872.98, [0.071, 0.111, 0.1077]), + (877.78, [0.0708, 0.106, 0.1053]), + (880.18, [0.069, 0.1034, 0.101]), + (881.77, [0.0649, 0.0977, 0.0953]), + (884.17, [0.0591, 0.0921, 0.0823]), + (886.57, [0.0484, 0.0816, 0.0612]), + (888.17, [0.0326, 0.0647, 0.0378]), + (889.77, [0.0119, 0.0423, 0.0171]), + (890.57, [0.0032, 0.0313, 0.0091]), + (891.37, [-0.008, 0.02, 0.0014]), + (892.17, [-0.0108, 0.0152, -0.0017]), + (892.97, [-0.0127, 0.0148, -0.0017]), + (894.57, [-0.0059, 0.0132, 0.0039]), + (895.37, [0.001, 0.0112, 0.0083]), + (896.96, [0.0101, 0.0107, 0.0142]), + (897.76, [0.0061, 0.003, 0.0125]), + (898.56, [0.0021, -0.0017, 0.0082]), + (899.36, [0.0021, -0.0017, 0.004]), + (900.16, [0.0055, 0.0018, 0.0013]), + (900.96, [0.0022, -0.0019, -0.0019]), + (901.76, [0.0016, 0.003, 0.0025]), + (902.56, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [0.1151, 0.2719, 0.0433]), + (0.79965, [0.1303, 0.2853, 0.0521]), + (1.5993, [0.1226, 0.2764, 0.0464]), + (2.399, [0.1206, 0.2723, 0.0469]), + (4.7979, [0.1039, 0.2451, 0.0412]), + (7.9965, [0.0796, 0.1946, 0.029]), + (12.794, [0.0495, 0.1239, 0.0173]), + (17.592, [0.03, 0.0591, 0.0033]), + (19.991, [0.024, 0.0341, -0.0015]), + (23.19, [0.0202, 0.0118, -0.0066]), + (26.389, [0.0193, 0.0011, -0.0095]), + (29.587, [0.0193, -0.0029, -0.0106]), + (33.585, [0.0206, -0.0011, -0.0095]), + (40.782, [0.0241, 0.0093, -0.0055]), + (47.979, [0.0272, 0.014, -0.004]), + (77.566, [0.0341, 0.0196, -0.0043]), + (78.366, [0.0338, 0.0189, -0.0042]), + (79.166, [0.0265, 0.0142, -0.0043]), + (79.965, [0.0144, 0.0064, -0.0041]), + (80.765, [0.01, 0.0028, -0.0038]), + (81.565, [0.0098, 0.0024, -0.0041]), + (157.53, [0.0209, 0.011, -0.0043]), + (158.33, [0.02, 0.0102, -0.0043]), + (159.13, [0.0095, 0.0034, -0.0043]), + (159.93, [-0.0031, -0.0058, -0.0043]), + (160.73, [-0.0047, -0.0073, -0.0042]), + (474.99, [-0.004, -0.0068, -0.0043]), + (543.76, [-0.0071, -0.0095, -0.0055]), + (568.55, [-0.0075, -0.009, -0.0037]), + (582.15, [-0.0123, -0.0123, -0.0045]), + (585.35, [-0.0101, -0.0106, -0.0031]), + (589.34, [-0.0108, -0.0114, -0.0046]), + (590.94, [-0.0098, -0.0098, -0.0029]), + (595.74, [-0.0099, -0.011, -0.0054]), + (597.34, [-0.0091, -0.0101, -0.0039]), + (604.54, [-0.0073, -0.0092, -0.0041]), + (605.34, [-0.0055, -0.008, -0.0031]), + (610.14, [-0.005, -0.0079, -0.0051]), + (611.74, [-0.0047, -0.0087, -0.0044]), + (614.93, [-0.0032, -0.0063, -0.0028]), + (619.73, [-0.0041, -0.0058, -0.0052]), + (625.33, [-0.0005, -0.0028, -0.0029]), + (626.93, [-0.002, -0.0044, -0.0047]), + (629.33, [-0.0012, -0.0035, -0.0026]), + (630.93, [-0.0023, -0.0047, -0.0035]), + (632.53, [-0.0003, -0.0021, -0.0012]), + (637.32, [0.0011, -0.0005, 0.0001]), + (639.72, [-0.0009, -0.0022, -0.0014]), + (640.52, [0.0, -0.0022, -0.0008]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (7.7937, [0.0, 0.0, 0.0]), + (8.5731, [-0.0083, -0.0133, -0.0427]), + (9.3524, [0.0003, 0.002, 0.0084]), + (10.132, [-0.003, -0.0032, -0.0011]), + (10.911, [-0.0051, -0.0074, -0.0047]), + (11.691, [-0.0063, -0.009, -0.0052]), + (12.47, [-0.0053, -0.0064, -0.0037]), + (13.249, [-0.0013, -0.0031, 0.0002]), + (14.808, [-0.0001, -0.0009, -0.0029]), + (15.587, [0.0017, 0.0015, 0.0011]), + (17.146, [0.0015, 0.0016, 0.0011]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [-0.0044, 0.0165, 0.0121]), + (1.5977, [-0.0032, 0.0168, 0.0145]), + (3.1955, [-0.0016, 0.0204, 0.0185]), + (3.9944, [-0.0026, 0.0182, 0.0171]), + (6.391, [-0.0021, 0.017, 0.0157]), + (10.385, [-0.003, 0.0172, 0.0159]), + (13.581, [-0.0013, 0.0179, 0.0169]), + (18.374, [-0.0025, 0.0167, 0.0154]), + (61.513, [0.0004, 0.0194, 0.0174]), + (62.312, [0.0003, 0.0177, 0.0157]), + (63.91, [0.0003, 0.0034, 0.003]), + (64.709, [0.0004, 0.0006, 0.0003]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [0.045, 0.443, 0.1729]), + (0.79862, [0.0487, 0.4494, 0.1671]), + (1.5972, [0.046, 0.4409, 0.1663]), + (3.1945, [0.0353, 0.414, 0.1526]), + (3.9931, [0.0332, 0.3986, 0.1442]), + (6.3889, [0.0287, 0.332, 0.1107]), + (9.5834, [0.0207, 0.2326, 0.0663]), + (11.181, [0.0179, 0.1882, 0.0489]), + (12.778, [0.0156, 0.1523, 0.0366]), + (14.375, [0.0135, 0.1224, 0.0284]), + (17.57, [0.0115, 0.0767, 0.0182]), + (20.764, [0.009, 0.04, 0.0091]), + (23.16, [0.0079, 0.0184, 0.0028]), + (24.757, [0.0071, 0.0068, -0.0012]), + (27.952, [0.0075, -0.0065, -0.006]), + (31.146, [0.0085, -0.0085, -0.0071]), + (42.327, [0.0156, 0.0082, -0.0011]), + (50.313, [0.0166, 0.0126, -0.0001]), + (77.466, [0.01, 0.0077, -0.0003]), + (145.35, [0.0012, 0.0007, -0.0004]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0008, 0.0194, 0.0117]), + (0.79873, [0.0019, 0.0241, 0.013]), + (1.5975, [-0.004, 0.0169, 0.0073]), + (2.3962, [-0.0016, 0.0196, 0.0091]), + (3.1949, [-0.0034, 0.0183, 0.0078]), + (5.5911, [-0.0035, 0.0177, 0.0086]), + (11.182, [-0.0013, 0.0193, 0.0108]), + (20.767, [-0.0021, 0.0188, 0.0099]), + (23.962, [-0.0032, 0.0217, 0.0122]), + (25.559, [-0.0055, 0.0134, 0.0095]), + (27.157, [-0.0053, 0.009, 0.008]), + (31.15, [-0.004, -0.0024, -0.0004]), + (43.131, [-0.0017, -0.0012, 0.0004]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[ + (0.0, [-0.0051, 0.0094, 0.0007]), + (0.7912, [-0.0025, 0.0105, 0.0047]), + (1.5824, [-0.004, 0.011, 0.0028]), + (2.3736, [-0.002, 0.0118, 0.0049]), + (3.1648, [-0.0038, 0.0109, 0.0042]), + (7.1208, [-0.0017, 0.0114, 0.005]), + (13.45, [-0.0018, 0.0116, 0.0047]), + (18.989, [-0.0021, 0.0187, 0.0084]), + (19.78, [-0.0023, 0.0172, 0.0075]), + (21.362, [-0.0014, 0.0037, 0.0017]), + (22.154, [-0.0014, 0.0001, 0.0001]), + ], + }, + Radial { + t: 0.39897, + perp: 0.0, + knots: &[ + (0.0, [0.0026, -0.0014, -0.0018]), + (0.77909, [0.0035, 0.0022, 0.0005]), + (1.5582, [0.0035, 0.0025, 0.0009]), + (3.1164, [0.0014, 0.0012, -0.0018]), + (3.8954, [0.0021, 0.002, 0.0]), + (7.0118, [0.0013, 0.0017, 0.0002]), + (8.57, [0.0026, 0.0024, 0.0003]), + (13.245, [0.0014, 0.0011, -0.0012]), + (28.826, [0.0024, 0.0021, 0.0]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.5375, 0.4461, 0.2299]), + (7.9212, [0.5215, 0.4317, 0.2148]), + (8.7134, [0.5319, 0.4408, 0.2181]), + (9.5055, [0.3206, 0.2664, 0.1331]), + (10.298, [0.0344, 0.0281, 0.0151]), + (11.09, [-0.0011, -0.001, 0.0]), + (11.882, [0.0038, 0.0034, 0.0019]), + (12.674, [0.0018, 0.0015, 0.0012]), + (14.258, [0.0018, 0.0017, 0.0016]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [0.0357, 0.0709, 0.0276]), + (0.79613, [0.0392, 0.0688, 0.0269]), + (6.369, [0.0392, 0.0702, 0.0271]), + (8.7574, [0.0387, 0.0682, 0.0243]), + (42.195, [0.0418, 0.0683, 0.0247]), + (43.787, [0.0479, 0.0719, 0.029]), + (46.175, [0.0398, 0.067, 0.0237]), + (50.952, [0.0337, 0.0639, 0.0201]), + (52.544, [0.0368, 0.0658, 0.0218]), + (59.709, [0.0369, 0.0661, 0.0214]), + (63.69, [0.035, 0.0628, 0.0202]), + (64.486, [0.0321, 0.0575, 0.0186]), + (66.078, [0.0054, 0.0125, 0.0035]), + (66.875, [-0.0011, 0.0017, -0.0001]), + (67.671, [-0.0028, -0.0004, -0.0012]), + (71.651, [-0.0038, -0.0021, -0.0018]), + (92.351, [-0.0019, -0.0007, -0.0012]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.4758, 0.6007, 0.7234]), + (1.5834, [0.3106, 0.4603, 0.6349]), + (2.375, [0.2109, 0.3771, 0.5827]), + (3.1667, [0.1621, 0.3399, 0.5584]), + (3.9584, [0.1526, 0.3304, 0.5522]), + (4.7501, [0.153, 0.3317, 0.5531]), + (5.5418, [0.1479, 0.3223, 0.5487]), + (6.3335, [0.1375, 0.301, 0.5406]), + (7.9168, [0.0509, 0.113, 0.4694]), + (8.7085, [0.0415, 0.093, 0.423]), + (9.5002, [0.0343, 0.0778, 0.3642]), + (10.292, [0.0297, 0.0672, 0.3061]), + (11.084, [0.0231, 0.0519, 0.2484]), + (11.875, [0.0179, 0.0401, 0.1874]), + (12.667, [0.0111, 0.0242, 0.1261]), + (13.459, [0.007, 0.0146, 0.0647]), + (14.25, [0.0032, 0.0063, 0.009]), + (15.042, [0.0024, 0.0047, 0.0034]), + (16.625, [0.0011, 0.0018, 0.0013]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (13.514, [0.0, 0.0, 0.0]), + (14.309, [0.0049, -0.0115, -0.0106]), + (15.104, [0.0039, 0.0018, -0.0006]), + (15.899, [0.0047, 0.0021, 0.0015]), + (17.488, [0.0069, 0.0043, 0.0011]), + (19.078, [0.0037, 0.0023, 0.0008]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.2124, 0.1781, 0.0331]), + (0.78746, [0.2039, 0.1635, 0.0291]), + (1.5749, [0.1663, 0.1348, 0.0248]), + (3.1498, [0.0896, 0.0686, 0.0088]), + (3.9373, [0.0617, 0.0482, 0.0042]), + (4.7247, [0.0455, 0.0352, 0.0036]), + (6.2997, [0.0245, 0.0181, 0.0011]), + (7.8746, [0.0146, 0.009, -0.0007]), + (10.237, [0.0067, 0.004, -0.0003]), + (23.624, [0.0045, 0.0028, -0.0009]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0112, 0.0752, 0.0939]), + (0.79673, [0.0134, 0.082, 0.0976]), + (3.1869, [0.0104, 0.0769, 0.0923]), + (3.9837, [0.0079, 0.0722, 0.086]), + (4.7804, [0.0055, 0.0353, 0.0419]), + (5.5771, [0.0023, 0.0074, 0.0083]), + (6.3739, [0.0019, 0.0019, 0.0028]), + (7.1706, [0.0006, 0.0, 0.0006]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.0288, -0.3538, -0.0912]), + (0.78812, [0.0274, -0.3496, -0.0935]), + (2.3644, [0.0262, -0.3473, -0.0915]), + (4.7287, [0.0247, -0.3205, -0.083]), + (6.305, [0.024, -0.2919, -0.074]), + (7.8812, [0.0204, -0.2639, -0.0654]), + (11.034, [0.0165, -0.1973, -0.0473]), + (14.186, [0.0163, -0.1311, -0.0316]), + (17.339, [0.0168, -0.0754, -0.02]), + (19.703, [0.0177, -0.039, -0.0117]), + (21.279, [0.0187, -0.0178, -0.0054]), + (22.856, [0.0196, -0.0001, -0.0001]), + (24.432, [0.0192, 0.0135, 0.0036]), + (26.008, [0.0191, 0.0233, 0.0065]), + (27.584, [0.0187, 0.0298, 0.0091]), + (30.737, [0.0169, 0.034, 0.0101]), + (32.313, [0.0156, 0.0324, 0.0097]), + (44.135, [0.0036, 0.0071, 0.0011]), + (49.652, [0.0009, 0.0015, 0.0]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.3659, 0.4474, 0.6377]), + (0.79199, [0.387, 0.458, 0.6143]), + (1.584, [0.3559, 0.4128, 0.5396]), + (3.168, [0.2338, 0.2613, 0.3197]), + (3.96, [0.169, 0.1898, 0.2231]), + (4.752, [0.1182, 0.13, 0.1397]), + (5.544, [0.0838, 0.0882, 0.0861]), + (6.336, [0.0456, 0.049, 0.0407]), + (7.1279, [0.0268, 0.0294, 0.0174]), + (15.048, [0.0359, 0.037, 0.0209]), + (20.592, [0.0329, 0.034, 0.0193]), + (30.096, [0.0344, 0.0358, 0.0202]), + (30.888, [0.0246, 0.0257, 0.0143]), + (31.68, [0.0046, 0.0052, 0.0026]), + (32.472, [0.0001, 0.0006, 0.0003]), + ], + }, + Radial { + t: 2.5968, + perp: 0.0, + knots: &[ + (0.0, [0.0033, 0.0011, -0.0002]), + (0.79014, [0.0005, -0.0013, 0.0016]), + (4.7409, [0.0037, 0.0045, 0.0029]), + (6.3211, [0.0017, 0.0021, 0.0015]), + (10.272, [0.0018, 0.0038, 0.0019]), + (11.852, [0.0006, 0.0016, 0.0013]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.003, 0.0037, 0.004]), + (10.261, [0.0029, 0.0044, 0.0046]), + (11.05, [0.003, 0.0071, 0.0073]), + (12.629, [0.0028, 0.0076, 0.0078]), + (14.207, [0.0029, 0.0118, 0.0115]), + (15.786, [0.0031, 0.016, 0.0119]), + (16.575, [0.003, 0.0159, 0.0125]), + (18.154, [0.0072, 0.0199, 0.0162]), + (20.522, [0.0068, 0.0271, 0.0233]), + (22.1, [0.0114, 0.031, 0.0278]), + (23.679, [0.0116, 0.0328, 0.0332]), + (25.257, [0.0157, 0.0378, 0.0386]), + (26.836, [0.0134, 0.0388, 0.0429]), + (27.625, [0.0164, 0.0427, 0.0467]), + (29.993, [0.0192, 0.0517, 0.0563]), + (30.783, [0.0152, 0.0404, 0.0421]), + (31.572, [0.0027, 0.0102, 0.0092]), + (32.361, [-0.0008, -0.0013, -0.0008]), + (41.043, [0.0003, 0.0005, 0.0003]), + (42.622, [-0.0019, -0.0032, -0.001]), + (43.411, [-0.0006, -0.001, -0.0002]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (2.3556, [0.0, 0.0, 0.0]), + (3.1407, [-0.004, 0.0044, 0.0132]), + (3.9259, [-0.0087, -0.0048, -0.0009]), + (4.7111, [-0.0089, -0.0042, -0.0039]), + (5.4963, [-0.007, -0.0045, -0.0019]), + (6.2815, [-0.0088, -0.0067, -0.0068]), + (7.0667, [-0.0088, -0.0067, -0.0042]), + (7.8519, [-0.0081, -0.0044, 0.0014]), + (8.637, [-0.0077, -0.0042, 0.0017]), + (9.4222, [-0.01, -0.0079, -0.003]), + (10.207, [-0.0078, -0.0062, -0.0002]), + (10.993, [-0.0081, -0.0049, -0.0002]), + (11.778, [-0.008, -0.0047, 0.0002]), + (12.563, [-0.0098, -0.0071, -0.0017]), + (13.348, [-0.0076, -0.0045, 0.0012]), + (18.059, [-0.0068, -0.0045, -0.0006]), + (18.844, [-0.0085, -0.0064, -0.0025]), + (19.63, [-0.0065, -0.0047, 0.0]), + ], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0133, 0.1419, 0.1541]), + (0.7998, [0.0116, 0.0854, 0.089]), + (1.5996, [0.005, 0.0473, 0.0474]), + (2.3994, [0.0008, 0.004, 0.0036]), + (3.1992, [-0.0001, 0.0001, -0.0008]), + ], + }, + Radial { + t: 0.87489, + perp: 0.0, + knots: &[ + (0.0, [0.0046, 0.0041, 0.0011]), + (0.78535, [0.0029, 0.0035, 0.0019]), + (1.5707, [-0.0004, -0.0019, -0.0026]), + (3.1414, [-0.0019, -0.0017, -0.0019]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [-0.0325, -0.0619, -0.0111]), + (2.3795, [-0.0301, -0.0625, -0.0127]), + (4.759, [-0.0275, -0.0538, -0.0112]), + (7.9316, [-0.0204, -0.039, -0.0077]), + (10.311, [-0.0159, -0.0258, -0.0026]), + (13.484, [-0.0115, -0.0058, 0.0133]), + (14.277, [-0.0104, -0.002, 0.0127]), + (15.07, [-0.0099, -0.0001, 0.0092]), + (15.863, [-0.0086, -0.0002, 0.0059]), + (16.656, [-0.0071, -0.0055, 0.0025]), + (17.45, [-0.0067, -0.0136, -0.0015]), + (18.243, [-0.0059, -0.0157, -0.0031]), + (23.002, [-0.002, -0.0011, 0.0007]), + (25.381, [-0.0018, 0.002, 0.0012]), + (29.347, [-0.0007, 0.0048, 0.0025]), + (35.692, [0.0002, 0.0017, 0.001]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.3158, 0.1509, -0.0017]), + (0.77016, [0.2646, 0.0936, -0.0353]), + (1.5403, [0.1916, 0.0407, -0.0549]), + (2.3105, [0.1392, -0.001, -0.0619]), + (3.0807, [0.0963, -0.0313, -0.0652]), + (3.8508, [0.0602, -0.0489, -0.0647]), + (4.621, [0.0402, -0.0581, -0.0592]), + (6.1613, [0.013, -0.0563, -0.0462]), + (6.9315, [0.008, -0.0488, -0.039]), + (8.4718, [0.0016, -0.0333, -0.0253]), + (10.012, [-0.0021, -0.0195, -0.0129]), + (12.323, [-0.0002, -0.0035, -0.0029]), + ], + }, + Radial { + t: 1.6547, + perp: 0.0, + knots: &[ + (0.0, [0.0006, 0.0072, 0.0057]), + (1.5729, [0.0007, 0.007, 0.0035]), + (3.1458, [-0.0001, 0.0042, 0.0011]), + (3.9322, [0.001, 0.0036, 0.0012]), + (6.2916, [-0.0005, 0.0016, 0.0005]), + (9.4374, [0.0002, 0.0034, 0.002]), + (14.156, [-0.0004, 0.0014, 0.0001]), + ], + }, + Radial { + t: 2.6079, + perp: 0.0, + knots: &[ + (0.0, [-0.0011, -0.0009, 0.002]), + (0.79055, [-0.0029, -0.0018, -0.0024]), + (5.5338, [-0.0031, -0.0037, -0.0026]), + (7.1149, [-0.0012, -0.0018, -0.0015]), + (8.696, [-0.0024, -0.0038, -0.002]), + (11.858, [-0.0006, -0.0016, -0.0018]), + ], + }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.0869, 0.0439, 0.0523]), + (10.377, [0.0884, 0.0463, 0.0541]), + (46.298, [0.0842, 0.0455, 0.0529]), + (47.096, [0.0497, 0.0231, 0.0295]), + (47.895, [0.0059, -0.0025, 0.0013]), + (48.693, [-0.0025, -0.0065, -0.0022]), + (53.482, [0.0005, -0.0055, -0.0009]), + (59.868, [0.0001, -0.0051, -0.001]), + (60.666, [-0.0002, -0.0058, -0.0011]), + (61.465, [-0.0033, -0.0108, -0.0028]), + (62.263, [-0.0035, -0.0107, -0.0029]), + (63.061, [-0.001, -0.0064, -0.0014]), + (64.658, [0.002, -0.0016, 0.0003]), + (69.447, [0.0052, 0.0039, 0.0023]), + (71.044, [0.004, 0.0018, 0.0017]), + (98.184, [0.002, 0.0008, 0.001]), + ], + }, +]; +const PRIME105_125_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_125_RAYS: &[Ray] = &[ + Ray { angle: 0.2, r0: 19.531, r1: 136.72, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 3.03, r0: 107.03, r1: 148.44, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, + Ray { angle: 7.91, r0: 17.188, r1: 148.44, fade: 0.05, rgb: [0.0118, 0.0098, 0.0078] }, + Ray { angle: 8.2, r0: 17.188, r1: 153.12, fade: 0.05, rgb: [0.0118, 0.0098, 0.0078] }, + Ray { angle: 10.36, r0: 141.41, r1: 157.81, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 17.42, r0: 32.031, r1: 204.69, fade: 0.05, rgb: [0.0196, 0.0196, 0.0157] }, + Ray { angle: 24.35, r0: 28.906, r1: 144.53, fade: 0.05, rgb: [0.0078, 0.0059, 0.0039] }, + Ray { angle: 24.6, r0: 29.688, r1: 190.62, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 38.95, r0: 26.562, r1: 161.72, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 46.13, r0: 28.125, r1: 242.97, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 53.53, r0: 27.344, r1: 191.41, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 60.44, r0: 40.625, r1: 324.22, fade: 0.05, rgb: [0.0137, 0.0118, 0.0118] }, + Ray { angle: 74.51, r0: 103.12, r1: 162.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 89.06, r0: 56.25, r1: 367.97, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 95.98, r0: 15.625, r1: 248.44, fade: 0.05, rgb: [0.0176, 0.0176, 0.0137] }, + Ray { angle: 96.25, r0: 15.625, r1: 372.66, fade: 0.05, rgb: [0.0176, 0.0157, 0.0137] }, + Ray { angle: 96.9, r0: 157.03, r1: 249.22, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 97.21, r0: 17.188, r1: 249.22, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 97.75, r0: 83.594, r1: 250.0, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 103.31, r0: 88.281, r1: 207.81, fade: 0.05, rgb: [0.0078, 0.0098, 0.0078] }, + Ray { angle: 104.33, r0: 101.56, r1: 417.19, fade: 0.05, rgb: [0.0137, 0.0137, 0.0118] }, + Ray { angle: 110.61, r0: 25.0, r1: 213.28, fade: 0.05, rgb: [0.0196, 0.0196, 0.0167] }, + Ray { angle: 118.59, r0: 35.156, r1: 251.56, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 125.78, r0: 50.781, r1: 162.5, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 147.22, r0: 32.812, r1: 278.12, fade: 0.05, rgb: [0.0118, 0.0098, 0.0098] }, + Ray { angle: 161.56, r0: 28.125, r1: 246.88, fade: 0.05, rgb: [0.0294, 0.0275, 0.0255] }, + Ray { angle: 168.47, r0: 35.156, r1: 167.19, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 168.74, r0: 37.5, r1: 238.28, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 197.51, r0: 32.031, r1: 204.69, fade: 0.05, rgb: [0.0176, 0.0157, 0.0118] }, + Ray { angle: 197.79, r0: 30.469, r1: 153.12, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 204.7, r0: 26.562, r1: 196.88, fade: 0.05, rgb: [0.0118, 0.0098, 0.0078] }, + Ray { angle: 219.21, r0: 25.0, r1: 152.34, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 225.94, r0: 35.938, r1: 211.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 233.3, r0: 27.344, r1: 189.84, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 240.05, r0: 35.156, r1: 160.94, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 240.32, r0: 32.812, r1: 224.22, fade: 0.05, rgb: [0.0118, 0.0098, 0.0098] }, + Ray { angle: 254.4, r0: 42.188, r1: 153.91, fade: 0.05, rgb: [0.0078, 0.0059, 0.0049] }, + Ray { angle: 269.0, r0: 125.78, r1: 195.31, fade: 0.05, rgb: [0.0196, 0.0186, 0.0186] }, + Ray { angle: 269.71, r0: 17.969, r1: 147.66, fade: 0.05, rgb: [0.0235, 0.0294, 0.0118] }, + Ray { angle: 269.99, r0: 17.969, r1: 147.66, fade: 0.05, rgb: [0.0235, 0.0294, 0.0118] }, + Ray { angle: 270.29, r0: 22.656, r1: 149.22, fade: 0.05, rgb: [0.0196, 0.0255, 0.0078] }, + Ray { angle: 275.29, r0: 16.406, r1: 196.09, fade: 0.05, rgb: [0.0157, 0.0137, 0.0118] }, + Ray { angle: 275.73, r0: 15.625, r1: 121.88, fade: 0.05, rgb: [0.0176, 0.0137, 0.0118] }, + Ray { angle: 276.24, r0: 15.625, r1: 196.09, fade: 0.05, rgb: [0.0176, 0.0157, 0.0137] }, + Ray { angle: 283.35, r0: 112.5, r1: 200.0, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 284.45, r0: 71.875, r1: 201.56, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 290.71, r0: 23.438, r1: 208.59, fade: 0.05, rgb: [0.0216, 0.0196, 0.0167] }, + Ray { angle: 298.75, r0: 25.781, r1: 222.66, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 305.87, r0: 48.438, r1: 161.72, fade: 0.05, rgb: [0.0059, 0.0069, 0.0039] }, + Ray { angle: 327.33, r0: 32.812, r1: 323.44, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 341.64, r0: 28.906, r1: 349.22, fade: 0.05, rgb: [0.0294, 0.0294, 0.0275] }, + Ray { angle: 348.49, r0: 37.5, r1: 173.44, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 348.77, r0: 38.281, r1: 507.03, fade: 0.05, rgb: [0.0255, 0.0255, 0.0235] }, +]; +const PRIME105_150_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.5797, 0.5585, 0.4441]), + (0.8, [0.5658, 0.547, 0.4338]), + (24.8, [0.573, 0.5581, 0.4481]), + (41.6, [0.563, 0.5603, 0.4649]), + (60.8, [0.5384, 0.5303, 0.4737]), + (71.2, [0.5357, 0.511, 0.4748]), + (77.6, [0.5433, 0.4991, 0.4677]), + (87.2, [0.5316, 0.481, 0.4541]), + (121.6, [0.5232, 0.4353, 0.3953]), + (140.8, [0.4862, 0.4067, 0.3741]), + (171.2, [0.3729, 0.3568, 0.3533]), + (184.0, [0.3071, 0.3163, 0.345]), + (193.6, [0.2453, 0.2794, 0.3406]), + (203.2, [0.1726, 0.2351, 0.3351]), + (209.6, [0.1206, 0.2004, 0.3326]), + (232.0, [-0.0451, 0.0667, 0.3162]), + (242.4, [-0.1182, 0.0221, 0.3086]), + (248.0, [-0.1495, 0.0036, 0.3014]), + (252.0, [-0.169, -0.0073, 0.2962]), + (260.8, [-0.1951, -0.0267, 0.284]), + (264.8, [-0.193, -0.0289, 0.2792]), + (268.8, [-0.1838, -0.0258, 0.2739]), + (272.0, [-0.1724, -0.023, 0.2675]), + (275.2, [-0.1578, -0.0172, 0.2626]), + (280.0, [-0.1308, -0.0021, 0.2562]), + (284.8, [-0.0969, 0.0182, 0.25]), + (288.8, [-0.0654, 0.0379, 0.2452]), + (293.6, [-0.0247, 0.0631, 0.2396]), + (304.0, [0.0605, 0.1114, 0.2161]), + (313.6, [0.1372, 0.1584, 0.2069]), + (325.6, [0.2162, 0.2087, 0.1916]), + (332.0, [0.2533, 0.23, 0.1777]), + (341.6, [0.2922, 0.2525, 0.1638]), + (355.2, [0.3123, 0.2531, 0.1373]), + (368.8, [0.3072, 0.2375, 0.1122]), + (377.6, [0.2843, 0.2128, 0.0849]), + (384.0, [0.2667, 0.1962, 0.072]), + (390.4, [0.2394, 0.1783, 0.0561]), + (394.4, [0.2222, 0.1685, 0.0503]), + (403.2, [0.1754, 0.1414, 0.0331]), + (408.8, [0.1484, 0.1276, 0.0272]), + (411.2, [0.1384, 0.1231, 0.027]), + (419.2, [0.0967, 0.0933, 0.0197]), + (423.2, [0.0741, 0.0734, 0.0139]), + (427.2, [0.0572, 0.0589, 0.0149]), + (432.0, [0.035, 0.0403, 0.0132]), + (434.4, [0.0273, 0.0342, 0.0155]), + (438.4, [0.0075, 0.0186, 0.0112]), + (443.2, [-0.0129, 0.002, 0.0091]), + (448.8, [-0.0318, -0.015, 0.0102]), + (452.8, [-0.0467, -0.0328, 0.008]), + (456.0, [-0.0557, -0.0425, 0.0084]), + (460.0, [-0.0652, -0.0514, 0.0105]), + (462.4, [-0.0689, -0.0537, 0.0148]), + (466.4, [-0.0846, -0.0648, 0.0098]), + (475.2, [-0.0978, -0.0668, 0.0184]), + (487.2, [-0.1045, -0.0609, 0.0337]), + (490.4, [-0.1091, -0.0619, 0.0346]), + (496.0, [-0.1145, -0.057, 0.0397]), + (500.0, [-0.1158, -0.0519, 0.0455]), + (505.6, [-0.1202, -0.0495, 0.0497]), + (519.2, [-0.1287, -0.0489, 0.0527]), + (528.0, [-0.129, -0.0586, 0.0442]), + (532.0, [-0.1296, -0.0627, 0.0367]), + (540.0, [-0.1205, -0.0638, 0.0274]), + (544.8, [-0.1067, -0.0546, 0.0279]), + (554.4, [-0.0779, -0.0406, 0.0227]), + (559.2, [-0.0577, -0.0328, 0.0212]), + (563.2, [-0.0338, -0.0229, 0.0235]), + (566.4, [-0.0084, -0.012, 0.0288]), + (571.2, [0.0217, -0.0014, 0.0278]), + (575.2, [0.0482, 0.012, 0.0304]), + (577.6, [0.0614, 0.0194, 0.029]), + (580.0, [0.0715, 0.0257, 0.0226]), + (584.0, [0.0904, 0.0445, 0.0241]), + (588.0, [0.1075, 0.0676, 0.0299]), + (593.6, [0.1213, 0.0953, 0.031]), + (596.0, [0.1224, 0.1027, 0.0275]), + (601.6, [0.1295, 0.1225, 0.0247]), + (605.6, [0.1262, 0.1218, 0.0126]), + (612.8, [0.1237, 0.1241, 0.0067]), + (616.8, [0.115, 0.1148, -0.0008]), + (620.0, [0.1051, 0.1041, -0.0093]), + (626.4, [0.0903, 0.0858, -0.0189]), + (628.8, [0.0851, 0.0779, -0.018]), + (630.4, [0.0789, 0.0692, -0.0191]), + (635.2, [0.0657, 0.0546, -0.0221]), + (638.4, [0.0549, 0.0412, -0.0251]), + (645.6, [0.0323, 0.0122, -0.0217]), + (650.4, [0.0116, -0.0131, -0.0183]), + (653.6, [-0.0071, -0.033, -0.0168]), + (659.2, [-0.0508, -0.0743, -0.0219]), + (661.6, [-0.0671, -0.0898, -0.0235]), + (665.6, [-0.1059, -0.1232, -0.0383]), + (673.6, [-0.1696, -0.1762, -0.0638]), + (677.6, [-0.1953, -0.1932, -0.076]), + (684.8, [-0.2446, -0.2266, -0.1101]), + (688.0, [-0.2595, -0.24, -0.1283]), + (691.2, [-0.2651, -0.2467, -0.1465]), + (694.4, [-0.2585, -0.2443, -0.1612]), + (697.6, [-0.2329, -0.2262, -0.1561]), + (700.8, [-0.1884, -0.1912, -0.1475]), + (703.2, [-0.1462, -0.1567, -0.1361]), + (704.8, [-0.112, -0.1288, -0.1247]), + (706.4, [-0.0669, -0.0901, -0.108]), + (707.2, [-0.0384, -0.0653, -0.0977]), + (708.0, [-0.0051, -0.0357, -0.0831]), + (708.8, [0.0323, -0.0025, -0.0666]), + (709.6, [0.0749, 0.0361, -0.0471]), + (711.2, [0.1664, 0.1209, -0.0027]), + (712.0, [0.207, 0.1581, 0.0161]), + (712.8, [0.2411, 0.1905, 0.032]), + (714.4, [0.288, 0.24, 0.058]), + (716.0, [0.3078, 0.265, 0.0714]), + (717.6, [0.3048, 0.2697, 0.0728]), + (719.2, [0.2795, 0.2528, 0.0662]), + (720.0, [0.2605, 0.2367, 0.0599]), + (722.4, [0.1847, 0.1682, 0.0353]), + (725.6, [0.0691, 0.0683, -0.002]), + (727.2, [0.0152, 0.0222, -0.0206]), + (728.8, [-0.0328, -0.0219, -0.0389]), + (730.4, [-0.072, -0.0597, -0.0543]), + (732.0, [-0.1023, -0.0911, -0.0677]), + (733.6, [-0.1241, -0.1148, -0.0783]), + (735.2, [-0.1391, -0.1302, -0.0859]), + (737.6, [-0.1504, -0.1407, -0.0919]), + (740.8, [-0.1536, -0.1435, -0.0954]), + (743.2, [-0.1497, -0.1412, -0.0961]), + (748.8, [-0.1227, -0.1292, -0.095]), + (751.2, [-0.1176, -0.1265, -0.0971]), + (755.2, [-0.1276, -0.1311, -0.1105]), + (759.2, [-0.1482, -0.1458, -0.1414]), + (761.6, [-0.1516, -0.1463, -0.1528]), + (765.6, [-0.138, -0.1312, -0.1588]), + (772.0, [-0.1251, -0.1025, -0.1486]), + (775.2, [-0.1192, -0.0886, -0.1498]), + (777.6, [-0.1171, -0.0866, -0.1556]), + (780.0, [-0.1188, -0.0936, -0.1778]), + (780.8, [-0.1212, -0.0995, -0.1892]), + (782.4, [-0.1286, -0.1178, -0.2118]), + (784.8, [-0.1382, -0.138, -0.2317]), + (790.4, [-0.1471, -0.1534, -0.2315]), + (796.0, [-0.1475, -0.165, -0.2275]), + (801.6, [-0.1313, -0.155, -0.2233]), + (805.6, [-0.1095, -0.1355, -0.2162]), + (808.0, [-0.1042, -0.1306, -0.2109]), + (809.6, [-0.099, -0.1265, -0.204]), + (811.2, [-0.0887, -0.1162, -0.1909]), + (813.6, [-0.078, -0.0966, -0.1603]), + (815.2, [-0.0705, -0.0808, -0.1272]), + (816.0, [-0.059, -0.0665, -0.1035]), + (816.8, [-0.0412, -0.0468, -0.0733]), + (818.4, [0.0101, 0.0052, -0.004]), + (819.2, [0.0338, 0.0277, 0.0263]), + (820.0, [0.0396, 0.0368, 0.0415]), + (820.8, [0.0155, 0.0224, 0.033]), + (821.6, [-0.044, -0.0166, -0.0026]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.332, 0.3562, 0.4701]), + (46.4, [0.3186, 0.3339, 0.4356]), + (63.2, [0.3068, 0.3353, 0.4296]), + (87.2, [0.2621, 0.3366, 0.4271]), + (125.6, [0.186, 0.2957, 0.4135]), + (132.8, [0.1803, 0.2869, 0.4038]), + (146.4, [0.1878, 0.2789, 0.381]), + (172.0, [0.2338, 0.2683, 0.3354]), + (185.6, [0.2781, 0.2811, 0.3141]), + (192.8, [0.3105, 0.2957, 0.3024]), + (205.6, [0.3827, 0.3314, 0.2804]), + (232.8, [0.5374, 0.4407, 0.2403]), + (240.0, [0.5742, 0.4598, 0.2312]), + (252.0, [0.6198, 0.4766, 0.2191]), + (260.8, [0.6323, 0.4783, 0.2132]), + (269.6, [0.6032, 0.4596, 0.2048]), + (280.8, [0.5276, 0.4115, 0.1973]), + (313.6, [0.1976, 0.1901, 0.1794]), + (326.4, [0.0869, 0.1134, 0.1706]), + (332.8, [0.0385, 0.0795, 0.1691]), + (337.6, [0.0081, 0.0578, 0.1679]), + (342.4, [-0.0173, 0.0394, 0.1666]), + (352.8, [-0.0555, 0.0175, 0.1662]), + (357.6, [-0.0667, 0.0127, 0.1676]), + (360.8, [-0.076, 0.0067, 0.1659]), + (367.2, [-0.0839, 0.004, 0.1674]), + (372.0, [-0.0828, 0.0074, 0.1716]), + (378.4, [-0.0764, 0.0153, 0.1792]), + (385.6, [-0.0666, 0.0216, 0.1806]), + (395.2, [-0.0377, 0.0332, 0.1831]), + (403.2, [-0.0078, 0.0442, 0.1841]), + (413.6, [0.0254, 0.0527, 0.1755]), + (418.4, [0.0427, 0.0627, 0.1712]), + (424.8, [0.0676, 0.0823, 0.1676]), + (436.0, [0.0989, 0.1062, 0.1487]), + (439.2, [0.1105, 0.1152, 0.1475]), + (456.8, [0.1501, 0.151, 0.1216]), + (463.2, [0.1541, 0.1531, 0.1043]), + (467.2, [0.164, 0.1586, 0.1029]), + (476.0, [0.1663, 0.1476, 0.0826]), + (484.0, [0.1598, 0.13, 0.0615]), + (492.0, [0.1594, 0.12, 0.0445]), + (498.4, [0.1583, 0.1072, 0.0302]), + (501.6, [0.1552, 0.0989, 0.0202]), + (512.8, [0.1517, 0.0837, 0.0014]), + (517.6, [0.1501, 0.0792, -0.0044]), + (520.0, [0.1476, 0.0771, -0.009]), + (524.8, [0.1432, 0.078, -0.0115]), + (529.6, [0.1377, 0.0771, -0.0128]), + (532.0, [0.1357, 0.0773, -0.011]), + (538.4, [0.1226, 0.0727, -0.0112]), + (545.6, [0.0964, 0.0544, -0.0176]), + (548.8, [0.0854, 0.0477, -0.0178]), + (555.2, [0.0581, 0.0313, -0.0197]), + (560.8, [0.0289, 0.0176, -0.0213]), + (564.0, [0.0061, 0.0066, -0.0258]), + (567.2, [-0.0225, -0.0072, -0.0337]), + (572.0, [-0.0571, -0.0222, -0.0372]), + (576.8, [-0.0923, -0.042, -0.0448]), + (578.4, [-0.1025, -0.0484, -0.0459]), + (581.6, [-0.1193, -0.0607, -0.0431]), + (584.8, [-0.1372, -0.0787, -0.0485]), + (588.8, [-0.1585, -0.1057, -0.0592]), + (593.6, [-0.175, -0.134, -0.0646]), + (602.4, [-0.1915, -0.1746, -0.0641]), + (606.4, [-0.1916, -0.1783, -0.0551]), + (610.4, [-0.1945, -0.1857, -0.0549]), + (614.4, [-0.1938, -0.1872, -0.0525]), + (617.6, [-0.1885, -0.1823, -0.0482]), + (621.6, [-0.1793, -0.1727, -0.0404]), + (627.2, [-0.1704, -0.161, -0.0363]), + (629.6, [-0.1666, -0.1549, -0.0384]), + (640.0, [-0.1393, -0.1197, -0.0394]), + (645.6, [-0.1248, -0.1002, -0.0476]), + (651.2, [-0.1044, -0.0738, -0.0552]), + (655.2, [-0.0822, -0.0503, -0.059]), + (660.8, [-0.0403, -0.0105, -0.056]), + (662.4, [-0.0309, -0.0014, -0.0563]), + (666.4, [0.0064, 0.0306, -0.0435]), + (672.8, [0.0561, 0.0738, -0.0248]), + (678.4, [0.0923, 0.0978, -0.0095]), + (686.4, [0.1438, 0.1324, 0.026]), + (689.6, [0.1561, 0.1439, 0.0432]), + (693.6, [0.1575, 0.1478, 0.0625]), + (695.2, [0.1517, 0.1448, 0.0695]), + (696.8, [0.1409, 0.1376, 0.0663]), + (698.4, [0.1254, 0.1261, 0.0625]), + (700.0, [0.1049, 0.1101, 0.0594]), + (702.4, [0.0669, 0.0796, 0.0491]), + (704.0, [0.0376, 0.0556, 0.0411]), + (705.6, [0.0035, 0.0277, 0.0304]), + (706.4, [-0.0178, 0.0096, 0.0221]), + (707.2, [-0.042, -0.0112, 0.0126]), + (708.0, [-0.0704, -0.0362, 0.0022]), + (708.8, [-0.1033, -0.0653, -0.0109]), + (709.6, [-0.1406, -0.0986, -0.0278]), + (710.4, [-0.1831, -0.137, -0.0469]), + (712.8, [-0.326, -0.2691, -0.1157]), + (714.4, [-0.4019, -0.3414, -0.1517]), + (716.0, [-0.4496, -0.3925, -0.179]), + (716.8, [-0.4629, -0.4082, -0.188]), + (718.4, [-0.472, -0.4246, -0.1955]), + (720.0, [-0.4579, -0.4208, -0.1942]), + (721.6, [-0.4245, -0.3945, -0.1847]), + (728.0, [-0.217, -0.2089, -0.1193]), + (729.6, [-0.1705, -0.1667, -0.1029]), + (731.2, [-0.1322, -0.1295, -0.0884]), + (732.8, [-0.1029, -0.0993, -0.0764]), + (734.4, [-0.083, -0.0764, -0.0669]), + (736.8, [-0.0653, -0.0569, -0.0578]), + (739.2, [-0.0604, -0.0521, -0.0553]), + (742.4, [-0.0667, -0.0588, -0.058]), + (745.6, [-0.0833, -0.0724, -0.0644]), + (748.8, [-0.1085, -0.0872, -0.0712]), + (752.0, [-0.125, -0.0958, -0.0735]), + (753.6, [-0.1339, -0.1121, -0.0818]), + (755.2, [-0.1435, -0.1363, -0.0947]), + (756.8, [-0.1554, -0.1676, -0.1139]), + (758.4, [-0.1603, -0.1867, -0.1248]), + (759.2, [-0.1617, -0.1952, -0.1292]), + (761.6, [-0.1598, -0.2057, -0.1401]), + (763.2, [-0.1588, -0.2035, -0.1482]), + (765.6, [-0.1548, -0.1845, -0.1581]), + (768.8, [-0.1302, -0.1265, -0.1685]), + (772.0, [-0.0955, -0.0548, -0.1775]), + (772.8, [-0.0887, -0.0392, -0.179]), + (774.4, [-0.0786, -0.0156, -0.179]), + (775.2, [-0.0766, -0.0097, -0.1779]), + (776.0, [-0.0758, -0.007, -0.1747]), + (776.8, [-0.076, -0.0064, -0.17]), + (777.6, [-0.0781, -0.0099, -0.1659]), + (778.4, [-0.0829, -0.019, -0.1626]), + (780.0, [-0.0957, -0.0443, -0.1502]), + (780.8, [-0.1031, -0.0604, -0.1467]), + (783.2, [-0.1319, -0.1227, -0.1589]), + (784.8, [-0.1551, -0.1722, -0.1824]), + (785.6, [-0.1625, -0.1885, -0.1929]), + (786.4, [-0.1645, -0.1944, -0.2021]), + (788.0, [-0.1577, -0.1852, -0.2092]), + (791.2, [-0.1434, -0.1607, -0.2075]), + (793.6, [-0.1389, -0.149, -0.2108]), + (795.2, [-0.1384, -0.1441, -0.216]), + (797.6, [-0.134, -0.1396, -0.2151]), + (801.6, [-0.1276, -0.1401, -0.216]), + (807.2, [-0.109, -0.1395, -0.209]), + (809.6, [-0.0937, -0.1292, -0.2018]), + (811.2, [-0.0819, -0.1177, -0.1965]), + (813.6, [-0.0878, -0.1101, -0.1867]), + (814.4, [-0.086, -0.1037, -0.1769]), + (815.2, [-0.0777, -0.0898, -0.1587]), + (816.0, [-0.0658, -0.0716, -0.1259]), + (816.8, [-0.0327, -0.0391, -0.0774]), + (817.6, [0.0149, 0.0038, -0.0273]), + (818.4, [0.0274, 0.0185, 0.014]), + (819.2, [0.0052, 0.0062, 0.0145]), + (820.0, [-0.0229, -0.0117, 0.0057]), + (820.8, [-0.0347, -0.0189, -0.0006]), + (821.6, [-0.0311, -0.0119, -0.0191]), + (822.4, [0.007, 0.0007, -0.0046]), + (823.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.1094, 0.1056, 0.1031]), + (0.79943, [0.1031, 0.0976, 0.0977]), + (63.955, [0.1023, 0.1031, 0.1015]), + (181.47, [0.1, 0.1015, 0.1026]), + (208.65, [0.0945, 0.0984, 0.1035]), + (275.01, [0.0728, 0.0845, 0.1042]), + (309.38, [0.0678, 0.082, 0.1045]), + (394.12, [0.0794, 0.09, 0.1051]), + (518.03, [0.083, 0.0925, 0.1051]), + (637.15, [0.1029, 0.1072, 0.1086]), + (680.32, [0.1117, 0.1156, 0.1099]), + (763.46, [0.1145, 0.1152, 0.1073]), + (782.65, [0.1266, 0.1247, 0.1115]), + (786.64, [0.1358, 0.133, 0.1159]), + (799.43, [0.175, 0.1729, 0.1364]), + (810.63, [0.1981, 0.1866, 0.1479]), + (818.62, [0.2167, 0.202, 0.1576]), + (825.81, [0.2365, 0.2222, 0.1718]), + (830.61, [0.2457, 0.2286, 0.1778]), + (831.41, [0.2481, 0.2319, 0.1802]), + (833.01, [0.2589, 0.2509, 0.1937]), + (836.21, [0.2901, 0.3107, 0.238]), + (838.61, [0.3081, 0.3458, 0.2709]), + (841.0, [0.3151, 0.3602, 0.299]), + (843.4, [0.3107, 0.3492, 0.3174]), + (845.0, [0.3001, 0.3265, 0.3258]), + (848.2, [0.2661, 0.2533, 0.3335]), + (852.2, [0.2174, 0.1489, 0.3362]), + (853.79, [0.2056, 0.1201, 0.3357]), + (854.59, [0.2021, 0.1108, 0.3339]), + (856.19, [0.1989, 0.1023, 0.3273]), + (856.99, [0.2009, 0.1058, 0.3239]), + (857.79, [0.2053, 0.1151, 0.3231]), + (859.39, [0.2181, 0.1435, 0.3214]), + (860.19, [0.2264, 0.1625, 0.3263]), + (861.79, [0.2501, 0.216, 0.3539]), + (864.19, [0.2934, 0.3103, 0.4134]), + (864.99, [0.3038, 0.3328, 0.4299]), + (865.79, [0.3076, 0.3419, 0.4408]), + (871.38, [0.2924, 0.318, 0.4441]), + (875.38, [0.2885, 0.3125, 0.4499]), + (880.18, [0.273, 0.3088, 0.4463]), + (886.57, [0.2211, 0.2815, 0.4304]), + (888.97, [0.2008, 0.2668, 0.4176]), + (890.57, [0.1828, 0.2505, 0.4047]), + (892.17, [0.1762, 0.2354, 0.3866]), + (893.77, [0.1688, 0.2121, 0.3518]), + (894.57, [0.1588, 0.1923, 0.3211]), + (895.37, [0.1445, 0.1672, 0.2731]), + (896.16, [0.1047, 0.1243, 0.2047]), + (896.96, [0.0435, 0.0653, 0.1286]), + (897.76, [0.0096, 0.0278, 0.0546]), + (898.56, [0.006, 0.0137, 0.0187]), + (899.36, [0.0072, 0.0052, -0.0072]), + (900.16, [-0.0006, -0.0072, -0.0282]), + (900.96, [-0.0014, -0.0167, -0.0183]), + (901.76, [0.0004, -0.004, -0.012]), + (902.56, [-0.0007, -0.0003, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [-0.1166, 0.2582, 0.015]), + (0.79965, [-0.105, 0.2627, 0.0163]), + (1.5993, [-0.1105, 0.2573, 0.0158]), + (2.399, [-0.1105, 0.2527, 0.0152]), + (3.9983, [-0.1171, 0.2348, 0.0118]), + (7.9965, [-0.137, 0.1712, -0.0033]), + (10.395, [-0.141, 0.1325, -0.0105]), + (12.794, [-0.1393, 0.0966, -0.0153]), + (16.793, [-0.1302, 0.0386, -0.0274]), + (19.192, [-0.1201, 0.0096, -0.0321]), + (21.591, [-0.1065, -0.0107, -0.0331]), + (23.19, [-0.0967, -0.0207, -0.0335]), + (25.589, [-0.08, -0.0299, -0.032]), + (27.988, [-0.0652, -0.0344, -0.03]), + (33.585, [-0.0342, -0.0309, -0.02]), + (39.183, [-0.0135, -0.0192, -0.0098]), + (45.58, [-0.0028, -0.0126, -0.0055]), + (51.178, [0.0013, -0.0088, -0.004]), + (55.976, [0.0023, -0.008, -0.0057]), + (77.566, [0.014, 0.0018, -0.0046]), + (78.366, [0.0132, 0.0011, -0.0044]), + (79.166, [0.0064, -0.0035, -0.0048]), + (79.965, [-0.0052, -0.0107, -0.0045]), + (80.765, [-0.0108, -0.0143, -0.0044]), + (83.964, [-0.0104, -0.0141, -0.005]), + (116.75, [0.0024, -0.0051, -0.0059]), + (157.53, [0.0228, 0.0101, -0.0056]), + (158.33, [0.0212, 0.0086, -0.0057]), + (159.13, [0.0123, 0.0033, -0.0056]), + (159.93, [-0.0013, -0.005, -0.0055]), + (160.73, [-0.005, -0.0083, -0.0059]), + (178.32, [-0.003, -0.0066, -0.0058]), + (236.7, [-0.0052, -0.0086, -0.0058]), + (474.19, [-0.0022, -0.0062, -0.0052]), + (514.18, [-0.0044, -0.0078, -0.0065]), + (546.16, [-0.0045, -0.0074, -0.0051]), + (548.56, [-0.006, -0.0087, -0.0064]), + (556.56, [-0.005, -0.0075, -0.005]), + (572.55, [-0.0081, -0.0102, -0.0061]), + (574.15, [-0.0068, -0.0088, -0.0044]), + (576.55, [-0.0081, -0.0104, -0.0054]), + (582.95, [-0.0082, -0.0095, -0.0042]), + (590.14, [-0.0085, -0.0107, -0.0062]), + (601.34, [-0.0054, -0.0085, -0.0047]), + (608.54, [-0.0048, -0.007, -0.0062]), + (610.14, [-0.0027, -0.005, -0.004]), + (614.13, [-0.0033, -0.0065, -0.0043]), + (618.13, [-0.0011, -0.0044, -0.0038]), + (619.73, [-0.0023, -0.0057, -0.0049]), + (623.73, [-0.0001, -0.0035, -0.0024]), + (630.13, [-0.0006, -0.0041, -0.0044]), + (631.73, [0.0006, -0.0029, -0.0027]), + (636.52, [-0.0005, -0.0046, -0.0025]), + (638.12, [-0.0003, -0.0021, -0.001]), + (640.52, [-0.0008, -0.0025, -0.0005]), + ], + }, + Radial { + t: -1.469, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (7.7937, [0.0, 0.0, 0.0]), + (8.5731, [0.0098, -0.0143, 0.0312]), + (9.3524, [0.0238, 0.019, 0.0268]), + (10.132, [0.0186, 0.0134, 0.0216]), + (10.911, [0.0124, 0.011, 0.016]), + (11.691, [0.0129, 0.0103, 0.0155]), + (12.47, [0.0085, 0.0076, 0.011]), + (14.808, [0.0025, 0.0019, 0.0031]), + (17.146, [0.0016, 0.0019, 0.0024]), + (18.705, [-0.001, -0.0028, -0.0019]), + ], + }, + Radial { + t: -0.501, + perp: 0.0, + knots: &[ + (0.0, [-0.0035, 0.0064, 0.004]), + (16.776, [-0.0026, 0.0053, 0.0036]), + (61.513, [-0.0005, 0.0083, 0.0056]), + (62.312, [-0.0005, 0.0073, 0.005]), + (63.91, [-0.0006, 0.001, 0.0009]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0625, 0.5287, 0.1448]), + (0.79862, [-0.0609, 0.5162, 0.1425]), + (1.5972, [-0.0598, 0.5175, 0.1452]), + (2.3959, [-0.0654, 0.502, 0.1373]), + (3.9931, [-0.0703, 0.4675, 0.1232]), + (5.5903, [-0.0769, 0.4165, 0.098]), + (8.7848, [-0.0825, 0.3022, 0.05]), + (11.181, [-0.0816, 0.2254, 0.0214]), + (13.577, [-0.0763, 0.1679, 0.002]), + (14.375, [-0.0732, 0.1525, -0.0019]), + (17.57, [-0.0591, 0.1036, -0.0115]), + (19.965, [-0.0464, 0.0739, -0.0159]), + (22.361, [-0.0343, 0.0491, -0.0208]), + (24.757, [-0.0222, 0.0298, -0.0235]), + (27.952, [-0.0064, 0.0158, -0.0243]), + (29.549, [0.0017, 0.0145, -0.0226]), + (34.341, [0.0223, 0.0203, -0.0141]), + (38.334, [0.0342, 0.0268, -0.0082]), + (41.528, [0.0408, 0.0314, -0.0038]), + (48.716, [0.0457, 0.0348, -0.0004]), + (64.688, [0.0402, 0.029, -0.0008]), + (78.265, [0.0316, 0.0226, -0.001]), + (114.2, [0.0194, 0.0138, -0.0001]), + (144.55, [0.0057, 0.0039, -0.0002]), + (146.15, [0.003, 0.002, -0.0006]), + (150.14, [0.0004, 0.0004, 0.0003]), + (161.32, [-0.0027, -0.0021, -0.0002]), + (168.51, [-0.0018, -0.0012, 0.0005]), + ], + }, + Radial { + t: 0.78396, + perp: 0.0, + knots: &[ + (0.0, [-0.0011, 0.0104, 0.0069]), + (1.5975, [-0.001, 0.0124, 0.0075]), + (7.1885, [-0.0042, 0.0102, 0.006]), + (19.968, [-0.0036, 0.0104, 0.0052]), + (23.163, [-0.0028, 0.0141, 0.0071]), + (23.962, [-0.0034, 0.0139, 0.007]), + (25.559, [-0.0056, 0.0078, 0.0056]), + (31.15, [-0.0046, -0.0027, -0.0002]), + (49.521, [-0.0017, -0.0008, 0.0006]), + ], + }, + Radial { + t: 0.28263, + perp: 0.0, + knots: &[(0.0, [0.0014, -0.0006, -0.0006]), (0.7912, [-0.0007, 0.0014, -0.0004]), (18.989, [-0.0001, 0.0042, 0.0002]), (21.362, [0.0002, 0.0006, 0.0])], + }, + Radial { t: 0.39897, perp: 0.0, knots: &[(0.0, [0.002, 0.0008, 0.0]), (0.77909, [-0.0004, -0.0005, -0.0007])] }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.5559, 0.4551, 0.2261]), + (0.79212, [0.5696, 0.4697, 0.232]), + (5.5449, [0.5601, 0.4594, 0.2214]), + (7.9212, [0.5571, 0.4567, 0.2221]), + (8.7134, [0.5684, 0.465, 0.2235]), + (9.5055, [0.3522, 0.288, 0.1413]), + (10.298, [0.0468, 0.0385, 0.0211]), + (11.09, [0.0021, 0.0021, 0.0021]), + (11.882, [0.0068, 0.0051, 0.0028]), + (12.674, [0.0046, 0.0038, 0.0022]), + (13.466, [0.0053, 0.0044, 0.0032]), + (15.842, [0.0019, 0.0016, 0.0003]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [-0.023, 0.03, -0.0213]), + (0.79613, [-0.026, 0.0276, -0.0202]), + (2.3884, [-0.0254, 0.0258, -0.0216]), + (7.1651, [-0.0218, 0.0297, -0.0192]), + (8.7574, [-0.022, 0.0278, -0.0212]), + (14.33, [-0.0172, 0.0297, -0.0181]), + (16.719, [-0.0161, 0.0287, -0.0189]), + (23.088, [-0.0094, 0.0322, -0.0143]), + (34.233, [0.0058, 0.0395, -0.0058]), + (42.195, [0.0186, 0.0489, 0.0048]), + (43.787, [0.0267, 0.0529, 0.0092]), + (46.175, [0.0225, 0.052, 0.008]), + (48.564, [0.0226, 0.053, 0.0084]), + (50.952, [0.0189, 0.0522, 0.0072]), + (52.544, [0.0245, 0.0561, 0.0104]), + (55.729, [0.0258, 0.0572, 0.0104]), + (61.302, [0.0305, 0.0622, 0.0143]), + (63.69, [0.0299, 0.0611, 0.0135]), + (64.486, [0.0267, 0.0562, 0.0115]), + (65.282, [0.0137, 0.0356, 0.0054]), + (66.078, [-0.0006, 0.0111, -0.0024]), + (66.875, [-0.0087, -0.0028, -0.0066]), + (67.671, [-0.0108, -0.006, -0.0084]), + (72.447, [-0.0112, -0.0076, -0.0083]), + (91.554, [-0.0076, -0.0049, -0.0085]), + (108.27, [-0.0018, 0.0, -0.0015]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.5001, 0.6146, 0.7136]), + (0.79168, [0.4273, 0.5549, 0.6866]), + (1.5834, [0.341, 0.4859, 0.6518]), + (2.375, [0.235, 0.4035, 0.6083]), + (3.1667, [0.1797, 0.3637, 0.5867]), + (3.9584, [0.1689, 0.3545, 0.581]), + (4.7501, [0.1696, 0.3573, 0.5818]), + (5.5418, [0.1643, 0.3476, 0.5773]), + (6.3335, [0.1524, 0.3261, 0.5699]), + (7.9168, [0.0602, 0.1301, 0.5106]), + (8.7085, [0.0485, 0.1065, 0.4625]), + (9.5002, [0.041, 0.0911, 0.4]), + (10.292, [0.0359, 0.0809, 0.3396]), + (11.875, [0.0238, 0.053, 0.2137]), + (12.667, [0.0161, 0.035, 0.146]), + (13.459, [0.01, 0.0217, 0.0751]), + (14.25, [0.0048, 0.0103, 0.0114]), + (15.042, [0.0034, 0.0073, 0.0041]), + (17.417, [0.0004, 0.0005, -0.0001]), + ], + }, + Radial { + t: -1.5029, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (13.514, [0.0, 0.0, 0.0]), + (14.309, [-0.0547, -0.0385, -0.0569]), + (15.104, [-0.0126, -0.0124, -0.0179]), + (15.899, [-0.0129, -0.0115, -0.0171]), + (16.693, [-0.008, -0.0088, -0.0105]), + (19.078, [0.0001, -0.0006, -0.0003]), + ], + }, + Radial { + t: 0.65115, + perp: 0.0, + knots: &[ + (0.0, [0.1307, 0.1025, 0.0141]), + (0.78746, [0.1209, 0.0927, 0.012]), + (1.5749, [0.0954, 0.0721, 0.0083]), + (3.1498, [0.055, 0.0389, 0.0043]), + (4.7247, [0.0306, 0.0208, 0.0007]), + (6.2997, [0.0181, 0.0104, -0.0007]), + (7.8746, [0.0101, 0.0055, -0.0018]), + (9.4495, [0.0061, 0.002, -0.0012]), + (23.624, [0.004, 0.0015, -0.0013]), + ], + }, + Radial { + t: 0.92394, + perp: 0.0, + knots: &[ + (0.0, [0.0149, 0.0624, 0.0664]), + (0.79673, [0.0121, 0.0582, 0.0636]), + (1.5935, [0.0093, 0.0576, 0.0611]), + (3.9837, [0.0063, 0.0507, 0.055]), + (4.7804, [0.0036, 0.0251, 0.0285]), + (5.5771, [0.0011, 0.0073, 0.0086]), + (6.3739, [-0.0007, 0.0002, 0.0006]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.2896, -0.3971, -0.0464]), + (0.78812, [0.2996, -0.3847, -0.0454]), + (1.5762, [0.2931, -0.3876, -0.0499]), + (3.9406, [0.2903, -0.3596, -0.0382]), + (6.305, [0.2846, -0.3153, -0.0265]), + (8.6693, [0.2723, -0.2607, -0.0091]), + (12.61, [0.2427, -0.1671, 0.014]), + (15.762, [0.2141, -0.1023, 0.0258]), + (18.127, [0.1911, -0.0602, 0.0315]), + (20.491, [0.1693, -0.0232, 0.0384]), + (22.067, [0.1544, -0.0026, 0.0413]), + (23.644, [0.1401, 0.0149, 0.0441]), + (25.22, [0.1241, 0.0261, 0.0433]), + (26.796, [0.1096, 0.0343, 0.0426]), + (28.372, [0.0946, 0.0375, 0.0397]), + (30.737, [0.075, 0.0383, 0.0338]), + (35.465, [0.0402, 0.0255, 0.0192]), + (38.618, [0.0251, 0.0181, 0.0127]), + (42.559, [0.0116, 0.0092, 0.0054]), + (46.499, [0.0042, 0.0033, 0.0014]), + (49.652, [0.0019, 0.0018, 0.0008]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.2846, 0.3732, 0.586]), + (0.79199, [0.3393, 0.4185, 0.6065]), + (1.584, [0.3142, 0.3773, 0.5339]), + (3.168, [0.2212, 0.2537, 0.3256]), + (3.96, [0.1677, 0.1892, 0.2321]), + (4.752, [0.1241, 0.1357, 0.1494]), + (5.544, [0.0927, 0.0985, 0.096]), + (6.336, [0.0549, 0.0578, 0.0498]), + (7.1279, [0.0323, 0.0345, 0.0225]), + (7.9199, [0.0293, 0.0315, 0.0176]), + (9.5039, [0.0341, 0.0355, 0.0202]), + (15.048, [0.0413, 0.0413, 0.0228]), + (20.592, [0.0379, 0.0385, 0.0213]), + (30.096, [0.0395, 0.04, 0.0214]), + (30.888, [0.0282, 0.029, 0.0163]), + (31.68, [0.0052, 0.0063, 0.0034]), + (32.472, [-0.0004, 0.0007, -0.0002]), + ], + }, + Radial { + t: 2.5968, + perp: 0.0, + knots: &[ + (0.0, [-0.0073, -0.0099, -0.0048]), + (0.79014, [-0.0045, -0.0063, 0.0003]), + (1.5803, [-0.0015, -0.0037, 0.0028]), + (5.531, [-0.0004, -0.001, 0.0024]), + (7.1113, [0.0015, 0.0012, 0.0045]), + (10.272, [0.0016, 0.0025, 0.0031]), + (16.593, [-0.0018, -0.0013, -0.0001]), + (22.914, [0.0008, 0.0008, 0.0004]), + (30.025, [-0.001, -0.0006, -0.0018]), + (34.766, [0.0005, 0.0021, -0.0006]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [-0.0008, 0.0078, 0.0064]), + (0.7893, [-0.0014, 0.0043, 0.0029]), + (1.5786, [-0.0006, 0.0065, 0.0046]), + (2.3679, [-0.0019, 0.0042, 0.002]), + (3.9465, [-0.0024, 0.0037, 0.0011]), + (7.1037, [-0.0008, 0.0045, 0.0034]), + (7.893, [-0.002, 0.0038, 0.0026]), + (11.839, [-0.0008, 0.0087, 0.0082]), + (14.997, [-0.0006, 0.0144, 0.0145]), + (17.365, [0.0041, 0.018, 0.0191]), + (19.732, [0.0052, 0.0237, 0.0234]), + (24.468, [0.0125, 0.0392, 0.0389]), + (26.836, [0.0127, 0.0454, 0.0462]), + (29.204, [0.0168, 0.0545, 0.0568]), + (29.993, [0.0173, 0.0564, 0.0578]), + (30.783, [0.0137, 0.0445, 0.0459]), + (31.572, [0.0032, 0.0124, 0.0127]), + (32.361, [-0.0011, 0.0012, 0.0008]), + (33.15, [-0.0021, -0.0014, -0.0011]), + (41.043, [-0.0001, -0.0005, 0.0]), + (42.622, [-0.0022, -0.0034, -0.0013]), + (44.201, [-0.0007, -0.0014, -0.0008]), + ], + }, + Radial { + t: -1.4318, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.0, 0.0]), + (2.3556, [0.0, 0.0, 0.0]), + (3.1407, [-0.017, -0.0079, -0.0104]), + (3.9259, [-0.0188, -0.0132, -0.0129]), + (4.7111, [-0.0131, -0.0058, -0.0061]), + (5.4963, [-0.0111, -0.0076, -0.0071]), + (6.2815, [-0.0092, -0.0043, -0.0031]), + (7.0667, [-0.0132, -0.0095, -0.0078]), + (7.8519, [-0.0119, -0.0104, -0.0072]), + (9.4222, [-0.0082, -0.0064, -0.0022]), + (10.993, [-0.007, -0.0049, -0.0015]), + (11.778, [-0.0079, -0.0061, -0.0033]), + (13.348, [-0.0072, -0.0064, -0.0028]), + (14.133, [-0.0053, -0.0034, 0.0]), + (14.919, [-0.0055, -0.0037, 0.0013]), + (15.704, [-0.0027, -0.001, 0.0034]), + (16.489, [-0.0063, -0.004, -0.0004]), + (18.059, [-0.0053, -0.0034, 0.0004]), + (19.63, [-0.0069, -0.0055, -0.0016]), + ], + }, + Radial { + t: 0.94074, + perp: 0.0, + knots: &[ + (0.0, [0.0091, 0.0966, 0.0887]), + (0.7998, [0.0085, 0.0612, 0.0564]), + (1.5996, [0.0032, 0.0308, 0.0279]), + (2.3994, [-0.0006, 0.0025, 0.0026]), + (3.1992, [-0.0004, 0.0002, -0.0012]), + ], + }, + Radial { + t: 0.87489, + perp: 0.0, + knots: &[ + (0.0, [0.0031, 0.0, -0.0019]), + (0.78535, [0.0002, 0.0008, -0.0007]), + (3.9268, [0.0036, 0.0022, 0.0011]), + (5.4975, [0.0027, 0.0011, 0.0002]), + (6.2828, [0.0005, -0.0004, -0.0022]), + (11.78, [0.0006, -0.0003, -0.0014]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [0.0407, -0.0825, -0.0081]), + (0.79316, [0.0417, -0.0777, -0.0064]), + (1.5863, [0.044, -0.071, -0.0009]), + (4.759, [0.0449, -0.066, -0.0007]), + (5.5521, [0.0474, -0.0602, 0.0007]), + (7.9316, [0.0483, -0.0477, 0.0022]), + (9.5179, [0.05, -0.0374, 0.0055]), + (10.311, [0.0481, -0.0336, 0.0063]), + (12.691, [0.0485, -0.0143, 0.02]), + (14.277, [0.0447, -0.0069, 0.0214]), + (15.07, [0.0434, -0.004, 0.0185]), + (15.863, [0.0427, -0.0028, 0.0167]), + (16.656, [0.0418, -0.0066, 0.0125]), + (17.45, [0.0402, -0.0154, 0.0085]), + (18.243, [0.039, -0.016, 0.0069]), + (21.415, [0.0331, -0.0049, 0.0089]), + (25.381, [0.0243, 0.0042, 0.0098]), + (30.14, [0.0136, 0.0065, 0.0072]), + (34.106, [0.0064, 0.0033, 0.0036]), + (38.865, [0.0013, -0.0004, 0.0]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.2696, 0.0714, -0.0124]), + (0.77016, [0.2295, 0.0442, -0.0455]), + (1.5403, [0.1607, -0.0073, -0.0591]), + (3.0807, [0.0761, -0.0646, -0.0699]), + (3.8508, [0.045, -0.0799, -0.0667]), + (4.621, [0.0267, -0.0799, -0.0616]), + (5.3911, [0.0136, -0.0807, -0.055]), + (6.9315, [0.0013, -0.0613, -0.039]), + (7.7016, [-0.0021, -0.0507, -0.0321]), + (9.242, [-0.005, -0.0319, -0.0193]), + (10.012, [-0.0044, -0.0235, -0.0145]), + (12.323, [-0.0007, -0.0048, -0.0034]), + ], + }, + Radial { + t: 1.6547, + perp: 0.0, + knots: &[ + (0.0, [0.0108, 0.0094, 0.0067]), + (0.78645, [0.0037, 0.0057, 0.0016]), + (1.5729, [0.0053, 0.0072, 0.0043]), + (3.1458, [0.0019, 0.0015, -0.0005]), + (9.4374, [0.0065, 0.0049, 0.0048]), + (11.797, [0.0036, 0.0023, 0.0009]), + (18.088, [0.0018, -0.0008, -0.0001]), + ], + }, + Radial { + t: 2.6079, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, -0.0002, -0.0101]), + (0.79055, [-0.0007, 0.0015, -0.0073]), + (1.5811, [-0.0002, 0.0007, -0.0059]), + (2.3716, [0.0016, 0.0024, -0.0024]), + (4.7433, [-0.0006, -0.0003, -0.0049]), + (7.1149, [-0.0005, -0.0006, -0.0043]), + (8.696, [-0.0022, -0.0033, -0.0054]), + (11.068, [0.0003, -0.0008, -0.0022]), + (13.439, [0.0004, -0.0003, -0.0028]), + (15.811, [0.0027, 0.0014, -0.0005]), + (16.602, [0.0015, 0.0009, -0.001]), + ], + }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.1735, 0.0997, 0.1048]), + (0.79824, [0.1751, 0.1045, 0.1072]), + (3.193, [0.1724, 0.1007, 0.1057]), + (11.175, [0.1685, 0.1013, 0.1076]), + (20.754, [0.1603, 0.0983, 0.1031]), + (31.93, [0.1446, 0.092, 0.0945]), + (40.71, [0.1288, 0.0825, 0.0837]), + (45.5, [0.1241, 0.0781, 0.0792]), + (46.298, [0.1204, 0.0739, 0.0747]), + (47.096, [0.0801, 0.0512, 0.0514]), + (47.895, [0.032, 0.0245, 0.0249]), + (48.693, [0.022, 0.0167, 0.0162]), + (54.281, [0.0185, 0.0115, 0.0126]), + (56.675, [0.0148, 0.008, 0.0099]), + (59.868, [0.0142, 0.0077, 0.01]), + (60.666, [0.0126, 0.006, 0.0088]), + (61.465, [0.0092, 0.001, 0.0069]), + (62.263, [0.0083, -0.0003, 0.0064]), + (64.658, [0.0124, 0.0067, 0.0082]), + (69.447, [0.0142, 0.0104, 0.0092]), + (74.237, [0.0119, 0.0075, 0.0081]), + (83.816, [0.0109, 0.0068, 0.0093]), + (94.193, [0.0081, 0.0051, 0.0076]), + (113.35, [0.0018, -0.0004, -0.0005]), + ], + }, +]; +const PRIME105_150_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_150_RAYS: &[Ray] = &[ + Ray { angle: 0.28, r0: 67.969, r1: 148.44, fade: 0.05, rgb: [0.0078, 0.0059, 0.002] }, + Ray { angle: 2.93, r0: 117.19, r1: 164.84, fade: 0.05, rgb: [0.0078, 0.0069, 0.0059] }, + Ray { angle: 17.46, r0: 102.34, r1: 277.34, fade: 0.05, rgb: [0.0137, 0.0137, 0.0098] }, + Ray { angle: 24.65, r0: 70.312, r1: 262.5, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 39.06, r0: 99.219, r1: 211.72, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 46.03, r0: 164.06, r1: 275.0, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 53.43, r0: 83.594, r1: 262.5, fade: 0.05, rgb: [0.0108, 0.0098, 0.0078] }, + Ray { angle: 60.39, r0: 131.25, r1: 360.94, fade: 0.05, rgb: [0.0118, 0.0118, 0.0098] }, + Ray { angle: 89.04, r0: 76.562, r1: 404.69, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 94.19, r0: 189.06, r1: 246.88, fade: 0.05, rgb: [0.0059, 0.0039, 0.0039] }, + Ray { angle: 96.31, r0: 189.84, r1: 277.34, fade: 0.05, rgb: [0.0137, 0.0098, 0.0098] }, + Ray { angle: 96.82, r0: 219.53, r1: 278.12, fade: 0.05, rgb: [0.0157, 0.0157, 0.0137] }, + Ray { angle: 97.32, r0: 96.875, r1: 272.66, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 97.6, r0: 94.531, r1: 247.66, fade: 0.05, rgb: [0.0098, 0.0098, 0.0059] }, + Ray { angle: 103.35, r0: 124.22, r1: 160.16, fade: 0.05, rgb: [0.0078, 0.0059, 0.0059] }, + Ray { angle: 104.37, r0: 163.28, r1: 417.19, fade: 0.05, rgb: [0.0137, 0.0118, 0.0118] }, + Ray { angle: 110.59, r0: 66.406, r1: 285.94, fade: 0.05, rgb: [0.0157, 0.0137, 0.0108] }, + Ray { angle: 118.67, r0: 86.719, r1: 265.62, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 125.73, r0: 158.59, r1: 217.97, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 147.27, r0: 100.0, r1: 278.12, fade: 0.05, rgb: [0.0098, 0.0078, 0.0078] }, + Ray { angle: 161.57, r0: 67.969, r1: 246.88, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 168.73, r0: 82.812, r1: 238.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 183.03, r0: 146.09, r1: 190.62, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, + Ray { angle: 197.54, r0: 99.219, r1: 245.31, fade: 0.05, rgb: [0.0137, 0.0118, 0.0098] }, + Ray { angle: 204.76, r0: 78.125, r1: 257.81, fade: 0.05, rgb: [0.0098, 0.0078, 0.0059] }, + Ray { angle: 219.23, r0: 85.938, r1: 212.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0059] }, + Ray { angle: 225.87, r0: 118.75, r1: 226.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 233.25, r0: 123.44, r1: 243.75, fade: 0.05, rgb: [0.0157, 0.0157, 0.0118] }, + Ray { angle: 240.27, r0: 153.12, r1: 224.22, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 269.03, r0: 142.19, r1: 195.31, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 269.75, r0: 54.688, r1: 155.47, fade: 0.05, rgb: [0.0078, 0.0167, 0.002] }, + Ray { angle: 270.22, r0: 74.219, r1: 163.28, fade: 0.05, rgb: [0.0118, 0.0196, 0.002] }, + Ray { angle: 275.2, r0: 135.16, r1: 196.09, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 276.15, r0: 67.969, r1: 86.719, fade: 0.05, rgb: [0.0059, 0.0059, 0.0] }, + Ray { angle: 283.43, r0: 141.41, r1: 200.78, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 284.45, r0: 108.59, r1: 201.56, fade: 0.05, rgb: [0.0078, 0.0078, 0.0059] }, + Ray { angle: 290.67, r0: 71.094, r1: 208.59, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 298.8, r0: 111.72, r1: 222.66, fade: 0.05, rgb: [0.0118, 0.0118, 0.0078] }, + Ray { angle: 305.84, r0: 171.09, r1: 226.56, fade: 0.05, rgb: [0.0069, 0.0069, 0.0049] }, + Ray { angle: 327.39, r0: 90.625, r1: 350.0, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 341.63, r0: 72.656, r1: 396.88, fade: 0.05, rgb: [0.0216, 0.0216, 0.0176] }, + Ray { angle: 348.78, r0: 79.688, r1: 575.78, fade: 0.05, rgb: [0.0216, 0.0216, 0.0176] }, +]; +const PRIME105_200_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.7926, 0.6984, 0.5194]), + (44.0, [0.7514, 0.6746, 0.5372]), + (114.4, [0.6147, 0.5914, 0.5933]), + (155.2, [0.5354, 0.5446, 0.6049]), + (202.4, [0.4227, 0.4741, 0.5927]), + (271.2, [0.2119, 0.3247, 0.548]), + (290.4, [0.1717, 0.2882, 0.5295]), + (304.8, [0.1529, 0.2666, 0.513]), + (314.4, [0.1517, 0.2613, 0.5045]), + (324.8, [0.1602, 0.2642, 0.4987]), + (340.8, [0.1996, 0.288, 0.4936]), + (356.8, [0.2496, 0.3215, 0.4911]), + (375.2, [0.3393, 0.3793, 0.4914]), + (382.4, [0.3665, 0.4037, 0.4887]), + (405.6, [0.4256, 0.4357, 0.4579]), + (424.8, [0.4806, 0.4422, 0.4369]), + (432.0, [0.4996, 0.4509, 0.4177]), + (449.6, [0.5132, 0.4375, 0.3357]), + (460.0, [0.5221, 0.4321, 0.3133]), + (470.4, [0.5162, 0.4174, 0.3104]), + (473.6, [0.5125, 0.4146, 0.3151]), + (484.0, [0.4673, 0.3883, 0.3355]), + (489.6, [0.4199, 0.3551, 0.3445]), + (494.4, [0.368, 0.3195, 0.3531]), + (500.8, [0.2832, 0.2611, 0.3675]), + (506.4, [0.2013, 0.2103, 0.394]), + (512.0, [0.1047, 0.1571, 0.421]), + (515.2, [0.051, 0.1315, 0.4397]), + (520.0, [-0.0226, 0.1019, 0.4738]), + (525.6, [-0.1035, 0.0742, 0.5104]), + (530.4, [-0.1756, 0.044, 0.5231]), + (536.0, [-0.2353, 0.0243, 0.5392]), + (537.6, [-0.2489, 0.0214, 0.5434]), + (540.8, [-0.2668, 0.0235, 0.5551]), + (544.8, [-0.2894, 0.0228, 0.5577]), + (548.8, [-0.3007, 0.0282, 0.5571]), + (550.4, [-0.3017, 0.0325, 0.5562]), + (553.6, [-0.2957, 0.0449, 0.5555]), + (558.4, [-0.2698, 0.0708, 0.5497]), + (560.8, [-0.2447, 0.0934, 0.5498]), + (563.2, [-0.2113, 0.1217, 0.5507]), + (569.6, [-0.1018, 0.2157, 0.5516]), + (575.2, [0.0025, 0.3021, 0.5465]), + (580.0, [0.1043, 0.3839, 0.5462]), + (583.2, [0.1794, 0.4421, 0.5503]), + (592.0, [0.3415, 0.5341, 0.5345]), + (596.0, [0.3972, 0.55, 0.5233]), + (601.6, [0.4448, 0.539, 0.4988]), + (610.4, [0.4715, 0.4913, 0.4555]), + (616.8, [0.4916, 0.4758, 0.4461]), + (623.2, [0.5347, 0.4869, 0.4575]), + (632.8, [0.6596, 0.5474, 0.5149]), + (644.8, [0.793, 0.609, 0.5778]), + (648.8, [0.7964, 0.5977, 0.5804]), + (654.4, [0.7243, 0.5296, 0.558]), + (657.6, [0.6397, 0.4558, 0.5166]), + (660.0, [0.5641, 0.3892, 0.4791]), + (664.0, [0.3975, 0.2489, 0.3915]), + (666.4, [0.2736, 0.1493, 0.3247]), + (669.6, [0.0912, 0.0033, 0.2248]), + (670.4, [0.0429, -0.0354, 0.1988]), + (672.8, [-0.1122, -0.1585, 0.1152]), + (674.4, [-0.2206, -0.2408, 0.0616]), + (676.0, [-0.344, -0.3361, -0.004]), + (676.8, [-0.4091, -0.3872, -0.0392]), + (680.0, [-0.6813, -0.5988, -0.174]), + (684.0, [-1.089, -0.9121, -0.3792]), + (686.4, [-1.373, -1.133, -0.5266]), + (691.2, [-1.879, -1.533, -0.8161]), + (693.6, [-2.04, -1.657, -0.9227]), + (696.0, [-2.121, -1.722, -1.011]), + (697.6, [-2.088, -1.694, -1.026]), + (700.0, [-1.913, -1.556, -1.001]), + (701.6, [-1.69, -1.38, -0.9269]), + (703.2, [-1.379, -1.127, -0.7994]), + (704.0, [-1.197, -0.9793, -0.7229]), + (704.8, [-0.9914, -0.8092, -0.6314]), + (705.6, [-0.7533, -0.6126, -0.5205]), + (706.4, [-0.4845, -0.3899, -0.394]), + (707.2, [-0.1806, -0.1369, -0.2471]), + (708.0, [0.1601, 0.1487, -0.0784]), + (708.8, [0.5371, 0.4662, 0.1109]), + (709.6, [0.9496, 0.8168, 0.3231]), + (711.2, [1.804, 1.545, 0.7671]), + (712.0, [2.175, 1.861, 0.958]), + (712.8, [2.461, 2.105, 1.103]), + (713.6, [2.661, 2.274, 1.203]), + (714.4, [2.784, 2.377, 1.266]), + (716.0, [2.837, 2.421, 1.304]), + (717.6, [2.691, 2.303, 1.269]), + (718.4, [2.555, 2.194, 1.23]), + (720.0, [2.202, 1.907, 1.129]), + (721.6, [1.768, 1.552, 1.002]), + (723.2, [1.292, 1.163, 0.8543]), + (725.6, [0.5835, 0.5797, 0.5727]), + (726.4, [0.3716, 0.4066, 0.4724]), + (727.2, [0.1759, 0.2476, 0.3713]), + (728.0, [-0.0025, 0.1015, 0.2716]), + (728.8, [-0.1551, -0.0234, 0.1829]), + (729.6, [-0.2829, -0.1292, 0.1053]), + (730.4, [-0.3879, -0.2169, 0.041]), + (731.2, [-0.4798, -0.2961, -0.0228]), + (732.8, [-0.6246, -0.4335, -0.1454]), + (733.6, [-0.6786, -0.4914, -0.2025]), + (734.4, [-0.7209, -0.5392, -0.2512]), + (735.2, [-0.7415, -0.5672, -0.2794]), + (736.0, [-0.7424, -0.5768, -0.2881]), + (736.8, [-0.7206, -0.5653, -0.2772]), + (737.6, [-0.6801, -0.5368, -0.2517]), + (738.4, [-0.6261, -0.4956, -0.2156]), + (739.2, [-0.5616, -0.4441, -0.1707]), + (740.0, [-0.4916, -0.387, -0.123]), + (741.6, [-0.3557, -0.2737, -0.0411]), + (742.4, [-0.2966, -0.2247, -0.0076]), + (743.2, [-0.2462, -0.1835, 0.0208]), + (744.0, [-0.2103, -0.1541, 0.0389]), + (744.8, [-0.1819, -0.1305, 0.0547]), + (745.6, [-0.1667, -0.1196, 0.0634]), + (746.4, [-0.1675, -0.1249, 0.0625]), + (747.2, [-0.1863, -0.1479, 0.0485]), + (748.0, [-0.2249, -0.1902, 0.0164]), + (748.8, [-0.2785, -0.2468, -0.0305]), + (750.4, [-0.3909, -0.3638, -0.1312]), + (751.2, [-0.434, -0.4093, -0.1725]), + (752.0, [-0.4694, -0.4484, -0.2115]), + (753.6, [-0.5216, -0.5215, -0.2933]), + (755.2, [-0.5372, -0.5547, -0.3775]), + (756.8, [-0.5336, -0.5704, -0.476]), + (759.2, [-0.5007, -0.5443, -0.5899]), + (760.8, [-0.498, -0.5423, -0.6974]), + (761.6, [-0.5041, -0.5543, -0.7565]), + (763.2, [-0.5331, -0.5989, -0.8815]), + (765.6, [-0.5933, -0.6805, -1.088]), + (768.0, [-0.6515, -0.7583, -1.351]), + (768.8, [-0.6615, -0.7765, -1.435]), + (771.2, [-0.6598, -0.8116, -1.676]), + (775.2, [-0.6658, -0.8927, -2.025]), + (777.6, [-0.7237, -1.007, -2.252]), + (780.8, [-0.7565, -1.09, -2.454]), + (784.0, [-0.865, -1.267, -2.657]), + (785.6, [-0.9204, -1.349, -2.747]), + (787.2, [-0.9547, -1.414, -2.806]), + (794.4, [-1.052, -1.606, -2.91]), + (800.0, [-1.026, -1.591, -2.936]), + (803.2, [-0.9692, -1.535, -2.901]), + (806.4, [-0.8705, -1.437, -2.726]), + (808.0, [-0.7915, -1.357, -2.543]), + (809.6, [-0.6785, -1.234, -2.323]), + (811.2, [-0.5473, -1.078, -2.069]), + (812.0, [-0.4849, -0.9807, -1.946]), + (812.8, [-0.4152, -0.8772, -1.797]), + (813.6, [-0.3212, -0.7425, -1.606]), + (814.4, [-0.2127, -0.5852, -1.364]), + (815.2, [-0.0923, -0.4106, -1.099]), + (816.0, [0.0095, -0.2407, -0.826]), + (816.8, [0.0931, -0.0749, -0.4975]), + (817.6, [0.1349, 0.0608, -0.1509]), + (818.4, [0.1089, 0.1362, 0.158]), + (819.2, [0.0556, 0.1498, 0.4088]), + (820.0, [0.0703, 0.1822, 0.6078]), + (820.8, [0.0379, 0.076, 0.4067]), + (821.6, [0.0639, -0.0137, -0.146]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.1317, 0.2654, 0.5565]), + (0.8, [0.1353, 0.2684, 0.5632]), + (8.0, [0.1359, 0.2678, 0.5604]), + (21.6, [0.1455, 0.2736, 0.5582]), + (43.2, [0.174, 0.2904, 0.5453]), + (115.2, [0.3155, 0.3765, 0.4874]), + (157.6, [0.3998, 0.4263, 0.4762]), + (206.4, [0.52, 0.5018, 0.4901]), + (272.8, [0.7264, 0.6492, 0.535]), + (296.0, [0.7728, 0.6928, 0.559]), + (311.2, [0.7857, 0.7103, 0.5753]), + (328.0, [0.7716, 0.7058, 0.5851]), + (356.8, [0.6887, 0.6527, 0.5918]), + (369.6, [0.6283, 0.6149, 0.5914]), + (383.2, [0.5441, 0.5635, 0.5944]), + (406.4, [0.4186, 0.4719, 0.6255]), + (420.0, [0.3426, 0.4359, 0.6403]), + (432.8, [0.2715, 0.3877, 0.6523]), + (451.2, [0.2039, 0.3514, 0.6873]), + (455.2, [0.1851, 0.3393, 0.6828]), + (468.0, [0.1478, 0.3199, 0.6619]), + (474.4, [0.1401, 0.314, 0.6434]), + (479.2, [0.1428, 0.3089, 0.6239]), + (485.6, [0.1607, 0.3141, 0.5929]), + (490.4, [0.1903, 0.3313, 0.5734]), + (495.2, [0.2294, 0.3548, 0.5507]), + (500.8, [0.2879, 0.3903, 0.5222]), + (506.4, [0.3546, 0.4286, 0.4836]), + (514.4, [0.4705, 0.4837, 0.4241]), + (526.4, [0.6214, 0.5248, 0.3139]), + (531.2, [0.681, 0.5432, 0.2883]), + (538.4, [0.7348, 0.5471, 0.248]), + (541.6, [0.7439, 0.5366, 0.2274]), + (549.6, [0.7569, 0.512, 0.2055]), + (553.6, [0.7435, 0.4889, 0.1973]), + (559.2, [0.7004, 0.4442, 0.1895]), + (564.0, [0.6291, 0.3811, 0.1768]), + (566.4, [0.5808, 0.3398, 0.1673]), + (576.8, [0.3649, 0.1571, 0.1524]), + (578.4, [0.3243, 0.1228, 0.1457]), + (581.6, [0.2472, 0.0613, 0.1407]), + (583.2, [0.2028, 0.0256, 0.1329]), + (584.0, [0.1824, 0.0099, 0.1307]), + (585.6, [0.1477, -0.0142, 0.1315]), + (588.0, [0.0992, -0.0446, 0.1348]), + (591.2, [0.0277, -0.088, 0.1277]), + (592.8, [-0.0052, -0.1053, 0.126]), + (596.8, [-0.0729, -0.131, 0.1277]), + (602.4, [-0.1369, -0.1336, 0.1392]), + (606.4, [-0.1632, -0.1204, 0.1542]), + (608.8, [-0.179, -0.1157, 0.1569]), + (611.2, [-0.1859, -0.1057, 0.1648]), + (613.6, [-0.2044, -0.1091, 0.1589]), + (616.8, [-0.2171, -0.1033, 0.1638]), + (617.6, [-0.2225, -0.1046, 0.1617]), + (622.4, [-0.2644, -0.1219, 0.1456]), + (624.8, [-0.2927, -0.1374, 0.1325]), + (628.8, [-0.3559, -0.1745, 0.0985]), + (631.2, [-0.3884, -0.1893, 0.0812]), + (633.6, [-0.4302, -0.2121, 0.0595]), + (636.0, [-0.4602, -0.2273, 0.0482]), + (637.6, [-0.4851, -0.2421, 0.0347]), + (639.2, [-0.504, -0.2515, 0.0252]), + (640.8, [-0.5275, -0.2639, 0.0106]), + (642.4, [-0.548, -0.2731, 0.0001]), + (645.6, [-0.5938, -0.2977, -0.027]), + (647.2, [-0.6063, -0.3011, -0.0329]), + (648.8, [-0.6078, -0.2967, -0.0345]), + (650.4, [-0.6074, -0.2933, -0.041]), + (652.0, [-0.5953, -0.2805, -0.0393]), + (652.8, [-0.5842, -0.2699, -0.0349]), + (655.2, [-0.5509, -0.2397, -0.0287]), + (656.0, [-0.536, -0.2264, -0.0238]), + (658.4, [-0.4751, -0.1732, 0.0038]), + (661.6, [-0.3764, -0.0877, 0.0493]), + (664.0, [-0.2797, -0.0051, 0.0994]), + (664.8, [-0.2455, 0.0222, 0.1149]), + (665.6, [-0.2069, 0.0525, 0.1344]), + (668.0, [-0.0821, 0.1518, 0.1992]), + (671.2, [0.0998, 0.2953, 0.2941]), + (675.2, [0.3575, 0.4956, 0.4246]), + (677.6, [0.5423, 0.638, 0.5204]), + (680.8, [0.8104, 0.8469, 0.6515]), + (684.8, [1.213, 1.155, 0.8504]), + (688.0, [1.588, 1.447, 1.044]), + (692.0, [2.001, 1.771, 1.279]), + (694.4, [2.161, 1.893, 1.383]), + (697.6, [2.239, 1.954, 1.482]), + (700.0, [2.113, 1.85, 1.468]), + (701.6, [1.942, 1.715, 1.424]), + (703.2, [1.681, 1.507, 1.327]), + (704.8, [1.337, 1.226, 1.179]), + (705.6, [1.136, 1.06, 1.091]), + (706.4, [0.9006, 0.8647, 0.9808]), + (707.2, [0.6322, 0.6417, 0.8511]), + (708.0, [0.3302, 0.3894, 0.7029]), + (708.8, [-0.0061, 0.1075, 0.5361]), + (709.6, [-0.3793, -0.2066, 0.3483]), + (710.4, [-0.7883, -0.553, 0.1393]), + (711.2, [-1.231, -0.9306, -0.0914]), + (712.0, [-1.685, -1.318, -0.3301]), + (712.8, [-2.112, -1.684, -0.5529]), + (713.6, [-2.476, -1.994, -0.7392]), + (714.4, [-2.747, -2.225, -0.875]), + (715.2, [-2.935, -2.385, -0.9705]), + (716.0, [-3.05, -2.482, -1.032]), + (716.8, [-3.099, -2.523, -1.063]), + (718.4, [-3.036, -2.475, -1.062]), + (719.2, [-2.937, -2.396, -1.04]), + (720.8, [-2.639, -2.156, -0.9624]), + (722.4, [-2.246, -1.838, -0.8572]), + (724.8, [-1.573, -1.29, -0.6635]), + (726.4, [-1.122, -0.9215, -0.4972]), + (727.2, [-0.9093, -0.7476, -0.403]), + (728.0, [-0.7144, -0.5898, -0.3099]), + (728.8, [-0.5401, -0.4496, -0.2208]), + (729.6, [-0.3841, -0.3228, -0.134]), + (730.4, [-0.2525, -0.2157, -0.0566]), + (731.2, [-0.1453, -0.1276, 0.009]), + (732.0, [-0.0601, -0.0565, 0.0609]), + (732.8, [0.0118, 0.0062, 0.1111]), + (733.6, [0.0733, 0.0639, 0.162]), + (734.4, [0.1184, 0.1108, 0.2061]), + (735.2, [0.1532, 0.1516, 0.2468]), + (736.0, [0.18, 0.185, 0.2804]), + (736.8, [0.1873, 0.2008, 0.2945]), + (737.6, [0.1741, 0.1981, 0.2883]), + (738.4, [0.1367, 0.1723, 0.261]), + (739.2, [0.0793, 0.1272, 0.2176]), + (740.0, [0.0097, 0.0709, 0.1639]), + (741.6, [-0.1545, -0.0672, 0.0384]), + (742.4, [-0.23, -0.1312, -0.0131]), + (744.0, [-0.3684, -0.2528, -0.1093]), + (744.8, [-0.4239, -0.3021, -0.1513]), + (746.4, [-0.4977, -0.3689, -0.2153]), + (747.2, [-0.5193, -0.3881, -0.2409]), + (748.0, [-0.5254, -0.3918, -0.2578]), + (748.8, [-0.5071, -0.3708, -0.2551]), + (749.6, [-0.473, -0.3329, -0.2373]), + (751.2, [-0.3734, -0.2307, -0.1693]), + (752.0, [-0.3264, -0.1872, -0.1387]), + (752.8, [-0.3048, -0.1789, -0.1306]), + (753.6, [-0.2973, -0.1926, -0.1336]), + (754.4, [-0.3059, -0.2248, -0.1484]), + (755.2, [-0.321, -0.2658, -0.1624]), + (756.0, [-0.3535, -0.3302, -0.1878]), + (756.8, [-0.3984, -0.4052, -0.223]), + (758.4, [-0.4829, -0.5495, -0.2792]), + (760.0, [-0.562, -0.6941, -0.3687]), + (761.6, [-0.6308, -0.8209, -0.5008]), + (763.2, [-0.6601, -0.8745, -0.6294]), + (764.0, [-0.6599, -0.8752, -0.7051]), + (765.6, [-0.6337, -0.84, -0.8642]), + (767.2, [-0.5641, -0.7513, -1.016]), + (768.0, [-0.5236, -0.6963, -1.071]), + (768.8, [-0.4973, -0.6551, -1.142]), + (770.4, [-0.4741, -0.5986, -1.314]), + (772.0, [-0.4329, -0.5179, -1.466]), + (772.8, [-0.4269, -0.4922, -1.56]), + (774.4, [-0.4417, -0.4753, -1.749]), + (776.0, [-0.4743, -0.4974, -1.93]), + (776.8, [-0.4813, -0.5062, -1.994]), + (777.6, [-0.4914, -0.5279, -2.057]), + (778.4, [-0.513, -0.5716, -2.109]), + (780.0, [-0.5806, -0.7069, -2.211]), + (780.8, [-0.6425, -0.8172, -2.269]), + (782.4, [-0.8115, -1.13, -2.423]), + (783.2, [-0.882, -1.266, -2.495]), + (784.8, [-1.015, -1.524, -2.642]), + (785.6, [-1.06, -1.614, -2.699]), + (786.4, [-1.067, -1.639, -2.713]), + (788.0, [-1.041, -1.602, -2.693]), + (792.8, [-1.007, -1.524, -2.751]), + (795.2, [-1.014, -1.515, -2.796]), + (799.2, [-0.9618, -1.47, -2.767]), + (803.2, [-0.8822, -1.416, -2.649]), + (804.8, [-0.8168, -1.353, -2.554]), + (805.6, [-0.765, -1.3, -2.485]), + (808.0, [-0.7531, -1.26, -2.55]), + (809.6, [-0.7873, -1.285, -2.622]), + (810.4, [-0.7672, -1.274, -2.584]), + (811.2, [-0.7315, -1.245, -2.494]), + (812.0, [-0.6608, -1.164, -2.345]), + (812.8, [-0.6121, -1.108, -2.203]), + (813.6, [-0.4339, -0.898, -1.933]), + (814.4, [-0.2664, -0.7207, -1.66]), + (815.2, [-0.0797, -0.5244, -1.314]), + (816.0, [0.021, -0.3624, -0.7921]), + (816.8, [0.1033, -0.1711, -0.1526]), + (817.6, [0.1727, 0.0322, 0.3113]), + (818.4, [0.2038, 0.1774, 0.5556]), + (819.2, [0.2379, 0.1945, 0.5059]), + (820.0, [-0.0764, -0.0926, -0.0887]), + (820.8, [-0.5861, -0.6086, -1.046]), + (821.6, [0.2565, 0.3822, 0.1155]), + (822.4, [0.0056, -0.0036, -0.0877]), + (823.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.0772, 0.0371, -0.0814]), + (353.35, [0.0625, 0.0264, -0.0824]), + (417.3, [0.0645, 0.0279, -0.0823]), + (466.07, [0.0699, 0.0308, -0.0838]), + (498.05, [0.0782, 0.0362, -0.083]), + (518.03, [0.0806, 0.0369, -0.0853]), + (522.83, [0.0841, 0.0399, -0.0832]), + (543.61, [0.0891, 0.0439, -0.0798]), + (562.0, [0.0895, 0.0433, -0.0811]), + (575.59, [0.0916, 0.0467, -0.076]), + (605.17, [0.0957, 0.0514, -0.0745]), + (628.35, [0.1071, 0.0606, -0.0733]), + (658.73, [0.1312, 0.0772, -0.0707]), + (676.32, [0.1377, 0.0793, -0.0731]), + (704.3, [0.1528, 0.0873, -0.0695]), + (719.49, [0.1555, 0.0876, -0.0692]), + (729.08, [0.1527, 0.0847, -0.0695]), + (745.87, [0.1389, 0.0725, -0.0773]), + (753.07, [0.1303, 0.0657, -0.082]), + (764.26, [0.1053, 0.0449, -0.0977]), + (766.66, [0.1057, 0.0449, -0.0992]), + (769.85, [0.1143, 0.0521, -0.0967]), + (774.65, [0.1448, 0.0766, -0.0836]), + (776.25, [0.1584, 0.0863, -0.0786]), + (777.85, [0.1754, 0.1004, -0.0708]), + (780.25, [0.2057, 0.1247, -0.0545]), + (783.44, [0.2387, 0.1533, -0.0353]), + (785.04, [0.2673, 0.1781, -0.0187]), + (787.44, [0.3227, 0.2275, 0.0173]), + (791.44, [0.4126, 0.3074, 0.0682]), + (796.24, [0.5036, 0.3873, 0.1153]), + (801.83, [0.5665, 0.4432, 0.1531]), + (808.23, [0.6579, 0.5261, 0.2193]), + (810.63, [0.6891, 0.5537, 0.2365]), + (814.62, [0.7571, 0.616, 0.2963]), + (817.82, [0.7808, 0.6368, 0.3258]), + (821.02, [0.8264, 0.6822, 0.3821]), + (823.42, [0.8469, 0.7061, 0.4154]), + (830.61, [0.9185, 0.7909, 0.5657]), + (831.41, [0.9252, 0.8055, 0.5847]), + (833.01, [0.9698, 0.8973, 0.6599]), + (835.41, [1.08, 1.12, 0.8373]), + (837.01, [1.166, 1.284, 0.9948]), + (838.61, [1.228, 1.42, 1.145]), + (839.41, [1.251, 1.475, 1.23]), + (841.8, [1.324, 1.623, 1.576]), + (843.4, [1.356, 1.676, 1.844]), + (845.0, [1.363, 1.689, 2.134]), + (847.4, [1.31, 1.621, 2.569]), + (849.8, [1.3, 1.584, 3.068]), + (852.2, [1.249, 1.514, 3.549]), + (853.79, [1.258, 1.524, 3.871]), + (856.19, [1.312, 1.617, 4.318]), + (857.79, [1.386, 1.766, 4.584]), + (859.39, [1.482, 1.963, 4.807]), + (860.19, [1.549, 2.094, 4.913]), + (864.19, [2.015, 2.961, 5.52]), + (864.99, [2.086, 3.092, 5.62]), + (865.79, [2.118, 3.151, 5.676]), + (875.38, [2.196, 3.289, 5.935]), + (879.38, [2.121, 3.238, 5.893]), + (882.57, [2.012, 3.146, 5.797]), + (884.97, [1.829, 2.967, 5.569]), + (888.17, [1.712, 2.809, 5.416]), + (888.97, [1.681, 2.781, 5.338]), + (889.77, [1.607, 2.71, 5.196]), + (890.57, [1.512, 2.618, 4.992]), + (891.37, [1.366, 2.456, 4.7]), + (892.17, [1.256, 2.315, 4.44]), + (893.77, [0.773, 1.718, 3.608]), + (894.57, [0.4839, 1.376, 3.056]), + (895.37, [0.2653, 1.047, 2.284]), + (896.16, [0.0671, 0.6838, 1.377]), + (896.96, [-0.0986, 0.3119, 0.622]), + (897.76, [-0.2027, 0.0037, 0.0263]), + (898.56, [-0.2614, -0.1368, -0.2718]), + (899.36, [0.0868, 0.0875, 0.0211]), + (900.16, [0.6392, 0.5913, 0.7396]), + (900.96, [-0.1996, -0.3848, -0.5084]), + (901.76, [0.0656, 0.1096, 0.0597]), + (902.56, [0.0011, -0.0002, -0.0025]), + (903.36, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: 1.4995, + perp: 0.0, + knots: &[ + (0.0, [-0.2041, -0.1018, 0.0017]), + (0.79965, [-0.194, -0.0946, 0.0078]), + (1.5993, [-0.1987, -0.0995, 0.0035]), + (3.9983, [-0.1966, -0.1016, 0.0059]), + (9.5958, [-0.1918, -0.1156, -0.0004]), + (15.193, [-0.1663, -0.1187, -0.0073]), + (19.991, [-0.1416, -0.1134, -0.0111]), + (24.789, [-0.1182, -0.1027, -0.0134]), + (31.986, [-0.0875, -0.0747, -0.0121]), + (35.185, [-0.0761, -0.0615, -0.0096]), + (38.383, [-0.0669, -0.0502, -0.0071]), + (42.382, [-0.0585, -0.041, -0.0048]), + (50.378, [-0.0498, -0.0321, -0.0032]), + (77.566, [-0.0346, -0.0221, -0.0036]), + (80.765, [-0.0388, -0.0249, -0.0037]), + (84.763, [-0.0375, -0.024, -0.0038]), + (112.75, [-0.0213, -0.0136, -0.0023]), + (131.94, [-0.0141, -0.0106, -0.006]), + (157.53, [0.007, 0.0029, -0.0048]), + (158.33, [0.0065, 0.0025, -0.005]), + (160.73, [-0.0023, -0.0029, -0.0052]), + (167.93, [-0.0001, -0.0017, -0.0055]), + (183.12, [-0.0012, -0.0024, -0.0056]), + (205.51, [0.0012, 0.0009, -0.0009]), + (219.11, [-0.0006, 0.0001, 0.0003]), + (231.9, [-0.0033, -0.0022, -0.001]), + (278.28, [-0.0019, -0.0011, -0.0005]), + ], + }, + Radial { + t: 1.5446, + perp: 0.0, + knots: &[ + (0.0, [-0.0509, 0.0376, 0.0331]), + (0.79862, [-0.0606, 0.0308, 0.0344]), + (1.5972, [-0.0568, 0.0329, 0.0321]), + (2.3959, [-0.0595, 0.0295, 0.0326]), + (3.1945, [-0.0614, 0.0238, 0.028]), + (3.9931, [-0.0647, 0.0191, 0.0284]), + (5.5903, [-0.0759, 0.0013, 0.0198]), + (6.3889, [-0.0832, -0.01, 0.0154]), + (7.9862, [-0.0926, -0.0293, 0.0084]), + (9.5834, [-0.0987, -0.045, 0.0027]), + (10.382, [-0.1002, -0.0518, -0.0003]), + (11.979, [-0.0968, -0.0595, -0.0033]), + (13.577, [-0.0885, -0.0619, -0.004]), + (14.375, [-0.0819, -0.0604, -0.0029]), + (16.771, [-0.0675, -0.0597, -0.0066]), + (18.368, [-0.0554, -0.0561, -0.0063]), + (23.959, [-0.0215, -0.0431, -0.0096]), + (27.952, [-0.0013, -0.0301, -0.0106]), + (33.542, [0.0243, -0.0031, -0.0068]), + (35.938, [0.0319, 0.0064, -0.0062]), + (37.535, [0.0379, 0.0136, -0.0037]), + (41.528, [0.0489, 0.0263, 0.0006]), + (43.125, [0.0501, 0.0281, -0.0003]), + (45.521, [0.0541, 0.0327, 0.002]), + (50.313, [0.0572, 0.0367, 0.0033]), + (83.855, [0.0394, 0.0248, 0.0026]), + (93.438, [0.0367, 0.0234, 0.0033]), + (111.01, [0.026, 0.0165, 0.0018]), + (138.96, [0.0135, 0.01, 0.005]), + (144.55, [0.0092, 0.0073, 0.0049]), + (149.34, [0.0029, 0.0033, 0.0053]), + (158.13, [-0.0025, -0.0001, 0.0043]), + (164.52, [-0.0034, -0.0007, 0.0046]), + (190.07, [-0.0009, 0.0011, 0.005]), + (203.65, [-0.0034, -0.0017, 0.0017]), + (220.42, [-0.0019, -0.0012, 0.0002]), + ], + }, + Radial { + t: 2.1508, + perp: 0.0, + knots: &[ + (0.0, [0.3136, 0.229, 0.0623]), + (0.79212, [0.3456, 0.2574, 0.0827]), + (2.3764, [0.333, 0.2461, 0.0763]), + (3.1685, [0.3422, 0.2533, 0.0778]), + (3.9606, [0.3252, 0.2388, 0.0688]), + (4.7527, [0.3406, 0.2526, 0.078]), + (5.5449, [0.3268, 0.2409, 0.0742]), + (6.337, [0.3326, 0.2472, 0.081]), + (7.9212, [0.3267, 0.2446, 0.087]), + (8.7134, [0.336, 0.2506, 0.0902]), + (9.5055, [0.231, 0.1747, 0.069]), + (10.298, [0.0668, 0.0523, 0.0273]), + (11.09, [0.0202, 0.016, 0.0103]), + (11.882, [0.022, 0.017, 0.0104]), + (12.674, [0.0167, 0.0134, 0.0086]), + (13.466, [0.0146, 0.0111, 0.0073]), + (14.258, [0.0094, 0.0064, 0.0033]), + (15.842, [0.0042, 0.0024, -0.0006]), + (18.219, [0.0016, 0.0006, -0.0007]), + ], + }, + Radial { + t: 2.8825, + perp: 0.0, + knots: &[ + (0.0, [-0.1805, -0.1582, -0.147]), + (0.79613, [-0.1872, -0.1708, -0.1562]), + (1.5923, [-0.1833, -0.1648, -0.153]), + (2.3884, [-0.1847, -0.167, -0.1557]), + (3.9806, [-0.1783, -0.1607, -0.1497]), + (9.5535, [-0.1706, -0.1528, -0.143]), + (13.534, [-0.1678, -0.1497, -0.1423]), + (25.476, [-0.1455, -0.1304, -0.1376]), + (35.03, [-0.1142, -0.1037, -0.1234]), + (42.195, [-0.0798, -0.0722, -0.1009]), + (43.787, [-0.0697, -0.0639, -0.0946]), + (46.175, [-0.0607, -0.0541, -0.0873]), + (49.36, [-0.0541, -0.0436, -0.0814]), + (50.952, [-0.0554, -0.0408, -0.0809]), + (54.137, [-0.044, -0.0269, -0.0726]), + (57.321, [-0.0382, -0.0178, -0.0696]), + (59.709, [-0.0309, -0.0085, -0.0643]), + (63.69, [-0.0238, 0.0012, -0.0594]), + (64.486, [-0.0272, -0.0034, -0.0613]), + (65.282, [-0.035, -0.015, -0.0651]), + (66.078, [-0.0445, -0.0303, -0.0687]), + (66.875, [-0.0491, -0.0391, -0.0701]), + (68.467, [-0.0507, -0.0423, -0.0709]), + (82.001, [-0.044, -0.0397, -0.0684]), + (89.166, [-0.0417, -0.0397, -0.0701]), + (95.535, [-0.0367, -0.0361, -0.0627]), + (101.9, [-0.027, -0.0261, -0.0438]), + (109.07, [-0.0161, -0.0127, -0.0172]), + (110.66, [-0.012, -0.0084, -0.0105]), + (114.64, [-0.0066, -0.0027, -0.0017]), + (117.83, [-0.0035, -0.0011, 0.0023]), + (122.6, [0.0003, 0.0003, 0.0025]), + (126.58, [0.0001, -0.0001, 0.0008]), + (128.18, [-0.0012, -0.0015, -0.0009]), + (133.75, [-0.0019, -0.0018, -0.0008]), + (134.55, [-0.004, -0.0037, -0.0024]), + (137.73, [-0.0045, -0.0038, -0.0024]), + (138.53, [-0.0062, -0.0055, -0.004]), + (140.91, [-0.0047, -0.0037, -0.0015]), + (145.69, [-0.005, -0.0035, -0.0015]), + (151.26, [-0.0072, -0.0065, -0.0024]), + (155.24, [-0.0047, -0.0048, -0.0002]), + (160.82, [-0.0047, -0.0041, -0.0011]), + (162.41, [-0.0028, -0.002, -0.0004]), + ], + }, + Radial { + t: 2.3814, + perp: 0.0, + knots: &[ + (0.0, [0.3461, 0.3718, 0.335]), + (0.79168, [0.2913, 0.3338, 0.332]), + (1.5834, [0.2429, 0.308, 0.3313]), + (2.375, [0.1641, 0.2597, 0.322]), + (3.1667, [0.1333, 0.2442, 0.3204]), + (3.9584, [0.1252, 0.2397, 0.3179]), + (4.7501, [0.1286, 0.247, 0.3203]), + (5.5418, [0.1247, 0.2403, 0.3166]), + (6.3335, [0.1146, 0.2274, 0.3158]), + (7.9168, [0.0551, 0.1063, 0.3101]), + (8.7085, [0.0428, 0.0864, 0.2839]), + (9.5002, [0.038, 0.0777, 0.2493]), + (10.292, [0.0361, 0.0739, 0.2155]), + (11.084, [0.0309, 0.0635, 0.1792]), + (12.667, [0.0201, 0.0427, 0.0975]), + (13.459, [0.0159, 0.0341, 0.0502]), + (14.25, [0.0125, 0.0254, 0.0145]), + (15.042, [0.0083, 0.0169, 0.0014]), + (15.834, [0.0058, 0.012, -0.0023]), + (16.625, [0.0019, 0.0026, -0.0073]), + (17.417, [-0.0017, -0.0037, -0.0106]), + (18.209, [-0.0027, -0.0069, -0.0119]), + (21.375, [-0.0014, -0.004, -0.0086]), + (24.542, [-0.003, -0.005, -0.0094]), + (29.292, [-0.0023, -0.0036, -0.0069]), + ], + }, + Radial { + t: 1.5292, + perp: 0.0, + knots: &[ + (0.0, [0.2844, 0.136, -0.0135]), + (0.78812, [0.2889, 0.1438, -0.0105]), + (3.9406, [0.2884, 0.1478, -0.0065]), + (6.305, [0.2942, 0.1653, 0.0019]), + (8.6693, [0.2861, 0.1751, 0.0056]), + (11.822, [0.2645, 0.1813, 0.0116]), + (17.339, [0.2063, 0.1706, 0.0168]), + (22.856, [0.149, 0.1471, 0.0209]), + (27.584, [0.1062, 0.1182, 0.0213]), + (30.737, [0.0825, 0.0953, 0.0201]), + (36.254, [0.0452, 0.0526, 0.0123]), + (40.194, [0.0266, 0.0305, 0.0078]), + (43.347, [0.0154, 0.0177, 0.0042]), + (47.287, [0.0063, 0.0072, 0.0012]), + (51.228, [0.0013, 0.0017, -0.0001]), + ], + }, + Radial { + t: 2.1792, + perp: 0.0, + knots: &[ + (0.0, [0.0124, 0.0678, 0.1364]), + (0.79199, [0.0781, 0.1244, 0.1818]), + (1.584, [0.0744, 0.1173, 0.1659]), + (2.376, [0.0755, 0.1102, 0.1482]), + (3.168, [0.0736, 0.0995, 0.125]), + (3.96, [0.0487, 0.0684, 0.0862]), + (4.752, [0.0437, 0.0564, 0.0648]), + (5.544, [0.023, 0.0328, 0.0358]), + (7.1279, [0.0008, 0.0073, 0.0091]), + (7.9199, [-0.0025, 0.0047, 0.0051]), + (8.7119, [0.0032, 0.0089, 0.0075]), + (10.296, [0.0076, 0.0117, 0.0066]), + (12.672, [0.0167, 0.0179, 0.01]), + (13.464, [0.0169, 0.0187, 0.009]), + (15.84, [0.0312, 0.0293, 0.0142]), + (18.216, [0.0291, 0.0273, 0.0131]), + (19.8, [0.0185, 0.0187, 0.0081]), + (22.176, [0.0223, 0.0217, 0.0115]), + (27.72, [0.0252, 0.0238, 0.0108]), + (29.304, [0.0248, 0.0232, 0.0089]), + (30.096, [0.0221, 0.0209, 0.0072]), + (30.888, [0.0146, 0.0142, 0.0036]), + (31.68, [0.0019, 0.002, -0.0032]), + (32.472, [-0.0045, -0.0041, -0.0072]), + (33.264, [-0.0053, -0.0049, -0.008]), + (39.6, [-0.0062, -0.0071, -0.0087]), + (45.936, [-0.0046, -0.0049, -0.0065]), + (49.896, [-0.0012, 0.0007, -0.001]), + (56.232, [-0.0026, -0.0012, 0.0023]), + ], + }, + Radial { + t: 2.5968, + perp: 0.0, + knots: &[ + (0.0, [0.0006, -0.0111, 0.0097]), + (0.79014, [-0.0015, -0.0089, 0.0099]), + (2.3704, [0.002, -0.005, 0.0108]), + (3.1606, [0.0003, -0.0072, 0.0094]), + (3.9507, [0.0026, -0.0038, 0.011]), + (6.3211, [-0.0022, -0.0072, 0.0057]), + (7.9014, [0.0002, -0.0041, 0.0068]), + (8.6916, [-0.0007, -0.0045, 0.0058]), + (9.4817, [0.0025, -0.0009, 0.0085]), + (12.642, [0.0035, 0.0021, 0.0077]), + (14.223, [-0.0022, -0.0019, 0.0026]), + (15.013, [-0.0007, -0.0004, 0.004]), + (15.803, [-0.0024, -0.0017, 0.0021]), + (16.593, [-0.0017, -0.0009, 0.0029]), + (17.383, [0.0009, 0.0015, 0.0048]), + (18.173, [-0.0009, -0.0002, 0.002]), + (19.754, [-0.0015, -0.0013, 0.0004]), + (20.544, [-0.0002, 0.0004, 0.0016]), + (22.914, [-0.0018, -0.0013, -0.0009]), + (28.445, [-0.0015, -0.0013, -0.0003]), + (34.766, [-0.0011, -0.0009, -0.006]), + ], + }, + Radial { + t: 3.0, + perp: 0.0, + knots: &[ + (0.0, [0.014, 0.0155, 0.0193]), + (0.7893, [0.0067, 0.0059, 0.0116]), + (1.5786, [0.0049, 0.0033, 0.0074]), + (2.3679, [-0.0003, -0.0023, 0.0039]), + (3.1572, [0.0, -0.0015, 0.0039]), + (3.9465, [-0.0036, -0.005, -0.0002]), + (5.5251, [-0.002, -0.0044, 0.0]), + (7.1037, [0.0014, -0.0006, 0.0046]), + (11.05, [-0.0001, 0.0024, 0.0061]), + (13.418, [0.0011, 0.0061, 0.0097]), + (14.997, [-0.0001, 0.0062, 0.009]), + (18.154, [0.0043, 0.0129, 0.0152]), + (22.1, [0.0052, 0.0193, 0.0196]), + (24.468, [0.0111, 0.0269, 0.0268]), + (26.836, [0.0098, 0.0305, 0.0298]), + (29.204, [0.0131, 0.0373, 0.0354]), + (29.993, [0.0127, 0.0369, 0.0344]), + (30.783, [0.0087, 0.0281, 0.0267]), + (31.572, [0.0014, 0.0084, 0.0093]), + (32.361, [-0.0025, -0.0013, -0.0002]), + (33.15, [-0.0033, -0.0045, -0.0028]), + (37.097, [-0.0044, -0.0055, -0.0046]), + (40.254, [-0.003, -0.0038, -0.0028]), + (42.622, [-0.0046, -0.0062, -0.0042]), + (45.779, [-0.0025, -0.0035, -0.0025]), + (47.358, [-0.0033, -0.0045, -0.0033]), + (56.829, [-0.0006, -0.0008, -0.0003]), + ], + }, + Radial { + t: 1.4785, + perp: 0.0, + knots: &[ + (0.0, [0.028, 0.0035, -0.0118]), + (0.79316, [0.024, -0.0015, -0.0133]), + (3.1726, [0.0277, 0.0033, -0.0079]), + (3.9658, [0.0276, 0.0033, -0.0096]), + (4.759, [0.0284, 0.005, -0.0076]), + (7.9316, [0.029, 0.009, -0.0076]), + (11.104, [0.0279, 0.0131, -0.0037]), + (15.07, [0.0218, 0.0147, -0.0014]), + (19.036, [0.0169, 0.0119, -0.0011]), + (23.795, [0.0138, 0.0137, 0.0016]), + (28.554, [0.0072, 0.0089, 0.0011]), + (32.52, [0.0051, 0.006, 0.0021]), + (36.485, [0.0018, 0.0018, 0.0006]), + ], + }, + Radial { + t: 1.5515, + perp: 0.0, + knots: &[ + (0.0, [0.0042, -0.022, -0.0081]), + (0.77016, [-0.0007, -0.0227, -0.0161]), + (1.5403, [-0.0149, -0.0354, -0.016]), + (2.3105, [-0.0243, -0.0408, -0.0186]), + (3.8508, [-0.034, -0.0454, -0.0192]), + (5.3911, [-0.0309, -0.0394, -0.013]), + (9.242, [-0.0104, -0.0127, -0.0021]), + (12.323, [0.001, 0.0004, 0.0012]), + ], + }, + Radial { + t: 1.6547, + perp: 0.0, + knots: &[ + (0.0, [0.0091, 0.0048, 0.0025]), + (0.78645, [0.004, 0.0008, -0.0006]), + (1.5729, [0.0055, 0.0016, 0.0]), + (4.7187, [0.0035, -0.0004, -0.0018]), + (6.2916, [0.0049, 0.0013, -0.0001]), + (7.8645, [0.0036, 0.0006, -0.0016]), + (11.797, [0.0051, 0.0027, 0.0008]), + (12.583, [0.0034, 0.0014, -0.0008]), + (18.875, [0.0023, 0.0005, 0.0003]), + (19.661, [0.0007, -0.0008, -0.0004]), + ], + }, + Radial { + t: 2.6079, + perp: 0.0, + knots: &[ + (0.0, [-0.0007, 0.0074, -0.0096]), + (1.5811, [-0.0032, 0.0045, -0.0117]), + (2.3716, [0.0003, 0.008, -0.0078]), + (4.7433, [-0.0021, 0.0038, -0.0087]), + (5.5338, [-0.0009, 0.0041, -0.0071]), + (7.1149, [-0.0032, 0.0016, -0.0093]), + (9.4866, [-0.0022, 0.0009, -0.0067]), + (11.068, [-0.0042, -0.0021, -0.0084]), + (13.439, [-0.0032, -0.0024, -0.0079]), + (14.23, [-0.0013, -0.0009, -0.0055]), + (15.02, [-0.0019, -0.0017, -0.0062]), + (17.392, [0.0003, -0.0003, -0.0029]), + (19.764, [-0.0007, -0.0012, -0.0031]), + (20.554, [0.0011, 0.0007, -0.0002]), + ], + }, + Radial { + t: 2.9016, + perp: 0.0, + knots: &[ + (0.0, [0.2962, 0.2613, 0.2034]), + (1.5965, [0.2984, 0.2647, 0.206]), + (6.3859, [0.2879, 0.2517, 0.1962]), + (12.772, [0.2819, 0.2457, 0.1935]), + (19.956, [0.2732, 0.2385, 0.1955]), + (34.324, [0.2317, 0.2032, 0.1802]), + (45.5, [0.1809, 0.1556, 0.1474]), + (46.298, [0.1705, 0.1478, 0.1407]), + (47.096, [0.1418, 0.1303, 0.126]), + (47.895, [0.1077, 0.1112, 0.1106]), + (48.693, [0.0953, 0.1009, 0.102]), + (51.886, [0.0846, 0.0867, 0.093]), + (54.281, [0.0818, 0.0807, 0.0914]), + (56.675, [0.0733, 0.0699, 0.0849]), + (60.666, [0.0646, 0.0575, 0.0794]), + (62.263, [0.0589, 0.0487, 0.0761]), + (67.052, [0.0537, 0.0434, 0.0715]), + (69.447, [0.054, 0.0452, 0.0717]), + (76.631, [0.049, 0.0418, 0.0695]), + (89.403, [0.0443, 0.0408, 0.0698]), + (96.587, [0.0374, 0.0347, 0.0584]), + (101.38, [0.031, 0.0279, 0.0445]), + (106.96, [0.0204, 0.0157, 0.0224]), + (109.36, [0.0191, 0.014, 0.0167]), + (111.75, [0.0141, 0.0088, 0.0089]), + (118.14, [0.006, 0.0021, -0.0017]), + (121.33, [0.0026, 0.001, -0.0031]), + (122.93, [0.0019, 0.0011, -0.0016]), + ], + }, +]; +const PRIME105_200_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_200_RAYS: &[Ray] = &[]; +const PRIME105_300_RADIAL: &[Radial] = &[ + Radial { + t: 0.00059373, + perp: 0.0, + knots: &[ + (0.0, [0.3434, 0.3434, 0.3434]), + (718.4, [0.346, 0.346, 0.346]), + (810.4, [0.3302, 0.3302, 0.3302]), + (816.0, [0.3443, 0.3443, 0.3443]), + (817.6, [0.338, 0.338, 0.338]), + (819.2, [0.3168, 0.3168, 0.3168]), + (821.6, [0.2463, 0.2463, 0.2463]), + (822.4, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.0035238, + perp: 0.0, + knots: &[ + (0.0, [0.3449, 0.3449, 0.3449]), + (730.4, [0.3443, 0.3443, 0.3443]), + (812.8, [0.3356, 0.3356, 0.3356]), + (817.6, [0.3253, 0.3253, 0.3253]), + (819.2, [0.3291, 0.3291, 0.3291]), + (820.0, [0.3369, 0.3369, 0.3369]), + (820.8, [0.3553, 0.3553, 0.3553]), + (821.6, [0.3414, 0.3414, 0.3414]), + (822.4, [0.3729, 0.3729, 0.3729]), + (823.2, [0.0, 0.0, 0.0]), + ], + }, + Radial { + t: -0.2499, + perp: 0.0, + knots: &[ + (0.0, [0.3117, 0.3117, 0.3117]), + (822.62, [0.3125, 0.3125, 0.3125]), + (853.79, [0.3184, 0.3184, 0.3184]), + (879.38, [0.3338, 0.3338, 0.3338]), + (897.76, [0.3283, 0.3283, 0.3283]), + (899.36, [0.3376, 0.3376, 0.3376]), + (900.16, [0.3335, 0.3335, 0.3335]), + (900.96, [0.369, 0.369, 0.369]), + (901.76, [0.3656, 0.3656, 0.3656]), + (902.56, [0.0042, 0.0042, 0.0042]), + (903.36, [0.0, 0.0, 0.0]), + ], + }, +]; +const PRIME105_300_STREAKS: &[Streak] = + &[Streak { t: 0.0097303, perp: -1.1376, angle: 96.391, half: 231.44, taper: 0.17403, width: 2.711, rgb: [0.0315, 0.0318, 0.0299] }]; +const PRIME105_300_RAYS: &[Ray] = &[]; +pub(super) const PRIME105: &[Level] = &[ + Level { brightness: 10.0, radial: PRIME105_10_RADIAL, streaks: PRIME105_10_STREAKS, rays: PRIME105_10_RAYS }, + Level { brightness: 50.0, radial: PRIME105_50_RADIAL, streaks: PRIME105_50_STREAKS, rays: PRIME105_50_RAYS }, + Level { brightness: 75.0, radial: PRIME105_75_RADIAL, streaks: PRIME105_75_STREAKS, rays: PRIME105_75_RAYS }, + Level { brightness: 100.0, radial: PRIME105_100_RADIAL, streaks: PRIME105_100_STREAKS, rays: PRIME105_100_RAYS }, + Level { brightness: 125.0, radial: PRIME105_125_RADIAL, streaks: PRIME105_125_STREAKS, rays: PRIME105_125_RAYS }, + Level { brightness: 150.0, radial: PRIME105_150_RADIAL, streaks: PRIME105_150_STREAKS, rays: PRIME105_150_RAYS }, + Level { brightness: 200.0, radial: PRIME105_200_RADIAL, streaks: PRIME105_200_STREAKS, rays: PRIME105_200_RAYS }, + Level { brightness: 300.0, radial: PRIME105_300_RADIAL, streaks: PRIME105_300_STREAKS, rays: PRIME105_300_RAYS }, +]; + +const MOVIE_PRIME_10_RADIAL: &[Radial] = &[Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [0.797, 0.6681, 0.6641]), + (0.8, [0.7816, 0.6455, 0.6416]), + (1.6, [0.7035, 0.5304, 0.5264]), + (2.4, [0.5987, 0.3796, 0.3742]), + (3.2, [0.5134, 0.263, 0.2546]), + (4.0, [0.442, 0.1709, 0.1616]), + (4.8, [0.3982, 0.1233, 0.1145]), + (5.6, [0.3764, 0.1061, 0.0979]), + (7.2, [0.3466, 0.0972, 0.0886]), + (11.2, [0.2805, 0.0834, 0.0762]), + (16.8, [0.1985, 0.0616, 0.0546]), + (21.6, [0.1389, 0.045, 0.0392]), + (24.0, [0.115, 0.0391, 0.0331]), + (24.8, [0.0894, 0.031, 0.0265]), + (25.6, [0.0559, 0.0198, 0.017]), + (26.4, [0.0325, 0.0118, 0.0098]), + (27.2, [0.0218, 0.0079, 0.0065]), + (28.0, [0.0156, 0.0058, 0.0048]), + (29.6, [0.0089, 0.0033, 0.0028]), + (33.6, [0.0017, 0.0008, 0.0007]), + ], +}]; +const MOVIE_PRIME_10_STREAKS: &[Streak] = &[ + Streak { t: -0.0013744, perp: 0.0, angle: 0.017496, half: 6000.0, taper: 0.12679, width: 1.1111, rgb: [0.0039, 0.0116, 0.0216] }, + Streak { t: 0.12399, perp: 0.0, angle: 45.165, half: 325.79, taper: 0.39567, width: 0.81918, rgb: [0.0176, 0.0394, 0.0677] }, + Streak { t: 1.7983, perp: 0.0, angle: 45.122, half: 511.94, taper: 0.75998, width: 1.4135, rgb: [0.0034, 0.0097, 0.018] }, + Streak { t: 1.9986, perp: 0.0, angle: 0.0018621, half: 157.85, taper: 1.0, width: 1.116, rgb: [0.0085, 0.0237, 0.0437] }, +]; +const MOVIE_PRIME_10_RAYS: &[Ray] = &[ + Ray { angle: 43.29, r0: 139.06, r1: 195.31, fade: 0.05, rgb: [0.0137, 0.0314, 0.0549] }, + Ray { angle: 179.62, r0: 60.938, r1: 234.38, fade: 0.05, rgb: [0.002, 0.0059, 0.0118] }, + Ray { angle: 180.0, r0: 60.938, r1: 234.38, fade: 0.05, rgb: [0.0039, 0.0118, 0.0176] }, +]; +const MOVIE_PRIME_50_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [0.9958, 0.9842, 0.9842]), + (1.6, [0.9814, 0.9552, 0.9513]), + (5.6, [0.8918, 0.7676, 0.7461]), + (8.8, [0.8017, 0.6156, 0.5844]), + (12.8, [0.6887, 0.4547, 0.4172]), + (15.2, [0.624, 0.3713, 0.3362]), + (17.6, [0.5637, 0.3068, 0.2703]), + (19.2, [0.5298, 0.2757, 0.2426]), + (20.8, [0.4995, 0.253, 0.2172]), + (23.2, [0.4624, 0.2259, 0.2003]), + (31.2, [0.3453, 0.1601, 0.1395]), + (33.6, [0.3126, 0.1369, 0.125]), + (34.4, [0.3016, 0.1326, 0.1225]), + (36.0, [0.2809, 0.1241, 0.1103]), + (39.2, [0.2384, 0.1051, 0.0989]), + (41.6, [0.2124, 0.0924, 0.0832]), + (44.8, [0.1749, 0.072, 0.0696]), + (48.8, [0.1319, 0.0539, 0.0507]), + (52.0, [0.1106, 0.0378, 0.0419]), + (53.6, [0.0978, 0.0339, 0.0327]), + (56.0, [0.0771, 0.0292, 0.0285]), + (58.4, [0.0623, 0.019, 0.0238]), + (60.8, [0.0516, 0.0178, 0.0184]), + (62.4, [0.0431, 0.0176, 0.018]), + (67.2, [0.028, 0.0095, 0.0128]), + (68.8, [0.0234, 0.0091, 0.0097]), + (70.4, [0.017, 0.0089, 0.0092]), + (76.0, [0.0085, 0.0069, 0.009]), + (78.4, [0.0069, 0.0046, 0.005]), + (80.0, [0.0044, 0.0046, 0.0048]), + (88.0, [0.004, 0.0041, 0.0044]), + (88.8, [0.0041, 0.0024, 0.0043]), + (91.2, [0.0005, 0.0003, 0.0003]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.0458, 0.0601]), + (0.79957, [-0.0001, 0.0485, 0.0594]), + (11.194, [0.001, 0.0416, 0.0529]), + (23.987, [0.0002, 0.0093, 0.0117]), + (35.181, [0.0001, 0.0014, 0.0019]), + ], + }, + Radial { + t: 1.8215, + perp: 0.0, + knots: &[ + (0.0, [0.0031, 0.0069, 0.0116]), + (0.79072, [0.0032, 0.0069, 0.0114]), + (2.3722, [0.0012, 0.0026, 0.0042]), + (5.535, [0.0003, 0.0007, 0.0011]), + (7.9072, [0.0007, 0.0014, 0.0024]), + (16.605, [0.0001, 0.0004, 0.0006]), + (19.768, [0.0006, 0.0013, 0.0022]), + (32.42, [0.0005, 0.001, 0.0017]), + ], + }, + Radial { + t: 0.84541, + perp: 0.0, + knots: &[ + (0.0, [-0.0003, 0.0119, 0.0136]), + (4.6304, [-0.0003, 0.0095, 0.0115]), + (11.576, [-0.0004, 0.0043, 0.0049]), + (18.522, [0.0003, 0.0019, 0.0027]), + ], + }, +]; +const MOVIE_PRIME_50_STREAKS: &[Streak] = &[ + Streak { t: -0.0010401, perp: 0.0, angle: 0.018284, half: 5943.2, taper: 0.17314, width: 1.0624, rgb: [0.0429, 0.0849, 0.1394] }, + Streak { t: 0.1239, perp: 0.0, angle: 45.148, half: 379.48, taper: 0.99231, width: 5.3801, rgb: [0.0209, 0.0345, 0.0495] }, + Streak { t: 1.7985, perp: 0.0, angle: 45.116, half: 371.18, taper: 0.010862, width: 1.9725, rgb: [0.0178, 0.0332, 0.0508] }, + Streak { t: 1.9989, perp: 0.0, angle: 0.00086328, half: 177.25, taper: 1.0, width: 1.0363, rgb: [0.0711, 0.1475, 0.2468] }, +]; +const MOVIE_PRIME_50_RAYS: &[Ray] = &[ + Ray { angle: 0.38, r0: 17.969, r1: 233.59, fade: 0.05, rgb: [0.0098, 0.0176, 0.0294] }, + Ray { angle: 0.63, r0: 17.969, r1: 139.84, fade: 0.05, rgb: [0.0059, 0.0118, 0.0176] }, + Ray { angle: 1.01, r0: 17.969, r1: 86.719, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 37.2, r0: 60.938, r1: 83.594, fade: 0.05, rgb: [0.0127, 0.0137, 0.0118] }, + Ray { angle: 43.02, r0: 123.44, r1: 171.09, fade: 0.05, rgb: [0.0902, 0.1804, 0.2941] }, + Ray { angle: 43.29, r0: 137.5, r1: 196.09, fade: 0.05, rgb: [0.0843, 0.1627, 0.2647] }, + Ray { angle: 43.55, r0: 160.94, r1: 227.34, fade: 0.05, rgb: [0.0804, 0.1647, 0.2755] }, + Ray { angle: 43.81, r0: 196.09, r1: 272.66, fade: 0.05, rgb: [0.0941, 0.1922, 0.3098] }, + Ray { angle: 45.59, r0: 36.719, r1: 75.781, fade: 0.05, rgb: [0.0098, 0.0098, 0.0078] }, + Ray { angle: 135.46, r0: 47.656, r1: 80.469, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 179.62, r0: 8.5938, r1: 234.38, fade: 0.05, rgb: [0.0137, 0.0294, 0.0471] }, + Ray { angle: 180.0, r0: 16.406, r1: 234.38, fade: 0.05, rgb: [0.0294, 0.0569, 0.0922] }, + Ray { angle: 181.01, r0: 16.406, r1: 42.969, fade: 0.05, rgb: [0.0059, 0.0039, 0.0078] }, + Ray { angle: 181.28, r0: 16.406, r1: 32.812, fade: 0.05, rgb: [0.0039, 0.0039, 0.0059] }, + Ray { angle: 181.55, r0: 16.406, r1: 26.562, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, + Ray { angle: 225.01, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0098, 0.0127, 0.0137] }, + Ray { angle: 226.81, r0: 160.94, r1: 232.81, fade: 0.05, rgb: [0.0706, 0.1412, 0.2294] }, + Ray { angle: 227.11, r0: 136.72, r1: 191.41, fade: 0.05, rgb: [0.0863, 0.1725, 0.2922] }, + Ray { angle: 227.38, r0: 117.97, r1: 166.41, fade: 0.05, rgb: [0.0961, 0.1922, 0.3157] }, + Ray { angle: 227.72, r0: 102.34, r1: 144.53, fade: 0.05, rgb: [0.1078, 0.2118, 0.3431] }, + Ray { angle: 228.0, r0: 92.188, r1: 131.25, fade: 0.05, rgb: [0.1098, 0.2118, 0.3353] }, + Ray { angle: 247.08, r0: 28.906, r1: 82.812, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 358.49, r0: 6.25, r1: 26.562, fade: 0.05, rgb: [0.0059, 0.0039, 0.0078] }, + Ray { angle: 358.75, r0: 6.25, r1: 33.594, fade: 0.05, rgb: [0.0059, 0.0039, 0.0039] }, + Ray { angle: 359.01, r0: 6.25, r1: 42.188, fade: 0.05, rgb: [0.0059, 0.0039, 0.0059] }, +]; +const MOVIE_PRIME_75_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 0.9991, 0.9989]), + (2.4, [0.9921, 0.9757, 0.9696]), + (9.6, [0.9179, 0.7845, 0.7519]), + (18.4, [0.7627, 0.5299, 0.491]), + (24.0, [0.6486, 0.3888, 0.3549]), + (27.2, [0.5875, 0.324, 0.2963]), + (30.4, [0.5337, 0.2789, 0.2542]), + (37.6, [0.4309, 0.2055, 0.1974]), + (39.2, [0.4093, 0.1971, 0.1882]), + (40.8, [0.3901, 0.1791, 0.175]), + (44.8, [0.3364, 0.1533, 0.1531]), + (51.2, [0.2642, 0.1091, 0.1156]), + (53.6, [0.2419, 0.1002, 0.1031]), + (56.8, [0.2111, 0.0876, 0.0962]), + (58.4, [0.1998, 0.0816, 0.0885]), + (60.0, [0.1874, 0.0756, 0.0854]), + (62.4, [0.1697, 0.0734, 0.0797]), + (68.0, [0.1405, 0.062, 0.0671]), + (70.4, [0.1264, 0.0573, 0.0657]), + (72.0, [0.117, 0.0536, 0.0598]), + (75.2, [0.1017, 0.0527, 0.0572]), + (76.8, [0.0924, 0.0455, 0.0531]), + (77.6, [0.0891, 0.0449, 0.0509]), + (79.2, [0.0874, 0.0448, 0.0492]), + (81.6, [0.0774, 0.0407, 0.0449]), + (84.8, [0.065, 0.0384, 0.0438]), + (86.4, [0.06, 0.0332, 0.0371]), + (89.6, [0.0488, 0.0325, 0.0354]), + (92.0, [0.0439, 0.0284, 0.0326]), + (92.8, [0.0432, 0.0283, 0.0325]), + (94.4, [0.0361, 0.0284, 0.0323]), + (96.0, [0.0348, 0.0283, 0.0285]), + (97.6, [0.0313, 0.0242, 0.0282]), + (99.2, [0.0276, 0.024, 0.0283]), + (100.8, [0.0277, 0.024, 0.0247]), + (102.4, [0.0278, 0.0237, 0.0244]), + (104.0, [0.0241, 0.02, 0.0243]), + (108.8, [0.0231, 0.0201, 0.0204]), + (110.4, [0.0197, 0.0162, 0.0204]), + (112.0, [0.0198, 0.0162, 0.0203]), + (113.6, [0.0198, 0.0162, 0.0165]), + (114.4, [0.0196, 0.0163, 0.0166]), + (116.0, [0.0158, 0.0163, 0.0166]), + (117.6, [0.0159, 0.0127, 0.0165]), + (119.2, [0.0159, 0.0124, 0.0163]), + (120.8, [0.0159, 0.0124, 0.0126]), + (123.2, [0.0118, 0.0123, 0.0127]), + (124.8, [0.0119, 0.0122, 0.0126]), + (126.4, [0.0119, 0.0083, 0.0126]), + (127.2, [0.0119, 0.0084, 0.0123]), + (128.8, [0.012, 0.0083, 0.0085]), + (131.2, [0.0079, 0.0083, 0.0085]), + (134.4, [0.008, 0.0084, 0.0085]), + (136.0, [0.008, 0.0046, 0.0085]), + (137.6, [0.0081, 0.0043, 0.0051]), + (140.0, [0.004, 0.0042, 0.0043]), + (147.2, [0.004, 0.0041, 0.0041]), + (148.8, [0.004, 0.001, 0.0042]), + (150.4, [0.0033, 0.0001, 0.0003]), + (151.2, [0.0009, 0.0, 0.0001]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0001, 0.0691, 0.0923]), + (0.79957, [-0.0001, 0.0712, 0.0921]), + (3.1983, [-0.0001, 0.0698, 0.0905]), + (9.5948, [-0.0004, 0.0618, 0.0813]), + (11.194, [0.0013, 0.0612, 0.0817]), + (19.989, [0.0008, 0.0282, 0.0385]), + (22.388, [0.0002, 0.0167, 0.0229]), + (24.787, [0.0, 0.0103, 0.0141]), + (27.185, [0.0007, 0.0087, 0.0122]), + (30.384, [0.0002, 0.0048, 0.0066]), + (35.981, [0.0, 0.0013, 0.0018]), + ], + }, + Radial { + t: 1.8191, + perp: 0.0, + knots: &[ + (0.0, [0.0039, 0.0078, 0.0134]), + (0.79604, [0.0039, 0.0079, 0.0132]), + (2.3881, [0.0012, 0.0025, 0.0042]), + (3.9802, [0.0006, 0.0011, 0.002]), + (5.5723, [0.0005, 0.0009, 0.0016]), + (6.3684, [0.0009, 0.0019, 0.0032]), + (14.329, [0.0002, 0.0005, 0.0009]), + (32.638, [0.0005, 0.0011, 0.0018]), + ], + }, + Radial { + t: 0.84673, + perp: 0.0, + knots: &[ + (0.0, [-0.0002, 0.0183, 0.0236]), + (3.9523, [-0.0003, 0.0157, 0.0206]), + (11.857, [-0.0002, 0.0066, 0.0085]), + (21.343, [0.0002, 0.0012, 0.0016]), + ], + }, +]; +const MOVIE_PRIME_75_STREAKS: &[Streak] = &[ + Streak { t: -0.00095449, perp: 0.0, angle: 0.01348, half: 5779.5, taper: 0.40206, width: 1.1047, rgb: [0.0659, 0.1249, 0.2017] }, + Streak { t: 0.12405, perp: 0.0, angle: 45.164, half: 290.83, taper: 0.1344, width: 0.79032, rgb: [0.1723, 0.3301, 0.5247] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.122, half: 285.95, taper: 0.034813, width: 0.83456, rgb: [0.072, 0.1438, 0.2356] }, + Streak { t: 1.999, perp: 0.0, angle: 0.00023081, half: 179.3, taper: 1.0, width: 1.0836, rgb: [0.0999, 0.2026, 0.3352] }, +]; +const MOVIE_PRIME_75_RAYS: &[Ray] = &[ + Ray { angle: 0.38, r0: 17.188, r1: 234.38, fade: 0.05, rgb: [0.0137, 0.0275, 0.0451] }, + Ray { angle: 0.63, r0: 17.188, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0275] }, + Ray { angle: 1.01, r0: 17.188, r1: 86.719, fade: 0.05, rgb: [0.0078, 0.0118, 0.0176] }, + Ray { angle: 1.4, r0: 17.188, r1: 61.719, fade: 0.05, rgb: [0.0078, 0.0098, 0.0127] }, + Ray { angle: 1.69, r0: 17.188, r1: 51.562, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 6.97, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 8.31, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 9.3, r0: 53.906, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 10.69, r0: 50.781, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0118] }, + Ray { angle: 12.68, r0: 6.25, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 14.03, r0: 21.094, r1: 97.656, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 14.95, r0: 22.656, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 37.2, r0: 60.938, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 38.24, r0: 74.219, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 39.52, r0: 6.25, r1: 27.344, fade: 0.05, rgb: [0.0098, 0.0206, 0.0235] }, + Ray { angle: 40.37, r0: 6.25, r1: 30.469, fade: 0.05, rgb: [0.0078, 0.0147, 0.0186] }, + Ray { angle: 42.82, r0: 112.5, r1: 156.25, fade: 0.05, rgb: [0.1216, 0.2431, 0.3882] }, + Ray { angle: 43.12, r0: 129.69, r1: 179.69, fade: 0.05, rgb: [0.1412, 0.2706, 0.4353] }, + Ray { angle: 43.38, r0: 145.31, r1: 206.25, fade: 0.05, rgb: [0.1176, 0.2333, 0.3804] }, + Ray { angle: 43.7, r0: 178.91, r1: 251.56, fade: 0.05, rgb: [0.152, 0.302, 0.4716] }, + Ray { angle: 67.71, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 68.69, r0: 6.25, r1: 81.25, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 69.77, r0: 59.375, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 71.23, r0: 60.938, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 73.13, r0: 6.25, r1: 44.531, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 75.52, r0: 23.438, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0137, 0.0157] }, + Ray { angle: 97.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 98.74, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 99.73, r0: 51.562, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 101.16, r0: 39.844, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 103.07, r0: 6.25, r1: 42.188, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 104.37, r0: 6.25, r1: 76.562, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 105.45, r0: 21.094, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 127.41, r0: 6.25, r1: 96.094, fade: 0.05, rgb: [0.0078, 0.0118, 0.0108] }, + Ray { angle: 127.66, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0127, 0.0118] }, + Ray { angle: 128.66, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 129.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0059, 0.0098, 0.0078] }, + Ray { angle: 133.19, r0: 6.25, r1: 39.844, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 157.61, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 158.68, r0: 6.25, r1: 93.75, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 159.74, r0: 67.188, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 161.17, r0: 60.938, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 163.12, r0: 6.25, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 165.52, r0: 25.0, r1: 97.656, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 179.62, r0: 9.375, r1: 234.38, fade: 0.05, rgb: [0.0196, 0.0412, 0.0706] }, + Ray { angle: 180.0, r0: 16.406, r1: 234.38, fade: 0.05, rgb: [0.0431, 0.0843, 0.1373] }, + Ray { angle: 181.03, r0: 16.406, r1: 42.188, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 181.28, r0: 16.406, r1: 34.375, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 181.55, r0: 16.406, r1: 28.906, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 181.8, r0: 16.406, r1: 25.781, fade: 0.05, rgb: [0.0059, 0.0078, 0.0118] }, + Ray { angle: 187.42, r0: 6.25, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 188.85, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 189.73, r0: 53.906, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 191.14, r0: 43.75, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0078, 0.0078] }, + Ray { angle: 193.11, r0: 6.25, r1: 42.188, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 194.37, r0: 6.25, r1: 67.188, fade: 0.05, rgb: [0.0078, 0.0118, 0.0098] }, + Ray { angle: 195.45, r0: 18.75, r1: 97.656, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 217.67, r0: 16.406, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 218.73, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0078, 0.0098, 0.0118] }, + Ray { angle: 220.01, r0: 48.438, r1: 99.219, fade: 0.05, rgb: [0.0078, 0.0088, 0.0088] }, + Ray { angle: 221.96, r0: 6.25, r1: 25.0, fade: 0.05, rgb: [0.0098, 0.0235, 0.0275] }, + Ray { angle: 223.25, r0: 6.25, r1: 42.969, fade: 0.05, rgb: [0.0078, 0.0127, 0.0127] }, + Ray { angle: 224.26, r0: 6.25, r1: 95.312, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 225.57, r0: 6.25, r1: 30.469, fade: 0.05, rgb: [0.0039, 0.0088, 0.0098] }, + Ray { angle: 225.92, r0: 6.25, r1: 17.188, fade: 0.05, rgb: [0.0039, 0.0098, 0.0157] }, + Ray { angle: 227.06, r0: 139.06, r1: 196.09, fade: 0.05, rgb: [0.1255, 0.2539, 0.4118] }, + Ray { angle: 227.31, r0: 123.44, r1: 171.09, fade: 0.05, rgb: [0.1431, 0.2784, 0.4431] }, + Ray { angle: 227.56, r0: 109.38, r1: 153.91, fade: 0.05, rgb: [0.151, 0.2961, 0.4637] }, + Ray { angle: 227.84, r0: 97.656, r1: 138.28, fade: 0.05, rgb: [0.1588, 0.3059, 0.4647] }, + Ray { angle: 228.1, r0: 88.281, r1: 126.56, fade: 0.05, rgb: [0.1451, 0.2824, 0.4245] }, + Ray { angle: 247.09, r0: 22.656, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 248.26, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 249.3, r0: 70.312, r1: 99.219, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 250.73, r0: 60.938, r1: 96.875, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 251.57, r0: 7.8125, r1: 84.375, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 252.65, r0: 53.906, r1: 94.531, fade: 0.05, rgb: [0.0059, 0.0059, 0.0059] }, + Ray { angle: 254.97, r0: 23.438, r1: 98.438, fade: 0.05, rgb: [0.0118, 0.0157, 0.0137] }, + Ray { angle: 276.94, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 278.35, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 279.33, r0: 53.125, r1: 97.656, fade: 0.05, rgb: [0.0108, 0.0118, 0.0118] }, + Ray { angle: 280.73, r0: 48.438, r1: 97.656, fade: 0.05, rgb: [0.0088, 0.0078, 0.0078] }, + Ray { angle: 281.54, r0: 39.062, r1: 95.312, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 282.68, r0: 6.25, r1: 42.969, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 284.03, r0: 6.25, r1: 97.656, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 285.07, r0: 20.312, r1: 98.438, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 307.25, r0: 26.562, r1: 96.875, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 308.4, r0: 6.25, r1: 74.219, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 309.64, r0: 55.469, r1: 98.438, fade: 0.05, rgb: [0.0078, 0.0118, 0.0108] }, + Ray { angle: 311.61, r0: 56.25, r1: 85.938, fade: 0.05, rgb: [0.0059, 0.0078, 0.0059] }, + Ray { angle: 312.9, r0: 6.25, r1: 32.812, fade: 0.05, rgb: [0.0078, 0.0176, 0.0176] }, + Ray { angle: 313.92, r0: 6.25, r1: 68.75, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 315.0, r0: 6.25, r1: 85.938, fade: 0.05, rgb: [0.0118, 0.0157, 0.0157] }, + Ray { angle: 337.25, r0: 8.5938, r1: 97.656, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 338.3, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 339.3, r0: 60.938, r1: 99.219, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 340.76, r0: 57.812, r1: 96.875, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 342.67, r0: 6.25, r1: 40.625, fade: 0.05, rgb: [0.0098, 0.0157, 0.0196] }, + Ray { angle: 345.08, r0: 21.875, r1: 98.438, fade: 0.05, rgb: [0.0137, 0.0137, 0.0157] }, + Ray { angle: 357.95, r0: 10.156, r1: 28.125, fade: 0.05, rgb: [0.0059, 0.0078, 0.0059] }, + Ray { angle: 358.2, r0: 10.156, r1: 28.125, fade: 0.05, rgb: [0.0059, 0.0088, 0.0078] }, + Ray { angle: 358.49, r0: 10.156, r1: 29.688, fade: 0.05, rgb: [0.0059, 0.0098, 0.0098] }, + Ray { angle: 358.75, r0: 10.156, r1: 33.594, fade: 0.05, rgb: [0.0059, 0.0078, 0.0098] }, + Ray { angle: 359.01, r0: 10.156, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, +]; +const MOVIE_PRIME_100_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [0.9999, 1.0, 1.0]), + (2.4, [1.0, 0.9929, 0.9926]), + (7.2, [0.9871, 0.9371, 0.9152]), + (16.8, [0.9129, 0.7518, 0.7172]), + (28.0, [0.7523, 0.4998, 0.4769]), + (32.8, [0.6739, 0.4055, 0.3923]), + (36.0, [0.6215, 0.3543, 0.3422]), + (38.4, [0.586, 0.3197, 0.3114]), + (39.2, [0.5732, 0.308, 0.3071]), + (44.0, [0.5127, 0.2629, 0.2655]), + (47.2, [0.4754, 0.236, 0.2424]), + (48.8, [0.456, 0.2281, 0.2384]), + (49.6, [0.4474, 0.2202, 0.2287]), + (55.2, [0.3871, 0.1845, 0.2005]), + (56.8, [0.3748, 0.1776, 0.1899]), + (60.0, [0.3459, 0.164, 0.1806]), + (72.8, [0.2614, 0.1328, 0.1467]), + (77.6, [0.234, 0.1257, 0.1344]), + (84.8, [0.1949, 0.1118, 0.1235]), + (85.6, [0.1913, 0.1086, 0.1175]), + (87.2, [0.1851, 0.1082, 0.1167]), + (88.8, [0.1746, 0.1007, 0.1125]), + (90.4, [0.1685, 0.1, 0.1123]), + (92.0, [0.1582, 0.0959, 0.1043]), + (93.6, [0.1532, 0.096, 0.1042]), + (96.8, [0.1392, 0.0877, 0.0998]), + (98.4, [0.1345, 0.0843, 0.093]), + (100.8, [0.1225, 0.0837, 0.0885]), + (101.6, [0.122, 0.0808, 0.0883]), + (102.4, [0.1163, 0.0798, 0.0882]), + (104.0, [0.1111, 0.0798, 0.0844]), + (106.4, [0.1068, 0.0718, 0.0839]), + (107.2, [0.1034, 0.0718, 0.0775]), + (108.0, [0.1025, 0.0715, 0.0763]), + (108.8, [0.0969, 0.0684, 0.0763]), + (110.4, [0.094, 0.0677, 0.0733]), + (112.0, [0.0866, 0.0667, 0.072]), + (112.8, [0.0869, 0.0635, 0.0723]), + (113.6, [0.0843, 0.0638, 0.0689]), + (115.2, [0.0792, 0.064, 0.0682]), + (116.8, [0.0745, 0.0596, 0.0676]), + (117.6, [0.0752, 0.0599, 0.0642]), + (119.2, [0.07, 0.0601, 0.0642]), + (120.0, [0.0667, 0.058, 0.0646]), + (120.8, [0.0673, 0.0556, 0.0622]), + (123.2, [0.0631, 0.0563, 0.0603]), + (124.0, [0.0631, 0.0537, 0.0605]), + (124.8, [0.0624, 0.0518, 0.057]), + (125.6, [0.059, 0.052, 0.0562]), + (128.0, [0.0594, 0.0502, 0.0562]), + (128.8, [0.0561, 0.0477, 0.0525]), + (132.0, [0.0553, 0.048, 0.0524]), + (132.8, [0.0518, 0.0446, 0.0489]), + (136.0, [0.0506, 0.0439, 0.0484]), + (136.8, [0.0472, 0.0431, 0.0459]), + (137.6, [0.0474, 0.0398, 0.0439]), + (140.0, [0.0469, 0.0399, 0.0441]), + (140.8, [0.0433, 0.0398, 0.0436]), + (141.6, [0.0435, 0.0394, 0.04]), + (142.4, [0.0434, 0.0358, 0.0401]), + (144.0, [0.0436, 0.0358, 0.04]), + (144.8, [0.0412, 0.0358, 0.0401]), + (146.4, [0.0395, 0.0354, 0.0357]), + (147.2, [0.0394, 0.032, 0.036]), + (148.8, [0.0389, 0.0317, 0.0358]), + (149.6, [0.0354, 0.0317, 0.0361]), + (151.2, [0.0355, 0.0317, 0.0316]), + (152.8, [0.0356, 0.0275, 0.0318]), + (154.4, [0.0312, 0.0276, 0.0319]), + (156.0, [0.0315, 0.0276, 0.0277]), + (157.6, [0.0317, 0.0265, 0.0279]), + (158.4, [0.0301, 0.0235, 0.0279]), + (159.2, [0.0273, 0.0238, 0.0278]), + (160.0, [0.0277, 0.0237, 0.028]), + (161.6, [0.0277, 0.0238, 0.0236]), + (163.2, [0.0278, 0.024, 0.024]), + (164.8, [0.0234, 0.0195, 0.0239]), + (166.4, [0.0237, 0.0199, 0.0235]), + (167.2, [0.0237, 0.0199, 0.0202]), + (168.8, [0.024, 0.0198, 0.0201]), + (169.6, [0.0209, 0.0201, 0.0201]), + (171.2, [0.0198, 0.0157, 0.02]), + (172.8, [0.0198, 0.0159, 0.0197]), + (173.6, [0.0198, 0.016, 0.016]), + (175.2, [0.0198, 0.0159, 0.0161]), + (176.0, [0.0161, 0.0159, 0.0161]), + (176.8, [0.0158, 0.016, 0.0161]), + (178.4, [0.0158, 0.0119, 0.016]), + (179.2, [0.0158, 0.012, 0.0161]), + (180.8, [0.0158, 0.012, 0.012]), + (181.6, [0.016, 0.012, 0.0122]), + (183.2, [0.0118, 0.012, 0.0121]), + (185.6, [0.0119, 0.0119, 0.0122]), + (186.4, [0.0119, 0.0083, 0.0121]), + (187.2, [0.0119, 0.008, 0.0123]), + (188.8, [0.0119, 0.0081, 0.0078]), + (189.6, [0.0115, 0.0081, 0.0083]), + (190.4, [0.008, 0.0081, 0.0082]), + (195.2, [0.008, 0.0083, 0.0083]), + (196.8, [0.0079, 0.0039, 0.0084]), + (198.4, [0.0077, 0.0042, 0.004]), + (199.2, [0.0042, 0.0042, 0.0044]), + (208.0, [0.004, 0.0042, 0.0043]), + (209.6, [0.0039, 0.0001, 0.0041]), + (210.4, [0.0038, 0.0003, 0.0004]), + (211.2, [0.0001, 0.0003, 0.0004]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [0.0115, 0.118, 0.1485]), + (0.79957, [0.0089, 0.1182, 0.1461]), + (3.1983, [0.0022, 0.1055, 0.1289]), + (9.5948, [0.0005, 0.0923, 0.1121]), + (11.994, [0.0017, 0.0864, 0.106]), + (15.192, [0.0006, 0.0702, 0.0853]), + (21.588, [0.0005, 0.0314, 0.0388]), + (24.787, [0.0003, 0.017, 0.0213]), + (30.384, [0.0002, 0.0071, 0.0088]), + (36.78, [0.0, 0.0012, 0.0016]), + ], + }, + Radial { + t: 1.83, + perp: 0.0, + knots: &[ + (0.0, [0.0018, 0.0058, 0.0098]), + (0.79309, [0.0025, 0.0054, 0.0094]), + (3.1724, [0.0008, 0.0016, 0.0029]), + (7.1378, [0.0002, 0.0006, 0.0011]), + (30.931, [0.0005, 0.0011, 0.0019]), + ], + }, + Radial { + t: 0.84646, + perp: 0.0, + knots: &[ + (0.0, [0.0047, 0.0355, 0.0455]), + (0.79449, [0.0053, 0.0369, 0.0474]), + (1.589, [0.0042, 0.0345, 0.0444]), + (2.3835, [0.0017, 0.0296, 0.0369]), + (11.917, [-0.0001, 0.011, 0.0133]), + (15.89, [0.0001, 0.0052, 0.0063]), + (21.451, [0.0, 0.0013, 0.0014]), + ], + }, +]; +const MOVIE_PRIME_100_STREAKS: &[Streak] = &[ + Streak { t: 0.0000864, perp: 0.0, angle: 0.02032, half: 4760.3, taper: 0.01495, width: 1.1183, rgb: [0.0977, 0.1807, 0.2877] }, + Streak { t: 0.12391, perp: 0.0, angle: 45.163, half: 329.95, taper: 0.42439, width: 1.1752, rgb: [0.1469, 0.2728, 0.4212] }, + Streak { t: 1.7989, perp: 0.0, angle: 45.143, half: 288.76, taper: 0.039305, width: 2.0621, rgb: [0.0337, 0.0602, 0.0905] }, + Streak { t: 2.0001, perp: 0.0, angle: -0.0000721, half: 184.38, taper: 0.99976, width: 1.0663, rgb: [0.1469, 0.2947, 0.483] }, +]; +const MOVIE_PRIME_100_RAYS: &[Ray] = &[ + Ray { angle: 0.47, r0: 17.969, r1: 189.06, fade: 0.05, rgb: [0.0137, 0.0275, 0.049] }, + Ray { angle: 0.8, r0: 17.969, r1: 114.84, fade: 0.05, rgb: [0.0078, 0.0157, 0.0255] }, + Ray { angle: 1.05, r0: 17.969, r1: 83.594, fade: 0.05, rgb: [0.0078, 0.0137, 0.0196] }, + Ray { angle: 1.31, r0: 17.969, r1: 66.406, fade: 0.05, rgb: [0.0059, 0.0118, 0.0157] }, + Ray { angle: 1.57, r0: 17.969, r1: 54.688, fade: 0.05, rgb: [0.0059, 0.0098, 0.0137] }, + Ray { angle: 1.85, r0: 17.969, r1: 46.875, fade: 0.05, rgb: [0.0059, 0.0098, 0.0127] }, + Ray { angle: 2.33, r0: 24.219, r1: 38.281, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 7.32, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 8.01, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0167, 0.0167] }, + Ray { angle: 9.32, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0147, 0.0137, 0.0137] }, + Ray { angle: 10.53, r0: 78.906, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 11.76, r0: 35.938, r1: 107.03, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 12.6, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 13.85, r0: 18.75, r1: 67.188, fade: 0.05, rgb: [0.0039, 0.0098, 0.0078] }, + Ray { angle: 15.14, r0: 40.625, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 37.26, r0: 60.938, r1: 110.94, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 38.27, r0: 73.438, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0108, 0.0118] }, + Ray { angle: 39.41, r0: 85.938, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0127, 0.0137] }, + Ray { angle: 40.49, r0: 56.25, r1: 78.125, fade: 0.05, rgb: [0.1196, 0.2765, 0.4431] }, + Ray { angle: 41.61, r0: 6.25, r1: 40.625, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 43.02, r0: 122.66, r1: 171.09, fade: 0.05, rgb: [0.1725, 0.3373, 0.5255] }, + Ray { angle: 43.29, r0: 137.5, r1: 196.09, fade: 0.05, rgb: [0.1608, 0.3118, 0.4922] }, + Ray { angle: 43.55, r0: 160.16, r1: 227.34, fade: 0.05, rgb: [0.1608, 0.3235, 0.5412] }, + Ray { angle: 43.81, r0: 195.31, r1: 272.66, fade: 0.05, rgb: [0.1912, 0.3627, 0.5706] }, + Ray { angle: 67.69, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 68.57, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0127, 0.0137] }, + Ray { angle: 69.89, r0: 76.562, r1: 113.28, fade: 0.05, rgb: [0.0127, 0.0137, 0.0137] }, + Ray { angle: 71.13, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 71.87, r0: 32.812, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0098] }, + Ray { angle: 73.17, r0: 77.344, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 74.48, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 75.38, r0: 22.656, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 97.67, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 98.46, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 99.71, r0: 81.25, r1: 114.06, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 100.95, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0167, 0.0147, 0.0157] }, + Ray { angle: 102.24, r0: 32.812, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 102.97, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 104.25, r0: 6.25, r1: 39.844, fade: 0.05, rgb: [0.0039, 0.0127, 0.0176] }, + Ray { angle: 105.43, r0: 20.312, r1: 114.06, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 127.69, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 128.59, r0: 6.25, r1: 81.25, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 129.71, r0: 6.25, r1: 32.812, fade: 0.05, rgb: [0.0059, 0.0118, 0.0157] }, + Ray { angle: 130.84, r0: 85.938, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 132.0, r0: 89.844, r1: 110.94, fade: 0.05, rgb: [0.0157, 0.0167, 0.0157] }, + Ray { angle: 133.05, r0: 6.25, r1: 35.156, fade: 0.05, rgb: [0.0059, 0.0137, 0.0147] }, + Ray { angle: 134.26, r0: 6.25, r1: 57.031, fade: 0.05, rgb: [0.0059, 0.0127, 0.0147] }, + Ray { angle: 135.0, r0: 19.531, r1: 101.56, fade: 0.05, rgb: [0.0137, 0.0216, 0.0196] }, + Ray { angle: 157.63, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 158.56, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 159.79, r0: 75.781, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 161.13, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 161.87, r0: 45.312, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 163.13, r0: 73.438, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0137] }, + Ray { angle: 164.47, r0: 20.312, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 165.35, r0: 22.656, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 178.15, r0: 11.719, r1: 46.875, fade: 0.05, rgb: [0.0059, 0.0127, 0.0176] }, + Ray { angle: 178.45, r0: 11.719, r1: 55.469, fade: 0.05, rgb: [0.0078, 0.0137, 0.0196] }, + Ray { angle: 179.3, r0: 11.719, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0284, 0.0461] }, + Ray { angle: 179.62, r0: 11.719, r1: 234.38, fade: 0.05, rgb: [0.0255, 0.0569, 0.0922] }, + Ray { angle: 180.0, r0: 16.406, r1: 234.38, fade: 0.05, rgb: [0.0529, 0.1059, 0.1725] }, + Ray { angle: 181.01, r0: 17.969, r1: 42.969, fade: 0.05, rgb: [0.0059, 0.0098, 0.0137] }, + Ray { angle: 181.28, r0: 17.969, r1: 34.375, fade: 0.05, rgb: [0.0049, 0.0098, 0.0118] }, + Ray { angle: 181.55, r0: 17.969, r1: 29.688, fade: 0.05, rgb: [0.0059, 0.0088, 0.0108] }, + Ray { angle: 181.8, r0: 17.969, r1: 26.562, fade: 0.05, rgb: [0.0039, 0.0078, 0.0069] }, + Ray { angle: 187.67, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0157, 0.0176] }, + Ray { angle: 188.46, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 189.71, r0: 79.688, r1: 114.06, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 190.96, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0147, 0.0176, 0.0176] }, + Ray { angle: 192.24, r0: 34.375, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0098] }, + Ray { angle: 192.98, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 194.31, r0: 76.562, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 195.5, r0: 18.75, r1: 114.06, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 217.72, r0: 19.531, r1: 112.5, fade: 0.05, rgb: [0.0088, 0.0137, 0.0137] }, + Ray { angle: 218.63, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 219.77, r0: 6.25, r1: 39.062, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 220.93, r0: 87.5, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0118, 0.0118] }, + Ray { angle: 222.02, r0: 6.25, r1: 31.25, fade: 0.05, rgb: [0.0039, 0.0137, 0.0176] }, + Ray { angle: 223.24, r0: 6.25, r1: 75.0, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 224.27, r0: 6.25, r1: 84.375, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 225.57, r0: 6.25, r1: 14.844, fade: 0.05, rgb: [0.0039, 0.0049, 0.0108] }, + Ray { angle: 226.7, r0: 173.44, r1: 247.66, fade: 0.05, rgb: [0.1206, 0.2324, 0.3745] }, + Ray { angle: 226.95, r0: 150.0, r1: 214.06, fade: 0.05, rgb: [0.1353, 0.2706, 0.4314] }, + Ray { angle: 227.2, r0: 128.91, r1: 182.81, fade: 0.05, rgb: [0.1706, 0.3353, 0.5412] }, + Ray { angle: 227.54, r0: 110.16, r1: 155.47, fade: 0.05, rgb: [0.1863, 0.3608, 0.5569] }, + Ray { angle: 227.84, r0: 97.656, r1: 138.28, fade: 0.05, rgb: [0.1961, 0.3745, 0.549] }, + Ray { angle: 228.1, r0: 88.281, r1: 126.56, fade: 0.05, rgb: [0.1824, 0.3392, 0.4873] }, + Ray { angle: 247.23, r0: 22.656, r1: 113.28, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 248.21, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0088, 0.0137, 0.0137] }, + Ray { angle: 249.45, r0: 83.594, r1: 114.06, fade: 0.05, rgb: [0.0157, 0.0157, 0.0147] }, + Ray { angle: 250.74, r0: 39.062, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 251.44, r0: 42.969, r1: 112.5, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 252.75, r0: 70.312, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0118] }, + Ray { angle: 254.06, r0: 7.0312, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 254.97, r0: 21.875, r1: 111.72, fade: 0.05, rgb: [0.0127, 0.0137, 0.0137] }, + Ray { angle: 277.32, r0: 6.25, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 278.05, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 279.3, r0: 61.719, r1: 112.5, fade: 0.05, rgb: [0.0088, 0.0098, 0.0098] }, + Ray { angle: 280.59, r0: 82.031, r1: 112.5, fade: 0.05, rgb: [0.0147, 0.0137, 0.0137] }, + Ray { angle: 281.86, r0: 34.375, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0098, 0.0118] }, + Ray { angle: 282.6, r0: 6.25, r1: 113.28, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 283.9, r0: 6.25, r1: 44.531, fade: 0.05, rgb: [0.0039, 0.0127, 0.0127] }, + Ray { angle: 285.09, r0: 22.656, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 307.46, r0: 21.094, r1: 111.72, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 308.33, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0127, 0.0137] }, + Ray { angle: 309.42, r0: 6.25, r1: 39.844, fade: 0.05, rgb: [0.0059, 0.0118, 0.0127] }, + Ray { angle: 310.6, r0: 80.469, r1: 111.72, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 311.76, r0: 83.594, r1: 112.5, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 312.9, r0: 70.312, r1: 111.72, fade: 0.05, rgb: [0.0069, 0.0069, 0.0078] }, + Ray { angle: 315.0, r0: 6.25, r1: 100.0, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 337.28, r0: 21.875, r1: 112.5, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 338.21, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0098, 0.0137, 0.0137] }, + Ray { angle: 339.49, r0: 78.125, r1: 114.06, fade: 0.05, rgb: [0.0235, 0.0255, 0.0235] }, + Ray { angle: 340.81, r0: 6.25, r1: 110.94, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 341.56, r0: 31.25, r1: 112.5, fade: 0.05, rgb: [0.0098, 0.0118, 0.0118] }, + Ray { angle: 342.81, r0: 78.906, r1: 113.28, fade: 0.05, rgb: [0.0137, 0.0137, 0.0137] }, + Ray { angle: 344.06, r0: 6.25, r1: 111.72, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 344.97, r0: 21.094, r1: 111.72, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 357.95, r0: 10.156, r1: 32.031, fade: 0.05, rgb: [0.0039, 0.0078, 0.0059] }, + Ray { angle: 358.2, r0: 10.156, r1: 32.031, fade: 0.05, rgb: [0.0039, 0.0078, 0.0059] }, + Ray { angle: 358.49, r0: 10.156, r1: 32.031, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 358.75, r0: 10.156, r1: 35.938, fade: 0.05, rgb: [0.0059, 0.0078, 0.0078] }, + Ray { angle: 359.01, r0: 10.156, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0078, 0.0098] }, +]; +const MOVIE_PRIME_125_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 0.9999, 1.0]), + (4.8, [1.0, 0.9906, 0.9819]), + (9.6, [0.9951, 0.9549, 0.9326]), + (14.4, [0.9807, 0.8977, 0.8691]), + (22.4, [0.9289, 0.7669, 0.7422]), + (33.6, [0.8067, 0.5605, 0.5522]), + (40.8, [0.7142, 0.4408, 0.449]), + (44.0, [0.6726, 0.3962, 0.4099]), + (48.0, [0.6252, 0.3514, 0.368]), + (50.4, [0.6003, 0.3289, 0.3511]), + (57.6, [0.5318, 0.2866, 0.3107]), + (61.6, [0.5023, 0.2683, 0.2963]), + (83.2, [0.3653, 0.2134, 0.2335]), + (96.0, [0.2956, 0.1811, 0.2006]), + (105.6, [0.2458, 0.1644, 0.1785]), + (119.2, [0.1878, 0.1356, 0.1483]), + (125.6, [0.1653, 0.1266, 0.1381]), + (130.4, [0.1479, 0.1155, 0.127]), + (143.2, [0.115, 0.0988, 0.1075]), + (165.6, [0.083, 0.0712, 0.0786]), + (180.8, [0.066, 0.0555, 0.06]), + (187.2, [0.0592, 0.0507, 0.0557]), + (192.8, [0.0515, 0.0439, 0.0483]), + (195.2, [0.0509, 0.044, 0.0481]), + (196.8, [0.0475, 0.0414, 0.0447]), + (200.0, [0.0458, 0.0402, 0.0438]), + (202.4, [0.0434, 0.0362, 0.0405]), + (204.8, [0.0399, 0.0362, 0.0394]), + (207.2, [0.0396, 0.0323, 0.0365]), + (210.4, [0.0356, 0.0322, 0.0342]), + (212.8, [0.0352, 0.0283, 0.0324]), + (214.4, [0.0318, 0.0281, 0.0322]), + (216.0, [0.0316, 0.0282, 0.0287]), + (218.4, [0.0298, 0.0241, 0.0282]), + (219.2, [0.0278, 0.024, 0.0283]), + (222.4, [0.0277, 0.0236, 0.0241]), + (224.0, [0.0249, 0.0201, 0.0239]), + (228.8, [0.0227, 0.0197, 0.0197]), + (231.2, [0.0197, 0.0157, 0.0198]), + (233.6, [0.0198, 0.0156, 0.0158]), + (236.0, [0.016, 0.0156, 0.0156]), + (238.4, [0.0158, 0.0117, 0.0157]), + (240.8, [0.0159, 0.0117, 0.0118]), + (244.0, [0.0119, 0.0118, 0.0117]), + (246.4, [0.0119, 0.0079, 0.0119]), + (248.8, [0.0116, 0.0079, 0.008]), + (250.4, [0.008, 0.0079, 0.008]), + (253.6, [0.0079, 0.0082, 0.0081]), + (256.0, [0.0079, 0.0041, 0.0082]), + (258.4, [0.0058, 0.0041, 0.0042]), + (259.2, [0.004, 0.0041, 0.0042]), + (267.2, [0.004, 0.0041, 0.0042]), + (268.0, [0.0039, 0.0024, 0.0043]), + (270.4, [0.001, 0.0001, 0.0001]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0031, 0.1096, 0.1372]), + (0.79957, [-0.0016, 0.1105, 0.1404]), + (7.1961, [-0.0011, 0.1052, 0.1313]), + (9.5948, [-0.0012, 0.0991, 0.1243]), + (11.194, [0.0022, 0.099, 0.1268]), + (15.192, [0.0, 0.0774, 0.0972]), + (18.39, [-0.0003, 0.0562, 0.0707]), + (19.989, [0.0011, 0.0486, 0.0625]), + (22.388, [0.0, 0.0316, 0.0403]), + (24.787, [-0.0003, 0.0204, 0.0262]), + (27.185, [0.0008, 0.0171, 0.0228]), + (27.985, [0.0003, 0.0138, 0.0182]), + (31.983, [-0.0001, 0.0075, 0.0098]), + (39.179, [-0.0004, 0.0009, 0.0014]), + ], + }, + Radial { t: 1.372, perp: 0.0, knots: &[(0.0, [0.0, 0.0007, -0.0002]), (9.5534, [0.0005, 0.0009, 0.0015])] }, + Radial { + t: 0.84359, + perp: 0.0, + knots: &[ + (0.0, [0.0001, 0.0322, 0.0426]), + (4.7869, [-0.0004, 0.0261, 0.0326]), + (11.17, [-0.0003, 0.0122, 0.0147]), + (13.563, [0.0011, 0.0109, 0.0143]), + (15.956, [0.0005, 0.0069, 0.0087]), + (18.35, [0.0013, 0.0064, 0.0088]), + (19.148, [0.0008, 0.0048, 0.0064]), + (23.137, [0.0004, 0.0013, 0.0018]), + ], + }, +]; +const MOVIE_PRIME_125_STREAKS: &[Streak] = &[ + Streak { t: -0.00094542, perp: 0.0, angle: 0.013245, half: 5315.3, taper: 0.72268, width: 1.1147, rgb: [0.1205, 0.219, 0.3446] }, + Streak { t: 0.12404, perp: 0.0, angle: 45.159, half: 289.29, taper: 0.11141, width: 0.79857, rgb: [0.2496, 0.485, 0.7848] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.11, half: 285.89, taper: 0.032659, width: 0.84042, rgb: [0.1175, 0.2334, 0.3793] }, + Streak { t: 1.999, perp: 0.0, angle: -0.0000396, half: 181.22, taper: 1.0, width: 1.0809, rgb: [0.1685, 0.3348, 0.544] }, +]; +const MOVIE_PRIME_125_RAYS: &[Ray] = &[ + Ray { angle: 0.38, r0: 18.75, r1: 234.38, fade: 0.05, rgb: [0.0196, 0.0412, 0.0686] }, + Ray { angle: 0.7, r0: 18.75, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0196, 0.0333] }, + Ray { angle: 1.01, r0: 18.75, r1: 87.5, fade: 0.05, rgb: [0.0059, 0.0137, 0.0216] }, + Ray { angle: 1.28, r0: 18.75, r1: 67.969, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 1.55, r0: 18.75, r1: 55.469, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 1.8, r0: 18.75, r1: 48.438, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 2.05, r0: 25.781, r1: 42.969, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 2.3, r0: 25.781, r1: 38.281, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 7.19, r0: 6.25, r1: 130.47, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 8.29, r0: 6.25, r1: 71.875, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 9.4, r0: 89.062, r1: 137.5, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 10.51, r0: 89.844, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 11.64, r0: 78.125, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0196, 0.0157] }, + Ray { angle: 12.73, r0: 6.25, r1: 49.219, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 13.83, r0: 18.75, r1: 67.969, fade: 0.05, rgb: [0.0078, 0.0118, 0.0137] }, + Ray { angle: 14.93, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 37.13, r0: 60.156, r1: 126.56, fade: 0.05, rgb: [0.0196, 0.0216, 0.0206] }, + Ray { angle: 38.24, r0: 81.25, r1: 125.78, fade: 0.05, rgb: [0.0137, 0.0157, 0.0176] }, + Ray { angle: 39.41, r0: 88.281, r1: 125.0, fade: 0.05, rgb: [0.0186, 0.0196, 0.0196] }, + Ray { angle: 40.44, r0: 55.469, r1: 77.344, fade: 0.05, rgb: [0.1118, 0.2941, 0.4529] }, + Ray { angle: 41.73, r0: 6.25, r1: 40.625, fade: 0.05, rgb: [0.002, 0.0098, 0.0118] }, + Ray { angle: 42.91, r0: 6.25, r1: 63.281, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 43.16, r0: 129.69, r1: 184.38, fade: 0.05, rgb: [0.1961, 0.3882, 0.6039] }, + Ray { angle: 43.45, r0: 150.78, r1: 214.06, fade: 0.05, rgb: [0.1853, 0.3716, 0.6088] }, + Ray { angle: 43.7, r0: 178.91, r1: 251.56, fade: 0.05, rgb: [0.2363, 0.4618, 0.6814] }, + Ray { angle: 67.39, r0: 21.094, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 68.57, r0: 6.25, r1: 72.656, fade: 0.05, rgb: [0.0059, 0.0098, 0.0108] }, + Ray { angle: 69.79, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 70.95, r0: 82.812, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 72.11, r0: 93.75, r1: 126.56, fade: 0.05, rgb: [0.0314, 0.0353, 0.0353] }, + Ray { angle: 73.26, r0: 90.625, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 74.27, r0: 6.25, r1: 76.562, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 75.52, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 97.57, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 98.68, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 99.76, r0: 94.531, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 100.84, r0: 89.062, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 101.99, r0: 85.938, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 103.11, r0: 6.25, r1: 42.969, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 104.14, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 105.3, r0: 21.875, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 127.45, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0157, 0.0176] }, + Ray { angle: 128.41, r0: 6.25, r1: 124.22, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 129.76, r0: 50.781, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 130.78, r0: 87.5, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 132.02, r0: 82.031, r1: 125.78, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 133.05, r0: 6.25, r1: 68.75, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 134.11, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0039, 0.0098, 0.0118] }, + Ray { angle: 135.0, r0: 21.875, r1: 110.94, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 157.1, r0: 6.25, r1: 119.53, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 157.35, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0157, 0.0176] }, + Ray { angle: 158.51, r0: 6.25, r1: 65.625, fade: 0.05, rgb: [0.0039, 0.0118, 0.0137] }, + Ray { angle: 159.77, r0: 67.188, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 160.9, r0: 82.812, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 162.07, r0: 82.031, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0196] }, + Ray { angle: 163.23, r0: 6.25, r1: 46.875, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 164.25, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 165.41, r0: 24.219, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 177.79, r0: 12.5, r1: 39.062, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 178.08, r0: 12.5, r1: 45.312, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 178.36, r0: 12.5, r1: 53.125, fade: 0.05, rgb: [0.0059, 0.0137, 0.0176] }, + Ray { angle: 179.62, r0: 12.5, r1: 234.38, fade: 0.05, rgb: [0.0294, 0.0647, 0.1059] }, + Ray { angle: 180.0, r0: 17.969, r1: 234.38, fade: 0.05, rgb: [0.0608, 0.1216, 0.1961] }, + Ray { angle: 181.01, r0: 18.75, r1: 43.75, fade: 0.05, rgb: [0.0059, 0.0098, 0.0137] }, + Ray { angle: 181.28, r0: 18.75, r1: 35.156, fade: 0.05, rgb: [0.0039, 0.0088, 0.0108] }, + Ray { angle: 181.55, r0: 18.75, r1: 28.906, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, + Ray { angle: 187.57, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 188.68, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0157, 0.0157] }, + Ray { angle: 189.79, r0: 91.406, r1: 126.56, fade: 0.05, rgb: [0.0167, 0.0196, 0.0196] }, + Ray { angle: 190.84, r0: 86.719, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0176, 0.0157] }, + Ray { angle: 192.0, r0: 82.812, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 193.11, r0: 6.25, r1: 43.75, fade: 0.05, rgb: [0.0039, 0.0098, 0.0118] }, + Ray { angle: 194.19, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.0059, 0.0118, 0.0147] }, + Ray { angle: 195.35, r0: 20.312, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 217.5, r0: 26.562, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 218.52, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 219.81, r0: 84.375, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0176] }, + Ray { angle: 220.84, r0: 42.969, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0127, 0.0137] }, + Ray { angle: 222.12, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 223.15, r0: 6.25, r1: 73.438, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 224.23, r0: 6.25, r1: 125.0, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 227.06, r0: 139.06, r1: 196.09, fade: 0.05, rgb: [0.1892, 0.3784, 0.5961] }, + Ray { angle: 227.31, r0: 123.44, r1: 171.09, fade: 0.05, rgb: [0.198, 0.3941, 0.6127] }, + Ray { angle: 227.56, r0: 109.38, r1: 153.91, fade: 0.05, rgb: [0.2049, 0.4078, 0.6078] }, + Ray { angle: 227.88, r0: 96.094, r1: 136.72, fade: 0.05, rgb: [0.202, 0.402, 0.5588] }, + Ray { angle: 247.06, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0196, 0.0176] }, + Ray { angle: 248.21, r0: 73.438, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 249.45, r0: 82.812, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 250.53, r0: 81.25, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0186] }, + Ray { angle: 251.72, r0: 80.469, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 252.85, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 253.9, r0: 18.75, r1: 74.219, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 255.08, r0: 24.219, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 277.19, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 278.33, r0: 6.25, r1: 126.56, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 279.44, r0: 86.719, r1: 126.56, fade: 0.05, rgb: [0.0157, 0.0167, 0.0176] }, + Ray { angle: 280.53, r0: 89.062, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 281.63, r0: 42.188, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0098, 0.0098] }, + Ray { angle: 282.76, r0: 6.25, r1: 46.875, fade: 0.05, rgb: [0.0039, 0.0118, 0.0098] }, + Ray { angle: 283.83, r0: 6.25, r1: 67.969, fade: 0.05, rgb: [0.0059, 0.0127, 0.0147] }, + Ray { angle: 284.93, r0: 20.312, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 285.25, r0: 21.875, r1: 121.88, fade: 0.05, rgb: [0.0039, 0.0039, 0.0078] }, + Ray { angle: 307.17, r0: 21.094, r1: 126.56, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 308.24, r0: 6.25, r1: 125.78, fade: 0.05, rgb: [0.0059, 0.0127, 0.0137] }, + Ray { angle: 309.52, r0: 80.469, r1: 126.56, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 310.59, r0: 46.094, r1: 126.56, fade: 0.05, rgb: [0.0078, 0.0098, 0.0108] }, + Ray { angle: 311.83, r0: 80.469, r1: 126.56, fade: 0.05, rgb: [0.0167, 0.0186, 0.0196] }, + Ray { angle: 312.91, r0: 6.25, r1: 37.5, fade: 0.05, rgb: [0.002, 0.0098, 0.0137] }, + Ray { angle: 313.92, r0: 6.25, r1: 70.312, fade: 0.05, rgb: [0.0059, 0.0118, 0.0137] }, + Ray { angle: 315.0, r0: 6.25, r1: 114.06, fade: 0.05, rgb: [0.0137, 0.0196, 0.0196] }, + Ray { angle: 337.07, r0: 22.656, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 338.21, r0: 7.0312, r1: 126.56, fade: 0.05, rgb: [0.0069, 0.0137, 0.0137] }, + Ray { angle: 339.49, r0: 78.906, r1: 127.34, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 340.53, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0196, 0.0216, 0.0196] }, + Ray { angle: 341.74, r0: 50.0, r1: 127.34, fade: 0.05, rgb: [0.0059, 0.0098, 0.0118] }, + Ray { angle: 342.9, r0: 86.719, r1: 127.34, fade: 0.05, rgb: [0.0157, 0.0157, 0.0176] }, + Ray { angle: 343.94, r0: 6.25, r1: 71.875, fade: 0.05, rgb: [0.0039, 0.0118, 0.0137] }, + Ray { angle: 345.08, r0: 21.875, r1: 126.56, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 357.45, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0039] }, + Ray { angle: 357.7, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0039] }, + Ray { angle: 357.95, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 358.2, r0: 14.062, r1: 28.125, fade: 0.05, rgb: [0.002, 0.0059, 0.0078] }, + Ray { angle: 358.49, r0: 14.062, r1: 31.25, fade: 0.05, rgb: [0.0039, 0.0059, 0.0078] }, + Ray { angle: 358.75, r0: 14.062, r1: 35.938, fade: 0.05, rgb: [0.0039, 0.0059, 0.0078] }, + Ray { angle: 359.01, r0: 14.062, r1: 43.75, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, +]; +const MOVIE_PRIME_150_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 1.0, 1.0]), + (8.0, [1.0, 0.9947, 0.9807]), + (17.6, [0.9904, 0.9239, 0.9043]), + (26.4, [0.9573, 0.8173, 0.8054]), + (33.6, [0.9094, 0.7129, 0.7119]), + (44.8, [0.8091, 0.5546, 0.5754]), + (54.4, [0.7201, 0.4495, 0.4808]), + (59.2, [0.6826, 0.4167, 0.4508]), + (77.6, [0.5729, 0.3575, 0.388]), + (105.6, [0.4168, 0.2807, 0.3037]), + (128.8, [0.3039, 0.2258, 0.245]), + (145.6, [0.2366, 0.1898, 0.2058]), + (150.4, [0.2233, 0.1777, 0.1938]), + (155.2, [0.2056, 0.1694, 0.1843]), + (164.8, [0.1836, 0.1537, 0.1667]), + (167.2, [0.1742, 0.1498, 0.1622]), + (205.6, [0.1105, 0.0955, 0.1042]), + (230.4, [0.0786, 0.0669, 0.0713]), + (235.2, [0.0708, 0.0621, 0.0668]), + (243.2, [0.0629, 0.0542, 0.0591]), + (244.8, [0.061, 0.051, 0.0555]), + (247.2, [0.0591, 0.051, 0.0553]), + (248.8, [0.0552, 0.0473, 0.0515]), + (251.2, [0.0551, 0.0476, 0.0515]), + (252.8, [0.0512, 0.0442, 0.0476]), + (255.2, [0.051, 0.0436, 0.0479]), + (256.8, [0.0472, 0.0412, 0.0442]), + (259.2, [0.0474, 0.0396, 0.0438]), + (260.8, [0.0435, 0.0397, 0.0416]), + (263.2, [0.0434, 0.0357, 0.0397]), + (264.8, [0.0395, 0.0356, 0.0391]), + (266.4, [0.0393, 0.0344, 0.0357]), + (268.0, [0.0391, 0.0317, 0.0358]), + (269.6, [0.0354, 0.0317, 0.0355]), + (271.2, [0.0354, 0.0312, 0.0317]), + (272.8, [0.0353, 0.0276, 0.0318]), + (274.4, [0.0314, 0.0276, 0.0319]), + (276.8, [0.0315, 0.0277, 0.0277]), + (278.4, [0.0292, 0.0235, 0.0276]), + (279.2, [0.0274, 0.0236, 0.0277]), + (282.4, [0.0277, 0.0236, 0.0237]), + (284.0, [0.0235, 0.0208, 0.0237]), + (285.6, [0.0236, 0.0198, 0.024]), + (287.2, [0.0236, 0.0198, 0.0199]), + (288.0, [0.0239, 0.0197, 0.0198]), + (289.6, [0.0199, 0.0192, 0.0199]), + (290.4, [0.0197, 0.0164, 0.0198]), + (292.0, [0.0197, 0.0159, 0.0196]), + (293.6, [0.0196, 0.0158, 0.0157]), + (294.4, [0.02, 0.0159, 0.016]), + (296.0, [0.0158, 0.0159, 0.016]), + (299.2, [0.0158, 0.0119, 0.0158]), + (300.8, [0.0158, 0.0119, 0.0118]), + (303.2, [0.0117, 0.0119, 0.012]), + (304.8, [0.0118, 0.012, 0.012]), + (307.2, [0.0118, 0.0078, 0.0121]), + (308.8, [0.0116, 0.0079, 0.0079]), + (310.4, [0.0077, 0.0079, 0.008]), + (314.4, [0.0079, 0.0081, 0.008]), + (316.0, [0.0079, 0.0039, 0.0083]), + (317.6, [0.0082, 0.004, 0.0041]), + (319.2, [0.004, 0.004, 0.004]), + (327.2, [0.004, 0.0043, 0.0041]), + (328.8, [0.0039, 0.0004, 0.0041]), + (329.6, [0.0042, 0.0, 0.0014]), + (331.2, [0.0002, 0.0001, 0.0002]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [0.0, 0.1279, 0.1503]), + (0.79957, [-0.0019, 0.1277, 0.1521]), + (4.7974, [-0.0017, 0.1242, 0.1482]), + (9.5948, [-0.002, 0.1138, 0.1354]), + (11.194, [0.0025, 0.1155, 0.1397]), + (18.39, [-0.0006, 0.0665, 0.0796]), + (19.989, [0.0013, 0.0595, 0.0722]), + (21.588, [0.0003, 0.0454, 0.0552]), + (24.787, [-0.0005, 0.027, 0.033]), + (27.185, [0.0015, 0.023, 0.0299]), + (27.985, [0.0004, 0.0184, 0.0237]), + (28.784, [0.0004, 0.0167, 0.0209]), + (33.582, [-0.0004, 0.0076, 0.0094]), + (39.978, [-0.0006, 0.001, 0.0017]), + ], + }, + Radial { + t: 1.4706, + perp: 0.0, + knots: &[ + (0.0, [0.0, -0.0001, -0.0005]), + (4.7819, [-0.0001, -0.0001, -0.0004]), + (7.1728, [0.001, 0.002, 0.0034]), + (9.5638, [0.0004, 0.0007, 0.0011]), + (15.94, [0.0005, 0.0008, 0.0015]), + ], + }, + Radial { + t: 0.8409, + perp: 0.0, + knots: &[ + (0.0, [-0.0009, 0.0356, 0.0385]), + (0.79741, [-0.0002, 0.0382, 0.0422]), + (3.1896, [-0.0008, 0.0345, 0.0395]), + (11.164, [-0.0007, 0.0154, 0.0176]), + (13.556, [0.0018, 0.0157, 0.0194]), + (15.948, [0.0005, 0.0091, 0.0106]), + (18.34, [0.002, 0.009, 0.0119]), + (19.138, [0.0008, 0.0063, 0.0078]), + (23.125, [0.0004, 0.0013, 0.0013]), + ], + }, +]; +const MOVIE_PRIME_150_STREAKS: &[Streak] = &[ + Streak { t: -0.00097202, perp: 0.0, angle: 0.01593, half: 5063.9, taper: 0.24264, width: 1.0908, rgb: [0.1543, 0.2762, 0.431] }, + Streak { t: 0.12417, perp: 0.0, angle: 45.155, half: 354.78, taper: 0.5404, width: 3.0732, rgb: [0.0673, 0.1218, 0.1752] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.09, half: 475.21, taper: 0.80649, width: 2.0575, rgb: [0.0492, 0.0882, 0.1334] }, + Streak { t: 1.999, perp: 0.0, angle: 0.0044542, half: 180.45, taper: 1.0, width: 1.0392, rgb: [0.2189, 0.4317, 0.6954] }, +]; +const MOVIE_PRIME_150_RAYS: &[Ray] = &[ + Ray { angle: 0.47, r0: 20.312, r1: 189.06, fade: 0.05, rgb: [0.0157, 0.0353, 0.0569] }, + Ray { angle: 0.8, r0: 20.312, r1: 114.84, fade: 0.05, rgb: [0.0078, 0.0176, 0.0265] }, + Ray { angle: 1.05, r0: 20.312, r1: 84.375, fade: 0.05, rgb: [0.0059, 0.0137, 0.0196] }, + Ray { angle: 1.31, r0: 20.312, r1: 67.188, fade: 0.05, rgb: [0.0039, 0.0118, 0.0157] }, + Ray { angle: 1.57, r0: 20.312, r1: 55.469, fade: 0.05, rgb: [0.0039, 0.0098, 0.0127] }, + Ray { angle: 1.85, r0: 20.312, r1: 46.875, fade: 0.05, rgb: [0.002, 0.0078, 0.0098] }, + Ray { angle: 2.13, r0: 27.344, r1: 40.625, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, + Ray { angle: 7.09, r0: 6.25, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 8.42, r0: 72.656, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0167, 0.0137] }, + Ray { angle: 9.42, r0: 99.219, r1: 138.28, fade: 0.05, rgb: [0.0157, 0.0176, 0.0176] }, + Ray { angle: 10.47, r0: 91.406, r1: 140.62, fade: 0.05, rgb: [0.0275, 0.0284, 0.0284] }, + Ray { angle: 11.74, r0: 39.062, r1: 145.31, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 12.73, r0: 64.062, r1: 149.22, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 14.03, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 15.14, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0176] }, + Ray { angle: 37.2, r0: 60.938, r1: 139.06, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 38.45, r0: 73.438, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0176, 0.0176] }, + Ray { angle: 39.41, r0: 96.875, r1: 140.62, fade: 0.05, rgb: [0.0216, 0.0216, 0.0216] }, + Ray { angle: 40.43, r0: 102.34, r1: 138.28, fade: 0.05, rgb: [0.0314, 0.0353, 0.0314] }, + Ray { angle: 42.02, r0: 83.594, r1: 118.75, fade: 0.05, rgb: [0.1412, 0.3549, 0.5078] }, + Ray { angle: 43.04, r0: 123.44, r1: 173.44, fade: 0.05, rgb: [0.2039, 0.4118, 0.5863] }, + Ray { angle: 43.29, r0: 137.5, r1: 196.09, fade: 0.05, rgb: [0.2, 0.3941, 0.598] }, + Ray { angle: 43.55, r0: 160.94, r1: 230.47, fade: 0.05, rgb: [0.1824, 0.3725, 0.6127] }, + Ray { angle: 43.81, r0: 196.09, r1: 275.78, fade: 0.05, rgb: [0.2451, 0.4843, 0.7176] }, + Ray { angle: 45.28, r0: 42.969, r1: 127.34, fade: 0.05, rgb: [0.0196, 0.0275, 0.0275] }, + Ray { angle: 67.47, r0: 31.25, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 68.72, r0: 84.375, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 69.77, r0: 67.969, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 70.9, r0: 85.156, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0186, 0.0176] }, + Ray { angle: 71.94, r0: 59.375, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 73.26, r0: 75.0, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 74.28, r0: 6.25, r1: 80.469, fade: 0.05, rgb: [0.0039, 0.0118, 0.0118] }, + Ray { angle: 75.31, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 97.43, r0: 6.25, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 98.72, r0: 6.25, r1: 141.41, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 99.76, r0: 100.0, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0216, 0.0196] }, + Ray { angle: 100.77, r0: 87.5, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 102.02, r0: 38.281, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0098, 0.0108] }, + Ray { angle: 103.05, r0: 83.594, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 104.37, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0137, 0.0137] }, + Ray { angle: 105.42, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 127.5, r0: 8.5938, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0127, 0.0137] }, + Ray { angle: 128.7, r0: 9.375, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 129.58, r0: 94.531, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0186] }, + Ray { angle: 130.73, r0: 78.125, r1: 139.84, fade: 0.05, rgb: [0.0255, 0.0324, 0.0314] }, + Ray { angle: 132.12, r0: 60.938, r1: 139.84, fade: 0.05, rgb: [0.0098, 0.0176, 0.0137] }, + Ray { angle: 133.05, r0: 90.625, r1: 138.28, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 134.21, r0: 97.656, r1: 136.72, fade: 0.05, rgb: [0.0314, 0.0392, 0.0392] }, + Ray { angle: 135.27, r0: 74.219, r1: 133.59, fade: 0.05, rgb: [0.0294, 0.0353, 0.0353] }, + Ray { angle: 157.36, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0157, 0.0157] }, + Ray { angle: 158.71, r0: 10.156, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 159.75, r0: 91.406, r1: 139.84, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 160.9, r0: 96.094, r1: 140.62, fade: 0.05, rgb: [0.0167, 0.0216, 0.0196] }, + Ray { angle: 161.9, r0: 78.906, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0176] }, + Ray { angle: 163.23, r0: 74.219, r1: 140.62, fade: 0.05, rgb: [0.0167, 0.0216, 0.0176] }, + Ray { angle: 164.27, r0: 18.75, r1: 76.562, fade: 0.05, rgb: [0.0059, 0.0137, 0.0157] }, + Ray { angle: 165.31, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 177.98, r0: 14.844, r1: 42.969, fade: 0.05, rgb: [0.002, 0.0098, 0.0118] }, + Ray { angle: 178.26, r0: 14.844, r1: 50.0, fade: 0.05, rgb: [0.0039, 0.0098, 0.0137] }, + Ray { angle: 178.53, r0: 14.844, r1: 58.594, fade: 0.05, rgb: [0.0039, 0.0137, 0.0157] }, + Ray { angle: 178.78, r0: 14.844, r1: 71.875, fade: 0.05, rgb: [0.0059, 0.0157, 0.0235] }, + Ray { angle: 179.3, r0: 14.844, r1: 126.56, fade: 0.05, rgb: [0.0118, 0.0314, 0.049] }, + Ray { angle: 179.62, r0: 14.844, r1: 234.38, fade: 0.05, rgb: [0.0294, 0.0686, 0.1127] }, + Ray { angle: 180.0, r0: 20.312, r1: 234.38, fade: 0.05, rgb: [0.0569, 0.1294, 0.2059] }, + Ray { angle: 181.01, r0: 20.312, r1: 43.75, fade: 0.05, rgb: [0.0039, 0.0078, 0.0098] }, + Ray { angle: 181.28, r0: 20.312, r1: 34.375, fade: 0.05, rgb: [0.002, 0.0078, 0.0078] }, + Ray { angle: 187.45, r0: 6.25, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 188.74, r0: 6.25, r1: 141.41, fade: 0.05, rgb: [0.0039, 0.0098, 0.0098] }, + Ray { angle: 189.76, r0: 100.0, r1: 140.62, fade: 0.05, rgb: [0.0196, 0.0196, 0.0196] }, + Ray { angle: 190.78, r0: 83.594, r1: 140.62, fade: 0.05, rgb: [0.0186, 0.0206, 0.0196] }, + Ray { angle: 192.03, r0: 85.156, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 193.1, r0: 57.812, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0137, 0.0137] }, + Ray { angle: 194.39, r0: 6.25, r1: 75.781, fade: 0.05, rgb: [0.002, 0.0078, 0.0098] }, + Ray { angle: 195.43, r0: 20.312, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 217.53, r0: 42.969, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 218.74, r0: 20.312, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 219.66, r0: 7.8125, r1: 84.375, fade: 0.05, rgb: [0.002, 0.0078, 0.0098] }, + Ray { angle: 220.84, r0: 67.969, r1: 141.41, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 222.16, r0: 78.906, r1: 139.84, fade: 0.05, rgb: [0.0137, 0.0176, 0.0157] }, + Ray { angle: 223.05, r0: 78.906, r1: 138.28, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 224.25, r0: 6.25, r1: 99.219, fade: 0.05, rgb: [0.0059, 0.0147, 0.0157] }, + Ray { angle: 225.0, r0: 8.5938, r1: 124.22, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 225.27, r0: 8.5938, r1: 73.438, fade: 0.05, rgb: [0.002, 0.0088, 0.0118] }, + Ray { angle: 226.81, r0: 160.94, r1: 233.59, fade: 0.05, rgb: [0.1657, 0.3284, 0.4941] }, + Ray { angle: 227.06, r0: 139.06, r1: 200.0, fade: 0.05, rgb: [0.1667, 0.349, 0.5529] }, + Ray { angle: 227.31, r0: 123.44, r1: 171.88, fade: 0.05, rgb: [0.1725, 0.3608, 0.5392] }, + Ray { angle: 228.0, r0: 92.188, r1: 131.25, fade: 0.05, rgb: [0.1686, 0.3745, 0.5157] }, + Ray { angle: 247.11, r0: 23.438, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 248.42, r0: 69.531, r1: 140.62, fade: 0.05, rgb: [0.0137, 0.0186, 0.0176] }, + Ray { angle: 249.45, r0: 90.625, r1: 140.62, fade: 0.05, rgb: [0.0176, 0.0196, 0.0196] }, + Ray { angle: 250.54, r0: 92.969, r1: 141.41, fade: 0.05, rgb: [0.0314, 0.0373, 0.0373] }, + Ray { angle: 251.59, r0: 57.812, r1: 141.41, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 252.9, r0: 72.656, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 253.94, r0: 19.531, r1: 74.219, fade: 0.05, rgb: [0.0059, 0.0157, 0.0157] }, + Ray { angle: 254.96, r0: 22.656, r1: 141.41, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 277.13, r0: 10.156, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0176, 0.0157] }, + Ray { angle: 278.42, r0: 64.062, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0137] }, + Ray { angle: 279.45, r0: 97.656, r1: 140.62, fade: 0.05, rgb: [0.0147, 0.0196, 0.0186] }, + Ray { angle: 280.47, r0: 85.938, r1: 139.84, fade: 0.05, rgb: [0.0176, 0.0206, 0.0206] }, + Ray { angle: 281.73, r0: 39.844, r1: 140.62, fade: 0.05, rgb: [0.0078, 0.0118, 0.0118] }, + Ray { angle: 282.75, r0: 62.5, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 284.03, r0: 87.5, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 285.13, r0: 21.875, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0196, 0.0176] }, + Ray { angle: 307.32, r0: 30.469, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 308.48, r0: 16.406, r1: 85.156, fade: 0.05, rgb: [0.0039, 0.0098, 0.0118] }, + Ray { angle: 309.42, r0: 10.938, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0118, 0.0118] }, + Ray { angle: 310.59, r0: 73.438, r1: 140.62, fade: 0.05, rgb: [0.0216, 0.0294, 0.0294] }, + Ray { angle: 311.95, r0: 49.219, r1: 140.62, fade: 0.05, rgb: [0.0098, 0.0157, 0.0137] }, + Ray { angle: 312.84, r0: 71.875, r1: 139.06, fade: 0.05, rgb: [0.0137, 0.0157, 0.0157] }, + Ray { angle: 314.01, r0: 85.156, r1: 138.28, fade: 0.05, rgb: [0.0275, 0.0353, 0.0333] }, + Ray { angle: 337.12, r0: 34.375, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 338.44, r0: 81.25, r1: 140.62, fade: 0.05, rgb: [0.0157, 0.0196, 0.0196] }, + Ray { angle: 339.49, r0: 93.75, r1: 140.62, fade: 0.05, rgb: [0.0216, 0.0333, 0.0314] }, + Ray { angle: 340.57, r0: 86.719, r1: 141.41, fade: 0.05, rgb: [0.0275, 0.0353, 0.0314] }, + Ray { angle: 341.68, r0: 80.469, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0186, 0.0176] }, + Ray { angle: 342.96, r0: 67.969, r1: 140.62, fade: 0.05, rgb: [0.0118, 0.0167, 0.0157] }, + Ray { angle: 343.98, r0: 17.969, r1: 72.656, fade: 0.05, rgb: [0.0059, 0.0157, 0.0137] }, + Ray { angle: 344.99, r0: 21.875, r1: 141.41, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 358.49, r0: 19.531, r1: 30.469, fade: 0.05, rgb: [0.002, 0.0059, 0.0059] }, + Ray { angle: 358.75, r0: 19.531, r1: 35.156, fade: 0.05, rgb: [0.002, 0.0059, 0.0078] }, + Ray { angle: 359.01, r0: 19.531, r1: 44.531, fade: 0.05, rgb: [0.002, 0.0078, 0.0078] }, +]; +const MOVIE_PRIME_200_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 1.0, 1.0]), + (13.6, [1.0, 1.0, 0.9967]), + (28.0, [1.0, 0.9627, 0.9735]), + (43.2, [0.9874, 0.8778, 0.9117]), + (60.8, [0.9438, 0.7641, 0.8185]), + (83.2, [0.8674, 0.6687, 0.7223]), + (151.2, [0.5757, 0.448, 0.489]), + (180.8, [0.4478, 0.3681, 0.3995]), + (196.8, [0.388, 0.3268, 0.3551]), + (224.8, [0.3084, 0.264, 0.2875]), + (254.4, [0.2397, 0.2055, 0.2245]), + (280.0, [0.1883, 0.1612, 0.1741]), + (310.4, [0.1335, 0.1141, 0.1261]), + (333.6, [0.0983, 0.0858, 0.0931]), + (344.8, [0.084, 0.0713, 0.079]), + (347.2, [0.0824, 0.0708, 0.0751]), + (348.8, [0.0784, 0.0669, 0.0752]), + (350.4, [0.0786, 0.0672, 0.071]), + (352.0, [0.0746, 0.0635, 0.0713]), + (353.6, [0.0748, 0.0631, 0.0672]), + (356.0, [0.0707, 0.0599, 0.0673]), + (358.4, [0.0669, 0.0593, 0.0632]), + (360.0, [0.067, 0.0553, 0.0615]), + (361.6, [0.0629, 0.0551, 0.0593]), + (364.0, [0.0626, 0.0514, 0.058]), + (366.4, [0.0589, 0.0514, 0.0554]), + (368.8, [0.0554, 0.0472, 0.0513]), + (371.2, [0.0552, 0.0475, 0.0516]), + (372.8, [0.0513, 0.0435, 0.0476]), + (375.2, [0.0508, 0.0434, 0.0477]), + (376.8, [0.0471, 0.041, 0.0436]), + (379.2, [0.0471, 0.0394, 0.0434]), + (380.0, [0.0446, 0.0396, 0.0436]), + (381.6, [0.0433, 0.0361, 0.0394]), + (384.0, [0.0416, 0.0355, 0.0398]), + (385.6, [0.0393, 0.0351, 0.036]), + (387.2, [0.0393, 0.0315, 0.0357]), + (388.8, [0.0373, 0.0316, 0.0359]), + (390.4, [0.0354, 0.0316, 0.0319]), + (392.8, [0.0354, 0.0275, 0.0317]), + (394.4, [0.0314, 0.0276, 0.0316]), + (396.8, [0.0316, 0.0274, 0.0278]), + (398.4, [0.0281, 0.0236, 0.0277]), + (402.4, [0.0277, 0.0239, 0.0238]), + (404.0, [0.0236, 0.0198, 0.0238]), + (405.6, [0.0236, 0.0198, 0.024]), + (407.2, [0.0236, 0.0198, 0.0198]), + (408.8, [0.0226, 0.02, 0.0199]), + (410.4, [0.0196, 0.0159, 0.0198]), + (414.4, [0.0199, 0.0159, 0.016]), + (416.0, [0.0159, 0.0158, 0.016]), + (416.8, [0.0157, 0.0159, 0.016]), + (418.4, [0.0158, 0.0119, 0.016]), + (420.8, [0.0156, 0.0119, 0.0119]), + (422.4, [0.0117, 0.0119, 0.012]), + (424.8, [0.0118, 0.0122, 0.012]), + (426.4, [0.0119, 0.0081, 0.0122]), + (428.0, [0.012, 0.008, 0.0083]), + (430.4, [0.0079, 0.008, 0.0081]), + (434.4, [0.0079, 0.008, 0.0081]), + (436.0, [0.0079, 0.004, 0.0082]), + (437.6, [0.0081, 0.0041, 0.0047]), + (439.2, [0.0039, 0.0041, 0.0042]), + (446.4, [0.004, 0.0043, 0.0042]), + (448.0, [0.004, 0.0002, 0.0044]), + (449.6, [0.0042, 0.0001, 0.0002]), + (451.2, [0.0001, 0.0001, 0.0002]), + ], + }, + Radial { + t: 0.73278, + perp: 0.0, + knots: &[ + (0.0, [-0.0012, 0.1206, 0.1371]), + (0.79957, [-0.0031, 0.1209, 0.1376]), + (1.5991, [-0.0017, 0.122, 0.1377]), + (5.597, [-0.0021, 0.1181, 0.1332]), + (9.5948, [-0.0028, 0.1091, 0.1229]), + (11.194, [0.0024, 0.1128, 0.1293]), + (12.793, [0.0002, 0.1014, 0.1166]), + (18.39, [-0.0012, 0.0661, 0.0753]), + (19.989, [0.0011, 0.0604, 0.0708]), + (21.588, [-0.0002, 0.047, 0.0548]), + (24.787, [-0.0009, 0.029, 0.0337]), + (27.185, [0.0013, 0.0259, 0.0323]), + (27.985, [0.0002, 0.0207, 0.0251]), + (29.584, [-0.0001, 0.0161, 0.0195]), + (33.582, [-0.0009, 0.0084, 0.0103]), + (38.379, [-0.0012, 0.0027, 0.0034]), + (44.776, [-0.0013, -0.001, -0.0009]), + (46.375, [-0.0004, 0.0008, 0.0019]), + (47.974, [-0.0005, 0.0007, 0.0017]), + ], + }, + Radial { + t: 1.6911, + perp: 0.0, + knots: &[ + (0.0, [0.0006, 0.0007, 0.0009]), + (0.79924, [-0.0003, -0.0005, -0.0009]), + (2.3977, [0.0019, 0.0036, 0.006]), + (4.7954, [0.0019, 0.0038, 0.0062]), + (7.9924, [0.0008, 0.0015, 0.0024]), + (18.382, [0.0002, 0.0004, 0.0006]), + (30.371, [0.0001, 0.0001, 0.0001]), + (33.568, [0.0014, 0.0029, 0.0047]), + (35.166, [0.0008, 0.0015, 0.0025]), + (40.761, [0.0004, 0.0008, 0.0013]), + (43.159, [0.001, 0.002, 0.0031]), + (57.545, [0.0006, 0.0011, 0.0018]), + ], + }, + Radial { + t: 0.83516, + perp: 0.0, + knots: &[ + (0.0, [-0.0036, 0.0307, 0.0338]), + (0.77929, [-0.0013, 0.0348, 0.0382]), + (3.1172, [-0.0009, 0.0335, 0.0379]), + (8.5722, [-0.0011, 0.0253, 0.0286]), + (11.689, [-0.0009, 0.0179, 0.0206]), + (12.469, [0.0018, 0.0217, 0.0263]), + (13.248, [0.0036, 0.0228, 0.0276]), + (14.806, [0.0014, 0.015, 0.0179]), + (16.365, [0.0007, 0.0107, 0.0123]), + (17.144, [0.0013, 0.0098, 0.0118]), + (17.924, [0.003, 0.0124, 0.0158]), + (18.703, [0.0026, 0.0108, 0.0139]), + (19.482, [0.0015, 0.0068, 0.0085]), + (23.379, [0.0006, 0.0004, 0.0005]), + ], + }, +]; +const MOVIE_PRIME_200_STREAKS: &[Streak] = &[ + Streak { t: -0.00099547, perp: 0.0, angle: 0.015305, half: 4745.1, taper: 0.01495, width: 1.082, rgb: [0.2123, 0.3739, 0.6285] }, + Streak { t: 0.12445, perp: 0.0, angle: 45.155, half: 302.34, taper: 0.011295, width: 0.76047, rgb: [0.2109, 0.4419, 0.7519] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.03, half: 237.44, taper: 0.70807, width: 4.587, rgb: [0.0284, 0.0457, 0.0645] }, + Streak { t: 1.9989, perp: 0.0, angle: 0.022816, half: 180.17, taper: 1.0, width: 1.0643, rgb: [0.275, 0.5361, 0.8465] }, +]; +const MOVIE_PRIME_200_RAYS: &[Ray] = &[ + Ray { angle: 0.7, r0: 32.031, r1: 126.56, fade: 0.05, rgb: [0.0039, 0.0137, 0.0176] }, + Ray { angle: 1.47, r0: 43.75, r1: 57.812, fade: 0.05, rgb: [0.0, 0.0059, 0.0059] }, + Ray { angle: 7.27, r0: 48.438, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0098] }, + Ray { angle: 8.36, r0: 90.625, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 9.42, r0: 88.281, r1: 167.97, fade: 0.05, rgb: [0.0157, 0.0216, 0.0196] }, + Ray { angle: 10.53, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 11.63, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 12.73, r0: 94.531, r1: 168.75, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 13.86, r0: 92.188, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 15.0, r0: 49.219, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, + Ray { angle: 37.28, r0: 62.5, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 38.4, r0: 89.062, r1: 167.97, fade: 0.05, rgb: [0.0118, 0.0176, 0.0176] }, + Ray { angle: 39.35, r0: 101.56, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0255, 0.0235] }, + Ray { angle: 40.49, r0: 104.69, r1: 167.97, fade: 0.05, rgb: [0.0196, 0.0294, 0.0255] }, + Ray { angle: 41.85, r0: 82.031, r1: 114.06, fade: 0.05, rgb: [0.0471, 0.1961, 0.2451] }, + Ray { angle: 42.98, r0: 121.09, r1: 171.09, fade: 0.05, rgb: [0.1314, 0.2941, 0.3373] }, + Ray { angle: 43.26, r0: 139.06, r1: 196.09, fade: 0.05, rgb: [0.1471, 0.3294, 0.4078] }, + Ray { angle: 43.52, r0: 161.72, r1: 227.34, fade: 0.05, rgb: [0.1706, 0.3392, 0.4804] }, + Ray { angle: 43.78, r0: 191.41, r1: 271.09, fade: 0.05, rgb: [0.1667, 0.3686, 0.5824] }, + Ray { angle: 45.26, r0: 63.281, r1: 152.34, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 67.59, r0: 45.312, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 68.54, r0: 99.219, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0176] }, + Ray { angle: 69.67, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 70.85, r0: 94.531, r1: 168.75, fade: 0.05, rgb: [0.0196, 0.0294, 0.0255] }, + Ray { angle: 71.94, r0: 96.094, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 73.06, r0: 90.625, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 74.19, r0: 87.5, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 75.3, r0: 48.438, r1: 167.97, fade: 0.05, rgb: [0.0069, 0.0137, 0.0137] }, + Ray { angle: 97.51, r0: 50.781, r1: 167.97, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 98.61, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 99.71, r0: 100.78, r1: 169.53, fade: 0.05, rgb: [0.0216, 0.0314, 0.0294] }, + Ray { angle: 100.79, r0: 91.406, r1: 167.97, fade: 0.05, rgb: [0.0176, 0.0275, 0.0235] }, + Ray { angle: 101.89, r0: 98.438, r1: 167.97, fade: 0.05, rgb: [0.0186, 0.0314, 0.0275] }, + Ray { angle: 103.03, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 104.12, r0: 95.312, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 105.23, r0: 46.875, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0157, 0.0137] }, + Ray { angle: 127.43, r0: 49.219, r1: 167.97, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 128.6, r0: 82.812, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0157, 0.0137] }, + Ray { angle: 129.56, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0118, 0.0216, 0.0176] }, + Ray { angle: 130.78, r0: 87.5, r1: 167.97, fade: 0.05, rgb: [0.0118, 0.0196, 0.0157] }, + Ray { angle: 132.04, r0: 82.031, r1: 167.97, fade: 0.05, rgb: [0.0098, 0.0216, 0.0196] }, + Ray { angle: 133.2, r0: 87.5, r1: 167.97, fade: 0.05, rgb: [0.0137, 0.0216, 0.0206] }, + Ray { angle: 134.31, r0: 115.62, r1: 163.28, fade: 0.05, rgb: [0.0235, 0.0353, 0.0314] }, + Ray { angle: 135.22, r0: 90.625, r1: 160.94, fade: 0.05, rgb: [0.0176, 0.0294, 0.0275] }, + Ray { angle: 157.56, r0: 34.375, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 158.54, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0147, 0.0225, 0.0216] }, + Ray { angle: 159.63, r0: 86.719, r1: 168.75, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 160.83, r0: 87.5, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 161.94, r0: 96.094, r1: 168.75, fade: 0.05, rgb: [0.0167, 0.0294, 0.0265] }, + Ray { angle: 163.04, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0265, 0.0235] }, + Ray { angle: 164.17, r0: 85.156, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0235, 0.0216] }, + Ray { angle: 165.27, r0: 48.438, r1: 167.97, fade: 0.05, rgb: [0.0059, 0.0137, 0.0127] }, + Ray { angle: 165.52, r0: 71.875, r1: 159.38, fade: 0.05, rgb: [0.0039, 0.0059, 0.0039] }, + Ray { angle: 178.33, r0: 39.844, r1: 50.781, fade: 0.05, rgb: [0.0, 0.0059, 0.0078] }, + Ray { angle: 178.6, r0: 39.844, r1: 61.719, fade: 0.05, rgb: [0.0, 0.0078, 0.0098] }, + Ray { angle: 179.37, r0: 28.906, r1: 140.62, fade: 0.05, rgb: [0.0059, 0.0255, 0.0343] }, + Ray { angle: 179.62, r0: 28.906, r1: 234.38, fade: 0.05, rgb: [0.0196, 0.0598, 0.0912] }, + Ray { angle: 187.52, r0: 51.562, r1: 167.97, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 188.63, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 189.73, r0: 100.0, r1: 169.53, fade: 0.05, rgb: [0.0206, 0.0314, 0.0275] }, + Ray { angle: 190.82, r0: 95.312, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0255, 0.0255] }, + Ray { angle: 191.92, r0: 84.375, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0235] }, + Ray { angle: 193.03, r0: 93.75, r1: 167.97, fade: 0.05, rgb: [0.0167, 0.0275, 0.0235] }, + Ray { angle: 194.17, r0: 80.469, r1: 168.75, fade: 0.05, rgb: [0.0118, 0.0216, 0.0176] }, + Ray { angle: 195.25, r0: 44.531, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 217.53, r0: 52.344, r1: 169.53, fade: 0.05, rgb: [0.0059, 0.0157, 0.0118] }, + Ray { angle: 218.63, r0: 96.875, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 219.61, r0: 67.188, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 220.84, r0: 91.406, r1: 167.97, fade: 0.05, rgb: [0.0118, 0.0196, 0.0157] }, + Ray { angle: 222.12, r0: 89.062, r1: 169.53, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 223.24, r0: 101.56, r1: 167.97, fade: 0.05, rgb: [0.0176, 0.0294, 0.0265] }, + Ray { angle: 224.33, r0: 30.469, r1: 117.97, fade: 0.05, rgb: [0.002, 0.0098, 0.0078] }, + Ray { angle: 225.0, r0: 35.156, r1: 150.78, fade: 0.05, rgb: [0.0098, 0.0216, 0.0157] }, + Ray { angle: 226.89, r0: 153.91, r1: 223.44, fade: 0.05, rgb: [0.1059, 0.2275, 0.348] }, + Ray { angle: 227.23, r0: 131.25, r1: 182.81, fade: 0.05, rgb: [0.1275, 0.298, 0.4137] }, + Ray { angle: 227.52, r0: 112.5, r1: 159.38, fade: 0.05, rgb: [0.1039, 0.2608, 0.351] }, + Ray { angle: 247.29, r0: 40.625, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 248.3, r0: 97.656, r1: 169.53, fade: 0.05, rgb: [0.0157, 0.0275, 0.0255] }, + Ray { angle: 249.39, r0: 85.938, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 250.59, r0: 95.312, r1: 169.53, fade: 0.05, rgb: [0.0186, 0.0294, 0.0275] }, + Ray { angle: 251.68, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 252.8, r0: 90.625, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0235] }, + Ray { angle: 253.9, r0: 96.875, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0314, 0.0275] }, + Ray { angle: 254.99, r0: 47.656, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0137, 0.0137] }, + Ray { angle: 277.26, r0: 48.438, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 278.37, r0: 87.5, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0216] }, + Ray { angle: 279.46, r0: 103.12, r1: 167.97, fade: 0.05, rgb: [0.0225, 0.0333, 0.0304] }, + Ray { angle: 280.54, r0: 92.969, r1: 168.75, fade: 0.05, rgb: [0.0167, 0.0294, 0.0255] }, + Ray { angle: 281.65, r0: 89.062, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0235] }, + Ray { angle: 282.76, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0294, 0.0255] }, + Ray { angle: 283.88, r0: 82.812, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 285.01, r0: 46.875, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0176, 0.0137] }, + Ray { angle: 307.32, r0: 52.344, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0147, 0.0137] }, + Ray { angle: 308.45, r0: 94.531, r1: 167.97, fade: 0.05, rgb: [0.0137, 0.0216, 0.0196] }, + Ray { angle: 309.42, r0: 97.656, r1: 168.75, fade: 0.05, rgb: [0.0118, 0.0186, 0.0176] }, + Ray { angle: 310.54, r0: 73.438, r1: 167.97, fade: 0.05, rgb: [0.0108, 0.0196, 0.0167] }, + Ray { angle: 311.83, r0: 81.25, r1: 168.75, fade: 0.05, rgb: [0.0137, 0.0255, 0.0216] }, + Ray { angle: 313.05, r0: 93.75, r1: 167.19, fade: 0.05, rgb: [0.0157, 0.0255, 0.0235] }, + Ray { angle: 314.13, r0: 90.625, r1: 165.62, fade: 0.05, rgb: [0.0118, 0.0176, 0.0157] }, + Ray { angle: 337.32, r0: 46.875, r1: 168.75, fade: 0.05, rgb: [0.0059, 0.0137, 0.0118] }, + Ray { angle: 338.33, r0: 105.47, r1: 169.53, fade: 0.05, rgb: [0.0196, 0.0314, 0.0275] }, + Ray { angle: 339.42, r0: 94.531, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0275, 0.0255] }, + Ray { angle: 340.6, r0: 97.656, r1: 169.53, fade: 0.05, rgb: [0.0196, 0.0314, 0.0294] }, + Ray { angle: 341.72, r0: 81.25, r1: 168.75, fade: 0.05, rgb: [0.0098, 0.0157, 0.0157] }, + Ray { angle: 342.81, r0: 91.406, r1: 168.75, fade: 0.05, rgb: [0.0157, 0.0275, 0.0245] }, + Ray { angle: 343.9, r0: 93.75, r1: 168.75, fade: 0.05, rgb: [0.0176, 0.0294, 0.0255] }, + Ray { angle: 345.06, r0: 47.656, r1: 168.75, fade: 0.05, rgb: [0.0078, 0.0137, 0.0118] }, +]; +const MOVIE_PRIME_300_RADIAL: &[Radial] = &[ + Radial { + t: 0.0031985, + perp: 0.0, + knots: &[ + (0.0, [1.0, 1.0, 1.0]), + (224.0, [1.0, 0.9997, 1.0]), + (244.0, [1.0, 0.9227, 0.9989]), + (260.8, [0.9997, 0.8587, 0.9298]), + (335.2, [0.7014, 0.6018, 0.6524]), + (396.8, [0.4939, 0.4238, 0.4598]), + (432.0, [0.3919, 0.3355, 0.3653]), + (474.4, [0.2836, 0.2443, 0.2649]), + (512.0, [0.2034, 0.1735, 0.1896]), + (538.4, [0.1534, 0.1326, 0.1432]), + (561.6, [0.1175, 0.0989, 0.1089]), + (564.0, [0.1165, 0.1028, 0.1131]), + (565.6, [0.1114, 0.0969, 0.1064]), + (573.6, [0.0987, 0.0857, 0.0928]), + (584.8, [0.0848, 0.0718, 0.0801]), + (590.4, [0.0781, 0.0679, 0.0722]), + (592.0, [0.0749, 0.0638, 0.0723]), + (594.4, [0.0716, 0.0639, 0.0681]), + (600.0, [0.0674, 0.0559, 0.0634]), + (601.6, [0.0633, 0.0558, 0.0604]), + (604.0, [0.0632, 0.052, 0.0581]), + (604.8, [0.0602, 0.0519, 0.0564]), + (607.2, [0.0595, 0.0517, 0.0561]), + (608.0, [0.0583, 0.0486, 0.0531]), + (608.8, [0.0556, 0.048, 0.0524]), + (611.2, [0.0556, 0.0483, 0.0527]), + (612.8, [0.0516, 0.0442, 0.0486]), + (615.2, [0.0506, 0.0443, 0.0487]), + (616.8, [0.0475, 0.0402, 0.0446]), + (619.2, [0.0472, 0.0399, 0.0445]), + (620.0, [0.044, 0.0403, 0.0441]), + (620.8, [0.0435, 0.0386, 0.0408]), + (621.6, [0.0435, 0.0362, 0.0405]), + (623.2, [0.0438, 0.0361, 0.0404]), + (624.0, [0.0409, 0.036, 0.0407]), + (625.6, [0.0396, 0.0359, 0.0366]), + (626.4, [0.0395, 0.0325, 0.0364]), + (628.0, [0.0392, 0.0321, 0.0364]), + (628.8, [0.036, 0.0321, 0.0366]), + (631.2, [0.0356, 0.0313, 0.0324]), + (632.0, [0.0356, 0.0283, 0.0324]), + (632.8, [0.0355, 0.0281, 0.0323]), + (633.6, [0.0322, 0.028, 0.0324]), + (634.4, [0.0317, 0.0281, 0.0322]), + (635.2, [0.0316, 0.028, 0.0288]), + (636.8, [0.0316, 0.0282, 0.0284]), + (637.6, [0.0319, 0.0249, 0.0284]), + (639.2, [0.0278, 0.0241, 0.0288]), + (640.8, [0.0277, 0.0241, 0.0244]), + (643.2, [0.0276, 0.0239, 0.0245]), + (644.0, [0.0241, 0.0204, 0.0244]), + (645.6, [0.0238, 0.0201, 0.0247]), + (647.2, [0.0239, 0.0201, 0.0204]), + (648.0, [0.0233, 0.0201, 0.0205]), + (648.8, [0.0199, 0.0201, 0.0204]), + (649.6, [0.0199, 0.0165, 0.0203]), + (652.0, [0.0198, 0.0161, 0.0201]), + (652.8, [0.0197, 0.0161, 0.0165]), + (653.6, [0.0201, 0.0161, 0.0164]), + (655.2, [0.0158, 0.016, 0.0163]), + (656.0, [0.0159, 0.0165, 0.0163]), + (656.8, [0.0159, 0.0134, 0.0164]), + (658.4, [0.0159, 0.0122, 0.0167]), + (660.0, [0.0158, 0.0122, 0.0124]), + (660.8, [0.0162, 0.0122, 0.0124]), + (661.6, [0.0127, 0.0121, 0.0124]), + (662.4, [0.012, 0.0121, 0.0124]), + (664.8, [0.012, 0.0117, 0.0124]), + (665.6, [0.012, 0.0081, 0.0124]), + (667.2, [0.012, 0.0082, 0.0128]), + (668.0, [0.012, 0.0082, 0.0093]), + (668.8, [0.0118, 0.0082, 0.0084]), + (669.6, [0.0081, 0.0082, 0.0084]), + (674.4, [0.008, 0.0085, 0.0083]), + (676.0, [0.008, 0.0041, 0.0087]), + (677.6, [0.0084, 0.0042, 0.0042]), + (679.2, [0.0038, 0.0042, 0.0044]), + (687.2, [0.004, 0.0044, 0.0045]), + (688.8, [0.0039, -0.0002, -0.0001]), + (689.6, [0.0043, 0.0003, 0.0004]), + (691.2, [-0.0004, 0.0002, 0.0003]), + (780.0, [-0.0013, -0.0024, -0.0035]), + (816.8, [-0.0009, -0.0017, -0.0026]), + (820.8, [-0.0008, -0.0014, -0.0021]), + (821.6, [0.0, 0.0, 0.0]), + ], + }, + Radial { t: 0.73278, perp: 0.0, knots: &[(0.0, [0.0, 0.0018, 0.0001]), (20.789, [0.0, 0.0018, 0.0008])] }, + Radial { + t: 2.4547, + perp: 0.0, + knots: &[ + (0.0, [0.0011, 0.0006, 0.0009]), + (9.5151, [0.0006, 0.0011, 0.0014]), + (11.894, [0.0028, 0.0054, 0.0077]), + (14.273, [0.0017, 0.0031, 0.0045]), + (21.409, [0.0011, 0.002, 0.0028]), + (26.959, [0.001, 0.0017, 0.0024]), + (29.338, [0.0019, 0.0036, 0.0053]), + (42.818, [0.0008, 0.0013, 0.002]), + (48.368, [0.0012, 0.0023, 0.0035]), + (59.469, [0.0006, 0.0011, 0.0016]), + (63.434, [0.0011, 0.0023, 0.0034]), + (72.156, [0.0005, 0.0009, 0.0014]), + (76.121, [0.0007, 0.0014, 0.0022]), + ], + }, +]; +const MOVIE_PRIME_300_STREAKS: &[Streak] = &[ + Streak { t: -0.00095944, perp: 0.0, angle: 0.012041, half: 5435.5, taper: 0.011317, width: 1.064, rgb: [0.2758, 0.554, 1.142] }, + Streak { t: 0.12868, perp: 0.0, angle: 45.14, half: 283.55, taper: 0.01, width: 0.63873, rgb: [0.031, 0.1591, 0.3557] }, + Streak { t: 1.7984, perp: 0.0, angle: 45.074, half: 273.03, taper: 0.033361, width: 0.8378, rgb: [0.2456, 0.4704, 0.7752] }, + Streak { t: 1.9987, perp: 0.0, angle: 0.0065955, half: 182.6, taper: 1.0, width: 1.0982, rgb: [0.3026, 0.5787, 0.9971] }, +]; +const MOVIE_PRIME_300_RAYS: &[Ray] = &[]; +pub(super) const MOVIE_PRIME: &[Level] = &[ + Level { brightness: 10.0, radial: MOVIE_PRIME_10_RADIAL, streaks: MOVIE_PRIME_10_STREAKS, rays: MOVIE_PRIME_10_RAYS }, + Level { brightness: 50.0, radial: MOVIE_PRIME_50_RADIAL, streaks: MOVIE_PRIME_50_STREAKS, rays: MOVIE_PRIME_50_RAYS }, + Level { brightness: 75.0, radial: MOVIE_PRIME_75_RADIAL, streaks: MOVIE_PRIME_75_STREAKS, rays: MOVIE_PRIME_75_RAYS }, + Level { brightness: 100.0, radial: MOVIE_PRIME_100_RADIAL, streaks: MOVIE_PRIME_100_STREAKS, rays: MOVIE_PRIME_100_RAYS }, + Level { brightness: 125.0, radial: MOVIE_PRIME_125_RADIAL, streaks: MOVIE_PRIME_125_STREAKS, rays: MOVIE_PRIME_125_RAYS }, + Level { brightness: 150.0, radial: MOVIE_PRIME_150_RADIAL, streaks: MOVIE_PRIME_150_STREAKS, rays: MOVIE_PRIME_150_RAYS }, + Level { brightness: 200.0, radial: MOVIE_PRIME_200_RADIAL, streaks: MOVIE_PRIME_200_STREAKS, rays: MOVIE_PRIME_200_RAYS }, + Level { brightness: 300.0, radial: MOVIE_PRIME_300_RADIAL, streaks: MOVIE_PRIME_300_STREAKS, rays: MOVIE_PRIME_300_RAYS }, +]; diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 04acf276da..ad29080b89 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -26,6 +26,7 @@ mod blur2; pub mod camera_raw; pub mod color_to_alpha; pub mod content_aware; +mod cutout; mod denoise; pub mod dff; pub mod direct; @@ -42,6 +43,8 @@ pub mod histogram; mod image; pub mod inpaint; pub mod lens; +mod lens_flare; +mod lens_flare_data; pub mod liquify; pub mod magnetic; pub mod matting; @@ -72,6 +75,7 @@ pub mod seam; pub mod segment; pub mod selection; mod selection_blur; +pub mod separable; mod shape_blur; mod sharpen; pub mod stack; @@ -564,6 +568,9 @@ impl FilterParams { pub fn halo(&self) -> Halo { let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1)); match self { + // Small Gaussian radii use the calibrated seven-tap kernel as well: its reach is + // three pixels even when ceil(3 * radius) + 1 would be smaller. + FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } if (0.05..1.0).contains(radius) => Halo::Radius(3), FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius), FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)), FilterParams::SmartSharpen { radius, .. } => g(*radius), @@ -730,6 +737,7 @@ fn halo_ext(p: &FilterParams) -> Halo { FilterParams::PathBlur { paths } => r(paths.iter().map(|p| p.speed.abs()).fold(0.0, f32::max) / 2.0 + 1.0), FilterParams::Custom { .. } => r(2.0), FilterParams::DeInterlace { .. } => r(1.0), + FilterParams::FilterGallery { effects } if artistic_fx::is_global(effects) => Halo::Bounds, FilterParams::FilterGallery { effects } => Halo::Radius(artistic_fx::reach(effects)), _ => Halo::Radius(0), } @@ -840,7 +848,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec { render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)) } - FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens), + FilterParams::LensFlare { brightness, center_x, center_y, lens } => lens_flare::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens), FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting( src, out, @@ -1017,6 +1025,31 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } } +/// Conservative transient allocation estimate for filter admission. Output tiles remain in +/// the paged store; whole-image kernels must admit their float input before reading it. +pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: usize) -> u64 { + if area.is_empty() { + return 0; + } + #[cfg(not(target_arch = "wasm32"))] + let threads = rayon::current_num_threads().max(1) as u64; + #[cfg(target_arch = "wasm32")] + let threads = 1u64; + let tile = auto_tile(params, bounds).max(1) as u64; + let floats = (channels as u64).saturating_mul(4); + // Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area. + let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3); + match params.halo_for(bounds) { + Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)), + Halo::Radius(r) => { + let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2)); + base.saturating_add( + side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads), + ) + } + } +} + /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). /// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( @@ -1120,6 +1153,14 @@ fn apply_tiled_with_impl( } } let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area))); + // A Filter Gallery stack with a Cutout traces the whole image once; tiles only fill it in. + let gallery = match (params, &shared) { + (FilterParams::FilterGallery { effects }, Some(src)) => artistic_fx::Prepared::new(effects, src, &ctx), + _ => None, + }; + if ctl.cancelled() { + return None; + } // Shape rasterization depends only on params, not on a tile's pixels. let shape = match params { FilterParams::ShapeBlur { radius, shape } => { @@ -1197,6 +1238,7 @@ fn apply_tiled_with_impl( } }; let Some(mut data) = (match (&shape, &radial) { + _ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)), (Some(shape), _) => shape.checked_run(src, *t, &ctx), (None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) { Some(data) => Some(data), @@ -1254,7 +1296,10 @@ fn apply_tiled_with_impl( } for (t, data) in results { match data { - TileData::Samples(data) => out.write_region(t, &data), + TileData::Samples(data) => { + out.write_region(t, &data); + photocraft_raster::scratch::give(data); + } TileData::Encoded(data) => out.write_interleaved(t, &data), } } @@ -1309,6 +1354,7 @@ fn apply_box_blur( mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha); } s.write_region(*t, data); + photocraft_raster::scratch::give(std::mem::take(data)); } }; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index 379500b25c..40865c09b2 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -33,6 +33,9 @@ pub enum LiquifyTool { Reconstruct, Smooth, TwirlCw, + /// Twirl Clockwise with Alt (Option) held: a stroke's direction, not a tool of its own. The + /// reference application's Liquify has Twirl Clockwise only; strokes may still name + /// `twirlCcw` (Alt-strokes record it, and older scripts and saved strokes use it). TwirlCcw, Pucker, Bloat, @@ -49,12 +52,13 @@ pub enum LiquifyTool { } impl LiquifyTool { - pub const ALL: [LiquifyTool; 11] = [ + /// The tool strip, in order. Twirl Counterclockwise is not on it: it is Twirl Clockwise with + /// Alt (Option) held. + pub const ALL: [LiquifyTool; 10] = [ LiquifyTool::ForwardWarp, LiquifyTool::Reconstruct, LiquifyTool::Smooth, LiquifyTool::TwirlCw, - LiquifyTool::TwirlCcw, LiquifyTool::Pucker, LiquifyTool::Bloat, LiquifyTool::PushLeft, diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index f4bfccb0b5..6cc03e2094 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -35,8 +35,11 @@ pub struct RefineParams { pub feather: f32, /// 0–100. pub contrast: f32, - /// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole - /// pixels blend the two neighbouring shifts, so the slider does not jump. + /// −100–100. A curve on the refined coverage, applied last: each pixel's odds (`a / (1 − a)`) + /// are multiplied by `49^(shift / 100)`, so at −100% a half-covered pixel drops to 2% and at + /// +100% it rises to 98%. Fully selected and unselected pixels never change, so Shift Edge + /// moves only soft borders and a hard edge stays put (measured against the reference + /// application: the fit holds within ±0.05 in log-odds at feathers of 2, 5 and 10 px). pub shift_edge: f32, } @@ -60,18 +63,10 @@ impl RefineParams { fn feather_sigma(&self) -> f32 { self.feather.max(0.0) / 2.0 } - /// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64. - fn shift_amount(&self) -> f32 { - let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0); - if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 } - } - /// How many pixels the mask can move: the farther of the two integer radii a fractional - /// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`). - fn shift_px(&self) -> i32 { - let px = self.shift_amount(); - let reach = px.abs().ceil(); - let signed = if px.is_sign_negative() { -reach } else { reach }; - if signed.is_finite() { signed as i32 } else { 0 } + /// The factor Shift Edge multiplies each pixel's odds by: `49^(shift / 100)`, 1 at 0%. + fn shift_odds(&self) -> f32 { + let s = self.shift_edge.clamp(-100.0, 100.0) / 100.0; + if s.is_finite() { 49f32.powf(s) } else { 1.0 } } fn uses_image(&self) -> bool { self.radius > 0.0 @@ -84,14 +79,13 @@ impl RefineParams { } else { 0 }; - edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().abs() + 2 + edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + 2 } /// How far the refined mask can extend beyond the original. pub fn spread(&self) -> i32 { (if self.uses_image() { self.radius.ceil() as i32 } else { 0 }) + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 - + self.shift_px().max(0) + 1 } } @@ -307,7 +301,7 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec { /// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`). pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec { - refine_buffer_with(img, m, w, h, p, None, morph) + refine_buffer_with(img, m, w, h, p, None) } /// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the @@ -318,10 +312,7 @@ struct BrushBuf<'a> { window: usize, } -fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option, morphology: F) -> Vec -where - F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy, -{ +fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option) -> Vec { let mut a = m.to_vec(); if let (true, Some(img)) = (p.uses_image(), img) { let bin: Vec = m.iter().map(|v| *v >= 0.5).collect(); @@ -381,36 +372,23 @@ where *v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0); } } - let amount = p.shift_amount(); - if amount.abs() >= 1.0e-4 { - a = shift_mask(&a, w, h, amount, morphology); + let k = p.shift_odds(); + if (k - 1.0).abs() >= 1.0e-6 { + for v in &mut a { + *v = shift_coverage(*v, k); + } } a } -/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology; -/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px. -fn shift_mask(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec -where - F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy, -{ - let mag = amount.abs(); - if !mag.is_finite() || mag < 1.0e-4 { - return src.to_vec(); +/// Shift Edge on one coverage value: its odds times `k`. 0 and 1 stay exact. +fn shift_coverage(v: f32, k: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + if v <= 0.0 || v >= 1.0 { + return v; } - let grow = amount > 0.0; - let whole = mag.floor() as usize; - let frac = mag - whole as f32; - if frac < 1.0e-4 { - return morphology(src, w, h, whole, grow); - } - let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) }; - let hi = morphology(src, w, h, whole + 1, grow); - let n = w.saturating_mul(h); - if lo.len() < n || hi.len() < n || lo.len() != hi.len() { - return hi; - } - lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect() + let shifted = k * v / (k * v + (1.0 - v)); + if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v } } /// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`] @@ -433,7 +411,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option< /// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image /// (only read when `radius > 0`). Returns `None` when nothing remains selected. pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option { - refine_mask_with(sampler, mask, content, canvas, p, None, morph) + refine_mask_with(sampler, mask, content, canvas, p, None) } /// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image @@ -447,21 +425,17 @@ pub fn refine_mask_brushed( p: &RefineParams, brush: Option<&EdgeBrush>, ) -> Option { - refine_mask_with(sampler, mask, content, canvas, p, brush, morph) + refine_mask_with(sampler, mask, content, canvas, p, brush) } -fn refine_mask_with( +fn refine_mask_with( sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Sync), content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>, - morphology: F, -) -> Option -where - F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy + Sync + Send, -{ +) -> Option { let bbox = refine_area(content, canvas, p, brush); if bbox.is_empty() { return None; @@ -501,7 +475,7 @@ where (Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }), _ => None, }; - let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology); + let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb); (*t, core(&out)) }; #[cfg(not(target_arch = "wasm32"))] @@ -765,22 +739,30 @@ mod tests { assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1); let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() }); assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0); - let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() }); - assert_eq!(grown[5 * w + 33], 1.0); - assert_eq!(grown[5 * w + 34], 0.0); - let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() }); - assert_eq!(shrunk[5 * w + 27], 1.0); - assert_eq!(shrunk[5 * w + 28], 0.0); - // Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel. - // The pixel just outside a hard edge fades in instead of snapping on. - let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() }); - let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() }); - assert_eq!(m[5 * w + 30], 0.0); - assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]); - assert_eq!(one[5 * w + 30], 1.0); - assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel"); - let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() }); - assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]); + // Shift Edge leaves a hard edge where it is, at any amount: only soft coverage moves. + for shift_edge in [-100.0, -50.0, 50.0, 100.0] { + let s = refine_buffer(None, &m, w, h, &RefineParams { shift_edge, ..Default::default() }); + assert_eq!(s, m, "Shift Edge {shift_edge}% moved a hard edge"); + } + // Coverage before → after, read off the reference application's feathered edges. + for (shift_edge, before, after) in [ + (-100.0, 0.898, 0.153), + (-100.0, 0.612, 0.031), + (-100.0, 0.965, 0.357), + (-50.0, 0.537, 0.141), + (-50.0, 0.898, 0.557), + (100.0, 0.102, 0.847), + (100.0, 0.388, 0.969), + ] { + let flat = vec![before; w * h]; + let s = refine_buffer(None, &flat, w, h, &RefineParams { shift_edge, ..Default::default() }); + assert!((s[5 * w + 30] - after).abs() < 0.01, "{shift_edge}%: {before} became {}, expected {after}", s[5 * w + 30]); + } + // So a feathered edge moves: at −100% its half-covered point lands where it was 98%. + let soft = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, ..Default::default() }); + let pulled = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, shift_edge: -100.0, ..Default::default() }); + let half = |v: &[f32]| (0..w).find(|x| v[5 * w + x] < 0.5).unwrap_or(w); + assert!(half(&pulled) < half(&soft), "−100% did not pull the soft edge in"); let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() }); assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0); } diff --git a/crates/algo/src/matting/morphology/tests.rs b/crates/algo/src/matting/morphology/tests.rs index 4100f70bed..0866419cfd 100644 --- a/crates/algo/src/matting/morphology/tests.rs +++ b/crates/algo/src/matting/morphology/tests.rs @@ -230,65 +230,6 @@ fn octagonal_morphology_release_comparison() { } } -#[test] -#[ignore = "release comparison of the full tiled Select and Mask CPU path"] -#[allow(clippy::assertions_on_constants)] -fn tiled_refinement_release_comparison() { - use crate::{ - matting::{RefineParams, refine_mask_with, region_reader}, - segment::{ImageSampler, RgbImage, trim_region}, - selection::Region, - }; - use photocraft_geom::Rect; - use std::{hint::black_box, time::Instant}; - assert!(!cfg!(debug_assertions), "run with --release"); - for (w, h) in [(6000usize, 4000usize), (7200, 5000)] { - let image = RgbImage::from_fn(w, h, |x, y| { - let t = x as f32 / w as f32; - let u = y as f32 / h as f32; - [0.2 + 0.5 * t, 0.15 + 0.55 * u, 0.3 + 0.15 * ((x / 17 + y / 23) % 3) as f32] - }); - let sampler = ImageSampler { img: &image, origin: (0, 0) }; - let canvas = Rect::new(0, 0, w as i32, h as i32); - let mask: Vec<_> = (0..w * h) - .map(|i| { - let dx = (i % w) as f64 - w as f64 * 0.51; - let dy = (i / w) as f64 - h as f64 * 0.48; - let d = (dx * dx + dy * dy).sqrt(); - ((0.5 + (w.min(h) as f64 * 0.27 - d) / 7.0).clamp(0.0, 1.0) * 255.0).round() as u8 - }) - .collect(); - let region = trim_region(Region { bbox: canvas, mask }).unwrap(); - let reader = region_reader(®ion); - let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() }; - let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap(); - let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap(); - assert_eq!(new.bbox, old.bbox); - assert_eq!(new.mask, old.mask); - let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new()); - for run in 0..3 { - let mut measure = |fast: bool| { - let start = Instant::now(); - let out = if fast { - refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph) - } else { - refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated) - } - .unwrap(); - let elapsed = start.elapsed().as_secs_f64() * 1000.0; - assert_eq!(out.bbox, old.bbox); - assert_eq!(out.mask, old.mask); - if fast { new_ms.push(elapsed) } else { old_ms.push(elapsed) } - }; - measure(run % 2 == 0); - measure(run % 2 != 0); - } - old_ms.sort_by(f64::total_cmp); - new_ms.sort_by(f64::total_cmp); - println!("REFINE {w}x{h} radius=64 shift=100 old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]); - } -} - #[test] #[ignore = "release comparison on a dense fractional 24 MP matte"] #[allow(clippy::assertions_on_constants)] diff --git a/crates/algo/src/photo_util.rs b/crates/algo/src/photo_util.rs index 7316088079..96c6f7b434 100644 --- a/crates/algo/src/photo_util.rs +++ b/crates/algo/src/photo_util.rs @@ -62,68 +62,213 @@ impl Normal { } } -/// A sparse least-squares problem `min Σ w²(row·x − rhs)²`, solved by conjugate gradients on the -/// normal equations (CGLS). Used for mesh warps with thousands of unknowns. -#[derive(Default)] -pub(crate) struct SparseLs { - pub n: usize, - rows: Vec<(Vec<(usize, f64)>, f64)>, +/// A compressed sparse row matrix (symmetric, used by CG). +pub(crate) struct Csr { + pub rows: Vec, + pub cols: Vec, + pub vals: Vec, + pub diag: Vec, } -impl SparseLs { - pub fn new(n: usize) -> Self { - SparseLs { n, rows: Vec::new() } - } - pub fn add(&mut self, row: Vec<(usize, f64)>, rhs: f64, w: f64) { - self.rows.push((row.into_iter().map(|(i, c)| (i, c * w)).collect(), rhs * w)); - } - /// CGLS from `x0`; at most `iters` iterations. - pub fn solve(&self, x0: &[f64], iters: usize) -> Vec { - let n = self.n; - let mut x = x0.to_vec(); - let ax = |x: &[f64]| -> Vec { self.rows.iter().map(|(r, _)| r.iter().map(|&(i, c)| c * x[i]).sum()).collect() }; - let atv = |v: &[f64]| -> Vec { - let mut out = vec![0.0; n]; - for ((r, _), vi) in self.rows.iter().zip(v) { - for &(i, c) in r { - out[i] += c * vi; +impl Csr { + pub fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr { + t.sort_by_key(|a| (a.0, a.1)); + let mut rows = vec![0usize; n + 1]; + let mut cols = Vec::with_capacity(t.len()); + let mut vals: Vec = Vec::with_capacity(t.len()); + let mut last = (usize::MAX, usize::MAX); + for (i, j, v) in t { + if (i, j) == last { + *vals.last_mut().unwrap_or(&mut 0.0) += v; + continue; + } + last = (i, j); + cols.push(j); + vals.push(v); + rows[i + 1] = cols.len(); + } + for i in 0..n { + rows[i + 1] = rows[i + 1].max(rows[i]); + } + let mut diag = vec![1.0; n]; + for (i, d) in diag.iter_mut().enumerate() { + for k in rows[i]..rows[i + 1] { + if cols[k] == i && vals[k] > 0.0 { + *d = vals[k]; } } - out - }; - let mut r: Vec = self.rows.iter().zip(ax(&x)).map(|((_, b), a)| b - a).collect(); - let mut s = atv(&r); - let mut p = s.clone(); - let mut gamma: f64 = s.iter().map(|v| v * v).sum(); - let g0 = gamma.max(1e-30); - for _ in 0..iters { - if gamma <= g0 * 1e-20 { + } + Csr { rows, cols, vals, diag } + } + + pub fn mul(&self, x: &[f64], out: &mut [f64]) { + for (i, o) in out.iter_mut().enumerate() { + let mut s = 0.0; + for k in self.rows[i]..self.rows[i + 1] { + s += self.vals[k] * x[self.cols[k]]; + } + *o = s; + } + } + + /// Jacobi-preconditioned conjugate gradients, warm-started from `x`. + pub fn solve(&self, b: &[f64], x: &mut [f64]) { + let n = b.len(); + let mut r = vec![0.0; n]; + self.mul(x, &mut r); + for i in 0..n { + r[i] = b[i] - r[i]; + } + let bn: f64 = b.iter().map(|v| v * v).sum::().sqrt().max(1e-30); + let mut z: Vec = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect(); + let mut p = z.clone(); + let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); + let mut ap = vec![0.0; n]; + for _ in 0..(4 * n).clamp(50, 2000) { + if r.iter().map(|v| v * v).sum::().sqrt() <= 1e-12 * bn { break; } - let q = ax(&p); - let qq: f64 = q.iter().map(|v| v * v).sum(); - if qq <= 1e-300 { + self.mul(&p, &mut ap); + let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum(); + if pap.abs() < 1e-300 { break; } - let alpha = gamma / qq; + let alpha = rz / pap; for i in 0..n { x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; } - for (ri, qi) in r.iter_mut().zip(&q) { - *ri -= alpha * qi; - } - s = atv(&r); - let g1: f64 = s.iter().map(|v| v * v).sum(); - let beta = g1 / gamma; - gamma = g1; for i in 0..n { - p[i] = s[i] + beta * p[i]; + z[i] = r[i] / self.diag[i]; + } + let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); + let beta = rz2 / rz; + rz = rz2; + for i in 0..n { + p[i] = z[i] + beta * p[i]; } } - x } } +/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee +/// order, so the system's bandwidth is about one breadth-first front): factored once per pin +/// set, then each global step is two triangular solves. +pub(crate) struct BandChol { + n: usize, + p: usize, + /// Row `i` holds `L[i][i - p ..= i]`. + l: Vec, +} + +impl BandChol { + /// `None` when the band would be too large (or the matrix isn't positive definite). + pub fn new(a: &Csr) -> Option { + let n = a.diag.len(); + let mut p = 0; + for i in 0..n { + for k in a.rows[i]..a.rows[i + 1] { + p = p.max(i.abs_diff(a.cols[k])); + } + } + if n.saturating_mul(p + 1) > 40_000_000 { + return None; + } + let w = p + 1; + let mut l = vec![0.0f64; n * w]; + for i in 0..n { + for k in a.rows[i]..a.rows[i + 1] { + let j = a.cols[k]; + if j <= i { + l[i * w + (j + p - i)] = a.vals[k]; + } + } + } + for i in 0..n { + let lo = i.saturating_sub(p); + for j in lo..=i { + let jlo = j.saturating_sub(p).max(lo); + let mut s = l[i * w + (j + p - i)]; + for k in jlo..j { + s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)]; + } + if i == j { + if s <= 0.0 || !s.is_finite() { + return None; + } + l[i * w + p] = s.sqrt(); + } else { + l[i * w + (j + p - i)] = s / l[j * w + p]; + } + } + } + Some(BandChol { n, p, l }) + } + + #[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices + pub fn solve(&self, b: &[f64], x: &mut [f64]) { + let (n, p, w) = (self.n, self.p, self.p + 1); + let mut y = b.to_vec(); + for i in 0..n { + let mut s = y[i]; + for k in i.saturating_sub(p)..i { + s -= self.l[i * w + (k + p - i)] * y[k]; + } + y[i] = s / self.l[i * w + p]; + } + for i in (0..n).rev() { + let mut s = y[i]; + for k in i + 1..(i + p + 1).min(n) { + s -= self.l[k * w + (i + p - k)] * x[k]; + } + x[i] = s / self.l[i * w + p]; + } + } +} + +/// Preconditioned conjugate gradients on the SPD system `A x = b`, warm-started from `x`. +/// `a(v, out)` writes `A·v` and `m(r, out)` writes `M⁻¹·r`. Stops when `‖r‖ ≤ tol·‖b‖`, when the +/// system stops being usable (non-finite or non-positive curvature), or after `max_iter` +/// iterations. Returns the iterations used. +pub(crate) fn pcg(a: impl Fn(&[f64], &mut [f64]), m: impl Fn(&[f64], &mut [f64]), b: &[f64], x: &mut [f64], tol: f64, max_iter: usize) -> usize { + let dot = |u: &[f64], v: &[f64]| -> f64 { u.iter().zip(v).map(|(p, q)| p * q).sum() }; + let n = b.len().min(x.len()); + let mut r = vec![0.0; n]; + a(x, &mut r); + for (ri, bi) in r.iter_mut().zip(b) { + *ri = bi - *ri; + } + let stop = tol * dot(b, b).sqrt().max(1e-30); + let mut z = vec![0.0; n]; + m(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot(&r, &z); + let mut ap = vec![0.0; n]; + for it in 0..max_iter { + if dot(&r, &r).sqrt() <= stop || rz <= 1e-300 || !rz.is_finite() { + return it; + } + a(&p, &mut ap); + let pap = dot(&p, &ap); + if pap <= 1e-300 || !pap.is_finite() { + return it; + } + let alpha = rz / pap; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + m(&r, &mut z); + let rz2 = dot(&r, &z); + let beta = rz2 / rz; + rz = rz2; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + } + max_iter +} + /// Runs `f(y, row)` for every row of a `w × h × ch` buffer, in parallel on native targets. pub(crate) fn par_rows(buf: &mut [T], w: usize, ch: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { let stride = (w * ch).max(1); @@ -251,22 +396,50 @@ mod tests { } #[test] - fn dense_and_sparse_solvers_agree() { + fn dense_solver_fits_a_line_and_rejects_singular_systems() { // Overdetermined line fit y = 2x + 1. let mut ne = Normal::new(2); - let mut sp = SparseLs::new(2); for x in 0..10 { - let y = 2.0 * x as f64 + 1.0; - ne.add(&[(0, x as f64), (1, 1.0)], y, 1.0); - sp.add(vec![(0, x as f64), (1, 1.0)], y, 1.0); + ne.add(&[(0, x as f64), (1, 1.0)], 2.0 * x as f64 + 1.0, 1.0); } let a = ne.solve().unwrap(); - let b = sp.solve(&[0.0, 0.0], 50); assert!((a[0] - 2.0).abs() < 1e-9 && (a[1] - 1.0).abs() < 1e-9); - assert!((b[0] - 2.0).abs() < 1e-6 && (b[1] - 1.0).abs() < 1e-6, "{b:?}"); assert!(solve_dense(2, vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 2.0]).is_none()); } + /// A tridiagonal SPD system: identity preconditioning needs many iterations, the exact + /// banded Cholesky preconditioner one or two; both match the dense solve. + #[test] + fn pcg_matches_the_dense_solve_and_an_exact_preconditioner_converges_at_once() { + let n = 40; + let mut trip = Vec::new(); + for i in 0..n { + trip.push((i, i, 2.5)); + if i + 1 < n { + trip.push((i, i + 1, -1.0)); + trip.push((i + 1, i, -1.0)); + } + } + let k = Csr::from_triplets(n, trip); + let mut dense = vec![0.0; n * n]; + for i in 0..n { + for e in k.rows[i]..k.rows[i + 1] { + dense[i * n + k.cols[e]] = k.vals[e]; + } + } + let b: Vec = (0..n).map(|i| (i as f64 * 0.7).sin()).collect(); + let want = solve_dense(n, dense, b.clone()).unwrap(); + let mul = |v: &[f64], out: &mut [f64]| k.mul(v, out); + let mut x = vec![0.0; n]; + let slow = pcg(mul, |r, z| z.copy_from_slice(r), &b, &mut x, 1e-12, 500); + assert!(x.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "identity-preconditioned result differs"); + let chol = BandChol::new(&k).unwrap(); + let mut y = vec![0.0; n]; + let fast = pcg(mul, |r, z| chol.solve(r, z), &b, &mut y, 1e-12, 500); + assert!(y.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "cholesky-preconditioned result differs"); + assert!(fast <= 2 && slow > fast, "iterations: exact {fast}, identity {slow}"); + } + #[test] fn srgb_round_trip_and_sampling() { for v in [0.0f32, 0.01, 0.2, 0.5, 1.0] { diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index c3c5c3693c..a1b8cbd91d 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -26,6 +26,7 @@ use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; +use crate::photo_util::{BandChol, Csr}; use crate::transform::Interp; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -744,170 +745,6 @@ fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec { order } -/// A compressed sparse row matrix (symmetric, used by CG). -struct Csr { - rows: Vec, - cols: Vec, - vals: Vec, - diag: Vec, -} - -impl Csr { - fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr { - t.sort_by_key(|a| (a.0, a.1)); - let mut rows = vec![0usize; n + 1]; - let mut cols = Vec::with_capacity(t.len()); - let mut vals: Vec = Vec::with_capacity(t.len()); - let mut last = (usize::MAX, usize::MAX); - for (i, j, v) in t { - if (i, j) == last { - *vals.last_mut().unwrap_or(&mut 0.0) += v; - continue; - } - last = (i, j); - cols.push(j); - vals.push(v); - rows[i + 1] = cols.len(); - } - for i in 0..n { - rows[i + 1] = rows[i + 1].max(rows[i]); - } - let mut diag = vec![1.0; n]; - for (i, d) in diag.iter_mut().enumerate() { - for k in rows[i]..rows[i + 1] { - if cols[k] == i && vals[k] > 0.0 { - *d = vals[k]; - } - } - } - Csr { rows, cols, vals, diag } - } - - fn mul(&self, x: &[f64], out: &mut [f64]) { - for (i, o) in out.iter_mut().enumerate() { - let mut s = 0.0; - for k in self.rows[i]..self.rows[i + 1] { - s += self.vals[k] * x[self.cols[k]]; - } - *o = s; - } - } - - /// Jacobi-preconditioned conjugate gradients, warm-started from `x`. - fn solve(&self, b: &[f64], x: &mut [f64]) { - let n = b.len(); - let mut r = vec![0.0; n]; - self.mul(x, &mut r); - for i in 0..n { - r[i] = b[i] - r[i]; - } - let bn: f64 = b.iter().map(|v| v * v).sum::().sqrt().max(1e-30); - let mut z: Vec = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect(); - let mut p = z.clone(); - let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); - let mut ap = vec![0.0; n]; - for _ in 0..(4 * n).clamp(50, 2000) { - if r.iter().map(|v| v * v).sum::().sqrt() <= 1e-12 * bn { - break; - } - self.mul(&p, &mut ap); - let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum(); - if pap.abs() < 1e-300 { - break; - } - let alpha = rz / pap; - for i in 0..n { - x[i] += alpha * p[i]; - r[i] -= alpha * ap[i]; - } - for i in 0..n { - z[i] = r[i] / self.diag[i]; - } - let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum(); - let beta = rz2 / rz; - rz = rz2; - for i in 0..n { - p[i] = z[i] + beta * p[i]; - } - } - } -} - -/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee -/// order, so the system's bandwidth is about one breadth-first front): factored once per pin -/// set, then each global step is two triangular solves. -struct BandChol { - n: usize, - p: usize, - /// Row `i` holds `L[i][i - p ..= i]`. - l: Vec, -} - -impl BandChol { - /// `None` when the band would be too large (or the matrix isn't positive definite). - fn new(a: &Csr) -> Option { - let n = a.diag.len(); - let mut p = 0; - for i in 0..n { - for k in a.rows[i]..a.rows[i + 1] { - p = p.max(i.abs_diff(a.cols[k])); - } - } - if n.saturating_mul(p + 1) > 40_000_000 { - return None; - } - let w = p + 1; - let mut l = vec![0.0f64; n * w]; - for i in 0..n { - for k in a.rows[i]..a.rows[i + 1] { - let j = a.cols[k]; - if j <= i { - l[i * w + (j + p - i)] = a.vals[k]; - } - } - } - for i in 0..n { - let lo = i.saturating_sub(p); - for j in lo..=i { - let jlo = j.saturating_sub(p).max(lo); - let mut s = l[i * w + (j + p - i)]; - for k in jlo..j { - s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)]; - } - if i == j { - if s <= 0.0 || !s.is_finite() { - return None; - } - l[i * w + p] = s.sqrt(); - } else { - l[i * w + (j + p - i)] = s / l[j * w + p]; - } - } - } - Some(BandChol { n, p, l }) - } - - #[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices - fn solve(&self, b: &[f64], x: &mut [f64]) { - let (n, p, w) = (self.n, self.p, self.p + 1); - let mut y = b.to_vec(); - for i in 0..n { - let mut s = y[i]; - for k in i.saturating_sub(p)..i { - s -= self.l[i * w + (k + p - i)] * y[k]; - } - y[i] = s / self.l[i * w + p]; - } - for i in (0..n).rev() { - let mut s = y[i]; - for k in i + 1..(i + p + 1).min(n) { - s -= self.l[k * w + (i + p - k)] * x[k]; - } - x[i] = s / self.l[i * w + p]; - } - } -} - /// A pin bound to the mesh: barycentric weights on a triangle's vertices. #[derive(Clone, Debug)] struct Bound { diff --git a/crates/algo/src/quantize.rs b/crates/algo/src/quantize.rs index ceb9c332ad..3b22ed05a5 100644 --- a/crates/algo/src/quantize.rs +++ b/crates/algo/src/quantize.rs @@ -639,53 +639,162 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 { /// Converts gray values (0..=1, row-major `w` wide) to 0/1 in place. pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) { - let h = gray.len().checked_div(w).unwrap_or(0); - match method { - BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }), - BitmapMethod::Pattern => { - for (i, v) in gray.iter_mut().enumerate() { - *v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 }; + BitmapStrips::new(w, method).convert(gray); +} + +/// Image › Mode › Bitmap fed in strips of whole rows, top to bottom, so a huge image never needs a +/// full-size gray plane (#8477): [`BitmapStrips::convert`] turns each strip to 0/1 in place, +/// carrying error diffusion's error into the next strip. The result is the same however the image +/// is cut into strips. +pub struct BitmapStrips<'a> { + w: usize, + kind: StripKind<'a>, + /// Image row of the next strip's first row. + y: usize, +} + +enum StripKind<'a> { + Threshold, + Pattern, + /// Floyd–Steinberg: the error each pixel of the last converted row passes down. + Diffusion { + errs: Vec, + }, + /// The spot function's values over a cell, sorted (its CDF ranks coverage by tone). + Halftone { + cell: f32, + sin: f32, + cos: f32, + shape: HalftoneShape, + table: Vec, + }, + /// Custom Pattern's threshold map, `mw` x `mh`, tiled from the top-left corner. + Custom { + map: &'a [f32], + mw: usize, + mh: usize, + }, +} + +impl<'a> BitmapStrips<'a> { + pub fn new(w: usize, method: BitmapMethod) -> Self { + let kind = match method { + BitmapMethod::Threshold => StripKind::Threshold, + BitmapMethod::Pattern => StripKind::Pattern, + BitmapMethod::Diffusion => StripKind::Diffusion { errs: Vec::new() }, + BitmapMethod::Halftone { cell, angle, shape } => { + let (sin, cos) = angle.to_radians().sin_cos(); + // Rank the spot function over the cell so coverage is proportional to the tone. + let mut table: Vec = + (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect(); + table.sort_by(f32::total_cmp); + StripKind::Halftone { cell: cell.max(2.0), sin, cos, shape, table } } + }; + Self { w, kind, y: 0 } + } + + /// Bitmap's Custom Pattern: each gray value 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 custom(w: usize, map: &'a [f32], mw: usize, mh: usize) -> Self { + let fits = mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len()); + let kind = if fits { StripKind::Custom { map, mw, mh } } else { StripKind::Threshold }; + Self { w, kind, y: 0 } + } + + /// Converts the next rows (gray 0..=1, `w` wide; a short last row is allowed) to 0/1 in place. + pub fn convert(&mut self, gray: &mut [f32]) { + let (w, y0) = (self.w, self.y); + if w == 0 { + // No rows to lay a pattern or diffuse along: a 50% threshold. + gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }); + return; } - BitmapMethod::Diffusion => { - let mut buf: Vec = gray.to_vec(); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let old = buf[i]; - let new = if old >= 0.5 { 1.0 } else { 0.0 }; - let e = old - new; - gray[i] = new; - if x + 1 < w { - buf[i + 1] += e * 7.0 / 16.0; - } - if y + 1 < h { - if x > 0 { - buf[i + w - 1] += e * 3.0 / 16.0; - } - buf[i + w] += e * 5.0 / 16.0; - if x + 1 < w { - buf[i + w + 1] += e / 16.0; - } - } + self.y = y0.saturating_add(gray.len().div_ceil(w)); + // Every method but diffusion is pointwise: rows convert in parallel. + let par = gray.len() >= 1 << 16; + match &mut self.kind { + StripKind::Threshold => crate::par::chunks_mut(gray, w, par, |_, row| { + row.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }); + }), + StripKind::Pattern => crate::par::chunks_mut(gray, w, par, |r, row| { + for (x, v) in row.iter_mut().enumerate() { + *v = if *v > bayer8(x, y0 + r) + 0.5 { 1.0 } else { 0.0 }; + } + }), + StripKind::Diffusion { errs } => { + // The previous strip's last row passes its error down to this strip's first. + let first = w.min(gray.len()); + pass_down(errs, gray.get_mut(..first).unwrap_or_default()); + let mut rest = gray; + while !rest.is_empty() { + let n = w.min(rest.len()); + let (row, tail) = std::mem::take(&mut rest).split_at_mut(n); + diffuse_row(row, errs); + let below = w.min(tail.len()); + pass_down(errs, tail.get_mut(..below).unwrap_or_default()); + rest = tail; } } - } - BitmapMethod::Halftone { cell, angle, shape } => { - let cell = cell.max(2.0); - let (s, c) = angle.to_radians().sin_cos(); - // Rank the spot function over the cell so coverage is proportional to the tone. - let mut table: Vec = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect(); - table.sort_by(f32::total_cmp); - let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32; - for (i, v) in gray.iter_mut().enumerate() { - let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5); - let (rx, ry) = (x * c + y * s, -x * s + y * c); - let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0; - let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0; - // Ink (black) covers where the spot function exceeds the gray level. - *v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 }; + StripKind::Halftone { cell, sin, cos, shape, table } => { + let (cell, s, c, shape, table) = (*cell, *sin, *cos, *shape, &*table); + let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32; + crate::par::chunks_mut(gray, w, par, |r, row| { + let y = (y0 + r) as f32 + 0.5; + for (x, v) in row.iter_mut().enumerate() { + let x = x as f32 + 0.5; + let (rx, ry) = (x * c + y * s, -x * s + y * c); + let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0; + let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0; + // Ink (black) covers where the spot function exceeds the gray level. + *v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 }; + } + }); } + StripKind::Custom { map, mw, mh } => { + let (map, mw, mh) = (&**map, *mw, *mh); + crate::par::chunks_mut(gray, w, par, |r, row| { + let base = (y0 + r) % mh * mw; + for (x, v) in row.iter_mut().enumerate() { + let t = map.get(base + x % mw).copied().unwrap_or(0.5); + *v = if *v >= t { 1.0 } else { 0.0 }; + } + }); + } + } + } +} + +/// One Floyd–Steinberg row, in place: each pixel takes its error from the left (7/16), and +/// `errs` gets the error it passes down to the next row ([`pass_down`]). +fn diffuse_row(row: &mut [f32], errs: &mut Vec) { + errs.clear(); + for x in 0..row.len() { + let old = row[x]; + let new = if old >= 0.5 { 1.0 } else { 0.0 }; + let e = old - new; + row[x] = new; + if let Some(next) = row.get_mut(x + 1) { + *next += e * 7.0 / 16.0; + } + errs.push(e); + } +} + +/// Adds the error `errs` of a row to the row below: 1/16 from the pixel up-left, 5/16 from the +/// one above, 3/16 from the one up-right, in that order (the order the row's pixels were +/// converted in), so the result is the same whether or not the rows are in the same strip. +fn pass_down(errs: &[f32], below: &mut [f32]) { + for (t, v) in below.iter_mut().enumerate() { + if let Some(e) = t.checked_sub(1).and_then(|l| errs.get(l)) { + *v += e / 16.0; + } + if let Some(e) = errs.get(t) { + *v += e * 5.0 / 16.0; + } + if let Some(e) = errs.get(t + 1) { + *v += e * 3.0 / 16.0; } } } @@ -719,15 +828,10 @@ pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec { 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%. +/// Bitmap's Custom Pattern over a whole image (gray 0..=1, row-major `w` wide), see +/// [`BitmapStrips::custom`]. An image `w` of 0 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 }; - } + BitmapStrips::custom(w, map, mw, mh).convert(gray); } #[cfg(test)] diff --git a/crates/algo/src/quantize/tests.rs b/crates/algo/src/quantize/tests.rs index 14afa17f56..2bf4713917 100644 --- a/crates/algo/src/quantize/tests.rs +++ b/crates/algo/src/quantize/tests.rs @@ -440,3 +440,110 @@ fn custom_pattern_tiles_from_the_top_left() { to_bitmap_custom(&mut g, 0, &map, 2, 1); assert_eq!(g, [1.0]); } + +/// `gray` converted by `conv` in strips of `rows` rows each, cycling through `rows`. +fn in_strips(gray: &[f32], w: usize, rows: &[usize], mut conv: BitmapStrips<'_>) -> Vec { + let mut out = gray.to_vec(); + let mut rest = out.as_mut_slice(); + for r in rows.iter().cycle() { + if rest.is_empty() { + break; + } + let (strip, tail) = rest.split_at_mut((r * w).min(rest.len())); + conv.convert(strip); + rest = tail; + } + out +} + +#[test] +fn bitmap_strips_match_the_whole_image_however_it_is_cut() { + // #8477: strip boundaries must preserve diffusion state and keep patterns/screens anchored. + let (w, h) = (37, 29); + let gray: Vec = (0..w * h).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 / h as f32 * 0.3).fract()).collect(); + let map = custom_pattern_thresholds(&[0.1, 0.9, 0.4, 0.6, 0.2, 0.8]); + let methods = [ + BitmapMethod::Threshold, + BitmapMethod::Pattern, + BitmapMethod::Diffusion, + BitmapMethod::Halftone { cell: 5.5, angle: 30.0, shape: HalftoneShape::Ellipse }, + ]; + for rows in [&[1][..], &[3], &[5, 2, 7], &[h]] { + for method in methods { + let mut whole = gray.clone(); + to_bitmap(&mut whole, w, method); + assert!(whole.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}"); + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}"); + } + let mut whole = gray.clone(); + to_bitmap_custom(&mut whole, w, &map, 3, 2); + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::custom(w, &map, 3, 2)), whole, "custom in strips of {rows:?}"); + } +} + +#[test] +fn bitmap_strips_match_the_whole_image_with_a_ragged_last_row() { + // A short final row still receives the error from the row above, including in its own strip. + let w = 37; + let gray: Vec = (0..w * 5 + 11).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 * 0.13).fract()).collect(); + for method in [BitmapMethod::Diffusion, BitmapMethod::Threshold, BitmapMethod::Pattern] { + let mut whole = gray.clone(); + to_bitmap(&mut whole, w, method); + for rows in [&[1][..], &[5], &[2, 3], &[5, 1]] { + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}"); + } + } +} + +/// Floyd–Steinberg over the whole image as `to_bitmap` did before strips (#8477). +fn diffusion_reference(gray: &mut [f32], w: usize) { + let h = gray.len() / w; + let mut buf = gray.to_vec(); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let old = buf[i]; + let new = if old >= 0.5 { 1.0 } else { 0.0 }; + let error = old - new; + gray[i] = new; + if x + 1 < w { + buf[i + 1] += error * 7.0 / 16.0; + } + if y + 1 < h { + if x > 0 { + buf[i + w - 1] += error * 3.0 / 16.0; + } + buf[i + w] += error * 5.0 / 16.0; + if x + 1 < w { + buf[i + w + 1] += error / 16.0; + } + } + } + } +} + +#[test] +fn bitmap_diffusion_in_strips_is_bit_identical_to_one_pass() { + // Keep error accumulation order identical; one rounding change can flip many later pixels. + let (w, h) = (173, 61); + let gray: Vec = (0..w * h).map(|i| ((i % w) as f32 * 0.013 + (i / w) as f32 * 0.007 + ((i * 7919) % 101) as f32 * 0.001).fract()).collect(); + let mut expected = gray.clone(); + diffusion_reference(&mut expected, w); + for rows in [&[1][..], &[4, 9], &[h]] { + assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, BitmapMethod::Diffusion)), expected, "strips of {rows:?}"); + } +} + +#[test] +fn bitmap_strips_handle_empty_and_ragged_input() { + // A zero width thresholds at 50%, and a short final row is converted too. + for method in [BitmapMethod::Pattern, BitmapMethod::Diffusion, BitmapMethod::Halftone { cell: 4.0, angle: 0.0, shape: HalftoneShape::Round }] { + let mut gray = vec![0.4, 0.6]; + to_bitmap(&mut gray, 0, method); + assert_eq!(gray, [0.0, 1.0], "{method:?}"); + let mut gray = vec![0.3; 7]; + to_bitmap(&mut gray, 3, method); + assert!(gray.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}: {gray:?}"); + to_bitmap(&mut [], 3, method); + } +} diff --git a/crates/algo/src/render.rs b/crates/algo/src/render.rs index 2a1867dac8..9a0a4189ab 100644 --- a/crates/algo/src/render.rs +++ b/crates/algo/src/render.rs @@ -1,11 +1,11 @@ -//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects. +//! Render: Clouds, Difference Clouds, Fibers, Lighting Effects (Lens Flare: `lens_flare.rs`). use photocraft_geom::Rect; use crate::fxutil::{MAXC, luma, rgba, set_rgba, smoothstep, xy}; use crate::image::Image; use crate::noise::hash01; -use crate::{Ctx, LensType, Light, LightKind, TextureChannel}; +use crate::{Ctx, Light, LightKind, TextureChannel}; /// Smooth value noise in [0, 1). fn value_noise(x: f32, y: f32, seed: u32) -> f32 { @@ -106,107 +106,6 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength: res } -/// One lens-flare ghost: position along the flare→centre axis, radius and tint. -struct Ghost { - t: f32, - r: f32, - tint: [f32; 3], - k: f32, -} - -/// Lens Flare: an additive (screen) glow, rays, halo ring and ghost discs -/// along the line through the image centre. Lens types vary the elements. -pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, centre: (f32, f32), lens: LensType) -> Vec { - let n = src.ch; - let b = ctx.bounds; - let (bw, bh) = (b.width() as f32, b.height() as f32); - let diag = bw.hypot(bh).max(1.0); - let (cx, cy) = (b.x0 as f32 + centre.0 * bw, b.y0 as f32 + centre.1 * bh); - let (mx, my) = (b.x0 as f32 + bw / 2.0, b.y0 as f32 + bh / 2.0); - let bright = brightness.clamp(10.0, 300.0) / 100.0; - let s = diag / 1000.0; - let (core, glow, ring_r, ring_w, rays, streak) = match lens { - LensType::Zoom => (14.0, 90.0, 0.0, 0.0, 0.5, 0.0), - LensType::Prime35 => (12.0, 70.0, 90.0, 6.0, 0.3, 0.0), - LensType::Prime105 => (22.0, 130.0, 0.0, 0.0, 0.8, 0.0), - LensType::MoviePrime => (10.0, 60.0, 0.0, 0.0, 0.2, 1.0), - }; - let ghosts: Vec = match lens { - LensType::Zoom => vec![ - Ghost { t: 0.35, r: 10.0, tint: [0.6, 0.8, 1.0], k: 0.18 }, - Ghost { t: 0.6, r: 26.0, tint: [0.5, 1.0, 0.6], k: 0.10 }, - Ghost { t: 0.85, r: 6.0, tint: [1.0, 0.7, 0.4], k: 0.25 }, - Ghost { t: 1.25, r: 40.0, tint: [0.7, 0.5, 1.0], k: 0.08 }, - Ghost { t: 1.5, r: 16.0, tint: [1.0, 0.6, 0.5], k: 0.15 }, - Ghost { t: 1.9, r: 70.0, tint: [0.5, 0.7, 1.0], k: 0.06 }, - ], - LensType::Prime35 => vec![Ghost { t: 0.7, r: 18.0, tint: [0.6, 1.0, 0.7], k: 0.12 }, Ghost { t: 1.4, r: 30.0, tint: [1.0, 0.7, 0.5], k: 0.09 }], - LensType::Prime105 => vec![Ghost { t: 0.5, r: 14.0, tint: [1.0, 0.8, 0.5], k: 0.12 }, Ghost { t: 1.6, r: 50.0, tint: [0.6, 0.7, 1.0], k: 0.07 }], - LensType::MoviePrime => vec![ - Ghost { t: 0.4, r: 8.0, tint: [0.5, 0.7, 1.0], k: 0.2 }, - Ghost { t: 1.0, r: 20.0, tint: [0.4, 0.6, 1.0], k: 0.12 }, - Ghost { t: 1.6, r: 12.0, tint: [0.5, 0.8, 1.0], k: 0.16 }, - ], - }; - let mut res = src.crop(out); - for (i, px) in res.chunks_exact_mut(n).enumerate() { - let (x, y) = xy(out, i); - let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); - let (dx, dy) = (fx - cx, fy - cy); - let d = (dx * dx + dy * dy).sqrt(); - let mut f = [0.0f32; 3]; - // Hot core and wide glow (warm white). - let g = (-(d / (core * s)).powi(2)).exp() + 0.35 * (-d / (glow * s)).exp(); - for (k, w) in [1.0, 0.95, 0.85].iter().enumerate() { - f[k] += g * w; - } - // Rays: angular modulation decaying with distance. - if rays > 0.0 { - let th = dy.atan2(dx); - let m = ((th * 12.0).sin() * 0.5 + 0.5).powi(8) + ((th * 7.0 + 1.3).sin() * 0.5 + 0.5).powi(12) * 0.6; - let rr = rays * m * (-d / (glow * 1.6 * s)).exp(); - for v in f.iter_mut() { - *v += rr * 0.6; - } - } - // Anamorphic horizontal streak (movie prime). - if streak > 0.0 { - let st = (-(dy.abs() / (2.5 * s))).exp() * (-(dx.abs() / (450.0 * s))).exp(); - f[0] += st * 0.5; - f[1] += st * 0.7; - f[2] += st; - } - // Halo ring with a slight chromatic spread. - if ring_r > 0.0 { - for (k, off) in [1.04f32, 1.0, 0.96].iter().enumerate() { - let rd = (d - ring_r * s * off) / (ring_w * s); - f[k] += 0.18 * (-rd * rd).exp(); - } - } - // Ghosts along the axis through the centre. - for gh in &ghosts { - let (gx, gy) = (cx + (mx - cx) * gh.t * 2.0, cy + (my - cy) * gh.t * 2.0); - let gd = ((fx - gx).powi(2) + (fy - gy).powi(2)).sqrt() / (gh.r * s); - if gd < 1.2 { - // Soft-edged disc, slightly brighter at the rim. - let e = smoothstep(1.0, 0.85, gd) * (0.6 + 0.4 * gd); - for (fk, t) in f.iter_mut().zip(gh.tint) { - *fk += e * gh.k * t; - } - } - } - let mut c = rgba(ctx, px); - for k in 0..3 { - let a = (f[k] * bright).max(0.0); - // Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit - // value already above white is left as is rather than pulled down towards it. - c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0); - } - set_rgba(ctx, px, c); - } - res -} - pub(crate) struct LightingSpec<'a> { pub lights: &'a [Light], pub gloss: f32, diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs index 00388fe399..78b588f787 100644 --- a/crates/algo/src/retouch.rs +++ b/crates/algo/src/retouch.rs @@ -210,34 +210,80 @@ pub fn local_sharpen( protect_detail: bool, ) -> Vec { let (x0, y0, x1, y1) = win; - let ow = x1 - x0; + let (ow, oh) = (x1 - x0, y1 - y0); + if ow == 0 || oh == 0 || ch == 0 { + return Vec::new(); + } let blur = local_blur(data, w, h, ch, alpha, win, 1.0); let mut out = vec![0.0f32; blur.len()]; - for y in y0..y1 { - for x in x0..x1 { - let o = ((y - y0) * ow + (x - x0)) * ch; + let stride = ow * ch; + + // Rows are independent, so process them in parallel with rayon (#5426). + let process_row = |(oy, dst): (usize, &mut [f32])| { + let y = y0 + oy; + let blur_row = &blur[oy * stride..(oy + 1) * stride]; + for ox in 0..ow { + let x = x0 + ox; + let o = ox * ch; let i = (y * w + x) * ch; + + // Precompute 3×3 neighbourhood offsets once per pixel if protecting detail. + let offsets: [usize; 8] = if protect_detail { + if x > 0 && x + 1 < w && y > 0 && y + 1 < h { + let w_ch = w * ch; + [i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch] + } else { + let get_n_idx = |dx: i32, dy: i32| -> usize { + let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize; + let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize; + (ny * w + nx) * ch + }; + [ + get_n_idx(-1, -1), + get_n_idx(0, -1), + get_n_idx(1, -1), + get_n_idx(-1, 0), + get_n_idx(1, 0), + get_n_idx(-1, 1), + get_n_idx(0, 1), + get_n_idx(1, 1), + ] + } + } else { + [0; 8] + }; + for c in 0..ch { let v = data[i + c]; if Some(c) == alpha { - out[o + c] = v; + dst[o + c] = v; continue; } - let mut s = v + amount * (v - blur[o + c]); + let mut s = v + amount * (v - blur_row[o + c]); if protect_detail { let (mut lo, mut hi) = (v, v); - for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); - let nv = data[(ny * w + nx) * ch + c]; + for &n_idx in &offsets { + let nv = data[n_idx + c]; lo = lo.min(nv); hi = hi.max(nv); } s = s.clamp(lo, hi); } - out[o + c] = s; + dst[o + c] = s; } } + }; + + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(stride).enumerate().for_each(process_row); } + #[cfg(target_arch = "wasm32")] + { + out.chunks_mut(stride).enumerate().for_each(process_row); + } + out } diff --git a/crates/algo/src/segment.rs b/crates/algo/src/segment.rs index f586453db4..958d3d0f6a 100644 --- a/crates/algo/src/segment.rs +++ b/crates/algo/src/segment.rs @@ -252,14 +252,28 @@ pub fn grid_cut_cancellable( weight: impl Fn(f32) -> f32, cancel: &dyn Fn() -> bool, ) -> Option> { - grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel) + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel, false) +} + +/// [`grid_cut_cancellable`] solved with Boykov–Kolmogorov instead of push/relabel: faster when +/// the data costs come from colour models (GrabCut and the band re-cut), where terminal links +/// are few and paths short. The minimum cut value is the same. +pub fn grid_cut_bk_cancellable( + img: &RgbImage, + cost_fg: &[f32], + cost_bg: &[f32], + fixed: &[u8], + weight: impl Fn(f32) -> f32, + cancel: &dyn Fn() -> bool, +) -> Option> { + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, cancel, true) } mod edge_starts; // The const parameter also runs the previous full scan in before/after workflow tests. fn grid_cut_weighted(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec { - grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default() + grid_cut_impl::(img, cost_fg, cost_bg, fixed, weight, &|| false, false).unwrap_or_default() } fn grid_cut_impl( @@ -269,6 +283,7 @@ fn grid_cut_impl( fixed: &[u8], weight: impl Fn(f32) -> f32, cancel: &dyn Fn() -> bool, + bk: bool, ) -> Option> { let (w, h) = (img.w as i32, img.h as i32); let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) }; @@ -336,7 +351,9 @@ fn grid_cut_impl( if cancel() { return None; } - if OPTIMIZED { + if OPTIMIZED && bk { + g.maxflow_bk_with(cancel)?; + } else if OPTIMIZED { g.maxflow_with(cancel)?; } else { g.maxflow_bk(); @@ -733,7 +750,7 @@ fn refine_band( } let beta = contrast_beta(&img); // `grid_cut`'s weight, with the cut stopping on cancel. - let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; + let cut = grid_cut_bk_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; let (cw, ch) = (t.width() as usize, t.height() as usize); let mut core = Vec::with_capacity(cw * ch); for yy in 0..ch { diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 95ed668e68..249763ad49 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -10,7 +10,7 @@ use photocraft_geom::Rect; use photocraft_raster::{Cancelled, Interrupt}; use super::gmm::{DEFAULT_REG, Gmm}; -use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample}; +use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_bk_cancellable, subsample}; /// Definite background. pub const BG: u8 = 0; @@ -67,7 +67,7 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter }; let (cf, cb) = data_costs(img, &fg, &bg); // `grid_cut`'s weight, with the cut stopping on cancel. - let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; + let cut = grid_cut_bk_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?; let mut changed = 0usize; for (t, fgnd) in trimap.iter_mut().zip(&cut) { if *t == PR_BG || *t == PR_FG { diff --git a/crates/algo/src/segment/maxflow.rs b/crates/algo/src/segment/maxflow.rs index dcde4ebdfb..6fef7f8faf 100644 --- a/crates/algo/src/segment/maxflow.rs +++ b/crates/algo/src/segment/maxflow.rs @@ -140,11 +140,22 @@ impl Graph { self.bk(cancel) } + /// [`Graph::maxflow_with`] that always runs Boykov–Kolmogorov. It is the faster solver on + /// the 8-connected colour-model grids of GrabCut (about 2.3x on a 107k-pixel working image); + /// push/relabel wins on Quick Selection's many area-cost terminals, which keep + /// [`Graph::maxflow_with`]. + pub fn maxflow_bk_with(&mut self, cancel: &dyn Fn() -> bool) -> Option { + self.bk(cancel) + } + pub(super) fn maxflow_bk(&mut self) -> f64 { self.bk(&never).unwrap_or(self.flow) } fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option { + if cancel() { + return None; + } let n = self.node_count(); let mut queue: VecDeque = VecDeque::new(); let mut orphans: VecDeque = VecDeque::new(); @@ -219,7 +230,7 @@ impl Graph { self.adopt(o as usize, time, &mut queue, &mut orphans); } } - Some(self.flow) + if cancel() { None } else { Some(self.flow) } } fn augment(&mut self, m: usize, orphans: &mut VecDeque) { @@ -408,3 +419,51 @@ impl Graph { s } } + +#[cfg(test)] +mod cancellation_tests { + use super::{CANCEL_EVERY, Graph}; + use std::cell::Cell; + + #[test] + fn bk_checks_cancellation_on_short_runs() { + let mut graph = Graph::with_capacity(2, 0); + graph.add_tweights(0, 1.0, 0.0); + graph.add_tweights(1, 0.0, 1.0); + assert!(graph.maxflow_bk_with(&|| true).is_none()); + + let calls = Cell::new(0); + assert!( + graph + .maxflow_bk_with(&|| { + calls.set(calls.get() + 1); + calls.get() == 2 + }) + .is_none() + ); + assert_eq!(calls.get(), 2, "short run must check before returning"); + } + + #[test] + fn bk_checks_cancellation_during_a_long_run() { + let mut graph = Graph::with_capacity(CANCEL_EVERY as usize + 1, 0); + for i in 0..graph.node_count() { + graph.add_tweights(i, 1.0, 0.0); + } + let calls = Cell::new(0); + assert!( + graph + .maxflow_bk_with(&|| { + calls.set(calls.get() + 1); + calls.get() == 2 + }) + .is_none() + ); + // The first check is at entry; more than CANCEL_EVERY active nodes ensure the + // second is the periodic poll, before the solver can finish. + assert_eq!(calls.get(), 2); + // Without the periodic poll the final check would be the second call. The poll stops the + // run with the last queued node unprocessed, still marked active. + assert!(graph.active[CANCEL_EVERY as usize], "periodic cancel must stop before the last queued node is processed"); + } +} diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index 8306b6e662..6ff0dfe398 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -265,7 +265,7 @@ fn cut_levels( let (w, h) = (img.w, img.h); let zeros = vec![0.0f32; w * h]; if w * h <= direct_px || w < 8 || h < 8 { - return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel); + return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel, false); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let coarse_img = img.downsample(2); @@ -310,7 +310,7 @@ fn cut_levels( } }) .collect(); - super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel) + super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel, false) } /// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel. diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 135c47b223..07f52b815b 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -288,6 +288,65 @@ fn grabcut_trimap_on_disc() { assert!(iou > 0.95, "IoU {iou}"); } +#[test] +fn grabcut_small_disc_mask_is_pinned() { + // The mask GrabCut produced with the push/relabel solver, before the switch to BK. Min-cut + // ties may break differently between solvers, so any drift here is a visible change. + let (img, _) = disc_scene(32, 24, 16.0, 12.0, 8.0, 3); + let mut tri = vec![grabcut::BG; img.w * img.h]; + for y in 2..22 { + for x in 6..26 { + tri[y * img.w + x] = grabcut::PR_FG; + } + } + grabcut::grabcut(&img, &mut tri, 8).unwrap(); + let got: Vec = tri.chunks(img.w).map(|r| r.iter().map(|t| if *t == grabcut::PR_FG || *t == grabcut::FG { '#' } else { '.' }).collect()).collect(); + let want = [ + "................................", + "................................", + "................................", + "................................", + ".............######.............", + "...........##########...........", + "..........############..........", + ".........##############.........", + ".........##############.........", + "........################........", + "........################........", + "........################........", + "........################........", + "........################........", + "........################........", + ".........##############.........", + ".........##############.........", + "..........############..........", + "...........##########...........", + ".............######.............", + "................................", + "................................", + "................................", + "................................", + ]; + assert_eq!(got, want); +} + +#[test] +fn grabcut_large_disc_mask_is_pinned() { + // 5168 free nodes with terminal links both ways, so the pre-BK solver was push/relabel here. + // The count and FNV-1a hash are of the mask that push/relabel produced at the merge base. + let (img, _) = disc_scene(96, 80, 48.0, 40.0, 26.0, 3); + let mut tri = vec![grabcut::BG; img.w * img.h]; + for y in 6..74 { + for x in 10..86 { + tri[y * img.w + x] = grabcut::PR_FG; + } + } + grabcut::grabcut(&img, &mut tri, 8).unwrap(); + let fg: Vec = tri.iter().map(|t| *t == grabcut::PR_FG || *t == grabcut::FG).collect(); + let hash = fg.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)); + assert_eq!((fg.iter().filter(|b| **b).count(), hash), (2128, 0x62fd_c64a_0ee4_4e75)); +} + #[test] fn object_select_disc_full_resolution() { let (img, truth) = disc_scene(200, 160, 100.0, 80.0, 50.0, 2); @@ -1068,3 +1127,87 @@ fn hole_fill_without_specks_fills_object_coloured_holes_only() { assert!(out[25 * w + 25], "a speck of the object's colour fills"); assert!(!out[10 * w + 10], "a speck of the background's colour stays open"); } + +/// Energy of a labelling: data costs plus the contrast weights of separated 8-neighbours. +fn grid_energy(img: &RgbImage, cf: &[f32], cb: &[f32], cut: &[bool], weight: impl Fn(f32) -> f32) -> f64 { + let (w, h) = (img.w as i32, img.h as i32); + let mut e = 0.0f64; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) as usize; + e += f64::from(if cut[i] { cf[i] } else { cb[i] }); + for (dx, dy, len) in NB { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && nx < w && ny < h { + let j = (ny * w + nx) as usize; + if cut[i] != cut[j] { + e += f64::from(weight(d2(img.px[i], img.px[j])) / len); + } + } + } + } + } + e +} + +#[test] +fn bk_and_default_grid_cut_tie_reaches_the_same_energy() { + // Hard background down the left edge, hard foreground down the right, uniform pixels and equal + // data costs: a vertical cut at any column separates the borders at the same energy, so only + // the solvers' tie-breaking decides the labelling. + let (w, h) = (120usize, 100usize); + let img = RgbImage::from_fn(w, h, |_, _| [0.5, 0.5, 0.5]); + let (cf, cb) = (vec![1.0f32; w * h], vec![1.0f32; w * h]); + let fixed: Vec = (0..w * h) + .map(|i| match i % w { + 0 => HARD_BG, + x if x == w - 1 => HARD_FG, + _ => FREE, + }) + .collect(); + let weight = |_: f32| 2.0; + // Two distinct candidate labellings (vertical cuts at different columns) tie at the minimum. + let split = |col: usize| -> Vec { (0..w * h).map(|i| i % w >= col).collect() }; + let (e30, e90) = (grid_energy(&img, &cf, &cb, &split(30), weight), grid_energy(&img, &cf, &cb, &split(90), weight)); + assert!((e30 - e90).abs() <= 1e-6 * e30.abs().max(1.0), "candidates must tie: {e30} vs {e90}"); + assert_ne!(split(30), split(90)); + let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut"); + let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut"); + let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight)); + assert!((ea - e30).abs() <= 1e-3 * e30.abs().max(1.0), "default {ea} is not the tied minimum {e30}"); + assert!((eb - e30).abs() <= 1e-3 * e30.abs().max(1.0), "bk {eb} is not the tied minimum {e30}"); + // The solvers may pick different minimum cuts here; only the energy is guaranteed. + assert!(a.iter().any(|&l| l) && b.iter().any(|&l| l), "both label the foreground border"); +} + +#[test] +fn bk_and_default_grid_cut_reach_the_same_minimum_energy() { + // Large enough (>= 4096 free nodes, both terminals) that the default solver is push/relabel. + let (w, h) = (120usize, 100usize); + let mut rng = Rng::new(7); + let img = RgbImage::from_fn(w, h, |x, y| { + let inside = (x as f32 - 60.0).powi(2) + (y as f32 - 50.0).powi(2) < 1600.0; + let base = if inside { [0.8, 0.3, 0.2] } else { [0.2, 0.4, 0.7] }; + base.map(|v| v + 0.08 * rng.normal()) + }); + let cf: Vec = img.px.iter().map(|p| 4.0 * d2(*p, [0.8, 0.3, 0.2])).collect(); + let cb: Vec = img.px.iter().map(|p| 4.0 * d2(*p, [0.2, 0.4, 0.7])).collect(); + let fixed: Vec = (0..w * h) + .map(|i| { + if i % w == 0 { + HARD_BG + } else if i == (50 * w + 60) { + HARD_FG + } else { + FREE + } + }) + .collect(); + let weight = |d: f32| 50.0 * (-8.0 * d).exp(); + let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut"); + let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut"); + let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight)); + assert!((ea - eb).abs() <= 1e-3 * ea.abs().max(1.0), "default {ea} vs bk {eb}"); + assert_eq!(a, b, "same labelling, not just the same energy"); + assert!(grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| true).is_none(), "BK honours cancel"); +} diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index d6e1837dd1..1e0c1dd149 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -53,9 +53,10 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec { let tc = TileCoord::containing(x, y); let x1 = (tc.rect().x1).min(area.x1); let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default(); - let src = s.tile(tc).and_then(|t| { + let guard = s.tile(tc).map(|t| t.bytes()); + let src = guard.as_deref().and_then(|bytes| { let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize; - t.bytes().get(base..base + dst.len()) + bytes.get(base..base + dst.len()) }); match src { Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]), @@ -115,7 +116,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn( let tr = tc.rect().intersect(&area); let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize); let mut tile = Arc::clone(blank); - let data = Arc::make_mut(&mut tile).bytes_mut(); + let mut data = Arc::make_mut(&mut tile).bytes_mut(); let (mut lo, mut hi) = (if tr == tc.rect() { u8::MAX } else { 0 }, 0u8); for y in tr.y0..tr.y1 { let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize; @@ -123,6 +124,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn( row((y - area.y0) as usize * w + lx, dst); (lo, hi) = dst.iter().fold((lo, hi), |(lo, hi), b| (lo.min(*b), hi.max(*b))); } + drop(data); (hi > 0).then(|| (*tc, if lo == u8::MAX { Arc::clone(&full) } else { tile })) }); s.put_tiles(built.into_iter().flatten()); @@ -134,18 +136,30 @@ pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface { surface_from_masks(area, m, None, |_, v| v) } +mod combine_mask; +pub use combine_mask::combine_band; + /// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected. pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option { if area.is_empty() { return None; } + // Replace already writes straight from the input mask to tiles; it needs no old mask. + if mode == SelectionMode::Replace || (old.is_none() && mode == SelectionMode::Add) { + let s = mask_to_surface(new, area); + return (s.tile_count() > 0).then_some(s); + } + let old = old?; + if old.format() == PixelFormat::GRAY8 { + return combine_mask::gray8(Some(old), new, area, mode, false); + } // Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is // exact. Below half an 8-bit step is nothing. let q = |v: f32| f32::from(coverage8(v)) / 255.0; let s = match mode { SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v), _ => { - let prev = mask_from_surface(old, area); + let prev = mask_from_surface(Some(old), area); match mode { SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))), SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)), @@ -381,6 +395,9 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() }; let new = new.unwrap_or(&empty); let b = new.bbox; + if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) { + return combine_mask::gray8_bytes(old, &new.mask, b, mode); + } let prev_u8 = |s: &Surface| -> Vec { let mut v = Vec::new(); s.read_region_into(b, &mut v); @@ -761,11 +778,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { } mod distance; +mod inverse; mod lasso; mod marquee; mod morphology; pub use distance::edt; +pub use inverse::inverse_surface; pub(crate) use lasso::clip_segment; pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface}; pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface}; diff --git a/crates/algo/src/selection/combine_mask.rs b/crates/algo/src/selection/combine_mask.rs new file mode 100644 index 0000000000..5c45ed8ff0 --- /dev/null +++ b/crates/algo/src/selection/combine_mask.rs @@ -0,0 +1,114 @@ +//! Float mask inputs meet stored GRAY8 selections at the tile boundary. No dense copy of +//! the previous selection or of the combined result is needed. + +use super::{SelectionMode, combine, coverage8}; +use crate::photo_util::par_map; +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use std::sync::Arc; + +/// Combines a mask covering only `band` (zero elsewhere). Add and Subtract retain the old +/// selection outside it, including pixels sharing a tile with the band's edges. Replace +/// and Intersect return only the selection inside the band. Missing mask samples read zero. +pub fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, mode: SelectionMode) -> Option { + let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract); + if !keep || old.is_none() { + return combine(old, mask, band, mode); + } + if band.is_empty() { + return old.cloned(); + } + if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) { + return gray8(old, mask, band, mode, true); + } + // Preserve the format and conversion behavior of unusual selection surfaces. The normal + // GRAY8 path above never materializes these band-sized buffers. + let inside = combine(old, mask, band, mode); + let mut out = old.cloned()?; + let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |s| s.read_region(band)); + if out.channels() == 1 { + out.write_region(band, &values); + } else { + // A selection uses its first channel as coverage. Broadcast it when writing back to + // an unusual multichannel surface, rather than passing a short buffer to write_region. + let pixels: Vec = values.iter().flat_map(|v| std::iter::repeat_n(*v, out.channels())).collect(); + out.write_region(band, &pixels); + } + out.prune(); + (out.tile_count() > 0).then_some(out) +} + +pub(super) fn gray8(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode, keep_outside: bool) -> Option { + build(old, area, mode, keep_outside, |i| coverage8(mask.get(i).copied().unwrap_or(0.0))) +} + +pub(super) fn gray8_bytes(old: Option<&Surface>, mask: &[u8], area: Rect, mode: SelectionMode) -> Option { + let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract); + if area.is_empty() { + return if keep { old.cloned().filter(|s| !s.content_bounds().is_empty()) } else { None }; + } + build(old, area, mode, keep, |i| mask.get(i).copied().unwrap_or(0)) +} + +fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: bool, sample: impl Fn(usize) -> u8 + Sync) -> Option { + let mut out = if keep_outside { old.cloned().unwrap_or_else(|| Surface::new(PixelFormat::GRAY8)) } else { Surface::new(PixelFormat::GRAY8) }; + let default = old.and_then(|s| s.default_bytes().first()).copied().unwrap_or(0); + let out_default = out.default_bytes().first().copied().unwrap_or(0); + let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0])); + let untouched = out.solid_tile(&[f32::from(out_default) / 255.0]); + let coords: Vec = area.tiles().collect(); + let width = area.width() as usize; + let ts = TILE_SIZE as usize; + let built = par_map(coords.len(), |k| { + let tc = *coords.get(k)?; + let rect = tc.rect(); + let cut = rect.intersect(&area); + let previous = old.and_then(|s| s.tile(tc)); + let mut tile = Tile::clone(if keep_outside { previous.unwrap_or(&untouched) } else { &blank }); + for y in cut.y0..cut.y1 { + let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize; + let len = cut.width() as usize; + let mut data = tile.bytes_mut(); + let Some(dst) = data.get_mut(offset..offset + len) else { continue }; + let previous_bytes = previous.map(|t| t.bytes()); + let src = previous_bytes.as_ref().and_then(|t| t.get(offset..offset + len)); + let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize)); + for (i, d) in dst.iter_mut().enumerate() { + let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0); + let prev = src.and_then(|s| s.get(i)).copied().unwrap_or(default); + *d = match mode { + SelectionMode::Replace => new, + SelectionMode::Add => prev.max(new), + SelectionMode::Subtract => prev.saturating_sub(new), + SelectionMode::Intersect => prev.min(new), + }; + } + } + let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b))); + let tile = if lo == out_default && hi == out_default { + None + } else if previous.is_some_and(|p| *p.bytes() == *tile.bytes()) { + previous.cloned() + } else if lo == u8::MAX { + Some(Arc::clone(&full)) + } else { + Some(Arc::new(tile)) + }; + Some((tc, tile)) + }); + for (tc, tile) in built.into_iter().flatten() { + match tile { + Some(tile) => out.put_tiles([(tc, tile)]), + None if keep_outside && out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())), + None => {} + } + } + if keep_outside { + out.prune(); + } + (out.tile_count() > 0).then_some(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/selection/combine_mask/tests.rs b/crates/algo/src/selection/combine_mask/tests.rs new file mode 100644 index 0000000000..5c55d9d90a --- /dev/null +++ b/crates/algo/src/selection/combine_mask/tests.rs @@ -0,0 +1,179 @@ +use super::*; +use crate::selection::{Region, combine_region, mask_from_surface, surface_from_masks}; +use photocraft_color::{ColorMode, SampleType}; + +const MODES: [SelectionMode; 4] = [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect]; + +// The pre-refactor float formulas, including quantization before the binary operation. +fn reference(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option { + if area.is_empty() { + return None; + } + if mode == SelectionMode::Replace { + let s = surface_from_masks(area, mask, None, |_, v| v); + return (s.tile_count() > 0).then_some(s); + } + let prev = mask_from_surface(old, area); + let s = surface_from_masks(area, mask, Some(&prev), |p, v| { + let q = f32::from(coverage8(v)) / 255.0; + match mode { + SelectionMode::Replace => v, + SelectionMode::Add => p.max(q), + SelectionMode::Subtract => (p - q).max(0.0), + SelectionMode::Intersect => p.min(q), + } + }); + (s.tile_count() > 0).then_some(s) +} + +fn reference_band(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option { + let inside = reference(old, mask, area, mode); + let Some(old) = old.filter(|_| matches!(mode, SelectionMode::Add | SelectionMode::Subtract)) else { return inside }; + if area.is_empty() { + return Some(old.clone()); + } + let values = inside.map_or_else(|| vec![0.0; area.width() as usize * area.height() as usize], |s| s.read_region(area)); + let mut out = old.clone(); + out.write_region(area, &values); + out.prune(); + (out.tile_count() > 0).then_some(out) +} + +#[test] +fn every_byte_pair_matches_float_combining() { + let area = Rect::new(0, 0, 256, 256); + let prev: Vec = (0..65536).map(|i| (i / 256) as f32 / 255.0).collect(); + let mask: Vec = (0..65536).map(|i| (i % 256) as f32 / 255.0).collect(); + let old = crate::selection::mask_to_surface(&prev, area); + for mode in MODES { + assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode), "{mode:?}"); + } +} + +#[test] +fn cropped_bands_match_dense_writeback_with_defaults_and_short_masks() { + let values = [f32::NAN, f32::NEG_INFINITY, -0.2, 0.0, 0.49 / 255.0, 0.5 / 255.0, 0.51 / 255.0, 0.5, 1.0, 1.2, f32::INFINITY]; + for area in [Rect::new(-259, -2, 259, 3), Rect::new(255, 255, 258, 259), Rect::new(0, 0, 512, 256), Rect::EMPTY] { + let n = area.width() as usize * area.height() as usize; + let mask: Vec = (0..n).map(|i| values[i % values.len()]).collect(); + for default in [0.0, 0.5, 1.0] { + let mut old = Surface::with_default(PixelFormat::GRAY8, &[default]); + old.fill_rect(Rect::new(-300, -20, 300, 270), &[0.75]); + old.write_pixel(1024, 1024, &[1.0]); + old.write_pixel(-257, 0, &[0.0]); + let snapshot = old.clone(); + for prev in [None, Some(&old)] { + for len in [0, n / 2, n] { + for mode in MODES { + let mask = &mask[..len]; + assert_eq!(combine(prev, mask, area, mode), reference(prev, mask, area, mode), "{area:?} {mode:?} {default} {len}"); + assert_eq!(combine_band(prev, mask, area, mode), reference_band(prev, mask, area, mode), "band {area:?} {mode:?} {default} {len}"); + } + } + } + assert_eq!(old, snapshot, "combining must leave undo snapshots unchanged"); + } + } +} + +#[test] +fn unusual_selection_formats_keep_their_float_behavior() { + let area = Rect::new(-2, -1, 3, 2); + for sample in SampleType::ALL { + for color in [ColorMode::Grayscale, ColorMode::Rgb] { + let mut old = Surface::with_default(PixelFormat::new(color, sample, false), &vec![0.123; if color == ColorMode::Rgb { 3 } else { 1 }]); + old.write_pixel(0, 0, &vec![0.754; old.channels()]); + for mode in MODES { + let mask = [0.5; 15]; + assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode)); + if color == ColorMode::Grayscale { + assert_eq!(combine_band(Some(&old), &mask, area, mode), reference_band(Some(&old), &mask, area, mode)); + } else { + let out = combine_band(Some(&old), &mask, area, mode).unwrap(); + let inside = reference(Some(&old), &mask, area, mode).unwrap(); + let expected = inside.pixel(0, 0)[0]; + let encoded = Surface::with_default(out.format(), &vec![expected; out.channels()]); + assert_eq!(out.pixel(0, 0), encoded.pixel(0, 0)); + if matches!(mode, SelectionMode::Add | SelectionMode::Subtract) { + assert_eq!(out.pixel(20, 20), old.pixel(20, 20)); + } + } + } + } + } +} + +#[test] +fn region_bytes_match_float_bands() { + let bbox = Rect::new(-257, 255, 258, 258); + let mask: Vec = (0..bbox.width() * bbox.height()).map(|i| (i % 256) as u8).collect(); + let floats: Vec = mask.iter().map(|b| f32::from(*b) / 255.0).collect(); + let region = Region { bbox, mask }; + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(Rect::new(-300, 250, 300, 270), &[0.75]); + for prev in [None, Some(&old)] { + for mode in MODES { + assert_eq!(combine_region(prev, Some(®ion), mode), reference_band(prev, &floats, bbox, mode)); + assert_eq!(combine_region(prev, None, mode), reference_band(prev, &[], Rect::EMPTY, mode)); + } + } +} + +#[test] +fn tiles_stay_shared_or_are_pruned() { + let area = Rect::new(0, 0, 768, 256); + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(area, &[1.0]); + let tc = TileCoord::new(0, 0); + let unchanged = combine(Some(&old), &[], area, SelectionMode::Add).unwrap(); + assert!(Arc::ptr_eq(unchanged.tile(tc).unwrap(), old.tile(tc).unwrap())); + let band = Rect::new(257, 1, 260, 3); + let changed = combine_band(Some(&old), &[1.0; 6], band, SelectionMode::Subtract).unwrap(); + assert!(Arc::ptr_eq(changed.tile(tc).unwrap(), old.tile(tc).unwrap())); + assert_eq!(changed.pixel(256, 1), vec![1.0]); + assert_eq!(changed.pixel(257, 1), vec![0.0]); + assert_eq!(changed.pixel(260, 1), vec![1.0]); + let full = combine(None, &vec![1.0; 768 * 256], area, SelectionMode::Replace).unwrap(); + assert!(Arc::ptr_eq(full.tile(tc).unwrap(), full.tile(TileCoord::new(1, 0)).unwrap())); + assert!(combine_band(Some(&old), &vec![1.0; 768 * 256], area, SelectionMode::Subtract).is_none()); + assert!(combine(None, &[], area, SelectionMode::Replace).is_none()); +} + +/// Run only in release on CI: compares the former implementation, including dense reads and +/// band writeback, against the byte path with identical 36 MP inputs and exact output checks. +#[test] +#[ignore = "36 MP release performance comparison; run on GitHub Actions"] +fn gray8_combining_release_comparison() { + use std::{hint::black_box, time::Instant}; + let area = Rect::new(0, 0, 6000, 6000); + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(Rect::new(100, 100, 5900, 5900), &[0.75]); + println!("36 MP; median of three release runs including allocation; exact output checks; previous dense mask alone = 144000000 bytes"); + for (case, size) in [("sparse", 50), ("dense", 4000), ("full", 6000)] { + let mut mask = vec![0.0; 6000 * 6000]; + let start = (6000 - size) / 2; + for row in mask.as_chunks_mut::<6000>().0.iter_mut().skip(start).take(size) { + row[start..start + size].fill(0.5); + } + for mode in MODES { + for band in [false, true] { + let baseline = if band { reference_band } else { reference }; + let direct = if band { combine_band } else { combine }; + assert_eq!(direct(Some(&old), &mask, area, mode), baseline(Some(&old), &mask, area, mode)); + let mut medians = [0.0; 2]; + for (median, op) in medians.iter_mut().zip([baseline, direct]) { + let mut runs = [0.0; 3]; + for ms in &mut runs { + let start = Instant::now(); + let out = black_box(op(Some(black_box(&old)), black_box(&mask), area, mode)); + *ms = start.elapsed().as_secs_f64() * 1000.0; + drop(out); + } + runs.sort_by(f64::total_cmp); + *median = runs[1]; + } + println!("{case} {mode:?} band={band}: old={:.2}ms new={:.2}ms speedup={:.2}x exact=true", medians[0], medians[1], medians[0] / medians[1]); + } + } + } +} diff --git a/crates/algo/src/selection/inverse.rs b/crates/algo/src/selection/inverse.rs new file mode 100644 index 0000000000..8616ac3111 --- /dev/null +++ b/crates/algo/src/selection/inverse.rs @@ -0,0 +1,105 @@ +//! Tile-wise selection inversion, with shared solid tiles and no canvas-sized buffers. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use photocraft_color::{PixelFormat, read_sample}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; + +use crate::photo_util::par_map; + +use super::coverage8; + +/// Invert coverage within `area`, returning a zero-default GRAY8 selection with nothing +/// outside it. Missing tiles become shared solid tiles; selected solid tiles disappear. +/// Each distinct interior source tile is inverted only once, preserving sharing and snapshots. +/// Selections are GRAY8; other formats use their first channel, quantized to selection coverage. +pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { + let mut out = Surface::new(PixelFormat::GRAY8); + if area.is_empty() { + return out; + } + let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0])); + let default = coverage8(1.0 - source.default_pixel().first().copied().unwrap_or(0.0)); + let missing = match default { + 0 => None, + 255 => Some(Arc::clone(&full)), + v => Some(out.solid_tile(&[f32::from(v) / 255.0])), + }; + // Solid fills share one payload across many coordinates. Cache by payload, rather than + // scanning/copying the same 64 KiB for every tile. OnceLock allows parallel tile workers. + let cache: HashMap<_, OnceLock>>> = + source.tiles().filter(|(c, _)| area.contains_rect(&c.rect())).map(|(_, t)| (Arc::as_ptr(t) as usize, OnceLock::new())).collect(); + let format = source.format(); + let value = |t: &Tile, i: usize| { + if format == PixelFormat::GRAY8 { + t.bytes().get(i).map_or(default, |b| 255 - b) + } else { + let bpp = format.bytes_per_pixel(); + t.bytes().get(i * bpp..(i + 1) * bpp).map_or(default, |pixel| coverage8(1.0 - read_sample(pixel, format.sample, 0))) + } + }; + let coords: Vec = area.tiles().collect(); + let built = par_map(coords.len(), |k| { + let tc = *coords.get(k)?; + let rect = tc.rect(); + let cut = rect.intersect(&area); + let input = source.tile(tc); + // At i32::MAX the coordinate rectangle is saturated and omits the tile's last + // column/row, so it must still take the clipped path. + if cut == rect && cut.width() == TILE_SIZE as u32 && cut.height() == TILE_SIZE as u32 { + let Some(input) = input else { return missing.clone().map(|t| (tc, t)) }; + let cell = cache.get(&(Arc::as_ptr(input) as usize))?; + let tile = cell.get_or_init(|| { + if format == PixelFormat::GRAY8 { + if *input.bytes() == *full.bytes() { + return None; + } + if *input.bytes() == *blank.bytes() { + return Some(Arc::clone(&full)); + } + let mut tile = Tile::clone(input); + for b in tile.bytes_mut().iter_mut() { + *b = 255 - *b; + } + return Some(Arc::new(tile)); + } + let mut tile = Tile::clone(&blank); + for (i, b) in tile.bytes_mut().iter_mut().enumerate() { + *b = value(input, i); + } + canonical(tile, &blank, &full) + }); + return tile.clone().map(|t| (tc, t)); + } + // Boundary tiles start at zero so no inverted coverage leaks past the canvas. + let mut tile = Tile::clone(&blank); + let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize); + for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize { + let start = y * TILE_SIZE as usize + x0; + let mut data = tile.bytes_mut(); + if let Some(row) = data.get_mut(start..start + x1 - x0) { + for (i, b) in (start..).zip(row) { + *b = input.map_or(default, |t| value(t, i)); + } + } + } + canonical(tile, &blank, &full).map(|t| (tc, t)) + }); + out.put_tiles(built.into_iter().flatten()); + out +} + +fn canonical(tile: Tile, blank: &Arc, full: &Arc) -> Option> { + if *tile.bytes() == *blank.bytes() { + None + } else if *tile.bytes() == *full.bytes() { + Some(Arc::clone(full)) + } else { + Some(Arc::new(tile)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/selection/inverse/tests.rs b/crates/algo/src/selection/inverse/tests.rs new file mode 100644 index 0000000000..2a7d94ab07 --- /dev/null +++ b/crates/algo/src/selection/inverse/tests.rs @@ -0,0 +1,85 @@ +use super::*; +use photocraft_color::{ColorMode, SampleType}; + +fn reference(source: &Surface, area: Rect) -> Surface { + let data: Vec = source.read_region(area).chunks_exact(source.channels()).map(|p| 1.0 - p[0]).collect(); + let mut out = Surface::new(PixelFormat::GRAY8); + out.write_region(area, &data); + out.prune(); + out +} + +#[test] +fn inverse_matches_float_reference_and_clips_boundary_tiles() { + // Negative, misaligned and empty bounds, including stored pixels beyond the canvas. + for area in [Rect::new(-17, -11, 530, 270), Rect::new(0, 0, 512, 256), Rect::new(3, 5, 4, 6), Rect::EMPTY] { + for sample in SampleType::ALL { + for default in [0.0, 0.25, 1.0] { + let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); + let mut source = Surface::with_default(fmt, &[default]); + source.fill_rect(Rect::new(-100, -100, 260, 120), &[1.0]); + // Every encoded GRAY8 value, including feathered coverage at tile seams. + let values: Vec = (0..256).map(|v| v as f32 / 255.0).collect(); + source.write_region(Rect::new(240, 125, 496, 126), &values); + source.write_pixel(750, 280, &[0.7]); + let got = inverse_surface(&source, area); + let want = reference(&source, area); + let window = Rect::new(-30, -20, 800, 290); + assert_eq!(got.to_interleaved(window), want.to_interleaved(window), "{sample:?} {default} {area:?}"); + assert_eq!(got.default_bytes(), &[0]); + assert_eq!(got.tile_count(), want.tile_count(), "zero tiles must be pruned"); + } + } + } +} + +#[test] +fn inverse_preserves_sparse_storage_and_shared_payloads() { + let area = Rect::new(0, 0, 2048, 1024); + let empty = Surface::new(PixelFormat::GRAY8); + let selected = inverse_surface(&empty, area); + assert_eq!(selected.tile_count(), 32); + let full = selected.tile(TileCoord::new(0, 0)).unwrap(); + assert!(selected.tiles().all(|(_, t)| Arc::ptr_eq(t, full)), "missing tiles share one solid payload"); + let cleared = inverse_surface(&selected, area); + assert_eq!(cleared.tile_count(), 0, "fully selected tiles disappear"); + + let mut mixed = Surface::new(PixelFormat::GRAY8); + mixed.write_pixel(10, 11, &[0.25]); + let payload = mixed.tile(TileCoord::new(0, 0)).unwrap().clone(); + mixed.put_tiles(area.tiles().map(|c| (c, payload.clone()))); + let snapshot = mixed.clone(); + let inverted = inverse_surface(&mixed, area); + let result = inverted.tile(TileCoord::new(0, 0)).unwrap(); + assert!(inverted.tiles().all(|(_, t)| Arc::ptr_eq(t, result)), "shared mixed tiles are inverted once"); + assert!(!Arc::ptr_eq(result, &payload)); + assert_eq!(mixed.to_interleaved(area), snapshot.to_interleaved(area)); + assert_eq!(inverse_surface(&inverted, area).to_interleaved(area), mixed.to_interleaved(area)); +} + +#[test] +fn inverse_of_unpruned_zero_tiles_is_shared_full() { + let mut source = Surface::new(PixelFormat::GRAY8); + let area = Rect::new(0, 0, 768, 256); + source.fill_rect(Rect::new(0, 0, 256, 256), &[0.0]); + source.fill_rect(Rect::new(256, 0, 512, 256), &[1.0]); + let out = inverse_surface(&source, area); + assert_eq!(out.tile_count(), 2); + assert!(out.tile(TileCoord::new(1, 0)).is_none()); + assert!(Arc::ptr_eq(out.tile(TileCoord::new(0, 0)).unwrap(), out.tile(TileCoord::new(2, 0)).unwrap())); +} + +#[test] +fn inverse_clips_at_coordinate_limits_and_uses_first_channel() { + for area in [Rect::new(i32::MAX - 255, 0, i32::MAX, 256), Rect::new(0, i32::MAX - 255, 256, i32::MAX), Rect::new(i32::MIN, -1, i32::MIN + 3, 2)] { + let source = Surface::new(PixelFormat::GRAY8); + let out = inverse_surface(&source, area); + assert_eq!(out.to_interleaved(area), reference(&source, area).to_interleaved(area)); + assert_eq!(out.sample_channel(area.x1, area.y0, 0), 0.0); + assert_eq!(out.sample_channel(area.x0, area.y1, 0), 0.0); + } + let area = Rect::new(0, 0, 513, 260); + let mut source = Surface::with_default(PixelFormat::RGBA32F, &[0.25, 0.5, 0.75, 1.0]); + source.fill_rect(Rect::new(10, 10, 400, 200), &[0.75, 1.0, 0.0, 0.5]); + assert_eq!(inverse_surface(&source, area).to_interleaved(area), reference(&source, area).to_interleaved(area)); +} diff --git a/crates/algo/src/selection/lasso.rs b/crates/algo/src/selection/lasso.rs index 2471a379b5..a13e4de1df 100644 --- a/crates/algo/src/selection/lasso.rs +++ b/crates/algo/src/selection/lasso.rs @@ -202,7 +202,8 @@ pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option { } for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::().0) { let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize; - let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue }; + let mut data = tile.bytes_mut(); + let Some(dst) = data.get_mut(base..base + r.width() as usize) else { continue }; for (d, v) in dst.iter_mut().zip(row) { *d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index c4830ae134..7afd788fdc 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -217,19 +217,25 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3 } } let mut tile = Tile::clone(&blank); - let data = tile.bytes_mut(); + let mut data = tile.bytes_mut(); + let same_tile = ta.zip(tb).is_some_and(|(left, right)| Arc::ptr_eq(left, right)); + let read_a = ta.map(|tile| tile.bytes()); + let read_b = tb.filter(|_| !same_tile).map(|tile| tile.bytes()); + let bytes_a = read_a.as_deref(); + let bytes_b = if same_tile { bytes_a } else { read_b.as_deref() }; let (mut lo, mut hi) = (if tr == rect { u8::MAX } else { 0 }, 0u8); let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize); for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize { let (s, e) = (ly * TS + x0, ly * TS + x1); - let src_a = ta.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_a[..x1 - x0]); - let src_b = tb.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_b[..x1 - x0]); + let src_a = bytes_a.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_a[..x1 - x0]); + let src_b = bytes_b.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_b[..x1 - x0]); let Some(dst) = data.get_mut(s..e) else { continue }; for (d, (x, y)) in dst.iter_mut().zip(src_a.iter().zip(src_b)) { *d = pair(*x, *y); (lo, hi) = (lo.min(*d), hi.max(*d)); } } + drop(data); (hi > 0).then(|| (tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) })) }); out.put_tiles(built.into_iter().flatten()); @@ -258,15 +264,17 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) let tr = rect.intersect(&area); let tb = b.tile(tc)?; let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched)); - let data = tile.bytes_mut(); + let mut data = tile.bytes_mut(); + let source = tb.bytes(); let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize); for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize { let (s, e) = (ly * TS + x0, ly * TS + x1); - let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue }; + let (Some(dst), Some(src)) = (data.get_mut(s..e), source.get(s..e)) else { continue }; for (d, y) in dst.iter_mut().zip(src) { *d = pair(*d, *y); } } + drop((data, source)); let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); Some(( tc, diff --git a/crates/algo/src/separable.rs b/crates/algo/src/separable.rs new file mode 100644 index 0000000000..9ab6220135 --- /dev/null +++ b/crates/algo/src/separable.rs @@ -0,0 +1,191 @@ +//! Separable blurs described as data, so another device (the GPU compositor's layer operators, +//! #5772) runs exactly the blur the CPU runs: the kernel weights or box widths come from here, +//! and [`crate::blur`] builds its own passes from the same description, so the two can't drift. +//! +//! [`OperatorRegion`] is the region rule that goes with it: which source pixels an output area +//! reads, with the edge pixels repeated at the extent as the CPU filters do. + +use photocraft_geom::Rect; + +use crate::{FilterParams, blur}; + +/// A blur made of passes along each axis, horizontal first, as the CPU runs it. +#[derive(Clone, Debug, PartialEq)] +pub enum Separable { + /// One pass of this normalised kernel (odd length, centred) along each axis. + Kernel(Vec), + /// Running-mean box passes of these odd widths, in turn, along each axis. + Boxes(Vec), +} + +impl Separable { + /// The blur `params` runs on the CPU, or `None` for a filter that isn't one (only Gaussian + /// Blur so far). Small radii use the exact kernel, larger ones three box passes. + pub fn of(params: &FilterParams) -> Option { + match *params { + FilterParams::GaussianBlur { radius } => Some(match blur::box_widths(params) { + Some(boxes) => Separable::Boxes(boxes), + None => Separable::Kernel(blur::gaussian_blur_kernel(radius)), + }), + _ => None, + } + } + + /// How far outside an output pixel the blur reads, along each axis. + pub fn reach(&self) -> i32 { + let reach = match self { + Separable::Kernel(k) => k.len() / 2, + Separable::Boxes(boxes) => boxes.iter().fold(0usize, |sum, w| sum.saturating_add(w / 2)), + }; + i32::try_from(reach).unwrap_or(i32::MAX) + } +} + +/// The area a neighbourhood filter on a layer repeats its edge pixels at: the canvas, or past it +/// where the layer has pixels off the canvas, as Photoshop does. +pub fn edge_extent(canvas: Rect, content: Rect) -> Rect { + canvas.union(&content) +} + +/// Which source pixels an output area of a blur reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperatorRegion { + /// How far the blur reads outside an output pixel ([`Separable::reach`]). + pub reach: i32, + /// Where the source's edge pixels repeat ([`edge_extent`]): nothing outside it is read. + pub extent: Rect, +} + +impl OperatorRegion { + /// The source pixels output `out` reads: `out` grown by the reach, inside the extent. Pixels + /// it needs beyond the extent repeat the edge of this area, so they need no source of their + /// own. Empty when `out` is empty or doesn't come within reach of the extent. + pub fn input(&self, out: Rect) -> Rect { + if out.is_empty() { + return Rect::EMPTY; + } + out.inflate(self.reach.max(0)).intersect(&self.extent) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Halo; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_raster::{Interrupt, Surface}; + + const RADII: [f32; 11] = [0.01, 0.05, 0.1, 0.3, 0.8, 2.0, 4.0, 4.5, 12.0, 40.0, 250.0]; + + /// The CPU blur's own choice: the exact kernel up to radius 4, boxes beyond; and its halo + /// (how far the CPU grows an output area) covers the reach. + #[test] + fn the_description_is_the_cpus_blur() { + for radius in RADII { + let params = FilterParams::GaussianBlur { radius }; + let blur = Separable::of(¶ms).unwrap(); + match &blur { + Separable::Kernel(k) => { + assert!(radius <= 4.0, "radius {radius}"); + assert_eq!(k, &blur::gaussian_blur_kernel(radius)); + assert_eq!(k.len() % 2, 1); + assert!((k.iter().sum::() - 1.0).abs() < 1e-5); + } + Separable::Boxes(b) => { + assert!(radius > 4.0, "radius {radius}"); + assert_eq!(Some(b.clone()), blur::box_widths(¶ms)); + assert!(b.iter().all(|w| w % 2 == 1)); + } + } + let Halo::Radius(halo) = params.halo() else { panic!("a Gaussian has a radius halo") }; + assert!(blur.reach() <= halo, "radius {radius}: reach {} past the halo {halo}", blur.reach()); + } + assert_eq!(Separable::of(&FilterParams::BoxBlur { radius: 9.0 }), None, "not a Gaussian (yet)"); + assert_eq!(Separable::Boxes(vec![usize::MAX, 3]).reach(), i32::MAX, "saturates"); + } + + /// One opaque pixel in a transparent layer, blurred on the CPU, spreads no further than the + /// reach (beyond it the box passes' running sums leave only float residue, far below a + /// 16-bit level), and the exact kernel spreads exactly that far. + #[test] + fn an_impulse_spreads_no_further_than_the_reach() { + for radius in RADII { + let params = FilterParams::GaussianBlur { radius }; + let reach = Separable::of(¶ms).unwrap().reach(); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let size = 2 * reach + 41; + let canvas = Rect::new(0, 0, size, size); + let mut s = Surface::new(fmt); + let dot = Rect::new(size / 2, size / 2, size / 2 + 1, size / 2 + 1); + s.fill_rect(dot, &[1.0, 1.0, 1.0, 1.0]); + let out = crate::apply_in_with(&s, ¶ms, canvas, canvas, None, canvas, &Interrupt::NONE).unwrap(); + let mut px = Vec::new(); + out.read_region_into(canvas, &mut px); + let touched = px + .as_chunks::<4>() + .0 + .iter() + .enumerate() + // The box passes' running sums leave float residue (~1e-8) after the impulse. + .filter(|(_, p)| p[3] > 1e-6) + .fold(Rect::EMPTY, |r, (i, _)| { + let (x, y) = ((i as i32) % size, (i as i32) / size); + r.union(&Rect::new(x, y, x + 1, y + 1)) + }); + // Nothing past the reach; the exact kernel reaches all of it. (A single pixel's box + // tails are smaller than the running sums' residue, so they aren't checked for that.) + assert!(dot.inflate(reach).contains_rect(&touched), "radius {radius}: {touched:?} past the reach {reach}"); + if let Some(Separable::Kernel(k)) = Separable::of(¶ms) { + // Calibrated subpixel kernels have tails below the visibility threshold. + let center = k.len() / 2; + let visible = k.iter().enumerate().filter(|(_, w)| **w * k[center] > 1e-6).map(|(i, _)| i.abs_diff(center)).max().unwrap_or(0); + assert_eq!(touched, dot.inflate(visible as i32), "radius {radius}"); + } + } + } + + /// Pixels outside [`OperatorRegion::input`] don't change an output area's result, and the + /// edge repeats at the extent: a layer's area next to the extent's edge blurs the same + /// whatever lies beyond it. + #[test] + fn output_depends_only_on_the_input_region() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let extent = Rect::new(0, 0, 300, 200); + let noise = |seed: u32| { + let mut s = Surface::new(fmt); + for y in 0..200 { + for x in 0..300 { + let h = (x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663) ^ seed; + let v = (h % 1000) as f32 / 999.0; + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, 1.0 - v, (h % 7) as f32 / 6.0, (h % 5) as f32 / 4.0]); + } + } + s + }; + let a = noise(1); + for radius in [0.8, 4.0, 12.0] { + let params = FilterParams::GaussianBlur { radius }; + let region = OperatorRegion { reach: Separable::of(¶ms).unwrap().reach(), extent }; + for out in [Rect::new(100, 80, 140, 120), Rect::new(0, 0, 30, 25), Rect::new(270, 170, 300, 200)] { + let input = region.input(out); + assert!(extent.contains_rect(&input) && input.contains_rect(&out)); + // Everything outside the input region replaced by other pixels. + let mut b = noise(2); + let mut inside = Vec::new(); + a.read_region_into(input, &mut inside); + b.write_region(input, &inside); + let blur = |s: &Surface| { + let r = crate::apply_in_with(s, ¶ms, out, extent, None, extent, &Interrupt::NONE).unwrap(); + let mut px = Vec::new(); + r.read_region_into(out, &mut px); + px + }; + assert_eq!(blur(&a), blur(&b), "radius {radius}, {out:?}"); + } + } + assert_eq!(OperatorRegion { reach: 5, extent }.input(Rect::EMPTY), Rect::EMPTY); + assert_eq!(OperatorRegion { reach: 5, extent }.input(Rect::new(400, 400, 410, 410)), Rect::EMPTY, "out of reach of the extent"); + assert_eq!(OperatorRegion { reach: -3, extent }.input(Rect::new(10, 10, 20, 20)), Rect::new(10, 10, 20, 20)); + assert_eq!(edge_extent(extent, Rect::new(-10, 50, 20, 60)), Rect::new(-10, 0, 300, 200)); + } +} diff --git a/crates/algo/src/sharpen/tests.rs b/crates/algo/src/sharpen/tests.rs index 4699bb3a10..aef62259a3 100644 --- a/crates/algo/src/sharpen/tests.rs +++ b/crates/algo/src/sharpen/tests.rs @@ -267,7 +267,12 @@ fn malformed_inputs_and_allocations_stop_gracefully() { assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none()); assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s); } - for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] { + let with = |edit: &dyn Fn(&mut Image)| { + let mut bad = src.clone(); + edit(&mut bad); + bad + }; + for bad in [with(&|i| i.ch = 0), with(&|i| i.data = vec![0.0]), with(&|i| i.rect = Rect::new(5, 0, 0, 2))] { assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none()); } for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] { diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 3d0c9a87b3..52e67a3b71 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -37,8 +37,54 @@ impl EntropyScratch { } } +/// One shared table per combine, never per pixel or worker. Rows cover the contributing sample +/// count, which can change at every pixel because of transparency or unequal layer lengths. +/// The triangular layout stores log2(n) followed by -p*log2(p) for counts 1..=n. Keeping the +/// division after the sum (rather than normalising each term) preserves the old output bits. +struct EntropyTerms { + values: Vec, +} + +impl EntropyTerms { + // At most 130 KiB, independent of image size and total layer count. Deeper stacks retain + // the original calculation; pixels with fewer contributors can still use a cached row. + const MAX_SAMPLES: usize = 256; + + fn new(samples: usize, pixels: usize) -> Self { + let mut values = Vec::new(); + // Tiny images do not amortise preparation, especially with hundreds of layers. + if samples < 2 || pixels < 256 { + return Self { values }; + } + let max = samples.min(Self::MAX_SAMPLES); + // max is capped at 256 before any size arithmetic or allocation. + let len = (max + 1) * (max + 2) / 2; + if values.try_reserve_exact(len).is_err() { + return Self { values }; + } + values.extend_from_slice(&[0.0; 3]); // Unused rows for zero and one sample. + for samples in 2..=max { + let n = samples as f32; + values.push(n.log2()); + for count in 1..=samples { + let p = count as f32 / n; + values.push(-p * p.log2()); + } + } + Self { values } + } + + fn row(&self, samples: usize) -> Option<&[f32]> { + if !(2..=Self::MAX_SAMPLES).contains(&samples) { + return None; + } + let start = samples * (samples + 1) / 2; + self.values.get(start..start + samples + 1) + } +} + /// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`. -fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { +fn entropy(v: &[f32], scratch: &mut EntropyScratch, terms: Option<&[f32]>) -> f32 { let n = v.len() as f32; for x in v { let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8; @@ -57,8 +103,11 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { .touched .iter() .map(|&bin| { - let p = scratch.counts[usize::from(bin)] as f32 / n; - -p * p.log2() + let count = scratch.counts[usize::from(bin)]; + terms.and_then(|row| row.get(count as usize)).copied().unwrap_or_else(|| { + let p = count as f32 / n; + -p * p.log2() + }) }) .sum(); // Reset only the bins used, so the 256-entry array is never cleared wholesale. @@ -66,7 +115,7 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { scratch.counts[usize::from(bin)] = 0; } scratch.touched.clear(); - h / n.log2() + h / terms.and_then(|row| row.first()).copied().unwrap_or_else(|| n.log2()) } /// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the @@ -99,17 +148,23 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max), Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), Stat::Median => { - // `total_cmp` is a total order whose ties are bit-identical values, so an unstable - // sort gives exactly the stable sort's result. - v.sort_unstable_by(f32::total_cmp); let k = v.len(); - return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }; + // Nonempty above, so k / 2 is in bounds. Selection puts the upper middle value in + // place in O(k); the lower partition need not be sorted. Use the same total order + // for its maximum to preserve signed zeros, infinities and NaNs. + let (lower, middle, _) = v.select_nth_unstable_by(k / 2, f32::total_cmp); + return if k % 2 == 1 { + *middle + } else { + // For even k >= 2 the lower partition contains the other middle value. + lower.iter().max_by(|a, b| a.total_cmp(b)).map_or(*middle, |lo| (*lo + *middle) / 2.0) + }; } Stat::Entropy => { if v.len() < 2 { return 0.0; } - return entropy(v, scratch); + return entropy(v, scratch, None); } _ => {} } @@ -147,7 +202,11 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { // serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's // instructions by a fifth. match s { - Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)), + Stat::Entropy => { + let pixels = layers.iter().map(Vec::len).max().unwrap_or(0); + let terms = EntropyTerms::new(layers.len(), pixels); + combine_with(layers, |v, sc| if v.len() < 2 { 0.0 } else { entropy(v, sc, terms.row(v.len())) }) + } Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)), Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)), Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)), diff --git a/crates/algo/src/stack/tests.rs b/crates/algo/src/stack/tests.rs index 9e14a6be44..d9f041124d 100644 --- a/crates/algo/src/stack/tests.rs +++ b/crates/algo/src/stack/tests.rs @@ -60,6 +60,86 @@ fn median_removes_outlier() { assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5); } +fn sorted_median(v: &mut [f32]) -> f32 { + v.sort_unstable_by(f32::total_cmp); + let k = v.len(); + if k == 0 { + 0.0 + } else if k % 2 == 1 { + v[k / 2] + } else { + (v[k / 2 - 1] + v[k / 2]) / 2.0 + } +} + +fn assert_median_matches_sort(mut values: Vec) { + let expected = sorted_median(&mut values.clone()); + let actual = stat(&mut values, Stat::Median, &mut EntropyScratch::new()); + if expected.is_nan() { + // IEEE arithmetic does not specify which NaN payload an even median propagates. + assert!(actual.is_nan()); + } else { + assert_eq!(actual.to_bits(), expected.to_bits(), "{values:?}"); + } +} + +#[test] +fn median_selection_matches_sort_for_varied_stack_sizes_and_orders() { + for len in 0..=257 { + let ascending: Vec = (0..len).map(|i| i as f32 / 16.0 - 8.0).collect(); + assert_median_matches_sort(ascending.clone()); + assert_median_matches_sort(ascending.into_iter().rev().collect()); + assert_median_matches_sort(vec![0.25; len]); + assert_median_matches_sort((0..len).map(|i| ((i * 31) % 7) as f32 - 3.0).collect()); + // Deterministic shuffled samples, including float bit patterns outside 0..=1. + let mut seed = 0x9e37_79b9u32; + assert_median_matches_sort( + (0..len) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from_bits(seed) + }) + .collect(), + ); + } +} + +#[test] +fn median_selection_preserves_total_float_order() { + let special = [f32::from_bits(0xffc0_0001), f32::NEG_INFINITY, -f32::MAX, -1.0, -0.0, 0.0, 1.0, f32::MAX, f32::INFINITY, f32::from_bits(0x7fc0_0001)]; + for &a in &special { + // An odd median returns the selected NaN's bits without doing arithmetic. + assert_eq!(stat(&mut [a], Stat::Median, &mut EntropyScratch::new()).to_bits(), a.to_bits()); + for &b in &special { + assert_median_matches_sort(vec![a, b]); + for &c in &special { + let mut values = [a, b, c]; + let expected = sorted_median(&mut values.clone()); + assert_eq!(stat(&mut values, Stat::Median, &mut EntropyScratch::new()).to_bits(), expected.to_bits()); + } + } + } +} + +#[test] +fn deep_median_stack_uses_only_visible_samples_per_channel() { + for count in [32, 33, 128, 129] { + let layers: Vec> = (0..count) + .map(|i| { + let pixel = [((i * 31) % 137) as f32, -(i as f32), (i % 5) as f32, if i % 3 == 0 { 0.0 } else { 0.5 }]; + if i % 7 == 0 { vec![pixel] } else { vec![pixel, [0.0; 4]] } + }) + .collect(); + let actual = combine(&layers, Stat::Median); + for channel in 0..3 { + let mut samples: Vec = layers.iter().filter(|l| l[0][3] > 0.0).map(|l| l[0][channel]).collect(); + assert_eq!(actual[0][channel].to_bits(), sorted_median(&mut samples).to_bits()); + } + assert_eq!(actual[0][3], 0.5); + assert_eq!(actual[1], [0.0; 4]); + } +} + /// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent, /// fully transparent, HDR / negative, single-sample and tied-value pixels. fn fixture() -> Vec> { @@ -243,6 +323,102 @@ fn entropy_is_deterministic() { } } +/// The pre-lookup calculation, summing the 8-bit bins in ascending order. This deliberately +/// does not call `entropy` or use its lookup/scratch, so it catches changes in rounding too. +fn entropy_reference(v: &[f32]) -> f32 { + if v.len() < 2 { + return 0.0; + } + let mut counts = [0u32; 256]; + for x in v { + counts[(x.clamp(0.0, 1.0) * 255.0).round() as usize] += 1; + } + let n = v.len() as f32; + let h: f32 = counts + .iter() + .filter(|&&count| count > 0) + .map(|&count| { + let p = count as f32 / n; + -p * p.log2() + }) + .sum(); + h / n.log2() +} + +#[test] +fn entropy_terms_preserve_each_operation_and_bound_storage() { + let terms = EntropyTerms::new(usize::MAX, usize::MAX); + let max = EntropyTerms::MAX_SAMPLES; + assert_eq!(terms.values.len(), (max + 1) * (max + 2) / 2); + assert!(terms.values.len() * size_of::() < 130 * 1024); + for samples in 2..=max { + let row = terms.row(samples).unwrap(); + let n = samples as f32; + assert_eq!(row[0].to_bits(), n.log2().to_bits()); + for (count, &term) in row.iter().enumerate().skip(1) { + let p = count as f32 / n; + assert_eq!(term.to_bits(), (-p * p.log2()).to_bits(), "{count}/{samples}"); + } + } + for samples in [0, 1, max + 1, usize::MAX] { + assert!(terms.row(samples).is_none()); + } + assert!(EntropyTerms::new(1, usize::MAX).values.is_empty()); + assert!(EntropyTerms::new(usize::MAX, 255).values.is_empty()); + assert!(EntropyTerms { values: Vec::new() }.row(2).is_none()); +} + +#[test] +fn entropy_lookup_and_fallback_match_reference_bits() { + let terms = EntropyTerms::new(EntropyTerms::MAX_SAMPLES, CHUNK); + let mut scratch = EntropyScratch::new(); + let special = [f32::NAN, f32::NEG_INFINITY, f32::INFINITY, -3.0, -0.0, 0.0, 0.5 / 255.0, 0.5, 1.0, 4.0]; + // Move repeatedly between counts and distributions to exercise scratch reset, duplicate + // bins, every cached denominator and the uncached side of the table boundary. + for samples in (2..=EntropyTerms::MAX_SAMPLES + 2).chain([513, 3, 256, 2]) { + for pattern in 0..4 { + let v: Vec = (0..samples) + .map(|i| match pattern { + 0 => 0.5, + 1 => (i % 256) as f32 / 255.0, + 2 => ((i * 137 + samples * 29) % 65536) as f32 / 65535.0, + _ => special[i % special.len()], + }) + .collect(); + let expected = entropy_reference(&v).to_bits(); + assert_eq!(entropy(&v, &mut scratch, terms.row(samples)).to_bits(), expected, "cached: n={samples}, pattern={pattern}"); + assert_eq!(entropy(&v, &mut scratch, None).to_bits(), expected, "fallback: n={samples}, pattern={pattern}"); + } + } +} + +#[test] +fn entropy_combine_matches_reference_with_changing_contributor_counts() { + for (count, len) in [(19, 2 * CHUNK + 17), (EntropyTerms::MAX_SAMPLES + 1, 513)] { + let layers: Vec> = (0..count) + .map(|k| { + (0..len.saturating_sub(k % 13)) + .map(|i| { + [ + ((i * 31 + k * 7) % 256) as f32 / 255.0, + ((i * 17 + k) % 65536) as f32 / 65535.0, + if (i + k).is_multiple_of(23) { f32::NAN } else { (i % 7) as f32 - 2.0 }, + if k > i % (count + 1) { 0.0 } else { 0.5 }, + ] + }) + .collect() + }) + .collect(); + let expected = bits(&combine_with(&layers, |v, _| entropy_reference(v))); + assert_eq!(bits(&combine(&layers, Stat::Entropy)), expected); + #[cfg(not(target_arch = "wasm32"))] + for threads in [1, 3] { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + assert_eq!(pool.install(|| bits(&combine(&layers, Stat::Entropy))), expected, "{threads} threads"); + } + } +} + /// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal /// length) gives, pixel by pixel, what combining that pixel on its own gives. #[test] diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs index c7139153af..4751511e62 100644 --- a/crates/algo/src/stylize2/tests.rs +++ b/crates/algo/src/stylize2/tests.rs @@ -26,8 +26,9 @@ fn bits(v: Vec) -> Vec { const MODELS: [ColorMode; 8] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel]; -#[test] -fn swept_blocks_match_the_pixel_search() { +// Keep the same daily/full-sweep cases, but expose four independent tests to nextest so one +// large reference comparison cannot hold a runner after its other tests have finished. +fn swept_blocks_match_the_pixel_search_part(part: u64) { let bounds = Rect::new(-13, -9, 31, 26); // Unless the heights are level-based, the colour mode reaches extrude only through the channel // count and whether the samples are CMYK ink, so modes that agree on both repeat those cases. @@ -35,6 +36,7 @@ fn swept_blocks_match_the_pixel_search() { let mut seen = std::collections::HashSet::new(); let sweep = photocraft_testkit::Sweep::new(4); let mut case = 0; + let mut selected = 0; for mode in MODELS { for alpha in [false, true] { let n = PixelFormat::new(mode, SampleType::F32, alpha).channels(); @@ -48,6 +50,11 @@ fn swept_blocks_match_the_pixel_search() { if !sweep.includes(case) { continue; } + let assigned = selected % 4; + selected += 1; + if assigned != part { + continue; + } let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, @@ -70,6 +77,26 @@ fn swept_blocks_match_the_pixel_search() { } } +#[test] +fn swept_blocks_match_the_pixel_search_part_0() { + swept_blocks_match_the_pixel_search_part(0); +} + +#[test] +fn swept_blocks_match_the_pixel_search_part_1() { + swept_blocks_match_the_pixel_search_part(1); +} + +#[test] +fn swept_blocks_match_the_pixel_search_part_2() { + swept_blocks_match_the_pixel_search_part(2); +} + +#[test] +fn swept_blocks_match_the_pixel_search_part_3() { + swept_blocks_match_the_pixel_search_part(3); +} + #[test] fn streak_scatter_matches_the_pixel_search() { let bounds = Rect::new(-17, -9, 39, 14); diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 57b7428330..8a2e6a272d 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -4,6 +4,18 @@ mod ext; mod mosaic; mod selection_mix; +#[test] +fn relight_admission_tracks_the_document_dependent_working_set() { + let params = FilterParams::Relight { angle: 0.0, elevation: 45.0, intensity: 50.0, ambient: 50.0, warmth: 0.0, softness: 100.0 }; + let small = Rect::new(0, 0, 128, 128); + let large = Rect::new(0, 0, 86400, 43200); + assert!(working_bytes(¶ms, small, small, 4) < working_bytes(¶ms, small, large, 4)); + assert_eq!(working_bytes(¶ms, Rect::EMPTY, large, 4), 0); + // A tiny image must not reserve the 256 MiB group budget three times over. + let tiny = Rect::new(0, 0, 24, 18); + assert!(working_bytes(¶ms, tiny, tiny, 4) < 64 << 20, "{}", working_bytes(¶ms, tiny, tiny, 4)); +} + fn fmt(s: SampleType) -> PixelFormat { PixelFormat::new(ColorMode::Rgb, s, true) } diff --git a/crates/algo/src/tests/ext.rs b/crates/algo/src/tests/ext.rs index 7c28485f1f..88b5138ded 100644 --- a/crates/algo/src/tests/ext.rs +++ b/crates/algo/src/tests/ext.rs @@ -435,6 +435,82 @@ fn lens_flare_is_brightest_at_its_centre() { assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens"); } +/// A Lens Flare on black over `r` (its reference bounds), as straight RGB. +fn flare_on_black(r: Rect, lens: LensType) -> Vec { + let s = flat(SampleType::F32, r, BLACK); + let p = FilterParams::LensFlare { brightness: 100.0, center_x: 0.3, center_y: 0.3, lens }; + apply(&s, &p, output_area(&p, s.content_bounds(), r, None), r, None).read_region(r) +} + +#[test] +fn lens_flare_scales_with_the_image() { + // Sizes are fractions of the image diagonal: the same flare on a twice-as-large image, halved, + // is the small one (up to the one-pixel rays, which stay one pixel wide). + for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] { + let (small, big) = (Rect::new(0, 0, 200, 150), Rect::new(0, 0, 400, 300)); + let a = flare_on_black(small, lens); + let b = flare_on_black(big, lens); + let mut diff = 0.0f64; + for y in 0..150usize { + for x in 0..200usize { + for k in 0..3 { + let at = |xx: usize, yy: usize| f64::from(b[(yy * 400 + xx) * 4 + k]); + let halved = (at(2 * x, 2 * y) + at(2 * x + 1, 2 * y) + at(2 * x, 2 * y + 1) + at(2 * x + 1, 2 * y + 1)) / 4.0; + diff += (halved - f64::from(a[(y * 200 + x) * 4 + k])).abs(); + } + } + } + let mean = diff / (200.0 * 150.0 * 3.0); + assert!(mean < 0.01, "{lens:?}: mean difference {mean}"); + } +} + +#[test] +fn every_lens_flare_lens_looks_different() { + let r = Rect::new(0, 0, 160, 120); + let lenses = [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime]; + let flares: Vec> = lenses.iter().map(|l| flare_on_black(r, *l)).collect(); + for i in 0..flares.len() { + for j in i + 1..flares.len() { + let d: f32 = flares[i].iter().zip(&flares[j]).map(|(a, b)| (a - b).abs()).sum::() / flares[i].len() as f32; + assert!(d > 0.01, "{:?} and {:?} are nearly the same ({d})", lenses[i], lenses[j]); + } + } +} + +#[test] +fn lens_flare_with_an_overflowing_centre_leaves_finite_pixels() { + // A centre of 1e300 from a command becomes infinite in f32; NaN must not reach the pixels. + let src = flat(SampleType::F32, R, [0.2, 0.3, 0.4, 1.0]); + for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] { + for (cx, cy) in [(f32::INFINITY, f32::NEG_INFINITY), (1e38, -1e38), (f32::NAN, 0.5), (5.0, -5.0)] { + let out = run(&src, &FilterParams::LensFlare { brightness: 100.0, center_x: cx, center_y: cy, lens }); + let px = out.read_region(R); + assert!(px.iter().all(|v| v.is_finite()), "{lens:?} at ({cx}, {cy})"); + assert!(px.iter().zip(src.read_region(R)).all(|(a, b)| *a >= b - 1e-6), "{lens:?}: screen never darkens"); + } + } +} + +#[test] +fn lens_flare_between_two_measured_brightnesses_blends_their_light() { + // 75 % and 100 % are measured levels; 87.5 % is halfway, so its light is their mean. On black + // 32-bit the result is the light itself; at 0 and 1 the clamp makes the blend nonlinear. + let black = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]); + for lens in [LensType::Zoom, LensType::Prime35, LensType::Prime105, LensType::MoviePrime] { + let at = |brightness: f32| run(&black, &FilterParams::LensFlare { brightness, center_x: 0.25, center_y: 0.5, lens }).read_region(R); + let (lo, mid, hi) = (at(75.0), at(87.5), at(100.0)); + let mut checked = 0; + for ((l, m), h) in lo.iter().zip(&mid).zip(&hi) { + if [*l, *m, *h].iter().all(|v| *v > 0.0 && *v < 1.0) { + assert!((m - 0.5 * (l + h)).abs() < 1e-4, "{lens:?}: {m} is not the mean of {l} and {h}"); + checked += 1; + } + } + assert!(checked >= 100, "{lens:?}: too few unclipped values ({checked})"); + } +} + #[test] fn lens_flare_never_darkens_32_bit_values_above_one() { // #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core). diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 1ed0ec7c04..b1f7bacaae 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -521,6 +521,7 @@ fn warp( outp[o + a] = al as f32; } } + photocraft_raster::scratch::give(px); Ok(any.then_some((*t, outp))) }; // Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32` diff --git a/crates/algo/src/wideangle.rs b/crates/algo/src/wideangle.rs index ba3fafcc71..19da93fc24 100644 --- a/crates/algo/src/wideangle.rs +++ b/crates/algo/src/wideangle.rs @@ -22,7 +22,7 @@ use photocraft_geom::Rect; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; -use crate::photo_util::SparseLs; +use crate::photo_util::{BandChol, Csr, par_rows, pcg, solve_dense}; use crate::transform::Interp; /// Lens projection of the source image. @@ -234,95 +234,293 @@ impl WideMesh { } } -/// Solves the content-preserving warp for `p` over `frame`. -pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { - let cam = Camera::new(p, frame); - let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64); - let cells = 48.0; - let cell = fw.max(fh) / cells; - let nx = ((fw / cell).round() as usize).clamp(2, 96); - let ny = ((fh / cell).round() as usize).clamp(2, 96); - let (cw, ch) = (fw / nx as f64, fh / ny as f64); - let w1 = nx + 1; - let nv = w1 * (ny + 1); - let src: Vec<[f64; 2]> = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect(); - let curves: Vec> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect(); - let mut x: Vec = src.iter().flat_map(|s| [s[0], s[1]]).collect(); - // Bilinear weights of a source point in the grid. - let weights = |q: [f64; 2]| -> [(usize, f64); 4] { - let gx = ((q[0] - frame.x0 as f64) / cw).clamp(0.0, nx as f64 - 1e-9); - let gy = ((q[1] - frame.y0 as f64) / ch).clamp(0.0, ny as f64 - 1e-9); +/// Weight of the line rows (applied squared in the normal equations). +const LINE_WEIGHT: f64 = 10.0; +/// Weight of the weak anchoring to the source (it fixes the global similarity). +const ANCHOR_WEIGHT: f64 = 0.02; +/// Grid cells along the longer side of the frame. +const GRID_CELLS: f64 = 48.0; + +/// A sparse row `Σ coef·x[idx]`. +type Row = Vec<(usize, f64)>; +/// A solver for `(K + LᵀL) x = rhs` given the grid, the line rows `L` and a warm start. +type LinSolve<'a> = dyn FnMut(&Grid, &[Row], &mut Vec) + 'a; + +/// The warp's grid over a frame; the unknowns are the output positions of its vertices, +/// interleaved `[x0, y0, x1, y1, …]`. +struct Grid { + frame: Rect, + nx: usize, + ny: usize, + cw: f64, + ch: f64, + w1: usize, + nv: usize, + /// Source position of each vertex. + src: Vec<[f64; 2]>, +} + +impl Grid { + fn new(frame: Rect, cells: f64) -> Grid { + let (fw, fh) = (frame.width().max(1) as f64, frame.height().max(1) as f64); + let cell = fw.max(fh) / cells; + let nx = ((fw / cell).round() as usize).clamp(2, 96); + let ny = ((fh / cell).round() as usize).clamp(2, 96); + let (cw, ch) = (fw / nx as f64, fh / ny as f64); + let w1 = nx + 1; + let nv = w1 * (ny + 1); + let src = (0..nv).map(|k| [frame.x0 as f64 + (k % w1) as f64 * cw, frame.y0 as f64 + (k / w1) as f64 * ch]).collect(); + Grid { frame, nx, ny, cw, ch, w1, nv, src } + } + + /// Bilinear weights of a source point in the grid. + fn weights(&self, q: [f64; 2]) -> [(usize, f64); 4] { + let gx = ((q[0] - self.frame.x0 as f64) / self.cw).clamp(0.0, self.nx as f64 - 1e-9); + let gy = ((q[1] - self.frame.y0 as f64) / self.ch).clamp(0.0, self.ny as f64 - 1e-9); let (i, j) = (gx.floor() as usize, gy.floor() as usize); let (tx, ty) = (gx - i as f64, gy - j as f64); - let a = j * w1 + i; - [(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + w1, (1.0 - tx) * ty), (a + w1 + 1, tx * ty)] - }; - let tris = { - let mut t = Vec::new(); - for j in 0..ny { - for i in 0..nx { - let a = j * w1 + i; - t.push([a, a + 1, a + w1 + 1]); - t.push([a, a + w1 + 1, a + w1]); - // Both diagonals so the shape term is symmetric. - t.push([a, a + 1, a + w1]); - t.push([a + 1, a + w1 + 1, a + w1]); + let a = j * self.w1 + i; + [(a, (1.0 - tx) * (1.0 - ty)), (a + 1, tx * (1.0 - ty)), (a + self.w1, (1.0 - tx) * ty), (a + self.w1 + 1, tx * ty)] + } + + /// The anchoring right-hand side `ANCHOR_WEIGHT² · src`, interleaved. + fn anchor_rhs(&self) -> Vec { + self.src.iter().flat_map(|s| [ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[0], ANCHOR_WEIGHT * ANCHOR_WEIGHT * s[1]]).collect() + } +} + +/// The two shape rows (x then y) that pin vertex `i0` to `v1 + u (v2 − v1) + v R90 (v2 − v1)`, +/// with `(u, v)` taken from the source positions. Vertex ids are whatever the caller numbers its +/// unknowns by; the rows' right-hand sides are zero. +fn shape_rows(i: [usize; 3], p: [[f64; 2]; 3]) -> [[(usize, f64); 5]; 2] { + let (e, d) = ([p[2][0] - p[1][0], p[2][1] - p[1][1]], [p[0][0] - p[1][0], p[0][1] - p[1][1]]); + let l2 = e[0] * e[0] + e[1] * e[1]; + let u = (d[0] * e[0] + d[1] * e[1]) / l2; + let v = (d[0] * -e[1] + d[1] * e[0]) / l2; + let (i0, i1, i2) = (i[0], i[1], i[2]); + [ + // x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0 + [(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)], + // y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0 + [(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)], + ] +} + +/// The four triangles of a cell over its corners `[a, a + 1, a + w1, a + w1 + 1]` (as 0..4); +/// both diagonals are used so the shape term is symmetric. +const CELL_TRIS: [[usize; 3]; 4] = [[0, 1, 3], [0, 3, 2], [0, 1, 2], [1, 3, 2]]; + +/// Every shape row of the grid (with global vertex ids), for reference solves. +#[cfg(test)] +fn all_shape_rows(g: &Grid) -> Vec { + let mut out = Vec::new(); + for j in 0..g.ny { + for i in 0..g.nx { + let a = j * g.w1 + i; + let corner = [a, a + 1, a + g.w1, a + g.w1 + 1]; + for t in CELL_TRIS { + for rot in 0..3 { + let ids = [corner[t[rot]], corner[t[(rot + 1) % 3]], corner[t[(rot + 2) % 3]]]; + for row in shape_rows(ids, ids.map(|k| g.src[k])) { + out.push(row.to_vec()); + } + } } } - t - }; + } + out +} + +/// The line rows (already multiplied by `LINE_WEIGHT`): every sample of a constraint's curve is +/// tied to the curve's first point along the constraint's normal. +fn line_rows(g: &Grid, curves: &[Vec<[f64; 2]>], active: &[usize], normals: &[[f64; 2]], p: &WideAngle) -> Vec { + let mut rows = Vec::new(); + for (ci, &k) in active.iter().enumerate() { + let Some(c) = curves.get(k) else { continue }; + let n = match p.constraints.get(k).map(|c| c.orientation) { + Some(Orientation::Horizontal) => [0.0, 1.0], + Some(Orientation::Vertical) => [1.0, 0.0], + _ => normals.get(ci).copied().unwrap_or([0.0, 1.0]), + }; + let Some(first) = c.first() else { continue }; + let w0 = g.weights(*first); + for q in c.iter().skip(1) { + let mut row: Row = Vec::with_capacity(16); + for &(i, w) in &g.weights(*q) { + row.push((2 * i, LINE_WEIGHT * n[0] * w)); + row.push((2 * i + 1, LINE_WEIGHT * n[1] * w)); + } + for &(i, w) in &w0 { + row.push((2 * i, -LINE_WEIGHT * n[0] * w)); + row.push((2 * i + 1, -LINE_WEIGHT * n[1] * w)); + } + rows.push(row); + } + } + rows +} + +/// Above this many line rows the low-rank update's dense `rows × rows` system gets too big and +/// the solve falls back to conjugate gradients. +const MAX_UPDATE_ROWS: usize = 256; + +/// The part of the normal equations that doesn't depend on the constraints: the shape and +/// anchor terms `K = Σ rᵀr + ANCHOR_WEIGHT²·I`, assembled and factored once per solve. +struct StaticSystem { + k: Csr, + chol: Option, + rhs: Vec, + /// `K⁻¹ · rhs`, the solution without any line rows. + base: Vec, +} + +impl StaticSystem { + fn new(g: &Grid) -> StaticSystem { + let n = 2 * g.nv; + let mut trip: Vec<(usize, usize, f64)> = Vec::with_capacity(g.nx * g.ny * 64 + n); + for j in 0..g.ny { + for i in 0..g.nx { + let a = j * g.w1 + i; + let corner = [a, a + 1, a + g.w1, a + g.w1 + 1]; + // The cell's 24 shape rows only involve its own 8 unknowns: accumulate their + // 8 × 8 normal block (unknown `2·c + axis` is corner `c`'s coordinate). + let mut m = [[0.0f64; 8]; 8]; + for t in CELL_TRIS { + for rot in 0..3 { + let local = [t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]]; + for row in shape_rows(local, local.map(|c| g.src[corner[c]])) { + for &(si, ca) in &row { + for &(sj, cb) in &row { + m[si][sj] += ca * cb; + } + } + } + } + } + for (si, mi) in m.iter().enumerate() { + for (sj, &v) in mi.iter().enumerate() { + trip.push((2 * corner[si / 2] + si % 2, 2 * corner[sj / 2] + sj % 2, v)); + } + } + } + } + for d in 0..n { + trip.push((d, d, ANCHOR_WEIGHT * ANCHOR_WEIGHT)); + } + let k = Csr::from_triplets(n, trip); + let chol = BandChol::new(&k); + let rhs = g.anchor_rhs(); + let mut base = vec![0.0; n]; + if let Some(c) = &chol { + c.solve(&rhs, &mut base); + } + StaticSystem { k, chol, rhs, base } + } + + /// Solves `(K + LᵀL) x = rhs` for the line rows `L = lines`. With `K`'s factor this is the + /// exact low-rank update `x = K⁻¹b − K⁻¹Lᵀ (I + L K⁻¹ Lᵀ)⁻¹ L K⁻¹b` (one parallel solve per + /// line row). Without it, or with very many rows, conjugate gradients warm-started from `x`. + /// A solve that goes non-finite leaves `x` as it was. + fn solve(&self, lines: &[Row], x: &mut Vec) { + let n = self.rhs.len(); + let updated = match &self.chol { + Some(chol) if lines.len() <= MAX_UPDATE_ROWS => self.solve_update(chol, lines), + _ => None, + }; + let solved = match updated { + Some(v) => v, + None => { + let mut v = x.clone(); + self.solve_cg(lines, &mut v); + v + } + }; + if solved.len() == n && solved.iter().all(|v| v.is_finite()) { + *x = solved; + } + } + + fn solve_update(&self, chol: &BandChol, lines: &[Row]) -> Option> { + let (n, m) = (self.rhs.len(), lines.len()); + if m == 0 { + return Some(self.base.clone()); + } + // Column `j` of `K⁻¹Lᵀ` is `K⁻¹` applied to line row `j`. + let mut cols = vec![0.0; n * m]; + par_rows(&mut cols, n, 1, |j, col| { + let mut dense = vec![0.0; n]; + for &(i, c) in lines.get(j).into_iter().flatten() { + if let Some(d) = dense.get_mut(i) { + *d += c; + } + } + chol.solve(&dense, col); + }); + let dot_row = |row: &Row, v: &[f64]| -> f64 { row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum() }; + // S = I + L K⁻¹ Lᵀ, and t = L K⁻¹ b. + let mut s = vec![0.0; m * m]; + for (a, row) in lines.iter().enumerate() { + for (b, col) in cols.chunks(n).enumerate() { + s[a * m + b] = dot_row(row, col) + if a == b { 1.0 } else { 0.0 }; + } + } + let t: Vec = lines.iter().map(|row| dot_row(row, &self.base)).collect(); + let z = solve_dense(m, s, t)?; + let mut x = self.base.clone(); + for (zj, col) in z.iter().zip(cols.chunks(n)) { + for (xi, ci) in x.iter_mut().zip(col) { + *xi -= zj * ci; + } + } + Some(x) + } + + fn solve_cg(&self, lines: &[Row], x: &mut [f64]) { + let op = |v: &[f64], out: &mut [f64]| { + self.k.mul(v, out); + for row in lines { + let t: f64 = row.iter().map(|&(i, c)| c * v.get(i).copied().unwrap_or(0.0)).sum(); + for &(i, c) in row { + if let Some(o) = out.get_mut(i) { + *o += c * t; + } + } + } + }; + match &self.chol { + Some(chol) => { + pcg(op, |r, z| chol.solve(r, z), &self.rhs, x, 1e-8, 500); + } + // The band was too wide to factor: Jacobi-preconditioned, and many more iterations. + None => { + let diag = &self.k.diag; + pcg(op, |r, z| z.iter_mut().zip(r.iter().zip(diag)).for_each(|(z, (r, d))| *z = r / d), &self.rhs, x, 1e-8, 4000); + } + } + } +} + +/// Solves the content-preserving warp for `p` over `frame`. +pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { + let mut system: Option = None; + solve_with(p, frame, GRID_CELLS, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x)) +} + +/// [`solve`] with the grid resolution and the linear solver for `(K + LᵀL) x = rhs` (given the +/// current line rows and a warm start) chosen by the caller. +fn solve_with(p: &WideAngle, frame: Rect, cells: f64, linsolve: &mut LinSolve<'_>) -> WideMesh { + let cam = Camera::new(p, frame); + let g = Grid::new(frame, cells); + let (nx, ny, nv, src) = (g.nx, g.ny, g.nv, &g.src); + let curves: Vec> = p.constraints.iter().map(|c| cam.arc(c.a, c.b, 24)).collect(); + let mut x: Vec = src.iter().flat_map(|s| [s[0], s[1]]).collect(); let active: Vec = (0..curves.len()).filter(|&k| curves[k].len() >= 3).collect(); if !active.is_empty() { let mut normals: Vec<[f64; 2]> = active.iter().map(|_| [0.0, 1.0]).collect(); for round in 0..4 { - let mut ls = SparseLs::new(2 * nv); - // Shape: v0 = v1 + u (v2 − v1) + v R90 (v2 − v1), with (u, v) from the source. - for t in &tris { - for rot in 0..3 { - let (i0, i1, i2) = (t[rot], t[(rot + 1) % 3], t[(rot + 2) % 3]); - let (p0, p1, p2) = (src[i0], src[i1], src[i2]); - let e = [p2[0] - p1[0], p2[1] - p1[1]]; - let l2 = e[0] * e[0] + e[1] * e[1]; - let d = [p0[0] - p1[0], p0[1] - p1[1]]; - let u = (d[0] * e[0] + d[1] * e[1]) / l2; - let v = (d[0] * -e[1] + d[1] * e[0]) / l2; - // x: v0x − v1x − u (v2x − v1x) + v (v2y − v1y) = 0 - ls.add(vec![(2 * i0, 1.0), (2 * i1, -1.0 + u), (2 * i2, -u), (2 * i2 + 1, v), (2 * i1 + 1, -v)], 0.0, 1.0); - // y: v0y − v1y − u (v2y − v1y) − v (v2x − v1x) = 0 - ls.add(vec![(2 * i0 + 1, 1.0), (2 * i1 + 1, -1.0 + u), (2 * i2 + 1, -u), (2 * i2, -v), (2 * i1, v)], 0.0, 1.0); - } - } - // Weak anchoring to the source (fixes the global similarity). - for (k, s) in src.iter().enumerate() { - ls.add(vec![(2 * k, 1.0)], s[0], 0.02); - ls.add(vec![(2 * k + 1, 1.0)], s[1], 0.02); - } - // Lines. - for (ci, &k) in active.iter().enumerate() { - let c = &curves[k]; - let n = match p.constraints[k].orientation { - Orientation::Horizontal => [0.0, 1.0], - Orientation::Vertical => [1.0, 0.0], - Orientation::Free => normals[ci], - }; - let w0 = weights(c[0]); - for q in &c[1..] { - let wq = weights(*q); - let mut row: Vec<(usize, f64)> = Vec::with_capacity(16); - for &(i, w) in &wq { - row.push((2 * i, n[0] * w)); - row.push((2 * i + 1, n[1] * w)); - } - for &(i, w) in &w0 { - row.push((2 * i, -n[0] * w)); - row.push((2 * i + 1, -n[1] * w)); - } - ls.add(row, 0.0, 10.0); - } - } - x = ls.solve(&x, 400 + 200 * round); + linsolve(&g, &line_rows(&g, &curves, &active, &normals, p), &mut x); // Re-estimate free normals from the current end points. - let pos = |q: [f64; 2]| -> [f64; 2] { weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) }; + let pos = |q: [f64; 2]| -> [f64; 2] { g.weights(q).iter().fold([0.0, 0.0], |acc, &(i, w)| [acc[0] + w * x[2 * i], acc[1] + w * x[2 * i + 1]]) }; let mut changed = false; for (ci, &k) in active.iter().enumerate() { let c = &curves[k]; @@ -350,7 +548,7 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { let mut mesh = WideMesh { nx, ny, verts, curves, residual: 0.0 }; // Straightness residual: distance of curve samples from the chord of their mapped ends. let mut worst = 0.0f64; - for (k, cv) in mesh.curves.iter().enumerate() { + for cv in mesh.curves.iter() { if cv.len() < 3 { continue; } @@ -361,7 +559,6 @@ pub fn solve(p: &WideAngle, frame: Rect) -> WideMesh { for q in &pts { worst = worst.max(((q[0] - a[0]) * d[1] - (q[1] - a[1]) * d[0]).abs() / l); } - let _ = k; } mesh.residual = worst; mesh @@ -433,6 +630,108 @@ mod tests { assert!((c[0] - 300.0).abs() < 80.0 && (c[1] - 250.0).abs() < 40.0, "{c:?}"); } + /// The exact solution of the same normal equations (dense, small grids only). + fn dense_linsolve(g: &Grid, lines: &[Row], x: &mut Vec) { + use crate::photo_util::Normal; + let mut ne = Normal::new(2 * g.nv); + for row in all_shape_rows(g) { + ne.add(&row, 0.0, 1.0); + } + for k in 0..2 * g.nv { + ne.add(&[(k, 1.0)], g.src[k / 2][k % 2], ANCHOR_WEIGHT); + } + for row in lines { + ne.add(row, 0.0, 1.0); + } + *x = ne.solve().expect("the anchored system is positive definite"); + } + + #[test] + fn direct_solve_matches_the_dense_reference() { + let frame = Rect::new(0, 0, 240, 160); + for constraints in [ + vec![ + Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal }, + Constraint { a: [40.0, 30.0], b: [45.0, 130.0], orientation: Orientation::Free }, + ], + vec![ + Constraint { a: [20.0, 120.0], b: [220.0, 110.0], orientation: Orientation::Free }, + Constraint { a: [200.0, 20.0], b: [205.0, 140.0], orientation: Orientation::Vertical }, + ], + ] { + let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() }; + let mut system: Option = None; + let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x)); + let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve); + assert_eq!(fast.verts.len(), exact.verts.len()); + let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max); + assert!(worst < 0.05, "worst vertex difference {worst} px"); + assert!(fast.residual <= exact.residual + 0.05, "residual {} vs exact {}", fast.residual, exact.residual); + } + } + + /// More line rows than the low-rank update takes falls back to conjugate gradients, which + /// still agrees with the exact solution. + #[test] + fn many_constraints_use_the_conjugate_gradient_fallback() { + let frame = Rect::new(0, 0, 240, 160); + let constraints: Vec = (0..12) + .map(|k| Constraint { + a: [20.0 + 4.0 * k as f64, 15.0 + 10.0 * k as f64], + b: [210.0 - 3.0 * k as f64, 18.0 + 10.0 * k as f64], + orientation: Orientation::Horizontal, + }) + .collect(); + let p = WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints, ..Default::default() }; + let mut rows = 0; + let mut system: Option = None; + let fast = solve_with(&p, frame, 12.0, &mut |g, lines, x| { + rows = lines.len(); + system.get_or_insert_with(|| StaticSystem::new(g)).solve(lines, x) + }); + assert!(rows > MAX_UPDATE_ROWS, "{rows} line rows"); + let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve); + let worst = fast.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max); + assert!(worst < 0.05, "worst vertex difference {worst} px"); + } + + /// Without a banded factor (band too wide) the Jacobi-preconditioned fallback still solves. + #[test] + fn unfactored_system_falls_back_to_jacobi_cg() { + let frame = Rect::new(0, 0, 240, 160); + let p = WideAngle { + model: WideModel::Fisheye, + focal_length: 8.0, + constraints: vec![Constraint { a: [30.0, 40.0], b: [210.0, 40.0], orientation: Orientation::Horizontal }], + ..Default::default() + }; + let mut system: Option = None; + let jacobi = solve_with(&p, frame, 12.0, &mut |g, lines, x| { + let sys = system.get_or_insert_with(|| { + let mut sys = StaticSystem::new(g); + sys.chol = None; + sys + }); + sys.solve(lines, x) + }); + let exact = solve_with(&p, frame, 12.0, &mut dense_linsolve); + let worst = jacobi.verts.iter().zip(&exact.verts).map(|(a, b)| (a.0[0] - b.0[0]).abs().max((a.0[1] - b.0[1]).abs())).fold(0.0, f64::max); + assert!(worst < 0.1, "worst vertex difference {worst} px"); + } + + #[test] + fn hostile_frames_and_constraints_solve_to_finite_meshes() { + let wild = Constraint { a: [-1e6, 5e5], b: [1e6, -5e5], orientation: Orientation::Free }; + let same = Constraint { a: [3.0, 3.0], b: [3.0, 3.0], orientation: Orientation::Free }; + let nan = Constraint { a: [f64::NAN, 1.0], b: [2.0, f64::INFINITY], orientation: Orientation::Horizontal }; + for frame in [Rect::new(0, 0, 1, 1), Rect::new(0, 0, 2, 200), Rect::new(0, 0, 400, 3), Rect::new(-50, -20, 90, 60)] { + let p = + WideAngle { model: WideModel::Fisheye, focal_length: 8.0, constraints: vec![wild.clone(), same.clone(), nan.clone()], ..Default::default() }; + let mesh = solve(&p, frame); + assert!(mesh.verts.iter().all(|(o, s)| o.iter().chain(s).all(|v| v.is_finite())), "{frame:?}"); + } + } + #[test] fn no_constraints_is_identity_and_renders() { use photocraft_color::PixelFormat; diff --git a/crates/algo/tests/it/gallery_reach_extreme.rs b/crates/algo/tests/it/gallery_reach_extreme.rs new file mode 100644 index 0000000000..32d4356267 --- /dev/null +++ b/crates/algo/tests/it/gallery_reach_extreme.rs @@ -0,0 +1,76 @@ +//! A project file stores a Filter Gallery effect's values as plain numbers, bypassing the clamp that +//! `GalleryEffect::set` applies, so the reach arithmetic must survive any `f32` in them. + +use photocraft_algo::{FilterParams, GalleryEffect, GalleryFilter, GalleryParamKind, Halo, apply}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +const EXTREME: [f32; 6] = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, 3.0e9]; + +/// The reach in pixels; a whole-image effect (`Halo::Bounds`) has no radius to check. +fn radius(effects: Vec) -> i32 { + match (FilterParams::FilterGallery { effects }).halo() { + Halo::Radius(r) => r, + _ => 0, + } +} + +fn with_all_ranges(filter: GalleryFilter, v: f32) -> GalleryEffect { + let mut e = GalleryEffect::new(filter); + for p in filter.params() { + if matches!(p.kind, GalleryParamKind::Range { .. }) { + e.values.insert(p.key.to_string(), v); + } + } + e +} + +#[test] +fn gallery_reach_survives_extreme_stored_values() { + for &filter in GalleryFilter::ALL { + for v in EXTREME { + let r = radius(vec![with_all_ranges(filter, v)]); + assert!(r >= 0, "{filter:?} with every range at {v}: reach {r}"); + } + } +} + +#[test] +fn gallery_reach_of_a_stack_survives_extreme_stored_values() { + for &filter in GalleryFilter::ALL { + let stack = vec![with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX), with_all_ranges(filter, f32::MAX)]; + let r = radius(stack); + assert!(r >= 0, "{filter:?} stacked three times: reach {r}"); + } +} + +/// Every range value at `scale` times its declared maximum (past the range when `scale` > 1). +fn scaled_to_max(filter: GalleryFilter, scale: f32) -> GalleryEffect { + let mut e = GalleryEffect::new(filter); + for p in filter.params() { + if let GalleryParamKind::Range { max, .. } = p.kind { + e.values.insert(p.key.to_string(), max * scale); + } + } + e +} + +/// The tile halo comes from the clamped values, so the pixels must be drawn from them too: a stored +/// value past its range renders exactly as the clamped one, with no seam where tiles meet. +#[test] +fn gallery_pixels_use_the_same_clamped_values_as_the_reach() { + let mut src = Surface::new(PixelFormat::RGBA8); + src.fill_rect(Rect::new(0, 0, 160, 160), &[0.2, 0.5, 0.8, 1.0]); + src.fill_rect(Rect::new(30, 40, 120, 100), &[0.9, 0.3, 0.1, 1.0]); + src.fill_rect(Rect::new(70, 60, 90, 130), &[0.1, 0.9, 0.4, 1.0]); + let area = Rect::new(0, 0, 160, 160); + for &filter in GalleryFilter::ALL { + let run = |e: GalleryEffect| apply(&src, &FilterParams::FilterGallery { effects: vec![e] }, area, area, None).read_region(area); + assert_eq!( + run(scaled_to_max(filter, 3.0)), + run(scaled_to_max(filter, 1.0)), + "{filter:?}: a stored value past its range must render as the clamped one" + ); + } +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index e611f93cb9..342eeb2501 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -1,6 +1,8 @@ //! The crate's integration tests, linked into one test binary. +mod gallery_reach_extreme; mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; +mod retouch_sharpen_exact; mod warp_clipped; diff --git a/crates/algo/tests/it/retouch_sharpen_exact.rs b/crates/algo/tests/it/retouch_sharpen_exact.rs new file mode 100644 index 0000000000..9e75a261ab --- /dev/null +++ b/crates/algo/tests/it/retouch_sharpen_exact.rs @@ -0,0 +1,82 @@ +//! Frozen scalar sharpen from main, used as an exact-output oracle. +use photocraft_algo::retouch::{local_blur, local_sharpen}; + +#[test] +fn matches_scalar_sharpen_bit_for_bit_across_layouts_edges_and_hdr() { + for (w, h) in [(1, 1), (3, 7), (37, 29)] { + for ch in 1..=5 { + for alpha in std::iter::once(None).chain((0..ch).map(Some)) { + let data: Vec = (0..w * h * ch) + .map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 }) + .collect(); + for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] { + for amount in [0.0, 0.5, 1.0, 2.5] { + for protect_detail in [false, true] { + let expected = scalar_local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail); + let actual = local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail); + assert_eq!(actual.len(), expected.len()); + for (i, (a, b)) in actual.iter().zip(&expected).enumerate() { + assert_eq!( + a.to_bits(), + b.to_bits(), + "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} amount={amount} protect={protect_detail} sample={i}" + ); + } + } + } + } + } + } + } +} + +#[test] +fn empty_channel_layout_remains_empty() { + assert!(local_sharpen(&[], 5, 5, 0, None, (0, 0, 5, 5), 1.0, true).is_empty()); +} + +#[allow(clippy::too_many_arguments)] +fn scalar_local_sharpen( + data: &[f32], + w: usize, + h: usize, + ch: usize, + alpha: Option, + win: (usize, usize, usize, usize), + amount: f32, + protect_detail: bool, +) -> Vec { + let (x0, y0, x1, y1) = win; + let (ow, oh) = (x1 - x0, y1 - y0); + if ow == 0 || oh == 0 || ch == 0 { + return Vec::new(); + } + let blur = local_blur(data, w, h, ch, alpha, win, 1.0); + let mut out = vec![0.0f32; blur.len()]; + for y in y0..y1 { + for x in x0..x1 { + let o = ((y - y0) * ow + (x - x0)) * ch; + let i = (y * w + x) * ch; + for c in 0..ch { + let v = data[i + c]; + if Some(c) == alpha { + out[o + c] = v; + continue; + } + let mut s = v + amount * (v - blur[o + c]); + if protect_detail { + let (mut lo, mut hi) = (v, v); + for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); + let nv = data[(ny * w + nx) * ch + c]; + lo = lo.min(nv); + hi = hi.max(nv); + } + s = s.clamp(lo, hi); + } + out[o + c] = s; + } + } + } + out +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index ba073af2ff..320a1f50f4 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -29,8 +29,13 @@ cap-std = "4" clap = { workspace = true } [dev-dependencies] +photocraft-geom = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } +# Tiny generated PSDs and the scoped estimate charge for the high-memory approval tests +# (no multi-gigabyte fixture or global override). +photocraft-io = { workspace = true, features = ["testgen"] } +photocraft-psd = { 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/bridge.rs b/crates/automation/src/bridge.rs index 5c238a3b51..adb6d8eea6 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -227,18 +227,39 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res if v.get("id").and_then(Value::as_u64) != Some(id) { continue; // stale reply from an earlier, timed-out request } - return Ok(if v.get("ok").and_then(Value::as_bool) == Some(true) { - Ok(v.get("result").cloned().unwrap_or(Value::Null)) - } else { - Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_owned())) - }); + return Ok(reply_result(&v)); } } +/// An app reply as the caller's result. A failed reply can still carry a `result`: a cancelled +/// Batch lists the outputs it already wrote, so the error keeps it rather than dropping it. +fn reply_result(v: &Value) -> Result { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + return Ok(v.get("result").cloned().unwrap_or(Value::Null)); + } + let error = v.get("error").and_then(Value::as_str).unwrap_or("unknown error"); + Err(AutomationError::App(match v.get("result") { + Some(result) if !result.is_null() => format!("{error}; partial result: {result}"), + _ => error.to_owned(), + })) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_failed_reply_keeps_its_partial_result() { + assert_eq!(reply_result(&json!({"ok": true, "result": {"files": ["a.png"]}})).unwrap(), json!({"files": ["a.png"]})); + let Err(AutomationError::App(message)) = reply_result(&json!({"ok": false, "error": "cancelled", "result": {"files": ["a.png"], "cancelled": true}})) + else { + panic!("a failed reply is an app error") + }; + assert!(message.starts_with("cancelled; partial result: ") && message.contains("a.png"), "{message}"); + let Err(AutomationError::App(message)) = reply_result(&json!({"ok": false, "error": "cancelled"})) else { panic!("a failed reply is an app error") }; + assert_eq!(message, "cancelled"); + } + #[test] fn the_call_timeout_stays_below_the_app_reply_deadline() { // At or above the app's 60s deadline, callers would see their own cutoff first. diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 0b8ff103aa..0ecc057c59 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -18,17 +18,27 @@ pub struct Opened { /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { + open_bytes_with_options(name, bytes, &photocraft_io::ImportOptions::default()) +} + +/// Decode a document that was read through a filesystem capability with custom import options. +pub fn open_bytes_with_options(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { let document = photocraft_format::load_from_bytes(bytes)?; return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } - let r = photocraft_io::import(name, bytes)?; + let r = photocraft_io::import_with_options(name, bytes, opts)?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) } /// Open a document from disk. Directory bundles and `.pcraft` ZIPs load /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { + open_with_options(path, &photocraft_io::ImportOptions::default()) +} + +/// Open a document from disk with custom import options. +pub fn open_with_options(path: &Path, opts: &photocraft_io::ImportOptions) -> Result { if path.is_dir() { let document = photocraft_format::load_path(path)?; return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); @@ -38,23 +48,39 @@ pub fn open(path: &Path) -> Result { // convert or info of a 2 GB file does not hold the file in memory next to the document. #[cfg(any(unix, windows))] { - let r = - photocraft_io::import_file(&name, path, &photocraft_io::ImportOptions::default(), &photocraft_raster::Interrupt::NONE).map_err(|e| match e { - photocraft_io::IoError::Read(msg) => AutomationError::Io(msg), - e => e.into(), - })?; + let r = photocraft_io::import_file(&name, path, opts, &photocraft_raster::Interrupt::NONE).map_err(|e| match e { + photocraft_io::IoError::Read(msg) => AutomationError::Io(msg), + e => e.into(), + })?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) } #[cfg(not(any(unix, windows)))] { let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; - open_bytes(&name, &bytes) + open_bytes_with_options(&name, &bytes, opts) } } /// Previews for a `.pcraft` bundle. pub fn previews(doc: &Document) -> SaveOptions { - SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) } + SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)), ..Default::default() } +} + +/// [`previews`], plus the full-size composite when `opts` asks for it and it can be rendered +/// ([`ExportOptions::pcraft_full_composite`]). Fails when a scratch tile of `doc` could not be +/// read while they were rendered: the previews may then hold placeholder pixels, and the +/// bundle's own scratch checks only cover reads made after it starts. +fn native_options(doc: &Document, opts: &ExportOptions) -> Result { + doc.scratch_health().map_err(AutomationError::Io)?; + let generation = photocraft_raster::spill::read_error_generation(); + let mut out = previews(doc); + if opts.pcraft_full_composite { + out.full_composite = photocraft_io::pcraft_full_composite(doc); + out.full_composite_icc = out.full_composite.as_ref().map(|_| photocraft_io::pcraft_full_composite_icc(doc)); + } + photocraft_raster::spill::check_since(generation).map_err(AutomationError::Io)?; + doc.scratch_health().map_err(AutomationError::Io)?; + Ok(out) } /// Encode the name the saved native bundle will report on reopening. Save As updates @@ -86,7 +112,7 @@ pub fn save( let named = named_for_output(doc, &path.to_string_lossy()); let mut local = PcraftWriter::new(); let w = writer.unwrap_or(&mut local); - w.save_path(&named, path, &previews(&named))?; + w.save_path(&named, path, &native_options(&named, opts)?)?; return Ok(Vec::new()); } let r = photocraft_io::export(doc, &ext, opts)?; @@ -104,7 +130,7 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt .ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?; if ext == photocraft_format::EXTENSION { let named = named_for_output(doc, name); - return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new())); + return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?, Vec::new())); } let result = photocraft_io::export(doc, &ext, opts)?; Ok((result.bytes, result.warnings)) diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 6722c5c00b..e623e69f2c 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -90,14 +90,25 @@ impl Headless { /// Open a file and make it the active document. pub fn open(&mut self, path: &Path) -> Result { + self.open_with_approval(path, false) + } + + /// Open a file with optional high-memory approval and make it the active document. + pub fn open_with_approval(&mut self, path: &Path, approve_high_memory: bool) -> Result { + let opts = photocraft_io::ImportOptions { psd_high_memory_approved: approve_high_memory, ..Default::default() }; + self.open_with_options(path, &opts) + } + + /// Open a file with custom import options and make it the active document. + pub fn open_with_options(&mut self, path: &Path, opts: &photocraft_io::ImportOptions) -> Result { let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?; let mut o = match &self.filesystem { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), - Filesystem::TrustedLocal => files::open(path)?, + Filesystem::TrustedLocal => files::open_with_options(path, opts)?, Filesystem::Workspace(workspace) => { let bytes = workspace.read(requested)?; let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); - files::open_bytes(name, &bytes)? + files::open_bytes_with_options(name, &bytes, opts)? } }; // Preferences › Raw Defaults › Resolution, as the app's File › Open applies it. @@ -129,7 +140,8 @@ impl Headless { /// Place a file into a document as a Smart Object layer: the capability-scoped counterpart /// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in /// automation (#2810). `path` resolves beneath the read root the way `open` resolves one, - /// and `params` carries the command's own options (`scale`, `fit`, `center`). + /// and `params` carries the command's own options (`scale`, `fit`, `center`, + /// `approveHighMemory`). pub fn place(&mut self, index: Option, path: &str, params: &Value) -> Result { self.on_doc(index, |this| { let mut opts = params.as_object().cloned().unwrap_or_default(); @@ -358,6 +370,8 @@ impl Headless { if let Some(d) = self.session.close(i) { self.writers.remove(&d.doc.id.0); } + // The closed document's buffers went to the scratch pool: hand them back to the OS. + photocraft_raster::scratch::release(); Ok(self.session_list()) } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 98ccc9a24a..6ac8d96247 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -61,6 +61,36 @@ pub const METHODS: &[&str] = &[ "methods", ]; +/// Adds the structured memory fields (`required`, soft and hard limits and measured capacity) +/// from a typed admission error to a JSON error object, so clients can offer approval. +fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { + match err { + AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => { + if let Some(m) = obj.as_object_mut() { + m.insert("required".into(), json!(required)); + m.insert("soft".into(), json!(soft_limit)); + m.insert("softLimit".into(), json!(soft_limit)); + m.insert("hard".into(), json!(hard_limit)); + m.insert("hardLimit".into(), json!(hard_limit)); + if let Some(avail) = available_ram { + m.insert("available".into(), json!(avail)); + m.insert("availableRam".into(), json!(avail)); + } + } + } + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => { + if let Some(m) = obj.as_object_mut() { + m.insert("required".into(), json!(required)); + m.insert("hard".into(), json!(limit)); + m.insert("hardLimit".into(), json!(limit)); + } + } + _ => {} + } +} + fn bad(msg: impl Into) -> AutomationError { AutomationError::BadRequest(msg.into()) } @@ -86,6 +116,9 @@ impl Headless { match method { "engine.execute" => { let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?; + if METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() { + return Err(bad(photocraft_engine::command_hints::protocol_method_as_command(id))); + } let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true); self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait) } @@ -104,10 +137,18 @@ impl Headless { "session.list" => Ok(self.session_list()), "doc.open" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?; - self.open(&PathBuf::from(path)) + let approve = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad("`approveHighMemory` must be a boolean")), + }; + self.open_with_approval(&PathBuf::from(path), approve) } "doc.place" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?; + if p.get("approveHighMemory").is_some_and(|v| !v.is_boolean()) { + return Err(bad("`approveHighMemory` must be a boolean")); + } let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); @@ -123,10 +164,22 @@ impl Headless { "doc.new" => self.command_run("file.new", p), "doc.save" => { let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; - let mut opts = - photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), + tga_bits, + pcraft_full_composite: photocraft_engine::file_cmds::pcraft_full_composite_param(&p, "doc.save") + .map_err(|e| bad(e.to_string()))? + .unwrap_or(false), + ..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(c) = photocraft_engine::file_cmds::tiff_compression_param(&p, "doc.save").map_err(|e| bad(e.to_string()))? { + opts.encode.tiff_compression = c; + } + let tiff_bool = |key| photocraft_engine::file_cmds::tiff_bool_param(&p, key, "doc.save").map_err(|e| bad(e.to_string())); + opts.tiff_transparency = tiff_bool("tiffTransparency")?; + opts.encode.tiff_pyramid = tiff_bool("tiffPyramid")?.unwrap_or(false); match p.get("quality") { None | Some(Value::Null) => {} Some(v) => { @@ -186,7 +239,7 @@ impl Headless { "doc.close" => self.close(index_of(&p)?), "batch" => self.batch(&p), "methods" => Ok(json!(METHODS)), - other => Err(bad(format!("unknown method `{other}` (try `methods`)"))), + other => Err(bad(format!("{} (try `methods`)", photocraft_engine::command_hints::unknown_method(other, METHODS)))), } } @@ -221,7 +274,11 @@ impl Headless { let was_error = r.is_err(); let result = match r { Ok(v) => json!({"ok": true, "result": v}), - Err(e) => json!({"ok": false, "error": e.to_string()}), + Err(e) => { + let mut obj = json!({"ok": false, "error": e.to_string()}); + attach_memory_error_details(&mut obj, &e); + obj + } }; if let Err(error) = reply_budget.charge(&result) { failed += 1; @@ -259,7 +316,11 @@ pub fn respond(h: &Mutex, line: &str) -> Value { .unwrap_or_else(|_| Err(AutomationError::Other(format!("internal error: `{method}` panicked")))); match r { Ok(v) => json!({"id": id, "ok": true, "result": v}), - Err(e) => json!({"id": id, "ok": false, "error": e.to_string()}), + Err(e) => { + let mut obj = json!({"id": id, "ok": false, "error": e.to_string()}); + attach_memory_error_details(&mut obj, &e); + obj + } } } diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index 5136b0363b..0a20075267 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -268,7 +268,8 @@ fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() { } 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"); + // The lossy chunk; an extended WebP (one carrying a colour profile) puts `VP8X` first. + assert!(bytes.windows(4).any(|c| c == b"VP8 ") && !bytes.windows(4).any(|c| c == b"VP8L"), "a WebP saved with a quality is lossy"); std::fs::remove_dir_all(dir).unwrap(); } @@ -817,6 +818,23 @@ fn methods_lists_the_job_methods_and_every_listed_name_is_served() { assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method")); } +/// #3674: a guessed name gets a pointer to the right one, in both directions. +#[test] +fn unknown_names_suggest_the_right_method_or_command() { + let mut h = Headless::new(); + let err = |h: &mut Headless, m: &str, p: Value| h.handle(m, p).unwrap_err().to_string(); + let e = err(&mut h, "engine.execute", json!({"command": "engine.commands"})); + assert!(e.contains("did you mean `command.list`") && e.contains("protocol method"), "{e}"); + let e = err(&mut h, "engine.execute", json!({"command": "doc.inspect"})); + assert!(e.contains("`doc.inspect` is a protocol method"), "{e}"); + let e = err(&mut h, "engine.execute", json!({"command": "edit.undoo"})); + assert!(e.contains("did you mean `edit.undo`"), "{e}"); + let e = err(&mut h, "doc.inspct", Value::Null); + assert!(e.contains("did you mean `doc.inspect`") && e.contains("(try `methods`)"), "{e}"); + let e = err(&mut h, "edit.undo", Value::Null); + assert!(e.contains("engine.execute {\"command\": \"edit.undo\"}"), "{e}"); +} + #[test] fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() { let mut h = Headless::new(); @@ -1010,3 +1028,88 @@ fn invalid_document_index_is_rejected_not_the_active_document() { assert_eq!(reply["ok"], true, "{reply}"); assert_eq!(count(&h), 1); } + +#[test] +fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() { + let h = session(); + // Non-boolean approveHighMemory is rejected strictly + let reply = respond(&h, r#"{"method":"doc.open","params":{"path":"test.psd","approveHighMemory":"yes"}}"#); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().contains("must be a boolean")); + + let reply_place = respond(&h, r#"{"method":"doc.place","params":{"path":"test.psd","approveHighMemory":123}}"#); + assert_eq!(reply_place["ok"], false); + assert!(reply_place["error"].as_str().unwrap().contains("must be a boolean")); + + // Verify attach_memory_error_details adds machine-readable fields + let mut err_obj = json!({"id": 1, "ok": false, "error": "test"}); + let auto_err = crate::AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { + required: 5000, + soft_limit: 2000, + hard_limit: 8000, + available_ram: Some(10000), + }); + super::attach_memory_error_details(&mut err_obj, &auto_err); + assert_eq!(err_obj["required"], 5000); + assert_eq!(err_obj["soft"], 2000); + assert_eq!(err_obj["softLimit"], 2000); + assert_eq!(err_obj["hard"], 8000); + assert_eq!(err_obj["hardLimit"], 8000); + assert_eq!(err_obj["available"], 10000); + assert_eq!(err_obj["availableRam"], 10000); + + // An engine-command hard refusal (Open As / Place go through `EngineError`) keeps its typed + // fields, and only the fields an approval request has: no soft limit and no capacity, since + // no approval can raise the ceiling. + let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"}); + let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 }); + super::attach_memory_error_details(&mut hard_obj, &hard); + assert_eq!(hard_obj["required"], 9000); + assert_eq!(hard_obj["hard"], 8000); + assert_eq!(hard_obj["hardLimit"], 8000); + for absent in ["soft", "softLimit", "available", "availableRam"] { + assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}"); + } +} + +/// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the +/// read-root route (the bytes are imported in-process) and trusted local (the ambient +/// `file.placeEmbedded` command reads the path). A tiny PSD plus the scoped estimate charge +/// reaches the real approval window without allocating anything close to a gigabyte. +#[test] +fn doc_place_forwards_high_memory_approval() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let root = std::env::temp_dir().join(format!("pc-doc-place-approval-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let psd = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Zip).to_bytes().unwrap(); + std::fs::write(root.join("high.psd"), &psd).unwrap(); + // Known 5 GiB RAM: S = 2.5 GiB, H = 3.75 GiB. A 3 GiB estimate charge lands in (S, H]. + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + let trial = |h: &mut Headless, path: &str| { + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + let refused = h.handle("doc.place", json!({"path": path})).unwrap_err(); + assert!( + matches!(refused, crate::AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { .. })), + "an unapproved high-memory place must ask for approval: {refused}" + ); + let placed = h.handle("doc.place", json!({"path": path, "approveHighMemory": true})).unwrap(); + assert!(placed["layer"].as_u64().is_some(), "the approval lets the same place proceed: {placed}"); + }); + }); + }; + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap(); + trial(&mut h, "high.psd"); + // Release directory capabilities before cleanup on Windows. + drop(h); + let mut t = Headless::trusted_local(); + t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap(); + let absolute = root.join("high.psd").to_string_lossy().into_owned(); + trial(&mut t, &absolute); + drop(t); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 61a6454e79..5119c694ea 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -50,6 +50,9 @@ pub struct DocIndex { pub struct OpenParams { /// Path beneath the configured automation read root: relative (forward slashes) or absolute. pub path: String, + /// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget. + #[serde(default)] + pub approve_high_memory: bool, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -69,6 +72,9 @@ pub struct PlaceParams { /// Centre of the placed layer, in document pixels. #[serde(default)] pub center: Option<[f64; 2]>, + /// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget. + #[serde(default)] + pub approve_high_memory: bool, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -177,6 +183,17 @@ pub struct SaveParams { /// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF. #[serde(default, rename = "tiffLayers")] pub tiff_layers: bool, + /// TIFF: image compression, "none" (the default), "lzw" or "zip". + #[serde(default, rename = "tiffCompression")] + pub tiff_compression: Option, + /// TIFF: Save Transparency. On keeps the transparency, ahead of any alpha or spot channels; + /// off composites the image over white. Unset: kept unless the document has alpha or spot + /// channels. + #[serde(default, rename = "tiffTransparency")] + pub tiff_transparency: Option, + /// TIFF: Save Image Pyramid, half-size copies after the full image. + #[serde(default, rename = "tiffPyramid")] + pub tiff_pyramid: bool, /// TGA: 24 or 32 bits per pixel. 32 writes the alpha channel (or else the transparency) as the /// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24. #[serde(default, rename = "tgaBits")] @@ -191,6 +208,10 @@ pub struct SaveParams { /// DDS: also write the full mip chain, each level a box filter of the one above. #[serde(default, rename = "ddsMipmaps")] pub dds_mipmaps: bool, + /// .pcraft: request the flattened document at its canvas size as `composite/full.png`. + /// The save may omit it; check the manifest's `full_composite` member. Off by default. + #[serde(default, rename = "pcraftFullComposite")] + pub pcraft_full_composite: Option, #[serde(default)] pub index: Option, } @@ -342,15 +363,23 @@ pub struct UiSetParams { /// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled, /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100), /// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware, - /// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit, + /// the Crop tool's Fill), penMode (path or shape, the Pen tools' mode), penPathOp (newLayer, + /// combine, subtract, intersect or exclude: how the next Pen path joins the selected shape + /// layer or path), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or - /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a - /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": - /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not - /// over this channel). updatesOpen (bool, the Help › Check for Updates window). + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "effect", + /// "mode", "attribute", "color", "smartObject" or "artboard", which lists the artboards that + /// hold the selected layers), kinds (a list of layer kinds), name (matches part of the layer + /// name), effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay", + /// "outerGlow" or "dropShadow"), mode (a blend mode name), attribute ("visible", "locked", + /// "empty", "linked", "clipped", "layerMask", "vectorMask", "layerEffects", + /// "advancedBlending", or "notVisible" … "noAdvancedBlending"), color (a layer colour label, + /// or "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel + /// filter). strokeTrace (true, false, "stderr" or "off": the stroke latency trace at runtime; a + /// CSV file is set at launch with --stroke-trace, not over this channel). updatesOpen (bool, + /// the Help › Check for Updates window). /// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel /// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b", /// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage", @@ -478,13 +507,64 @@ fn png_result(png: &[u8], note: String) -> CallToolResult { bounded_tool_result(CallToolResult::success(vec![Content::image(b64, "image/png"), Content::text(note)])) } +/// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps +/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required +/// and hard amounts, because no approval can raise that ceiling. +fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { + let Some(obj) = obj.as_object_mut() else { return }; + match err { + AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => { + obj.insert("required".into(), json!(required)); + obj.insert("soft".into(), json!(soft_limit)); + obj.insert("softLimit".into(), json!(soft_limit)); + obj.insert("hard".into(), json!(hard_limit)); + obj.insert("hardLimit".into(), json!(hard_limit)); + if let Some(avail) = available_ram { + obj.insert("available".into(), json!(avail)); + obj.insert("availableRam".into(), json!(avail)); + } + } + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => { + obj.insert("required".into(), json!(required)); + obj.insert("hard".into(), json!(limit)); + obj.insert("hardLimit".into(), json!(limit)); + } + _ => {} + } +} + fn to_result(r: Result) -> Result { Ok(match r { Ok(v) => ok_json(&v), - Err(e) => fail(e), + Err(e) => { + let mut obj = json!({"error": e.to_string()}); + attach_memory_error_details(&mut obj, &e); + let text = serde_json::to_string(&obj).unwrap_or_else(|_| e.to_string()); + CallToolResult::error(vec![Content::text(text)]) + } }) } +/// A `doc_place` call's command options, including the explicit high-memory approval. +fn place_options(p: &PlaceParams) -> Value { + let mut opts = serde_json::Map::new(); + if let Some(v) = p.scale { + opts.insert("scale".into(), json!(v)); + } + if let Some(v) = p.fit { + opts.insert("fit".into(), json!(v)); + } + if let Some(v) = p.center { + opts.insert("center".into(), json!(v)); + } + if p.approve_high_memory { + opts.insert("approveHighMemory".into(), json!(true)); + } + Value::Object(opts) +} + fn bridge_only(name: &str) -> Result { Ok(fail(format!( "`{name}` drives the live GUI and needs bridge mode: start the app with \ @@ -629,30 +709,25 @@ impl PhotocraftMcp { #[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")] async fn doc_open(&self, Parameters(p): Parameters) -> Result { let path = PathBuf::from(&p.path); - if let Some(r) = self.headless_op(move |h| h.open(&path)).await { + let approve = p.approve_high_memory; + if let Some(r) = self.headless_op(move |h| h.open_with_approval(&path, approve)).await { return to_result(r); } let Some(b) = self.bridge_client() else { return Ok(no_backend()); }; - to_result(b.call("app.open", json!({"path": p.path})).await) + let mut params = json!({"path": p.path}); + if approve { + params["approveHighMemory"] = json!(true); + } + to_result(b.call("app.open", params).await) } #[tool( - description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own." + description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit`, `center` and `approve_high_memory` (one-shot PSD/PSB consent, default false) are the command's own." )] async fn doc_place(&self, Parameters(p): Parameters) -> Result { - let mut opts = serde_json::Map::new(); - if let Some(v) = p.scale { - opts.insert("scale".into(), json!(v)); - } - if let Some(v) = p.fit { - opts.insert("fit".into(), json!(v)); - } - if let Some(v) = p.center { - opts.insert("center".into(), json!(v)); - } - let opts = Value::Object(opts); + let opts = place_options(&p); // Bridge mode runs the app on this machine, which reads the path itself like any command. let mut bridge_params = opts.clone(); if let Some(o) = bridge_params.as_object_mut() { @@ -790,7 +865,7 @@ impl PhotocraftMcp { } #[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \ - (psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. Returns warnings about anything the format cannot hold.")] + (psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")] async fn doc_save(&self, Parameters(p): Parameters) -> Result { self.save_impl(p).await } @@ -937,7 +1012,11 @@ impl PhotocraftMcp { let was_error = response.is_err(); let result = match response { Ok(value) => json!({"ok": true, "result": value}), - Err(error) => json!({"ok": false, "error": error.to_string()}), + Err(error) => { + let mut obj = json!({"ok": false, "error": error.to_string()}); + attach_memory_error_details(&mut obj, &error); + obj + } }; if let Err(error) = reply_budget.charge(&result) { failed += 1; @@ -1060,6 +1139,10 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), }; + let tiff_compression = match photocraft_engine::file_cmds::tiff_compression_param(&json!({"tiffCompression": p.tiff_compression}), "save") { + Ok(c) => c, + Err(e) => return Ok(fail(e.to_string())), + }; 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}"))); } @@ -1071,12 +1154,14 @@ impl PhotocraftMcp { if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless - // does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats - // saving with defaults while the caller believes their quality or format was applied. + // does), the Targa bits, the DDS options and the TIFF compression, transparency and + // pyramid, so the other options are headless-only. Saying so beats saving with + // defaults while the caller believes their quality or format was applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), p.tiff_layers.then_some("tiffLayers"), + p.pcraft_full_composite.is_some().then_some("pcraftFullComposite"), dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] @@ -1099,12 +1184,32 @@ impl PhotocraftMcp { if p.dds_mipmaps { params["ddsMipmaps"] = json!(true); } + // The app's TIFF Options answer, as its Save As takes it. + if let Some(c) = &p.tiff_compression { + params["tiffCompression"] = json!(c); + } + if let Some(t) = p.tiff_transparency { + params["tiffTransparency"] = json!(t); + } + if p.tiff_pyramid { + params["tiffPyramid"] = json!(true); + } return to_result(b.call("app.save", params).await); } let Some(r) = self .headless_op(move |h| { - let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: p.tiff_layers, + tiff_transparency: p.tiff_transparency, + tga_bits, + pcraft_full_composite: p.pcraft_full_composite.unwrap_or(false), + ..Default::default() + }; opts.encode.dpx_bits = dpx_bits; + if let Some(c) = tiff_compression { + opts.encode.tiff_compression = c; + } + opts.encode.tiff_pyramid = p.tiff_pyramid; 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; @@ -1253,6 +1358,66 @@ mod tests { assert_eq!(docs, 0, "the step after the budget ran out did not run"); } + #[tokio::test(flavor = "multi_thread")] + async fn mcp_doc_open_schema_and_structured_error() { + let schema = &PhotocraftMcp::tool_router().map["doc_open"].attr.input_schema; + assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean"); + let omitted: OpenParams = serde_json::from_value(json!({"path": "file.psd"})).unwrap(); + assert!(!omitted.approve_high_memory); + let approved: OpenParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": true})).unwrap(); + assert!(approved.approve_high_memory); + for invalid in [Value::Null, json!("true"), json!(1)] { + assert!(serde_json::from_value::(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err()); + } + + let err = AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { + required: 6000, + soft_limit: 3000, + hard_limit: 9000, + available_ram: Some(12000), + }); + let res = to_result(Err(err)).unwrap(); + assert_eq!(res.is_error, Some(true)); + let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text; + let v: Value = serde_json::from_str(text).unwrap(); + assert_eq!(v["required"], 6000); + assert_eq!(v["soft"], 3000); + assert_eq!(v["hard"], 9000); + assert_eq!(v["available"], 12000); + } + + /// #10897: the MCP `doc_place` tool takes the same strict, defaulted approval as `doc_open` + /// and forwards it as the command's `approveHighMemory` option. A hard refusal keeps its + /// typed `required`/`hardLimit` fields and claims nothing about a soft limit or capacity. + #[tokio::test(flavor = "multi_thread")] + async fn mcp_doc_place_schema_approval_and_structured_hard_error() { + let schema = &PhotocraftMcp::tool_router().map["doc_place"].attr.input_schema; + assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean"); + let omitted: PlaceParams = serde_json::from_value(json!({"path": "file.psd"})).unwrap(); + assert!(!omitted.approve_high_memory); + assert!(place_options(&omitted).get("approveHighMemory").is_none(), "false is not forwarded"); + let approved: PlaceParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": true})).unwrap(); + assert!(approved.approve_high_memory); + assert_eq!(place_options(&approved)["approveHighMemory"], true); + let explicit_false: PlaceParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": false})).unwrap(); + assert!(!explicit_false.approve_high_memory); + for invalid in [Value::Null, json!("true"), json!(1)] { + assert!(serde_json::from_value::(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}"); + } + + let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 }); + let res = to_result(Err(err)).unwrap(); + assert_eq!(res.is_error, Some(true)); + let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text; + let v: Value = serde_json::from_str(text).unwrap(); + assert_eq!(v["required"], 9_000); + assert_eq!(v["hard"], 8_000); + assert_eq!(v["hardLimit"], 8_000); + for absent in ["soft", "softLimit", "available", "availableRam"] { + assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}"); + } + } + /// The MCP server applies finished jobs before every tool call too (#503). #[tokio::test(flavor = "multi_thread")] async fn mcp_tools_see_a_finished_background_job() { diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 4dd1a5df05..3e068258fe 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -1,7 +1,7 @@ //! Capability-based filesystem policy for untrusted automation paths. use std::io::{Read, Write}; -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Components, Path, PathBuf, Prefix}; use std::sync::Arc; use cap_std::ambient_authority; @@ -16,16 +16,18 @@ const DENIED: &str = "automation filesystem access is not granted"; #[derive(Clone)] struct RootCapability { dir: Arc, - /// The root as an absolute path (trusted launch configuration), to recognise absolute request + /// The canonical root path (trusted launch configuration), to recognise absolute request /// paths that name a file beneath it. path: PathBuf, + /// The trusted launch spelling also names the same held capability, even through a link. + configured_path: PathBuf, } /// Separate directory capabilities for automation reads and writes. /// /// Root paths are trusted launch-time configuration. Request paths are always /// untrusted, forward-slash relative paths resolved by `cap-std` beneath the -/// held directory handle, or absolute paths whose text lies beneath the root +/// held directory handle, or absolute paths beneath the canonical or configured root spelling /// (translated to relative ones, see [`request_path`]). Parent traversal, /// alternate separators, Windows prefixes and empty components are rejected /// before I/O. @@ -85,6 +87,13 @@ impl AuthorizedWorkspace { /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> { + self.write_stream(path, &mut |w| w.write_all(bytes)) + } + + /// [`Self::write`] with the file's bytes produced by `write` straight into the temporary + /// file (it may seek back to patch lengths), so a large PSD/PSB is never held in memory + /// whole (#5333). The same refusals, the same atomic replace. + pub fn write_stream(&self, path: &str, write: &mut photocraft_format::atomic::StreamWriter<'_>) -> Result<(), AutomationError> { let relative = request_path(path, self.write.as_ref())?; let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?; let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -108,16 +117,23 @@ impl AuthorizedWorkspace { let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|file| { + let opened = root.dir.open_with(&tmp, &options); + // Removes the temporary file on every way out but a rename, a panicking writer included + // (unwinding closes the handle inside the closure first), and only one this call made. + let mut cleanup = TempRemoval { dir: &root.dir, path: &tmp, armed: opened.is_ok() }; + let written = opened.and_then(|file| { let mut file = file.into_std(); - file.write_all(bytes)?; + write(&mut file)?; #[cfg(unix)] { use std::os::fd::AsFd; // Best effort, and only when there is a file being replaced: a save still // succeeds for a document whose Finder tag could not come along. if let Ok(old) = root.dir.open(&relative) { + let old = old.into_std(); let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd()); + // The creation time too (#9388), through the same handles. + let _ = photocraft_format::atomic::meta::copy_created_file(&old, &file); } } // Falls back to a plain fsync on shares that refuse a full flush (#1336). @@ -125,9 +141,9 @@ impl AuthorizedWorkspace { }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { - let _ = root.dir.remove_file(&tmp); return Err(file_error("write", path, e)); } + cleanup.armed = false; // Flush the directory entry (Unix); best effort, the new bytes are already in place. #[cfg(unix)] { @@ -140,6 +156,21 @@ impl AuthorizedWorkspace { } } +/// Removes a temporary file on drop unless `armed` was cleared after its rename. +struct TempRemoval<'a> { + dir: &'a Dir, + path: &'a Path, + armed: bool, +} + +impl Drop for TempRemoval<'_> { + fn drop(&mut self) { + if self.armed { + let _ = self.dir.remove_file(self.path); + } + } +} + /// Whether `name`'s extension is one of the formats PhotoCraft saves or exports. fn written_kind(name: &str) -> bool { let Some((_, ext)) = name.rsplit_once('.') else { return false }; @@ -190,35 +221,60 @@ fn open_root(path: Option<&Path>, authority: &str) -> Result) -> Result { - let Some(root) = root.filter(|_| Path::new(raw).is_absolute()) else { return relative_path(raw) }; - let mut request = Path::new(raw).components(); - for want in root.path.components() { - let same = match (request.next(), want) { - (Some(Component::Prefix(a)), Component::Prefix(b)) => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()), - (Some(got), want) => got == want, - (None, _) => false, + let path = Path::new(raw); + let Some(root) = root.filter(|_| path.is_absolute()) else { return relative_path(raw) }; + // Prefer the trusted launch spelling: it can itself contain `..`. Only that + // exact granted prefix is exempt; traversal in the remaining request still fails below. + let request = path_beneath_root(path, &root.configured_path) + .or_else(|| path_beneath_root(path, &root.path)) + .ok_or_else(|| path_error(raw, "absolute paths must be inside the automation root"))?; + let rest = request.map(|c| c.as_os_str().to_str().ok_or_else(|| path_error(raw, "path is not UTF-8"))).collect::, _>>()?; + relative_path(&rest.join("/")) +} + +/// Strip a granted root prefix without resolving the client path with ambient authority. +/// Windows ASCII case variants name the same grant; the unstripped suffix keeps its case. +fn path_beneath_root<'a>(path: &'a Path, root: &Path) -> Option> { + let mut request = path.components(); + for want in root.components() { + let same = match (request.next()?, want) { + (Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) { + // std::fs::canonicalize uses verbatim paths on Windows. Accept their ordinary + // drive/UNC spelling too, without treating a device prefix as a disk prefix. + (Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b), + (Prefix::UNC(a, b) | Prefix::VerbatimUNC(a, b), Prefix::UNC(c, d) | Prefix::VerbatimUNC(c, d)) => { + a.eq_ignore_ascii_case(c) && b.eq_ignore_ascii_case(d) + } + _ => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()), + }, + #[cfg(windows)] + (Component::Normal(a), Component::Normal(b)) => a.eq_ignore_ascii_case(b), + (got, want) => got == want, }; if !same { - return Err(path_error(raw, "absolute paths must be inside the automation root")); + return None; } } - let rest: Vec<&str> = request.map(|c| c.as_os_str().to_str().unwrap_or("\u{FFFD}")).collect(); - relative_path(&rest.join("/")) + Some(request) } fn relative_path(raw: &str) -> Result { @@ -322,6 +378,8 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati | "layer.smartObjects.editContents" | "layer.smartObjects.convertToLayers" | "layer.smartObjects.saveContents" + | "layer.smartObjects.exportSvg" + | "layer.smartObjects.updateSvg" ); if (id.starts_with("layer.smartObjects.") && !in_memory_smart_command) || matches!( @@ -371,9 +429,13 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool { let keys: &[&str] = match id { "image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"], "filter.distort.displace" => &["mapPath"], - "layer.quickExportAsPng" | "layer.exportAs" | "image.applyDataSet" | "layer.smartObjects.editContents" | "layer.smartObjects.convertToLayers" => { - &["path"] - } + "layer.quickExportAsPng" + | "layer.exportAs" + | "image.applyDataSet" + | "layer.smartObjects.editContents" + | "layer.smartObjects.convertToLayers" + | "layer.smartObjects.exportSvg" + | "layer.smartObjects.updateSvg" => &["path"], "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. diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index ad5138ef12..5ea4a5b3fe 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -122,6 +122,52 @@ fn write_replaces_atomically_and_leaves_no_temp_file() { assert!(temp_files(&inside).is_empty()); } +/// #5333: a streamed write (a PSD/PSB serialized as it goes, lengths patched by seeking back) +/// replaces the file the same way; a writer that fails leaves the original and no temp file. +#[test] +fn write_stream_seeks_back_and_keeps_the_original_on_failure() { + use std::io::SeekFrom; + let (inside, _, workspace) = roots("stream"); + workspace.write("big.psb", b"original").unwrap(); + workspace + .write_stream("big.psb", &mut |w| { + w.write_all(b"....body")?; + w.seek(SeekFrom::Start(0))?; + w.write_all(b"head")?; + w.seek(SeekFrom::End(0))?; + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(inside.join("big.psb")).unwrap(), b"headbody"); + let failed = workspace.write_stream("big.psb", &mut |w| { + w.write_all(b"partial")?; + Err(std::io::Error::other("encoder failed")) + }); + assert!(failed.is_err()); + assert_eq!(std::fs::read(inside.join("big.psb")).unwrap(), b"headbody"); + assert!(temp_files(&inside).is_empty()); + // The same refusals as a plain write. + std::fs::write(inside.join("notes.txt"), b"keep").unwrap(); + assert!(workspace.write_stream("notes.txt", &mut |w| w.write_all(b"x")).is_err()); + assert_eq!(std::fs::read(inside.join("notes.txt")).unwrap(), b"keep"); +} + +/// A writer that panics leaves the original and no temporary file behind. +#[test] +fn write_stream_cleans_up_after_a_panicking_writer() { + let (inside, _, workspace) = roots("stream-panic"); + workspace.write("big.psb", b"original").unwrap(); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = workspace.write_stream("big.psb", &mut |w| { + w.write_all(b"partial")?; + panic!("encoder bug") + }); + })); + assert!(unwound.is_err()); + assert_eq!(std::fs::read(inside.join("big.psb")).unwrap(), b"original"); + assert!(temp_files(&inside).is_empty()); +} + /// A directory at the target is refused before anything is opened, even under an image name. #[test] fn write_refuses_a_directory_target_before_opening_it() { @@ -162,6 +208,22 @@ fn write_refuses_a_fifo_target_without_blocking() { assert!(temp_files(&inside).is_empty()); } +/// #9401 review: replacing a file through the automation path keeps its creation time, the +/// same as a save through `atomic_write` does on macOS (#9388). +#[cfg(target_os = "macos")] +#[test] +fn write_keeps_the_replaced_files_creation_time() { + use std::os::macos::fs::FileTimesExt; + let (inside, _, workspace) = roots("created"); + let path = inside.join("doc.psd"); + workspace.write("doc.psd", b"original").unwrap(); + let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap(); + workspace.write("doc.psd", b"replacement").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"replacement"); + assert_eq!(std::fs::metadata(&path).unwrap().created().unwrap(), born, "the creation time survives the automation write"); +} + /// #7095: replacing a file through the automation path keeps the old file's extended /// attributes, the same as a save through `atomic_write` does. #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] @@ -510,6 +572,132 @@ fn registry_filesystem_path_params_are_classified() { assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}"); } +#[test] +fn native_vector_preview_updates_are_not_an_untrusted_automation_capability() { + let params = serde_json::json!({"document":1,"layer":1,"contentsId":1,"fingerprint":"00".repeat(32),"extension":"ai","sourceBase64":"JVBERi0=","previewSvg":""}); + assert!(authorize_engine_command("layer.smartObjects.updateVectorSource", ¶ms).is_err()); + assert!(authorize_desktop_engine_command("layer.smartObjects.updateVectorSource", ¶ms).is_err()); +} + +#[test] +fn svg_smart_handoff_is_memory_only_and_linked_sources_stay_denied() { + let mut headless = crate::Headless::new(); + headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap(); + let svg = r#""#; + let inserted = photocraft_engine::file_cmds::place_bytes( + &mut headless.session, + "Artwork.svg", + svg.as_bytes().to_vec(), + None, + &serde_json::json!({"smartObject":true}), + ) + .unwrap(); + let layer = inserted["layer"].clone(); + let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer": layer})).unwrap(); + assert_eq!(handoff["svg"], svg); + let params = serde_json::json!({ + "document": handoff["document"], "layer": layer, + "contentsId": handoff["contentsId"], "fingerprint": handoff["fingerprint"], + "svg": svg.replace("red", "blue"), + }); + assert!(authorize_desktop_engine_command("layer.smartObjects.updateSvg", ¶ms).is_ok()); + assert_eq!(headless.command_run("layer.smartObjects.updateSvg", params.clone()).unwrap()["updated"], true); + for id in ["layer.smartObjects.exportSvg", "layer.smartObjects.updateSvg"] { + let mut ambient = params.clone(); + ambient["path"] = serde_json::json!("/outside/source.svg"); + assert!(authorize_engine_command(id, &ambient).is_err(), "{id}"); + assert!(authorize_desktop_engine_command(id, &ambient).is_err(), "{id}"); + assert!(headless.command_run(id, ambient).is_err(), "{id}"); + } + let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc); + let id = photocraft_doc::LayerId(layer.as_u64().unwrap()); + let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("SVG smart object") }; + sm.source = photocraft_doc::SmartSource::Linked { path: "/outside/source.svg".into() }; + let before = headless.session.active().unwrap().doc.clone(); + assert!(headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer": layer})).is_err()); + assert!(headless.command_run("layer.smartObjects.updateSvg", params).is_err()); + assert_eq!(headless.session.active().unwrap().doc, before); + assert!(headless.command_run("layer.smartObjects.exportContents", serde_json::json!({"layer": layer, "path": "/outside/export.svg"})).is_err()); +} + +#[test] +fn svg_update_cannot_replay_desktop_lut_file_in_any_shared_instance() { + let (_, outside, _) = roots("svg-stored-filter"); + let lut = outside.join("private.cube"); + let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + std::fs::write(&lut, cube).unwrap(); + for shared_only in [false, true] { + let mut headless = crate::Headless::new(); + headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap(); + let svg = r#""#; + let pasted = photocraft_engine::file_cmds::place_bytes( + &mut headless.session, + "Artwork.svg", + svg.as_bytes().to_vec(), + None, + &serde_json::json!({"smartObject":true}), + ) + .unwrap(); + let target = photocraft_doc::LayerId(pasted["layer"].as_u64().unwrap()); + let filtered = if shared_only { + let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc); + let duplicate = doc.layer(target).unwrap().duplicate(); + let id = duplicate.id; + doc.layers.push(duplicate); + id + } else { + target + }; + headless.session.select_layer(filtered).unwrap(); + // Record a natural desktop Color Lookup operation with its original file params, + // then restore the untrusted automation hook as for an imported document. + let authorization = headless.session.authorize.take(); + headless.session.execute("image.adjustments.colorLookup", serde_json::json!({"file":lut.to_string_lossy()})).unwrap(); + headless.session.authorize = authorization; + let photocraft_doc::LayerContent::Smart(sm) = &headless.session.active().unwrap().doc.layer(filtered).unwrap().content else { panic!("smart object") }; + assert_eq!(sm.smart_filters.last().unwrap().params["file"], lut.to_string_lossy().as_ref()); + let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer":target.0})).unwrap(); + let params = serde_json::json!({"document":handoff["document"],"layer":target.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"svg":svg.replace("red","blue")}); + let before = headless.session.active().unwrap().doc.clone(); + let revision = headless.session.active().unwrap().revision; + let error = headless.command_run("layer.smartObjects.updateSvg", params.clone()).unwrap_err(); + assert!(error.to_string().contains("ambient filesystem paths"), "{error}"); + assert_eq!(headless.session.active().unwrap().doc, before); + assert_eq!(headless.session.active().unwrap().revision, revision); + // A hidden stored filter is not executed, so its unused filename grants no access. + let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc); + let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(filtered).unwrap().content else { panic!("smart object") }; + sm.smart_filters.last_mut().unwrap().visible = false; + assert_eq!(headless.command_run("layer.smartObjects.updateSvg", params).unwrap()["updated"], true); + } +} + +#[test] +fn svg_update_keeps_inline_and_builtin_smart_filters_working() { + let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + for filter_params in [serde_json::json!({"lut":"warm"}), serde_json::json!({"data":cube,"fileName":"look.cube"})] { + let mut headless = crate::Headless::new(); + headless.command_run("file.new", serde_json::json!({"width":16,"height":16})).unwrap(); + let svg = r#""#; + let pasted = photocraft_engine::file_cmds::place_bytes( + &mut headless.session, + "Artwork.svg", + svg.as_bytes().to_vec(), + None, + &serde_json::json!({"smartObject":true}), + ) + .unwrap(); + let id = photocraft_doc::LayerId(pasted["layer"].as_u64().unwrap()); + headless.command_run("image.adjustments.colorLookup", filter_params).unwrap(); + let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer":id.0})).unwrap(); + let result = headless.command_run("layer.smartObjects.updateSvg",serde_json::json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"svg":svg.replace("red","blue")})).unwrap(); + assert_eq!(result["updated"], true); + let photocraft_doc::LayerContent::Smart(sm) = &headless.session.active().unwrap().doc.layer(id).unwrap().content else { panic!("smart object") }; + assert_eq!(sm.smart_filters.len(), 1); + assert!(!sm.cache.as_ref().unwrap().content_bounds().is_empty()); + } +} + fn documents_filesystem_path_params(id: &str, params: &str) -> bool { // These commands return settings or installed-profile paths; they do not accept a host // filesystem path as an automation input. @@ -528,6 +716,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "shape.presets.new" | "path.list" | "path.info" + | "path.addToSelection" + | "path.subtractFromSelection" | "path.set" | "path.transform" | "path.moveAnchors" @@ -536,6 +726,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.convertPoint" | "path.addAnchor" | "path.deleteAnchor" + | "path.mergeShapeComponents" + | "path.setOperation" | "path.clippingPath.set" | "path.rename" | "path.stroke" @@ -548,6 +740,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "layer.vectorMask.info" | "paint.symmetryFromPath" | "edit.defineCustomShape" + // `"as":"path"` pastes the clipboard as a vector path (#10694). + | "edit.paste" | "layer.combineShapes.unite" | "layer.combineShapes.subtractFrontShape" | "layer.combineShapes.intersectShapeAreas" @@ -638,6 +832,217 @@ fn absolute_paths_beneath_the_root_are_translated_and_others_refused() { assert_eq!(std::fs::read(inside.join("a.bin")).unwrap(), b"one"); } +/// Equivalent drive, UNC and ASCII-case root spellings need no ambient path resolution. +#[cfg(windows)] +#[test] +fn canonical_windows_prefixes_match_without_ambient_resolution() { + for (request, root) in [ + (r"c:\root\new.bin", r"\\?\C:\root"), + (r"\\?\C:\root\new.bin", r"C:\root"), + (r"\\server\share\root\new.bin", r"\\?\UNC\SERVER\SHARE\root"), + (r"c:\ROOT\new.bin", r"\\?\C:\root"), + (r"\\?\C:\ROOT\new.bin", r"c:\root"), + (r"\\server\share\ROOT\new.bin", r"\\?\UNC\SERVER\SHARE\root"), + ] { + let remaining = path_beneath_root(Path::new(request), Path::new(root)).unwrap(); + assert_eq!(remaining.as_path(), Path::new("new.bin")); + } + for request in [r"C:\root-sibling\new.bin", r"D:\root\new.bin", r"\\.\C:\root\new.bin", r"\\other\share\root\new.bin"] { + assert!(path_beneath_root(Path::new(request), Path::new(r"\\?\C:\root")).is_none()); + } +} + +/// Case variants of the granted Windows prefix still use its held directory capability. +#[cfg(windows)] +#[test] +fn windows_root_case_variants_keep_io_inside_the_grant() { + let (inside, outside, workspace) = roots("root-case"); + let configured = PathBuf::from(inside.to_string_lossy().to_ascii_uppercase()); + let canonical = PathBuf::from(std::fs::canonicalize(&inside).unwrap().to_string_lossy().to_ascii_uppercase()); + for spelling in [&configured, &canonical] { + let path = spelling.join("new.png"); + workspace.write(&path.to_string_lossy(), b"inside").unwrap(); + assert_eq!(workspace.read(&path.to_string_lossy()).unwrap(), b"inside"); + assert!(workspace.write(&spelling.join("..").join("outside").join("blocked.bin").to_string_lossy(), b"no").is_err()); + } + assert_eq!(std::fs::read(inside.join("new.png")).unwrap(), b"inside"); + assert!(workspace.write(&configured.with_file_name("INSIDE-SIBLING").join("blocked.bin").to_string_lossy(), b"no").is_err()); + assert!(!outside.join("blocked.bin").exists()); + // Case normalization must not bypass the refusal to replace unrelated existing files. + std::fs::write(inside.join("notes.bin"), b"keep").unwrap(); + assert!(workspace.write(&configured.join("notes.bin").to_string_lossy(), b"no").is_err()); + assert_eq!(std::fs::read(inside.join("notes.bin")).unwrap(), b"keep"); +} + +/// Unix roots with different case must not become aliases of the granted prefix. +#[cfg(unix)] +#[test] +fn unix_root_prefixes_remain_case_sensitive() { + assert!(path_beneath_root(Path::new("/Root/new.bin"), Path::new("/root")).is_none()); +} + +#[test] +fn configured_parent_components_accept_resolved_absolute_paths() { + let (inside, outside, _) = roots("resolved-parent-root"); + let configured = inside.join("..").join("inside"); + let workspace = AuthorizedWorkspace::new(Some(&configured), Some(&configured)).unwrap(); + let resolved = std::fs::canonicalize(&inside).unwrap(); + workspace.write(&resolved.join("new.bin").to_string_lossy(), b"new").unwrap(); + assert_eq!(workspace.read(&resolved.join("new.bin").to_string_lossy()).unwrap(), b"new"); + workspace.write(&configured.join("launch-spelling.bin").to_string_lossy(), b"trusted prefix").unwrap(); + assert_eq!(workspace.read(&configured.join("launch-spelling.bin").to_string_lossy()).unwrap(), b"trusted prefix"); + assert_eq!(workspace.read(&resolved.join("launch-spelling.bin").to_string_lossy()).unwrap(), b"trusted prefix"); + assert!(workspace.write(&configured.join("..").join("outside/escaped.bin").to_string_lossy(), b"no").is_err()); + assert!(!outside.join("escaped.bin").exists()); + assert!(workspace.write(&outside.join("blocked.bin").to_string_lossy(), b"no").is_err()); + assert!(!outside.join("blocked.bin").exists()); +} + +#[test] +fn relative_configured_root_accepts_resolved_absolute_paths() { + let relative = PathBuf::from("target").join(format!("automation-relative-root-{}", std::process::id())); + std::fs::create_dir_all(&relative).unwrap(); + let workspace = AuthorizedWorkspace::new(Some(&relative), Some(&relative)).unwrap(); + let resolved = std::fs::canonicalize(&relative).unwrap(); + workspace.write(&resolved.join("new.bin").to_string_lossy(), b"relative").unwrap(); + assert_eq!(workspace.read(&resolved.join("new.bin").to_string_lossy()).unwrap(), b"relative"); + std::fs::remove_file(resolved.join("new.bin")).unwrap(); + drop(workspace); + std::fs::remove_dir(resolved).unwrap(); +} + +#[cfg(unix)] +#[test] +fn trusted_root_names_are_not_validated_as_relative_request_names() { + let (inside, outside, _) = roots("trusted-root-names"); + let root = inside.join("aux"); + std::fs::create_dir(&root).unwrap(); + let alias = outside.join("CON"); + link_directory(&root, &alias); + let workspace = AuthorizedWorkspace::new(Some(&alias), Some(&alias)).unwrap(); + for (index, spelling) in [&root, &alias].into_iter().enumerate() { + let fresh = spelling.join(format!("new-{index}.bin")); + workspace.write(&fresh.to_string_lossy(), b"trusted").unwrap(); + assert_eq!(workspace.read(&fresh.to_string_lossy()).unwrap(), b"trusted"); + assert!(workspace.write(&fresh.to_string_lossy(), b"replacement").is_err(), "unknown existing files remain protected"); + assert!(workspace.write(&spelling.join("NUL").to_string_lossy(), b"no").is_err()); + } + assert!(!root.join("NUL").exists()); + remove_directory_link(&alias); +} + +// Windows junctions exercise the same root alias without requiring Developer Mode. +#[cfg(any(unix, windows))] +fn link_directory(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + assert!(std::process::Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap().status.success()); +} + +#[cfg(any(unix, windows))] +fn remove_directory_link(link: &Path) { + #[cfg(unix)] + std::fs::remove_file(link).unwrap(); + #[cfg(windows)] + std::fs::remove_dir(link).unwrap(); +} + +#[cfg(any(unix, windows))] +#[test] +fn linked_root_accepts_both_spellings_and_new_nested_exports() { + let (inside, outside, _) = roots("linked-root"); + let link = outside.join("root-link"); + link_directory(&inside, &link); + let workspace = AuthorizedWorkspace::new(Some(&link), Some(&link)).unwrap(); + let resolved = std::fs::canonicalize(&inside).unwrap(); + for (i, spelling) in [resolved, link.clone()].iter().enumerate() { + let name = format!("new-{i}.bin"); + workspace.write(&spelling.join(&name).to_string_lossy(), b"new").unwrap(); + assert_eq!(workspace.read(&spelling.join(&name).to_string_lossy()).unwrap(), b"new"); + assert_eq!(workspace.read(&name).unwrap(), b"new"); + let mut export = workspace.export_sequence(&spelling.join(format!("frames-{i}")).join("nested").to_string_lossy()).unwrap(); + export.write("frame.png", b"frame").unwrap(); + export.commit(); + assert_eq!(std::fs::read(inside.join(format!("frames-{i}/nested/frame.png"))).unwrap(), b"frame"); + } + remove_directory_link(&link); +} + +/// TEMP can name the same directory differently from canonicalize (8.3 on Windows). +/// A junction makes that mismatch deterministic even when 8.3 names are disabled. +#[cfg(windows)] +#[test] +fn root_alias_tests_work_with_an_aliased_windows_temp() { + let (inside, outside, _) = roots("aliased-temp"); + let temp_alias = outside.join("temp-alias"); + link_directory(&inside, &temp_alias); + let exe = std::env::current_exe().unwrap(); + for test in [ + "workspace::tests::ungranted_absolute_aliases_and_link_escapes_are_refused", + "workspace::tests::retargeting_root_alias_does_not_regrant_the_held_authority", + ] { + // Set TEMP only in the child; changing the process environment races other tests. + let output = std::process::Command::new(&exe).args(["--exact", test, "--nocapture"]).env("TEMP", &temp_alias).env("TMP", &temp_alias).output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{test}: {stdout}\n{stderr}"); + assert!(stdout.contains("1 passed;"), "child must run the requested test: {stdout}"); + } + remove_directory_link(&temp_alias); +} + +#[cfg(any(unix, windows))] +#[test] +fn ungranted_absolute_aliases_and_link_escapes_are_refused() { + let (inside, outside, workspace) = roots("request-alias"); + // TEMP may use an 8.3 name or a junction. A grant through `alias` below recognises + // that configured alias and the canonical target, not every other name for `inside`. + let resolved_inside = std::fs::canonicalize(&inside).unwrap(); + let alias = outside.join("inside-alias"); + link_directory(&inside, &alias); + workspace.write("new.bin", b"inside").unwrap(); + assert!(workspace.read(&alias.join("new.bin").to_string_lossy()).is_err()); + assert!(workspace.write(&alias.join("ungranted.bin").to_string_lossy(), b"no").is_err()); + assert!(!inside.join("ungranted.bin").exists()); + std::fs::write(outside.join("secret.bin"), b"outside").unwrap(); + let escape = inside.join("escape"); + link_directory(&outside, &escape); + assert!(workspace.read(&escape.join("secret.bin").to_string_lossy()).is_err()); + assert!(workspace.write(&escape.join("blocked.bin").to_string_lossy(), b"no").is_err()); + assert!(workspace.export_sequence(&escape.join("frames").join("nested").to_string_lossy()).is_err()); + assert!(!outside.join("blocked.bin").exists()); + assert!(!outside.join("frames").exists()); + assert_eq!(std::fs::read(outside.join("secret.bin")).unwrap(), b"outside"); + let read_only = AuthorizedWorkspace::new(Some(&alias), Some(&outside)).unwrap(); + assert_eq!(read_only.read(&resolved_inside.join("new.bin").to_string_lossy()).unwrap(), b"inside"); + assert!(read_only.write(&alias.join("denied.bin").to_string_lossy(), b"no").is_err()); + assert!(!inside.join("denied.bin").exists()); + remove_directory_link(&escape); + remove_directory_link(&alias); +} + +#[cfg(any(unix, windows))] +#[test] +fn retargeting_root_alias_does_not_regrant_the_held_authority() { + let (inside, outside, _) = roots("retargeted-root"); + let resolved_inside = std::fs::canonicalize(&inside).unwrap(); + let alias = outside.join("root-alias"); + link_directory(&inside, &alias); + let workspace = AuthorizedWorkspace::new(Some(&alias), Some(&alias)).unwrap(); + remove_directory_link(&alias); + link_directory(&outside, &alias); + std::fs::write(outside.join("secret.bin"), b"secret").unwrap(); + workspace.write("held.bin", b"held").unwrap(); + assert_eq!(workspace.read(&resolved_inside.join("held.bin").to_string_lossy()).unwrap(), b"held"); + assert!(workspace.read(&alias.join("secret.bin").to_string_lossy()).is_err()); + workspace.write(&alias.join("held-alias.bin").to_string_lossy(), b"original grant").unwrap(); + assert_eq!(std::fs::read(inside.join("held-alias.bin")).unwrap(), b"original grant"); + assert!(!outside.join("held-alias.bin").exists()); + assert_eq!(std::fs::read(outside.join("secret.bin")).unwrap(), b"secret"); + remove_directory_link(&alias); +} + /// Each authority is checked against its own root, and none granted refuses absolute paths. #[test] fn absolute_paths_are_checked_against_the_authority_s_own_root() { diff --git a/crates/automation/tests/it/layered_save_dirty.rs b/crates/automation/tests/it/layered_save_dirty.rs index 731a52e850..060560a62f 100644 --- a/crates/automation/tests/it/layered_save_dirty.rs +++ b/crates/automation/tests/it/layered_save_dirty.rs @@ -80,3 +80,28 @@ fn a_flat_export_is_a_copy_and_leaves_the_document_dirty() { assert!(!headless.session.active().unwrap().is_dirty()); std::fs::remove_dir_all(dir).unwrap(); } + +/// The control channel's `doc.save` takes the TIFF Options (#8827): compression, Save +/// Transparency and Save Image Pyramid; a bad value is an error and writes nothing. +#[test] +fn doc_save_takes_tiff_options() { + let dir = tmp("tiff-options"); + let mut headless = Headless::trusted_local(); + headless.handle("doc.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let path = dir.join("o.tif"); + let p = path.to_str().unwrap(); + headless.handle("doc.save", json!({"path": p, "tiffCompression": "zip", "tiffTransparency": false, "tiffPyramid": true})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), 3); + assert!(info.pages.iter().all(|pg| pg.compression == 8)); + assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha()); + let bad = dir.join("bad.tif"); + for params in [json!({"tiffCompression": "jpeg"}), json!({"tiffPyramid": 1}), json!({"tiffTransparency": "no"})] { + let mut params = params; + params["path"] = json!(bad.to_str().unwrap()); + assert!(headless.handle("doc.save", params.clone()).is_err(), "{params}"); + } + assert!(!bad.exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index f701bd705b..0fdd94a4a8 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -10,6 +10,7 @@ mod layered_save_dirty; mod mcp; mod mcp_wire; mod missing_font_warnings; +mod native_full_composite; mod native_save_name; mod openraster; mod raw_defaults; diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index 0ac5c17a5f..2320bf7adc 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -311,6 +311,10 @@ async fn errors_are_tool_errors_not_crashes() { let r = call(&client, "command_run", json!({"id": "no.such.command"})).await; assert_eq!(r.is_error, Some(true)); assert!(text(&r).contains("unknown command")); + // #3674: the error names the nearest registered id. + let r = call(&client, "command_run", json!({"id": "engine.commands"})).await; + assert_eq!(r.is_error, Some(true)); + assert!(text(&r).contains("did you mean `command.list`"), "{}", text(&r)); let r = call(&client, "doc_inspect", json!({"index": 0})).await; assert_eq!(r.is_error, Some(true)); let r = call(&client, "doc_open", json!({"path": "/definitely/missing.png"})).await; @@ -356,6 +360,8 @@ async fn bridge_doc_save_rejects_headless_only_options_instead_of_saving_with_de json!({"path": "out.jpg", "quality": 50}), json!({"path": "out.png", "format": "png"}), json!({"path": "out.tif", "tiffLayers": true}), + json!({"path": "out.pcraft", "pcraftFullComposite": true}), + json!({"path": "out.pcraft", "pcraftFullComposite": false}), json!({"path": "out.pcraft", "index": 0}), ] { let r = call(&client, "doc_save", params.clone()).await; @@ -460,6 +466,53 @@ async fn headless_doc_save_writes_dds_options() { cleanup(&dir); } +/// TIFF Options (#8827) reach the app's `app.save` through the bridge; a bad compression is +/// refused before anything is sent. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_forwards_tiff_options() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.tif", "tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true})).await); + assert_eq!(saved, json!({"saved": {"path": "out.tif", "tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true}})); + for bad in [json!({"path": "out.tif", "tiffCompression": "jpeg"}), json!({"path": "out.tif", "tiffPyramid": "yes"})] { + let r = call(&client, "doc_save", bad.clone()).await; + assert_eq!(r.is_error, Some(true), "{bad}: {}", text(&r)); + } + client.cancel().await.unwrap(); + app.abort(); +} + +/// Headless `doc_save`/`doc_export` write the TIFF Options asked for (#8827): compression, +/// Save Transparency and Save Image Pyramid. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_save_writes_tiff_options() { + let dir = tmp("tiff-options"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 200, "height": 100, "background": "transparent"})).await; + // (params, compression tag, alpha, directories) + for (tool, params, compression, alpha, dirs) in [ + ("doc_export", json!({"path": "plain.tif"}), 1, true, 1), + ("doc_export", json!({"path": "lzw.tif", "tiffCompression": "lzw"}), 5, true, 1), + ("doc_save", json!({"path": "zip.tif", "tiffCompression": "zip", "tiffTransparency": false}), 8, false, 1), + ("doc_save", json!({"path": "pyr.tif", "tiffPyramid": true, "tiffTransparency": true}), 1, true, 3), + ] { + let r = call(&client, tool, params.clone()).await; + assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); + let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert!(info.pages.iter().all(|p| p.compression == compression), "{params}: {:?}", info.pages); + assert_eq!(info.pages.len(), dirs, "{params}"); + assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{params}"); + } + for bad in [json!({"path": "bad.tif", "tiffCompression": "jpeg"}), json!({"path": "bad.tif", "tiffTransparency": "off"})] { + let r = call(&client, "doc_export", bad.clone()).await; + assert_eq!(r.is_error, Some(true), "{bad}: {}", text(&r)); + } + assert!(!dir.join("bad.tif").exists()); + client.cancel().await.unwrap(); + cleanup(&dir); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; diff --git a/crates/automation/tests/it/native_full_composite.rs b/crates/automation/tests/it/native_full_composite.rs new file mode 100644 index 0000000000..f699087cc6 --- /dev/null +++ b/crates/automation/tests/it/native_full_composite.rs @@ -0,0 +1,49 @@ +//! Scripted `.pcraft` saves write the full-size composite when they ask for it +//! (`pcraftFullComposite`), and leave it out by default, as the app's Save does. + +use photocraft_automation::{Headless, files}; +use photocraft_format::zip::ZipReader; +use serde_json::json; + +fn full_composite(bytes: &[u8]) -> ((u32, u32), Vec) { + let png = photocraft_format::read_full_composite(bytes).unwrap().expect("the manifest names the full-size composite"); + let image = photocraft_codecs::decode(&png).unwrap(); + (image.dimensions(), image.to_rgba8()) +} + +fn has_full_composite(bytes: &[u8]) -> bool { + photocraft_format::read_full_composite(bytes).unwrap().is_some() || ZipReader::new(bytes).unwrap().find("composite/full.png").is_some() +} + +#[test] +fn scripted_native_saves_write_the_full_size_composite_when_asked() { + let dir = std::env::temp_dir().join(format!( + "pc-native-full-composite-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut headless = Headless::trusted_local(); + headless.handle("doc.new", json!({"width": 40, "height": 30, "name": "Full"})).unwrap(); + headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap(); + let doc = headless.session.active().unwrap().doc.clone(); + let flattened = photocraft_compose::render(&doc, doc.bounds()).to_rgba8().pixels; + + let (bytes, _) = files::save_bytes(&doc, "Full.pcraft", None, &Default::default()).unwrap(); + assert!(!has_full_composite(&bytes), "off by default"); + let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() }; + let (bytes, _) = files::save_bytes(&doc, "Full.pcraft", None, &on).unwrap(); + assert_eq!(full_composite(&bytes), ((40, 30), flattened.clone())); + + let path = dir.join("Full.pcraft"); + headless.handle("doc.save", json!({"path": path.to_str().unwrap()})).unwrap(); + assert!(!has_full_composite(&std::fs::read(&path).unwrap()), "off by default"); + headless.handle("doc.save", json!({"path": path.to_str().unwrap(), "pcraftFullComposite": true})).unwrap(); + assert_eq!(full_composite(&std::fs::read(&path).unwrap()), ((40, 30), flattened)); + for bad in [json!("true"), json!(1)] { + let err = headless.handle("doc.save", json!({"path": dir.join("Bad.pcraft").to_str().unwrap(), "pcraftFullComposite": bad})).unwrap_err(); + assert!(format!("{err:?}").contains("`pcraftFullComposite` must be true or false"), "{bad}: {err:?}"); + assert!(!dir.join("Bad.pcraft").exists(), "{bad}: nothing is written"); + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/scratch_save.rs b/crates/automation/tests/scratch_save.rs new file mode 100644 index 0000000000..e61c607e8f --- /dev/null +++ b/crates/automation/tests/scratch_save.rs @@ -0,0 +1,55 @@ +//! A scripted `.pcraft` save refuses to write previews or a full-size composite rendered from +//! scratch tiles that could not be read. Its own test binary: the scratch store is per process. +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_automation::{AutomationError, files}; +use photocraft_doc::{ColorMode, Document, Layer, PixelFormat, SampleType, Size}; +use photocraft_geom::TileCoord; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn native_save_refuses_previews_rendered_from_unreadable_scratch() { + let dir = std::env::temp_dir().join(format!("photocraft-automation-scratch-save-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Scratch", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + for i in 0..2 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, byte) in bytes.iter_mut().enumerate() { + *byte = (j as u8).wrapping_mul(29).wrapping_add((i * 7) as u8 + 1); + } + } + doc.layers.push(layer); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + + let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() }; + // Refused while rendering the previews, before the bundle is assembled. + let err = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + let path = dir.join("Scratch.pcraft"); + let err = files::save(&doc, &path, None, &on, None).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + assert!(!path.exists()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for (_, tile) in doc.layers[0].surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + assert!(spill::check_integrity().is_ok()); + let (bytes, _) = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().layers, doc.layers); + drop((doc, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 3feeb3a97d..d547f064ed 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -225,34 +225,89 @@ pub fn parse_cube(text: &str) -> Result { err("no LUT_3D_SIZE in .cube file") } -/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue** -/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12 -/// bits in a file without a header. +/// Lustre `.3dl`: an optional `3DMESH` magic and `Mesh ` line, an optional +/// shaper line of input values, then integer RGB rows with **blue** varying fastest. The +/// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to +/// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube +/// root of the row count and the range is inferred from the largest value (#9121). pub fn parse_3dl(text: &str) -> Result { + let mut mesh: Option<(u32, u32)> = None; let mut rows: Vec<[f64; 3]> = Vec::new(); + // How many numbers the first numeric line carried: a shaper of exactly `n` values (n ≤ 3) + // is otherwise indistinguishable from a node row. + let mut first_len: Option = None; + // Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is + // on the 12-bit scale, as Photoshop 27.11 reads it (#9121). let mut header = false; for line in text.lines() { let line = line.split('#').next().unwrap_or("").trim(); - if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) { - header |= !line.is_empty(); + if line.is_empty() { + continue; + } + if line.starts_with(|c: char| c.is_ascii_alphabetic()) { + header = true; + // The only header keyword that carries data. A malformed or out-of-range `Mesh` + // line is ignored, as every text line always was. + let mut it = line.split_whitespace(); + if it.next().is_some_and(|k| k.eq_ignore_ascii_case("Mesh")) { + // A declaration is exactly two integer fields after the keyword. Anything else + // (a non-numeric field, too few or too many) is not a declaration: `mesh` stays + // unset so headerless inference applies, rather than recovering numbers from + // the wrong positions (#9078). + let fields: Vec<&str> = it.collect(); + if let [a, b] = fields.as_slice() + && let (Ok(n), Ok(bits)) = (a.parse::(), b.parse::()) + && (1..=7).contains(&n) + && (1..=16).contains(&bits) + { + mesh = Some((n, bits)); + } + } continue; } let v: Vec = line.split_whitespace().filter_map(|s| s.parse().ok()).collect(); + if v.is_empty() { + // The `3DMESH` magic carries no numbers, but it is a header all the same: a table + // behind it reads a 1023 peak on the 10-bit scale (#9121). + header = true; + continue; + } + first_len.get_or_insert(v.len()); if v.len() == 3 { rows.push([v[0], v[1], v[2]]); } // Lines with more than three numbers are the input shaper; it is assumed linear. } - let n = (rows.len() as f64).cbrt().round() as usize; - if n < 2 || n * n * n != rows.len() { - return err(format!("{} rows is not a cube", rows.len())); - } - let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); - // Photoshop 27.11 reads a headerless table peaking at 255 or 1023 on the 12-bit scale (#9121). - let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] }; - let scale = if max <= 1.0 { 1.0 } else { depths.iter().copied().find(|&s| max <= s).unwrap_or(max) }; + let (n, nodes, scale) = match mesh { + Some((in_bits, out_bits)) => { + let n = 2usize.pow(in_bits) + 1; + let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?; + // One row more than the declared cube, and the first row has exactly `n` values: + // that row is the shaper, not a 28th node of a 3³ cube (#9078). + let nodes = if rows.len() == expected + 1 && first_len == Some(n) { &rows[1..] } else { &rows[..] }; + if nodes.len() != expected { + return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len())); + } + // Under a valid declaration the node codes are integers in the declared range, so + // the declaration wins for every in-range value — including a small max, where 1 is + // 1/declared of full output, not full output itself (#9073). Only a node beyond the + // declared range is a wrong declaration, and the old inference keeps such a file + // loading as before. + let max = nodes.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); + let declared = 2f64.powi(i32::try_from(out_bits).unwrap_or(16)) - 1.0; + (n, nodes, if max > declared { inferred_3dl_scale(max, header) } else { declared }) + } + None => { + let n = (rows.len() as f64).cbrt().round() as usize; + if n < 2 || n * n * n != rows.len() { + return err(format!("{} rows is not a cube", rows.len())); + } + let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); + (n, &rows[..], inferred_3dl_scale(max, header)) + } + }; let mut data = vec![0.0f32; n * n * n * 3]; - for (i, row) in rows.iter().enumerate() { + for (i, row) in nodes.iter().enumerate() { let (r, g, b) = (i / (n * n), (i / n) % n, i % n); let at = ((b * n + g) * n + r) * 3; for k in 0..3 { @@ -262,6 +317,18 @@ pub fn parse_3dl(text: &str) -> Result { LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check() } +/// The code range a `.3dl` node table without a declared output depth was written with: the +/// smallest common integer depth that covers the largest value; a table of 0..1 floats passes +/// through. A headerless table has no 10-bit reading — 255 and 1023 are on the 12-bit scale, as +/// Photoshop 27.11 reads them (#9121). +fn inferred_3dl_scale(max: f64, header: bool) -> f64 { + if max <= 1.0 { + return 1.0; + } + let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] }; + depths.iter().copied().find(|&s| max <= s).unwrap_or(max) +} + /// SpeedGrade `.look`: `` and a hex `` string of little-endian float32 RGB triplets /// (red fastest). pub fn parse_look(text: &str) -> Result { @@ -515,8 +582,9 @@ DOMAIN_MAX 0.5 1 1 let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec(); assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}"); } - // A header keeps the inferred 10-bit depth. - let white = parse_3dl(&format!("3DMESH\nMesh 1 10\n{}", table(1023))).unwrap().data[21..24].to_vec(); + // A header keeps the inferred 10-bit depth. The bare `3DMESH` magic is a header without a + // cube declaration; `Mesh 1 10` would declare a 3³ cube and refuse this 2³ table (#9078). + let white = parse_3dl(&format!("3DMESH\n{}", table(1023))).unwrap().data[21..24].to_vec(); assert_eq!(white, [1.0; 3]); } @@ -535,6 +603,130 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(l.data, LutFile::identity(2).data); } + /// A `size`³ inverse-identity `.3dl` (blue fastest): node (r,g,b) outputs `max - max·v/(size-1)` + /// per channel, with a uniform shaper of `size` codes up to 1023. + fn mesh_3dl(n_bits: u32, out_bits: u32, size: usize, max: f64, shaper: bool) -> String { + let mut text = format!("3DMESH\nMesh {n_bits} {out_bits}\n"); + if shaper { + for i in 0..size { + if i > 0 { + text.push(' '); + } + text.push_str(&format!("{}", (1023.0 * i as f64 / (size - 1) as f64).round())); + } + text.push('\n'); + } + for r in 0..size { + for g in 0..size { + for b in 0..size { + let v = [r, g, b].map(|c| (max - max * c as f64 / (size - 1) as f64).round()); + text.push_str(&format!("{} {} {}\n", v[0], v[1], v[2])); + } + } + } + text + } + + #[test] + fn three_dl_mesh_three_point_shaper_is_not_a_node() { + // #9078: Photoshop loads a 3³ cube whose shaper line has three values; counting it as a + // 28th node rejected the whole file. + let l = parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, true)).unwrap(); + assert_eq!(l.size, 3); + assert_eq!(l.data.len(), 3 * 3 * 3 * 3); + // The inverse identity: node (0,0,0) is full, node (2,2,2) black. + assert!((l.data[0] - 1.0).abs() < 1e-6 && (l.data[1] - 1.0).abs() < 1e-6 && (l.data[2] - 1.0).abs() < 1e-6); + let last = &l.data[l.data.len() - 3..]; + assert_eq!(last, [0.0, 0.0, 0.0]); + // The same 27 nodes without the shaper row still load (the node-only control). + assert_eq!(parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, false)).unwrap().data, l.data); + } + + #[test] + fn three_dl_mesh_declares_the_output_depth() { + // #9073: identical node codes divide by the declared range, not by the largest value. + let mut text_10 = String::new(); + let mut text_12 = String::new(); + for (n_bits, out_bits, dst) in [(2, 10, &mut text_10), (2, 12, &mut text_12)] { + // A 5³ identity scaled to 10-bit codes (max 1023). + *dst = format!("3DMESH\nMesh {n_bits} {out_bits}\n0 256 512 768 1023\n"); + for r in 0..5 { + for g in 0..5 { + for b in 0..5 { + dst.push_str(&format!("{} {} {}\n", r * 255, g * 255, b * 255)); + } + } + } + } + let white_10 = parse_3dl(&text_10).unwrap(); + let white_12 = parse_3dl(&text_12).unwrap(); + assert_eq!(white_10.size, 5); + let last_10 = &white_10.data[white_10.data.len() - 3..]; + let last_12 = &white_12.data[white_12.data.len() - 3..]; + assert!((last_10[0] - 1020.0 / 1023.0).abs() < 1e-6, "{last_10:?}"); + // 1020/4095 ≈ 0.249: white renders grey, as Photoshop measures it. + assert!((last_12[0] - 1020.0 / 4095.0).abs() < 1e-6, "{last_12:?}"); + } + + #[test] + fn three_dl_mesh_header_mismatch_is_rejected() { + // A Mesh header that disagrees with the row count is refused, not silently re-inferred. + assert!(parse_3dl("3DMESH\nMesh 1 12\n0 0 0\n0 0 1\n").is_err()); + // A node beyond the declared range is a wrong declaration: the old inference from the + // largest value keeps the file loading as it did before the header was honoured. + let mut text = String::from("3DMESH\nMesh 1 10\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + text.push_str(&format!("{} {} {}\n", r * 2047, g * 2047, b * 2047)); + } + } + } + let l = parse_3dl(&text).unwrap(); + let last = &l.data[l.data.len() - 3..]; + assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}"); + } + + #[test] + fn three_dl_malformed_mesh_header_falls_back_to_inference() { + // A `Mesh` line that is not exactly two integer fields is not a declaration: it is + // ignored and the table is inferred as if headerless, rather than recovering numbers + // from the wrong positions (#9078). A 2³ cube would otherwise be read as a 3³ cube. + for header in ["Mesh invalid 1 12", "Mesh 1 12 13", "Mesh 1 x", "Mesh 1"] { + let mut text = format!("3DMESH\n{header}\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + text.push_str(&format!("{} {} {}\n", r * 1023, g * 1023, b * 1023)); + } + } + } + let l = parse_3dl(&text).unwrap(); + assert_eq!(l.size, 2, "{header}"); + assert_eq!(l.data, LutFile::identity(2).data, "{header}"); + } + } + + #[test] + fn three_dl_mesh_uses_declared_range_for_small_integer_codes() { + // #9073: under a valid `Mesh` declaration the node codes are integers in the declared + // range, so a max value of 1 is 1/4095 of full output — not full output itself. + let mut text = String::from("3DMESH\nMesh 1 12\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + let v = u8::from(r == 0 && g == 0 && b == 0); + text.push_str(&format!("{v} {v} {v}\n")); + } + } + } + let l = parse_3dl(&text).unwrap(); + assert_eq!(l.size, 3); + // blue fastest: node (0,0,0) is the first entry, code 1 → 1/4095. + assert!((l.data[0] - 1.0 / 4095.0).abs() < 1e-6, "{:?}", &l.data[0..3]); + assert!(l.data[3..].iter().all(|&v| v == 0.0), "{:?}", l.data); + } + #[test] fn look_hex_floats() { let id = LutFile::identity(2); diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 062f7c4f98..69e3bd78b5 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -33,6 +33,8 @@ zune-core = "0.5" jpeg-encoder = "0.6" # 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode. craft-jpeg.workspace = true +# Pure-Rust modular JPEG XL lossless encoder. +zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] } photocraft-heif = { path = "../heif", optional = true } # Orientation is applied a band of rows per task on native; wasm stays single-threaded. @@ -46,5 +48,9 @@ proptest.workspace = true # Oracle decoders for cross-checking in tests only. image = { workspace = true, features = ["png", "jpeg", "tiff"] } +# Independent JPEG XL decoder oracle; not linked into the application. +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +jxl-oxide = { version = "=0.12.6", default-features = false, features = ["rayon"] } + [lints] workspace = true diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 29d4d1234d..ffd7b655b1 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | +| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, RGB, RGBA (GrayA expands to RGBA) | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` | | 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 | 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 | @@ -73,6 +74,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them ## Documented asymmetries and limitations +* **JPEG XL export.** Writes lossless 8/16-bit bare codestreams with `zune-jpegxl`. + Native layouts are Gray, RGB and RGBA. GrayA is expanded to RGBA at the + original depth. For 16-bit RGBA (including expanded GrayA), we correct + zune-jpegxl 0.5.2's extra-channel header, which otherwise describes the + 16-bit alpha samples as 8-bit. This preserves alpha precision; the independent + jxl-oxide decoder test checks the actual gray and alpha samples. The expansion + is not a native GrayA layout. No ICC, EXIF, XMP or DPI container boxes are written; the + document pipeline converts tagged images to sRGB and reports metadata loss. + Reading remains in the separate #2868 PR. The `ASYMMETRIC_EXCEPTIONS` + entry stays until import is merged. + * **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. Uncompressed row padding follows the header's @@ -242,8 +254,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them ms in release). Image data cut off by the end of the file decodes with `DecodeWarning::Truncated`; a file far too small for its declared size (more than any compression could expand) is refused before allocating. - * YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT, - JPEG-in-TIFF) return `Unsupported`. + * YCbCr with three 8- or 16-bit samples decodes to RGB. YCbCr with other sample counts or + bit depths returns `Unsupported`. JPEG-compressed YCbCr uses the full-range JFIF conversion. + * 8/16-bit CIELab and ICCLab decode to the Lab channel layout. + * Signed integer samples and unsupported compression methods return `Unsupported`. * **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is diff --git a/crates/codecs/src/codecs.rs b/crates/codecs/src/codecs.rs index efdbbf8229..4c4c22388c 100644 --- a/crates/codecs/src/codecs.rs +++ b/crates/codecs/src/codecs.rs @@ -11,6 +11,7 @@ pub(crate) mod gif_palette; pub(crate) mod heif; pub(crate) mod iff; pub(crate) mod jpeg; +pub(crate) mod jxl; pub(crate) mod pcx; pub(crate) mod png; pub(crate) mod pnm; diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index e179b13044..ec0956a4ec 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -33,9 +33,63 @@ fn map_exr(e: exr::error::Error) -> CodecError { } } +/// Refuses headers whose `preview` attributes declare more pixels than their value holds. +/// +/// The `exr` crate sizes a preview's pixel buffer from its declared dimensions (up to 4 MiB at +/// once, zero-filled) before it finds the attribute too short, and in the non-pedantic mode +/// every reader here uses it then skips the attribute and goes on: thousands of tiny malformed +/// previews would each cost that allocation. This walks the raw headers (name, type, size, +/// value per attribute; an empty name ends a header, an empty header ends a multi-part list) +/// and checks each preview against its own size, so the crate only reads consistent ones. +/// Anything else odd is left to the crate to report. +pub(crate) fn check_previews(b: &[u8]) -> Result<(), CodecError> { + if b.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01][..]) { + return Ok(()); + } + let Some(flags) = b.get(4..8).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes) else { return Ok(()) }; + let multipart = flags & 0x1000 != 0; + let cstr = |at: usize| -> Option<(&[u8], usize)> { + let rest = b.get(at..)?; + let n = rest.iter().position(|&c| c == 0)?; + Some((rest.get(..n)?, at + n + 1)) + }; + let u32_at = |at: usize| b.get(at..at.checked_add(4)?).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes); + let mut at = 8; + loop { + // One header's attributes. + loop { + let Some((name, next)) = cstr(at) else { return Ok(()) }; + at = next; + if name.is_empty() { + break; + } + let Some((kind, next)) = cstr(at) else { return Ok(()) }; + let Some(size) = u32_at(next).and_then(|n| i32::try_from(n).ok()).and_then(|n| usize::try_from(n).ok()) else { return Ok(()) }; + let value = next + 4; + let end = match value.checked_add(size) { + Some(end) if end <= b.len() => end, + _ => return Ok(()), + }; + if kind == b"preview" { + let declared = + u32_at(value).zip(u32_at(value + 4)).and_then(|(w, h)| u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)).filter(|_| size >= 8); + if declared.is_none_or(|n| n > (size - 8) as u64) { + return Err(err("a preview attribute declares more pixels than it holds")); + } + } + at = end; + } + // A multi-part list ends with an empty header (a lone NUL). + if !multipart || b.get(at) == Some(&0) { + return Ok(()); + } + } +} + /// Read the headers without the `exr` crate's validation (it rejects deep parts outright), /// after checking every part's declared size against the limits, before any pixel allocation. pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result { + check_previews(bytes)?; let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; // Flat decoding reads every part at once, so the parts' sizes are also capped together. let mut total: u64 = 0; @@ -130,6 +184,7 @@ impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart { /// Decode one part's flat layer via the `exr` crate (it handles every flat compression). /// Other parts are not decompressed, so an AOV file costs one part per request, not the file. pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result>, CodecError> { + check_previews(bytes)?; let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) }; only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr) } @@ -334,3 +389,45 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Vec { + let mut attrs = LayerAttributes::named("rgb"); + attrs.preview = Some(Preview { size: exr::math::Vec2(2, 1), pixel_data: vec![7; 8] }); + let list = ["R", "G", "B"].into_iter().map(|n| AnyChannel::new(n, FlatSamples::F32(vec![0.5; 4]))).collect(); + let layer = Layer::new( + (2, 2), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (2, 2))), vec![layer]); + let mut out = Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).unwrap(); + out.into_inner() + } + + #[test] + fn a_preview_declaring_more_pixels_than_it_holds_is_refused_before_the_crate_reads_it() { + let good = with_preview(); + check_previews(&good).unwrap(); + assert_eq!(decode(&good, &Limits::default()).unwrap().dimensions(), (2, 2)); + // Patch the preview to 1024x1024 (4 MiB) while its value still holds 8 bytes. + let tag = b"preview\0preview\0"; + let at = good.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len() + 4; + let mut bad = good.clone(); + bad[at..at + 4].copy_from_slice(&1024u32.to_le_bytes()); + bad[at + 4..at + 8].copy_from_slice(&1024u32.to_le_bytes()); + for r in [check_previews(&bad), decode(&bad, &Limits::default()).map(|_| ()), read_layer(&bad, 0).map(|_| ())] { + assert!(matches!(&r, Err(CodecError::Malformed { message, .. }) if message.contains("preview")), "{r:?}"); + } + // A header cut off mid-attribute is left to the crate. + check_previews(&good[..at]).unwrap(); + } +} diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index 370c7c7e56..62fd2e1e0d 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -148,6 +148,7 @@ fn layer_streams(header: &exr::meta::header::Header, name: &str) -> Vec /// Lists every Cryptomatte layer of the file from the headers alone: what a Cryptomatte /// picker offers. The embedded manifest (capped, JSON) is parsed into (ID, name) pairs. pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result, CodecError> { + super::exr::check_previews(bytes)?; let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?; // Manifests can be large; bound the embedded text before parsing anything. const MANIFEST_CAP: usize = 16 << 20; diff --git a/crates/codecs/src/codecs/gif_frames.rs b/crates/codecs/src/codecs/gif_frames.rs index 6a99b54f7b..b5439f6eff 100644 --- a/crates/codecs/src/codecs/gif_frames.rs +++ b/crates/codecs/src/codecs/gif_frames.rs @@ -48,6 +48,9 @@ pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result, decoder.set_limits(il).map_err(map)?; let (w, h) = decoder.dimensions(); limits.check_bytes(w, h, 4)?; + // The backend initializes the whole logical screen before it reads the first frame, so the + // same file-size plausibility as the first-frame decode applies before iterating (#8597). + super::via_image::gif_plausible(bytes, w, h, 4)?; let frame_bytes = u64::from(w) * u64::from(h) * 4; let mut out: Vec = Vec::new(); diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index f2c4d4b9ee..e2e854622f 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -196,6 +196,54 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } +const EXTENDED_XMP_HEADER: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + +/// The file without its Extended XMP APP1 segments (`None` when it has none), for `zune-jpeg`. +/// +/// zune-jpeg keeps every Extended XMP fragment until a GUID's set is complete and re-sorts and +/// re-checks all pending fragments after every marker it parses, so many small, consistent but +/// incomplete fragments (or a few followed by many other markers) cost quadratic time before a +/// pixel is decoded. Nothing here reads its reassembled Extended XMP ([`scan_metadata`] works on +/// the original bytes), so the fragments are left out of what it parses. The walk stops at the +/// first scan, as the header parse does, and skips stray bytes to the next `FF` as it does. +fn without_extended_xmp(b: &[u8]) -> Option> { + let mut drop: Vec> = Vec::new(); + let mut i = 2; // past SOI + while let Some(&byte) = b.get(i) { + if byte != 0xFF { + i += b.get(i..)?.iter().position(|&v| v == 0xFF)?; + continue; + } + let Some(&code) = b.get(i + 1) else { break }; + match code { + 0xFF => i += 1, + 0x01 | 0xD0..=0xD8 => i += 2, + 0xD9 | 0xDA => break, + _ => { + let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else { break }; + let end = i.saturating_add(2 + usize::from(len)); + if code == 0xE1 && b.get(i + 4..).is_some_and(|p| p.starts_with(EXTENDED_XMP_HEADER)) { + drop.push(i..end.min(b.len())); + } + i = end; + } + } + } + if drop.is_empty() { + return None; + } + // Reserve what is kept, not the input: the dropped fragments may be most of it. + let kept = b.len().saturating_sub(drop.iter().map(|r| r.end - r.start).sum::()); + let mut out = Vec::with_capacity(kept); + let mut at = 0; + for r in drop { + out.extend_from_slice(b.get(at..r.start)?); + at = r.end; + } + out.extend_from_slice(b.get(at..)?); + Some(out) +} + /// Whether `craft-jpeg` (rather than `zune-jpeg`) decodes the file: arithmetic coding /// (SOF9–SOF11) or a DCT frame whose precision isn't 8 bits (#4934, #5003). Lossless (SOF3) and /// 12-bit progressive Huffman (SOF2) go there too: craft-jpeg reports them unsupported, where @@ -273,7 +321,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result return Ok(img); } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535); - let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options); + let stripped = without_extended_xmp(bytes); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(stripped.as_deref().unwrap_or(bytes)), options); dec.decode_headers().map_err(err)?; let (w, h) = dec.dimensions().ok_or_else(|| err("no dimensions"))?; let (w, h) = (w as u32, h as u32); @@ -525,6 +574,36 @@ mod tests { bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") } + /// An APP1 Extended XMP fragment (GUID, total size, offset, data). + fn extended_xmp(offset: u32) -> Vec { + let mut seg = EXTENDED_XMP_HEADER.to_vec(); + seg.extend_from_slice(&[b'A'; 32]); + seg.extend_from_slice(&1_000_000u32.to_be_bytes()); + seg.extend_from_slice(&offset.to_be_bytes()); + seg.push(b'x'); + let mut out = vec![0xFF, 0xE1]; + out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + out.extend_from_slice(&seg); + out + } + + #[test] + fn extended_xmp_fragments_are_left_out_of_the_backend_parse() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![120; 16 * 16 * 3]).unwrap(); + let plain = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + assert!(without_extended_xmp(&plain).is_none()); + // 20000 consistent but incomplete fragments right after SOI: zune-jpeg would re-sort and + // re-check the growing pending list after each one. + let mut hostile = plain[..2].to_vec(); + for k in 0..20_000u32 { + hostile.extend_from_slice(&extended_xmp(k * 2)); + } + hostile.extend_from_slice(&plain[2..]); + assert_eq!(without_extended_xmp(&hostile).as_deref(), Some(&plain[..])); + let back = decode(&hostile, &Limits::default()).unwrap(); + assert_eq!(back.dimensions(), (16, 16)); + } + /// A tiny truncated scan under a frame patched to 20000×20000 is refused before the backend /// fills 400 million pixels from it; the same file with its real size still recovers. #[test] diff --git a/crates/codecs/src/codecs/jxl.rs b/crates/codecs/src/codecs/jxl.rs new file mode 100644 index 0000000000..11591c9300 --- /dev/null +++ b/crates/codecs/src/codecs/jxl.rs @@ -0,0 +1,159 @@ +//! Pure-Rust lossless JPEG XL (Modular) writer. +//! +//! Currently writes a bare JPEG XL codestream. The encoder accepts 8- or 16-bit +//! gray, RGB and RGBA samples, but does not write ICC/EXIF/XMP boxes. +//! Gray+alpha sources are expanded losslessly to RGBA by the export plan. +//! The flat export pipeline converts tagged RGB documents to sRGB first. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions as ZuneOptions}; +use zune_jpegxl::JxlSimpleEncoder; + +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::format::Format; +use crate::image::{ChannelLayout, Image, SampleType}; + +const F: Format = Format::Jxl; + +// zune-jpegxl 0.5.2 emits ExtraChannelInfo(all_default=true) for every alpha +// channel. In JPEG XL that flag means *8-bit* alpha, even when the modular +// payload was encoded from 16-bit samples. Replace only that metadata bundle, +// leaving the entire encoded frame and its TOC untouched. The replacement is +// exactly 16 bits longer, so subsequent byte-aligned data stays aligned. +// +// This deliberately verifies the pinned encoder's header layout rather than +// trying to parse or rewrite arbitrary JPEG XL codestreams. A different header +// must fail closed, not produce a file that silently corrupts transparency. +fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result, CodecError> { + fn read(bytes: &[u8], pos: &mut usize, count: usize) -> Option { + let mut value = 0u32; + for bit in 0..count { + let offset = *pos; + let byte = *bytes.get(offset / 8)?; + value |= u32::from((byte >> (offset % 8)) & 1) << bit; + *pos += 1; + } + Some(value) + } + + fn read_dimension(bytes: &[u8], pos: &mut usize) -> Option { + let bits = match read(bytes, pos, 2)? { + 0 => 9, + 1 => 13, + 2 => 18, + _ => 30, + }; + read(bytes, pos, bits)?.checked_add(1) + } + + fn write_bits(out: &mut Vec, pos: &mut usize, count: usize, value: u32) { + for bit in 0..count { + if (*pos).is_multiple_of(8) { + out.push(0); + } + out[*pos / 8] |= (((value >> bit) & 1) as u8) << (*pos % 8); + *pos += 1; + } + } + + let mut pos = 0; + let header_matches = read(bytes, &mut pos, 16) == Some(0x0aff) + && read(bytes, &mut pos, 1) == Some(0) + && read_dimension(bytes, &mut pos) == Some(height) + && read(bytes, &mut pos, 3) == Some(0) + && read_dimension(bytes, &mut pos) == Some(width) + && read(bytes, &mut pos, 1) == Some(0) // metadata not all-default + && read(bytes, &mut pos, 1) == Some(0) // no extra fields + && read(bytes, &mut pos, 1) == Some(0) // integer samples + && read(bytes, &mut pos, 2) == Some(3) // custom bit depth + && read(bytes, &mut pos, 6) == Some(15) // 16 bits + && read(bytes, &mut pos, 1) == Some(0) // uses >14 bits + && read(bytes, &mut pos, 2) == Some(1); // one extra channel + let alpha_flag_pos = pos; + let header_matches = header_matches + && read(bytes, &mut pos, 1) == Some(1) // incorrect default 8-bit alpha + && read(bytes, &mut pos, 1) == Some(0) // not XYB + && read(bytes, &mut pos, 1) == Some(1) // default sRGB encoding (RGBA) + && read(bytes, &mut pos, 2) == Some(0) // no extensions + && read(bytes, &mut pos, 1) == Some(1); // default transform + + let frame_offset = pos.div_ceil(8); + let header_matches = header_matches && frame_offset <= bytes.len() && (pos..frame_offset * 8).all(|bit| bytes[bit / 8] & (1 << (bit % 8)) == 0); + if !header_matches { + return Err(CodecError::encode(F, "unexpected zune-jpegxl header for 16-bit alpha")); + } + + let mut fixed = Vec::with_capacity(bytes.len() + 2); + let mut cursor = 0; + for bit in 0..alpha_flag_pos { + write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1)); + } + + // JPEG XL ExtraChannelInfo, non-default, straight 16-bit alpha: + // type=kAlpha (Enum=0), bit_depth=uint16, dim_shift=0, name_len=0, + // alpha_associated=false. Adds exactly 16 bits versus all_default=true. + write_bits(&mut fixed, &mut cursor, 1, 0); + write_bits(&mut fixed, &mut cursor, 2, 0); + write_bits(&mut fixed, &mut cursor, 1, 0); + write_bits(&mut fixed, &mut cursor, 2, 3); + write_bits(&mut fixed, &mut cursor, 6, 15); + write_bits(&mut fixed, &mut cursor, 2, 0); + write_bits(&mut fixed, &mut cursor, 2, 0); + write_bits(&mut fixed, &mut cursor, 1, 0); + + for bit in alpha_flag_pos + 1..frame_offset * 8 { + write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1)); + } + if cursor != (frame_offset + 2) * 8 { + return Err(CodecError::encode(F, "JPEG XL alpha header alignment mismatch")); + } + fixed.extend_from_slice(&bytes[frame_offset..]); + Ok(fixed) +} + +pub(crate) fn encode(image: &Image, plan: Plan) -> Result, CodecError> { + let image = image.converted(plan.layout, plan.sample); + let space = match image.layout() { + ChannelLayout::Gray => ColorSpace::Luma, + ChannelLayout::GrayA => ColorSpace::LumaA, + ChannelLayout::Rgb => ColorSpace::RGB, + ChannelLayout::Rgba => ColorSpace::RGBA, + _ => return Err(CodecError::unsupported(F, "JPEG XL export supports grayscale and RGB, with optional alpha")), + }; + let depth = match image.sample_type() { + SampleType::U8 => BitDepth::Eight, + SampleType::U16 => BitDepth::Sixteen, + _ => return Err(CodecError::unsupported(F, "JPEG XL lossless export supports 8 or 16-bit integer pixels")), + }; + let (width, height) = image.dimensions(); + let samples = (width as usize) + .checked_mul(height as usize) + .and_then(|n| n.checked_mul(image.layout().channels())) + .and_then(|n| n.checked_mul(image.sample_type().bytes())) + .ok_or_else(|| CodecError::encode(F, "image dimensions overflow the buffer size"))?; + if image.data().len() != samples { + return Err(CodecError::InvalidImage("incorrect JPEG XL pixel buffer length".into())); + } + // A panic in an external image encoder must not terminate the host application. + catch_unwind(AssertUnwindSafe(|| { + let options = ZuneOptions::new(width as usize, height as usize, space, depth); + let mut output = Vec::new(); + JxlSimpleEncoder::new(image.data(), options).encode(&mut output).map_err(|e| CodecError::encode(F, e.to_string()))?; + if depth == BitDepth::Sixteen && image.layout() == ChannelLayout::Rgba { correct_16_bit_alpha_header(&output, width, height) } else { Ok(output) } + })) + .unwrap_or_else(|_| Err(CodecError::encode(F, "the JPEG XL encoder failed"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alpha_header_correction_rejects_unexpected_or_truncated_headers() { + for bytes in [&[][..], &[0xff, 0x0a][..], &[0; 32][..]] { + assert!(correct_16_bit_alpha_header(bytes, 2, 2).is_err()); + } + } +} diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index e09115416c..f1d51e7732 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -1,7 +1,7 @@ //! PNG via the `png` crate directly: 8/16-bit, Adam7 read (and write), //! iCCP, eXIf, tEXt/zTXt/iTXt (XMP in `XML:com.adobe.xmp`), pHYs. -use std::io::{Cursor, Write}; +use std::io::{self, Cursor, Write}; use crate::Format; use crate::error::CodecError; @@ -233,8 +233,13 @@ fn read_text_metadata(bytes: &[u8], mut remaining: usize, meta: &mut Metadata) - }; let keyword_len = latin1_len(keyword).ok_or_else(text_limit)?; let limit = remaining.saturating_sub(keyword_len); - let Some(text) = bounded_text(value, deflated, latin1, limit)? else { continue }; - remaining = remaining.checked_sub(keyword_len).and_then(|left| left.checked_sub(text.len())).ok_or_else(text_limit)?; + // What a chunk inflated is charged even when its text is then dropped (bad UTF-8, a + // corrupt or truncated stream): otherwise every rejected chunk could inflate up to the + // whole remaining budget again. + let (text, produced) = bounded_text(value, deflated, latin1, limit)?; + remaining = remaining.checked_sub(produced).ok_or_else(text_limit)?; + let Some(text) = text else { continue }; + remaining = remaining.checked_sub(keyword_len).ok_or_else(text_limit)?; let keyword: String = keyword.iter().map(|&b| char::from(b)).collect(); match kind { // Some writers put XMP in Latin-1 text chunks instead of the usual iTXt. @@ -254,9 +259,11 @@ fn read_text_metadata(bytes: &[u8], mut remaining: usize, meta: &mut Metadata) - /// No input-sized allocation until its decoded size fits. Inflation uses a /// fixed scratch buffer and checks each batch before growing the output. -fn bounded_text(input: &[u8], compressed: bool, latin1: bool, limit: usize) -> Result, CodecError> { +/// Returns the text (`None` when it is dropped) and the bytes produced for it, +/// which the caller charges whether or not the text is kept. +fn bounded_text(input: &[u8], compressed: bool, latin1: bool, limit: usize) -> Result<(Option, usize), CodecError> { let mut output = Vec::new(); - let mut append = |bytes: &[u8]| -> Result<(), CodecError> { + let append = |output: &mut Vec, bytes: &[u8]| -> Result<(), CodecError> { let added = if latin1 { latin1_len(bytes).ok_or_else(text_limit)? } else { bytes.len() }; if output.len().checked_add(added).is_none_or(|len| len > limit) { return Err(text_limit()); @@ -279,115 +286,73 @@ fn bounded_text(input: &[u8], compressed: bool, latin1: bool, limit: usize) -> R loop { let start_in = decoder.total_in(); let start_out = decoder.total_out(); - let Some(tail) = usize::try_from(start_in).ok().and_then(|at| input.get(at..)) else { return Ok(None) }; - let Ok(status) = decoder.decompress(tail, &mut scratch, flate2::FlushDecompress::None) else { return Ok(None) }; - let Some(batch) = usize::try_from(decoder.total_out() - start_out).ok().and_then(|len| scratch.get(..len)) else { return Ok(None) }; - append(batch)?; + let Some(tail) = usize::try_from(start_in).ok().and_then(|at| input.get(at..)) else { return Ok((None, output.len())) }; + let Ok(status) = decoder.decompress(tail, &mut scratch, flate2::FlushDecompress::None) else { + // The backend may write a batch before it reports corruption: charge it too. + let written = usize::try_from(decoder.total_out().saturating_sub(start_out)).unwrap_or(usize::MAX); + // Latin-1 text is charged at its UTF-8 length, as on the success path. + let charged = match scratch.get(..written) { + Some(batch) if latin1 => latin1_len(batch).unwrap_or(usize::MAX), + _ => written, + }; + return Ok((None, output.len().saturating_add(charged))); + }; + let Some(batch) = usize::try_from(decoder.total_out() - start_out).ok().and_then(|len| scratch.get(..len)) else { return Ok((None, output.len())) }; + append(&mut output, batch)?; if status == flate2::Status::StreamEnd { break; } if start_in == decoder.total_in() && start_out == decoder.total_out() { - return Ok(None); // Truncated stream: never keep partial text. + return Ok((None, output.len())); // Truncated stream: never keep partial text. } } } else { if !latin1 && std::str::from_utf8(input).is_err() { - return Ok(None); + return Ok((None, output.len())); } - append(input)?; + append(&mut output, input)?; } - Ok(String::from_utf8(output).ok()) + let produced = output.len(); + Ok((String::from_utf8(output).ok(), produced)) } fn is_latin1_keyword(k: &str) -> bool { !k.is_empty() && k.len() <= 79 && k.chars().all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c)) } +/// Makes the PNG writer's output growth report an encode error on allocation failure. +struct FallibleOutput<'a>(&'a mut Vec); + +impl Write for FallibleOutput<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.try_reserve(bytes.len()).map_err(|_| io::Error::other("not enough memory for PNG output"))?; + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); - let color = match img.layout() { - ChannelLayout::Gray => png::ColorType::Grayscale, - ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha, - ChannelLayout::Rgb => png::ColorType::Rgb, - ChannelLayout::Rgba => png::ColorType::Rgba, - l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), - }; - let depth = match img.sample_type() { - SampleType::U8 => png::BitDepth::Eight, - SampleType::U16 => png::BitDepth::Sixteen, - s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), - }; + let bpp = img.layout().channels() * img.sample_type().bytes(); // Big-endian sample bytes. let mut data = std::borrow::Cow::Borrowed(img.data()); - if depth == png::BitDepth::Sixteen { - for c in data.to_mut().as_chunks_mut::<2>().0 { - let v = u16::from_ne_bytes([c[0], c[1]]); - c.copy_from_slice(&v.to_be_bytes()); - } + if img.sample_type() == SampleType::U16 { + to_big_endian(data.to_mut()); } - - let mut info = png::Info::with_size(w, h); - info.color_type = color; - info.bit_depth = depth; - info.interlaced = opts.png_interlaced; - if opts.embed_icc - && let Some(icc) = &img.icc - { - info.icc_profile = Some(icc.clone().into()); - } - if opts.embed_metadata { - if let Some(exif) = &img.meta.exif { - // The pixels are written as they are shown: never let a viewer rotate them again. - info.exif_metadata = Some(crate::resolution::export_exif(exif, img.meta.dpi).into_owned().into()); - } - if let Some((x, y)) = img.meta.dpi - && x > 0.0 - && y > 0.0 - { - info.pixel_dims = - Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter }); - } - } - let mut out = Vec::new(); { - let mut encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; - encoder.set_compression(match opts.png_compression { - PngCompression::None => png::Compression::NoCompression, - PngCompression::Fast => png::Compression::Fast, - PngCompression::Default => png::Compression::Balanced, - PngCompression::Best => png::Compression::High, - }); - if opts.embed_metadata { - for (k, v) in &img.meta.text { - let latin1_text = v.chars().all(|c| (c as u32) < 256); - let res = if is_latin1_keyword(k) && latin1_text { - encoder.add_text_chunk(k.clone(), v.clone()) - } else if is_latin1_keyword(k) { - encoder.add_itxt_chunk(k.clone(), v.clone()) - } else { - continue; - }; - res.map_err(|e| CodecError::encode(F, e))?; - } - if let Some(xmp) = &img.meta.xmp { - encoder - .add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, img.meta.dpi).into_owned()) - .map_err(|e| CodecError::encode(F, e))?; - } - } - let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; - if let Some(stereo) = opts.png_stereo { - // Ancillary chunk before IDAT, as the spec recommends for `sTER`. - writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; - } + let mut output = FallibleOutput(&mut out); + let mut writer = start(&mut output, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?; if opts.png_interlaced { - let bpp = img.layout().channels() * img.sample_type().bytes(); let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; - writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?; - } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) { - writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?; + write_idat(&mut writer, &idat, MAX_IDAT)?; + } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) { + write_idat(&mut writer, &idat, MAX_IDAT)?; } else { writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?; } @@ -396,11 +361,211 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result().0 { + let v = u16::from_ne_bytes([c[0], c[1]]); + c.copy_from_slice(&v.to_be_bytes()); + } +} + +/// Writes the signature and every chunk ahead of the image data for a `w` × `h` PNG of +/// `layout` and `sample` (already the encode plan's), with its profile and metadata. +fn start( + out: W, + (w, h): (u32, u32), + (layout, sample): (ChannelLayout, SampleType), + icc: Option<&Vec>, + meta: &Metadata, + opts: &EncodeOptions, +) -> Result, CodecError> { + let color = match layout { + ChannelLayout::Gray => png::ColorType::Grayscale, + ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha, + ChannelLayout::Rgb => png::ColorType::Rgb, + ChannelLayout::Rgba => png::ColorType::Rgba, + l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), + }; + let depth = match sample { + SampleType::U8 => png::BitDepth::Eight, + SampleType::U16 => png::BitDepth::Sixteen, + s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), + }; + let mut info = png::Info::with_size(w, h); + info.color_type = color; + info.bit_depth = depth; + info.interlaced = opts.png_interlaced; + if opts.embed_icc + && let Some(icc) = icc + { + info.icc_profile = Some(icc.clone().into()); + } + if opts.embed_metadata { + if let Some(exif) = &meta.exif { + // The pixels are written as they are shown: never let a viewer rotate them again. + info.exif_metadata = Some(crate::resolution::export_exif(exif, meta.dpi).into_owned().into()); + } + if let Some((x, y)) = meta.dpi + && x > 0.0 + && y > 0.0 + { + info.pixel_dims = + Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter }); + } + } + let mut encoder = png::Encoder::with_info(out, info).map_err(|e| CodecError::encode(F, e))?; + encoder.set_compression(match opts.png_compression { + PngCompression::None => png::Compression::NoCompression, + PngCompression::Fast => png::Compression::Fast, + PngCompression::Default => png::Compression::Balanced, + PngCompression::Best => png::Compression::High, + }); + if opts.embed_metadata { + for (k, v) in &meta.text { + let latin1_text = v.chars().all(|c| (c as u32) < 256); + let res = if is_latin1_keyword(k) && latin1_text { + encoder.add_text_chunk(k.clone(), v.clone()) + } else if is_latin1_keyword(k) { + encoder.add_itxt_chunk(k.clone(), v.clone()) + } else { + continue; + }; + res.map_err(|e| CodecError::encode(F, e))?; + } + if let Some(xmp) = &meta.xmp { + encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, meta.dpi).into_owned()).map_err(|e| CodecError::encode(F, e))?; + } + } + let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = opts.png_stereo { + // Ancillary chunk before IDAT, as the spec recommends for `sTER`. + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } + Ok(writer) +} + +/// Largest IDAT chunk written. A chunk holds at most 2^31 - 1 bytes, so a zlib stream past that +/// (a multi-gigapixel image) is split over several IDAT chunks, which readers join back. +const MAX_IDAT: usize = 1 << 30; + +/// `zlib` as IDAT chunks of at most `max` bytes. +fn write_idat(writer: &mut png::Writer, zlib: &[u8], max: usize) -> Result<(), CodecError> { + for part in zlib.chunks(max.max(1)) { + writer.write_chunk(png::chunk::IDAT, part).map_err(|e| CodecError::encode(F, e))?; + } + Ok(()) +} + +/// A PNG encoded a band of rows at a time, for images too large to hold whole (a multi-gigapixel +/// print): each band is converted to the encode plan, filtered and deflated on all cores as it +/// arrives and written as IDAT chunks, so only about one band is ever held. The deflate bands are +/// [`parallel_idat`]'s, so the zlib stream is the one [`crate::encode`] writes for a large image. +/// Never interlaced (Adam7 needs the whole image). +pub struct PngRows { + writer: png::Writer, + plan: Plan, + width: u32, + height: u32, + /// Rows received so far. + rows: u32, + level: PngCompression, + /// Converted, big-endian rows that don't fill a deflate band yet. + pending: Vec, + /// The last row deflated: the row above `pending` for the filters. + prev: Vec, + adler: u32, + /// Whether the zlib header is written. + started: bool, +} + +impl PngRows { + /// Writes the header of a `first.width()` × `height` PNG to `out`. The layout, sample type, + /// profile and metadata come from `first`, the first band (not written yet: [`Self::push`] + /// it like the others), through the same plan as [`crate::encode`]. + pub fn new(out: W, first: &Image, height: u32, opts: &EncodeOptions) -> Result { + if opts.png_interlaced { + return Err(CodecError::unsupported(F, "an interlaced PNG can't be written a band of rows at a time")); + } + let width = first.width(); + if width == 0 || height == 0 { + return Err(CodecError::InvalidImage("cannot encode an empty image".into())); + } + let plan = crate::fidelity::plan(first, F, opts); + let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?; + Ok(Self { writer, plan, width, height, rows: 0, level: opts.png_compression, pending: Vec::new(), prev: Vec::new(), adler: 1, started: false }) + } + + /// Encodes the next rows of the image: `band` is as wide as the image, in any layout and + /// sample type (converted like the first band). + pub fn push(&mut self, band: &Image) -> Result<(), CodecError> { + if band.width() != self.width { + return Err(CodecError::InvalidImage(format!("a {} px wide band for a {} px wide image", band.width(), self.width))); + } + let rows = self.rows.checked_add(band.height()).filter(|&r| r <= self.height); + let Some(rows) = rows else { + return Err(CodecError::InvalidImage(format!("more than the image's {} rows", self.height))); + }; + let img = band.converted(self.plan.layout, self.plan.sample); + let at = self.pending.len(); + self.pending.try_reserve(img.data().len()).map_err(|_| CodecError::encode(F, "not enough memory for a band of rows"))?; + self.pending.extend_from_slice(img.data()); + if self.plan.sample == SampleType::U16 { + to_big_endian(self.pending.get_mut(at..).unwrap_or_default()); + } + self.rows = rows; + self.deflate_pending() + } + + /// Deflates the whole bands held, and the rest once every row is in. + fn deflate_pending(&mut self) -> Result<(), CodecError> { + let bpp = self.plan.layout.channels() * self.plan.sample.bytes(); + let stride = self.width as usize * bpp; + let per_band = band_rows(stride); + let held = self.pending.len() / stride.max(1); + let finish = self.rows == self.height; + let take = if finish { held } else { held / per_band * per_band }; + if take == 0 { + return Ok(()); + } + let rows = self.pending.get(..take * stride).unwrap_or_default(); + let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice()); + let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads()) + .ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?; + let mut zlib = Vec::new(); + zlib.try_reserve_exact(parts.iter().map(|p| p.z.len()).sum::() + 6).map_err(|_| CodecError::encode(F, "not enough memory for PNG data"))?; + if !self.started { + zlib.extend_from_slice(&zlib_header(self.level)); + self.started = true; + } + for p in &parts { + zlib.extend_from_slice(&p.z); + self.adler = adler32_combine(self.adler, p.adler, p.len); + } + if finish { + zlib.extend_from_slice(&self.adler.to_be_bytes()); + } + write_idat(&mut self.writer, &zlib, MAX_IDAT)?; + self.prev.clear(); + self.prev.extend_from_slice(rows.get(rows.len() - stride..).unwrap_or_default()); + self.pending.drain(..take * stride); + Ok(()) + } + + /// Ends the file. Fails when fewer rows than the image's height were pushed. + pub fn finish(self) -> Result<(), CodecError> { + if self.rows != self.height || !self.pending.is_empty() { + return Err(CodecError::InvalidImage(format!("{} of the image's {} rows were written", self.rows, self.height))); + } + self.writer.finish().map_err(|e| CodecError::encode(F, e)) + } +} + /// Build the zlib stream for an Adam7-interlaced image (filter type 0). fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression) -> Result, CodecError> { // (x0, y0, dx, dy) per pass. const PASSES: [(usize, usize, usize, usize); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)]; - let mut raw = Vec::with_capacity(data.len() + h * 7); + let mut raw = Vec::new(); + raw.try_reserve_exact(data.len() + h * 7).map_err(|_| CodecError::encode(F, "not enough memory for PNG rows"))?; for (x0, y0, dx, dy) in PASSES { if x0 >= w || y0 >= h { continue; @@ -417,13 +582,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression y += dy; } } - let lvl = match level { - PngCompression::None => flate2::Compression::none(), - PngCompression::Fast => FAST, - PngCompression::Default => flate2::Compression::default(), - PngCompression::Best => flate2::Compression::best(), - }; - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), lvl); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), deflate_level(level)); z.write_all(&raw).map_err(|e| CodecError::encode(F, e))?; z.finish().map_err(|e| CodecError::encode(F, e)) } @@ -447,44 +606,112 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress if cfg!(target_arch = "wasm32") || data.len() < PARALLEL_MIN_BYTES || level == PngCompression::None || w == 0 || h == 0 { return None; } - let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32); - banded_idat(data, w, h, bpp, level, threads) + banded_idat(data, w, h, bpp, level, encode_threads()) } /// Filtered bytes per deflate band. The bands depend only on the image, never on the number of /// threads, so the same image and options give the same bytes whatever the machine's core count. const BAND_BYTES: usize = 512 << 10; +/// The deflate effort for `level`. +fn deflate_level(level: PngCompression) -> flate2::Compression { + match level { + PngCompression::None => flate2::Compression::none(), + PngCompression::Fast => FAST, + PngCompression::Default => flate2::Compression::default(), + PngCompression::Best => flate2::Compression::best(), + } +} + +/// The zlib stream header: deflate, 32 K window; FLEVEL only advertises the effort. +fn zlib_header(level: PngCompression) -> [u8; 2] { + match level { + PngCompression::None | PngCompression::Fast => [0x78, 0x01], + PngCompression::Best => [0x78, 0xDA], + PngCompression::Default => [0x78, 0x9C], + } +} + +/// Rows per deflate band for rows of `stride` bytes. +fn band_rows(stride: usize) -> usize { + (BAND_BYTES / stride.saturating_add(1)).max(1) +} + +/// Worker threads for the banded encoders: one on wasm (no threads), else the machine's cores. +fn encode_threads() -> usize { + if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) } +} + /// [`parallel_idat`] on `threads` worker threads. fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option> { let stride = w * bpp; - let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1); - let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect(); - let lvl = match level { - PngCompression::Fast => FAST, - PngCompression::Best => flate2::Compression::best(), - _ => flate2::Compression::default(), + let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?; + let mut out = Vec::new(); + out.try_reserve_exact(parts.iter().map(|p| p.z.len()).sum::() + 6).ok()?; + out.extend_from_slice(&zlib_header(level)); + let mut adler = 1u32; + for p in &parts { + out.extend_from_slice(&p.z); + adler = adler32_combine(adler, p.adler, p.len); + } + out.extend_from_slice(&adler.to_be_bytes()); + Some(out) +} + +/// One band of rows filtered and deflated: the raw deflate bytes, and the Adler-32 and length of +/// the filtered bytes they hold. +struct Deflated { + z: Vec, + adler: u32, + len: usize, +} + +/// `rows` (whole rows of `stride` bytes; `prev` is the row above the first, if any) filtered and +/// deflated in independent bands of `band_rows` rows on up to `threads` workers (inline for one). +/// The last band finishes the deflate stream when `finish`; every other band ends on a sync flush. +/// `None` when a band did not deflate in one call ([`deflate_band`]). +#[allow(clippy::too_many_arguments)] +fn deflate_rows( + rows: &[u8], + prev: Option<&[u8]>, + stride: usize, + bpp: usize, + band_rows: usize, + lvl: flate2::Compression, + finish: bool, + threads: usize, +) -> Option> { + let h = rows.len() / stride.max(1); + let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect(); + let band = |&(y0, y1): &(usize, usize)| -> Option { + let mut filtered = Vec::new(); + filtered.try_reserve_exact((y1 - y0) * (stride + 1)).ok()?; + let mut scratch = Vec::new(); + scratch.try_reserve_exact(stride).ok()?; + scratch.resize(stride, 0); + for y in y0..y1 { + let row = rows.get(y * stride..(y + 1) * stride)?; + let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev }; + filter_row(row, above, bpp, &mut scratch, &mut filtered); + } + let adler = adler32(1, &filtered); + let z = deflate_band(&filtered, lvl, finish && y1 == h)?; + Some(Deflated { z, adler, len: filtered.len() }) }; + if threads <= 1 || bands.len() <= 1 { + return bands.iter().map(band).collect(); + } let next = std::sync::atomic::AtomicUsize::new(0); - type Band = (usize, Vec, u32, usize); - let mut parts: Vec = std::thread::scope(|sc| { - let workers: Vec<_> = (0..threads.min(bands.len()).max(1)) + let mut parts: Vec<(usize, Deflated)> = std::thread::scope(|sc| { + let workers: Vec<_> = (0..threads.min(bands.len())) .map(|_| { sc.spawn(|| { - let mut out: Vec = Vec::new(); + let mut out = Vec::new(); loop { let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(&(y0, y1)) = bands.get(i) else { break }; - let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1)); - let mut scratch = vec![0u8; stride]; - for y in y0..y1 { - let row = &data[y * stride..(y + 1) * stride]; - let prev = (y > 0).then(|| &data[(y - 1) * stride..y * stride]); - filter_row(row, prev, bpp, &mut scratch, &mut filtered); - } - let adler = adler32(1, &filtered); - let Some(z) = deflate_band(&filtered, lvl, y1 == h) else { return Vec::new() }; - out.push((i, z, adler, filtered.len())); + let Some(b) = bands.get(i) else { break }; + let Some(d) = band(b) else { return Vec::new() }; + out.push((i, d)); } out }) @@ -496,20 +723,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio return None; } parts.sort_by_key(|p| p.0); - let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::() + 6); - // zlib header: deflate, 32 K window; FLEVEL only advertises the effort. - out.extend_from_slice(match level { - PngCompression::Fast => &[0x78, 0x5E], - PngCompression::Best => &[0x78, 0xDA], - _ => &[0x78, 0x9C], - }); - let mut adler = 1u32; - for (_, z, a, len) in &parts { - out.extend_from_slice(z); - adler = adler32_combine(adler, *a, *len); - } - out.extend_from_slice(&adler.to_be_bytes()); - Some(out) + Some(parts.into_iter().map(|p| p.1).collect()) } /// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band. @@ -521,7 +735,8 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio fn deflate_band(filtered: &[u8], lvl: flate2::Compression, last: bool) -> Option> { let mut c = flate2::Compress::new(lvl, false); // Stored blocks cost 5 bytes per 64 KiB, the flush an empty stored block: 1/8 is ample. - let mut z = Vec::with_capacity(filtered.len() + filtered.len() / 8 + 1024); + let mut z = Vec::new(); + z.try_reserve_exact(filtered.len() + filtered.len() / 8 + 1024).ok()?; let flush = if last { flate2::FlushCompress::Finish } else { flate2::FlushCompress::Sync }; let status = c.compress_vec(filtered, &mut z, flush).ok()?; let done = if last { status == flate2::Status::StreamEnd } else { c.total_in() as usize == filtered.len() && z.len() < z.capacity() }; @@ -978,3 +1193,166 @@ mod parallel_tests { } } } + +#[cfg(test)] +mod streamed_tests { + use super::*; + + /// (bytes ahead of the first IDAT, the IDAT payloads joined, how many IDAT chunks). + fn split(png: &[u8]) -> (Vec, Vec, usize) { + let (mut at, mut head, mut zlib, mut count) = (8, None, Vec::new(), 0); + while at + 12 <= png.len() { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + if &png[at + 4..at + 8] == b"IDAT" { + head.get_or_insert_with(|| png[..at].to_vec()); + zlib.extend_from_slice(&png[at + 8..at + 8 + len]); + count += 1; + } + at += 12 + len; + } + (head.unwrap(), zlib, count) + } + + fn noise(n: usize, seed: u32) -> Vec { + (0..n).map(|i| ((i / 3) as u32 ^ seed).wrapping_mul(2_654_435_761).rotate_left(i as u32 % 7) as u8 / 3 + (i % 97) as u8).collect() + } + + /// `img` pushed in bands of `heights` rows (the last one takes the rest). + fn streamed(img: &Image, heights: &[u32], opts: &EncodeOptions) -> Vec { + let (w, h) = img.dimensions(); + let row = img.data().len() / h as usize; + let mut bands = Vec::new(); + let mut y = 0; + for &n in heights.iter().chain(std::iter::once(&u32::MAX)) { + let n = n.min(h - y); + if n == 0 { + continue; + } + let data = img.data()[y as usize * row..(y + n) as usize * row].to_vec(); + bands.push(Image::from_raw(w, n, img.layout(), img.sample_type(), data).unwrap().with_icc(img.icc.clone()).with_meta(img.meta.clone())); + y += n; + } + let mut out = Vec::new(); + let mut rows = PngRows::new(&mut out, &bands[0], h, opts).unwrap(); + for b in &bands { + rows.push(b).unwrap(); + } + rows.finish().unwrap(); + out + } + + /// Bands of any height give the zlib stream, header chunks and pixels the whole-image encoder + /// gives, at 8 and 16 bits, with a profile, resolution, text and XMP. + #[test] + fn streamed_rows_match_the_whole_image_encoder() { + for (layout, sample, ch, bytes) in [(ChannelLayout::Rgba, SampleType::U8, 4usize, 1usize), (ChannelLayout::Rgb, SampleType::U16, 3, 2)] { + let (w, h) = (1201u32, 1013u32); + let meta = Metadata { + dpi: Some((300.0, 300.0)), + text: vec![("Title".into(), "Tent".into())], + xmp: Some("".into()), + ..Default::default() + }; + let img = Image::from_raw(w, h, layout, sample, noise(w as usize * h as usize * ch * bytes, 7)) + .unwrap() + .with_icc(Some((0..600u32).map(|i| (i % 251) as u8).collect())) + .with_meta(meta); + for level in [PngCompression::Fast, PngCompression::Default] { + let opts = EncodeOptions { png_compression: level, ..Default::default() }; + let whole = crate::encode(&img, Format::Png, &opts).unwrap(); + let (head, zlib, _) = split(&whole); + for heights in [&[1u32, 77, 300][..], &[256, 256, 256], &[1013]] { + let png = streamed(&img, heights, &opts); + let (h2, z2, _) = split(&png); + assert!(h2 == head, "{layout:?} {level:?} {heights:?}: header chunks differ"); + assert!(z2 == zlib, "{layout:?} {level:?} {heights:?}: zlib stream differs"); + assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data()); + } + } + } + } + + /// Small images, no compression and bands in another sample type (converted by the plan). + #[test] + fn small_uncompressed_and_converted_bands_round_trip() { + let img = Image::from_raw(3, 5, ChannelLayout::GrayA, SampleType::U8, noise(30, 3)).unwrap(); + for level in [PngCompression::None, PngCompression::Best] { + let opts = EncodeOptions { png_compression: level, ..Default::default() }; + let png = streamed(&img, &[2, 1], &opts); + assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data(), "{level:?}"); + } + // Float bands are written with 16 bits, as `encode` writes them. + let f = img.convert(ChannelLayout::GrayA, SampleType::F32); + let png = streamed(&f, &[4], &EncodeOptions::default()); + let back = decode(&png, &Limits::default()).unwrap(); + assert_eq!(back.sample_type(), SampleType::U16); + assert_eq!(back.convert(ChannelLayout::GrayA, SampleType::U8).data(), img.data()); + } + + /// A zlib stream longer than one chunk may hold is split over IDAT chunks that read back. + #[test] + fn long_streams_split_over_several_idat_chunks() { + let img = Image::from_raw(64, 40, ChannelLayout::Rgb, SampleType::U8, noise(64 * 40 * 3, 9)).unwrap(); + let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let (_, zlib, _) = split(&whole); + let mut out = Vec::new(); + let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap(); + write_idat(&mut w, &zlib, 100).unwrap(); + w.finish().unwrap(); + let (_, joined, count) = split(&out); + assert_eq!(count, zlib.len().div_ceil(100)); + assert!(joined == zlib); + assert_eq!(decode(&out, &Limits::default()).unwrap().data(), img.data()); + } + + /// Bands that don't fit the image, missing rows and interlacing are errors, not panics. + #[test] + fn bands_that_dont_fit_are_errors() { + let band = Image::from_raw(4, 2, ChannelLayout::Rgb, SampleType::U8, vec![0; 24]).unwrap(); + let opts = EncodeOptions::default(); + assert!(PngRows::new(Vec::new(), &band, 0, &opts).is_err()); + assert!(PngRows::new(Vec::new(), &band, 3, &EncodeOptions { png_interlaced: true, ..Default::default() }).is_err()); + let mut rows = PngRows::new(Vec::new(), &band, 3, &opts).unwrap(); + rows.push(&band).unwrap(); + // Two more rows than the image holds. + assert!(rows.push(&band).is_err()); + let narrow = Image::from_raw(3, 1, ChannelLayout::Rgb, SampleType::U8, vec![0; 9]).unwrap(); + assert!(rows.push(&narrow).is_err()); + // Two of three rows. + assert!(rows.finish().is_err()); + } +} + +#[cfg(test)] +mod text_budget_tests { + use super::*; + use std::io::Write; + + /// A compressed iTXt chunk whose text inflates to `len` bytes of invalid UTF-8. + fn bad_itxt(len: usize) -> Vec { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&vec![0xff; len]).unwrap(); + let mut data = b"Comment\0\x01\x00\0\0".to_vec(); + data.extend_from_slice(&z.finish().unwrap()); + let mut out = (data.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(b"iTXt"); + out.extend_from_slice(&data); + let mut crc = flate2::Crc::new(); + crc.update(b"iTXt"); + crc.update(&data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + out + } + + #[test] + fn dropped_text_still_counts_against_the_budget() { + let one = [b"\x89PNG\r\n\x1a\n".to_vec(), bad_itxt(100_000)].concat(); + let mut meta = Metadata::default(); + read_text_metadata(&one, 1 << 20, &mut meta).unwrap(); + assert!(meta.text.is_empty(), "invalid UTF-8 is dropped"); + // Twenty chunks inflate 2 MB between them: each is dropped, but the work is charged. + let many = [b"\x89PNG\r\n\x1a\n".to_vec(), bad_itxt(100_000).repeat(20)].concat(); + let mut meta = Metadata::default(); + assert!(matches!(read_text_metadata(&many, 1 << 20, &mut meta), Err(CodecError::LimitExceeded(_)))); + } +} diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index e3ccfbc952..8b2d5d4e8f 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -18,6 +18,8 @@ //! output's layout is decoded in place; anything else goes through one strip- or tile-sized //! scratch buffer per worker, so no second full-size buffer exists. Image data cut off by the //! end of the file leaves the missing chunks empty and adds a [`DecodeWarning::Truncated`]. +//! [`TiffBands`] runs the same decode a group of bands at a time and hands each group to its +//! caller, so a document can be filled without the image ever existing whole (#8169). //! //! Photoshop's private tags are carried as opaque bytes in [`Metadata`]: 34377 (image //! resources) and 37724 (`ImageSourceData`, the layers of a layered TIFF). They are read with @@ -166,7 +168,9 @@ mod bands; mod decode; mod encode; mod tags; +mod ycbcr; +pub use decode::TiffBands; pub(crate) use decode::{decode, decode_page, orientation}; pub(crate) use encode::encode; #[cfg(test)] diff --git a/crates/codecs/src/codecs/tiff/bands.rs b/crates/codecs/src/codecs/tiff/bands.rs index 8eaeadf8a6..093f90efdf 100644 --- a/crates/codecs/src/codecs/tiff/bands.rs +++ b/crates/codecs/src/codecs/tiff/bands.rs @@ -1,4 +1,4 @@ -use super::decode::{Layout, Patched, Src, is_eof}; +use super::decode::{Layout, Patched, Prepared, Src, is_eof}; use super::{CodecError, Decoder, SampleType, err, f16, map_err}; /// The a* and b* samples (1 and 2 of every pixel) of a Lab image between the file's encoding and @@ -96,6 +96,23 @@ pub(super) fn zeroed(len: usize) -> Result, CodecError> { Ok(v) } +/// `v` refilled with `len` zero bytes, reusing its allocation when it is large enough. +pub(super) fn refill_zeroed(mut v: Vec, len: usize) -> Result, CodecError> { + if v.capacity() < len { + drop(v); + return zeroed(len); + } + v.clear(); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + v.par_extend(rayon::iter::repeat_n(0u8, len)); + } + #[cfg(target_arch = "wasm32")] + v.resize(len, 0); + Ok(v) +} + /// Chunks decoded and chunks whose data ended early. #[derive(Default, Clone, Copy)] pub(super) struct Tally { @@ -110,32 +127,29 @@ impl std::ops::Add for Tally { } } -/// Decodes every band into `data`, in parallel on native targets (one decoder and scratch -/// buffer per worker). `extra` receives samples that are not part of the colour image, one -/// band of rows at a time, when it is non-empty. +/// Decodes the bands from `first` on into `data` (`band_bytes` each), in parallel on native +/// targets (one decoder and scratch buffer per worker). `extra` receives samples that are not +/// part of the colour image, `extra_band` bytes per band, when it is non-empty. #[cfg(not(target_arch = "wasm32"))] -pub(super) fn decode_bands<'a, O>( - lay: &Layout, +pub(super) fn decode_bands( + p: &Prepared, + b: &[u8], data: &mut [u8], band_bytes: usize, + first: usize, extra: &mut [u8], extra_band: usize, - _first: &mut Decoder>, - open: &O, -) -> Result -where - O: Fn() -> Result>, CodecError> + Sync, -{ +) -> Result { use rayon::prelude::*; if extra.is_empty() { return data .par_chunks_mut(band_bytes.max(1)) .enumerate() .map_init( - || open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()), - |worker, (b, band)| { + || p.open(b).map(|d| (d, Vec::new())).map_err(|e| e.to_string()), + |worker, (i, band)| { let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?; - decode_band(lay, dec, scratch, b, band, None) + decode_band(&p.lay, dec, scratch, first + i, band, None) }, ) .try_reduce(Tally::default, |a, b| Ok(a + b)); @@ -144,39 +158,37 @@ where .zip(extra.par_chunks_mut(extra_band.max(1))) .enumerate() .map_init( - || open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()), - |worker, (b, (band, extra_band))| { + || p.open(b).map(|d| (d, Vec::new())).map_err(|e| e.to_string()), + |worker, (i, (band, extra_band))| { let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?; - decode_band(lay, dec, scratch, b, band, Some(extra_band)) + decode_band(&p.lay, dec, scratch, first + i, band, Some(extra_band)) }, ) .try_reduce(Tally::default, |a, b| Ok(a + b)) } -/// Decodes every band into `data`, one after the other. +/// Decodes the bands from `first` on into `data` (`band_bytes` each), one after the other. #[cfg(target_arch = "wasm32")] -pub(super) fn decode_bands<'a, O>( - lay: &Layout, +pub(super) fn decode_bands( + p: &Prepared, + b: &[u8], data: &mut [u8], band_bytes: usize, + first: usize, extra: &mut [u8], extra_band: usize, - first: &mut Decoder>, - _open: &O, -) -> Result -where - O: Fn() -> Result>, CodecError> + Sync, -{ +) -> Result { + let mut dec = p.open(b)?; let mut scratch = Vec::new(); let mut tally = Tally::default(); if extra.is_empty() { - for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() { - tally = tally + decode_band(lay, first, &mut scratch, b, band, None)?; + for (i, band) in data.chunks_mut(band_bytes.max(1)).enumerate() { + tally = tally + decode_band(&p.lay, &mut dec, &mut scratch, first + i, band, None)?; } return Ok(tally); } - for (b, (band, extra_band)) in data.chunks_mut(band_bytes.max(1)).zip(extra.chunks_mut(extra_band.max(1))).enumerate() { - tally = tally + decode_band(lay, first, &mut scratch, b, band, Some(extra_band))?; + for (i, (band, extra_band)) in data.chunks_mut(band_bytes.max(1)).zip(extra.chunks_mut(extra_band.max(1))).enumerate() { + tally = tally + decode_band(&p.lay, &mut dec, &mut scratch, first + i, band, Some(extra_band))?; } Ok(tally) } @@ -209,7 +221,11 @@ fn decode_band( if lay.direct() && pref.len == band.len() && stride == out_row { match dec.read_chunk_bytes(chunk, band) { Ok(()) => tally.decoded += 1, - Err(e) if is_eof(&e) => tally.missing += 1, + // Rows read before the end would survive: a missing strip is zeros. + Err(e) if is_eof(&e) => { + band.fill(0); + tally.missing += 1; + } Err(e) => return Err(map_err(e)), } continue; @@ -218,6 +234,9 @@ fn decode_band( return Err(CodecError::LimitExceeded(format!("a {} byte strip or tile", pref.len))); } if scratch.len() < pref.len { + scratch + .try_reserve_exact(pref.len - scratch.len()) + .map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {} bytes for a strip or tile", pref.len)))?; scratch.resize(pref.len, 0); } let buf = scratch.get_mut(..pref.len).ok_or_else(|| err("scratch buffer"))?; @@ -295,6 +314,9 @@ fn read_planar_extras( return Err(CodecError::LimitExceeded(format!("a {} byte strip or tile", pref.len))); } if scratch.len() < pref.len { + scratch + .try_reserve_exact(pref.len - scratch.len()) + .map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {} bytes for a strip or tile", pref.len)))?; scratch.resize(pref.len, 0); } let buf = scratch.get_mut(..pref.len).ok_or_else(|| err("scratch buffer"))?; diff --git a/crates/codecs/src/codecs/tiff/decode.rs b/crates/codecs/src/codecs/tiff/decode.rs index 0872739f3e..e2359e6828 100644 --- a/crates/codecs/src/codecs/tiff/decode.rs +++ b/crates/codecs/src/codecs/tiff/decode.rs @@ -1,5 +1,8 @@ -use super::bands::{convert_ab, decode_bands, extra_sample_indices, split_extra_samples, zeroed}; +use std::borrow::Cow; + +use super::bands::{Tally, convert_ab, decode_bands, extra_sample_indices, refill_zeroed, split_extra_samples, zeroed}; use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags}; +use super::ycbcr::YCbCr; use super::{ ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag, camera_raw, err, ifd, io, map_err, tiff_exif, @@ -14,6 +17,18 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result /// carries the page's metadata, a [`DecodeWarning::MorePages`] when the file holds more /// full-resolution pages, and a [`DecodeWarning::Truncated`] when image data is cut off. pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> Result { + let (file, dir, more) = open_page(bytes, page)?; + let mut img = decode_ifd(&file, &dir, limits)?; + let extra_samples = std::mem::take(&mut img.meta.extra_samples); + img.meta = metadata(&file, &dir, &mut img.icc); + img.meta.extra_samples = extra_samples; + img.warnings.extend(more); + Ok(img) +} + +/// The file, page `page`'s directory (`None`: the default page) and the +/// [`DecodeWarning::MorePages`] the page opens with when the file holds more. +fn open_page(bytes: &[u8], page: Option) -> Result<(File<'_>, Ifd, Option), CodecError> { if camera_raw(bytes) { return Err(CodecError::unsupported(F, "camera raw files (such as CR2, NEF, ARW or DNG) are not flat images; decode them with photocraft-raw")); } @@ -28,15 +43,202 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .get(index) .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; - let mut img = decode_ifd(&file, &dir, limits)?; - let extra_samples = std::mem::take(&mut img.meta.extra_samples); - img.meta = metadata(&file, &dir, &mut img.icc); - img.meta.extra_samples = extra_samples; let pages = info.page_count(); - if pages > 1 { - img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); + let more = (pages > 1).then(|| DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); + Ok((file, dir, more)) +} + +/// Output bytes [`TiffBands::decode`] aims to decode at once (at least one band per worker): small +/// next to the document the rows are written into, and to the copy a caller may convert them to. +const GROUP_BYTES: usize = 64 << 20; + +/// A TIFF page opened for decoding a group of bands at a time, for images too large to hold +/// twice (#8169): [`TiffBands::decode`] hands each group to its caller, who writes it into the +/// document, so only one group exists beside the document. The pixels, layout and metadata are +/// those [`decode_page`] returns; [`TiffBands::upright`] applies the orientation as +/// [`Image::oriented`] does. +pub struct TiffBands<'a> { + b: &'a [u8], + p: Prepared, + /// The page's Orientation tag (1–8). + stored: u16, + /// The orientation the bands are turned by (1: as stored). + turn: u16, + /// `Limits::max_alloc`: the most one group of bands may take. + cap: u64, + pub icc: Option>, + pub meta: Metadata, + /// What the decoder noticed: more pages, and after [`TiffBands::decode`] data cut off. + pub warnings: Vec, +} + +impl<'a> TiffBands<'a> { + /// Opens page `page` (`None`: the page [`decode_page`] opens by default) and checks it + /// against `limits`; on 64-bit targets its `max_alloc` bounds each group of bands, not the + /// whole image. + pub fn open(bytes: &'a [u8], page: Option, limits: &Limits) -> Result { + let (file, dir, more) = open_page(bytes, page)?; + // On 32-bit targets the whole image must still fit `max_alloc`, as it did before: the + // document holding it shares the same small address space (wasm32: 4 GiB). + let p = prepare(&file, &dir, limits, cfg!(target_pointer_width = "32"))?; + let band = p.band_bytes()?; + if band as u64 > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!( + "one strip or row of tiles of this {}x{} image decodes to {band} bytes, over the {} byte limit", + p.w, p.h, limits.max_alloc + ))); + } + let mut icc = None; + let meta = metadata(&file, &dir, &mut icc); + let stored = ifd::orientation_of(&file, &dir); + // Extra samples are dropped here only on 32-bit targets, where the whole image counts. + let mut warnings = prepared_warnings(&p); + warnings.extend(more); + Ok(TiffBands { b: bytes, p, stored, turn: 1, cap: limits.max_alloc, icc, meta, warnings }) } - Ok(img) + + /// The bands turned upright for the page's Orientation tag, as [`Image::oriented`] turns a + /// whole image: the size and resolution swap for 5–8, and the EXIF and XMP orientation are + /// rewritten to 1. + pub fn upright(mut self) -> Self { + let o = self.stored; + if !(2..=8).contains(&o) || self.turn == o { + return self; + } + self.turn = o; + if let Some(e) = &mut self.meta.exif + && let Cow::Owned(fixed) = crate::orientation::upright_exif(e) + { + *e = fixed; + } + if let Some(x) = &mut self.meta.xmp + && let Cow::Owned(fixed) = crate::orientation::upright_xmp(x) + { + *x = fixed; + } + if o >= 5 { + self.meta.dpi = self.meta.dpi.map(|(x, y)| (y, x)); + } + self + } + + /// The page's Orientation tag (1–8; 1 when absent or invalid). + pub fn orientation(&self) -> u16 { + self.stored + } + + /// The size of the image the bands make up (upright when [`TiffBands::upright`] was applied). + pub fn dimensions(&self) -> (u32, u32) { + if self.turn >= 5 { (self.p.h, self.p.w) } else { (self.p.w, self.p.h) } + } + + pub fn layout(&self) -> ChannelLayout { + self.p.lay.out_layout + } + + /// How many samples of a pixel are neither colour nor the alpha channel: alpha and spot + /// channels that [`TiffBands::decode`] does not read (a whole-image decode does). + pub fn extra_samples(&self) -> usize { + self.p.lay.extra_at.len() + } + + pub fn sample_type(&self) -> SampleType { + self.p.lay.out + } + + /// Bands decoded at once: about [`GROUP_BYTES`], at least one per worker, at most `max_alloc`. + fn bands_per_group(&self) -> Result { + let band = self.p.band_bytes()?.max(1); + #[cfg(not(target_arch = "wasm32"))] + let workers = rayon::current_num_threads(); + #[cfg(target_arch = "wasm32")] + let workers = 1; + let cap = usize::try_from(self.cap).unwrap_or(usize::MAX); + Ok((GROUP_BYTES / band).max(workers).min(cap / band).max(1)) + } + + /// Bytes of the largest group [`TiffBands::decode`] hands over: what decoding needs beside + /// the document, for a caller checking that it fits in memory. A file stored as one strip + /// is one group, the whole image. + pub fn group_bytes(&self) -> Result { + let rows = self.bands_per_group()?.saturating_mul(self.p.band_rows).min(self.p.h as usize); + self.p.lay.out_row().checked_mul(rows).ok_or_else(|| CodecError::LimitExceeded("band size".into())) + } + + /// A conservative bound on the decoder's own scratch memory in a whole-image decode of this + /// page (as `decode_page` does it): one strip or tile per worker that decodes a band at + /// once. Zero when strips are decoded in place. Add the decoded image itself and any + /// extra-sample planes. + pub fn decode_scratch_bytes(&self) -> u64 { + #[cfg(not(target_arch = "wasm32"))] + let workers = rayon::current_num_threads() as u64; + #[cfg(target_arch = "wasm32")] + let workers = 1; + scratch_bound(self.p.lay.direct(), self.p.scratch_bytes as u64, self.p.band_rows, self.p.h, workers) + } + + /// Decodes the image a group of bands at a time, in order, handing each group to `sink` + /// with the position of its top-left pixel in the image [`TiffBands::dimensions`] gives. A + /// group is about 64 MiB (at least one strip or row of tiles per worker, at most `max_alloc`; + /// see [`TiffBands::group_bytes`]). Data cut off by the end of the file adds a + /// [`DecodeWarning::Truncated`] to `warnings`. + pub fn decode>(&mut self, mut sink: impl FnMut(u32, u32, &Image) -> Result<(), E>) -> Result<(), E> { + let per_group = self.bands_per_group()?; + let p = &self.p; + let band = p.band_bytes()?.max(1); + let bands = (p.h as usize).div_ceil(p.band_rows.max(1)); + let mut buf: Vec = Vec::new(); + let mut tally = Tally::default(); + let mut first = 0; + while first < bands { + let n = per_group.min(bands - first); + let y0 = first * p.band_rows; + let rows = (n * p.band_rows).min(p.h as usize - y0); + let len = p.lay.out_row().checked_mul(rows).ok_or_else(|| CodecError::LimitExceeded("band size".into()))?; + // Missing chunks leave their rows as they are: they must start empty in every group. + buf = refill_zeroed(buf, len)?; + tally = tally + decode_bands(p, self.b, &mut buf, band, first, &mut [], 0)?; + if let Some(c) = p.ycbcr { + c.convert(&mut buf, p.lay.out_channels(), p.lay.out); + } + if let Some(signed) = p.lab { + convert_ab(&mut buf, p.lay.out_channels(), p.lay.out, signed, false); + } + let rows = u32::try_from(rows).map_err(|_| err("band too tall"))?; + let y0 = u32::try_from(y0).map_err(|_| err("band out of range"))?; + let group = Image::from_raw(p.w, rows, p.lay.out_layout, p.lay.out, std::mem::take(&mut buf))?; + let (x, y) = match self.turn { + 3 | 4 => (0, p.h.saturating_sub(y0 + rows)), + 5 | 8 => (y0, 0), + 6 | 7 => (p.h.saturating_sub(y0 + rows), 0), + _ => (0, y0), + }; + let group = group.oriented(self.turn)?; + sink(x, y, &group)?; + buf = group.into_data(); + first += n; + } + if tally.decoded == 0 && tally.missing > 0 { + return Err(err("the image data is missing (cut off or past the end of the file)").into()); + } + if tally.missing > 0 { + // After the extra-sample warnings and before `MorePages`, as the whole-image decode + // orders them. + let at = self.warnings.iter().position(|w| matches!(w, DecodeWarning::MorePages { .. })).unwrap_or(self.warnings.len()); + self.warnings.insert(at, DecodeWarning::Truncated { format: F }); + } + Ok(()) + } +} + +/// [`TiffBands::decode_scratch_bytes`]'s bound: none when chunks are decoded in place, +/// otherwise one chunk per worker that decodes a band, and never more chunks than the image has. +fn scratch_bound(direct: bool, chunk_bytes: u64, band_rows: usize, h: u32, workers: u64) -> u64 { + if direct { + return 0; + } + let bands = u64::from(h).div_ceil(band_rows.max(1) as u64).max(1); + chunk_bytes.saturating_mul(workers.min(bands)) } /// The Orientation (1–8) of page `page` (`None`: the default page); 1 when absent, malformed @@ -280,7 +482,7 @@ fn multiband_layout(photometric: u64, has_alpha: bool, gray_alpha: bool) -> Chan /// The chunk tables must match the geometry. Checked in 64 bits, before the `tiff` crate does /// the same arithmetic in narrower types. -fn check_chunk_tables(f: &File<'_>, dir: &Ifd, (w, h): (u32, u32), spp: u64, planar: bool, tiled: bool) -> Result<(u64, u64), CodecError> { +fn check_chunk_tables(f: &File<'_>, dir: &Ifd, (w, h): (u32, u32), spp: u64, planar: bool, tiled: bool) -> Result<(u64, u64, u64), CodecError> { let need = |tag: u16, name: &str| f.tag_uint(dir, tag).ok_or_else(|| err(format!("missing or unreadable {name}"))); let (cw, ch) = if tiled { let tw = need(ifd::TAG_TILE_WIDTH, "TileWidth")?; @@ -306,7 +508,7 @@ fn check_chunk_tables(f: &File<'_>, dir: &Ifd, (w, h): (u32, u32), spp: u64, pla if counts != (Some(expected), Some(expected)) { return Err(err(format!("{expected} strips or tiles expected, the tables list {counts:?}"))); } - Ok((cw, ch)) + Ok((cw, ch, expected)) } /// Byte patches the `tiff` crate reads through: the directory is seen as the last of its chain, @@ -344,8 +546,112 @@ fn header_patches( Ok(patches) } +/// Lab and YCbCr are converted after decoding, from 8 or 16-bit samples only: Lab needs at least +/// its three colour samples, YCbCr exactly three. +fn check_converted_model(photometric: u64, spp: u64, bits: u64, src: Src) -> Result<(), CodecError> { + let (name, samples_ok) = match photometric { + 8 | 9 => ("Lab", spp >= 3), + 6 => ("YCbCr", spp == 3), + _ => return Ok(()), + }; + if samples_ok && matches!(src, Src::U8 | Src::U16) { + return Ok(()); + } + Err(CodecError::unsupported(F, format!("{name} image with {spp} samples of {bits} bits"))) +} + +/// A directory checked and laid out for decoding, before any pixel is read. +pub(super) struct Prepared { + w: u32, + h: u32, + pub(super) lay: Layout, + /// Rows per band: one strip, or one row of tiles. + band_rows: usize, + patches: Vec<(u64, Vec)>, + tl: tiff::decoder::Limits, + /// Lab samples: `Some(true)` for CIELab (a* and b* stored signed), `Some(false)` for ICCLab. + lab: Option, + /// YCbCr samples: the conversion to RGB (#10316). + ycbcr: Option, + /// The colour samples of a pixel in the file's colour model. + color: usize, + /// The ExtraSamples tag (0 unspecified, 1 associated alpha, 2 unassociated alpha). + extra_kinds: Vec, + /// Extra samples past `MAX_EXTRA_SAMPLES`, left out. + extras_over_limit: usize, + /// Extra samples left out because they did not fit the allocation budget beside the image. + extras_dropped: u32, + /// Bytes one strip or tile decodes to, before clipping to the image: a worker's scratch + /// buffer. A strip is the full width; a tile keeps its declared size even at an edge. + scratch_bytes: usize, +} + +impl Prepared { + /// A decoder over `b` (the file the directory was read from) that opens the directory. + pub(super) fn open<'a>(&'a self, b: &'a [u8]) -> Result>, CodecError> { + Decoder::new(Patched { b, patches: &self.patches, pos: 0 }).map(|d| d.with_limits(self.tl.clone())).map_err(map_err) + } + + /// Output bytes of one full band. + fn band_bytes(&self) -> Result { + self.lay.out_row().checked_mul(self.band_rows).ok_or_else(|| CodecError::LimitExceeded("band size".into())) + } +} + /// Decodes the pixels of one directory. fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result { + let p = prepare(f, dir, limits, true)?; + // The output, allocated once and fallibly. + let len = p.lay.out_row().checked_mul(p.h as usize).ok_or_else(|| CodecError::LimitExceeded("image size".into()))?; + let mut data = zeroed(len)?; + let extra_row = p.lay.extra_row(); + let extra_len = extra_row.checked_mul(p.h as usize).ok_or_else(|| CodecError::LimitExceeded("extra samples".into()))?; + let mut extra_data = if p.lay.extra_at.is_empty() { Vec::new() } else { zeroed(extra_len)? }; + let extra_band = extra_row.saturating_mul(p.band_rows); + let tally = decode_bands(&p, f.b, &mut data, p.band_bytes()?, 0, extra_data.as_mut_slice(), extra_band)?; + if tally.decoded == 0 && tally.missing > 0 { + return Err(err("the image data is missing (cut off or past the end of the file)")); + } + if let Some(c) = p.ycbcr { + c.convert(&mut data, p.lay.out_channels(), p.lay.out); + } + if let Some(signed) = p.lab { + convert_ab(&mut data, p.lay.out_channels(), p.lay.out, signed, false); + } + let mut img = Image::from_raw(p.w, p.h, p.lay.out_layout, p.lay.out, data)?; + if !p.lay.extra_at.is_empty() { + // TIFF 6.0 defines ExtraSamples 0, 1 and 2 only; a reserved value reads as unspecified, + // so a save never writes it back. + let kind_of = |si: usize| match si.checked_sub(p.color).and_then(|k| p.extra_kinds.get(k)).copied() { + Some(v @ 0..=2) => v as u16, + _ => 0, + }; + img.meta.extra_samples = split_extra_samples(&extra_data, p.w as usize, p.h as usize, &p.lay.extra_at, p.lay.out, kind_of)?; + } + img.warnings.extend(prepared_warnings(&p)); + if tally.missing > 0 { + img.warnings.push(DecodeWarning::Truncated { format: F }); + } + Ok(img) +} + +/// What [`prepare`] left out of the image: extra samples past the supported count, then extra +/// samples over the allocation budget. Both decoders report them, in this order. +fn prepared_warnings(p: &Prepared) -> Vec { + let mut warnings = Vec::new(); + if p.extras_over_limit > 0 { + let count = u32::try_from(p.extras_over_limit).unwrap_or(u32::MAX); + warnings.push(DecodeWarning::ExtraSamplesOverLimit { format: F, count }); + } + if p.extras_dropped > 0 { + warnings.push(DecodeWarning::ExtraSamplesDropped { format: F, count: p.extras_dropped }); + } + warnings +} + +/// Checks one directory and lays it out for decoding. `whole`: the image is decoded into one +/// buffer, which `limits.max_alloc` bounds; otherwise only its size is checked here. +fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result { // The values the `tiff` crate reads must exist: a count past the end of the file is // neither allocated nor read. if let Some(e) = dir.entries.iter().find(|e| ifd::STRUCTURAL_TAGS.contains(&e.tag) && f.data(e).is_none()) { @@ -372,7 +678,8 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result 3, + 2 | 6 | 8 | 9 => 3, 5 => 4, _ => 1, }; @@ -409,8 +718,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::Rgb, 3), + tiff::ColorType::YCbCr(_) if ycbcr => (ChannelLayout::Rgb, 3), tiff::ColorType::RGBA(_) => (ChannelLayout::Rgba, 4), tiff::ColorType::CMYK(_) => (ChannelLayout::Cmyk, 4), tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5), @@ -456,19 +765,46 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result SampleType::U8, _ => src.out(), }; - limits.check(w, h, out_layout, out)?; + if whole { + limits.check(w, h, out_layout, out)?; + } else { + limits.check_size(w, h)?; + } // Decompression-bomb guard: no compression method expands data by more than a few thousand // times (Deflate at most ~1032:1, LZW ~2730:1, PackBits 128:1), so a file far too small for - // its declared size is refused before the image is allocated. Uncompressed data may be up - // to three quarters missing (that decodes with a `Truncated` warning). + // its declared size is refused before the image is allocated. CCITT Group 4 codes a blank row + // of a strip or tile in one bit (a V0 code), so its bound is the chunk width (the decoder + // reads at most 65535): a blank landscape A4 page at 600 dpi, 7016 px wide, compresses past + // 4096:1 (Groups 3 and 2 are not decoded). Uncompressed data may be up + // to three quarters missing (that decodes with a `Truncated` warning). Only the bytes the + // strip or tile tables point at inside the file count (and never more than the file), so + // padding the file while pointing the chunks past its end does not raise the bound: the + // banded decoder has no whole-image `max_alloc` to fall back on. + let compression = f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1); let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(spp).saturating_mul(bits).div_ceil(8); - let ratio: u64 = match f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1) { + let ratio: u64 = match compression { 1 => 4, 32773 => 130, + 4 => cw.clamp(4096, 65_535), _ => 4096, }; - if stored > (f.b.len() as u64).saturating_mul(ratio).saturating_add(1 << 20) { - return Err(err(format!("a {} byte file cannot hold a {w}x{h} image", f.b.len()))); + let len = f.b.len() as u64; + let (offsets, sizes) = ( + dir.get(offsets_tag).ok_or_else(|| err("missing strip or tile offsets"))?, + dir.get(counts_tag).ok_or_else(|| err("missing strip or tile byte counts"))?, + ); + let mut held = 0u64; + for i in 0..expected { + if held >= len { + break; + } + if let (Some(at), Some(n)) = (f.uint(offsets, i), f.uint(sizes, i)) { + held = held.saturating_add(n.min(len.saturating_sub(at))); + } + } + let held = held.min(len); + if stored > held.saturating_mul(ratio).saturating_add(1 << 20) { + return Err(err(format!("the strips or tiles in this file hold {held} bytes, too few for a {w}x{h} image"))); } let (wu, hu) = (w as usize, h as usize); @@ -478,6 +814,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result 0 { - return Err(err("the image data is missing (cut off or past the end of the file)")); + /// `TiffBands::open` drops extra samples over the budget only on 32-bit targets (wasm32), + /// so it reports them through the same function as the whole-image decode. + #[test] + fn extra_samples_over_the_budget_are_reported_by_both_decoders() { + // Several strips (about 1 MB each), so one strip of all four samples fits the image's budget. + let (w, h) = (1024u32, 1024u32); + let n = (w * h) as usize; + let mut img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, vec![90; 3 * n]).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![7; n] }]; + let bytes = crate::encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let (file, dir, _) = open_page(&bytes, None).unwrap(); + let tight = Limits { max_alloc: 3 * n as u64, ..Limits::default() }; + let p = prepare(&file, &dir, &tight, true).unwrap(); + assert_eq!(prepared_warnings(&p), vec![DecodeWarning::ExtraSamplesDropped { format: F, count: 1 }]); + assert!(prepared_warnings(&prepare(&file, &dir, &tight, false).unwrap()).is_empty(), "banded: only the band counts"); + assert!(prepared_warnings(&prepare(&file, &dir, &Limits::default(), true).unwrap()).is_empty()); + let whole = decode(&bytes, &tight).unwrap(); + assert_eq!(whole.warnings, prepared_warnings(&p)); } - if lab { - convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false); - } - let mut img = Image::from_raw(w, h, out_layout, out, data)?; - if !lay.extra_at.is_empty() { - // TIFF 6.0 defines ExtraSamples 0, 1 and 2 only; a reserved value reads as unspecified, - // so a save never writes it back. - let kind_of = |si: usize| match si.checked_sub(color).and_then(|k| extra.get(k)).copied() { - Some(v @ 0..=2) => v as u16, - _ => 0, - }; - img.meta.extra_samples = split_extra_samples(&extra_data, wu, hu, &lay.extra_at, out, kind_of)?; - } - if extras_over_limit > 0 { - let count = u32::try_from(extras_over_limit).unwrap_or(u32::MAX); - img.warnings.push(DecodeWarning::ExtraSamplesOverLimit { format: F, count }); - } - if extras_dropped > 0 { - img.warnings.push(DecodeWarning::ExtraSamplesDropped { format: F, count: extras_dropped }); - } - if tally.missing > 0 { - img.warnings.push(DecodeWarning::Truncated { format: F }); + + /// The scratch bound a whole-image decode reserves: none for chunks decoded in place, one + /// chunk per worker otherwise, never more chunks than the image has bands. + #[test] + fn whole_image_scratch_is_one_chunk_per_worker() { + assert_eq!(scratch_bound(true, 1 << 20, 16, 1024, 8), 0, "strips decoded in place need no scratch"); + assert_eq!(scratch_bound(false, 1 << 20, 16, 1024, 8), 8 << 20, "one strip per worker"); + assert_eq!(scratch_bound(false, 1 << 20, 16, 32, 8), 2 << 20, "never more strips than the image has"); + assert_eq!(scratch_bound(false, 1 << 20, 0, 2, 8), 2 << 20, "a zero band height counts as one row"); + assert_eq!(scratch_bound(false, u64::MAX, 1, 1, 8), u64::MAX, "the bound saturates"); } - Ok(img) } diff --git a/crates/codecs/src/codecs/tiff/encode.rs b/crates/codecs/src/codecs/tiff/encode.rs index a0b7539b86..18b02a6031 100644 --- a/crates/codecs/src/codecs/tiff/encode.rs +++ b/crates/codecs/src/codecs/tiff/encode.rs @@ -260,8 +260,13 @@ struct TagSet<'a> { extra_samples: &'a [u16], photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, + /// An image pyramid level: written with `NewSubfileType` 1 (reduced resolution). + reduced: bool, } +/// TIFF `NewSubfileType` (254). +const TAG_NEW_SUBFILE_TYPE: u16 = 254; + /// Writes one image directory. (The `tiff` encoder only compresses through `write_data`, which /// takes every sample at once.) fn write_one(enc: &mut TiffEncoder<&mut Cursor>, K>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()> @@ -287,6 +292,9 @@ where im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 }); } let d = im.encoder(); + if tags.reduced { + d.write_tag(Tag::Unknown(TAG_NEW_SUBFILE_TYPE), 1u32)?; + } if !tags.extra_samples.is_empty() { d.write_tag(Tag::ExtraSamples, tags.extra_samples)?; } @@ -374,6 +382,117 @@ fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: } } +/// Writes the image pyramid after the full image: each level half the size of the one before +/// (rounded up), colour and alpha only, until the longer side is at most +/// [`crate::TIFF_PYRAMID_MIN`]. An image already that small gets no levels. +fn write_pyramid(enc: &mut TiffEncoder<&mut Cursor>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> { + let mut level: Option = None; + loop { + let prev = level.as_ref().unwrap_or(img); + let (w, h) = prev.dimensions(); + if w.max(h) <= crate::TIFF_PYRAMID_MIN { + return Ok(()); + } + let next = half_size(prev).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; + write_image::(enc, &next, tags)?; + level = Some(next); + } +} + +/// `img` at half its size, rounded up, by averaging each 2×2 block (an odd last row or column +/// averages what it has). Colour is weighted by alpha, so transparent pixels don't darken +/// the edges of what they surround. Extra samples are not carried over. Rows are worked in +/// parallel on native targets. +pub(super) fn half_size(img: &Image) -> Result { + let (w, h) = img.dimensions(); + let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); + let layout = img.layout(); + let sample = img.sample_type(); + let ch = layout.channels(); + let bps = sample.bytes(); + let src_row = (w as usize).checked_mul(ch).and_then(|n| n.checked_mul(bps)).ok_or_else(|| CodecError::encode(F, "image row size overflows"))?; + let out_row = (nw as usize).checked_mul(ch).and_then(|n| n.checked_mul(bps)).ok_or_else(|| CodecError::encode(F, "pyramid row size overflows"))?; + let len = out_row.checked_mul(nh as usize).ok_or_else(|| CodecError::encode(F, "pyramid level size overflows"))?; + if img.data().len() < src_row.saturating_mul(h as usize) { + return Err(CodecError::encode(F, "image buffer is shorter than its dimensions")); + } + let mut out = super::bands::zeroed(len)?; + let level = Level { data: img.data(), w: w as usize, h: h as usize, ch, sample, src_row, alpha: layout.has_alpha() }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(out_row.max(1)).enumerate().for_each(|(y, row)| level.row(y, row)); + } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(out_row.max(1)).enumerate().for_each(|(y, row)| level.row(y, row)); + let mut img2 = Image::from_raw(nw, nh, layout, sample, out)?; + img2.icc = img.icc.clone(); + Ok(img2) +} + +/// The source of one [`half_size`] level. +struct Level<'a> { + data: &'a [u8], + w: usize, + h: usize, + ch: usize, + sample: SampleType, + src_row: usize, + /// The last channel is alpha. + alpha: bool, +} + +impl Level<'_> { + /// Source row `y` as numbers (empty past the bottom). + fn samples(&self, y: usize) -> Vec { + let Some(row) = (y < self.h).then(|| self.data.get(y * self.src_row..(y + 1) * self.src_row)).flatten() else { return Vec::new() }; + match self.sample { + SampleType::U8 => row.iter().map(|&v| f64::from(v)).collect(), + SampleType::U16 => row.as_chunks::<2>().0.iter().map(|b| f64::from(u16::from_ne_bytes(*b))).collect(), + SampleType::F32 => row.as_chunks::<4>().0.iter().map(|b| f64::from(f32::from_ne_bytes(*b))).collect(), + SampleType::F16 => row.as_chunks::<2>().0.iter().map(|b| f64::from(half::f16::from_bits(u16::from_ne_bytes(*b)).to_f32())).collect(), + } + } + + /// Writes output row `y` into `out`. + fn row(&self, y: usize, out: &mut [u8]) { + let rows = [self.samples(y * 2), self.samples(y * 2 + 1)]; + let ch = self.ch; + let bps = self.sample.bytes(); + let mut sums = vec![0f64; ch]; + for (x, px) in out.chunks_exact_mut(ch * bps).enumerate() { + sums.iter_mut().for_each(|s| *s = 0.0); + let (mut n, mut weight) = (0f64, 0f64); + for row in rows.iter().filter(|r| !r.is_empty()) { + for sx in [x * 2, x * 2 + 1].into_iter().filter(|&sx| sx < self.w) { + let Some(p) = row.get(sx * ch..(sx + 1) * ch) else { continue }; + let a = if self.alpha { p.last().copied().unwrap_or(1.0) } else { 1.0 }; + for (c, (sum, v)) in sums.iter_mut().zip(p).enumerate() { + *sum += if self.alpha && c + 1 == ch { *v } else { v * a }; + } + n += 1.0; + weight += a; + } + } + for (c, (sum, dst)) in sums.iter().zip(px.chunks_exact_mut(bps)).enumerate() { + let v = if self.alpha && c + 1 == ch { + sum / n.max(1.0) + } else if weight > 0.0 { + sum / weight + } else { + 0.0 + }; + match self.sample { + SampleType::U8 => dst.copy_from_slice(&[v.round().clamp(0.0, 255.0) as u8]), + SampleType::U16 => dst.copy_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_ne_bytes()), + SampleType::F32 => dst.copy_from_slice(&(v as f32).to_ne_bytes()), + SampleType::F16 => dst.copy_from_slice(&half::f16::from_f32(v as f32).to_bits().to_ne_bytes()), + } + } + } + } +} + /// Classic TIFF offsets are 32-bit: a file that may grow past this is written as BigTIFF. const CLASSIC_LIMIT: u64 = u32::MAX as u64; @@ -476,23 +595,62 @@ fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), + reduced: false, + }; + // A pyramid level holds the colour and alpha only, with the profile so it shows the same. + let alpha_kind: &[u16] = if img.layout().has_alpha() { &[2] } else { &[] }; + let level_tags = TagSet { + icc: tags.icc, + exif: None, + xmp: None, + dpi: None, + text: Vec::new(), + extra_samples: alpha_kind, + photoshop_resources: None, + photoshop_layers: None, + reduced: true, }; // BigTIFF when asked, or when the file could pass the 4 GiB a classic TIFF can address. A // classic write that still runs out of 32-bit offsets (compression grew the data) is redone // as BigTIFF. + let estimate = estimated_size(img, &tags); + // The pyramid adds at most a third of the image (1/4 + 1/16 + …). + let estimate = if opts.tiff_pyramid { estimate.saturating_add(img.data().len() as u64 / 3) } else { estimate }; let write = |big: bool| -> tiff::TiffResult> { - let mut cursor = Cursor::new(Vec::new()); + // An uncompressed file is allocated once at its estimated size: grown as written, the + // buffer would double near the end and copy the file (#6607). Compressed output is + // usually a fraction of the estimate, so it grows as written instead: reserving the + // estimate would hold a second full-size buffer while encoding, which wasm32 never + // returns and Windows counts as committed memory. + let mut out = Vec::new(); + if matches!(opts.tiff_compression, TiffCompression::None) { + // Best effort: without the room the buffer grows as before. + let _ = out.try_reserve(usize::try_from(estimate).unwrap_or(usize::MAX)); + } + let mut cursor = Cursor::new(out); if big { let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor); write_image::(&mut enc, img, &tags)?; + if opts.tiff_pyramid { + write_pyramid::(&mut enc, img, &level_tags)?; + } } else { let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor); write_image::(&mut enc, img, &tags)?; + if opts.tiff_pyramid { + write_pyramid::(&mut enc, img, &level_tags)?; + } } - Ok(cursor.into_inner()) + // Growth (or an estimate that was too high) can leave spare capacity: give it back + // instead of holding it while the file is saved. + let mut out = cursor.into_inner(); + if out.capacity() - out.len() > out.len() / 8 { + out.shrink_to_fit(); + } + Ok(out) }; - let big = opts.tiff_bigtiff || estimated_size(img, &tags) > CLASSIC_LIMIT; + let big = opts.tiff_bigtiff || estimate > CLASSIC_LIMIT; match write(big) { Ok(v) => Ok(v), Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)), diff --git a/crates/codecs/src/codecs/tiff/ycbcr.rs b/crates/codecs/src/codecs/tiff/ycbcr.rs new file mode 100644 index 0000000000..2983a8050e --- /dev/null +++ b/crates/codecs/src/codecs/tiff/ycbcr.rs @@ -0,0 +1,222 @@ +//! YCbCr (PhotometricInterpretation 6) to RGB, after the `tiff` crate has decoded the samples. +//! +//! The crate decompresses JPEG data and upsamples subsampled chroma, but hands back the stored +//! Y, Cb and Cr values. This module turns them into RGB in place: ReferenceBlackWhite scales the +//! codes and YCbCrCoefficients gives the luma weights. JPEG-compressed data (compression 7) uses +//! the JPEG (JFIF) conversion, full range with the BT.601 weights, and ignores both tags, as +//! libtiff does when it asks the JPEG library for RGB. +//! +//! # Sources +//! +//! - TIFF Revision 6.0 (Final, June 3, 1992), Section 21 "YCbCr Images", pp. 89–92; the +//! YCbCrCoefficients field and its inverse equations are on pp. 90–91: +//! `R = Cr·(2 - 2·LumaRed) + Y`, `B = Cb·(2 - 2·LumaBlue) + Y`, +//! `G = (Y - LumaBlue·B - LumaRed·R) / LumaGreen`. +//! +//! - libtiff, `libtiff/tif_color.c`: `TIFFYCbCrToRGBInit` (tables) and `TIFFYCbCrtoRGB` (per pixel). +//! +//! +//! # Green: a compatibility choice +//! +//! Red and blue follow the spec. Green follows libtiff, which rewrites the spec's equation as +//! `G = Y - (LumaRed·(2 - 2·LumaRed) / LumaGreen)·Cr - (LumaBlue·(2 - 2·LumaBlue) / LumaGreen)·Cb`, +//! adding Y unscaled. The two agree only when the weights sum to 1. Every standard set does +//! (the spec's own examples, BT.601 and BT.709), but the spec only calls the weights "the +//! proportions of red, green, and blue respectively in luminance" and does not require the sum. +//! For other weights the spec's equation scales Y in green by `(1 - LumaRed - LumaBlue) / LumaGreen`, +//! which tints neutral grays. +//! +//! PhotoCraft deliberately follows libtiff here (decided in PR #10319, for issue #10316), so such +//! files open as they do in libtiff-based tools (ImageMagick, GIMP) and grays stay neutral. This is +//! a contributor choice, not a requirement of the spec; the test +//! `weights_that_do_not_sum_to_one_keep_grays_neutral` in `tests/it/tiff_ycbcr.rs` pins it. + +use super::{File, Ifd, SampleType}; + +const TAG_YCBCR_COEFFICIENTS: u16 = 529; +const TAG_REFERENCE_BLACK_WHITE: u16 = 532; + +/// BT.601 luma weights: the TIFF default for YCbCrCoefficients. +const BT601: [f64; 3] = [0.299, 0.587, 0.114]; + +/// How one file's YCbCr codes map to RGB. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct YCbCr { + /// Luma weights of red, green and blue. + luma: [f64; 3], + /// Black and white codes of Y, Cb and Cr. + ref_bw: [f64; 6], +} + +impl YCbCr { + /// The conversion for directory `dir`, whose samples have `max` as their largest code + /// (255 or 65535). Missing or unusable tags fall back to the TIFF defaults. + pub(super) fn of(f: &File<'_>, dir: &Ifd, compression: u64, max: f64) -> Self { + let defaults = Self::defaults(max); + if compression == 7 { + return defaults; + } + let luma = f + .tag_f64s(dir, TAG_YCBCR_COEFFICIENTS, 3) + .and_then(|v| <[f64; 3]>::try_from(v).ok()) + .filter(|l| l.iter().all(|c| c.is_finite() && *c > 0.0 && *c < 1.0)) + .unwrap_or(defaults.luma); + let ref_bw = f + .tag_f64s(dir, TAG_REFERENCE_BLACK_WHITE, 6) + .and_then(|v| <[f64; 6]>::try_from(v).ok()) + .filter(|r| r.iter().all(|v| v.is_finite()) && r.as_chunks::<2>().0.iter().all(|[black, white]| (white - black).abs() > f64::EPSILON)) + .unwrap_or(defaults.ref_bw); + YCbCr { luma, ref_bw } + } + + /// TIFF's defaults: BT.601 weights, full-range luma, chroma centred on the middle code. + fn defaults(max: f64) -> Self { + let mid = (max + 1.0) / 2.0; + YCbCr { luma: BT601, ref_bw: [0.0, max, mid, max, mid, max] } + } + + /// Converts interleaved Y, Cb, Cr pixels of `channels` samples (the first three are the + /// colour) to R, G, B in place. Other sample types are left as they are. + pub(super) fn convert(self, data: &mut [u8], channels: usize, sample: SampleType) { + match sample { + SampleType::U8 => { + let t = Tables::new(&self, 255); + for_each_pixel(data, channels, |px| { + if let [y, cb, cr, ..] = px { + let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr)); + (*y, *cb, *cr) = (r as u8, g as u8, b as u8); + } + }); + } + SampleType::U16 => { + let t = Tables::new(&self, 65535); + for_each_pixel(data, channels * 2, |px| { + if let [y0, y1, b0, b1, r0, r1, ..] = px { + let get = |a: u8, b: u8| usize::from(u16::from_ne_bytes([a, b])); + let [r, g, b] = t.rgb(get(*y0, *y1), get(*b0, *b1), get(*r0, *r1)); + [*y0, *y1] = (r as u16).to_ne_bytes(); + [*b0, *b1] = (g as u16).to_ne_bytes(); + [*r0, *r1] = (b as u16).to_ne_bytes(); + } + }); + } + _ => {} + } + } +} + +/// Runs `f` on every `stride`-byte pixel, in parallel on native targets. +fn for_each_pixel(data: &mut [u8], stride: usize, f: impl Fn(&mut [u8]) + Sync + Send) { + if stride == 0 { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + // Bands of whole pixels, so each task has a useful amount of work. + let band = stride.saturating_mul(1 << 14); + data.par_chunks_mut(band).for_each(|b| b.chunks_exact_mut(stride).for_each(&f)); + } + #[cfg(target_arch = "wasm32")] + data.chunks_exact_mut(stride).for_each(f); +} + +/// Per-code contributions, so a pixel costs a few additions. +struct Tables { + max: f64, + /// Scaled luma of each Y code. + y: Vec, + /// Red and green contributions of each Cr code. + cr_r: Vec, + cr_g: Vec, + /// Green and blue contributions of each Cb code. + cb_g: Vec, + cb_b: Vec, +} + +impl Tables { + fn new(c: &YCbCr, max: u32) -> Self { + let m = f64::from(max); + let [lr, lg, lb] = c.luma; + let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = c.ref_bw; + // TIFF 6.0: chroma codes span ±(max + 1) / 2 - 1 around the black code; with the + // default ReferenceBlackWhite that is the code minus the middle code. + let half = (m + 1.0) / 2.0 - 1.0; + let codes = || (0..=max).map(f64::from); + let y = codes().map(|v| (v - y_black) * m / (y_white - y_black)).collect(); + let cb: Vec = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect(); + let cr: Vec = codes().map(|v| (v - cr_black) * half / (cr_white - cr_black)).collect(); + // R = Y + (2 - 2·Lr)·Cr and B = Y + (2 - 2·Lb)·Cb (TIFF 6.0 p. 91); green as libtiff: + // G = Y - (Lr·(2 - 2·Lr) / Lg)·Cr - (Lb·(2 - 2·Lb) / Lg)·Cb (see the module notes). + let (kr, kb) = (2.0 - 2.0 * lr, 2.0 - 2.0 * lb); + Tables { + max: m, + y, + cr_r: cr.iter().map(|v| kr * v).collect(), + cr_g: cr.iter().map(|v| -lr * kr * v / lg).collect(), + cb_g: cb.iter().map(|v| -lb * kb * v / lg).collect(), + cb_b: cb.iter().map(|v| kb * v).collect(), + } + } + + /// R, G and B of one pixel, rounded and clamped to the code range. + fn rgb(&self, y: usize, cb: usize, cr: usize) -> [u32; 3] { + let at = |t: &[f64], i: usize| t.get(i).copied().unwrap_or(0.0); + let yv = at(&self.y, y); + // Green adds Y unscaled, as libtiff does (see "Green: a compatibility choice" above). + let r = yv + at(&self.cr_r, cr); + let g = yv + at(&self.cr_g, cr) + at(&self.cb_g, cb); + let b = yv + at(&self.cb_b, cb); + [r, g, b].map(|v| v.round().clamp(0.0, self.max) as u32) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn convert(c: &YCbCr, px: [u8; 3]) -> [u8; 3] { + let mut d = px.to_vec(); + c.convert(&mut d, 3, SampleType::U8); + [d[0], d[1], d[2]] + } + + #[test] + fn jfif_conversion_matches_known_colours() { + let c = YCbCr::defaults(255.0); + assert_eq!(convert(&c, [0, 128, 128]), [0, 0, 0]); + assert_eq!(convert(&c, [255, 128, 128]), [255, 255, 255]); + assert_eq!(convert(&c, [128, 128, 128]), [128, 128, 128]); + // Pure red, green and blue as JFIF encodes them. + assert_eq!(convert(&c, [76, 85, 255]), [254, 0, 0]); + assert_eq!(convert(&c, [150, 44, 21]), [0, 255, 1]); + assert_eq!(convert(&c, [29, 255, 107]), [0, 0, 254]); + } + + #[test] + fn sixteen_bit_and_extra_samples() { + let c = YCbCr::defaults(65535.0); + let mut d: Vec = [65535u16, 32768, 32768, 1234].iter().flat_map(|v| v.to_ne_bytes()).collect(); + c.convert(&mut d, 4, SampleType::U16); + let v: Vec = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [65535, 65535, 65535, 1234]); + } + + #[test] + fn studio_range_reference_black_white() { + // Y 16–235, chroma 128 ± 112 (ITU-R BT.601 studio range). + let c = YCbCr { luma: BT601, ref_bw: [16.0, 235.0, 128.0, 240.0, 128.0, 240.0] }; + assert_eq!(convert(&c, [16, 128, 128]), [0, 0, 0]); + assert_eq!(convert(&c, [235, 128, 128]), [255, 255, 255]); + } + + #[test] + fn odd_buffers_do_not_panic() { + let c = YCbCr::defaults(255.0); + let mut d = vec![1u8, 2]; + c.convert(&mut d, 3, SampleType::U8); + c.convert(&mut d, 0, SampleType::U8); + c.convert(&mut d, 0, SampleType::U16); + assert_eq!(d, [1, 2]); + } +} diff --git a/crates/codecs/src/codecs/tiff_ifd.rs b/crates/codecs/src/codecs/tiff_ifd.rs index 63b39258cf..cdaa890ec4 100644 --- a/crates/codecs/src/codecs/tiff_ifd.rs +++ b/crates/codecs/src/codecs/tiff_ifd.rs @@ -236,18 +236,33 @@ impl<'a> File<'a> { /// The first value of a numeric tag as a float: RATIONAL, FLOAT, DOUBLE or an integer. pub fn tag_f64(&self, ifd: &Ifd, tag: u16) -> Option { + self.float(ifd.get(tag)?, 0) + } + + /// The values of a numeric tag as floats, when it holds exactly `n` (`None` for any other + /// count or an unreadable value). + pub fn tag_f64s(&self, ifd: &Ifd, tag: u16, n: u64) -> Option> { let e = ifd.get(tag)?; - if e.count == 0 { + if e.count != n { return None; } + (0..n).map(|i| self.float(e, i)).collect() + } + + /// Value `i` of a numeric entry as a float: RATIONAL, FLOAT, DOUBLE or an integer. + fn float(&self, e: &Entry, i: u64) -> Option { + if i >= e.count { + return None; + } + let at = e.value_at.checked_add(i.checked_mul(type_size(e.ty)?)?)?; match e.ty { 5 => { - let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?); + let (n, d) = (self.u32_at(at)?, self.u32_at(at.checked_add(4)?)?); (d != 0).then(|| f64::from(n) / f64::from(d)) } - 11 => Some(f64::from(f32::from_bits(self.u32_at(e.value_at)?))), - 12 => Some(f64::from_bits(self.u64_at(e.value_at)?)), - _ => self.uint(e, 0).map(|v| v as f64), + 11 => Some(f64::from(f32::from_bits(self.u32_at(at)?))), + 12 => Some(f64::from_bits(self.u64_at(at)?)), + _ => self.uint(e, i).map(|v| v as f64), } } diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 0cb0bd4202..3fbe9a222f 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -2,7 +2,7 @@ //! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by //! [`super::tga`] and only read here. -use std::io::Cursor; +use std::io::{Cursor, Read}; use image::{DynamicImage, ImageDecoder, ImageFormat}; @@ -91,6 +91,11 @@ fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result(f: Format, bytes: &'a [u8], limits: &Limits) -> Result u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(1 << 28)), - _ => declared, - }; - if !matches!(f, Format::Avif | Format::Hdr) && !(f == Format::Bmp && bmp_is_rle(bytes)) { - Limits::check_plausible(declared, bytes.len(), 1 << 14)?; + match f { + Format::Avif | Format::Hdr => {} + Format::Gif => gif_plausible(bytes, w, h, bpp)?, + // A BMP's RLE escapes (end of line, end of bitmap, delta) leave pixels unwritten, so no + // ratio bounds a valid file either. The decoder still allocates and clears the whole + // canvas, so like a GIF's screen only `UNBACKED_CANVAS` of it is free of data. + Format::Bmp if bmp_is_rle(bytes) => Limits::check_plausible(declared.saturating_sub(UNBACKED_CANVAS), bytes.len(), 1 << 14)?, + _ => Limits::check_plausible(declared, bytes.len(), 1 << 14)?, } Ok(dec) } +/// Canvas bytes a GIF's logical screen or a sparse RLE BMP may declare without data behind +/// them (256 MiB: an 8192x8192 RGBA screen). Past this, the file must back the rest. +const UNBACKED_CANVAS: u64 = 1 << 28; + +/// Whether a GIF whose `w`x`h` logical screen decodes to `bpp` bytes a pixel is plausible for +/// its file size. The first frame is what the file must hold: the logical screen around it needs +/// no data, so up to [`UNBACKED_CANVAS`] of screen is free and only the rest must be backed. +/// Shared by the first-frame decode and the animation decode ([`super::gif_frames`]), whose +/// backend also initializes the whole screen before reading the first frame. +pub(crate) fn gif_plausible(bytes: &[u8], w: u32, h: u32, bpp: u64) -> Result<(), CodecError> { + let declared = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp); + let declared = match gif_first_frame(bytes) { + Some((fw, fh)) => u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(UNBACKED_CANVAS)), + None => declared, + }; + Limits::check_plausible(declared, bytes.len(), 1 << 14) +} + +/// Most bytes one embedded iCCP profile of an icon may inflate to: 255 APP2 segments of a JPEG, +/// the largest profile any of our formats carries, and far past any real one. +const MAX_ICO_ICC_BYTES: u64 = 255 * 65_519; +/// Most bytes all of an icon's embedded iCCP profiles may inflate to together while checked. +/// Entries may point into overlapping chunk chains, so this bounds the check's own work. +const MAX_ICO_ICC_TOTAL: u64 = 64 << 20; + +/// Refuses an icon whose embedded PNG images carry an iCCP profile larger than the profile cap +/// (or `max_alloc`). The `image` crate's ICO decoder opens the PNG with no limits, and the `png` +/// crate inflates the profile while the decoder is built: a few kilobytes of zlib would ask for +/// gigabytes. Each profile is inflated here into a sink, so the check itself allocates nothing. +fn ico_png_profiles_fit(b: &[u8], limits: &Limits) -> Result<(), CodecError> { + const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let u16_at = |at: usize| b.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let u32_at = |at: usize| b.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let be32 = |at: usize| b.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + let cap = MAX_ICO_ICC_BYTES.min(limits.max_alloc); + let Some(count) = u16_at(4) else { return Ok(()) }; + let mut offsets: Vec = (0..usize::from(count)).filter_map(|i| u32_at(6 + 16 * i + 12)).filter_map(|o| usize::try_from(o).ok()).collect(); + offsets.sort_unstable(); + offsets.dedup(); + let mut total = 0u64; + // Chunk offsets already walked by any start: a walk from here repeats the earlier one, so stop. + let mut visited = std::collections::HashSet::new(); + for start in offsets { + if b.get(start..start.saturating_add(8)) != Some(&PNG_SIGNATURE[..]) { + continue; + } + // Chunks up to the image data: the png crate parses iCCP only before IDAT. + let mut at = start + 8; + while let (Some(len), Some(kind)) = (be32(at), b.get(at.saturating_add(4)..at.saturating_add(8))) { + if !visited.insert(at) { + break; + } + let data_at = at + 8; + let Some(data) = usize::try_from(len).ok().and_then(|n| b.get(data_at..data_at.checked_add(n)?)) else { break }; + match kind { + b"IDAT" | b"IEND" => break, + b"iCCP" => { + // Profile name, NUL, compression method, then the zlib stream. + let Some(z) = data.iter().position(|&c| c == 0).and_then(|n| data.get(n + 2..)) else { break }; + // Count what inflates before a corrupt stream fails too: the png crate + // allocates that much before it finds the error. + let mut inflated = flate2::read::ZlibDecoder::new(z).take(cap.saturating_add(1)); + let (mut n, mut buf) = (0u64, [0u8; 8192]); + while let Ok(got @ 1..) = inflated.read(&mut buf) { + n += got as u64; + } + total = total.saturating_add(n); + if n > cap || total > MAX_ICO_ICC_TOTAL { + return Err(CodecError::LimitExceeded(format!("an icon's embedded colour profile inflates past {cap} bytes"))); + } + // The png crate refuses a second iCCP chunk without inflating it. + break; + } + _ => {} + } + at = data_at + data.len() + 4; + } + } + Ok(()) +} + pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { let mut dec = open(f, bytes, limits)?; let icc = dec.icc_profile().ok().flatten(); @@ -310,6 +394,48 @@ mod indexed_tests { assert_eq!(bmp_palette_len(&bytes), None); } + /// A 64x48 icon holding one PNG with an iCCP chunk of `profile` bytes (zeros). + fn icon_with_profile(profile: usize) -> Vec { + use std::io::Write; + let img = Image::from_u8(64, 48, ChannelLayout::Rgba, vec![128; 64 * 48 * 4]).unwrap(); + let png = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(&vec![0u8; profile]).unwrap(); + let mut data = b"icc\0\0".to_vec(); + data.extend_from_slice(&z.finish().unwrap()); + let mut crc = flate2::Crc::new(); + crc.update(b"iCCP"); + crc.update(&data); + let mut chunk = (data.len() as u32).to_be_bytes().to_vec(); + chunk.extend_from_slice(b"iCCP"); + chunk.extend_from_slice(&data); + chunk.extend_from_slice(&crc.sum().to_be_bytes()); + // Signature (8) and IHDR (25), then the profile. + let mut png_icc = png[..33].to_vec(); + png_icc.extend_from_slice(&chunk); + png_icc.extend_from_slice(&png[33..]); + let mut ico = vec![0, 0, 1, 0, 1, 0, 64, 48, 0, 0, 1, 0, 32, 0]; + ico.extend_from_slice(&(png_icc.len() as u32).to_le_bytes()); + ico.extend_from_slice(&22u32.to_le_bytes()); + ico.extend_from_slice(&png_icc); + ico + } + + #[test] + fn an_icon_profile_inflating_past_the_cap_is_refused_before_the_png_opens() { + let small = icon_with_profile(4096); + let img = decode(Format::Ico, &small, &Limits::default()).unwrap(); + assert_eq!(img.dimensions(), (64, 48)); + let huge = icon_with_profile(MAX_ICO_ICC_BYTES as usize + 1); + assert!(huge.len() < 64 << 10, "{} bytes", huge.len()); + for r in [decode(Format::Ico, &huge, &Limits::default()).map(|_| ()), probe(Format::Ico, &huge, &Limits::default()).map(|_| ())] { + assert!(matches!(&r, Err(CodecError::LimitExceeded(m)) if m.contains("colour profile")), "{r:?}"); + } + // max_alloc lowers the cap. + let tight = Limits { max_alloc: 2048, ..Limits::default() }; + assert!(matches!(decode(Format::Ico, &small, &tight), Err(CodecError::LimitExceeded(_)))); + } + #[test] fn palette_bmp_rejects_bad_input() { let pal = [[0, 0, 0], [255, 255, 255]]; diff --git a/crates/codecs/src/exr_stream.rs b/crates/codecs/src/exr_stream.rs new file mode 100644 index 0000000000..1ed8ba1276 --- /dev/null +++ b/crates/codecs/src/exr_stream.rs @@ -0,0 +1,294 @@ +//! Bounded, seekable single-part scanline OpenEXR decoding. Total pixel count does not +//! size a decoded image buffer: only one compression block is returned at a time. +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use crate::{CodecError, Format}; +use exr::{ + block::{ + UncompressedBlock, + chunk::{Chunk, CompressedBlock, CompressedScanLineBlock}, + lines::LineIndex, + }, + meta::{BlockDescription, MetaData, attribute::SampleType}, +}; + +const META_BYTES: usize = 8 << 20; +const BLOCK_BYTES: usize = 64 << 20; +const MAX_CHUNKS: usize = 1 << 20; + +fn invalid(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::OpenExr, e) +} +fn limit(s: &str) -> CodecError { + CodecError::LimitExceeded(s.into()) +} +fn unsupported(s: &str) -> CodecError { + CodecError::unsupported(Format::OpenExr, s) +} + +fn read(r: &mut impl Read) -> Result<[u8; N], CodecError> { + let mut bytes = [0; N]; + r.read_exact(&mut bytes).map_err(invalid)?; + Ok(bytes) +} + +fn string(r: &mut impl Read, prefix: &mut Vec) -> Result { + for i in 0..256 { + let b = read::<1>(r)?; + prefix.extend(b); + if b == [0] { + return Ok(i != 0); + } + } + Err(limit("EXR attribute name exceeds 255 bytes")) +} + +fn metadata(r: &mut impl Read, cancel: &dyn Fn() -> bool) -> Result, CodecError> { + let start = read::<8>(r)?; + if start.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01]) { + return Err(invalid("EXR signature")); + } + let version = u32::from_le_bytes(start.get(4..).ok_or_else(|| invalid("version"))?.try_into().map_err(invalid)?); + if version & (0x200 | 0x800 | 0x1000) != 0 { + return Err(unsupported("streaming requires a single flat scanline part")); + } + let mut prefix = start.to_vec(); + let mut attributes = 0; + loop { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + if !string(r, &mut prefix)? { + break; + } + attributes += 1; + if attributes > 4096 { + return Err(limit("EXR metadata exceeds 4096 attributes")); + } + string(r, &mut prefix)?; + let length = read::<4>(r)?; + prefix.extend(length); + let n = usize::try_from(i32::from_le_bytes(length)).map_err(invalid)?; + if n > META_BYTES || prefix.len().checked_add(n).is_none_or(|v| v > META_BYTES) { + return Err(limit("EXR metadata exceeds 8 MiB")); + } + let begin = prefix.len(); + prefix.try_reserve_exact(n).map_err(|_| limit("EXR metadata allocation"))?; + prefix.resize(begin + n, 0); + for bytes in prefix.get_mut(begin..).ok_or_else(|| invalid("attribute size"))?.chunks_mut(64 << 10) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + r.read_exact(bytes).map_err(invalid)?; + } + } + Ok(prefix) +} + +/// Header facts used by the document owner to admit tile and working memory. +#[derive(Clone, Copy, Debug)] +pub struct Info { + pub width: u32, + pub height: u32, + pub gray: bool, + pub has_alpha: bool, + pub all_half: bool, + pub working_bytes: u64, + pub blocks: usize, +} + +/// Interleaved little-endian F32, GrayA or RGBA, at source resolution. +pub struct Band { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub data: Vec, +} + +pub struct Decoder { + source: R, + meta: MetaData, + info: Info, + offsets: Vec, + seen: Vec, + channels: Vec>, + next: usize, + file_len: u64, + chunks_start: u64, + raw_limit: usize, + rows: usize, +} + +impl Decoder { + /// Read only bounded metadata and offsets; allocate no full-image or channel buffer. + pub fn new(mut source: R, cancel: &dyn Fn() -> bool) -> Result { + let file_len = source.seek(SeekFrom::End(0)).map_err(invalid)?; + source.seek(SeekFrom::Start(0)).map_err(invalid)?; + let prefix = metadata(&mut source, cancel)?; + let meta = std::panic::catch_unwind(|| MetaData::read_from_buffered(Cursor::new(&prefix), false)) + .map_err(|_| invalid("invalid EXR metadata"))? + .map_err(invalid)?; + let header = meta.headers.first().ok_or_else(|| invalid("missing header"))?; + if meta.headers.len() != 1 || header.deep || !matches!(header.blocks, BlockDescription::ScanLines) { + return Err(unsupported("streaming requires a single flat scanline part")); + } + let (w, h) = (header.layer_size.0, header.layer_size.1); + if w == 0 || h == 0 || w > 1 << 18 || h > 1 << 18 { + return Err(limit("EXR dimensions exceed the streaming limit of 262144")); + } + let list = &header.channels.list; + if list.is_empty() || list.len() > 64 || list.iter().any(|c| c.sampling.0 != 1 || c.sampling.1 != 1) { + return Err(unsupported("EXR channel count or subsampling")); + } + // The same channels the whole-file decoder shows, so both routes open the same image. + let names: Vec = list.iter().map(|c| c.name.to_string()).collect(); + let (layout, picked) = crate::image::exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| unsupported("no R/G/B or Y channels"))?; + let gray = matches!(layout, crate::ChannelLayout::Gray | crate::ChannelLayout::GrayA); + let mut selected: Vec> = picked.into_iter().map(Some).collect(); + if !matches!(layout, crate::ChannelLayout::GrayA | crate::ChannelLayout::Rgba) { + selected.push(None); + } + if selected.len() != if gray { 2 } else { 4 } { + return Err(unsupported("EXR channel selection")); + } + // The document path currently tags Rec. 709 linear samples. Keep custom + // primaries on the existing decoder until their profiles can be mapped. + if !gray && header.shared_attributes.chromaticities.is_some() { + return Err(unsupported("streaming custom EXR chromaticities requires a color profile")); + } + let all_half = selected.iter().flatten().all(|i| list.get(*i).is_some_and(|c| c.sample_type == SampleType::F16)); + let has_alpha = selected.last().copied().flatten().is_some(); + let channels = (0..list.len()).map(|i| selected.iter().position(|s| *s == Some(i))).collect(); + let rows = header.max_block_pixel_size().1; + let expected = h.div_ceil(rows.max(1)); + if rows == 0 || header.chunk_count != expected || expected > MAX_CHUNKS { + return Err(limit("EXR block count exceeds the streaming budget")); + } + let raw_limit = w + .checked_mul(rows) + .and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)) + .filter(|n| *n <= BLOCK_BYTES) + .ok_or_else(|| limit("EXR compression block exceeds 64 MiB"))?; + let output = (w as u64).saturating_mul(rows as u64).saturating_mul(selected.len() as u64 * 4); + let info = Info { + width: w as u32, + height: h as u32, + gray, + has_alpha, + all_half, + blocks: expected, + working_bytes: (raw_limit as u64).saturating_mul(8).saturating_add(output.saturating_mul(2)).saturating_add(1 << 20), + }; + let chunks_start = source + .stream_position() + .map_err(invalid)? + .checked_add(expected as u64 * 8) + .filter(|v| *v <= file_len) + .ok_or_else(|| invalid("truncated offset table"))?; + let mut offsets = Vec::new(); + offsets.try_reserve_exact(expected).map_err(|_| limit("EXR offset allocation"))?; + for _ in 0..expected { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + let offset = u64::from_le_bytes(read(&mut source)?); + if offset < chunks_start || offset.checked_add(8).is_none_or(|v| v > file_len) { + return Err(invalid("EXR chunk offset")); + } + offsets.push(offset); + } + Ok(Self { source, meta, info, offsets, seen: vec![false; expected], channels, next: 0, file_len, chunks_start, raw_limit, rows }) + } + + pub fn info(&self) -> Info { + self.info + } + + /// Decode one block. Check cancellation while reading and between channel rows. + pub fn next_band(&mut self, cancel: &dyn Fn() -> bool) -> Result, CodecError> { + let Some(offset) = self.offsets.get(self.next).copied() else { + return Ok(None); + }; + if cancel() { + return Err(invalid("EXR import cancelled")); + } + self.source.seek(SeekFrom::Start(offset)).map_err(invalid)?; + let y = i32::from_le_bytes(read(&mut self.source)?); + let n = usize::try_from(i32::from_le_bytes(read(&mut self.source)?)).map_err(invalid)?; + if n > self.raw_limit.saturating_mul(2).saturating_add(64 << 10) + || offset < self.chunks_start + || offset.checked_add(8).and_then(|v| v.checked_add(n as u64)).is_none_or(|v| v > self.file_len) + { + return Err(invalid("EXR compressed block length")); + } + let header = self.meta.headers.first().ok_or_else(|| invalid("missing header"))?; + let row = i64::from(y) - i64::from(header.own_attributes.layer_position.1); + if row < 0 || row as u64 >= u64::from(self.info.height) || !(row as usize).is_multiple_of(self.rows) { + return Err(invalid("EXR block coordinate")); + } + let seen = self.seen.get_mut(row as usize / self.rows).ok_or_else(|| invalid("EXR block coordinate"))?; + if *seen { + return Err(invalid("duplicate EXR block")); + } + *seen = true; + let mut packed = Vec::new(); + packed.try_reserve_exact(n).map_err(|_| limit("EXR block allocation"))?; + packed.resize(n, 0); + for bytes in packed.chunks_mut(64 << 10) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + self.source.read_exact(bytes).map_err(invalid)?; + } + let chunk = + Chunk { layer_index: 0, compressed_block: CompressedBlock::ScanLine(CompressedScanLineBlock { y_coordinate: y, compressed_pixels_le: packed }) }; + let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| UncompressedBlock::decompress_chunk(chunk, &self.meta, false))) + .map_err(|_| invalid("invalid EXR compression block"))? + .map_err(invalid)?; + let (w, h) = (block.index.pixel_size.0, block.index.pixel_size.1); + if w != self.info.width as usize + || block.index.pixel_position.0 != 0 + || block.index.pixel_position.1 != row as usize + || h != self.rows.min(self.info.height as usize - row as usize) + { + return Err(invalid("EXR block shape")); + } + let expected = w.checked_mul(h).and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)).ok_or_else(|| invalid("EXR block size"))?; + if block.data.len() != expected { + return Err(invalid("EXR block sample count")); + } + let nc = if self.info.gray { 2 } else { 4 }; + let count = w.checked_mul(h).and_then(|n| n.checked_mul(nc * 4)).ok_or_else(|| limit("EXR output block size"))?; + let mut data = Vec::new(); + data.try_reserve_exact(count).map_err(|_| limit("EXR output allocation"))?; + data.resize(count, 0); + if !self.info.has_alpha { + for pixel in data.chunks_exact_mut(nc * 4) { + pixel.get_mut((nc - 1) * 4..).ok_or_else(|| invalid("alpha sample"))?.copy_from_slice(&1_f32.to_le_bytes()); + } + } + for (range, line) in LineIndex::lines_in_block(block.index, &header.channels) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + let Some(channel) = self.channels.get(line.channel).copied().flatten() else { + continue; + }; + let sample = header.channels.list.get(line.channel).ok_or_else(|| invalid("EXR channel"))?.sample_type; + let input = block.data.get(range).ok_or_else(|| invalid("EXR row size"))?; + let y = line.position.1.checked_sub(block.index.pixel_position.1).ok_or_else(|| invalid("EXR row coordinate"))?; + let output = data.get_mut(y * w * nc * 4..(y + 1) * w * nc * 4).ok_or_else(|| invalid("EXR row coordinate"))?; + for (bytes, pixel) in input.chunks_exact(sample.bytes_per_sample()).zip(output.chunks_exact_mut(nc * 4)) { + let value = match sample { + SampleType::F16 => half::f16::from_bits(u16::from_ne_bytes(bytes.try_into().map_err(invalid)?)).to_f32(), + SampleType::F32 => f32::from_ne_bytes(bytes.try_into().map_err(invalid)?), + SampleType::U32 => u32::from_ne_bytes(bytes.try_into().map_err(invalid)?) as f32, + }; + pixel.get_mut(channel * 4..channel * 4 + 4).ok_or_else(|| invalid("EXR channel"))?.copy_from_slice(&value.to_le_bytes()); + } + } + self.next += 1; + Ok(Some(Band { x: 0, y: row as u32, width: w as u32, height: h as u32, data })) + } +} diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 54f3ecd64e..55079b07ce 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -34,6 +34,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// JPEG XL: lossless export-only until the separate importer lands. + Jxl, /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from). Cineon, /// DICOM single-frame images (uncompressed): read-only. @@ -85,6 +87,7 @@ 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::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -104,9 +107,10 @@ 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; 22] = [ + pub const ALL: [Format; 23] = [ Format::Png, Format::Jpeg, + Format::Jxl, Format::Tiff, Format::WebP, Format::Gif, @@ -160,6 +164,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Jxl => "JPEG XL", Format::Cineon => "Cineon", Format::Dicom => "DICOM", Format::Iff => "Amiga IFF", @@ -189,6 +194,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Jxl => &["jxl"], Format::Cineon => &["cin"], Format::Dicom => &["dcm", "dicom", "dc3"], Format::Iff => &["iff", "lbm", "ilbm"], @@ -216,6 +222,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Jxl => "image/jxl", Format::Cineon => "image/x-cin", Format::Dicom => "application/dicom", Format::Iff => "image/x-iff", @@ -276,6 +283,8 @@ 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 }, + // GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected. + Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..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 { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base }, @@ -292,6 +301,10 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(&[0xFF, 0xD8, 0xFF]) { return Some(Format::Jpeg); } + // JPEG XL bare codestream and ISO/IEC 18181-2 container signature box. + if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") { + return Some(Format::Jxl); + } if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") { return Some(Format::Tiff); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 1ae285e7ce..ec9f0995de 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -7,8 +7,9 @@ //! metadata (EXIF, XMP, DPI, text) are preserved where the format allows. //! * [`caps`] declares per-format capabilities and [`fidelity_warnings`] //! tells the UI exactly what an export will lose. -//! * The API is I/O free (`&[u8]` in, `Vec` out) and builds for -//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating. +//! * APIs use bytes or caller-provided readers, with no ambient filesystem access, +//! and build for `wasm32-unknown-unknown`. Byte decoders enforce [`Limits`]; +//! [`exr_stream`] instead bounds metadata and one decoded compression block. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -17,6 +18,7 @@ pub mod cicp; mod codecs; mod error; pub mod exif_color; +pub mod exr_stream; mod fidelity; mod format; mod image; @@ -29,8 +31,10 @@ 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}; -pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; +pub use crate::codecs::png::{PngPalette, PngRows, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; +pub use crate::codecs::tiff::{ + PhotoshopTags as TiffPhotoshopTags, TiffBands, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian, +}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; @@ -41,14 +45,15 @@ pub use crate::image::{ MAX_EXTRA_SAMPLES, Metadata, SampleType, }; pub use crate::options::{ - DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression, + DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TIFF_PYRAMID_MIN, + 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, dicom, dpx, exr, fits, heif, iff, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -69,7 +74,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result { /// Decode as a specific format. pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result { // HEIF without the `heif` feature: its decoder stub says what is missing from this build. - if !caps(format).read && format != Format::Heif { + if !caps(format).read && !matches!(format, Format::Heif | Format::Jxl) { return Err(CodecError::unsupported(format, "decoding is not available for this format")); } let l = &opts.limits; @@ -89,6 +94,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Iff => iff::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), + Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), }?; // Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the @@ -270,6 +276,7 @@ pub fn decode_exr_channels(bytes: &[u8], part: usize, names: &[&str], opts: &Dec /// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a /// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part. pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result { + codecs::exr::check_previews(bytes)?; let mut cur = std::io::Cursor::new(bytes); let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?; codecs::deep_exr::decode_deep(&meta, bytes, limits) @@ -307,6 +314,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result iff::encode(image, plan), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), + Format::Jxl => jxl::encode(image, plan), 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 2279504413..9674b4edfb 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -32,6 +32,15 @@ impl Default for Limits { } impl Limits { + /// For a decoder that writes a band of rows at a time into the document's tiles instead of + /// one buffer (TIFF, #8169): the largest document size, 300,000 px a side, on 64-bit + /// targets, with `max_alloc` bounding each group of bands. 32-bit targets keep the + /// [`Default`] limits: wasm32's 4 GiB address space can't hold a larger document anyway. + pub fn banded() -> Self { + let l = Limits::default(); + if cfg!(target_pointer_width = "64") { Limits { max_width: 300_000, max_height: 300_000, max_pixels: 300_000 * 300_000, ..l } } else { l } + } + /// Effectively unlimited (still bounded by `usize` overflow checks). pub fn none() -> Self { Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: u64::MAX, max_alloc: u64::MAX } @@ -44,6 +53,20 @@ impl Limits { } pub(crate) fn check_bytes(&self, width: u32, height: u32, bytes_per_pixel: u64) -> Result<(), CodecError> { + self.check_size(width, height)?; + let bytes = (width as u64 * height as u64).saturating_mul(bytes_per_pixel); + if bytes > self.max_alloc { + return Err(CodecError::LimitExceeded(format!("{bytes} bytes exceed max_alloc {}", self.max_alloc))); + } + if usize::try_from(bytes).is_err() { + return Err(CodecError::LimitExceeded(format!("{bytes} bytes do not fit in memory"))); + } + Ok(()) + } + + /// The dimension and pixel-count checks of [`Limits::check`], for a decoder that never holds + /// the whole image in one buffer (`max_alloc` then bounds each piece it decodes). + pub(crate) fn check_size(&self, width: u32, height: u32) -> Result<(), CodecError> { if width == 0 || height == 0 { return Err(CodecError::InvalidImage(format!("zero-sized image {width}x{height}"))); } @@ -54,13 +77,6 @@ impl Limits { if pixels > self.max_pixels { return Err(CodecError::LimitExceeded(format!("{pixels} pixels exceed max {}", self.max_pixels))); } - let bytes = pixels.saturating_mul(bytes_per_pixel); - if bytes > self.max_alloc { - return Err(CodecError::LimitExceeded(format!("{bytes} bytes exceed max_alloc {}", self.max_alloc))); - } - if usize::try_from(bytes).is_err() { - return Err(CodecError::LimitExceeded(format!("{bytes} bytes do not fit in memory"))); - } Ok(()) } @@ -270,6 +286,10 @@ pub struct DdsOptions { pub mipmaps: bool, } +/// The longer side, in pixels, at which a TIFF image pyramid stops: the last level written is +/// the first one no longer than this ([`EncodeOptions::tiff_pyramid`]). +pub const TIFF_PYRAMID_MIN: u32 = 64; + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -294,6 +314,11 @@ pub struct EncodeOptions { /// Always write TIFF as BigTIFF (8-byte offsets). Without it a TIFF is written as BigTIFF /// only when it could pass the 4 GiB a classic TIFF can address. pub tiff_bigtiff: bool, + /// TIFF: after the full image, write Photoshop's "Save Image Pyramid" levels: copies at half, + /// a quarter, … of the size, each marked reduced-resolution (`NewSubfileType` 1), down to + /// [`crate::TIFF_PYRAMID_MIN`] px on the longer side. Readers that don't know pyramids open + /// the full image, which comes first. + pub tiff_pyramid: bool, pub exr_compression: ExrCompression, /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. pub dpx_bits: Option, @@ -318,6 +343,7 @@ impl Default for EncodeOptions { webp_quality: 80, tiff_compression: TiffCompression::None, tiff_bigtiff: false, + tiff_pyramid: false, exr_compression: ExrCompression::Zip16, dpx_bits: None, dds: DdsOptions::default(), diff --git a/crates/codecs/tests/it/exr_stream.rs b/crates/codecs/tests/it/exr_stream.rs new file mode 100644 index 0000000000..65be2387b9 --- /dev/null +++ b/crates/codecs/tests/it/exr_stream.rs @@ -0,0 +1,161 @@ +use photocraft_codecs::{exr_stream::Decoder, *}; +use std::io::Cursor; + +fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { + let n = 31 * 35 * layout.channels(); + let values: Vec = (0..n) + .map(|i| match i % 5 { + 0 => -0.25, + 1 => 3.5, + 2 => 0.0, + 3 => 0.0001, + _ => 0.5, + }) + .collect(); + let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); + encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() +} + +#[test] +fn scanline_blocks_match_whole_decoder_at_each_layout_depth_and_compression() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::F16, SampleType::F32] { + for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let bytes = fixture(layout, sample, compression); + let reference = decode(&bytes).unwrap(); + let target = if layout.is_gray() { ChannelLayout::GrayA } else { ChannelLayout::Rgba }; + let reference = reference.convert(target, SampleType::F32); + let mut stream = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); + assert_eq!(stream.info().all_half, sample == SampleType::F16); + let row = 31 * target.channels() * 4; + let mut actual = vec![0; reference.data().len()]; + let mut blocks = 0; + while let Some(band) = stream.next_band(&|| false).unwrap() { + actual[band.y as usize * row..(band.y + band.height) as usize * row].copy_from_slice(&band.data); + blocks += 1; + } + assert_eq!(blocks, stream.info().blocks); + assert_eq!(actual, reference.data(), "{layout:?}/{sample:?}/{compression:?}"); + } + } + } +} + +fn change_window(bytes: &mut [u8], width: i32, height: i32) { + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + bytes[at + 8..at + 12].copy_from_slice(&(width - 1).to_le_bytes()); + bytes[at + 12..at + 16].copy_from_slice(&(height - 1).to_le_bytes()); +} + +#[test] +fn enormous_header_is_not_a_whole_image_allocation() { + let mut bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + change_window(&mut bytes, 65536, 32768); + assert!(matches!(decode(&bytes), Err(CodecError::LimitExceeded(ref message)) if message.contains("2147483648 pixels"))); + // Streaming gets as far as the now-truncated offset table, without a pixel allocation. + assert!(matches!(Decoder::new(Cursor::new(bytes), &|| false), Err(CodecError::Malformed { .. }))); +} + +#[test] +fn cancellation_truncation_duplicate_offsets_and_forged_lengths_fail() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + assert!(Decoder::new(Cursor::new(&bytes), &|| true).is_err()); + let mut decoder = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); + assert!(decoder.next_band(&|| true).is_err()); + for end in [0, 8, bytes.len() / 2] { + assert!(Decoder::new(Cursor::new(&bytes[..end]), &|| false).and_then(|mut r| r.next_band(&|| false)).is_err()); + } + // Locate the offset table by finding the validated header end. + let mut input = Cursor::new(&bytes); + let _ = exr::meta::MetaData::read_from_buffered(&mut input, false).unwrap(); + let at = input.position() as usize; + let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize; + let mut corrupt = bytes.clone(); + corrupt[offset + 4..offset + 8].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(Decoder::new(Cursor::new(corrupt), &|| false).unwrap().next_band(&|| false).is_err()); + let mut duplicate = bytes.clone(); + duplicate[at + 8..at + 16].copy_from_slice(&bytes[at..at + 8]); + let mut decoder = Decoder::new(Cursor::new(duplicate), &|| false).unwrap(); + assert!(decoder.next_band(&|| false).unwrap().is_some()); + assert!(decoder.next_band(&|| false).is_err()); +} + +#[test] +fn hostile_metadata_is_bounded_and_mutated_inputs_never_panic() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + let mut large = bytes.clone(); + let needle = b"channels\0chlist\0"; + let length = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + large[length..length + 4].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(matches!(Decoder::new(Cursor::new(large), &|| false), Err(CodecError::LimitExceeded(_)))); + let mut many = bytes[..8].to_vec(); + for _ in 0..4097 { + many.extend_from_slice(b"z\0string\0\0\0\0\0"); + } + many.push(0); + assert!(matches!(Decoder::new(Cursor::new(many), &|| false), Err(CodecError::LimitExceeded(_)))); + // Small deterministic mutations cover headers, offsets and compressed payloads. + for i in 0..256 { + let mut corrupt = bytes.clone(); + let at = i * (bytes.len() - 1) / 255; + corrupt[at] ^= 0xff; + assert!( + std::panic::catch_unwind(|| { + if let Ok(mut decoder) = Decoder::new(Cursor::new(corrupt), &|| false) { + while let Ok(Some(_)) = decoder.next_band(&|| false) {} + } + }) + .is_ok(), + "mutation at byte {at}" + ); + } +} + +#[test] +fn mixed_samples_and_offset_data_window_keep_coordinates_and_values() { + use exr::prelude::{AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, WritableImage}; + let channels = AnyChannels::sort( + vec![ + AnyChannel::new("R", FlatSamples::F16(vec![f16::from_f32(-0.25); 31 * 35])), + AnyChannel::new("G", FlatSamples::F32(vec![3.5; 31 * 35])), + AnyChannel::new("B", FlatSamples::U32(vec![42; 31 * 35])), + ] + .into_iter() + .collect(), + ); + let attributes = LayerAttributes { layer_position: exr::math::Vec2(-15, 40), ..Default::default() }; + let layer = + Layer::new((31, 35), attributes, Encoding { compression: Compression::ZIP16, blocks: Blocks::ScanLines, line_order: LineOrder::Decreasing }, channels); + let mut bytes = Cursor::new(Vec::new()); + exr::image::Image::from_layer(layer).write().to_buffered(&mut bytes).unwrap(); + let mut decoder = Decoder::new(bytes, &|| false).unwrap(); + assert!(!decoder.info().all_half); + let mut rows = 0; + while let Some(band) = decoder.next_band(&|| false).unwrap() { + assert_eq!(band.y, rows); + rows += band.height; + for p in band.data.as_chunks::<16>().0 { + let v: Vec = p.as_chunks::<4>().0.iter().map(|s| f32::from_le_bytes(*s)).collect(); + assert_eq!(v, [-0.25, 3.5, 42.0, 1.0]); + } + } + assert_eq!(rows, 35); +} + +#[test] +fn unsupported_variants_stay_on_the_guarded_legacy_path() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + for flag in [0x200_u32, 0x800, 0x1000] { + let mut changed = bytes.clone(); + changed[4..8].copy_from_slice(&(2 | flag).to_le_bytes()); + assert!(matches!(Decoder::new(Cursor::new(changed), &|| false), Err(CodecError::Unsupported { .. }))); + } + let mut custom = bytes[..8].to_vec(); + custom.extend_from_slice(b"chromaticities\0chromaticities\0\x20\0\0\0"); + for v in [0.64_f32, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329] { + custom.extend_from_slice(&v.to_le_bytes()); + } + custom.extend_from_slice(&bytes[8..]); + assert!(matches!(Decoder::new(Cursor::new(custom), &|| false), Err(CodecError::Unsupported { .. }))); +} diff --git a/crates/codecs/tests/it/limits.rs b/crates/codecs/tests/it/limits.rs index 536e303039..b6cfea5e69 100644 --- a/crates/codecs/tests/it/limits.rs +++ b/crates/codecs/tests/it/limits.rs @@ -564,6 +564,36 @@ fn an_rle_bmp_ending_early_still_decodes() { assert_eq!((img.width(), img.height()), (4000, 4000)); } +#[test] +fn an_rle_bmp_canvas_past_the_unbacked_allowance_needs_data() { + // 30000x30000 RLE8 with only the end-of-bitmap escape: 2.7 GB of RGB from about 1 KB. + let data = 14 + 40 + 1024; + let mut b = b"BM".to_vec(); + for v in [data + 2, 0, data, 40, 30000, 30000] { + b.extend_from_slice(&(v as u32).to_le_bytes()); + } + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&8u16.to_le_bytes()); + for v in [1, 2, 0, 0, 0, 0] { + b.extend_from_slice(&(v as u32).to_le_bytes()); + } + b.resize(data, 0); + b.extend_from_slice(&[0, 1]); + refused_as_implausible(Format::Bmp, &b); +} + +#[test] +fn animated_gif_frames_need_a_plausible_file_size_too() { + let frames = |b: &[u8]| decode_gif_frames(b, &DecodeOptions::default()); + assert_eq!(frames(&tiny_gif((4000, 4000), (1, 1))).unwrap().len(), 1); + for gif in [tiny_gif((30000, 30000), (1, 1)), tiny_gif((100, 100), (40000, 40000))] { + match frames(&gif) { + Err(CodecError::LimitExceeded(m)) => assert!(m.contains("cannot hold"), "{m}"), + other => panic!("expected an implausible-size refusal, got {:?}", other.map(|f| f.len())), + } + } +} + #[test] fn pcx_header_declaring_gigabytes_needs_a_plausible_file_size() { // 24-bit (three 8-bit planes) 65535 x 16384, with one row of data. diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index 3e77f071f0..f465a0cbbd 100644 --- a/crates/codecs/tests/it/main.rs +++ b/crates/codecs/tests/it/main.rs @@ -10,6 +10,7 @@ mod detect_caps; mod dpx; mod exr_cryptomatte; mod exr_multipart; +mod exr_stream; mod fidelity; mod gif_frames; mod heif; @@ -24,12 +25,15 @@ mod resolution; mod roundtrip; mod source_settings; mod tga; +mod tiff_bands; mod tiff_bench; mod tiff_exif; mod tiff_lab; mod tiff_pages; mod tiff_photoshop; +mod tiff_pyramid; mod tiff_regressions; +mod tiff_ycbcr; mod webp_frames; mod webp_lossy; mod xpm; diff --git a/crates/codecs/tests/it/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs index acbd16c5db..5a0175bbc2 100644 --- a/crates/codecs/tests/it/roundtrip.rs +++ b/crates/codecs/tests/it/roundtrip.rs @@ -213,6 +213,23 @@ fn tiff_all_compressions_lossless() { } } +#[test] +fn compressed_tiff_does_not_keep_the_uncompressed_reservation() { + // The encoder reserves the uncompressed size up front for an uncompressed file (#6607). A + // smooth image compresses to a fraction of it, and no returned file holds much spare room. + let (w, h) = (512u32, 512u32); + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x / 2) as u8, (y / 2) as u8, 128])).collect(); + let img = Image::from_u8(w, h, ChannelLayout::Rgb, px).unwrap(); + for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate] { + let v = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: comp, ..Default::default() }).unwrap(); + if !matches!(comp, TiffCompression::None) { + assert!(v.len() < img.data().len() / 2, "{comp:?}: the image should compress well ({} bytes)", v.len()); + } + assert!(v.capacity() - v.len() <= v.len() / 8, "{comp:?}: {} bytes kept for a {} byte file", v.capacity(), v.len()); + assert_eq!(decode(&v).unwrap().data(), img.data(), "{comp:?}"); + } +} + #[test] fn png_all_compressions_lossless() { let img = test_image(ChannelLayout::Rgba, SampleType::U16); diff --git a/crates/codecs/tests/it/tiff_bands.rs b/crates/codecs/tests/it/tiff_bands.rs new file mode 100644 index 0000000000..668c98dcc3 --- /dev/null +++ b/crates/codecs/tests/it/tiff_bands.rs @@ -0,0 +1,367 @@ +//! `TiffBands` (#8169): decoding a TIFF a group of bands at a time gives exactly the image the +//! whole-image decoder does, for every layout the decoder reads, every orientation, and groups of +//! any size; and it opens images whose single buffer the whole-image decoder refuses. + +use crate::common::tiffgen::*; +use photocraft_codecs::*; + +/// A deterministic byte pattern. +fn noise(n: usize, seed: u32) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 24) as u8 + }) + .collect() +} + +/// The image `TiffBands` decodes, assembled from its groups. +fn banded(bytes: &[u8], page: Option, limits: &Limits, upright: bool) -> Result { + let mut t = TiffBands::open(bytes, page, limits)?; + if upright { + t = t.upright(); + } + let (w, h) = t.dimensions(); + let mut out = Image::new(w, h, t.layout(), t.sample_type())?; + let bpp = t.layout().channels() * t.sample_type().bytes(); + let mut groups = 0; + t.decode(|x, y, g: &Image| -> Result<(), CodecError> { + groups += 1; + assert_eq!((g.layout(), g.sample_type()), (out.layout(), out.sample_type())); + let (gw, gh) = g.dimensions(); + assert!(x + gw <= w && y + gh <= h, "group {gw}x{gh} at {x},{y} outside {w}x{h}"); + let row = gw as usize * bpp; + for r in 0..gh as usize { + let dst = ((y as usize + r) * w as usize + x as usize) * bpp; + out.data_mut()[dst..dst + row].copy_from_slice(&g.data()[r * row..(r + 1) * row]); + } + Ok(()) + })?; + assert!(groups >= 1); + out.icc = t.icc.clone(); + out.meta = t.meta.clone(); + out.warnings = t.warnings.clone(); + Ok(out) +} + +/// The whole-image decode of the same page. +fn whole(bytes: &[u8], page: Option, upright: bool) -> Image { + let opts = DecodeOptions { keep_orientation: !upright, ..Default::default() }; + match page { + Some(p) => decode_tiff_page(bytes, p, &opts).unwrap(), + None => decode_with(bytes, &opts).unwrap(), + } +} + +/// Limits whose `max_alloc` makes each group `bands` bands of `band_bytes`. +fn grouped(band_bytes: usize, bands: usize) -> Limits { + Limits { max_alloc: (band_bytes * bands) as u64, ..Limits::banded() } +} + +fn assert_same(bytes: &[u8], band_bytes: usize, what: &str) { + for upright in [false, true] { + let want = whole(bytes, None, upright); + for limits in [Limits::banded(), grouped(band_bytes, 1), grouped(band_bytes, 2), grouped(band_bytes, 3)] { + let got = banded(bytes, None, &limits, upright).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(got.dimensions(), want.dimensions(), "{what}"); + assert_eq!((got.layout(), got.sample_type()), (want.layout(), want.sample_type()), "{what}"); + assert!(got.data() == want.data(), "{what}: pixels differ (max_alloc {})", limits.max_alloc); + assert_eq!(got.icc, want.icc, "{what}"); + assert_eq!(got.meta, want.meta, "{what}"); + assert_eq!(got.warnings, want.warnings, "{what}"); + } + } +} + +/// `n / 1` as a little-endian RATIONAL value. +fn rational(n: u64) -> u64 { + n | 1 << 32 +} + +const W: u32 = 37; +const H: u32 = 29; + +#[test] +fn every_layout_matches_the_whole_image_decoder() { + let n = (W * H) as usize; + let rows = 4u32; + let row = |spp: usize, bps: usize| W as usize * spp * bps; + let cases: Vec<(&str, Dir, usize)> = vec![ + ("rgb8 strips", strips(W, H, 8, 3, 2, rows, &noise(n * 3, 1)), row(3, 1) * rows as usize), + ("gray8 WhiteIsZero", strips(W, H, 8, 1, 0, rows, &noise(n, 2)), row(1, 1) * rows as usize), + ("gray16 big-endian", strips(W, H, 16, 1, 1, rows, &noise(n * 2, 3)), row(1, 2) * rows as usize), + ("cmyk8", strips(W, H, 8, 4, 5, rows, &noise(n * 4, 4)), row(4, 1) * rows as usize), + ("cielab8", strips(W, H, 8, 3, 8, rows, &noise(n * 3, 5)), row(3, 1) * rows as usize), + ("rgba16 associated alpha", strips(W, H, 16, 4, 2, rows, &noise(n * 8, 6)).tag(338, SHORT, &[1]), row(4, 2) * rows as usize), + ( + "gray f32", + strips(W, H, 32, 1, 1, rows, &f32s(&noise(n, 7).iter().map(|&b| f32::from(b) / 255.0).collect::>(), true)).tag(339, SHORT, &[3]), + row(1, 4) * rows as usize, + ), + ( + "palette 4-bit", + strips(W, H, 4, 1, 3, rows, &noise((W as usize).div_ceil(2) * H as usize, 8)).tag(320, SHORT, &(0..48u64).map(|i| i * 1365).collect::>()), + row(3, 1) * rows as usize, + ), + ("gray 1-bit", strips(W, H, 1, 1, 1, rows, &noise((W as usize).div_ceil(8) * H as usize, 9)), row(1, 1) * rows as usize), + ]; + for (what, dir, band) in cases { + // Both byte orders: the generator writes multi-byte samples as given, so the + // big-endian files hold different values; each is compared against its own decode. + for little in [true, false] { + assert_same(&build(little, false, std::slice::from_ref(&dir), &[0]).bytes, band, what); + } + } +} + +#[test] +fn tiles_and_planar_data_match() { + let (w, h) = (W as usize, H as usize); + let px = noise(w * h * 3 * 2, 11); + let tiled = |planar: bool| { + let mut d = Dir { entries: base(W, H, 16, 3, 2), chunks: tile_split(&px, w, h, 3, 2, 16, 16, planar), tiled: true }.tag(322, SHORT, &[16]).tag( + 323, + SHORT, + &[16], + ); + if planar { + d = d.tag(284, SHORT, &[2]); + } + d + }; + let band = w * 3 * 2 * 16; + assert_same(&build(true, false, &[tiled(false)], &[0]).bytes, band, "chunky tiles"); + assert_same(&build(true, true, &[tiled(true)], &[0]).bytes, band, "planar tiles, BigTIFF"); + let planar = Dir { entries: base(W, H, 16, 3, 2), chunks: planar_strips(&px, w, h, 3, 2, 5), tiled: false }.tag(278, LONG, &[5]).tag(284, SHORT, &[2]); + assert_same(&build(true, false, &[planar], &[0]).bytes, w * 3 * 2 * 5, "planar strips"); +} + +#[test] +fn every_orientation_matches() { + let n = (W * H) as usize; + for o in 1..=8u64 { + let dir = strips(W, H, 8, 3, 2, 3, &noise(n * 3, 20 + o as u32)) + .tag(274, SHORT, &[o]) + .tag(282, RATIONAL, &[rational(300)]) + .tag(283, RATIONAL, &[rational(150)]) + .tag(296, SHORT, &[2]); + let bytes = build(true, false, &[dir], &[0]).bytes; + assert_same(&bytes, W as usize * 3 * 3, &format!("orientation {o}")); + let t = TiffBands::open(&bytes, None, &Limits::banded()).unwrap(); + assert_eq!(t.orientation(), o as u16); + let up = t.upright(); + let swapped = o >= 5; + assert_eq!(up.dimensions(), if swapped { (H, W) } else { (W, H) }, "orientation {o}"); + assert_eq!(up.meta.dpi, Some(if swapped { (150.0, 300.0) } else { (300.0, 150.0) }), "orientation {o}"); + } +} + +#[test] +fn cut_off_data_matches_and_warns() { + let n = (W * H) as usize; + let built = build(true, false, &[strips(W, H, 8, 3, 2, 4, &noise(n * 3, 30))], &[0]); + // The last four of eight strips point past the end of the file: their data is missing. + let mut built = built; + let past = built.bytes.len() as u64 + 4096; + for i in 4..8 { + patch_value(&mut built, true, false, 0, 273, i, past); + } + assert_same(&built.bytes, W as usize * 3 * 4, "cut off"); + let img = banded(&built.bytes, None, &Limits::banded(), true).unwrap(); + assert!(img.warnings.contains(&DecodeWarning::Truncated { format: Format::Tiff })); +} + +#[test] +fn a_strip_cut_off_partway_decodes_as_zeros() { + // An uncompressed strip read straight into its output rows, whose data starts in the file + // and ends past it: the bytes read before the end must not survive (#8536 review). + let n = (W * H) as usize; + let mut built = build(true, false, &[strips(W, H, 8, 3, 2, 4, &noise(n * 3, 31))], &[0]); + let strip = W as usize * 3 * 4; + let near_end = built.bytes.len() as u64 - 100; + patch_value(&mut built, true, false, 0, 273, 6, near_end); + let img = banded(&built.bytes, None, &Limits::banded(), false).unwrap(); + assert!(img.warnings.contains(&DecodeWarning::Truncated { format: Format::Tiff })); + assert!(img.data()[6 * strip..7 * strip].iter().all(|&v| v == 0), "partial strip bytes kept"); + assert!(img.data()[..6 * strip] == whole(&built.bytes, None, false).data()[..6 * strip]); +} + +#[test] +fn other_pages_and_the_more_pages_warning_match() { + let n = (W * H) as usize; + let a = strips(W, H, 8, 1, 1, 5, &noise(n, 40)); + let b = strips(W, H, 8, 3, 2, 5, &noise(n * 3, 41)); + let bytes = build(true, false, &[a, b], &[0, 1]).bytes; + for page in [None, Some(0), Some(1)] { + let want = whole(&bytes, page, false); + let got = banded(&bytes, page, &Limits::banded(), false).unwrap(); + assert!(got.data() == want.data(), "page {page:?}"); + assert_eq!(got.warnings, want.warnings, "page {page:?}"); + } + assert!(matches!(TiffBands::open(&bytes, Some(9), &Limits::banded()), Err(CodecError::InvalidImage(_)))); +} + +#[test] +fn a_cut_off_page_of_several_warns_in_the_whole_image_order() { + // Truncated before MorePages, as the whole-image decode reports them (#8536 review). + let n = (W * H) as usize; + let a = strips(W, H, 8, 3, 2, 4, &noise(n * 3, 42)); + let b = strips(W, H, 8, 1, 1, 5, &noise(n, 43)); + let mut built = build(true, false, &[a, b], &[0, 1]); + let past = built.bytes.len() as u64 + 4096; + for i in 4..8 { + patch_value(&mut built, true, false, 0, 273, i, past); + } + let want = whole(&built.bytes, None, false); + assert!(matches!(want.warnings[..], [DecodeWarning::Truncated { .. }, DecodeWarning::MorePages { .. }]), "{:?}", want.warnings); + let got = banded(&built.bytes, None, &Limits::banded(), false).unwrap(); + assert_eq!(got.warnings, want.warnings); +} + +#[test] +fn group_bytes_is_the_largest_group_handed_over() { + let n = (W * H) as usize; + // Eight strips of four rows, two to a group; one strip holding the whole image. + let bytes = build(true, false, &[strips(W, H, 8, 3, 2, 4, &noise(n * 3, 44))], &[0]).bytes; + let band = W as usize * 3 * 4; + let mut t = TiffBands::open(&bytes, None, &grouped(band, 2)).unwrap(); + assert_eq!(t.group_bytes().unwrap(), 2 * band); + let mut largest = 0; + t.decode(|_, _, g: &Image| -> Result<(), CodecError> { + largest = largest.max(g.data().len()); + Ok(()) + }) + .unwrap(); + assert_eq!(largest, 2 * band); + let single = build(true, false, &[strips(W, H, 8, 3, 2, H, &noise(n * 3, 45))], &[0]).bytes; + let t = TiffBands::open(&single, None, &Limits::banded()).unwrap(); + assert_eq!(t.group_bytes().unwrap(), n * 3, "a single strip is one group: the whole image"); +} + +#[test] +#[cfg(target_pointer_width = "64")] +fn opens_an_image_over_the_whole_image_budget() { + // The whole-image decoder needs the image in one buffer; the banded one only a band (on + // 64-bit targets: 32-bit ones still check the whole image). + let n = (W * H) as usize; + let bytes = build(true, false, &[strips(W, H, 8, 3, 2, 2, &noise(n * 3, 50))], &[0]).bytes; + let band = W as u64 * 3 * 2; + let tight = Limits { max_alloc: band * 3, ..Limits::banded() }; + let refused = decode_with(&bytes, &DecodeOptions { limits: tight, keep_orientation: false }); + assert!(matches!(refused, Err(CodecError::LimitExceeded(_))), "{refused:?}"); + let got = banded(&bytes, None, &tight, true).unwrap(); + assert!(got.data() == whole(&bytes, None, true).data()); +} + +#[test] +fn size_limits_still_apply() { + let n = (W * H) as usize; + let bytes = build(true, false, &[strips(W, H, 8, 3, 2, 4, &noise(n * 3, 60))], &[0]).bytes; + let few_pixels = Limits { max_pixels: u64::from(W * H) - 1, ..Limits::banded() }; + assert!(matches!(TiffBands::open(&bytes, None, &few_pixels), Err(CodecError::LimitExceeded(_)))); + let narrow = Limits { max_width: W - 1, ..Limits::banded() }; + assert!(matches!(TiffBands::open(&bytes, None, &narrow), Err(CodecError::LimitExceeded(_)))); + // One strip is more than `max_alloc`: refused before decoding, with the image size. + let tiny = Limits { max_alloc: 10, ..Limits::banded() }; + match TiffBands::open(&bytes, None, &tiny) { + Err(CodecError::LimitExceeded(m)) => assert!(m.contains(&format!("{W}x{H}")), "{m}"), + other => panic!("{:?}", other.map(|t| t.dimensions())), + } +} + +#[test] +fn banded_limits_allow_the_largest_document_size() { + let l = Limits::banded(); + if cfg!(target_pointer_width = "64") { + assert_eq!((l.max_width, l.max_height), (300_000, 300_000)); + assert!(l.max_pixels >= 300_000 * 300_000); + // The banner from #8456 / #8169: 136 ft × 26 ft at 72 ppi. + assert!(117_504u64 * 22_464 > Limits::default().max_pixels); + assert!(117_504u64 * 22_464 <= l.max_pixels); + } else { + assert_eq!(l, Limits::default()); + } + assert_eq!(l.max_alloc, Limits::default().max_alloc); +} + +#[test] +fn a_hostile_header_is_refused_before_allocating() { + // 300000² declared in a file of a few kilobytes: one 270 GB strip, which neither the + // decompression-bomb guard nor the per-group budget lets through. + let mut built = build(true, true, &[strips(1000, 4, 8, 3, 2, 4, &noise(12_000, 70))], &[0]); + for tag in [256, 257, 278] { + patch_value(&mut built, true, true, 0, tag, 0, 300_000); + } + assert!(matches!(TiffBands::open(&built.bytes, None, &Limits::banded()), Err(CodecError::Malformed { .. } | CodecError::LimitExceeded(_)))); + // Past the 300000 px a side documents allow. + let mut wide = build(true, true, &[strips(1000, 4, 8, 3, 2, 4, &noise(12_000, 71))], &[0]); + patch_value(&mut wide, true, true, 0, 256, 0, 300_001); + assert!(matches!(TiffBands::open(&wide.bytes, None, &Limits::banded()), Err(CodecError::LimitExceeded(_)))); +} + +#[test] +fn padding_does_not_let_missing_chunks_past_the_bomb_guard() { + // 20000² RGB declared as Deflate (1.2 GB of samples), every strip past the end of the file, + // and 400 KB of padding: the file is large enough for that size at Deflate's ratio, but its + // strips hold nothing. Refused before anything is allocated, by both decoders (#8536 review). + let (w, h) = (20_000, 20_000); + let mut dir = Dir { entries: base(w, h, 8, 3, 2), chunks: vec![vec![0]; 20], tiled: false }.tag(278, LONG, &[1000]); + dir.entries.retain(|e| e.0 != 259); + let dir = dir.tag(259, SHORT, &[8]).bytes(65_000, UNDEFINED, &vec![0; 400_000]); + let mut built = build(true, false, &[dir], &[0]); + let past = built.bytes.len() as u64 + 4096; + for i in 0..20 { + patch_value(&mut built, true, false, 0, 273, i, past); + } + match TiffBands::open(&built.bytes, None, &Limits::banded()) { + Err(CodecError::Malformed { message, .. }) => assert!(message.contains("hold 0 bytes"), "{message}"), + other => panic!("{:?}", other.map(|t| t.dimensions())), + } + assert!(matches!(decode(&built.bytes), Err(CodecError::Malformed { .. }))); +} + +/// An all-white CCITT Group 4 strip: one V0 code (a 1 bit) per row, then the EOFB. +fn blank_g4(h: u32) -> Vec { + let mut bits = vec![true; h as usize]; + for _ in 0..2 { + bits.extend([false; 11]); + bits.push(true); + } + bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() +} + +#[test] +fn a_blank_fax_page_wider_than_4096_px_passes_the_bomb_guard() { + // A blank 600 dpi page (A4 landscape, A3) codes each row in one bit, past 4096:1: it must + // open, with or without the metadata and other pages that used to raise the bound. + for (w, h) in [(7016u32, 4961u32), (7016, 9921)] { + let mut d = Dir { entries: base(w, h, 1, 1, 0), chunks: vec![blank_g4(h)], tiled: false }.tag(278, LONG, &[u64::from(h)]); + d.entries.retain(|e| e.0 != 259); + let d = d.tag(259, SHORT, &[4]); + let pages: Vec = (0..3).map(|_| d.clone()).collect(); + for (what, bytes) in + [("one page", build(true, false, std::slice::from_ref(&d), &[0]).bytes), ("three pages", build(true, false, &pages, &[0, 1, 2]).bytes)] + { + let img = decode(&bytes).unwrap_or_else(|e| panic!("{w}x{h} {what}: {e}")); + assert_eq!(img.dimensions(), (w, h), "{what}"); + assert!(img.data().iter().all(|&v| v == 255), "{w}x{h} {what}: not white"); + } + } + let mut d = Dir { entries: base(7016, 4961, 1, 1, 0), chunks: vec![blank_g4(4961)], tiled: false }.tag(278, LONG, &[4961]); + d.entries.retain(|e| e.0 != 259); + let bytes = build(true, false, &[d.tag(259, SHORT, &[4])], &[0]).bytes; + assert!(banded(&bytes, None, &Limits::banded(), true).unwrap().data() == decode(&bytes).unwrap().data()); + // Still a bound: 100 bytes of Group 4 can't hold that page (4096:1 for Group 3, which is not decoded). + for compression in [4, 3] { + let mut d = Dir { entries: base(7016, 4961, 1, 1, 0), chunks: vec![vec![0x80; 100]], tiled: false }.tag(278, LONG, &[4961]); + d.entries.retain(|e| e.0 != 259); + let bytes = build(true, false, &[d.tag(259, SHORT, &[compression])], &[0]).bytes; + match decode(&bytes) { + Err(CodecError::Malformed { message, .. }) => assert!(message.contains("hold 100 bytes"), "{message}"), + other => panic!("compression {compression}: {:?}", other.map(|i| i.dimensions())), + } + } +} diff --git a/crates/codecs/tests/it/tiff_bench.rs b/crates/codecs/tests/it/tiff_bench.rs index e17f6466d4..3ab5c6dd5b 100644 --- a/crates/codecs/tests/it/tiff_bench.rs +++ b/crates/codecs/tests/it/tiff_bench.rs @@ -105,3 +105,20 @@ fn decode_30mp() { let d = strips(W, H, 8, 3, 2, 64, &rgb); time("RGB 8 BigTIFF strips, none", &build(le, true, &[d], &[0]).bytes); } + +/// Save Image Pyramid's cost on top of a plain encode (#8827): RGB/8 and RGBA/16. +#[test] +#[ignore = "benchmark: run in release with --ignored --nocapture"] +fn encode_with_pyramid() { + for (layout, sample, spp) in [(ChannelLayout::Rgb, SampleType::U8, 3), (ChannelLayout::Rgba, SampleType::U16, 4)] { + let raw = pixels(spp); + let data = if sample == SampleType::U16 { raw.iter().flat_map(|&v| (u16::from(v) * 257).to_ne_bytes()).collect() } else { raw }; + let img = Image::from_raw(W, H, layout, sample, data).unwrap(); + for pyramid in [false, true] { + let opts = EncodeOptions { tiff_pyramid: pyramid, ..Default::default() }; + let t = Instant::now(); + let bytes = encode(&img, Format::Tiff, &opts).unwrap(); + println!("{layout:?} {sample:?} pyramid {pyramid}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1000.0, bytes.len() >> 20); + } + } +} diff --git a/crates/codecs/tests/it/tiff_pyramid.rs b/crates/codecs/tests/it/tiff_pyramid.rs new file mode 100644 index 0000000000..5bc0e4bb23 --- /dev/null +++ b/crates/codecs/tests/it/tiff_pyramid.rs @@ -0,0 +1,184 @@ +//! TIFF "Save Image Pyramid" (#8827): reduced-resolution levels after the full image, each half +//! the size of the one before, that every reader skips when it opens the file. + +use photocraft_codecs::*; + +fn pyramid() -> EncodeOptions { + EncodeOptions { tiff_pyramid: true, ..Default::default() } +} + +/// An image of `layout`/`sample` where every sample is `v` (on the 0–1 scale). +fn solid(w: u32, h: u32, layout: ChannelLayout, sample: SampleType, v: f32) -> Image { + let n = w as usize * h as usize * layout.channels(); + let data: Vec = match sample { + SampleType::U8 => vec![(v * 255.0).round() as u8; n], + SampleType::U16 => (0..n).flat_map(|_| ((v * 65535.0).round() as u16).to_ne_bytes()).collect(), + SampleType::F32 => (0..n).flat_map(|_| v.to_ne_bytes()).collect(), + SampleType::F16 => (0..n).flat_map(|_| f16::from_f32(v).to_bits().to_ne_bytes()).collect(), + }; + Image::from_raw(w, h, layout, sample, data).unwrap() +} + +fn sizes(bytes: &[u8]) -> Vec<(TiffPageKind, u32, u32)> { + tiff_info(bytes).unwrap().pages.iter().map(|p| (p.kind, p.width, p.height)).collect() +} + +#[test] +fn levels_halve_down_to_the_minimum() { + use TiffPageKind::*; + let img = solid(300, 200, ChannelLayout::Rgb, SampleType::U8, 0.5); + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + // 300 → 150 → 75 → 38: the first level no longer than 64 px is the last. + assert_eq!(sizes(&bytes), [(Page, 300, 200), (ReducedResolution, 150, 100), (ReducedResolution, 75, 50), (ReducedResolution, 38, 25)]); + // Opening the file opens the full image, and the levels are not extra pages. + let back = decode(&bytes).unwrap(); + assert_eq!(back.dimensions(), (300, 200)); + assert_eq!(back.data(), img.data()); + assert!(!back.warnings.iter().any(|w| matches!(w, DecodeWarning::MorePages { .. })), "{:?}", back.warnings); +} + +#[test] +fn off_by_default_and_none_for_small_images() { + let img = solid(300, 200, ChannelLayout::Rgb, SampleType::U8, 0.5); + assert_eq!(sizes(&encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()).len(), 1); + let small = solid(64, 10, ChannelLayout::Rgb, SampleType::U8, 0.5); + assert_eq!(sizes(&encode(&small, Format::Tiff, &pyramid()).unwrap()).len(), 1); + // A 1-px-wide strip still halves its long side. + let strip = solid(1, 200, ChannelLayout::Gray, SampleType::U8, 0.5); + let s = sizes(&encode(&strip, Format::Tiff, &pyramid()).unwrap()); + assert_eq!(s.iter().map(|&(_, w, h)| (w, h)).collect::>(), [(1, 200), (1, 100), (1, 50)]); +} + +#[test] +fn every_depth_layout_and_compression() { + let layouts = + [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab]; + for layout in layouts { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let img = solid(120, 70, layout, sample, 0.6); + let opts = EncodeOptions { tiff_compression: compression, ..pyramid() }; + let bytes = encode(&img, Format::Tiff, &opts).unwrap_or_else(|e| panic!("{layout:?} {sample:?} {compression:?}: {e}")); + let info = tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), 2, "{layout:?} {sample:?} {compression:?}"); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.dimensions(), (60, 35)); + assert_eq!(level.layout(), decode(&bytes).unwrap().layout(), "{layout:?} {sample:?}"); + // A solid image stays the same colour at every level. + let full = decode(&bytes).unwrap().convert(level.layout(), SampleType::F32); + let level = level.convert(level.layout(), SampleType::F32); + let (a, b) = (full.to_f32_samples().unwrap(), level.to_f32_samples().unwrap()); + for (c, v) in b.iter().take(level.layout().channels()).enumerate() { + assert!((v - a[c]).abs() < 1e-3, "{layout:?} {sample:?} channel {c}: {v} vs {}", a[c]); + } + } + } + } +} + +/// A 128×128 image of 2×2 `tile`s (top left, top right, bottom left, bottom right; alpha last), +/// and the first pixel of its first reduced level. +fn first_level_pixel(layout: ChannelLayout, tile: [&[u8]; 4]) -> Vec { + let mut data = Vec::new(); + for _ in 0..64 { + for x in 0..128 { + data.extend_from_slice(tile[x % 2]); + } + for x in 0..128 { + data.extend_from_slice(tile[2 + x % 2]); + } + } + let img = Image::from_raw(128, 128, layout, SampleType::U8, data).unwrap(); + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.dimensions(), (64, 64), "{layout:?}"); + assert_eq!(level.layout(), layout); + level.data()[..layout.channels()].to_vec() +} + +#[test] +fn colour_is_weighted_by_alpha() { + // One opaque pixel and three transparent ones of another colour: the level keeps the opaque + // colour at a quarter alpha. Unweighted, the transparent colour would bleed in (pink, not red). + assert_eq!(first_level_pixel(ChannelLayout::Rgba, [&[255, 0, 0, 255], &[255, 255, 255, 0], &[255, 255, 255, 0], &[255, 255, 255, 0]]), [255, 0, 0, 64]); + // Gray and CMYK reach the same path with other channel counts. + assert_eq!(first_level_pixel(ChannelLayout::GrayA, [&[40, 255], &[255, 0], &[255, 0], &[255, 0]]), [40, 64]); + assert_eq!(first_level_pixel(ChannelLayout::CmykA, [&[200, 10, 30, 90, 255], &[0; 5], &[0; 5], &[0; 5]]), [200, 10, 30, 90, 64]); +} + +#[test] +fn colour_is_weighted_between_translucent_pixels() { + // Two half-transparent pixels of different colours (red, blue) and two clear ones: the level + // averages the two by their alpha (purple), where copying either pixel would give red or blue. + let tile: [&[u8]; 4] = [&[255, 0, 0, 128], &[0, 0, 255, 128], &[0; 4], &[0; 4]]; + assert_eq!(first_level_pixel(ChannelLayout::Rgba, tile), [128, 0, 128, 64]); +} + +#[test] +fn layered_and_tagged_data_stay_on_the_full_image() { + let mut img = solid(200, 100, ChannelLayout::Rgb, SampleType::U8, 0.25); + img.icc = Some(photocraft_cms_free_profile()); + img.meta.photoshop_layers = Some(vec![1, 2, 3, 4]); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![9; 200 * 100] }]; + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let info = tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), 3); + // Only the full image carries the layers and the extra samples. + assert_eq!(info.pages[0].samples_per_pixel, 4); + assert!(info.pages[1..].iter().all(|p| p.samples_per_pixel == 3)); + assert_eq!(tiff_photoshop_tags(&bytes).1, Some(&[1u8, 2, 3, 4][..])); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.icc, img.icc, "levels keep the profile so they show the same colours"); + assert!(level.meta.extra_samples.is_empty()); +} + +/// Any bytes a decoder keeps as the ICC profile: the codec stores it without parsing. +fn photocraft_cms_free_profile() -> Vec { + (0..=255u8).collect() +} + +#[test] +fn levels_average_exactly_at_every_depth() { + // One 2×2 block of gray values 0, 0.25, 0.5, 1 averages to 0.4375; a 2×2 block with half + // transparent pixels keeps only the opaque colour and averages the alpha. + for sample in [SampleType::U16, SampleType::F32] { + let vals = [0.0f32, 0.25, 0.5, 1.0]; + let mut img = solid(130, 2, ChannelLayout::Gray, sample, 0.0); + let w = 130; + for (i, v) in vals.iter().enumerate() { + let (x, y) = (i % 2, i / 2); + let at = (y * w + x) * sample.bytes(); + let bytes: Vec = match sample { + SampleType::U16 => ((v * 65535.0).round() as u16).to_ne_bytes().to_vec(), + _ => v.to_ne_bytes().to_vec(), + }; + img.data_mut()[at..at + bytes.len()].copy_from_slice(&bytes); + } + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap().convert(ChannelLayout::Gray, SampleType::F32); + let v = level.to_f32_samples().unwrap()[0]; + assert!((v - 0.4375).abs() < 1e-4, "{sample:?}: {v}"); + } + let mut data = Vec::new(); + for _ in 0..2 { + for x in 0..130u32 { + // RGBA16: opaque blue on even columns, transparent red on odd. + let px: [u16; 4] = if x % 2 == 0 { [0, 0, 65535, 65535] } else { [65535, 0, 0, 0] }; + data.extend(px.iter().flat_map(|v| v.to_ne_bytes())); + } + } + let img = Image::from_raw(130, 2, ChannelLayout::Rgba, SampleType::U16, data).unwrap(); + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.to_u16_samples().unwrap()[..4], [0, 0, 65535, 32768]); +} + +#[test] +fn bigtiff_files_carry_the_pyramid_too() { + let img = solid(300, 200, ChannelLayout::Rgba, SampleType::U8, 0.5); + let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_bigtiff: true, ..pyramid() }).unwrap(); + let info = tiff_info(&bytes).unwrap(); + assert!(info.big_tiff); + assert_eq!(info.pages.iter().map(|p| (p.kind, p.width)).collect::>().len(), 4); + assert_eq!(decode_tiff_page(&bytes, 3, &DecodeOptions::default()).unwrap().dimensions(), (38, 25)); +} diff --git a/crates/codecs/tests/it/tiff_ycbcr.rs b/crates/codecs/tests/it/tiff_ycbcr.rs new file mode 100644 index 0000000000..94ff9f87e2 --- /dev/null +++ b/crates/codecs/tests/it/tiff_ycbcr.rs @@ -0,0 +1,259 @@ +//! YCbCr TIFFs (PhotometricInterpretation 6), as scanners write them: JPEG-compressed with 2×2 +//! chroma subsampling, and uncompressed with ReferenceBlackWhite and YCbCrCoefficients (#10316). +//! Every file here is hand-built by `common::tiffgen`. + +use crate::common::sample_icc; +use crate::common::tiffgen::*; +use photocraft_codecs::*; + +const COMPRESSION: u16 = 259; +const SUBSAMPLING: u16 = 530; +const YCBCR_COEFFICIENTS: u16 = 529; +const REFERENCE_BLACK_WHITE: u16 = 532; +const ICC_PROFILE: u16 = 34675; + +/// A directory whose Compression is `c`. +fn compressed(mut d: Dir, c: u64) -> Dir { + d.entries.retain(|e| e.0 != COMPRESSION); + d.tag(COMPRESSION, SHORT, &[c]) +} + +/// A little-endian RATIONAL value for `tiffgen`. +fn rational(num: u32, den: u32) -> u64 { + u64::from(num) | (u64::from(den) << 32) +} + +/// The image's pixels as RGB bytes. +fn rgb(img: &Image) -> Vec { + img.to_rgba8().as_chunks::<4>().0.iter().flat_map(|[r, g, b, _]| [*r, *g, *b]).collect() +} + +/// A smooth colour test card, `w`×`h` RGB. +fn card(w: u32, h: u32) -> Vec { + let mut v = Vec::new(); + for y in 0..h { + for x in 0..w { + v.extend([(x * 255 / (w - 1)) as u8, (y * 255 / (h - 1)) as u8, (255 - x * 255 / (w - 1)) as u8]); + } + } + v +} + +/// A 4:2:0 baseline JPEG stream of `rgb`. +fn jpeg(rgb: &[u8], w: u32, h: u32) -> Vec { + let img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, rgb.to_vec()).unwrap(); + encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, ..Default::default() }).unwrap() +} + +#[test] +fn jpeg_compressed_ycbcr_with_subsampled_chroma_reads_as_rgb() { + let (w, h) = (32, 24); + let stream = jpeg(&card(w, h), w, h); + // The stream really is subsampled: the luma component samples 2×2 in the frame header. + let sof = stream.windows(2).position(|m| m == [0xFF, 0xC0]).expect("baseline frame header"); + assert_eq!(stream[sof + 11], 0x22, "luma sampling factors"); + let dir = compressed(strips(w, h, 8, 3, 6, h, &stream), 7).tag(SUBSAMPLING, SHORT, &[2, 2]); + let file = build(true, false, &[dir], &[0]).bytes; + + let img = decode(&file).unwrap(); + assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (w, h, ChannelLayout::Rgb, SampleType::U8)); + // The oracle: the same JPEG stream decoded to RGB by an independent decoder. + let want = image::load_from_memory_with_format(&stream, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw(); + let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap(); + assert!(worst <= 2, "largest difference from the oracle: {worst}"); +} + +#[test] +fn uncompressed_ycbcr_uses_the_default_conversion() { + // Black, white, mid gray and JFIF's pure red, without subsampling. + let px = [0, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255]; + let dir = strips(4, 1, 8, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgb); + assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0]); +} + +#[test] +fn reference_black_white_sets_the_code_range() { + // Studio range: Y 16–235, chroma 128 ± 112. + let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1)]; + let dir = strips(2, 1, 8, 3, 6, 1, &[16, 128, 128, 235, 128, 128]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(REFERENCE_BLACK_WHITE, RATIONAL, &rbw); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255]); +} + +#[test] +fn weights_that_do_not_sum_to_one_keep_grays_neutral() { + // 0.25 each (sum 0.75): TIFF 6.0's full inverse would scale green's Y by (1 − Lr − Lb) / Lg + // = 2 and give mid gray G 255 (clamped). Like libtiff, green keeps Y, so gray stays gray, and + // the weights still set the chroma terms: R = Y + 1.5·Cr, B = Y + 1.5·Cb. + let weights = [rational(1, 4), rational(1, 4), rational(1, 4)]; + let dir = strips(2, 1, 8, 3, 6, 1, &[128, 128, 128, 128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + // Green for the second pixel: 128 − (Lb·1.5/Lg)·Cb − (Lr·1.5/Lg)·Cr = 128 + 96 − 96. + assert_eq!(rgb(&img), [128, 128, 128, 224, 128, 32]); +} + +#[test] +fn ycbcr_coefficients_set_the_luma_weights() { + // Weights 0.25, 0.5, 0.25 with Cb −64 and Cr +64: R = Y + 1.5·Cr, B = Y + 1.5·Cb, and G keeps Y + // (BT.601 would give R 218 and B 15). + let weights = [rational(1, 4), rational(1, 2), rational(1, 4)]; + let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [224, 128, 32]); +} + +#[test] +fn ycbcr_tags_with_the_wrong_count_use_the_defaults() { + // Four weights and seven black/white codes: both tags are malformed, so BT.601 and full range + // apply (the weights alone would give 224, 128, 32). + let weights = [rational(1, 4), rational(1, 2), rational(1, 4), rational(1, 4)]; + let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1), rational(0, 1)]; + let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).tag( + REFERENCE_BLACK_WHITE, + RATIONAL, + &rbw, + ); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [218, 104, 15]); +} + +#[test] +fn sixteen_bit_ycbcr_reads_as_rgb() { + let px: Vec = [65535u16, 32768, 32768, 0, 32768, 32768].iter().flat_map(|v| v.to_le_bytes()).collect(); + let dir = strips(2, 1, 16, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16)); + let n = img.to_normalized(); + assert!(n[..3].iter().all(|&v| v == 1.0) && n[3..].iter().all(|&v| v == 0.0), "{n:?}"); +} + +#[test] +#[cfg(target_pointer_width = "64")] +fn sixteen_bit_ycbcr_bands_match_whole_decode_with_a_missing_last_strip() { + // On 32-bit targets max_alloc also bounds the whole image, so a small allocation cap + // cannot force these tiny strips into separate output groups there. + let (w, h, rows) = (4u32, 5u32, 2u32); + let pixels = [[0u16, 32768, 32768], [65535, 32768, 32768], [19595, 21710, 65535], [32768, 40960, 24576]]; + let samples: Vec = (0..w * h).flat_map(|i| pixels[i as usize % pixels.len()]).collect(); + let weights = [rational(1, 4), rational(1, 2), rational(1, 4)]; + let icc = sample_icc(512); + for little in [true, false] { + for missing in [false, true] { + let dir = strips(w, h, 16, 3, 6, rows, &u16s(&samples, little)).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).bytes( + ICC_PROFILE, + UNDEFINED, + &icc, + ); + let mut file = build(little, false, &[dir], &[0]); + if missing { + let past = file.bytes.len() as u64 + 4096; + patch_value(&mut file, little, false, 0, 273, 2, past); + } + let want = decode_with(&file.bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap(); + let limits = Limits { max_alloc: u64::from(w * rows * 3 * 2), ..Limits::banded() }; + let mut bands = TiffBands::open(&file.bytes, None, &limits).unwrap(); + assert_eq!((bands.dimensions(), bands.layout(), bands.sample_type()), ((w, h), ChannelLayout::Rgb, SampleType::U16)); + let mut got = Image::new(w, h, bands.layout(), bands.sample_type()).unwrap(); + let mut positions = Vec::new(); + bands + .decode(|x, y, group: &Image| -> Result<(), CodecError> { + positions.push((x, y, group.height())); + assert_eq!((x, group.width(), group.layout(), group.sample_type()), (0, w, ChannelLayout::Rgb, SampleType::U16)); + let at = y as usize * w as usize * 3 * 2; + got.data_mut()[at..at + group.data().len()].copy_from_slice(group.data()); + Ok(()) + }) + .unwrap(); + assert_eq!(positions, [(0, 0, 2), (0, 2, 2), (0, 4, 1)], "one output group per strip, including the short last strip"); + assert_eq!(got.data(), want.data(), "little endian: {little}, missing last strip: {missing}"); + assert_eq!(bands.icc, want.icc); + assert_eq!(bands.meta, want.meta); + assert_eq!(bands.warnings, want.warnings); + assert_eq!(bands.warnings.iter().any(|w| matches!(w, DecodeWarning::Truncated { .. })), missing); + } + } +} + +#[test] +fn unsupported_ycbcr_files_fail_cleanly() { + // Four samples (an extra sample next to YCbCr). + let four = strips(1, 1, 8, 4, 6, 1, &[128, 128, 128, 255]).tag(SUBSAMPLING, SHORT, &[1, 1]); + assert!(decode(&build(true, false, &[four], &[0]).bytes).is_err()); + // Subsampled chroma without JPEG compression (the default subsampling is 2×2). + let sub = strips(2, 2, 8, 3, 6, 2, &[128; 12]); + assert!(decode(&build(true, false, &[sub], &[0]).bytes).is_err()); + // A JPEG strip that is not JPEG data. + let junk = compressed(strips(16, 16, 8, 3, 6, 16, &[0x5A; 64]), 7).tag(SUBSAMPLING, SHORT, &[2, 2]); + assert!(decode(&build(true, false, &[junk], &[0]).bytes).is_err()); + // Coefficients and ReferenceBlackWhite that would divide by zero fall back to the defaults. + let bad = strips(1, 1, 8, 3, 6, 1, &[255, 128, 128]) + .tag(SUBSAMPLING, SHORT, &[1, 1]) + .tag(YCBCR_COEFFICIENTS, RATIONAL, &[rational(0, 1), rational(0, 0), rational(5, 1)]) + .tag(REFERENCE_BLACK_WHITE, RATIONAL, &[rational(7, 1); 6]); + let img = decode(&build(true, false, &[bad], &[0]).bytes).unwrap(); + assert_eq!(&img.to_rgba8()[..3], &[255, 255, 255]); +} + +#[test] +fn tiled_jpeg_ycbcr_keeps_its_icc_profile() { + // Two 16×16 tiles, each its own 4:2:0 JPEG stream, and an embedded profile, like a scanner's + // tiled output. + let (w, h, t) = (32u32, 16u32, 16u32); + let px = card(w, h); + let tile = |tx: u32| -> Vec { + (0..t) + .flat_map(|y| { + let at = ((y * w + tx * t) * 3) as usize; + px[at..at + (t * 3) as usize].to_vec() + }) + .collect() + }; + let streams = [jpeg(&tile(0), t, t), jpeg(&tile(1), t, t)]; + let icc = sample_icc(512); + let dir = Dir { entries: base(w, h, 8, 3, 6), chunks: streams.to_vec(), tiled: true }; + let dir = compressed(dir, 7).tag(322, SHORT, &[u64::from(t)]).tag(323, SHORT, &[u64::from(t)]).tag(SUBSAMPLING, SHORT, &[2, 2]).bytes( + ICC_PROFILE, + UNDEFINED, + &icc, + ); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (w, h, ChannelLayout::Rgb)); + assert_eq!(img.icc.as_deref(), Some(icc.as_slice()), "the embedded profile is kept"); + // The oracle: each tile's stream decoded by an independent decoder, placed side by side. + let tiles: Vec> = streams.iter().map(|s| image::load_from_memory_with_format(s, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw()).collect(); + let row = (t * 3) as usize; + let want: Vec = (0..t as usize).flat_map(|y| [&tiles[0][y * row..(y + 1) * row], &tiles[1][y * row..(y + 1) * row]].concat()).collect(); + let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap(); + assert!(worst <= 2, "largest difference from the oracle: {worst}"); +} + +#[test] +fn big_endian_sixteen_bit_ycbcr_reads_as_rgb() { + // White, black, and red as full-range 16-bit YCbCr (Y 0.299·65535, Cb 32768 − 0.1687·65535, Cr max). + let px = [65535u16, 32768, 32768, 0, 32768, 32768, 19595, 21710, 65535]; + let dir = strips(3, 1, 16, 3, 6, 1, &u16s(&px, false)).tag(SUBSAMPLING, SHORT, &[1, 1]); + let file = build(false, false, &[dir], &[0]).bytes; + assert_eq!(&file[..2], b"MM"); + let img = decode(&file).unwrap(); + assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16)); + let n = img.to_normalized(); + let want = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + assert!(n.iter().zip(want).all(|(g, w)| (g - w).abs() < 2.0 / 255.0), "{n:?}"); +} + +#[test] +fn planar_ycbcr_reads_as_rgb_in_both_byte_orders() { + // The pixels of the default-conversion test, one plane per component. + let px = [0u8, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255]; + for little in [true, false] { + let dir = Dir { entries: base(4, 1, 8, 3, 6), chunks: planar_strips(&px, 4, 1, 3, 1, 1), tiled: false }.tag(278, LONG, &[1]).tag(284, SHORT, &[2]).tag( + SUBSAMPLING, + SHORT, + &[1, 1], + ); + let img = decode(&build(little, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0], "little endian: {little}"); + } +} diff --git a/crates/codecs/tests/jxl_export.rs b/crates/codecs/tests/jxl_export.rs new file mode 100644 index 0000000000..8875cbce80 --- /dev/null +++ b/crates/codecs/tests/jxl_export.rs @@ -0,0 +1,110 @@ +//! Lossless JPEG XL export and honest export-only capability reporting. + +use photocraft_codecs::{ + ASYMMETRIC_EXCEPTIONS, ChannelLayout, CodecError, EncodeOptions, Format, Image, SampleType, caps, decode, detect, encode, from_extension, +}; + +#[test] +fn jpeg_xl_is_export_only_and_detects_both_signatures() { + let c = caps(Format::Jxl); + assert!(c.write && !c.read); + assert_eq!(c.depths, &[SampleType::U8, SampleType::U16]); + assert_eq!(c.layouts, &[ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Rgba]); + assert!(c.alpha && !c.icc && !c.exif && !c.xmp && !c.dpi && !c.lossy); + assert_eq!(Format::Jxl.mime_type(), "image/jxl"); + assert_eq!(from_extension("image.JXL"), Some(Format::Jxl)); + assert_eq!(detect(&[0xff, 0x0a, 0, 0]), Some(Format::Jxl)); + assert_eq!(detect(b"\0\0\0\x0cJXL \r\n\x87\n"), Some(Format::Jxl)); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(format, reason)| *format == Format::Jxl && !reason.is_empty())); + let err = decode(&[0xff, 0x0a]); + assert!(matches!(err, Err(CodecError::Unsupported { format: Format::Jxl, .. })), "{err:?}"); +} + +#[test] +fn jpeg_xl_encoding_is_deterministic_and_converts_float_to_integer() { + let input = + Image::from_u8(3, 2, ChannelLayout::Rgba, vec![0, 255, 17, 127, 20, 40, 80, 255, 255, 0, 2, 0, 10, 20, 30, 64, 0, 0, 0, 1, 90, 80, 70, 128]).unwrap(); + let opts = EncodeOptions::default(); + let one = encode(&input, Format::Jxl, &opts).unwrap(); + let two = encode(&input, Format::Jxl, &opts).unwrap(); + assert_eq!(one, two); + assert_eq!(detect(&one), Some(Format::Jxl)); + let float = Image::from_f32(2, 2, ChannelLayout::Rgb, &[0.0, 0.5, 1.0, 0.25, 0.5, 0.75, 0.75, 0.5, 0.25, 1.0, 0.0, 0.5]).unwrap(); + let bytes = encode(&float, Format::Jxl, &opts).unwrap(); + assert_eq!(detect(&bytes), Some(Format::Jxl)); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn jpeg_xl_decodes_losslessly_with_independent_decoder() { + use jxl_oxide::JxlImage; + + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + let channels = layout.channels(); + for sample in [SampleType::U8, SampleType::U16] { + let (w, h) = (7, 5); + let count = w as usize * h as usize * channels; + let expected = match sample { + SampleType::U8 => (0..count).map(|i| (((i * 37 + 19) % 256) as u16) * 257).collect::>(), + SampleType::U16 => (0..count).map(|i| ((i * 9973 + 27) % 65536) as u16).collect::>(), + _ => unreachable!(), + }; + let input = match sample { + SampleType::U8 => { + let data = expected.iter().map(|v| (v / 257) as u8).collect(); + Image::from_u8(w, h, layout, data).unwrap() + } + SampleType::U16 => Image::from_u16(w, h, layout, &expected).unwrap(), + _ => unreachable!(), + }; + // GrayA is losslessly widened to RGBA. Keep the oracle comparison + // against original gray and alpha samples, not only against a converted image. + let (output_channels, expected_output) = if layout == ChannelLayout::GrayA { + let expanded: Vec = expected.as_chunks::<2>().0.iter().flat_map(|[gray, alpha]| [*gray, *gray, *gray, *alpha]).collect(); + (4, expanded) + } else { + (channels, expected.clone()) + }; + let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap(); + let decoder = JxlImage::builder().read(&encoded[..]).unwrap(); + assert_eq!((decoder.width(), decoder.height()), (w, h)); + let render = decoder.render_frame(0).unwrap(); + let mut stream = render.stream(); + assert_eq!(stream.channels() as usize, output_channels, "{layout:?} {sample:?}"); + let mut output = vec![0u16; expected_output.len()]; + assert_eq!(stream.write_to_buffer(&mut output), expected_output.len()); + assert_eq!(output, expected_output, "{layout:?} {sample:?}"); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn jpeg_xl_preserves_sixteen_bit_alpha_across_dimension_header_sizes() { + use jxl_oxide::JxlImage; + + // Exercise each size-header coding tier, and non-opaque alpha extremes. + for width in [2u32, 513, 8193] { + let height = 2u32; + let mut samples = Vec::with_capacity(width as usize * height as usize * 4); + for i in 0..(width as usize * height as usize) { + samples.extend_from_slice(&[(i as u16).wrapping_mul(127), 65535, (i as u16).wrapping_mul(9973), [0u16, 1, 32768, 65535][i % 4]]); + } + let input = Image::from_u16(width, height, ChannelLayout::Rgba, &samples).unwrap(); + let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap(); + let decoder = JxlImage::builder().read(&encoded[..]).unwrap(); + let render = decoder.render_frame(0).unwrap(); + let mut stream = render.stream(); + assert_eq!(stream.channels(), 4, "width={width}"); + let mut output = vec![0u16; samples.len()]; + assert_eq!(stream.write_to_buffer(&mut output), samples.len()); + assert_eq!(output, samples, "width={width}"); + } +} + +#[test] +fn jpeg_xl_rejects_one_pixel_images_without_panicking() { + // zune-jpegxl currently requires at least two pixels on each axis. + let image = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![42, 100, 255]).unwrap(); + assert!(matches!(encode(&image, Format::Jxl, &EncodeOptions::default()), Err(CodecError::Encode { format: Format::Jxl, .. }))); +} diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index c25867c938..d2c020a76f 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -31,8 +31,8 @@ pub use color::{ use tone::LUT_SIZE; pub(crate) use tone::apply_tone; pub use tone::{ - TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, float_tone_extrapolates, float_tone_identity, levels, levels_float, - levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q, + TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, curve_samples, float_tone_extrapolates, float_tone_identity, levels, + levels_float, levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q, }; use tone::{lut, map_rgb}; diff --git a/crates/compose/src/adjust/tone.rs b/crates/compose/src/adjust/tone.rs index 5debc98a83..5c57608e02 100644 --- a/crates/compose/src/adjust/tone.rs +++ b/crates/compose/src/adjust/tone.rs @@ -402,11 +402,19 @@ pub fn curve_map_lut(points: &[CurvePoint]) -> Vec { /// overshoot between points, as Photoshop's does), constant outside the /// first/last point, clamped to 0..=1, sampled into a LUT. pub fn curve_lut(points: &[CurvePoint]) -> Vec { + curve_samples::(points) +} + +/// The same natural spline as [`curve_lut`], sampled directly at `N` evenly spaced inputs +/// including both endpoints. UI graphs use a smaller table without building the rendering LUT. +/// Zero samples returns an empty table; one sample evaluates at input zero. +pub fn curve_samples(points: &[CurvePoint]) -> Vec { + let last = N.saturating_sub(1).max(1) as f32; let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect(); pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); if pts.len() < 2 { - return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect(); + return (0..N).map(|i| i as f32 / last).collect(); } let n = pts.len(); // Second derivatives of the natural spline (tridiagonal solve). @@ -430,9 +438,9 @@ pub fn curve_lut(points: &[CurvePoint]) -> Vec { m2[i] = d[i] - c[i] * m2[i + 1]; } } - (0..LUT_SIZE) + (0..N) .map(|k| { - let xv = k as f32 / (LUT_SIZE - 1) as f32; + let xv = k as f32 / last; if xv <= pts[0].0 { return pts[0].1.clamp(0.0, 1.0); } diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs index 8ea5a86c6d..7a4068012d 100644 --- a/crates/compose/src/advanced.rs +++ b/crates/compose/src/advanced.rs @@ -32,6 +32,7 @@ use std::borrow::Cow; use photocraft_doc::{Knockout, Layer, LayerContent}; use photocraft_geom::Rect; +use crate::extents::recycle_mask; use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content}; /// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an @@ -157,6 +158,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl if target.rect != backdrop.rect || shape.len() != backdrop.px.len() { // Never reached (targets are built over the backdrop's rect); compositing without the // knockout beats a wrong pixel or a panic. + recycle_mask(Some(shape)); draw(backdrop); return; } @@ -172,7 +174,10 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl } let knocked = (layer.opacity < 1.0).then(|| backdrop.clone()); draw(backdrop); - let Some(knocked) = knocked else { return }; + let Some(knocked) = knocked else { + recycle_mask(Some(shape)); + return; + }; let rest = 1.0 - layer.opacity.clamp(0.0, 1.0); for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) { if *s <= 0.0 { @@ -186,6 +191,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl let a = pm[3].clamp(0.0, 1.0); *p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] }; } + recycle_mask(Some(shape)); } /// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend @@ -220,7 +226,12 @@ pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], b let op = layer.opacity.clamp(0.0, 1.0); let clip: Vec = match render_content(layer, rect, cx) { Some(b) => b.px.iter().map(|p| p[3] * op).collect(), - None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(), + None => { + let mv = mask_vals(layer, rect, cx); + let clip = mv.as_ref().map_or_else(|| vec![op; n], |m| m.iter().map(|m| m * op).collect()); + recycle_mask(mv); + clip + } }; if clip.len() != n { return; diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 608c6188e2..15d3cf2346 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -395,7 +395,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() { .unwrap(); let mut band_pixels = Vec::with_capacity(whole.px.len()); for band in bands { - band_pixels.extend(band.px); + band_pixels.extend(band.into_px()); } assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}"); } @@ -639,7 +639,7 @@ fn transparency_shapes_off_ignores_masks_and_reaches_groups() { l.mask = Some(LayerMask { surface: photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[77.0 / 255.0]), ..LayerMask::reveal_all() }); l.advanced.transparency_shapes = tsl; d.layers.push(l); - render(&d, d.bounds()).px + render(&d, d.bounds()).into_px() }; let (off, on) = (render_with(false), render_with(true)); assert!(off.iter().zip(&on).all(|(a, b)| close(*a, *b)), "{mode:?} ({depth:?}): a mask isn't the layer's transparency"); @@ -691,13 +691,13 @@ fn transparency_shapes_off_keeps_the_base_alpha_under_a_clipped_knockout() { base.advanced.transparency_shapes = false; base.vector_mask = Some(photocraft_doc::vector::VectorMask { feather: 1.5, ..photocraft_doc::vector::VectorMask::new(square.clone()) }); d.layers.push(base); - let alone = render(&d, d.bounds()).px; + let alone = render(&d, d.bounds()).into_px(); let mut red = solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]); red.clipped = true; let mut knock = knocker(&d, k); knock.clipped = true; d.layers.extend([red, knock]); - let got = render(&d, d.bounds()).px; + let got = render(&d, d.bounds()).into_px(); let what = format!("{k:?} ({depth:?})"); for (i, (g, a)) in got.iter().zip(&alone).enumerate() { if i % 12 < 6 { diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index 985a400e08..d6a3a88ef1 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -34,6 +34,12 @@ fn cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP })) } +pub(crate) fn purge() { + let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner()); + cache.entries.clear(); + cache.sweep_at = MIN_SWEEP; +} + /// `Surface::content_bounds`, cached per tile. pub fn content_bounds(s: &Surface) -> Rect { if s.tile_count() == 0 { @@ -54,8 +60,11 @@ pub fn content_bounds(s: &Surface) -> Rect { let b = match hit { Some(b) => b, None => { + let generation = photocraft_raster::spill::read_error_generation(); let b = t.content_bounds(&dp); - c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + if photocraft_raster::spill::check_since(generation).is_ok() && t.scratch_health().is_ok() { + c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + } b } }; diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index 96a6df0ceb..a12597463a 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -27,6 +27,7 @@ use photocraft_doc::adjust::ToneSpace; use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent}; use photocraft_geom::Rect; +use super::extents::recycle_mask; use super::{Ctx, RENDER_TILE, advanced, empty_in, mask_vals, pattern}; #[path = "cmyk_split.rs"] @@ -189,10 +190,16 @@ pub fn separable(mode: BlendMode) -> bool { /// channel's brightness (1 − ink), as CMYK channels are blended, so Multiply adds ink and Screen /// takes it away. pub fn blend_ink(mode: BlendMode, cb: [f32; 4], cs: [f32; 4]) -> [f32; 4] { + blend_ink_fill(mode, cb, cs, 1.0) +} + +/// [`blend_ink`] at `fill`: below 100% the special eight change the blend itself +/// (`psblend::blend_rgb_fill`, on each ink's brightness); other modes ignore `fill`. +fn blend_ink_fill(mode: BlendMode, cb: [f32; 4], cs: [f32; 4], fill: f32) -> [f32; 4] { let b = |i: usize| 1.0 - cb[i]; let s = |i: usize| 1.0 - cs[i]; - let cmy = super::psblend::blend_rgb(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)]); - let k = super::psblend::blend_rgb(mode, [b(3); 3], [s(3); 3]); + let cmy = super::psblend::blend_rgb_fill(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)], fill); + let k = super::psblend::blend_rgb_fill(mode, [b(3); 3], [s(3); 3], fill); // A result that is one of the inputs (Normal, Darken, Lighten, ...) is that ink exactly. let ink = |r: f32, i: usize| { if r == s(i) { @@ -355,6 +362,7 @@ fn apply_mask(l: &Layer, buf: &mut CmykBuffer, cx: &Ctx) { for (p, k) in buf.px.iter_mut().zip(&m) { p[4] *= clean(*k).max(0.0); } + recycle_mask(Some(m)); } } @@ -399,9 +407,11 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { } /// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and -/// mask; coverage is unchanged (the compositor's adjustment mix). +/// mask; coverage is unchanged (the compositor's adjustment mix). Fill below 100% in one of the +/// special eight is part of the blend and leaves the mix to Opacity, as on the RGB compositor. fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { - let opacity = l.opacity * l.fill_opacity; + let special = l.fill_opacity < 1.0 && super::psblend::fill_is_special(l.blend); + let (opacity, fill) = if special { (l.opacity, l.fill_opacity) } else { (l.opacity * l.fill_opacity, 1.0) }; let rect = base.rect; let w = (rect.width() as usize).max(1); let mv = mask_vals(l, rect, cx); @@ -420,11 +430,12 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct } continue; } - let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); + let bl = blend_ink_fill(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]], fill); for (v, b) in p.iter_mut().zip(bl) { *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; } } + recycle_mask(mv); } /// Rounds to the document's levels after an adjustment layer, as the compositor does (on the @@ -534,6 +545,7 @@ fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx: [0.0; 5] }; } + recycle_mask(mv); } return; } diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 44b265c8dd..894b87fd29 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -214,20 +214,24 @@ fn ref_adjust(adj: &Adjustment, p: Cmyka) -> Cmyka { } /// An adjustment layer `l` (adjusting `p`, then `clipped` onto that) mixed into `p` in its blend -/// mode by its opacity and mask; coverage stays. +/// mode by its opacity and mask; coverage stays. In the special eight below 100% Fill, Fill is +/// part of the blend (`psblend::blend_rgb_fill`, on brightness) and Opacity alone mixes it in. fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32, y: i32) -> Cmyka { let mut a = ref_adjust(adj, p); if p[4] > 0.0 { a = ref_clip(clipped, a, x, y); } - let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); + let special = l.fill_opacity < 1.0 && crate::psblend::fill_is_special(l.blend); + let (opacity, fill) = if special { (l.opacity, l.fill_opacity) } else { (l.opacity * l.fill_opacity, 1.0) }; + let k = opacity * ref_mask(l, x, y); if l.blend == BlendMode::Dissolve { // Opacity and mask are the chance the pixel takes the adjustment fully. return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p }; } let mut o = p; for c in 0..4 { - o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; + let m = 1.0 - crate::psblend::blend_rgb_fill(l.blend, [1.0 - p[c]; 3], [1.0 - a[c]; 3], fill)[0]; + o[c] = p[c] + (m - p[c]) * k; } o } @@ -1858,3 +1862,48 @@ fn every_cmyk_route_rounds_after_an_adjustment_layer() { assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}"); } } + +/// An adjustment layer in one of the special eight below 100% Fill: Fill is part of the blend and +/// Opacity alone mixes it in, on the ink path as on the RGB compositor (#9512; Linear Burn at +/// Fill 71% put Y 0.957 where the split route gives 0.988). +#[test] +fn adjustment_layers_keep_the_special_fill_rule() { + let levels = AdjSpec::Levels { channel: 3, levels: [0.125, 0.8, 1.1, 0.14, 0.78] }; + let s = Spec { + w: 3, + h: 1, + layers: vec![ + raster_spec(BlendMode::Normal, (Rect::new(0, 0, 3, 1), [0.0, 0.0, 0.88, 1.0, 1.0]), 1.0, false), + LayerSpec::Pixels { + kind: Kind::Adjust(levels.clone()), + blend: BlendMode::LinearBurn, + fills: vec![], + opacity: 0.9, + fill: 0.71, + clipped: false, + visible: true, + mask: None, + }, + ], + }; + assert_split_matches_exact(&s); + for mode in SEPARABLE.iter().copied().filter(|m| crate::psblend::fill_is_special(*m)) { + let mut d = doc(4, 2); + d.layers.push(paper(4, 2, [0.2, 0.5, 0.1, 0.1])); + d.layers.push(layer(&[(Rect::new(0, 0, 3, 2), [0.6, 0.1, 0.4, 0.2, 0.8])])); + let mut a = build(&LayerSpec::Pixels { + kind: Kind::Adjust(levels.clone()), + blend: mode, + fills: vec![], + opacity: 0.9, + fill: 0.4, + clipped: false, + visible: true, + mask: None, + }); + a.mask = Some(mask(&[(Rect::new(2, 0, 4, 2), 0.5)], 1.0, 1.0, true)); + d.layers.push(a); + assert_matches_reference(&d); + assert_routes_agree(&d); + } +} diff --git a/crates/compose/src/composite.rs b/crates/compose/src/composite.rs index bc0de2ffc2..4ef497672f 100644 --- a/crates/compose/src/composite.rs +++ b/crates/compose/src/composite.rs @@ -1,6 +1,6 @@ -use super::content::{channel_weights, empty_in, recycle_buffer, render_content, restore_channels, scratch_buffer, surface_to_buffer}; +use super::content::{channel_weights, empty_in, render_content, restore_channels, surface_to_buffer}; use super::ctx::Ctx; -use super::extents::{effects_frame, mask_k, mask_vals, shapeless_stroke_bounds}; +use super::extents::{effects_frame, mask_k, mask_vals, recycle_mask, shapeless_stroke_bounds}; use super::fx_cache::effect_maps; use super::{BlendMode, Buffer, Fill, Layer, LayerContent, Rect, Surface, advanced, blend, effects, psblend, shape_split}; @@ -72,8 +72,10 @@ pub(crate) fn branchless_floor(x: f32) -> f32 { } /// 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) { +/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode` at layer Fill `fill`: below +/// 100% the special eight ([`psblend::fill_is_special`]) blend a result pulled toward the mode's +/// neutral colour, as a pixel layer does (#9501); for every other mode Fill is in `ks`. +fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator, mode: BlendMode, fill: f32) { for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) { if k <= 0.0 { continue; @@ -81,8 +83,11 @@ fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterato 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]]; + let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb_fill(mode, [b[0], b[1], b[2]], s, fill) }; + // `b·(1−k) + s·k`, not `b + (s − b)·k`: with a huge 32-bit backdrop the difference loses a + // small result to f32 rounding, so full coverage gave 0 instead of a lookup's 0.5 (#9483). + let j = 1.0 - k; + *b = [b[0] * j + blended[0] * k, b[1] * j + blended[1] * k, b[2] * j + blended[2] * k, b[3]]; } } @@ -147,9 +152,9 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio let Some(p) = px else { continue }; let v = |c: usize| blend_if_level(p[c]); match mode { - // Gray is the colour channels' luma (Rec. 601 weights), then R, G, B. + // Gray, then R, G, B. M::Rgb => { - let gray = 0.299 * v(0) + 0.587 * v(1) + 0.114 * v(2); + let gray = blend_if_gray(v(0), v(1), v(2)); k *= bi.get(0)[side].weight(gray); for c in 0..3 { k *= bi.get(c + 1)[side].weight(v(c)); @@ -166,15 +171,28 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio k } -/// A channel value on Blend If's 0..=255 scale. Compositing can leave an 8- or 16-bit level a few -/// millionths off (a level-20 backdrop composited at partial alpha reads 19.999998), which would -/// put it on the wrong side of a hard point at that level: a value that close to a level is that -/// level. Values of those depths that aren't on a level are at least 1/257 away from one. The GPU -/// compositor (`compose.wgsl` `blend_if_weight`) snaps alike. +/// A channel value on Blend If's 0..=255 scale. Photoshop compares Blend If at its 16-bit +/// precision (0..=32768): a cutoff at level `c` falls on the step `round(c · 32768 / 255)`, and a +/// value on that step is exactly level `c`, inside both a black point and a white point there +/// (#9521: RGB16 32895 is step 16448, level 128's, so it passes a hard black point at 128; read as +/// 127.996 it didn't). Every 8-bit level is on its own step, which also absorbs compositing's few +/// millionths of drift (a level-20 backdrop at partial alpha reads 19.999998). The GPU compositor +/// (`compose.wgsl` `blend_if_weight`) snaps alike. fn blend_if_level(x: f32) -> f32 { - let v = x.clamp(0.0, 1.0) * 255.0; - let r = v.round(); - if (v - r).abs() < 1e-4 { r } else { v } + let x = x.clamp(0.0, 1.0); + let v = x * 255.0; + let level = v.round(); + if (x * 32768.0).round() == (level * 32768.0 / 255.0).round() { level } else { v } +} + +/// Blend If's Gray from channel levels: Photoshop's (30 R + 59 G + 11 B) / 100, rounded half down +/// on 8-bit levels (#9522). Measured in Photoshop 2025: pure red passes a black point at 20 from +/// 66 (19.8) but not 65 (19.5), pure blue at 28 from 251 (27.61) but not 250 (27.5); (123, 126, +/// 151) is 128 (127.85) and (120, 129, 149) is 128 (128.5), where Rec. 601 luma gave 127.95 and +/// 128.59. Levels between whole ones (deeper documents) keep the unrounded weighting. +fn blend_if_gray(r: f32, g: f32, b: f32) -> f32 { + let sum = 30.0 * r + 59.0 * g + 11.0 * b; + if [r, g, b].iter().all(|c| c.fract() == 0.0) { ((sum + 49.0) / 100.0).floor() } else { sum / 100.0 } } /// Blend If groups inside one another whose own colour is still rendered separately. Each extra @@ -191,13 +209,18 @@ thread_local! { /// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since /// every blend mode's source-over result is linear in the source alpha. fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { - // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are - // judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend - // Ifs: a group's own colour re-composites its children, which doubles the work at every - // level of Blend If groups inside one another. + // "This Layer" is the layer's own colour. An adjustment layer (no content of its own) is + // judged by its result at full strength, before Opacity and its mask mix it in, as Photoshop + // does (#9542). Groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend Ifs are judged by + // the composite: a group's own colour re-composites its children, which doubles the work at + // every level of Blend If groups inside one another. let is_group = matches!(layer.content, LayerContent::Group(_)); let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get); - let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) { + let own = if let LayerContent::Adjustment(adj) = &layer.content { + let mut result = before.clone(); + cx.apply_adjustment(adj, &mut result); + Some(result) + } else if is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH { None } else { BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group))); @@ -316,6 +339,7 @@ pub(crate) fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mu } blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma); } + recycle_mask(mv); } pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { @@ -334,6 +358,8 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: // is the base of a clipping group and holds an adjustment layer (psd-tools // passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone), // while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode). + // A group with Blend If renders isolated too: an adjustment inside it no longer changes the + // layers beneath (#9554). let clip_base_adjusts = clipped.iter().any(|c| c.visible) && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content @@ -341,6 +367,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: && layer.fill_opacity >= 1.0 && !effects::has_effects(layer) && !clip_base_adjusts + && !blend_if_active(layer, cx.mode) { let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some(); let has_clipped = clipped.iter().any(|c| c.visible); @@ -363,6 +390,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: [0.0; 4] }; } + recycle_mask(mv); } // A Frame's stroke over the frame's content, at the group's opacity. if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) { @@ -399,9 +427,10 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: if let LayerContent::Adjustment(adj) = &layer.content { let mv = mask_vals(layer, rect, cx); if mv.as_ref().is_some_and(|values| values.iter().all(|&value| opacity * value <= 0.0)) { + recycle_mask(mv); return; } - let mut adjusted = scratch_buffer(rect); + let mut adjusted = Buffer::transparent(rect); adjusted.px.copy_from_slice(&backdrop.px); cx.apply_adjustment(adj, &mut adjusted); // Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result. @@ -420,12 +449,16 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: } } } else { + // Fill below 100% in one of the special eight changes the blend itself and leaves + // coverage to Opacity, as for a pixel layer (psblend::composite_fill, #9501). + let (k, fill) = + if layer.fill_opacity < 1.0 && psblend::fill_is_special(layer.blend) { (layer.opacity, layer.fill_opacity) } else { (opacity, 1.0) }; 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), + Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| k * v), layer.blend, fill), + None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill), } } - recycle_buffer(adjusted); + recycle_mask(mv); return; } @@ -439,6 +472,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: if let (Some(m), Some(b)) = (&hide, &before) { advanced::apply_hiding_mask(backdrop, b, m); } + recycle_mask(hide); return; } if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) { @@ -448,6 +482,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: p[3] *= mask_k(&mask, i); } blend_layer_into(backdrop, &content, layer, cx, 1.0); + recycle_mask(mask); return; } if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) @@ -482,7 +517,6 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: let Some(mut content) = render_content(layer, rect, cx) else { return }; advanced::composite_clipped(clipped, &mut content, cx); blend_layer_into(backdrop, &content, layer, cx, text_gamma(layer)); - recycle_buffer(content); } /// A layer with effects (and its clipping group) onto `backdrop`. @@ -491,7 +525,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff // Neighbourhoods are already captured by the full-region effect maps; // content and effect application only need the output rectangle. // A stroked shape's vector stroke stays above its clipped layers and interior effects. - let (mut content, vstroke) = match split_parts(layer, rect, cx) { + let (mut content, parts) = match split_parts(layer, rect, cx) { Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), None => { let Some(content) = render_content(layer, rect, cx) else { return }; @@ -499,9 +533,12 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff } }; advanced::composite_clipped(clipped, &mut content, cx); - let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); + let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); + if let Some((_, mask)) = parts { + recycle_mask(mask); + } } /// The coverage-mixing gamma of a layer: the text blending gamma @@ -575,6 +612,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { p[c] += (bl[c] - p[c]) * k; } } + recycle_mask(mv); return; } if effects::has_effects(layer) { @@ -585,21 +623,28 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let hide = advanced::hiding_mask(layer, rect, cx); let src = advanced::effects_source(layer); let layer = src.as_ref(); - let (content, vstroke) = match split_parts(layer, rect, cx) { + let (content, parts) = match split_parts(layer, rect, cx) { Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), None => { - let Some(content) = render_content(layer, rect, cx) else { return }; + let Some(content) = render_content(layer, rect, cx) else { + recycle_mask(hide); + return; + }; (content, None) } }; - let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); + let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, rect, cx); effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); + if let Some((_, mask)) = parts { + recycle_mask(mask); + } if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; advanced::apply_hiding_mask(&mut opaque, &plain, m); } + recycle_mask(hide); for (p, o) in base.px.iter_mut().zip(&opaque.px) { if p[3] > 0.0 { *p = [o[0], o[1], o[2], p[3]]; @@ -659,6 +704,7 @@ fn blend_layer_into(backdrop: &mut Buffer, src: &Buffer, layer: &Layer, cx: &Ctx let alpha = (s[3] / m).clamp(0.0, 1.0); *b = psblend::composite_fill(mode, *b, [s[0], s[1], s[2], m], opacity, fill * alpha, gamma); } + recycle_mask(mv); } /// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for diff --git a/crates/compose/src/content.rs b/crates/compose/src/content.rs index ea868d715e..1f91c6a942 100644 --- a/crates/compose/src/content.rs +++ b/crates/compose/src/content.rs @@ -1,63 +1,29 @@ use super::composite::{adjustment_quantum, blend_into, composite_stack}; use super::ctx::Ctx; -use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside}; +use super::extents::{effect_shape, effect_source_bounds, mask_vals, recycle_mask, shapeless_stroke_bounds, transparent_outside}; use super::fx_cache::effect_maps; use super::{BlendMode, Buffer, Document, Fill, Layer, LayerContent, Pattern, Rect, Surface, bounds, effects, gradient_fill, pattern, shape_split}; /// Tile size for parallel rendering (tile results are independent). pub const RENDER_TILE: i32 = 256; -// One tile of scratch per worker; nested rendering takes its own buffer. -thread_local! { - static SCRATCH: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; -} - -pub(crate) fn scratch_buffer(rect: Rect) -> Buffer { - if rect.width() as usize * rect.height() as usize > (RENDER_TILE * RENDER_TILE) as usize { - return Buffer::transparent(rect); - } - let mut px = SCRATCH.with(|slot| std::mem::take(&mut *slot.borrow_mut())); - px.resize(rect.width() as usize * rect.height() as usize, [0.0; 4]); - Buffer { rect, px } -} - -pub(crate) fn recycle_buffer(buf: Buffer) { - if buf.px.capacity() <= (RENDER_TILE * RENDER_TILE) as usize { - SCRATCH.with(|slot| { - let mut slot = slot.borrow_mut(); - if slot.capacity() < buf.px.capacity() { - *slot = buf.px; - } - }); - } -} - #[cfg(test)] #[test] -fn scratch_reuse_preserves_pixels_and_bounds_retention() { - SCRATCH.with(|slot| slot.borrow_mut().clear()); - let rect = Rect::new(-2, -3, 18, 11); +fn scratch_reuse_preserves_pixels() { + // 256 × 256 pixels of [f32; 4] (1 MiB): over `MIN_POOLED_BYTES`, so the pool keeps it. + let rect = Rect::new(-2, -3, 254, 253); for fmt in [photocraft_color::PixelFormat::RGBA8, photocraft_color::PixelFormat::RGBA16, photocraft_color::PixelFormat::RGBA32F] { let s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.8]); - let buf = surface_to_scratch(&s, rect); + let mut buf = surface_to_scratch(&s, rect); assert_eq!(buf, surface_to_buffer(&s, rect)); - let ptr = buf.px.as_ptr(); - recycle_buffer(buf); - let reused = surface_to_scratch(&Surface::new(fmt), rect); - assert_eq!(reused.px.as_ptr(), ptr); + buf.px.fill([1.0; 4]); + // A dropped buffer goes back to the pool; the next one must come out cleared. + drop(buf); + let reused = Buffer::transparent(rect); assert!(reused.px.iter().all(|p| p == &[0.0; 4])); - let nested = scratch_buffer(rect); + let nested = Buffer::transparent(rect); assert_ne!(nested.px.as_ptr(), reused.px.as_ptr()); - recycle_buffer(nested); - recycle_buffer(reused); } - let cached = SCRATCH.with(|slot| slot.borrow().as_ptr()); - let huge = scratch_buffer(Rect::new(0, 0, RENDER_TILE + 1, RENDER_TILE)); - recycle_buffer(huge); - SCRATCH.with(|slot| { - assert_eq!(slot.borrow().as_ptr(), cached); - assert!(slot.borrow().capacity() <= (RENDER_TILE * RENDER_TILE) as usize); - }); } /// Render a layer's own content (no blending into the backdrop yet) into an isolated buffer. @@ -93,6 +59,7 @@ pub(crate) fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option Buffer { - let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize]; - s.read_rgba_into(rect, &mut px); - Buffer { rect, px } + let mut buf = Buffer::transparent(rect); + s.read_rgba_into(rect, &mut buf.px); + buf } fn surface_to_scratch(s: &Surface, rect: Rect) -> Buffer { - let mut buf = scratch_buffer(rect); + let mut buf = Buffer::transparent(rect); s.read_rgba_into(rect, &mut buf.px); buf } diff --git a/crates/compose/src/effects/bevel.rs b/crates/compose/src/effects/bevel.rs index 6d2352622d..ea07fbd709 100644 --- a/crates/compose/src/effects/bevel.rs +++ b/crates/compose/src/effects/bevel.rs @@ -108,8 +108,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern || (), |_, y, row, _| { for (x, o) in row.iter_mut().enumerate() { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); + let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; *o += k * if t.invert { 1.0 - l } else { l }; } diff --git a/crates/compose/src/effects/paint.rs b/crates/compose/src/effects/paint.rs index 6bd78df7de..99b0c6aa0b 100644 --- a/crates/compose/src/effects/paint.rs +++ b/crates/compose/src/effects/paint.rs @@ -339,8 +339,7 @@ pub(super) fn paint_pattern( m, |i| { let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5); - let (u, v) = place.map(x, y); - tile.sample(u, v) + tile.sample_area(&place, x, y) }, blend, opacity, diff --git a/crates/compose/src/extents.rs b/crates/compose/src/extents.rs index d895f14bc9..7e4f8d234e 100644 --- a/crates/compose/src/extents.rs +++ b/crates/compose/src/extents.rs @@ -134,6 +134,7 @@ pub(crate) fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec { out[i] = v * mask_k(&mv, i); } } + recycle_mask(mv); out } @@ -283,13 +284,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option> if masks::has_feather(layer) && let Some(s) = masks::combined_mask(layer, cx.canvas) { - let mut v = Vec::new(); + let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize); s.read_region_into(rect, &mut v); return Some(v); } let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect)); let Some(m) = layer.mask.as_ref() else { return vector }; - let mut v = Vec::new(); + let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize); m.values_into(rect, &mut v); if let Some(vm) = vector { for (a, b) in v.iter_mut().zip(vm) { @@ -299,6 +300,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option> Some(v) } +/// Hand a [`mask_vals`] result back to the scratch pool once its tile is done. +pub(crate) fn recycle_mask(m: Option>) { + if let Some(v) = m { + photocraft_raster::scratch::give(v); + } +} + #[inline] pub(crate) fn mask_k(m: &Option>, i: usize) -> f32 { m.as_ref().map_or(1.0, |v| v[i]) diff --git a/crates/compose/src/fx_cache.rs b/crates/compose/src/fx_cache.rs index 43e524d37f..b24aabc29b 100644 --- a/crates/compose/src/fx_cache.rs +++ b/crates/compose/src/fx_cache.rs @@ -122,6 +122,21 @@ pub fn purge_effect_cache() -> usize { freed } +/// Discard derived pixels and their source pins once after each new scratch read failure. +/// This lets closing the affected document release its failed tiles instead of keeping them +/// in a cache. Caches never admit results built from unreadable tiles, so healthy documents' +/// caches are not purged again on every call while one document stays unreadable. +pub fn discard_unreadable_pixel_caches() { + static SEEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let generation = photocraft_raster::spill::read_error_generation(); + if SEEN.swap(generation, std::sync::atomic::Ordering::AcqRel) != generation && photocraft_raster::spill::any_failed() { + purge_effect_cache(); + super::bounds::purge(); + super::masks::purge(); + super::shape_split::purge(); + } +} + fn fx_cache() -> &'static std::sync::Mutex { static C: std::sync::OnceLock> = std::sync::OnceLock::new(); C.get_or_init(|| std::sync::Mutex::new(FxCache { map: Default::default(), order: Default::default(), bytes: 0 })) @@ -218,13 +233,22 @@ pub(crate) fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc } fn build_fx_slot_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx, slot: &FxSlot) -> std::sync::Arc { + let generation = photocraft_raster::spill::read_error_generation(); let shape: Vec = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns); let mut pin = Vec::new(); - layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t))); + let mut readable = true; + layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| { + readable &= t.scratch_health().is_ok(); + pin.push(std::sync::Arc::downgrade(t)); + }); // The maps, plus what each reserved tile address keeps allocated. let bytes = maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::() + 2 * std::mem::size_of::())); let maps = std::sync::Arc::new(maps); + if !readable || photocraft_raster::spill::check_since(generation).is_err() { + // Built from a scratch placeholder: use it for this frame, never cache it. + return maps; + } if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() { // Counted exactly once, by the build that filled the slot, and only while the // slot is still cached (eviction may have dropped it meanwhile). diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e593290571..2af1c2e8fb 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -33,6 +33,7 @@ pub mod proxy; pub mod psblend; pub mod shape_split; mod uniform; +pub mod viewport; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; @@ -68,7 +69,9 @@ pub use extents::{ }; #[cfg(test)] use extents::{mask_k, shapeless_stroke_bounds, trim_to_painted}; -pub use fx_cache::{DEFAULT_FX_CACHE_BUDGET, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget}; +pub use fx_cache::{ + DEFAULT_FX_CACHE_BUDGET, discard_unreadable_pixel_caches, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget, +}; #[cfg(test)] use fx_cache::{effect_maps, effect_region, fx_region_bytes}; pub use reduced::{PROXY_THUMBNAIL_PIXELS, render_reduced, render_reduced_damage, render_reduced_rect, thumbnail, thumbnail_buffer, thumbnail_size}; @@ -84,18 +87,39 @@ pub use render::{ }; /// Straight-alpha RGBA float buffer covering a rectangle. -#[derive(Clone, Debug, PartialEq)] +/// +/// The pixel storage comes from, and goes back to, the scratch pool. +#[derive(Debug, PartialEq)] pub struct Buffer { pub rect: Rect, pub px: Vec<[f32; 4]>, } +impl Clone for Buffer { + fn clone(&self) -> Self { + let mut px = photocraft_raster::scratch::take(self.px.len(), [0.0; 4]); + px.copy_from_slice(&self.px); + Self { rect: self.rect, px } + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + photocraft_raster::scratch::give(std::mem::take(&mut self.px)); + } +} + impl Buffer { pub fn transparent(rect: Rect) -> Self { - Self { rect, px: vec![[0.0; 4]; rect.width() as usize * rect.height() as usize] } + Self::filled(rect, [0.0; 4]) } pub fn filled(rect: Rect, c: [f32; 4]) -> Self { - Self { rect, px: vec![c; rect.width() as usize * rect.height() as usize] } + Self { rect, px: photocraft_raster::scratch::take(rect.width() as usize * rect.height() as usize, c) } + } + /// The pixels, taken out of the buffer (it can't be destructured: it returns them to the + /// pool on drop). + pub fn into_px(mut self) -> Vec<[f32; 4]> { + std::mem::take(&mut self.px) } #[inline] pub fn get(&self, x: i32, y: i32) -> [f32; 4] { diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index 851c9bc509..faf45a19d0 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -81,6 +81,12 @@ fn cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(Cache::default())) } +pub(crate) fn purge() { + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + c.clear(); + c.in_flight.clear(); +} + /// Entries kept (least recently used dropped first). const CAPACITY: usize = 64; @@ -116,7 +122,11 @@ fn lookup(c: &Mutex, k: u64) -> CachedMask { fn finish_mask(c: &Mutex, k: u64, slot: &Arc>, generation: u64, build: impl FnOnce() -> (Surface, Option)) -> Surface { slot.get_or_init(|| { // Never hold the global cache mutex while rasterizing or feathering. + let read_generation = photocraft_raster::spill::read_error_generation(); let (surface, pixel_input) = build(); + if photocraft_raster::spill::check_since(read_generation).is_err() || pixel_input.as_ref().is_some_and(|p| p.scratch_health().is_err()) { + return surface; + } let bytes = entry_bytes(&surface, pixel_input.as_ref()); let mut c = c.lock().unwrap_or_else(|e| e.into_inner()); if c.in_flight.get(&k).and_then(Weak::upgrade).is_some_and(|active| Arc::ptr_eq(&active, slot)) { @@ -135,7 +145,7 @@ fn cached_mask(c: &Mutex, k: u64, build: impl FnOnce() -> (Surface, Optio } fn surface_bytes(surface: &Surface) -> usize { - surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.bytes().len())) + surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.len())) } fn entry_bytes(surface: &Surface, pixel_input: Option<&Surface>) -> usize { diff --git a/crates/compose/src/masks/tests.rs b/crates/compose/src/masks/tests.rs index b6aa42fe1f..9076770913 100644 --- a/crates/compose/src/masks/tests.rs +++ b/crates/compose/src/masks/tests.rs @@ -137,6 +137,36 @@ fn parallel_feathered_pixel_mask_returns_the_same_tiles() { assert!(outputs.iter().all(|surface| Arc::ptr_eq(surface.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1))); } +/// A feather reaching the canvas edge sees the mask continue past it with its edge values, as +/// in Photoshop (#9475): a rectangle near the edge feathers the same on a taller canvas holding +/// the same mask (clamping each blur pass at the edge brightened it by up to 12 levels at +/// 32 px), and a mask white up to the edge stays white along it. +#[test] +fn feather_at_the_canvas_edge_continues_the_mask_past_it() { + let mut layer = Layer::raster("edge", PixelFormat::RGBA8); + let mut mask = photocraft_doc::LayerMask::hide_all(); + mask.surface.fill_rect(Rect::new(40, 20, 120, 60), &[1.0]); + mask.feather = 32.0; + layer.mask = Some(mask); + let canvas = Rect::new(0, 0, 160, 80); + let tight = combined_mask(&layer, canvas).unwrap().read_region(canvas); + let tall = combined_mask(&layer, Rect::new(0, -200, 160, 280)).unwrap().read_region(canvas); + let worst = tight.iter().zip(&tall).map(|(a, b)| (a - b).abs()).fold(0.0_f32, f32::max); + assert!(worst < 1e-4, "the canvas edge changed the feather by {worst}"); + + let mut half = photocraft_doc::LayerMask::hide_all(); + half.surface.fill_rect(Rect::new(0, 0, 80, 80), &[1.0]); + half.feather = 32.0; + layer.mask = Some(half); + let m = combined_mask(&layer, canvas).unwrap().read_region(canvas); + // Photoshop: every row the same, no darkening towards the top or bottom edge. + let middle = &m[40 * 160..41 * 160]; + for (y, row) in m.as_chunks::<160>().0.iter().enumerate() { + assert!(row.iter().zip(middle).all(|(a, b)| (a - b).abs() < 1e-5), "row {y} differs from row 40"); + } + assert!(middle[0] > 0.99, "the left edge stays white: {}", middle[0]); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn vector_mask_build_inside_a_small_rayon_pool_completes() { diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b66ab0ad1e..b351bdef06 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -3,19 +3,32 @@ //! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas //! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with //! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile -//! exactly and scaled ones stay seamless. +//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an +//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a +//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType}; -pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20; +/// What one rendering call may keep of its patterns' working copies: every pattern it paints with +/// stays converted for the call (four of the largest 8-bit ones on desktop), so none is converted +/// once per output tile. A ceiling, not an allocation: only the patterns a call paints with are +/// converted. Past it a pattern is converted under one lock and shared while in use (see +/// [`PreparedPatterns::get`]). The web build keeps a quarter of that. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const PREPARED_PATTERN_BYTES: usize = 1 << 30; +/// See the desktop value above. +#[cfg(target_arch = "wasm32")] +pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 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; +/// Absolute cap on a single pattern's working copy on the uncached path: the document's own cap +/// on what paints (`Pattern::is_paintable`). A pattern above it is skipped, not converted. +const MAX_UNCACHED_PATTERN_BYTES: usize = photocraft_doc::pattern::MAX_WORKING_BYTES as usize; +// A call keeps at least two of the largest, or a second large fill is converted for every tile. +const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); type PreparedSlot = Arc>>>; struct PreparedState { @@ -23,6 +36,15 @@ struct PreparedState { reserved_bytes: usize, } +/// Patterns past the call's budget: converted one at a time under this lock, and shared by the +/// workers using one at the same moment (a `Weak` per pattern), so the extra copies in memory are +/// the patterns in use, not one per worker. +#[derive(Default)] +struct OverBudget { + live: Mutex>>, + warned: AtomicBool, +} + /// Demand-prepared pixels owned by one rendering call, never by the document. pub(crate) struct PreparedPatterns<'a> { patterns: &'a [Pattern], @@ -30,11 +52,18 @@ pub(crate) struct PreparedPatterns<'a> { /// The colour mode the patterns are painted into (see [`pattern_rgba`]). mode: ColorMode, state: Mutex, + over_budget: OverBudget, } impl<'a> PreparedPatterns<'a> { pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self { - Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) } + Self { + patterns, + budget, + mode: ColorMode::Rgb, + state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }), + over_budget: OverBudget::default(), + } } /// Patterns painted into a document of colour mode `mode`. @@ -52,10 +81,7 @@ impl<'a> PreparedPatterns<'a> { if !p.is_paintable() { return None; } - let bytes = usize::try_from(p.width) - .ok() - .and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h))) - .and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>())); + let bytes = usize::try_from(p.working_bytes()).ok(); // The borrowed immutable slice pins these addresses for the complete call. let key = std::ptr::from_ref(p) as usize; let slot = { @@ -82,14 +108,40 @@ 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. + // Uncached path: a pattern whose working copy would exceed an absolute cap is not + // materialized at all. One past the call's budget is converted under one lock, and + // shared while some worker still holds it, so workers painting tiles at the same + // time do not each convert their own copy (CPU and memory), and it says so once. + // 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), + None => { + // The lock is held through the conversion, so conversions of different + // over-budget patterns run one at a time. That is the point (one extra copy + // being built at once, not one per worker) and fine for a fallback: patterns + // that fit the budget never come here. + let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(shared) = live.get(&key).and_then(Weak::upgrade) { + return Some(shared); + } + if !self.over_budget.warned.swap(true, Ordering::Relaxed) { + // Written, not printed: a warning must never panic, even with no stderr. + #[cfg(not(target_arch = "wasm32"))] + { + use std::io::Write as _; + let _ = writeln!( + std::io::stderr(), + "photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it", + p.display_name(), + self.budget >> 20 + ); + } + } + let tile = Tile::new_in(p, self.mode).map(Arc::new)?; + live.retain(|_, w| w.strong_count() > 0); + live.insert(key, Arc::downgrade(&tile)); + Some(tile) + } } } } @@ -101,29 +153,80 @@ impl<'a> PreparedPatterns<'a> { /// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't /// [paintable](Pattern::is_paintable). pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> { - let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else { - return Vec::new(); - }; - let mut px = vec![[0.0f32; 4]; n]; - p.surface.read_rgba_into(p.rect(), &mut px); - let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); - if paints_in && p.surface.format().mode != mode { - let fmt = PixelFormat::new(mode, SampleType::F32, true); - let mut v = [0.0f32; 5]; - for q in &mut px { - let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); - *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); - } + let mut px = Vec::new(); + if p.is_paintable() && pattern_rgba_rows(p, mode, 0, p.height, &mut px).is_none() { + px = Vec::new(); } px } -/// A pattern converted once to straight-alpha RGBA for sampling. +/// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large +/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. `None` when the +/// memory for the rows cannot be had (on the web a failed allocation aborts the tab, so it is +/// asked for, not assumed). +pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) -> Option<()> { + let bottom = bottom.min(p.height); + let top = top.min(bottom); + out.clear(); + let pixels = (p.width as usize).checked_mul((bottom - top) as usize)?; + out.try_reserve_exact(pixels).ok()?; + out.resize(pixels, [0.0f32; 4]); + p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out); + let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk); + if paints_in && p.surface.format().mode != mode { + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let mut v = [0.0f32; 5]; + for q in out.iter_mut() { + let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v); + *q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default()); + } + } + Some(()) +} + +/// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded +/// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size +/// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that +/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. `None` +/// when the memory cannot be had. +fn quantized_rgba(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Option> { + let mut out = Vec::new(); + out.try_reserve_exact((p.width as usize).checked_mul(p.height as usize)?).ok()?; + let mut band = Vec::new(); + for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band)?; + out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])])); + } + Some(out) +} + +/// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as. +const UNIT8: [f32; 256] = { + let mut table = [0.0f32; 256]; + let mut i = 0; + while i < 256 { + table[i] = i as f32 / 255.0; + i += 1; + } + table +}; + +/// A pattern's pixels as the sampler holds them. +enum Texels { + /// Premultiplied `f32` RGBA, 16 bytes per pixel: any pattern that fits. + Premultiplied(Vec<[f32; 4]>), + /// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): a + /// large 8-bit pattern, with the values the expanded copy would hold. + Rgba8(Vec<[u8; 4]>), + /// Straight RGBA16, 8 bytes per pixel, premultiplied as each tap reads it: a large 16-bit one. + Rgba16(Vec<[u16; 4]>), +} + +/// A pattern converted once for sampling. pub struct Tile { w: usize, h: usize, - /// Premultiplied RGBA. - px: Vec<[f32; 4]>, + texels: Texels, } impl Tile { @@ -137,26 +240,83 @@ impl Tile { if !p.is_paintable() { return None; } + Tile::build(p, mode, p.pixel_count().saturating_mul(16) > photocraft_doc::pattern::MAX_WORKING_BYTES) + } + + /// [`Tile::new_in`], held at the pattern's own depth when `compact` and it has a smaller form. + fn build(p: &Pattern, mode: ColorMode, compact: bool) -> Option { let (w, h) = (p.width as usize, p.height as usize); - let mut px = pattern_rgba(p, mode); - for q in &mut px { - for c in 0..3 { - q[c] *= q[3]; + let texels = match p.surface.format().sample { + // A large pattern is held at its own depth (`Pattern::working_bytes`). + SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)?), + SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)?), + _ => { + let mut px = pattern_rgba(p, mode); + if px.len() as u64 != p.pixel_count() { + return None; + } + for q in &mut px { + for c in 0..3 { + q[c] *= q[3]; + } + } + Texels::Premultiplied(px) } - } - Some(Tile { w, h, px }) + }; + Some(Tile { w, h, texels }) } #[inline] fn at(&self, x: i64, y: i64) -> [f32; 4] { let xi = x.rem_euclid(self.w as i64) as usize; let yi = y.rem_euclid(self.h as i64) as usize; - self.px[yi * self.w + xi] + let i = yi * self.w + xi; + match &self.texels { + Texels::Premultiplied(px) => px.get(i).copied().unwrap_or_default(), + Texels::Rgba8(px) => { + let [r, g, b, a] = px.get(i).copied().unwrap_or_default(); + let a = UNIT8[usize::from(a)]; + [UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a] + } + Texels::Rgba16(px) => { + let [r, g, b, a] = px.get(i).copied().unwrap_or_default(); + let unit = |v: u16| f32::from(v) / 65535.0; + let a = unit(a); + [unit(r) * a, unit(g) * a, unit(b) * a, a] + } + } } /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). /// Returns straight alpha. pub fn sample(&self, u: f64, v: f64) -> [f32; 4] { + unpremultiply(self.sample_premul(u, v)) + } + + /// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's + /// footprint in the tile when the placement shrinks it. Returns straight alpha. + pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] { + let n = pixel_taps(place.inv_scale); + if n == 1 { + let (u, v) = place.map(x, y); + return self.sample(u, v); + } + let mut acc = [0.0f32; 4]; + for j in 0..n { + for i in 0..n { + let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n)); + let q = self.sample_premul(u, v); + for c in 0..4 { + acc[c] += q[c]; + } + } + } + let k = 1.0 / (n * n) as f32; + unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k]) + } + + /// Bilinear, wrapping sample, premultiplied. + fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] { let (u, v) = (u - 0.5, v - 0.5); let (x0, y0) = (u.floor(), v.floor()); let (fx, fy) = ((u - x0) as f32, (v - y0) as f32); @@ -177,13 +337,32 @@ impl Tile { } } } - if acc[3] <= 0.0 { - return [0.0; 4]; - } - [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] + acc } } +/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). +const MAX_TAPS: usize = 8; + +/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers +/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. +fn pixel_taps(inv_scale: f64) -> usize { + let s = inv_scale as f32; + if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 } +} + +/// Offset in document pixels of tap `i` of `n`, centred on the pixel. +fn tap_offset(i: usize, n: usize) -> f64 { + (i as f64 + 0.5) / n as f64 - 0.5 +} + +fn unpremultiply(acc: [f32; 4]) -> [f32; 4] { + if acc[3] <= 0.0 { + return [0.0; 4]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] +} + /// Where a pattern sits: document point → tile coordinates. #[derive(Clone, Copy, Debug)] pub struct Placement { @@ -234,8 +413,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> { let mut out = 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 (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5); - out.push(tile.sample(u, v)); + out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5)); } } out @@ -282,6 +460,114 @@ mod tests { assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none()); } + /// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands. A pattern that + /// fits is expanded as before; a large one is held at its own depth, so Define Pattern's + /// 8000 x 8000 fits at 8 bits. (Only the size is declared: the patterns themselves are empty.) + #[test] + fn the_most_paintable_pixels_still_expand() { + let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern { + id: name.into(), + name: name.into(), + width, + height, + surface: Surface::new(format), + }; + let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let list = [ + sparse("max8", (8000, 8000), PixelFormat::RGBA8), + sparse("max16", (5792, 5792), PixelFormat::RGBA16), + sparse("max32", (4096, 4096), float), + sparse("over8", (9000, 8000), PixelFormat::RGBA8), + sparse("over16", (8000, 8000), PixelFormat::RGBA16), + sparse("over32", (4097, 4096), float), + ]; + assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, true, false, false, false]); + let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES); + let held = |name: &str| prepared.get(name, "").map(|t| (matches!(t.texels, Texels::Rgba8(_)), matches!(t.texels, Texels::Rgba16(_)))); + assert_eq!(held("max8"), Some((true, false)), "an 8-bit 8000 x 8000 pattern expands to 4 bytes a pixel"); + assert_eq!(held("max16"), Some((false, true)), "a 16-bit one to 8"); + for name in ["over8", "over16", "over32"] { + assert!(prepared.get(name, "").is_none(), "{name} is skipped, not allocated"); + } + assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap"); + } + + /// A render that paints with two large patterns (a pattern fill and a pattern overlay, say) + /// converts each once, not once per output tile: both stay in the call's budget. A third that + /// does not fit is converted when asked for (correct, just not kept). + #[test] + fn two_large_patterns_are_each_converted_once_per_call() { + let sparse = |name: &str| Pattern { id: name.into(), name: name.into(), width: 5000, height: 5000, surface: Surface::new(PixelFormat::RGBA8) }; + let list = [sparse("a"), sparse("b"), sparse("c")]; + assert!(list.iter().all(|p| p.working_bytes() == 100_000_000)); + let prepared = PreparedPatterns::new(&list, 250_000_000); + let same = |name: &str| { + let (first, second) = (prepared.get(name, "").unwrap(), prepared.get(name, "").unwrap()); + Arc::ptr_eq(&first, &second) + }; + assert!(same("a"), "the first is kept"); + assert!(same("b"), "and the second"); + // A third past the budget is converted under one lock and shared while a worker holds it, + // so concurrent tiles use one copy; it is converted again once nobody holds it. + assert!(same("c"), "shared while in use"); + let held = prepared.get("c", "").unwrap(); + let other = prepared.get("c", "").unwrap(); + assert!(Arc::ptr_eq(&held, &other), "two workers at once get one copy"); + drop((held, other)); + assert!(prepared.get("c", "").is_some(), "and it converts again after"); + // The real budget holds two of the largest paintable ones. + assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64); + } + + /// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit + /// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers. + #[test] + fn the_compact_copies_read_the_same_as_the_expanded_one() { + for v in 0..=255u8 { + assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v); + assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0); + } + for v in 0..=u16::MAX { + assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v); + } + for (format, grid) in [(PixelFormat::RGBA8, 255.0f32), (PixelFormat::RGBA16, 65535.0)] { + let mut s = Surface::new(format); + for y in 0..16i32 { + for x in 0..16i32 { + let level = |v: i32| (v as f32 / 255.0 * grid).round() / grid; + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[level(x * 17), level(y * 17), level((x + y) * 8), level(255 - x * 13 - y * 3)]); + } + } + let p = Pattern::new("ramp", s, 16, 16); + let (expanded, compact) = (Tile::build(&p, ColorMode::Rgb, false).unwrap(), Tile::build(&p, ColorMode::Rgb, true).unwrap()); + assert!(matches!(expanded.texels, Texels::Premultiplied(_))); + assert!(matches!((&compact.texels, grid as u32), (Texels::Rgba8(_), 255) | (Texels::Rgba16(_), 65535))); + for y in 0..16 { + for x in 0..16 { + assert_eq!(compact.at(x, y), expanded.at(x, y), "{grid} ({x}, {y})"); + } + } + // And rendered, scaled, rotated and shifted across the wrap. + let place = Placement::new(Rect::new(3, 5, 40, 40), true, (2.0, 1.0), 0.6, 20.0); + assert_eq!(render(&compact, &place, Rect::new(-9, -7, 30, 25)), render(&expanded, &place, Rect::new(-9, -7, 30, 25))); + } + } + + /// Memory for the rows is asked for, not assumed: a pattern whose rows cannot be had (here a + /// declared size past what any allocation could be) gives `None`, never an aborted allocation. + #[test] + fn rows_that_cannot_be_allocated_give_none() { + let huge = Pattern { id: "huge".into(), name: "huge".into(), width: u32::MAX, height: u32::MAX, surface: Surface::new(PixelFormat::RGBA8) }; + let mut out = Vec::new(); + assert!(pattern_rgba_rows(&huge, ColorMode::Rgb, 0, u32::MAX, &mut out).is_none()); + assert!(quantized_rgba(&huge, ColorMode::Rgb, |v| v as u8).is_none()); + assert!(pattern_rgba(&huge, ColorMode::Rgb).is_empty()); + assert!(Tile::new(&huge).is_none()); + let small = Pattern { id: "small".into(), name: "small".into(), width: 3, height: 2, surface: Surface::new(PixelFormat::RGBA8) }; + assert_eq!(pattern_rgba_rows(&small, ColorMode::Rgb, 0, 2, &mut out), Some(())); + assert_eq!(out.len(), 6); + } + #[test] fn link_phase_scale_and_angle_move_the_origin() { let t = Tile::new(&checker()).unwrap(); @@ -299,6 +585,29 @@ mod tests { assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + #[test] + fn minified_pattern_is_filtered_not_aliased() { + // 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels. + // The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single + // bilinear tap would return pure white. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0); + let (u, _) = place.map(0.5, 0.5); + assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe"); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); + } + // 100 % stays exact. + let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0); + let row = render(&t, &one, Rect::new(0, 0, 2, 1)); + assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/compose/src/reduced.rs b/crates/compose/src/reduced.rs index 1249df6437..e342beedb0 100644 --- a/crates/compose/src/reduced.rs +++ b/crates/compose/src/reduced.rs @@ -2,6 +2,7 @@ use super::content::RENDER_TILE; use super::ctx::Ctx; use super::render::{band_rows_for, render, render_bands, render_tiled_with}; use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform}; +use photocraft_raster::Interrupt; /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. @@ -101,23 +102,66 @@ pub(crate) fn render_reduced_with( uniform: bool, stats: &mut ReducedStats, ) -> Buffer { + match render_reduced_cells(doc, b, Cells::Fit(w, h), damage, band_rows, uniform, stats, Interrupt::NONE) { + Ok(buf) => buf, + // Only an interrupt fails a reduction, and this one has none. + Err(_) => Buffer::transparent(Rect::EMPTY), + } +} + +/// How a reduced render maps document pixels of its area to output pixels. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Cells { + /// `w`×`h` output pixels spread evenly over the area ([`render_reduced`]). + Fit(u32, u32), + /// Output pixel `t` covers the area's pixels `[t·k, (t+1)·k)`, clipped to the area: a + /// globally aligned reduction by `k` when the area starts on a multiple of `k` + /// ([`crate::viewport::render_page`]). + Factor(u32), +} + +/// [`render_reduced_with`] on a cell grid, checking `interrupt` between bands. +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_reduced_cells( + doc: &Document, + b: Rect, + cells: Cells, + damage: Option, + band_rows: i32, + uniform: bool, + stats: &mut ReducedStats, + interrupt: Interrupt<'_>, +) -> Result { let (fw, fh) = (b.width() as usize, b.height() as usize); - let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize); + let (w, h, k) = match cells { + Cells::Fit(w, h) => (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize, None), + Cells::Factor(k) => { + let k = k.max(1); + (b.width().div_ceil(k).max(1) as usize, b.height().div_ceil(k).max(1) as usize, Some(k as usize)) + } + }; let full = Rect::from_xywh(0, 0, w as u32, h as u32); if fw == 0 || fh == 0 { - return Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full }); + return Ok(Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full })); } - // Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n). - let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)); + // Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n) + // (or [t·k, (t+1)·k) on a factor grid). + let span = |t: usize, f: usize, n: usize| match k { + Some(k) => ((t * k).min(f), ((t + 1) * k).min(f)), + None => (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)), + }; // The output pixel whose span holds document column / row `x` (spans are contiguous: n <= f). - let cell = |x: usize, f: usize, n: usize| ((x + 1) * n).saturating_sub(1) / f; + let cell = |x: usize, f: usize, n: usize| match k { + Some(k) => x / k, + None => ((x + 1) * n).saturating_sub(1) / f, + }; // Output pixels to produce and the document area they cover. let out = match damage { None => full, Some(d) => { let d = d.intersect(&b); if d.is_empty() { - return Buffer::transparent(Rect::EMPTY); + return Ok(Buffer::transparent(Rect::EMPTY)); } let (x0, x1) = ((d.x0 - b.x0) as usize, (d.x1 - b.x0) as usize); let (y0, y1) = ((d.y0 - b.y0) as usize, (d.y1 - b.y0) as usize); @@ -131,7 +175,7 @@ pub(crate) fn render_reduced_with( if (w, h) == (fw, fh) { let mut px = render(doc, src); px.rect = out; - return px; + return Ok(px); } let map = |f: usize, n: usize| { let mut m = vec![0u32; f]; @@ -200,6 +244,7 @@ pub(crate) fn render_reduced_with( let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize; let mut y = grid(sy0, b.y0); while y < sy1 as i64 { + interrupt.check().map_err(|e| e.to_string())?; let y1 = (y + band_h as i64).min(sy1 as i64); let tiles: Vec<(Rect, Rect)> = (y..y1) .step_by(t) @@ -255,7 +300,8 @@ pub(crate) fn render_reduced_with( y = y1; } } else { - let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { + render_bands(doc, src, band_rows, |band| -> Result<(), String> { + interrupt.check().map_err(|e| e.to_string())?; stats.composited += band.px.len() as u64; let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); let sw = sx1 - sx0; @@ -265,8 +311,7 @@ pub(crate) fn render_reduced_with( } }); Ok(()) - }); - debug_assert!(ok.is_ok()); + })?; } let col_w: Vec = (0..ow) .map(|x| { @@ -296,5 +341,6 @@ pub(crate) fn render_reduced_with( for (row, out) in acc.chunks_mut(ow).enumerate() { norm_row(row, out); } - Buffer { rect: out, px: acc } + interrupt.check().map_err(|e| e.to_string())?; + Ok(Buffer { rect: out, px: acc }) } diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index b739d3f42a..250a3ebce1 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -229,14 +229,12 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P let mut s = Surface::new(fmt); let n = fmt.channels(); let empty = s.default_pixel(); - let mut data = Vec::new(); let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> { let band = match background { Some(bg) => band.over_background(bg), None => band, }; - data.clear(); - data.resize(band.px.len() * n, 0.0); + let mut data = photocraft_raster::scratch::take(band.px.len() * n, 0.0f32); photocraft_color::convert::with_cmyk_space(space.as_ref(), || { for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n { @@ -247,6 +245,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P } }); s.write_region(band.rect, &data); + photocraft_raster::scratch::give(data); Ok(()) }); s.prune(); diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index c6e76fb0b2..53875d5922 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -21,6 +21,10 @@ fn cache() -> &'static Mutex<(HashMap, u64)> { C.get_or_init(|| Mutex::new((HashMap::new(), 0))) } +pub(crate) fn purge() { + cache().lock().unwrap_or_else(|e| e.into_inner()).0.clear(); +} + const CAPACITY: usize = 32; /// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces, /// so 32 of them at 24 MP could hold several GB outside every memory budget. @@ -32,7 +36,7 @@ const BUDGET: usize = 8 << 20; /// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's). fn entry_bytes(parts: &(Surface, Surface)) -> usize { - [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::()).sum() + [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.len()).sum::()).sum() } /// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may @@ -49,6 +53,7 @@ pub fn purge_cache() -> usize { /// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is /// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects). pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> { + let generation = photocraft_raster::spill::read_error_generation(); sh.stroke.as_ref()?; let mut h = std::collections::hash_map::DefaultHasher::new(); (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); @@ -78,6 +83,9 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac if let Some(cache) = &sh.cache { fit_stroke(&parts.0, &mut parts.1, cache, canvas); } + if photocraft_raster::spill::check_since(generation).is_err() || sh.cache.as_ref().is_some_and(|c| c.scratch_health().is_err()) { + return Some(parts); + } let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); // Least recently used first, until the new entry fits both the count and the byte budget. let new_bytes = entry_bytes(&parts); diff --git a/crates/compose/src/soft_shape_tests.rs b/crates/compose/src/soft_shape_tests.rs index 222b3f3e9a..9332f31af1 100644 --- a/crates/compose/src/soft_shape_tests.rs +++ b/crates/compose/src/soft_shape_tests.rs @@ -91,7 +91,8 @@ fn density_shows_the_fill_beyond_the_path() { /// 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)] { + // 40 px reaches past the canvas edge: both feather it the same way there (#9475). + 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), (1.0, 40.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] { @@ -114,7 +115,7 @@ fn soft_shape_matches_an_equal_vector_mask() { 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; + let px = render(&d, Rect::new(0, 100, 200, 101)).into_px(); // Centred 4 px stroke over x = 48..52: opaque red. for x in [48, 49, 50, 51] { let p = px[x]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7f939c84a5..7ea8655099 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -417,6 +417,59 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() { } } +/// #9483: a Color Lookup's bounded output survives full coverage over a huge 32-bit pixel. The +/// blend back to the backdrop computed `b + (r - b) · k`, and in f32 the difference had already +/// lost the small result (1e8 and 0.5 gave 0). +#[test] +fn full_coverage_adjustment_keeps_its_result_over_huge_hdr_values() { + for v in [1.0e6_f32, 1.0e8, -1.0e6] { + let mut d = Document::new("hdr", Size::new(2, 2), ColorMode::Rgb, SampleType::F32); + let mut base = Layer::raster("b", PixelFormat::RGBA32F); + base.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[v, v, v, 1.0]); + let look = Adjustment::ColorLookup { + name: "half".into(), + lut: Some(std::sync::Arc::new(vec![0.5; 24])), + size: 2, + tetrahedral: false, + dither: false, + source: None, + }; + d.layers = vec![base, Layer::new("look", LayerContent::Adjustment(look))]; + let got = px(&d, 0, 0); + for c in &got[..3] { + assert!((c - 0.5).abs() < 1e-6, "{v}: {got:?}"); + } + } +} + +/// #9501: an adjustment layer at 40% Fill in Color Dodge or Linear Burn blends a result pulled +/// toward the mode's neutral colour, as a pixel layer does, instead of fading the blend by Fill. +/// Photoshop: a constant 0.5 look over (64, 128, 192) gives (80, 160, 240) and (13, 77, 141). +#[test] +fn adjustment_fill_changes_the_special_blend_modes() { + for (mode, want) in [(BlendMode::ColorDodge, [80, 160, 240]), (BlendMode::LinearBurn, [13, 77, 141])] { + let mut d = Document::with_background("fill", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut base = Layer::raster("b", PixelFormat::RGBA8); + base.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]); + let look = Adjustment::ColorLookup { + name: "half".into(), + lut: Some(std::sync::Arc::new(vec![0.5; 24])), + size: 2, + tetrahedral: false, + dither: false, + source: None, + }; + let mut adj = Layer::new("look", LayerContent::Adjustment(look)); + adj.blend = mode; + adj.fill_opacity = 0.4; + d.layers.push(base); + d.layers.push(adj); + let got = px(&d, 0, 0); + let got: Vec = got[..3].iter().map(|v| (v * 255.0).round() as i32).collect(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "{mode:?}: {got:?} vs Photoshop {want:?}"); + } +} + /// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`. fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] { let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth); @@ -564,6 +617,35 @@ fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() { } } +/// #4921: Dissolve coverage must survive clipping and use Opacity × Fill. +#[test] +fn clipped_dissolve_is_binary_and_preserves_the_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}"); +} + #[test] fn hidden_base_hides_clipping_group() { let mut d = doc_white(4, 4); @@ -834,6 +916,32 @@ fn threshold_posterize_levels_curves() { assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75); } +#[test] +fn curve_samples_preserve_spline_and_endpoint_behavior() { + use photocraft_doc::adjust::CurvePoint; + let points = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }]; + // Analytic natural spline: at x=1/4 and 3/4 the output is 11/16. + assert_eq!(adjust::curve_samples::<5>(&points), vec![0.0, 0.6875, 1.0, 0.6875, 0.0]); + let low = adjust::curve_samples::<256>(&points); + let full = adjust::curve_lut(&points); + assert_eq!(low.len(), 256); + assert_eq!(full.len(), 4096); + // Both grids share these inputs exactly (17/255 == 273/4095). + for k in 0..=15 { + assert_eq!(low[k * 17], full[k * 273]); + } + + let unsorted = [CurvePoint { input: 0.75, output: 0.8 }, CurvePoint { input: 0.25, output: 0.2 }]; + let table = adjust::curve_samples::<5>(&unsorted); + for (actual, expected) in table.iter().zip([0.2, 0.2, 0.5, 0.8, 0.8]) { + assert!((actual - expected).abs() < 1e-6); + } + assert_eq!(adjust::curve_samples::<3>(&[]), vec![0.0, 0.5, 1.0]); + assert_eq!(adjust::curve_samples::<3>(&points[..1]), vec![0.0, 0.5, 1.0]); + assert!(adjust::curve_samples::<0>(&points).is_empty()); + assert_eq!(adjust::curve_samples::<1>(&points), vec![0.0]); +} + #[test] fn hue_saturation_roundtrips_and_desaturates() { let c = [0.8, 0.3, 0.1]; @@ -933,6 +1041,55 @@ fn dissolve_coverage_matches_opacity() { assert_eq!(flatten(&d), b); } +/// Invert at 50% Dissolve is black or white, not the gray of a Normal mix (#3050). +#[test] +fn dissolve_adjustment_is_a_per_pixel_choice() { + let mut d = doc_white(16, 16); + let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + inv.blend = photocraft_color::BlendMode::Dissolve; + inv.opacity = 0.5; + d.layers.push(inv); + let b = flatten(&d); + let (black, white, other) = b.px.iter().fold((0, 0, 0), |(k, w, o), p| { + if close4(*p, [0.0, 0.0, 0.0, 1.0]) { + (k + 1, w, o) + } else if close4(*p, [1.0, 1.0, 1.0, 1.0]) { + (k, w + 1, o) + } else { + (k, w, o + 1) + } + }); + assert_eq!(other, 0, "a partial mix would be gray"); + assert!(black > 40 && white > 40, "black {black} white {white}"); +} + +/// A clipped black fill at 50% Dissolve stays black or white on an opaque base (#3052). +#[test] +fn clipped_dissolve_fill_is_a_per_pixel_choice() { + let mut d = doc_white(16, 16); + let mut l = solid_layer("k", Rect::new(0, 0, 16, 16), [0.0, 0.0, 0.0, 1.0]); + l.clipped = true; + l.blend = photocraft_color::BlendMode::Dissolve; + l.opacity = 0.5; + d.layers.push(l); + let b = flatten(&d); + assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4]))); +} + +/// A Color Overlay must not turn Dissolve coverage into a Normal mix (#3051). +#[test] +fn color_overlay_keeps_dissolve_coverage() { + use photocraft_doc::effects::{Effect, FxCommon}; + let mut d = doc_white(16, 16); + let mut l = Layer::new("ink", LayerContent::Fill(Fill::Solid(photocraft_color::Color::BLACK))); + l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::BLACK }); + l.blend = photocraft_color::BlendMode::Dissolve; + l.opacity = 0.5; + d.layers.push(l); + let b = flatten(&d); + assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4]))); +} + #[test] fn render_is_tile_independent() { // Rendering a sub-rect must equal the same region of a full render. @@ -2453,6 +2610,71 @@ fn blend_if_hard_point_includes_its_own_level() { } } +/// #9521: Photoshop compares Blend If at its 16-bit precision (0..32768). A hard point at level +/// `c` sits on the step `round(c · 32768 / 255)`, inclusive for both black and white points: +/// measured in Photoshop 2025 at 1, 64, 128 and 200 (black first shown at steps 129, 8224, 16448, +/// 25700; white last shown at the same steps). RGB16 32895 is step 16448, so a white layer with +/// Underlying Blue black at 128 shows over it; 32894 (step 16447) hides it. +#[test] +fn blend_if_16_bit_cutoffs_follow_photoshops_steps() { + let shown = |v16: u16, black: u8, white: u8| { + let v = f32::from(v16) / 65535.0; + let mut d = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::U16); + let layer16 = |name: &str, c: [f32; 4]| { + let mut l = Layer::raster(name, PixelFormat::RGBA16); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &c); + l + }; + d.layers.push(layer16("under", [v, v, v, 1.0])); + let mut l = layer16("top", [1.0, 1.0, 1.0, 1.0]); + l.blend_if.set(3, [FULL, range([black, black], [white, white])]); + d.layers.push(l); + px(&d, 0, 0)[0] > 0.999 + }; + // 16-bit file values for Photoshop's steps n: round(n · 65535 / 32768). + let at = |n: u32| ((n * 65535 + 16384) / 32768) as u16; + for (c, step) in [(1u8, 129u32), (64, 8224), (128, 16448), (200, 25700)] { + assert!(shown(at(step), c, 255), "black {c}: step {step} shows"); + assert!(!shown(at(step - 1), c, 255), "black {c}: step {} hides", step - 1); + assert!(shown(at(step), 0, c), "white {c}: step {step} shows"); + assert!(!shown(at(step + 1), 0, c), "white {c}: step {} hides", step + 1); + } + assert!(shown(32895, 128, 255) && !shown(32894, 128, 255), "the reporter's RGB16 boundary"); +} + +/// #9522: Blend If's Gray is Photoshop's (30 R + 59 G + 11 B) / 100 rounded half down, measured +/// in Photoshop 2025 with a white layer over one 8-bit backdrop colour and a hard Gray point on +/// Underlying Layer. Rec. 601 luma hid both of the reporter's colours. +#[test] +fn blend_if_gray_follows_photoshops_weights_and_rounding() { + let shown = |rgb: [u8; 3], black: u8, white: u8| { + let mut d = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + let c = rgb.map(|v| f32::from(v) / 255.0); + d.layers.push(solid_layer("under", Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0])); + let mut l = solid_layer("top", Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]); + l.blend_if.set(0, [FULL, range([black, black], [white, white])]); + d.layers.push(l); + px(&d, 0, 0)[0] > 0.999 && px(&d, 0, 0)[2] > 0.999 + }; + // (colour hidden by a black point at `cut`, the next colour shown), single channels. + for (cut, below, at) in [ + (20, [65, 0, 0], [66, 0, 0]), + (25, [81, 0, 0], [82, 0, 0]), + (28, [91, 0, 0], [92, 0, 0]), + (20, [0, 33, 0], [0, 34, 0]), + (28, [0, 46, 0], [0, 47, 0]), + (20, [0, 0, 177], [0, 0, 178]), + (28, [0, 0, 250], [0, 0, 251]), + ] { + assert!(!shown(below, cut, 255), "{below:?} hidden by black {cut}"); + assert!(shown(at, cut, 255), "{at:?} shown by black {cut}"); + } + // The reporter's mixed colours, and the white point's half-down rounding. + assert!(shown([123, 126, 151], 128, 255), "127.85 is 128"); + assert!(shown([120, 129, 149], 0, 128), "128.5 is 128"); + assert!(!shown([121, 129, 149], 0, 128), "128.8 is 129"); +} + #[test] fn blend_if_split_point_fades_like_opacity() { // A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which @@ -2511,6 +2733,25 @@ fn blend_if_on_adjustment_and_clipped_layers() { assert!(close4(px(&d, 6, 1), [0.0, 1.0, 0.0, 1.0])); } +/// #9554: a Pass Through group with Blend If renders isolated, as Photoshop does: an Invert inside +/// it leaves the layer beneath alone. Photoshop: (64, 128, 192) under such a group with Red › +/// Underlying Layer white at 110 stays (64, 128, 192); without the Blend If it is inverted. +#[test] +fn pass_through_group_with_blend_if_is_isolated() { + for (gated, want) in [(true, [64, 128, 192]), (false, [191, 127, 63])] { + let mut d = Document::with_background("pt", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE); + d.layers.push(solid_layer("base", Rect::new(0, 0, 1, 1), [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0])); + let mut g = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]); + g.blend = BlendMode::PassThrough; + if gated { + g.blend_if.set(1, [FULL, range([0, 0], [110, 110])]); + } + d.layers.push(g); + let got: Vec = px(&d, 0, 0)[..3].iter().map(|v| (v * 255.0).round() as i32).collect(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "Blend If {gated}: {got:?} vs Photoshop {want:?}"); + } +} + #[test] fn blend_if_modes() { // Grayscale documents: the single channel's entry (or the gray entry) applies. @@ -2760,7 +3001,7 @@ fn levels_steps_match_photoshop() { let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect(); let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px }; adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8)); - b.px + b.into_px() }; let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]); let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200]; @@ -3123,7 +3364,7 @@ fn render_bands_cover_the_rect_in_order() { render_bands(&d, r, 100, |b| -> Result<(), ()> { assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1)); rows.push((b.rect.y0, b.rect.y1)); - px.extend(b.px); + px.extend(b.into_px()); Ok(()) }) .unwrap(); @@ -4161,12 +4402,8 @@ fn reused_render_matches_previous_allocating_stack() { adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode); for (b, a) in previous.px.iter_mut().zip(&adjusted.px) { let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); - *b = [ - b[0] + (blended[0] - b[0]) * layer.opacity, - b[1] + (blended[1] - b[1]) * layer.opacity, - b[2] + (blended[2] - b[2]) * layer.opacity, - b[3], - ]; + let (k, j) = (layer.opacity, 1.0 - layer.opacity); + *b = [b[0] * j + blended[0] * k, b[1] * j + blended[1] * k, b[2] * j + blended[2] * k, b[3]]; } if let Some(q) = adjustment_quantum(depth) { quantize(&mut previous, q); @@ -4232,13 +4469,13 @@ fn dissolve_adjustment_resolves_opacity_to_binary_coverage() { layer.blend = BlendMode::Dissolve; layer.opacity = 0.5; d.layers.push(layer); - let pixels = render(&d, d.bounds()).px; + let pixels = render(&d, d.bounds()).into_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; + let continuous = render(&d, d.bounds()).into_px(); assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E), "Normal remains continuously interpolated"); } @@ -4249,14 +4486,14 @@ fn clipped_dissolve_fill_matches_opaque_unclipped_coverage() { fill.blend = BlendMode::Dissolve; fill.opacity = 0.5; d.layers.push(fill); - let control = render(&d, d.bounds()).px; + let control = render(&d, d.bounds()).into_px(); d.layers.last_mut().unwrap().clipped = true; - let clipped = render(&d, d.bounds()).px; + let clipped = render(&d, d.bounds()).into_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; + let continuous = render(&d, d.bounds()).into_px(); assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); } @@ -4267,17 +4504,17 @@ fn dissolve_color_overlay_preserves_binary_layer_coverage() { layer.blend = BlendMode::Dissolve; layer.opacity = 0.5; d.layers.push(layer); - let control = render(&d, d.bounds()).px; + let control = render(&d, d.bounds()).into_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; + let styled = render(&d, d.bounds()).into_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; + let continuous = render(&d, d.bounds()).into_px(); assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); } @@ -4347,3 +4584,60 @@ fn branchless_floor_matches_floor_bitwise() { assert_eq!(branchless_floor(x).to_bits(), x.floor().to_bits(), "{x:e} ({:#010x})", x.to_bits()); } } + +/// #9542: an adjustment layer's "This Layer" range judges its result at full strength, before +/// Opacity mixes it in. Photoshop: Invert at 40% over (64, 128, 192) with Red / This Layer black +/// at 150 keeps the effect, (115, 128, 140), as Invert's red (191) passes 150; with white at 150 +/// it drops it. Judged after Opacity (red 115) the two came out the other way round. +#[test] +fn adjustment_blend_if_judges_the_result_before_opacity() { + for (black, white, want) in [(150u8, 255u8, [115, 128, 140]), (0, 150, [64, 128, 192])] { + let mut d = Document::with_background("bi", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE); + d.layers.push(solid_layer("base", Rect::new(0, 0, 1, 1), [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0])); + let mut inv = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + inv.opacity = 0.4; + inv.blend_if.set(1, [range([black, black], [white, white]), FULL]); + d.layers.push(inv); + let got: Vec = px(&d, 0, 0)[..3].iter().map(|v| (v * 255.0).round() as i32).collect(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "black {black} white {white}: {got:?} vs Photoshop {want:?}"); + } +} + +/// The reference application, measured: a 240 × 40 black document, an opaque white layer over +/// its left 100 px (full height), Outer Glow Softer, white, Normal, 100 %, Spread 0, Noise 0, +/// Size 60, Range 50 % and 100 % alike. Sampled at row 20, `d` px right of the edge, the glow +/// reads 4 at 50 px, 1 at 54 and 0 from 56 to 70: it fades out just before Size, with no tail +/// beyond it (#8039 added one; reverted). On black, the red value × 255 is the glow alpha × 255, +/// compared as 8-bit levels. The tent gives 4, 1, 0, 0, 0 at Range 50 % and 2, 1, 0, 0, 0 at 100 %. +#[test] +fn softer_outer_glow_fades_out_before_its_size() { + let mut d = Document::with_background("t", Size::new(240, 40), ColorMode::Rgb, SampleType::U8, Color::BLACK); + for range in [0.5, 1.0] { + let mut l = solid_layer("shape", Rect::new(0, 0, 100, 40), [1.0; 4]); + l.effects.items = vec![Effect::OuterGlow(Glow { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + paint: FxPaint::Color(Color::WHITE), + technique: GlowTechnique::Softer, + spread: 0.0, + size: 60.0, + contour: photocraft_doc::Contour::Linear, + anti_alias: false, + range, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + })]; + d.layers.truncate(1); + d.layers.push(l); + let got: Vec<(i32, f32)> = [50, 54, 56, 60, 70].iter().map(|&dist| (dist, (255.0 * px(&d, 100 + dist, 20)[0]).round())).collect(); + for &(dist, v) in &got { + let want = match dist { + 50 => 4.0, + 54 => 1.0, + _ => 0.0, + }; + let tolerance = if dist >= 56 { 0.0 } else { 2.0 }; + assert!((v - want).abs() <= tolerance, "range {range}, {dist} px: {v} vs the reference application's {want} ({got:?})"); + } + } +} diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs index 02bc4a4111..35ddd5b4cc 100644 --- a/crates/compose/src/uniform.rs +++ b/crates/compose/src/uniform.rs @@ -85,7 +85,8 @@ impl<'a> UniformAreas<'a> { Some(v) => v, None => { let bpp = s.default_bytes().len().max(1); - let mut px = t.bytes().chunks_exact(bpp); + let bytes = t.bytes(); + let mut px = bytes.chunks_exact(bpp); let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from); self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone()); v diff --git a/crates/compose/src/viewport.rs b/crates/compose/src/viewport.rs new file mode 100644 index 0000000000..40a6accee9 --- /dev/null +++ b/crates/compose/src/viewport.rs @@ -0,0 +1,307 @@ +//! Bounded, cancellable viewport pages. Reduction is globally aligned, so independently +//! rendered pages have exactly the same pixel footprints at their shared edges. +use crate::{Buffer, render, render_bands}; +use photocraft_doc::Document; +use photocraft_geom::Rect; +use photocraft_raster::{Interrupt, Surface}; + +/// A quick, explicitly provisional reduction. Only the requested source rows are read; the +/// exact composite below replaces it after visible pages have first become available. +pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result { + if factor <= 1 || !crate::proxy::proxy_faithful(doc) { + return render_page(doc, source, factor, interrupt); + } + let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?; + let source = source.intersect(&doc.bounds()); + if source.is_empty() { + return Ok(Buffer::transparent(Rect::EMPTY)); + } + let rect = Rect::new( + source.x0 / k, + source.y0 / k, + source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0), + source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0), + ); + if rect.width() > 1024 || rect.height() > 1024 { + return Err("viewport page is too large".into()); + } + // Cell `c` samples source pixel `c·k + k/2`, clamped to the last document pixel: a partial + // cell at the document's edge samples inside it, not the layer's default past it. (Document + // bounds, not this page's, so neighbouring pages agree.) + let bounds = doc.bounds(); + let last = (bounds.x1 - 1, bounds.y1 - 1); + fn shrink(s: &Surface, r: Rect, k: i32, last: (i32, i32), i: Interrupt<'_>) -> Result { + let mut out = Surface::with_default(s.format(), &s.default_pixel()); + let bpp = s.format().bytes_per_pixel(); + let x0 = r.x0.saturating_mul(k); + for y in r.y0..r.y1 { + i.check().map_err(|e| e.to_string())?; + let sy = y.saturating_mul(k).saturating_add(k / 2).min(last.1); + let row = s.to_interleaved(Rect::new(x0, sy, r.x1.saturating_mul(k).min(last.0 + 1), sy.saturating_add(1))); + let mut reduced = Vec::with_capacity(r.width() as usize * bpp); + for x in r.x0..r.x1 { + let sx = x.saturating_mul(k).saturating_add(k / 2).min(last.0); + let offset = (sx - x0) as usize * bpp; + reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?); + } + out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced); + } + Ok(out) + } + fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize, last: (i32, i32)) -> Result<(), String> { + if depth > 256 { + return Err("viewport layer nesting limit exceeded".into()); + } + if let Some(m) = &mut l.mask { + m.surface = shrink(&m.surface, r, k, last, i)?; + } + if let Some(c) = &mut l.fill_cache { + c.surface = shrink(&c.surface, r, k, last, i)?; + } + use photocraft_doc::LayerContent; + match &mut l.content { + LayerContent::Raster(s) => *s = shrink(s, r, k, last, i)?, + LayerContent::Group(g) => { + if let Some(a) = &mut g.artboard { + // A cell is on the board when its sample `c·k + k/2` is: each edge maps to the + // first cell sampling at or after it. + let edge = |e: i32| { + let (s, k) = (i64::from(e) - i64::from(k / 2), i64::from(k)); + (s.div_euclid(k) + i64::from(s.rem_euclid(k) != 0)) as i32 + }; + a.rect = Rect::new(edge(a.rect.x0), edge(a.rect.y0), edge(a.rect.x1), edge(a.rect.y1)); + } + for c in &mut g.children { + layer(c, r, k, i, depth + 1, last)?; + } + } + LayerContent::Text(t) => { + if let Some(s) = &mut t.cache { + *s = shrink(s, r, k, last, i)?; + } + } + LayerContent::Shape(t) => { + if let Some(s) = &mut t.cache { + *s = shrink(s, r, k, last, i)?; + } + } + LayerContent::Smart(t) => { + if let Some(s) = &mut t.cache { + *s = shrink(s, r, k, last, i)?; + } + } + _ => {} + } + Ok(()) + } + let mut p = doc.clone(); + p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); + for l in &mut p.layers { + layer(l, rect, k, interrupt, 0, last)?; + } + for c in &mut p.channels { + c.surface = shrink(&c.surface, rect, k, last, interrupt)?; + } + interrupt.check().map_err(|e| e.to_string())?; + Ok(render(&p, rect)) +} + +pub fn render_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result { + interrupt.check().map_err(|e| e.to_string())?; + let source = source.intersect(&doc.bounds()); + if source.is_empty() { + return Ok(Buffer::transparent(Rect::EMPTY)); + } + let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?; + let out = Rect::new( + source.x0.div_euclid(k), + source.y0.div_euclid(k), + source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0), + source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0), + ); + if out.width() > 1024 || out.height() > 1024 { + return Err("viewport page is too large".into()); + } + if k == 1 { + return Ok(render(doc, source)); + } + if source.x0.rem_euclid(k) != 0 || source.y0.rem_euclid(k) != 0 { + return average_page(doc, source, k, out, interrupt); + } + // The reduced-render path the CPU canvas and navigator use: one-colour document tiles are + // composited once instead of per pixel, and the rest in parallel tiles, then area-averaged + // into cells aligned to multiples of `k` (the same cells as [`average_page`]). + let stats = &mut crate::reduced::ReducedStats::default(); + let mut buf = crate::reduced::render_reduced_cells(doc, source, crate::reduced::Cells::Factor(factor), None, 128, true, stats, interrupt)?; + if buf.rect.width() != out.width() || buf.rect.height() != out.height() { + return Err("invalid viewport reduction".into()); + } + buf.rect = out; + Ok(buf) +} + +/// [`render_page`] by compositing every source pixel and averaging each `k`×`k` footprint (also +/// the reference its reduced path is tested against). +fn average_page(doc: &Document, source: Rect, k: i32, out: Rect, interrupt: Interrupt<'_>) -> Result { + let factor = k.unsigned_abs(); + let mut result = Buffer::transparent(out); + render_bands(doc, source, 128, |band| -> Result<(), String> { + interrupt.check().map_err(|e| e.to_string())?; + for (i, p) in band.px.iter().enumerate() { + let x = band.rect.x0 + (i % band.rect.width() as usize) as i32; + let y = band.rect.y0 + (i / band.rect.width() as usize) as i32; + let offset = (y.div_euclid(k) - out.y0) as usize * out.width() as usize + (x.div_euclid(k) - out.x0) as usize; + let q = result.px.get_mut(offset).ok_or("invalid viewport accumulator")?; + for (a, b) in q.iter_mut().take(3).zip(p.iter()) { + *a += *b * p.get(3).copied().unwrap_or(0.0); + } + if let Some(a) = q.get_mut(3) { + *a += p.get(3).copied().unwrap_or(0.0); + } + } + Ok(()) + })?; + for (i, p) in result.px.iter_mut().enumerate() { + let x = out.x0 + (i % out.width() as usize) as i32; + let y = out.y0 + (i / out.width() as usize) as i32; + let footprint = Rect::from_xywh(x.saturating_mul(k), y.saturating_mul(k), factor, factor).intersect(&source); + let alpha = p.get(3).copied().unwrap_or(0.0); + if alpha > 0.0 { + for c in p.iter_mut().take(3) { + *c /= alpha; + } + if let Some(a) = p.get_mut(3) { + *a /= (u64::from(footprint.width()) * u64::from(footprint.height())).max(1) as f32; + } + } + } + interrupt.check().map_err(|e| e.to_string())?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, ColorMode, SampleType}; + use photocraft_doc::Size; + #[test] + fn high_zoom_is_original_resolution_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = Rect::new(65001, 22003, 65513, 22515); + let mut d = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, depth); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + s.fill_rect(r, &[1.0; 4]); + d.layers.push(photocraft_doc::Layer::new("region", photocraft_doc::LayerContent::Raster(s))); + let p = render_page(&d, r, 1, Interrupt::NONE).unwrap(); + assert_eq!(p.rect, r); + assert_eq!(p.px, render(&d, r).px); + } + } + #[test] + fn independent_pages_match_whole_reduction_and_odd_edges() { + let d = Document::with_background("d", Size::new(53, 31), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let whole = render_page(&d, d.bounds(), 4, Interrupt::NONE).unwrap(); + for r in [Rect::new(0, 0, 28, 31), Rect::new(28, 0, 53, 31)] { + let p = render_page(&d, r, 4, Interrupt::NONE).unwrap(); + for y in p.rect.y0..p.rect.y1 { + for x in p.rect.x0..p.rect.x1 { + assert_eq!( + p.px[((y - p.rect.y0) * p.rect.width() as i32 + x - p.rect.x0) as usize], + whole.px[(y * whole.rect.width() as i32 + x) as usize] + ); + } + } + } + } + /// A partial cell at the document's right and bottom edges samples the last document pixel, + /// not the layer's (transparent) default past it (#5332 review). + #[test] + fn preview_edge_cells_sample_inside_the_document() { + let mut d = Document::new("edge", Size::new(10, 10), ColorMode::Rgb, SampleType::U8); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + s.fill_rect(d.bounds(), &[1.0, 0.0, 0.0, 1.0]); + d.layers.push(photocraft_doc::Layer::new("red", photocraft_doc::LayerContent::Raster(s))); + for source in [d.bounds(), Rect::new(4, 4, 10, 10)] { + let p = preview_page(&d, source, 4, Interrupt::NONE).unwrap(); + assert_eq!(p.rect, Rect::new(source.x0 / 4, source.y0 / 4, 3, 3)); + for (i, px) in p.px.iter().enumerate() { + assert!((px[0] - 1.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{source:?} cell {i}: {px:?}"); + } + } + } + /// An artboard covers the preview cells whose sample (`c·k + k/2`) lies on the board, so a + /// narrow board, or one whose edges fall mid-cell, keeps exactly those cells (#5332 review). + #[test] + fn preview_artboard_covers_the_cells_it_samples() { + let k = 4; + // (board x0..x1, cells expected on it); cell c samples x = 4c + 2. + for (x0, x1, on) in [(2, 3, 0..1), (0, 3, 0..1), (3, 16, 1..4), (1, 7, 0..2), (4, 14, 1..3), (-6, 6, 0..1), (-6, 7, 0..2)] { + let mut d = Document::new("boards", Size::new(16, 4), ColorMode::Rgb, SampleType::U8); + let board = photocraft_doc::Artboard::new(Rect::new(x0, 0, x1, 4)); + let group = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board), frame_stroke: None }; + d.layers.push(photocraft_doc::Layer::new("board", photocraft_doc::LayerContent::Group(group))); + let p = preview_page(&d, d.bounds(), k, Interrupt::NONE).unwrap(); + assert_eq!(p.rect, Rect::new(0, 0, 4, 1)); + for (c, px) in p.px.iter().enumerate() { + assert_eq!(px[3] > 0.5, on.contains(&c), "board {x0}..{x1}, cell {c}: {px:?}"); + } + } + } + /// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour + /// tiles, so the reduced path takes both its one-colour and per-pixel branches. + fn busy(depth: SampleType) -> Document { + let mut d = Document::with_background("busy", Size::new(1100, 700), ColorMode::Rgb, depth, Color::WHITE); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + let mut seed = 0x2545_f491_u32; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed % 1000) as f32 / 999.0 + }; + for y in (100..530).step_by(3) { + for x in (190..905).step_by(3) { + let px = [next(), next(), next(), next()]; + s.fill_rect(Rect::from_xywh(x, y, 3, 3), &px); + } + } + d.layers.push(photocraft_doc::Layer::new("noise", photocraft_doc::LayerContent::Raster(s))); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + s.fill_rect(Rect::new(400, 0, 1100, 650), &[0.2, 0.5, 0.9, 1.0]); + let mut top = photocraft_doc::Layer::new("tint", photocraft_doc::LayerContent::Raster(s)); + top.opacity = 0.6; + top.blend = photocraft_color::blend::BlendMode::Multiply; + d.layers.push(top); + d + } + #[test] + fn reduced_pages_match_averaging_every_composited_pixel() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = busy(depth); + for k in [2u32, 4, 16] { + let ki = k as i32; + for source in [d.bounds(), Rect::new(512, 256, 1024, 700), Rect::new(ki * 13, ki * 5, 1100, ki * 37)] { + let source = source.intersect(&d.bounds()); + let out = Rect::new(source.x0 / ki, source.y0 / ki, (source.x1 + ki - 1) / ki, (source.y1 + ki - 1) / ki); + let reference = average_page(&d, source, ki, out, Interrupt::NONE).unwrap(); + let got = render_page(&d, source, k, Interrupt::NONE).unwrap(); + assert_eq!(got.rect, reference.rect, "{depth:?} k={k} {source:?}"); + let worst = got.px.iter().zip(&reference.px).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + assert!(worst < 1e-4, "{depth:?} k={k} {source:?}: differs by {worst}"); + } + } + } + } + #[test] + fn rejects_invalid_sizes_and_honors_cancellation() { + let d = Document::new("d", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); + assert!(render_page(&d, d.bounds(), 1, Interrupt::NONE).is_err()); + assert!(render_page(&d, d.bounds(), 0, Interrupt::NONE).is_err()); + assert!(render_page(&d, Rect::new(0, 0, 512, 512), 1, Interrupt::cancel_only(&|| true)).is_err()); + // Cancelled after the first band of the reduced path. + let checks = std::sync::atomic::AtomicUsize::new(0); + let later = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 2; + assert!(render_page(&d, Rect::new(0, 0, 4096, 4096), 8, Interrupt::cancel_only(&later)).is_err()); + assert!(checks.load(std::sync::atomic::Ordering::Relaxed) >= 3); + } +} diff --git a/crates/compose/tests/buffer_reuse.rs b/crates/compose/tests/buffer_reuse.rs new file mode 100644 index 0000000000..f07bf8b69f --- /dev/null +++ b/crates/compose/tests/buffer_reuse.rs @@ -0,0 +1,27 @@ +use photocraft_compose::Buffer; +use photocraft_geom::Rect; +use photocraft_raster::scratch; + +/// Restores idle release even when an assertion fails. +struct IdleReleaseOff; +impl Drop for IdleReleaseOff { + fn drop(&mut self) { + scratch::release(); + scratch::set_idle_release(true); + } +} + +/// Runs in its own binary so other compositor tests cannot consume the pooled allocation. +#[test] +fn dropped_buffer_storage_is_reused() { + scratch::set_idle_release(false); + let _idle = IdleReleaseOff; + scratch::release(); + let rect = Rect::from_xywh(0, 0, 256, 256); + let buf = Buffer::transparent(rect); + let ptr = buf.px.as_ptr(); + drop(buf); + let reused = Buffer::transparent(rect); + assert_eq!(reused.px.as_ptr(), ptr); + assert!(reused.px.iter().all(|p| *p == [0.0; 4])); +} diff --git a/crates/compose/tests/scratch_retry.rs b/crates/compose/tests/scratch_retry.rs new file mode 100644 index 0000000000..70fdc41225 --- /dev/null +++ b/crates/compose/tests/scratch_retry.rs @@ -0,0 +1,49 @@ +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Effect, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn failed_effect_build_is_not_reused_after_original_pixels_recover() { + let dir = std::env::temp_dir().join(format!("photocraft-compose-scratch-retry-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Shadow", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut background = Layer::raster("Background", PixelFormat::RGBA8); + background.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 64, 64), &[1.0; 4]); + let mut foreground = Layer::raster("Shadow", PixelFormat::RGBA8); + foreground.surface_mut().unwrap().fill_rect(Rect::new(16, 16, 48, 48), &[1.0, 0.0, 0.0, 1.0]); + foreground.effects.items.push(Effect::default_drop_shadow()); + doc.layers.extend([background, foreground]); + let expected = photocraft_compose::flatten(&doc).px.clone(); + // Keep the known-good bounds, but force a new effect-map build from the damaged source. + photocraft_compose::purge_effect_cache(); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + drop(photocraft_compose::flatten(&doc)); + assert!(spill::check_integrity().is_err()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for layer in &doc.layers { + for (_, tile) in layer.surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + } + assert!(spill::check_integrity().is_ok()); + assert_eq!(photocraft_compose::flatten(&doc).px, expected); + photocraft_compose::purge_effect_cache(); + drop((doc, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 33589e731e..915ddca7da 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -503,12 +503,22 @@ fn normalize_runs(text: &str, runs: Vec<(usize, S)>, base: S) -> Vec<( out } +/// A bounded display preview for an embedded native vector, bound to its exact source bytes. +/// The SVG is display data only; saving the object retains the native source format. +#[derive(Clone, Debug, PartialEq)] +pub struct VectorPreview { + pub source_hash: [u8; 32], + pub svg: Arc>, +} + #[derive(Clone, Debug, PartialEq)] pub struct SmartObject { /// Ordinary layer duplicates share this identity; independent contents receive a fresh one. pub contents_id: SmartContentsId, /// Embedded source file bytes (PSD, PNG, …) or linked path. pub source: SmartSource, + /// Optional source-pixel display preview for AI/PDF/EPS; ignored after source bytes change. + pub vector_preview: Option, pub transform: Affine, pub smart_filters: Vec, /// Rendered appearance. @@ -540,6 +550,7 @@ impl SmartObject { Self { contents_id: SmartContentsId::fresh(), source, + vector_preview: None, transform, smart_filters: Vec::new(), cache, @@ -1015,12 +1026,17 @@ impl Document { /// Pixel format for new raster layers in this document. pub fn pixel_format(&self) -> PixelFormat { - let mode = match self.mode { + Self::pixel_format_for(self.mode, self.depth) + } + + /// [`Self::pixel_format`] of a document in `mode` at `depth`. + pub fn pixel_format_for(mode: ColorMode, depth: SampleType) -> PixelFormat { + let mode = match mode { ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb, ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale, m => m, }; - PixelFormat::new(mode, self.depth, true) + PixelFormat::new(mode, depth, true) } pub fn bounds(&self) -> Rect { @@ -1048,6 +1064,58 @@ impl Document { max } + /// Every pixel surface the document holds: layer pixels and caches, masks, smart filter + /// masks, video frames (including the originals), channels, the quick mask, patterns and + /// the selection. History snapshots and saves cover exactly these tiles. + pub fn for_each_surface(&self, mut visit: impl FnMut(&Surface)) { + for (_, _, layer) in self.walk() { + if let Some(surface) = layer.surface() { + visit(surface); + } + if let Some(mask) = &layer.mask { + visit(&mask.surface); + } + if let Some(cache) = &layer.fill_cache { + visit(&cache.surface); + } + if let LayerContent::Smart(smart) = &layer.content + && let Some(mask) = &smart.filter_mask + { + visit(&mask.surface); + } + if let Some(video) = &layer.video { + for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { + visit(frame); + } + } + } + for channel in self.channels.iter().chain(&self.quick_mask) { + visit(&channel.surface); + } + for pattern in &self.patterns { + visit(&pattern.surface); + } + if let Some(selection) = &self.selection { + visit(selection); + } + } + + /// `Err` if any of this document's tiles is unreadable on the scratch disk. Edits, saves + /// and exports of this document check it; other documents are unaffected. Free when no + /// tile in the process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !photocraft_raster::spill::any_failed() { + return Ok(()); + } + let mut health = Ok(()); + self.for_each_surface(|surface| { + if health.is_ok() { + health = surface.scratch_health(); + } + }); + health + } + /// Depth-first walk yielding `(path, depth, layer)` bottom-to-top. pub fn walk(&self) -> Vec<(LayerPath, usize, &Layer)> { fn rec<'a>(layers: &'a [Layer], prefix: &mut LayerPath, out: &mut Vec<(LayerPath, usize, &'a Layer)>) { diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 9a43c0a467..7d46274638 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -2,16 +2,30 @@ //! and brush textures. Pure data; rendering lives in `photocraft-compose`, the PSD mapping //! (`Patt`/`Pat2`/`Pat3` global blocks) in `photocraft-io`. +use photocraft_color::SampleType; use photocraft_geom::Rect; use photocraft_raster::Surface; -/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000). +/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000). pub const MAX_EDGE: u32 = 30_000; -/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel -/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing. +/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). Larger +/// patterns paint nothing. pub const MAX_PAINT_PIXELS: u64 = 1 << 26; +/// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose` +/// checks the two agree at build time). See [`Pattern::working_bytes`]. A browser tab has far less +/// memory than a desktop process, and a failed allocation there aborts the tab, so the web build +/// holds half as much: an 8-bit pattern paints up to 33,554,432 px (about 5792²) there. +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_WORKING_BYTES: u64 = 256 << 20; +/// See the desktop value above. +#[cfg(target_arch = "wasm32")] +pub const MAX_WORKING_BYTES: u64 = 128 << 20; + +/// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel. +pub const BAND_ROWS: u32 = 256; + /// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format /// (depth and colour model are the pattern's own, as in Photoshop). #[derive(Clone, Debug, PartialEq)] @@ -36,13 +50,18 @@ impl Pattern { /// defining a pattern is deterministic and replayable). pub fn new(name: impl Into, surface: Surface, width: u32, height: u32) -> Pattern { let name = name.into(); - let r = Rect::new(0, 0, width as i32, height as i32); - let px = surface.read_region(r); let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes()); h1 = fnv(h1, &width.to_le_bytes()); h1 = fnv(h1, &height.to_le_bytes()); - for v in &px { - h1 = fnv(h1, &v.to_bits().to_le_bytes()); + // The samples row by row, a band at a time: the same sequence as one read of the whole + // tile, without a full-size `f32` copy of a large pattern. + let mut band = Vec::new(); + for top in (0..height).step_by(BAND_ROWS as usize) { + let bottom = (top + BAND_ROWS).min(height); + surface.read_region_into(Rect::new(0, top as i32, width as i32, bottom as i32), &mut band); + for v in &band { + h1 = fnv(h1, &v.to_bits().to_le_bytes()); + } } let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes()); let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); @@ -70,9 +89,61 @@ impl Pattern { u64::from(self.width) * u64::from(self.height) } - /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]). + /// Bytes of working copy the compositor holds to paint this pattern. One that fits is expanded + /// to premultiplied `f32` RGBA (16 per pixel). A pattern too large for that is held at its own + /// depth instead, straight RGBA at 4 bytes per pixel for 8 bits and 8 for 16 bits: exactly its + /// values, in a quarter or half the memory. A float one has no smaller exact form. + pub fn working_bytes(&self) -> u64 { + working_bytes_for(self.pixel_count(), self.surface.format().sample) + } + + /// Why this pattern paints nothing, for the person or agent who chose it; `None` when it + /// paints. A pattern too large for the compositor's working copy renders as transparent, so + /// every command that picks a pattern, and the import that brought it in, says so. + pub fn paint_problem(&self) -> Option { + if self.is_paintable() { + return None; + } + if self.is_empty() { + return Some("it is empty".into()); + } + let sample = self.surface.format().sample; + Some(format!( + "it is {} × {} px at {} bits per channel, and a pattern of that depth paints up to {} px in all", + self.width, + self.height, + sample.bytes() * 8, + max_paint_pixels(sample) + )) + } + + /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and + /// [`MAX_WORKING_BYTES`]). pub fn is_paintable(&self) -> bool { - !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS + !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES + } +} + +/// [`Pattern::working_bytes`] of a pattern of `pixels` pixels with `sample` samples. +pub fn working_bytes_for(pixels: u64, sample: SampleType) -> u64 { + let expanded = pixels.saturating_mul(16); + if expanded <= MAX_WORKING_BYTES { + return expanded; + } + match sample { + SampleType::U8 => pixels.saturating_mul(4), + SampleType::U16 => pixels.saturating_mul(8), + SampleType::F32 => expanded, + } +} + +/// Most pixels a pattern with `sample` samples may have and still paint: [`MAX_PAINT_PIXELS`] for +/// 8 bits, fewer where the working copy is larger per pixel. +pub fn max_paint_pixels(sample: SampleType) -> u64 { + match sample { + SampleType::U8 => MAX_PAINT_PIXELS, + SampleType::U16 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 8), + SampleType::F32 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 16), } } @@ -118,4 +189,61 @@ mod tests { assert!(find(&list, "nope", "water").is_some()); assert!(find(&list, "nope", "").is_none()); } + + /// The id is a hash of every sample in row-major order; reading a tall pattern a band of rows + /// at a time must give the id one whole read gave, or saved documents would lose their + /// pattern links. + #[test] + fn the_id_of_a_tall_pattern_matches_a_single_read_of_it() { + let (w, h) = (7u32, BAND_ROWS * 2 + 37); + let mut s = Surface::new(PixelFormat::RGBA8); + for y in 0..h as i32 { + for x in 0..w as i32 { + s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[(x % 5) as f32 / 4.0, (y % 7) as f32 / 6.0, ((x + y) % 3) as f32 / 2.0, 1.0]); + } + } + let name = "tall"; + let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes()); + h1 = fnv(h1, &w.to_le_bytes()); + h1 = fnv(h1, &h.to_le_bytes()); + for v in s.read_region(Rect::new(0, 0, w as i32, h as i32)) { + h1 = fnv(h1, &v.to_bits().to_le_bytes()); + } + let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", s.format()).as_bytes()); + let expected = + format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff); + assert_eq!(Pattern::new(name, s, w, h).id, expected); + } + + #[test] + fn working_bytes_keep_a_pattern_exact_in_less_memory() { + let sparse = + |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; + let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + // What fits is expanded as before. + assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 1600); + assert_eq!(sparse(float, 4096, 4096).working_bytes(), MAX_WORKING_BYTES); + // Past that, a pattern is held at its own depth. + assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).working_bytes(), 256_000_000); + assert_eq!(sparse(PixelFormat::RGBA16, 5000, 5000).working_bytes(), 200_000_000); + assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable()); + assert!(sparse(PixelFormat::RGBA16, 5792, 5792).is_paintable()); + assert!(!sparse(PixelFormat::RGBA16, 8000, 8000).is_paintable(), "512 MB at 16 bits"); + assert!(!sparse(float, 4097, 4096).is_paintable(), "float has no smaller exact form"); + assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable()); + assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable()); + } + + #[test] + fn a_pattern_that_paints_nothing_says_why() { + let sparse = + |format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) }; + let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true); + assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).paint_problem(), None); + assert_eq!(sparse(PixelFormat::RGBA8, 0, 4).paint_problem().as_deref(), Some("it is empty")); + let reason = sparse(float, 5000, 5000).paint_problem().expect("float 5000 x 5000 paints nothing"); + assert!(reason.contains("5000 × 5000") && reason.contains("32 bits") && reason.contains("16777216"), "{reason}"); + let reason = sparse(PixelFormat::RGBA16, 8000, 8000).paint_problem().expect("16-bit 8000 x 8000 paints nothing"); + assert!(reason.contains("16 bits") && reason.contains("33554432"), "{reason}"); + } } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a4b9ca2fa3..055444c1e9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -101,6 +101,11 @@ pub struct CharStyle { pub variations: Vec, /// BCP-47 language tag (affects shaping and line breaking). pub language: Option, + /// Vertical type: roman text (Latin, digits) runs rotated 90° clockwise along the column + /// (PSD `BaselineDirection` 2) instead of standing upright one character per em + /// (`BaselineDirection` 1, what Photoshop's Vertical Type tool sets, #9470). CJK text stands + /// upright either way. + pub rotate_roman: bool, /// Applied character style (id in [`crate::TextStyles`]); `None` = "None". Attributes above /// stay fully resolved; differences from the style are local overrides. #[serde(skip_serializing_if = "Option::is_none")] @@ -135,6 +140,7 @@ impl Default for CharStyle { features: Vec::new(), variations: Vec::new(), language: None, + rotate_roman: false, style_sheet: None, } } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index a66aed8587..9dc8edf258 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -318,6 +318,17 @@ pub enum LiveShape { to: [f64; 2], weight: f64, }, + /// Editable vector arrow with parameters preserved by serde in the project document. + ArrowLine { + from: [f64; 2], + to: [f64; 2], + weight: f64, + start: bool, + end: bool, + width: f64, + length: f64, + concavity: f64, + }, } /// A shape layer: a filled and/or stroked path. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0db3487c56..4c48527a77 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -31,6 +31,7 @@ photocraft-io = { workspace = true } photocraft-psd = { workspace = true } photocraft-format = { workspace = true } photocraft-plugins = { workspace = true } +base64 = "0.22" blake3 = { version = "1.8", default-features = false, features = ["pure"] } photocraft-codecs = { workspace = true } flate2 = { workspace = true } @@ -60,6 +61,7 @@ photocraft-ipp = { workspace = true } [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } +photocraft-io = { workspace = true, features = ["testgen"] } # Synthetic camera raw files for the open tests. photocraft-raw = { workspace = true, features = ["testgen"] } photocraft-testkit = { workspace = true } diff --git a/crates/engine/examples/bench_dry_mixer.rs b/crates/engine/examples/bench_dry_mixer.rs new file mode 100644 index 0000000000..539053022f --- /dev/null +++ b/crates/engine/examples/bench_dry_mixer.rs @@ -0,0 +1,86 @@ +//! Release benchmark for the live Mixer Brush on a 24 MP document. +//! Setup and checksum are outside the timed command. Run with +//! `cargo run --release -p photocraft-engine --example bench_dry_mixer`. + +use std::time::Instant; + +use photocraft_engine::{EngineError, Session}; +use photocraft_geom::Rect; +use serde_json::json; + +const W: i32 = 6000; +const H: i32 = 4000; +const SIZES: [u32; 3] = [32, 128, 512]; +const REPEATS: usize = 4; + +fn fingerprint(session: &Session) -> Result> { + let state = session.active().ok_or("no active document")?; + let id = state.active_layer.ok_or("no active layer")?; + let surface = state.doc.layer(id).and_then(|layer| layer.surface()).ok_or("no raster surface")?; + let pixels = surface.read_region(Rect::new(0, 0, W, H)); + let mut hash = blake3::Hasher::new(); + for channel in pixels { + hash.update(&channel.to_le_bytes()); + } + hash.update(&serde_json::to_vec(&session.tools.mixer)?); + Ok(hash.finalize().to_hex().to_string()) +} + +fn run() -> Result<(), Box> { + for wet in [0, 100] { + for sample_all in [false, true] { + for size in SIZES { + let mut session = Session::new(); + session.execute("file.new", json!({"width": W, "height": H, "depth": 8, "mode": "rgb"}))?; + session.execute("layer.new.layer", json!({}))?; + session.edit("benchmark source", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no source layer".into()))?; + let surface = doc.layer_mut(id).and_then(|layer| layer.surface_mut()).ok_or_else(|| EngineError::Other("no source pixels".into()))?; + for x in (0..W).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.25, 0.08, 1.0] } else { [0.08, 0.35, 0.9, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(W), H), &color); + } + Ok(()) + })?; + session.execute("layer.new.layer", json!({}))?; + + let params = json!({ + "points": [[1200, 2000], [4800, 2000]], + "size": size, + "spacing": 0.25, + "wet": wet, + "load": 50, + "mix": 50, + "flow": 100, + "sampleAllLayers": sample_all, + "cleanAfterStroke": true + }); + let mut timings = Vec::with_capacity(REPEATS); + let mut output = String::new(); + for repeat in 0..REPEATS { + let start = Instant::now(); + session.execute("paint.mixerBrush", params.clone())?; + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + if repeat > 0 { + timings.push(elapsed_ms); + } + output = fingerprint(&session)?; + session.execute("edit.undo", json!({}))?; + } + timings.sort_by(f64::total_cmp); + println!( + "wet={wet:3}% sample_all={sample_all:5} size={size:3} median_ms={:.2} min_ms={:.2} max_ms={:.2} blake3={output}", + timings[1], timings[0], timings[2] + ); + } + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/engine/examples/bench_live_dab.rs b/crates/engine/examples/bench_live_dab.rs new file mode 100644 index 0000000000..3e98a44789 --- /dev/null +++ b/crates/engine/examples/bench_live_dab.rs @@ -0,0 +1,119 @@ +//! Live retouch latency on a materialized 24 MP layer. Run the same example at base and head +//! in release mode on one machine. Setup, Undo and the full output fingerprint are not timed. +//! This measures engine stroke work; canvas compositing/upload and presentation are excluded. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab}; +use photocraft_geom::Rect; +use serde_json::json; + +fn elapsed_ms(start: Instant) -> f64 { + start.elapsed().as_secs_f64() * 1000.0 +} + +fn median(values: &mut [f64]) -> f64 { + values.sort_by(f64::total_cmp); + values.get(values.len() / 2).copied().unwrap_or(0.0) +} + +fn run() -> Result<(), Box> { + // Four pointer samples per 60 Hz frame, ~1000 px/s, over a 960 px stroke. + // Large soft tips also exercise pixels beyond the canvas edges. + let points: Vec<_> = (0..=240).map(|i| StrokePoint::new(2500.0 + f64::from(i) * 4.0, 2000.0, 1.0)).collect(); + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?; + s.execute("layer.new.layer", json!({}))?; + s.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + let fmt = surface.format(); + for x in (0..6000).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 0.7] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &photocraft_raster::from_rgba(&fmt, color)); + } + Ok(()) + })?; + for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] { + for size in [300, 600, 1000, 2500] { + for hardness in [0, 100] { + let params = json!({ + "points":points.iter().map(|q| [q.x, q.y]).collect::>(), + "size":size,"hardness":hardness,"strength":50,"spacing":25 + }); + let mut start_params = params.clone(); + start_params["points"] = json!([[2500, 2000]]); + let (mut draws, mut worst_frames, mut releases) = (Vec::new(), Vec::new(), Vec::new()); + let mut fingerprint = None; + // One warm-up followed by three measured strokes, always starting from the same pixels. + for sample in 0..4 { + let start = Instant::now(); + let mut live = LiveDab::begin(&s, cmd, &start_params)?; + let mut draw = elapsed_ms(start); + let mut worst = draw; + for batch in points.get(1..).unwrap_or_default().chunks(4) { + let start = Instant::now(); + live.push(batch)?; + let duration = elapsed_ms(start); + draw += duration; + worst = worst.max(duration); + } + let bounds = live.bounds(); + let start = Instant::now(); + s.offer_live_dab(live)?; + let result = s.execute(cmd, params.clone())?; + let release = elapsed_ms(start); + if result.get("fromLive").and_then(|v| v.as_bool()) != Some(true) { + return Err("the commit did not take the live pixels".into()); + } + let st = s.active().ok_or("no document")?; + let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?; + // Include finishing dabs, without depending on a new API unavailable at base. + let damage = result.get("damage").and_then(|v| v.as_array()).ok_or("missing damage")?; + if damage.len() != 4 { + return Err("invalid damage length".into()); + } + let mut coords = [0i32; 4]; + for (coord, value) in coords.iter_mut().zip(damage) { + *coord = i32::try_from(value.as_i64().ok_or("invalid damage")?)?; + } + let [x, y, width, height] = coords; + let bounds = bounds.union(&Rect::new(x, y, x.saturating_add(width), y.saturating_add(height))); + let mut hash = 0xcbf29ce484222325u64; + // Hash every channel of the modified rectangle, one row at a time to bound memory. + for y in bounds.y0..bounds.y1 { + for channel in surface.read_region(Rect::new(bounds.x0, y, bounds.x1, y + 1)) { + hash = (hash ^ u64::from(channel.to_bits())).wrapping_mul(0x100000001b3); + } + } + if fingerprint.is_some_and(|previous| previous != hash) { + return Err("repeated strokes produced different pixels".into()); + } + fingerprint = Some(hash); + s.execute("edit.undo", json!({}))?; + if sample > 0 { + draws.push(draw); + worst_frames.push(worst); + releases.push(release); + } + } + println!( + "{cmd} depth={depth} size={size} hardness={hardness} draw_ms={:.3} worst_frame_ms={:.3} release_ms={:.3} fingerprint={:016x}", + median(&mut draws), + median(&mut worst_frames), + median(&mut releases), + fingerprint.unwrap_or_default() + ); + } + } + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/engine/examples/bench_selection_inverse.rs b/crates/engine/examples/bench_selection_inverse.rs new file mode 100644 index 0000000000..00f5aaa037 --- /dev/null +++ b/crates/engine/examples/bench_selection_inverse.rs @@ -0,0 +1,109 @@ +//! End-to-end timing of `select.inverse` on a 6000×4000 document. +//! +//! ```sh +//! cargo run --release -p photocraft-engine --example bench_selection_inverse -- [--quick] [--reps 11] +//! ``` +//! +//! Three rows: inverting a rectangular marquee, an elliptical marquee (central 2/3 of the +//! canvas) and `select.all`. Setup is never timed — only the `execute("select.inverse")` +//! call between `Instant`s. After the timed op (before deselect) the resulting selection +//! surface is inspected for storage diagnostics: unique tile payloads (distinct `Arc` +//! allocations behind the coverage surface) and allocated tile count. + +use std::collections::HashSet; +use std::time::Instant; + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +type Res = Result<(), Box>; + +fn arg<'a>(args: &'a [String], name: &str) -> Option<&'a String> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)) +} + +/// Nearest-rank percentile of a pre-sorted slice. +fn pct(sorted: &[f64], p: f64) -> f64 { + let n = sorted.len(); + let idx = ((p / 100.0) * n as f64).ceil() as usize; + sorted.get(idx.saturating_sub(1).min(n.saturating_sub(1))).copied().unwrap_or(0.0) +} + +/// Selection setup for one row; each previous sample already deselected. +fn setup(s: &mut Session, w: i32, h: i32, ellipse: Option) -> Result> { + let params = match ellipse { + Some(ellipse) => { + // Central 2/3 of the canvas. + let x = w / 6; + let y = h / 6; + json!({ + "x": x, + "y": y, + "width": w.saturating_mul(2) / 3, + "height": h.saturating_mul(2) / 3, + "ellipse": ellipse, + }) + } + None => json!({}), // select.all + }; + let id = if ellipse.is_some() { "select.rect" } else { "select.all" }; + let v = s.execute(id, params)?; + Ok(v) +} + +/// Storage diagnostics of the current selection surface: distinct tile payloads and tiles held. +fn diagnostics(s: &Session) -> Result<(usize, usize), Box> { + let Some(state) = s.active() else { + return Err("no active document after select.inverse".into()); + }; + let Some(sel) = state.doc.selection.as_ref() else { + return Err("no selection after select.inverse".into()); + }; + let mut payloads: HashSet = HashSet::new(); + let mut count = 0usize; + for (_, tile) in sel.tiles() { + payloads.insert(std::sync::Arc::as_ptr(tile) as usize); + count += 1; + } + Ok((payloads.len(), count)) +} + +/// One measured sample: setup, time ONLY `select.inverse`, print diagnostics, deselect. +fn sample(s: &mut Session, w: i32, h: i32, ellipse: Option) -> Result<(f64, usize, usize), Box> { + setup(s, w, h, ellipse)?; + let t = Instant::now(); + s.execute("select.inverse", json!({}))?; + let ms = t.elapsed().as_secs_f64() * 1000.0; + let (payloads, tiles) = diagnostics(s)?; + s.execute("select.deselect", json!({}))?; + Ok((ms, payloads, tiles)) +} + +fn main() -> Res { + let args: Vec = std::env::args().collect(); + let (w, h) = if args.iter().any(|a| a == "--quick") { (1200, 800) } else { (6000, 4000) }; + let reps = arg(&args, "--reps").and_then(|v| v.parse::().ok()).unwrap_or(11).clamp(1, 100); + + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "transparent"}))?; + + let rows: [(&str, Option); 3] = [("Inverse rectangular", Some(false)), ("Inverse ellipse", Some(true)), ("Inverse all", None)]; + for (label, ellipse) in rows { + // Warm-up: one untimed sample to page in allocations and caches. + sample(&mut s, w, h, ellipse)?; + let mut times: Vec = Vec::with_capacity(reps); + for _ in 0..reps { + let (ms, payloads, tiles) = sample(&mut s, w, h, ellipse)?; + times.push(ms); + println!("sample: {ms:8.2} ms (selection tiles: {payloads} unique payloads, {tiles} allocated)"); + } + times.sort_by(|a, b| a.total_cmp(b)); + println!( + "{label:<22} ({w}×{h}, {reps} reps) p50 {p50:8.2} ms p95 {p95:8.2} ms max {max:8.2} ms", + p50 = pct(×, 50.0), + p95 = pct(×, 95.0), + max = pct(×, 100.0), + ); + } + Ok(()) +} diff --git a/crates/engine/examples/bench_sharpen_tool.rs b/crates/engine/examples/bench_sharpen_tool.rs new file mode 100644 index 0000000000..5a5788b443 --- /dev/null +++ b/crates/engine/examples/bench_sharpen_tool.rs @@ -0,0 +1,62 @@ +//! Reproducible soft Sharpen strokes on a 24 MP striped document. Run in release mode. +//! Setup, Undo and the output fingerprint are excluded from the timing. +use std::time::Instant; + +use photocraft_engine::{EngineError, Session}; +use photocraft_geom::Rect; +use serde_json::json; + +fn run() -> Result<(), Box> { + for depth in [8, 16] { + let mut session = Session::new(); + session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?; + session.execute("layer.new.layer", json!({}))?; + session.edit("stripes", |doc, active| { + let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?; + let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?; + for x in (0..6000).step_by(64) { + let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] }; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color); + } + Ok(()) + })?; + for size in [100, 500, 1000] { + let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25,"protectDetail":true}); + let mut times = Vec::new(); + let mut fingerprint = 0u64; + for sample in 0..6 { + let start = Instant::now(); + session.execute("paint.sharpen", params.clone())?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + if sample > 0 { + times.push(ms); + } + if sample == 5 { + let st = session.active().ok_or("no document")?; + let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?; + for y in (1500..2500).step_by(17) { + for x in (2000..4000).step_by(13) { + for channel in surface.rgba(x, y) { + fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits()); + } + } + } + } + session.execute("edit.undo", json!({}))?; + } + times.sort_by(f64::total_cmp); + println!( + "depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}", + times[2], times[0], times[4] + ); + } + } + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/crates/engine/examples/indexed_color_perf.rs b/crates/engine/examples/indexed_color_perf.rs index 6dba948ff2..112f6eb0d9 100644 --- a/crates/engine/examples/indexed_color_perf.rs +++ b/crates/engine/examples/indexed_color_perf.rs @@ -25,7 +25,7 @@ fn main() -> Result<(), Box> { let path = std::env::args().nth(1).ok_or("provide a local image path")?; let bytes = photocraft_format::read::read_file(std::path::Path::new(&path))?; let doc = photocraft_io::import(&path, &bytes)?.document; - let px = photocraft_compose::flatten(&doc).px; + let px = photocraft_compose::flatten(&doc).into_px(); println!("{}", json!({"width":doc.size.width,"height":doc.size.height,"preview_factor":1})); for count in [8usize, 16, 32, 64, 128, 256] { for run in 0..4 { diff --git a/crates/engine/examples/multi_channel_bench.rs b/crates/engine/examples/multi_channel_bench.rs new file mode 100644 index 0000000000..dc3a8a33d8 --- /dev/null +++ b/crates/engine/examples/multi_channel_bench.rs @@ -0,0 +1,112 @@ +//! Interleaved 24 MP command timings. The same harness runs on the pre-change base. +//! `cargo run --release -p photocraft-engine --example multi_channel_bench -- --subset` +use photocraft_engine::{Session, brush_cmds::LiveStroke}; +use photocraft_geom::Rect; +use photocraft_paint::StrokePoint; +use serde_json::{Value, json}; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let subset = std::env::args().any(|a| a == "--subset"); + let region = std::env::args().any(|a| a == "--region"); + let clipboard = std::env::args().any(|a| a == "--clipboard"); + if region && clipboard { + return Err("choose --region or --clipboard".into()); + } + let mut fixture = Session::new(); + fixture.execute("file.new", json!({"width":6000,"height":4000,"depth":16,"background":"transparent"}))?; + fixture.execute("layer.new.layer", json!({}))?; + fixture.edit("synthetic bands", |doc, id| { + let surf = doc + .layer_mut(id.ok_or(photocraft_engine::EngineError::NoDocument)?) + .and_then(|l| l.surface_mut()) + .ok_or(photocraft_engine::EngineError::NoDocument)?; + for x in (0..6000).step_by(64) { + let value = 0.15 + (x % 640) as f32 / 1000.0; + surf.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &[value, 0.4, 1.0 - value, 0.8]); + } + Ok(()) + })?; + let layer = fixture.active().and_then(|st| st.active_layer).ok_or(photocraft_engine::EngineError::NoDocument)?; + let base = fixture.active().ok_or(photocraft_engine::EngineError::NoDocument)?.doc.clone(); + let commands: [(&str, Value); 9] = [ + ("image.adjustments.invert", json!({})), + ("image.adjustments.threshold", json!({"level":128})), + ("image.adjustments.levels", json!({"gamma":1.4})), + ("image.adjustments.curves", json!({"points":[[0,0],[100,145],[255,255]]})), + ("filter.blur.gaussianBlur", json!({"radius":8})), + ("paint.stroke", json!({"points":[[2000,1500,1],[2500,1700,0.8],[3000,1600,1]],"size":120,"color":"#ffffff","seed":42})), + ("paint.stroke", json!({"points":[[2000,1500,1],[2500,1700,0.8],[3000,1600,1]],"size":120,"erase":true,"seed":42})), + ("live.brush.extend", json!({"points":[[2000,1500,1]],"size":120,"color":"#ffffff","seed":42})), + ("live.eraser.extend", json!({"points":[[2000,1500,1]],"size":120,"erase":true,"seed":42})), + ]; + let modes = if clipboard { + if subset { vec!["single", "subset"] } else { vec!["single"] } + } else if subset { + vec!["composite", "single", "subset"] + } else { + vec!["composite", "single"] + }; + let clipboard_cases = [("edit.paste", json!({}))]; + let cases = if clipboard { &clipboard_cases[..] } else { &commands[..if region { 5 } else { commands.len() }] }; + let mut rows = Vec::new(); + for (index, (command, params)) in cases.iter().enumerate() { + let mut samples = vec![Vec::new(); modes.len()]; + for run in 0..7 { + for offset in 0..modes.len() { + let which = (run + offset) % modes.len(); + let mut s = Session::new(); + s.add_document((*base).clone(), None); + s.select_layer(layer)?; + match modes.get(which).copied().unwrap_or("composite") { + "single" => { + s.execute("channel.target", json!({"channel":{"color":0}}))?; + } + "subset" => { + s.execute("channel.target", json!({"colors":[0,2]}))?; + } + _ => {} + } + if clipboard { + s.execute("select.rect", json!({"x":2000,"y":1500,"width":1,"height":1}))?; + s.execute("edit.copy", json!({}))?; + s.execute("select.deselect", json!({}))?; + } + if region { + s.execute("select.rect", json!({"x":2000,"y":1500,"width":16,"height":16}))?; + } + let ms = if command.starts_with("live.") { + let mut stroke = LiveStroke::begin(&s, params)?; + let start = Instant::now(); + stroke.push(&[StrokePoint::new(2050.0, 1510.0, 0.8)])?; + start.elapsed().as_secs_f64() * 1000.0 + } else { + let start = Instant::now(); + s.execute(command, params.clone())?; + start.elapsed().as_secs_f64() * 1000.0 + }; + if run > 0 + && let Some(sample) = samples.get_mut(which) + { + sample.push(ms); + } + } + } + for (mode, mut samples) in modes.iter().zip(samples) { + let raw = samples.clone(); + samples.sort_by(f64::total_cmp); + let median = samples.get(samples.len() / 2).copied().unwrap_or(0.0); + let p95 = samples.last().copied().unwrap_or(0.0); + let row = json!({"command":command,"case":index,"target":mode,"p50_ms":median,"p95_ms":p95,"samples_ms":raw}); + eprintln!("{row}"); + rows.push(row); + } + } + println!( + "{}", + serde_json::to_string_pretty( + &json!({"size":[6000,4000],"depth":16,"clipboard_pixel":clipboard,"selection":if region {json!([2000,1500,16,16])}else{Value::Null},"warmups":1,"repeats":6,"rows":rows}) + )? + ); + Ok(()) +} diff --git a/crates/engine/src/actions_record.rs b/crates/engine/src/actions_record.rs new file mode 100644 index 0000000000..4bfb54befa --- /dev/null +++ b/crates/engine/src/actions_record.rs @@ -0,0 +1,148 @@ +//! Document-independent recording (#219). +//! +//! The journal is what Actions record, so an entry must mean the same thing on another document. +//! The UI names the layer it clicked by id (`"layer": 7`); ids are per document, so a step kept +//! that way replays only on the document it came from. Before an entry is journaled: +//! +//! - a `layer` that was the active (and only selected) layer when the command ran is dropped: +//! the step acts on the active layer, as Photoshop's "current layer" does; +//! - any other `layer` id becomes the layer's name when the name is unique in the document, and +//! `layer.select` always does (selecting is choosing a layer by name, as Photoshop records +//! it); an ambiguous name keeps the id, which still replays on the recording document; +//! - the Type tool's keystrokes fold into the `type.create` that made the layer. On an existing +//! layer, each replacement stays separate so replay keeps style runs between edited spans. +//! +//! Ids are still accepted everywhere; only what the journal keeps changes. + +use std::sync::Arc; + +use photocraft_doc::{DocId, Document, LayerContent, LayerId}; +use serde_json::Value; + +use crate::Session; + +/// The active document as it was before a command ran, captured by the dispatcher. +#[derive(Clone, Default)] +pub(crate) struct Before { + doc: Option>, + active_layer: Option, + /// Every selected layer: a `layer` that is one of several stays explicit, because without it + /// a layer command acts on the whole selection ([`crate::layer_multi_cmds::targets`]). + selected: Vec, +} + +impl Before { + pub(crate) fn capture(s: &Session) -> Self { + match s.active() { + Some(st) => Self { doc: Some(st.doc.clone()), active_layer: st.active_layer, selected: st.selected_layers() }, + None => Self::default(), + } + } +} + +/// Commands whose explicit `layer` names source content rather than a default target. +pub(crate) fn needs_source_layer(id: &str) -> bool { + matches!(id, "layer.new.frame" | "select.loadSelection" | "type.createSelection" | "edit.checkSpelling") +} + +/// `params` with its `layer` id made portable (see the module doc). +pub(crate) fn portable(s: &Session, id: &str, mut params: Value, before: &Before) -> Value { + if id == "document.activate" + && let (Some(from), Some(to)) = (before.doc.as_ref().map(|doc| doc.id), s.active().map(|state| state.doc.id)) + && let Some(offset) = s.document_offset_between(from, to) + { + return serde_json::json!({"offset": offset}); + } + let Some(doc) = before.doc.as_deref() else { return params }; + if id == "type.create" && s.actions.recording.is_some() && params.get("box").is_none() && doc.size.width > 0 && doc.size.height > 0 { + let x = params.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = params.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let x_percent = x / f64::from(doc.size.width) * 100.0; + let y_percent = y / f64::from(doc.size.height) * 100.0; + if x_percent.is_finite() + && y_percent.is_finite() + && let Some(object) = params.as_object_mut() + { + object.remove("x"); + object.remove("y"); + object.insert("xPercent".into(), serde_json::json!(x_percent)); + object.insert("yPercent".into(), serde_json::json!(y_percent)); + } + } + let Some(target) = params.get("layer").and_then(Value::as_u64).map(LayerId) else { return params }; + let Some(obj) = params.as_object_mut() else { return params }; + let only_selected = before.selected.len() <= 1; + if id != "layer.select" && !needs_source_layer(id) && before.active_layer == Some(target) && only_selected { + obj.remove("layer"); + return params; + } + if let Some(layer) = doc.layer(target) + && !(id == "select.loadSelection" && (layer.name.is_empty() || layer.name == "merged")) + && doc.walk().iter().filter(|(_, _, l)| l.name == layer.name).count() == 1 + { + obj.insert("layer".into(), Value::String(layer.name.clone())); + } + params +} + +/// The text layer a journaled `type.create` made or `type.edit` changed, so the keystrokes that +/// follow fold into that entry only when they type into the same layer (the Type tool selects +/// a clicked layer without a command, so the journal alone cannot tell). +pub(crate) fn typed_layer(s: &Session, id: &str, params: &Value) -> Option<(DocId, LayerId)> { + let doc_id = s.active()?.doc.id; + match id { + "type.create" => s.active()?.active_layer.map(|layer| (doc_id, layer)), + "type.edit" => crate::commands::layer_param(s, params).ok().map(|layer| (doc_id, layer)), + _ => None, + } +} + +/// What the journal keeps of a Type-tool keystroke. +pub(crate) enum Keystroke { + /// Not a keystroke: the entry is journaled as it is. + None, + /// Folded into the entry before it. + Folded, +} + +/// Fold a Type-tool keystroke into its creation step only. A whole-text step on an existing +/// layer can collapse distinct style runs between separate edited spans on replay (#4514). +/// `previous` is the layer the last entry typed into. +pub(crate) fn fold_keystroke(s: &mut Session, id: &str, params: &Value, previous: Option<(DocId, LayerId)>) -> Keystroke { + if id != "type.edit" { + return Keystroke::None; + } + let Some(obj) = params.as_object() else { return Keystroke::None }; + if obj.get("coalesce").and_then(Value::as_str).is_none() { + return Keystroke::None; + } + if !obj.contains_key("replace") || !obj.keys().all(|k| matches!(k.as_str(), "layer" | "replace" | "coalesce")) { + return Keystroke::None; + } + let Ok(target) = crate::commands::layer_param(s, params) else { return Keystroke::None }; + // The text as it is now, after this keystroke: the step sets it whole. + let Some(text) = s.active().and_then(|st| st.doc.layer(target)).and_then(|l| match &l.content { + LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) else { + return Keystroke::None; + }; + if previous != s.active().map(|st| (st.doc.id, target)) { + return Keystroke::None; + } + // A new recording starts at a journal boundary. Its first edit cannot change an entry + // before that boundary, or actions.stop would miss the edit altogether. + if s.actions.recording.is_some_and(|(start, _)| s.journal.len() <= start) { + return Keystroke::None; + } + let Some((last, lp)) = s.journal.last_mut() else { return Keystroke::None }; + if last != "type.create" || obj.contains_key("layer") { + return Keystroke::None; + } + let Some(lo) = lp.as_object_mut() else { return Keystroke::None }; + lo.insert("text".into(), Value::String(text)); + Keystroke::Folded +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/actions_record/tests.rs b/crates/engine/src/actions_record/tests.rs new file mode 100644 index 0000000000..dfffc2c08f --- /dev/null +++ b/crates/engine/src/actions_record/tests.rs @@ -0,0 +1,380 @@ +use photocraft_doc::LayerId; +use serde_json::{Value, json}; + +use crate::jobs::Started; +use crate::{EngineError, Session}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + s +} + +fn new_layer(s: &mut Session, name: &str) -> u64 { + s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap() +} + +fn last(s: &Session) -> &(String, Value) { + s.journal.last().unwrap() +} + +fn background(s: &Session) -> u64 { + s.active().unwrap().doc.layers[0].id.0 +} + +#[test] +fn the_active_layer_is_implicit() { + let mut s = session(); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": shade, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s), &("layer.setProps".into(), json!({"opacity": 0.5})), "the active layer's id is dropped"); + assert_eq!(s.active().unwrap().doc.layer(LayerId(shade)).unwrap().opacity, 0.5, "the command still ran on it"); +} + +#[test] +fn another_layer_is_named() { + let mut s = session(); + let bg = background(&s); + new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap(); + assert_eq!(last(&s), &("layer.setProps".into(), json!({"layer": "Background", "visible": false}))); +} + +#[test] +fn selecting_a_layer_is_always_by_name() { + let mut s = session(); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.select", json!({"layer": shade})).unwrap(); + assert_eq!(last(&s), &("layer.select".into(), json!({"layer": "Shade"})), "even though it was the active layer"); + s.execute("layer.select", json!({"layer": shade, "mode": "toggle"})).unwrap(); + assert_eq!(last(&s).1["mode"], "toggle", "the other params are kept"); +} + +#[test] +fn an_ambiguous_name_keeps_the_id() { + let mut s = session(); + let a = new_layer(&mut s, "Copy"); + let b = new_layer(&mut s, "Copy"); + s.execute("layer.setProps", json!({"layer": a, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s), &("layer.setProps".into(), json!({"layer": a, "opacity": 0.5})), "two layers are called Copy"); + s.execute("layer.setProps", json!({"layer": b, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s).1, json!({"opacity": 0.5}), "the active one is still implicit"); +} + +#[test] +fn one_of_several_selected_layers_stays_named() { + let mut s = session(); + let bg = background(&s); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.select", json!({"layer": bg, "mode": "add"})).unwrap(); + assert_eq!(s.active().unwrap().selected_layers().len(), 2); + let active = s.active().unwrap().active_layer.unwrap().0; + let name = if active == shade { "Shade" } else { "Background" }; + s.execute("layer.setProps", json!({"layer": active, "opacity": 0.5})).unwrap(); + assert_eq!(last(&s).1, json!({"layer": name, "opacity": 0.5}), "without the layer, a layer command acts on the whole selection"); +} + +#[test] +fn a_name_or_no_document_is_left_alone() { + let mut s = session(); + new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": "Background", "visible": false})).unwrap(); + assert_eq!(last(&s).1, json!({"layer": "Background", "visible": false})); + let e = s.execute("layer.setProps", json!({"layer": 999, "visible": false})).unwrap_err(); + assert!(matches!(e, EngineError::UnknownLayer { .. }), "{e}"); +} + +#[test] +fn a_background_job_journals_the_same_way() { + let mut s = session(); + let shade = new_layer(&mut s, "Shade"); + let id = match s.start("filter.blur.gaussianBlur", json!({"layer": shade, "radius": 2})).unwrap() { + Started::Job(id) => id, + Started::Done(v) => panic!("ran inline: {v}"), + }; + s.wait_job(id).unwrap(); + assert_eq!(last(&s), &("filter.blur.gaussianBlur".into(), json!({"radius": 2}))); +} + +#[test] +fn frame_action_keeps_the_layer_it_wraps() { + let mut s = session(); + let picture = new_layer(&mut s, "Picture"); + s.execute("actions.record", json!({"name": "Frame picture"})).unwrap(); + s.execute("layer.new.frame", json!({"x": 1, "y": 1, "width": 20, "height": 20, "layer": picture})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Frame picture"})).unwrap()["steps"].clone(); + assert_eq!(steps[0][1]["layer"], "Picture", "the frame must wrap the recorded source layer"); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + new_layer(&mut s, "Spare"); + let target = new_layer(&mut s, "Picture"); + assert_ne!(target, picture, "the replay target has a different document-local id"); + let played = s.execute("actions.play", json!({"action": "Frame picture"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!( + s.active().unwrap().doc.walk().iter().any( + |(_, _, layer)| matches!(&layer.content, photocraft_doc::LayerContent::Group(group) if group.children.iter().any(|child| child.id.0 == target)) + ), + "the replayed frame wraps Picture" + ); +} + +#[test] +fn selection_actions_keep_the_active_layer_as_their_source() { + let mut s = session(); + let raster = new_layer(&mut s, "Pixels"); + s.execute("actions.record", json!({"name": "Load pixels"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency", "layer": raster})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Load pixels"})).unwrap()["steps"].clone(); + assert_eq!(steps[0][1]["layer"], json!({"name": "Pixels"}), "missing layer loads merged pixels instead"); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + new_layer(&mut s, "Pixels"); + let played = s.execute("actions.play", json!({"action": "Load pixels"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!( + s.active().unwrap().doc.selection.as_ref().map(|mask| mask.content_bounds()).unwrap_or(photocraft_geom::Rect::EMPTY), + photocraft_geom::Rect::EMPTY, + "the empty source layer must not load the opaque merged document" + ); + + let type_id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let type_name = s.active().unwrap().doc.layer(LayerId(type_id)).unwrap().name.clone(); + s.execute("actions.record", json!({"name": "Mask text"})).unwrap(); + s.execute("type.createSelection", json!({"layer": type_id, "removeLayer": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Mask text"})).unwrap()["steps"].clone(); + assert_eq!(steps[0][1]["layer"], type_name, "without the source, replay cannot remove that type layer"); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + new_layer(&mut s, "Spare"); + let replay_type = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(replay_type, type_id); + let played = s.execute("actions.play", json!({"action": "Mask text"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.as_ref().is_some_and(|mask| !mask.content_bounds().is_empty()), "replay selects the glyphs"); + assert!(s.active().unwrap().doc.layer(LayerId(replay_type)).is_none(), "replay removed the named source type layer"); +} + +#[test] +fn named_nonactive_smart_layer_passes_admission_and_converts_to_layers() { + let mut s = session(); + s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap(); + let smart = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + let name = s.active().unwrap().doc.layer(LayerId(smart)).unwrap().name.clone(); + let top = new_layer(&mut s, "Top"); + let restored = s.execute("layer.smartObjects.convertToLayers", json!({"layer": name})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.active().unwrap().doc.layer(LayerId(smart)).is_none()); + assert!(s.active().unwrap().doc.layer(LayerId(top)).is_some()); + assert!(s.active().unwrap().doc.layer(LayerId(restored)).is_some(), "the restored layer is present"); + let error = s.execute("layer.smartObjects.convertToLayers", json!({"layer": "Removed smart"})).unwrap_err(); + assert!(error.to_string().contains("Removed smart"), "missing named layer should be identified: {error}"); +} + +#[test] +fn named_filter_mask_target_uses_the_recorded_smart_layer() { + let mut s = session(); + s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap(); + let smart = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + let name = s.active().unwrap().doc.layer(LayerId(smart)).unwrap().name.clone(); + new_layer(&mut s, "Top"); + assert_eq!(crate::channel_cmds::filter_mask_layer(&s, "edit.fill", &json!({"target": "filterMask", "layer": name})), Some(LayerId(smart))); +} + +#[test] +fn named_nonactive_layer_is_the_one_converted_to_smart() { + let mut s = session(); + let target = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let name = s.active().unwrap().doc.layer(LayerId(target)).unwrap().name.clone(); + new_layer(&mut s, "Top"); + s.execute("actions.record", json!({"name": "Convert source"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": target})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Convert source"})).unwrap()["steps"].clone(); + assert_eq!(steps[0][1]["layer"], name, "the journal names the explicit source"); + + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let replay_target = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let top = new_layer(&mut s, "Top"); + let played = s.execute("actions.play", json!({"action": "Convert source"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.layer(LayerId(replay_target)).is_none(), "replay removes the named source"); + assert!(s.active().unwrap().doc.layer(LayerId(top)).is_some(), "replay keeps the selected top layer"); + assert!( + s.active().unwrap().doc.walk().iter().any(|(_, _, layer)| matches!(layer.content, photocraft_doc::LayerContent::Smart(_))), + "replay creates a smart object" + ); +} + +#[test] +fn merged_named_layer_keeps_its_id_as_a_selection_source() { + let mut s = session(); + let source = new_layer(&mut s, "merged"); + new_layer(&mut s, "Top"); + s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source})).unwrap(); + assert_eq!(last(&s).1["layer"], source, "the reserved name must not mean the merged document"); +} + +#[test] +fn keystrokes_fold_into_the_type_layer_they_made() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let n = s.journal.len(); + for (i, ch) in "Hey".chars().enumerate() { + s.execute("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": ch.to_string()}, "coalesce": "t1"})).unwrap(); + } + // Backspace, then more. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 3, "text": ""}, "coalesce": "t1"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "llo"}, "coalesce": "t1"})).unwrap(); + assert_eq!(s.journal.len(), n, "every keystroke folded into Make Type Layer"); + let (cmd, p) = last(&s); + assert_eq!(cmd, "type.create"); + assert_eq!(p["text"], "Hello"); + assert!(p.get("replace").is_none() && p.get("coalesce").is_none(), "{p}"); +} + +#[test] +fn keystrokes_in_an_existing_layer_keep_separate_replacements() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Hi", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.9})).unwrap(); + let n = s.journal.len(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 1, "text": "h"}, "coalesce": "t2"})).unwrap(); + assert_eq!(s.journal.len(), n + 2); + assert_eq!(s.journal[n].1, json!({"replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"})); + assert_eq!(last(&s).1, json!({"replace": {"start": 0, "end": 1, "text": "h"}, "coalesce": "t2"})); + // A new editing session starts a new step. + s.execute("type.edit", json!({"layer": id, "replace": {"start": 3, "end": 3, "text": "?"}, "coalesce": "t3"})).unwrap(); + assert_eq!(s.journal.len(), n + 3); + assert_eq!(last(&s).1, json!({"replace": {"start": 3, "end": 3, "text": "?"}, "coalesce": "t3"})); +} + +#[test] +fn action_replay_preserves_styles_between_separate_typing_edits() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "a M z", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.edit", json!({"layer": id, "runs": [{"start": 2, "end": 3, "size": 24}]})).unwrap(); + let replay = s.active().unwrap().doc.as_ref().clone(); + s.execute("actions.record", json!({"name": "Two typed edits"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 1, "text": "A"}, "coalesce": "typing"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 5, "text": "Z"}, "coalesce": "typing"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let original = s.active().unwrap().doc.layer(LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Text(original) = &original.content else { panic!("type layer") }; + let original = original.clone(); + assert_eq!(original.text, "A M Z"); + s.add_document(replay, None); + let played = s.execute("actions.play", json!({"action": "Two typed edits"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let copy = s.active().unwrap().doc.layer(LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Text(copy) = ©.content else { panic!("type layer") }; + assert_eq!(copy.text, original.text); + assert_eq!(copy.char_runs(), original.char_runs(), "the unchanged middle run keeps its style"); +} + +#[test] +fn recording_does_not_fold_into_a_step_from_before_recording_started() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Hi", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("actions.record", json!({"name": "Type more"})).unwrap(); + s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Type more"})).unwrap()["steps"].clone(); + assert_eq!(steps, json!([["type.edit", {"replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"}]])); +} + +#[test] +fn keystrokes_into_another_layer_do_not_fold() { + let mut s = session(); + let first = s.execute("type.create", json!({"x": 2, "y": 10, "text": "One", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let second = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + // The Type tool selects a clicked layer without a command. + s.select_layer(LayerId(first)).unwrap(); + let n = s.journal.len(); + s.execute("type.edit", json!({"layer": first, "replace": {"start": 3, "end": 3, "text": "!"}, "coalesce": "t4"})).unwrap(); + assert_eq!(s.journal.len(), n + 1, "typing into the first layer is its own step"); + assert_eq!(last(&s).1, json!({"replace": {"start": 3, "end": 3, "text": "!"}, "coalesce": "t4"})); + assert_eq!(s.journal[n - 1].1["text"], "", "the second Make Type Layer keeps its own text"); + // Programmatic edits (no session key) and other edits stand on their own. + s.execute("type.edit", json!({"layer": second, "replace": {"start": 0, "end": 0, "text": "x"}})).unwrap(); + assert!(last(&s).1["layer"].is_string() && last(&s).1.get("replace").is_some(), "{}", last(&s).1); +} + +#[test] +fn keystrokes_in_another_document_do_not_fold_into_the_previous_document() { + let mut s = session(); + let first = s.execute("type.create", json!({"x": 2, "y": 20, "text": "A", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let mut copy = s.active().unwrap().doc.as_ref().clone(); + if let Some(layer) = copy.layer_mut(LayerId(first)) + && let photocraft_doc::LayerContent::Text(text) = &mut layer.content + { + text.text = "B".into(); + } + s.add_document(copy, None); + let second = first; + let before = s.journal.len(); + s.execute("type.edit", json!({"layer": second, "replace": {"start": 1, "end": 1, "text": "!"}, "coalesce": "typing"})).unwrap(); + assert_eq!(s.journal.len(), before + 1, "typing in a different document needs a new journal step"); + assert_eq!(s.journal[before - 1].1["text"], "A"); + assert_eq!(last(&s).1["replace"], json!({"start": 1, "end": 1, "text": "!"})); +} + +#[test] +fn a_recording_replays_on_a_document_with_other_layer_ids() { + let mut s = session(); + let bg = background(&s); + s.execute("actions.record", json!({"name": "Shade"})).unwrap(); + let shade = new_layer(&mut s, "Shade"); + s.execute("layer.setProps", json!({"layer": shade, "opacity": 0.5})).unwrap(); + s.execute("layer.select", json!({"layer": bg})).unwrap(); + s.execute("edit.fill", json!({"layer": bg, "color": "#ff0000"})).unwrap(); + let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + for (i, ch) in "Hi".chars().enumerate() { + s.execute("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": ch.to_string()}, "coalesce": "t5"})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Shade"})).unwrap()["steps"].clone(); + let ids: Vec<&str> = steps.as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect(); + assert_eq!(ids, ["layer.new.layer", "layer.setProps", "layer.select", "edit.fill", "type.create"], "{steps}"); + assert!(steps.as_array().unwrap().iter().all(|st| st[1].get("layer").is_none_or(Value::is_string)), "no layer ids: {steps}"); + + // A second document whose layers have other ids and a layer stacked over the background. + s.execute("file.new", json!({"width": 40, "height": 40, "background": "black"})).unwrap(); + new_layer(&mut s, "Top"); + assert_ne!(background(&s), bg); + let played = s.execute("actions.play", json!({"action": "Shade"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let d = &s.active().unwrap().doc; + let shade = d.walk().into_iter().find(|(_, _, l)| l.name == "Shade").map(|(_, _, l)| l).unwrap(); + assert_eq!(shade.opacity, 0.5, "the new layer, not the one of the first document, got the opacity"); + let text = d.walk().into_iter().find_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }); + assert_eq!(text.as_deref(), Some("Hi"), "the typed text replayed as one step"); + assert!(s.documents()[0].doc.layer(LayerId(bg)).is_some()); +} + +#[test] +fn recorded_edit_targets_a_named_nonactive_type_layer_on_another_document() { + let mut s = session(); + let first = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Old", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": first, "name": "Caption"})).unwrap(); + new_layer(&mut s, "Top"); + s.execute("actions.record", json!({"name": "Edit caption"})).unwrap(); + s.execute("type.edit", json!({"layer": first, "text": "New"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps = s.execute("actions.get", json!({"action": "Edit caption"})).unwrap()["steps"].clone(); + assert_eq!(steps, json!([["type.edit", {"layer": "Caption", "text": "New"}]])); + + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let second = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Other", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": second, "name": "Caption"})).unwrap(); + new_layer(&mut s, "Top"); + assert_ne!(first, second); + let played = s.execute("actions.play", json!({"action": "Edit caption"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let layer = s.active().unwrap().doc.layer(LayerId(second)).unwrap(); + let photocraft_doc::LayerContent::Text(text) = &layer.content else { panic!("expected type layer") }; + assert_eq!(text.text, "New"); +} diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 0a4ed6faa4..6276e432a3 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -107,6 +107,46 @@ pub(crate) fn set_resolution(doc: &mut Document, dpi: f32) { }); } +/// Moves the type of `doc` (scratch copies on their way to another document) to `dpi` keeping its +/// pixels, as the reference application does when a type layer is copied between documents of +/// different resolutions: every size in points is rewritten by old / new (24 pt at 72 ppi is +/// 5.76 pt at 300 ppi), so `pt x dpi / 72` pixels stay the same and the layer gets no +/// compensating transform. Box, path and transform are in pixels and stay; tracking and kerning +/// (1/1000 em) and auto leading (a multiple of the size) follow the size. +pub(crate) fn retarget_type_resolution(doc: &mut Document, dpi: f32) { + // The text engine lays out a resolution that isn't a positive number at 72 ppi. + let effective = |d: f32| if d.is_finite() && d > 0.0 { f64::from(d) } else { 72.0 }; + let old = std::mem::replace(&mut doc.resolution_dpi, dpi); + let k = effective(old) / effective(dpi); + if !(k.is_finite() && k > 0.0) || k == 1.0 { + return; + } + crate::image_cmds::for_each_layer(&mut doc.layers, &mut |l| { + let LayerContent::Text(t) = &mut l.content else { return }; + t.size_pt = point_size(t.size_pt, k); + for r in &mut t.runs { + let st = &mut r.style; + st.size_pt = point_size(st.size_pt, k); + st.leading_pt = st.leading_pt.map(|v| point_size(v, k)); + st.baseline_shift_pt = grown(st.baseline_shift_pt, k); + } + for p in &mut t.paragraphs { + let st = &mut p.style; + for v in [&mut st.first_line_indent_pt, &mut st.start_indent_pt, &mut st.end_indent_pt, &mut st.space_before_pt, &mut st.space_after_pt] { + *v = grown(*v, k); + } + } + retag_text(dpi, t); + }); +} + +/// A positive size in points times `k`, kept finite and above zero (a tiny ratio must not make +/// the text vanish). +fn point_size(v: f32, k: f64) -> f32 { + let out = grown(v, k); + if v > 0.0 && out <= 0.0 { f32::MIN_POSITIVE } else { out } +} + /// `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 diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs index 473a82e062..78a7a141c3 100644 --- a/crates/engine/src/canvas_geom/tests.rs +++ b/crates/engine/src/canvas_geom/tests.rs @@ -239,7 +239,7 @@ fn shape_gradient_angles_after(cmd: &str, p: serde_json::Value) -> (f32, f32) { fn shape_gradient_stroke_turns_with_the_canvas() { let same = |a: f32, b: f32| { let d = (a - b).rem_euclid(360.0); - d < 1e-3 || d > 360.0 - 1e-3 + d.is_finite() && !(1e-3..=360.0 - 1e-3).contains(&d) }; for (cmd, p, want) in [ ("image.imageRotation.90cw", json!({}), -90.0), diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index 7823fdb18a..3b64b10ca2 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -32,22 +32,30 @@ pub(crate) fn paste_color_target(s: &Session, p: &Value) -> Option { /// Copy the targeted colour or alpha channel as a grayscale clip, inside the selection (or the /// whole canvas). `Ok(None)` when no single channel is targeted: Copy copies the layer. -pub(crate) fn copy(s: &mut Session) -> Result> { +pub(crate) fn copy(s: &mut Session, p: &Value) -> Result> { + if let Some(colors) = crate::color_channels::target(s, p)?.filter(|c| c.len() > 1 || p.get("target").is_some() || p.get("layer").is_some()) { + return copy_colors(s, p, colors).map(Some); + } let Some(d) = s.active() else { return Ok(None) }; let doc = &d.doc; let canvas = doc.bounds(); - // The surface the values come from, which of its samples is the channel, and whether it stores - // ink (CMYK, a spot channel) rather than tone. - let (src, k, ink) = match d.channel_view.target { - ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => return Ok(None), - ChannelTarget::Color(k) => { - let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?; - (surf, k, surf.format().mode == ColorMode::Cmyk) - } - ChannelTarget::Alpha(i) => { - let c = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len())))?; - (&c.surface, 0, c.spot.is_some()) + // A single channel copies as tone; CMYK and spot channels store ink. + let (src, k, ink) = if crate::channel_cmds::is_channel_target(p) { + (crate::channel_cmds::read_target_surface(doc, d.active_layer, p)?, 0, crate::channel_cmds::is_spot_target(doc, p)) + } else if p.get("target").is_some() { + return Ok(None); + } else { + match d.channel_view.target { + ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => return Ok(None), + ChannelTarget::Color(k) => { + let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?; + (surf, k, surf.format().mode == ColorMode::Cmyk) + } + ChannelTarget::Alpha(i) => { + let c = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len())))?; + (&c.surface, 0, c.spot.is_some()) + } } }; let n = src.format().channels(); @@ -67,6 +75,7 @@ pub(crate) fn copy(s: &mut Session) -> Result> { for (i, p) in values.chunks_exact(n).enumerate() { // A CMYK or spot channel copies as its tone (#5787). px.push(p.get(k).map_or(0.0, |&v| if ink { 1.0 - v } else { v })); + px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0))); } let mut surface = Surface::new(fmt); @@ -76,7 +85,7 @@ pub(crate) fn copy(s: &mut Session) -> Result> { if bounds.is_empty() { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } - s.clipboard = Some(Clip { surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) }); Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k}))) } @@ -94,6 +103,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: s.edit(label, |doc, _| { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let (n, mode) = (surf.format().channels(), surf.format().mode); + if k >= n { return Err(EngineError::Other(format!("no colour channel {k} in this layer"))); } @@ -108,6 +118,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() { let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)); let v = crate::channel_cmds::tone(mode, photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]])); + if let Some(c) = dst.get_mut(k) { *c += (v - *c) * w.clamp(0.0, 1.0); } @@ -126,7 +137,7 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: /// value for it, as Photoshop does. The other channels and the layer's transparency stay as they /// are. One history step. pub(crate) fn cut(s: &mut Session, id: LayerId, k: usize) -> Result { - let r = copy(s)?.ok_or_else(|| EngineError::Other("no colour channel is targeted".into()))?; + let r = copy(s, &Value::Null)?.ok_or_else(|| EngineError::Other("no colour channel is targeted".into()))?; let bg = s.tools.background; s.edit("Cut Pixels", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, k, bg))?; Ok(r) @@ -140,3 +151,101 @@ pub(crate) fn copies_alpha_channel(s: &Session) -> bool { #[cfg(test)] #[path = "channel_clip/tests.rs"] mod tests; + +pub(crate) fn copy_colors(s: &mut Session, p: &Value, colors: crate::color_channels::ColorChannels) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let source = Some(crate::commands::layer_param(s, p)?) + .and_then(|id| st.doc.layer(id)) + .and_then(Layer::surface) + .ok_or_else(|| EngineError::Other("channel copying requires a pixel layer".into()))?; + let area = st.doc.selection.as_ref().map_or(st.doc.bounds(), |m| m.content_bounds().intersect(&st.doc.bounds())); + if area.is_empty() { + return Err(EngineError::Other("the selected area is empty".into())); + } + if u64::from(area.width()) * u64::from(area.height()) > 268_435_456 { + return Err(EngineError::Other("channel copy exceeds the materialization limit".into())); + } + // A singleton stays a scalar clip, like an implicit one-channel copy. Every clipboard + // consumer (masks, alpha channels, OS clipboard) then reads its tone without tint/luma loss. + let scalar = (colors.len() == 1).then(|| colors.indices().next()).flatten(); + let fmt = PixelFormat::new(if scalar.is_some() { ColorMode::Grayscale } else { source.format().mode }, source.format().sample, true); + let mut surface = Surface::new(fmt); + for coord in area.tiles() { + let r = coord.rect().intersect(&area); + let raw = crate::allocation::read_region(source, r, "copying selected colour channels")?; + let mut out = crate::allocation::capacity((raw.len() / source.channels()) * fmt.channels(), "copying selected colour channels")?; + for (i, pixel) in raw.chunks_exact(source.channels()).enumerate() { + if let Some(k) = scalar { + let value = pixel.get(k).copied().unwrap_or(0.0); + out.push(if source.format().mode == ColorMode::Cmyk { 1.0 - value } else { value }); + } else { + for k in 0..fmt.mode.color_channels() { + out.push(if colors.contains(k) { pixel.get(k).copied().unwrap_or(0.0) } else { 0.0 }); + } + } + let x = r.x0.saturating_add((i % r.width() as usize) as i32); + let y = r.y0.saturating_add((i / r.width() as usize) as i32); + out.push(st.doc.selection.as_ref().map_or(1.0, |m| m.sample_channel(x, y, 0))); + } + surface.try_write_region(r, &out)?; + } + s.clipboard = Some(Clip { + svg: None, + surface, + bounds: area, + layers: None, + resolution: Some(st.doc.resolution_dpi), + source: Some(st.doc.id), + components: (colors.len() > 1).then(|| colors.indices().collect()), + }); + Ok(json!({"bounds":[area.x0,area.y0,area.width(),area.height()],"colors":colors.indices().collect::>()})) +} + +pub(crate) fn paste_colors( + s: &mut Session, + p: &Value, + colors: crate::color_channels::ColorChannels, + in_place: bool, + limit: Option<&Surface>, + label: &str, +) -> Result { + let clip = s.clipboard.clone().ok_or_else(|| EngineError::Other("the clipboard is empty".into()))?; + if clip.components.as_ref().is_some_and(|c| c.len() != colors.len()) { + return Err(EngineError::Other("source and destination must select the same number of colour channels".into())); + } + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = crate::commands::layer_param(s, p)?; + let (dx, dy) = crate::edit_cmds::paste_offset(&clip, st.doc.id, st.doc.bounds(), p, in_place); + let source = crate::edit_cmds::shifted(&clip.surface, dx, dy); + let area = source.content_bounds().intersect(&st.doc.bounds()); + s.edit(label, |doc, _| { + let surf = crate::commands::paint_surface(doc, id, p)?; + for (ordinal, k) in colors.indices().enumerate() { + let source_k = clip.components.as_ref().and_then(|c| c.get(ordinal)).copied().unwrap_or(k); + let input = if colors.len() == 1 && clip.components.is_none() { + source.convert(PixelFormat::new(ColorMode::Grayscale, source.format().sample, false)) + } else if source_k < source.format().mode.color_channels() { + crate::color_channels::extract_region(&source, source_k, Some(area))? + } else { + source.convert(PixelFormat::new(ColorMode::Grayscale, source.format().sample, false)) + }; + let mut dest = crate::color_channels::extract_region(surf, k, Some(area))?; + for coord in area.tiles() { + let r = coord.rect().intersect(&area); + let mut values = crate::allocation::read_region(&dest, r, "pasting selected colour channels")?; + for (i, v) in values.iter_mut().enumerate() { + let x = r.x0.saturating_add((i % r.width() as usize) as i32); + let y = r.y0.saturating_add((i / r.width() as usize) as i32); + let alpha = if source.format().alpha { source.sample_channel(x, y, source.format().mode.color_channels()) } else { 1.0 }; + let weight = alpha * limit.map_or(1.0, |m| m.sample_channel(x, y, 0)); + *v += (input.sample_channel(x, y, 0) - *v) * weight.clamp(0.0, 1.0); + } + dest.try_write_region(r, &values)?; + } + crate::color_channels::scatter(surf, &dest, k, area)?; + } + doc.selection = None; + Ok(()) + })?; + Ok(json!({"layer":id.0,"colors":colors.indices().collect::>(),"offset":[dx,dy]})) +} diff --git a/crates/engine/src/channel_clip/tests.rs b/crates/engine/src/channel_clip/tests.rs index a5e687b297..484b91ffe5 100644 --- a/crates/engine/src/channel_clip/tests.rs +++ b/crates/engine/src/channel_clip/tests.rs @@ -114,6 +114,11 @@ fn spot_channels_copy_and_paste_as_tone() { s.execute("edit.copy", json!({})).unwrap(); let clip = &s.clipboard.as_ref().unwrap().surface; assert!(clip.pixel(5, 5)[0] < 1e-3 && (clip.pixel(30, 5)[0] - 1.0).abs() < 1e-3, "depth {depth}: ink copies dark"); + let expected = clip.clone(); + // An explicit spot target must keep the same tone while multiple colour rows are selected. + s.execute("channel.target", json!({"colors": [0, 2]})).unwrap(); + s.execute("edit.copy", json!({"target": {"channel": spot}})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().surface, expected, "depth {depth}: explicit spot target overrides the colour set"); s.execute("channel.target", json!({"channel": "black"})).unwrap(); s.execute("edit.paste", json!({})).unwrap(); assert!((px(&s, 5, 5)[3] - 1.0).abs() < 2e-3 && px(&s, 30, 5)[3] < 2e-3, "depth {depth}: black = the spot's ink"); @@ -247,7 +252,7 @@ fn channel_copy_allocation_failure_keeps_clipboard_and_document() { let clip = s.clipboard.as_ref().unwrap().surface.clone(); let history = s.active().unwrap().history.entries(); crate::allocation::fail_next_for_test(); - let result = copy(&mut s); + let result = copy(&mut s, &Value::Null); let _ = crate::allocation::capacity::(0, "reset failpoint"); assert!(result.is_err()); assert_eq!(*s.active().unwrap().doc, *before); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index fc8442d67a..5249ccee0b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -2,7 +2,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::Surface; use serde_json::{Map, Value, json}; @@ -103,6 +103,7 @@ pub mod layer_menu_cmds; pub mod layer_multi_cmds; pub mod layer_nav_cmds; mod layer_params; +mod layer_references; pub mod layer_xmp_cmds; pub mod lens_cmds; pub mod magnetic_cmds; @@ -133,6 +134,7 @@ pub mod select_extra_cmds; pub mod selection_cmds; pub mod slice_cmds; pub mod smart_cmds; +pub mod smart_svg_cmds; pub mod smartselect_cmds; pub mod solid_fill_cmds; pub mod stamp_cmds; @@ -151,6 +153,7 @@ pub mod type_spell_cmds; pub mod type_styles_cmds; pub mod updates_cmds; pub(super) mod variables_cmds; +mod vector_clipboard; pub mod vector_cmds; pub(super) mod video_cmds; pub mod vp_cmds; @@ -165,10 +168,11 @@ pub(crate) use enabled::{always, check_all_unlocked, check_pixels_unlocked, has_ use enabled::{can_redo, can_undo, has_doc, has_layer, has_pixel_layer, has_pixel_or_channel, has_selection}; pub use layer_params::blend_from_str; pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, layer_panel_option, layer_param, mask_at_depth, mask_new_layer, selection_mask}; +pub(crate) use layer_references::{recorded_drop, recorded_layer}; use ops::{arrange, merge_group, set_mask, set_mask_and_deselect}; pub(crate) use ops::{layer_copy, translate_layer}; use params::{bad, set_clipped}; -pub(crate) use params::{check_f32_values, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; +pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param}; pub fn command_specs() -> &'static [CommandSpec] { ®istry().specs @@ -238,6 +242,7 @@ fn registry() -> &'static Registry { // ---------- the table ---------- #[allow(clippy::too_many_lines)] +/// Register the core file, edit, layer and document commands. fn build() -> Vec { let mut v = vec![ // File @@ -304,11 +309,30 @@ fn build() -> Vec { Ok(json!({ "document": i })) } ), - cmd!("file.close", "Close", ["File"], Some("Cmd+W"), r##"{"document":index?}"##, has_doc, |s, p| { - let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; - s.close(i).ok_or(EngineError::NoDocument)?; - Ok(Value::Null) - }), + cmd!( + "file.close", + "Close", + ["File"], + Some("Cmd+W"), + r##"{"document":index?,"save":"discard|ask"|false="discard"} (false means discard; ask refuses to close a changed document in headless playback)"##, + has_doc, + |s, p| { + let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + match p.get("save").filter(|v| !v.is_null()) { + Some(v) if v == "ask" => { + if s.documents().get(i).ok_or(EngineError::NoDocument)?.is_dirty() { + return Err(bad("file.close", "the document has unsaved changes; save it or explicitly discard them")); + } + } + // `false` (don't save) is the older spelling of discard. + Some(Value::Bool(false)) => {} + Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask, discard or false")), + _ => {} + } + s.close(i).ok_or(EngineError::NoDocument)?; + Ok(Value::Null) + } + ), // Edit cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!(s.undo()))), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!(s.redo()))), @@ -317,30 +341,54 @@ 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":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"|"filterMask"|"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,"scaleAdaptation":bool=false,"mirror":bool=false,"rotationAdaptation":bool=false (contents=contentAware: enable quarter-turn source candidates within the sampling budget),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), - cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{} (a targeted channel or Quick Mask is cleared alone)", has_pixel_or_channel, |s, p| { - let bg = s.tools.background; - // A targeted alpha channel or Quick Mask is filled instead (#2734). - if let Some(t) = crate::channel_cmds::targeted_channel(s) { - s.edit("Clear", |doc, _| crate::channel_cmds::clear_channel(doc, &t, bg))?; - return Ok(Value::Null); + cmd!( + "edit.clear", + "Clear", + ["Edit"], + Some("Delete"), + r##"{"target":"mask"|{"color":i}|{"colors":[i,…]}?=Channels panel target,"layer":id|name?=active} (a targeted mask, channel or Quick Mask is cleared alone)"##, + has_pixel_or_channel, + |s, p| { + let bg = s.tools.background; + // A targeted alpha channel or Quick Mask is filled instead (#2734). + let channel = if crate::channel_cmds::is_channel_target(p) { + Some(p.clone()) + } else if p.get("target").is_none() { + crate::channel_cmds::targeted_channel(s) + } else { + None + }; + if let Some(t) = channel { + s.edit("Clear", |doc, _| crate::channel_cmds::clear_channel(doc, &t, bg))?; + return Ok(Value::Null); + } + let id = layer_param(s, p)?; + if is_mask_target(p) { + s.edit("Clear Mask", |doc, _| crate::edit_cmds::clear_mask(doc, id, bg, p))?; + return Ok(Value::Null); + } + // A targeted colour channel clears that channel only (#2720). + if let Some(colors) = crate::color_channels::target(s, p)? { + s.edit("Clear", |doc, _| { + for k in colors.indices() { + crate::channel_cmds::clear_color_channel(doc, id, k, bg)?; + } + Ok(()) + })?; + return Ok(Value::Null); + } + s.edit("Clear", |doc, _| { + let sel = doc.selection.clone(); + let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); + crate::edit_cmds::clear_area(doc, id, area, sel.as_ref(), bg) + })?; + Ok(Value::Null) } - let id = layer_param(s, p)?; - // A targeted colour channel clears that channel only (#2720). - if let Some(k) = crate::channel_cmds::targeted_color(s) { - s.edit("Clear", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, k, bg))?; - return Ok(Value::Null); - } - s.edit("Clear", |doc, _| { - let sel = doc.selection.clone(); - let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); - crate::edit_cmds::clear_area(doc, id, area, sel.as_ref(), bg) - })?; - Ok(Value::Null) - }), + ), // Select cmd!("select.all", "All", ["Select"], Some("Cmd+A"), "{}", has_doc, |s, _| { s.edit("Select All", |doc, _| { @@ -365,12 +413,7 @@ fn build() -> Vec { cmd!("select.inverse", "Inverse", ["Select"], Some("Cmd+Shift+I"), "{}", has_selection, |s, _| { s.edit("Inverse", |doc, _| { let old = doc.selection.take().unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8)); - let b = doc.bounds(); - let data: Vec = old.read_region(b).into_iter().map(|v| 1.0 - v).collect(); - let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8); - m.write_region(b, &data); - m.prune(); - doc.selection = Some(m); + doc.selection = Some(photocraft_algo::selection::inverse_surface(&old, doc.bounds())); Ok(()) })?; Ok(Value::Null) @@ -419,15 +462,34 @@ fn build() -> Vec { } let old = doc.selection.take(); let combined = match (mode.as_str(), old) { - ("add", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.max(b)), - ("subtract", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a * (1.0 - b)), + ("add", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a.max(b)), + ("subtract", Some(o)) => sel::combine_sparse(&o, &shape, area, |a, b| a * (1.0 - b)), ("intersect", Some(o)) => sel::combine_surfaces(&o, &shape, area, |a, b| a.min(b)), // With no existing selection, neither operation can select new pixels. ("subtract" | "intersect", None) => Surface::new(photocraft_color::PixelFormat::GRAY8), _ => shape, }; - // Nothing selected on the canvas (e.g. dragged entirely outside it) deselects. - doc.selection = (!combined.content_bounds().is_empty()).then_some(combined); + // Sparse Add/Subtract keeps untouched pixels outside the canvas. Only pixels + // inside the document count when deciding whether a selection remains. + let has_on_canvas_pixels = (combined.default_bytes().first().copied().unwrap_or(0) != 0 && { + let canvas_tiles = u64::from(area.width().div_ceil(TILE_SIZE as u32)) * u64::from(area.height().div_ceil(TILE_SIZE as u32)); + let stored = combined.tiles().filter(|(coord, _)| !coord.rect().intersect(&area).is_empty()).count() as u64; + stored < canvas_tiles + }) || combined.tiles().any(|(coord, tile)| { + let tile_rect = coord.rect(); + let overlap = tile_rect.intersect(&area); + if overlap.is_empty() { + return false; + } + let x0 = (overlap.x0 - tile_rect.x0) as usize; + let x1 = (overlap.x1 - tile_rect.x0) as usize; + (overlap.y0 - tile_rect.y0..overlap.y1 - tile_rect.y0).any(|y| { + let row_start = y as usize * TILE_SIZE as usize; + let start = row_start + x0; + tile.bytes().get(start..row_start + x1).is_some_and(|row| row.iter().any(|&v| v != 0)) + }) + }); + doc.selection = has_on_canvas_pixels.then_some(combined); Ok(()) })?; Ok(Value::Null) @@ -464,7 +526,6 @@ fn build() -> Vec { *active = Some(id); Ok(id) })?; - crate::layer_multi_cmds::note_inert_insert(s, id); Ok(json!({ "layer": id.0 })) } ), @@ -514,6 +575,8 @@ fn build() -> Vec { dup.name.clone_from(name); } let nid = doc.insert_above(Some(id), dup); + // A lone copy joins its original's link group, as in the reference application (#11217). + crate::layer_multi_cmds::relink_copies(doc, &[nid])?; if !in_place { crate::artboard_cmds::place_copy(doc, nid)?; } @@ -548,7 +611,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, + r##"{"layer":id|str|{"index":uint}|{"ordinal":"Trgt|Frwr|Bckw|Frnt|Back"} | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (typed references use the recorded full layer stack; name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), @@ -606,9 +669,6 @@ fn build() -> Vec { ["name"] => "Rename Layer", _ => "Layer Properties", }; - let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - // Clipping and channel changes reach the layers around it: recomposite everything. - let local = p.get("clipped").is_none() && p.get("channels").is_none(); // The eye: a history state only with History Options › Make Layer Visibility // Changes Undoable. let only_visibility = changed.as_slice() == ["visible"]; @@ -664,9 +724,6 @@ fn build() -> Vec { } Ok(()) })?; - if local { - crate::layer_multi_cmds::note_damage(s, &before, &ids); - } Ok(Value::Null) } ), @@ -785,13 +842,22 @@ fn build() -> Vec { "Solid Color…", ["Layer", "New Fill Layer"], None, - r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, + r##"{"color":"#rrggbb"=foreground,"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document's fill holds exactly these; default the foreground's CMYK pick),"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##, has_doc, |s, p| { let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?; + // The inks the colour was picked as, so CMYK 50/50/50/100 stays 50/50/50/100 instead + // of what its sRGB stand-in converts back to through the document profile (#8568). + let inks = match cmyk_param("layer.newFillLayer.solidColor", p, "cmyk")? { + Some(k) => Some(k), + None => s.tools.picked_cmyk([c[0], c[1], c[2]]), + }; 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 fill = match inks { + Some(k) if doc.mode == ColorMode::Cmyk => Fill::Solid(Color { mode: ColorMode::Cmyk, c: k, alpha: c[3] }), + _ => 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", default_mask)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); @@ -869,7 +935,7 @@ fn build() -> Vec { "Reorder Layer", [], None, - r##"{"layer":id?| "layers":[id,…]?, "target":id|name,"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; a recorded action keeps the target by name)"##, + r##"{"layer":id|{"index":uint}|{"ordinal":"Trgt|Frwr|Bckw|Frnt|Back"}?| "layers":[id,…]?, "target":id|name|{"index":uint},"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (typed target: pre-move recorded stack boundary, omit position; layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; recorded typed references retain their form)"##, has_layer, crate::layer_multi_cmds::move_to ), @@ -898,20 +964,28 @@ fn build() -> Vec { cmd!("image.imageRotation.90ccw", "90° Counter Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Ccw90) }), - cmd!("image.adjustments.desaturate", "Desaturate", ["Image", "Adjustments"], Some("Cmd+Shift+U"), "{}", has_pixel_layer, |s, _| { - destructive_adjust( - s, - "Desaturate", - Adjustment::HueSaturation { - hue: 0.0, - saturation: -100.0, - lightness: 0.0, - colorize: false, - ranges: photocraft_doc::adjust::HueRange::defaults(), - }, - &Value::Null, - ) - }), + cmd!( + "image.adjustments.desaturate", + "Desaturate", + ["Image", "Adjustments"], + Some("Cmd+Shift+U"), + r##"{"target":"pixels"|"mask"|{"color":i}|{"colors":[i,…]}?=Channels panel target,"layer":id|name?=active}"##, + has_pixel_layer, + |s, p| { + destructive_adjust( + s, + "Desaturate", + Adjustment::HueSaturation { + hue: 0.0, + saturation: -100.0, + lightness: 0.0, + colorize: false, + ranges: photocraft_doc::adjust::HueRange::defaults(), + }, + p, + ) + } + ), // Paint cmd!( "paint.stroke", @@ -930,17 +1004,7 @@ fn build() -> Vec { r##"{"foreground":"#rrggbb"|[r,g,b,a?]?,"background":same?,"foregroundCmyk":[c,m,y,k]? (inks 0..1 the foreground was picked as: a CMYK document is filled with exactly these),"backgroundCmyk":same?}"##, always, |s, p| { - let cmyk = |key: &str| -> Result> { - let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; - let inks: Option> = v - .as_array() - .filter(|a| a.len() == 4) - .and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect()); - match inks.as_deref() { - Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])), - _ => Err(bad("tools.setColors", format!("`{key}` must be [c, m, y, k] numbers 0..1"))), - } - }; + let cmyk = |key: &str| cmyk_param("tools.setColors", p, key); let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?; let background = color_param("tools.setColors", p, "background", s.tools.background)?; @@ -1006,7 +1070,7 @@ fn build() -> Vec { label: "Activate Document", menu: &[], shortcut: None, - 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}"##, + params: r##"{"document":index|{"index":uint}} or {"offset":int} (numeric document: zero-based tab index; typed index: one-based opening order; offset: places after the active document in opening order, before it when negative) → {document}"##, enabled: has_doc, run: |s, p| { // A null key counts as omitted. @@ -1014,27 +1078,29 @@ fn build() -> Vec { 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). + // Recorded offsets wrap in opening order, independently of dragged tabs. (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 + s.document_at_offset(o)? } (Some(d), None) => { + if let Some(selector) = d.as_object() { + let index = selector + .get("index") + .filter(|_| selector.len() == 1) + .and_then(Value::as_u64) + .ok_or_else(|| bad("document.activate", "document reference needs one non-negative index"))?; + return s + .document_at_index(index) + .and_then(|idx| if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::NoDocument) }); + } 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, + journal: true, }, CommandSpec { id: "document.move", @@ -1152,7 +1218,7 @@ fn build() -> Vec { label, menu: &["Layer", "New Adjustment Layer"], shortcut: None, - params: Box::leak(format!("{params} + {{{NEW_LAYER_PATH}}}").into_boxed_str()), + params: Box::leak(format!(r#"{params} + {{{NEW_LAYER_PATH},"opacity":0..1=1}}"#).into_boxed_str()), enabled: has_doc, run: |s, p| { let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); @@ -1206,6 +1272,13 @@ fn build() -> Vec { crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))? } }; + let adj = if matches!(kind.as_str(), "levels" | "curves") + && let Some(colors) = crate::color_channels::target(s, p)? + { + crate::color_channels::tone_adjustment(&kind, p, adj, doc_mode(s), colors)? + } else { + adj + }; let label = adj.label().to_string(); // A smart object records the adjustment as a smart filter (Photoshop), which // re-runs from the source instead of baking into a copy of the pixels. @@ -1237,6 +1310,7 @@ fn build() -> Vec { v.extend(crate::cutout_cmds::specs()); v.extend(crate::symmetry_cmds::specs()); v.extend(crate::edit_cmds::specs()); + v.extend(crate::smart_svg_cmds::specs()); v.extend(crate::color_cmds::specs()); v.extend(crate::brush_cmds::specs()); v.extend(crate::brush_preset_cmds::specs()); diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 4dcb8bb8b9..728abc4966 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -7,8 +7,9 @@ //! `edit.transform.againCopy`) into an action. A replayable command's journal entry replaces the //! entries of the commands it ran on its own behalf, so they are recorded once (#3067). Playback //! runs those steps with [`Session::execute`] and stops at the first error, leaving one history -//! step per step that ran. Steps switched off with `actions.toggle` (the Actions panel check -//! marks) are skipped by playback, Batch and droplets. +//! entry for the whole play by default (`actions.setHistory` opts into per-step history). +//! Steps switched off with `actions.toggle` (the Actions panel check marks) are skipped by +//! playback, Batch and droplets. //! //! An untrusted session installs [`Session::authorize`]. `actions.play` calls it for every nested //! step, because the outer `actions.play` id would otherwise hide a recorded `file.*` command from @@ -22,6 +23,11 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod history; +pub(crate) use history::ActionHistory; +pub use history::{ActionHistoryContinuation, finish_pending_history, suspend_history}; +pub(crate) use history::{before_edit as before_action_edit, begin_document_history, finish_document_history, history_rows, reconcile_history_removal}; + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -106,6 +112,12 @@ pub struct ActionState { /// Steps run by the current top-level script and everything it calls. #[serde(skip)] pub script_steps: u64, + /// Sets made with New Set (`actions.newSet`), or loaded from an `.atn` with no actions, that + /// hold no action yet: the Actions panel shows them as empty folders. Save Actions… writes one + /// to an `.atn` as Photoshop does, but the preset store keeps sets through their actions, so + /// an empty one isn't remembered past the session. + #[serde(skip)] + pub sets: Vec, /// Bumped whenever `list` changes, so the preset store can skip unchanged writes. #[serde(skip)] pub rev: u64, @@ -115,6 +127,71 @@ pub struct ActionState { pub take: u64, } +/// Visibility gestures name a layer for replay, since document-local ids change on another image. +pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value { + if s.actions.recording.is_none() || id != "layer.setProps" || p.get("visible").is_none() { + return p; + } + let Some(st) = s.active() else { return p }; + let Some(layer) = p.get("layer").and_then(Value::as_u64).map(photocraft_doc::LayerId).or(st.active_layer).and_then(|id| st.doc.layer(id)) else { return p }; + let ordinal = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).position(|(_, _, l)| l.id == layer.id).unwrap_or(0); + if let Some(object) = p.as_object_mut() { + object.remove("layer"); + object.remove("coalesce"); + object.insert("_actionLayer".into(), json!({"name": layer.name, "ordinal": ordinal})); + } + p +} + +/// Resolve the recorded visibility target identically in engine and shell playback, and drop +/// the `.atn` event a Filter Gallery step remembers for Save Actions: the command, its journal +/// entry (Last Filter), a smart filter and the step's dialog never see it. +pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { + let mut p = p.clone(); + if id == "type.create" && (p.get("xPercent").is_some() || p.get("yPercent").is_some()) { + if p.get("box").is_some() || p.get("x").is_some() || p.get("y").is_some() { + return Err(bad(id, "recorded type position cannot mix percentages with pixel coordinates or a box")); + } + let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; + for (percent_key, pixel_key, dimension) in [("xPercent", "x", size.width), ("yPercent", "y", size.height)] { + let percent = p.get(percent_key).and_then(Value::as_f64).ok_or_else(|| bad(id, format!("invalid `{percent_key}`")))?; + let pixel = percent / 100.0 * f64::from(dimension); + if !pixel.is_finite() || pixel.abs() > f64::from(f32::MAX) { + return Err(bad(id, format!("`{percent_key}` is outside the canvas coordinate range"))); + } + if let Some(object) = p.as_object_mut() { + object.remove(percent_key); + object.insert(pixel_key.into(), json!(pixel)); + } + } + } + if id.starts_with("filter.gallery.") + && let Some(object) = p.as_object_mut() + { + object.remove(crate::atn_cmds::RECORDED_EVENT); + } + if id == "layer.setProps" + && let Some(target) = p.get("_actionLayer") + { + let name = target.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing recorded layer name"))?; + let ordinal = + target.get("ordinal").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(id, "invalid recorded layer ordinal"))?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let layer = doc + .walk() + .into_iter() + .filter(|(_, _, l)| l.name == name) + .nth(ordinal) + .map(|(_, _, l)| l.id) + .ok_or_else(|| bad(id, format!("recorded layer `{name}` does not exist")))?; + if let Some(object) = p.as_object_mut() { + object.remove("_actionLayer"); + object.insert("layer".into(), json!(layer.0)); + } + } + Ok(p) +} + impl ActionState { pub(crate) fn touch(&mut self) { self.rev = self.rev.saturating_add(1); @@ -292,7 +369,7 @@ fn from_step(p: &Value, len: usize) -> Result { fn list(s: &mut Session, _p: &Value) -> Result { let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); - Ok(json!({"actions": actions, "recording": recording})) + Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording})) } fn get(s: &mut Session, p: &Value) -> Result { @@ -325,7 +402,15 @@ fn record(s: &mut Session, p: &Value) -> Result { if s.actions.list.iter().any(|action| action.name == name) { return Err(bad("actions.record", "an action with this name already exists")); } - s.actions.list.push(Action::new(name, Vec::new())); + let set = match p.get("set") { + None | Some(Value::Null) => None, + // An existing set, empty ones included, keeps its exact name (an older actions.json or + // a `.atn` may pad it); a new one is trimmed. + Some(Value::String(n)) if crate::actions_edit_cmds::set_names(s).contains(n) => Some(n.clone()), + Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), + Some(_) => return Err(bad("actions.record", "\"set\" must be a set name or null")), + }; + s.actions.list.push(Action { set, ..Action::new(name, Vec::new()) }); s.actions.touch(); s.actions.list.len() - 1 }; @@ -335,10 +420,19 @@ fn record(s: &mut Session, p: &Value) -> Result { Ok(json!({"action": name, "index": idx})) } +/// Whether a journal entry is recorded as an action step: a replayable command, except Check +/// Spelling's corrections and lookups. +fn recorded(id: &str, p: &Value) -> bool { + replayable(id) && (id != "edit.checkSpelling" || crate::type_spell_cmds::records(p)) +} + +/// Commands recorded with their step's dialog on: Check Spelling. +const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"]; + /// Replayable journal entries not yet committed to this action while recording. pub fn pending_steps(s: &Session, action: usize) -> impl Iterator { let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from); - s.journal.iter().skip(from).filter(|(id, _)| replayable(id)) + s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p)) } pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { @@ -346,15 +440,66 @@ pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { if idx >= s.actions.list.len() { return Err(bad("actions.stop", "the action being recorded no longer exists")); } - let steps: Vec<_> = pending_steps(s, idx).cloned().collect(); + let steps = fold_recorded_nudges(pending_steps(s, idx).cloned()); s.actions.recording = Some((s.journal.len(), idx)); if !steps.is_empty() { - s.actions.list[idx].steps.extend(steps); + let action = &mut s.actions.list[idx]; + let first = action.steps.len(); + action.dialogs.extend(steps.iter().enumerate().filter(|(_, (id, _))| RECORDED_WITH_DIALOG.contains(&id.as_str())).map(|(i, _)| first + i)); + action.steps.extend(steps); s.actions.touch(); } Ok(()) } +/// Consecutive Move-tool keystrokes in one UI nudge run are one relative action step. +/// The UI's `coalesce` run id distinguishes a held key from a later, separate gesture. +fn fold_recorded_nudges(steps: impl IntoIterator) -> Vec<(String, Value)> { + let mut out: Vec<(String, Value)> = Vec::new(); + let mut last_run: Option = None; + for (id, mut params) in steps { + let run = (id == "layer.translate") + .then(|| params.as_object()) + .flatten() + .filter(|p| p.keys().all(|key| matches!(key.as_str(), "dx" | "dy" | "coalesce"))) + .and_then(|p| p.get("coalesce").and_then(Value::as_str).filter(|run| run.starts_with("nudge:"))) + .map(str::to_string); + if let Some(run) = &run { + // `layer.translate` moves each step by whole pixels, so the fold adds rounded steps. + let dx = params.get("dx").and_then(Value::as_f64).map(f64::round); + let dy = params.get("dy").and_then(Value::as_f64).map(f64::round); + if let (Some(dx), Some(dy)) = (dx, dy) + && dx.is_finite() + && dy.is_finite() + { + if last_run.as_ref() == Some(run) + && let Some((last_id, previous)) = out.last_mut() + && last_id == &id + { + let sum_x = previous.get("dx").and_then(Value::as_f64).unwrap_or(0.0) + dx; + let sum_y = previous.get("dy").and_then(Value::as_f64).unwrap_or(0.0) + dy; + if sum_x.is_finite() && sum_y.is_finite() { + previous["dx"] = json!(sum_x); + previous["dy"] = json!(sum_y); + continue; + } + } + if let Some(object) = params.as_object_mut() { + object.remove("coalesce"); + object.insert("dx".into(), json!(dx)); + object.insert("dy".into(), json!(dy)); + } + last_run = Some(run.clone()); + out.push((id, params)); + continue; + } + } + last_run = None; + out.push((id, params)); + } + out +} + fn stop(s: &mut Session, _p: &Value) -> Result { not_playing(s, "actions.stop")?; let Some((_, idx)) = s.actions.recording else { @@ -367,9 +512,23 @@ fn stop(s: &mut Session, _p: &Value) -> Result { } /// 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()) +/// open document and nothing selected (actions often start with it to clear any selection, #6744), +/// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that +/// is already on top (#8453). The CLI's `run` and `batch` scripts skip these too (#8802). +pub fn noop_step(s: &Session, id: &str, params: &Value) -> bool { + let Some(d) = s.active() else { return false }; + let dir = match id { + "select.deselect" => return d.doc.selection.is_none(), + "layer.arrange.bringForward" | "layer.arrange.bringToFront" => 1, + "layer.arrange.sendBackward" | "layer.arrange.sendToBack" => -1, + _ => return false, + }; + crate::commands::layer_param(s, params).is_ok_and(|layer| super::ops::arrange_at_end(&d.doc, layer, dir)) +} + +pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents"; +pub fn saves_smart_contents(s: &Session, id: &str) -> bool { + id == "file.save" && s.is_enabled(SAVE_CONTENTS) } fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { @@ -386,11 +545,11 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option { if let Some(error) = nested_failure(id, &value) { failed = Some(json!({"step": i, "id": id, "error": error})); @@ -412,6 +571,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option Result> { flush_recording(s)?; if s.actions.playing == 0 { + history::begin(s, &action.name); s.actions.playback_steps = 0; } let recording = s.actions.recording.take(); @@ -420,9 +580,13 @@ pub fn begin_playback(s: &mut Session, action: &Action) -> Result, action: &Action, from: usize) { s.actions.playback_stack.pop(); s.actions.playing = s.actions.playing.saturating_sub(1); + if s.actions.playing == 0 { + history::finish_playback(s); + } if let Some((_, idx)) = recording { restore_recording(s, idx, &action.name, from); } @@ -674,8 +838,34 @@ fn set_color(s: &mut Session, p: &Value) -> Result { Ok(json!({"action": name, "color": color})) } +/// Active playback and retained dialog continuations keep their chosen history policy. +pub(crate) fn history_policy_in_use(s: &Session) -> bool { + s.actions.playing != 0 || s.action_history.is_some() || s.paused_action_history.iter().any(|lease| lease.strong_count() != 0) +} + +/// Kept separate from Playback Options' performance modes until #5212 provides that dialog. +fn set_history(s: &mut Session, p: &Value) -> Result { + not_playing(s, "actions.setHistory")?; + if history_policy_in_use(s) { + return Err(bad("actions.setHistory", "cannot change action history settings during playback")); + } + let enabled = p.get("singleState").and_then(Value::as_bool).ok_or_else(|| bad("actions.setHistory", "singleState must be a boolean"))?; + s.edit_prefs(|prefs| prefs.actions.create_single_history_state = enabled); + Ok(json!({"singleState":enabled})) +} + pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.setHistory", + label: "Set Actions History", + menu: &[], + shortcut: None, + params: r##"{"singleState":bool} → {singleState}. Persist Create a single history state (default true). Playback Options can use this setting without changing its performance mode."##, + enabled: always, + run: set_history, + journal: false, + }, CommandSpec { id: "actions.move", label: "Move Action or Step", @@ -691,7 +881,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count, set:name|null}], recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}", enabled: always, run: list, journal: false, @@ -711,7 +901,7 @@ pub fn specs() -> Vec { label: "Record Action", menu: &[], shortcut: None, - params: r##"{"name":str? (new action, default "Action N")} or {"action":name|index} (append) → {action, index}"##, + params: r##"{"name":str? (new action, default "Action N"),"set":name? (the set it goes in, as New Action records into the selected set)} or {"action":name|index} (append) → {action, index}"##, enabled: always, run: record, journal: false, @@ -731,7 +921,7 @@ pub fn specs() -> Vec { label: "Play Action", menu: &[], shortcut: None, - params: r##"{"action":name|index, "from":step?} → {action, ran, failed?:{step, id, error}}. step and from are 0-based; steps switched off with actions.toggle are skipped. Stops at the first error (the command still returns ok, with failed set) and leaves one history step per step that ran. Nested calls are supported up to 16 levels; cycles are rejected. Recording stores a named call rather than its expanded commands. Each step is checked with Session::authorize when one is installed."##, + params: r##"{"action":name|index, "from":step?} → {action, ran, failed?:{step, id, error}}. step and from are 0-based; steps switched off with actions.toggle are skipped. Stops at the first error (the command still returns ok, with failed set) and groups document edits in one Action: history entry by default; actions.setHistory can retain per-step history. Nested calls are supported up to 16 levels; cycles are rejected. Recording stores a named call rather than its expanded commands. Each step is checked with Session::authorize when one is installed."##, enabled: always, run: play, journal: false, diff --git a/crates/engine/src/cmd/actions_cmds/history.rs b/crates/engine/src/cmd/actions_cmds/history.rs new file mode 100644 index 0000000000..c6f5d52197 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/history.rs @@ -0,0 +1,465 @@ +//! Consolidate document history after commands finish their own atomic transactions. +use super::{EngineError, Result, Session}; +use photocraft_doc::{DocId, Document}; +use photocraft_ops::{History, LayerTarget}; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Clone, Debug)] +struct BeforeDocument { + id: DocId, + doc: Arc, + history: History, + layers: LayerTarget, + coalesce: Option, + saved_revision: u64, + clean: bool, +} + +impl BeforeDocument { + /// Capture the complete document boundary, including clean state and targeted layers. + fn capture(st: &crate::DocState) -> Self { + Self { + id: st.doc.id, + doc: st.doc.clone(), + history: st.history.clone(), + layers: st.layer_target(), + coalesce: st.coalesce.clone(), + saved_revision: st.saved_revision, + clean: !st.is_dirty(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ActionHistory { + pub name: String, + external: bool, + before: Vec, +} + +/// The outer play owns one lazy scope; nested plays share its document snapshots. +pub(super) fn begin(s: &mut Session, name: &str) { + if s.action_history.is_none() && s.prefs().actions.create_single_history_state { + s.action_history = Some(ActionHistory { name: name.into(), external: false, before: Vec::new() }); + } +} + +/// Capture each document lazily, before its first ordinary or compound edit. Commands retain +/// their own records, purge and rollback semantics until the outer action finishes. +pub(crate) fn before_edit(s: &mut Session) { + let Some(group) = &s.action_history else { return }; + let Some(st) = s.active() else { return }; + let previous = group.before.iter().position(|before| before.id == st.doc.id); + if previous.is_some_and(|i| !st.history.is_cleared() && !st.history.was_purged_since(&group.before[i].history)) { + return; + } + let before = BeforeDocument::capture(st); + if let Some(group) = &mut s.action_history { + match previous { + Some(i) => group.before[i] = before, + None => group.before.push(before), + } + } + if let Some(st) = s.active_mut() { + st.coalesce = None; + st.history.begin_visibility_capture(&[]); + } +} + +/// Rows visible just before the user deletes history, distinct from internal compound purges. +pub(crate) fn history_rows(s: &Session) -> Vec { + if s.action_history.is_none() { + return Vec::new(); + } + let Some(st) = s.active() else { return Vec::new() }; + st.history.row_ids().collect() +} + +/// Apply only the user's actual row removals to the existing action boundary immediately. +pub(crate) fn reconcile_history_removal(s: &mut Session, rows: &[u64]) { + let Some(st) = s.active() else { return }; + let Some(group) = &s.action_history else { return }; + let Some(index) = group.before.iter().position(|before| before.id == st.doc.id) else { return }; + let prior = &group.before[index].history; + let after: HashSet<_> = st.history.row_ids().collect(); + let baseline: HashSet<_> = prior.row_ids().collect(); + let removed: Vec<_> = rows.iter().copied().filter(|id| !after.contains(id) && baseline.contains(id)).collect(); + if removed.is_empty() { + return; + } + if prior.row_id(prior.past_len()).is_some_and(|id| removed.contains(&id)) { + // The original pixels themselves were deleted. Only the post-delete document may + // start a later grouped suffix; its accumulator excludes edits before that boundary. + let mut before = BeforeDocument::capture(st); + before.history.end_visibility_capture(); + if let Some(group) = &mut s.action_history { + group.before[index] = before; + } + if let Some(st) = s.active_mut() { + st.coalesce = None; + st.history.begin_visibility_capture(&[]); + } + } else if let Some(group) = &mut s.action_history { + let history = &mut group.before[index].history; + for id in removed { + let Some(row) = history.row_of(id) else { continue }; + let current = history.past_len(); + if row < current { + history.remove(row); + } else if row > current { + history.remove_later(row - current - 1); + } + } + } +} + +/// Fold ordinary edits while retaining real Clear History and irreversible purge boundaries. +fn consolidate(s: &mut Session, group: &ActionHistory) { + for before in &group.before { + let Some(st) = s.docs.iter_mut().find(|st| st.doc.id == before.id) else { continue }; + if st.history.was_purged_since(&before.history) { + st.history.end_visibility_capture(); + continue; + } + let mut history = before.history.clone(); + // Discarded command-local rows may have been visible while a dialog was open. Their + // identifiers must not be allocated to a different state after consolidation. + history.reserve_ids_from(&st.history); + history.max_states = st.history.max_states; + history.max_bytes = st.history.max_bytes; + history.non_linear = st.history.non_linear; + if st.doc.as_ref() == before.doc.as_ref() { + st.doc = before.doc.clone(); + st.coalesce = before.coalesce.clone(); + if before.clean && st.saved_revision == before.saved_revision { + st.saved_revision = st.revision; + } + } else { + let visibility = st.history.captured_visibility().unwrap_or(st.history.current_visibility()).to_vec(); + history.set_current_layers(before.layers.clone()); + history.record(format!("Action: {}", group.name), before.doc.clone(), st.layer_target()); + history.add_current_visibility(&visibility); + st.coalesce = None; + } + history.preserve_action_clear(&st.history); + if let Some(source) = history.preserve_action_brush_source(&st.history) { + let (source, restore_on_clear_undo) = match source { + photocraft_ops::ActionBrushSource::Current(source) => (source, false), + photocraft_ops::ActionBrushSource::Cleared(source) => (source, true), + }; + // The selected row will no longer exist, but the History Brush must still paint its + // exact pixels. Snapshots already retain source pixels without adding an Undo row. + let id = st.next_snapshot; + st.next_snapshot = id.saturating_add(1); + st.snapshots.push(crate::history_cmds::Snapshot { id, name: format!("{}: {}", group.name, source.label), doc: source.doc, layers: source.layers }); + if restore_on_clear_undo { + history.set_cleared_brush_snapshot(id); + } else { + history.set_action_brush_snapshot(id); + } + } + st.history = history; + st.trim_history(); + } +} + +/// Publish all edited documents once the outer play, including its failure prefix, ends. +pub(super) fn finish(s: &mut Session) { + if let Some(group) = s.action_history.take() { + consolidate(s, &group); + } +} + +/// Batch owns its scope across nested plays without adding an artificial playback depth. +pub(crate) fn begin_document_history(s: &mut Session, name: &str) { + begin(s, name); + if let Some(group) = &mut s.action_history { + group.external = true; + } +} + +/// Finalize the per-document scope explicitly owned by Batch, including a completed prefix on failure. +pub(crate) fn finish_document_history(s: &mut Session) { + finish(s); +} + +/// Finish an ordinary outer playback without closing a scope owned by the surrounding Batch. +pub(super) fn finish_playback(s: &mut Session) { + if s.action_history.as_ref().is_none_or(|group| !group.external) { + finish(s); + } +} + +#[derive(Clone, Debug, PartialEq)] +struct HistoryBinding { + past: usize, + rows: Vec<(Option, String)>, + source: Option, + source_snapshot: Option, + cleared: bool, +} + +impl HistoryBinding { + /// Rows and source identity detect history changes that leave document pixels untouched. + fn of(history: &History) -> Self { + let labels = history.entries().into_iter().chain(history.redo_labels().map(str::to_string)); + Self { + past: history.past_len(), + rows: labels.enumerate().map(|(i, label)| (history.row_id(i), label)).collect(), + source: history.brush_source(), + source_snapshot: history.brush_source_snapshot(), + cleared: history.is_cleared(), + } + } +} + +#[derive(Clone, Debug)] +struct PausedDocument { + id: DocId, + doc: Arc, + history: HistoryBinding, + source_snapshot: Option, +} + +/// Exact document/history binding of a dialog continuation. Never stored in dialog JSON. +#[derive(Clone, Debug)] +pub struct ActionHistoryContinuation { + _policy_lease: Arc<()>, + /// Set by the first successful `resume`, shared by every clone: a kept clone can't reopen a + /// scope that already ran (and may have finished). + resumed: Arc, + group: Option, + active: Option, + documents: Vec, +} + +impl ActionHistoryContinuation { + /// Resume before the dialog's edit; intervening edits must never join the original play. + pub fn resume(&self, s: &mut Session) -> Result<()> { + if s.action_history.is_some() || s.active().map(|st| st.doc.id) != self.active { + return Err(EngineError::Other("the paused action's document is no longer current".into())); + } + for before in &self.documents { + let Some(st) = s.docs.iter().find(|st| st.doc.id == before.id) else { + return Err(EngineError::Other("a paused action's document was closed".into())); + }; + if !Arc::ptr_eq(&st.doc, &before.doc) || HistoryBinding::of(&st.history) != before.history || st.source_snapshot != before.source_snapshot { + return Err(EngineError::Other("the paused action's document or history changed".into())); + } + } + if self.resumed.swap(true, Ordering::SeqCst) { + return Err(EngineError::Other("the paused action was already resumed".into())); + } + if let Some(group) = &self.group { + for before in &group.before { + if let Some(st) = s.docs.iter_mut().find(|st| st.doc.id == before.id) { + let prefix = st.history.current_visibility().to_vec(); + st.history.begin_visibility_capture(&prefix); + } + } + } + s.action_history = self.group.clone(); + Ok(()) + } + + /// Rebind a completed nonmodal dialog edit, leaving open-dialog time outside its scope. + /// Only the scope resumed from this continuation may be taken; a parent remains untouched. + pub fn suspend_after_edit(&self, s: &mut Session) -> Result { + if s.actions.playing != 0 || s.active().map(|st| st.doc.id) != self.active { + return Err(EngineError::Other("the dialog's action scope is no longer current".into())); + } + let active = self.documents.iter().find(|doc| Some(doc.id) == self.active); + let at_dialog_boundary = |before: &BeforeDocument| active.is_some_and(|doc| before.id == doc.id && Arc::ptr_eq(&before.doc, &doc.doc)); + let owned = match (&self.group, &s.action_history) { + (None, None) => true, + (Some(expected), Some(current)) => { + expected.name == current.name + && expected.external == current.external + && current.before.len() >= expected.before.len() + && current.before.len() <= expected.before.len().saturating_add(1) + && expected + .before + .iter() + .zip(¤t.before) + .all(|(before, now)| before.id == now.id && (Arc::ptr_eq(&before.doc, &now.doc) || at_dialog_boundary(now))) + && current.before.iter().skip(expected.before.len()).all(at_dialog_boundary) + } + _ => false, + }; + if !owned { + return Err(EngineError::Other("the dialog no longer owns the active action scope".into())); + } + Ok(Self::capture(s)) + } + + /// Publish the prefix and capture the same document/history/source binding for both callers. + fn capture(s: &mut Session) -> Self { + s.paused_action_history.retain(|lease| lease.strong_count() != 0); + let policy_lease = Arc::new(()); + s.paused_action_history.push(Arc::downgrade(&policy_lease)); + let group = s.action_history.take(); + if let Some(group) = &group { + consolidate(s, group); + } + let active = s.active().map(|st| st.doc.id); + let documents = s + .docs + .iter() + .filter(|st| Some(st.doc.id) == active || group.as_ref().is_some_and(|g| g.before.iter().any(|b| b.id == st.doc.id))) + .map(|st| PausedDocument { id: st.doc.id, doc: st.doc.clone(), history: HistoryBinding::of(&st.history), source_snapshot: st.source_snapshot }) + .collect(); + Self { _policy_lease: policy_lease, resumed: Arc::new(AtomicBool::new(false)), group, active, documents } + } +} + +/// Publish the completed prefix, then release the active scope while a dialog is open. +pub fn suspend_history(s: &mut Session) -> Option { + if s.actions.playing != 1 { + return None; + } + Some(ActionHistoryContinuation::capture(s)) +} + +/// End a dialog scope if its command or continuation fails before playback starts. +pub fn finish_pending_history(s: &mut Session) { + if s.actions.playing == 0 { + finish_playback(s); + } +} + +#[cfg(test)] +mod tests { + /// Batch 16 review: a kept clone can't reopen a scope its continuation already resumed and + /// finished, even with the document and history unchanged. + #[test] + fn a_continuation_resumes_once() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":true})).unwrap(); + begin_document_history(&mut s, "Spell"); + let continuation = ActionHistoryContinuation::capture(&mut s); + let kept = continuation.clone(); + continuation.resume(&mut s).unwrap(); + finish_document_history(&mut s); + assert!(kept.resume(&mut s).is_err(), "the clone shares the resumed mark"); + assert!(s.action_history.is_none()); + } + + use super::*; + use serde_json::json; + + #[test] + /// Keep the policy fixed until every clone of a paused continuation releases its lease, in either history mode. + fn history_policy_stays_fixed_until_all_paused_continuations_are_released() { + for single in [false, true] { + let mut s = Session::new(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Paused"); + let continuation = ActionHistoryContinuation::capture(&mut s); + let copy = continuation.clone(); + assert!(s.execute("actions.setHistory", json!({"singleState":!single})).is_err()); + assert_eq!(s.prefs().actions.create_single_history_state, single); + continuation.resume(&mut s).unwrap(); + finish_document_history(&mut s); + drop(continuation); + assert!(s.execute("actions.setHistory", json!({"singleState":!single})).is_err()); + drop(copy); + s.execute("actions.setHistory", json!({"singleState":!single})).unwrap(); + assert_eq!(s.prefs().actions.create_single_history_state, !single); + } + } + + #[test] + /// Generic preference set/reset routes must preserve the same paused-policy guard as actions.setHistory. + fn generic_preference_updates_cannot_change_a_paused_history_policy() { + for single in [false, true] { + let mut s = Session::new(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Paused"); + let continuation = ActionHistoryContinuation::capture(&mut s); + for params in [ + json!({"path":"actions.createSingleHistoryState","value":!single}), + json!({"path":"actions","value":{"createSingleHistoryState":!single}}), + json!({"path":"","value":{"actions":{"createSingleHistoryState":!single}}}), + json!({"path":"colorSettings.intent","value":"perceptual","values":{"actions.createSingleHistoryState":!single}}), + ] { + let before = s.prefs().clone(); + let color = s.color.settings.clone(); + assert!(s.execute("prefs.set", params).is_err()); + assert_eq!(s.prefs(), &before); + assert_eq!(s.color.settings, color); + } + for params in [json!({"path":"actions.createSingleHistoryState"}), json!({"path":"actions"}), json!({})] { + assert_eq!(s.execute("prefs.reset", params).is_err(), !single); + assert_eq!(s.prefs().actions.create_single_history_state, single); + } + s.execute("prefs.set", json!({"path":"actions.createSingleHistoryState","value":single})).unwrap(); + s.execute("prefs.set", json!({"path":"performance.historyStates","value":17})).unwrap(); + s.execute("prefs.reset", json!({"path":"cursors"})).unwrap(); + drop(continuation); + s.execute("prefs.set", json!({"path":"actions.createSingleHistoryState","value":!single})).unwrap(); + } + } + + #[test] + /// Rebinding a dialog retains its first edit while respecting an accepted Clear History boundary. + fn idle_dialog_rebind_keeps_first_edit_and_clear_boundaries() { + for single in [false, true] { + for clear in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Spell"); + if clear { + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + s.execute("history.clear", json!({})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + let mut continuation = ActionHistoryContinuation::capture(&mut s); + for name in ["First correction", "Second correction"] { + continuation.resume(&mut s).unwrap(); + s.execute("layer.renameLayer", json!({"name":name})).unwrap(); + continuation = continuation.suspend_after_edit(&mut s).unwrap(); + assert!(s.action_history.is_none(), "idle time must not own a history scope"); + assert_eq!(s.actions.playing, 0); + } + continuation.resume(&mut s).unwrap(); + finish_document_history(&mut s); + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + if single { + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + } else { + assert_ne!(s.active().unwrap().doc.as_ref(), before.as_ref()); + } + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } + } + + #[test] + /// A dialog continuation cannot adopt another caller's active grouping scope. + fn idle_dialog_rebind_never_takes_an_unrelated_parent_scope() { + for single in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + begin_document_history(&mut s, "Spell"); + let continuation = ActionHistoryContinuation::capture(&mut s); + // An independently owned parent scope must never be taken, even for a paused + // per-step action whose policy is now protected from changes. + s.action_history = Some(ActionHistory { name: "Parent".into(), external: true, before: Vec::new() }); + let before = s.active().unwrap().doc.clone(); + let rows = s.active().unwrap().history.entries(); + assert!(continuation.suspend_after_edit(&mut s).is_err()); + assert!(suspend_history(&mut s).is_none(), "ordinary suspension still requires playing == 1"); + assert_eq!(s.action_history.as_ref().unwrap().name, "Parent"); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), rows); + finish_document_history(&mut s); + } + } +} diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index b7974784b6..7496cf609e 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -5,6 +5,30 @@ fn pixel(s: &Session, layer: usize, x: i32, y: i32) -> Vec { s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y) } +/// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to +/// write: it updates the smart object, headless too. +#[test] +fn save_in_edit_contents_updates_the_smart_object() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).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("layer.smartObjects.editContents", json!({})).unwrap(); + let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))]; + s.actions.list.push(Action::new("Edit and save", steps)); + let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + s.set_active(0); + let doc = &s.active().unwrap().doc; + let flat = crate::file_cmds::flattened(doc, doc.pixel_format()); + let p = flat.pixel(2, 2); + assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}"); + // With no Edit Contents document, Save stays the shell's (it needs the document's file). + s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))])); + assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some()); +} + #[test] fn deselect_with_nothing_selected_is_a_noop_in_playback() { // #6744: imported actions often start with Deselect; with no selection Photoshop plays on. @@ -69,6 +93,69 @@ fn unchecked_steps_are_skipped_and_follow_move_and_delete() { assert_eq!(s.actions.list[0].disabled.iter().copied().collect::>(), [1], "rejected toggles change nothing"); } +#[test] +fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Same"})).unwrap(); + let hidden = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("actions.record", json!({"name": "Hide"})).unwrap(); + s.execute("layer.setProps", json!({"layer": hidden, "visible": false, "coalesce": "eye-sweep"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let keep = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap(); + let target = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.deselectLayers", json!({})).unwrap(); + let result = s.execute("actions.play", json!({"action": "Hide"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(keep)).unwrap().visible); + assert!(!s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible); +} + +/// ⌘-clicking a layer's thumbnail that is not the selected layer records the layer by name (the +/// Background as the Background), so the step saves in strict Save Actions and replays on the +/// same-named layer of another document. +#[test] +fn recorded_thumbnail_click_on_another_layer_saves_and_replays_by_name() { + use photocraft_geom::Rect; + let fixture = |s: &mut Session| { + s.execute("file.new", json!({"width": 4, "height": 1})).unwrap(); + let source = s.execute("layer.new.layer", json!({"name": "Source"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("Pixels", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({"name": "Active"})).unwrap(); + source + }; + let mut s = Session::new(); + let source = fixture(&mut s); + let background = s.active().unwrap().doc.background().unwrap().id.0; + s.execute("actions.record", json!({"name": "Load"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source, "operation": "new"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency", "layer": background, "operation": "new"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let action = s.actions.list.iter().find(|a| a.name == "Load").unwrap().clone(); + assert_eq!( + action.steps, + vec![ + ("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"name": "Source"}, "operation": "new"})), + ("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"background": true}, "operation": "new"})), + ] + ); + crate::atn_cmds::export("Set", &[&action], &[], false).unwrap(); + // Replayed in another document, the first step loads its Source layer. + let mut other = Session::new(); + fixture(&mut other); + let (id, params) = &action.steps[0]; + other.execute(id, params.clone()).unwrap(); + let doc = &other.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 4, 1)), vec![1.0, 0.0, 0.0, 0.0]); +} + #[test] fn rename_keeps_calls_and_function_key_and_rejects_bad_names() { let mut s = Session::new(); @@ -294,6 +381,115 @@ fn record_explicit_transform_keeps_one_replayable_step() { assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]); } +#[test] +fn recording_folds_one_run_of_move_tool_nudges() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Target"})).unwrap(); + s.execute("actions.record", json!({"name": "Nudge"})).unwrap(); + for (dx, dy, run) in [(1, 0, "nudge:1"), (1, 0, "nudge:1"), (1, 10, "nudge:1"), (0, 1, "nudge:2")] { + s.execute("layer.translate", json!({"dx": dx, "dy": dy, "coalesce": run})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let steps = &s.actions.list[0].steps; + assert_eq!(steps, &[("layer.translate".into(), json!({"dx": 3.0, "dy": 10.0})), ("layer.translate".into(), json!({"dx": 0.0, "dy": 1.0}))]); +} + +/// Each nudge moves by whole pixels, so two 0.6 px steps fold into the 2 px they moved, not 1.2. +#[test] +fn folded_nudges_add_whole_pixel_steps() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Target"})).unwrap(); + s.execute("actions.record", json!({"name": "Nudge"})).unwrap(); + for _ in 0..2 { + s.execute("layer.translate", json!({"dx": 0.6, "dy": 0.0, "coalesce": "nudge:1"})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, [("layer.translate".into(), json!({"dx": 2.0, "dy": 0.0}))]); +} + +#[test] +fn recording_document_activation_uses_opening_order_offset() { + let mut s = Session::new(); + for name in ["First", "Second"] { + s.execute("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap(); + } + s.execute("actions.record", json!({"name": "Switch"})).unwrap(); + s.execute("document.activate", json!({"document": 0})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, [("document.activate".into(), json!({"offset": -1}))]); +} + +#[test] +fn recorded_point_text_position_scales_with_the_playback_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap(); + s.execute("actions.record", json!({"name": "Caption"})).unwrap(); + s.execute("type.create", json!({"x": 20, "y": 30, "text": "Hi", "size": 12})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1["xPercent"], json!(20.0)); + assert_eq!(s.actions.list[0].steps[0].1["yPercent"], json!(30.0)); + s.execute("file.new", json!({"width": 200, "height": 300, "background": "transparent"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Caption"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let info = s.execute("type.info", json!({})).unwrap(); + assert_eq!(info["transform"][4], json!(40.0)); + assert_eq!(info["transform"][5], json!(90.0)); +} + +#[test] +fn recorded_point_text_rejects_invalid_relative_positions() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + for params in [ + json!({"xPercent": 20}), + json!({"xPercent": "20", "yPercent": 30}), + json!({"xPercent": 20, "yPercent": 30, "x": 20}), + json!({"xPercent": 20, "yPercent": 30, "box": [0, 0, 10, 10]}), + json!({"xPercent": 1e100, "yPercent": 30}), + ] { + assert!(playback_params(&s, "type.create", ¶ms).is_err(), "{params}"); + } +} + +#[test] +fn recorded_single_layer_transform_replays_after_layer_ids_shift() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap(); + let source = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("source pixel", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + + s.execute("actions.record", json!({"name": "Move target"})).unwrap(); + s.execute("edit.transform", json!({"layer": source, "dx": 4, "interpolation": "nearest"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let recorded = s.execute("actions.get", json!({"action": "Move target"})).unwrap(); + assert_eq!(recorded["steps"][0][1], json!({"dx": 4, "interpolation": "nearest"})); + + // Add a layer before the target, shifting its id; playback must resolve the current + // selection rather than keep the source document's numeric layer id. + s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Inserted"})).unwrap(); + let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(source, target); + s.edit("target pixel", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(target)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let target_index = s.active().unwrap().doc.layers.iter().position(|layer| layer.id.0 == target).unwrap(); + let before = s.active().unwrap().history.past_len(); + let played = s.execute("actions.play", json!({"action": "Move target"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().history.past_len(), before + 1); + assert_eq!(pixel(&s, target_index, 4, 2), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(pixel(&s, target_index, 0, 2), vec![0.0; 4]); +} + /// A Background with a red bar and a slanted ruler line, ready for Straighten. fn ruled_background(s: &mut Session, depth: u32) { s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap(); @@ -348,9 +544,12 @@ fn record_keeps_only_replayable_steps() { } #[test] +/// Playback on a different document must match native edits at every depth, independent of history grouping. fn play_on_another_document_matches_pixels_at_each_depth() { for depth in [8, 16, 32] { let mut s = Session::new(); + // This control checks the optional, per-step history behavior. + s.execute("actions.setHistory", json!({"singleState": false})).unwrap(); record_red(&mut s, depth); s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap(); let before = s.active().unwrap().history.past_len(); @@ -1040,3 +1239,92 @@ fn button_color_is_one_of_the_photoshop_swatches() { } assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing"); } + +#[test] +/// An arrangement that cannot move the layer must not create a grouped history entry. +fn arrange_that_cannot_move_is_a_noop_in_playback() { + // #8453: imported actions make an adjustment layer, then Bring it to Front, where it already is. + let mut s = Session::new(); + // Keep individual rows visible so grouping cannot hide an extra no-op record. + s.execute("actions.setHistory", json!({"singleState": false})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let steps = vec![ + ("layer.newAdjustmentLayer.curves".to_string(), json!({})), + ("layer.arrange.bringToFront".to_string(), json!({})), + ("layer.new.layer".to_string(), json!({"name": "After"})), + ]; + s.actions.list.push(Action::new("Repro", steps)); + let before = s.active().unwrap().history.past_len(); + let played = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(played["ran"], 3, "{played}"); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().history.past_len(), before + 2, "the no-op adds no history step"); + + // The predicate identifies no movement for every layer: top level, above the Background, + // and inside a group. + let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].clone(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + s.execute("layer.new.layer", json!({"name": "B"})).unwrap(); + let cmds = [("layer.arrange.bringForward", 1), ("layer.arrange.bringToFront", 1), ("layer.arrange.sendBackward", -1), ("layer.arrange.sendToBack", -1)]; + let ids: Vec<_> = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); + let bg = crate::background_cmds::background_id(&s.active().unwrap().doc); + for id in ids.into_iter().filter(|id| Some(*id) != bg) { + for (cmd, dir) in cmds { + let at_end = super::super::ops::arrange_at_end(&s.active().unwrap().doc, id, dir); + let history = s.active().unwrap().history.past_len(); + let ran = s.execute(cmd, json!({"layer": id.0})); + if at_end { + if matches!(cmd, "layer.arrange.bringToFront" | "layer.arrange.sendToBack") { + assert_eq!(ran.unwrap(), json!({"moved": false}), "{cmd} on {id:?}"); + } else { + assert!(ran.is_err(), "{cmd} on {id:?}: {ran:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history, "a no-op must not add history"); + } else { + ran.unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + s.execute("edit.undo", json!({})).unwrap(); + } + } + } + + // Arranging the Background still stops playback: Photoshop refuses it too. + let bg = bg.unwrap(); + s.actions.list.push(Action::new("Bg", vec![("layer.arrange.bringToFront".to_string(), json!({"layer": bg.0}))])); + let played = s.execute("actions.play", json!({"action": 1})).unwrap(); + assert_eq!(played["ran"], 0, "{played}"); + assert!(played["failed"].is_object(), "{played}"); +} + +/// #10459 review: recording into an existing set keeps that set's exact name, even one with +/// surrounding spaces (from an older actions.json); a new set name is trimmed. +#[test] +fn record_keeps_an_existing_sets_exact_name() { + let mut s = Session::new(); + s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) }); + s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let set_of = |s: &Session, n: &str| s.actions.list.iter().find(|a| a.name == n).and_then(|a| a.set.clone()); + assert_eq!(set_of(&s, "New").as_deref(), Some(" Web "), "the existing set"); + s.execute("actions.record", json!({"name": "Fresh", "set": " Print "})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(set_of(&s, "Fresh").as_deref(), Some("Print"), "a new set is trimmed"); +} + +/// CodeRabbit review on #10459: an empty set loaded from a `.atn` (or made by New Set) keeps its +/// exact name too, so the first action recorded into it lands in that folder and saves with it. +#[test] +fn record_keeps_an_empty_loaded_sets_exact_name() { + let mut src = Session::new(); + src.actions.sets.push(" Web ".into()); + let saved = src.execute("actions.save", json!({"set": " Web "})).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("actions.load", json!({"data": saved["data"]})).unwrap()["set"], " Web "); + s.execute("actions.record", json!({"name": "First", "set": " Web "})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let set = s.actions.list.iter().find(|a| a.name == "First").and_then(|a| a.set.clone()); + assert_eq!(set.as_deref(), Some(" Web ")); + assert_eq!(crate::actions_edit_cmds::set_names(&s), [" Web "], "one folder, not a trimmed second one"); + assert_eq!(s.execute("actions.save", json!({"set": " Web "})).unwrap()["actions"], json!(["First"])); +} diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index 1f9cce3431..f8e4da4f54 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -1,6 +1,7 @@ //! Editing an action's steps and its set, as the Actions panel does beyond recording: //! `actions.insert` (Photoshop's Insert Stop… and Insert Menu Item…), `actions.dialog` (the -//! panel's dialog toggle column) and `actions.assignSet` (which set folder an action sits in). +//! panel's dialog toggle column), `actions.assignSet` (which set folder an action sits in) and +//! `actions.newSet` (New Set…, an empty set folder). //! //! All three refuse to run during playback, commit pending recorded steps first, and keep the //! per-step flag sets (`disabled`, `dialogs`) in step with the moved indices, like @@ -126,6 +127,33 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// Every set name, in the Actions panel's order: the sets of the actions as they first appear, +/// then the empty ones New Set made. +pub fn set_names(s: &Session) -> Vec { + let mut seen = std::collections::HashSet::new(); + s.actions.list.iter().filter_map(|a| a.set.as_ref()).chain(&s.actions.sets).filter(|set| seen.insert(set.as_str())).cloned().collect() +} + +/// New Set…: an empty set folder, named `name` or "Set N" (the first number no set has), that New +/// Action can record into. +fn new_set(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.newSet"; + not_playing(s, cmd)?; + let names = set_names(s); + let name = match p.get("name") { + None | Some(Value::Null) => (1u32..).map(|n| format!("Set {n}")).find(|n| !names.contains(n)).ok_or_else(|| bad(cmd, "no free set name"))?, + Some(Value::String(n)) if !n.trim().is_empty() => n.trim().to_string(), + Some(Value::String(_)) => return Err(bad(cmd, "`name` is empty")), + Some(_) => return Err(bad(cmd, "`name` must be a string")), + }; + if names.contains(&name) { + return Err(bad(cmd, format!("a set named `{name}` already exists"))); + } + s.actions.sets.push(name.clone()); + s.actions.touch(); + Ok(json!({"set": name})) +} + /// The conditions of Photoshop's Insert Conditional, by their string ids. pub const CONDITIONS: &[&str] = &[ "always", @@ -285,6 +313,16 @@ pub fn specs() -> Vec { run: assign_set, journal: false, }, + CommandSpec { + id: "actions.newSet", + label: "New Set", + menu: &[], + shortcut: None, + params: r##"{"name":str? (default "Set N")} → {set}. An empty set folder in the Actions panel, as Photoshop's New Set… makes; New Action records into the selected set (actions.record's `set`). Save Actions… (actions.save's `set`) writes it even while empty; it is remembered between sessions once it holds an action."##, + enabled: always, + run: new_set, + journal: false, + }, ] } @@ -412,6 +450,28 @@ mod tests { } } + #[test] + fn new_set_makes_an_empty_folder_that_new_actions_record_into() { + let mut s = session_with(&["select.all"]); + s.execute("actions.assignSet", json!({"action": "A", "set": "Set 1"})).unwrap(); + // Numbered past the sets the actions already have. + assert_eq!(s.execute("actions.newSet", json!({})).unwrap()["set"], "Set 2"); + assert_eq!(s.execute("actions.newSet", json!({"name": " Retouch "})).unwrap()["set"], "Retouch"); + assert_eq!(set_names(&s), ["Set 1", "Set 2", "Retouch"]); + // CodeRabbit on batch 11: Actions List names the empty sets too, so an agent can find them. + assert_eq!(s.execute("actions.list", json!({})).unwrap()["sets"], json!(["Set 1", "Set 2", "Retouch"])); + assert!(s.execute("actions.newSet", json!({"name": "Set 1"})).is_err(), "a set an action is in"); + assert!(s.execute("actions.newSet", json!({"name": "Retouch"})).is_err(), "an empty set"); + assert!(s.execute("actions.newSet", json!({"name": " "})).is_err()); + // New Action into the empty set. + let r = s.execute("actions.record", json!({"set": "Retouch"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let i = r["index"].as_u64().unwrap() as usize; + assert_eq!(s.actions.list[i].set.as_deref(), Some("Retouch")); + assert_eq!(set_names(&s), ["Set 1", "Retouch", "Set 2"], "Retouch now holds an action"); + assert!(s.execute("actions.record", json!({"set": 3})).is_err()); + } + #[test] fn assign_set_moves_an_action_between_folders() { let mut s = session_with(&["select.all"]); diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index 5bbd7df961..d974576a53 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -18,6 +18,8 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod replace_color_range; + /// Built-in Color Lookup looks `(id, label)`. pub use photocraft_cms::lutfile::BUILTIN as LOOKS; @@ -262,7 +264,7 @@ fn process_surface(surf: &mut Surface, selection: Option<&Surface>, f: &mut dyn /// One history step running `f` on the active pixel layer (or targeted channel or layer mask). fn rgba_edit(s: &mut Session, label: &str, p: &Value, mut f: impl FnMut(&mut Vec<[f32; 4]>, Rect)) -> Result { - let id = crate::commands::layer_param(s, &Value::Null).ok(); + let id = if p.get("layer").is_some() { Some(crate::commands::layer_param(s, p)?) } else { crate::commands::layer_param(s, p).ok() }; if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { return s.edit(label, |doc, _| { let sel = doc.selection.clone(); @@ -316,10 +318,10 @@ pub(crate) fn has_adjustable(s: &Session) -> std::result::Result<(), String> { /// the filter mask); `Ok(false)` when the command should edit pixels instead (a mask or channel /// target, or the active layer is not a smart object). pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p: &Value) -> Result { - if crate::commands::is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { + if crate::commands::is_mask_target(p) || crate::channel_cmds::is_channel_target(p) || crate::color_channels::target(s, p)?.is_some() { return Ok(false); } - let Some(id) = s.active().and_then(|d| d.active_layer) else { return Ok(false) }; + let Ok(id) = crate::commands::layer_param(s, p) else { return Ok(false) }; let smart = s.active().is_some_and(|d| matches!(d.doc.layer(id).map(|l| &l.content), Some(LayerContent::Smart(_)))); if !smart { return Ok(false); @@ -401,7 +403,11 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { } } +/// Recorded Lab bounds use native profile matching; ordinary RGB samples keep their existing path. fn replace_color(s: &mut Session, p: &Value) -> Result { + if let Some(result) = replace_color_range::apply(s, p)? { + return Ok(result); + } const CMD: &str = "image.adjustments.replaceColor"; let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?; let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); @@ -459,9 +465,9 @@ fn stats_pixels(doc: &Document, layer: Option, use_selection: bool) -> Resu let px: Vec<[f32; 4]> = match layer { Some(id) => { let l = doc.layer(photocraft_doc::LayerId(id)).ok_or(EngineError::NoLayer(photocraft_doc::LayerId(id)))?; - photocraft_compose::render_layer(l, area).px + photocraft_compose::render_layer(l, area).into_px() } - None => photocraft_compose::flatten(doc).px, + None => photocraft_compose::flatten(doc).into_px(), }; let mask = doc.selection.as_ref().filter(|_| use_selection).map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), area)); Ok((px, mask)) @@ -521,7 +527,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result { s.edit("HDR Toning", |doc, active| { let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]); let (w, hgt) = (buf.rect.width() as usize, buf.rect.height() as usize); - let mut px = buf.px; + let mut px = buf.into_px(); tone::hdr_toning(&mut px, w, hgt, &h); let fmt = doc.pixel_format(); let data: Vec = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); @@ -562,7 +568,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##, + r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?,"labMinimum":[L,a,b]?,"labMaximum":[L,a,b]?} (Lab bounds: a finite ordered D50 box, mutually exclusive with color/points/localized/range; composite pixel-layer target only. Otherwise color defaults to the foreground; localized points use range % of the longer side.)"##, has_pixels, replace_color ), diff --git a/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs b/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs new file mode 100644 index 0000000000..574610523c --- /dev/null +++ b/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs @@ -0,0 +1,245 @@ +//! Recorded Replace Color samples are a Lab box, not a single RGB colour. +use photocraft_algo::{selection, tone}; +use photocraft_cms::{Builtin, TransformOptions}; +use photocraft_doc::{PixelFormat, SampleType}; +use photocraft_geom::{Rect, TileCoord}; +use photocraft_raster::Surface; +use serde_json::Value; + +use super::{bad, num}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "image.adjustments.replaceColor"; + +/// Unstored pixels can still be selected when the mask has a nonzero default. +fn edit_bounds(content: Rect, selected: Option<&Surface>) -> Rect { + match selected { + Some(mask) if mask.default_pixel().first() == Some(&0.0) => content.intersect(&mask.content_bounds()), + _ => content, + } +} + +/// Validate original precision before checking order and converting to native f32. +fn bound(p: &Value, key: &str) -> Result<[f64; 3]> { + let values = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(|| bad(CMD, format!("`{key}` must be three finite Lab numbers")))?; + let mut result = [0.0; 3]; + for (out, value) in result.iter_mut().zip(values) { + let n = value.as_f64().filter(|n| n.is_finite() && (*n as f32).is_finite()).ok_or_else(|| bad(CMD, format!("invalid `{key}` Lab number")))?; + *out = n; + } + Ok(result) +} + +/// Apply the same Lab match, tone shift and selection mix to stored and implicit pixels. +fn replace_sample(format: &PixelFormat, pixel: &mut [f32], sample: &[f32; 4], range: &selection::LabRange, fuzz: f32, shift: [f32; 3], selected: f32) -> bool { + if sample[3].is_nan() || sample[3] <= 0.0 || selected <= 0.0 { + return false; + } + let value = [sample[0] * 100.0, sample[1] * 255.0 - 128.0, sample[2] * 255.0 - 128.0]; + let coverage = if format.sample == SampleType::U8 { + selection::lab_range_coverage_8bit(selection::quantize_lab8(value), range, fuzz) + } else { + selection::lab_range_coverage(value, range, fuzz) + }; + if coverage <= 0.0 { + return false; + } + let original = photocraft_raster::to_rgba(format, pixel); + let mut edited = [original]; + tone::replace_color_masked(&mut edited, &[coverage], shift[0], shift[1], shift[2]); + // Unchanged native samples must not take an incidental RGB roundtrip. + if edited[0] == original { + return false; + } + let mut mixed = std::array::from_fn(|c| original[c] * (1.0 - selected) + edited[0][c] * selected); + mixed[3] = original[3]; + photocraft_raster::from_rgba_into(format, mixed, pixel); + true +} + +/// A zero-default selection or an unchanged source default needs only stored tiles. +/// Otherwise retain the full edit rectangle, subject to the existing adjustment storage budget. +fn work_tiles(surface: &Surface, selected: Option<&Surface>, bounds: Rect, default_changes: bool) -> Result>> { + let sparse = selected.filter(|mask| mask.default_pixel().first() == Some(&0.0)).or_else(|| (!default_changes).then_some(surface)); + if let Some(sparse) = sparse { + let coords = || sparse.tiles().map(|(tile, _)| *tile).filter(|tile| !tile.rect().intersect(&bounds).is_empty()); + let mut tiles = crate::allocation::capacity(coords().count(), "Replace Color tile coordinates")?; + tiles.extend(coords()); + return Ok(Some(tiles)); + } + let stored = surface.tiles().filter(|(tile, _)| !tile.rect().intersect(&bounds).is_empty()).count(); + crate::pixels::check_adjustment_tile_budget(bounds, stored, &surface.format()).map_err(EngineError::Other)?; + Ok(None) +} + +/// Apply explicit recorded bounds; `None` leaves the legacy RGB/sample path alone. +/// Matching converts original surface channels through their document profile, +/// before the existing RGBA tone shift can clip or change their colour space. +pub(super) fn apply(s: &mut Session, p: &Value) -> Result> { + if p.get("labMinimum").is_none() && p.get("labMaximum").is_none() { + return Ok(None); + } + if ["color", "colors", "points", "subtractPoints", "order", "localized", "range", "channel", "targetChannel"].iter().any(|key| p.get(*key).is_some()) { + return Err(bad(CMD, "Lab bounds cannot be combined with RGB samples, localized settings or channel targets")); + } + if p.get("target").is_some_and(|t| t.as_str() != Some("pixels")) || crate::channel_cmds::edits_channel(s) || crate::color_channels::target(s, p)?.is_some() + { + return Err(bad(CMD, "Lab bounds require the composite pixel-layer target")); + } + let (lo, hi) = (bound(p, "labMinimum")?, bound(p, "labMaximum")?); + if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) { + return Err(bad(CMD, "Lab minimum must not exceed maximum")); + } + let mut range = selection::LabRange { lo: lo.map(|v| v as f32), hi: hi.map(|v| v as f32) }; + // Validate supplied shifts before the edit rather than silently defaulting. + for key in ["fuzziness", "hue", "saturation", "lightness"] { + if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(f64::is_finite)) { + return Err(bad(CMD, format!("`{key}` must be a finite number"))); + } + } + let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); + let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); + let id = crate::commands::layer_param(s, p)?; + let st = s.active().ok_or(EngineError::NoDocument)?; + super::super::enabled::check_pixels_unlocked(&st.doc, id)?; + let surface = st.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad(CMD, "Lab bounds require a pixel layer"))?; + let format = surface.format(); + if format.mode != st.doc.mode { + return Err(bad(CMD, "pixel layout does not match the document profile")); + } + let transform = photocraft_cms::cached(&crate::color_cmds::document_profile(&st.doc), Builtin::LabD50.profile(), TransformOptions::default()) + .map_err(|e| bad(CMD, format!("Lab colour transform: {e}")))?; + if transform.inputs() != format.mode.color_channels() || transform.outputs() != 3 || format.channels() != transform.inputs() + usize::from(format.alpha) { + return Err(bad(CMD, "unsupported Lab colour-transform layout")); + } + let grid = format.sample == SampleType::U8; + if grid { + range.lo = selection::quantize_lab8(range.lo); + range.hi = selection::quantize_lab8(range.hi); + } + if h == 0.0 && sat == 0.0 && l == 0.0 { + // Validation still runs, but a zero shift must not re-separate native inks. + return Ok(Some(Value::Null)); + } + s.edit("Replace Color", |doc, _| { + let canvas = doc.bounds(); + let selected = doc.selection.clone(); + let surface = crate::commands::paint_surface(doc, id, &Value::Null)?; + let transparent_default = format.alpha && surface.default_pixel().last().is_some_and(|a| a.is_nan() || *a <= 0.0); + let content = surface.content_bounds(); + // A nontransparent default is visible across the canvas even without stored tiles. + let extent = if transparent_default { content } else { content.union(&canvas) }; + let bounds = edit_bounds(extent, selected.as_ref()); + let mut default = surface.default_pixel(); + let mut default_lab = [[0.0, 0.0, 0.0, 1.0]]; + transform.convert_f32(&default, format.channels(), default_lab.as_flattened_mut(), 4, true); + let default_selected = selected.as_ref().map_or(1.0, |mask| mask.default_pixel()[0]); + let default_changes = replace_sample(&format, &mut default, &default_lab[0], &range, fuzz, [h, sat, l], default_selected); + let tiles = work_tiles(surface, selected.as_ref(), bounds, default_changes)?; + // Matching and tone changes are pointwise, so pixel work stays tile-sized. + let mut process_tile = |tile: TileCoord| -> Result<()> { + let rect = tile.rect().intersect(&bounds); + if rect.is_empty() { + return Ok(()); + } + let mut raw = crate::allocation::read_region(surface, rect, "Replace Color")?; + let mut lab = crate::allocation::filled(raw.len() / format.channels(), [0.0, 0.0, 0.0, 1.0], "Replace Color")?; + transform.convert_f32(&raw, format.channels(), lab.as_flattened_mut(), 4, true); + let width = rect.width() as usize; + let mut selection_row = crate::allocation::filled(width, 1.0, "Replace Color")?; + let mut changed = false; + for (y, (row, lab_row)) in raw.chunks_exact_mut(width * format.channels()).zip(lab.chunks_exact(width)).enumerate() { + if let Some(selected) = selected.as_ref() { + selected.sample_row_strided(rect.y0.saturating_add(y as i32), rect.x0, 1, 0, &mut selection_row); + } + for ((pixel, sample), selected) in row.chunks_exact_mut(format.channels()).zip(lab_row).zip(&selection_row) { + changed |= replace_sample(&format, pixel, sample, &range, fuzz, [h, sat, l], *selected); + } + } + if changed { + surface.try_write_region(rect, &raw).map_err(|e| crate::allocation::error("Replace Color", &e.to_string()))?; + } + Ok(()) + }; + if let Some(tiles) = tiles { + for tile in tiles { + process_tile(tile)?; + } + } else { + for tile in bounds.tiles() { + process_tile(tile)?; + } + } + Ok(Value::Null) + }) + .map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + + /// Work buffers follow explicit selected pixels without cropping implicitly selected coverage. + #[test] + fn selected_work_bounds_preserve_implicit_selection_and_clip_explicit_pixels() { + let content = Rect::new(-100, -100, 100, 100); + let mut mask = Surface::new(PixelFormat::GRAY8); + assert_eq!(edit_bounds(content, Some(&mask)), Rect::EMPTY); + mask.fill_rect(Rect::new(98, 96, 110, 105), &[0.5]); + assert_eq!(edit_bounds(content, Some(&mask)), Rect::new(98, 96, 100, 100)); + assert_eq!(edit_bounds(content, None), content); + for coverage in [0.5, 1.0] { + let mut mask = Surface::with_default(PixelFormat::GRAY8, &[coverage]); + mask.fill_rect(Rect::new(2, 2, 3, 3), &[0.0]); + assert_eq!(edit_bounds(content, Some(&mask)), content, "stored holes do not bound selected pixels"); + } + } + + /// The work list follows stored source or selection tiles rather than their distant bounds. + #[test] + fn sparse_work_tiles_follow_pixels_that_can_change() { + let near = Rect::new(0, 0, 1, 1); + let far = Rect::new(100_000, 100_000, 100_001, 100_001); + let bounds = near.union(&far); + for alpha in [false, true] { + let format = PixelFormat { alpha, ..PixelFormat::RGBA8 }; + let mut surface = Surface::with_default(format, &[1.0; 4][..format.channels()]); + surface.fill_rect(far, &[0.0; 4][..format.channels()]); + assert_eq!(work_tiles(&surface, None, bounds, false).unwrap(), Some(vec![TileCoord::containing(far.x0, far.y0)])); + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(near, &[1.0]); + mask.fill_rect(far, &[1.0]); + assert_eq!( + work_tiles(&surface, Some(&mask), bounds, true).unwrap(), + Some(vec![TileCoord::containing(0, 0), TileCoord::containing(far.x0, far.y0)]) + ); + assert!(work_tiles(&surface, None, near, true).unwrap().is_none(), "a small default-only area remains editable"); + } + } + + /// A matching opaque default cannot allocate gap tiles beyond the existing adjustment budget. + #[test] + fn opaque_gap_budget_refuses_before_pixel_allocation_or_edit() { + let mut s = Session::new(); + s.execute("file.new", serde_json::json!({"width":4,"height":2,"mode":"lab","depth":32,"background":"transparent"})).unwrap(); + s.edit("Distant opaque exception", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + *surface = Surface::with_default(surface.format(), &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + surface.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + crate::allocation::fail_next_for_test(); + let result = s.execute(CMD, serde_json::json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})); + // Drain the probe even on failure so another test cannot inherit it. A skipped probe + // proves that the resource check ran before any fallible tile-buffer allocation. + let probe_unused = crate::allocation::capacity::(0, "allocation probe").is_err(); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("too large an area")), "{result:?}"); + assert!(probe_unused); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index e18e37f1ec..51843ae022 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -219,6 +219,31 @@ LUT_3D_SIZE 2 assert_eq!(looks.as_array().unwrap().len(), photocraft_cms::lutfile::BUILTIN.len()); } +#[test] +fn color_lookup_mesh_3dl_honours_the_header() { + // #9078 / #9073: a Lustre .3dl whose `Mesh ` line declares the cube edge + // (2^inBits + 1) and the output range (up to 2^outBits - 1). The three-value shaper row used + // to be counted as a 28th node ("28 rows is not a cube"), and 10-bit codes under a 12-bit + // declaration divided by the inferred 1023 instead of the declared 4095. + let mut lut = String::from("3DMESH\nMesh 1 12\n0 512 1023\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + let v = [r, g, b].map(|c| (4095.0 - 4095.0 * c as f64 / 2.0).round() as i32); + lut.push_str(&format!("{} {} {}\n", v[0], v[1], v[2])); + } + } + } + let mut s = session(8, "rgb"); + paint(&mut s, |_, _| [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]); + s.execute("image.adjustments.colorLookup", json!({"data": lut, "fileName": "inverse.3dl", "dither": false})).unwrap(); + // Photoshop 27.11 renders (64,128,192) as (191,127,63) through this file. + let after = rgba(&s, 5, 5); + for (c, want) in [(0, 191.0), (1, 127.0), (2, 63.0)] { + assert!((after[c] * 255.0 - want).abs() <= 1.0, "channel {c}: {after:?}"); + } +} + /// The kind's parameters, read back from the model (Color Lookup with its table name and size). #[test] fn color_lookup_file_round_trips_psd_in_its_own_format() { @@ -455,7 +480,7 @@ fn smart_session() -> Session { } fn flatten(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(doc(s)).px + photocraft_compose::flatten(doc(s)).into_px() } fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { diff --git a/crates/engine/src/cmd/adjust_params.rs b/crates/engine/src/cmd/adjust_params.rs index ae1ba0a2b8..6e89defef2 100644 --- a/crates/engine/src/cmd/adjust_params.rs +++ b/crates/engine/src/cmd/adjust_params.rs @@ -3,11 +3,20 @@ use super::layer_params::{layer_param, mask_new_layer}; use super::{Adjustment, ColorMode, EngineError, Layer, LayerContent, Map, Result, Session, Value, json, pixels}; /// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask). +/// Optional opacity is a 0..1 fraction, validated before the creation edit. pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result { + let opacity = match p.get("opacity") { + None => 1.0, + Some(value) => value + .as_f64() + .filter(|o| o.is_finite() && (0.0..=1.0).contains(o)) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "opacity must be a number in 0..1".into() })? as f32, + }; 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)); + l.opacity = opacity; mask_new_layer(doc, &mut l, p, cmd, true)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); @@ -18,9 +27,21 @@ pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: & /// 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`. pub(super) fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) -> Result { + if let Some(colors) = crate::color_channels::target(s, p)? { + let id = layer_param(s, p)?; + return s.edit(label, |doc, _| { + let bounds = doc.bounds(); + let selection = doc.selection.clone(); + let surf = paint_surface(doc, id, p)?; + let extent = bounds.union(&surf.content_bounds()); + crate::color_channels::adjust(surf, colors, &adj, selection.as_ref(), extent)?; + Ok(Value::Null) + }); + } + if is_mask_target(p) { // The targeted layer mask (#780): ⌘I inverts it, as in Photoshop. - let id = layer_param(s, &Value::Null)?; + let id = layer_param(s, p)?; return s.edit(label, |doc, _| { check_all_unlocked(doc, id)?; let sel = doc.selection.clone(); @@ -39,11 +60,11 @@ pub(super) fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, Ok(Value::Null) }); } - let id = layer_param(s, &Value::Null)?; + let id = layer_param(s, p)?; s.edit(label, |doc, _| { let sel = doc.selection.clone(); let mode = doc.mode; - let surf = paint_surface(doc, id, &Value::Null)?; + let surf = paint_surface(doc, id, p)?; pixels::adjust_surface(surf, &adj, sel.as_ref(), mode).map_err(EngineError::Other)?; Ok(()) })?; diff --git a/crates/engine/src/cmd/analysis_cmds.rs b/crates/engine/src/cmd/analysis_cmds.rs index defaa834d4..aa1648eaf7 100644 --- a/crates/engine/src/cmd/analysis_cmds.rs +++ b/crates/engine/src/cmd/analysis_cmds.rs @@ -175,6 +175,7 @@ fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> { /// as it was and the journal forgets the commands `f` ran, so an action being recorded does not /// replay an edit that was taken back. pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + crate::actions_cmds::before_action_edit(s); let st = s.active().ok_or(EngineError::NoDocument)?; let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target()); // An inner command may select a layer that disappears when the compound rolls back. diff --git a/crates/engine/src/cmd/artboard_cmds.rs b/crates/engine/src/cmd/artboard_cmds.rs index 2f05ddfae0..f9e31e4cd0 100644 --- a/crates/engine/src/cmd/artboard_cmds.rs +++ b/crates/engine/src/cmd/artboard_cmds.rs @@ -301,22 +301,38 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) } +/// Convert a plain group at its content bounds; recorded guide IDs are ignored session metadata. fn artboard_from_group(s: &mut Session, p: &Value) -> Result { + let cmd = "layer.new.artboardFromGroup"; let d = s.active().ok_or(EngineError::NoDocument)?; - let id = p.get("layer").and_then(Value::as_u64).map(LayerId).or(d.active_layer).ok_or(EngineError::Other("no active layer".into()))?; + let id = crate::commands::layer_param(s, p)?; + let preset = match p.get("preset") { + None => String::new(), + Some(value) => value.as_str().ok_or_else(|| bad(cmd, "preset must be text"))?.to_string(), + }; + let apply_background = match p.get("changeBackground") { + None | Some(Value::Bool(true)) => true, + Some(value) => value.as_i64().and_then(|n| i32::try_from(n).ok()).ok_or_else(|| bad(cmd, "changeBackground must be an integer"))? == 1, + }; + if let Some(guides) = p.get("guideIDs") + && !guides.as_array().is_some_and(|ids| ids.iter().all(|id| id.as_i64().is_some_and(|n| i32::try_from(n).is_ok()))) + { + return Err(bad(cmd, "guideIDs must be a list of integers")); + } let canvas = d.doc.bounds(); - let background = background_param(p, "layer.new.artboardFromGroup")?.unwrap_or_default(); + let background = if apply_background { background_param(p, cmd)?.unwrap_or_default() } else { ArtboardBackground::White }; let rect = s.edit("Artboard from Group", |doc, active| { if !doc.layers.iter().any(|t| t.id == id && t.is_group() && t.artboard().is_none()) { return Err(EngineError::Other("artboards are made from a top-level layer group".into())); } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let rect = crate::layer_multi_cmds::layer_bounds(l).unwrap_or(canvas); - set_artboard(l, Artboard { rect, background, preset: String::new() }); + set_artboard(l, Artboard { rect, background, preset }); fit_canvas(doc); *active = Some(id); Ok(rect) })?; + crate::layer_multi_cmds::set_selection(s, vec![id], Some(id), Some(id))?; Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]})) } @@ -377,10 +393,7 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result { fn set_props(s: &mut Session, p: &Value) -> Result { let cmd = "layer.artboard.set"; let d = s.active().ok_or(EngineError::NoDocument)?; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(v) => LayerId(v), - None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?, - }; + let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?; let old = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?; let preset = p.get("preset").and_then(Value::as_str).map(str::to_string); let mut base = xywh(old.rect); @@ -441,10 +454,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result { fn add_beside(s: &mut Session, p: &Value) -> Result { let cmd = "layer.artboard.addBeside"; let d = s.active().ok_or(EngineError::NoDocument)?; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(v) => LayerId(v), - None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?, - }; + let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?; let src = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?; let side = p.get("side").and_then(Value::as_str).unwrap_or("right"); let duplicate = p.get("duplicate").and_then(Value::as_bool).unwrap_or(false); @@ -703,7 +713,7 @@ pub fn specs() -> Vec { "layer.new.artboardFromGroup", "Artboard from Group…", &["Layer", "New"], - r##"{"layer":id? (a top-level group; default active),"background":…?} → {layer, rect}"##, + r##"{"layer":id? (a top-level group; default active),"background":…?,"color":[r,g,b]|"#rrggbb"?,"preset":str? (tag only; does not resize),"guideIDs":[int]? (ignored recording metadata),"changeBackground":int? (1 or absent applies background/color; other integers give white; legacy true = 1)} → {layer, rect}"##, has_plain_group, artboard_from_group ), diff --git a/crates/engine/src/cmd/artboard_cmds/tests.rs b/crates/engine/src/cmd/artboard_cmds/tests.rs index d21011a71b..39b663efbe 100644 --- a/crates/engine/src/cmd/artboard_cmds/tests.rs +++ b/crates/engine/src/cmd/artboard_cmds/tests.rs @@ -400,6 +400,116 @@ fn red_at(s: &Session, x: i32, y: i32) -> bool { px[0] > 0.99 && px[1] < 0.01 && px[3] > 0.99 } +fn content(s: &Session, id: LayerId) -> Rect { + doc(s).layer(id).unwrap().surface().unwrap().content_bounds() +} + +/// Board "A" at (10, 10, 50, 50) holding a red square at (10..30)², and an empty board "B" at +/// (200, 20, 240, 60), so B's origin is (190, 10) from A's. +fn two_boards(s: &mut Session) -> (LayerId, LayerId, LayerId) { + let red = add_square(s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + let a = LayerId(s.execute("layer.new.artboardFromLayers", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.artboard.set", json!({"width": 40, "height": 40})).unwrap(); + let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [200, 20, 40, 40], "name": "B"})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(s).artboard_of(red), Some(a)); + (red, a, b) +} + +/// Dragging a layer from one artboard onto another in the Layers panel (`layer.moveTo`) put it in +/// the other board's group but left it at its old canvas position, outside the board it now +/// belonged to, so the board clipped it away until it was moved by hand. The layer keeps its place +/// on its board instead: it shifts by the offset between the two boards' origins. +#[test] +fn moving_a_layer_to_another_artboard_keeps_its_place_on_the_board() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let (red, a, b) = two_boards(&mut s); + let steps = s.active().unwrap().history.past_len(); + + s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "into"})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(b), "{depth}"); + assert_eq!(content(&s, red), Rect::new(200, 20, 220, 40), "{depth}: same spot on board B"); + assert!(red_at(&s, 205, 25), "{depth}: shows on board B"); + assert!(!red_at(&s, 15, 15), "{depth}: and has left board A"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{depth}: one undo step for the move and the shift"); + + // Back again: the reverse offset. + s.execute("layer.moveTo", json!({"layer": red.0, "target": a.0, "position": "into"})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(a), "{depth}"); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30), "{depth}"); + assert!(red_at(&s, 15, 15), "{depth}"); + + // Undo takes the layer and its shift back together. + s.undo(); + assert_eq!(doc(&s).artboard_of(red), Some(b), "{depth}"); + assert_eq!(content(&s, red), Rect::new(200, 20, 220, 40), "{depth}"); + } +} + +/// ⌥-dragging a layer onto another board: the copy lands in place on that board, the original stays. +#[test] +fn copying_a_layer_to_another_artboard_keeps_its_place_on_the_board() { + let mut s = session(8); + let (red, a, b) = two_boards(&mut s); + let r = s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "into", "copy": true})).unwrap(); + let copy = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(doc(&s).artboard_of(copy), Some(b)); + assert_eq!(content(&s, copy), Rect::new(200, 20, 220, 40)); + assert_eq!(doc(&s).artboard_of(red), Some(a)); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30), "the original stays put"); + assert!(red_at(&s, 15, 15) && red_at(&s, 205, 25)); +} + +/// Only a layer going from one board to another is shifted: one reordered inside its own board, +/// or taken out of every board, or put into one from outside, keeps its canvas position as before. +#[test] +fn only_a_move_between_two_artboards_shifts_the_layer() { + let mut s = session(8); + let (red, a, b) = two_boards(&mut s); + // Out to the top level (above board B): no board on either side of the shift. + s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "above"})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), None); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30)); + // And in from outside any board: nothing says where it belongs on the board, so it stays. + s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "into"})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(b)); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30)); + // Dropped back on its own board's group: still the same board, so no shift. + s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "into"})).unwrap(); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30)); + // Moving a board beside another carries its own rect, as before. + let before = doc(&s).layer(a).unwrap().artboard().unwrap().rect; + s.execute("layer.moveTo", json!({"layer": a.0, "target": b.0, "position": "above"})).unwrap(); + assert_eq!(doc(&s).layer(a).unwrap().artboard().unwrap().rect, before); +} + +/// A position-locked layer is not moved by a drag between boards (it still changes parent). +#[test] +fn a_position_locked_layer_keeps_its_canvas_position_between_artboards() { + let mut s = session(8); + let (red, _a, b) = two_boards(&mut s); + s.execute("layer.select", json!({"layer": red.0})).unwrap(); + s.execute("layer.lockLayers", json!({"position": true})).unwrap(); + s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "into"})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(b)); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30)); +} + +/// A layer whose position is locked only through the board it leaves (the lock is on the board, +/// not the layer) is not moved either: it was locked when the drag began (#10489 review). +#[test] +fn a_layer_locked_through_its_board_keeps_its_canvas_position_between_artboards() { + let mut s = session(8); + let (red, a, b) = two_boards(&mut s); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.lockLayers", json!({"position": true})).unwrap(); + assert!(!doc(&s).layer(red).unwrap().locks.position, "the layer itself is unlocked"); + assert!(doc(&s).effective_locks(red).position, "but locked through its board"); + s.execute("layer.moveTo", json!({"layer": red.0, "target": b.0, "position": "into"})).unwrap(); + assert_eq!(doc(&s).artboard_of(red), Some(b)); + assert_eq!(content(&s, red), Rect::new(10, 10, 30, 30)); +} + #[test] fn duplicate_artboard_lands_beside_the_original_with_its_contents() { // #1531: the copy sat exactly on the original, so nothing visible happened. diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 7c717701bf..c43a019e28 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -15,7 +15,8 @@ //! Layer references by name (`select` of layer "Shadow") become `{"layer": "Shadow"}`, which //! `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). +//! {"offset"}`, the document that many places from the active one in opening order, not dragged +//! tab order (#7103). use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; @@ -31,15 +32,31 @@ mod descriptors; mod events; mod fields; mod files; + +mod steps_artboard; +mod steps_channels; +mod steps_content_aware; +mod steps_direct_adjustments; +mod steps_distort; mod steps_doc; +mod steps_gallery; +mod steps_gradient; mod steps_image; mod steps_layer; +mod steps_layer_style; mod steps_make; +mod steps_type_path; + +mod steps_stylize; + +mod steps_other; +mod steps_render; +mod steps_selection; mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; -pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, parse_function_key}; +pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; #[cfg(test)] @@ -55,6 +72,7 @@ use fields::Mapped; use files::{load, save}; #[cfg(test)] use steps_doc::crop_from; +pub(crate) use steps_gallery::RECORDED_EVENT; #[cfg(test)] use steps_image::{apply_image_from, transform_to}; #[cfg(test)] @@ -101,7 +119,7 @@ pub fn specs() -> Vec { label: "Load Actions…", menu: &[], shortcut: None, - params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##, + params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name),"encoding":"auto"|label="auto" (the encoding of 8-bit step names: shift_jis, gbk, big5, euc-kr, windows-1252, macintosh…; auto works it out)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings, encoding}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##, enabled: always, run: load, journal: false, @@ -111,7 +129,7 @@ pub fn specs() -> Vec { label: "Save Actions…", menu: &[], shortcut: None, - params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##, + params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions, none for an empty New Set set; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##, enabled: always, run: save, journal: false, @@ -144,3 +162,12 @@ mod tests; #[cfg(test)] mod layer_props_tests; + +#[cfg(test)] +mod stylize_tests; + +#[cfg(test)] +mod selection_tests; + +#[cfg(test)] +mod render_tests; diff --git a/crates/engine/src/cmd/atn_cmds/adjustments.rs b/crates/engine/src/cmd/atn_cmds/adjustments.rs index 5da802b800..383b0b6267 100644 --- a/crates/engine/src/cmd/atn_cmds/adjustments.rs +++ b/crates/engine/src/cmd/atn_cmds/adjustments.rs @@ -1,4 +1,6 @@ -use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, jbool, jnum, jstr, long, num, pct, text}; +use super::descriptors::{ + color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, jbool, jnum, jstr, lab_color_hex, long, num, pct, text, +}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, channel_key, channel_ref, lookup, lookup_back}; use super::{D, Descriptor, Map, Value, json}; @@ -66,6 +68,20 @@ fn levels_entry(chan: Option<&str>, p: &Value) -> Descriptor { e } +/// Whether the Levels settings in `p` (the master curve and every channel object) name both ends +/// of each range they touch, or neither. `levels_entry` writes a whole `Inpt` or `Otpt` pair and +/// fills the end a partial edit left out with 0 or 255, so replaying `inBlack: 31` alone would +/// also reset a retained Input White (#8530). A value given in a shape the writer can't read (a +/// text endpoint, a channel that isn't an object) would be left out, so it isn't complete either. +pub(super) fn levels_complete(p: &Value) -> bool { + let shaped = + |v: &Value| ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].iter().all(|k| v.get(*k).is_none_or(|x| x.is_null() || jnum(v, k).is_some())); + let pairs_whole = + |v: &Value| [("inBlack", "inWhite"), ("outBlack", "outWhite")].iter().all(|(low, high)| jnum(v, low).is_some() == jnum(v, high).is_some()); + let whole = |v: &Value| shaped(v) && pairs_whole(v); + whole(p) && CHANNELS.iter().filter_map(|(_, k)| p.get(*k).filter(|v| !v.is_null())).all(|v| v.is_object() && whole(v)) +} + pub(super) fn levels_to(p: &Value) -> Option { let mut entries = vec![D::Descriptor(levels_entry(None, p))]; for (_, k) in CHANNELS { @@ -343,7 +359,13 @@ fn gradient_map_from(d: &Descriptor) -> Option { Some(out) } +/// Write the legacy gradient form without absorbing settings owned by a direct recorded event. fn gradient_map_to(p: &Value) -> Option { + // A recorded GrMp has its own strict converter. Never fall back to the older + // event if its origin/settings were changed into an unrepresentable form. + if p.get(super::steps_direct_adjustments::GRADIENT_ORIGIN).is_some() { + return None; + } let stops: Option> = p .get("stops")? .as_array()? @@ -359,16 +381,7 @@ fn gradient_map_to(p: &Value) -> Option { )) }) .collect(); - let transparency = D::List(vec![ - D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))), - D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))), - ]); - let grad = desc("Grdn") - .with("Nm ", text("Custom")) - .with("GrdF", enm("GrdF", "CstS")) - .with("Intr", D::Double(4096.0)) - .with("Clrs", D::List(stops?)) - .with("Trns", transparency); + let grad = gradient_descriptor(stops?, "Custom"); let mut d = desc("GdMp").with("Grad", D::Descriptor(grad)); if let Some(r) = jbool(p, "reverse") { d = d.with("Rvrs", D::Boolean(r)); @@ -379,6 +392,20 @@ fn gradient_map_to(p: &Value) -> Option { Some(d) } +/// The classic, opaque gradient shared by direct and adjustment-layer mappings. +pub(super) fn gradient_descriptor(stops: Vec, name: &str) -> Descriptor { + let transparency = D::List(vec![ + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))), + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))), + ]); + desc("Grdn") + .with("Nm ", text(name)) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(stops)) + .with("Trns", transparency) +} + /// 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. @@ -395,6 +422,18 @@ macro_rules! adj_fields { }; } +const VIBRANCE: &[F] = &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]; + +/// Vibrance. Photoshop's recording also carries `temperature` and `tint`, which our Vibrance has +/// no params for: with either one non-zero the step stays a kept Photoshop step (saved back +/// unchanged) instead of playing and saving without them. +fn vibrance_from(d: &Descriptor) -> Option { + if ["temperature", "tint"].iter().any(|k| get_num(d, k).is_some_and(|v| v != 0.0)) { + return None; + } + fields_from(d, VIBRANCE).map(Value::Object) +} + const ADJUSTMENTS: &[Adjustment] = &[ Adjustment { class: "Lvls", suffix: "levels", from: levels_from, to: levels_to }, Adjustment { class: "Crvs", suffix: "curves", from: curves_from, to: curves_to }, @@ -404,9 +443,9 @@ const ADJUSTMENTS: &[Adjustment] = &[ adj_fields!("Pstr", "posterize", &[F("Lvls", "levels", Kind::Long)]), adj_fields!("Invr", "invert", &[]), 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)]), + Adjustment { class: "vibrance", suffix: "vibrance", from: vibrance_from, to: |p| fields_to(p, VIBRANCE, desc("vibrance")) }, adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]), - Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to }, + Adjustment { class: "PhtF", suffix: "photoFilter", from: photo_filter_from, to: photo_filter_to }, adj_fields!("BanW", "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 }, @@ -417,7 +456,9 @@ const ADJUSTMENTS: &[Adjustment] = &[ /// Photoshop (#8481): a Make whose Black & White type is `BlnW` fails ("the command Make is not /// currently available"), `BanW` runs. Import keeps accepting them so actions saved before still /// load; export never writes them. -const LEGACY_CLASSES: &[(&str, &str)] = &[("BlnW", "BanW")]; +/// `Vbrn` is a code PhotoCraft invented for Vibrance: Photoshop records the class by its string id +/// `vibrance` (#8534), and refuses the `Vbrn` code (#8383). +const LEGACY_CLASSES: &[(&str, &str)] = &[("BlnW", "BanW"), ("Vbrn", "vibrance")]; /// The adjustment with Photoshop class `c`, or the one an old PhotoCraft class stands for. pub(super) fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { @@ -428,6 +469,15 @@ pub(super) fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { const PHOTO_FILTER_FIELDS: &[F] = &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]; +/// Photo Filter records its default colour in Lab; other colour events retain their mappings. +fn photo_filter_from(d: &Descriptor) -> Option { + let mut p = fields_from(d, &PHOTO_FILTER_FIELDS[1..])?; + if let Some(c) = get_obj(d, "Clr ") { + p.insert("color".into(), color_hex(c).or_else(|| lab_color_hex(c))?.into()); + } + Some(Value::Object(p)) +} + /// 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 diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 78d65979bf..4b22515c09 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -23,15 +23,18 @@ pub struct Imported { pub function_keys: Vec<(String, String)>, pub unsupported: Vec, pub warnings: Vec, + /// The encoding the file's 8-bit step names were read in (`"GBK"`, `"Shift_JIS"`, + /// `"macintosh"`…); `None` when none needed one. + pub encoding: Option, } -fn photoshop_step(item: &AtnItem) -> (String, Value) { +fn photoshop_step(item: &AtnItem, label: String) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); let event_kind = match &item.event { AtnEvent::Code(_) => "code", AtnEvent::Name(_) => "name", }; - (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "eventKind": event_kind, "name": item.label(), "descriptor": descriptor})) + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "eventKind": event_kind, "name": label, "descriptor": descriptor})) } /// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings @@ -61,7 +64,7 @@ pub(super) fn clip_name(s: &str) -> String { s.chars().take(MAX_NAME_CHARS).collect() } -fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { +fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); @@ -72,10 +75,14 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) (None, _) => Mapped::Unsupported { harmless: false }, }; let (new_steps, enabled) = match mapped { - Mapped::Steps(s) => (s, item.enabled), + Mapped::Steps(mut s) => { + super::steps_gallery::remember_event(item, &mut s); + (s, item.enabled) + } Mapped::Unsupported { harmless } => { - unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() }); - (vec![photoshop_step(item)], item.enabled && !harmless) + let label = file.item_label(item); + unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: label.clone() }); + (vec![photoshop_step(item, label)], item.enabled && !harmless) } }; for mut s in new_steps { @@ -96,12 +103,18 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) /// Convert a `.atn` file. Fails only when the file is unreadable: every step imports, as a /// command or as a kept Photoshop step. pub fn import(bytes: &[u8]) -> std::result::Result { - let file = atn::parse(bytes).map_err(|e| e.to_string())?; + import_with_encoding(bytes, None) +} + +/// [`import`], reading 8-bit step names in `encoding` (a label such as `"shift_jis"` or +/// `"gbk"`) instead of working it out. An unknown label fails. +pub fn import_with_encoding(bytes: &[u8], encoding: Option<&str>) -> std::result::Result { + let file = atn::parse_with_encoding(bytes, encoding).map_err(|e| e.to_string())?; let set = file.name.to_string_lossy(); let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { clip_name(&set) }; - let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; + let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), encoding: file.encoding.clone(), ..Default::default() }; for a in &file.actions { - let action = convert_action(a, &set, &mut out.unsupported); + let action = convert_action(&file, a, &set, &mut out.unsupported); if a.function_key > 0 { out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command))); } @@ -145,6 +158,12 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option std::result::Result<(Vec, Vec), String> { let mut skipped = Vec::new(); - let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; + let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new(), ..Default::default() }; for a in actions { let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..681f89fce5 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -20,13 +20,17 @@ pub(super) fn get_num(d: &Descriptor, k: &str) -> Option { d.get(k).and_then(num) } -pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Boolean(b)) => Some(*b), +pub(super) fn boolean(v: &D) -> Option { + match v { + D::Boolean(b) => Some(*b), _ => None, } } +pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(boolean) +} + pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { match d.get(k) { Some(D::Text(t)) => Some(t.to_string_lossy()), @@ -35,13 +39,17 @@ pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { } /// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). -pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Enumerated { value, .. }) => Some(trimmed(value)), +pub(super) fn enumerated(v: &D) -> Option { + match v { + D::Enumerated { value, .. } => Some(trimmed(value)), _ => None, } } +pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(enumerated) +} + pub(super) fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { match d.get(k) { Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), @@ -109,13 +117,29 @@ pub(super) fn layer_target(r: &[ReferenceItem]) -> Option> { m.insert("layer".into(), json!(n)); } Target::Property(p) if p == "Bckg" => { - m.insert("layer".into(), json!("Background")); + m.insert("layer".into(), json!({"background": true})); } _ => return None, } Some(m) } +/// Select and Move retain stack references until playback rather than capturing local ids. +pub(super) fn recorded_layer_target(r: &[ReferenceItem]) -> Option> { + let selector = match r { + [ReferenceItem::Index { class, index }] if trimmed(&class.class_id) == "Lyr" => json!({"index": index}), + [ReferenceItem::Enumerated { class, type_id, value }] + if trimmed(&class.class_id) == "Lyr" && type_id.is("Ordn") && matches!(trimmed(value).as_str(), "Trgt" | "Frwr" | "Bckw" | "Frnt" | "Back") => + { + json!({"ordinal": trimmed(value)}) + } + [ReferenceItem::Index { .. } | ReferenceItem::Enumerated { .. }] => return None, + _ if matches!(target(r), Target::Index(_) | Target::Ordinal(_)) => return None, + _ => return layer_target(r), + }; + Some(Map::from_iter([("layer".into(), selector)])) +} + // ------------------------------------------------------------------ descriptor building /// A Photoshop id: codes shorter than four characters are space-padded (`H `, `Rd `), @@ -177,10 +201,32 @@ pub(super) fn layer_ref(p: &Value) -> Option { match p.get("layer") { None | Some(Value::Null) => Some(target_ref("Lyr ")), Some(Value::String(n)) => Some(name_ref("Lyr ", n)), + Some(v) if *v == json!({"background": true}) => Some(D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }])), _ => None, } } +/// Write the recorded form, including the padded four-byte ordinal codes. +pub(super) fn recorded_layer_ref(p: &Value) -> Option { + let Some(Value::Object(selector)) = p.get("layer") else { return layer_ref(p) }; + if !selector.contains_key("index") && !selector.contains_key("ordinal") { + return layer_ref(p); + } + if selector.len() != 1 { + return None; + } + let item = if let Some(index) = selector.get("index") { + ReferenceItem::Index { class: class("Lyr "), index: u32::try_from(index.as_u64()?).ok()? } + } else { + let ordinal = selector.get("ordinal")?.as_str()?; + if !matches!(ordinal, "Trgt" | "Frwr" | "Bckw" | "Frnt" | "Back") { + return None; + } + ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(ordinal) } + }; + Some(D::Reference(vec![item])) +} + pub(super) fn desc(cls: &str) -> Descriptor { Descriptor::new(cls) } @@ -201,6 +247,22 @@ pub(super) fn jstr<'a>(p: &'a Value, k: &str) -> Option<&'a str> { // ------------------------------------------------------------------ colours and enums +/// A recorded D50 Lab colour converted through the CMS to sRGB `#rrggbb`. +pub(super) fn lab_color_hex(c: &Descriptor) -> Option { + use photocraft_cms::{Builtin, TransformOptions}; + if !c.class_id.is("LbCl") { + return None; + } + let (l, a, b) = (get_num(c, "Lmnc")?, get_num(c, "A ")?, get_num(c, "B ")?); + let transform = photocraft_cms::cached(Builtin::LabD50.profile(), Builtin::Srgb.profile(), TransformOptions { bpc: false, ..Default::default() }).ok()?; + // ICC v4 Lab device values are normalized, unlike the recorded L*, a*, b*. + let input = [(l / 100.0) as f32, ((a + 128.0) / 255.0) as f32, ((b + 128.0) / 255.0) as f32]; + let mut rgb = [0.0; 3]; + transform.convert_f32(&input, 3, &mut rgb, 3, false); + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + Some(format!("#{r:02x}{g:02x}{b:02x}")) +} + /// A Photoshop colour object as `#rrggbb`: `RGBC` (0..255), `HSBC`, `Grsc`. pub(super) fn color_hex(c: &Descriptor) -> Option { let hex = |r: f64, g: f64, b: f64| { diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 3760e6916e..8bd37acbc1 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -1,22 +1,34 @@ +const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")]; use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to}; -use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class}; +use super::descriptors::{ + class, code, desc, get_bool, get_obj, get_ref, jbool, layer_ref, layer_target, path_text, path_value, ref_class, target_ref, trimmed, +}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; +use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from, artboard_to}; +use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to}; +use super::steps_content_aware::{caf_from, caf_to}; +use super::steps_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_to}; +use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, shear_from, shear_to, wave_from, wave_to}; use super::steps_doc::{ align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to, crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to, place_from, place_to, profile_from, profile_to, purge_from, purge_to, reset_from, reset_to, save_from, save_to, stop_from, stop_to, }; use super::steps_image::{ - GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, color_range_from, color_range_to, gallery_from, gallery_to, - has_refine_strokes, radial_blur_from, radial_blur_to, refine_edge_to, transform_from, transform_to, + GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, camera_raw_from, camera_raw_to, color_range_from, color_range_to, + empty_from, empty_to, gallery_from, gallery_to, has_refine_strokes, oil_paint_from, oil_paint_to, radial_blur_from, radial_blur_to, refine_edge_to, + select_sky_from, select_sky_to, smart_sharpen_from, smart_sharpen_to, transform_from, transform_to, }; use super::steps_layer::{ STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to, - move_from, move_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to, + move_from, move_to, paste_effects_from, paste_effects_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, + visibility_to, }; -use super::steps_make::{make_from, make_to, set_from, set_to}; +use super::steps_layer_style::{scale_from, style_to}; +use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to}; +use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; -use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json}; +use super::{AtnEvent, D, Descriptor, Id, Map, ReferenceItem, STOP_MESSAGE, Value, json}; /// An event: code (empty when Photoshop only has a string name), string name, the panel /// label, the command ids it exports (the first is the one simple events import to), and the @@ -51,6 +63,12 @@ macro_rules! ev { }; } +macro_rules! stylize_event { + ($code:literal, $name:literal, $label:literal, $id:literal) => { + Event { code: $code, name: $name, label: $label, ids: &[$id], from: |d| super::steps_stylize::from($id, d), to: super::steps_stylize::to } + }; +} + /// 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). @@ -174,6 +192,162 @@ fn clipping_to(_: &str, p: &Value) -> Option { Some(desc("null").with("null", layer_ref(p)?)) } +/// Check Spelling's `null`: the `spellCheck` property of all text layers. +fn spell_check_ref() -> D { + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") }, + ReferenceItem::Enumerated { class: class("TxLr"), type_id: code("Ordn"), value: code("Al") }, + ]) +} + +/// Edit › Check Spelling (`spellCheck`): `null` is [`spell_check_ref`] and `Usng` holds only +/// `checkAll`. Any other form stays a kept Photoshop step. +fn spell_check_from(d: &Descriptor) -> Mapped { + let same_ref = |r: &[ReferenceItem]| match r { + [ReferenceItem::Property { class: p, key }, ReferenceItem::Enumerated { class: t, type_id, value }] => { + trimmed(&p.class_id) == "Prpr" && key.is("spellCheck") && trimmed(&t.class_id) == "TxLr" && trimmed(type_id) == "Ordn" && trimmed(value) == "Al" + } + _ => false, + }; + // Two keys, read by name: `null` and `Usng` in either order, each once. + let using = get_obj(d, "Usng").filter(|u| u.class_id.is("spellCheck") && u.items.len() == 1); + match (d.items.len(), get_ref(d, "null"), using.and_then(|u| get_bool(u, "checkAll"))) { + (2, Some(r), Some(all)) if same_ref(r) => step("edit.checkSpelling", json!({"allLayers": all})), + _ => Mapped::Unsupported { harmless: false }, + } +} + +/// A Check Spelling step that holds nothing but `allLayers` (Check All Layers, on by default). +fn spell_check_to(_: &str, p: &Value) -> Option { + let params = p.as_object()?; + if params.keys().any(|k| k != "allLayers") { + return None; + } + let all = match params.get("allLayers") { + None => true, + Some(v) => v.as_bool()?, + }; + Some(desc("null").with("null", spell_check_ref()).with("Usng", D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(all))))) +} + +// Adapter-only transport bit: the reference permits either no target key or the +// current-layer target. Save must retain that distinction; native behavior is identical. +const NO_TARGET: &str = "_atnNoTarget"; + +/// Accept only bare or current-layer targets, retaining their key-presence distinction for save. +fn target_params(d: &Descriptor) -> Option> { + let mut p = Map::new(); + if d.items.is_empty() { + p.insert(NO_TARGET.into(), Value::Bool(true)); + } else if d.items != desc("null").with("null", target_ref("Lyr ")).items { + return None; + } + Some(p) +} + +/// Restore the bare or explicit current-layer descriptor selected by the private transport bit. +fn target_descriptor(p: &Value) -> Option { + match p.get(NO_TARGET) { + None => Some(desc("null").with("null", target_ref("Lyr "))), + Some(Value::Bool(true)) => Some(desc("null")), + Some(_) => None, + } +} + +// Native-parameter allowlist adapted from #10628; refuse unrepresented options on export. +/// Refuse native options that the selected Photoshop form cannot represent. +fn params(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_some_and(|p| p.keys().all(|key| allowed.contains(&key.as_str()))) +} + +/// Import a target-only event, refusing other targets and extra descriptor settings. +fn target_only_from(d: &Descriptor, id: &str) -> Mapped { + target_params(d).map_or(Mapped::Unsupported { harmless: false }, |p| step(id, Value::Object(p))) +} + +/// Export a current-target native step only when no unrepresented options are present. +fn target_only_to(_: &str, p: &Value) -> Option { + if !params(p, &[NO_TARGET]) { + return None; + } + target_descriptor(p) +} + +const SHAPE_BLUR: &[F] = &[F("Rds ", "radius", Kind::Px)]; + +/// Map the radius-only Shape Blur form; keep custom assets or extra keys unsupported. +fn shape_blur_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(p) = fields_from(d, SHAPE_BLUR) else { return unsupported }; + let p = Value::Object(p); + if shape_blur_to("filter.blur.shapeBlur", &p).is_some_and(|expected| d.items == expected.items) { step("filter.blur.shapeBlur", p) } else { unsupported } +} + +/// Write an optional finite 5–1000 pixel radius, refusing explicit shapes and other options. +fn shape_blur_to(_: &str, p: &Value) -> Option { + if !params(p, &["radius"]) { + return None; + } + if let Some(radius) = p.get("radius") { + let radius = radius.as_f64()?; + if !radius.is_finite() || !(5.0..=1000.0).contains(&radius) { + return None; + } + } + fields_to(p, SHAPE_BLUR, desc("null")) +} + +// Recorded Link and Unlink need explicit state: replaying the UI toggle can +// perform the opposite operation. Target matching follows #10628's adapter. +/// Import Link or Unlink as an explicit operation rather than the state-dependent UI toggle. +fn link_from(d: &Descriptor, linked: bool) -> Mapped { + let Some(mut p) = target_params(d) else { return Mapped::Unsupported { harmless: false } }; + p.insert("linked".into(), Value::Bool(linked)); + step("layer.linkLayers", Value::Object(p)) +} + +/// Export only the matching explicit link state and supported current-layer target form. +fn link_to(p: &Value, linked: bool) -> Option { + if !params(p, &["linked", NO_TARGET]) || p.get("linked") != Some(&Value::Bool(linked)) { + return None; + } + target_descriptor(p) +} + +/// Map canonical Smart Object file descriptors, refusing unrepresented paths, targets or keys. +fn smart_path_from(d: &Descriptor, id: &str) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let key = if id == "layer.smartObjects.convertToLinked" { "Usng" } else { "null" }; + let Some(path) = d.get(key).and_then(path_text) else { return unsupported }; + let p = json!({"path": path}); + let Some(mut expected) = smart_path_to(id, &p) else { return unsupported }; + if let Some(hint) = d.get("LyrI") { + // The reference explicitly omits recording-session IDs on save (#10603, + // docs/atn.md:200). They must never become PhotoCraft's playback target. + if !matches!(id, "layer.smartObjects.relinkToFile" | "layer.smartObjects.replaceContents") || !matches!(hint, D::Integer(n) if *n > 0) { + return unsupported; + } + expected = expected.with("LyrI", hint.clone()); + } + // Normalize the top-level header as Open/Place do, but refuse unknown keys, + // targets and path encodings rather than partly replaying them. + if d.items == expected.items { step(id, p) } else { unsupported } +} + +/// Export path-only native parameters using the existing Open/Place path encoding. +fn smart_path_to(id: &str, p: &Value) -> Option { + let p = p.as_object()?; + let path = p.get("path")?.as_str()?; + if p.len() != 1 || path.is_empty() || path.contains('\0') { + return None; + } + Some(if id == "layer.smartObjects.convertToLinked" { + desc("null").with("null", target_ref("Lyr ")).with("Usng", path_value(path)) + } else { + desc("null").with("null", path_value(path)) + }) +} + /// A clipping mask event: `clipping_from` / `clipping_to` with one command id. macro_rules! clipping_event { ($code:literal, $name:literal, $label:literal, $id:literal) => { @@ -181,9 +355,137 @@ macro_rules! clipping_event { }; } +/// Group F routes every event through `steps_selection::from`; the outer header (name, class) is not checked. +macro_rules! selection_event { + ($code:literal, $name:literal, $label:literal, $ids:expr, $from:expr, $to:expr) => { + Event { code: $code, name: $name, label: $label, ids: $ids, from: |d| super::steps_selection::from(d, $from), to: $to } + }; +} + +macro_rules! target_event { + ($name:literal, $label:literal, $id:literal) => { + Event { code: "", name: $name, label: $label, ids: &[$id], from: |d| target_only_from(d, $id), to: target_only_to } + }; +} + /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. pub(super) const EVENTS: &[Event] = &[ + selection_event!("AddT", "addTo", "Add to Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "add"), |_, p| { + super::steps_selection::selection_to(p, "add") + }), + selection_event!("SbtF", "subtractFrom", "Subtract from Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "subtract"), |_, p| { + super::steps_selection::selection_to(p, "subtract") + }), + selection_event!( + "IntW", + "intersectWith", + "Intersect with Selection", + &["select.rect"], + |d| super::steps_selection::selection_from(d, "intersect"), + |_, p| super::steps_selection::selection_to(p, "intersect") + ), + selection_event!( + "PstI", + "pasteInto", + "Paste Into", + &["edit.pasteSpecial.pasteInto"], + |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteInto"), + super::steps_selection::paste_to + ), + selection_event!( + "PstO", + "pasteOutside", + "Paste Outside", + &["edit.pasteSpecial.pasteOutside"], + |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteOutside"), + super::steps_selection::paste_to + ), + selection_event!("Fade", "fade", "Fade", &["edit.fade"], super::steps_selection::fade_from, super::steps_selection::fade_to), + selection_event!("", "linkSelectedLayers", "Link Layers", &["layer.linkLayers"], super::steps_selection::link_from, super::steps_selection::link_to), + selection_event!( + "Dstr", + "distribute", + "Distribute", + &[ + "layer.distribute.leftEdges", + "layer.distribute.horizontalCenters", + "layer.distribute.rightEdges", + "layer.distribute.topEdges", + "layer.distribute.verticalCenters", + "layer.distribute.bottomEdges", + ], + super::steps_selection::distribute_from, + super::steps_selection::distribute_to + ), + selection_event!( + "RmvL", + "removeLayerMask", + "Remove Layer Mask", + &["layer.layerMask.delete"], + |d| super::steps_selection::empty_from(d, "layer.layerMask.delete", Some("RmvL")), + super::steps_selection::empty_to + ), + selection_event!( + "DfnP", + "definePattern", + "Define Pattern", + &["edit.definePattern"], + |d| super::steps_selection::empty_from(d, "edit.definePattern", None), + super::steps_selection::empty_to + ), + selection_event!( + "MrgL", + "mergeLayers", + "Merge Layers", + &["layer.mergeLayers"], + |d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")), + super::steps_selection::empty_to + ), // Filters. + Event { + code: "Clds", + name: "clouds", + label: "Clouds", + ids: &["filter.render.clouds"], + from: |d| super::steps_render::clouds_from("filter.render.clouds", d), + to: super::steps_render::clouds_to, + }, + Event { + code: "DfrC", + name: "differenceClouds", + label: "Difference Clouds", + ids: &["filter.render.differenceClouds"], + from: |d| super::steps_render::clouds_from("filter.render.differenceClouds", d), + to: super::steps_render::clouds_to, + }, + Event { + code: "Fbrs", + name: "", + label: "Fibers", + ids: &["filter.render.fibers"], + from: super::steps_render::fibers_from, + to: super::steps_render::fibers_to, + }, + Event { + code: "LnsF", + name: "lensFlare", + label: "Lens Flare", + ids: &["filter.render.lensFlare"], + from: super::steps_render::flare_from, + to: super::steps_render::flare_to, + }, + stylize_event!("FndE", "findEdges", "Find Edges", "filter.stylize.findEdges"), + stylize_event!("Slrz", "solarize", "Solarize", "filter.stylize.solarize"), + stylize_event!("Fct ", "facet", "Facet", "filter.pixelate.facet"), + stylize_event!("Frgm", "fragment", "Fragment", "filter.pixelate.fragment"), + stylize_event!("Extr", "extrude", "Extrude", "filter.stylize.extrude"), + stylize_event!("TrcC", "traceContour", "Trace Contour", "filter.stylize.traceContour"), + stylize_event!("Wnd ", "wind", "Wind", "filter.stylize.wind"), + stylize_event!("Tls ", "tiles", "Tiles", "filter.stylize.tiles"), + stylize_event!("ClrH", "colorHalftone", "Color Halftone", "filter.pixelate.colorHalftone"), + stylize_event!("Mztn", "mezzotint", "Mezzotint", "filter.pixelate.mezzotint"), + stylize_event!("Msc ", "mosaic", "Mosaic", "filter.pixelate.mosaic"), + stylize_event!("Pntl", "pointillize", "Pointillize", "filter.pixelate.pointillize"), ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( "UnsM", @@ -218,6 +520,7 @@ pub(super) const EVENTS: &[Event] = &[ 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)]), + Event { code: "", name: "shapeBlur", label: "Shape Blur", ids: &["filter.blur.shapeBlur"], from: shape_blur_from, to: shape_blur_to }, ev!( "SmrB", "smartBlur", @@ -246,9 +549,112 @@ pub(super) const EVENTS: &[Event] = &[ "filter.other.offset", &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), + // Distort (#10605): every key in recorded order, Photoshop's limits and defaults. + Event { code: "Shr ", name: "shear", label: "Shear", ids: &["filter.distort.shear"], from: shear_from, to: shear_to }, + Event { + code: "Pnch", + name: "pinch", + label: "Pinch", + ids: &["filter.distort.pinch"], + from: |d| keys_from(d, "filter.distort.pinch", PINCH), + to: |_, p| keys_to(p, PINCH, &[("amount", json!(50))], &[]), + }, + Event { + code: "Plr ", + name: "polar", + label: "Polar Coordinates", + ids: &["filter.distort.polarCoordinates"], + from: |d| keys_from(d, "filter.distort.polarCoordinates", POLAR), + to: |_, p| keys_to(p, POLAR, &[("mode", json!("rectangularToPolar"))], &[]), + }, + Event { + code: "Sphr", + name: "spherize", + label: "Spherize", + ids: &["filter.distort.spherize"], + from: |d| keys_from(d, "filter.distort.spherize", SPHERIZE), + to: |_, p| keys_to(p, SPHERIZE, &[("amount", json!(100)), ("mode", json!("normal"))], &[]), + }, + Event { + code: "Twrl", + name: "twirl", + label: "Twirl", + ids: &["filter.distort.twirl"], + from: |d| keys_from(d, "filter.distort.twirl", TWIRL), + to: |_, p| keys_to(p, TWIRL, &[("angle", json!(50))], &[]), + }, + Event { code: "Wave", name: "wave", label: "Wave", ids: &["filter.distort.wave"], from: wave_from, to: wave_to }, + Event { + code: "ZgZg", + name: "zigZag", + label: "ZigZag", + ids: &["filter.distort.zigZag"], + from: |d| keys_from(d, "filter.distort.zigZag", ZIGZAG), + to: |_, p| keys_to(p, ZIGZAG, &[("amount", json!(10)), ("ridges", json!(5)), ("style", json!("pondRipples"))], &[]), + }, 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)]), + ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, + // What Photoshop needs in a written step (process version, the full settings set) is + // unverified, so only an imported step saves back, as its recorded descriptor while unedited. + Event { code: "", name: "Adobe Camera Raw Filter", label: "Camera Raw Filter", ids: &[crate::lens_cmds::RAW], from: camera_raw_from, to: camera_raw_to }, + // Video, Other, Average and matting (#10607). + Event { + code: "Dntr", + name: "deInterlace", + label: "De-Interlace", + ids: &["filter.video.deInterlace"], + from: |d| enums_from(d, "filter.video.deInterlace", DE_INTERLACE), + to: |_, p| enums_to(p, DE_INTERLACE), + }, + Event { + code: "NTSC", + name: "NTSC", + label: "NTSC Colors", + ids: &["filter.video.ntscColors"], + from: |d| bare_from(d, "filter.video.ntscColors"), + to: bare_to, + }, + Event { + code: "HsbP", + name: "HsbP", + label: "HSB/HSL", + ids: &["filter.other.hsbHsl"], + from: |d| enums_from(d, "filter.other.hsbHsl", HSB_HSL), + to: |_, p| enums_to(p, HSB_HSL), + }, + Event { code: "Cstm", name: "custom", label: "Custom", ids: &["filter.other.custom"], from: custom_from, to: custom_to }, + Event { code: "Avrg", name: "Avrg", label: "Average", ids: &["filter.blur.average"], from: |d| bare_from(d, "filter.blur.average"), to: bare_to }, + Event { + code: "RmvB", + name: "removeBlackMatte", + label: "Remove Black Matte", + ids: &["layer.matting.removeBlackMatte"], + from: |d| bare_from(d, "layer.matting.removeBlackMatte"), + to: bare_to, + }, + Event { + code: "RmvW", + name: "removeWhiteMatte", + label: "Remove White Matte", + ids: &["layer.matting.removeWhiteMatte"], + from: |d| bare_from(d, "layer.matting.removeWhiteMatte"), + to: bare_to, + }, + // Newer features (#10610): Photoshop records these by string ID only. + Event { code: "", name: "oilPaint", label: "Oil Paint", ids: &["filter.stylize.oilPaint"], from: oil_paint_from, to: oil_paint_to }, + Event { code: "", name: "smartSharpen", label: "Smart Sharpen", ids: &["filter.sharpen.smartSharpen"], from: smart_sharpen_from, to: smart_sharpen_to }, + Event { code: "", name: "focusMask", label: "Focus Area", ids: &["select.focusArea"], from: |d| empty_from(d, "select.focusArea"), to: empty_to }, + Event { code: "", name: "selectSky", label: "Sky", ids: &["select.sky"], from: select_sky_from, to: select_sky_to }, + Event { + code: "", + name: "removeBackground", + label: "Remove Background", + ids: &["layer.removeBackground"], + from: |d| empty_from(d, "layer.removeBackground"), + to: empty_to, + }, // Adjustments (Image › Adjustments). Event { code: "", @@ -273,17 +679,26 @@ pub(super) const EVENTS: &[Event] = &[ 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"), + // Written as the string event the reference application runs, with the `vibrance` class it + // records; the old `Vbrn` code still loads through LEGACY_CODES. + adjustment_event!("", "vibrance", "vibrance", "Vibrance"), adjustment_event!("Exps", "Exps", "exposure", "Exposure"), // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), adjustment_event!("", "BanW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"), adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), - adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), + Event { code: "GrMp", name: "", label: "Gradient Map", ids: &["image.adjustments.gradientMap"], from: gradient_from, to: gradient_to }, + adjustment_event!("", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), + Event { code: "RplC", name: "replaceColor", label: "Replace Color", ids: &["image.adjustments.replaceColor"], from: replace_from, to: replace_to }, adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), + Event { code: "", name: "matchColor", label: "Match Color", ids: &["image.adjustments.matchColor"], from: match_color_from, to: match_color_to }, ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), ev!("", "autoColor", "Auto Color", "image.autoColor", &[]), + // Channels and Trap (#10609). + Event { code: "SplC", name: "splitChannels", label: "Split Channels", ids: &["channel.split"], from: split_from, to: split_to }, + Event { code: "MSpt", name: "mergeSpotChannel", label: "Merge Spot Channel", ids: &["channel.mergeSpot"], from: merge_spot_from, to: merge_spot_to }, + Event { code: "Trap", name: "trap", label: "Trap", ids: &["image.trap"], from: trap_from, to: trap_to }, // Image. Event { code: "ImgS", name: "imageSize", label: "Image Size", ids: &["image.imageSize"], from: image_size_from, to: image_size_to }, Event { code: "CnvS", name: "canvasSize", label: "Canvas Size", ids: &["image.canvasSize"], from: canvas_size_from, to: canvas_size_to }, @@ -351,12 +766,43 @@ pub(super) const EVENTS: &[Event] = &[ }, Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to }, // Layers. + Event { code: "", name: "scaleEffectsEvent", label: "Scale Effects", ids: &["layer.layerStyle.scaleEffects"], from: scale_from, to: style_to }, + target_event!("reverse", "Reverse", "layer.arrange.reverse"), + target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"), + target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"), + target_event!("rasterizeAll", "Rasterize All Layers", "layer.rasterize.allLayers"), + target_event!("addBlankVideoLayer", "New Blank Video Layer", "layer.videoLayers.newBlankVideoLayer"), + target_event!("isolateLayers", "Isolate Layers", "select.isolateLayers"), + target_event!("textLoadDefaultTypeStyles", "Load Default Type Styles", "type.loadDefaultTypeStyles"), + target_event!("textSaveDefaultTypeStyles", "Save Default Type Styles", "type.saveDefaultTypeStyles"), + target_event!("recordMeasurements", "Record Measurements", "image.analysis.recordMeasurements"), + target_event!("CropPhotosAuto0001", "Crop and Straighten Photos", "file.automate.cropAndStraightenPhotos"), + Event { + code: "", + name: "artboardFromLayerGroupEvent", + label: "Artboard from Group", + ids: &["layer.new.artboardFromGroup"], + from: artboard_from, + to: artboard_to, + }, + Event { code: "", name: "editArtboardEvent", label: "Edit Artboard", ids: &["layer.artboard.set"], from: artboard_edit_from, to: artboard_edit_to }, + Event { + code: "", + name: "unlinkSelectedLayers", + label: "Unlink Layers", + ids: &["layer.linkLayers"], + from: |d| link_from(d, false), + to: |_, p| link_to(p, false), + }, Event { code: "Mk ", name: "make", label: "Make", ids: &[ + "type.convertToShape", + "type.createWorkPath", "layer.new.layer", + "layer.layerStyle.createLayer", "layer.new.group", "layer.groupLayers", "layer.duplicate", @@ -385,6 +831,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.layerMask.hideSelection", "file.new", "file.newFromClipboard", + "history.newSnapshot", ], from: make_from, to: make_to, @@ -408,6 +855,8 @@ pub(super) const EVENTS: &[Event] = &[ "layer.setAdjustment", "layer.fill.pattern.set", "layer.new.layerFromBackground", + "layer.layerStyle.blendingOptions", + "layer.smartFilter.blendingOptions", ], from: set_from, to: set_to, @@ -436,8 +885,25 @@ pub(super) const EVENTS: &[Event] = &[ from: select_from, to: select_to, }, - Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to }, - Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to }, + Event { + code: "Dlt ", + name: "delete", + label: "Delete", + ids: &["layer.delete", "edit.clear", "layer.layerMask.delete", "layer.smartFilter.deleteFilterMask"], + from: delete_from, + to: delete_to, + }, + Event { code: "", name: "applyLocking", label: "Apply Locking", ids: &["layer.lockLayers"], from: locking_from, to: locking_to }, + // Timeline › Make Frames from Layers: no frame animation in PhotoCraft, no document change. + Event { + code: "", + name: "animationFramesFromLayers", + label: "Make Frames from Layers", + ids: &[], + from: |_| Mapped::Unsupported { harmless: true }, + to: |_, _| None, + }, + Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode", "history.clear"], from: clear_from, to: clear_to }, Event { code: "Trnf", name: "transform", @@ -450,13 +916,63 @@ pub(super) const EVENTS: &[Event] = &[ ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]), ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]), ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]), + target_event!("placedLayerMakeCopy", "New Smart Object via Copy", "layer.smartObjects.newSmartObjectViaCopy"), + target_event!("placedLayerConvertToLayers", "Convert to Layers", "layer.smartObjects.convertToLayers"), + target_event!("placedLayerConvertToEmbedded", "Convert to Embedded", "layer.smartObjects.convertToEmbedded"), + target_event!("placedLayerUpdateAllModified", "Update All Modified Content", "layer.smartObjects.updateAllModifiedContent"), + Event { + code: "", + name: "placedLayerExportContents", + label: "Export Contents", + ids: &["layer.smartObjects.exportContents"], + from: |d| smart_path_from(d, "layer.smartObjects.exportContents"), + to: smart_path_to, + }, + Event { + code: "", + name: "placedLayerConvertToLinked", + label: "Convert to Linked", + ids: &["layer.smartObjects.convertToLinked"], + from: |d| smart_path_from(d, "layer.smartObjects.convertToLinked"), + to: smart_path_to, + }, + Event { + code: "", + name: "placedLayerRelinkToFile", + label: "Relink to File", + ids: &["layer.smartObjects.relinkToFile"], + from: |d| smart_path_from(d, "layer.smartObjects.relinkToFile"), + to: smart_path_to, + }, + Event { + code: "", + name: "placedLayerReplaceContents", + label: "Replace Contents", + ids: &["layer.smartObjects.replaceContents"], + from: |d| smart_path_from(d, "layer.smartObjects.replaceContents"), + to: smart_path_to, + }, Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, - Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, + Event { + code: "Shw ", + name: "show", + label: "Show", + ids: &["layer.showLayers", "layer.showOnly"], + from: |d| visibility_from(d, "layer.showLayers"), + to: visibility_to, + }, Event { code: "move", name: "move", label: "Move", - ids: &["layer.translate", "layer.arrange.bringToFront", "layer.arrange.sendToBack", "layer.arrange.bringForward", "layer.arrange.sendBackward"], + ids: &[ + "layer.translate", + "layer.moveTo", + "layer.arrange.bringToFront", + "layer.arrange.sendToBack", + "layer.arrange.bringForward", + "layer.arrange.sendBackward", + ], from: move_from, to: move_to, }, @@ -479,7 +995,14 @@ pub(super) const EVENTS: &[Event] = &[ 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: "pasteEffects", + label: "Paste Layer Style", + ids: &["layer.layerStyle.pasteLayerStyle"], + from: paste_effects_from, + to: paste_effects_to, + }, Event { code: "", name: "collapseAllGroupsEvent", @@ -530,6 +1053,14 @@ pub(super) const EVENTS: &[Event] = &[ 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: "Grdn", + name: "gradientClassEvent", + label: "Gradient", + ids: &["paint.gradient"], + from: super::steps_gradient::from, + to: super::steps_gradient::to, + }, Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, Event { code: "Strk", @@ -556,6 +1087,7 @@ pub(super) const EVENTS: &[Event] = &[ }, Event { code: "Rset", name: "reset", label: "Reset", ids: &["tools.defaultColors"], from: reset_from, to: reset_to }, ev!("Exch", "exchange", "Exchange", "tools.swapColors", &[]), + Event { code: "", name: "spellCheck", label: "Check Spelling", ids: &["edit.checkSpelling"], from: spell_check_from, to: spell_check_to }, Event { code: "", name: "assignProfile", @@ -572,6 +1104,7 @@ pub(super) const EVENTS: &[Event] = &[ from: |d| profile_from(d, "edit.convertToProfile"), to: profile_to, }, + Event { code: "", name: "cafWorkspace", label: "Content-Aware Fill", ids: &["edit.contentAwareFill"], from: caf_from, to: caf_to }, // File and actions. Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, @@ -590,20 +1123,53 @@ pub(super) 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"), ("PhtF", "photoFilter")]; +const LEGACY_CODES: &[(&str, &str)] = &[ + ("BoxB", "boxblur"), + ("SrfB", "surfaceBlur"), + ("Fltn", "flattenImage"), + ("BlnW", "blackAndWhite"), + ("PhtF", "photoFilter"), + ("Vbrn", "vibrance"), + ("GdMp", "gradientMapEvent"), +]; + +/// Older string names a step can carry for an event that is listed under another name: the +/// reference application reports `IntW` and `Dstr` as `interfaceWhite` and `distort` at run time. +const NAME_ALIASES: &[(&str, &str)] = &[("interfaceWhite", "intersectWith"), ("distort", "distribute")]; pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); - find(&id).or_else(|| match item { - AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), - AtnEvent::Name(_) => None, + let by_name = |name: &str| EVENTS.iter().find(|e| !e.name.is_empty() && e.name == name); + let found = match item { + AtnEvent::Code(_) => EVENTS.iter().find(|e| !e.code.is_empty() && e.code.trim_end() == id.trim_end()), + AtnEvent::Name(_) => by_name(&id), + }; + found.or_else(|| match item { + // An effect run from its own menu item records its id as the event (#10592). + // By 4-char code or, as newer versions record it, by string ID (`cutout`). + _ if super::steps_gallery::is_effect(item) => by_name("filterGallery"), + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| by_name(name)), + AtnEvent::Name(_) => NAME_ALIASES.iter().find(|(old, _)| *old == id).and_then(|(_, name)| by_name(name)), }) } /// The event `id` exports as: the first event listing it whose converter accepts the params /// (Quick Mask on is a `set`, off a `clearEvent`). pub(super) fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static Event, Descriptor)> { + if params.get("_atnBounds").is_some() + && (id != "select.rect" || !matches!(params.get("mode").and_then(Value::as_str), Some("add" | "subtract" | "intersect"))) + { + return None; + } + // A string names the recorded event of a mask or merge step; an object is a Filter Gallery + // effect's own event, which `export_item` checks (`steps_gallery::recorded_event`). + let gallery_event = id.starts_with("filter.gallery.") && params.get("_atnEvent").is_some_and(Value::is_object); + if let Some(origin) = params.get("_atnEvent").filter(|_| !gallery_event) { + let origin = origin.as_str()?; + if !matches!((id, origin), ("layer.layerMask.delete", "RmvL") | ("layer.mergeLayers", "MrgL")) { + return None; + } + return EVENTS.iter().find(|e| e.code == origin).and_then(|e| (e.to)(id, params).map(|d| (e, d))); + } EVENTS.iter().filter(|e| e.ids.contains(&id)).find_map(|e| (e.to)(id, params).map(|d| (e, d))) } diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index be7bd45151..307043b592 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -1,4 +1,4 @@ -use super::convert::{UniqueNames, export, import}; +use super::convert::{UniqueNames, export, import_with_encoding}; use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json}; pub(super) fn load(s: &mut Session, p: &Value) -> Result { @@ -6,8 +6,14 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { if s.actions.recording.is_some() || s.actions.playing > 0 { return Err(bad(cmd, "stop recording or playing before loading actions")); } + let encoding = match p.get("encoding") { + None | Some(Value::Null) => None, + Some(Value::String(e)) if e == "auto" => None, + Some(Value::String(e)) if !e.is_empty() => Some(e.as_str()), + Some(_) => return Err(bad(cmd, "encoding must be \"auto\" or a label such as shift_jis, gbk, big5, euc-kr, windows-1252, macintosh")), + }; let (bytes, _) = crate::preset_import_cmds::file_bytes(p, cmd, ".atn file")?; - let mut imp = import(&bytes).map_err(|e| bad(cmd, e))?; + let mut imp = import_with_encoding(&bytes, encoding).map_err(|e| bad(cmd, e))?; match p.get("set") { None | Some(Value::Null) => {} Some(Value::String(n)) if !n.trim().is_empty() => { @@ -59,6 +65,11 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { } } s.actions.list.extend(added); + // A set saved with no actions (Photoshop saves an empty set) is an empty folder, like one New + // Set made. + if !s.actions.list.iter().any(|a| a.set.as_deref() == Some(imp.set.as_str())) && !s.actions.sets.contains(&imp.set) { + s.actions.sets.push(imp.set.clone()); + } let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); if !keys.is_empty() { s.edit_prefs(|prefs| { @@ -77,6 +88,7 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { "unsupported": imp.unsupported, "functionKeys": keys.iter().map(|(n, k)| json!({"action": n, "key": k})).collect::>(), "warnings": imp.warnings, + "encoding": imp.encoding.as_deref().unwrap_or("UTF-8"), })) } @@ -90,6 +102,9 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { }; let set_param = match p.get("set") { None | Some(Value::Null) => None, + // A set is found by its exact name, as `actions.record` finds it: one loaded from a + // Photoshop file or an older actions.json may have surrounding spaces. Others are trimmed. + Some(Value::String(n)) if crate::actions_edit_cmds::set_names(s).contains(n) => Some(n.clone()), Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), }; @@ -99,16 +114,17 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { (set_param.or_else(|| a.set.clone()).unwrap_or_else(|| a.name.clone()), vec![a]) } else if let Some(set) = set_param { let list: Vec<&Action> = s.actions.list.iter().filter(|a| a.set.as_deref() == Some(set.as_str())).collect(); - if list.is_empty() { + // An empty set New Set made is written with no actions, as Photoshop saves one. + if list.is_empty() && !s.actions.sets.contains(&set) { return Err(bad(cmd, format!("no actions in a set named `{set}`"))); } (set, list) } else { + if s.actions.list.is_empty() { + return Err(bad(cmd, "there are no actions to save")); + } (DEFAULT_SET.to_string(), s.actions.list.iter().collect()) }; - if actions.is_empty() { - return Err(bad(cmd, "there are no actions to save")); - } let names: Vec = actions.iter().map(|a| a.name.clone()).collect(); let keys: Vec<(String, String)> = s.prefs().shortcuts.iter().filter_map(|(k, v)| k.strip_prefix(crate::actions_cmds::PLAY_SHORTCUT_PREFIX).map(|n| (n.to_string(), v.clone()))).collect(); diff --git a/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs b/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs index 155729923a..e4ebcd0ea0 100644 --- a/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs @@ -34,13 +34,13 @@ fn expanded_properties_keep_flags_and_original_skip_indices() { vec![ ("layer.setProps".into(), json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"})), ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), - ("filter.render.clouds".into(), json!({})), + ("unsupported.forTest".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() }]); + assert_eq!(skipped, [Skipped { action: "Flags".into(), step: 2, id: "unsupported.forTest".into() }]); let file = atn::parse(&bytes).unwrap(); let items = &file.actions[0].items; assert_eq!(items.len(), 4); diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs new file mode 100644 index 0000000000..edad254518 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -0,0 +1,312 @@ +use super::*; + +/// A real action file with one step, used by both descriptor and playback checks. +fn file(event: AtnEvent, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Render set"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Render"), + items: vec![AtnItem { event, dictionary: b"Render".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// Measured descriptor order and types, independent of the converter's field construction. +fn fibers(variance: i32, strength: i32, seed: i32) -> Descriptor { + desc("null").with("Vrnc", D::Integer(variance)).with("Strg", D::Integer(strength)).with("RndS", D::Integer(seed)) +} + +/// The measured center is a Point object with two doubles, not pixel or percent units. +fn flare(brightness: i32, center: [f64; 2], lens: &str) -> Descriptor { + desc("null") + .with("Brgh", D::Integer(brightness)) + .with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(center[0])).with("Vrtc", D::Double(center[1])))) + .with("Lns ", enm("Lns ", lens)) +} + +/// Codes and TEXT names have separate identities, including the old Code-only aliases. +#[test] +fn event_lookup_keeps_codes_separate_from_names() { + for (code, id) in [ + (*b"Clds", "filter.render.clouds"), + (*b"DfrC", "filter.render.differenceClouds"), + (*b"Fbrs", "filter.render.fibers"), + (*b"LnsF", "filter.render.lensFlare"), + (*b"BoxB", "filter.blur.boxBlur"), + (*b"SrfB", "filter.blur.surfaceBlur"), + (*b"Fltn", "layer.flattenImage"), + (*b"BlnW", "image.adjustments.blackWhite"), + (*b"PhtF", "image.adjustments.photoFilter"), + ] { + assert!(event_for(&AtnEvent::Code(code)).unwrap().ids.contains(&id)); + assert!(event_for(&AtnEvent::Name(code.to_vec())).is_none(), "{code:?} is not a TEXT name"); + } + assert!(event_for(&AtnEvent::name("play")).unwrap().ids.contains(&"actions.play")); + assert!(event_for(&AtnEvent::Code(*b"Ply ")).unwrap().ids.contains(&"actions.play")); + assert!(event_for(&AtnEvent::Code(*b"play")).is_none(), "a TEXT name is not a code"); + for event in [AtnEvent::Code(*b"save"), AtnEvent::name("save")] { + assert!(event_for(&event).unwrap().ids.contains(&"file.save"), "save is registered in both forms"); + } + assert!(event_for(&AtnEvent::name("")).is_none(), "empty registry names are not event identifiers"); +} + +/// Unknown event forms stay kept rather than becoming commands, and save verbatim. +#[test] +fn code_spellings_in_text_steps_keep_their_bytes_and_flags() { + let mut events: Vec<_> = ["Clds", "DfrC", "Fbrs", "LnsF", "BoxB", "SrfB", "Fltn", "BlnW", "PhtF"].into_iter().map(AtnEvent::name).collect(); + events.push(AtnEvent::Code(*b"play")); + for event in events { + let mut original = atn::parse(&file(event, desc("null"))).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let bytes = atn::write(&original).unwrap(); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{:?}", original.actions[0].items[0].event); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.actions[0].items, original.actions[0].items); + let mut compared = original; + compared.actions[0].items = back.actions[0].items.clone(); + assert_eq!(atn::write(&compared).unwrap(), bytes); + } +} + +/// Both recorded event IDs map empty Clouds descriptors without inventing frozen colors. +#[test] +fn clouds_events_map_and_refuse_unrepresentable_native_settings() { + for (code, name, id) in [(*b"Clds", "clouds", "filter.render.clouds"), (*b"DfrC", "differenceClouds", "filter.render.differenceClouds")] { + for event in [AtnEvent::Code(code), AtnEvent::name(name)] { + let file = AtnFile { + actions: vec![AtnAction { + name: UnicodeString::new_nul("Render"), + items: vec![AtnItem { event, descriptor: Some(desc("null")), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let loaded = import(&atn::write(&file).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps, vec![(id.into(), json!({}))]); + assert_eq!(export_item(id, &json!({}), "Render").unwrap().descriptor, Some(desc("null"))); + } + for p in [json!({"seed": 7}), json!({"foreground": "#ff0000"}), json!({"background": "#0000ff"}), json!({"layer": 7}), json!({"target": "mask"})] { + assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}"); + } + // The Clouds dialog records `seed: 0`, which is what the empty imported step plays as. + for p in [json!({"seed": 0}), json!({"seed": 0.0}), json!({"seed": 0, "target": "pixels"})] { + assert_eq!(export_item(id, &p, "Render").map(|item| item.descriptor), Some(Some(desc("null"))), "{id}: {p}"); + } + for p in [json!({"seed": -1}), json!({"seed": 0.5}), json!({"seed": "0"}), json!({"seed": 0, "foreground": "#ff0000"})] { + assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}"); + } + } +} + +/// A recorded dialog step saves through the real Save Actions command. +#[test] +fn clouds_recorded_through_the_dialog_save() { + for id in ["filter.render.clouds", "filter.render.differenceClouds"] { + let mut s = Session::new(); + s.actions.list.push(Action::new("Render", vec![(id.into(), json!({"seed": 0}))])); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + assert_eq!(saved["skipped"], json!([]), "{id}"); + let back = import(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.actions[0].steps, vec![(id.into(), json!({}))], "{id}"); + } +} + +/// Defaults and changed descriptors survive the actual load/save commands, including key order. +#[test] +fn render_descriptors_load_and_save_without_losing_values() { + let center = [0.40000000596046; 2]; + let mut cases = vec![("Fbrs", None, fibers(16, 4, 123)), ("LnsF", Some("lensFlare"), flare(100, center, "Zm"))]; + for seed in [i32::MIN, -1, 0, i32::MAX] { + cases.push(("Fbrs", None, fibers(-23, i32::MAX, seed))); + } + for lens in ["Zm", "Nkn", "Nkn1", "PnVs"] { + cases.push(("LnsF", Some("lensFlare"), flare(280, [5.0, -5.0], lens))); + } + for (code_id, name, descriptor) in cases { + let mut events = vec![AtnEvent::Code(code_id.as_bytes().try_into().unwrap())]; + if let Some(name) = name { + events.push(AtnEvent::name(name)); + } + for event in events { + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(event, descriptor.clone()))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + assert_eq!(back.actions[0].items[0].event.id(), code_id); + assert_eq!(back.actions[0].items[0].descriptor, Some(descriptor.clone())); + } + } +} + +/// Missing import fields use measured defaults; native export keeps the command's own defaults. +#[test] +fn render_defaults_seeds_and_brightness_bounds_are_explicit() { + let bytes = file(AtnEvent::Code(*b"Fbrs"), desc("null")); + let action = import(&bytes).unwrap().actions.remove(0); + let p = &action.steps[0].1; + assert_eq!(p["variance"], 16); + assert_eq!(p["strength"], 4); + assert!(i32::try_from(p["seed"].as_i64().unwrap()).is_ok()); + let saved = export("S", &[&action], &[], false).unwrap().0; + assert_eq!(saved, export("S", &[&action], &[], false).unwrap().0, "the captured seed is stable"); + assert_eq!(import(&saved).unwrap().actions[0].steps, action.steps); + for (brightness, expected) in [(-500, 10), (100, 100), (500, 300)] { + let action = import(&file(AtnEvent::Code(*b"LnsF"), desc("null").with("Brgh", D::Integer(brightness)))).unwrap().actions.remove(0); + assert_eq!(action.steps[0].1, json!({"brightness": expected, "centerX": 0.40000000596046, "centerY": 0.40000000596046, "lens": "zoom"})); + } + let native = export_item("filter.render.lensFlare", &json!({}), "S").unwrap(); + let back = import(&file(native.event, native.descriptor.unwrap())).unwrap().actions.remove(0); + assert_eq!(back.steps[0].1["centerX"], 0.5); + assert_eq!( + crate::filters_ext::params_for("filter.render.lensFlare", &back.steps[0].1), + crate::filters_ext::params_for("filter.render.lensFlare", &json!({})) + ); + let unsigned = export_item("filter.render.fibers", &json!({"seed": u32::MAX}), "S").unwrap(); + assert_eq!(unsigned.descriptor.unwrap().get("RndS"), Some(&D::Integer(-1))); +} + +/// Unknown enums, malformed types and unmeasured extra fields stay visibly unsupported. +#[test] +fn unsupported_render_descriptors_keep_their_bytes_and_flags() { + for descriptor in [ + desc("null").with("Lns ", enm("Lns ", "Bad!")), + desc("null").with("Lns ", enm("Other", "Zm")), + desc("null").with("Brgh", D::Double(100.0)), + desc("null").with("FlrC", D::Descriptor(desc("Other"))), + desc("null").with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.4)))), + desc("null").with("futureSetting", D::Boolean(true)), + ] { + let mut original = atn::parse(&file(AtnEvent::Code(*b"LnsF"), descriptor.clone())).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let loaded = import(&atn::write(&original).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let bytes = export("Render set", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], original.actions[0].items[0]); + } + for (event, descriptor) in [(*b"Fbrs", desc("null").with("RndS", D::Double(1.0))), (*b"Clds", desc("null").with("FlRs", D::Integer(1)))] { + assert_eq!(import(&file(AtnEvent::Code(event), descriptor)).unwrap().unsupported.len(), 1); + } + for id in ["filter.render.fibers", "filter.render.lensFlare"] { + for p in [json!({"target": "mask"}), json!({"foreground": "#ff0000"}), json!({"layer": "Other"}), json!({"unknown": true})] { + assert!(export_item(id, &p, "S").is_none(), "{id}: {p}"); + } + } + for p in [json!({"seed": 4294967296u64}), json!({"seed": -2147483649i64}), json!({"variance": 1.5})] { + assert!(export_item("filter.render.fibers", &p, "S").is_none(), "{p}"); + } + assert!(export_item("filter.render.lensFlare", &json!({"lens": "unknown"}), "S").is_none()); +} + +/// Duplicate keys must remain a kept step, rather than losing later values on save. +#[test] +fn duplicate_render_keys_keep_their_bytes_and_flags() { + let mut cases = Vec::new(); + for key in ["Vrnc", "Strg", "RndS"] { + cases.push(("Fbrs", key, fibers(16, 4, 123).with(key, D::Integer(99)))); + } + for key in ["Brgh", "FlrC", "Lns "] { + let descriptor = flare(100, [0.4; 2], "Zm"); + let duplicate = descriptor.get(key).unwrap().clone(); + cases.push(("LnsF", key, descriptor.with(key, duplicate))); + } + for key in ["Hrzn", "Vrtc"] { + let point = desc("Pnt ").with("Hrzn", D::Double(0.4)).with("Vrtc", D::Double(0.4)).with(key, D::Double(0.8)); + cases.push(("LnsF", key, desc("null").with("FlrC", D::Descriptor(point)))); + } + for (event, key, descriptor) in cases { + let mut original = atn::parse(&file(AtnEvent::Code(event.as_bytes().try_into().unwrap()), descriptor)).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let bytes = atn::write(&original).unwrap(); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{event}/{key}"); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.actions[0].items, original.actions[0].items, "{event}/{key}"); + // Keep the original set/action metadata to compare the encoded step bytes. + let mut compared = original.clone(); + compared.actions[0].items = back.actions[0].items.clone(); + assert_eq!(atn::write(&compared).unwrap(), bytes, "{event}/{key}"); + } +} + +/// Imported playback uses current tool colors and the same filter path as direct commands. +#[test] +fn render_actions_match_direct_filters_and_undo() { + let cases = [ + (*b"Clds", desc("null"), "filter.render.clouds", json!({})), + (*b"DfrC", desc("null"), "filter.render.differenceClouds", json!({})), + (*b"Fbrs", fibers(23, 6, -7), "filter.render.fibers", json!({"variance": 23, "strength": 6, "seed": -7})), + (*b"LnsF", flare(240, [5.0, -5.0], "Nkn1"), "filter.render.lensFlare", json!({"brightness": 240, "centerX": 5.0, "centerY": -5.0, "lens": "prime105"})), + // Just right of the canvas: the dialog's 0..1 range does not clamp a recorded centre. + (*b"LnsF", flare(100, [1.4, 0.25], "Zm"), "filter.render.lensFlare", json!({"brightness": 100, "centerX": 1.4, "centerY": 0.25, "lens": "zoom"})), + ]; + for single_state in [true, false] { + for mode in ["rgb", "gray"] { + for depth in [8, 16, 32] { + for (event, descriptor, id, params) in &cases { + let setup = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap(); + s + }; + let mut action = setup(); + let mut direct = setup(); + action.execute("actions.setHistory", json!({"singleState": single_state})).unwrap(); + let before_doc = action.active().unwrap().doc.clone(); + let history = action.active().unwrap().history.past_len(); + let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(); + action + .execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})) + .unwrap(); + let played = action.execute("actions.play", json!({"action": "Render"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + direct.execute(id, params.clone()).unwrap(); + let surface = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) + }; + assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}"); + let after_doc = action.active().unwrap().doc.clone(); + let changed = after_doc.as_ref() != before_doc.as_ref(); + let added = usize::from(!single_state || changed); + assert_eq!(action.active().unwrap().history.past_len(), history + added, "{mode}/{depth}/{id}, singleState={single_state}"); + // An unchanged grouped action preserves the setup row instead of adding one. + if added > 0 { + action.execute("edit.undo", json!({})).unwrap(); + } else { + assert!(std::sync::Arc::ptr_eq(&action.active().unwrap().doc, &before_doc)); + } + assert_eq!(action.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{mode}/{depth}/{id} undo"); + assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before); + if added > 0 { + action.execute("edit.redo", json!({})).unwrap(); + assert_eq!(action.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{mode}/{depth}/{id} redo"); + } + } + } + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs new file mode 100644 index 0000000000..66c99248c6 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -0,0 +1,541 @@ +use super::*; + +/// An independently constructed selection-property reference, as recorded in Group F. +fn selection() -> D { + D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]) +} + +/// Bounds stay in their recorded Top/Left/Bottom/Right order and pixel-double units. +fn rectangle(bounds: [f64; 4], ellipse: bool) -> Descriptor { + let mut shape = desc(if ellipse { "Elps" } else { "Rctn" }); + for (key, value) in ["Top ", "Left", "Btom", "Rght"].into_iter().zip(bounds) { + shape = shape.with(key, px(value)); + } + let mut d = desc("null").with("null", selection()).with("T ", D::Descriptor(shape)); + if ellipse { + d = d.with("AntA", D::Boolean(true)); + } + d +} + +/// One real action file, shared by descriptor and engine playback witnesses. +fn file(event: AtnEvent, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Group F"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Step"), + items: vec![AtnItem { event, dictionary: b"Step".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// Recorded and changed descriptors shared by successful and kept-step round trips. +fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> { + let mut cases = vec![ + ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], false)), + ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], true)), + ("SbtF", Some("subtractFrom"), rectangle([30.0, 30.0, 60.0, 60.0], false)), + ("IntW", Some("intersectWith"), rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("PstI", Some("pasteInto"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), + ("PstO", Some("pasteOutside"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), + ("Fade", Some("fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Mltp"))), + ("", Some("linkSelectedLayers"), desc("null").with("null", target_ref("Lyr "))), + ("Dstr", Some("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ("RmvL", Some("removeLayerMask"), desc("null")), + ("DfnP", Some("definePattern"), desc("null")), + ("MrgL", Some("mergeLayers"), desc("null")), + ]; + // Opposite-signed fractions can lose an endpoint through x+(right-x); preserve it explicitly. + for mode in ["AddT", "SbtF", "IntW"] { + cases.push((mode, None, rectangle([-0.1, -0.1, 0.3, 0.2], false))); + } + let mut ellipse = rectangle([1.5, 2.5, 8.5, 10.5], true); + ellipse.items.last_mut().unwrap().1 = D::Boolean(false); + cases.push(("AddT", Some("addTo"), ellipse)); + for mode in ["Scrn", "H", "linearDodge"] { + cases.push(("Fade", Some("fade"), desc("null").with("Opct", pct(25.5)).with("Md ", enm("BlnM", mode)))); + } + for edge in ["AdCH", "AdRg", "AdTp", "AdCV", "AdBt"] { + cases.push(("Dstr", Some("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); + } + cases +} + +/// Both recorded event forms retain explicit example settings and descriptor key/type order. +#[test] +fn group_f_load_save_preserves_recorded_descriptors() { + for (id, name, d) in fixtures() { + let mut events = Vec::new(); + if !id.is_empty() { + events.push(AtnEvent::Code(id.as_bytes().try_into().unwrap())); + } + if let Some(name) = name { + events.push(AtnEvent::name(name)); + } + for event in events { + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&file(event.clone(),d.clone()))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{id}"); + let saved = s.execute("actions.save", json!({"action":"Step"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + assert_eq!(back.actions[0].items[0].descriptor, Some(d.clone()), "{id}"); + assert_eq!(back.actions[0].items[0].event.id(), if id.is_empty() { name.unwrap() } else { id }); + } + } +} + +/// A kept step retains its descriptor bytes, event identity and playback flags. +fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) { + let mut original = atn::parse(&file(fixture_event(id, name), descriptor.clone())).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&atn::write(&original).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{id}/{name:?}/{descriptor:?}"); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + assert!(s.actions.list[0].disabled.contains(&0)); + assert!(s.actions.list[0].dialogs.contains(&0)); + let saved = s.execute("actions.save", json!({"action":"Step"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + let saved = &back.actions[0].items[0]; + let original = &original.actions[0].items[0]; + assert_eq!(saved.event, original.event); + assert_eq!((saved.enabled, saved.with_dialog, saved.dialog_options), (original.enabled, original.with_dialog, original.dialog_options)); + assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); +} + +/// The event of a fixture, in its code form where it has one. +fn fixture_event(id: &str, name: Option<&str>) -> AtnEvent { + if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) } +} + +/// The outer name and class and the nested shape's name and class encoding do not gate mapping: +/// such a step maps to the same command and params as the empty-name `null` header. +#[test] +fn noncanonical_group_f_headers_map_like_the_canonical_form() { + for (id, name, d) in fixtures() { + let event = fixture_event(id, name); + let canonical = import(&file(event.clone(), d.clone())).unwrap(); + assert!(canonical.unsupported.is_empty(), "{id}"); + for variant in 0..5 { + let mut descriptor = d.clone(); + match variant { + 0 => descriptor.name = UnicodeString::new_nul("Recorded descriptor name"), + 1 => descriptor.class_id = code("Lyr "), + 2 => descriptor.class_id = Id::Str(b"null".to_vec()), + _ => { + let Some((_, D::Descriptor(shape))) = descriptor.items.iter_mut().find(|(key, _)| key.is("T ")) else { continue }; + if variant == 3 { + shape.name = UnicodeString::new_nul("Recorded shape name"); + } else { + shape.class_id = Id::Str(shape.class_id.as_bytes().to_vec()); + } + } + } + let loaded = import(&file(event.clone(), descriptor.clone())).unwrap(); + assert!(loaded.unsupported.is_empty(), "{id}/{name:?}/{variant}"); + assert_eq!(loaded.actions[0].steps, canonical.actions[0].steps, "{id}/{name:?}/{variant}"); + } + } +} + +/// The reference application names each step descriptor after the localized step (with a NUL), +/// gives it the event code as its class and names nested shapes too (synthetic descriptors). +#[test] +fn group_f_maps_steps_recorded_with_the_application_header() { + let mut shape = Descriptor { name: UnicodeString::new_nul(""), class_id: code("Rctn"), items: Vec::new() }; + for (key, value) in ["Top ", "Left", "Btom", "Rght"].into_iter().zip([30.0, 30.0, 90.0, 90.0]) { + shape = shape.with(key, px(value)); + } + let cases = [ + ( + AtnEvent::name("fade"), + Descriptor { + name: UnicodeString::new_nul("Fade"), + class_id: code("Fade"), + items: vec![(code("Opct"), pct(50.0)), (code("Md "), enm("BlnM", "Nrml"))], + }, + ("edit.fade", json!({"opacity":50,"mode":"Normal"})), + ), + ( + AtnEvent::name("addTo"), + Descriptor { + name: UnicodeString::new_nul("Add To"), + class_id: code("AddT"), + items: vec![(code("null"), selection()), (code("T "), D::Descriptor(shape))], + }, + ("select.rect", json!({"x":30,"y":30,"width":60,"height":60,"mode":"add"})), + ), + ( + AtnEvent::Code(*b"MrgL"), + Descriptor { name: UnicodeString::new_nul("Merge Layers"), class_id: code("MrgL"), items: Vec::new() }, + ("layer.mergeLayers", json!({"_atnEvent":"MrgL"})), + ), + ]; + for (event, d, (command, params)) in cases { + let loaded = import(&file(event, d)).unwrap(); + assert!(loaded.unsupported.is_empty(), "{command}"); + assert_eq!(loaded.actions[0].steps, vec![(command.to_string(), params)]); + } +} + +/// Steps recorded with the reference application's string IDs map like their four-character codes. +#[test] +fn group_f_string_ids_map_like_their_codes() { + for (id, name, d) in [ + ("IntW", "intersectWith", rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("Dstr", "distribute", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ("MrgL", "mergeLayers", desc("null")), + // The run-time names the reference application reports for IntW and Dstr. + ("IntW", "interfaceWhite", rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("Dstr", "distort", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ] { + let by_code = import(&file(AtnEvent::Code(id.as_bytes().try_into().unwrap()), d.clone())).unwrap(); + let by_name = import(&file(AtnEvent::name(name), d)).unwrap(); + assert!(by_code.unsupported.is_empty() && by_name.unsupported.is_empty(), "{name}"); + assert_eq!(by_name.actions[0].steps, by_code.actions[0].steps, "{name}"); + } +} + +/// Every keyed converter rejects alternate key encodings and adjacent order changes. +#[test] +fn noncanonical_group_f_keys_keep_their_bytes_and_flags() { + let variants = |d: &Descriptor| { + let mut variants = Vec::new(); + for index in 0..d.items.len() { + let mut changed = d.clone(); + changed.items[index].0 = Id::Str(changed.items[index].0.as_bytes().to_vec()); + variants.push(changed); + if index > 0 { + let mut changed = d.clone(); + changed.items.swap(index - 1, index); + variants.push(changed); + } + } + variants + }; + for (id, name, d) in fixtures() { + for changed in variants(&d) { + assert_kept(id, name, changed); + } + for (index, (_, value)) in d.items.iter().enumerate() { + let D::Descriptor(shape) = value else { continue }; + for changed in variants(shape) { + let mut outer = d.clone(); + outer.items[index].1 = D::Descriptor(changed); + assert_kept(id, name, outer); + } + } + } +} + +/// Enum type/value encodings and Fade aliases must not be normalized on save. +#[test] +fn noncanonical_group_f_enums_keep_their_bytes_and_flags() { + for (id, name, d) in fixtures() { + for (index, (_, value)) in d.items.iter().enumerate() { + let D::Enumerated { type_id, value } = value else { continue }; + let mut padded = value.as_bytes().to_vec(); + padded.push(b' '); + for (type_id, value) in [ + (Id::Str(type_id.as_bytes().to_vec()), value.clone()), + (type_id.clone(), Id::Str(value.as_bytes().to_vec())), + (type_id.clone(), Id::Str(padded)), + ] { + let mut changed = d.clone(); + changed.items[index].1 = D::Enumerated { type_id, value }; + if changed != d { + assert_kept(id, name, changed); + } + } + } + } + for alias in ["normal", "multiply", "hue"] { + assert_kept("Fade", Some("fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", alias))); + } +} + +/// Fade writes native defaults and clamps only its documented opacity range. +#[test] +fn fade_defaults_and_opacity_limits_are_explicit() { + let native = export_item("edit.fade", &json!({}), "S").unwrap().descriptor.unwrap(); + assert_eq!(native, desc("null").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))); + for (opacity, expected) in [(-5.0, 0.0), (0.0, 0.0), (100.0, 100.0), (105.0, 100.0)] { + let bytes = file(AtnEvent::Code(*b"Fade"), desc("null").with("Opct", pct(opacity)).with("Md ", enm("BlnM", "Nrml"))); + let loaded = import(&bytes).unwrap(); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].1["opacity"].as_f64(), Some(expected)); + let saved = export("Group F", &[&loaded.actions[0]], &[], false).unwrap().0; + let d = atn::parse(&saved).unwrap().actions[0].items[0].descriptor.clone().unwrap(); + assert_eq!(d, desc("null").with("Opct", pct(expected)).with("Md ", enm("BlnM", "Nrml"))); + } +} + +/// Native exports keep their current event; only imported old mask/merge steps select old IDs. +#[test] +fn group_f_legacy_origin_is_allowlisted_and_does_not_replace_native_events() { + for (id, old, current) in [("layer.layerMask.delete", "RmvL", "Dlt "), ("layer.mergeLayers", "MrgL", "Mrg2")] { + assert_eq!(export_item(id, &json!({}), "S").unwrap().event.id(), current); + let params = json!({"_atnEvent":old}); + assert_eq!(export_item(id, ¶ms, "S").unwrap().event.id(), old); + assert!(export_item(id, &json!({"_atnEvent":"arbitraryEvent"}), "S").is_none()); + } + assert!(export_item("edit.definePattern", &json!({"_atnEvent":"MrgL"}), "S").is_none()); + assert!(export_item("select.rect", &json!({"x":0,"y":0,"width":1,"height":1,"mode":"replace","_atnBounds":[0,0,1,1]}), "S").is_none()); + assert!(export_item("select.rect", &json!({"x":0,"y":0,"width":2,"height":1,"mode":"add","_atnBounds":[0,0,1,1]}), "S").is_none()); +} + +/// Missing settings, unknown enums, repeated keys and wrong targets stay kept with their flags. +#[test] +fn group_f_unsupported_settings_are_not_silently_replaced() { + let mut wrong_ref = rectangle([1.0, 2.0, 3.0, 4.0], false); + wrong_ref.items[0].1 = target_ref("Lyr "); + let mut cases = vec![ + (*b"AddT", desc("null")), + (*b"AddT", wrong_ref), + (*b"PstI", desc("null").with("AntA", enm("Annt", "Unknown")).with("As ", D::Class(class("Pxel")))), + (*b"PstO", desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Other")))), + (*b"Fade", desc("null").with("Opct", pct(50.0))), + (*b"Fade", desc("null").with("Opct", px(50.0)).with("Md ", enm("BlnM", "Nrml"))), + (*b"Dstr", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "Unknown"))), + (*b"RmvL", desc("null").with("apply", D::Boolean(true))), + ]; + for (event, d) in [ + (*b"AddT", rectangle([1.0, 2.0, 3.0, 4.0], true)), + (*b"PstI", desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), + (*b"Fade", desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Nrml"))), + (*b"Dstr", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ] { + for item in &d.items { + let mut repeated = d.clone(); + repeated.items.push(item.clone()); + cases.push((event, repeated)); + } + } + for key in ["Top ", "Left", "Btom", "Rght"] { + let mut d = rectangle([1.0, 2.0, 3.0, 4.0], false); + let D::Descriptor(shape) = &mut d.items[1].1 else { panic!("rectangle fixture must contain a shape") }; + shape.items.push((code(key), px(9.0))); + cases.push((*b"AddT", d)); + } + for (event, d) in cases { + let mut original = atn::parse(&file(AtnEvent::Code(event), d)).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let loaded = import(&atn::write(&original).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let saved = export("Group F", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0], original.actions[0].items[0]); + } + for (id, p) in [ + ("edit.pasteSpecial.pasteInto", json!({"center":[1,2]})), + ("edit.pasteSpecial.pasteOutside", json!({"target":"mask"})), + ("layer.linkLayers", json!({"layer":"Other"})), + ("layer.distribute.leftEdges", json!({"layer":7})), + ("edit.definePattern", json!({"name":"Frozen name"})), + ("edit.fade", json!({"mode":"unknown"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add","feather":2})), + ] { + assert!(export_item(id, &p, "S").is_none(), "{id}: {p}"); + } +} + +/// Execute the action name assigned by the loader, including collision-safe renaming. +fn play(s: &mut Session, event: AtnEvent, d: Descriptor) -> Value { + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&file(event,d))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap() +} + +/// A small image with real pixels; all witnesses operate through the public command boundary. +fn image(depth: u8) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#4080c0"})).unwrap(); + s +} + +/// Compare the actual stored selection, rather than only the translated command params. +#[test] +fn selection_combinations_play_on_an_existing_mask_and_undo() { + for depth in [8, 16, 32] { + for (event, mode, ellipse) in [(*b"AddT", "add", false), (*b"AddT", "add", true), (*b"SbtF", "subtract", false), (*b"IntW", "intersect", false)] { + let mut actual = image(depth); + let mut direct = image(depth); + for s in [&mut actual, &mut direct] { + s.execute("select.rect", json!({"x":1,"y":1,"width":4,"height":4})).unwrap(); + } + let mask = |s: &Session| s.active().unwrap().doc.selection.as_ref().map(|m| m.read_region(s.active().unwrap().doc.bounds())); + let before = mask(&actual); + let result = play(&mut actual, AtnEvent::Code(event), rectangle([3.0, 3.0, 7.0, 7.0], ellipse)); + assert!(result.get("failed").is_none(), "{result}"); + let mut p = json!({"x":3,"y":3,"width":4,"height":4,"mode":mode}); + if ellipse { + p["ellipse"] = json!(true); + p["antiAlias"] = json!(true); + } + direct.execute("select.rect", p).unwrap(); + assert_eq!(mask(&actual), mask(&direct), "{depth}/{mode}/{ellipse}"); + assert_ne!(mask(&actual), before, "the action changes the existing mask"); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask(&actual), before); + } + } +} + +/// Clipboard-driven paste creates the same layer and mask through imported and direct commands. +#[test] +fn paste_actions_use_the_clipboard_and_selection_and_undo() { + for (event, id) in [(*b"PstI", "edit.pasteSpecial.pasteInto"), (*b"PstO", "edit.pasteSpecial.pasteOutside")] { + let setup = || { + let mut s = image(32); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("select.rect", json!({"x":2,"y":2,"width":3,"height":3})).unwrap(); + s + }; + let mut actual = setup(); + let mut direct = setup(); + let before = actual.active().unwrap().doc.layers.len(); + let d = desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel"))); + let result = play(&mut actual, AtnEvent::Code(event), d); + assert!(result.get("failed").is_none(), "{result}"); + direct.execute(id, json!({})).unwrap(); + let state = |s: &Session| { + let st = s.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (st.doc.layers.len(), layer.surface().unwrap().read_region(st.doc.bounds()), layer.mask.as_ref().unwrap().surface.read_region(st.doc.bounds())) + }; + assert_eq!(actual.active().unwrap().doc.layers.len(), before + 1); + assert_eq!(state(&actual), state(&direct)); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(actual.active().unwrap().doc.layers.len(), before); + } +} + +/// Zero-opacity Fade restores the preceding pixels; no previous edit fails without mutation. +#[test] +fn fade_action_consumes_the_previous_edit_and_fails_without_it() { + for depth in [8, 16, 32] { + let mut s = image(depth); + let before = s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert_ne!(s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(), before); + let result = play(&mut s, AtnEvent::Code(*b"Fade"), desc("null").with("Opct", pct(0.0)).with("Md ", enm("BlnM", "Nrml"))); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(), before); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let result = play(&mut s, AtnEvent::Code(*b"Fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Mltp"))); + assert_eq!(result["failed"]["id"], "edit.fade"); + assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc)); +} + +/// The Link event uses the specified existing toggle, including a second playback and Undo. +#[test] +fn link_action_targets_the_selected_layers() { + let mut s = image(8); + s.execute("layer.new.layer", json!({"name":"One"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Two"})).unwrap(); + s.execute("layer.select", json!({"layer":"One"})).unwrap(); + s.execute("layer.select", json!({"layer":"Two","mode":"add"})).unwrap(); + let linked = |s: &Session| s.active().unwrap().doc.layers.iter().filter(|l| l.link_group.is_some()).count(); + for expected in [2, 0] { + let result = play(&mut s, AtnEvent::name("linkSelectedLayers"), desc("null").with("null", target_ref("Lyr "))); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(linked(&s), expected); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(linked(&s), 2); +} + +/// Distinct sized/placed layers exercise each recorded distribution edge and center. +#[test] +fn distribute_actions_match_the_requested_native_direction_and_undo() { + for (value, edge) in [ + ("AdLf", "leftEdges"), + ("AdCH", "horizontalCenters"), + ("AdRg", "rightEdges"), + ("AdTp", "topEdges"), + ("AdCV", "verticalCenters"), + ("AdBt", "bottomEdges"), + ] { + let setup = || { + let mut s = image(8); + for (name, x, y, size) in [("One", 0, 0, 1), ("Two", 4, 3, 2), ("Three", 6, 6, 1)] { + s.execute("layer.new.layer", json!({"name":name})).unwrap(); + s.execute("select.rect", json!({"x":x,"y":y,"width":size,"height":size})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + } + s.execute("layer.select", json!({"layer":"One"})).unwrap(); + for name in ["Two", "Three"] { + s.execute("layer.select", json!({"layer":name,"mode":"add"})).unwrap(); + } + s + }; + let mut actual = setup(); + let mut direct = setup(); + let bounds = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| (l.name.clone(), l.surface().map(|v| v.content_bounds()))).collect::>(); + let before = bounds(&actual); + let result = play(&mut actual, AtnEvent::Code(*b"Dstr"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", value))); + assert!(result.get("failed").is_none(), "{result}"); + direct.execute(&format!("layer.distribute.{edge}"), json!({})).unwrap(); + assert_eq!(bounds(&actual), bounds(&direct), "{edge}"); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&actual), before); + } +} + +/// Legacy steps perform real edits and keep their native Undo behavior. +#[test] +fn old_mask_and_merge_actions_execute_their_mapped_commands() { + let mut s = image(8); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let has_mask = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.is_some() + }; + assert!(has_mask(&s)); + let result = play(&mut s, AtnEvent::Code(*b"RmvL"), desc("null")); + assert!(result.get("failed").is_none(), "{result}"); + assert!(!has_mask(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_mask(&s)); + s.execute("layer.new.layer", json!({"name":"Overlay"})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("select.allLayers", json!({})).unwrap(); + let before = s.active().unwrap().doc.layers.len(); + let result = play(&mut s, AtnEvent::Code(*b"MrgL"), desc("null")); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), before); +} + +/// The empty Define Pattern event creates the selected region without changing canvas pixels. +#[test] +fn define_pattern_action_uses_the_selected_region() { + let mut s = image(8); + s.execute("select.rect", json!({"x":1,"y":2,"width":3,"height":2})).unwrap(); + let before = s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(); + let count = s.patterns.items.len(); + let result = play(&mut s, AtnEvent::Code(*b"DfnP"), desc("null")); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.patterns.items.len(), count + 1); + let p = s.patterns.items.last().unwrap(); + assert_eq!((p.width, p.height), (3, 2)); + assert!(!p.surface.format().alpha); + assert_eq!(s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(), before); +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_artboard.rs b/crates/engine/src/cmd/atn_cmds/steps_artboard.rs new file mode 100644 index 0000000000..de7b7be9e3 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_artboard.rs @@ -0,0 +1,160 @@ +//! Measured Artboard conversion and background-only Properties edits. + +use super::descriptors::{desc, get_num, get_obj, get_text, target_ref, text}; +use super::fields::{Mapped, none, step}; +use super::{D, Descriptor, Value, json}; + +const FROM_GROUP: &str = "layer.new.artboardFromGroup"; +const EDIT: &str = "layer.artboard.set"; + +/// Keep recorded colors and ignored guide IDs, including an omitted background type. +fn settings_from(board: &Descriptor, preset_key: &str) -> Option { + let color = get_obj(board, "Clr ")?; + let preset = get_text(board, "artboardPresetName")?; + let background = match board.get("artboardBackgroundType") { + None | Some(D::Integer(1)) => "white", + Some(D::Integer(2)) => "black", + Some(D::Integer(3)) => "transparent", + Some(D::Integer(4)) => "custom", + _ => return None, + }; + let Some(D::List(guides)) = board.get("guideIDs") else { return None }; + let guides: Vec = guides.iter().map(|v| if let D::Integer(n) = v { Some(*n) } else { None }).collect::>()?; + let recorded: Vec = ["Rd ", "Grn ", "Bl "].iter().map(|key| get_num(color, key)).collect::>()?; + let rgb: Vec = recorded.iter().map(|n| n / 255.0).collect(); + let mut p = json!({"background": background, "color": rgb, "guideIDs": guides}); + if recorded.iter().zip(&rgb).any(|(recorded, native)| *recorded != native * 255.0) { + p["recordedColor"] = json!(recorded); + } + p[preset_key] = preset.into(); + if board.get("artboardBackgroundType").is_none() { + p["backgroundTypeOmitted"] = true.into(); + } + Some(p) +} + +/// Rebuild only settings whose native meaning still agrees with the saved wire form. +fn settings_to(p: &Value, preset_key: &str) -> Option { + let preset = p.get(preset_key)?.as_str()?; + let background = match p.get("background")?.as_str()? { + "white" => 1, + "black" => 2, + "transparent" => 3, + "custom" => 4, + _ => return None, + }; + let guides = p.get("guideIDs")?.as_array()?.iter().map(|v| Some(D::Integer(i32::try_from(v.as_i64()?).ok()?))).collect::>>()?; + let rgb = p.get("color")?.as_array()?; + if rgb.len() != 3 { + return None; + } + let rgb: Vec = rgb.iter().map(Value::as_f64).collect::>()?; + if !rgb.iter().all(|n| n.is_finite() && (0.0..=1.0).contains(n)) { + return None; + } + let recorded = match p.get("recordedColor") { + None => rgb.iter().map(|n| n * 255.0).collect(), + Some(value) => { + let values = value.as_array()?.iter().map(Value::as_f64).collect::>>()?; + if values.len() != 3 || values.iter().zip(&rgb).any(|(wire, native)| !(0.0..=255.0).contains(wire) || wire / 255.0 != *native) { + return None; + } + values + } + }; + let color = ["Rd ", "Grn ", "Bl "].into_iter().zip(recorded).fold(desc("RGBC"), |d, (key, n)| d.with(key, D::Double(n))); + let board = desc("artboard").with("guideIDs", D::List(guides)).with("artboardPresetName", text(preset)).with("Clr ", D::Descriptor(color)); + match p.get("backgroundTypeOmitted") { + None => Some(board.with("artboardBackgroundType", D::Integer(background))), + Some(Value::Bool(true)) if background == 1 => Some(board), + _ => None, + } +} + +/// Import target-only conversion or complete recorded settings, preserving flag absence and values. +pub(super) fn artboard_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + if d.items == desc("null").with("null", target_ref("Lyr ")).items { + return none(FROM_GROUP); + } + let Some(mut p) = get_obj(d, "artboard").and_then(|b| settings_from(b, "preset")) else { return unsupported }; + match d.get("changeBackground") { + None => {} + Some(D::Integer(n)) => p["changeBackground"] = (*n).into(), + _ => return unsupported, + } + if artboard_to(FROM_GROUP, &p).is_some_and(|expected| expected.items == d.items) { step(FROM_GROUP, p) } else { unsupported } +} + +/// Fill behavior-neutral metadata for native settings; unknown options must not disappear. +pub(super) fn artboard_to(_: &str, p: &Value) -> Option { + let fields = p.as_object()?; + let d = desc("null").with("null", target_ref("Lyr ")); + if fields.is_empty() { + return Some(d); + } + if fields.keys().any(|k| !["background", "color", "recordedColor", "preset", "guideIDs", "changeBackground", "backgroundTypeOmitted"].contains(&k.as_str())) + { + return None; + } + let mut settings = p.clone(); + let fields = settings.as_object_mut()?; + if !fields.contains_key("color") && fields.get("background").and_then(Value::as_str) == Some("custom") { + fields.insert("background".into(), json!("white")); + } + let background = if fields.contains_key("color") { "custom" } else { "white" }; + // Native custom-without-color means white. Other types ignore this last-used color. + for (key, value) in [("background", json!(background)), ("color", json!([1.0, 1.0, 1.0])), ("preset", json!("")), ("guideIDs", json!([]))] { + fields.entry(key).or_insert(value); + } + let d = d.with("artboard", D::Descriptor(settings_to(&settings, "preset")?)); + match p.get("changeBackground") { + None => Some(d), + // Older native action files used true for the recorded integer 1. + Some(Value::Bool(true)) => Some(d.with("changeBackground", D::Integer(1))), + Some(value) => Some(d.with("changeBackground", D::Integer(i32::try_from(value.as_i64()?).ok()?))), + } +} + +/// Background-only edits retain recorded geometry as metadata, never as native resize parameters. +pub(super) fn artboard_edit_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(board) = get_obj(d, "artboard") else { return unsupported }; + let Some(rect) = get_obj(board, "artboardRect") else { return unsupported }; + let Some(mut p) = settings_from(board, "recordedPreset") else { return unsupported }; + let Some(edges) = ["Top ", "Left", "Btom", "Rght"].iter().map(|key| get_num(rect, key)).collect::>>() else { return unsupported }; + p["recordedRect"] = json!(edges); + p["changeSizes"] = 8.into(); + p["changeBackground"] = 1.into(); + if artboard_edit_to(EDIT, &p).is_some_and(|expected| expected.items == d.items) { step(EDIT, p) } else { unsupported } +} + +/// Export only the measured background-edit flags and exact recorded rectangle, without quantization. +pub(super) fn artboard_edit_to(_: &str, p: &Value) -> Option { + let fields = p.as_object()?; + if fields.keys().any(|k| { + !["background", "color", "recordedColor", "recordedPreset", "guideIDs", "recordedRect", "changeSizes", "changeBackground", "backgroundTypeOmitted"] + .contains(&k.as_str()) + }) || p.get("changeSizes")?.as_i64()? != 8 + || p.get("changeBackground")?.as_i64()? != 1 + { + return None; + } + let edges = p.get("recordedRect")?.as_array()?; + if edges.len() != 4 { + return None; + } + let rect = ["Top ", "Left", "Btom", "Rght"] + .into_iter() + .zip(edges) + .try_fold(desc("classFloatRect"), |d, (key, value)| Some(d.with(key, D::Double(value.as_f64().filter(|v| v.is_finite())?))))?; + let mut board = desc("artboard").with("artboardRect", D::Descriptor(rect)); + board.items.extend(settings_to(p, "recordedPreset")?.items); + Some( + desc("null") + .with("null", target_ref("Lyr ")) + .with("artboard", D::Descriptor(board)) + .with("changeSizes", D::Integer(8)) + .with("changeBackground", D::Integer(1)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs new file mode 100644 index 0000000000..bfd3e07904 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -0,0 +1,160 @@ +//! Photoshop's channel and colour steps (#10609): Split Channels, Merge Spot Channel, Trap and +//! Match Color, with the keys Photoshop records. + +use super::descriptors::{PX, boolean, desc, px, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Value, json}; +use std::ops::RangeInclusive; + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, + } +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// Split Channels (`splitChannels`). Its `documentIDs` are the documents the split made when it +/// was recorded, so they mean nothing on replay: the step splits the active document and closes +/// it, as Photoshop's does. +pub(super) fn split_from(d: &Descriptor) -> Mapped { + if only_keys(d, &["documentIDs"]) { step("channel.split", json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Written without `documentIDs`, which only a run of the split can know. Keeping the original +/// open has no Photoshop counterpart. +pub(super) fn split_to(_: &str, p: &Value) -> Option { + let closes = p.get("closeOriginal").is_none_or(|v| v.is_null() || *v == Value::Bool(true)); + (sets_only(p, &["closeOriginal"]) && closes).then(|| desc("null")) +} + +/// Merge Spot Channel (`mergeSpotChannel`): an empty step merges the targeted spot channel. +pub(super) fn merge_spot_from(d: &Descriptor) -> Mapped { + if d.items.is_empty() { step("channel.mergeSpot", json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// A step naming its channel cannot be written: the event has no key for it. +pub(super) fn merge_spot_to(_: &str, p: &Value) -> Option { + sets_only(p, &[]).then(|| desc("null")) +} + +/// Trap's widest width: Photoshop clamps larger ones to it. +const MAX_TRAP: f64 = 32767.0; + +/// Trap's narrowest width, the command's `width:px>=1`. Photoshop's own minimum wasn't +/// measured, so a narrower recorded width isn't clamped up to it. +const MIN_TRAP: f64 = 1.0; + +/// A width in `#Pxl`, the unit Trap saves: a width in another unit (points, millimetres) or +/// without one would load as that many pixels and save back as pixels. +fn pixels(v: &D) -> Option { + match v { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() => Some(*value), + _ => None, + } +} + +/// Trap (`Trap`): `Wdth` in pixels. A step without `Wdth` plays the command's 1 px, written +/// out so it saves back as the width it plays. A width that isn't a number of pixels, or is +/// below `MIN_TRAP`, keeps the Photoshop step. +pub(super) fn trap_from(d: &Descriptor) -> Mapped { + if !only_keys(d, &["Wdth"]) { + return Mapped::Unsupported { harmless: false }; + } + match setting(d, "Wdth", |v| pixels(v).map(Some), None) { + Some(Some(w)) if w >= MIN_TRAP => step("image.trap", json!({"width": w.min(MAX_TRAP)})), + Some(None) => step("image.trap", json!({"width": MIN_TRAP})), + _ => Mapped::Unsupported { harmless: false }, + } +} + +/// `Wdth` is always written: the command traps 1 px when the step leaves the width out. A +/// width outside `MIN_TRAP..=MAX_TRAP` refuses the step rather than being clamped to another +/// value. +pub(super) fn trap_to(_: &str, p: &Value) -> Option { + if !sets_only(p, &["width"]) { + return None; + } + let width = match p.get("width") { + None | Some(Value::Null) => 1.0, + Some(v) => v.as_f64().filter(|n| (MIN_TRAP..=MAX_TRAP).contains(n))?, + }; + Some(desc("null").with("Wdth", px(width))) +} + +/// Match Color's integer keys in recorded order: (key, param, default, the command's range). +const MATCH_COLOR: &[(&str, &str, i32, RangeInclusive)] = + &[("Lght", "luminance", 100, 1..=200), ("ClrR", "intensity", 100, 1..=200), ("Fade", "fade", 0, 0..=100)]; + +/// Match Color (`matchColor`) without a source, as Photoshop records it (`noReference`). A +/// step that matches another image (`noReference` false, or not a Boolean) names no source +/// here, so it keeps the Photoshop step, as does a setting that isn't an integer: the step +/// saves its settings as integers, so it couldn't write a fraction, a large integer or a unit +/// back as recorded. A setting outside the command's range keeps the step too: the command +/// would clamp it, and Photoshop's handling of it wasn't measured. +pub(super) fn match_color_from(d: &Descriptor) -> Mapped { + match match_color_params(d) { + Some(p) => step("image.adjustments.matchColor", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn match_color_params(d: &Descriptor) -> Option> { + if !only_keys(d, &["Lght", "ClrR", "Fade", "noReference"]) || !setting(d, "noReference", boolean, true)? { + return None; + } + let mut p = serde_json::Map::new(); + for (key, param, default, range) in MATCH_COLOR { + let integer = |v: &D| match v { + D::Integer(n) if range.contains(n) => Some(Value::from(*n)), + _ => None, + }; + p.insert((*param).into(), setting(d, key, integer, Value::from(*default))?); + } + Some(p) +} + +/// Every key in recorded order. A source document or layer, Neutralize or a selection option +/// has no key in the event, so such a step is refused; the integers refuse a fraction or a +/// value outside the command's range, which it plays clamped. A negative `source` is the +/// dialog's Source "None", which the command plays without a source, so it is written as one +/// (as is a `sourceLayer` with it, which the command then ignores). +pub(super) fn match_color_to(_: &str, p: &Value) -> Option { + let off = |k: &str| p.get(k).is_none_or(|v| v.is_null() || *v == Value::Bool(false)); + let no_source = p.get("source").is_none_or(|v| v.is_null() || v.as_i64().is_some_and(|i| i < 0)); + if !no_source + || !pixel_target(p) + || !sets_only(p, &["target", "source", "sourceLayer", "luminance", "intensity", "fade", "neutralize", "useSelectionInSource", "useSelectionInTarget"]) + || !["neutralize", "useSelectionInSource", "useSelectionInTarget"].iter().all(|k| off(k)) + { + return None; + } + let mut d = desc("null"); + for (key, param, default, range) in MATCH_COLOR { + let n = match p.get(*param) { + None | Some(Value::Null) => f64::from(*default), + Some(v) => v.as_f64()?, + }; + if n.fract() != 0.0 || !(f64::from(*range.start())..=f64::from(*range.end())).contains(&n) { + return None; + } + d = d.with(key, D::Integer(n as i32)); + } + Some(d.with("noReference", D::Boolean(true))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs b/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs new file mode 100644 index 0000000000..3e30609ea2 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_content_aware.rs @@ -0,0 +1,91 @@ +//! Recorded Content-Aware Fill settings; selection and job/history behavior belong to the command. +use super::descriptors::{desc, enm, jstr}; +use super::fields::{Mapped, step}; +use super::tables::lookup_back; +use super::{D, Descriptor, Map, Value}; + +const CMD: &str = "edit.contentAwareFill"; +const SOURCE: &str = "_atnCafDescriptor"; +// The measured row bounds these values; its ellipses are not additional enum bindings. +const SAMPLING: &[(&str, &str)] = &[("cafSamplingRegionRectangular", "rectangular")]; +const COLOR: &[(&str, &str)] = &[("cafColorAdaptationDefault", "default")]; +const ROTATION: &[(&str, &str)] = &[("cafRotationAmountNone", "none")]; +// Current Layer is also explicit in the primary Action Manager example cited in docs/atn.md. +const OUTPUT: &[(&str, &str)] = &[("cafOutputToNewLayer", "new"), ("cafOutputToCurrentLayer", "current")]; +const KEYS: &[&str] = &["cafSamplingRegion", "cafSampleAllLayers", "cafColorAdaptationLevel", "cafRotationAmount", "cafScale", "cafMirror", "cafOutput"]; +const PARAMS: &[&str] = &["sampling", "sampleAllLayers", "colorAdaptation", "rotationAdaptation", "scale", "mirror", "output"]; + +/// All seven settings are recorded. Missing keys have no measured Photoshop default here. +fn recorded(d: &Descriptor) -> Option { + if d.items.len() != KEYS.len() || KEYS.iter().any(|name| d.items.iter().filter(|(key, _)| key.is(name)).count() != 1) { + return None; + } + let mut p = Map::new(); + for (key, param, table) in [(KEYS[0], PARAMS[0], SAMPLING), (KEYS[2], PARAMS[2], COLOR), (KEYS[3], PARAMS[3], ROTATION), (KEYS[6], PARAMS[6], OUTPUT)] { + let D::Enumerated { type_id, value } = d.get(key)? else { return None }; + if !type_id.is(key) { + return None; + } + p.insert(param.into(), table.iter().find(|(wire, _)| value.is(wire))?.1.into()); + } + for (key, param) in [(KEYS[1], PARAMS[1]), (KEYS[4], PARAMS[4]), (KEYS[5], PARAMS[5])] { + let D::Boolean(value) = d.get(key)? else { return None }; + p.insert(param.into(), (*value).into()); + } + Some(Value::Object(p)) +} + +/// Write only represented native options, making their defaults explicit in all seven fields. +fn descriptor(p: &Value) -> Option { + let object = p.as_object()?; + if !super::events::pixel_target(p) + || object.keys().any(|key| !PARAMS.contains(&key.as_str()) && !matches!(key.as_str(), "target" | "seed" | SOURCE)) + || p.get("seed").is_some_and(|seed| seed.as_u64() != Some(1)) + { + return None; + } + let enumeration = |key: &str, param: &str, table: &[(&str, &str)], default: &str| { + let value = match p.get(param) { + None => default, + Some(value) => value.as_str()?, + }; + Some(enm(key, lookup_back(table, value)?)) + }; + let boolean = |param: &str| match p.get(param) { + None => Some(D::Boolean(false)), + Some(value) => value.as_bool().map(D::Boolean), + }; + Some( + desc("null") + .with(KEYS[0], enumeration(KEYS[0], PARAMS[0], SAMPLING, "auto")?) + .with(KEYS[1], boolean(PARAMS[1])?) + .with(KEYS[2], enumeration(KEYS[2], PARAMS[2], COLOR, "default")?) + .with(KEYS[3], enumeration(KEYS[3], PARAMS[3], ROTATION, "none")?) + .with(KEYS[4], boolean(PARAMS[4])?) + .with(KEYS[5], boolean(PARAMS[5])?) + .with(KEYS[6], enumeration(KEYS[6], PARAMS[6], OUTPUT, "current")?), + ) +} + +/// Import the verified seven-field form, retaining noncanonical wire data for an unchanged save. +pub(super) fn caf_from(d: &Descriptor) -> Mapped { + let Some(mut p) = recorded(d) else { return Mapped::Unsupported { harmless: false } }; + if descriptor(&p).as_ref() != Some(d) { + // Key order, descriptor identity and ID encoding are wire data, not native options. + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(CMD, p) +} + +/// Export supported settings; preserve the recorded wire form only while its settings agree. +pub(super) fn caf_to(_: &str, p: &Value) -> Option { + let canonical = descriptor(p)?; + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + // Validate preserved data even after an edit; it cannot hide unsupported source options. + let original = descriptor(&recorded(&source)?)?; + // A supported edit has a verified canonical form; stale wire data cannot override it. + Some(if original == canonical { source } else { canonical }) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_direct_adjustments.rs b/crates/engine/src/cmd/atn_cmds/steps_direct_adjustments.rs new file mode 100644 index 0000000000..a3e0bf4c9e --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_direct_adjustments.rs @@ -0,0 +1,187 @@ +//! Direct GrMp and RplC forms measured in #10603 (Group E). +use super::adjustments::gradient_descriptor; +use super::descriptors::{desc, enm, get_list, get_num, get_obj}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Value, json}; + +/// Typed source values needed to retain the direct event and fractional RGB on save. +/// Execution uses ordinary numeric RGB stops, never a stored descriptor. +pub(super) const GRADIENT_ORIGIN: &str = "__atnGrMp"; + +/// Refuse extra native settings instead of exporting an incomplete edit. +fn only_params(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_some_and(|p| p.keys().all(|k| allowed.contains(&k.as_str()))) +} + +/// Read exactly three channels; callers enforce their colour-space limits. +fn triple(p: &Value) -> Option<[f64; 3]> { + let a = p.as_array().filter(|a| a.len() == 3)?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) +} + +/// Import only a descriptor the native stop model and the strict writer retain. +/// Comparing the reconstructed typed descriptor rejects dropped settings, unknown +/// keys, duplicates, unsupported units/classes and lossy wire-type conversions. +pub(super) fn gradient_from(d: &Descriptor) -> Mapped { + let mapped = || { + let g = get_obj(d, "Grad")?; + let D::Text(name) = g.get("Nm ")? else { return None }; + let mut stops = Vec::new(); + let mut colors = Vec::new(); + for s in get_list(g, "Clrs").filter(|s| (2..=64).contains(&s.len()))? { + let D::Descriptor(s) = s else { return None }; + let c = get_obj(s, "Clr ")?; + let rgb = [get_num(c, "Rd ")?, get_num(c, "Grn ")?, get_num(c, "Bl ")?]; + colors.push(rgb); + stops.push(json!([get_num(s, "Lctn")? / 4096.0, rgb.map(|v| v / 255.0)])); + } + let mut p = json!({"stops": stops, GRADIENT_ORIGIN: {"name": name.to_string_lossy(), "colors": colors}}); + if d.get("gradientsInterpolationMethod").is_none() { + // The measured default is classic; retain omission as well as meaning. + p[GRADIENT_ORIGIN]["omitInterpolation"] = json!(true); + } + for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither")] { + if let Some(v) = d.get(key) { + let D::Boolean(v) = v else { return None }; + p[param] = json!(v); + } + } + (gradient_to("", &p)?.eq(d)).then_some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.gradientMap", p)) +} + +/// Write the measured classic form. The exact source RGB values must still agree +/// with the executed stops, so changing a stop never revives stale source data. +pub(super) fn gradient_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !only_params(p, &["stops", "reverse", "dither", "target", GRADIENT_ORIGIN]) { + return None; + } + let origin = p.get(GRADIENT_ORIGIN)?; + if !only_params(origin, &["name", "colors", "omitInterpolation"]) { + return None; + } + let name = origin.get("name")?.as_str()?; + let colors = origin.get("colors")?.as_array()?; + let stops = p.get("stops")?.as_array().filter(|s| (2..=64).contains(&s.len()) && s.len() == colors.len())?; + let mut encoded = Vec::with_capacity(stops.len()); + let mut previous = -1.0; + for (stop, color) in stops.iter().zip(colors) { + let a = stop.as_array().filter(|a| a.len() == 2)?; + let position = a.first()?.as_f64()?; + if !position.is_finite() || !(0.0..=1.0).contains(&position) || position < previous || (position * 4096.0).fract() != 0.0 { + return None; + } + previous = position; + let rgb = triple(color)?; + if !rgb.iter().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) || triple(a.get(1)?)? != rgb.map(|v| v / 255.0) { + return None; + } + let rgb = desc("RGBC").with("Rd ", D::Double(rgb[0])).with("Grn ", D::Double(rgb[1])).with("Bl ", D::Double(rgb[2])); + encoded.push(D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(rgb)) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer((position * 4096.0) as i32)) + .with("Mdpn", D::Integer(50)), + )); + } + let mut d = desc("null"); + let omit = match origin.get("omitInterpolation") { + None => false, + Some(v) => v.as_bool()?, + }; + if !omit { + d = d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls")); + } + d = d.with("Grad", D::Descriptor(gradient_descriptor(encoded, name))); + for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr")] { + if let Some(v) = p.get(param) { + d = d.with(key, D::Boolean(v.as_bool()?)); + } + } + Some(d) +} + +/// Retain source precision while requiring coordinates to stay finite in native f32. +fn lab_bound(p: &Value) -> Option<[f64; 3]> { + let values = triple(p)?; + values.iter().all(|v| v.is_finite() && (*v as f32).is_finite()).then_some(values) +} + +/// Reject extra, duplicate or reordered keys that the strict writer cannot retain. +fn ordered(d: &Descriptor, keys: &[&str]) -> bool { + d.items.len() == keys.len() && d.items.iter().zip(keys).all(|((key, _), expected)| key.is(expected)) +} + +/// Read the measured Lab encoding directly, without RGB clipping or type coercion. +fn lab_from(v: &D) -> Option<[f64; 3]> { + let D::Descriptor(d) = v else { return None }; + if !d.class_id.is("LbCl") || !d.name.0.is_empty() || !ordered(d, &["Lmnc", "A ", "B "]) { + return None; + } + let values = d + .items + .iter() + .map(|(_, v)| match v { + D::Double(n) => Some(*n), + _ => None, + }) + .collect::>>()?; + lab_bound(&json!(values)) +} + +/// RplC records the sampled D50 box directly. RGB conversion would lose both the +/// box width and out-of-gamut coordinates, so pass its bounds to the native command. +pub(super) fn replace_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !d.class_id.is("null") || !d.name.0.is_empty() || !ordered(d, &["Fzns", "Mnm ", "Mxm ", "H ", "Strt", "Lght", "colorModel"]) { + return None; + } + let (lo, hi) = (lab_from(d.get("Mnm ")?)?, lab_from(d.get("Mxm ")?)?); + if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) || !matches!(d.get("colorModel"), Some(D::Integer(0))) { + return None; + } + let mut p = json!({"labMinimum": lo, "labMaximum": hi}); + for (key, param, lo, hi) in + [("Fzns", "fuzziness", 0, 200), ("H ", "hue", -180, 180), ("Strt", "saturation", -100, 100), ("Lght", "lightness", -100, 100)] + { + let D::Integer(n) = d.get(key)? else { return None }; + p[param] = json!((*n).clamp(lo, hi)); + } + Some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.replaceColor", p)) +} + +/// Emit all measured keys in order. Fractional native shifts have no integer ATN +/// representation and are refused; an ordinary RGB-sample call is not a Lab box. +pub(super) fn replace_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !only_params(p, &["labMinimum", "labMaximum", "fuzziness", "hue", "saturation", "lightness", "target"]) { + return None; + } + let (lo, hi) = (lab_bound(p.get("labMinimum")?)?, lab_bound(p.get("labMaximum")?)?); + if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) { + return None; + } + let lab = |v: [f64; 3]| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(v[0])).with("A ", D::Double(v[1])).with("B ", D::Double(v[2]))); + let field = |key, default: f64, lo, hi| { + let n = match p.get(key) { + None => default, + Some(v) => v.as_f64()?, + }; + let n = n.is_finite().then_some(n)?.clamp(lo, hi); + (n.fract() == 0.0).then_some(D::Integer(n as i32)) + }; + Some( + desc("null") + .with("Fzns", field("fuzziness", 40.0, 0.0, 200.0)?) + .with("Mnm ", lab(lo)) + .with("Mxm ", lab(hi)) + .with("H ", field("hue", 0.0, -180.0, 180.0)?) + .with("Strt", field("saturation", 0.0, -100.0, 100.0)?) + .with("Lght", field("lightness", 0.0, -100.0, 100.0)?) + .with("colorModel", D::Integer(0)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs new file mode 100644 index 0000000000..703b2327dd --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -0,0 +1,343 @@ +//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave and +//! ZigZag, each key in recorded order with the limits and defaults Photoshop measures. + +use super::descriptors::{desc, enm, num, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Map, Value, json}; + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, + } +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// An enumeration's value when its type is `ty`, the type Photoshop records it with and the +/// step saves back: one of another type isn't that setting. +fn enum_of(ty: &str) -> impl Fn(&D) -> Option + '_ { + move |v| match v { + D::Enumerated { type_id, value } if trimmed(type_id) == ty.trim_end() => Some(trimmed(value)), + _ => None, + } +} + +/// One key of a Distort filter's step, in recorded order. +pub(super) enum Key { + /// An integer clamped to `min..=max` on import, as Photoshop clamps it; `default` when the + /// step leaves it out. + Int { key: &'static str, param: &'static str, min: i32, max: i32, default: i32 }, + /// An enumeration (`ty`, Photoshop ↔ PhotoCraft values); `default` is Photoshop's value when + /// the step leaves it out. An unknown value, or one of another type, keeps the step. + Enum { key: &'static str, param: &'static str, ty: &'static str, table: &'static [(&'static str, &'static str)], default: &'static str }, +} + +impl Key { + fn param(&self) -> &'static str { + match self { + Key::Int { param, .. } | Key::Enum { param, .. } => param, + } + } + + fn key(&self) -> &'static str { + match self { + Key::Int { key, .. } | Key::Enum { key, .. } => key, + } + } +} + +/// A step with exactly `keys` (any missing): each value clamped or looked up, Photoshop's +/// default for what it leaves out. Any other key, or a value of the wrong type, keeps the step. +pub(super) fn keys_from(d: &Descriptor, id: &str, keys: &[Key]) -> Mapped { + keys_map(d, keys, &[]).map_or(Mapped::Unsupported { harmless: false }, |m| step(id, Value::Object(m))) +} + +fn keys_map(d: &Descriptor, keys: &[Key], extra: &[&'static str]) -> Option> { + let known: Vec<&str> = keys.iter().map(Key::key).chain(extra.iter().copied()).map(str::trim_end).collect(); + if !only_keys(d, &known) { + return None; + } + let mut m = Map::new(); + for k in keys { + let v = match k { + Key::Int { key, min, max, default, .. } => { + Value::from(setting(d, key, |v| num(v).map(|n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32), *default)?) + } + Key::Enum { key, ty, table, default, .. } => Value::from(lookup(table, &setting(d, key, enum_of(ty), (*default).to_string())?)?), + }; + m.insert(k.param().into(), v); + } + Some(m) +} + +/// Every key in recorded order, the command's default for what the step leaves out +/// (`ours`, by param). An integer key refuses a fractional value, which would replay rounded, +/// and one outside its `min..=max`, which would load back clamped. +pub(super) fn keys_to(p: &Value, keys: &[Key], ours: &[(&str, Value)], extra: &[&'static str]) -> Option { + let params: Vec<&str> = keys.iter().map(Key::param).chain(extra.iter().copied()).chain(["target"]).collect(); + if !pixel_target(p) || !sets_only(p, ¶ms) { + return None; + } + let value = |param: &str| match p.get(param) { + None | Some(Value::Null) => ours.iter().find(|(k, _)| *k == param).map(|(_, v)| v.clone()), + Some(v) => Some(v.clone()), + }; + let mut d = desc("null"); + for k in keys { + let v = value(k.param())?; + d = match k { + Key::Int { key, min, max, .. } => { + let n = v.as_f64()?; + if n.fract() != 0.0 || !(f64::from(*min)..=f64::from(*max)).contains(&n) { + return None; + } + d.with(key, D::Integer(n as i32)) + } + Key::Enum { key, ty, table, .. } => d.with(key, enm(ty, lookup_back(table, v.as_str()?)?)), + }; + } + Some(d) +} + +pub(super) const PINCH: &[Key] = &[Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 50 }]; + +pub(super) const TWIRL: &[Key] = &[Key::Int { key: "Angl", param: "angle", min: -999, max: 999, default: 50 }]; + +pub(super) const POLAR: &[Key] = + &[Key::Enum { key: "Cnvr", param: "mode", ty: "Cnvr", table: &[("RctP", "rectangularToPolar"), ("PlrR", "polarToRectangular")], default: "RctP" }]; + +pub(super) const SPHERIZE: &[Key] = &[ + Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 100 }, + Key::Enum { key: "SphM", param: "mode", ty: "SphM", table: &[("Nrml", "normal"), ("HrzO", "horizontalOnly"), ("VrtO", "verticalOnly")], default: "Nrml" }, +]; + +pub(super) const ZIGZAG: &[Key] = &[ + Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 10 }, + Key::Int { key: "NmbR", param: "ridges", min: 0, max: 20, default: 5 }, + Key::Enum { + key: "ZZTy", + param: "style", + ty: "ZZTy", + table: &[("PndR", "pondRipples"), ("OtFr", "outFromCenter"), ("ArnC", "aroundCenter")], + default: "PndR", + }, +]; + +/// Wave's keys before its scale, undefined areas and seed (handled in `wave_from` / `wave_to`). +const WAVE: &[Key] = &[ + Key::Enum { key: "Wvtp", param: "type", ty: "Wvtp", table: &[("WvSn", "sine"), ("WvSq", "square"), ("WvTr", "triangle")], default: "WvSn" }, + Key::Int { key: "NmbG", param: "generators", min: 1, max: 999, default: 5 }, + Key::Int { key: "WLMn", param: "wavelengthMin", min: 1, max: 999, default: 10 }, + Key::Int { key: "WLMx", param: "wavelengthMax", min: 1, max: 999, default: 120 }, + Key::Int { key: "AmMn", param: "amplitudeMin", min: 1, max: 999, default: 5 }, + Key::Int { key: "AmMx", param: "amplitudeMax", min: 1, max: 999, default: 35 }, +]; + +const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")]; + +/// `filter.distort.wave`'s own limits where the command plays narrower than Photoshop's 1..999 +/// (the renderer draws at most [`photocraft_algo::MAX_WAVE_GENERATORS`] generators). A value +/// Photoshop plays but the command rejects or plays differently keeps the step on import and +/// refuses it on export, rather than mapping a step that fails or differs when it plays. +const WAVE_COMMAND_LIMITS: &[(&str, f64, f64)] = &[ + ("generators", 1.0, photocraft_algo::MAX_WAVE_GENERATORS as f64), + ("wavelengthMin", 1.0, 998.0), + ("wavelengthMax", 2.0, 999.0), + ("amplitudeMin", 1.0, 998.0), +]; + +/// Whether every value `p` sets for a key of [`WAVE_COMMAND_LIMITS`] is within the command's limits. +fn wave_command_takes(p: &Map) -> bool { + WAVE_COMMAND_LIMITS.iter().all(|(param, min, max)| p.get(*param).and_then(Value::as_f64).is_none_or(|n| (*min..=*max).contains(&n))) +} + +/// Wave (`Wave`). The command has no horizontal or vertical scale, so a step that scales the +/// waves below 100 %, or by a fraction (saved back as 100), keeps the Photoshop step. Missing undefined areas are Photoshop's Repeat +/// Edge Pixels (the command's own default is wrap); `RndS` keeps its 32 bits as the seed. +pub(super) fn wave_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]).filter(wave_command_takes) else { return unsupported }; + if ["SclH", "SclV"].iter().any(|k| setting(d, k, num, 100.0).is_none_or(|n| n.fract() != 0.0 || n.clamp(1.0, 100.0) != 100.0)) { + return unsupported; + } + let Some(undefined) = setting(d, "UndA", enum_of("UndA"), "RptE".into()).and_then(|e| lookup(WAVE_UNDEFINED, &e)) else { return unsupported }; + m.insert("undefinedAreas".into(), undefined.into()); + match setting(d, "RndS", |v| seed_bits(v).map(Some), None) { + Some(Some(seed)) => { + m.insert("seed".into(), Value::from(seed)); + } + Some(None) => {} + None => return unsupported, + } + step("filter.distort.wave", Value::Object(m)) +} + +/// `RndS` as the seed's 32 bits: a negative value is a signed seed's bit pattern. A fraction +/// or a value past 32 bits is no seed, rather than one truncated or saturated to another. +fn seed_bits(v: &D) -> Option { + let n = num(v).filter(|n| n.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(u32::MAX)).contains(n))?; + Some(if n < 0.0 { n as i32 as u32 } else { n as u32 }) +} + +pub(super) fn wave_to(_: &str, p: &Value) -> Option { + let ours = [ + ("type", json!("sine")), + ("generators", json!(5)), + ("wavelengthMin", json!(10)), + ("wavelengthMax", json!(120)), + ("amplitudeMin", json!(5)), + ("amplitudeMax", json!(35)), + ]; + if !p.as_object().is_none_or(wave_command_takes) { + return None; + } + let d = keys_to(p, WAVE, &ours, &["undefinedAreas", "seed"])?; + let undefined = match p.get("undefinedAreas") { + None | Some(Value::Null) => "WrpA", + Some(v) => lookup_back(WAVE_UNDEFINED, v.as_str()?)?, + }; + let seed = match p.get("seed") { + None | Some(Value::Null) => 0, + Some(v) => u32::try_from(v.as_u64()?).ok()? as i32, + }; + Some(d.with("SclH", D::Integer(100)).with("SclV", D::Integer(100)).with("UndA", enm("UndA", undefined)).with("RndS", D::Integer(seed))) +} + +// Adapter safety budget, not a Photoshop format maximum: at most 64 KiB per f64 point +// vector and 32 KiB in the native renderer, whose per-row curve search is linear. +const MAX_SHEAR_POINTS: usize = 4096; + +/// A curve must fit the playback budget and retain its order through native deduplication. +fn shear_curve(points: &[[f64; 2]]) -> bool { + (2..=MAX_SHEAR_POINTS).contains(&points.len()) + && points.iter().all(|p| p[0].is_finite() && (0.0..=1.0).contains(&p[0]) && (-1.0..=63.0 / 64.0).contains(&p[1])) + && points.windows(2).all(|p| p[1][0] as f32 - p[0][0] as f32 >= 1e-6) +} + +/// Recorded grid coordinates become native top-to-bottom positions and half-width offsets. +fn shear_normalized(points: &[[f64; 2]]) -> Vec<[f64; 2]> { + points.iter().map(|p| [(p[1] - 1.0) / 127.0, p[0] / 64.0]).collect() +} + +/// Read every measured point; an invalid entry must not disappear in the native list parser. +fn shear_points(value: &D) -> Option> { + let D::List(values) = value else { return None }; + if !(2..=MAX_SHEAR_POINTS).contains(&values.len()) { + return None; + } + values + .iter() + .map(|v| { + let D::Descriptor(point) = v else { return None }; + if trimmed(&point.class_id) != "Pnt" { + return None; + } + let [(h, D::Double(x)), (v, D::Double(y))] = point.items.as_slice() else { return None }; + (trimmed(h) == "Hrzn" && trimmed(v) == "Vrtc" && x.is_finite() && y.is_finite()).then(|| [x.clamp(-64.0, 63.0), y.clamp(1.0, 128.0)]) + }) + .collect() +} + +/// Bound native/source JSON lists before conversion, without cloning the input array. +fn shear_json_points(value: &Value) -> Option> { + let points = value.as_array()?; + if !(2..=MAX_SHEAR_POINTS).contains(&points.len()) { + return None; + } + points + .iter() + .map(|point| { + let [x, y] = point.as_array()?.as_slice() else { return None }; + Some([x.as_f64()?, y.as_f64()?]) + }) + .collect() +} + +/// Import only the complete measured curve range, with dialog clamps and exact fractional save data. +pub(super) fn shear_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !only_keys(d, &["ShrP", "UndA", "ShrS", "ShrE"]) { + return None; + } + let grid = setting(d, "ShrP", shear_points, vec![[0.0, 1.0], [0.0, 128.0]])?; + let index = |v: &D| if let D::Integer(n) = v { Some(*n) } else { None }; + if setting(d, "ShrS", index, 0)? != 0 || setting(d, "ShrE", index, 1)? != i32::try_from(grid.len().checked_sub(1)?).ok()? { + return None; + } + let points = shear_normalized(&grid); + if !shear_curve(&points) { + return None; + } + let undefined = setting(d, "UndA", enum_of("UndA"), "WrpA".into())?; + let mut p = json!({"points": points, "undefinedAreas": lookup(WAVE_UNDEFINED, &undefined)?}); + // Dividing by 127 can lose a wire bit (for example Vrtc = 4.975609756097561). + // Preserve only that case, and reject stale metadata on export after a point edit. + if points.iter().zip(&grid).any(|(p, g)| [p[1] * 64.0, p[0] * 127.0 + 1.0] != *g) { + p["_atnShearPoints"] = json!(grid); + } + Some(step("filter.distort.shear", p)) + }; + mapped().unwrap_or(Mapped::Unsupported { harmless: false }) +} + +/// Export the effective native curve; settings without a faithful measured wire form are refused. +pub(super) fn shear_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["points", "amount", "undefinedAreas", "target", "_atnShearPoints"]) { + return None; + } + let points: Vec<[f64; 2]> = match p.get("points") { + None | Some(Value::Null) => { + let amount = match p.get("amount") { + None | Some(Value::Null) => 0.0, + Some(value) => value.as_f64().filter(|n| n.is_finite())?, + }; + // Match the native command's f32 conversion and amount clamp. + let amount = (amount as f32).clamp(-100.0, 100.0) / 100.0; + vec![[0.0, 0.0], [0.5, f64::from(amount)], [1.0, 0.0]] + } + Some(value) => shear_json_points(value)?, + }; + if !shear_curve(&points) { + return None; + } + let stored = p.get("_atnShearPoints"); + let grid: Vec<[f64; 2]> = match stored { + None => points.iter().map(|p| [p[1] * 64.0, p[0] * 127.0 + 1.0]).collect(), + Some(value) => shear_json_points(value)?, + }; + // Derived grid arithmetic may change an f64 wire bit without changing the f32 curve + // the native command consumes. Stored source coordinates still require exact agreement: + // even an edit below f32 precision must not revive stale recorded metadata. + let matches = grid.len() == points.len() + && grid.iter().zip(&points).all(|(grid, point)| { + let normalized = [(grid[1] - 1.0) / 127.0, grid[0] / 64.0]; + if stored.is_some() { normalized == *point } else { normalized.map(|v| v as f32) == point.map(|v| v as f32) } + }); + if grid.iter().any(|p| !(-64.0..=63.0).contains(&p[0]) || !(1.0..=128.0).contains(&p[1])) || !matches { + return None; + } + let end = i32::try_from(grid.len().checked_sub(1)?).ok()?; + let undefined = match p.get("undefinedAreas") { + None | Some(Value::Null) => "WrpA", + Some(value) => lookup_back(WAVE_UNDEFINED, value.as_str()?)?, + }; + let grid = grid.into_iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + Some(desc("null").with("ShrP", D::List(grid)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(end))) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 12852536ca..c0c442f443 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -1,7 +1,7 @@ use super::convert::clip_name; use super::descriptors::{ - PCT, PX, Target, ang, class, color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, get_text, jbool, jnum, jstr, long, path_text, - path_value, pct, px, target, target_ref, text, trimmed, + PCT, PT, PX, Target, ang, class, color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, get_text, jbool, jnum, jstr, long, + path_text, path_value, pct, px, target, target_ref, text, trimmed, }; use super::fields::{Mapped, none, step, steps}; use super::tables::{ALIGN, EXTENSION, INTENT, INTERPOLATION, MODES, PROFILES, PURGE, SAVE_FORMATS, lookup, lookup_back}; @@ -44,9 +44,12 @@ fn path_extension(path: &str) -> Option { } /// `save`: a plain Save, or a Save As (`As` format, `In` destination, `Cpy` As a Copy). -/// Only a destination that is a file of the recorded format imports: a folder (#5430), a missing -/// or unknown format or a mismatched extension stays a kept Photoshop step, so playback stops -/// before writing rather than saving over the played file (#7102). +/// A destination that is a file of the recorded format imports with its path. "Save in +/// directory" (`DIDr`, recorded when the suggested name was kept) imports as a folder step, so +/// playback saves each document under its own name in that folder (#5305). Anything else (a +/// missing or unknown format, a mismatched extension, a copy into a folder) stays a kept +/// Photoshop step, so playback stops before writing rather than saving over the played file +/// (#7102). pub(super) fn save_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; if d.get("As ").is_none() && d.get("In ").is_none() { @@ -56,11 +59,16 @@ pub(super) fn save_from(d: &Descriptor) -> Mapped { // with a format that isn't a descriptor) the step is kept. let Some(format) = get_obj(d, "As ") else { return unsupported }; let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; + let class = trimmed(&format.class_id); if get_bool(d, "DIDr") == Some(true) { - return unsupported; + // `In` is the folder alone. Save a Copy has no folder step: it stays kept. + let Some(ext) = SAVE_FORMATS.iter().find(|(c, _)| *c == class).and_then(|(_, exts)| exts.first()) else { return unsupported }; + if get_bool(d, "Cpy ") == Some(true) { + return unsupported; + } + return step("file.saveAs", json!({"folder": path, "extension": ext})); } let Some(ext) = path_extension(&path) else { return unsupported }; - let class = trimmed(&format.class_id); let Some((_, exts)) = SAVE_FORMATS.iter().find(|(c, _)| *c == class) else { return unsupported }; if !exts.contains(&ext.as_str()) { return unsupported; @@ -72,7 +80,15 @@ pub(super) fn save_to(id: &str, p: &Value) -> Option { if id == "file.save" { return Some(desc("null")); } - let path = jstr(p, "path")?; + let Some(path) = jstr(p, "path").filter(|v| !v.is_empty()) else { + // A Save As recorded with the document's own name: "save in directory" (#5305). + let (folder, ext) = (jstr(p, "folder")?, jstr(p, "extension")?.to_ascii_lowercase()); + if id != "file.saveAs" { + return None; + } + let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?; + return Some(desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(folder)).with("DIDr", D::Boolean(true))); + }; let ext = path_extension(path)?; let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?; let d = desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(path)); @@ -113,9 +129,14 @@ pub(super) fn conditional_to(_: &str, p: &Value) -> Option { Some(d) } -/// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. +/// Import Close's save policy: absent asks, Yes saves first, and No explicitly discards. pub(super) fn close_from(d: &Descriptor) -> Mapped { - if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } + match d.get("Svng") { + None => step("file.close", json!({"save": "ask"})), + Some(D::Enumerated { value, .. }) if value.is("Ys ") => steps(vec![("file.save", json!({})), ("file.close", json!({}))]), + Some(D::Enumerated { value, .. }) if value.is("N ") => none("file.close"), + _ => Mapped::Unsupported { harmless: false }, + } } /// A Close of an explicit document has no mapping here: written as a plain Close it would @@ -124,7 +145,12 @@ pub(super) fn close_to(_: &str, p: &Value) -> Option { if p.get("document").is_some_and(|d| !d.is_null()) { return None; } - Some(desc("null").with("Svng", enm("YsN ", "N "))) + match p.get("save").filter(|v| !v.is_null()) { + Some(v) if v == "ask" => Some(desc("null")), + Some(Value::Bool(false)) => Some(desc("null").with("Svng", enm("YsN ", "N "))), + Some(v) if v != "discard" => None, + _ => Some(desc("null").with("Svng", enm("YsN ", "N "))), + } } const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")]; @@ -134,7 +160,8 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; let Some(u) = get_obj(d, "Usng") else { return unsupported }; let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; - let mut p = json!({"path": path}); + // "Save in directory": `In` is only the folder, and each document saves under its own name. + let mut p = if get_bool(u, "DIDr") == Some(true) { json!({"folder": path}) } else { json!({"path": path}) }; if let Some(f) = get_enum(u, "Fmt ") { match lookup(WEB_FORMATS, &f) { Some(f) => p["format"] = json!(f), @@ -161,8 +188,16 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped { } pub(super) fn export_to(_: &str, p: &Value) -> Option { - let path = jstr(p, "path")?; + // A step recorded with the document's own name keeps only its folder (#5305). + let (path, in_folder) = match (jstr(p, "path").filter(|v| !v.is_empty()), jstr(p, "folder")) { + (Some(path), _) => (path, false), + (None, Some(folder)) => (folder, true), + (None, None) => return None, + }; let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?)); + if in_folder { + u = u.with("DIDr", D::Boolean(true)); + } for (key, param, default) in [ ("Intr", "interlaced", false), ("Trns", "transparency", true), @@ -192,7 +227,14 @@ pub(super) fn export_to(_: &str, p: &Value) -> Option { Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) } +/// Import layer-retaining conversions; keep unsupported destructive choices verbatim. pub(super) fn convert_mode_from(d: &Descriptor) -> Mapped { + let layer_options = d.get("Fltt").is_some() || d.get("Rstr").is_some(); + for key in ["Fltt", "Rstr"] { + if !matches!(d.get(key), None | Some(D::Boolean(false))) { + return Mapped::Unsupported { harmless: false }; + } + } if let Some(depth) = get_num(d, "Dpth") { return match depth as i64 { 8 => none("image.mode.bits8"), @@ -210,8 +252,14 @@ pub(super) fn convert_mode_from(d: &Descriptor) -> Mapped { _ => return Mapped::Unsupported { harmless: false }, }; if mode == "BtmM" { + if layer_options { + return Mapped::Unsupported { harmless: false }; + } return bitmap_from(options); } + if mode == "Mltc" && layer_options { + return Mapped::Unsupported { harmless: false }; + } // Bitmap to Grayscale records a size ratio; only 1 keeps the pixels as they are. if options.and_then(|o| get_num(o, "Rt ")).is_some_and(|r| (r - 1.0).abs() > 1e-9) { return Mapped::Unsupported { harmless: false }; @@ -248,27 +296,47 @@ fn bitmap_to(p: &Value) -> Option { Some(desc("null").with("T ", D::Descriptor(desc("BtmM").with("Mthd", enm("Mthd", method))))) } +/// Write mode classes and the native CMS conversion's explicit layer-retaining policy. pub(super) fn convert_mode_to(id: &str, p: &Value) -> Option { Some(match id { "image.mode.bits8" => desc("null").with("Dpth", long(8.0)), "image.mode.bits16" => desc("null").with("Dpth", long(16.0)), "image.mode.bits32" => desc("null").with("Dpth", long(32.0)), "image.mode.bitmap" => bitmap_to(p)?, - _ => desc("null").with("T ", enm("ClrS", lookup_back(MODES, id.strip_prefix("image.mode.")?)?)), + _ => { + let mode = id.strip_prefix("image.mode.")?; + let mut d = desc("null").with("T ", D::Class(class(lookup_back(MODES, mode)?))); + if mode != "multichannel" { + d = d.with("Fltt", D::Boolean(false)).with("Rstr", D::Boolean(false)); + } + d + } }) } +/// Import canvas dimensions, anchors and explicit extension-colour choices. pub(super) fn canvas_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); + let mut units = None; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { - Some(D::UnitFloat { unit: PX, value }) => { - p.insert(param.into(), value.round().into()); + Some(D::UnitFloat { unit, value }) if (*unit == PX || *unit == PCT) && value.is_finite() => { + if units.is_some_and(|previous| previous != *unit) || (*unit == PCT && *value <= 0.0) { + return Mapped::Unsupported { harmless: false }; + } + units = Some(*unit); + p.insert(param.into(), if *unit == PX { value.round() } else { *value }.into()); } Some(_) => return Mapped::Unsupported { harmless: false }, None => {} } } + if units == Some(PCT) { + if !matches!(d.get("Rltv"), None | Some(D::Boolean(false))) { + return Mapped::Unsupported { harmless: false }; + } + p.insert("units".into(), "percent".into()); + } if let Some(r) = get_bool(d, "Rltv") { p.insert("relative".into(), r.into()); } @@ -287,23 +355,42 @@ pub(super) fn canvas_size_from(d: &Descriptor) -> Mapped { _ => return Mapped::Unsupported { harmless: false }, }; p.insert("anchor".into(), anchor.into()); - if let Some(c) = get_enum(d, "canvasExtensionColorType") { + if d.get("canvasExtensionColorType").is_some() { + let Some(c) = get_enum(d, "canvasExtensionColorType") else { return Mapped::Unsupported { harmless: false } }; match lookup(EXTENSION, &c) { Some(c) => { p.insert("extensionColor".into(), c.into()); } - None => match get_obj(d, "canvasExtensionColor").and_then(color_hex) { + None if c == "Clr" || c == "Othr" => match get_obj(d, "canvasExtensionColor").and_then(color_hex) { Some(hex) => { p.insert("extensionColor".into(), hex.into()); } None => return Mapped::Unsupported { harmless: false }, }, + None => return Mapped::Unsupported { harmless: false }, } } step("image.canvasSize", Value::Object(p)) } +/// Write canvas geometry and canonical extension-colour identifiers. pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { + let units = match p.get("units") { + None => PX, + Some(Value::String(unit)) if unit == "px" => PX, + Some(Value::String(unit)) if unit == "percent" && jbool(p, "relative") != Some(true) => PCT, + _ => return None, + }; + if units == PCT { + for key in ["width", "height"] { + if p.get(key).is_some_and(|value| !value.is_null() && value.as_f64().is_none_or(|number| !number.is_finite() || number <= 0.0)) { + return None; + } + } + if jnum(p, "width").is_none() && jnum(p, "height").is_none() { + return None; + } + } let anchor = jstr(p, "anchor").unwrap_or("center"); let v = if anchor.starts_with("top") { "Top " @@ -324,32 +411,50 @@ pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { d = d.with("Rltv", D::Boolean(r)); } if let Some(w) = jnum(p, "width") { - d = d.with("Wdth", px(w)); + if units == PCT && w <= 0.0 { + return None; + } + d = d.with("Wdth", D::UnitFloat { unit: units, value: w }); } if let Some(hh) = jnum(p, "height") { - d = d.with("Hght", px(hh)); + if units == PCT && hh <= 0.0 { + return None; + } + d = d.with("Hght", D::UnitFloat { unit: units, value: hh }); } d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); if let Some(c) = jstr(p, "extensionColor") { d = match lookup_back(EXTENSION, c) { Some(e) => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", e)), - None => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", "Othr")).with("canvasExtensionColor", D::Descriptor(color_desc(c)?)), + None => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", "Clr ")).with("canvasExtensionColor", D::Descriptor(color_desc(c)?)), }; } Some(d) } +/// Keep recorded physical dimensions until playback can use the current document's dpi. +/// Import pixel or percentage dimensions and their recorded style-scaling option. pub(super) fn image_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); let mut percent: Option = None; + let mut physical = false; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { Some(D::UnitFloat { unit: PX, value }) => { + if physical { + return Mapped::Unsupported { harmless: false }; + } p.insert(param.into(), value.round().into()); } - // Percent scales both sides at playback; two different percentages, or physical - // units, need the document's size and resolution and stay unsupported. - Some(D::UnitFloat { unit: PCT, value }) => match percent { + Some(D::UnitFloat { unit: PT, value }) if value.is_finite() && *value > 0.0 => { + if percent.is_some() || (!physical && !p.is_empty()) { + return Mapped::Unsupported { harmless: false }; + } + physical = true; + p.insert(param.into(), (*value).into()); + } + // Percent scales both sides; retain the existing pixel/percent precedence. + Some(D::UnitFloat { unit: PCT, value }) if !physical => match percent { Some(other) if (other - value).abs() > 1e-9 => return Mapped::Unsupported { harmless: false }, _ => percent = Some(*value), }, @@ -357,6 +462,18 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { None => {} } } + if physical { + // A simultaneous dpi change or an ambiguous aspect choice needs its own reference form. + if d.get("Rslt").is_some() + || !matches!(d.get("Intr"), Some(D::Enumerated { type_id, .. }) if type_id.is("Intp")) + || !matches!(d.get("CnsP"), None | Some(D::Boolean(_))) + || (get_bool(d, "CnsP") == Some(false) && (p.contains_key("width") != p.contains_key("height"))) + || (get_bool(d, "CnsP") == Some(true) && p.contains_key("width") && p.contains_key("height")) + { + return Mapped::Unsupported { harmless: false }; + } + p.insert("units".into(), "pt".into()); + } if let Some(pc) = percent { p.insert("percent".into(), pc.into()); } @@ -380,10 +497,25 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { if p.is_empty() { return Mapped::Unsupported { harmless: false }; } + // An omitted recorded flag preserves style sizes; native calls still default to scaling. + let scale_styles = match d.get("scaleStyles") { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p.insert("scaleStyles".into(), scale_styles.into()); step("image.imageSize", Value::Object(p)) } +/// Export the recorded unit, avoiding sizes the native no-resample operation ignores. +/// Export effective style scaling with the existing Image Size dimensions. pub(super) fn image_size_to(_: &str, p: &Value) -> Option { + let unit = match p.get("units") { + None => PX, + Some(Value::String(unit)) if unit == "px" => PX, + Some(Value::String(unit)) if unit == "pt" => PT, + _ => return None, + }; // 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() @@ -399,6 +531,19 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { // 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 unit == PT && !resolution_only { + for key in ["width", "height"] { + if p.get(key).is_some_and(|value| !value.is_null() && value.as_f64().is_none_or(|number| !number.is_finite() || number <= 0.0)) { + return None; + } + } + if jnum(p, "width").is_none() && jnum(p, "height").is_none() { + return None; + } + } + if unit == PT && !resolution_only && ["resolution", "percent"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + return None; + } if resolution_only && jnum(p, "resolution").is_none() { // Nothing left to record: the step changes nothing. return None; @@ -407,17 +552,33 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); } if let Some(w) = jnum(p, "width").filter(|_| !resolution_only) { - d = d.with("Wdth", px(w)); + if unit == PT && w <= 0.0 { + return None; + } + d = d.with("Wdth", D::UnitFloat { unit, value: w }); } if let Some(h) = jnum(p, "height").filter(|_| !resolution_only) { - d = d.with("Hght", px(h)); + if unit == PT && h <= 0.0 { + return None; + } + d = d.with("Hght", D::UnitFloat { unit, value: h }); + } + if unit == PT && !resolution_only { + d = d.with("CnsP", D::Boolean(jnum(p, "width").is_none() || jnum(p, "height").is_none())); } if let Some(r) = jnum(p, "resolution") { d = d.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: r }); } - if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { + // Point sizes need an interpolation entry; omission uses the native bicubic default. + let resample = jstr(p, "resample").or_else(|| (unit == PT).then_some("bicubic")); + if let Some(i) = resample.filter(|r| *r != "none") { d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); } + // A recorded step without the key keeps style sizes, so only scaling needs the key: an + // Image Size step recorded without it saves back unchanged. + if jbool(p, "scaleStyles").unwrap_or(true) { + d = d.with("scaleStyles", D::Boolean(true)); + } Some(d) } @@ -475,11 +636,51 @@ pub(super) fn stop_to(_: &str, p: &Value) -> Option { } pub(super) fn paste_from(d: &Descriptor) -> Mapped { + // As is a class, not text or an enum; unknown forms must not fall back to raster paste. + if let Some(value) = d.get("As ") { + // `Pxel` is what an ordinary Paste and Paste in Place record; it keeps the ordinary route + // (layer recreation, mask and channel targets, in place). Only a vector form is explicit. + let mode = match value { + D::Class(value) if value.class_id.is("Pxel") => None, + D::Class(value) if value.class_id.is("Path") => Some("path"), + D::Class(value) if value.class_id.is("shapeClass") => Some("shape"), + _ => return Mapped::Unsupported { harmless: false }, + }; + let in_place = match d.get("inPlace") { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + if (in_place && mode.is_some()) || d.get("null").is_some() { + return Mapped::Unsupported { harmless: false }; + } + if d.get("AntA").is_some_and(|v| !matches!(v, D::Enumerated { type_id, value } if type_id.is("Annt") && value.is("Anno"))) { + return Mapped::Unsupported { harmless: false }; + } + if let Some(mode) = mode { + return step("edit.paste", json!({"as": mode})); + } + } none(if get_bool(d, "inPlace") == Some(true) { "edit.pasteSpecial.pasteInPlace" } else { "edit.paste" }) } -pub(super) fn paste_to(id: &str, _: &Value) -> Option { +pub(super) fn paste_to(id: &str, p: &Value) -> Option { let mut d = desc("null"); + if let Some(mode) = p.get("as") { + if id != "edit.paste" + || ["center", "inPlace", "clampToCanvas", "document", "layer", "layers", "path"].iter().any(|key| p.get(key).is_some()) + || p.get("target").is_some_and(|v| v.as_str() != Some("pixels")) + { + return None; + } + let id = match mode.as_str()? { + "pixels" => "Pxel", + "path" => "Path", + "shape" => "shapeClass", + _ => return None, + }; + d = d.with("As ", D::Class(class(id))); + } if id == "edit.pasteSpecial.pasteInPlace" { d = d.with("inPlace", D::Boolean(true)); } @@ -528,12 +729,28 @@ pub(super) fn reset_to(_: &str, _: &Value) -> Option { Some(desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("Clrs") }]))) } +/// Resolve an assigned or converted profile, preserving a supported rendering intent. +/// Import represented RGB targets and strict profile flags; keep other destination modes intact. pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { let name = get_text(d, "T ").or_else(|| get_text(d, "profile")); let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"profile": profile}); if id == "edit.convertToProfile" { - if let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) { + // All mapped PROFILES are RGB; the reference application records -1 for these RGB + // targets. Other values need their own target mapping. + if !matches!(d.get("sdwM"), None | Some(D::Integer(-1))) { + return Mapped::Unsupported { harmless: false }; + } + for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] { + let value = match d.get(key) { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p[param] = json!(value); + } + if d.get("Inte").is_some() { + let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) else { return Mapped::Unsupported { harmless: false } }; p["intent"] = json!(i); } if let Some(b) = get_bool(d, "MpBl") { @@ -543,14 +760,28 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { step(id, p) } +/// Export the supported profile and canonical rendering-intent identifier. +/// Export effective native flags explicitly, with the `sdwM` value recorded for RGB targets. pub(super) fn profile_to(id: &str, p: &Value) -> Option { let name = lookup_back(PROFILES, jstr(p, "profile")?)?; let mut d = desc("null").with("null", target_ref("Dcmn")); if id == "edit.convertToProfile" { + if p.get("sdwM").is_some() { + return None; + } d = d .with("T ", text(name)) .with("Inte", enm("Inte", lookup_back(INTENT, jstr(p, "intent").unwrap_or("relative"))?)) - .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))); + .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))) + .with("sdwM", D::Integer(-1)); + for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] { + let value = match p.get(param) { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + Some(_) => return None, + }; + d = d.with(key, D::Boolean(value)); + } } else { d = d.with("profile", text(name)); } diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs new file mode 100644 index 0000000000..215fab4a67 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -0,0 +1,775 @@ +//! Photoshop's Filter Gallery steps (#10592): the `GEfc` event with the effect's id in `GEfk` +//! and its settings, for all 47 effects. Each maps to its `filter.gallery.` command with +//! the keys, limits and defaults Photoshop measures (`docs/atn.md`, Group A); a stack of several +//! effects stays a Photoshop step. + +use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, jstr, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{AtnEvent, AtnItem, D, Descriptor, Map, Value, json}; +use photocraft_algo::{GalleryEffect, GalleryFilter}; + +/// One key of an effect, in recorded order. +enum G { + /// An integer clamped to `min..=max` on import, as Photoshop clamps it, and its default. + Int(&'static str, &'static str, i32, i32, i32), + /// An enumeration (Photoshop ↔ PhotoCraft values) and Photoshop's default value. An unknown + /// value keeps the step. + Enum(&'static str, &'static str, &'static [(&'static str, &'static str)], &'static str), + Bool(&'static str, &'static str, bool), + /// A colour object ↔ `#rrggbb` and Photoshop's default, which a step recorded without the + /// key loads with. A PhotoCraft step without it saves the effect's own default colour, the + /// one it plays with. + Color(&'static str, &'static str, &'static str), +} + +impl G { + fn key(&self) -> &'static str { + match self { + G::Int(k, ..) | G::Enum(k, ..) | G::Bool(k, ..) | G::Color(k, ..) => k, + } + } + + fn param(&self) -> &'static str { + match self { + G::Int(_, p, ..) | G::Enum(_, p, ..) | G::Bool(_, p, _) | G::Color(_, p, _) => p, + } + } +} + +/// The type Photoshop records an enumeration key with. Its terminology gives stroke direction +/// its own type (`strokeDirectionType`); every other gallery enumeration's type is its key (Glass +/// records `TxtT` / `TxtT`). +fn enum_type(key: &'static str) -> &'static str { + match key { + "SDir" => "StrD", + k => k, + } +} + +/// Photoshop's effect id (`GEfk`), the command, and the effect's keys in recorded order. +/// Generated from the event reference's Group A tables; Glass maps only the textures whose +/// codes the reference gives (Frosted, Canvas). +const GALLERY: &[(&str, &str, &[G])] = &[ + // Accented Edges + ( + "AccE", + "filter.gallery.accentedEdges", + &[G::Int("EdgW", "edgeWidth", 1, 14, 2), G::Int("EdgB", "edgeBrightness", 0, 50, 38), G::Int("Smth", "smoothness", 1, 15, 5)], + ), + // Angled Strokes + ( + "AngS", + "filter.gallery.angledStrokes", + &[G::Int("DrcB", "directionBalance", 0, 100, 50), G::Int("StrL", "strokeLength", 3, 50, 15), G::Int("Shrp", "sharpness", 0, 10, 3)], + ), + // Bas Relief + ( + "BsRl", + "filter.gallery.basRelief", + &[ + G::Int("Dtl ", "detail", 1, 15, 13), + G::Int("Smth", "smoothness", 1, 15, 3), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDBt", + ), + ], + ), + // Chalk Charcoal + ( + "ChlC", + "filter.gallery.chalkCharcoal", + &[G::Int("ChrA", "charcoalArea", 0, 20, 6), G::Int("ChlA", "chalkArea", 0, 20, 6), G::Int("StrP", "strokePressure", 0, 5, 1)], + ), + // Charcoal + ( + "Chrc", + "filter.gallery.charcoal", + &[G::Int("ChAm", "charcoalThickness", 1, 7, 1), G::Int("Dtl ", "detail", 0, 5, 5), G::Int("LgDr", "lightDarkBalance", 0, 100, 50)], + ), + // Chrome + ("Chrm", "filter.gallery.chrome", &[G::Int("Dtl ", "detail", 0, 10, 4), G::Int("Smth", "smoothness", 0, 10, 7)]), + // Colored Pencil + ( + "ClrP", + "filter.gallery.coloredPencil", + &[G::Int("Pncl", "pencilWidth", 1, 24, 4), G::Int("StrP", "strokePressure", 0, 15, 8), G::Int("PprB", "paperBrightness", 0, 50, 25)], + ), + // Conte Crayon + ( + "CntC", + "filter.gallery.conteCrayon", + &[ + G::Int("FrgL", "foregroundLevel", 1, 15, 11), + G::Int("BckL", "backgroundLevel", 1, 15, 7), + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 4), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDTp", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Craquelure + ( + "Crql", + "filter.gallery.craquelure", + &[G::Int("CrcS", "crackSpacing", 2, 100, 15), G::Int("CrcD", "crackDepth", 0, 10, 6), G::Int("CrcB", "crackBrightness", 0, 10, 9)], + ), + // Crosshatch + ( + "Crsh", + "filter.gallery.crosshatch", + &[G::Int("StrL", "strokeLength", 3, 50, 9), G::Int("Shrp", "sharpness", 0, 20, 6), G::Int("Strg", "strength", 1, 3, 1)], + ), + // Cutout + ( + "Ct", + "filter.gallery.cutout", + &[G::Int("NmbL", "numberOfLevels", 2, 8, 4), G::Int("EdgS", "edgeSimplicity", 0, 10, 4), G::Int("EdgF", "edgeFidelity", 1, 3, 2)], + ), + // Dark Strokes + ( + "DrkS", + "filter.gallery.darkStrokes", + &[G::Int("Blnc", "balance", 0, 10, 5), G::Int("BlcI", "blackIntensity", 0, 10, 6), G::Int("WhtI", "whiteIntensity", 0, 10, 2)], + ), + // Diffuse Glow + ( + "DfsG", + "filter.gallery.diffuseGlow", + &[G::Int("Grns", "graininess", 0, 10, 6), G::Int("GlwA", "glowAmount", 0, 20, 10), G::Int("ClrA", "clearAmount", 0, 20, 15)], + ), + // Dry Brush + ("DryB", "filter.gallery.dryBrush", &[G::Int("BrsS", "brushSize", 0, 10, 2), G::Int("BrsD", "brushDetail", 0, 10, 8), G::Int("Txtr", "texture", 1, 3, 1)]), + // Film Grain + ( + "FlmG", + "filter.gallery.filmGrain", + &[G::Int("Grn ", "grain", 0, 20, 4), G::Int("HghA", "highlightArea", 0, 20, 0), G::Int("Intn", "intensity", 0, 10, 10)], + ), + // Fresco + ("Frsc", "filter.gallery.fresco", &[G::Int("BrsS", "brushSize", 0, 10, 2), G::Int("BrsD", "brushDetail", 0, 10, 8), G::Int("Txtr", "texture", 1, 3, 1)]), + // Glass + ( + "Gls", + "filter.gallery.glass", + &[ + G::Int("Dstr", "distortion", 0, 20, 5), + G::Int("Smth", "smoothness", 1, 15, 3), + G::Enum("TxtT", "texture", &[("TxFr", "frosted"), ("TxCa", "canvas")], "TxFr"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Bool("InvT", "invert", false), + ], + ), + // Glowing Edges + ( + "GlwE", + "filter.gallery.glowingEdges", + &[G::Int("EdgW", "edgeWidth", 1, 14, 2), G::Int("EdgB", "edgeBrightness", 0, 20, 6), G::Int("Smth", "smoothness", 1, 15, 5)], + ), + // Graphic Pen + ( + "GraP", + "filter.gallery.graphicPen", + &[ + G::Int("StrL", "strokeLength", 1, 15, 15), + G::Int("LgDr", "lightDarkBalance", 0, 100, 50), + G::Enum("SDir", "strokeDirection", &[("SDHz", "horizontal"), ("SDLD", "leftDiagonal"), ("SDRD", "rightDiagonal"), ("SDVt", "vertical")], "SDRD"), + ], + ), + // Halftone Screen + ( + "HlfS", + "filter.gallery.halftonePattern", + &[ + G::Int("HlSz", "size", 1, 12, 1), + G::Int("Cntr", "contrast", 0, 50, 5), + G::Enum("ScrT", "patternType", &[("ScrC", "circle"), ("ScrD", "dot"), ("ScrL", "line")], "ScrD"), + ], + ), + // Ink Outlines + ( + "InkO", + "filter.gallery.inkOutlines", + &[G::Int("StrL", "strokeLength", 1, 50, 4), G::Int("DrkI", "darkIntensity", 0, 50, 20), G::Int("LghI", "lightIntensity", 0, 50, 10)], + ), + // Mosaic Tiles + ( + "MscT", + "filter.gallery.mosaicTiles", + &[G::Int("TlSz", "tileSize", 2, 100, 12), G::Int("GrtW", "groutWidth", 1, 15, 3), G::Int("LghG", "lightenGrout", 0, 10, 9)], + ), + // Neon Glow + ( + "NGlw", + "filter.gallery.neonGlow", + &[G::Int("Sz ", "glowSize", -24, 24, 5), G::Int("Brgh", "glowBrightness", 0, 50, 15), G::Color("Clr ", "glowColor", "#0000ff")], + ), + // Note Paper + ( + "NtPr", + "filter.gallery.notePaper", + &[G::Int("ImgB", "imageBalance", 0, 50, 25), G::Int("Grns", "graininess", 0, 20, 10), G::Int("Rlf ", "relief", 0, 25, 11)], + ), + // Ocean Ripple + ("OcnR", "filter.gallery.oceanRipple", &[G::Int("RplS", "rippleSize", 1, 15, 9), G::Int("RplM", "rippleMagnitude", 0, 20, 9)]), + // Paint Daubs + ( + "PntD", + "filter.gallery.paintDaubs", + &[ + G::Int("Sz ", "brushSize", 1, 50, 8), + G::Int("Shrp", "sharpness", 0, 40, 7), + G::Enum( + "BrsT", + "brushType", + &[("BrDR", "darkRough"), ("BrSm", "simple"), ("BrSp", "sparkle"), ("BrbW", "wideBlurry"), ("BrsL", "lightRough"), ("BrsW", "wideSharp")], + "BrSm", + ), + ], + ), + // Palette Knife + ( + "PltK", + "filter.gallery.paletteKnife", + &[G::Int("StrS", "strokeSize", 1, 50, 25), G::Int("StDt", "strokeDetail", 1, 3, 3), G::Int("Sftn", "softness", 0, 10, 0)], + ), + // Patchwork + ("Ptch", "filter.gallery.patchwork", &[G::Int("SqrS", "squareSize", 0, 10, 4), G::Int("Rlf ", "relief", 0, 25, 8)]), + // Photocopy + ("Phtc", "filter.gallery.photocopy", &[G::Int("Dtl ", "detail", 1, 24, 7), G::Int("Drkn", "darkness", 1, 50, 8)]), + // Plaster + ( + "Plst", + "filter.gallery.plaster", + &[ + G::Int("ImgB", "imageBalance", 0, 50, 20), + G::Int("Smth", "smoothness", 1, 15, 2), + G::Enum( + "LghP", + "light", + &[ + ("LPBL", "bottomLeft"), + ("LPBr", "bottomRight"), + ("LPBt", "bottom"), + ("LPLf", "left"), + ("LPRg", "right"), + ("LPTL", "topLeft"), + ("LPTR", "topRight"), + ("LPTp", "top"), + ], + "LPTp", + ), + ], + ), + // Plastic Wrap + ( + "PlsW", + "filter.gallery.plasticWrap", + &[G::Int("HghS", "highlightStrength", 0, 20, 15), G::Int("Dtl ", "detail", 1, 15, 9), G::Int("Smth", "smoothness", 1, 15, 7)], + ), + // Poster Edges + ( + "PstE", + "filter.gallery.posterEdges", + &[G::Int("EdgT", "edgeThickness", 0, 10, 2), G::Int("EdgI", "edgeIntensity", 0, 10, 1), G::Int("Pstr", "posterization", 0, 6, 2)], + ), + // Reticulation + ( + "Rtcl", + "filter.gallery.reticulation", + &[G::Int("Dnst", "density", 0, 50, 12), G::Int("BlcL", "foregroundLevel", 0, 50, 40), G::Int("WhtL", "backgroundLevel", 0, 50, 5)], + ), + // Rough Pastels + ( + "RghP", + "filter.gallery.roughPastels", + &[ + G::Int("StrL", "strokeLength", 0, 40, 6), + G::Int("StDt", "strokeDetail", 1, 20, 4), + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 20), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDBt", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Smudge Stick + ( + "SmdS", + "filter.gallery.smudgeStick", + &[G::Int("StrL", "strokeLength", 0, 10, 2), G::Int("HghA", "highlightArea", 0, 20, 0), G::Int("Intn", "intensity", 0, 10, 10)], + ), + // Spatter + ("Spt", "filter.gallery.spatter", &[G::Int("SprR", "sprayRadius", 0, 25, 10), G::Int("Smth", "smoothness", 1, 15, 5)]), + // Sponge + ( + "Spng", + "filter.gallery.sponge", + &[G::Int("BrsS", "brushSize", 0, 10, 2), G::Int("Dfnt", "definition", 0, 25, 12), G::Int("Smth", "smoothness", 1, 15, 5)], + ), + // Sprayed Strokes + ( + "SprS", + "filter.gallery.sprayedStrokes", + &[ + G::Int("StrL", "strokeLength", 0, 20, 12), + G::Int("SprR", "sprayRadius", 0, 25, 7), + G::Enum("SDir", "strokeDirection", &[("SDHz", "horizontal"), ("SDLD", "leftDiagonal"), ("SDRD", "rightDiagonal"), ("SDVt", "vertical")], "SDRD"), + ], + ), + // Stained Glass + ( + "StnG", + "filter.gallery.stainedGlass", + &[G::Int("ClSz", "cellSize", 2, 50, 10), G::Int("BrdT", "borderThickness", 1, 20, 4), G::Int("LghI", "lightIntensity", 0, 10, 3)], + ), + // Stamp + ("Stmp", "filter.gallery.stamp", &[G::Int("LgDr", "lightDarkBalance", 0, 50, 25), G::Int("Smth", "smoothness", 1, 50, 5)]), + // Sumie + ( + "Smie", + "filter.gallery.sumiE", + &[G::Int("StrW", "strokeWidth", 3, 15, 10), G::Int("StrP", "strokePressure", 0, 15, 2), G::Int("Cntr", "contrast", 0, 40, 16)], + ), + // Texturizer + ( + "Txtz", + "filter.gallery.texturizer", + &[ + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 4), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDTp", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Torn Edges + ( + "TrnE", + "filter.gallery.tornEdges", + &[G::Int("ImgB", "imageBalance", 0, 50, 25), G::Int("Smth", "smoothness", 1, 15, 11), G::Int("Cntr", "contrast", 1, 25, 17)], + ), + // Underpainting + ( + "Undr", + "filter.gallery.underpainting", + &[ + G::Int("BrsS", "brushSize", 0, 40, 6), + G::Int("TxtC", "textureCoverage", 0, 40, 16), + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 4), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDTp", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Water Paper + ( + "WtrP", + "filter.gallery.waterPaper", + &[G::Int("FbrL", "fiberLength", 3, 50, 15), G::Int("Brgh", "brightness", 0, 100, 60), G::Int("Cntr", "contrast", 0, 100, 80)], + ), + // Watercolor + ( + "Wtrc", + "filter.gallery.watercolor", + &[G::Int("BrsD", "brushDetail", 1, 14, 9), G::Int("ShdI", "shadowIntensity", 0, 10, 1), G::Int("Txtr", "texture", 1, 3, 1)], + ), + // Grain + ( + "Grn", + "filter.gallery.grain", + &[ + G::Int("Intn", "intensity", 0, 100, 40), + G::Int("Cntr", "contrast", 0, 100, 50), + G::Enum( + "Grnt", + "grainType", + &[ + ("GrCn", "contrasty"), + ("GrSf", "soft"), + ("GrSp", "speckle"), + ("GrSr", "sprinkles"), + ("GrSt", "stippled"), + ("GrnC", "clumped"), + ("GrnE", "enlarged"), + ("GrnH", "horizontal"), + ("GrnR", "regular"), + ("GrnV", "vertical"), + ], + "GrnR", + ), + ], + ), +]; + +pub(super) const GALLERY_IDS: &[&str] = &[ + "filter.gallery.accentedEdges", + "filter.gallery.angledStrokes", + "filter.gallery.basRelief", + "filter.gallery.chalkCharcoal", + "filter.gallery.charcoal", + "filter.gallery.chrome", + "filter.gallery.coloredPencil", + "filter.gallery.conteCrayon", + "filter.gallery.craquelure", + "filter.gallery.crosshatch", + "filter.gallery.cutout", + "filter.gallery.darkStrokes", + "filter.gallery.diffuseGlow", + "filter.gallery.dryBrush", + "filter.gallery.filmGrain", + "filter.gallery.fresco", + "filter.gallery.glass", + "filter.gallery.glowingEdges", + "filter.gallery.graphicPen", + "filter.gallery.halftonePattern", + "filter.gallery.inkOutlines", + "filter.gallery.mosaicTiles", + "filter.gallery.neonGlow", + "filter.gallery.notePaper", + "filter.gallery.oceanRipple", + "filter.gallery.paintDaubs", + "filter.gallery.paletteKnife", + "filter.gallery.patchwork", + "filter.gallery.photocopy", + "filter.gallery.plaster", + "filter.gallery.plasticWrap", + "filter.gallery.posterEdges", + "filter.gallery.reticulation", + "filter.gallery.roughPastels", + "filter.gallery.smudgeStick", + "filter.gallery.spatter", + "filter.gallery.sponge", + "filter.gallery.sprayedStrokes", + "filter.gallery.stainedGlass", + "filter.gallery.stamp", + "filter.gallery.sumiE", + "filter.gallery.texturizer", + "filter.gallery.tornEdges", + "filter.gallery.underpainting", + "filter.gallery.waterPaper", + "filter.gallery.watercolor", + "filter.gallery.grain", +]; + +/// Each effect's own event by string ID, the form newer Photoshop versions record it in +/// (`docs/atn.md`, Group A). Mosaic Tiles (`MscT`) has none. +const EVENT_NAMES: &[(&str, &str)] = &[ + ("AccE", "accentedEdges"), + ("AngS", "angledStrokes"), + ("BsRl", "basRelief"), + ("ChlC", "chalkCharcoal"), + ("Chrc", "charcoal"), + ("Chrm", "chrome"), + ("ClrP", "coloredPencil"), + ("CntC", "conteCrayon"), + ("Crql", "craquelure"), + ("Crsh", "crosshatch"), + ("Ct", "cutout"), + ("DrkS", "darkStrokes"), + ("DfsG", "diffuseGlow"), + ("DryB", "dryBrush"), + ("FlmG", "filmGrain"), + ("Frsc", "fresco"), + ("Gls", "glass"), + ("GlwE", "glowingEdges"), + ("GraP", "graphicPen"), + ("HlfS", "halftoneScreen"), + ("InkO", "inkOutlines"), + ("NGlw", "neonGlow"), + ("NtPr", "notePaper"), + ("OcnR", "oceanRipple"), + ("PntD", "paintDaubs"), + ("PltK", "paletteKnife"), + ("Ptch", "patchwork"), + ("Phtc", "photocopy"), + ("Plst", "plaster"), + ("PlsW", "plasticWrap"), + ("PstE", "posterEdges"), + ("Rtcl", "reticulation"), + ("RghP", "roughPastels"), + ("SmdS", "smudgeStick"), + ("Spt", "spatter"), + ("Spng", "sponge"), + ("SprS", "sprayedStrokes"), + ("StnG", "stainedGlass"), + ("Stmp", "stamp"), + ("Smie", "sumie"), + ("Txtz", "texturizer"), + ("TrnE", "tornEdges"), + ("Undr", "underpainting"), + ("WtrP", "waterPaper"), + ("Wtrc", "watercolor"), + ("Grn", "grain"), +]; + +/// Whether `event` is a Filter Gallery effect's own event: its 4-char code, or its string ID. +/// The reference has each effect recorded as its own event too (Filter › Artistic › Cutout…), +/// carrying the same `GEfk` and settings. +pub(super) fn is_effect(event: &AtnEvent) -> bool { + effect_command(event).is_some() +} + +/// The command of the effect whose own event `event` is (`AccE` or `accentedEdges` → +/// `filter.gallery.accentedEdges`). +fn effect_command(event: &AtnEvent) -> Option<&'static str> { + let id = event.id(); + let code = match event { + AtnEvent::Code(_) => GALLERY.iter().map(|(c, _, _)| *c).find(|c| *c == id.trim_end()), + AtnEvent::Name(_) => EVENT_NAMES.iter().find(|(_, n)| *n == id).map(|(c, _)| *c), + }?; + GALLERY.iter().find(|(c, _, _)| *c == code).map(|(_, cmd, _)| *cmd) +} + +/// The hidden param a step loaded from an effect's own event carries, so it saves back as that +/// event (and panel name) rather than as a `GEfc` Filter Gallery step. Playback strips it +/// (`actions_cmds::playback_params`), so the command, the journal and smart filters never see it. +pub(crate) const RECORDED_EVENT: &str = "_atnEvent"; + +/// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`). +/// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft. +pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { + if !is_effect(&item.event) { + return; + } + let [(id, Value::Object(p))] = steps else { return }; + if effect_command(&item.event) != Some(id.as_str()) { + return; + } + let kind = match item.event { + AtnEvent::Code(_) => "code", + AtnEvent::Name(_) => "name", + }; + // `dictionary` keeps the panel name's bytes as recorded (a GBK or Shift_JIS name would not + // survive a round trip through text); `name` is for reading. + let dictionary = photocraft_paint::tile::b64_encode(&item.dictionary); + p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary})); +} + +/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves +/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec)> { + let r = params.get(RECORDED_EVENT)?; + let event = jstr(r, "event")?; + let event = match jstr(r, "eventKind")? { + "code" => AtnEvent::Code(event.as_bytes().try_into().ok()?), + "name" => AtnEvent::name(event), + _ => return None, + }; + let name = match jstr(r, "dictionary").and_then(photocraft_paint::tile::b64_decode) { + Some(bytes) => bytes, + None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(), + }; + (effect_command(&event) == Some(id)).then_some((event, name)) +} + +/// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's +/// default for what it leaves out. An unknown effect, value or key keeps the Photoshop step. +pub(super) fn gallery_from(d: &Descriptor) -> Mapped { + effect_step(d).unwrap_or(Mapped::Unsupported { harmless: false }) +} + +fn effect_step(d: &Descriptor) -> Option { + let effect = get_enum(d, "GEfk")?; + let (_, id, keys) = GALLERY.iter().find(|(code, _, _)| *code == effect)?; + let known: Vec<&str> = keys.iter().map(|k| k.key().trim_end()).chain(["GEfk"]).collect(); + if !d.items.iter().all(|(k, _)| known.contains(&trimmed(k).as_str())) { + return None; + } + // A key recorded twice would play and save one of its values: keep the step instead. + let names: Vec = d.items.iter().map(|(k, _)| trimmed(k)).collect(); + if names.iter().enumerate().any(|(i, n)| names[..i].contains(n)) { + return None; + } + let mut p = Map::new(); + for k in keys.iter() { + // Only a missing key takes Photoshop's default: a key that is there with the wrong type + // or a non-finite number keeps the step (`docs/atn.md`) rather than playing a default. + let present = d.get(k.key()).is_some(); + let v = match k { + G::Int(key, _, min, max, default) => { + Value::from(if present { get_num(d, key)?.round().clamp(f64::from(*min), f64::from(*max)) as i32 } else { *default }) + } + G::Enum(key, _, table, default) => Value::from(lookup(table, &if present { get_enum(d, key)? } else { (*default).to_string() })?), + G::Bool(key, _, default) => Value::from(if present { get_bool(d, key)? } else { *default }), + G::Color(key, _, default) => { + if present { + Value::from(color_hex(get_obj(d, key)?)?) + } else { + Value::from(*default) + } + } + }; + p.insert(k.param().into(), v); + } + Some(step(id, Value::Object(p))) +} + +/// The colour `id`'s effect plays with when the step names none (Neon Glow's glow colour), as +/// Photoshop's 0–255 components, unrounded, so the step saves the colour it plays with. +fn default_color(id: &str) -> Option { + let filter = id.strip_prefix("filter.gallery.").and_then(GalleryFilter::from_key)?; + let [r, g, b, _] = GalleryEffect::new(filter).color; + let c = |v: f32| D::Double(f64::from(v) * 255.0); + Some(desc("RGBC").with("Rd ", c(r)).with("Grn ", c(g)).with("Bl ", c(b))) +} + +/// Every key in recorded order, Photoshop's default (which is the command's) for what the step +/// leaves out. A setting the event has no key for (the Sketch effects' pinned colours, a mask +/// target), a fraction in an integer key, an integer outside its setting's range or a value +/// without a Photoshop code refuses the step. +pub(super) fn gallery_to(id: &str, p: &Value) -> Option { + let (code, _, keys) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; + let allowed: Vec<&str> = keys.iter().map(G::param).chain(["target", RECORDED_EVENT]).collect(); + if !pixel_target(p) || !p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) { + return None; + } + let mut d = desc("null").with("GEfk", enm("GEft", code)); + for k in keys.iter() { + let v = p.get(k.param()).filter(|v| !v.is_null()); + let out = match k { + G::Int(_, _, min, max, default) => { + let n = v.map_or(Some(f64::from(*default)), Value::as_f64)?; + // Outside the setting's range, Photoshop would play it clamped and it would load + // back as another value (`edgeWidth` 99 as 14). + if n.fract() != 0.0 || !(f64::from(*min)..=f64::from(*max)).contains(&n) { + return None; + } + D::Integer(n as i32) + } + G::Enum(key, _, table, default) => enm(enum_type(key), v.map_or(Some(*default), |v| v.as_str().and_then(|s| lookup_back(table, s)))?), + G::Bool(_, _, default) => D::Boolean(v.map_or(Some(*default), Value::as_bool)?), + G::Color(..) => D::Descriptor(match v { + Some(v) => color_desc(v.as_str()?)?, + None => default_color(id)?, + }), + }; + d = d.with(k.key(), out); + } + Some(d) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_algo::GalleryParamKind; + + fn params(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } + } + + /// Every effect recorded with nothing but `GEfk` loads with Photoshop's defaults, saves + /// back, and loads again as the same step (#10592). + #[test] + fn every_gallery_effect_with_its_defaults_round_trips() { + assert_eq!(GALLERY.len(), GALLERY_IDS.len()); + for (code, id, _) in GALLERY { + assert!(GALLERY_IDS.contains(id), "{id} is not in GALLERY_IDS"); + let (got, first) = params(gallery_from(&desc("null").with("GEfk", enm("GEft", code)))); + assert_eq!(got, *id, "{code}"); + let back = gallery_to(id, &first).unwrap_or_else(|| panic!("{id} {first} does not export")); + assert_eq!(params(gallery_from(&back)), (id.to_string(), first.clone()), "{id}"); + } + } + + /// Each effect's integer limits and defaults, enumeration default and Boolean default are + /// the command's own (`GalleryFilter`'s schema), and every setting of the schema has a key. + #[test] + fn every_gallery_effect_matches_the_commands_schema() { + for (code, id, keys) in GALLERY { + let schema = GalleryFilter::from_key(id).unwrap_or_else(|| panic!("{id} has no schema")).params(); + for p in &schema { + assert!(keys.iter().any(|k| k.param() == p.key), "{id}: {} has no key", p.key); + } + for k in keys.iter() { + let kind = schema.iter().find(|p| p.key == k.param()).map(|p| &p.kind); + match (k, kind) { + (G::Int(_, param, min, max, default), Some(GalleryParamKind::Range { min: lo, max: hi, default: d })) => { + assert_eq!((*min as f32, *max as f32, *default as f32), (*lo, *hi, *d), "{id} {param}"); + } + (G::Enum(_, param, table, default), Some(GalleryParamKind::Choice(choices))) => { + assert_eq!(lookup(table, default), choices.first().copied(), "{id} {param}"); + assert!(table.iter().all(|(_, ours)| choices.contains(ours)), "{id} {param}"); + } + (G::Bool(_, param, default), Some(GalleryParamKind::Bool(d))) => assert_eq!(default, d, "{id} {param}"), + (G::Color(..), None) => {} + (k, kind) => panic!("{code} {id} {}: {kind:?}", k.param()), + } + } + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_gradient.rs b/crates/engine/src/cmd/atn_cmds/steps_gradient.rs new file mode 100644 index 0000000000..8f58d03fed --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_gradient.rs @@ -0,0 +1,297 @@ +//! Classic Gradient recordings (#10710), using the existing pixel-paint command. +//! +//! Presets have centred midpoints and RGB byte colours. Other gradient forms stay +//! kept steps rather than losing settings on playback. Source descriptors are +//! retained only after decoding to the same executable parameters. + +use super::convert::normalise; +use super::descriptors::{desc, enm, pct, px, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{blend_label, blend_ps}; +use super::{D, Descriptor, Value, json}; + +const ORIGIN: &str = "__atnClassicGradient"; +const MAX_STOPS: usize = 64; +const MAX_DESCRIPTOR: usize = 64 * 1024; +const STYLES: &[(&str, &str)] = &[("Lnr ", "linear"), ("Rdl ", "radial"), ("Angl", "angle"), ("Rflc", "reflected"), ("Dmnd", "diamond")]; +const METHODS: &[(&str, &str)] = &[("Gcls", "classic"), ("Perc", "perceptual"), ("Lnr ", "linear"), ("Smoo", "smooth")]; + +/// Extra or repeated fields must not disappear from the operation being played. +fn shape(d: &Descriptor, class: &str, keys: &[&str]) -> Option<()> { + if !d.class_id.is(class) || d.name.0.len() > 1024 || d.items.len() > keys.len() { + return None; + } + for (i, (key, _)) in d.items.iter().enumerate() { + if !keys.iter().any(|k| key.is(k)) || d.items.iter().take(i).any(|(other, _)| other.as_bytes() == key.as_bytes()) { + return None; + } + } + Some(()) +} + +fn object<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> { + match d.get(key)? { + D::Descriptor(d) => Some(d), + _ => None, + } +} + +fn number(v: &D, unit: Option<&[u8; 4]>) -> Option { + let n = match (v, unit) { + (D::Integer(n), None) => f64::from(*n), + (D::Double(n), None) => *n, + (D::UnitFloat { unit: actual, value }, Some(expected)) if actual == expected => *value, + _ => return None, + }; + (n.is_finite() && (n as f32).is_finite()).then_some(n) +} + +fn enumeration<'a>(v: &'a D, ty: &str) -> Option<&'a super::Id> { + match v { + D::Enumerated { type_id, value } if type_id.is(ty) => Some(value), + _ => None, + } +} + +fn flag(d: &Descriptor, key: &str, default: bool) -> Option { + match d.get(key) { + None => Some(default), + Some(D::Boolean(v)) => Some(*v), + _ => None, + } +} + +fn point(d: &Descriptor, key: &str) -> Option { + let p = object(d, key)?; + shape(p, "Pnt ", &["Hrzn", "Vrtc"])?; + Some(json!([number(p.get("Hrzn")?, Some(b"#Pxl"))?, number(p.get("Vrtc")?, Some(b"#Pxl"))?])) +} + +fn list<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [D]> { + match d.get(key)? { + D::List(v) if (2..=MAX_STOPS).contains(&v.len()) => Some(v), + _ => None, + } +} + +fn location(d: &Descriptor, previous: &mut f64) -> Option { + let D::Integer(position) = d.get("Lctn")? else { return None }; + let n = f64::from(*position) / 4096.0; + if !(0.0..=1.0).contains(&n) || n < *previous || !matches!(d.get("Mdpn"), Some(D::Integer(50))) { + return None; + } + *previous = n; + Some(n) +} + +/// Decode every accepted setting; no source metadata is ever executed. +fn params(d: &Descriptor) -> Option { + // Recordings use the event class; PhotoCraft's canonical exports use null. + let class = if d.class_id.is("Grdn") { "Grdn" } else { "null" }; + shape(d, class, &["From", "T ", "Opct", "Md ", "Type", "Rvrs", "Dthr", "UsMs", "gradientsInterpolationMethod", "Grad"])?; + let style = enumeration(d.get("Type")?, "GrdT")?; + let style = STYLES.iter().find(|(ps, _)| style.is(ps))?.1; + // No omitted-method default was established by the recorded-tools reference. + let method = enumeration(d.get("gradientsInterpolationMethod")?, "gradientInterpolationMethodType")?; + let method = METHODS.iter().find(|(ps, _)| method.is(ps))?.1; + let opacity = match d.get("Opct") { + None => 100.0, + Some(v) => number(v, Some(b"#Prc"))?, + }; + if !(1.0..=100.0).contains(&opacity) { + return None; + } + let mode = match d.get("Md ") { + None => "Normal", + Some(v) => blend_label(&trimmed(enumeration(v, "BlnM")?))?, + }; + if mode == "Pass Through" { + return None; + } + let g = object(d, "Grad")?; + shape(g, "Grdn", &["Nm ", "GrdF", "Intr", "Clrs", "Trns"])?; + let D::Text(name) = g.get("Nm ")? else { return None }; + if name.0.len() > 1024 || !enumeration(g.get("GrdF")?, "GrdF")?.is("CstS") { + return None; + } + let smoothness = number(g.get("Intr")?, None)? * 100.0 / 4096.0; + if !(0.0..=100.0).contains(&smoothness) { + return None; + } + let mut stops = Vec::new(); + let mut previous = -1.0; + for s in list(g, "Clrs")? { + let D::Descriptor(s) = s else { return None }; + shape(s, "Clrt", &["Clr ", "Type", "Lctn", "Mdpn"])?; + if !enumeration(s.get("Type")?, "Clry")?.is("UsrS") { + return None; + } + let c = object(s, "Clr ")?; + shape(c, "RGBC", &["Rd ", "Grn ", "Bl "])?; + let rgb = [number(c.get("Rd ")?, None)?, number(c.get("Grn ")?, None)?, number(c.get("Bl ")?, None)?]; + if !rgb.iter().all(|v| (0.0..=255.0).contains(v) && v.fract() == 0.0) { + return None; + } + stops.push(json!([location(s, &mut previous)?, format!("#{:02x}{:02x}{:02x}", rgb[0] as u8, rgb[1] as u8, rgb[2] as u8)])); + } + let use_transparency = flag(d, "UsMs", false)?; + let mut transparency = Vec::new(); + previous = -1.0; + for s in list(g, "Trns")? { + let D::Descriptor(s) = s else { return None }; + shape(s, "TrnS", &["Opct", "Lctn", "Mdpn"])?; + let opacity = number(s.get("Opct")?, Some(b"#Prc"))?; + if !(0.0..=100.0).contains(&opacity) { + return None; + } + let t = location(s, &mut previous)?; + if use_transparency { + transparency.push(json!([t, opacity])); + } + } + Some(json!({"from":point(d,"From")?,"to":point(d,"T ")?,"style":style,"opacity":opacity,"mode":mode, + "reverse":flag(d,"Rvrs",false)?,"dither":flag(d,"Dthr",false)?,"stops":stops, + "transparency":transparency,"method":method,"smoothness":smoothness})) +} + +pub(super) fn from(d: &Descriptor) -> Mapped { + let mapped = || { + let mut p = params(d)?; + let bytes = d.to_bytes(); + if bytes.len() > MAX_DESCRIPTOR { + return None; + } + p[ORIGIN] = json!(crate::paint::tile::b64_encode(&bytes)); + Some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("paint.gradient", p)) +} + +fn finite(v: &Value, lo: f64, hi: f64) -> Option { + v.as_f64().filter(|v| v.is_finite() && (lo..=hi).contains(v)) +} + +fn native_point(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array().filter(|a| a.len() == 2)?; + let coordinate = |i: usize| a.get(i)?.as_f64().filter(|v| v.is_finite() && (*v as f32).is_finite()); + Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(coordinate(0)?)).with("Vrtc", px(coordinate(1)?)))) +} + +fn native_location(a: &[Value], previous: &mut f64) -> Option { + let t = finite(a.first()?, 0.0, 1.0)? * 4096.0; + if t.fract() != 0.0 || t < *previous { + return None; + } + *previous = t; + Some(t as i32) +} + +/// Canonical edited/native form. Never substitute current presets or drop a target. +fn canonical(p: &Value) -> Option { + let style = STYLES.iter().find(|(_, native)| Some(*native) == p.get("style").map_or(Some("linear"), Value::as_str))?.0; + let method = METHODS.iter().find(|(_, native)| Some(*native) == p.get("method").and_then(Value::as_str))?.0; + let smoothness = finite(p.get("smoothness").unwrap_or(&json!(100)), 0.0, 100.0)?; + let mut colors = Vec::new(); + let mut previous = -1.0; + for a in p.get("stops")?.as_array().filter(|a| (2..=MAX_STOPS).contains(&a.len()))? { + let a = a.as_array().filter(|a| a.len() == 2)?; + let text = a.get(1)?.as_str()?.strip_prefix('#')?; + if text.len() != 6 || !text.is_ascii() { + return None; + } + let rgb = u32::from_str_radix(text, 16).ok()?; + let c = desc("RGBC") + .with("Rd ", D::Double(f64::from((rgb >> 16) & 255))) + .with("Grn ", D::Double(f64::from((rgb >> 8) & 255))) + .with("Bl ", D::Double(f64::from(rgb & 255))); + colors.push(D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(c)) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", D::Integer(native_location(a, &mut previous)?)) + .with("Mdpn", D::Integer(50)), + )); + } + let mut transparency = Vec::new(); + previous = -1.0; + let opacity_stops = p.get("transparency")?.as_array().filter(|a| a.len() <= MAX_STOPS)?; + if opacity_stops.len() == 1 { + return None; + } + for a in opacity_stops { + let a = a.as_array().filter(|a| a.len() == 2)?; + transparency.push(D::Descriptor( + desc("TrnS") + .with("Opct", pct(finite(a.get(1)?, 0.0, 100.0)?)) + .with("Lctn", D::Integer(native_location(a, &mut previous)?)) + .with("Mdpn", D::Integer(50)), + )); + } + let use_transparency = !transparency.is_empty(); + if !use_transparency { + transparency = [0, 4096] + .into_iter() + .map(|t| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50)))) + .collect(); + } + let gradient = desc("Grdn") + .with("Nm ", super::descriptors::text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(smoothness * 4096.0 / 100.0)) + .with("Clrs", D::List(colors)) + .with("Trns", D::List(transparency)); + let mut d = desc("null").with("From", native_point(p, "from")?).with("T ", native_point(p, "to")?); + let opacity = finite(p.get("opacity").unwrap_or(&json!(100)), 1.0, 100.0)?; + if opacity != 100.0 { + d = d.with("Opct", pct(opacity)); + } + let mode = blend_ps(p.get("mode").map_or(Some("Normal"), Value::as_str)?)?; + if mode == "passThrough" || mode == "PsTh" { + return None; + } + if mode != "Nrml" { + d = d.with("Md ", enm("BlnM", mode)); + } + d = d.with("Type", enm("GrdT", style)); + for (param, key, default) in [("reverse", "Rvrs", false), ("dither", "Dthr", true)] { + let value = p.get(param).map_or(Some(default), Value::as_bool)?; + if value { + d = d.with(key, D::Boolean(true)); + } + } + if use_transparency { + d = d.with("UsMs", D::Boolean(true)); + } + Some(d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", method)).with("Grad", D::Descriptor(gradient))) +} + +pub(super) fn to(_: &str, p: &Value) -> Option { + let values = p.as_object()?; + if !pixel_target(p) + || !values.keys().all(|k| { + ["from", "to", "style", "opacity", "mode", "reverse", "dither", "stops", "transparency", "method", "smoothness", "target", ORIGIN] + .contains(&k.as_str()) + }) + { + return None; + } + if let Some(origin) = p.get(ORIGIN) { + let text = origin.as_str().filter(|v| v.len() <= MAX_DESCRIPTOR * 2)?; + let bytes = crate::paint::tile::b64_decode(text)?; + if bytes.len() > MAX_DESCRIPTOR { + return None; + } + let d = Descriptor::from_bytes(&bytes).ok()?; + let mut expected = params(&d)?; + let mut actual = p.clone(); + actual.as_object_mut()?.remove(ORIGIN); + actual.as_object_mut()?.remove("target"); + normalise(&mut expected); + normalise(&mut actual); + if expected == actual { + return Some(d); + } + } + canonical(p) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index a1b808bf34..149f1939fb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,12 +1,27 @@ use super::descriptors::{ - PX, Target, ang, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, pct, px, - ref_class, target, trimmed, + PX, Target, ang, boolean, class, code, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, + layer_target, long, num, pct, px, ref_class, target, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back}; use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; +/// The reference point of a Free Transform (`QCSt`): `Qcsa` the centre (average), `Qcs0`..`Qcs3` +/// the corners clockwise from the top left, `Qcs4`..`Qcs7` the sides top, right, bottom, left. +/// `Qcsi` (independent) and unknown codes have no mapping and stay kept steps. +const TRANSFORM_REFERENCE: &[(&str, &str)] = &[ + ("Qcsa", "center"), + ("Qcs0", "topLeft"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), + ("Qcs5", "right"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "bottomRight"), +]; + /// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, /// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything /// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. @@ -40,6 +55,13 @@ pub(super) 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; + if let Some(value) = d.get("FTcs") { + let D::Enumerated { type_id, value } = value else { return unsupported }; + let Some(reference) = lookup(TRANSFORM_REFERENCE, &trimmed(value)).filter(|_| type_id.is("QCSt")) else { return unsupported }; + if reference != "center" && !geometry_only { + p.insert("reference".into(), reference.into()); + } + } // The resampling applies to a selection as much as to a layer (#7515). // Photoshop's bicubic variants are kept (the transforms render them as Bicubic, and they export // back unchanged); Automatic and Preserve Details become the nearest bicubic choice. Dropping @@ -74,17 +96,15 @@ pub(super) fn transform_from(d: &Descriptor) -> Mapped { } } +/// Export relative transforms with frame anchors, refusing unsupported geometry and targets. pub(super) fn transform_to(id: &str, p: &Value) -> Option { 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). + // Skew and warp still have no descriptor mapping (#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)) { + if nonzero("skewX") || nonzero("skewY") || ["warp", "style", "mesh", "grid"].iter().any(|k| given(k)) { return None; } let target = match id { @@ -95,7 +115,11 @@ pub(super) fn transform_to(id: &str, p: &Value) -> Option { "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), _ => return None, }; - let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", "Qcsa")); + let reference = match p.get("reference").filter(|v| !v.is_null()) { + Some(v) => lookup_back(TRANSFORM_REFERENCE, v.as_str()?)?, + None => "Qcsa", + }; + let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", reference)); d = d.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0))))); for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY")] { if let Some(v) = jnum(p, param) { @@ -249,6 +273,193 @@ pub(super) fn apply_image_to(_: &str, p: &Value) -> Option { pub(super) const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")]; +/// Camera Raw Filter's scalar sliders. Every one is an integer in recorded steps except `Ex12` +/// and `ShpR` (doubles); the layout is from 62 recorded steps in 14 sets (#6745) and matches the +/// verified PSD Smart Filter mapping (`photocraft-io`'s `camera_raw_map.rs`). +const CAMERA_RAW: &[F] = &[ + F("Temp", "temperature", Kind::Long), + F("Tint", "tint", Kind::Long), + F("Ex12", "exposure", Kind::Dbl), + F("Cr12", "contrast", Kind::Long), + F("Hi12", "highlights", Kind::Long), + F("Sh12", "shadows", Kind::Long), + F("Wh12", "whites", Kind::Long), + F("Bk12", "blacks", Kind::Long), + F("CrTx", "texture", Kind::Long), + F("Cl12", "clarity", Kind::Long), + F("Dhze", "dehaze", Kind::Long), + F("Vibr", "vibrance", Kind::Long), + F("Strt", "saturation", Kind::Long), + F("PC_H", "curveHighlights", Kind::Long), + F("PC_L", "curveLights", Kind::Long), + F("PC_D", "curveDarks", Kind::Long), + F("PC_S", "curveShadows", Kind::Long), + F("CgBl", "gradeBlending", Kind::Long), + F("STB ", "gradeBalance", Kind::Long), + F("Shrp", "sharpenAmount", Kind::Long), + F("ShpR", "sharpenRadius", Kind::Dbl), + F("ShpD", "sharpenDetail", Kind::Long), + F("ShpM", "sharpenMasking", Kind::Long), + F("LNR ", "noiseLuminance", Kind::Long), + F("LNRD", "noiseLuminanceDetail", Kind::Long), + F("CNR ", "noiseColor", Kind::Long), + F("CNRD", "noiseColorDetail", Kind::Long), + F("GRNA", "grainAmount", Kind::Long), + F("GRNS", "grainSize", Kind::Long), + F("GRNF", "grainRoughness", Kind::Long), + F("PCVA", "vignetteAmount", Kind::Long), + F("PCVM", "vignetteMidpoint", Kind::Long), + F("PCVR", "vignetteRoundness", Kind::Long), + F("PCVF", "vignetteFeather", Kind::Long), + F("PCVH", "vignetteHighlights", Kind::Long), +]; + +/// The range a Camera Raw Filter descriptor key's value can take, as `filter.cameraRaw` and the +/// PSD Smart Filter mapping (`photocraft-io`'s `camera_raw_map.rs`) accept it. +fn camera_raw_range(key: &str) -> std::ops::RangeInclusive { + match key { + "Ex12" => -5.0..=5.0, + "ShpR" => 0.5..=3.0, + "Shrp" => 0.0..=150.0, + "STSH" | "STHH" | "CgMH" | "CgGH" => 0.0..=360.0, + "PC_1" | "PC_2" | "PC_3" | "ShpD" | "ShpM" | "LNR " | "LNRD" | "CNR " | "CNRD" | "STSS" | "STHS" | "CgMS" | "CgGS" | "CgBl" | "GRNA" | "GRNS" + | "GRNF" | "PCVM" | "PCVF" | "PCVH" => 0.0..=100.0, + _ => -100.0..=100.0, + } +} + +/// The hidden param an imported Camera Raw Filter step keeps its recorded descriptor in, so +/// Save Actions writes the step back unchanged while its settings are the ones it imported with. +const CAMERA_RAW_DESCRIPTOR: &str = "__atnDescriptor"; + +/// The colour grading wheels' keys, the tone curve splits and the vignette style. +const CAMERA_RAW_GRADES: &[&str] = + &["STSH", "STSS", "CgSL", "CgMH", "CgMS", "CgML", "STHH", "STHS", "CgHL", "CgGH", "CgGS", "CgGL", "PC_1", "PC_2", "PC_3", "PCVS"]; + +/// A recorded Camera Raw Filter step (`Adobe Camera Raw Filter`) as `filter.cameraRaw` (#6745). +/// Settings the command has no counterpart for refuse the step rather than replaying a different +/// look: the Calibration panel, a white balance other than Custom, the controls the PSD mapping +/// keeps only at their neutral values, and any key outside this table (lens, geometry, profile, +/// masks…), as the PSD mapping's `known_key` does, and a process version the PSD mapping hasn't +/// verified (Camera Raw renders each version differently). +pub(super) fn camera_raw_from(d: &Descriptor) -> Mapped { + const CALIBRATION: &[&str] = &["RHue", "RSat", "GHue", "GSat", "BHue", "BSat", "BlkB"]; + const NEUTRAL_ONLY: &[(&str, f64)] = + &[("LNRC", 0.0), ("CNRS", 50.0), ("MDis", 0.0), ("VigA", 0.0), ("VigM", 50.0), ("crfs", 100.0), ("TMMs", 0.0), ("PGTM", 0.0), ("RGBt", 0.0)]; + const OTHER: &[&str] = &["CrVe", "PrVe", "PrVN", "WBal", "Crv ", "CrvR", "CrvG", "CrvB"]; + let known = |k: &str| { + (k.len() == 4 && ["HA_", "SA_", "LA_"].iter().any(|p| k.starts_with(p)) && ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().any(|c| k.ends_with(c))) + || CAMERA_RAW.iter().any(|F(f, ..)| *f == k) + || CAMERA_RAW_GRADES.contains(&k) + || OTHER.contains(&k) + || CALIBRATION.contains(&k) + || NEUTRAL_ONLY.iter().any(|(n, _)| *n == k) + }; + // An empty step (no descriptor) would play as a no-op, and a key the table doesn't know may + // be active processing (lens, geometry, profile, masks) that playback would leave out. + if d.items.is_empty() || d.items.iter().any(|(k, _)| !std::str::from_utf8(k.as_bytes()).is_ok_and(known)) { + return Mapped::Unsupported { harmless: false }; + } + let version = (get_num(d, "PrVN"), get_num(d, "PrVe")); + let verified = [(5.0, 184_549_376.0), (6.0, 251_920_384.0)].iter().any(|(n, v)| version == (Some(*n), Some(*v))); + if !verified || !matches!(d.get("CrVe"), Some(D::Text(_))) { + return Mapped::Unsupported { harmless: false }; + } + let active = |k: &str, neutral: f64| d.get(k).is_some() && get_num(d, k) != Some(neutral); + let custom_wb = d.get("WBal").is_none() || get_enum(d, "WBal").as_deref() == Some("Cst"); + if !custom_wb || CALIBRATION.iter().any(|k| active(k, 0.0)) || NEUTRAL_ONLY.iter().any(|(k, n)| active(k, *n)) { + return Mapped::Unsupported { harmless: false }; + } + // A value that is recorded but isn't a number, or lies outside the slider's range, can't + // replay: `fields_from` and `nums` would drop it (or read it as 0), and playback would clamp + // it, so the step would play a different look. The ranges are the PSD mapping's. + let unreadable = |k: &str| match (d.get(k), get_num(d, k)) { + (None, _) => false, + (Some(_), None) => true, + (Some(_), Some(v)) => k != "PCVS" && !(v.is_finite() && camera_raw_range(k).contains(&v)), + }; + let grouped = ["HA_", "SA_", "LA_"].iter().flat_map(|p| ["R", "O", "Y", "G", "A", "B", "P", "M"].map(|c| format!("{p}{c}"))); + if CAMERA_RAW.iter().any(|F(k, ..)| unreadable(k)) || grouped.into_iter().any(|k| unreadable(&k)) || CAMERA_RAW_GRADES.iter().any(|k| unreadable(k)) { + return Mapped::Unsupported { harmless: false }; + } + let Some(mut m) = fields_from(d, CAMERA_RAW) else { return Mapped::Unsupported { harmless: false } }; + let nums = |keys: &[&str]| -> Option> { + let v: Vec> = keys.iter().map(|k| get_num(d, k)).collect(); + // Absent members keep the command's defaults only when the whole group is absent. + if v.iter().all(Option::is_none) { None } else { Some(v.into_iter().map(|n| Value::from(n.unwrap_or(0.0))).collect()) } + }; + // The splits are one setting: all three, or none (the defaults); a partial set can't replay, + // nor can splits outside 0..=100 or out of order, which would build a different curve. + match (get_num(d, "PC_1"), get_num(d, "PC_2"), get_num(d, "PC_3")) { + (Some(a), Some(b), Some(c)) if (0.0..=100.0).contains(&a) && a < b && b < c && c <= 100.0 => { + m.insert("curveSplits".into(), json!([a, b, c])); + } + (Some(_), Some(_), Some(_)) => return Mapped::Unsupported { harmless: false }, + _ if ["PC_1", "PC_2", "PC_3"].iter().any(|k| d.get(k).is_some()) => return Mapped::Unsupported { harmless: false }, + _ => {} + } + for (prefix, param) in [("HA_", "hslHue"), ("SA_", "hslSat"), ("LA_", "hslLum")] { + let keys: Vec = ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().map(|c| format!("{prefix}{c}")).collect(); + if let Some(v) = nums(&keys.iter().map(String::as_str).collect::>()) { + m.insert(param.into(), Value::Array(v)); + } + } + for (keys, param) in [ + (["STSH", "STSS", "CgSL"], "gradeShadows"), + (["CgMH", "CgMS", "CgML"], "gradeMidtones"), + (["STHH", "STHS", "CgHL"], "gradeHighlights"), + (["CgGH", "CgGS", "CgGL"], "gradeGlobal"), + ] { + if let Some(v) = nums(&keys) { + m.insert(param.into(), json!({"hue": v[0], "sat": v[1], "lum": v[2]})); + } + } + match get_num(d, "PCVS") { + None => {} + Some(n) => match [(1.0, "highlightPriority"), (2.0, "colorPriority"), (3.0, "paintOverlay")].iter().find(|(k, _)| *k == n) { + Some((_, style)) => { + m.insert("vignetteStyle".into(), json!(style)); + } + None => return Mapped::Unsupported { harmless: false }, + }, + } + // Point curves: flat `[in0, out0, in1, out1, …]` lists on 0..255, as the command's pairs. + for (key, param) in [("Crv ", "pointCurve"), ("CrvR", "pointCurveRed"), ("CrvG", "pointCurveGreen"), ("CrvB", "pointCurveBlue")] { + if d.get(key).is_none() { + continue; + } + let Some(list) = get_list(d, key) else { return Mapped::Unsupported { harmless: false } }; + let Some(flat) = list.iter().map(num).collect::>>() else { return Mapped::Unsupported { harmless: false } }; + // Odd lengths, and curves `filter.cameraRaw` refuses (one point, out of range, inputs not + // increasing), stay Photoshop steps rather than failing on playback. + let points: Vec<[f32; 2]> = flat.as_chunks::<2>().0.iter().map(|[a, b]| [*a as f32, *b as f32]).collect(); + if flat.len() % 2 != 0 || photocraft_algo::camera_raw::validate_curve(&points).is_err() { + return Mapped::Unsupported { harmless: false }; + } + let identity = flat == [0.0, 0.0, 255.0, 255.0]; + if !identity { + m.insert(param.into(), Value::Array(flat.as_chunks::<2>().0.iter().map(|[a, b]| json!([a, b])).collect())); + } + } + m.insert(CAMERA_RAW_DESCRIPTOR.into(), json!(photocraft_paint::tile::b64_encode(&d.to_bytes()))); + step(crate::lens_cmds::RAW, Value::Object(m)) +} + +/// An imported Camera Raw Filter step back as its recorded descriptor, unchanged. Only while the +/// params are still what that descriptor imports as: an edited step, or one PhotoCraft recorded, +/// has no verified Photoshop form and Save Actions refuses it. +pub(super) fn camera_raw_to(_: &str, p: &Value) -> Option { + let d = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, CAMERA_RAW_DESCRIPTOR)?)?).ok()?; + let Mapped::Steps(steps) = camera_raw_from(&d) else { return None }; + let [(_, imported)] = steps.as_slice() else { return None }; + let (mut a, mut b) = (imported.clone(), p.clone()); + for v in [&mut a, &mut b] { + super::convert::normalise(v); + v.as_object_mut()?.remove(CAMERA_RAW_DESCRIPTOR); + } + (a == b).then_some(d) +} + /// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool /// edits (Quick Selection, Brush, Lasso…), which no descriptor holds. pub(super) fn has_refine_strokes(p: &Value) -> bool { @@ -286,26 +497,11 @@ const REFINE_OUTPUT: &[(&str, &str)] = &[ ("refineEdgeOutputNewLayerWithMask", "newLayerWithMask"), ("refineEdgeOutputNewDocument", "newDocument"), ("refineEdgeOutputNewDocumentWithMask", "newDocumentWithMask"), + // Newer Photoshop's name for Output To: Selection. + ("selectionOutputToSelection", "selection"), ]; -/// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the -/// command, and its fields. A stack of several effects stays unsupported. -const GALLERY: &[(&str, &str, &[F])] = &[("Spt", "filter.gallery.spatter", &[F("SprR", "sprayRadius", Kind::Long), F("Smth", "smoothness", Kind::Long)])]; - -pub(super) const GALLERY_IDS: &[&str] = &["filter.gallery.spatter"]; - -pub(super) fn gallery_from(d: &Descriptor) -> Mapped { - let Some(effect) = get_enum(d, "GEfk") else { return Mapped::Unsupported { harmless: false } }; - match GALLERY.iter().find(|(code, _, _)| *code == effect).and_then(|(_, id, fields)| Some((*id, fields_from(d, fields)?))) { - Some((id, p)) => step(id, Value::Object(p)), - None => Mapped::Unsupported { harmless: false }, - } -} - -pub(super) fn gallery_to(id: &str, p: &Value) -> Option { - let (code, _, fields) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; - fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code))) -} +pub(super) use super::steps_gallery::{GALLERY_IDS, gallery_from, gallery_to}; /// 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. @@ -451,3 +647,239 @@ pub(super) fn radial_blur_to(_: &str, p: &Value) -> Option { } fields_to(p, RADIAL_BLUR_FIELDS, desc("null")) } + +// ------------------------------------------------------------------ newer features (#10610) + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, + } +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// Focus Area (`focusMask`) and Remove Background (`removeBackground`): Photoshop runs both with +/// an empty descriptor. A step that carries settings keeps them as a Photoshop step. +pub(super) fn empty_from(d: &Descriptor, id: &str) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Only a step with no settings writes the empty descriptor; any setting would be lost. +pub(super) fn empty_to(_: &str, p: &Value) -> Option { + sets_only(p, &[]).then(|| desc("null")) +} + +/// Select Sky (`selectSky`): `sampleAllLayers` only, false when the step leaves it out +/// (the command's own default is true). +pub(super) fn select_sky_from(d: &Descriptor) -> Mapped { + match setting(d, "sampleAllLayers", boolean, false) { + Some(all) if only_keys(d, &["sampleAllLayers"]) => step("select.sky", json!({"sampleAllLayers": all})), + _ => Mapped::Unsupported { harmless: false }, + } +} + +/// The event has no mode, threshold or softness: only their replay values survive. +pub(super) fn select_sky_to(_: &str, p: &Value) -> Option { + let at = |k: &str, v: Value| p.get(k).is_none_or(|x| x.is_null() || *x == v || (x.as_f64().is_some() && x.as_f64() == v.as_f64())); + if !sets_only(p, &["sampleAllLayers", "mode", "threshold", "softness"]) + || !at("mode", json!("replace")) + || !at("threshold", json!(50)) + || !at("softness", json!(50)) + { + return None; + } + let all = match p.get("sampleAllLayers") { + None | Some(Value::Null) => true, + Some(v) => v.as_bool()?, + }; + Some(desc("null").with("sampleAllLayers", D::Boolean(all))) +} + +/// A plain double, as Photoshop records Oil Paint's settings and the step saves them back: a +/// value with a unit, or an integer, would save back as another value. +fn double(v: &D) -> Option { + match v { + D::Double(n) if n.is_finite() => Some(*n), + _ => None, + } +} + +/// An integer, as Photoshop records Oil Paint's light direction and the step saves it back. +fn integer(v: &D) -> Option { + match v { + D::Integer(n) => Some(*n), + _ => None, + } +} + +/// Oil Paint's keys in recorded order: (key, param, Photoshop's default, the command's default). +const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ + ("stylization", "stylization", 4.0, 4.0), + ("cleanliness", "cleanliness", 2.299_999_952_316_28, 5.0), + ("brushScale", "scale", 0.800_000_011_920_93, 1.0), + ("microBrush", "bristleDetail", 10.0, 5.0), +]; + +/// `filter.stylize.oilPaint`'s ranges (the light direction's is the command's -180..180). Photoshop +/// clamps a recorded value outside them when the step plays, so the import clamps it too; export +/// refuses one, as the dialog cannot hold it. +const OIL_PAINT_LIMITS: &[(&str, f64, f64)] = &[ + ("stylization", 0.1, 10.0), + ("cleanliness", 0.0, 10.0), + ("scale", 0.1, 10.0), + ("bristleDetail", 0.0, 10.0), + ("angle", -180.0, 180.0), + ("shine", 0.0, 10.0), +]; + +/// Whether `n` is within `filter.stylize.oilPaint`'s range for `param` (a param without one is). +fn in_oil_paint_limits(param: &str, n: f64) -> bool { + OIL_PAINT_LIMITS.iter().all(|(p, min, max)| *p != param || (*min..=*max).contains(&n)) +} + +/// `n` clamped to `filter.stylize.oilPaint`'s range for `param` (unchanged for a param without one). +fn oil_paint_clamped(param: &str, n: f64) -> f64 { + OIL_PAINT_LIMITS.iter().find(|(p, ..)| *p == param).map_or(n, |(_, min, max)| n.clamp(*min, *max)) +} + +/// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the +/// command's for cleanliness, scale, bristle detail and shine, so they are written out. A +/// setting of another number type or with a unit keeps the Photoshop step; one outside the +/// command's ranges is clamped, as Photoshop clamps it. +pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped { + match oil_paint_params(d) { + Some(p) => step("filter.stylize.oilPaint", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn oil_paint_params(d: &Descriptor) -> Option> { + let keys = ["lightingOn", "stylization", "cleanliness", "brushScale", "microBrush", "LghD", "specularity"]; + if !only_keys(d, &keys) { + return None; + } + let mut p = Map::new(); + p.insert("lighting".into(), setting(d, "lightingOn", boolean, true)?.into()); + for (key, param, ps, _) in OIL_PAINT { + p.insert((*param).into(), oil_paint_clamped(param, setting(d, key, double, *ps)?).into()); + } + p.insert("angle".into(), oil_paint_clamped("angle", setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?).into()); + p.insert("shine".into(), oil_paint_clamped("shine", setting(d, "specularity", double, 1.299_999_952_316_28)?).into()); + Some(p) +} + +/// Every key in recorded order, the command's defaults for what the step leaves out. The light +/// direction is an integer: a fractional angle would replay rounded, so the step is refused, as +/// is a setting outside the command's ranges. +pub(super) fn oil_paint_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["target", "lighting", "stylization", "cleanliness", "scale", "bristleDetail", "angle", "shine"]) { + return None; + } + let num = |k: &str, default: f64| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => v.as_f64().filter(|n| n.is_finite() && in_oil_paint_limits(k, *n)), + }; + let lighting = match p.get("lighting") { + None | Some(Value::Null) => true, + Some(v) => v.as_bool()?, + }; + let angle = num("angle", -60.0)?; + if angle.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&angle) { + return None; + } + let mut d = desc("null").with("lightingOn", D::Boolean(lighting)); + for (key, param, _, ours) in OIL_PAINT { + d = d.with(key, D::Double(num(param, *ours)?)); + } + Some(d.with("LghD", D::Integer(angle as i32)).with("specularity", D::Double(num("shine", 1.0)?))) +} + +/// A unit double in `unit`: the step saves `Amnt` in `#Prc` and the radius in `#Pxl`, so a +/// value in another unit, or without one, would save back as another value. +fn in_unit(unit: [u8; 4]) -> impl Fn(&D) -> Option { + move |v| match v { + D::UnitFloat { unit: u, value } if *u == unit && value.is_finite() => Some(*value), + _ => None, + } +} + +/// `filter.sharpen.smartSharpen`'s amount, radius and noise reduction limits, in +/// `smart_sharpen_params`' key order. Photoshop clamps a recorded value outside them when the step +/// plays, so the import clamps it too; export refuses one, as the dialog cannot hold it. +const SMART_SHARPEN_LIMITS: &[(&str, f64, f64)] = &[("amount", 1.0, 500.0), ("radius", 0.1, 64.0), ("reduceNoise", 0.0, 100.0)]; + +/// Smart Sharpen (`smartSharpen`) in the one form the command plays, the same form PSD smart +/// filters map (`photocraft-io`'s `smart_map`): the newer filter (`useLegacy` false) removing a +/// Gaussian blur (`blur` `GsnB`), both recorded. The legacy filter (Photoshop's default, which +/// removes Lens Blur), Lens Blur, Motion Blur, a preset other than Custom or any other key, or an +/// amount, radius or noise reduction in another unit, keeps it as a Photoshop step. A missing +/// amount, radius or noise reduction takes Photoshop's default (200 %, 1 px, 10 %). +pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { + match smart_sharpen_params(d) { + Some(p) => step("filter.sharpen.smartSharpen", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +/// A `blurType` enumeration's value (`enumerated` alone does not check the type). +fn blur_type(v: &D) -> Option { + match v { + D::Enumerated { type_id, .. } if trimmed(type_id) == "blurType" => enumerated(v), + _ => None, + } +} + +fn smart_sharpen_params(d: &Descriptor) -> Option> { + let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "noiseReduction", "blur"]; + let gaussian = only_keys(d, &keys) + && setting(d, "presetKind", enumerated, "presetKindCustom".into())? == "presetKindCustom" + && !setting(d, "useLegacy", boolean, true)? + && setting(d, "blur", blur_type, "lensBlur".into())? == "GsnB"; + if !gaussian { + return None; + } + let mut p = Map::new(); + let read = [("Amnt", *b"#Prc", 200.0), ("Rds", PX, 1.0), ("noiseReduction", *b"#Prc", 10.0)]; + for ((key, unit, default), (param, min, max)) in read.into_iter().zip(SMART_SHARPEN_LIMITS) { + let n = setting(d, key, in_unit(unit), default)?.clamp(*min, *max); + p.insert((*param).into(), n.into()); + } + Some(p) +} + +/// The form `smart_sharpen_params` reads, with the keys in the order Photoshop records them (as +/// recorded steps carry them, `presetKind` Custom first). The command's defaults fill what the +/// step leaves out. +pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) { + return None; + } + let num = |k: &str, default: f64| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => v.as_f64().filter(|n| SMART_SHARPEN_LIMITS.iter().any(|(param, min, max)| *param == k && (*min..=*max).contains(n))), + }; + Some( + desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("useLegacy", D::Boolean(false)) + .with("Amnt", pct(num("amount", 100.0)?)) + .with("Rds ", px(num("radius", 1.0)?)) + .with("noiseReduction", pct(num("reduceNoise", 10.0)?)) + .with("blur", enm("blurType", "GsnB")), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 9dbdad579b..ed1d9caabb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -1,27 +1,41 @@ use super::convert::clip_name; use super::descriptors::{ PT, PX, Target, ang, class, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, get_text, jbool, jnum, jstr, layer_ref, - layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, + layer_target, long, name_ref, pct, px, recorded_layer_ref, recorded_layer_target, ref_class, target, target_ref, text, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_to, none, step, steps}; use super::steps_make::is_tool_class; -use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; + +use super::tables::{CHANNELS, FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; +/// Import recorded targets while retaining stack and opening-order references for playback. +/// Resolve a recorded selection while retaining layer names, Background properties and modifiers. +/// Translate supported selection targets, retaining unknown references as Photoshop steps. pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // The layer selection's modifier, whichever way the step names the layer. + let with_modifier = |params| { + let mut p = Value::Object(params); + 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) + }; match (ref_class(r).as_deref(), target(r)) { - (Some("Lyr"), Target::Name(n)) => { - let mut p = json!({"layer": n}); - 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) + (Some("Lyr"), Target::Name(_) | Target::Index(_) | Target::Ordinal(_)) => { + let Some(params) = recorded_layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + with_modifier(params) + } + // A Cmd-click recorded on a layer property (e.g. the Background, #10702). + (Some("Lyr"), Target::Property(_)) => { + let Some(p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + with_modifier(p) } // The Channels panel: the active layer's mask, or back to the composite (`RGB`, `CMYK`…). (Some("Chnl"), Target::Ordinal(o)) => match o.as_str() { @@ -29,24 +43,20 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { "RGB" | "CMYK" | "Lab" | "Gry" => step("channel.target", json!({"channel": "composite"})), // Re-selecting the channel that is already targeted changes nothing. "Trgt" => Mapped::Unsupported { harmless: true }, + _ if r.len() == 1 + && d.get("selectionModifier").is_none() + // Shift-click (`Extd` true) adds the channel to the selection: kept verbatim. + && !matches!(d.get("Extd"), Some(v) if v != &D::Boolean(false)) + && matches!(r.first(), Some(ReferenceItem::Enumerated { type_id, .. }) if type_id.is("Chnl")) => + { + // The exact code only: the legacy `Lmnc` would save back as `Lght`. + lookup(CHANNELS, &o).map_or(Mapped::Unsupported { harmless: false }, |channel| step("channel.target", json!({"channel": {"color": channel}}))) + } _ => Mapped::Unsupported { harmless: false }, }, // History › Step Backward / Step Forward. (Some("HstS"), Target::Ordinal(o)) if o == "Prvs" => none("edit.undo"), (Some("HstS"), Target::Ordinal(o)) if o == "Nxt" => none("edit.redo"), - (Some("Lyr"), Target::Ordinal(o)) => { - // Shift+Alt+[ / ] add the next layer to the selection instead of replacing it. - let add = get_enum(d, "selectionModifier").as_deref() == Some("addToSelection"); - match (o.as_str(), add) { - ("Frwr", false) => none("layer.selectAbove"), - ("Frwr", true) => none("layer.addAboveToSelection"), - ("Bckw", false) => none("layer.selectBelow"), - ("Bckw", true) => none("layer.addBelowToSelection"), - ("Frnt", _) => none("layer.selectTop"), - ("Back", _) => none("layer.selectBottom"), - _ => Mapped::Unsupported { harmless: false }, - } - } (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) { @@ -54,12 +64,16 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { 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})), + // opened document and 1 for the next, independently of dragged tabs (#7103). + (Some("Dcmn"), Target::Offset(o)) if o != 0 => step("document.activate", json!({"offset": o})), + (Some("Dcmn"), Target::Index(index)) if r.len() == 1 => step("document.activate", json!({"document": {"index": index}})), _ => Mapped::Unsupported { harmless: false }, } } +/// Write supported selection/navigation references without replacing their kind with a name. +/// Write the supported layer, channel, document and history selection references. +/// Encode selections whose target and selection mode can be represented faithfully in ATN. pub(super) 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) }]); @@ -68,8 +82,13 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { 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)); + let reference = if let Some(name) = p.get("name") { + name_ref("Lyr ", name.as_str()?) + } else { + p.get("layer").filter(|v| !v.is_null())?; + recorded_layer_ref(p).or_else(|| layer_ref(p))? + }; + let mut d = desc("null").with("null", reference); match jstr(p, "mode") { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), @@ -80,10 +99,27 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { d.with("MkVs", D::Boolean(false)) } "channel.target" => { - let v = match jstr(p, "channel") { - Some("mask") => "Msk ", - None | Some("composite") => "RGB ", - Some(_) => return None, + if p.get("colors").is_some() + || p.get("channel").and_then(|channel| channel.get("colors")).is_some() + || p.get("extend").is_some_and(|v| v != &Value::Bool(false)) + { + return None; + } + let v = match p.get("channel") { + None => "RGB ", + Some(Value::String(channel)) => match channel.as_str() { + "mask" => "Msk ", + "composite" => "RGB ", + _ => return None, + }, + Some(channel) => { + let color = channel.get("color")?.as_str()?.to_ascii_lowercase(); + // Gry imports as portable composite, unlike a mode-specific Gray object. + if color == "gray" { + return None; + } + lookup_back(CHANNELS, &color)? + } }; desc("null").with("null", enumerated("Chnl", "Chnl", v)).with("MkVs", D::Boolean(false)) } @@ -95,9 +131,21 @@ pub(super) 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")), - // An absolute tab index has no Photoshop equivalent; an offset is what Photoshop records. + "document.activate" if p.get("document").is_some_and(Value::is_object) => { + if p.get("offset").is_some_and(|v| !v.is_null()) { + return None; + } + let selector = p.get("document")?.as_object().filter(|selector| selector.len() == 1)?; + let index = u32::try_from(selector.get("index")?.as_u64()?).ok()?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Index { class: class("Dcmn"), index }])) + } + // A native numeric document selects a tab; the typed index above selects opening order. "document.activate" if p.get("document").is_none_or(Value::is_null) => { let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; + // The reference application rejects zero, though the native command permits that no-op. + if offset == 0 { + return None; + } desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])) } _ => { @@ -117,6 +165,15 @@ pub(super) fn delete_from(d: &Descriptor) -> Mapped { // The targeted channel of a layer is its mask (what Photoshop records after making an // adjustment layer over a selection and discarding the mask). Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"), + // The mask Photoshop adds to a new fill or adjustment layer, or to a smart object's filters. + Some(r) if ref_class(r).as_deref() == Some("Chnl") => match target(r) { + Target::Ordinal(o) if o == "Msk" => none("layer.layerMask.delete"), + // Photoshop adds an empty filter mask with the first smart filter; PhotoCraft doesn't. + Target::Ordinal(o) if o == "filterMask" => step("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), + _ => Mapped::Unsupported { harmless: false }, + }, + // Timeline frames: PhotoCraft has no frame animation, so there is nothing to delete. + Some(r) if ref_class(r).as_deref() == Some("animationClass") => Mapped::Unsupported { harmless: true }, Some(_) => Mapped::Unsupported { harmless: false }, } } @@ -131,6 +188,8 @@ pub(super) fn quick_mask_ref() -> D { pub(super) fn clear_from(d: &Descriptor) -> Mapped { match get_ref(d, "null").map(target) { Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})), + // History panel menu › Clear History. + Some(Target::Property(p)) if p == "HsSt" => none("history.clear"), None => none("edit.clear"), _ => Mapped::Unsupported { harmless: false }, } @@ -139,6 +198,13 @@ pub(super) fn clear_from(d: &Descriptor) -> Mapped { pub(super) fn clear_to(id: &str, p: &Value) -> Option { match id { "select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())), + "history.clear" => Some(desc("null").with( + "null", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: code("HsSt") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") }, + ]), + )), _ => None, } } @@ -148,17 +214,29 @@ pub(super) fn delete_to(id: &str, p: &Value) -> Option { "edit.clear" => desc("null"), "layer.delete" => desc("null").with("null", layer_ref(p)?), "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), + "layer.smartFilter.deleteFilterMask" => desc("null") + .with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: Id::new("filterMask") }])), _ => return None, }) } -/// `hide` / `show` take a list of references (or one). +/// Import visibility references; a single Show with TglO is the reversible Show Only command. pub(super) fn visibility_from(d: &Descriptor, id: &str) -> Mapped { let refs: Vec<&[ReferenceItem]> = match d.get("null") { - Some(D::List(l)) => l.iter().filter_map(|v| if let D::Reference(r) = v { Some(r.as_slice()) } else { None }).collect(), + Some(D::List(l)) => { + let Some(refs) = l.iter().map(|v| if let D::Reference(r) = v { Some(r.as_slice()) } else { None }).collect() else { + return Mapped::Unsupported { harmless: false }; + }; + refs + } Some(D::Reference(r)) => vec![r.as_slice()], _ => return Mapped::Unsupported { harmless: false }, }; + let id = match d.get("TglO") { + Some(D::Boolean(true)) if id == "layer.showLayers" && refs.len() == 1 => "layer.showOnly", + Some(D::Boolean(false)) | None => id, + _ => return Mapped::Unsupported { harmless: false }, + }; let mut v = Vec::new(); for r in refs { match layer_target(r) { @@ -169,10 +247,16 @@ pub(super) fn visibility_from(d: &Descriptor, id: &str) -> Mapped { if v.is_empty() { Mapped::Unsupported { harmless: false } } else { steps(v) } } -pub(super) fn visibility_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", D::List(vec![layer_ref(p)?]))) +/// Write the layer reference and preserve Show Only's toggle flag. +pub(super) fn visibility_to(id: &str, p: &Value) -> Option { + let mut d = desc("null").with("null", D::List(vec![layer_ref(p)?])); + if id == "layer.showOnly" { + d = d.with("TglO", D::Boolean(true)); + } + Some(d) } +/// Import supported layer moves while retaining pre-move stack boundaries. pub(super) fn move_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; // The selection outline nudged. @@ -188,7 +272,7 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { if ref_class(r).as_deref() != Some("Lyr") { return Mapped::Unsupported { harmless: false }; } - let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + let Some(mut p) = layer_target(r).or_else(|| recorded_layer_target(r)) else { return Mapped::Unsupported { harmless: false } }; match d.get("T ") { Some(D::Descriptor(t)) => { let unit = |k: &str| match t.get(k) { @@ -213,15 +297,19 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { step("layer.translate", Value::Object(p)) } Some(D::Reference(t)) => { + // Only a layer destination: an index or ordinal of anything else isn't a stack move. + if t.len() != 1 || ref_class(t).as_deref() != Some("Lyr") { + return Mapped::Unsupported { harmless: false }; + } + if let Target::Index(index) = target(t) { + p.insert("target".into(), json!({"index": index})); + return step("layer.moveTo", Value::Object(p)); + } let id = match target(t) { Target::Ordinal(o) if o == "Frnt" => "layer.arrange.bringToFront", Target::Ordinal(o) if o == "Back" => "layer.arrange.sendToBack", Target::Ordinal(o) if o == "Nxt" => "layer.arrange.bringForward", 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)) @@ -230,9 +318,20 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { } } +/// Encode an indexed boundary or a supported relative move without losing its source selector. pub(super) fn move_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 t = match id { + "layer.moveTo" => { + if ["layers", "position", "targetOccurrence"].iter().any(|key| p.get(key).is_some_and(|value| !value.is_null())) + || p.get("copy").is_some_and(|value| value.as_bool() != Some(false)) + { + return None; + } + let selector = p.get("target")?.as_object().filter(|selector| selector.len() == 1)?; + let index = u32::try_from(selector.get("index")?.as_u64()?).ok()?; + D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index }]) + } "layer.translate" => { let unit = match jstr(p, "units") { None | Some("px") => PX, @@ -248,7 +347,7 @@ pub(super) 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", recorded_layer_ref(p)?).with("T ", t)) } pub(super) fn duplicate_from(d: &Descriptor) -> Mapped { @@ -306,18 +405,52 @@ pub(super) fn duplicate_to(id: &str, p: &Value) -> Option { Some(d) } -/// Only a comp name in the active document is portable across action playback. +/// Retain measured comp reference forms and resolve them against the playback document. pub(super) fn apply_comp_from(d: &Descriptor) -> Mapped { - match get_ref(d, "null") { + let comp = 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()})) + json!(name.to_string_lossy()) } - _ => Mapped::Unsupported { harmless: false }, - } + Some([ReferenceItem::Index { class, index }]) if trimmed(&class.class_id) == "compsClass" => json!({"index": index}), + Some([ReferenceItem::Identifier { class, id }]) if trimmed(&class.class_id) == "compsClass" => json!({"id": id}), + Some([ReferenceItem::Enumerated { class, type_id, value }]) + if trimmed(&class.class_id) == "compsClass" + && type_id.is("Ordn") + && matches!(trimmed(value).as_str(), "Trgt" | "Nxt" | "Prvs" | "Frst" | "Lst" | "Al") => + { + json!({"ordinal": trimmed(value)}) + } + _ => return Mapped::Unsupported { harmless: false }, + }; + step("layerComp.apply", json!({"comp": comp})) } +/// Write the original reference kind, including padded ordinal codes and signed index bits. pub(super) fn apply_comp_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))) + let reference = match p.get("comp")? { + Value::String(name) => name_ref("compsClass", name), + Value::Object(selector) if selector.len() == 1 => { + let item = if let Some(index) = selector.get("index") { + let index = if let Some(index) = index.as_u64() { + u32::try_from(index).ok()? + } else { + u32::from_be_bytes(i32::try_from(index.as_i64()?).ok()?.to_be_bytes()) + }; + ReferenceItem::Index { class: class("compsClass"), index } + } else if let Some(id) = selector.get("id") { + ReferenceItem::Identifier { class: class("compsClass"), id: u32::try_from(id.as_u64()?).ok()? } + } else { + let ordinal = selector.get("ordinal")?.as_str()?; + if !matches!(ordinal, "Trgt" | "Nxt" | "Prvs" | "Frst" | "Lst" | "Al") { + return None; + } + ReferenceItem::Enumerated { class: class("compsClass"), type_id: code("Ordn"), value: code(ordinal) } + }; + D::Reference(vec![item]) + } + _ => return None, + }; + Some(desc("null").with("null", reference)) } pub(super) fn play_from(d: &Descriptor) -> Mapped { @@ -340,6 +473,7 @@ pub(super) fn play_to(_: &str, p: &Value) -> Option { Some(desc("null").with("null", D::Reference(items))) } +/// Import Fill or Paint Bucket, keeping Content-Aware choices the engine cannot replay. pub(super) fn fill_from(d: &Descriptor) -> Mapped { if let Some(from) = get_obj(d, "From") { // The Paint Bucket tool's click: its point is in pixels, or in percent of the canvas @@ -378,6 +512,23 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { } let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); + if contents == "contentAware" { + let rotate = match d.get("contentAwareRotateFill") { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p["rotationAdaptation"] = json!(rotate); + for (key, param) in + [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")] + { + match d.get(key) { + None => {} + Some(D::Boolean(value)) => p[param] = json!(value), + Some(_) => return Mapped::Unsupported { harmless: false }, + } + } + } if contents == "color" { match get_obj(d, "Clr ").and_then(color_hex) { Some(c) => p["color"] = json!(c), @@ -399,6 +550,7 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } +/// Export Fill settings, including the effective native Content-Aware rotation option. pub(super) fn fill_to(id: &str, p: &Value) -> Option { if id == "paint.bucket" { let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); @@ -446,9 +598,52 @@ pub(super) fn fill_to(id: &str, p: &Value) -> Option { if let Some(t) = jbool(p, "preserveTransparency") { d = d.with("PrsT", D::Boolean(t)); } + if contents.eq_ignore_ascii_case("contentAware") { + let rotate = match p.get("rotationAdaptation") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + Some(_) => return None, + }; + d = d.with("contentAwareRotateFill", D::Boolean(rotate)); + for (key, param) in + [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")] + { + match p.get(param) { + None | Some(Value::Null) => {} + Some(Value::Bool(value)) => d = d.with(key, D::Boolean(*value)), + Some(_) => return None, + } + } + } Some(d) } +/// Preserve the group eligibility flag without approximating an explicit recorded target. +pub(super) fn paste_effects_from(d: &Descriptor) -> Mapped { + if d.get("null").is_some() { + return Mapped::Unsupported { harmless: false }; + } + let allow = match d.get("allowPasteFXOnLayerSet") { + None => true, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + step("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": allow})) +} + +/// Emit the effective default; unrepresented target parameters must not silently disappear. +pub(super) fn paste_effects_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || p.get("layer").is_some() || p.get("layers").is_some() { + return None; + } + let allow = match p.get("allowGroups") { + None | Some(Value::Null) => true, + Some(Value::Bool(value)) => *value, + Some(_) => return None, + }; + Some(desc("null").with("allowPasteFXOnLayerSet", D::Boolean(allow))) +} + /// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or /// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the /// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1). @@ -479,6 +674,11 @@ pub(super) fn stroke_to(_: &str, p: &Value) -> Option { } 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))); + // Photoshop opens the Stroke dialog for a descriptor that leaves out a setting, even with + // dialogs off (#8303), so the command's defaults for the ones left out are written too. + for (key, default) in [("location", json!("center")), ("opacity", json!(100)), ("mode", json!("Normal")), ("preserveTransparency", json!(false))] { + params.entry(key).or_insert(default); + } fields_to(&Value::Object(params), STROKE, desc("null")) } diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs new file mode 100644 index 0000000000..4b85127cf4 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -0,0 +1,130 @@ +//! The measured Scale Effects event and Make-from-layer-effects form. +use super::descriptors::{PCT, class, class_ref, desc, get_ref, jstr, pct, ref_class}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; + +const SCALE: &str = "layer.layerStyle.scaleEffects"; +pub(super) const CREATE: &str = "layer.layerStyle.createLayer"; +const SOURCE: &str = "_atnLayerStyleDescriptor"; + +/// Only documented keys, once each: unknown or repeated settings cannot be discarded. +fn keys(d: &Descriptor, expected: &[&str]) -> bool { + d.items.len() == expected.len() && expected.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) +} + +/// The dialog records whole percentages; native fractional/clamped values are not guessed. +fn whole_percent(value: f64) -> bool { + value.is_finite() && (1.0..=1000.0).contains(&value) && value.fract() == 0.0 +} + +/// The native Create Layers operation has only the measured implicit active-layer target. +fn create_descriptor() -> Descriptor { + desc("null").with("null", class_ref("Lyr ")).with( + "Usng", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + ) +} + +/// Parse the exact recorded values and reference chain, keeping unsupported forms complete. +fn parameters(id: &str, d: &Descriptor) -> Option { + if id == SCALE { + if !keys(d, &["Scl "]) { + return None; + } + let D::UnitFloat { unit, value } = d.get("Scl ")? else { return None }; + return (*unit == PCT && whole_percent(*value)).then(|| json!({"scale":value})); + } + if id != CREATE || !keys(d, &["null", "Usng"]) { + return None; + } + let [ReferenceItem::Class(layer)] = get_ref(d, "null")? else { return None }; + let [ReferenceItem::Property { class: property, key }, ReferenceItem::Enumerated { class: target, type_id, value }] = get_ref(d, "Usng")? else { + return None; + }; + (layer.class_id.is("Lyr ") && property.class_id.is("Prpr") && key.is("Lefx") && target.class_id.is("Lyr ") && type_id.is("Ordn") && value.is("Trgt")) + .then(|| json!({})) +} + +/// Build supported native values explicitly; an absent scale uses the native default of 100. +fn descriptor(id: &str, p: &Value) -> Option { + let object = p.as_object()?; + if object.keys().any(|key| key != SOURCE && !(id == SCALE && key == "scale")) { + return None; + } + match id { + SCALE => { + let value = match p.get("scale") { + None => 100.0, + Some(v) => v.as_f64()?, + }; + whole_percent(value).then(|| desc("null").with("Scl ", pct(value))) + } + CREATE => Some(create_descriptor()), + _ => None, + } +} + +/// Preserve wire identity/order while unchanged, without making it a native option. +fn from(id: &str, d: &Descriptor) -> Mapped { + let Some(mut p) = parameters(id, d) else { return Mapped::Unsupported { harmless: false } }; + if descriptor(id, &p).as_ref() != Some(d) { + p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(id, p) +} + +/// Import Scale Effects only when the whole recorded percentage is represented. +pub(super) fn scale_from(d: &Descriptor) -> Mapped { + from(SCALE, d) +} + +/// Find typed effects references in parsed values; opaque data and class names are not references. +fn has_style_reference(value: &D) -> bool { + let mut pending = vec![value]; + while let Some(value) = pending.pop() { + match value { + D::Reference(parts) => { + if parts.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx"))) { + return true; + } + } + D::Descriptor(d) | D::GlobalObject(d) => pending.extend(d.items.iter().map(|(_, value)| value)), + D::List(values) => pending.extend(values), + D::ObjectArray(array) => pending.extend(array.body.items.iter().map(|(_, value)| value)), + _ => {} + } + } + false +} + +/// Intercept layer-style Make intent before generic Make can turn it into a blank layer. +pub(super) fn create_from_descriptor(d: &Descriptor) -> Option { + let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr"); + let style_intent = d.items.iter().any(|(item_key, value)| { + // Deep inspection belongs to Layer Make; other Make classes retain direct-reference handling. + let style_reference = (layer_make || matches!(value, D::Reference(_))) && has_style_reference(value); + if item_key.is("Usng") { + // A layer Make may omit Usng, or use the ordinary layer-options object. Other + // shapes must not be discarded as if they were an empty layer-options object. + style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr "))) + } else { + // A miskeyed effects reference is still style intent, not an ordinary blank layer. + layer_make && style_reference + } + }); + style_intent.then(|| from(CREATE, d)) +} + +/// Validate preserved data before fallback; supported edits export their current canonical values. +pub(super) fn style_to(id: &str, p: &Value) -> Option { + let canonical = descriptor(id, p)?; + if p.get(SOURCE).is_none() { + return Some(canonical); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + let original = descriptor(id, ¶meters(id, &source)?)?; + Some(if original == canonical { source } else { canonical }) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 96f9033102..eed2974b6a 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1,7 +1,7 @@ -use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps}; +use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps, levels_complete}; use super::descriptors::{ - PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, jnum, - jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, + PCT, PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, + jnum, jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, }; use super::fields::{Mapped, none, step, steps}; use super::steps_layer::quick_mask_ref; @@ -9,14 +9,23 @@ use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_b use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json}; /// A `patternLayer` descriptor's pattern, scale, angle and link as the params of -/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. The pattern goes by name: ids differ -/// between Photoshop's library and one rebuilt from the same `.pat` file. None for a pattern -/// recorded without a name or id. +/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. A pattern recorded with both an id +/// and a name goes by the id with the name as `patternName`: ids differ between Photoshop's +/// library and one rebuilt from the same `.pat` file, while names may collide. None for a +/// pattern recorded without a name or id. fn pattern_params(t: &Descriptor) -> Option> { let mut p = Map::new(); if let Some(ptrn) = get_obj(t, "Ptrn") { - let key = get_text(ptrn, "Nm ").filter(|n| !n.is_empty()).or_else(|| get_text(ptrn, "Idnt").filter(|i| !i.is_empty()))?; - p.insert("pattern".into(), key.into()); + let name = get_text(ptrn, "Nm ").filter(|n| !n.is_empty()); + match (get_text(ptrn, "Idnt").filter(|i| !i.is_empty()), name) { + (Some(id), Some(name)) => { + p.insert("pattern".into(), id.into()); + p.insert("patternName".into(), name.into()); + } + (key, name) => { + p.insert("pattern".into(), key.or(name)?.into()); + } + } } if let Some(s) = get_num(t, "Scl ") { p.insert("scale".into(), s.into()); @@ -33,8 +42,10 @@ fn pattern_params(t: &Descriptor) -> Option> { /// The `patternLayer` descriptor for [`pattern_params`]' params. fn pattern_desc(p: &Value) -> Option { let mut t = desc("patternLayer"); - if let Some(n) = jstr(p, "pattern") { - t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n)))); + match (jstr(p, "pattern"), jstr(p, "patternName")) { + (Some(id), Some(n)) => t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n)).with("Idnt", text(id)))), + (Some(n), None) | (None, Some(n)) => t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n)))), + (None, None) => {} } if let Some(s) = jnum(p, "scale") { t = t.with("Scl ", pct(s)); @@ -92,13 +103,35 @@ fn mask_from_descriptor(d: &Descriptor) -> Option { }) } +/// Import New Document and selection-based Copy/Cut, retaining the new layer's options. fn new_document_from_descriptor(d: &Descriptor) -> Option { let nw = get_obj(d, "Nw ")?; if get_enum(d, "Usng").as_deref() == Some("Slct") { let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; let mut mapped_steps = vec![(id, json!({}))]; + let mut props = Map::new(); if let Some(n) = get_text(nw, "Nm ") { - mapped_steps.push(("layer.setProps", json!({"name": n}))); + props.insert("name".into(), n.into()); + } + if nw.get("Opct").is_some() { + if matches!(nw.get("Opct"), Some(D::UnitFloat { unit, .. }) if *unit != PCT) { + return Some(Mapped::Unsupported { harmless: false }); + } + let Some(o) = get_num(nw, "Opct").filter(|o| (0.0..=100.0).contains(o)) else { + return Some(Mapped::Unsupported { harmless: false }); + }; + props.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(m) = get_enum(nw, "Md ") { + let Some(mode) = blend_label(&m) else { return Some(Mapped::Unsupported { harmless: false }) }; + props.insert("blend".into(), mode.into()); + } + if let Some(c) = get_enum(nw, "Clr ") { + let Some(color) = lookup(LABEL_COLORS, &c) else { return Some(Mapped::Unsupported { harmless: false }) }; + mapped_steps.push(("layer.setLabelColor", json!({"color": color}))); + } + if !props.is_empty() { + mapped_steps.push(("layer.setProps", Value::Object(props))); } return Some(steps(mapped_steps)); } @@ -136,7 +169,25 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option { Some(step("file.new", params)) } +/// New Snapshot's From: the full document, the layers merged, or the current layer. +const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")]; + +fn type_shape_descriptor() -> Descriptor { + desc("null").with("null", class_ref("contentLayer")).with("Usng", target_ref("textLayer")) +} + +/// Import Make events only when their geometry and options can be represented faithfully, +/// including the exact target-text-to-content-layer references. pub(super) fn make_from(d: &Descriptor) -> Mapped { + if get_ref(d, "null").and_then(ref_class).as_deref() == Some("Path") { + return super::steps_type_path::from(d); + } + if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) { + return mapped; + } + if d.items == type_shape_descriptor().items { + return none("type.convertToShape"); + } if let Some(mapped) = duplicate_layer_from_descriptor(d) { return mapped; } @@ -162,13 +213,46 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { if let Some(m) = get_enum(u, "Md ").and_then(|m| blend_label(&m)) { p.insert("mode".into(), m.into()); } + match u.get("Grup") { + Some(D::Boolean(true)) => { + p.insert("clipToPrevious".into(), true.into()); + } + Some(D::Boolean(false)) | None => {} + _ => return Mapped::Unsupported { harmless: false }, + } } step("layer.new.layer", Value::Object(p)) } Some("AdjL") => match type_desc.and_then(|t| adjustment_by_class(&trimmed(&t.class_id))) { - Some(a) => adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc), + Some(a) => { + let Mapped::Steps(mut mapped) = adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc) else { + return Mapped::Unsupported { harmless: false }; + }; + if let Some(u) = using + && u.get("Opct").is_some() + { + if matches!(u.get("Opct"), Some(D::UnitFloat { unit, .. }) if *unit != PCT) { + return Mapped::Unsupported { harmless: false }; + } + let Some(opacity) = get_num(u, "Opct").filter(|o| (0.0..=100.0).contains(o)) else { + return Mapped::Unsupported { harmless: false }; + }; + let Some((_, Value::Object(params))) = mapped.first_mut() else { return Mapped::Unsupported { harmless: false } }; + // Recorded layer opacity uses 0..255 even in a 16-bit document. + params.insert("opacity".into(), ((opacity * 255.0 / 100.0).round() / 255.0).into()); + } + if let Some(c) = using.and_then(|u| get_enum(u, "Clr ")) { + let Some(color) = lookup(LABEL_COLORS, &c) else { return Mapped::Unsupported { harmless: false } }; + mapped.push(("layer.setLabelColor".into(), json!({"color": color}))); + } + Mapped::Steps(mapped) + } None => Mapped::Unsupported { harmless: false }, }, + Some("contentLayer") if d.get("Shp ").is_some() || using.is_some_and(|u| u.get("Shp ").is_some() || u.get("strokeStyle").is_some()) => { + // A shape's geometry and stroke cannot be represented by a canvas-wide fill. + Mapped::Unsupported { harmless: false } + } Some("contentLayer") => match type_desc { Some(t) if t.class_id.is("solidColorLayer") => match get_obj(t, "Clr ").and_then(color_hex) { Some(c) => step("layer.newFillLayer.solidColor", json!({"color": c})), @@ -182,7 +266,7 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { }, Some("layerSection") => { let mut p = Map::new(); - if let Some(n) = using.and_then(|u| get_text(u, "Nm ")) { + if let Some(n) = using.and_then(|u| get_text(u, "Nm ")).or_else(|| get_text(d, "Nm ")) { p.insert("name".into(), n.into()); } match get_ref(d, "From") { @@ -198,11 +282,50 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { None => step("layer.new.group", Value::Object(p)), } } + // History panel › New Snapshot (of the current state). + Some("SnpS") => { + let mut p = Map::new(); + if let Some(n) = get_text(d, "Nm ") { + p.insert("name".into(), n.into()); + } + match get_enum(d, "Usng").as_deref().map(|u| lookup(SNAPSHOT_FROM, u)) { + None => {} + Some(Some(f)) => { + p.insert("from".into(), f.into()); + } + Some(None) => return Mapped::Unsupported { harmless: false }, + } + if get_bool(d, "replaceExisting") == Some(true) { + p.insert("replaceExisting".into(), true.into()); + } + step("history.newSnapshot", Value::Object(p)) + } _ => Mapped::Unsupported { harmless: false }, } } +/// The percent to save for an adjustment layer's opacity. Loading quantises a recorded percent +/// to 8 bits (70 % becomes 179/255); a whole percent that quantises to that same level is the +/// one that was recorded, so it is saved back (load then save keeps `70`, not `70.196`). +fn recorded_percent(opacity: f64) -> f64 { + let level = opacity * 255.0; + let percent = opacity * 100.0; + let whole = percent.round(); + if (level - level.round()).abs() < 1e-6 && (whole * 255.0 / 100.0).round() == level.round() { whole } else { percent } +} + +/// Write a creation step, refusing native options the target descriptor cannot carry; Type to +/// Shape exports only without native options. pub(super) fn make_to(id: &str, p: &Value) -> Option { + if id == super::steps_type_path::COMMAND { + return super::steps_type_path::to(p); + } + if id == super::steps_layer_style::CREATE { + return super::steps_layer_style::style_to(id, p); + } + if id == "type.convertToShape" { + return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor()); + } let with_name = |cls: &str| { let mut u = desc(cls); if let Some(n) = jstr(p, "name") { @@ -212,12 +335,12 @@ pub(super) 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 + // The descriptor carries the name, opacity, mode and clipping flag. A step that + // 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). + // (strict export fails, a partial one skips it: #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)) + if p.get("clipToPrevious").is_some_and(|v| !v.is_null() && !v.is_boolean()) || set("color", json!("none")) || set("fillNeutral", Value::Bool(false)) || set("position", json!("above")) @@ -225,6 +348,9 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { return None; } let mut u = with_name("Lyr "); + if jbool(p, "clipToPrevious") == Some(true) { + u = u.with("Grup", D::Boolean(true)); + } if let Some(o) = jnum(p, "opacity") { u = u.with("Opct", pct(o * 100.0)); } @@ -286,6 +412,18 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { 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")))), + "history.newSnapshot" => { + let current = D::Reference(vec![ReferenceItem::Property { class: class("HstS"), key: code("CrnH") }]); + let mut d = desc("null").with("null", class_ref("SnpS")).with("From", current); + if let Some(n) = jstr(p, "name") { + d = d.with("Nm ", text(n)); + } + d = d.with("Usng", enm("HstS", lookup_back(SNAPSHOT_FROM, jstr(p, "from").unwrap_or("fullDocument"))?)); + if jbool(p, "replaceExisting") == Some(true) { + d = d.with("replaceExisting", D::Boolean(true)); + } + d + } "file.new" => { let mut n = desc("Dcmn"); if let Some(name) = jstr(p, "name") { @@ -316,16 +454,30 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { _ => { let suffix = id.strip_prefix("layer.newAdjustmentLayer.")?; let a = adjustment_by_suffix(suffix)?; - let t = if p.as_object().is_some_and(|o| o.is_empty()) { + let opacity = match p.get("opacity") { + None => None, + Some(value) => Some(value.as_f64().filter(|o| o.is_finite() && (0.0..=1.0).contains(o))?), + }; + let mut params = p.clone(); + if let Some(params) = params.as_object_mut() { + params.remove("opacity"); + } + // Layer opacity must not turn default adjustment settings into a custom preset. + let t = if params.as_object().is_some_and(|o| o.is_empty()) { desc(a.class).with("presetKind", enm("presetKindType", "presetKindDefault")) } else { - (a.to)(p)? + (a.to)(¶ms)? }; - desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t)))) + let mut using = desc("AdjL").with("Type", D::Descriptor(t)); + if let Some(opacity) = opacity { + using = using.with("Opct", pct(recorded_percent(opacity))); + } + desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)) } }) } +/// Import selection geometry or a channel source, including its optional source layer. fn selection_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; match d.get("T ") { @@ -336,7 +488,24 @@ fn selection_from(d: &Descriptor) -> Mapped { _ => unsupported, }, Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { - (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), + (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => { + let mut p = Map::new(); + p.insert("channel".into(), "transparency".into()); + if t.len() > 1 { + if t.len() != 2 || ref_class(&t[1..]).as_deref() != Some("Lyr") { + return unsupported; + } + let Some(mut layer) = layer_target(&t[1..]) else { return unsupported }; + if let Some(Value::String(name)) = layer.get("layer") + && (name.is_empty() || name == "merged") + { + let selector = json!({"name": name}); + layer.insert("layer".into(), selector); + } + p.extend(layer); + } + step("select.loadSelection", Value::Object(p)) + } // ⌘-click on the targeted channel (a layer mask after `select` of `Msk`). (Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})), (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), @@ -401,6 +570,121 @@ fn background_from(d: &Descriptor) -> Mapped { steps(v) } +/// The Layers panel lock buttons (`layerLocking`) ↔ `layer.lockLayers` params. +const LOCKS: &[(&str, &str)] = &[ + ("protectTransparency", "transparency"), + ("protectComposite", "pixels"), + ("protectPosition", "position"), + ("protectArtboardAutonest", "artboard"), + ("protectAll", "all"), +]; + +/// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params. +const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")]; + +fn lock_params(locks: &Descriptor) -> Option> { + let mut m = Map::new(); + // "Unlock all" clears every lock. + if get_bool(locks, "protectNone") == Some(true) { + for (_, param) in LOCKS { + m.insert((*param).into(), false.into()); + } + } + for (key, param) in LOCKS { + if let Some(b) = get_bool(locks, key) { + m.insert((*param).into(), b.into()); + } + } + (!m.is_empty()).then_some(m) +} + +/// `applyLocking`: a lock button in the Layers panel, on the target layer. +pub(super) fn locking_from(d: &Descriptor) -> Mapped { + let on_target = get_ref(d, "null").is_none_or(|r| ref_class(r).as_deref() == Some("Lyr") && matches!(target(r), Target::Ordinal(o) if o == "Trgt")); + match get_obj(d, "layerLocking").and_then(lock_params) { + Some(l) if on_target => step("layer.lockLayers", Value::Object(l)), + _ => Mapped::Unsupported { harmless: false }, + } +} + +pub(super) fn locking_to(_: &str, p: &Value) -> Option { + let mut locks = desc("layerLocking"); + for (key, param) in LOCKS { + if let Some(b) = jbool(p, param) { + locks = locks.with(key, D::Boolean(b)); + } + } + if locks.items.is_empty() { + return None; + } + Some(desc("null").with("null", target_ref("Lyr ")).with("layerLocking", D::Descriptor(locks))) +} + +fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(mut p) = layer_target(r) else { return unsupported }; + let Some(t) = get_obj(d, "T ") else { return unsupported }; + let mut v = Vec::new(); + if let Some(c) = get_enum(t, "Clr ").and_then(|c| lookup(LABEL_COLORS, &c)) { + let mut lp = p.clone(); + lp.insert("color".into(), c.into()); + v.push(("layer.setLabelColor", Value::Object(lp))); + } + if let Some(n) = get_text(t, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(o) = get_num(t, "Opct") { + p.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(f) = get_num(t, "fillOpacity") { + p.insert("fill".into(), (f / 100.0).into()); + } + if let Some(m) = get_enum(t, "Md ") { + let Some(label) = blend_label(&m) else { return unsupported }; + p.insert("blend".into(), label.into()); + } + if let Some(vis) = get_bool(t, "Vsbl") { + p.insert("visible".into(), vis.into()); + } + // Lock buttons and Blending Options' Advanced checkboxes act on the target layer. + let targeted = !p.contains_key("layer"); + if let Some(locks) = get_obj(t, "layerLocking") { + match lock_params(locks) { + Some(l) if targeted => v.push(("layer.lockLayers", Value::Object(l))), + _ => return unsupported, + } + } + let mut advanced = Map::new(); + for (key, param) in ADVANCED_BLENDING { + if let Some(b) = get_bool(t, key) { + advanced.insert((*param).into(), b.into()); + } + } + if !advanced.is_empty() { + if !targeted { + return unsupported; + } + v.push(("layer.layerStyle.blendingOptions", Value::Object(advanced))); + } + if p.keys().any(|k| k != "layer") { + v.push(("layer.setProps", Value::Object(p))); + } + // The Layer Style dialog's OK on a layer without effects records only the effects' + // scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes + // the document. + let no_effect = t.items.iter().all(|(k, val)| match val { + D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")), + _ => false, + }); + if !v.is_empty() { + steps(v) + } else if no_effect { + Mapped::Unsupported { harmless: true } + } else { + unsupported + } +} + pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; @@ -416,35 +700,28 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { 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 }; - let mut v = Vec::new(); - if let Some(c) = get_enum(t, "Clr ").and_then(|c| lookup(LABEL_COLORS, &c)) { - let mut lp = p.clone(); - lp.insert("color".into(), c.into()); - v.push(("layer.setLabelColor", Value::Object(lp))); + (Some("Lyr"), _) => layer_set_from(d, r), + // Layer › Smart Filter › Blending Options (`indx` counts the filters from 1, bottom first). + (Some("filterFX"), _) => { + let index = match r.first() { + Some(ReferenceItem::Index { index, .. }) if *index >= 1 => index - 1, + _ => return unsupported, + }; + let on_target = + matches!(r.get(1..), Some(rest) if ref_class(rest).as_deref() == Some("Lyr") && matches!(target(rest), Target::Ordinal(o) if o == "Trgt")); + if !on_target { + return unsupported; } - if let Some(n) = get_text(t, "Nm ") { - p.insert("name".into(), n.into()); + let Some(opts) = get_obj(d, "filterFX").and_then(|f| get_obj(f, "blendOptions")) else { return unsupported }; + let mut p = json!({"index": index}); + if let Some(o) = get_num(opts, "Opct") { + p["opacity"] = json!(o / 100.0); } - if let Some(o) = get_num(t, "Opct") { - p.insert("opacity".into(), (o / 100.0).into()); - } - if let Some(f) = get_num(t, "fillOpacity") { - p.insert("fill".into(), (f / 100.0).into()); - } - if let Some(m) = get_enum(t, "Md ") { + if let Some(m) = get_enum(opts, "Md ") { let Some(label) = blend_label(&m) else { return unsupported }; - p.insert("blend".into(), label.into()); + p["blend"] = json!(label); } - if let Some(vis) = get_bool(t, "Vsbl") { - p.insert("visible".into(), vis.into()); - } - if p.keys().any(|k| k != "layer") { - v.push(("layer.setProps", Value::Object(p))); - } - if v.is_empty() { unsupported } else { steps(v) } + step("layer.smartFilter.blendingOptions", p) } // A Pattern Fill layer's pattern (double-click its thumbnail). (Some("contentLayer"), Target::Ordinal(o)) if o == "Trgt" => match get_obj(d, "T ") { @@ -475,6 +752,39 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { } } +fn layer_set_to(p: &Value) -> Option { + // 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)); + } + if let Some(o) = jnum(p, "opacity") { + t = t.with("Opct", pct(o * 100.0)); + } + if let Some(f) = jnum(p, "fill") { + t = t.with("fillOpacity", pct(f * 100.0)); + } + if let Some(m) = jstr(p, "blend") { + t = t.with("Md ", enm("BlnM", blend_ps(m)?)); + } + if let Some(v) = jbool(p, "visible") { + t = t.with("Vsbl", D::Boolean(v)); + } + if let Some(c) = jstr(p, "color") { + t = t.with("Clr ", enm("Clr ", lookup_back(LABEL_COLORS, c)?)); + } + if t.items.is_empty() { + return None; + } + Some(desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t))) +} + +/// Encode selection and colour-setting steps as Photoshop Set descriptors. pub(super) fn set_to(id: &str, p: &Value) -> Option { let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); @@ -491,7 +801,22 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { 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") }])), + "transparency" => { + let mut r = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }]; + if p.get("layer").is_some_and(|v| !v.is_null()) { + let reference = match p.get("layer") { + // These native source defaults mean the active layer for transparency. + Some(Value::String(name)) if name.is_empty() || name == "merged" => target_ref("Lyr "), + Some(Value::Object(selector)) if selector.len() == 1 && selector.contains_key("name") => { + name_ref("Lyr ", selector.get("name")?.as_str()?) + } + _ => layer_ref(p)?, + }; + let D::Reference(layer) = reference else { return None }; + r.extend(layer); + } + sel(D::Reference(r)) + } "targeted" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])), name => sel(name_ref("Chnl", name)), }, @@ -538,35 +863,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { 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)); - } - if let Some(o) = jnum(p, "opacity") { - t = t.with("Opct", pct(o * 100.0)); - } - if let Some(f) = jnum(p, "fill") { - t = t.with("fillOpacity", pct(f * 100.0)); - } - if let Some(m) = jstr(p, "blend") { - t = t.with("Md ", enm("BlnM", blend_ps(m)?)); - } - if let Some(v) = jbool(p, "visible") { - t = t.with("Vsbl", D::Boolean(v)); - } - if let Some(c) = jstr(p, "color") { - t = t.with("Clr ", enm("Clr ", lookup_back(LABEL_COLORS, c)?)); - } - if t.items.is_empty() { - return None; - } - desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t)) + return layer_set_to(p); } "layer.new.layerFromBackground" => { let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]); @@ -578,13 +875,53 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?)) } + "layer.layerStyle.blendingOptions" => { + let o = p.as_object()?; + let mut t = desc("Lyr "); + for (key, param) in ADVANCED_BLENDING { + if let Some(b) = jbool(p, param) { + t = t.with(key, D::Boolean(b)); + } + } + // Only the Advanced checkboxes export: Photoshop records the dialog's other options + // differently. + if t.items.is_empty() || t.items.len() != o.len() { + return None; + } + desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)) + } + "layer.smartFilter.blendingOptions" => { + if p.get("layer").is_some() { + return None; + } + let index = u32::try_from(p.get("index")?.as_u64()?).ok()?.checked_add(1)?; + let mut opts = desc("blendOptions"); + if let Some(o) = jnum(p, "opacity") { + opts = opts.with("Opct", pct(o * 100.0)); + } + if let Some(m) = jstr(p, "blend") { + opts = opts.with("Md ", enm("BlnM", blend_ps(m)?)); + } + let r = D::Reference(vec![ + ReferenceItem::Index { class: class("filterFX"), index }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }, + ]); + desc("null").with("null", r).with("filterFX", D::Descriptor(desc("filterFX").with("blendOptions", D::Descriptor(opts)))) + } "layer.setAdjustment" => { + // The event edits the current adjustment layer: a step that names its layer or + // document would play back on whichever one is active instead (#8532). A null + // `layer` or `document` is the target one, as the command reads it. + if ["layer", "document"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return None; + } // The adjustment kind isn't in the params: Photoshop needs it, so only steps that // name one (`kind`) export. let kind = jstr(p, "kind")?; // `layer.setAdjustment` changes only the settings it names. Color Balance and // Hue/Saturation descriptors carry every setting, filling absent ones with defaults, - // so a partial edit would replay as one that also resets the rest (#8531). + // so a partial edit would replay as one that also resets the rest (#8531). Levels writes + // each range whole, so one end alone resets the other (#8530). // Present and of the shape the writer reads: anything else it would write as 0. let number = |p: &Value, k: &str| p.get(k).and_then(Value::as_f64).is_some_and(f64::is_finite); let numbers = |p: &Value, keys: &[&str]| keys.iter().all(|k| number(p, k)); @@ -606,6 +943,8 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { r.is_object() && numbers(r, &["hue", "saturation", "lightness"]) && bounds }) } + // Both ends of each range it names (#8530). + "levels" => levels_complete(p), _ => true, }; if !complete { diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs new file mode 100644 index 0000000000..599ed7cab7 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -0,0 +1,152 @@ +//! Photoshop's Video, Other, Average and matting steps (#10607): De-Interlace, NTSC Colors, +//! HSB/HSL, Custom, Average and Remove Black / White Matte, with the keys Photoshop records. + +use super::descriptors::{desc, enm, enumerated, get_list, num, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Map, Value, json}; +use std::ops::RangeInclusive; + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, + } +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). Params that are neither an +/// object nor `null` are no command's params, so they set something no event can hold. +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// NTSC Colors, Average and Remove Black / White Matte: Photoshop runs them with an empty +/// descriptor. A step that carries a key keeps the Photoshop step. +pub(super) fn bare_from(d: &Descriptor, id: &str) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// The empty descriptor, for a step that edits the layer's pixels and sets nothing else. +pub(super) fn bare_to(_: &str, p: &Value) -> Option { + (pixel_target(p) && sets_only(p, &["target"])).then(|| desc("null")) +} + +/// An enumeration key: (key, param, type, Photoshop ↔ PhotoCraft values, Photoshop's default). +type EnumKey = (&'static str, &'static str, &'static str, &'static [(&'static str, &'static str)], &'static str); + +pub(super) const DE_INTERLACE: &[EnumKey] = &[ + ("IntE", "eliminate", "IntE", &[("ElmO", "oddFields"), ("ElmE", "evenFields")], "ElmO"), + ("IntC", "createBy", "IntC", &[("CrtI", "interpolation"), ("CrtD", "duplication")], "CrtI"), +]; + +const HSB_MODELS: &[(&str, &str)] = &[("RGBC", "rgb"), ("HSBl", "hsb"), ("HSLC", "hsl")]; + +pub(super) const HSB_HSL: &[EnumKey] = &[("Inpt", "inputMode", "ClrS", HSB_MODELS, "RGBC"), ("Otpt", "rowOrder", "ClrS", HSB_MODELS, "HSBl")]; + +/// A step of enumerations only: each value looked up, Photoshop's default for a missing one. +/// An unknown value or key, or a value that is not an enumeration, keeps the Photoshop step. +pub(super) fn enums_from(d: &Descriptor, id: &str, keys: &[EnumKey]) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let names: Vec<&str> = keys.iter().map(|k| k.0).collect(); + if !only_keys(d, &names) { + return unsupported; + } + let mut p = Map::new(); + for (key, param, _, table, default) in keys { + let Some(v) = setting(d, key, enumerated, (*default).to_string()).and_then(|e| lookup(table, &e)) else { return unsupported }; + p.insert((*param).into(), v.into()); + } + step(id, Value::Object(p)) +} + +/// Every key in recorded order; a value left out is the command's default, which is +/// Photoshop's for these two filters. +pub(super) fn enums_to(p: &Value, keys: &[EnumKey]) -> Option { + let params: Vec<&str> = keys.iter().map(|k| k.1).chain(["target"]).collect(); + if !pixel_target(p) || !sets_only(p, ¶ms) { + return None; + } + let mut d = desc("null"); + for (key, param, ty, table, default) in keys { + let code = match p.get(*param) { + None | Some(Value::Null) => *default, + Some(v) => lookup_back(table, v.as_str()?)?, + }; + d = d.with(key, enm(ty, code)); + } + Some(d) +} + +/// `filter.other.custom`'s ranges: (scale, offset, kernel cell). The command clamps a value +/// outside them when it plays, so `Scl 0` would play as 1 and save back as 0. +const CUSTOM_SCALE: RangeInclusive = 1.0..=9999.0; +const CUSTOM_OFFSET: RangeInclusive = -9999.0..=9999.0; +const CUSTOM_CELL: RangeInclusive = -999.0..=999.0; + +/// A whole number within `range`. +fn within(range: &RangeInclusive) -> impl Fn(&D) -> Option + '_ { + move |v| num(v).filter(|n| n.fract() == 0.0 && range.contains(n)) +} + +/// Custom (`Cstm`): `Mtrx` is the 5 × 5 kernel, row by row. Photoshop takes exactly 25 cells (a +/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl `, +/// `Ofst` or cell that is not a number, one that is not a whole number (export writes integers, +/// so Save Actions would refuse it), or one outside the command's range (it would play clamped). +pub(super) fn custom_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) { + return unsupported; + } + let Some(cells) = get_list(d, "Mtrx").filter(|c| c.len() == 25) else { return unsupported }; + let Some(kernel) = cells.iter().map(within(&CUSTOM_CELL)).collect::>>() else { return unsupported }; + let (Some(scale), Some(offset)) = (setting(d, "Scl ", within(&CUSTOM_SCALE), 1.0), setting(d, "Ofst", within(&CUSTOM_OFFSET), 0.0)) else { + return unsupported; + }; + let mut p = Map::new(); + p.insert("kernel".into(), json!(kernel)); + p.insert("scale".into(), scale.into()); + p.insert("offset".into(), offset.into()); + step("filter.other.custom", Value::Object(p)) +} + +/// `Scl `, `Ofst` and the 25 cells as integers; a kernel left out is the command's identity +/// (1 in the middle). A fraction anywhere refuses the step: it would replay rounded; so does a +/// value outside the command's range, which it doesn't play. +pub(super) fn custom_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["target", "kernel", "scale", "offset"]) { + return None; + } + let int = |range: &RangeInclusive, v: &Value| { + let n = v.as_f64()?; + (n.fract() == 0.0 && range.contains(&n)).then_some(n as i32) + }; + let field = |k: &str, range: &RangeInclusive, default: i32| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => int(range, v), + }; + let cells: Vec = match p.get("kernel") { + None | Some(Value::Null) => (0..25).map(|i| i32::from(i == 12)).collect(), + Some(v) => { + let a = v.as_array().filter(|a| a.len() == 25)?; + a.iter().map(|c| int(&CUSTOM_CELL, c)).collect::>()? + } + }; + Some( + desc("null") + .with("Scl ", D::Integer(field("scale", &CUSTOM_SCALE, 1)?)) + .with("Ofst", D::Integer(field("offset", &CUSTOM_OFFSET, 0)?)) + .with("Mtrx", D::List(cells.into_iter().map(D::Integer).collect())), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs new file mode 100644 index 0000000000..b4911b6207 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -0,0 +1,138 @@ +//! Recorded descriptors for the Render filters. + +use std::hash::BuildHasher; + +use super::descriptors::{desc, enm}; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Value, json}; + +const CENTER: f64 = 0.40000000596046; +const LENSES: &[(&str, &str)] = &[("Zm", "zoom"), ("Nkn", "prime35"), ("Nkn1", "prime105"), ("PnVs", "moviePrime")]; + +/// Translate only unique measured keys so later values cannot be lost on save. +fn keys(d: &Descriptor, allowed: &[&str]) -> bool { + d.items.iter().all(|(key, _)| allowed.iter().any(|name| key.is(name))) + && allowed.iter().all(|name| d.items.iter().filter(|(key, _)| key.is(name)).count() <= 1) +} + +/// Read a recorded integer without accepting another descriptor type as its default. +fn integer(d: &Descriptor, key: &str, default: i32) -> Option { + match d.get(key) { + None => Some(default), + Some(D::Integer(n)) => Some(*n), + _ => None, + } +} + +/// Native numeric parameters must fit the descriptor integer without rounding. +fn param_integer(p: &Value, key: &str, default: i32) -> Option { + let Some(v) = p.get(key) else { return Some(default) }; + let n = v.as_f64()?; + (n.is_finite() && n.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n)).then_some(n as i32) +} + +/// Pixel-only export cannot discard an explicit native layer, seed or color setting. +fn params(p: &Value, allowed: &[&str]) -> bool { + super::events::pixel_target(p) && p.as_object().is_some_and(|p| p.keys().all(|k| k == "target" || allowed.contains(&k.as_str()))) +} + +/// Clouds events have no fields; tool colors are read when the step plays. +pub(super) fn clouds_from(id: &str, d: &Descriptor) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Explicit colors and layer references have no fields in these ATN events. A seed has none +/// either, but `seed: 0` (what PhotoCraft's dialog records) is what the imported empty step +/// plays as, so it is dropped; any other seed would play differently and is refused. +pub(super) fn clouds_to(_: &str, p: &Value) -> Option { + let seed_replays = p.get("seed").is_none_or(|s| s.as_f64() == Some(0.0)); + (params(p, &["seed"]) && seed_replays).then(|| desc("null")) +} + +/// Capture a missing recorded seed once; later playback and export reuse the stored bits. +fn new_seed() -> i32 { + std::collections::hash_map::RandomState::new().hash_one(0u8) as i32 +} + +/// Fibers keeps raw variance/strength until their dialog limits are measured. +pub(super) fn fibers_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["Vrnc", "Strg", "RndS"]) { + return None; + } + let seed = if d.get("RndS").is_none() { new_seed() } else { integer(d, "RndS", 0)? }; + Some(json!({"variance": integer(d, "Vrnc", 16)?, "strength": integer(d, "Strg", 4)?, "seed": seed})) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("filter.render.fibers", p)) +} + +/// Write signed seed bits even when the native command used the equivalent unsigned value. +pub(super) fn fibers_to(_: &str, p: &Value) -> Option { + if !params(p, &["variance", "strength", "seed"]) { + return None; + } + let seed = match p.get("seed").and_then(Value::as_u64) { + Some(n) => u32::try_from(n).ok()? as i32, + None => param_integer(p, "seed", 0)?, + }; + Some( + desc("null") + .with("Vrnc", D::Integer(param_integer(p, "variance", 16)?)) + .with("Strg", D::Integer(param_integer(p, "strength", 4)?)) + .with("RndS", D::Integer(seed)), + ) +} + +/// Read the point's doubles without changing the recorded descriptor precision. +fn coordinate(d: &Descriptor, key: &str) -> Option { + match d.get(key) { + None => Some(CENTER), + Some(D::Double(n)) if n.is_finite() => Some(*n), + _ => None, + } +} + +/// Lens Flare materializes the measured defaults and retains valid off-canvas centers. +pub(super) fn flare_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["Brgh", "FlrC", "Lns "]) { + return None; + } + let center = match d.get("FlrC") { + None => [CENTER; 2], + Some(D::Descriptor(point)) if point.class_id.is("Pnt ") && keys(point, &["Hrzn", "Vrtc"]) => { + [coordinate(point, "Hrzn")?, coordinate(point, "Vrtc")?] + } + _ => return None, + }; + let lens = match d.get("Lns ") { + None => "zoom", + Some(D::Enumerated { type_id, value }) if type_id.is("Lns ") => lookup(LENSES, &super::descriptors::trimmed(value))?, + _ => return None, + }; + Some(json!({"brightness": integer(d, "Brgh", 100)?.clamp(10, 300), "centerX": center[0], "centerY": center[1], "lens": lens})) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("filter.render.lensFlare", p)) +} + +/// Native omitted centers mean 0.5; imported steps already carry the measured 0.4 values. +pub(super) fn flare_to(_: &str, p: &Value) -> Option { + if !params(p, &["brightness", "centerX", "centerY", "lens"]) { + return None; + } + let number = |key| match p.get(key) { + None => Some(0.5), + Some(n) => n.as_f64().filter(|n| n.is_finite()), + }; + let lens = match p.get("lens") { + None => "zoom", + Some(v) => v.as_str()?, + }; + Some( + desc("null") + .with("Brgh", D::Integer(param_integer(p, "brightness", 100)?.clamp(10, 300))) + .with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(number("centerX")?)).with("Vrtc", D::Double(number("centerY")?)))) + .with("Lns ", enm("Lns ", lookup_back(LENSES, lens)?)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_selection.rs b/crates/engine/src/cmd/atn_cmds/steps_selection.rs new file mode 100644 index 0000000000..1c30dc93ab --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_selection.rs @@ -0,0 +1,233 @@ +//! Recorded selection, clipboard and layer-operation descriptors (Group F). + +use super::descriptors::{class, code, desc, enm, px, trimmed}; +use super::fields::{Mapped, step}; +use super::tables::{ALIGN, blend_label, blend_ps, lookup, lookup_back}; +use super::{D, Descriptor, ReferenceItem, Value, json}; + +/// Group F converters ignore the outer header, like every other event: the reference application +/// records the localized step name (with its NUL) as the name and the event code as the class. +pub(super) fn from(d: &Descriptor, convert: fn(&Descriptor) -> Mapped) -> Mapped { + convert(d) +} + +/// Keys must retain the encoding and order used by export, with no repeats or unknowns. +fn keys(d: &Descriptor, allowed: &[&str]) -> bool { + let mut previous = None; + d.items.iter().all(|(key, _)| { + let Some(index) = allowed.iter().position(|name| *key == code(name)) else { return false }; + if previous.is_some_and(|previous| index <= previous) { + return false; + } + previous = Some(index); + true + }) +} + +/// Native settings without a recorded representation cannot be exported faithfully. +fn params(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_some_and(|p| p.keys().all(|k| allowed.contains(&k.as_str()))) +} + +/// Measured numeric fields retain their units and finite double precision. +fn unit(d: &Descriptor, key: &str, expected: &[u8; 4]) -> Option { + match d.get(key)? { + D::UnitFloat { unit, value } if unit == expected && value.is_finite() => Some(*value), + _ => None, + } +} + +/// Enum identifiers must retain their encoded form, including short-code padding. +fn enumeration(d: &Descriptor, key: &str, expected: &str) -> Option { + let raw = d.get(key)?; + let D::Enumerated { value, .. } = raw else { return None }; + let value = trimmed(value); + (raw == &enm(expected, &value)).then_some(value) +} + +/// The recorded reference identifies either the selection property or the targeted layers. +fn target(selection: bool) -> D { + D::Reference(vec![if selection { + ReferenceItem::Property { class: class("Chnl"), key: code("fsel") } + } else { + ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") } + }]) +} + +/// Preserve recorded rectangle endpoints when subtracting/adding widths would lose a low bit. +pub(super) fn selection_from(d: &Descriptor, mode: &str) -> Mapped { + let mapped = || { + if !keys(d, &["null", "T ", "AntA"]) || d.get("null") != Some(&target(true)) { + return None; + } + let D::Descriptor(shape) = d.get("T ")? else { return None }; + let ellipse = shape.class_id.is("Elps"); + if (!ellipse && !shape.class_id.is("Rctn")) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) { + return None; + } + let [top, left, bottom, right] = + [unit(shape, "Top ", b"#Pxl")?, unit(shape, "Left", b"#Pxl")?, unit(shape, "Btom", b"#Pxl")?, unit(shape, "Rght", b"#Pxl")?]; + let (width, height) = (right - left, bottom - top); + if !(width.is_finite() && height.is_finite() && width >= 0.0 && height >= 0.0) { + return None; + } + let mut p = json!({"x": left, "y": top, "width": width, "height": height, "mode": mode}); + if ellipse { + let D::Boolean(aa) = d.get("AntA")? else { return None }; + p["ellipse"] = json!(true); + p["antiAlias"] = json!(aa); + } else if d.get("AntA").is_some() { + return None; + } + if left + width != right || top + height != bottom { + p["_atnBounds"] = json!([top, left, bottom, right]); + } + Some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("select.rect", p)) +} + +/// Each combination event exports only its measured shape and settings. +pub(super) fn selection_to(p: &Value, mode: &str) -> Option { + if !params(p, &["x", "y", "width", "height", "mode", "ellipse", "antiAlias", "feather", "_atnBounds"]) || p.get("mode")?.as_str()? != mode { + return None; + } + if p.get("feather").is_some_and(|v| v.as_f64() != Some(0.0)) { + return None; + } + let ellipse = match p.get("ellipse") { + None => false, + Some(v) => v.as_bool()?, + }; + if ellipse && mode != "add" { + return None; + } + let number = |key| p.get(key)?.as_f64().filter(|v| v.is_finite()); + let (left, top, width, height) = (number("x")?, number("y")?, number("width")?, number("height")?); + if width < 0.0 || height < 0.0 { + return None; + } + let (mut bottom, mut right) = (top + height, left + width); + if let Some(bounds) = p.get("_atnBounds") { + let a = bounds.as_array()?; + let [t, l, b, r] = a.as_slice() else { return None }; + let (t, l, b, r) = (t.as_f64()?, l.as_f64()?, b.as_f64()?, r.as_f64()?); + if [t, l, b, r].iter().any(|v| !v.is_finite()) || t != top || l != left || b - t != height || r - l != width { + return None; + } + (bottom, right) = (b, r); + } + if !bottom.is_finite() || !right.is_finite() { + return None; + } + let shape = desc(if ellipse { "Elps" } else { "Rctn" }).with("Top ", px(top)).with("Left", px(left)).with("Btom", px(bottom)).with("Rght", px(right)); + let mut d = desc("null").with("null", target(true)).with("T ", D::Descriptor(shape)); + if ellipse { + let aa = match p.get("antiAlias") { + None => true, + Some(v) => v.as_bool()?, + }; + d = d.with("AntA", D::Boolean(aa)); + } else if p.get("antiAlias").is_some_and(|v| !v.is_boolean()) { + return None; + } + Some(d) +} + +/// Paste accepts the recorded no-antialias pixel form; other choices stay kept. +pub(super) fn paste_from(d: &Descriptor, id: &str) -> Mapped { + if keys(d, &["AntA", "As "]) && enumeration(d, "AntA", "Annt").as_deref() == Some("Anno") && d.get("As ") == Some(&D::Class(class("Pxel"))) { + step(id, json!({})) + } else { + Mapped::Unsupported { harmless: false } + } +} + +/// Pixel clipboard placement has no recorded layer, center or channel override. +pub(super) fn paste_to(_: &str, p: &Value) -> Option { + if !super::events::pixel_target(p) || !params(p, &["target"]) { + return None; + } + Some(desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))) +} + +/// Fade requires the settings recorded after the edit it fades, not guessed factory values. +pub(super) fn fade_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["Opct", "Md "]) { + return None; + } + let mode = blend_label(&enumeration(d, "Md ", "BlnM")?)?; + // An accepted alias would be rewritten to the exporter's preferred blend identifier. + if d.get("Md ") != Some(&enm("BlnM", blend_ps(mode)?)) { + return None; + } + Some(json!({"opacity":unit(d,"Opct",b"#Prc")?.clamp(0.0,100.0),"mode":mode})) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("edit.fade", p)) +} + +/// Native omitted Fade settings retain the command's 100% Normal behavior. +pub(super) fn fade_to(_: &str, p: &Value) -> Option { + if !params(p, &["opacity", "mode"]) { + return None; + } + let opacity = match p.get("opacity") { + None => 100.0, + Some(v) => v.as_f64().filter(|v| v.is_finite())?, + }; + let mode = match p.get("mode") { + None => "Normal", + Some(v) => v.as_str()?, + }; + Some(desc("null").with("Opct", super::descriptors::pct(opacity.clamp(0.0, 100.0))).with("Md ", enm("BlnM", blend_ps(mode)?))) +} + +/// Link acts on the currently targeted layers through the existing toggle command. +pub(super) fn link_from(d: &Descriptor) -> Mapped { + if keys(d, &["null"]) && d.get("null") == Some(&target(false)) { step("layer.linkLayers", json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// A targeted-layer event cannot represent explicit native layer lists. +pub(super) fn link_to(_: &str, p: &Value) -> Option { + params(p, &[]).then(|| desc("null").with("null", target(false))) +} + +/// Distribute shares the established ADSt edge/center codes used by Align. +pub(super) fn distribute_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["null", "Usng"]) || d.get("null") != Some(&target(false)) { + return None; + } + let edge = lookup(ALIGN, &enumeration(d, "Usng", "ADSt")?)?; + Some(format!("layer.distribute.{edge}")) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |id| step(&id, json!({}))) +} + +/// Equal-gap distribution is a different command and has no measured field here. +pub(super) fn distribute_to(id: &str, p: &Value) -> Option { + if !params(p, &[]) { + return None; + } + Some(desc("null").with("null", target(false)).with("Usng", enm("ADSt", lookup_back(ALIGN, id.strip_prefix("layer.distribute.")?)?))) +} + +/// Empty legacy events keep only their allowlisted origin, leaving native exports unchanged. +pub(super) fn empty_from(d: &Descriptor, id: &str, origin: Option<&str>) -> Mapped { + if !d.items.is_empty() { + return Mapped::Unsupported { harmless: false }; + } + step(id, origin.map_or(json!({}), |event| json!({"_atnEvent":event}))) +} + +/// Refuse native options or a mismatched legacy origin that the empty descriptor cannot carry. +pub(super) fn empty_to(id: &str, p: &Value) -> Option { + let allowed = match id { + "layer.layerMask.delete" => p.get("_atnEvent").and_then(Value::as_str) == Some("RmvL") && params(p, &["_atnEvent"]), + "layer.mergeLayers" => p.get("_atnEvent").and_then(Value::as_str) == Some("MrgL") && params(p, &["_atnEvent"]), + "edit.definePattern" => params(p, &[]), + _ => false, + }; + allowed.then(|| desc("null")) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs new file mode 100644 index 0000000000..611fb9e694 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -0,0 +1,202 @@ +//! Descriptor adapters for the measured Stylize and Pixelate action events. + +use super::descriptors::{desc, enm, px}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Map, Value, json}; + +enum Kind { + Long(i32, i32, i32), + Pixels(f64, f64, f64), + Bool(bool), + Enum(&'static str, &'static [(&'static str, &'static str)], &'static str), +} + +// Descriptor key, command parameter, and measured type/default/range. +struct Field(&'static str, &'static str, Kind); + +const EXTRUDE: &[Field] = &[ + Field("ExtS", "size", Kind::Long(30, 2, 255)), + Field("ExtD", "depth", Kind::Long(30, 1, 255)), + Field("ExtF", "solidFrontFaces", Kind::Bool(false)), + Field("ExtM", "maskIncompleteBlocks", Kind::Bool(false)), + Field("ExtT", "type", Kind::Enum("ExtT", &[("Blks", "blocks"), ("Pyrm", "pyramids")], "blocks")), + Field("ExtR", "depthMode", Kind::Enum("ExtR", &[("Rndm", "random"), ("LvlB", "levelBased")], "random")), +]; +const TRACE: &[Field] = &[ + // Whole levels 0..255; the reference application's dialog clamps to the nearest. + Field("Lvl ", "level", Kind::Long(128, 0, 255)), + Field("Edg ", "edge", Kind::Enum("CntE", &[("Lwr ", "lower"), ("Upr ", "upper")], "upper")), +]; +const WIND: &[Field] = &[ + Field("WndM", "method", Kind::Enum("WndM", &[("Wnd ", "wind"), ("Blst", "blast"), ("Stgr", "stagger")], "wind")), + // Photoshop names the direction the streaks extend, rather than their source. + Field("Drct", "direction", Kind::Enum("Drct", &[("Left", "fromRight"), ("Rght", "fromLeft")], "fromRight")), +]; +const TILES: &[Field] = &[ + Field("TlNm", "count", Kind::Long(10, 1, 99)), + Field("TlOf", "maxOffset", Kind::Long(10, 1, 99)), + Field("FlCl", "fill", Kind::Enum("FlCl", &[("FlBc", "background"), ("FlFr", "foreground"), ("FlIn", "inverse"), ("FlSm", "unaltered")], "background")), +]; +const HALFTONE: &[Field] = &[ + Field("Rds ", "maxRadius", Kind::Long(8, 4, 127)), + Field("Ang1", "channel1", Kind::Long(108, -10000, 10000)), + Field("Ang2", "channel2", Kind::Long(162, -10000, 10000)), + Field("Ang3", "channel3", Kind::Long(90, -10000, 10000)), + Field("Ang4", "channel4", Kind::Long(45, -10000, 10000)), +]; +const MEZZOTINT: &[Field] = &[Field( + "MztT", + "type", + Kind::Enum( + "MztT", + &[ + ("FnDt", "fineDots"), + ("CrsD", "coarseDots"), + ("GrnD", "grainyDots"), + ("LngL", "longLines"), + ("LngS", "longStrokes"), + ("MdmD", "mediumDots"), + ("MdmL", "mediumLines"), + ("MdmS", "mediumStrokes"), + ("ShSt", "shortStrokes"), + ("ShrL", "shortLines"), + ], + "fineDots", + ), +)]; +const MOSAIC: &[Field] = &[Field("ClSz", "cellSize", Kind::Pixels(2.0, 2.0, 200.0))]; +const POINTILLIZE: &[Field] = &[Field("ClSz", "cellSize", Kind::Long(5, 3, 300))]; + +/// The event's fields in recorded order; other filter families keep their own adapters. +fn fields(id: &str) -> Option<&'static [Field]> { + Some(match id { + "filter.stylize.extrude" => EXTRUDE, + "filter.stylize.traceContour" => TRACE, + "filter.stylize.wind" => WIND, + "filter.stylize.tiles" => TILES, + "filter.pixelate.colorHalftone" => HALFTONE, + "filter.pixelate.mezzotint" => MEZZOTINT, + "filter.pixelate.mosaic" => MOSAIC, + "filter.pixelate.pointillize" => POINTILLIZE, + "filter.stylize.findEdges" | "filter.stylize.solarize" | "filter.pixelate.facet" | "filter.pixelate.fragment" => &[], + _ => return None, + }) +} + +impl Kind { + /// Missing ATN keys take the measured default, which can differ from the native command. + fn default(&self) -> Value { + match self { + Self::Long(n, ..) => json!(n), + Self::Pixels(n, ..) => json!(n), + Self::Bool(b) => json!(b), + Self::Enum(_, _, value) => json!(value), + } + } + + /// Reject incompatible types and enum identities; clamp only measured numeric ranges. + fn read(&self, value: &D) -> Option { + Some(match (self, value) { + (Self::Long(_, min, max), D::Integer(n)) => json!(n.clamp(min, max)), + (Self::Pixels(_, min, max), D::UnitFloat { unit, value: n }) if unit == b"#Pxl" && n.is_finite() => json!(n.clamp(*min, *max)), + (Self::Pixels(_, min, max), D::Double(n)) if n.is_finite() => json!(n.clamp(*min, *max)), + (Self::Pixels(_, min, max), D::Integer(n)) => json!(f64::from(*n).clamp(*min, *max)), + (Self::Bool(_), D::Boolean(b)) => json!(b), + (Self::Enum(ty, values, _), D::Enumerated { type_id, value }) if type_id.is(ty) => { + json!(values.iter().find(|(code, _)| value.is(code))?.1) + } + _ => return None, + }) + } + + /// Refuse native values the field cannot represent instead of changing the saved edit. + fn write(&self, value: &Value) -> Option { + Some(match self { + Self::Long(_, min, max) => { + let n = value.as_f64()?; + let integer = n as i32; + if f64::from(integer) != n || !(*min..=*max).contains(&integer) { + return None; + } + D::Integer(integer) + } + Self::Pixels(_, min, max) => { + let n = value.as_f64()?; + if !n.is_finite() || !(*min..=*max).contains(&n) { + return None; + } + px(n) + } + Self::Bool(_) => D::Boolean(value.as_bool()?), + Self::Enum(ty, values, _) => enm(ty, values.iter().find(|(_, label)| Some(*label) == value.as_str())?.0), + }) + } +} + +/// Unknown or duplicate keys keep the original step intact rather than lose settings. +fn params_from(id: &str, d: &Descriptor) -> Option> { + let fields = fields(id)?; + if d.items.iter().any(|(key, _)| !fields.iter().any(|f| key.is(f.0))) { + return None; + } + let mut params = Map::new(); + for Field(key, param, kind) in fields { + if d.items.iter().filter(|(k, _)| k.is(key)).count() > 1 { + return None; + } + let value = match d.get(key) { + Some(value) => kind.read(value)?, + None => kind.default(), + }; + params.insert((*param).into(), value); + } + Some(params) +} + +/// Map a complete measured descriptor or leave it as an unsupported Photoshop step. +pub(super) fn from(id: &str, d: &Descriptor) -> Mapped { + match params_from(id, d) { + Some(params) => step(id, Value::Object(params)), + None => Mapped::Unsupported { harmless: false }, + } +} + +/// Write every measured key, preserving native defaults and rejecting native-only settings. +pub(super) fn to(id: &str, p: &Value) -> Option { + let fields = fields(id)?; + // No params mean the native defaults, as for the other mapped events. + let empty = Map::new(); + let params = if p.is_null() { &empty } else { p.as_object()? }; + // The filter dialog freezes the current colours; the recorded event has none and plays with the current ones. + let colours = matches!(id, "filter.stylize.tiles" | "filter.pixelate.pointillize"); + let seeded = + matches!(id, "filter.stylize.extrude" | "filter.stylize.wind" | "filter.stylize.tiles" | "filter.pixelate.mezzotint" | "filter.pixelate.pointillize"); + if !super::events::pixel_target(p) + || params.iter().any(|(key, value)| { + !fields.iter().any(|f| f.1 == key.as_str()) + && key != "target" + && !(colours && matches!(key.as_str(), "foreground" | "background")) + && !(key == "seed" && seeded && (value.is_null() || value.as_f64() == Some(0.0))) + }) + { + return None; + } + let mut d = desc("null"); + for Field(key, param, kind) in fields { + // Imports materialize Photoshop's defaults. Native commands retain their own defaults. + let default = match (id, *param) { + ("filter.pixelate.mosaic", "cellSize") => json!(10), + ("filter.stylize.traceContour", "edge") => json!("lower"), + _ => kind.default(), + }; + let mut value = p.get(param).filter(|v| !v.is_null()).unwrap_or(&default).clone(); + // The native command rounds the tile count, so a fractional dialog value plays as the whole one. + if (id, *param) == ("filter.stylize.tiles", "count") + && let Some(n) = value.as_f64().filter(|n| n.is_finite()) + { + value = json!(n.round()); + } + d = d.with(key, kind.write(&value)?); + } + Some(d) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_type_path.rs b/crates/engine/src/cmd/atn_cmds/steps_type_path.rs new file mode 100644 index 0000000000..ec99bfd2e9 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_type_path.rs @@ -0,0 +1,48 @@ +//! The measured Make Path from the target text layer, without guessing named sources. +use super::descriptors::{class_ref, desc, get_ref, jstr, target_ref}; +use super::fields::{Mapped, step}; +use super::{Descriptor, ReferenceItem, Value, json}; + +pub(super) const COMMAND: &str = "type.createWorkPath"; +const SOURCE: &str = "_atnTypePathDescriptor"; + +/// Canonical Make Path descriptor for the active text layer; it carries no path geometry. +fn descriptor() -> Descriptor { + desc("null").with("null", class_ref("Path")).with("From", target_ref("TxLr")) +} + +/// Unknown keys, duplicate keys and different reference chains stay complete kept steps. +fn supported(d: &Descriptor) -> bool { + if d.items.len() != 2 || ["null", "From"].iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() != 1) { + return false; + } + matches!(get_ref(d, "null"), Some([ReferenceItem::Class(path)]) if path.class_id.is("Path")) + && matches!(get_ref(d, "From"), Some([ReferenceItem::Enumerated { class, type_id, value }]) + if class.class_id.is("TxLr") && type_id.is("Ordn") && value.is("Trgt")) +} + +/// Map only the measured active-text reference, retaining noncanonical wire identity for export. +pub(super) fn from(d: &Descriptor) -> Mapped { + if !supported(d) { + return Mapped::Unsupported { harmless: false }; + } + let mut params = json!({}); + // Descriptor/class names, key order and ID encodings are wire identity, not native options. + if *d != descriptor() { + params[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(COMMAND, params) +} + +/// Export implicit native playback or a validated original descriptor; explicit targets cannot +/// be represented by this measured form and must use the caller's unsupported-step handling. +pub(super) fn to(p: &Value) -> Option { + if p.as_object()?.keys().any(|key| key != SOURCE) { + return None; + } + if p.get(SOURCE).is_none() { + return Some(descriptor()); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + supported(&source).then_some(source) +} diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs new file mode 100644 index 0000000000..0e8ca94d79 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -0,0 +1,497 @@ +use super::*; + +/// The parameterless events use an empty descriptor and accept both event identifiers. +#[test] +fn parameterless_stylize_and_pixelate_events_use_both_event_id_forms() { + for (code, name, id) in [ + (*b"FndE", "findEdges", "filter.stylize.findEdges"), + (*b"Slrz", "solarize", "filter.stylize.solarize"), + (*b"Fct ", "facet", "filter.pixelate.facet"), + (*b"Frgm", "fragment", "filter.pixelate.fragment"), + ] { + for event in [AtnEvent::Code(code), AtnEvent::name(name)] { + let mut s = load_items(vec![AtnItem { event, descriptor: Some(desc("null")), ..Default::default() }]); + assert_eq!(s.actions.list[0].steps, vec![(id.into(), json!({}))]); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(desc("null"))); + assert!(export_item(id, &json!({"layer": 17}), "Group D").is_none()); + assert!(export_item(id, &json!({"target": "mask"}), "Group D").is_none()); + } + } +} + +struct Fixture { + code: &'static str, + name: &'static str, + id: &'static str, + default: Descriptor, + defaults: Value, + changed: Descriptor, + changes: Value, +} + +/// Independent descriptor fixtures spell out measured key order, types, values and defaults. +fn fixtures() -> Vec { + vec![ + Fixture { + code: "Extr", + name: "extrude", + id: "filter.stylize.extrude", + default: desc("null") + .with("ExtS", D::Integer(30)) + .with("ExtD", D::Integer(30)) + .with("ExtF", D::Boolean(false)) + .with("ExtM", D::Boolean(false)) + .with("ExtT", enm("ExtT", "Blks")) + .with("ExtR", enm("ExtR", "Rndm")), + defaults: json!({"size":30,"depth":30,"solidFrontFaces":false,"maskIncompleteBlocks":false,"type":"blocks","depthMode":"random"}), + changed: desc("null") + .with("ExtS", D::Integer(42)) + .with("ExtD", D::Integer(91)) + .with("ExtF", D::Boolean(true)) + .with("ExtM", D::Boolean(true)) + .with("ExtT", enm("ExtT", "Pyrm")) + .with("ExtR", enm("ExtR", "LvlB")), + changes: json!({"size":42,"depth":91,"solidFrontFaces":true,"maskIncompleteBlocks":true,"type":"pyramids","depthMode":"levelBased"}), + }, + Fixture { + code: "TrcC", + name: "traceContour", + id: "filter.stylize.traceContour", + default: desc("null").with("Lvl ", D::Integer(128)).with("Edg ", enm("CntE", "Upr ")), + defaults: json!({"level":128,"edge":"upper"}), + changed: desc("null").with("Lvl ", D::Integer(43)).with("Edg ", enm("CntE", "Lwr ")), + changes: json!({"level":43,"edge":"lower"}), + }, + Fixture { + code: "Wnd ", + name: "wind", + id: "filter.stylize.wind", + default: desc("null").with("WndM", enm("WndM", "Wnd ")).with("Drct", enm("Drct", "Left")), + defaults: json!({"method":"wind","direction":"fromRight"}), + changed: desc("null").with("WndM", enm("WndM", "Stgr")).with("Drct", enm("Drct", "Rght")), + changes: json!({"method":"stagger","direction":"fromLeft"}), + }, + Fixture { + code: "Tls ", + name: "tiles", + id: "filter.stylize.tiles", + default: desc("null").with("TlNm", D::Integer(10)).with("TlOf", D::Integer(10)).with("FlCl", enm("FlCl", "FlBc")), + defaults: json!({"count":10,"maxOffset":10,"fill":"background"}), + changed: desc("null").with("TlNm", D::Integer(17)).with("TlOf", D::Integer(23)).with("FlCl", enm("FlCl", "FlFr")), + changes: json!({"count":17,"maxOffset":23,"fill":"foreground"}), + }, + Fixture { + code: "ClrH", + name: "colorHalftone", + id: "filter.pixelate.colorHalftone", + default: desc("null") + .with("Rds ", D::Integer(8)) + .with("Ang1", D::Integer(108)) + .with("Ang2", D::Integer(162)) + .with("Ang3", D::Integer(90)) + .with("Ang4", D::Integer(45)), + defaults: json!({"maxRadius":8,"channel1":108,"channel2":162,"channel3":90,"channel4":45}), + changed: desc("null") + .with("Rds ", D::Integer(12)) + .with("Ang1", D::Integer(-10000)) + .with("Ang2", D::Integer(10000)) + .with("Ang3", D::Integer(-721)) + .with("Ang4", D::Integer(1001)), + changes: json!({"maxRadius":12,"channel1":-10000,"channel2":10000,"channel3":-721,"channel4":1001}), + }, + Fixture { + code: "Mztn", + name: "mezzotint", + id: "filter.pixelate.mezzotint", + default: desc("null").with("MztT", enm("MztT", "FnDt")), + defaults: json!({"type":"fineDots"}), + changed: desc("null").with("MztT", enm("MztT", "LngS")), + changes: json!({"type":"longStrokes"}), + }, + Fixture { + code: "Msc ", + name: "mosaic", + id: "filter.pixelate.mosaic", + default: desc("null").with("ClSz", px(2.0)), + defaults: json!({"cellSize":2}), + changed: desc("null").with("ClSz", px(31.5)), + changes: json!({"cellSize":31.5}), + }, + Fixture { + code: "Pntl", + name: "pointillize", + id: "filter.pixelate.pointillize", + default: desc("null").with("ClSz", D::Integer(5)), + defaults: json!({"cellSize":5}), + changed: desc("null").with("ClSz", D::Integer(19)), + changes: json!({"cellSize":19}), + }, + ] +} + +/// Load an actual action file through the command boundary, including flag and name handling. +fn load_items(items: Vec) -> Session { + let file = AtnFile { + name: UnicodeString::new_nul("Group D"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + }; + let bytes = atn::write(&file).unwrap(); + let mut s = Session::new(); + s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + s +} + +/// Save through the real command and parse its bytes so descriptor order and types stay visible. +fn saved_items(s: &mut Session) -> Vec { + let result = s.execute("actions.save", json!({"action":"A"})).unwrap(); + assert_eq!(result["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(result["data"].as_str().unwrap()).unwrap(); + atn::parse(&bytes).unwrap().actions.remove(0).items +} + +/// A measured four-character event with the supplied independent descriptor. +fn code_item(code: &str, descriptor: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(code.as_bytes().try_into().unwrap()), descriptor: Some(descriptor), ..Default::default() } +} + +/// Check exact independent descriptors through real load/save commands, not a codec against itself. +#[test] +fn measured_defaults_and_every_changed_value_round_trip_in_order() { + for fixture in fixtures() { + for (descriptor, params) in [(&fixture.default, &fixture.defaults), (&fixture.changed, &fixture.changes)] { + for named in [false, true] { + let mut item = code_item(fixture.code, descriptor.clone()); + if named { + item.event = AtnEvent::name(fixture.name); + } + let mut s = load_items(vec![item]); + assert_eq!(s.actions.list[0].steps, vec![(fixture.id.into(), params.clone())], "{} named={named}", fixture.code); + let saved = saved_items(&mut s); + assert_eq!(saved[0].event, AtnEvent::Code(fixture.code.as_bytes().try_into().unwrap())); + assert_eq!(saved[0].descriptor.as_ref(), Some(descriptor), "{} named={named}", fixture.code); + } + } + let mut s = load_items(vec![code_item(fixture.code, desc("null"))]); + assert_eq!(s.actions.list[0].steps[0], (fixture.id.into(), fixture.defaults), "{} missing keys", fixture.code); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(fixture.default)); + } +} + +/// Every measured numeric bound is checked independently, including angles beyond 360 degrees. +#[test] +fn numeric_imports_clamp_measured_ranges() { + for (code, key, param, min, max) in [ + ("Extr", "ExtS", "size", 2, 255), + ("Extr", "ExtD", "depth", 1, 255), + ("TrcC", "Lvl ", "level", 0, 255), + ("Tls ", "TlNm", "count", 1, 99), + ("Tls ", "TlOf", "maxOffset", 1, 99), + ("ClrH", "Rds ", "maxRadius", 4, 127), + ("ClrH", "Ang1", "channel1", -10000, 10000), + ("ClrH", "Ang2", "channel2", -10000, 10000), + ("ClrH", "Ang3", "channel3", -10000, 10000), + ("ClrH", "Ang4", "channel4", -10000, 10000), + ("Msc ", "ClSz", "cellSize", 2, 200), + ("Pntl", "ClSz", "cellSize", 3, 300), + ] { + for (input, expected) in [(i32::MIN, min), (min - 1, min), (min, min), (max, max), (max + 1, max), (i32::MAX, max)] { + let value = if code == "Msc " { px(f64::from(input)) } else { D::Integer(input) }; + let mut s = load_items(vec![code_item(code, desc("null").with(key, value))]); + assert_ne!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{code}/{key}"); + assert_eq!(s.actions.list[0].steps[0].1[param], expected, "{code}/{key}/{input}"); + assert!(saved_items(&mut s)[0].descriptor.is_some()); + } + } + for unitless in [D::Integer(2), D::Double(2.0)] { + let mut s = load_items(vec![code_item("Msc ", desc("null").with("ClSz", unitless))]); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(desc("null").with("ClSz", px(2.0)))); + } +} + +/// Trace levels are whole numbers 0..255: imports clamp to that range and play like the clamped direct command. +#[test] +fn trace_levels_clamp_on_import_and_play_at_the_boundary() { + for level in [i32::MIN, -1, 0, 128, 255, 256, 300, i32::MAX] { + let clamped = level.clamp(0, 255); + for (edge, name) in [("Lwr ", "lower"), ("Upr ", "upper")] { + let descriptor = desc("null").with("Lvl ", D::Integer(level)).with("Edg ", enm("CntE", edge)); + let mut item = code_item("TrcC", descriptor); + item.with_dialog = true; + let mut imported = load_items(vec![item]); + assert_eq!(imported.actions.list[0].steps[0].0, "filter.stylize.traceContour", "{level}/{name}"); + assert_eq!(imported.actions.list[0].steps[0].1["level"], clamped); + let saved = saved_items(&mut imported); + let expected = desc("null").with("Lvl ", D::Integer(clamped)).with("Edg ", enm("CntE", edge)); + assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), expected.to_bytes()); + assert!(saved[0].with_dialog); + for depth in [8, 16, 32] { + let mut direct = Session::new(); + prepare_pattern(&mut imported, depth, "rgb"); + prepare_pattern(&mut direct, depth, "rgb"); + let played = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(played.get("failed").is_none(), "{level}/{name}/{depth}: {played}"); + direct.execute("filter.stylize.traceContour", json!({"level":level.clamp(0,255),"edge":name})).unwrap(); + assert_eq!(pixels(&imported), pixels(&direct), "{level}/{name}/{depth}"); + } + } + } + for level in [0, 128, 255] { + assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_some(), "{level}"); + } + for level in [json!(-1), json!(256), json!(255.5), json!(2147483648.0)] { + assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none(), "{level}"); + } +} + +/// Every enumerator is explicit, and unknown values or enum types retain the original flagged step. +#[test] +fn enum_values_map_exactly_and_unknown_descriptors_stay_kept() { + for (code, key, param, ty, values) in [ + ("Extr", "ExtT", "type", "ExtT", vec![("Blks", "blocks"), ("Pyrm", "pyramids")]), + ("Extr", "ExtR", "depthMode", "ExtR", vec![("Rndm", "random"), ("LvlB", "levelBased")]), + ("TrcC", "Edg ", "edge", "CntE", vec![("Lwr ", "lower"), ("Upr ", "upper")]), + ("Wnd ", "WndM", "method", "WndM", vec![("Wnd ", "wind"), ("Blst", "blast"), ("Stgr", "stagger")]), + ("Wnd ", "Drct", "direction", "Drct", vec![("Left", "fromRight"), ("Rght", "fromLeft")]), + ("Tls ", "FlCl", "fill", "FlCl", vec![("FlBc", "background"), ("FlFr", "foreground"), ("FlIn", "inverse"), ("FlSm", "unaltered")]), + ( + "Mztn", + "MztT", + "type", + "MztT", + vec![ + ("FnDt", "fineDots"), + ("CrsD", "coarseDots"), + ("GrnD", "grainyDots"), + ("LngL", "longLines"), + ("LngS", "longStrokes"), + ("MdmD", "mediumDots"), + ("MdmL", "mediumLines"), + ("MdmS", "mediumStrokes"), + ("ShSt", "shortStrokes"), + ("ShrL", "shortLines"), + ], + ), + ] { + for (value, expected) in &values { + let mut s = load_items(vec![code_item(code, desc("null").with(key, enm(ty, value)))]); + assert_eq!(s.actions.list[0].steps[0].1[param], *expected, "{code}/{value}"); + assert_eq!(saved_items(&mut s)[0].descriptor.as_ref().unwrap().get(key), Some(&enm(ty, value))); + } + for value in [enm(ty, "????"), enm("Bad!", values[0].0), D::Boolean(true)] { + let descriptor = desc("null").with(key, value); + let mut item = code_item(code, descriptor.clone()); + item.enabled = false; + item.with_dialog = true; + item.dialog_options = 2; + let mut s = load_items(vec![item]); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{code}/{key}"); + assert!(s.actions.list[0].disabled.contains(&0)); + assert!(s.actions.list[0].dialogs.contains(&0)); + let saved = saved_items(&mut s); + assert_eq!(saved[0].descriptor, Some(descriptor)); + assert!(!saved[0].enabled); + assert!(saved[0].with_dialog); + } + } + for (code, descriptor) in [ + ("Msc ", desc("null").with("ClSz", D::UnitFloat { unit: *b"#Prc", value: 3.0 })), + ("Msc ", desc("null").with("ClSz", D::Double(f64::INFINITY))), + ("Extr", desc("null").with("ExtS", D::Double(30.5))), + ("Extr", desc("null").with("ExtS", D::Integer(30)).with("ExtS", D::Integer(31))), + ("FndE", desc("null").with("unknown", D::Boolean(true))), + ] { + let mut s = load_items(vec![code_item(code, descriptor.clone())]); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(descriptor)); + } +} + +/// Native defaults stay native; seeds, frozen colors, targets and lossy values are not discarded. +#[test] +fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { + for fixture in fixtures() { + let item = export_item(fixture.id, &json!({}), "Group D").unwrap(); + let mut expected = fixture.default.clone(); + if fixture.code == "Msc " { + expected = desc("null").with("ClSz", px(10.0)); + } + if fixture.code == "TrcC" { + expected = desc("null").with("Lvl ", D::Integer(128)).with("Edg ", enm("CntE", "Lwr ")); + } + assert_eq!(item.descriptor, Some(expected), "{}", fixture.id); + for (key, value) in [ + ("seed", json!(1)), + ("foreground", json!("#000000")), + ("background", json!([1, 1, 1, 1])), + ("layer", json!(17)), + ("target", json!("mask")), + ("target", json!("channel:red")), + ("unknown", json!(true)), + ] { + // Tiles and Pointillize drop the dialog's frozen colours (tested below). + if matches!(key, "foreground" | "background") && matches!(fixture.code, "Tls " | "Pntl") { + continue; + } + let mut params = fixture.defaults.clone(); + params[key] = value; + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![(fixture.id.into(), params)])); + assert!(s.execute("actions.save", json!({"action":"A"})).is_err(), "{}/{key}", fixture.id); + } + let mut params = fixture.defaults.clone(); + params["target"] = json!("pixels"); + assert!(export_item(fixture.id, ¶ms, "Group D").is_some()); + } + for id in ["filter.stylize.extrude", "filter.stylize.wind", "filter.stylize.tiles", "filter.pixelate.mezzotint", "filter.pixelate.pointillize"] { + assert!(export_item(id, &json!({"seed":0}), "Group D").is_some(), "{id}"); + } + for (id, params) in [ + ("filter.stylize.extrude", json!({"size":30.5})), + ("filter.stylize.wind", json!({"direction":"unknown"})), + ("filter.stylize.tiles", json!({"fill":"unknown"})), + ("filter.stylize.traceContour", json!({"edge":"unknown"})), + ("filter.pixelate.colorHalftone", json!({"channel1":10001})), + ("filter.pixelate.pointillize", json!({"cellSize":5.5})), + ("filter.pixelate.mosaic", json!({"cellSize":201})), + ("filter.pixelate.mezzotint", json!({"type":"unknown"})), + ] { + assert!(export_item(id, ¶ms, "Group D").is_none(), "{id}/{params}"); + } +} + +/// Imported actions match native pixels with either grouped or per-step Undo and Redo. +#[test] +fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() { + let items: Vec<_> = fixtures() + .into_iter() + .map(|f| code_item(f.code, f.changed)) + .chain(["FndE", "Slrz", "Fct ", "Frgm"].map(|code| code_item(code, desc("null")))) + .collect(); + for single_state in [true, false] { + for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] { + let mut imported = load_items(items.clone()); + imported.execute("actions.setHistory", json!({"singleState": single_state})).unwrap(); + imported.actions.list[0].disabled.insert(10); + imported.actions.list[0].dialogs.insert(0); + let steps = imported.actions.list[0].steps.clone(); + let mut direct = Session::new(); + for s in [&mut imported, &mut direct] { + prepare_pattern(s, depth, mode); + } + let before = pixels(&imported); + let before_doc = imported.active().unwrap().doc.clone(); + let history = imported.active().unwrap().history.past_len(); + let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}"); + assert_eq!(result["ran"], 11); + for (index, (id, params)) in steps.into_iter().enumerate() { + if index != 10 { + direct.execute(&id, params).unwrap(); + } + } + assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}"); + let after_doc = imported.active().unwrap().doc.clone(); + if single_state { + assert_eq!(imported.active().unwrap().history.past_len(), history + 1); + } else { + assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len()); + } + let added = imported.active().unwrap().history.past_len() - history; + assert!(added > 0); + for _ in 0..added { + imported.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(pixels(&imported), before, "{depth}/{mode} undo"); + assert_eq!(imported.active().unwrap().doc.as_ref(), before_doc.as_ref(), "{depth}/{mode} undo"); + for _ in 0..added { + imported.execute("edit.redo", json!({})).unwrap(); + } + assert_eq!(imported.active().unwrap().doc.as_ref(), after_doc.as_ref(), "{depth}/{mode} redo"); + let saved = saved_items(&mut imported); + assert!(!saved[10].enabled); + assert!(saved[0].with_dialog); + } + } +} + +/// A nonuniform image and nondefault tool colors expose differences hidden by flat inputs. +fn prepare_pattern(s: &mut Session, depth: u32, mode: &str) { + s.execute("file.new", json!({"width":32,"height":24,"depth":depth,"mode":mode,"background":"#102030"})).unwrap(); + s.execute("select.rect", json!({"x":3,"y":2,"width":14,"height":11})).unwrap(); + s.execute("edit.fill", json!({"color":"#ee9922"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#e83a91","background":"#148fca"})).unwrap(); +} + +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::render(&s.active().unwrap().doc, photocraft_geom::Rect::new(0, 0, 32, 24)).into_px() +} + +/// Saving omitted native params preserves their actual command behavior, not Photoshop's defaults. +#[test] +fn native_and_measured_defaults_stay_distinct_through_playback() { + use photocraft_algo::FilterParams; + let mosaic = load_items(vec![code_item("Msc ", desc("null"))]); + let measured = &mosaic.actions.list[0].steps[0].1; + assert!(matches!(crate::filters::params_for("filter.pixelate.mosaic",&json!({})),Some(FilterParams::Mosaic{cell_size}) if cell_size==10.0)); + assert!(matches!(crate::filters::params_for("filter.pixelate.mosaic",measured),Some(FilterParams::Mosaic{cell_size}) if cell_size==2.0)); + let trace = load_items(vec![code_item("TrcC", desc("null"))]); + let measured = &trace.actions.list[0].steps[0].1; + assert!(matches!(crate::filters_ext::params_for("filter.stylize.traceContour", &json!({})), Some(FilterParams::TraceContour { upper: false, .. }))); + assert!(matches!(crate::filters_ext::params_for("filter.stylize.traceContour", measured), Some(FilterParams::TraceContour { upper: true, .. }))); + for id in ["filter.pixelate.mosaic", "filter.stylize.traceContour"] { + let item = export_item(id, &json!({}), "Group D").unwrap(); + let mut imported = load_items(vec![item]); + let mut native = Session::new(); + prepare_pattern(&mut imported, 8, "rgb"); + prepare_pattern(&mut native, 8, "rgb"); + native.execute(id, json!({})).unwrap(); + let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(result.get("failed").is_none(), "{id}: {result}"); + assert_eq!(pixels(&imported), pixels(&native), "{id}"); + } +} + +/// Dialog recordings of Tiles and Pointillize carry the current colours; the recorded event has none. +#[test] +fn tiles_and_pointillize_exports_drop_dialog_colours() { + for (id, params) in + [("filter.stylize.tiles", json!({"count":17,"maxOffset":23,"fill":"foreground"})), ("filter.pixelate.pointillize", json!({"cellSize":19}))] + { + let plain = export_item(id, ¶ms, "Group D").unwrap().descriptor; + let mut coloured = params.clone(); + coloured["foreground"] = json!([0.2, 0.4, 0.6, 1.0]); + coloured["background"] = json!("#ffffff"); + assert_eq!(export_item(id, &coloured, "Group D").unwrap().descriptor, plain, "{id}"); + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![(id.into(), coloured)])); + assert!(s.execute("actions.save", json!({"action":"A"})).is_ok(), "{id}"); + } +} + +/// The native Tiles command rounds its count, so a fractional dialog value exports as the whole count it plays. +#[test] +fn tiles_count_rounds_like_the_native_command() { + for (count, expected) in [(17.4, 17), (17.5, 18), (1.2, 1), (98.6, 99)] { + let d = export_item("filter.stylize.tiles", &json!({"count":count}), "Group D").unwrap().descriptor.unwrap(); + assert_eq!(d.get("TlNm"), Some(&D::Integer(expected)), "{count}"); + } + for count in [0.4, 99.5] { + assert!(export_item("filter.stylize.tiles", &json!({"count":count}), "Group D").is_none(), "{count}"); + } + assert!(export_item("filter.stylize.tiles", &json!({"maxOffset":10.5}), "Group D").is_none()); +} + +/// Null params mean the native defaults, as for the other mapped events. +#[test] +fn null_params_export_native_defaults() { + for id in ["filter.stylize.findEdges", "filter.stylize.tiles", "filter.stylize.traceContour"] { + assert_eq!( + export_item(id, &Value::Null, "Group D").map(|item| item.descriptor), + export_item(id, &json!({}), "Group D").map(|item| item.descriptor), + "{id}" + ); + assert!(export_item(id, &Value::Null, "Group D").is_some(), "{id}"); + } +} diff --git a/crates/engine/src/cmd/atn_cmds/tables.rs b/crates/engine/src/cmd/atn_cmds/tables.rs index 0f3bc278e9..0a6a07abee 100644 --- a/crates/engine/src/cmd/atn_cmds/tables.rs +++ b/crates/engine/src/cmd/atn_cmds/tables.rs @@ -47,8 +47,8 @@ const BLEND_MODES: &[(&str, &str)] = &[ ("color", "Color"), ("Lmns", "Luminosity"), ("luminosity", "Luminosity"), - ("PsTh", "Pass Through"), ("passThrough", "Pass Through"), + ("PsTh", "Pass Through"), ]; pub(super) fn blend_label(ps: &str) -> Option<&'static str> { @@ -125,7 +125,8 @@ pub(super) const OFFSET_FILL: &[(&str, &str)] = &[("Wrp ", "wrap"), ("Rpt ", "re pub(super) const TRIM: &[(&str, &str)] = &[("Trns", "transparent"), ("topLeftPixelColor", "topLeft"), ("bottomRightPixelColor", "bottomRight")]; -pub(super) const EXTENSION: &[(&str, &str)] = &[("Bckg", "background"), ("Frgd", "foreground"), ("Wht ", "white"), ("Blck", "black"), ("Trns", "transparent")]; +pub(super) const EXTENSION: &[(&str, &str)] = + &[("Bckg", "background"), ("FrgC", "foreground"), ("Frgd", "foreground"), ("Wht ", "white"), ("Blck", "black"), ("Trns", "transparent")]; pub(super) const FILL_CONTENTS: &[(&str, &str)] = &[ ("FrgC", "foreground"), @@ -138,9 +139,9 @@ pub(super) const FILL_CONTENTS: &[(&str, &str)] = &[ ("contentAware", "contentAware"), ]; -pub(super) const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center"), ("OtsF", "outside")]; +pub(super) const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center"), ("Otsd", "outside"), ("OtsF", "outside")]; -pub(super) const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; +pub(super) const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AClr", "absolute"), ("AbsC", "absolute")]; pub(super) const PROFILES: &[(&str, &str)] = &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 08286b5329..0b218e23d5 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -4,7 +4,7 @@ use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, V use serde_json::json; #[test] -fn blend_modes_accept_photoshop_string_ids_and_export_canonical_char_ids() { +fn blend_modes_accept_photoshop_string_ids_and_export_canonical_ids() { for (string_id, char_id, label) in [ ("normal", "Nrml", "Normal"), ("dissolve", "Dslv", "Dissolve"), @@ -23,7 +23,7 @@ fn blend_modes_accept_photoshop_string_ids_and_export_canonical_char_ids() { ("saturation", "Strt", "Saturation"), ("color", "Clr", "Color"), ("luminosity", "Lmns", "Luminosity"), - ("passThrough", "PsTh", "Pass Through"), + ("passThrough", "passThrough", "Pass Through"), ] { assert_eq!(blend_label(string_id), Some(label), "{string_id}"); assert_eq!(blend_ps(label), Some(char_id), "{label} remains serialized canonically"); @@ -45,6 +45,7 @@ fn layer_steps_with_string_id_blend_modes_import_with_their_mode() { ..Default::default() }], warnings: vec![], + ..Default::default() }; let imp = import(&atn::write(&file).unwrap()).unwrap(); assert!(imp.unsupported.is_empty(), "{:?}", imp.unsupported.iter().map(|u| &u.event).collect::>()); @@ -102,13 +103,13 @@ fn sample() -> AtnFile { function_key: 3, items: vec![ blur, - item("make", "Make", Some(make_levels)), + item("Mk ", "Make", Some(make_levels)), item("setd", "Set", Some(select_all)), item("select", "Select", Some(select_layer)), item("slct", "Select", Some(tool)), - item("stop", "Stop", Some(stop)), + item("Stop", "Stop", Some(stop)), item("syntheticFill", "Generative Fill", Some(fancy)), - item("play", "Play", Some(play)), + item("Ply ", "Play", Some(play)), off, ], ..Default::default() @@ -116,6 +117,7 @@ fn sample() -> AtnFile { AtnAction { name: UnicodeString::new_nul("Finish"), items: vec![item("Invs", "Inverse", None)], ..Default::default() }, ], warnings: Vec::new(), + ..Default::default() } } @@ -184,10 +186,9 @@ fn layer_comp_apply_unknown_references_are_kept_and_stop_playback() { 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::Offset { class: class("compsClass"), offset: 1 }])), + Some(D::Reference(vec![ReferenceItem::Enumerated { class: class("compsClass"), type_id: code("Ordn"), value: code("unsupported") }])), 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") }, @@ -374,11 +375,11 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(t.actions.list, first); // A step with no Photoshop equivalent fails unless skipped. - s.actions.list.push(Action::new("Ours", vec![("filter.render.clouds".into(), json!({}))])); + s.actions.list.push(Action::new("Ours", vec![("unsupported.forTest".into(), json!({}))])); let e = s.execute("actions.save", json!({"action": "Ours"})).unwrap_err().to_string(); - assert!(e.contains("filter.render.clouds"), "{e}"); + assert!(e.contains("unsupported.forTest"), "{e}"); let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap(); - assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "filter.render.clouds"}])); + assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "unsupported.forTest"}])); assert_eq!(r["set"], "Ours"); } @@ -428,7 +429,8 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { /// 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. +/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. So is +/// an adjustment edit that names its layer: the event edits the active one (#8532). #[test] fn a_setting_the_event_cannot_hold_is_refused() { for (id, params) in [ @@ -436,6 +438,8 @@ fn a_setting_the_event_cannot_hold_is_refused() { ("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"})), + ("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})), + ("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})), ] { let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(id.into(), params)])); @@ -460,6 +464,9 @@ fn settings_at_the_value_the_event_plays_still_export() { ("filter.blur.radialBlur", json!({"amount": 10})), ("filter.other.minimum", json!({"radius": 4, "preserve": "squareness"})), ("filter.other.maximum", json!({"radius": 4})), + ("layer.setAdjustment", json!({"kind": "exposure", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})), + ("layer.setAdjustment", json!({"kind": "exposure", "layer": null, "exposure": -0.75})), + ("layer.setAdjustment", json!({"kind": "exposure", "document": null, "exposure": -0.75})), // `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). @@ -473,9 +480,54 @@ fn settings_at_the_value_the_event_plays_still_export() { } } +/// Every exported command has a canonical import, including recorded full-stack navigation. #[test] +/// Each mapped command retains its parameters through its corresponding ATN descriptor. fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ + ( + "paint.gradient", + json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100, + "stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":false}), + ), + ("filter.render.clouds", json!({})), + ("filter.render.differenceClouds", json!({})), + ("filter.render.fibers", json!({"variance": 20, "strength": 7, "seed": -123})), + ("filter.render.lensFlare", json!({"brightness": 180, "centerX": 5, "centerY": -5, "lens": "moviePrime"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add","ellipse":true,"antiAlias":false})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"subtract"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"intersect"})), + ("edit.pasteSpecial.pasteInto", json!({})), + ("edit.pasteSpecial.pasteOutside", json!({})), + ( + "edit.contentAwareFill", + json!({"sampling":"rectangular","sampleAllLayers":true,"colorAdaptation":"default", + "rotationAdaptation":"none","scale":true,"mirror":false,"output":"new"}), + ), + ("edit.fade", json!({"opacity":50,"mode":"Multiply"})), + ("layer.linkLayers", json!({})), + ("layer.distribute.leftEdges", json!({})), + ("layer.distribute.horizontalCenters", json!({})), + ("layer.distribute.rightEdges", json!({})), + ("layer.distribute.topEdges", json!({})), + ("layer.distribute.verticalCenters", json!({})), + ("layer.distribute.bottomEdges", json!({})), + ("edit.definePattern", json!({})), + ("layer.layerMask.delete", json!({"_atnEvent":"RmvL"})), + ("layer.mergeLayers", json!({"_atnEvent":"MrgL"})), + ("filter.stylize.findEdges", json!({})), + ("filter.stylize.solarize", json!({})), + ("filter.pixelate.facet", json!({})), + ("filter.pixelate.fragment", json!({})), + ("filter.stylize.extrude", json!({"size":42,"depth":91,"solidFrontFaces":true,"maskIncompleteBlocks":true,"type":"pyramids","depthMode":"levelBased"})), + ("filter.stylize.traceContour", json!({"level":43,"edge":"lower"})), + ("filter.stylize.wind", json!({"method":"stagger","direction":"fromLeft"})), + ("filter.stylize.tiles", json!({"count":17,"maxOffset":23,"fill":"foreground"})), + ("filter.pixelate.colorHalftone", json!({"maxRadius":12,"channel1":-10000,"channel2":10000,"channel3":-721,"channel4":1001})), + ("filter.pixelate.mezzotint", json!({"type":"longStrokes"})), + ("filter.pixelate.mosaic", json!({"cellSize":31.5})), + ("filter.pixelate.pointillize", json!({"cellSize":19})), ("filter.blur.gaussianBlur", json!({"radius": 3.5})), ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), @@ -485,6 +537,36 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})), + ("filter.distort.pinch", json!({"amount": -30})), + ("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})), + ("filter.distort.spherize", json!({"amount": -50, "mode": "verticalOnly"})), + ("filter.distort.twirl", json!({"angle": -200})), + ( + "filter.distort.wave", + json!({"type": "square", "generators": 3, "wavelengthMin": 20, "wavelengthMax": 200, "amplitudeMin": 4, "amplitudeMax": 40, + "undefinedAreas": "wrap", "seed": 4_000_000_000_u32}), + ), + ("filter.distort.zigZag", json!({"amount": -20, "ridges": 12, "style": "aroundCenter"})), + ("filter.video.deInterlace", json!({"eliminate": "evenFields", "createBy": "duplication"})), + ("filter.video.ntscColors", json!({})), + ("filter.other.hsbHsl", json!({"inputMode": "hsl", "rowOrder": "rgb"})), + ( + "filter.other.custom", + json!({"kernel": [0, 0, 0, 0, 0, 0, -1, -1, -1, 0, 0, -1, 9, -1, 0, 0, -1, -1, -1, 0, 0, 0, 0, 0, 0], "scale": 3, "offset": -10}), + ), + ("filter.blur.average", json!({})), + ("layer.matting.removeBlackMatte", json!({})), + ("layer.matting.removeWhiteMatte", json!({})), + ("filter.stylize.oilPaint", json!({"lighting": false, "stylization": 6.5, "cleanliness": 3, "scale": 2, "bristleDetail": 7, "angle": 45, "shine": 2})), + ("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 0})), + ("select.focusArea", json!({})), + ("select.sky", json!({"sampleAllLayers": false})), + ("layer.removeBackground", json!({})), + ("channel.split", json!({})), + ("channel.mergeSpot", json!({})), + ("image.trap", json!({"width": 3})), + ("image.adjustments.matchColor", json!({"luminance": 120, "intensity": 80, "fade": 25})), ( "image.adjustments.levels", json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}), @@ -527,7 +609,7 @@ fn every_mapped_command_exports_and_imports_back() { ("tools.setColors", json!({"background": "#ffffff"})), ("image.adjustments.desaturate", json!({})), ("image.autoTone", json!({})), - ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), + ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper", "scaleStyles": true})), ("image.canvasSize", json!({"width": 100, "height": 50, "relative": true, "anchor": "topLeft", "extensionColor": "white"})), ("image.imageRotation.90cw", json!({})), ("image.rotation.arbitrary", json!({"angle": 12.5, "direction": "ccw"})), @@ -546,8 +628,32 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), + ("type.convertToShape", json!({})), + ("type.createWorkPath", json!({})), + ("filter.blur.shapeBlur", json!({})), + ("filter.blur.shapeBlur", json!({"radius": 10})), ("layer.new.group", json!({"name": "G"})), ("layer.groupLayers", json!({})), + ("layer.linkLayers", json!({"linked": false})), + ("layer.linkLayers", json!({"linked": false, "_atnNoTarget": true})), + ("layer.arrange.reverse", json!({})), + ("layer.arrange.reverse", json!({"_atnNoTarget": true})), + ("layer.selectLinkedLayers", json!({})), + ("layer.layerStyle.clear", json!({})), + ("layer.rasterize.allLayers", json!({})), + ("layer.videoLayers.newBlankVideoLayer", json!({})), + ("select.isolateLayers", json!({})), + ("type.loadDefaultTypeStyles", json!({})), + ("type.saveDefaultTypeStyles", json!({})), + ("image.analysis.recordMeasurements", json!({})), + ("file.automate.cropAndStraightenPhotos", json!({})), + ("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})), + ("layer.new.artboardFromGroup", json!({})), + ("layer.new.artboardFromGroup", json!({"background":"white","color":[1,1,1],"preset":"","guideIDs":[],"changeBackground":1})), + ( + "layer.artboard.set", + json!({"background":"custom","color":[0.25,0.5,0.75],"recordedPreset":"","guideIDs":[],"recordedRect":[2,2,6,6],"changeSizes":8,"changeBackground":1}), + ), ("layer.new.layerViaCopy", json!({})), ("layer.newFillLayer.solidColor", json!({"color": "#336699"})), ("layer.newAdjustmentLayer.hueSaturation", json!({})), @@ -562,8 +668,14 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.setProps", json!({"name": "Renamed", "opacity": 0.4, "blend": "Linear Light"})), ("layer.setProps", json!({"layer": "Shadow", "visible": false})), ("layer.setLabelColor", json!({"color": "red"})), + ("layer.showOnly", json!({"layer": "Solo"})), ("layer.select", json!({"layer": "Shadow", "mode": "add"})), ("layer.select", json!({"layer": "Shadow", "mode": "range"})), + ("layer.select", json!({"layer": {"index": 2}})), + ("layer.moveTo", json!({"target": {"index": 0}})), + ("layer.moveTo", json!({"layer": {"index": 2}, "target": {"index": 4}})), + ("layer.selectAbove", json!({})), + ("layer.selectBottom", json!({})), ("layer.selectTop", json!({})), ("layer.selectBelow", json!({})), ("layer.addBelowToSelection", json!({})), @@ -589,8 +701,13 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.pasteSpecial.pasteInPlace", json!({})), ("edit.paste", json!({})), ("edit.purge.histories", json!({})), + ("edit.checkSpelling", json!({"allLayers": true})), + ("edit.checkSpelling", json!({"allLayers": false})), ("tools.defaultColors", json!({})), - ("edit.convertToProfile", json!({"profile": "display-p3", "intent": "perceptual", "bpc": false})), + ( + "edit.convertToProfile", + json!({"profile": "display-p3", "intent": "perceptual", "bpc": false, "dither": false, "flatten": false, "rasterizePlaced": false}), + ), ("edit.assignProfile", json!({"profile": "srgb"})), ("file.close", json!({})), ("file.save", json!({})), @@ -610,17 +727,31 @@ fn every_mapped_command_exports_and_imports_back() { "file.export.saveForWebLegacy", json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}), ), + ( + "file.export.saveForWebLegacy", + json!({"folder": "/tmp/out", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}), + ), ( "file.export.saveForWebLegacy", json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}), ), ("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})), + ("image.adjustments.gradientMap", json!({"stops": [[0,[0.5,0,0]],[1,[0,1,0]]], "__atnGrMp":{"name":"Recorded","colors":[[127.5,0,0],[0,255,0]]}})), + ("image.adjustments.replaceColor", json!({"labMinimum":[40,30,40],"labMaximum":[60,35,45],"fuzziness":40,"hue":30,"saturation":0,"lightness":0})), ("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})), ("layer.smartObjects.convertToSmartObject", json!({})), ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), + ("layer.smartObjects.newSmartObjectViaCopy", json!({})), + ("layer.smartObjects.convertToLayers", json!({})), + ("layer.smartObjects.convertToEmbedded", json!({})), + ("layer.smartObjects.updateAllModifiedContent", json!({})), + ("layer.smartObjects.exportContents", json!({"path": "/tmp/contents.png"})), + ("layer.smartObjects.convertToLinked", json!({"path": "/tmp/linked.png"})), + ("layer.smartObjects.relinkToFile", json!({"path": "/tmp/relinked.png"})), + ("layer.smartObjects.replaceContents", json!({"path": "/tmp/replacement.png"})), ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), - ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})), ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), ("file.placeLinked", json!({"path": "/tmp/logo.png"})), ("file.openAs", json!({"path": "/tmp/a.psd"})), @@ -635,16 +766,74 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.createClippingMask", json!({})), ("layer.ungroupLayers", json!({})), ("layer.layerStyle.copyLayerStyle", json!({})), - ("layer.layerStyle.pasteLayerStyle", json!({})), + ("layer.layerStyle.scaleEffects", json!({"scale": 200})), + ("layer.layerStyle.createLayer", json!({})), + ("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": true})), ("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})), + ("document.activate", json!({"document": {"index": 2}})), // 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})), ("filter.gallery.spatter", json!({"sprayRadius": 7, "smoothness": 15})), + ("filter.gallery.accentedEdges", json!({"edgeWidth": 14, "edgeBrightness": 50, "smoothness": 15})), + ("filter.gallery.angledStrokes", json!({"directionBalance": 100, "strokeLength": 50, "sharpness": 10})), + ("filter.gallery.basRelief", json!({"detail": 15, "smoothness": 15, "light": "bottomLeft"})), + ("filter.gallery.chalkCharcoal", json!({"charcoalArea": 20, "chalkArea": 20, "strokePressure": 5})), + ("filter.gallery.charcoal", json!({"charcoalThickness": 7, "detail": 0, "lightDarkBalance": 100})), + ("filter.gallery.chrome", json!({"detail": 10, "smoothness": 10})), + ("filter.gallery.coloredPencil", json!({"pencilWidth": 24, "strokePressure": 15, "paperBrightness": 50})), + ( + "filter.gallery.conteCrayon", + json!({"foregroundLevel": 15, "backgroundLevel": 15, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}), + ), + ("filter.gallery.craquelure", json!({"crackSpacing": 100, "crackDepth": 10, "crackBrightness": 10})), + ("filter.gallery.crosshatch", json!({"strokeLength": 50, "sharpness": 20, "strength": 3})), + ("filter.gallery.cutout", json!({"numberOfLevels": 8, "edgeSimplicity": 10, "edgeFidelity": 3})), + ("filter.gallery.darkStrokes", json!({"balance": 10, "blackIntensity": 10, "whiteIntensity": 10})), + ("filter.gallery.diffuseGlow", json!({"graininess": 10, "glowAmount": 20, "clearAmount": 20})), + ("filter.gallery.dryBrush", json!({"brushSize": 10, "brushDetail": 10, "texture": 3})), + ("filter.gallery.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 0})), + ("filter.gallery.fresco", json!({"brushSize": 10, "brushDetail": 10, "texture": 3})), + ("filter.gallery.glass", json!({"distortion": 20, "smoothness": 15, "texture": "canvas", "scaling": 200, "invert": true})), + ("filter.gallery.glowingEdges", json!({"edgeWidth": 14, "edgeBrightness": 20, "smoothness": 15})), + ("filter.gallery.graphicPen", json!({"strokeLength": 1, "lightDarkBalance": 100, "strokeDirection": "horizontal"})), + ("filter.gallery.halftonePattern", json!({"size": 12, "contrast": 50, "patternType": "circle"})), + ("filter.gallery.inkOutlines", json!({"strokeLength": 50, "darkIntensity": 50, "lightIntensity": 50})), + ("filter.gallery.mosaicTiles", json!({"tileSize": 100, "groutWidth": 15, "lightenGrout": 10})), + ("filter.gallery.neonGlow", json!({"glowSize": 24, "glowBrightness": 50, "glowColor": "#336699"})), + ("filter.gallery.notePaper", json!({"imageBalance": 50, "graininess": 20, "relief": 25})), + ("filter.gallery.oceanRipple", json!({"rippleSize": 15, "rippleMagnitude": 20})), + ("filter.gallery.paintDaubs", json!({"brushSize": 50, "sharpness": 40, "brushType": "darkRough"})), + ("filter.gallery.paletteKnife", json!({"strokeSize": 50, "strokeDetail": 1, "softness": 10})), + ("filter.gallery.patchwork", json!({"squareSize": 10, "relief": 25})), + ("filter.gallery.photocopy", json!({"detail": 24, "darkness": 50})), + ("filter.gallery.plaster", json!({"imageBalance": 50, "smoothness": 15, "light": "bottomLeft"})), + ("filter.gallery.plasticWrap", json!({"highlightStrength": 20, "detail": 15, "smoothness": 15})), + ("filter.gallery.posterEdges", json!({"edgeThickness": 10, "edgeIntensity": 10, "posterization": 6})), + ("filter.gallery.reticulation", json!({"density": 50, "foregroundLevel": 50, "backgroundLevel": 50})), + ( + "filter.gallery.roughPastels", + json!({"strokeLength": 40, "strokeDetail": 20, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}), + ), + ("filter.gallery.smudgeStick", json!({"strokeLength": 10, "highlightArea": 20, "intensity": 0})), + ("filter.gallery.sponge", json!({"brushSize": 10, "definition": 25, "smoothness": 15})), + ("filter.gallery.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})), + ("filter.gallery.stainedGlass", json!({"cellSize": 50, "borderThickness": 20, "lightIntensity": 10})), + ("filter.gallery.stamp", json!({"lightDarkBalance": 50, "smoothness": 50})), + ("filter.gallery.sumiE", json!({"strokeWidth": 15, "strokePressure": 15, "contrast": 40})), + ("filter.gallery.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true})), + ("filter.gallery.tornEdges", json!({"imageBalance": 50, "smoothness": 15, "contrast": 25})), + ( + "filter.gallery.underpainting", + json!({"brushSize": 40, "textureCoverage": 40, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}), + ), + ("filter.gallery.waterPaper", json!({"fiberLength": 50, "brightness": 100, "contrast": 100})), + ("filter.gallery.watercolor", json!({"brushDetail": 14, "shadowIntensity": 10, "texture": 3})), + ("filter.gallery.grain", json!({"intensity": 100, "contrast": 100, "grainType": "contrasty"})), ( "select.refineEdge", json!({"radius": 0, "smartRadius": false, "smooth": 2, "feather": 0.5, "contrast": 15, "shiftEdge": -100, "decontaminate": false, "output": "layerMask"}), @@ -654,14 +843,28 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.undo", json!({})), ("edit.redo", json!({})), ("layer.select", json!({"layer": "YELLOW", "mode": "remove"})), + ("layer.select", json!({"layer": "Or paste photocopy texture here", "mode": "range"})), ("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})), ("select.loadSelection", json!({"channel": "targeted"})), ("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})), ("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})), + ("layer.newFillLayer.pattern", json!({"pattern": "b7a3c1d2-0000-4000-8000-000000000001", "patternName": "Twin", "scale": 50})), ("image.mode.bitmap", json!({"method": "threshold"})), - ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), + ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true, "scaleStyles": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), + // COPYCAT, AJ_Actions2023 and the Line and Color tutorial set. + ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), + ("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})), + ("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})), + ("layer.createClippingMask", json!({})), + ("layer.lockLayers", json!({"transparency": true})), + ("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})), + ("history.clear", json!({})), + ("history.newSnapshot", json!({"name": "CopyCat - action result", "from": "fullDocument", "replaceExisting": true})), ("layerComp.apply", json!({"comp": "Comp 1"})), + ("layerComp.apply", json!({"comp": {"index": 2}})), + ("layerComp.apply", json!({"comp": {"id": 7}})), + ("layerComp.apply", json!({"comp": {"ordinal": "Nxt"}})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); @@ -674,8 +877,29 @@ fn every_mapped_command_exports_and_imports_back() { match (e.from)(item.descriptor.as_ref().unwrap()) { Mapped::Steps(steps) => { assert_eq!(steps.len(), 1, "{id}: {steps:?}"); + let navigation = match *id { + "layer.selectAbove" => Some(("Frwr", false)), + "layer.selectBelow" => Some(("Bckw", false)), + "layer.selectTop" => Some(("Frnt", false)), + "layer.selectBottom" => Some(("Back", false)), + "layer.addAboveToSelection" => Some(("Frwr", true)), + "layer.addBelowToSelection" => Some(("Bckw", true)), + _ => None, + }; + if let Some((ordinal, add)) = navigation { + let mut expected = json!({"layer": {"ordinal": ordinal}}); + if add { + expected["mode"] = json!("add"); + } + assert_eq!(steps, vec![("layer.select".into(), expected)]); + continue; + } assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); + if *id == "paint.gradient" { + // Executable values match; independent public tests verify the source carrier. + back.as_object_mut().unwrap().remove("__atnClassicGradient"); + } normalise(&mut back); if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); @@ -685,6 +909,13 @@ fn every_mapped_command_exports_and_imports_back() { Mapped::Unsupported { .. } => panic!("{id} does not import back"), } } + // Partial native backgrounds need no recorded preset, guide IDs or last-used color. + for (background, imported_background) in [("white", "white"), ("black", "black"), ("transparent", "transparent"), ("custom", "white")] { + let item = export_item("layer.new.artboardFromGroup", &json!({"background":background}), "S").unwrap(); + let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("Artboard did not import") }; + assert_eq!(steps, [("layer.new.artboardFromGroup".into(), json!({"background":imported_background,"color":[1.0,1.0,1.0],"preset":"","guideIDs":[]}))]); + assert_eq!(export_item(&steps[0].0, &steps[0].1, "S").unwrap().descriptor, item.descriptor); + } // Every command the table claims to export has a case above. let covered: std::collections::BTreeSet<&str> = cases.iter().map(|(id, _)| *id).collect(); let mut missing: Vec<&str> = EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| !covered.contains(id)).collect(); @@ -729,8 +960,6 @@ fn every_mapped_command_exports_and_imports_back() { "layer.newAdjustmentLayer.vibrance", "layer.rasterize.layer", "layer.renameLayer", - "layer.selectAbove", - "layer.selectBottom", "layer.setAdjustment", "select.allLayers", "select.deselectLayers", @@ -764,6 +993,8 @@ fn every_mapped_command_exports_and_imports_back() { "filter.sharpen.sharpen", "filter.sharpen.sharpenEdges", "filter.sharpen.sharpenMore", + // Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged). + "filter.cameraRaw", ]; let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect(); assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}"); @@ -826,6 +1057,27 @@ fn bucket_keeps_an_explicit_color_through_atn() { } } +/// A Stroke step that leaves settings at their defaults still writes them (#8303): Photoshop +/// opens the Stroke dialog for a descriptor without its blend mode, even with dialogs off. +#[test] +fn stroke_writes_the_defaults_it_leaves_out() { + let exported = |params: Value| export_item("edit.stroke", ¶ms, "S").and_then(|item| item.descriptor).unwrap(); + let d = exported(json!({"width": 2, "location": "inside", "color": "#d03060", "opacity": 63})); + assert_eq!(get_enum(&d, "Md ").as_deref(), Some("Nrml"), "{d:?}"); + assert_eq!(get_enum(&d, "Lctn").as_deref(), Some("Insd"), "an explicit setting is kept"); + assert_eq!(get_num(&d, "Opct"), Some(63.0)); + let d = exported(json!({"width": 2})); + assert_eq!(get_enum(&d, "Lctn").as_deref(), Some("Cntr"), "{d:?}"); + assert_eq!(get_num(&d, "Opct"), Some(100.0)); + assert_eq!(get_enum(&d, "Md ").as_deref(), Some("Nrml")); + assert!(get_bool(&d, "PrsT") == Some(false), "{d:?}"); + // An explicit mode is kept, and the step imports back to the same stroke. + let p = json!({"width": 3, "location": "outside", "mode": "Multiply", "opacity": 40, "color": "#000000", "preserveTransparency": true}); + let d = exported(p.clone()); + let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"Strk")).unwrap().from)(&d) else { panic!("{d:?}") }; + assert_eq!(steps[0].1, json!({"width": 3.0, "location": "outside", "mode": "Multiply", "opacity": 40.0, "color": "#000000", "preserveTransparency": true})); +} + /// `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")?; @@ -952,6 +1204,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], warnings: vec![], + ..Default::default() }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); @@ -975,6 +1228,7 @@ fn save_preserves_the_kind_of_unsupported_atn_events() { ..Default::default() }], warnings: vec![], + ..Default::default() }; let mut session = Session::new(); session.execute("actions.load", json!({"data": b64(&atn::write(&source).unwrap())})).unwrap(); @@ -1037,7 +1291,7 @@ fn retouching_set_events_map_to_their_commands() { 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("Mk ", "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 ")))), @@ -1050,6 +1304,7 @@ fn retouching_set_events_map_to_their_commands() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], warnings: vec![], + ..Default::default() }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let steps = &imp.actions[0].steps; @@ -1342,6 +1597,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], warnings: vec![], + ..Default::default() }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let steps = &imp.actions[0].steps; @@ -1420,8 +1676,8 @@ fn remaining_events_import_in_their_photoshop_shapes() { let items = vec![ item("conditional", "If", Some(cond)), item("imageSize", "Image Size", Some(size)), - item("fill", "Fill", Some(bucket_px)), - item("fill", "Fill", Some(bucket_pct)), + item("Fl ", "Fill", Some(bucket_px)), + item("Fl ", "Fill", Some(bucket_pct)), item("placeEvent", "Place", Some(place)), item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))), ]; @@ -1430,6 +1686,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], warnings: vec![], + ..Default::default() }; let rest = import(&atn::write(&file).unwrap()).unwrap(); let rest_steps = &rest.actions[0].steps; @@ -1468,19 +1725,21 @@ fn remaining_events_import_in_their_photoshop_shapes() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], warnings: vec![], + ..Default::default() }; 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"] + ["edit.transform", "select.transformSelection", "layer.moveTo", "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[2].1, json!({"target": {"index": 0}})); 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[1].1, json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})); assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"})); @@ -1532,6 +1791,8 @@ fn select_document_by_offset_imports_plays_and_saves_back() { 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(); + // Reorder tabs before playback: the source remains the previous *opened* document. + s.execute("document.move", json!({"document": 1, "to": 0})).unwrap(); let r = s.execute("actions.load", json!({"data": data})).unwrap(); assert_eq!(r["unsupported"], json!([]), "{r}"); let steps = &s.actions.list[0].steps; @@ -1545,7 +1806,7 @@ fn select_document_by_offset_imports_plays_and_saves_back() { 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"); + assert_eq!(s.documents()[1].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(); @@ -1573,9 +1834,8 @@ fn select_document_by_offset_imports_plays_and_saves_back() { 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); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(one.active().unwrap().doc.layers.len(), 2, "offsets wrap back to the only open document"); } fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { @@ -1584,6 +1844,7 @@ fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { expanded: false, actions: actions.into_iter().map(|(n, items)| AtnAction { name: UnicodeString::new_nul(n), items, ..Default::default() }).collect(), warnings: vec![], + ..Default::default() }; b64(&atn::write(&file).unwrap()) } @@ -2070,7 +2331,7 @@ fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() { } /// #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, +/// does. An arbitrary coordinate reference, 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() { @@ -2082,7 +2343,6 @@ fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry( 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}), @@ -2126,15 +2386,13 @@ fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { } } -/// #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 +/// #7512, #7513, #7514: a New Layer step with a colour label, neutral fill or a position below +/// the active layer isn't exported. Invalid clipping values are also refused. 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"}), @@ -2268,6 +2526,54 @@ fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() { } } +/// Levels, like Color Balance and Hue/Saturation: the descriptor writes both ends of the Input and +/// of the Output range, filling the end an edit did not name with 0 or 255, so replaying +/// `inBlack: 31` alone would also reset a retained Input White (#8530). Strict export refuses it; +/// a pair given whole, or none of it, still exports and round-trips. +#[test] +fn partial_levels_edits_refuse_strict_export() { + let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() }; + for p in [ + json!({"kind": "levels", "inBlack": 31}), + json!({"kind": "levels", "inWhite": 239}), + json!({"kind": "levels", "outBlack": 10}), + json!({"kind": "levels", "outWhite": 240}), + json!({"kind": "levels", "inBlack": 31, "inWhite": 239, "outWhite": 240}), + json!({"kind": "levels", "red": {"inBlack": 2}}), + json!({"kind": "levels", "inBlack": 1, "inWhite": 250, "blue": {"outBlack": 3}}), + // A value of the wrong shape is no value: the other end is then alone. + json!({"kind": "levels", "inBlack": 31, "inWhite": "239"}), + // CodeRabbit on batch 11: both ends of the wrong shape, or a channel that isn't an object, + // would be written as nothing too. + json!({"kind": "levels", "inBlack": "31", "inWhite": "239"}), + json!({"kind": "levels", "gamma": "1.2"}), + json!({"kind": "levels", "red": 5}), + json!({"kind": "levels", "green": [0, 255]}), + json!({"kind": "levels", "blue": {"outBlack": "3", "outWhite": "250"}}), + ] { + let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err(); + assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}"); + let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}"); + } + for p in [ + json!({"kind": "levels", "inBlack": 31, "inWhite": 239}), + json!({"kind": "levels", "outBlack": 10, "outWhite": 240}), + json!({"kind": "levels", "inBlack": 31, "inWhite": 239, "outBlack": 10, "outWhite": 240, "gamma": 1.2}), + json!({"kind": "levels", "gamma": 0.8}), + json!({"kind": "levels", "inBlack": 1, "inWhite": 250, "red": {"inBlack": 2, "inWhite": 200}}), + ] { + let (bytes, skipped) = export("Audit8", &[&one(p.clone())], &[], false).unwrap_or_else(|e| panic!("{p}: {e}")); + assert!(skipped.is_empty(), "{p}"); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + let (id, back) = steps.first().unwrap(); + assert_eq!(id, "layer.setAdjustment"); + for k in ["inBlack", "inWhite", "outBlack", "outWhite"] { + assert_eq!(back.get(k), p.get(k), "{p}: {k} in {back}"); + } + } +} + /// 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] @@ -2294,6 +2600,7 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }], warnings: vec![], + ..Default::default() }; let imp = import(&atn::write(&file).unwrap()).unwrap(); assert!(imp.unsupported.is_empty()); @@ -2706,12 +3013,105 @@ fn adjustment_layer_reset_still_resets() { ..Default::default() }], warnings: vec![], + ..Default::default() }; 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"})); } +/// Photoshop's Properties panel records an edit of an existing Vibrance layer with the string +/// class `vibrance`, not the `Vbrn` code: both import as the same `layer.setAdjustment` (#8534). +#[test] +fn adjustment_layer_set_accepts_the_vibrance_string_class() { + let set = |class: &str| { + let t = desc(class).with("temperature", long(0.0)).with("tint", long(0.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0)); + desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t)) + }; + let file = AtnFile { + name: UnicodeString::new_nul("Vibrance"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![item("set", "Set", Some(set("vibrance"))), item("set", "Set", Some(set("Vbrn")))], + ..Default::default() + }], + warnings: vec![], + ..Default::default() + }; + let action = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0); + assert_eq!(action.steps[0].0, "layer.setAdjustment", "{:?}", action.steps); + assert_eq!(action.steps[0].1, json!({"vibrance": 28, "saturation": -7, "kind": "vibrance"})); + assert_eq!(action.steps[0], action.steps[1]); +} + +/// A Vibrance Set and Make as Photoshop records them (class `vibrance`) save back with that +/// class, not the `Vbrn` code Photoshop refuses (#8383); old PhotoCraft files with `Vbrn` still +/// load and now save with `vibrance` too. +#[test] +fn vibrance_adjustment_layer_steps_save_with_the_string_class() { + let set = |class: &str| { + let t = desc(class).with("temperature", long(0.0)).with("tint", long(0.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0)); + desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t)) + }; + let make = |class: &str| { + let t = desc(class).with("vibrance", long(10.0)).with("Strt", long(5.0)); + desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t)))) + }; + let data = set_of( + "Vibrance", + vec![( + "A", + vec![ + item("set", "Set", Some(set("vibrance"))), + item("Mk ", "Make", Some(make("vibrance"))), + item("set", "Set", Some(set("Vbrn"))), + item("Mk ", "Make", Some(make("Vbrn"))), + ], + )], + ); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let steps = &s.actions.list[0].steps; + assert_eq!(steps[1], ("layer.newAdjustmentLayer.vibrance".to_string(), json!({"vibrance": 10, "saturation": 5})), "{steps:?}"); + assert_eq!((&steps[0], &steps[1]), (&steps[2], &steps[3]), "the old `Vbrn` class imports the same"); + let saved = s.execute("actions.save", json!({"set": "Vibrance"})).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(); + let items = &file.actions[0].items; + assert_eq!(items.len(), 4); + for pair in items.chunks(2) { + let t = get_obj(pair[0].descriptor.as_ref().unwrap(), "T ").unwrap(); + assert!(t.class_id.is("vibrance"), "Set class {:?}", t.class_id); + let ty = get_obj(pair[1].descriptor.as_ref().unwrap(), "Usng").and_then(|u| get_obj(u, "Type")).unwrap(); + assert!(ty.class_id.is("vibrance"), "Make class {:?}", ty.class_id); + } + let mut reloaded = Session::new(); + reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(reloaded.actions.list[0].steps, s.actions.list[0].steps); +} + +/// Our Vibrance has no `temperature` or `tint`: a recorded Vibrance Set or Make with either one +/// non-zero stays a kept Photoshop step and saves back unchanged instead of losing them. +#[test] +fn vibrance_steps_with_temperature_or_tint_save_back_unchanged() { + let t = || desc("vibrance").with("temperature", long(0.0)).with("tint", long(12.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0)); + let set = desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t())); + let warm = desc("vibrance").with("temperature", long(-20.0)).with("vibrance", long(5.0)); + let make = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(warm)))); + let data = set_of("Vibrance", vec![("A", vec![item("set", "Set", Some(set.clone())), item("Mk ", "Make", Some(make.clone()))])]); + let mut s = Session::new(); + s.execute("actions.load", json!({"data": data})).unwrap(); + assert!(s.actions.list[0].steps.iter().all(|(id, _)| id == "actions.photoshopStep"), "{:?}", s.actions.list[0].steps); + let saved = s.execute("actions.save", json!({"set": "Vibrance"})).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[0].descriptor, Some(set)); + assert_eq!(file.actions[0].items[1].descriptor, Some(make)); +} + /// 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] @@ -2842,8 +3242,8 @@ fn image_size_resolution_only_does_not_gain_keep_print_size() { #[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"}), + json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true, "scaleStyles": true}), + json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest", "scaleStyles": true}), ] { assert_eq!(image_size_round_trip(¶ms), Some(params.clone())); } @@ -2879,7 +3279,7 @@ fn recorded_layer_select_exports_to_atn() { } #[test] -fn select_rect_mode_strict_export_rejects_unrepresentable_modes() { +fn select_rect_modes_export_without_becoming_replace() { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); @@ -2888,22 +3288,23 @@ fn select_rect_mode_strict_export_rejects_unrepresentable_modes() { 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"); + for (mode, event) in [("add", "AddT"), ("subtract", "SbtF"), ("intersect", "IntW")] { + s.actions.list.push(Action::new(mode, vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":mode}))])); + let before = s.actions.list.clone(); + let saved = s.execute("actions.save", json!({"action":mode})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].event.id(), event); + assert_eq!(import(&bytes).unwrap().actions[0].steps[0].1["mode"], mode); + assert_eq!(s.actions.list, before); + } - // "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"); + s.actions.list.push(Action::new("Unsupported", vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":"xor"}))])); + let before = s.actions.list.clone(); + assert!(s.execute("actions.save", json!({"action":"Unsupported"})).is_err()); + assert_eq!(s.actions.list, before, "rejected export must leave actions unchanged"); // With skipUnsupported: true, unrepresentable modes are reported in skipped - let skipped = s.execute("actions.save", json!({"action": "AddAct", "skipUnsupported": true})).unwrap(); + let skipped = s.execute("actions.save", json!({"action": "Unsupported", "skipUnsupported": true})).unwrap(); assert_eq!(skipped["steps"], 0); assert_eq!(skipped["skipped"][0]["id"], "select.rect"); } @@ -3000,12 +3401,15 @@ fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() { 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}"); + assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none", "scaleStyles": true})), "{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"}))); + assert_eq!( + back(json!({"width": 12, "height": 10, "resample": "bicubic"})), + Some(json!({"width": 12, "height": 10, "resample": "bicubic", "scaleStyles": true})) + ); } /// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written @@ -3051,12 +3455,207 @@ fn hue_saturation_uses_photoshops_hue_key() { assert_eq!(p["hue"], json!(30), "{p}"); } +/// #6745: a recorded Camera Raw Filter step (`Adobe Camera Raw Filter`, the layout of 62 real +/// recordings) imports as `filter.cameraRaw` with its sliders, colour mixer, grading wheels, +/// vignette style and point curves, and plays. One that uses the Calibration panel (or noise +/// settings the command lacks) stays a Photoshop step rather than replaying a different look. +#[test] +fn camera_raw_filter_steps_import_as_filter_camera_raw() { + let int = |v: i32| D::Integer(v); + // Every step carries the version keys a recording has (a verified process version). + let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(5)).with("PrVe", int(184_549_376)); + let mut d = cr() + .with("WBal", enm("WBal", "Cst ")) + .with("Temp", int(12)) + .with("Tint", int(-4)) + .with("Ex12", D::Double(0.35)) + .with("Cr12", int(10)) + .with("Hi12", int(-30)) + .with("Sh12", int(25)) + .with("Cl12", int(8)) + .with("Vibr", int(15)) + .with("PC_1", int(20)) + .with("PC_2", int(50)) + .with("PC_3", int(80)) + .with("STSH", int(210)) + .with("STSS", int(12)) + .with("CgSL", int(0)) + .with("CgBl", int(60)) + .with("STB ", int(-10)) + .with("Shrp", int(40)) + .with("ShpR", D::Double(1.2)) + .with("LNR ", int(20)) + .with("LNRC", int(0)) + .with("CNR ", int(25)) + .with("CNRS", int(50)) + .with("PCVA", int(-20)) + .with("PCVS", int(2)) + .with("Crv ", D::List([0, 0, 64, 50, 255, 255].map(int).to_vec())) + .with("CrvR", D::List([0, 0, 255, 255].map(int).to_vec())); + for (k, v) in ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().zip(1..) { + d = d.with(&format!("HA_{k}"), int(v)); + } + d = d.with("SA_O", int(-15)); + let calibrated = cr().with("Ex12", D::Double(0.2)).with("RHue", int(8)); + let smoothed = cr().with("CNR ", int(25)).with("CNRS", int(70)); + let auto_wb = cr().with("WBal", enm("WBal", "Atmt")).with("Temp", int(5)); + let partial_splits = cr().with("PC_1", int(20)); + let odd_curve = cr().with("Crv ", int(3)); + // Review of #8714: values that would replay differently, or fail to play, stay Photoshop steps. + let reversed_splits = cr().with("PC_1", int(80)).with("PC_2", int(50)).with("PC_3", int(20)); + let split_past_100 = cr().with("PC_1", int(20)).with("PC_2", int(50)).with("PC_3", int(120)); + let one_point_curve = cr().with("Crv ", D::List([0, 0].map(int).to_vec())); + let unordered_curve = cr().with("CrvG", D::List([0, 0, 128, 90, 64, 50, 255, 255].map(int).to_vec())); + let text_contrast = cr().with("Cr12", D::Text(UnicodeString::new_nul("ten"))); + let text_hue = cr().with("HA_R", D::Text(UnicodeString::new_nul("red"))); + let text_grade = cr().with("STSH", D::Text(UnicodeString::new_nul("blue"))); + let exposure_past_5 = cr().with("Ex12", D::Double(6.0)); + let negative_sharpening = cr().with("Shrp", int(-10)); + // CodeRabbit on batch 11: a process version the PSD mapping hasn't verified renders differently. + let unknown_version = + desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(4)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2)); + let no_version_number = desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2)); + let mismatched_pair = + desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(6)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2)); + let no_camera_raw_version = desc("null").with("PrVN", int(5)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2)); + let items = vec![ + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(d)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(calibrated)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(smoothed)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(auto_wb)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(partial_splits)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(odd_curve)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(reversed_splits)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(split_past_100)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(one_point_curve)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(unordered_curve)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_contrast)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_hue)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_grade)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(exposure_past_5)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(negative_sharpening)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(unknown_version)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(no_version_number)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(mismatched_pair)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(no_camera_raw_version)), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Looks"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("Warm"), items, ..Default::default() }], + warnings: vec![], + ..Default::default() + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = &imp.actions[0].steps; + let (id, p) = &steps[0]; + assert_eq!(id, "filter.cameraRaw"); + // The recorded descriptor rides along, so Save Actions can write the step back. + let mut p = p.clone(); + assert!(p.as_object_mut().unwrap().remove("__atnDescriptor").is_some(), "{p}"); + assert_eq!( + p, + json!({ + "temperature": 12, "tint": -4, "exposure": 0.35, "contrast": 10, "highlights": -30, "shadows": 25, + "clarity": 8, "vibrance": 15, "curveSplits": [20, 50, 80], + "hslHue": [1, 2, 3, 4, 5, 6, 7, 8], "hslSat": [0, -15, 0, 0, 0, 0, 0, 0], + "gradeShadows": {"hue": 210, "sat": 12, "lum": 0}, "gradeBlending": 60, "gradeBalance": -10, + "sharpenAmount": 40, "sharpenRadius": 1.2, "noiseLuminance": 20, "noiseColor": 25, + "vignetteAmount": -20, "vignetteStyle": "colorPriority", "pointCurve": [[0, 0], [64, 50], [255, 255]] + }) + ); + for (k, (id, _)) in steps.iter().enumerate().skip(1) { + assert_eq!(id, PHOTOSHOP_STEP, "step {k}"); + } + assert_eq!(imp.unsupported.iter().map(|u| u.step).collect::>(), (1..=18).collect::>()); + // The imported step plays. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 12})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s.execute(id, p.clone()).unwrap(); +} + +/// Review of #8714: a Camera Raw Filter step that was loaded saves back as the recorded step, +/// byte for byte, as it did while it was a kept Photoshop step; once edited (or when PhotoCraft +/// recorded it) Save Actions refuses it as before. The loaded step plays. +#[test] +fn loaded_camera_raw_filter_steps_save_back_unchanged() { + let recorded = desc("null") + .with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))) + .with("PrVN", D::Integer(5)) + .with("PrVe", D::Integer(184_549_376)) + .with("WBal", enm("WBal", "Cst ")) + .with("Ex12", D::Double(0.35)) + .with("Cr12", D::Integer(10)) + .with("Crv ", D::List([0, 0, 64, 50, 255, 255].map(D::Integer).to_vec())); + let file = one_action(vec![item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(recorded.clone()))]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + assert_eq!(s.actions.list[0].steps[0].0, "filter.cameraRaw"); + let saved_step = |r: &Value| { + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + saved.actions[0].items.first().map(|i| (i.event.clone(), i.descriptor.clone())) + }; + // Strict save, and the Actions panel's save (which skips what can't be written). + for skip in [false, true] { + let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": skip})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{r}"); + assert_eq!(saved_step(&r), Some((AtnEvent::name("Adobe Camera Raw Filter"), Some(recorded.clone())))); + } + // It plays. + s.execute("file.new", json!({"width": 16, "height": 12})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + assert!(played.get("failed").is_none(), "{played}"); + // Edited, it no longer matches what was recorded. + let original = s.actions.list[0].steps[0].1.clone(); + s.actions.list[0].steps[0].1["exposure"] = json!(1.5); + let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); + assert!(e.contains("filter.cameraRaw"), "{e}"); + // Recorded by PhotoCraft: no descriptor to write back. + s.actions.list[0].steps[0].1 = original; + s.actions.list[0].steps[0].1.as_object_mut().unwrap().remove("__atnDescriptor"); + let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap(); + assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "{r}"); +} + +/// Review of #8714: a Camera Raw Filter step with a key the table doesn't map (an Upright +/// setting, a profile name, any other panel) or with no settings at all stays a Photoshop step +/// and is reported, rather than replaying without that processing. +#[test] +fn camera_raw_filter_steps_with_unknown_keys_stay_photoshop_steps() { + let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", D::Integer(5)).with("PrVe", D::Integer(184_549_376)); + let upright = cr().with("Ex12", D::Double(0.2)).with("PerV", D::Integer(20)); + let profile = cr().with("Cr12", D::Integer(10)).with("LookName", D::Text(UnicodeString::new_nul("B&W 05"))); + let file = one_action(vec![ + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(upright.clone())), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(profile)), + item("Adobe Camera Raw Filter", "Camera Raw Filter", None), + item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(cr().with("Ex12", D::Double(0.2)))), + ]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + let unsupported: Vec = loaded["unsupported"].as_array().unwrap().iter().map(|u| u["step"].as_u64().unwrap()).collect(); + assert_eq!(unsupported, [0, 1, 2], "{loaded}"); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.cameraRaw"]); + // Kept, it still saves back as recorded. + let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, Some(upright)); +} + fn one_action(items: Vec) -> AtnFile { AtnFile { name: UnicodeString::new_nul("Saves"), expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], warnings: vec![], + ..Default::default() } } @@ -3095,7 +3694,9 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file( // action was played on. let file = one_action(vec![ item("save", "Save", Some(save_as("PNGF", "/out/folder"))), - item("save", "Save", Some(save_as("PNGF", "/out/v1.png").with("DIDr", D::Boolean(true)))), + // Save a Copy into a folder under the document's own name has no PhotoCraft step. + item("save", "Save", Some(save_as("PNGF", "/out").with("DIDr", D::Boolean(true)).with("Cpy ", D::Boolean(true)))), + item("save", "Save", Some(save_as("Not a format", "/out").with("DIDr", D::Boolean(true)))), item("save", "Save", Some(save_as("PNGF", "/out/result.webp"))), item("save", "Save", Some(save_as("GIFf", "/out/result.gif"))), item("save", "Save", Some(desc("null").with("As ", D::Descriptor(desc("PNGF"))))), @@ -3107,7 +3708,7 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file( ]); let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP; 8]); + assert_eq!(ids, [PHOTOSHOP_STEP; 9]); assert!(imp.actions[0].disabled.is_empty(), "kept saves stay on, so playback stops on them"); // The kept steps are written back byte for byte. let a = &imp.actions[0]; @@ -3119,6 +3720,39 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file( } } +#[test] +fn save_as_into_a_folder_imports_as_a_folder_step_and_saves_back() { + // #5305: "save in directory" (`DIDr`), recorded when Photoshop's suggested name is kept: + // `In` is the folder, and each played document saves under its own name there. + let file = one_action(vec![ + item("save", "Save", Some(save_as("PNGF", "/out/web").with("DIDr", D::Boolean(true)))), + item("save", "Save", Some(save_as("JPEG", "C:\\shots").with("DIDr", D::Boolean(true)))), + ]); + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = vec![ + ("file.saveAs".to_string(), json!({"folder": "/out/web", "extension": "png"})), + ("file.saveAs".to_string(), json!({"folder": "C:\\shots", "extension": "jpg"})), + ]; + assert_eq!(imp.actions[0].steps, steps); + assert!(imp.unsupported.is_empty()); + // Saved back as the same "save in directory" steps. + let (bytes, skipped) = export("Saves", &[&imp.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let back = atn::parse(&bytes).unwrap(); + for (out, orig) in back.actions[0].items.iter().zip(&file.actions[0].items) { + let (out, orig) = (out.descriptor.as_ref().unwrap(), orig.descriptor.as_ref().unwrap()); + for key in ["As ", "In ", "DIDr"] { + assert_eq!(out.get(key), orig.get(key), "{key}"); + } + } + assert_eq!(import(&bytes).unwrap().actions[0].steps, steps); + // A folder step without its extension, or as a copy, has no Photoshop event. + let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"folder": "/out"}))]); + assert!(export("Saves", &[&odd], &[], false).is_err()); + let copy = Action::new("C", vec![("file.saveACopy".to_string(), json!({"folder": "/out", "extension": "png"}))]); + assert!(export("Saves", &[©], &[], false).is_err()); +} + #[test] fn saves_round_trip_through_photoshop_events() { let steps = vec![ @@ -3155,6 +3789,7 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { expanded: false, actions: vec![AtnAction { name: UnicodeString::new_nul(&long), items: unknown(50), ..Default::default() }], warnings: vec![], + ..Default::default() }; let imp = import(&atn::write(&file).unwrap()).unwrap(); assert_eq!(imp.set.chars().count(), MAX_NAME_CHARS); @@ -3163,3 +3798,2283 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// A Filter Gallery step as Photoshop records it: `GEfc` with the effect in `GEfk`, or the +/// effect's own event carrying the same key. +fn gallery_step(event: &str, effect: &str, d: Descriptor) -> Mapped { + let d = d.with("GEfk", enm("GEft", effect)); + let event = AtnEvent::Code(event.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{effect} is unknown")).from)(&d) +} + +fn gallery_params(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("kept"), + } +} + +/// The Filter Gallery effects load with Photoshop's defaults for missing keys and its clamping, +/// from `GEfc` or from the effect's own event, play, and save back as `GEfc` with every key in +/// recorded order (#10592). +#[test] +fn gallery_effect_steps_load_clamp_play_and_save_back() { + let edges = desc("null").with("EdgW", D::Integer(3)).with("EdgB", D::Integer(20)).with("Smth", D::Integer(7)); + assert_eq!( + gallery_params(gallery_step("GEfc", "AccE", edges.clone())), + ("filter.gallery.accentedEdges".into(), json!({"edgeWidth": 3, "edgeBrightness": 20, "smoothness": 7})) + ); + // Run from its own menu item, the effect's id is the event. + assert_eq!(gallery_params(gallery_step("AccE", "AccE", edges.clone())).0, "filter.gallery.accentedEdges"); + // Missing keys take Photoshop's defaults; enumerations, booleans and colours map. + assert_eq!(gallery_params(gallery_step("GEfc", "BsRl", desc("null"))).1, json!({"detail": 13, "smoothness": 3, "light": "bottom"})); + let pastels = desc("null").with("TxtT", enm("TxtT", "TxBr")).with("LghD", enm("LghD", "LDTR")).with("InvT", D::Boolean(true)); + let p = gallery_params(gallery_step("GEfc", "RghP", pastels)).1; + assert_eq!((p["texture"].as_str(), p["light"].as_str(), p["invert"].as_bool()), (Some("brick"), Some("topRight"), Some(true))); + let glow = desc("null").with("Clr ", D::Descriptor(color_desc("#33cc66").unwrap())); + assert_eq!(gallery_params(gallery_step("GEfc", "NGlw", glow)).1["glowColor"], "#33cc66"); + // Out-of-range values are clamped as Photoshop clamps them. + let wide = desc("null").with("EdgW", D::Integer(99)).with("EdgB", D::Integer(-5)); + let p = gallery_params(gallery_step("GEfc", "AccE", wide)).1; + assert_eq!((p["edgeWidth"].as_i64(), p["edgeBrightness"].as_i64()), (Some(14), Some(0))); + + // Saved back as `GEfc`, with `GEfk` first and every key in recorded order. + let item = export_item("filter.gallery.accentedEdges", &json!({"edgeWidth": 3, "edgeBrightness": 20, "smoothness": 7}), "S").unwrap(); + assert_eq!(item.event.id(), "GEfc"); + let mut expected = vec![(Id::new("GEfk"), enm("GEft", "AccE"))]; + expected.extend(edges.items); + assert_eq!(item.descriptor.unwrap().items, expected); + + // They play. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, effect) in [("AccE", "accentedEdges"), ("Ct ", "cutout"), ("Plst", "plaster")] { + let (id, p) = gallery_params(gallery_step("GEfc", code, desc("null"))); + assert_eq!(id, format!("filter.gallery.{effect}")); + s.actions.list.push(Action::new(code, vec![(id, p)])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } +} + +/// What the effects cannot play keeps the Photoshop step: an unknown effect or value, a Glass +/// texture the reference gives no code for, a key the effect does not have (#10592). +#[test] +fn gallery_effect_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(gallery_step("GEfc", "Zzzz", desc("null")))); + assert!(kept(gallery_step("GEfc", "BsRl", desc("null").with("LghD", enm("LghD", "LDCn"))))); + assert!(kept(gallery_step("GEfc", "Gls ", desc("null").with("TxtT", enm("TxtT", "TxBr"))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("Rds ", D::Integer(3))))); +} + +/// What the event cannot hold refuses the step: a fraction in an integer key, the colours a +/// Sketch effect pinned, a value with no Photoshop code, a mask target (#10592). +#[test] +fn gallery_effect_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.gallery.accentedEdges", json!({"edgeWidth": 2.5})), + ("filter.gallery.charcoal", json!({"foreground": "#000000"})), + ("filter.gallery.glass", json!({"texture": "blocks"})), + ("filter.gallery.cutout", json!({"target": "mask"})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + assert!(export_item("filter.gallery.glass", &json!({"texture": "frosted"}), "S").is_some()); +} + +/// An integer outside the setting's range refuses the step rather than saving a value Photoshop +/// clamps on playback and that loads back as another (`edgeWidth` 99 as 14); each end of a range +/// still saves (#10592). +#[test] +fn gallery_effect_integers_outside_the_settings_range_are_refused() { + for (id, params) in [ + ("filter.gallery.accentedEdges", json!({"edgeWidth": 99})), + ("filter.gallery.accentedEdges", json!({"edgeWidth": 0})), + ("filter.gallery.neonGlow", json!({"glowSize": -25, "glowColor": "#336699"})), + ("filter.gallery.glass", json!({"scaling": 201})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + for (id, params) in [ + ("filter.gallery.accentedEdges", json!({"edgeWidth": 1})), + ("filter.gallery.accentedEdges", json!({"edgeWidth": 14})), + ("filter.gallery.neonGlow", json!({"glowSize": -24, "glowColor": "#336699"})), + ("filter.gallery.glass", json!({"scaling": 200})), + ] { + assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); + } +} + +/// Neon Glow without a colour plays with the effect's default glow colour, so it saves with that +/// colour, component for component (#10592). +#[test] +fn gallery_neon_glow_without_a_colour_saves_the_colour_it_plays_with() { + let item = export_item("filter.gallery.neonGlow", &json!({"glowSize": 5}), "S").expect("refused"); + let d = item.descriptor.unwrap(); + let c = get_obj(&d, "Clr ").expect("no colour"); + assert_eq!(trimmed(&c.class_id), "RGBC"); + let neon = photocraft_algo::GalleryEffect::new(photocraft_algo::GalleryFilter::NeonGlow).color; + for (key, v) in [("Rd ", neon[0]), ("Grn ", neon[1]), ("Bl ", neon[2])] { + assert_eq!(get_num(c, key), Some(f64::from(v) * 255.0), "{key}"); + } +} + +/// Each enumeration saves with the type Photoshop's terminology gives it: stroke direction's is +/// `StrD`, not its key, and texture's is `TxtT`, as Photoshop records Glass (#10592). +#[test] +fn gallery_effect_enumerations_save_with_photoshops_type_ids() { + let enum_type = |id: &str, params: Value, key: &str| match export_item(id, ¶ms, "S").unwrap().descriptor.unwrap().get(key) { + Some(D::Enumerated { type_id, .. }) => trimmed(type_id), + other => panic!("{id} {key}: {other:?}"), + }; + assert_eq!(enum_type("filter.gallery.graphicPen", json!({}), "SDir"), "StrD"); + assert_eq!(enum_type("filter.gallery.sprayedStrokes", json!({}), "SDir"), "StrD"); + for id in ["filter.gallery.conteCrayon", "filter.gallery.glass", "filter.gallery.roughPastels", "filter.gallery.texturizer", "filter.gallery.underpainting"] + { + assert_eq!(enum_type(id, json!({}), "TxtT"), "TxtT", "{id}"); + } + assert_eq!(enum_type("filter.gallery.basRelief", json!({}), "LghD"), "LghD"); + assert_eq!(enum_type("filter.gallery.plaster", json!({}), "LghP"), "LghP"); + assert_eq!(enum_type("filter.gallery.halftonePattern", json!({}), "ScrT"), "ScrT"); + assert_eq!(enum_type("filter.gallery.paintDaubs", json!({}), "BrsT"), "BrsT"); + assert_eq!(enum_type("filter.gallery.grain", json!({}), "Grnt"), "Grnt"); +} + +/// Neon Glow recorded without `Clr ` glows in Photoshop's default colour, blue (0, 0, 255) per +/// the event reference, not PhotoCraft's own default: the step loads with that colour and saves +/// it back, loading again as the same step (#10592). +#[test] +fn gallery_neon_glow_without_a_colour_loads_photoshops_blue() { + let (id, p) = gallery_params(gallery_step("GEfc", "NGlw", desc("null"))); + assert_eq!(id, "filter.gallery.neonGlow"); + assert_eq!(p, json!({"glowSize": 5, "glowBrightness": 15, "glowColor": "#0000ff"})); + let d = export_item(&id, &p, "S").expect("refused").descriptor.unwrap(); + let c = get_obj(&d, "Clr ").expect("no colour"); + assert_eq!(trimmed(&c.class_id), "RGBC"); + assert_eq!([get_num(c, "Rd "), get_num(c, "Grn "), get_num(c, "Bl ")], [Some(0.0), Some(0.0), Some(255.0)]); + let event = AtnEvent::Code(*b"GEfc"); + let back = (events::event_for(&event).unwrap().from)(&d); + assert_eq!(gallery_params(back), (id, p)); +} + +/// An effect recorded under its own event by string ID (`cutout`, as newer versions record it) +/// maps like its 4-char code; only the effect's string IDs count as names (#10592). +#[test] +fn gallery_effects_recorded_by_string_id_map_like_their_codes() { + for (code, name, effect) in [("Ct ", "cutout", "Ct"), ("HlfS", "halftoneScreen", "HlfS"), ("Grn ", "grain", "Grn")] { + let d = desc("null").with("GEfk", enm("GEft", effect)); + let by_name = (events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} by name")).from)(&d); + let by_code = (events::event_for(&AtnEvent::Code(code.as_bytes().try_into().unwrap())).unwrap().from)(&d); + assert_eq!(gallery_params(by_name), gallery_params(by_code), "{name}"); + } + assert!(events::event_for(&AtnEvent::name("Ct ")).is_none()); + assert!(events::event_for(&AtnEvent::Code(*b"MscT")).is_some()); +} + +/// A step recorded as the effect's own event (by code or string ID) loads, plays and saves back +/// as that event with its panel name and descriptor unchanged; a `GEfc` step stays `GEfc` +/// (#10592). +#[test] +fn gallery_effect_steps_save_back_as_the_event_they_were_recorded_as() { + let edges = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5)); + let cutout = desc("null").with("GEfk", enm("GEft", "Ct")).with("NmbL", D::Integer(6)).with("EdgS", D::Integer(4)).with("EdgF", D::Integer(2)); + let gallery = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(3)).with("EdgB", D::Integer(20)).with("Smth", D::Integer(7)); + let source = vec![item("AccE", "Accented Edges", Some(edges)), item("cutout", "Cutout", Some(cutout)), item("GEfc", "Filter Gallery", Some(gallery))]; + let data = set_of("S", vec![("A", source.clone())]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["filter.gallery.accentedEdges", "filter.gallery.cutout", "filter.gallery.accentedEdges"]); + + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert_eq!(played["ran"], 3, "{played}"); + // The remembered event is for Save Actions only: playback strips it, so the journal (Last + // Filter) and the smart filters (`.pcraft` saves) never carry it. + let gallery: Vec<&Value> = s.journal.iter().filter(|(id, _)| id.starts_with("filter.gallery.")).map(|(_, p)| p).collect(); + assert_eq!(gallery.len(), 3, "{:?}", s.journal); + assert!(gallery.iter().all(|p| p.get("_atnEvent").is_none()), "{gallery:?}"); + let d = s.active().unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not smart") }; + assert_eq!(sm.smart_filters.len(), 3); + assert!(sm.smart_filters.iter().all(|f| f.params.get("_atnEvent").is_none()), "{:?}", sm.smart_filters); + + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let got: Vec<_> = file.actions[0].items.iter().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone())).collect(); + let want: Vec<_> = source.iter().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone())).collect(); + assert_eq!(got, want); + + // A step made in PhotoCraft keeps saving as `GEfc`, and so does a forged event param. + assert_eq!(export_item("filter.gallery.cutout", &json!({}), "S").unwrap().event, AtnEvent::Code(*b"GEfc")); + let forged = json!({"_atnEvent": {"event": "GsnB", "eventKind": "code", "name": "Gaussian Blur"}}); + assert_eq!(export_item("filter.gallery.cutout", &forged, "S").unwrap().event, AtnEvent::Code(*b"GEfc")); + // Another effect's event (stale or forged) does not relabel the step either. + for (event, kind) in [("AccE", "code"), ("accentedEdges", "name")] { + let other = json!({"_atnEvent": {"event": event, "eventKind": kind, "name": "Accented Edges"}}); + let item = export_item("filter.gallery.cutout", &other, "S").unwrap(); + assert_eq!((item.event, item.dictionary), (AtnEvent::Code(*b"GEfc"), b"Filter Gallery".to_vec()), "{event}"); + } + let own = json!({"_atnEvent": {"event": "cutout", "eventKind": "name", "name": "Cutout"}}); + assert_eq!(export_item("filter.gallery.cutout", &own, "S").unwrap().event, AtnEvent::name("cutout")); +} + +/// Only a missing key takes Photoshop's default: a key there with the wrong type or a non-finite +/// number keeps the Photoshop step instead of playing a default (#10592). +#[test] +fn gallery_effect_keys_with_the_wrong_type_keep_the_step() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(gallery_step("GEfc", "Txtz", desc("null").with("InvT", D::Integer(1))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", D::Double(f64::NAN))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", D::Double(f64::INFINITY))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", text("9"))))); + assert!(kept(gallery_step("GEfc", "BsRl", desc("null").with("LghD", D::Integer(3))))); + assert!(kept(gallery_step("GEfc", "NGlw", desc("null").with("Clr ", D::Integer(3))))); + // Missing keys still take the defaults. + assert_eq!(gallery_params(gallery_step("GEfc", "Txtz", desc("null"))).1["invert"], false); + assert_eq!(gallery_params(gallery_step("GEfc", "AccE", desc("null").with("EdgB", D::Integer(9)))).1["edgeWidth"], 2); +} + +/// Photoshop's Split Channels, Merge Spot Channel, Trap and Match Color steps load as their +/// commands and save back with the keys Photoshop records (#10609). +#[test] +fn channel_and_color_steps_load_and_save_back() { + let ids = D::List(vec![D::Integer(182), D::Integer(185), D::Integer(188)]); + let matched = desc("null").with("Lght", D::Integer(120)).with("ClrR", D::Integer(80)).with("Fade", D::Integer(25)).with("noReference", D::Boolean(true)); + let items = vec![ + item("SplC", "Split Channels", Some(desc("null").with("documentIDs", ids))), + item("MSpt", "Merge Spot Channel", Some(desc("null"))), + item("Trap", "Trap", Some(desc("null").with("Wdth", px(2.0)))), + item("matchColor", "Match Color", Some(matched.clone())), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Prepress"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Inks"), items: items.clone(), ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["unsupported"], json!([])); + assert_eq!( + s.actions.list[0].steps, + vec![ + ("channel.split".to_string(), json!({})), + ("channel.mergeSpot".to_string(), json!({})), + ("image.trap".to_string(), json!({"width": 2})), + ("image.adjustments.matchColor".to_string(), json!({"luminance": 120, "intensity": 80, "fade": 25})), + ] + ); + let saved = s.execute("actions.save", json!({"action": "Inks"})).unwrap(); + let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let back = &back.actions[0].items; + let events: Vec = back.iter().map(|i| i.event.id()).collect(); + assert_eq!(events, ["SplC", "MSpt", "Trap", "matchColor"]); + // The split's document IDs belong to the run that recorded it, so none are written. + assert!(back[0].descriptor.as_ref().unwrap().items.is_empty()); + for (b, original) in back.iter().zip(&items).skip(1) { + assert_eq!(b.descriptor.as_ref().unwrap().items, original.descriptor.as_ref().unwrap().items, "{}", original.event.id()); + } + // Match Color with no reference plays on the image itself. + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + s.actions.list.push(Action::new("Match", vec![s.actions.list[0].steps[3].clone()])); + let played = s.execute("actions.play", json!({"action": "Match"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); +} + +/// Missing Match Color keys take Photoshop's defaults and Trap's width is clamped to its +/// 32767 px; a step matching another image, or with a key the event lacks, stays a Photoshop +/// step (#10609). +#[test] +fn channel_and_color_steps_default_clamp_and_keep() { + let from = |name: &str, d: Descriptor| { + let event = if name.len() == 4 { AtnEvent::Code(name.as_bytes().try_into().unwrap()) } else { AtnEvent::name(name) }; + (events::event_for(&event).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) + }; + let params = |m: Mapped| match m { + Mapped::Steps(s) => s[0].1.clone(), + Mapped::Unsupported { .. } => panic!("kept"), + }; + assert_eq!(params(from("matchColor", desc("null"))), json!({"luminance": 100, "intensity": 100, "fade": 0})); + assert_eq!(params(from("Trap", desc("null").with("Wdth", px(99_999.0)))), json!({"width": 32767.0})); + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(from("matchColor", desc("null").with("noReference", D::Boolean(false))))); + assert!(kept(from("SplC", desc("null").with("Chnl", D::Integer(1))))); + assert!(kept(from("MSpt", desc("null").with("Chnl", D::Integer(1))))); + assert!(kept(from("Trap", desc("null").with("Hght", px(2.0))))); +} + +/// Settings the events have no key for refuse the step instead of replaying differently: a +/// split that keeps the original, a named spot channel, a Match Color source or option, a +/// fractional luminance (#10609). +#[test] +fn channel_and_color_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("channel.split", json!({"closeOriginal": false})), + ("channel.mergeSpot", json!({"channel": 1})), + ("image.trap", json!({"width": 2, "layer": 3})), + ("image.adjustments.matchColor", json!({"source": 0})), + ("image.adjustments.matchColor", json!({"neutralize": true})), + ("image.adjustments.matchColor", json!({"luminance": 99.5})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + assert!(export_item("image.adjustments.matchColor", &json!({"neutralize": false}), "S").is_some()); +} + +/// A Trap width above the 32767 px the import clamps to refuses the step instead of writing a +/// `Wdth` Photoshop doesn't take (#10609). +#[test] +fn trap_wider_than_photoshop_takes_is_refused() { + assert!(export_item("image.trap", &json!({"width": 32768}), "S").is_none()); + let d = export_item("image.trap", &json!({"width": 32767}), "S").unwrap().descriptor.unwrap(); + assert_eq!(d.items, desc("null").with("Wdth", px(32767.0)).items); +} + +/// The channel or colour step `d` of the Photoshop event `name`, as PhotoCraft loads it. +fn channel_step(name: &str, d: Descriptor) -> Mapped { + let event = if name.len() == 4 { AtnEvent::Code(name.as_bytes().try_into().unwrap()) } else { AtnEvent::name(name) }; + (events::event_for(&event).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) +} + +/// A `noReference` that isn't a Boolean doesn't say the step has no source, so the step stays +/// a Photoshop step instead of playing as a source-less Match Color (#10609). +#[test] +fn match_color_with_a_non_boolean_no_reference_is_kept() { + for v in [D::Integer(1), text("true"), enm("Ordn", "Trgt")] { + let m = channel_step("matchColor", desc("null").with("noReference", v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "noReference {v:?} was mapped"); + } +} + +/// A recorded Match Color or Trap setting that isn't a number is the step's own setting, not a +/// missing one: the step stays a Photoshop step instead of playing with the default (#10609). +#[test] +fn channel_and_color_steps_with_a_non_numeric_setting_are_kept() { + for (name, key, v) in [ + ("matchColor", "Lght", text("120")), + ("matchColor", "ClrR", D::Boolean(true)), + ("matchColor", "Fade", enm("Ordn", "Trgt")), + ("matchColor", "Lght", D::Double(f64::NAN)), + ("Trap", "Wdth", text("2")), + ("Trap", "Wdth", D::Boolean(true)), + ] { + let m = channel_step(name, desc("null").with(key, v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key} = {v:?} was mapped"); + } +} + +/// Trap saves its width as `#Pxl`, so a width in another unit, or without one, stays a +/// Photoshop step instead of loading as that many pixels and saving back as pixels (#10609). +#[test] +fn trap_width_not_in_pixels_is_kept() { + for v in [ + D::UnitFloat { unit: *b"#Pnt", value: 2.0 }, + D::UnitFloat { unit: *b"#Mlm", value: 2.0 }, + D::UnitFloat { unit: *b"#Prc", value: 2.0 }, + D::Double(2.0), + D::Integer(2), + ] { + let m = channel_step("Trap", desc("null").with("Wdth", v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "Wdth {v:?} was mapped"); + } + assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(2.5))), Mapped::Steps(_))); +} + +/// Match Color saves its settings as integers, so a recorded value it couldn't save back as +/// the same integer stays a Photoshop step instead of loading and then failing to save (#10609). +#[test] +fn match_color_setting_that_cannot_be_saved_back_is_kept() { + for (key, v) in [ + ("Lght", D::Double(99.5)), + ("Lght", D::Double(100.0)), + ("ClrR", D::LargeInteger(1 << 40)), + ("ClrR", D::LargeInteger(80)), + ("Fade", D::UnitFloat { unit: *b"#Prc", value: 25.0 }), + ] { + let m = channel_step("matchColor", desc("null").with(key, v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {v:?} was mapped"); + } +} + +/// Photoshop's narrowest Trap width wasn't measured, so a recorded width below the command's +/// 1 px isn't clamped up to it: the step stays a Photoshop step, and a PhotoCraft step that +/// narrow is refused on export instead of written as a width the command doesn't trap (#10609). +#[test] +fn trap_width_below_one_pixel_is_kept() { + for w in [0.25, 0.0, -2.0] { + let m = channel_step("Trap", desc("null").with("Wdth", px(w))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "Wdth {w} was mapped"); + assert!(export_item("image.trap", &json!({"width": w}), "S").is_none(), "width {w} exported"); + } + let d = export_item("image.trap", &json!({"width": 1}), "S").unwrap().descriptor.unwrap(); + assert_eq!(d.items, desc("null").with("Wdth", px(1.0)).items); + assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(1.0))), Mapped::Steps(_))); +} + +/// Match Color's Luminance and Color Intensity take 1..200 and Fade 0..100, and Photoshop's +/// handling of a recorded value outside them wasn't measured: such a step stays a Photoshop +/// step rather than being clamped, and a PhotoCraft step outside them is refused on export +/// instead of written as a value the command plays clamped (#10609). +#[test] +fn match_color_setting_outside_its_range_is_kept() { + for (key, n) in [("Lght", 0), ("Lght", 201), ("ClrR", 0), ("ClrR", 201), ("Fade", -1), ("Fade", 101)] { + let m = channel_step("matchColor", desc("null").with(key, D::Integer(n))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {n} was mapped"); + } + let edges = desc("null").with("Lght", D::Integer(1)).with("ClrR", D::Integer(200)).with("Fade", D::Integer(100)); + assert!(matches!(channel_step("matchColor", edges), Mapped::Steps(_))); + for p in [json!({"luminance": 0}), json!({"luminance": 201}), json!({"intensity": 0}), json!({"fade": -1}), json!({"fade": 101})] { + assert!(export_item("image.adjustments.matchColor", &p, "S").is_none(), "{p} exported"); + } + assert!(export_item("image.adjustments.matchColor", &json!({"luminance": 1, "intensity": 200, "fade": 100}), "S").is_some()); +} + +/// A Match Color step that names its source (`Srce`, a document by name, or the name as text) +/// matches another image the command isn't given, so it stays a Photoshop step even when it +/// also says `noReference` (#10609). +#[test] +fn match_color_with_a_source_is_kept() { + let from = |d: Descriptor| (events::event_for(&AtnEvent::name("matchColor")).unwrap().from)(&d); + let document = D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Reference.psd") }]); + for source in [document, text("Reference.psd")] { + for d in [desc("null").with("Srce", source.clone()), desc("null").with("Srce", source.clone()).with("noReference", D::Boolean(true))] { + assert!(matches!(from(d.clone()), Mapped::Unsupported { harmless: false }), "{:?} was mapped", d.items); + } + } +} + +/// A Trap step recorded without `Wdth` plays the command's 1 px and saves back as `Wdth` 1 px, +/// so it must load as that width too: load, save and load give the same step. +#[test] +fn trap_step_without_a_width_saves_back_the_same() { + let file = AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("T"), items: vec![item("Trap", "Trap", Some(desc("null")))], ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + let loaded = s.actions.list.clone(); + assert_eq!(loaded[0].steps, vec![("image.trap".to_string(), json!({"width": 1}))]); + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let mut t = Session::new(); + t.execute("actions.load", json!({"data": saved["data"].as_str().unwrap()})).unwrap(); + assert_eq!(t.actions.list, loaded); +} + +/// The Match Color dialog records Source "None" as `"source": -1`, which the command plays +/// without a source: such a step saves, while a real source document is still refused. +#[test] +fn match_color_recorded_with_source_none_saves() { + let none = json!({"source": -1, "luminance": 100.0, "intensity": 100.0, "fade": 0.0, "neutralize": false, "useSelectionInSource": false, "useSelectionInTarget": false}); + let d = export_item("image.adjustments.matchColor", &none, "S").unwrap().descriptor.unwrap(); + let expected = desc("null").with("Lght", D::Integer(100)).with("ClrR", D::Integer(100)).with("Fade", D::Integer(0)).with("noReference", D::Boolean(true)); + assert_eq!(d.items, expected.items); + assert!(export_item("image.adjustments.matchColor", &json!({"source": -1, "sourceLayer": 4}), "S").is_some()); + for source in [json!({"source": 0}), json!({"source": 0, "sourceLayer": 4}), json!({"source": "x"}), json!({"source": -1, "neutralize": true})] { + assert!(export_item("image.adjustments.matchColor", &source, "S").is_none(), "{source} exported"); + } +} + +/// Native component references import, select the same target as the command and export again. +#[test] +fn select_color_components_import_play_and_round_trip() { + for (mode, channels) in [ + ("rgb", &["red", "green", "blue"][..]), + ("cmyk", &["cyan", "magenta", "yellow", "black"][..]), + ("lab", &["lightness", "a", "b"][..]), + ("gray", &["gray"][..]), + ] { + for depth in [8, 16, 32] { + for channel in channels { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode, "depth": depth})).unwrap(); + let expected = s.execute("channel.target", json!({"channel": channel})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let selection = desc("null").with("null", channel_ref(Some(channel))); + let data = set_of("Components", vec![("Pick", vec![item("select", "Select", Some(selection))])]); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{mode}/{depth}/{channel}"); + let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.execute("channel.list", json!({})).unwrap(), expected, "{mode}/{depth}/{channel}"); + let saved = s.execute("actions.save", json!({"set": "Components"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps); + } + } + } +} + +/// Unknown targets and selection modes retain their failure instead of selecting another channel. +#[test] +fn select_color_components_preserve_unsupported_selection_shapes() { + let chained = vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Cyn") }, + ReferenceItem::Identifier { class: class("Dcmn"), id: 7 }, + ]; + for (mode, selection) in [ + ("cmyk", desc("null").with("null", channel_ord("Xxxx"))), + ("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection"))), + ("cmyk", desc("null").with("null", D::Reference(chained))), + ("cmyk", desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }]))), + // Shift-click adds the channel to the selection; playing it as a plain select would lose that. + ("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(true))), + // The legacy `Lmnc` code would otherwise target Lightness and save back as `Lght`. + ("lab", desc("null").with("null", channel_ord("Lmnc"))), + ] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap(); + let data = set_of("Unknown", vec![("Pick", vec![item("select", "Select", Some(selection.clone()))])]); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + let target = s.execute("channel.list", json!({})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap(); + assert_eq!(played["failed"]["id"], PHOTOSHOP_STEP); + assert_eq!(s.execute("channel.list", json!({})).unwrap(), target); + let saved = s.execute("actions.save", json!({"set": "Unknown"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref(), Some(&selection)); + } + for params in [ + json!({"channel": {"color": "cyan"}, "extend": true}), + json!({"channel": {"color": "cyan"}, "colors": [0, 1]}), + json!({"channel": 0}), + json!({"channel": null}), + json!({"channel": {"alpha": 0}}), + json!({"channel": "cyan"}), + ] { + assert!(super::steps_layer::select_to("channel.target", ¶ms).is_none(), "{params}"); + } + assert!(super::steps_layer::select_to("channel.target", &json!({"channel": {"color": "CYAN"}, "extend": false})).is_some()); + let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]); + let bytes = photocraft_paint::tile::b64_decode(&gray).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": "composite"}))); + // A plain click (`Extd` false) is still an ordinary single-channel select. + let plain = set_of( + "Plain", + vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(false))))])], + ); + let bytes = photocraft_paint::tile::b64_decode(&plain).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": {"color": "cyan"}}))); +} + +/// A CMYK component cannot retarget a same-named alpha channel when replayed on RGB. +#[test] +fn select_color_components_do_not_fall_back_to_alpha_names() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": "rgb"})).unwrap(); + s.execute("channel.new", json!({"name": "cyan"})).unwrap(); + for channel in [json!("cyan"), json!(0), json!({"alpha": 0})] { + assert_eq!(s.execute("channel.target", json!({"channel": channel})).unwrap()["target"]["kind"], "alpha"); + } + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let data = set_of("Components", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan")))))])]); + assert_eq!(s.execute("actions.load", json!({"data": data})).unwrap()["unsupported"], json!([])); + let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap(); + assert_eq!(played["failed"]["id"], "channel.target"); + assert_eq!(s.execute("channel.list", json!({})).unwrap()["target"]["kind"], "composite"); +} + +/// Nested color indices take precedence over the object's single-color fallback at runtime. +#[test] +fn select_channel_export_rejects_nested_color_selections() { + for colors in [vec![0, 1], vec![1]] { + let params = json!({"channel":{"color":"cyan","colors":colors}}); + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":1,"mode":"cmyk"})).unwrap(); + s.execute("channel.target", params.clone()).unwrap(); + assert_eq!(s.active().unwrap().channel_view.selected_colors, colors); + s.actions.list.push(Action::new("Nested", vec![("channel.target".into(), params.clone())])); + assert!(s.execute("actions.save", json!({"action":"Nested"})).is_err(), "{params} must not become a single Cyan selection"); + let skipped = s.execute("actions.save", json!({"action":"Nested","skipUnsupported":true})).unwrap(); + assert_eq!(skipped["skipped"], json!([{"action":"Nested","step":0,"id":"channel.target"}])); + let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap(); + assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); + assert_eq!(s.actions.list[0].steps[0].1, params); + } + let mut s = Session::new(); + s.actions.list.push(Action::new("Cyan", vec![("channel.target".into(), json!({"channel":{"color":"CYAN"},"extend":false}))])); + let saved = s.execute("actions.save", json!({"action":"Cyan"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![("channel.target".into(), json!({"channel":{"color":"cyan"}}))]); +} + +/// A typed Gray target must retain its failure on RGB instead of becoming portable composite. +#[test] +fn select_channel_export_rejects_mode_specific_gray_objects() { + for color in ["gray", "GRAY", "GrAy"] { + for mode in ["gray", "rgb"] { + let params = json!({"channel":{"color":color}}); + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":1,"mode":mode})).unwrap(); + assert_eq!(s.execute("channel.target", params.clone()).is_ok(), mode == "gray"); + s.actions.list.push(Action::new("Gray object", vec![("channel.target".into(), params.clone())])); + assert!(s.execute("actions.save", json!({"action":"Gray object"})).is_err(), "{mode}/{color} must not become composite"); + let skipped = s.execute("actions.save", json!({"action":"Gray object","skipUnsupported":true})).unwrap(); + assert_eq!(skipped["skipped"], json!([{"action":"Gray object","step":0,"id":"channel.target"}])); + let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap(); + assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); + assert_eq!(s.actions.list[0].steps[0].1, params); + } + } + let mut s = Session::new(); + s.actions.list.push(Action::new("Portable", vec![("channel.target".into(), json!({"channel":"composite"}))])); + let saved = s.execute("actions.save", json!({"action":"Portable"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps); + let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]); + let mut rgb = Session::new(); + rgb.execute("file.new", json!({"width":2,"height":1,"mode":"rgb"})).unwrap(); + rgb.execute("actions.load", json!({"data":gray})).unwrap(); + assert_eq!(rgb.actions.list[0].steps[0], ("channel.target".into(), json!({"channel":"composite"}))); + assert!(rgb.execute("actions.play", json!({"action":"Pick"})).unwrap().get("failed").is_none()); +} + +/// Newer Group from Layers recordings put the name outside Usng; existing nested names win. +#[test] +fn mapped_make_group_name_survives_load_and_save() { + for (top, nested, expected) in [(Some("Selected"), None, "Selected"), (None, Some("Nested"), "Nested"), (Some("Top"), Some("Nested"), "Nested")] { + let mut d = desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")); + if let Some(name) = top { + d = d.with("Nm ", text(name)); + } + if let Some(name) = nested { + d = d.with("Usng", D::Descriptor(desc("layerSection").with("Nm ", text(name)))); + } + let source = one_action(vec![item("Mk ", "Group from Layers", Some(d))]); + let loaded = import(&atn::write(&source).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps, vec![("layer.groupLayers".into(), json!({"name": expected}))]); + let (saved, skipped) = export("Groups", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(import(&saved).unwrap().actions[0].steps, loaded.actions[0].steps); + } +} + +/// A recorded New Layer clipping flag must reach the command and survive strict export. +#[test] +fn mapped_make_clipping_flag_survives_load_and_save() { + for clip in [None, Some(false), Some(true)] { + let mut using = desc("Lyr ").with("Nm ", text("Clipped")); + if let Some(clip) = clip { + using = using.with("Grup", D::Boolean(clip)); + } + let d = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(using)); + let loaded = import(&atn::write(&one_action(vec![item("Mk ", "New Layer", Some(d))])).unwrap()).unwrap(); + let mut expected = json!({"name": "Clipped"}); + if clip == Some(true) { + expected["clipToPrevious"] = json!(true); + } + assert_eq!(loaded.actions[0].steps, vec![("layer.new.layer".into(), expected)]); + let (saved, skipped) = export("Layers", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(import(&saved).unwrap().actions[0].steps, loaded.actions[0].steps); + } +} + +/// An unreadable clipping flag keeps the original step instead of creating an unclipped layer. +#[test] +fn mapped_make_invalid_clipping_flag_is_kept() { + let using = desc("Lyr ").with("Grup", long(1.0)); + let d = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(using)); + let loaded = import(&atn::write(&one_action(vec![item("Mk ", "New Layer", Some(d))])).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); +} + +/// Load a recorded descriptor through the same parser as Load Actions. +fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported { + import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap() +} + +/// Every frame anchor survives import/export; unknown anchors stay available verbatim. +#[test] +fn mapped_transform_reference_points_round_trip() { + for (code, reference) in [ + ("Qcsa", "center"), + ("Qcs0", "topLeft"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), + ("Qcs5", "right"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "bottomRight"), + ] { + for target in [target_ref("Lyr "), D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])] { + let d = desc("null") + .with("null", target) + .with("FTcs", enm("QCSt", code)) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(0.0)).with("Vrtc", px(0.0)))); + let loaded = mapped_recording("Trnf", d); + let params = &loaded.actions[0].steps[0].1; + assert_eq!(params.get("reference").and_then(Value::as_str).unwrap_or("center"), reference); + let (bytes, _) = export("Anchors", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + if reference == "topLeft" && loaded.actions[0].steps[0].0 == "select.transformSelection" { + let mut s = Session::new(); + s.execute("file.new", json!({"width":256,"height":256})).unwrap(); + s.execute("select.rect", json!({"x":60,"y":60,"width":100,"height":100})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(60, 60, 110, 110)); + } + } + } + // The reference application never records `Qcs8` or `Qcsi` (independent): kept steps. + for code in ["unknown", "Qcs8", "Qcsi"] { + let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", code)).with("Wdth", pct(50.0)); + assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP, "{code}"); + } + // A native anchor saves as the reference application's code; nothing writes `Qcs8`. + for (reference, code) in [("top", "Qcs4"), ("topRight", "Qcs1"), ("bottom", "Qcs6"), ("bottomRight", "Qcs2")] { + let native = Action::new("Native", vec![("edit.transform".into(), json!({"scaleX":50,"scaleY":50,"reference":reference}))]); + let (bytes, _) = export("Anchors", &[&native], &[], false).unwrap(); + let saved = atn::parse(&bytes).unwrap(); + let d = saved.actions[0].items[0].descriptor.as_ref().unwrap(); + assert!(matches!(d.get("FTcs"), Some(D::Enumerated { value, .. }) if value.is(code)), "{reference}: {:?}", d.get("FTcs")); + } +} + +/// Alt-clicking an eye twice must isolate and then restore visibility, not just show a layer. +#[test] +fn mapped_show_only_round_trip_and_toggle() { + let d = desc("null").with("null", D::List(vec![name_ref("Lyr ", "Solo")])).with("TglO", D::Boolean(true)); + let loaded = mapped_recording("Shw ", d); + assert_eq!(loaded.actions[0].steps, vec![("layer.showOnly".into(), json!({"layer": "Solo"}))]); + let (bytes, _) = export("Eyes", &[&loaded.actions[0]], &[], false).unwrap(); + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(get_bool(saved.actions[0].items[0].descriptor.as_ref().unwrap(), "TglO"), Some(true)); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Solo"})).unwrap(); + let visible = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.visible).collect::>(); + let original = visible(&s); + let (id, params) = &loaded.actions[0].steps[0]; + assert_eq!(s.execute(id, params.clone()).unwrap()["shownAlone"], true); + assert_ne!(visible(&s), original); + assert_eq!(s.execute(id, params.clone()).unwrap()["shownAlone"], false); + assert_eq!(visible(&s), original); + let malformed = desc("null").with("null", D::List(vec![name_ref("Lyr ", "Solo"), long(1.0)])).with("TglO", D::Boolean(true)); + assert_eq!(mapped_recording("Shw ", malformed).actions[0].steps[0].0, PHOTOSHOP_STEP); +} + +/// A layer label is separate from an adjustment's own colour parameter. +#[test] +fn mapped_adjustment_label_survives_export() { + let d = desc("null") + .with("null", class_ref("AdjL")) + .with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("Invr"))).with("Clr ", enm("Clr ", "Rd ")))); + let loaded = mapped_recording("Mk ", d); + assert_eq!(loaded.actions[0].steps, vec![("layer.newAdjustmentLayer.invert".into(), json!({})), ("layer.setLabelColor".into(), json!({"color":"red"}))]); + let (bytes, _) = export("Labels", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + for (id, params) in &loaded.actions[0].steps { + s.execute(id, params.clone()).unwrap(); + } + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().label.id(), "red"); +} + +/// Keeping an unmapped shape is safer than creating a solid fill over the whole canvas. +#[test] +fn mapped_shape_creation_is_kept_with_its_geometry_and_stroke() { + let d = desc("null").with("null", class_ref("contentLayer")).with("Shp ", D::Descriptor(desc("Rctn").with("Wdth", px(20.0)))).with( + "Usng", + D::Descriptor( + desc("contentLayer") + .with("Type", D::Descriptor(desc("solidColorLayer"))) + .with("strokeStyle", D::Descriptor(desc("strokeStyle").with("strokeStyleLineWidth", px(3.0)))), + ), + ); + let loaded = mapped_recording("Mk ", d.clone()); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let (bytes, _) = export("Shapes", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref(), Some(&d)); +} + +/// Copy and Cut retain all recorded layer options, including labels and fractional opacity. +#[test] +fn mapped_copy_cut_layer_options_survive_export() { + for (copy, id) in [(true, "layer.new.layerViaCopy"), (false, "layer.new.layerViaCut")] { + let options = desc("Lyr ").with("Nm ", text("Extracted")).with("Md ", enm("BlnM", "Mltp")).with("Opct", pct(37.5)).with("Clr ", enm("Clr ", "Bl ")); + let d = desc("null").with("Nw ", D::Descriptor(options)).with("Usng", enm("ArSl", "Slct")).with("Cpy ", D::Boolean(copy)); + let loaded = mapped_recording("Mk ", d); + assert_eq!( + loaded.actions[0].steps, + vec![ + (id.into(), json!({})), + ("layer.setLabelColor".into(), json!({"color":"blue"})), + ("layer.setProps".into(), json!({"name":"Extracted","blend":"Multiply","opacity":0.375})) + ] + ); + let (bytes, _) = export("Extract", &[&loaded.actions[0]], &[], false).unwrap(); + let saved = import(&bytes).unwrap(); + assert_eq!(saved.actions[0].steps[..2], loaded.actions[0].steps[..2]); + let mut props = serde_json::Map::new(); + for (id, p) in &saved.actions[0].steps[2..] { + assert_eq!(id, "layer.setProps"); + props.extend(p.as_object().unwrap().clone()); + } + assert_eq!(Value::Object(props), loaded.actions[0].steps[2].1); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":2})).unwrap(); + for (id, params) in &saved.actions[0].steps { + s.execute(id, params.clone()).unwrap(); + } + let st = s.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(layer.name, "Extracted"); + assert_eq!(layer.opacity, 0.375); + assert_eq!(layer.blend, photocraft_color::BlendMode::Multiply); + assert_eq!(layer.label.id(), "blue"); + } +} + +/// Loading transparency must keep the source layer instead of using the active layer. +#[test] +fn mapped_named_layer_transparency_survives_export() { + let source = D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Trsp") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") }, + ]); + let d = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }])).with("T ", source); + let loaded = mapped_recording("setd", d); + assert_eq!(loaded.actions[0].steps, vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":"Source"}))]); + let (bytes, _) = export("Transparency", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("layer.setProps", json!({"name":"Source"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Empty"})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 0, 4, 4)); +} + +/// A localized Background and an ordinary layer named Background are distinct targets. +#[test] +fn mapped_background_property_survives_export_and_playback() { + let r = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]); + let loaded = mapped_recording("Hd ", desc("null").with("null", D::List(vec![r.clone()]))); + assert_eq!(loaded.actions[0].steps, vec![("layer.hideLayers".into(), json!({"layer":{"background":true}}))]); + let (bytes, _) = export("Background", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(get_list(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap(), "null"), Some(&[r][..])); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers[0].name = "Hintergrund".into(); + s.execute("layer.new.layer", json!({"name":"Background"})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + let layers = &s.active().unwrap().doc.layers; + assert!(!layers[0].visible); + assert!(layers[1].visible); + s.execute("layer.hideLayers", json!({"layer":"Background"})).unwrap(); + assert!(!s.active().unwrap().doc.layers[1].visible); + assert!(s.execute("layer.showLayers", json!({"layer":{"background":false}})).is_err()); + s.execute("layer.setLabelColor", json!({"layer":{"background":true},"color":"red"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].label.id(), "red"); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":{"background":true}})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 0, 4, 4)); + let selected = mapped_recording("slct", desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]))); + let (bytes, _) = export("Select Background", &[&selected.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, selected.actions[0].steps); + let (id, p) = &selected.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(s.active().unwrap().doc.layers[0].id)); + let revision = s.active().unwrap().revision; + for selector in [json!({"background":false}), json!({"background":"true"}), json!({"background":true,"extra":0})] { + assert!(s.execute("select.loadSelection", json!({"channel":"transparency","layer":selector})).is_err()); + assert_eq!(s.active().unwrap().revision, revision); + } +} + +/// An unanswered Close must not turn into an explicit discard or close a changed document. +#[test] +fn mapped_close_keeps_ask_and_discard_distinct() { + let loaded = mapped_recording("Cls ", desc("null")); + assert_eq!(loaded.actions[0].steps, vec![("file.close".into(), json!({"save":"ask"}))]); + let (bytes, _) = export("Close", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("Svng").is_none()); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + assert!(s.execute(id, p.clone()).is_err()); + assert_eq!(s.documents().len(), 1); + s.execute("file.close", json!({"save":"discard"})).unwrap(); + assert!(s.documents().is_empty()); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute(id, p.clone()).unwrap(); + assert!(s.documents().is_empty()); +} + +/// `"save": false` is the older spelling of discard: it closes a changed document and saves as +/// "don't save" (`Svng N`); `true` and other values stay refused. +#[test] +fn close_save_false_discards_and_saves_as_dont_save() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + for refused in [json!(true), json!("yes"), json!(0)] { + assert!(s.execute("file.close", json!({"save": refused})).is_err(), "{refused}"); + assert_eq!(s.documents().len(), 1); + } + s.execute("file.close", json!({"save": false})).unwrap(); + assert!(s.documents().is_empty()); + let native = Action::new("Close", vec![("file.close".into(), json!({"save": false}))]); + let (bytes, _) = export("Close", &[&native], &[], false).unwrap(); + let d = atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.clone().unwrap(); + assert!(matches!(d.get("Svng"), Some(D::Enumerated { type_id, value }) if type_id.is("YsN ") && value.is("N ")), "{:?}", d.get("Svng")); +} + +/// Lab Photo Filter colours use the CMS conversion and remain mapped after saving as RGB. +#[test] +fn mapped_lab_photo_filter_imports_and_round_trips() { + for (lab, expected) in + [([0.0, 0.0, 0.0], "#000000"), ([100.0, 0.0, 0.0], "#ffffff"), ([50.0, 0.0, 0.0], "#777777"), ([54.2917, 80.8125, 69.8851], "#ff0000")] + { + let color = desc("LbCl").with("Lmnc", D::Double(lab[0])).with("A ", D::Double(lab[1])).with("B ", D::Double(lab[2])); + let loaded = mapped_recording("photoFilter", desc("null").with("Clr ", D::Descriptor(color)).with("Dnst", long(25.0))); + let (id, params) = &loaded.actions[0].steps[0]; + assert_eq!(id, "image.adjustments.photoFilter"); + assert_eq!(params["color"], expected); + assert_eq!(params["density"].as_f64(), Some(25.0)); + let (bytes, _) = export("Lab", &[&loaded.actions[0]], &[], false).unwrap(); + let saved = import(&bytes).unwrap(); + assert_eq!(saved.actions[0].steps[0].0, *id); + assert_eq!(saved.actions[0].steps[0].1["color"], expected); + assert_eq!(saved.actions[0].steps[0].1["density"].as_f64(), Some(25.0)); + } +} + +/// A Photoshop step of one of the newer features, as its event reference records it (#10610). +fn newer(name: &str, d: Descriptor) -> Mapped { + (events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) +} + +fn mapped(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } +} + +/// Oil Paint, Smart Sharpen, Focus Area, Select Sky and Remove Background load with the keys and +/// defaults Photoshop records; Oil Paint's and Select Sky's defaults differ from the commands' +/// and are written out (#10610). +#[test] +fn newer_feature_steps_load_with_photoshops_defaults() { + let oil = desc("null") + .with("lightingOn", D::Boolean(true)) + .with("stylization", D::Double(4.0)) + .with("cleanliness", D::Double(2.299_999_952_316_28)) + .with("brushScale", D::Double(0.800_000_011_920_93)) + .with("microBrush", D::Double(10.0)) + .with("LghD", D::Integer(-60)) + .with("specularity", D::Double(1.299_999_952_316_28)); + let expected_oil = json!({"lighting": true, "stylization": 4.0, "cleanliness": 2.299_999_952_316_28, "scale": 0.800_000_011_920_93, + "bristleDetail": 10.0, "angle": -60.0, "shine": 1.299_999_952_316_28}); + assert_eq!(mapped(newer("oilPaint", oil.clone())), ("filter.stylize.oilPaint".into(), expected_oil.clone())); + assert_eq!(mapped(newer("oilPaint", desc("null"))).1, expected_oil, "a missing key takes Photoshop's default"); + // Smart Sharpen in the form the command plays (the newer filter, removing a Gaussian blur). + let sharpen = desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("useLegacy", D::Boolean(false)) + .with("Amnt", pct(150.0)) + .with("Rds ", px(2.5)) + .with("noiseReduction", pct(20.0)) + .with("blur", enm("blurType", "GsnB")); + assert_eq!( + mapped(newer("smartSharpen", sharpen.clone())), + ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 20.0})) + ); + assert_eq!( + mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1, + json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 10.0}), + "a missing amount, radius or noise reduction takes Photoshop's default" + ); + assert_eq!(mapped(newer("selectSky", desc("null"))), ("select.sky".into(), json!({"sampleAllLayers": false}))); + assert_eq!(mapped(newer("focusMask", desc("null"))), ("select.focusArea".into(), json!({}))); + assert_eq!(mapped(newer("removeBackground", desc("null"))), ("layer.removeBackground".into(), json!({}))); + + // Written back, they carry the same keys in the same order. + let back = |id: &str, p: Value| export_item(id, &p, "S").unwrap_or_else(|| panic!("{id} does not export")).descriptor.unwrap(); + assert_eq!(back("filter.stylize.oilPaint", expected_oil).items, oil.items); + assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 20})).items, sharpen.items); + let (id, params) = mapped(newer("smartSharpen", sharpen.clone())); + assert_eq!(back(&id, params).items, sharpen.items, "load -> save changed the step"); + + // Oil Paint and Smart Sharpen play. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + let action = Action::new( + "Paint", + vec![ + ("filter.stylize.oilPaint".into(), mapped(newer("oilPaint", desc("null"))).1), + ("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1), + ], + ); + s.actions.list.push(action); + let played = s.execute("actions.play", json!({"action": "Paint"})).unwrap(); + assert_eq!(played["ran"], 2, "{played}"); +} + +/// What the commands cannot do keeps the Photoshop step instead of playing something else +/// (#10610). +#[test] +fn newer_feature_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + // Photoshop's own default step: the legacy filter removing Lens Blur, which the command's + // Gaussian sharpen does not play. + let legacy = desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("useLegacy", D::Boolean(true)) + .with("Amnt", pct(200.0)) + .with("Rds ", px(1.0)) + .with("moreAccurate", D::Boolean(false)) + .with("blur", enm("blurType", "lensBlur")); + assert!(kept(newer("smartSharpen", legacy))); + assert!(kept(newer("smartSharpen", desc("null"))), "an empty step is the legacy filter"); + assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false)))), "no blur recorded is Lens Blur"); + assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "GsnB")))), "no useLegacy recorded is the legacy filter"); + for blur in ["lensBlur", "MtnB", "motionBlur"] { + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("blurType", blur))]))), "{blur}"); + } + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("useLegacy", D::Boolean(true))])))); + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("Ordn", "GsnB"))]))), "a blur of another type"); + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("moreAccurate", D::Boolean(false))])))); + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("presetKind", enm("presetKindType", "presetKindDefault"))])))); + assert!(kept(newer("focusMask", desc("null").with("Rng ", D::Double(3.0))))); + assert!(kept(newer("removeBackground", desc("null").with("Lyr ", D::Integer(1))))); + assert!(kept(newer("selectSky", desc("null").with("smartRadius", D::Boolean(true))))); + assert!(kept(newer("oilPaint", desc("null").with("Angl", D::Integer(3))))); +} + +/// Settings the events have no key for refuse the step rather than replay differently (#10610). +#[test] +fn newer_feature_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.sharpen.smartSharpen", json!({"amount": 150, "blur": "lens"})), + ("filter.sharpen.smartSharpen", json!({"target": "mask"})), + ("filter.stylize.oilPaint", json!({"angle": 10.5})), + ("filter.stylize.oilPaint", json!({"target": "mask"})), + ("select.focusArea", json!({"range": 0.3})), + ("select.sky", json!({"mode": "add"})), + ("select.sky", json!({"threshold": 70})), + ("layer.removeBackground", json!({"layer": 3})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + assert!(export_item("select.sky", &json!({"mode": "replace", "threshold": 50, "softness": 50}), "S").is_some()); +} + +/// A Smart Sharpen step in the form the command plays (`useLegacy` false, `blur` `GsnB`) plus +/// `settings`, which replace those two when they name them. +fn gaussian_sharpen(settings: &[(&str, D)]) -> Descriptor { + let named = |k: &str| settings.iter().any(|(key, _)| key.trim_end() == k); + let mut d = desc("null"); + if !named("useLegacy") { + d = d.with("useLegacy", D::Boolean(false)); + } + if !named("blur") { + d = d.with("blur", enm("blurType", "GsnB")); + } + settings.iter().fold(d, |d, (k, v)| d.with(k, v.clone())) +} + +/// `kept_when_wrong_typed` for Smart Sharpen, on the form it would otherwise map. +fn sharpen_kept_when(settings: &[(&str, D)]) { + for (key, v) in settings { + let m = newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())])); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "smartSharpen with {key:?} = {v:?} was mapped"); + } +} + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper must not play it with a default but keep the Photoshop step (#10610). +fn kept_when_wrong_typed(name: &str, settings: &[(&str, D)]) { + for (key, v) in settings { + let m = newer(name, desc("null").with(key, v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key:?} = {v:?} was mapped"); + } +} + +#[test] +fn select_sky_with_a_wrong_typed_setting_is_kept() { + kept_when_wrong_typed("selectSky", &[("sampleAllLayers", D::Integer(1)), ("sampleAllLayers", text("true"))]); +} + +#[test] +fn oil_paint_with_a_wrong_typed_setting_is_kept() { + kept_when_wrong_typed( + "oilPaint", + &[ + ("lightingOn", D::Integer(0)), + ("stylization", D::Boolean(true)), + ("cleanliness", text("5")), + ("brushScale", enm("Ordn", "Trgt")), + ("microBrush", D::Double(f64::NAN)), + ("LghD", text("45")), + ("specularity", D::Boolean(false)), + ], + ); +} + +#[test] +fn smart_sharpen_with_a_wrong_typed_setting_is_kept() { + sharpen_kept_when(&[ + ("presetKind", text("presetKindDefault")), + ("useLegacy", D::Integer(0)), + ("blur", text("GsnB")), + ("Amnt", D::Boolean(true)), + ("Rds ", text("2")), + ("Rds", text("2")), + ("noiseReduction", text("10")), + ]); +} + +/// `only_keys` admits a code without its space padding, so the mapper reads it too: an +/// unpadded `Rds` radius is the step's radius, not the default 1 (#10610). +#[test] +fn smart_sharpen_reads_an_unpadded_radius() { + let d = gaussian_sharpen(&[("Amnt", pct(150.0)), ("Rds", px(2.5))]); + assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0}))); +} + +/// A step recording one of its keys twice, padded or not, can't be saved back with both values, +/// so it stays a Photoshop step instead of playing the first and dropping the second (#10610). +#[test] +fn newer_feature_steps_with_a_repeated_key_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let mapped = [ + ("smartSharpen", "Amnt", "Amnt", pct(150.0), pct(250.0)), + ("smartSharpen", "Rds ", "Rds ", px(2.0), px(3.0)), + ("smartSharpen", "Rds ", "Rds", px(2.0), px(3.0)), + ("smartSharpen", "Rds", "Rds", px(2.0), px(2.0)), + ("smartSharpen", "presetKind", "presetKind", enm("presetKindType", "presetKindCustom"), enm("presetKindType", "presetKindCustom")), + ("smartSharpen", "useLegacy", "useLegacy", D::Boolean(false), D::Boolean(false)), + ("smartSharpen", "noiseReduction", "noiseReduction", pct(10.0), pct(20.0)), + ("smartSharpen", "blur", "blur", enm("blurType", "GsnB"), enm("blurType", "GsnB")), + ("oilPaint", "lightingOn", "lightingOn", D::Boolean(true), D::Boolean(false)), + ("oilPaint", "stylization", "stylization", D::Double(4.0), D::Double(6.0)), + ("oilPaint", "cleanliness", "cleanliness", D::Double(2.0), D::Double(3.0)), + ("oilPaint", "brushScale", "brushScale", D::Double(1.0), D::Double(2.0)), + ("oilPaint", "microBrush", "microBrush", D::Double(5.0), D::Double(7.0)), + ("oilPaint", "LghD", "LghD", D::Integer(-60), D::Integer(45)), + ("oilPaint", "specularity", "specularity", D::Double(1.0), D::Double(2.0)), + ("selectSky", "sampleAllLayers", "sampleAllLayers", D::Boolean(false), D::Boolean(true)), + ] + .into_iter() + .filter(|(name, key, again, a, b)| { + let d = if *name == "smartSharpen" { + gaussian_sharpen(&[(key, a.clone()), (again, b.clone())]) + } else { + desc("null").with(key, a.clone()).with(again, b.clone()) + }; + !kept(newer(name, d)) + }) + .map(|(name, key, again, ..)| format!("{name} {key:?} {again:?}")) + .collect::>(); + assert!(mapped.is_empty(), "mapped with a repeated key: {mapped:?}"); +} + +/// Oil Paint records its settings as plain doubles and the light direction as an integer, and +/// saves them back so: a value with a unit, or of the other number type, would load as a bare +/// number and save back changed, so the step stays a Photoshop step (#10610). +#[test] +fn oil_paint_setting_with_a_unit_or_another_number_type_is_kept() { + kept_when_wrong_typed( + "oilPaint", + &[ + ("stylization", pct(4.0)), + ("cleanliness", px(2.0)), + ("brushScale", D::UnitFloat { unit: *b"#Rlt", value: 0.8 }), + ("microBrush", D::Integer(10)), + ("LghD", ang(-60.0)), + ("LghD", D::Double(-60.5)), + ("specularity", D::UnitFloat { unit: *b"#Prc", value: 1.3 }), + ], + ); +} + +/// Smart Sharpen records `Amnt` in `#Prc` and its radius in `#Pxl`, and saves them back so: +/// another unit, or none, would load as that number and save back in the other unit, so the +/// step stays a Photoshop step, whether the radius key is padded or not (#10610). +#[test] +fn smart_sharpen_amount_or_radius_in_another_unit_is_kept() { + sharpen_kept_when(&[ + ("Amnt", px(150.0)), + ("Amnt", D::Double(150.0)), + ("Amnt", D::Integer(150)), + ("Rds ", pct(2.0)), + ("Rds ", D::Double(2.0)), + ("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }), + ("Rds", D::Double(2.0)), + ("noiseReduction", px(10.0)), + ("noiseReduction", D::Double(10.0)), + ]); + for rds in ["Rds ", "Rds"] { + let d = gaussian_sharpen(&[("Amnt", pct(150.0)), (rds, px(2.5))]); + assert_eq!( + mapped(newer("smartSharpen", d)), + ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0})), + "{rds:?}" + ); + } +} + +/// `filter.sharpen.smartSharpen` takes an amount of 1..500 %, a radius of 0.1..64 px and a noise +/// reduction of 0..100 %. Photoshop clamps a recorded value outside them when the step plays, +/// so the import clamps it too; a step outside them is not written (#10610). +#[test] +fn smart_sharpen_outside_the_commands_limits_is_clamped_on_import_and_refused_on_export() { + for (key, v, param, want) in [ + ("Amnt", pct(0.5), "amount", 1.0), + ("Amnt", pct(500.5), "amount", 500.0), + ("Rds ", px(0.05), "radius", 0.1), + ("Rds ", px(64.5), "radius", 64.0), + ("Rds", px(1000.0), "radius", 64.0), + ("noiseReduction", pct(-5.0), "reduceNoise", 0.0), + ("noiseReduction", pct(150.0), "reduceNoise", 100.0), + ] { + let (id, p) = mapped(newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())]))); + assert_eq!((id.as_str(), &p[param]), ("filter.sharpen.smartSharpen", &json!(want)), "{key:?} = {v:?}"); + } + for (amount, radius, noise) in [(1.0, 0.1, 0.0), (500.0, 64.0, 100.0)] { + let d = gaussian_sharpen(&[("Amnt", pct(amount)), ("Rds ", px(radius)), ("noiseReduction", pct(noise))]); + assert_eq!( + mapped(newer("smartSharpen", d)), + ("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": noise})), + "{amount} {radius}" + ); + } + for params in [ + json!({"amount": 0.5}), + json!({"amount": 501}), + json!({"radius": 0.05}), + json!({"radius": 65}), + json!({"reduceNoise": -1}), + json!({"reduceNoise": 101}), + ] { + assert!(export_item("filter.sharpen.smartSharpen", ¶ms, "S").is_none(), "{params} exported"); + } +} + +/// Focus Area, Select Sky and Remove Background, mapped from the empty steps Photoshop records, +/// play and do their work: on a textured red disc against a smooth blue sky over textured ground, +/// Focus Area selects the textured ground, Select Sky the sky, and Remove Background masks the +/// layer to the disc. Each step plays on its own, with nothing selected, so a step that runs +/// without finding anything, or that works from the selection left by the step before, fails +/// (#10610). +#[test] +fn newer_feature_selection_steps_play() { + let (w, h) = (160u32, 100u32); + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + let n = bg.channels(); + let mut data = Vec::with_capacity((w * h) as usize * n); + for y in 0..h { + for x in 0..w { + let nz = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.25; + let (dx, dy) = (x as f32 - 80.0, y as f32 - 40.0); + let c = if dx * dx + dy * dy <= 22.0 * 22.0 { + [0.85 + nz * 0.5, 0.2 + nz, 0.15] + } else if y < 60 { + let t = y as f32 / 60.0; + [0.25 + 0.4 * t, 0.5 + 0.3 * t, 0.9 + 0.05 * t] + } else { + [0.35 + nz, 0.3 + nz, 0.15] + }; + data.extend_from_slice(&c.map(|v| v.clamp(0.0, 1.0))[..n.min(3)]); + if n == 4 { + data.push(1.0); + } + } + } + bg.write_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &data); + Ok(()) + }) + .unwrap(); + let play = |s: &mut Session, name: &str| { + s.actions.list.push(Action::new(name, vec![mapped(newer(name, desc("null")))])); + let played = s.execute("actions.play", json!({"action": name})).unwrap(); + assert!(played.get("failed").is_none(), "{name}: {played}"); + assert_eq!(played["ran"], 1, "{name}: {played}"); + }; + let selected = |s: &Session, x: i32, y: i32| s.active().unwrap().doc.selection.as_ref().map(|m| m.sample_channel(x, y, 0)); + + play(&mut s, "focusMask"); + assert!(selected(&s, 20, 85).is_some_and(|v| v > 0.5), "Focus Area left the textured ground out"); + assert!(selected(&s, 20, 15).is_some_and(|v| v < 0.5), "Focus Area took the smooth sky"); + s.execute("select.deselect", json!({})).unwrap(); + + play(&mut s, "selectSky"); + assert!(selected(&s, 20, 15).is_some_and(|v| v > 0.9), "Select Sky left the sky out"); + assert!(selected(&s, 20, 85).is_some_and(|v| v < 0.1), "Select Sky took the ground"); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "Remove Background would play with the sky selected"); + + play(&mut s, "removeBackground"); + let mask = s.active().unwrap().doc.layers[0].mask.as_ref().expect("Remove Background set no mask"); + assert!(mask.value(80, 40) > 0.8, "Remove Background hid the red disc"); + assert!(mask.value(20, 15) < 0.2, "Remove Background revealed the sky"); +} + +/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10, a cleanliness, bristle +/// detail and shine of 0..10 and a light direction of -180..180. Photoshop clamps a recorded +/// value outside them when the step plays, so the import clamps it too; a step outside them is +/// not written (#10610). +#[test] +fn oil_paint_outside_the_commands_ranges_is_clamped_on_import_and_refused_on_export() { + for (key, v, param, want) in [ + ("stylization", D::Double(0.05), "stylization", 0.1), + ("stylization", D::Double(10.5), "stylization", 10.0), + ("cleanliness", D::Double(-0.5), "cleanliness", 0.0), + ("cleanliness", D::Double(10.5), "cleanliness", 10.0), + ("brushScale", D::Double(0.05), "scale", 0.1), + ("brushScale", D::Double(10.5), "scale", 10.0), + ("microBrush", D::Double(-1.0), "bristleDetail", 0.0), + ("microBrush", D::Double(10.5), "bristleDetail", 10.0), + ("specularity", D::Double(-0.5), "shine", 0.0), + ("specularity", D::Double(10.5), "shine", 10.0), + ("LghD", D::Integer(500), "angle", 180.0), + ("LghD", D::Integer(-500), "angle", -180.0), + ("LghD", D::Integer(181), "angle", 180.0), + ] { + let (id, p) = mapped(newer("oilPaint", desc("null").with(key, v.clone()))); + assert_eq!((id.as_str(), &p[param]), ("filter.stylize.oilPaint", &json!(want)), "{key:?} = {v:?}"); + } + for (low, high) in [(0.1, 0.0), (10.0, 10.0)] { + let d = desc("null") + .with("stylization", D::Double(low)) + .with("cleanliness", D::Double(high)) + .with("brushScale", D::Double(low)) + .with("microBrush", D::Double(high)) + .with("specularity", D::Double(high)); + assert_eq!( + mapped(newer("oilPaint", d)), + ( + "filter.stylize.oilPaint".into(), + json!({"lighting": true, "stylization": low, "cleanliness": high, "scale": low, "bristleDetail": high, "angle": -60.0, "shine": high}) + ), + "{low} {high}" + ); + } + for params in [ + json!({"stylization": 0.05}), + json!({"stylization": 10.5}), + json!({"cleanliness": -0.5}), + json!({"cleanliness": 10.5}), + json!({"scale": 0.05}), + json!({"scale": 10.5}), + json!({"bristleDetail": -1}), + json!({"bristleDetail": 11}), + json!({"shine": -0.5}), + json!({"shine": 10.5}), + json!({"angle": 181}), + json!({"angle": -181}), + json!({"angle": 1000}), + ] { + assert!(export_item("filter.stylize.oilPaint", ¶ms, "S").is_none(), "{params} exported"); + } +} + +/// The step `d` of the Photoshop event `code` (a four-character code), as PhotoCraft loads it. +fn other_step(code: &str, d: Descriptor) -> Mapped { + let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) +} + +/// Photoshop's De-Interlace, NTSC Colors, HSB/HSL, Custom, Average and Remove Black / White +/// Matte steps load as their commands, play, and save back with the same keys (#10607). +#[test] +fn video_other_and_matting_steps_load_play_and_save_back() { + let cells: Vec = (0..25) + .map(|i| { + D::Integer(if i == 12 { + 5 + } else if [7, 11, 13, 17].contains(&i) { + -1 + } else { + 0 + }) + }) + .collect(); + let kernel: Vec = (0..25) + .map(|i| { + if i == 12 { + 5 + } else if [7, 11, 13, 17].contains(&i) { + -1 + } else { + 0 + } + }) + .collect(); + let cases = [ + ( + "Dntr", + desc("null").with("IntE", enm("IntE", "ElmE")).with("IntC", enm("IntC", "CrtD")), + "filter.video.deInterlace", + json!({"eliminate": "evenFields", "createBy": "duplication"}), + ), + ("NTSC", desc("null"), "filter.video.ntscColors", json!({})), + ( + "HsbP", + desc("null").with("Inpt", enm("ClrS", "RGBC")).with("Otpt", enm("ClrS", "HSLC")), + "filter.other.hsbHsl", + json!({"inputMode": "rgb", "rowOrder": "hsl"}), + ), + ( + "Cstm", + desc("null").with("Scl ", D::Integer(1)).with("Ofst", D::Integer(0)).with("Mtrx", D::List(cells)), + "filter.other.custom", + json!({"kernel": kernel, "scale": 1, "offset": 0}), + ), + ("Avrg", desc("null"), "filter.blur.average", json!({})), + ("RmvB", desc("null"), "layer.matting.removeBlackMatte", json!({})), + ("RmvW", desc("null"), "layer.matting.removeWhiteMatte", json!({})), + ]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, d, id, params) in cases { + let Mapped::Steps(steps) = other_step(code, d.clone()) else { panic!("{code} kept") }; + let mut p = steps[0].1.clone(); + // Numbers come back as the descriptor's integers. + if let Some(k) = p.get_mut("kernel") { + *k = json!(k.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as i32).collect::>()); + } + for k in ["scale", "offset"] { + if let Some(v) = p.get_mut(k) { + *v = json!(v.as_f64().unwrap() as i32); + } + } + assert_eq!((steps[0].0.as_str(), &p), (id, ¶ms), "{code}"); + assert_eq!(export_item(id, &steps[0].1, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); + s.actions.list.push(Action::new(code, vec![(id.to_string(), steps[0].1.clone())])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } + // Missing keys are Photoshop's defaults. + let Mapped::Steps(steps) = other_step("Dntr", desc("null")) else { panic!("kept") }; + assert_eq!(steps[0].1, json!({"eliminate": "oddFields", "createBy": "interpolation"})); + let Mapped::Steps(steps) = other_step("HsbP", desc("null")) else { panic!("kept") }; + assert_eq!(steps[0].1, json!({"inputMode": "rgb", "rowOrder": "hsb"})); +} + +/// A step the commands cannot play keeps the Photoshop step: a Custom matrix that is not 5 × 5, +/// an unknown enumeration value, or a key the event does not have (#10607). +#[test] +fn video_other_and_matting_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let nine = D::List((0..9).map(D::Integer).collect()); + assert!(kept(other_step("Cstm", desc("null").with("Mtrx", nine)))); + assert!(kept(other_step("Cstm", desc("null")))); + assert!(kept(other_step("HsbP", desc("null").with("Inpt", enm("ClrS", "LbCl"))))); + assert!(kept(other_step("Dntr", desc("null").with("IntE", enm("IntE", "ElmA"))))); + assert!(kept(other_step("NTSC", desc("null").with("Lvl ", D::Integer(1))))); + assert!(kept(other_step("RmvW", desc("null").with("Lyr ", D::Integer(1))))); +} + +/// What the events cannot hold refuses the step: a fractional kernel cell, a kernel that is not +/// 25 cells, a mask target, an unknown mode (#10607). +#[test] +fn video_other_and_matting_steps_that_cannot_be_written_are_refused() { + let mut cells = vec![json!(0); 25]; + cells[12] = json!(1.5); + for (id, params) in [ + ("filter.other.custom", json!({"kernel": cells})), + ("filter.other.custom", json!({"kernel": [0, 0, 0, 0, 1, 0, 0, 0, 0]})), + ("filter.other.custom", json!({"scale": 2.5})), + ("filter.blur.average", json!({"target": "mask"})), + ("filter.other.hsbHsl", json!({"inputMode": "lab"})), + ("filter.video.deInterlace", json!({"eliminate": "allFields"})), + ("filter.other.custom", json!([1])), + ("filter.blur.average", json!(5)), + ("filter.other.hsbHsl", json!("hsl")), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // `null` params set nothing. + assert!(export_item("filter.blur.average", &Value::Null, "S").is_some()); + // A Custom step without a kernel is the command's identity kernel. + let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap(); + let cells = get_list(&d, "Mtrx").unwrap(); + assert_eq!((cells.len(), cells.get(12)), (25, Some(&D::Integer(1)))); +} + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10607). +#[test] +fn video_other_steps_with_a_wrong_typed_setting_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + for (code, d) in [ + ("Dntr", desc("null").with("IntE", D::Integer(1))), + ("Dntr", desc("null").with("IntC", text("CrtD"))), + ("HsbP", desc("null").with("Inpt", D::Boolean(true))), + ("HsbP", desc("null").with("Otpt", text("HSLC"))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", text("2"))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Boolean(true))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl", enm("Ordn", "Trgt"))), + ] { + assert!(kept(other_step(code, d.clone())), "{code} {d:?} was mapped"); + } +} + +/// The key gate admits `Scl` without its padding, so the mapper reads it too: an unpadded +/// scale is the step's scale, not the default 1. +#[test] +fn custom_reads_an_unpadded_scale() { + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Scl", D::Integer(3)).with("Mtrx", identity)) else { panic!("kept") }; + assert_eq!(steps[0].1["scale"], json!(3.0)); +} + +/// `filter.other.custom` plays a scale of 1..9999, an offset of -9999..9999 and cells of +/// -999..999, and clamps anything else: `Scl 0` would play as 1 and save back as 0. Such a step +/// stays a Photoshop step, as does a matrix of more than 25 cells, and export refuses those +/// values instead of writing what the command doesn't play (#10607). +#[test] +fn custom_outside_the_commands_range_is_kept_and_refused() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let kernel = |cell: i32| D::List((0..25).map(|i| D::Integer(if i == 12 { cell } else { 0 })).collect()); + let identity = kernel(1); + for d in [ + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(0)), + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(10_000)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(-10_000)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(10_000)), + desc("null").with("Mtrx", kernel(1000)), + desc("null").with("Mtrx", kernel(-1000)), + desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())), + desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())), + // Fractions: export writes integers, so Save Actions would refuse the mapped step. + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(1.5)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Double(2.5)), + desc("null").with("Mtrx", D::List((0..25).map(|i| if i == 12 { D::Double(0.5) } else { D::Integer(0) }).collect())), + ] { + assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items); + } + for (scale, offset, cell) in [(1, -9999, -999), (9999, 9999, 999)] { + let d = desc("null").with("Scl ", D::Integer(scale)).with("Ofst", D::Integer(offset)).with("Mtrx", kernel(cell)); + let Mapped::Steps(steps) = other_step("Cstm", d) else { panic!("{scale} {offset} {cell} kept") }; + assert_eq!( + (&steps[0].1["scale"], &steps[0].1["offset"], &steps[0].1["kernel"][12]), + (&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell))) + ); + } + // An integral Double is a whole number and still maps. + let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(2.0))) else { panic!("Scl 2.0 kept") }; + assert_eq!(steps[0].1["scale"], json!(2.0)); + let mut cells = vec![json!(0); 25]; + cells[12] = json!(1000); + for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] { + assert!(export_item("filter.other.custom", ¶ms, "S").is_none(), "{params} exported"); + } +} + +/// The step `d` of Photoshop event `code`, as PhotoCraft loads it. +fn distort(code: &str, d: Descriptor) -> Mapped { + let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) +} + +fn one_step(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } +} + +/// Photoshop's Distort filter steps load with its defaults for missing keys and its clamping for +/// out-of-range values, play, and save back with every key in recorded order (#10605). +#[test] +fn distort_filter_steps_load_clamp_play_and_save_back() { + // Photoshop's defaults, as its steps record them. + let wave = desc("null") + .with("Wvtp", enm("Wvtp", "WvSn")) + .with("NmbG", D::Integer(5)) + .with("WLMn", D::Integer(10)) + .with("WLMx", D::Integer(120)) + .with("AmMn", D::Integer(5)) + .with("AmMx", D::Integer(35)) + .with("SclH", D::Integer(100)) + .with("SclV", D::Integer(100)) + .with("UndA", enm("UndA", "RptE")) + .with("RndS", D::Integer(-7)); + let zigzag = desc("null").with("Amnt", D::Integer(10)).with("NmbR", D::Integer(5)).with("ZZTy", enm("ZZTy", "PndR")); + let cases = [ + ("Pnch", desc("null").with("Amnt", D::Integer(50)), "filter.distort.pinch", json!({"amount": 50})), + ("Plr ", desc("null").with("Cnvr", enm("Cnvr", "RctP")), "filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})), + ( + "Sphr", + desc("null").with("Amnt", D::Integer(100)).with("SphM", enm("SphM", "Nrml")), + "filter.distort.spherize", + json!({"amount": 100, "mode": "normal"}), + ), + ("Twrl", desc("null").with("Angl", D::Integer(50)), "filter.distort.twirl", json!({"angle": 50})), + ( + "Wave", + wave, + "filter.distort.wave", + json!({"type": "sine", "generators": 5, "wavelengthMin": 10, "wavelengthMax": 120, "amplitudeMin": 5, "amplitudeMax": 35, + "undefinedAreas": "repeat", "seed": 4_294_967_289_u32}), + ), + ("ZgZg", zigzag, "filter.distort.zigZag", json!({"amount": 10, "ridges": 5, "style": "pondRipples"})), + ]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, d, id, params) in cases { + let (got, p) = one_step(distort(code, d.clone())); + assert_eq!((got.as_str(), &p), (id, ¶ms), "{code}"); + // Every key, in the order Photoshop records them. + assert_eq!(export_item(id, &p, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); + s.actions.list.push(Action::new(code, vec![(id.to_string(), p)])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } + // A step that leaves its keys out takes Photoshop's defaults: Wave's undefined areas are + // Repeat Edge Pixels, where the command's own default is wrap. + assert_eq!(one_step(distort("Wave", desc("null"))).1["undefinedAreas"], "repeat"); + assert_eq!(one_step(distort("Sphr", desc("null"))).1, json!({"amount": 100, "mode": "normal"})); + // Out-of-range values are clamped, as Photoshop clamps them. + assert_eq!(one_step(distort("Pnch", desc("null").with("Amnt", D::Integer(500)))).1, json!({"amount": 100})); + assert_eq!(one_step(distort("Twrl", desc("null").with("Angl", D::Integer(-5000)))).1, json!({"angle": -999})); + assert_eq!(one_step(distort("ZgZg", desc("null").with("NmbR", D::Integer(40)))).1["ridges"], 20); + assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1); +} + +/// PhotoCraft's renderer draws at most `MAX_WAVE_GENERATORS` (64) generators, so a Wave step +/// recorded with more keeps the Photoshop step on import and is refused on export, as the PSD +/// smart-filter mapper does, rather than mapping a step that plays differently (#10605). +#[test] +fn wave_generators_past_what_photocraft_draws_keep_the_step() { + assert_eq!(photocraft_algo::MAX_WAVE_GENERATORS, 64); + let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(64)))).1; + assert_eq!(p["generators"], 64, "{p}"); + for n in [65, 999, 1_000_000] { + let m = distort("Wave", desc("null").with("NmbG", D::Integer(n))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "NmbG {n}"); + } + assert!(export_item("filter.distort.wave", &json!({"generators": 65}), "S").is_none()); + assert!(export_item("filter.distort.wave", &json!({"generators": 64}), "S").is_some()); +} + +/// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave +/// scaled below 100 % (the command has no scale), or a key the event does not have (#10605). +#[test] +fn distort_filter_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(distort("Sphr", desc("null").with("SphM", enm("SphM", "Dgnl"))))); + assert!(kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "Sprl"))))); + assert!(kept(distort("Wave", desc("null").with("SclH", D::Integer(50))))); + assert!(kept(distort("Wave", desc("null").with("UndA", enm("UndA", "Trns"))))); + assert!(kept(distort("Pnch", desc("null").with("Rds ", D::Integer(3))))); + // A scale of 100 % (or above, which Photoshop clamps to 100) plays. + assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(250))))); +} + +/// A setting the events cannot hold refuses the step instead of replaying differently: a +/// fractional amount (the keys are integers), a mask target, a seed past 32 bits (#10605). +#[test] +fn distort_filter_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": 12.5})), + ("filter.distort.twirl", json!({"angle": 10, "target": "mask"})), + ("filter.distort.zigZag", json!({"ridges": 2.5})), + ("filter.distort.wave", json!({"seed": 4_294_967_296_u64})), + ("filter.distort.wave", json!({"undefinedAreas": "transparent"})), + ("filter.distort.spherize", json!({"mode": "diagonal"})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // Left out, a setting is written as the command replays it: Wave wraps. + let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap(); + assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA")); +} + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10605). +#[test] +fn distort_filter_steps_with_a_wrong_typed_setting_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, v) in [ + ("Pnch", "Amnt", D::Boolean(true)), + ("Twrl", "Angl", text("90")), + ("Plr ", "Cnvr", D::Integer(1)), + ("Sphr", "SphM", text("HrzO")), + ("ZgZg", "NmbR", enm("Ordn", "Trgt")), + ("Wave", "NmbG", D::Boolean(false)), + ("Wave", "Wvtp", D::Integer(2)), + ("Wave", "SclH", text("50")), + ("Wave", "SclV", D::Boolean(true)), + ("Wave", "UndA", D::Integer(1)), + ("Wave", "RndS", text("7")), + ] { + assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); + } +} + +/// An integer outside the range Photoshop clamps the key to refuses the step: written as is, it +/// would load back clamped, as another value (#10605). +#[test] +fn distort_filter_steps_outside_photoshops_range_are_refused() { + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": 500})), + ("filter.distort.spherize", json!({"amount": -101})), + ("filter.distort.twirl", json!({"angle": 1000})), + ("filter.distort.zigZag", json!({"ridges": 21})), + ("filter.distort.zigZag", json!({"ridges": -1})), + ("filter.distort.wave", json!({"generators": 0})), + ("filter.distort.wave", json!({"amplitudeMax": 1000})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // The limits themselves are written. + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": -100})), + ("filter.distort.twirl", json!({"angle": 999})), + ("filter.distort.zigZag", json!({"ridges": 0})), + ("filter.distort.wave", json!({"generators": 64, "wavelengthMin": 1})), + ] { + assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); + } +} + +/// Wave's `RndS` is the seed's 32 bits, signed or not: it loads exactly or keeps the step, +/// never saturated or truncated to another seed (#10605). +#[test] +fn wave_seed_loads_its_exact_32_bits_or_keeps_the_step() { + let seed = |v: D| match distort("Wave", desc("null").with("RndS", v)) { + Mapped::Steps(s) => Some(s[0].1["seed"].clone()), + Mapped::Unsupported { .. } => None, + }; + assert_eq!(seed(D::LargeInteger(4_000_000_000)), Some(json!(4_000_000_000_u32))); + assert_eq!(seed(D::LargeInteger(-1)), Some(json!(u32::MAX))); + assert_eq!(seed(D::Integer(-7)), Some(json!(4_294_967_289_u32))); + assert_eq!(seed(D::Double(12.0)), Some(json!(12))); + for v in [D::Double(7.5), D::LargeInteger(4_294_967_296), D::LargeInteger(i64::from(i32::MIN) - 1)] { + assert_eq!(seed(v.clone()), None, "RndS {v:?} was mapped"); + } +} + +/// A step recording one of its keys twice can't be saved back with both values, so it stays a +/// Photoshop step instead of playing the first and dropping the second (#10605). +#[test] +fn distort_filter_steps_with_a_repeated_key_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, a, b) in [ + ("Pnch", "Amnt", D::Integer(10), D::Integer(20)), + ("Sphr", "SphM", enm("SphM", "Nrml"), enm("SphM", "HrzO")), + ("Plr ", "Cnvr", enm("Cnvr", "RctP"), enm("Cnvr", "RctP")), + ("Wave", "NmbG", D::Integer(5), D::Integer(7)), + ("Wave", "SclH", D::Integer(100), D::Integer(50)), + ("Wave", "UndA", enm("UndA", "WrpA"), enm("UndA", "RptE")), + ("Wave", "RndS", D::Integer(1), D::Integer(2)), + ("ZgZg", "NmbR", D::Integer(5), D::Integer(6)), + ] { + assert!(kept(distort(code, desc("null").with(key, a.clone()).with(key, b.clone()))), "{code} with {key} twice was mapped"); + } +} + +/// An enumeration of another type isn't the setting Photoshop records, so the step stays a +/// Photoshop step instead of loading its value and saving back the expected type (#10605). +#[test] +fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, v) in [ + ("Plr ", "Cnvr", enm("Ordn", "RctP")), + ("Sphr", "SphM", enm("Cnvr", "Nrml")), + ("ZgZg", "ZZTy", enm("Ordn", "PndR")), + ("Wave", "Wvtp", enm("WvSn", "WvSn")), + ("Wave", "UndA", enm("Ordn", "WrpA")), + ] { + assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); + } + assert!(!kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "PlrR"))))); + assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA"))))); +} + +/// Photoshop clamps Wave's wavelengths and amplitudes to 1..999, but `filter.distort.wave` +/// takes a minimum wavelength of 1..998, a maximum of 2..999 and a minimum amplitude of 1..998. +/// A value the command rejects keeps the Photoshop step on import and refuses it on export, +/// rather than mapping a step that fails when it plays; a minimum above its maximum, which the +/// command takes, maps and plays (#10605). +#[test] +fn wave_values_the_command_rejects_are_kept_or_refused() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (key, n) in [("WLMn", 999), ("WLMn", 5000), ("WLMx", 1), ("WLMx", 0), ("AmMn", 999), ("AmMn", 1200)] { + assert!(kept(distort("Wave", desc("null").with(key, D::Integer(n)))), "{key} {n} was mapped"); + } + for (param, n) in [("wavelengthMin", 999), ("wavelengthMax", 1), ("amplitudeMin", 999)] { + assert!(export_item("filter.distort.wave", &json!({ param: n }), "S").is_none(), "{param} {n} exported"); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + let plays = [ + desc("null").with("WLMn", D::Integer(998)), + desc("null").with("WLMx", D::Integer(2)), + desc("null").with("AmMn", D::Integer(998)), + desc("null").with("WLMn", D::Integer(200)).with("WLMx", D::Integer(100)), + desc("null").with("AmMn", D::Integer(40)).with("AmMx", D::Integer(20)), + ]; + for (k, d) in plays.into_iter().enumerate() { + let (id, p) = one_step(distort("Wave", d)); + assert!(export_item(&id, &p, "S").is_some(), "{p} refused"); + let name = format!("W{k}"); + s.actions.list.push(Action::new(&name, vec![(id, p.clone())])); + let played = s.execute("actions.play", json!({"action": name})).unwrap(); + assert_eq!(played["ran"], 1, "{p}: {played}"); + } +} + +/// Wave saves its scales as the integer 100, so a fractional scale, even one that rounds or +/// clamps to 100, stays a Photoshop step instead of saving back as 100 (#10605). +#[test] +fn wave_with_a_fractional_scale_is_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (key, n) in [("SclH", 99.6), ("SclV", 99.5), ("SclH", 100.4)] { + assert!(kept(distort("Wave", desc("null").with(key, D::Double(n)))), "{key} {n} was mapped"); + } + assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(100))))); +} + +/// A Pattern Fill step whose `Ptrn` holds `id` and `name`, as the reference application records it. +fn pattern_fill_step(id: &str, name: &str) -> AtnItem { + let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text(name)))); + item( + "Mk ", + "Make", + Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))), + ) +} + +/// Defines a 4×4 pattern of `color` named `name` in `s`'s library from its active document. +fn define_pattern(s: &mut Session, color: &str, name: &str) -> String { + s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + s.execute("edit.definePattern", json!({"name": name})).unwrap()["pattern"].as_str().unwrap().to_string() +} + +fn pixel(s: &mut Session) -> Vec { + let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap(); + v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect() +} + +/// A Pattern Fill step whose `Ptrn` names both an installed id and a display name shared by +/// another pattern plays the pattern with that id; a foreign id falls back to the name. +#[test] +fn pattern_fill_prefers_an_installed_id_over_a_colliding_name() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let mut define = |color: &str| { + s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Twin"})).unwrap()["pattern"].as_str().unwrap().to_string() + }; + let red = define("#ff0000"); + let blue = define("#0000ff"); + assert_ne!(red, blue); + s.patterns.items.retain(|p| p.id == red || p.id == blue); + s.patterns.items.sort_by_key(|p| p.id != blue); + let fill_step = |id: &str| { + let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text("Twin")))); + item( + "Mk ", + "Make", + Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))), + ) + }; + let data = set_of("Pat", vec![("Installed", vec![fill_step(&red)]), ("Foreign", vec![fill_step("not-installed-id")])]); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert!(r["unsupported"].as_array().is_none_or(|u| u.is_empty()), "{r}"); + for (action, want) in [("Installed", [1.0, 0.0, 0.0, 1.0]), ("Foreign", [0.0, 0.0, 1.0, 1.0])] { + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let r = s.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap(); + let got: Vec = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect(); + assert_eq!(got, want, "{action}"); + } +} + +/// The recorded id is looked up by id alone, in the document and then the library, before any +/// name: a document pattern *named* like the id does not beat the library pattern with that id, +/// and a foreign id that happens to be another pattern's name falls back to the recorded name. +/// An id and a name of the same pattern play it. +#[test] +fn pattern_fill_ids_beat_names_across_document_and_library() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let red = define_pattern(&mut s, "#ff0000", "Red"); + define_pattern(&mut s, "#0000ff", "Blue"); + let green = define_pattern(&mut s, "#00ff00", &red); + let data = set_of( + "Pat", + vec![ + ("IdWins", vec![pattern_fill_step(&red, "Blue")]), + ("NameFallback", vec![pattern_fill_step("Red", "Blue")]), + ("Same", vec![pattern_fill_step(&red, "Red")]), + ], + ); + s.execute("actions.load", json!({"data": data})).unwrap(); + // Only the playback document holds the green pattern named like the red one's id. + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": green})).unwrap(); + s.execute("pattern.delete", json!({"pattern": green})).unwrap(); + assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == green && p.name == red)); + assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]); + for (action, want) in [("IdWins", [1.0, 0.0, 0.0, 1.0]), ("NameFallback", [0.0, 0.0, 1.0, 1.0]), ("Same", [1.0, 0.0, 0.0, 1.0])] { + let r = s.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{action}: {r}"); + assert_eq!(pixel(&mut s), want, "{action}"); + } +} + +/// CodeRabbit on batch 11: a pattern step that names its pattern only by `patternName` exports +/// that name, rather than a step with no pattern (which plays the current one). +#[test] +fn pattern_steps_named_only_by_pattern_name_export_the_name() { + let one = Action { name: "P".into(), steps: vec![("layer.newFillLayer.pattern".into(), json!({"patternName": "Checkerboard"}))], ..Default::default() }; + let (bytes, skipped) = export("Pat", &[&one], &[], false).unwrap(); + assert!(skipped.is_empty()); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + assert_eq!(steps[0].0, "layer.newFillLayer.pattern"); + assert_eq!(steps[0].1["pattern"], json!("Checkerboard"), "{}", steps[0].1); +} + +/// CodeRabbit on batch 11: when the recorded id is installed nowhere, the recorded name is looked +/// up as a name only. A pattern whose id equals that name text doesn't beat the one named so. +#[test] +fn pattern_fill_name_fallback_matches_names_not_ids() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let red = define_pattern(&mut s, "#ff0000", "Red"); + // Green is named like the red pattern's id. + define_pattern(&mut s, "#00ff00", &red); + let data = set_of("Pat", vec![("NameIsAnId", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", &red)])]); + s.execute("actions.load", json!({"data": data})).unwrap(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let r = s.execute("actions.play", json!({"action": "NameIsAnId"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]); +} + +/// A recorded pattern installed neither by id nor by name fails with an error that names the +/// pattern, from the imported step and from the Pattern Fill dialog's command alike. +#[test] +fn pattern_fill_missing_everywhere_names_the_pattern() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + define_pattern(&mut s, "#ff0000", "Red"); + let data = set_of("Pat", vec![("Missing", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", "My Wood")])]); + s.execute("actions.load", json!({"data": data})).unwrap(); + let r = s.execute("actions.play", json!({"action": "Missing"})).unwrap(); + let err = r["failed"]["error"].as_str().unwrap_or_default(); + assert!(err.contains("\"My Wood\""), "{r}"); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Red"})).unwrap(); + let e = s.execute("layer.fill.pattern.set", json!({"pattern": "5e1c0000-aaaa-4bbb-8ccc-000000000000", "patternName": "My Wood"})).unwrap_err(); + assert!(e.to_string().contains("\"My Wood\""), "{e}"); +} + +/// FredApps review on #10459: a set whose name has surrounding spaces (a Photoshop `.atn` or an +/// older actions.json can have one, and New Action records into it under that exact name) saves +/// by that name. `actions.save` used to trim it and then find no set. +#[test] +fn save_finds_a_set_by_its_exact_name_with_spaces() { + let mut s = Session::new(); + s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) }); + s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved = s.execute("actions.save", json!({"set": " Web "})).unwrap(); + assert_eq!((&saved["set"], &saved["actions"]), (&json!(" Web "), &json!(["Old", "New"]))); + // The file keeps the name, as Photoshop's does. + let mut reloaded = Session::new(); + let r = reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(r["set"], " Web "); + // Other names are still trimmed. + s.execute("actions.assignSet", json!({"action": "Old", "set": "Print"})).unwrap(); + assert_eq!(s.execute("actions.save", json!({"set": " Print "})).unwrap()["set"], "Print"); +} + +/// FredApps review on #10459: Save Actions… on an empty set made with New Set writes it, as +/// Photoshop saves an empty set to the `.atn`, and loading that file brings the empty set back. +#[test] +fn an_empty_new_set_saves_and_loads_back() { + let mut s = Session::new(); + s.execute("actions.newSet", json!({"name": "Later"})).unwrap(); + let saved = s.execute("actions.save", json!({"set": "Later"})).unwrap(); + assert_eq!((&saved["set"], &saved["actions"], &saved["steps"]), (&json!("Later"), &json!([]), &json!(0))); + let mut other = Session::new(); + let r = other.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!((&r["set"], &r["actions"]), (&json!("Later"), &json!([]))); + assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "an empty folder"); + other.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "loaded twice, still one folder"); + // New Action records into it as into any set, and it saves with that action. + other.execute("actions.record", json!({"name": "Now", "set": "Later"})).unwrap(); + other.execute("actions.stop", json!({})).unwrap(); + assert_eq!(other.execute("actions.save", json!({"set": "Later"})).unwrap()["actions"], json!(["Now"])); + // A set that doesn't exist is still refused, and so is saving every action when there is none. + let err = s.execute("actions.save", json!({"set": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no actions in a set named `Nope`"), "{err}"); + let err = Session::new().execute("actions.save", json!({})).unwrap_err().to_string(); + assert!(err.contains("there are no actions to save"), "{err}"); +} + +/// A set from Chinese Photoshop: UTF-16 set and action names, a step name in GBK (可选颜色, +/// the bytes from #6743) on an event PhotoCraft has no command for, with no descriptor. +fn gbk_set() -> Vec { + let step = AtnItem { dictionary: b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab".to_vec(), ..item("syntheticFill", "", None) }; + let file = AtnFile { + name: UnicodeString::new_nul("相馆"), + actions: vec![AtnAction { name: UnicodeString::new_nul("调白"), items: vec![step], ..Default::default() }], + ..Default::default() + }; + atn::write(&file).unwrap() +} + +#[test] +fn load_decodes_legacy_step_names() { + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&gbk_set())})).unwrap(); + assert_eq!(r["encoding"], "GBK", "{r}"); + assert_eq!(r["unsupported"][0]["name"], "可选颜色", "{r}"); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let played = s.execute("actions.play", json!({"action": "调白"})).unwrap(); + assert!(played["failed"]["error"].as_str().unwrap().contains("可选颜色"), "{played}"); +} + +#[test] +fn load_keeps_windows_1252_symbols() { + // Windows Photoshop step names with no Unicode copy: Mac Roman reads ° and » as ∞ and ª, + // which must not win ("50 ∞", "Grˆfle ª"). Save Actions writes the names back as that text. + let steps = vec![ + AtnItem { dictionary: b"50 \xb0".to_vec(), ..item("syntheticFill", "", None) }, + AtnItem { dictionary: b"Gr\xf6\xdfe \xbb".to_vec(), ..item("syntheticFill", "", None) }, + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Größe ändern"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Logo"), items: steps, ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["encoding"], "windows-1252", "{r}"); + assert_eq!(r["unsupported"][0]["name"], "50 °", "{r}"); + assert_eq!(r["unsupported"][1]["name"], "Größe »", "{r}"); + let saved = s.execute("actions.save", json!({"set": "Größe ändern"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let names: Vec = saved.actions[0].items.iter().map(|i| saved.item_label(i)).collect(); + assert_eq!(names, ["50 °", "Größe »"]); +} + +#[test] +fn load_encoding_override() { + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "shift_jis"})).unwrap(); + assert_eq!(r["encoding"], "Shift_JIS", "{r}"); + let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "auto"})).unwrap(); + assert_eq!(r["encoding"], "GBK", "{r}"); +} + +#[test] +fn load_reports_utf8() { + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap(); + assert_eq!(r["encoding"], "UTF-8", "{r}"); +} + +#[test] +fn load_rejects_bad_encodings() { + let mut s = Session::new(); + for bad in [json!(3), json!(""), json!("nope"), json!("replacement"), json!(["gbk"])] { + assert!(s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": bad})).is_err(), "{bad}"); + } + assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad"); +} + +/// Edit › Check Spelling in its recorded form: `spellCheck`, `null` = the `spellCheck` property of +/// all text layers, `Usng` = `checkAll`, with the step's dialog on. +fn spell_check(all: bool) -> Descriptor { + desc("null") + .with( + "null", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") }, + ReferenceItem::Enumerated { class: class("TxLr"), type_id: code("Ordn"), value: code("Al") }, + ]), + ) + .with("Usng", D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(all)))) +} + +#[test] +fn check_spelling_loads_and_saves_as_the_spell_check_event() { + for all in [true, false] { + let mut step = item("spellCheck", "Check Spelling", Some(spell_check(all))); + step.with_dialog = true; + let file = AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![step], ..Default::default() }], + ..Default::default() + }; + let imported = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty(), "{:?}", imported.unsupported); + let action = &imported.actions[0]; + assert_eq!(action.steps, [("edit.checkSpelling".to_string(), json!({"allLayers": all}))]); + assert_eq!(action.dialogs.iter().copied().collect::>(), [0]); + let (bytes, skipped) = export("S", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = atn::parse(&bytes).unwrap(); + let saved = &saved.actions[0].items[0]; + assert_eq!(saved.event.id(), "spellCheck"); + assert!(saved.with_dialog); + assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), spell_check(all).to_bytes(), "checkAll {all}"); + } +} + +#[test] +fn other_check_spelling_forms_stay_kept_steps_and_corrections_are_not_saved() { + let event = event_for(&AtnEvent::name("spellCheck")).unwrap(); + let mut extra = spell_check(true); + extra.items.push((Id::new("Fwd "), D::Boolean(true))); + let mut other_target = spell_check(true); + other_target.items[0].1 = D::Reference(vec![ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") }]); + let mut other_class = spell_check(true); + other_class.items[1].1 = D::Descriptor(desc("findReplace").with("checkAll", D::Boolean(true))); + let mut more_using = spell_check(true); + more_using.items[1].1 = D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(true)).with("Fwd ", D::Boolean(true))); + let mut twice = spell_check(true); + twice.items[1] = twice.items[0].clone(); + for d in [extra, other_target, other_class, more_using, twice, desc("null")] { + assert!(matches!((event.from)(&d), Mapped::Unsupported { harmless: false }), "{d:?}"); + } + for params in [ + json!({"action": "change", "start": 0, "end": 3, "replace": "The"}), + json!({"action": "changeAll", "word": "teh", "replace": "the"}), + json!({"allLayers": true, "layer": "Title"}), + json!({"allLayers": "yes"}), + ] { + assert!(export_item("edit.checkSpelling", ¶ms, "S").is_none(), "{params}"); + } +} + +/// Check Spelling's `null` and `Usng` are read by name: a step that holds them in the other +/// order loads as the same step. +#[test] +fn check_spelling_reads_its_keys_in_either_order() { + let event = event_for(&AtnEvent::name("spellCheck")).unwrap(); + for all in [true, false] { + let mut reversed = spell_check(all); + reversed.items.reverse(); + match (event.from)(&reversed) { + Mapped::Steps(steps) => assert_eq!(steps, [("edit.checkSpelling".to_string(), json!({"allLayers": all}))]), + Mapped::Unsupported { .. } => panic!("Usng before null was kept: {reversed:?}"), + } + } +} + +/// A setting recorded twice (`Wdth`, `Fade`, `Scl `, a gallery key) would play one value and +/// save one, dropping the other: such a step stays a Photoshop step (CodeRabbit on #10776). +#[test] +fn steps_that_record_a_setting_twice_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + let once = [ + ("Trap", desc("null").with("Wdth", px(2.0))), + ("matchColor", desc("null").with("Fade", D::Integer(10))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(3))), + ]; + for (event, d) in once { + assert!(!kept(channel_step(event, d.clone())), "{event} once was kept"); + let (key, value) = d.items.last().cloned().unwrap(); + let twice = Descriptor { items: [d.items.clone(), vec![(key, value)]].concat(), ..d }; + assert!(kept(channel_step(event, twice)), "{event} twice was mapped"); + } + let edges = || desc("null").with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5)); + assert!(!kept(gallery_step("GEfc", "AccE", edges()))); + assert!(kept(gallery_step("GEfc", "AccE", edges().with("EdgW", D::Integer(9))))); +} + +/// A gallery effect's own step whose panel name is in the file's legacy code page (GBK +/// "强化的边缘", not UTF-8) saves back with the same name bytes (CodeRabbit on #10776). +#[test] +fn gallery_effect_steps_keep_a_legacy_panel_name_byte_for_byte() { + let edges = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5)); + let gbk = vec![0xC7, 0xBF, 0xBB, 0xAF, 0xB5, 0xC4, 0xB1, 0xDF, 0xD4, 0xB5]; + assert!(std::str::from_utf8(&gbk).is_err()); + let source = AtnItem { dictionary: gbk.clone(), ..item("AccE", "", Some(edges)) }; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": set_of("S", vec![("A", vec![source])])})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let back = &file.actions[0].items[0]; + assert_eq!((back.event.clone(), back.dictionary.clone()), (AtnEvent::Code(*b"AccE"), gbk)); +} + +/// A Cmd-click that adds the Background (a property reference) to the selection keeps its +/// modifier, as a click on a named layer does, and saves back the same (CodeRabbit on #10776). +#[test] +fn a_background_selection_keeps_its_modifier() { + let bckg = || D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]); + for (modifier, mode) in [("addToSelection", "add"), ("removeFromSelection", "remove"), ("addToSelectionContinuous", "range")] { + let d = desc("null").with("null", bckg()).with("selectionModifier", enm("selectionModifierType", modifier)).with("MkVs", D::Boolean(false)); + let loaded = mapped_recording("slct", d.clone()); + assert_eq!(loaded.actions[0].steps, vec![("layer.select".into(), json!({"layer":{"background":true},"mode":mode}))], "{modifier}"); + let (bytes, _) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(d)); + } +} + +/// A Move whose destination is an ordinal of something other than a layer is no stack move: +/// it stays a Photoshop step (CodeRabbit on #10776). +#[test] +fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() { + let ord = |cls: &str, o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code("Ordn"), value: code(o) }]); + let target = || D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }]); + for o in ["Frnt", "Back", "Nxt ", "Prvs"] { + let layer = mapped_recording("move", desc("null").with("null", target()).with("T ", ord("Lyr ", o))); + assert!(layer.unsupported.is_empty(), "{o} on a layer"); + let channel = mapped_recording("move", desc("null").with("null", target()).with("T ", ord("Chnl", o))); + assert_eq!(channel.unsupported.len(), 1, "{o} on a channel was mapped: {:?}", channel.actions[0].steps); + } +} + +/// Nonfinite recorded doubles remain unsupported before native parameters can be constructed. +#[test] +fn shear_nonfinite_coordinates_stay_kept() { + let point = |x, y| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(x)).with("Vrtc", D::Double(y))); + let finite = desc("null").with("ShrP", D::List(vec![point(-10000.0, -1000.0), point(10000.0, 1000.0)])); + assert!(matches!(steps_distort::shear_from(&finite), Mapped::Steps(_)), "finite coordinates still clamp and map"); + for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for d in [ + desc("null").with("ShrP", D::List(vec![point(n, 1.0), point(0.0, 128.0)])), + desc("null").with("ShrP", D::List(vec![point(0.0, 1.0), point(0.0, n)])), + ] { + assert!(matches!(steps_distort::shear_from(&d), Mapped::Unsupported { .. }), "{d:?}"); + } + } +} diff --git a/crates/engine/src/cmd/automate_cmds.rs b/crates/engine/src/cmd/automate_cmds.rs index 42fde9e9bf..f499f036b0 100644 --- a/crates/engine/src/cmd/automate_cmds.rs +++ b/crates/engine/src/cmd/automate_cmds.rs @@ -324,9 +324,13 @@ pub fn document_opened(s: &mut Session) { fire_event(s, "openDocument"); } -/// After the shell saved document `index`: Save Document scripts and Image Assets. -pub fn document_saved(s: &mut Session, index: usize) -> Option { +/// After the shell saved document `doc`: Save Document scripts and Image Assets. +/// The script runs first and may close or reorder tabs, so asset generation resolves the saved +/// document's identity again: a script that closed a different tab must not skip its assets, and +/// one that closed the saved document must not generate a replacement tab's (#11121). +pub fn document_saved(s: &mut Session, doc: photocraft_doc::DocId) -> Option { fire_event(s, "saveDocument"); + let index = s.documents().iter().position(|st| st.doc.id == doc)?; crate::web_cmds::on_saved(s, index) } @@ -375,7 +379,19 @@ fn write_shim(droplet: &str) -> Result { // quoted segment, emit a literal quote, then resume it. let quoted_abs = format!("'{}'", abs.replace('\'', "'\\''")); let body = format!( - "#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet {quoted_abs} \"$@\"\n" + r#"#!/bin/sh +# PhotoCraft droplet: runs the action on the files given (or dropped). +if [ -n "${{PHOTOCRAFT_CLI:-}}" ]; then + cli="$PHOTOCRAFT_CLI" +elif [ -x /Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli ]; then + cli=/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli +elif [ -x "$HOME/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli" ]; then + cli="$HOME/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli" +else + cli=photocraft-cli +fi +exec "$cli" droplet {quoted_abs} "$@" +"# ); crate::file_cmds::write_file(&shim, body.as_bytes())?; #[cfg(unix)] @@ -419,6 +435,9 @@ fn run_droplet(s: &mut Session, p: &Value) -> Result { join(&first.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(), "droplet-output") }); let mut bp = json!({"steps": steps, "input": inputs, "output": output, "format": opts.get("format").cloned().unwrap_or(json!("same"))}); + if let Some(name) = v.get("action").and_then(|action| action.get("name")).or_else(|| v.get("name")) { + bp["action"] = name.clone(); + } if let Some(q) = opts.get("quality") { bp["quality"] = q.clone(); } diff --git a/crates/engine/src/cmd/automate_cmds/tests.rs b/crates/engine/src/cmd/automate_cmds/tests.rs index 3402496331..c6206cc041 100644 --- a/crates/engine/src/cmd/automate_cmds/tests.rs +++ b/crates/engine/src/cmd/automate_cmds/tests.rs @@ -156,6 +156,8 @@ fn droplets_are_written_and_run() { let shim = r["shim"].as_str().unwrap(); let text = std::fs::read_to_string(shim).unwrap(); assert!(text.starts_with("#!/bin/sh") && text.contains("droplet")); + assert!(text.contains("/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli")); + assert!(text.contains("${PHOTOCRAFT_CLI:-}"), "an explicit CLI path must take priority"); use std::os::unix::fs::PermissionsExt; assert_ne!(std::fs::metadata(shim).unwrap().permissions().mode() & 0o111, 0, "executable"); } diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index 05af865ddc..e09ff2098c 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -236,23 +236,18 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result } fn layer_id(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(photocraft_doc::LayerId(id)), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), - } + crate::commands::layer_param(s, p) } fn damage_json(s: &mut Session, dmg: Rect) -> Value { - if let Some(st) = s.active_mut() { - st.last_damage = Some(dmg); - } + s.hint_damage(dmg); json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] }) } /// The target layer (`None` for a channel), brush and zoom of a stroke. On a mask or channel the /// eraser paints the background colour (Photoshop). fn stroke_target(s: &Session, p: &Value, brush: BrushSettings) -> Result<(Option, BrushSettings, f32)> { - let gray = is_mask_target(p) || crate::channel_cmds::is_channel_target(p); + let gray = is_mask_target(p) || crate::channel_cmds::is_channel_target(p) || crate::color_channels::target(s, p)?.is_some(); let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; let brush = if gray && brush.erase { BrushSettings { erase: false, color: s.tools.background, ..brush } } else { brush }; Ok((id, brush, num(p, "zoom").unwrap_or(1.0))) @@ -293,12 +288,16 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS return Ok(l.bounds); } let sel = doc.selection.clone(); + let table = crate::mode_cmds::paint_color_table(doc, p); let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; let mut brush = brush; erase_locked(&mut brush, lock, bg); if auto_erase { + // A stroke in the table's colour starts on what the foreground painted as. + let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg)); apply_auto_erase(&mut brush, surf, pts.first(), fg, bg); } + let unpainted = table.as_ref().map(|_| surf.clone()); let damage = if let Some(axis) = &symmetry { let reflected = axis.reflected_passes(&pts); let copies: Vec<_> = reflected @@ -323,6 +322,9 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS } else { render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom) }; + if let (Some(table), Some(pre)) = (&table, &unpainted) { + crate::mode_cmds::keep_in_color_table(table, pre, surf, damage); + } Ok(damage) }) })?; @@ -405,14 +407,19 @@ fn clear_mode(p: &Value) -> bool { } /// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name -/// (normal|multiply|screen|…), or `clear` to erase with the brush. The Eraser has no blend mode in +/// (normal|multiply|screen|…), or `behind` to paint beneath existing pixels, or `clear` to erase with the brush. The Eraser has no blend mode in /// Photoshop, so it is forced to Normal. fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings { b.erase |= clear_mode(p); if b.erase { b.mode = photocraft_color::BlendMode::Normal; + b.behind = false; + } else if p.get("mode").and_then(Value::as_str).is_some_and(|m| m.eq_ignore_ascii_case("behind")) { + b.mode = photocraft_color::BlendMode::Normal; + b.behind = true; } else if let Some(m) = p.get("mode").and_then(Value::as_str).and_then(crate::commands::blend_from_str) { b.mode = m; + b.behind = false; } b } @@ -526,6 +533,7 @@ pub struct LiveStroke { /// passes it merged (a mirror joining redraws every tile). union_live: Option<(photocraft_paint::UnionLive, usize)>, pre: Surface, + colors: Option, sel: Option, lock: bool, layer: Option, @@ -588,6 +596,8 @@ impl LiveStroke { /// Start a live stroke of `cmd`: `paint.stroke` (the Brush and Eraser) or `paint.pencil`. /// Committing `cmd` with the same params and `"seed": live.seed` gives the same pixels. pub fn begin_with(s: &Session, cmd: &str, p: &Value) -> Result { + let captured = crate::channel_cmds::inject_target(s, cmd, p.clone()); + let p = &captured; has_paintable(s).map_err(EngineError::Other)?; let pts = parse_points(p, cmd)?; let pencil = match cmd { @@ -602,9 +612,11 @@ impl LiveStroke { let base = (st.doc.id, st.revision); let mut doc = (*st.doc).clone(); let sel = doc.selection.clone(); + let table = crate::mode_cmds::paint_color_table(&doc, p); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?; erase_locked(&mut brush, lock, s.tools.background); if pencil && flag(p, "autoErase", false) { + let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg)); apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background); } let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom); @@ -622,6 +634,7 @@ impl LiveStroke { mirror_distinct, union_live: None, pre, + colors: crate::color_channels::target(s, p)?, sel, lock, layer, @@ -678,7 +691,7 @@ impl LiveStroke { check_coords(pts, &self.cmd)?; self.points.extend_from_slice(pts); let cmyk = self.cmyk.clone(); - photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result { self.renderer.push(pts); if let Some(symmetry) = &self.symmetry { for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) { @@ -708,6 +721,9 @@ impl LiveStroke { dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock)); self.tail = old.union(&live.bounds()); self.union_live = Some((live, passes)); + if let Some(colors) = self.colors { + crate::color_channels::restrict_region(&self.pre, surf, colors, dmg)?; + } return Ok(dmg); } let mut dmg = Rect::EMPTY; @@ -723,8 +739,17 @@ impl LiveStroke { self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false); dmg = dmg.union(&self.tail); } + if let Some(colors) = self.colors { + crate::color_channels::restrict_region(&self.pre, surf, colors, dmg)?; + } Ok(dmg) - }) + })?; + // The preview shows the pixels the commit keeps (see `stroke_with`). + if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; + crate::mode_cmds::keep_in_color_table(&table, &self.pre, surf, dmg); + } + Ok(dmg) } } @@ -776,12 +801,40 @@ fn mixer_clean(s: &mut Session, _: &Value) -> Result { Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) } +/// `paint.mixerLoad`: ⌥-click with the Mixer Brush loads the reservoir from the canvas colour at +/// (`x`, `y`) (Photoshop's Current Brush Load), from the layers Sample All Layers names. The +/// foreground colour is left alone. Over transparency or off the canvas nothing is loaded. +fn mixer_load_pixel(s: &mut Session, p: &Value) -> Result { + let cmd = "paint.mixerLoad"; + let coord = |k: &str| { + let v = p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + v.ok_or_else(|| bad(cmd, format!("`{k}` must be a finite number (document pixels)"))) + }; + let (x, y) = (coord("x")?, coord("y")?); + let size = crate::sample_cmds::size_param(cmd, p)?; + let which = if flag(p, "sampleAllLayers", s.tools.brush.mixer.sample_all_layers) { + crate::sample_cmds::SampleLayers::All + } else { + crate::sample_cmds::SampleLayers::Current + }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let [r, g, b, a] = crate::sample_cmds::sample_color(&d.doc, d.active_layer, x, y, size, which); + if a.is_nan() || a <= 0.0 { + return Ok(json!({"loaded": false})); + } + let foreground = s.tools.foreground; + s.tools.mixer.load_sampled([r, g, b, 1.0], foreground); + Ok(json!({"loaded": true, "color": [r, g, b, 1.0]})) +} + /// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour /// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSettings, photocraft_doc::LayerId)> { let cmd = "paint.colorReplacement"; - if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels")) { + if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels")) + && crate::color_channels::explicit(p, s.active().map_or(0, |st| crate::channel_cmds::color_count(&st.doc)))?.is_none() + { return Err(bad(cmd, format!("Color Replacement paints layer pixels only (`target` {t} is not \"pixels\")"))); } let pts = parse_points(p, cmd)?; @@ -817,10 +870,8 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { /// recolouring per dab as the commit, so the preview at the last point is the committed result. pub fn live_color_replacement(s: &Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", &json!({}), stroke, Some(id), 0, |pre| { - Box::new(color_replacement_effect(pre.clone(), rs)) - })? - .with_tail_preview() + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", p, stroke, Some(id), 0, |pre| Box::new(color_replacement_effect(pre.clone(), rs)))? + .with_tail_preview() } fn brush_json(b: &BrushSettings) -> Value { @@ -1054,6 +1105,9 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { } b = merge_brush(&b, &patch, cmd)?; validate_brush(&b, cmd)?; + // Loading a stored tip can fail. Finish preparing the brush before changing its identity or + // foreground colour, so a failed pick leaves the entire current brush state intact. + s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; // The picked preset stays "the current brush" across later edits, so `brush.presets.update` // knows what to overwrite. Only once the brush is accepted: a failed call leaves no state // behind (a `reset` wins over a `preset`, as it replaces the whole brush). @@ -1066,8 +1120,6 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { s.tools.foreground = c; } } - // The tool's brush always paints with full tips: load a library preset's from the store. - s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; let before = std::mem::replace(&mut s.tools.brush, b); // A coalesced gesture (one slider drag) journals as one call: remember the brush it started from. let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none()); @@ -1258,7 +1310,7 @@ pub fn specs() -> Vec { spec!( "paint.pencil", "Pencil", - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|behind (paints beneath existing pixels)|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true @@ -1273,6 +1325,14 @@ pub fn specs() -> Vec { ), spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true), spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true), + spec!( + "paint.mixerLoad", + "Load Mixer Brush", + r##"{"x":px,"y":px,"size":"point"|1|3|5|11|31|51|101=1,"sampleAllLayers":bool=brush.mixer.sampleAllLayers} → {"loaded":bool,"color"?:[r,g,b,a]} (loads the Mixer Brush reservoir from the canvas colour there, as ⌥-click does; the foreground colour is unchanged; the next strokes keep that load while the foreground stays as it is)"##, + has_paintable, + mixer_load_pixel, + true + ), spec!( "paint.colorReplacement", "Color Replacement", diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live.rs b/crates/engine/src/cmd/brush_cmds/mixer_live.rs index 3c15001553..bfe409c471 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live.rs @@ -30,6 +30,7 @@ fn valid_state(state: &MixerState) -> bool { && state.reservoir.as_ref().is_none_or(valid_color) && state.pickup.as_ref().is_none_or(valid_color) && state.color.as_ref().is_none_or(valid_color) + && state.sampled_over.as_ref().is_none_or(valid_color) && state.spatial.as_ref().is_none_or(|colors| colors.len() == 4096 && colors.iter().all(valid_color)) } @@ -48,7 +49,7 @@ struct Core { params: Value, selection: Option, lock: bool, - color_restrict: Option, + color_restrict: Option, count: usize, last: Option, bounds: Rect, @@ -89,7 +90,7 @@ impl Core { } // Render the visible document including the active layer exactly once, before // deposition. Masks, transforms, blend modes and adjustments use the compositor. - let sample = self.settings.sample_all_layers.then(|| { + let sample = (self.settings.sample_all_layers && self.settings.samples_canvas(dab)).then(|| { let buf = photocraft_compose::render(&self.doc, r); let fmt = self.pre.format(); let mut sample = Region::new(r, fmt.channels()); @@ -105,18 +106,8 @@ impl Core { let original = Region::read(&self.pre, r); let (target, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?; mix_back(target, &original, ®ion, r, 1.0, self.selection.as_ref(), self.lock); - if let Some(k) = self.color_restrict { - let n = target.channels(); - let mut values = target.read_region(r); - let before = self.pre.read_region(r); - for (new, old) in values.chunks_exact_mut(n).zip(before.chunks_exact(n)) { - for (c, (new, old)) in new.iter_mut().zip(old).enumerate() { - if c != k { - *new = *old; - } - } - } - target.write_region(r, &values); + if let Some(restrict) = self.color_restrict { + crate::color_channels::restrict_region(&self.pre, target, restrict.colors, r)?; } damage = damage.union(&r); } @@ -160,6 +151,8 @@ impl LiveMixer { /// Validate the params and capture the document and paint load, without rendering a dab. fn prepare(s: &Session, p: &Value, preview_limits: bool) -> Result<(Self, Vec)> { + let captured = crate::channel_cmds::inject_target(s, CMD, p.clone()); + let p = &captured; has_paintable(s).map_err(EngineError::Other)?; if preview_limits && p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() > 65_536) { return Err(bad(CMD, "stroke exceeds 65536 recorded input samples")); diff --git a/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs b/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs index d0c04c30e3..af8e754ce2 100644 --- a/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs +++ b/crates/engine/src/cmd/brush_cmds/mixer_live_tests.rs @@ -70,6 +70,29 @@ fn mixer_live_empty_layer_exact_commit_undo_source_preservation_and_chunking() { } } +/// A dry live stroke commits exactly its preview for either sampling setting and supports undo. +#[test] +fn dry_mixer_live_matches_commit_with_and_without_all_layer_sampling() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for sample_all in [false, true] { + let mut s = fixture(fmt); + let before = target(&s); + let p = + json!({"points":[[40,48],[80,48],[120,48]],"size":24,"wet":0,"sampleAllLayers":sample_all,"cleanAfterStroke":false,"loadAfterStroke":false}); + let live = LiveMixer::begin(&s, &p).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let bounds = s.active().unwrap().doc.bounds(); + let preview = live.doc.layer(id).unwrap().surface().unwrap().read_region(bounds); + s.execute("paint.mixerBrush", p).unwrap(); + assert_eq!(preview, target(&s).read_region(bounds)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(before.read_region(bounds), target(&s).read_region(bounds)); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(preview, target(&s).read_region(bounds)); + } + } +} + /// Current-layer mixing matches the frozen kernel; dropping a preview and cleaning the load paint nothing. #[test] fn mixer_live_current_layer_matches_reference_and_cancels() { diff --git a/crates/engine/src/cmd/brush_cmds/tests.rs b/crates/engine/src/cmd/brush_cmds/tests.rs index 3a3041c4a6..c29d4c3146 100644 --- a/crates/engine/src/cmd/brush_cmds/tests.rs +++ b/crates/engine/src/cmd/brush_cmds/tests.rs @@ -1407,6 +1407,23 @@ fn clear_mode_erases_with_the_brush_or_pencil() { } } +#[test] +fn behind_brush_and_pencil_preserve_foreground_and_round_trip_history() { + for cmd in ["paint.stroke", "paint.pencil"] { + let mut s = session(40, 20); + s.execute(cmd, json!({"points": [[10, 10]], "size": 6, "color": "#ff0000", "hardness": 1.0})).unwrap(); + let before = surface(&s); + s.execute(cmd, json!({"points": [[2, 10], [38, 10]], "size": 6, "color": "#0000ff", "mode": "behind", "hardness": 1.0})).unwrap(); + assert_eq!(rgba(&s, 10, 10), [1.0, 0.0, 0.0, 1.0], "{cmd}: existing opaque paint"); + assert_eq!(rgba(&s, 25, 10), [0.0, 0.0, 1.0, 1.0], "{cmd}: transparent pixels"); + let painted = surface(&s); + s.execute("edit.undo", json!({})).unwrap(); + assert!(same_pixels(&surface(&s), &before, Rect::new(0, 0, 40, 20))); + s.execute("edit.redo", json!({})).unwrap(); + assert!(same_pixels(&surface(&s), &painted, Rect::new(0, 0, 40, 20))); + } +} + /// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by /// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17). #[test] @@ -1424,3 +1441,36 @@ fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() { assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}"); } } + +#[test] +fn mixer_load_fills_the_reservoir_from_the_canvas_and_keeps_the_foreground() { + let mut s = session(80, 40); + s.execute("paint.stroke", json!({"points": [[4, 20], [60, 20]], "size": 6, "color": "#0000ff"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + // Over transparency nothing is loaded. + let r = s.execute("paint.mixerLoad", json!({"x": 30, "y": 5})).unwrap(); + assert_eq!(r["loaded"], json!(false)); + assert!(s.tools.mixer.reservoir.is_none()); + // On the blue stroke the reservoir takes its colour; the foreground stays red. + let r = s.execute("paint.mixerLoad", json!({"x": 30, "y": 20})).unwrap(); + assert_eq!(r["loaded"], json!(true)); + assert_eq!(s.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0])); + assert_eq!(s.tools.foreground, [1.0, 0.0, 0.0, 1.0]); + // The next dry stroke paints the sampled blue, not the foreground. + let brush = json!({"pressureSize": false, "spacing": 0.25}); + let dry = |y: i32, size: u32| json!({"points": [[5, y], [40, y]], "size": size, "wet": 0, "load": 100, "mix": 0, "brush": brush}); + s.execute("paint.mixerBrush", dry(30, 8)).unwrap(); + assert_eq!(rgba(&s, 20, 30), [0.0, 0.0, 1.0, 1.0]); + // A new foreground colour takes the reservoir back. + s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + s.execute("paint.mixerBrush", dry(36, 4)).unwrap(); + assert_eq!(rgba(&s, 20, 36), [0.0, 1.0, 0.0, 1.0]); + // Bad coordinates are an error, not a panic, and leave the reservoir, the foreground and the + // document untouched. + let (mixer, foreground) = (s.tools.mixer.clone(), s.tools.foreground); + let revision = s.active().unwrap().revision; + assert!(s.execute("paint.mixerLoad", json!({"x": "a", "y": 1})).is_err()); + assert_eq!(s.tools.mixer, mixer); + assert_eq!(s.tools.foreground, foreground); + assert_eq!(s.active().unwrap().revision, revision); +} diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index a676c05d6f..e890a492f2 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -15,7 +15,7 @@ use photocraft_algo::selection::{self as sel, SelectionMode}; use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, blend::blend_channel}; use photocraft_doc::{AlphaChannel, ColorIndicates, Document, Layer, LayerContent, LayerId}; -use photocraft_geom::{Rect, TileCoord}; +use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba, to_rgba}; use serde::Serialize; use serde_json::{Value, json}; @@ -46,7 +46,13 @@ fn has_spot(s: &Session) -> std::result::Result<(), String> { } fn can_split(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; - if color_count(&d.doc) + d.doc.channels.len() < 2 { Err("only one channel to split".into()) } else { Ok(()) } + if !is_flat(&d.doc) { + Err(spot::NOT_FLAT.into()) + } else if color_count(&d.doc) + d.doc.channels.len() < 2 { + Err("only one channel to split".into()) + } else { + Ok(()) + } } fn can_merge(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; @@ -83,6 +89,7 @@ pub fn channels_json(st: &DocState) -> Value { })).collect::>(), "quickMask": doc.quick_mask.as_ref().map(|q| json!({ "visible": !v.quick_mask_hidden, "color": q.color.to_rgb(), "opacity": q.opacity, "indicates": q.indicates })), "target": v.target, + "selectedColors": v.color_selection(colors).map(|c| c.indices().collect::>()), "compositeVisible": v.visible_colors(colors) == colors, "layerMaskView": crate::mask_view_cmds::view_json(st), }) @@ -115,20 +122,19 @@ use manage::{delete_channel, duplicate_channel, move_channel, new_channel, new_s use planes::edit_mode; pub(crate) use planes::tone; pub use planes::{ - GraySurface, IsolatedCurveChannel, channel_format, color_count, filter_mask_curve_channel, isolated_curve_channel, isolated_curve_channel_for, - next_alpha_name, + GraySurface, IsolatedCurveChannel, channel_format, color_count, curve_channel_for_colors, filter_mask_curve_channel, isolated_curve_channel, + isolated_curve_channel_for, layer_mask_curve_channel, next_alpha_name, }; pub(crate) use refs::source_spec; pub use refs::{Blending, ChanRef, parse_ref}; use selection::{load_selection, quick_mask, save_selection}; -use spot::{merge, merge_spot, mergeable, split}; +use spot::{is_flat, merge, merge_spot, mergeable, split}; use targeting::{SLOT_KEY, target_cmd, target_slot}; pub(crate) use targeting::{ Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target, invert_region, - is_channel_target, is_spot_target, mask_target_enabled, read_target_surface, restrict_to_color, routed, target_of, target_surface, targeted_channel, - targeted_color, + is_channel_target, is_spot_target, mask_target_enabled, restrict_to_color, routed, target_of, target_surface, targeted_channel, }; -pub use targeting::{follows_filter_mask, follows_target}; +pub use targeting::{follows_filter_mask, follows_target, read_target_surface}; pub(crate) use view::fix_view; pub use view::{ChannelTarget, ChannelView, QuickMaskOptions, color_names, composite_name}; @@ -148,7 +154,7 @@ pub fn specs() -> Vec { "Load Selection…", ["Select"], None, - r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, + r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id|name|{"name":str}|{"background":true}?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, has_doc, load_selection ), @@ -158,7 +164,7 @@ pub fn specs() -> Vec { "Apply Image…", ["Image"], None, - r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, + r##"{"source":{"document":index?,"layer":id|name|{"name":str}|{"background":true}|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, has_apply_target, apply_image ), @@ -206,7 +212,7 @@ pub fn specs() -> Vec { "Target Channel", [], None, - r##"{"channel":"composite"|"mask"|"vectorMask"|"red|green|blue|…"|index|name="composite"}"##, + r##"{"channel":"composite"|"mask"|"vectorMask"|"red|green|blue|…"|index|name|{"color":index|name}|{"alpha":index}="composite","colors":[index,…]?,"extend":bool=false} (a color object selects only a native component, never a named alpha channel; colors selects native colour components only; extend toggles one colour channel, as Shift-click; all components select the composite)"##, has_doc, target_cmd ), diff --git a/crates/engine/src/cmd/channel_cmds/planes.rs b/crates/engine/src/cmd/channel_cmds/planes.rs index e027dbb3df..c70a549dfe 100644 --- a/crates/engine/src/cmd/channel_cmds/planes.rs +++ b/crates/engine/src/cmd/channel_cmds/planes.rs @@ -7,6 +7,8 @@ use super::{ColorMode, DocState, Document, PixelFormat, Rect, Surface}; pub struct IsolatedCurveChannel { /// Channels-panel name, including a custom alpha name or "{layer} Mask". pub name: String, + /// A partial colour selection exposes its combined entry and individual components. + pub components: Option, /// Curves param key: empty = composite `points`; otherwise `red`/`green`/… pub key: &'static str, /// Index into the tone-space histograms (composite = 0 for RGB/CMYK). @@ -45,27 +47,36 @@ pub fn isolated_curve_channel(st: &DocState) -> Option { /// must give the same Curves dropdown (#5335). pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option { let doc = &st.doc; - let mode = edit_mode(doc); + if (matches!(st.channel_view.target, ChannelTarget::Color(_)) || (st.channel_view.target == ChannelTarget::Composite && doc.quick_mask.is_none())) + && crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) + { + return filter_mask_curve_channel(st); + } + if matches!(st.channel_view.target, ChannelTarget::Color(_)) && (crate::mask_view_cmds::current(st).is_some() || layers_panel_mask) { + return layer_mask_curve_channel(st); + } + if let Some(colors) = st.channel_view.color_selection(color_count(doc)) { + return curve_channel_for_colors(doc, colors); + } match st.channel_view.target { - ChannelTarget::Color(k) => { - let names = color_names(mode); - let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode)); - let name = names.get(k)?.to_string(); - let key = *keys.get(k)?; - let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) }; - Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None }) - } ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel { name: c.name.clone(), + components: None, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::Alpha(i)), }), - ChannelTarget::Composite if doc.quick_mask.is_some() => { - Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) }) - } + ChannelTarget::Composite if doc.quick_mask.is_some() => Some(IsolatedCurveChannel { + name: "Quick Mask".into(), + components: None, + key: "", + hist: 0, + tint: false, + translate: true, + gray: Some(GraySurface::QuickMask), + }), ChannelTarget::LayerMask => layer_mask_curve_channel(st), ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => filter_mask_curve_channel(st), ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st), @@ -73,11 +84,33 @@ pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Opt } } -fn layer_mask_curve_channel(st: &DocState) -> Option { +/// Tone editor descriptor for captured native colour targets, without cloning document history. +pub fn curve_channel_for_colors(doc: &Document, colors: crate::color_channels::ColorChannels) -> Option { + let mode = edit_mode(doc); + let names = color_names(mode); + if colors.len() > 1 { + let name = colors.indices().filter_map(|i| names.get(i)).filter_map(|n| n.chars().next()).collect(); + return Some(IsolatedCurveChannel { name, components: Some(colors), key: "", hist: 0, tint: false, translate: false, gray: None }); + } + let k = colors.indices().next()?; + let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode)); + Some(IsolatedCurveChannel { + name: names.get(k)?.to_string(), + components: None, + key: keys.get(k)?, + hist: if mode == ColorMode::Lab { k } else { k.saturating_add(1) }, + tint: true, + translate: true, + gray: None, + }) +} + +/// The active layer mask's tone descriptor, independent of the selected colour rows. +pub fn layer_mask_curve_channel(st: &DocState) -> Option { let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; layer.mask.as_ref()?; let name = format!("{} Mask", layer.name); - Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) }) + Some(IsolatedCurveChannel { name, components: None, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) }) } /// The Curves target when the active smart object's filter mask is targeted (its thumbnail on the @@ -86,7 +119,7 @@ pub fn filter_mask_curve_channel(st: &DocState) -> Option let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; crate::smart_cmds::filter_mask_paintable(layer).ok()?; let name = format!("{} Filter Mask", layer.name); - Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::FilterMask) }) + Some(IsolatedCurveChannel { name, components: None, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::FilterMask) }) } pub(super) fn edit_mode(doc: &Document) -> ColorMode { diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 58708475da..5cedd21e31 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -20,12 +20,17 @@ pub enum ChanRef { VectorMask, } +/// Resolve channel references; a color-name object cannot fall back to a named alpha channel. pub fn parse_ref(v: &Value, doc: &Document) -> Option { let mode = edit_mode(doc); match v { Value::Number(n) => n.as_u64().map(|i| ChanRef::Alpha(i as usize)), Value::Object(o) => { - if let Some(k) = o.get("color").and_then(Value::as_u64) { + if let Some(name) = o.get("color").and_then(Value::as_str) { + let names = color_names(mode); + let k = names.iter().position(|n| n.eq_ignore_ascii_case(name))?; + Some(if names.len() == 1 { ChanRef::Composite } else { ChanRef::Color(k) }) + } else if let Some(k) = o.get("color").and_then(Value::as_u64) { Some(ChanRef::Color(k as usize)) } else { o.get("alpha").and_then(Value::as_u64).map(|i| ChanRef::Alpha(i as usize)) @@ -102,19 +107,29 @@ pub(super) fn native_pixels(doc: &Document, layer: Option) -> Result<(V } /// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded -/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error. +/// Photoshop action refers to it), `{"name":"merged"}` for a literal name, `{"background":true}`, +/// or `"merged"` (default). +/// An unknown name or a missing requested Background is an error. fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result> { - match p.get("layer") { - Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)), - Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc - .walk() - .into_iter() - .rev() - .find(|(_, _, l)| l.name == *name) - .map(|(_, _, l)| Some(l.id)) - .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))), - _ => Ok(None), - } + let name = match p.get("layer") { + Some(Value::Number(n)) => return Ok(n.as_u64().map(LayerId)), + Some(v) if *v == serde_json::json!({"background": true}) => { + return doc.background().map(|l| Some(l.id)).ok_or_else(|| bad(cmd, "the source document has no Background layer")); + } + Some(Value::Object(selector)) => selector + .get("name") + .filter(|_| selector.len() == 1) + .and_then(Value::as_str) + .ok_or_else(|| bad(cmd, "a source layer selector must be {\"background\":true} or {\"name\":\"layer name\"}"))?, + Some(Value::String(name)) if !name.is_empty() && name != "merged" => name, + _ => return Ok(None), + }; + doc.walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))) } /// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one diff --git a/crates/engine/src/cmd/channel_cmds/spot.rs b/crates/engine/src/cmd/channel_cmds/spot.rs index 773089faad..f2aa4a6e34 100644 --- a/crates/engine/src/cmd/channel_cmds/spot.rs +++ b/crates/engine/src/cmd/channel_cmds/spot.rs @@ -55,9 +55,21 @@ pub(super) fn merge_spot(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Whether `doc` has a single layer (a Background or a normal one; a group counts as a layer, +/// so a lone group is refused too). As in the reference application only such an image can be +/// split: the split closes the original, so a layered one would lose its layers. +pub(super) fn is_flat(doc: &Document) -> bool { + matches!(doc.layers.as_slice(), [l] if l.children().is_none()) +} + +pub(super) const NOT_FLAT: &str = "only a single-layer image can be split: flatten the image first"; + /// Split Channels: one grayscale document per colour and alpha channel; closes the original. pub(super) fn split(s: &mut Session, p: &Value) -> Result { let idx = s.active_index().ok_or(EngineError::NoDocument)?; + if !s.documents().get(idx).is_some_and(|d| is_flat(&d.doc)) { + return Err(bad("channel.split", NOT_FLAT)); + } let doc = s.documents()[idx].doc.clone(); let area = doc.bounds(); let (px, n) = native_pixels(&doc, None)?; diff --git a/crates/engine/src/cmd/channel_cmds/targeting.rs b/crates/engine/src/cmd/channel_cmds/targeting.rs index 34e1f269fb..13926d1d89 100644 --- a/crates/engine/src/cmd/channel_cmds/targeting.rs +++ b/crates/engine/src/cmd/channel_cmds/targeting.rs @@ -1,8 +1,8 @@ use super::manage::fix_view_now; use super::planes::{color_count, materialize}; use super::refs::{ChanRef, bad, parse_ref}; -use super::view::ChannelTarget; -use super::{ColorMode, Document, EngineError, Layer, LayerId, Rect, Result, Session, Surface, TileCoord, Value, from_rgba, json, list}; +use super::view::{ChannelTarget, touch}; +use super::{ColorMode, Document, EngineError, Layer, LayerId, Rect, Result, Session, Surface, Value, from_rgba, json, list}; // --------------------------------------------------------------------------------------------- // Targets for pixel commands @@ -66,7 +66,8 @@ pub(crate) fn target_surface<'a>(doc: &'a mut Document, layer: Option, } } -pub(crate) fn read_target_surface<'a>(doc: &'a Document, layer: Option, p: &Value) -> Result<&'a Surface> { +/// Read the explicitly targeted layer pixels, layer mask, saved channel or Quick Mask. +pub fn read_target_surface<'a>(doc: &'a Document, layer: Option, p: &Value) -> Result<&'a Surface> { match target_of(p) { Target::Alpha(i) => { let n = doc.channels.len(); @@ -113,7 +114,7 @@ fn edits_mask(id: &str) -> bool { || crate::filters::params_for(id, &Value::Null).is_some() || id == "edit.fill" // Copying a targeted mask (#2928). - || id == "edit.copy" + || matches!(id, "edit.copy" | "edit.cut" | "edit.clear") || crate::fill_key_cmds::IDS.contains(&id) } @@ -187,6 +188,8 @@ const PAINT_IDS: &[&str] = &[ "paint.artHistoryBrush", "paint.redEye", "paint.remove", + "paint.patch", + "paint.contentAwareMove", ]; /// Pasting into a targeted mask or channel (#1035). @@ -194,8 +197,12 @@ const PASTE_IDS: &[&str] = &["edit.paste", "edit.pasteSpecial.pasteInPlace", "ed /// Commands whose target follows the Channels panel. pub(crate) fn routed(id: &str) -> bool { + // An allow-list, not a `paint.` prefix: Symmetry and the Mixer Brush's ⌥-click load + // (`paint.mixerLoad`) don't edit pixels, so they never follow or refuse a channel target. id.starts_with("filter.") + || id == "edit.stroke" || id == "plugin.run" + || matches!(id, "paint.magicEraser" | "paint.backgroundEraser" | "paint.colorReplacement" | "shape.paint" | "path.fill" | "path.stroke" | "edit.clear" | "edit.cut") || id.starts_with("image.adjustments.") || matches!(id, "edit.fill" | "image.applyImage") || crate::fill_key_cmds::IDS.contains(&id) @@ -224,6 +231,17 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value { ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }), ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"), ChannelTarget::LayerMask => json!("mask"), + ChannelTarget::Color(_) if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => { + if !follows_filter_mask(id) { + return params; + } + json!("filterMask") + } + ChannelTarget::Color(_) if crate::mask_view_cmds::current(st).is_some() => json!("mask"), + ChannelTarget::Color(_) => match crate::color_channels::selected(s).filter(|c| c.len() > 1) { + Some(c) => c.params(), + None => return params, + }, // Viewing the active layer's mask (⌥-click): pixel commands edit the mask. // A shown filter mask takes only the commands that edit it; the rest act on the smart object. ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => { @@ -251,53 +269,33 @@ pub(crate) fn filter_mask_layer(s: &Session, id: &str, params: &Value) -> Option if target_of(params) != Target::FilterMask || !follows_filter_mask(id) { return None; } - params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer) + crate::commands::layer_param(s, params).ok() } /// The colour channel pixel commands are limited to, if a single colour channel is targeted. -pub(crate) fn color_restriction(s: &Session, id: &str, params: &Value) -> Option { +pub(crate) fn color_restriction(s: &Session, id: &str, params: &Value) -> Option { if !routed(id) || is_channel_target(params) || crate::commands::is_mask_target(params) { return None; } - match s.active()?.channel_view.target { - ChannelTarget::Color(k) => Some(k), - _ => None, + // Native plane implementations already write only the selected encoded samples. Avoid a + // second full-layer restore pass after these commands (painting still needs the guard). + if id.strip_prefix("image.adjustments.").is_some_and(|kind| crate::adjust_params::default_for(kind, ColorMode::Rgb).is_ok()) + || crate::filters::params_for(id, params).is_some() + { + return None; } + let colors = crate::color_channels::target(s, params).ok().flatten()?; + let layer = crate::commands::layer_param(s, params).ok()?; + Some(crate::color_channels::Restriction { colors, layer }) } /// After a pixel edit with colour channel `k` targeted, put the other channels (and the layer's /// transparency) of layer `id` back as they were. Only changed tiles are touched. -pub(crate) fn restrict_to_color(before: &Document, after: &mut Document, id: LayerId, k: usize) { - let Some(old) = before.layer(id).and_then(Layer::surface) else { return }; - let Some(new) = after.layer_mut(id).and_then(Layer::surface_mut) else { return }; - if old.format() != new.format() { - 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)) { - for ch in 0..n { - if ch != k { - pn[ch] = po[ch]; - } - } - } - new.write_region(r, &v); - } - new.prune(); -} - -/// The colour channel Clear is limited to: the Channels panel targets a single one. -pub(crate) fn targeted_color(s: &Session) -> Option { - match s.active()?.channel_view.target { - ChannelTarget::Color(k) => Some(k), - _ => None, - } +pub(crate) fn restrict_to_color(before: &Document, after: &mut Document, id: LayerId, colors: crate::color_channels::ColorChannels) -> Result<()> { + let Some(old) = before.layer(id).and_then(Layer::surface) else { return Ok(()) }; + let Some(new) = after.layer_mut(id).and_then(Layer::surface_mut) else { return Ok(()) }; + let extent = before.bounds().union(&old.content_bounds()).union(&new.content_bounds()); + crate::color_channels::restrict_surface_in(old, new, colors, extent) } /// Clear (Delete) with colour channel `k` of layer `id` targeted (#2720): fill the selected area @@ -374,14 +372,17 @@ pub(super) fn target(s: &mut Session, t: ChannelTarget) { let n = st.doc.channels.len(); let v = &mut st.channel_view; v.target = t; + v.selected_colors.clear(); match t { // Like clicking RGB in Photoshop: the alpha overlays go away with their eyes. ChannelTarget::Composite => { + v.layer_mask = None; v.color_hidden.clear(); v.alpha_visible.clear(); } ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(), ChannelTarget::Color(k) => { + v.layer_mask = None; v.color_hidden = (0..colors).map(|c| c != k).collect(); v.alpha_visible.clear(); } @@ -420,8 +421,46 @@ fn parse_target(s: &Session, p: &Value, cmd: &str) -> Result { } pub(super) fn target_cmd(s: &mut Session, p: &Value) -> Result { - let t = parse_target(s, p, "channel.target")?; - target(s, t); + let count = s.active().map_or(0, |st| color_count(&st.doc)); + let colors = p.get("colors").or_else(|| p.get("channel").and_then(|c| c.get("colors"))); + let extend = match p.get("extend") { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad("channel.target", "extend must be a boolean"))?, + }; + let selected = if let Some(indices) = colors { + crate::color_channels::explicit(&json!({"target":{"colors":indices}}), count)? + } else { + let t = parse_target(s, p, "channel.target")?; + if extend { + let ChannelTarget::Color(k) = t else { return Err(bad("channel.target", "only colour channels can be selected together")) }; + let mut indices: Vec<_> = crate::color_channels::selected(s).map(|c| c.indices().collect()).unwrap_or_else(|| { + if s.active().is_some_and(|st| st.channel_view.target == ChannelTarget::Composite) { (0..count).collect() } else { Vec::new() } + }); + if indices.contains(&k) { + if indices.len() > 1 { + indices.retain(|i| *i != k); + } + } else { + indices.push(k); + } + Some(crate::color_channels::ColorChannels::from_indices(indices, count)?) + } else { + target(s, t); + return Ok(list(s)); + } + }; + if let Some(colors) = selected { + if colors.len() == count { + target(s, ChannelTarget::Composite); + } else if let Some(first) = colors.indices().next() { + target(s, ChannelTarget::Color(first)); + if let Some(st) = s.active_mut() { + st.channel_view.selected_colors = colors.indices().collect(); + st.channel_view.color_hidden = (0..count).map(|i| !colors.contains(i)).collect(); + touch(st); + } + } + } Ok(list(s)) } diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index 079b2e4c86..a640a7b45b 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1185,3 +1185,37 @@ fn layers_panel_mask_target_isolates_the_curve_channel() { let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins"); assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); } + +/// Split Channels closes the original, so as in the reference application it splits only an +/// image with a single layer: two layers (even with one hidden) or a lone group are refused and +/// the document stays open with its layers; a flattened image or a lone normal layer still +/// splits (a played `SplC` step goes through the same check). +#[test] +fn split_refuses_an_image_with_more_than_one_layer() { + let mut s = session(); + s.execute("edit.fill", json!({"color": "#ff8000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(!s.is_enabled("channel.split")); + let err = s.execute("channel.split", json!({})).unwrap_err(); + assert!(err.to_string().contains("flatten"), "{err}"); + assert_eq!(s.documents().len(), 1, "the layered document stays open"); + assert_eq!(doc(&s).layers.len(), 2, "with its layers"); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers[1].visible = false; + assert!(s.execute("channel.split", json!({})).is_err(), "a hidden second layer still counts"); + assert_eq!((s.documents().len(), doc(&s).layers.len()), (1, 2)); + s.execute("layer.flattenImage", json!({})).unwrap(); + assert!(s.is_enabled("channel.split")); + assert_eq!(s.execute("channel.split", json!({})).unwrap()["documents"].as_array().unwrap().len(), 3); + // A lone normal layer (Layer 0) splits too. + let mut s = session(); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + assert!(!doc(&s).is_background(doc(&s).layers[0].id)); + assert!(s.is_enabled("channel.split")); + assert_eq!(s.execute("channel.split", json!({})).unwrap()["documents"].as_array().unwrap().len(), 3); + // A lone group is a layer the split would lose. + let mut s = session(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers = vec![Layer::group("Group 1", vec![])]; + assert!(!s.is_enabled("channel.split")); + assert!(s.execute("channel.split", json!({})).is_err()); + assert_eq!(s.documents().len(), 1); +} diff --git a/crates/engine/src/cmd/channel_cmds/view.rs b/crates/engine/src/cmd/channel_cmds/view.rs index e93be8ff0a..9b918656f4 100644 --- a/crates/engine/src/cmd/channel_cmds/view.rs +++ b/crates/engine/src/cmd/channel_cmds/view.rs @@ -26,6 +26,8 @@ pub enum ChannelTarget { #[serde(rename_all = "camelCase")] pub struct ChannelView { pub target: ChannelTarget, + /// Selected native colour components; empty retains the legacy single target. + pub selected_colors: Vec, /// Hidden colour channels (missing entries are visible). pub color_hidden: Vec, /// Visible alpha channels (missing entries are hidden). @@ -36,6 +38,9 @@ pub struct ChannelView { } impl ChannelView { + pub fn color_selection(&self, count: usize) -> Option { + crate::color_channels::from_view(self, count) + } pub fn color_visible(&self, k: usize) -> bool { !self.color_hidden.get(k).copied().unwrap_or(false) } @@ -97,6 +102,7 @@ pub(crate) fn fix_view(st: &mut DocState) { let v = &mut st.channel_view; v.alpha_visible.truncate(n); v.color_hidden.truncate(colors); + v.selected_colors.retain(|k| *k < colors); match v.target { ChannelTarget::Alpha(i) if i >= n => v.target = ChannelTarget::Composite, ChannelTarget::Color(k) if k >= colors || colors < 2 => v.target = ChannelTarget::Composite, @@ -108,6 +114,12 @@ pub(crate) fn fix_view(st: &mut DocState) { } _ => {} } + if v.color_selection(colors).is_some_and(|selected| selected.len() == colors) { + v.target = ChannelTarget::Composite; + } + if !matches!(v.target, ChannelTarget::Color(_)) { + v.selected_colors.clear(); + } } crate::mask_view_cmds::fix(st); // An alpha/spot eye, Quick Mask, or the active layer's mask can be the only thing on the diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index 9e9c0aafd3..5fb2d7d0f2 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -23,6 +23,8 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod profile_dither; + // ------------------------------------------------------------------ state /// View › Proof Setup settings. @@ -657,24 +659,29 @@ pub fn convert_surface(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transf .collect(); let tile_px = (photocraft_geom::TILE_SIZE * photocraft_geom::TILE_SIZE) as usize; let dst_len = tile_px * to.bytes_per_pixel(); - let mut staging = vec![0u8; dst_len * distinct.len()]; - let jobs: Vec<(&[u8], &mut [u8])> = distinct.iter().map(|t| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect(); let kind = match sf.sample { SampleType::U8 => SampleKind::U8, SampleType::U16 => SampleKind::U16, SampleType::F32 => SampleKind::F32, }; - t.convert_bytes_many(kind, jobs, ss, ds, true); - let mut converted: Vec>> = vec![None; distinct.len()]; - for (c, i) in tiles { - if let Some(Some(tile)) = converted.get(i) { - out.put_tiles([(c, tile.clone())]); - continue; + let mut converted = Vec::with_capacity(distinct.len()); + for batch in distinct.chunks(32) { + let mut staging = vec![0u8; dst_len * batch.len()]; + { + let sources: Vec<_> = batch.iter().map(|tile| tile.bytes()).collect(); + let jobs: Vec<(&[u8], &mut [u8])> = sources.iter().map(|bytes| &**bytes).zip(staging.chunks_mut(dst_len.max(1))).collect(); + t.convert_bytes_many(kind, jobs, ss, ds, true); } - let Some(bytes) = staging.chunks(dst_len.max(1)).nth(i) else { continue }; - out.tile_mut(c).bytes_mut().copy_from_slice(bytes); - if let Some(slot) = converted.get_mut(i) { - *slot = out.tile(c).cloned(); + for bytes in staging.chunks(dst_len.max(1)) { + let coord = photocraft_geom::TileCoord::new(0, 0); + let mut surface = Surface::new(to); + surface.tile_mut(coord).bytes_mut().copy_from_slice(bytes); + converted.push(surface.tile(coord).cloned()); + } + } + for (coord, index) in tiles { + if let Some(Some(tile)) = converted.get(index) { + out.put_tiles([(coord, tile.clone())]); } } out @@ -845,7 +852,7 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { let (mut px, mut o) = ([0.0f32; 2], [0.0f32; 4]); for i in 0..n { for (k, v) in px.iter_mut().enumerate().take(ss) { - *v = photocraft_color::read_sample(src, sf.sample, i * ss + k); + *v = photocraft_color::read_sample(&src, sf.sample, i * ss + k); } ink(&px[..ss], &mut o[..ds]); for (k, v) in o.iter().enumerate().take(ds) { @@ -856,7 +863,7 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { }) .collect(); for ((c, _), bytes) in tiles.iter().zip(inked) { - let tile = out.tile_mut(*c).bytes_mut(); + let mut tile = out.tile_mut(*c).bytes_mut(); if tile.len() == bytes.len() { tile.copy_from_slice(&bytes); } @@ -950,6 +957,78 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({ "profile": desc })) } +/// Read opt-in destructive profile options without accepting malformed values as defaults. +fn profile_option(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(value) => Err(EngineError::BadParams { cmd: "edit.convertToProfile".into(), msg: format!("`{key}` must be true or false, not {value}") }), + } +} + +/// Prepare displayed Smart pixels, refreshing only absent caches before a destructive edit. +fn prepare_profile_smart_layers(doc: &mut Document, rasterize: bool) -> Result<()> { + let ids = { + let mut pending = crate::allocation::capacity(doc.layers.len(), "preparing placed layers for profile conversion")?; + pending.extend(doc.layers.iter().rev().map(|layer| (layer, true))); + let mut ids = Vec::new(); + while let Some((layer, parent_visible)) = pending.pop() { + let visible = parent_visible && layer.visible; + if (rasterize || visible) && matches!(layer.content, LayerContent::Smart(_)) { + ids.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to prepare placed layers for profile conversion".into()))?; + ids.push(layer.id); + } + if let Some(children) = layer.children() { + pending + .try_reserve(children.len()) + .map_err(|_| EngineError::Other("not enough memory to prepare placed layers for profile conversion".into()))?; + pending.extend(children.iter().rev().map(|child| (child, visible))); + } + } + ids + }; + let format = doc.pixel_format(); + for id in ids { + let mut layer = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; + if matches!(&layer.content, LayerContent::Smart(smart) if smart.cache.is_none()) { + crate::smart_cmds::refresh_layer(doc, &mut layer)?; + } + let LayerContent::Smart(smart) = &layer.content else { return Err(EngineError::NoLayer(id)) }; + let surface = + smart.cache.clone().ok_or_else(|| EngineError::Other(format!("could not render placed layer `{}` for profile conversion", layer.name)))?; + let target = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if rasterize { + target.content = LayerContent::Raster(if surface.format() == format { surface } else { surface.convert(format) }); + target.psd_blocks.retain(|(key, _)| key != b"SoLd" && key != b"SoLE" && key != b"PlLd"); + } else { + target.content = layer.content; + } + } + Ok(()) +} + +/// Flatten in the source space so conversion preserves the composited appearance. +fn flatten_for_profile(doc: &mut Document, active: &mut Option) -> Result<()> { + let bounds = doc.bounds(); + if u64::from(bounds.width()) * u64::from(bounds.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(EngineError::BadParams { + cmd: "edit.convertToProfile".into(), + msg: format!("the document's {}×{} canvas is too large to flatten", bounds.width(), bounds.height()), + }); + } + prepare_profile_smart_layers(doc, false)?; + let format = doc.pixel_format(); + let surface = photocraft_compose::flatten_to_surface(doc, format, Some([1.0, 1.0, 1.0])); + let mut background = Layer::raster("Background", format); + background.locks.transparency = true; + background.locks.position = true; + *crate::pixels_mut(&mut background)? = surface; + *active = Some(background.id); + doc.layers = vec![background]; + Ok(()) +} + +/// Optionally freeze placed pixels or flatten the source, then convert in one atomic edit. fn convert_to_profile(s: &mut Session, p: &Value) -> Result { let spec = p .get("profile") @@ -959,7 +1038,18 @@ fn convert_to_profile(s: &mut Session, p: &Value) -> Result { let dst = s.color.resolve(spec, Some(doc), None)?; let intent = intent_or(p, s.color.settings.intent())?; let bpc = bool_param(p, "bpc", s.color.settings.bpc); - s.edit("Convert to Profile", |doc, _| convert_document(doc, &dst, intent, bpc))?; + let flatten = profile_option(p, "flatten")?; + let rasterize = profile_option(p, "rasterizePlaced")?; + let dither = profile_option(p, "dither")?; + s.edit("Convert to Profile", |doc, active| { + if flatten { + // Flatten consumes the displayed image; discarded hidden objects need no source read. + flatten_for_profile(doc, active)?; + } else if rasterize { + prepare_profile_smart_layers(doc, true)?; + } + if dither { profile_dither::convert(doc, &dst, intent, bpc) } else { convert_document(doc, &dst, intent, bpc) } + })?; let d = &s.active().ok_or(EngineError::NoDocument)?.doc; Ok(json!({ "profile": dst.description, "mode": format!("{:?}", d.mode), "intent": intent.id(), "bpc": bpc })) } @@ -1168,7 +1258,7 @@ pub fn specs() -> Vec { "edit.convertToProfile", "Convert to Profile…", ["Edit"], - r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"dither":bool=false (deterministic color quantization for 8-bit profile changes),"flatten":bool=false (flatten in the source profile before conversion),"rasterizePlaced":bool=false (freeze displayed Smart Object pixels; flatten takes precedence)}"##, has_doc, convert_to_profile, true @@ -1675,7 +1765,10 @@ mod settings_tests { fn exif_srgb_jpeg_round_trip() { let mut s = Session::new(); s.execute("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap(); - let r = photocraft_io::export(&untagged_camera_file(Some(1)), "x.jpg", &photocraft_io::ExportOptions::default()).unwrap(); + // Embed Color Profile off: an untagged document is otherwise tagged sRGB on export (#9325). + let mut opts = photocraft_io::ExportOptions::default(); + opts.encode.embed_icc = false; + let r = photocraft_io::export(&untagged_camera_file(Some(1)), "x.jpg", &opts).unwrap(); let back = photocraft_io::import("x.jpg", &r.bytes).unwrap().document; assert!(back.icc_profile.is_none(), "no ICC profile in the file"); let (_, r) = s.open_document(back, None); diff --git a/crates/engine/src/cmd/color_cmds/profile_dither.rs b/crates/engine/src/cmd/color_cmds/profile_dither.rs new file mode 100644 index 0000000000..4bc5854ac0 --- /dev/null +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -0,0 +1,205 @@ +//! Opt-in 8-bit profile quantization. The ordinary CMS buffer paths stay unchanged. + +use std::sync::Arc; + +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Tile; +use rayon::prelude::*; + +use super::{ + ColorMode, Document, EngineError, Intent, PixelFormat, Profile, Result, SampleType, Surface, Transform, cms_err, convert_color, convert_document, + convert_layer_colors, convert_surface, document_profile, space_mode, +}; + +const TILE_PIXELS: u128 = (TILE_SIZE * TILE_SIZE) as u128; +// Two per-pixel arrays and the decoded source default; no tile or plane staging. +const SCRATCH_BYTES: u128 = (3 * 16 * std::mem::size_of::()) as u128; + +fn bad(message: &str) -> EngineError { + EngineError::BadParams { cmd: "edit.convertToProfile".into(), msg: message.into() } +} + +/// Dither only layouts the CMS can transform directly, copying alpha without conversion. +fn eligible(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transform) -> bool { + let sf = s.format(); + sf.sample == SampleType::U8 + && sf.mode == from + && t.inputs() == sf.mode.color_channels() + && t.outputs() == to.mode.color_channels() + && sf.alpha == to.alpha + && sf.channels() <= 16 + && to.channels() <= 16 +} + +fn in_canvas_tiles(c: TileCoord, bounds: Rect) -> bool { + if bounds.is_empty() { + return false; + } + let a = TileCoord::containing(bounds.x0, bounds.y0); + let b = TileCoord::containing(bounds.x1 - 1, bounds.y1 - 1); + c.tx >= a.tx && c.tx <= b.tx && c.ty >= a.ty && c.ty <= b.ty +} + +/// Noise needs real pixel coordinates, not the saturating rectangle of a malformed tile. +fn tile_origin(c: TileCoord) -> Result<(i32, i32)> { + let x = i64::from(c.tx) * i64::from(TILE_SIZE); + let y = i64::from(c.ty) * i64::from(TILE_SIZE); + if x < i64::from(i32::MIN) + || y < i64::from(i32::MIN) + || x + i64::from(TILE_SIZE - 1) > i64::from(i32::MAX) + || y + i64::from(TILE_SIZE - 1) > i64::from(i32::MAX) + { + return Err(bad("a stored tile has unaddressable coordinates; disable dithering to use the ordinary conversion")); + } + Ok((x as i32, y as i32)) +} + +/// A transparent default stays transparent: its converted default already stands for the absent +/// tiles, and dither noise under alpha 0 is invisible, so only stored tiles are dithered. +fn sparse(s: &Surface) -> bool { + let sf = s.format(); + sf.alpha && s.default_pixel().get(sf.mode.color_channels()).is_some_and(|a| *a == 0.0) +} + +/// The tiles the dithered output stores: every in-canvas tile (unless the surface is sparse) +/// plus every stored tile. +fn dither_tiles(s: &Surface, bounds: Rect) -> Vec { + if sparse(s) { + return s.tiles().map(|(c, _)| *c).collect(); + } + let outside = s.tiles().map(|(c, _)| *c).filter(|c| !in_canvas_tiles(*c, bounds)); + bounds.tiles().chain(outside).collect() +} + +/// Price every additional output tile before allocating any; distant tiles add no bounding box. +fn output_bytes(s: &Surface, to: PixelFormat, bounds: Rect) -> Result { + let mut count = if sparse(s) { 0 } else { u128::from(bounds.tile_count()) }; + for (c, _) in s.tiles() { + tile_origin(*c)?; + if sparse(s) || !in_canvas_tiles(*c, bounds) { + count = count.checked_add(1).ok_or_else(|| bad("dither output size overflowed"))?; + } + } + count + .checked_mul(TILE_PIXELS) + .and_then(|n| n.checked_mul(to.bytes_per_pixel() as u128)) + .and_then(|n| n.checked_add(to.bytes_per_pixel() as u128)) + .ok_or_else(|| bad("dither output size overflowed")) +} + +/// Stochastic rounding before quantization preserves integer samples and neutral channels. +fn quantize(value: f32, threshold: f64) -> u8 { + if !value.is_finite() { + // Match the ordinary converter's saturating float cast, including NaN -> zero. + return (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + let scaled = f64::from(value.clamp(0.0, 1.0) * 255.0); + let lower = scaled.floor(); + (lower + f64::from(u8::from(scaled - lower > threshold))) as u8 +} + +/// Stream the finite canvas and stored deviations, retaining the undithered infinite default. +/// Tiles are independent (the noise depends on pixel coordinates only) and run in parallel. +fn surface(s: &Surface, to: PixelFormat, t: &Transform, bounds: Rect) -> Result { + let ds = to.channels(); + let mut converted_default = [0.0; 16]; + t.convert_f32(&s.default_pixel(), s.channels(), &mut converted_default[..ds], ds, true); + let mut out = Surface::with_default(to, &converted_default[..ds]); + let tiles = dither_tiles(s, bounds).par_iter().map(|c| dither_tile(s, &out, t, bounds, *c).map(|tile| (*c, Arc::new(tile)))).collect::>>()?; + out.put_tiles(tiles); + out.prune(); + Ok(out) +} + +/// One output tile of [`surface`], starting from the converted default of `out`. +fn dither_tile(s: &Surface, out: &Surface, t: &Transform, bounds: Rect, c: TileCoord) -> Result { + let (ss, ds) = (s.channels(), out.channels()); + let (ni, no) = (t.inputs(), t.outputs()); + let default = s.default_bytes(); + let (x0, y0) = tile_origin(c)?; + let source = s.tile(c).map(|tile| tile.bytes()); + let mut dest = out.try_default_tile().map_err(|e| EngineError::Other(format!("could not allocate dither output: {e}")))?; + for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() { + let x = x0 + (index % TILE_SIZE as usize) as i32; + let y = y0 + (index / TILE_SIZE as usize) as i32; + let sp = match &source { + Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?, + None => default, + }; + // A redundant default tile outside the canvas must match the implicit plane. + if !bounds.contains(x, y) && sp == default { + continue; + } + let mut input = [0.0; 16]; + let mut output = [0.0; 16]; + for (d, s) in input.iter_mut().zip(sp).take(ni) { + *d = f32::from(*s) / 255.0; + } + t.eval_fast(&input[..ni], &mut output[..no]); + // The hash can return 1.0; recenter its 65536 bins strictly inside (0, 1). + let threshold = (f64::from(photocraft_color::dither_noise(x, y)) * 65535.0 + 0.5) / 65536.0; + for (d, value) in dp.iter_mut().zip(output).take(no) { + *d = quantize(value, threshold); + } + for (d, s) in dp.iter_mut().skip(no).zip(sp.iter().skip(ni)) { + *d = *s; + } + } + Ok(dest) +} + +/// Preserve ordinary conversion outside opt-in U8, admitting the whole dither edit first. +pub(super) fn convert(doc: &mut Document, dst: &Profile, intent: Intent, bpc: bool) -> Result<()> { + let src = document_profile(doc); + if doc.depth != SampleType::U8 || src.to_bytes() == dst.to_bytes() || doc.mode == ColorMode::Duotone { + return convert_document(doc, dst, intent, bpc); + } + let to_mode = space_mode(dst.color_space).ok_or_else(|| EngineError::Other(format!("cannot convert to a {:?} profile", dst.color_space)))?; + let from = doc.pixel_format().mode; + let t = Transform::new(&src, dst, intent, bpc).map_err(cms_err)?; + let format = PixelFormat::new(to_mode, doc.depth, true); + let bounds = doc.bounds(); + if doc.size.width > i32::MAX as u32 || doc.size.height > i32::MAX as u32 { + return Err(bad("the canvas has unaddressable coordinates; disable dithering to use the ordinary conversion")); + } + let mut bytes = SCRATCH_BYTES; + let mut admission = Ok(()); + crate::image_cmds::for_each_surface(&mut doc.layers, false, &mut |s, _| { + if admission.is_err() { + return; + } + let to = PixelFormat { alpha: s.format().alpha, ..format }; + if eligible(s, from, to, &t) { + admission = output_bytes(s, to, bounds).and_then(|additional| { + bytes = bytes.checked_add(additional).ok_or_else(|| bad("dither output size overflowed"))?; + Ok(()) + }); + } + }); + admission?; + // This is an output-raster cap, not process peak: the source and history remain retained. + if bytes > u128::from(crate::image_cmds::MAX_RESAMPLE_BYTES) { + return Err(bad("dither output exceeds the raster allocation budget; use a smaller canvas or disable dithering")); + } + let mut result = Ok(()); + crate::image_cmds::for_each_surface(&mut doc.layers, false, &mut |s, _| { + if result.is_err() { + return; + } + let to = PixelFormat { alpha: s.format().alpha, ..format }; + if eligible(s, from, to, &t) { + result = surface(s, to, &t, bounds).map(|converted| *s = converted); + } else { + *s = convert_surface(s, from, to, &t); + } + }); + result?; + for layer in &mut doc.layers { + convert_layer_colors(layer, &mut |c| convert_color(c, from, to_mode, &t)); + } + doc.mode = to_mode; + doc.icc_profile = Some(dst.to_bytes()); + doc.color_table = None; + doc.duotone = None; + Ok(()) +} diff --git a/crates/engine/src/cmd/comps_cmds.rs b/crates/engine/src/cmd/comps_cmds.rs index db239c328f..ee6bc6d196 100644 --- a/crates/engine/src/cmd/comps_cmds.rs +++ b/crates/engine/src/cmd/comps_cmds.rs @@ -202,8 +202,39 @@ fn apply(s: &mut Session, id: u32, label: &str) -> Result { Ok(json!({"comp": id, "missingLayers": missing})) } +/// Resolve recorded comp forms without confusing panel indexes with persisted ids. +fn recorded_comp(doc: &Document, selector: &Value) -> Result> { + const CMD: &str = "layerComp.apply"; + let fields = selector.as_object().filter(|fields| fields.len() == 1).ok_or_else(|| bad(CMD, "comp reference needs one index, id or ordinal"))?; + if let Some(index) = fields.get("index") { + if let Some(index) = index.as_u64() { + let index = u32::try_from(index).map_err(|_| bad(CMD, "comp index must be a 32-bit integer"))?; + return Ok(index.checked_sub(1).and_then(|index| usize::try_from(index).ok()).and_then(|index| doc.layer_comps.get(index)).map(|comp| comp.id)); + } + if index.as_i64().is_some_and(|index| index < 0 && i32::try_from(index).is_ok()) { + return Ok(None); + } + return Err(bad(CMD, "comp index must be a 32-bit integer")); + } + if let Some(id) = fields.get("id") { + let id = crate::commands::u32_id(CMD, "comp id", id)?; + return doc.comp(id).map(|comp| Some(comp.id)).ok_or_else(|| bad(CMD, format!("no layer comp with id {id}"))); + } + let ordinal = fields.get("ordinal").and_then(Value::as_str).ok_or_else(|| bad(CMD, "comp reference needs an index, id or ordinal"))?; + match ordinal { + "Trgt" | "Nxt" | "Prvs" => Ok(doc.last_applied_comp.filter(|id| doc.comp(*id).is_some()).or_else(|| doc.layer_comps.first().map(|comp| comp.id))), + "Frst" | "Lst" | "Al" => Ok(doc.layer_comps.first().map(|comp| comp.id)), + _ => Err(bad(CMD, format!("unsupported comp ordinal {ordinal}"))), + } +} + +/// Apply the selected comp; a 32-bit index outside the comp list is a no-op. fn apply_cmd(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; + if let Some(selector @ Value::Object(_)) = p.get("comp") { + let Some(id) = recorded_comp(&d.doc, selector)? else { return Ok(json!({"comp": null, "unchanged": true})) }; + return apply(s, id, "Apply Layer Comp"); + } let id = comp_param(&d.doc, p, "layerComp.apply")?; apply(s, id, "Apply Layer Comp") } @@ -213,6 +244,9 @@ fn apply_cmd(s: &mut Session, p: &Value) -> Result { /// (#7181); by name the step finds that document's comp of the name, or stops with an error. /// The comp is the one just applied, so `{}` and `null` (the last applied) record its name too. pub(crate) fn recorded_apply(s: &Session, mut p: Value) -> Value { + if p.get("comp").is_some_and(Value::is_object) { + return p; + } let Some(d) = s.active() else { return p }; let Some(comp) = d.doc.last_applied_comp.and_then(|id| d.doc.comp(id)) else { return p }; // `null` params run as `{}` (the last applied comp), so they are recorded by name too. @@ -461,7 +495,14 @@ pub fn specs() -> Vec { has_comps, update_comp ), - spec!("layerComp.apply", "Apply Layer Comp", &[], r##"{"comp":id|name? (default: last applied)} → {comp, missingLayers}"##, has_comps, apply_cmd), + spec!( + "layerComp.apply", + "Apply Layer Comp", + &[], + r##"{"comp":id|name|{"index":int}|{"id":uint}|{"ordinal":"Trgt|Nxt|Prvs|Frst|Lst|Al"}? (default: last applied; typed index is one-based panel order, invalid indexes do nothing)} → {comp, missingLayers}"##, + has_comps, + apply_cmd + ), 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), diff --git a/crates/engine/src/cmd/distort_cmds.rs b/crates/engine/src/cmd/distort_cmds.rs index ed56e50023..156095288f 100644 --- a/crates/engine/src/cmd/distort_cmds.rs +++ b/crates/engine/src/cmd/distort_cmds.rs @@ -191,10 +191,7 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect) // ---------- commands ---------- fn target(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(v) => Ok(LayerId(v)), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), - } + crate::commands::layer_param(s, p) } fn pixel_layer(s: &Session) -> std::result::Result<(), String> { @@ -232,6 +229,7 @@ type LayerFn<'a> = &'a dyn Fn(&Surface, Option<&Surface>, Rect) -> Result Result { let id = target(s, p)?; + let colors = crate::color_channels::target(s, p)?.is_some(); let mut params = p.clone(); if let Value::Object(m) = &mut params { m.remove("layer"); @@ -241,7 +239,7 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, let selection = doc.selection.clone(); let group = crate::transform_cmds::group_locks(doc, id); // A Background gains transparency when the distortion moves pixels (as in Photoshop). - if moves { + if moves && !colors { crate::extra_cmds::background_to_layer(doc, id); } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -252,13 +250,14 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } - check_locks(l, group, moves)?; + check_locks(l, group, moves && !colors)?; let LayerContent::Raster(surf) = &mut l.content else { return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)"))); }; *surf = f(surf, selection.as_ref(), canvas)?; // A linked layer mask follows a geometric warp, only where the pixels moved (#3083). - if let (Some(mf), Some(m)) = (mask, l.mask.as_mut()) + if !colors + && let (Some(mf), Some(m)) = (mask, l.mask.as_mut()) && m.linked { m.surface = mf(&m.surface, selection.as_ref())?; @@ -381,7 +380,7 @@ fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result { } pub fn specs() -> Vec { - const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##; + const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (twirlCcw: twirlCw reversed, what an Alt-drag with Twirl Clockwise records; reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##; const P: &str = r##"{"pins":[{"src":[x,y],"dst":[x,y],"rotate":deg?,"depth":n?}…],"mode":"rigid|normal|distort"="normal","density":"fewer|normal|more"="normal","expansion":px=2,"interpolation":"bicubic|bilinear|nearest","layer":id?} — mesh over the opaque region, as-rigid-as-possible; on a smart object it becomes a smart filter"##; const Q: &str = r##"{"planes":[{"src":[[x,y]×4],"dst":[[x,y]×4]}…] (corners clockwise from top-left; corners that coincide in src are linked),"straighten":"horizontal|vertical|auto"?,"interpolation":"bicubic|bilinear|nearest","layer":id?}"##; vec![ diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index 71800c63c6..509abf3d2a 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -22,6 +22,10 @@ pub(crate) use paste_target; /// Pixels on the session clipboard, with where they came from (for Paste in Place). #[derive(Clone, Debug)] pub struct Clip { + /// Original SVG snapshot, when available. The raster mirror belongs to the same source. + pub svg: Option>>, + /// Native colour indices for a multi-component clip, in channel order. Singletons are grayscale. + pub components: Option>, pub surface: Surface, pub bounds: Rect, /// The copied layer itself, when Copy took a whole layer that Paste should recreate rather @@ -36,6 +40,13 @@ pub struct Clip { pub source: Option, } +impl Clip { + /// Prepare bounded original SVG and its raster mirror before installing the clipboard. + pub fn from_svg(bytes: &[u8]) -> Result { + super::vector_clipboard::from_svg(bytes) + } +} + /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) /// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no @@ -198,7 +209,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }); + return Ok(Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }); } // Only the selected area is read: converting the whole layer (a 24 MP Background gaining // alpha) cost about a second per Layer via Copy (#668). @@ -220,7 +231,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { out.put_tiles(shared); out.prune(); let bounds = out.content_bounds(); - Ok(Clip { surface: out, bounds, layers: None, resolution: None, source: None }) + Ok(Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None }) } /// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection @@ -281,7 +292,7 @@ fn copy(s: &mut Session, merged: bool) -> Result { copy_linked_sources(&d.doc, copies)?; } match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) { - (Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, + (Some(surf), _) => Clip { components: None, layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? }, // A group or fill layer has no pixels of its own: the clipboard pixels are what it // shows. Adjustment layers show nothing by themselves, so their clip spans the canvas // they apply to: Paste places them as a full-canvas layer, and the UI still mirrors @@ -293,7 +304,7 @@ fn copy(s: &mut Session, merged: bool) -> Result { } let clip = lift(&merged_surface(&shown), None, canvas)?; let bounds = if clip.bounds.is_empty() { canvas } else { clip.bounds }; - Clip { layers, bounds, ..clip } + Clip { components: None, layers, bounds, ..clip } } (None, None) => return Err(EngineError::Other("the active layer has no pixels".into())), } @@ -349,7 +360,7 @@ fn copy_mask(s: &mut Session, filter: bool) -> Result { } let b = bounds; let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id)); - s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution, source }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: b, layers: None, resolution, source }); Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }})) } @@ -360,6 +371,15 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu clear_area(doc, id, area, sel.as_ref(), background) } +/// Clear/Cut fill the selected mask samples with background gray, preserving layer pixels. +pub(crate) fn clear_mask(doc: &mut Document, id: LayerId, background: [f32; 4], params: &Value) -> Result<()> { + let sel = doc.selection.clone(); + let canvas = doc.bounds(); + let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + let mask = crate::commands::paint_surface(doc, id, params)?; + crate::fill_cmds::fill_color(mask, area, [background[0], background[1], background[2], 1.0], sel.as_ref()) +} + /// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold /// transparency, so there the area is filled with the background colour instead (Photoshop). pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> { @@ -378,23 +398,40 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio /// Paste as a new layer (or into the targeted mask or channel: [`paste_to_target`]). `in_place` keeps the original position; otherwise the pixels are centred /// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas. fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { + let mode = super::vector_clipboard::mode(p)?; + if let Some(mode) = mode { + super::vector_clipboard::validate(s, p, in_place)?; + if mode != super::vector_clipboard::Mode::Pixels || s.clipboard.as_ref().is_some_and(|clip| clip.svg.is_some()) { + return super::vector_clipboard::paste(s, p, mode); + } + super::vector_clipboard::layer_target(s)?; + } crate::allocation::checkpoint("pasting pixels")?; let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?; let Some(d) = s.active() else { // Nothing open to paste into: the clipboard becomes a document of its own (#368). return new_from_clipboard(s); }; - if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { + if mode.is_none() && crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { return paste_to_target(s, p, in_place, None, "Paste"); } + if mode.is_none() + && let Some(colors) = crate::color_channels::target(s, p)?.filter(|c| c.len() > 1 || p.get("target").is_some() || clip.components.is_some()) + { + return crate::channel_clip::paste_colors(s, p, colors, in_place, None, "Paste"); + } // A targeted colour channel takes the paste instead of a new layer (#2307). - if let Some(k) = crate::channel_clip::paste_color_target(s, p) { + if mode.is_none() + && let Some(k) = crate::channel_clip::paste_color_target(s, p) + { return crate::channel_clip::paste(s, p, k, in_place, None, "Paste"); } let canvas = d.doc.bounds(); let fmt = d.doc.pixel_format(); let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place); - if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) { + if mode.is_none() + && let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) + { return paste_layers(s, layers, dx, dy); } let moved = shifted(&clip.surface, dx, dy); @@ -573,6 +610,8 @@ fn layer_via(s: &mut Session, cut: bool) -> Result { dup.name = doc.copy_name(&src.name); dup.locks = Default::default(); let nid = doc.insert_above(Some(id), dup); + // A lone copy joins its original's link group, as in the reference application (#11217). + crate::layer_multi_cmds::relink_copies(doc, &[nid])?; *active = Some(nid); Ok(nid) })?; @@ -746,9 +785,10 @@ fn toggle_last_state(s: &mut Session) -> Result { Ok(json!(if redo { s.redo() } else { s.undo() })) } -/// Edit › Transform › Again: replay the last `edit.transform` on the active layer. +/// Edit › Transform › Again: replay the last `edit.transform` on the selected layers (#6501). fn transform_again(s: &mut Session) -> Result { - let p = transform_again_params(s)?; + let ids = s.active().map(crate::transform_cmds::selection_targets).unwrap_or_default(); + let p = transform_again_params(s, &ids)?; s.execute("edit.transform", p) } @@ -756,8 +796,8 @@ fn transform_again(s: &mut Session) -> Result { /// last transform on the copy, as one history step, so pressing it again steps and repeats /// (#352). The transform is worked out before anything changes, so a refusal leaves no copy. fn transform_again_copy(s: &mut Session) -> Result { - let p = transform_again_params(s)?; let id = active_id(s)?; + let p = transform_again_params(s, &[id])?; let journal = s.journal.len(); // The steps are recorded (Again on a Copy is not replayable itself): with one layer selected, // "duplicate the current layer" plays back on any document, a layer id only on this one. @@ -780,8 +820,9 @@ fn transform_again_copy(s: &mut Session) -> Result { } } -/// The `edit.transform` params that repeat the last transform on the active layer. -fn transform_again_params(s: &Session) -> Result { +/// The `edit.transform` params that repeat the last transform on the layers `ids` (their frame +/// together is the box it starts from). +fn transform_again_params(s: &Session, ids: &[LayerId]) -> Result { let (_, last) = s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?; let (rect, quad) = (last.get("rect").cloned(), last.get("quad").cloned()); @@ -800,8 +841,8 @@ fn transform_again_params(s: &Session) -> Result { ) .ok_or(EngineError::Other("the last transform can't be repeated".into()))?; let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; - let b = crate::transform_cmds::transform_bounds(&d.doc, l); + d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?; + let b = crate::transform_cmds::transform_bounds_of(&d.doc, ids); if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); } @@ -914,7 +955,18 @@ fn edit_guide(s: &mut Session, p: &Value) -> Result { /// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states). fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical"; - let pos = guide_position(s, p, vertical)?; + // Move Guide can turn the guide (⌥ while dragging it, as in Photoshop): `toOrientation`. + let to_vertical = match p.get("toOrientation").map(|v| v.as_str()) { + None => vertical, + Some(Some("vertical")) => true, + Some(Some("horizontal")) => false, + Some(_) => return Err(EngineError::Other("`toOrientation` must be \"horizontal\" or \"vertical\"".into())), + }; + if to_vertical != vertical && op != "move" { + return Err(EngineError::Other("`toOrientation` applies to view.moveGuide only".into())); + } + // A % position is of the axis the guide ends up on. + let pos = guide_position(s, p, to_vertical)?; let color = if op == "new" { guide_color(p)? } else { None }; let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize); let label = match op { @@ -937,10 +989,20 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result { "move" => { let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?; let to = pos.ok_or(EngineError::Other("missing `position`".into()))?; - let from = std::mem::replace(&mut g[i], to); + let from = if to_vertical == vertical { + std::mem::replace(&mut g[i], to) + } else { + let from = g.remove(i); + if to_vertical { + guides.vertical.push(to) + } else { + guides.horizontal.push(to) + } + from + }; // The guide takes its colour along; another guide left at the old position keeps it. if let Some(rgb) = guides.color(vertical, from) { - guides.set_color(vertical, to, Some(rgb)); + guides.set_color(to_vertical, to, Some(rgb)); } guides.prune_colors(); } @@ -993,31 +1055,61 @@ pub fn specs() -> Vec { }; } vec![ - spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{} (a targeted colour channel cuts that channel only)", has_pixels, |s, p| { - // Refuse a locked layer before copying, so a refused Cut leaves the clipboard alone. - let id = active_id(s)?; - crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; - // A targeted colour channel cuts that channel only (#2698). - if let Some(k) = crate::channel_clip::paste_color_target(s, p) { - return crate::channel_clip::cut(s, id, k); + spec!( + "edit.cut", + "Cut", + &["Edit"], + Some("Cmd+X"), + r##"{"target":"mask|filterMask"|{"color":i}|{"colors":[i,…]}?=Channels panel target,"layer":id|name?=active (colour targets only)} (a targeted mask or colour channel cuts that channel only; a colour set cuts its selected components)"##, + has_pixels, + |s, p| { + if crate::commands::is_mask_target(p) { + let id = active_id(s)?; + crate::commands::check_all_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; + let result = copy_mask(s, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask)?; + let bg = s.tools.background; + s.edit("Cut Mask", |doc, _| clear_mask(doc, id, bg, p))?; + return Ok(result); + } + // Refuse a locked layer before copying, so a refused Cut leaves the clipboard alone. + let colors = crate::color_channels::target(s, p)?.filter(|c| c.len() > 1 || p.get("target").is_some() || p.get("layer").is_some()); + // Composite Cut retains Copy's active-layer semantics. A native colour target uses + // the same resolved destination for both the copied planes and the cleared samples. + let id = if colors.is_some() { crate::commands::layer_param(s, p)? } else { active_id(s)? }; + crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; + if let Some(colors) = colors { + let result = crate::channel_clip::copy_colors(s, p, colors)?; + let bg = s.tools.background; + s.edit("Cut Pixels", |doc, _| { + for k in colors.indices() { + crate::channel_cmds::clear_color_channel(doc, id, k, bg)?; + } + Ok(()) + })?; + return Ok(result); + } + // A targeted colour channel cuts that channel only (#2698). + if let Some(k) = crate::channel_clip::paste_color_target(s, p) { + return crate::channel_clip::cut(s, id, k); + } + let r = copy(s, false)?; + let bg = s.tools.background; + s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?; + Ok(r) } - let r = copy(s, false)?; - let bg = s.tools.background; - s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?; - Ok(r) - }), + ), spec!( "edit.copy", "Copy", &["Edit"], Some("Cmd+C"), - r##"{"target":"mask|filterMask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##, + r##"{"target":"mask|filterMask"|{"color":i}|{"colors":[i,…]}?=Channels panel target,"layer":id|name?=active (colour targets only)} (a targeted mask, colour or alpha channel copies that channel as grayscale; a colour set copies its selected components)"##, can_copy, |s, p| { if crate::commands::is_mask_target(p) { return copy_mask(s, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask); } - match crate::channel_clip::copy(s)? { + match crate::channel_clip::copy(s, p)? { Some(r) => Ok(r), None => copy(s, false), } @@ -1030,7 +1122,7 @@ pub fn specs() -> Vec { &["Edit"], Some("Cmd+V"), concat!( - r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"clampToCanvas":bool=true (keep the paste on the canvas, or at its top-left when larger),"#, + r#"{"as":"pixels|path|shape"? (explicit mode: active layered document/composite only; SVG canvas is centred, Path/Shape require retained vector source),"center":[x,y]? (finite explicit coordinates within ±10 million; default keeps raster position when it overlaps the canvas),"clampToCanvas":bool=true (legacy mode only; keep the paste on the canvas, or at its top-left when larger),"#, paste_target!(), "} (with no document open: a new document from the clipboard)" ), @@ -1086,7 +1178,7 @@ pub fn specs() -> Vec { "Move Guide", &[], None, - r##"{"orientation":"horizontal|vertical","index":n,"position":px or ""} (units as for view.newGuide)"##, + r##"{"orientation":"horizontal|vertical","index":n,"position":px or "","toOrientation":"horizontal|vertical"?} (units as for view.newGuide; toOrientation turns the guide, its position then on the new axis)"##, has_doc, |s, p| guide_cmd(s, p, "move") ), diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs index ec9a9fa8b7..58b92ae779 100644 --- a/crates/engine/src/cmd/edit_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_cmds/tests.rs @@ -17,7 +17,7 @@ fn new_clipboard_document_rejects_an_unrepresentable_origin() { let mut s = Session::new(); let mut surface = Surface::new(PixelFormat::RGBA8); surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]); - s.clipboard = Some(Clip { surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None }); let result = new_from_clipboard(&mut s); assert!(result.unwrap_err().to_string().contains("clipboard origin")); assert!(s.execute("file.newFromClipboard", json!({})).is_err()); @@ -50,8 +50,15 @@ fn paste_centres_pixels_from_another_document() { #[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, source: None }; + let clip = Clip { + svg: None, + components: None, + surface: Surface::new(PixelFormat::RGBA8), + bounds: Rect::from_xywh(origin, origin, 2, 2), + layers: None, + resolution: None, + source: None, + }; let (dx, dy) = paste_offset(&clip, DocId(1), 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); @@ -233,7 +240,7 @@ fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session { s.execute("file.new", json!({"width": w, "height": h})).unwrap(); let mut surface = Surface::new(PixelFormat::RGBA8); surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]); - s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None }); s } @@ -290,6 +297,8 @@ fn paste_offset_clamp_survives_extreme_coordinates() { assert_eq!(clamp_shift(f64::NEG_INFINITY, i32::MIN, i32::MAX, 0, 1), i32::MAX); assert_eq!(clamp_shift(1e30, i32::MIN, i32::MIN + 1, i32::MAX - 1, i32::MAX), i32::MAX); let clip = Clip { + svg: None, + components: None, surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX), layers: None, @@ -1191,6 +1200,35 @@ fn guides_are_undoable() { assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err()); } +/// #5034: Move Guide turns a guide with `toOrientation` (⌥ while dragging it), in one undo +/// step, taking its colour along; a % position is of the new axis. +#[test] +fn move_guide_turns_a_guide() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 30, "color": "magenta"})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 70})).unwrap(); + s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": "50%", "toOrientation": "vertical"})).unwrap(); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.horizontal.clone(), g.vertical.clone()), (vec![70.0], vec![100.0]), "50% of the width"); + assert_eq!(g.color(true, 100.0), Some([255, 0, 255]), "the colour moves with the guide"); + assert_eq!(g.color(false, 30.0), None); + // The same orientation is a plain move. + s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 20, "toOrientation": "vertical"})).unwrap(); + assert_eq!(s.active().unwrap().doc.guides.vertical, vec![20.0]); + s.undo(); + s.undo(); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.horizontal.clone(), g.vertical.clone()), (vec![30.0, 70.0], vec![]), "one undo step per move"); + // Bad params are refused and leave the guides alone. + assert!(s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": 5, "toOrientation": "diagonal"})).is_err()); + assert!(s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": 5, "toOrientation": 1})).is_err()); + assert!(s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 5, "toOrientation": "vertical"})).is_err()); + assert!(s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 9, "position": 5, "toOrientation": "vertical"})).is_err()); + let g = &s.active().unwrap().doc.guides; + assert_eq!((g.horizontal.clone(), g.vertical.clone()), (vec![30.0, 70.0], vec![])); +} + /// #2955: the rulers' zero point is set and reset by command, undoable, and bad params are /// refused without touching the document. #[test] @@ -1415,13 +1453,13 @@ fn lift_reference(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Clip { surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }; + return Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }; } let px = lift_region(src, sel, area, with_alpha.channels()).unwrap(); out.try_write_region(area, &px).unwrap(); out.prune(); let bounds = out.content_bounds(); - Clip { surface: out, bounds, layers: None, resolution: None, source: None } + Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None } } fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface { diff --git a/crates/engine/src/cmd/edit_menu_cmds.rs b/crates/engine/src/cmd/edit_menu_cmds.rs index f7603d2908..b4287e649e 100644 --- a/crates/engine/src/cmd/edit_menu_cmds.rs +++ b/crates/engine/src/cmd/edit_menu_cmds.rs @@ -74,9 +74,17 @@ pub(crate) fn after_command(s: &mut Session, id: &str, params: &Value) { } let Some(st) = s.active() else { return }; let label = st.history.undo_label().unwrap_or(id).to_string(); + let prev = st.history.state(st.history.past_len().wrapping_sub(1)); + let prev = prev.as_deref(); + let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { + (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), + (None, None) => true, + _ => false, + }; // 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. + // routed to channels edit them, and some routed ones (Edit › Stroke, the Magic and Background + // Erasers) still edit the layer whatever `target` says, so the channel must have changed. 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, @@ -84,21 +92,23 @@ pub(crate) fn after_command(s: &mut Session, id: &str, params: &Value) { Target::QuickMask if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask), _ => None, }; + fn channel_surface<'a>(doc: &'a Document, target: &FadeTarget) -> Option<&'a Surface> { + match target { + FadeTarget::Channel(i) => doc.channels.get(*i).map(|c| &c.surface), + _ => doc.quick_mask.as_ref().map(|c| &c.surface), + } + } + let channel = channel.filter(|target| prev.is_none_or(|prev| !surf_eq(channel_surface(&st.doc, target), channel_surface(prev, target)))); 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| { + && prev.is_some_and(|prev| { // The mask changed and the pixels didn't: the command painted the mask. let now = st.doc.layer(layer); let was = prev.layer(layer); - let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) { - (Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b), - (None, None) => true, - _ => false, - }; surf_eq(now.and_then(|l| l.surface()), was.and_then(|l| l.surface())) && !surf_eq(now.and_then(|l| l.mask.as_ref().map(|m| &m.surface)), was.and_then(|l| l.mask.as_ref().map(|m| &m.surface))) }); @@ -335,7 +345,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id|name?=active (the layer filled and sampled),"seed":u64=1}"##, can_caf, content_aware_fill ), diff --git a/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs b/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs index 928f315bdf..c979d8114b 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs @@ -383,8 +383,8 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & use photocraft_algo::content_aware::fill_owned; // The workspace names the layer it opened on: after an Apply to a new layer that one is // active, but the fill still samples the original. - let id = match p.get("layer").and_then(Value::as_u64) { - Some(l) => pixel_layer_id(s, LayerId(l)), + let id = match crate::commands::explicit_layer(s, p)? { + Some(layer) => pixel_layer_id(s, layer), None => pixel_layer(s), } .map_err(EngineError::Other)?; diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index ae0a090a40..90a8f043af 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -14,16 +14,17 @@ fn history_bytes(s: &Session, all: bool) -> usize { } fn clip_bytes(s: &Session) -> usize { - s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.bytes().len()).sum()) + s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.len()).sum()) } +/// Apply the requested irreversible purge and record its boundary so action finalization cannot resurrect undo states. pub(super) fn purge(s: &mut Session, what: &str) -> Result { let mut freed = 0usize; let mut items: Vec<&str> = Vec::new(); if matches!(what, "undo" | "all") && s.active().is_some_and(|d| d.history.can_undo()) { let before = history_bytes(s, false); if let Some(st) = s.active_mut() { - st.history.purge_last(); + st.history.purge_undo(); } freed += before.saturating_sub(history_bytes(s, false)); items.push("undo"); @@ -61,6 +62,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result { freed = freed.saturating_add(stages); items.push("smart filter cache"); } + let sources = crate::smart_cmds::purge_source_cache(); + if sources > 0 { + freed = freed.saturating_add(sources); + items.push("smart object source cache"); + } let strokes = photocraft_compose::shape_split::purge_cache(); if strokes > 0 { freed = freed.saturating_add(strokes); @@ -86,7 +92,11 @@ pub(super) fn can_purge_histories(s: &Session) -> std::result::Result<(), String pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> { can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| { - if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 { + if photocraft_compose::effect_cache_bytes() > 0 + || photocraft_compose::masks::cache_bytes() > 0 + || crate::smart_cmds::stack_cache_bytes() > 0 + || crate::smart_cmds::source_cache_bytes() > 0 + { Ok(()) } else { Err("nothing to purge".into()) diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs index 21d667ecce..fb8dcacdcf 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs @@ -143,6 +143,36 @@ fn fade_follows_the_target_the_command_ran_on() { assert_eq!(s.active().unwrap().doc.channels[0].surface.pixel(5, 5)[0], 0.0, "the channel is left alone"); } +/// Edit › Stroke and the Magic Eraser follow the Channels panel's target but still edit the layer, +/// so with an alpha channel targeted Fade fades the layer they changed, not the untouched channel. +#[test] +fn fade_after_a_routed_command_that_edited_the_layer_fades_the_layer() { + for depth in [8, 16, 32] { + for command in ["edit.stroke", "paint.magicEraser"] { + let mut s = session(depth); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + s.execute("channel.new", json!({"fill": "black"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + let (x, y) = if command == "edit.stroke" { + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute(command, json!({"width": 2, "color": "#000000", "location": "inside"})).unwrap(); + assert_eq!(px(&s, 10, 15)[0], 0.0, "{depth} {command}: the layer was stroked"); + (10, 15) + } else { + s.execute(command, json!({"x": 5, "y": 5, "antiAlias": false})).unwrap(); + assert_eq!(px(&s, 5, 5)[3], 0.0, "{depth} {command}: the layer was erased"); + (5, 5) + }; + assert!(matches!(s.edit_state.fade.as_ref().map(|f| f.target), Some(FadeTarget::Pixels(_))), "{depth} {command}"); + s.execute("edit.fade", json!({"opacity": 50})).unwrap(); + let k = if command == "edit.stroke" { 0 } else { 3 }; + assert!((px(&s, x, y)[k] - 0.5).abs() < 0.01, "{depth} {command}: the layer faded {:?}", px(&s, x, y)); + assert_eq!(s.active().unwrap().doc.channels[0].surface.pixel(x, y)[0], 0.0, "{depth} {command}: 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() { @@ -196,6 +226,16 @@ fn blob_session(depth: u32) -> Session { s } +#[test] +fn content_aware_fill_uses_a_named_nonactive_source_layer() { + let mut s = blob_session(8); + let id = s.active().unwrap().active_layer.unwrap(); + let name = s.active().unwrap().doc.layer(id).unwrap().name.clone(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let result = s.execute("edit.contentAwareFill", json!({"layer": name, "colorAdaptation": "none"})).unwrap(); + assert_eq!(result["layer"], id.0, "fill must use the recorded source rather than the active layer"); +} + #[test] fn content_aware_fill_huge_area_neither_panics_nor_wraps() { let mut s = blob_session(8); diff --git a/crates/engine/src/cmd/eraser_cmds.rs b/crates/engine/src/cmd/eraser_cmds.rs index 129753ea86..1433498078 100644 --- a/crates/engine/src/cmd/eraser_cmds.rs +++ b/crates/engine/src/cmd/eraser_cmds.rs @@ -56,9 +56,13 @@ fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { } } -fn active_pixel_layer(s: &Session, cmd: &str) -> Result { - has_pixel_layer(s).map_err(|m| bad(cmd, m))?; - s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer")) +fn active_pixel_layer(s: &Session, p: &Value, cmd: &str) -> Result { + let id = crate::commands::layer_param(s, p)?; + let layer = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; + if !matches!(layer.content, LayerContent::Raster(_)) { + return Err(bad(cmd, "the targeted layer is not a pixel layer")); + } + Ok(id) } fn pixels_locked(doc: &Document, l: &Layer) -> Option { @@ -67,10 +71,8 @@ fn pixels_locked(doc: &Document, l: &Layer) -> Option { } fn damage(s: &mut Session, dmg: Rect, erased: bool) -> Value { - if let Some(st) = s.active_mut() - && !dmg.is_empty() - { - st.last_damage = Some(dmg); + if !dmg.is_empty() { + s.hint_damage(dmg); } json!({ "erased": erased, "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] }) } @@ -85,7 +87,7 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result { let tolerance = number(p, CMD, "tolerance", 32.0, 0.0..=255.0)? as f32; let opacity = number(p, CMD, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0; let (anti_alias, contiguous, sample_all) = (flag(p, "antiAlias", true), flag(p, "contiguous", true), flag(p, "sampleAllLayers", false)); - let id = active_pixel_layer(s, CMD)?; + let id = active_pixel_layer(s, p, CMD)?; { let d = s.active().ok_or(EngineError::NoDocument)?; if let Some(e) = d.doc.layer(id).and_then(|l| pixels_locked(&d.doc, l)) { @@ -96,17 +98,26 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result { return Ok(damage(s, Rect::EMPTY, false)); } } - let (area, img) = crate::selection_cmds::sample_rgba8(s, sample_all)?; + let (area, img) = if sample_all { + crate::selection_cmds::sample_rgba8(s, true)? + } else { + let d = s.active().ok_or(EngineError::NoDocument)?; + let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::NoLayer(id))?; + (d.doc.bounds(), sel::rgba8_image(surf, d.doc.bounds())) + }; let region = sel::wand_region(&img, area, (x, y), tolerance, contiguous, anti_alias); drop(img); let Some(region) = region else { return Ok(damage(s, Rect::EMPTY, false)) }; let bg = s.tools.background; + let channels = crate::color_channels::target(s, p)?.is_some(); let dmg = s.edit("Magic Eraser", |doc, _| { let selection = doc.selection.clone(); // Photoshop converts the Background to a normal layer before erasing to transparency. doc.layer(id).ok_or(EngineError::NoLayer(id))?; - crate::extra_cmds::background_to_layer(doc, id); - let lock = doc.effective_locks(id).transparency; + if !channels { + crate::extra_cmds::background_to_layer(doc, id); + } + let lock = doc.effective_locks(id).transparency || channels; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; let fmt = surf.format(); @@ -134,13 +145,13 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet foreground: s.tools.foreground, background: s.tools.background, }; - let id = active_pixel_layer(s, CMD)?; + let id = active_pixel_layer(s, p, 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) { + if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) && crate::color_channels::target(s, p)?.is_none() { 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)) @@ -150,16 +161,66 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet fn background_eraser(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.backgroundEraser"; let (stroke, bs, id) = background_eraser_setup(s, p)?; + let colors = crate::color_channels::target(s, p)?; + let bg = s.tools.background; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); - crate::extra_cmds::background_to_layer(doc, id); + if colors.is_none() { + crate::extra_cmds::background_to_layer(doc, id); + } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; - Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) + if let Some(colors) = colors { + let effect = selected_background_effect(surf, &bs, colors, bg); + Ok(photocraft_paint::retouch::apply_dab_stroke(surf, &background_eraser_stroke(&stroke), selection.as_ref(), false, 0, effect)) + } else { + Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) + } })?; Ok(damage(s, dmg, true)) } +fn selected_background_effect( + pre: &photocraft_raster::Surface, + bs: &BgEraseSettings, + colors: crate::color_channels::ColorChannels, + bg: [f32; 4], +) -> crate::retouch_cmds::DabEffect { + let fmt = pre.format(); + let alpha = fmt.mode.color_channels(); + let background = photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0]); + // Opaque Backgrounds have no alpha sample. The matching algorithm uses a temporary opaque + // alpha to calculate erased coverage; the real layer keeps its original format and identity. + let opaque = pre.convert(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true)); + let Some(mut effect) = background_eraser_effect(&opaque, bs) else { + return Box::new(|_, _| {}); + }; + Box::new(move |region, footprint| { + use photocraft_paint::retouch::Region; + let count = region.ch.max(1); + let before = region.data.clone(); + let mut work = Region::new(region.rect, alpha + 1); + for (src, dst) in before.chunks_exact(count).zip(work.data.chunks_exact_mut(alpha + 1)) { + for (v, o) in dst.iter_mut().zip(src) { + *v = *o; + } + if !fmt.alpha + && let Some(a) = dst.get_mut(alpha) + { + *a = 1.0; + } + } + effect(&mut work, footprint); + for ((out, old), erased) in region.data.chunks_exact_mut(count).zip(before.chunks_exact(count)).zip(work.data.chunks_exact(alpha + 1)) { + let a = if fmt.alpha { old.get(alpha).copied().unwrap_or(0.0) } else { 1.0 }; + let coverage = if a > 0.0 { (1.0 - erased.get(alpha).copied().unwrap_or(a) / a).clamp(0.0, 1.0) } else { 0.0 }; + for (k, (v, original)) in out.iter_mut().zip(old).enumerate() { + *v = if colors.contains(k) { original + (background.get(k).copied().unwrap_or(*original) - original) * coverage } else { *original }; + } + } + }) +} + /// 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. @@ -168,8 +229,18 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), // No alpha: the commit erases nothing either. diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index cdefb64cba..290c129507 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -76,7 +76,7 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String { /// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that /// grayscale surface is rewritten instead, as the core filters do (#935). fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { - let id = layer_param(s, &Value::Null)?; + let id = layer_param(s, p)?; let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels; s.edit(label, |doc, _| { let canvas = doc.bounds(); @@ -361,6 +361,15 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { if let Some(c) = p.get("center") { params["center"] = c.clone(); } + if let Some(colors) = crate::color_channels::target(s, p)? + .filter(|c| c.len() > 1 || p.get("target").is_some() || s.clipboard.as_ref().is_some_and(|clip| clip.components.is_some())) + { + params["target"] = colors.params(); + if let Some(layer) = p.get("layer") { + params["layer"] = layer.clone(); + } + return crate::channel_clip::paste_colors(s, ¶ms, colors, false, Some(&limit), label); + } // A targeted colour channel (#2307): the paste goes into it, only where `limit` allows. if let Some(k) = crate::channel_clip::paste_color_target(s, p) { return crate::channel_clip::paste(s, ¶ms, k, false, Some(&limit), label); @@ -431,7 +440,7 @@ fn visible_bounds(s: &photocraft_raster::Surface, canvas: Rect) -> Rect { } out }; - tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b)) + tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), &t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b)) } // ---------- layers ---------- @@ -547,6 +556,10 @@ fn is_empty_layer(l: &Layer) -> bool { } } +/// Layer › Ungroup Layers: replace the selected group with its children in place, the topmost +/// child becoming active. Ungrouping an empty group next to other layers simply removes it; +/// when it is the only root layer the edit is refused, as `layer.delete` refuses to drop the +/// last layer (#11118). fn ungroup(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; s.edit("Ungroup Layers", |doc, active| { @@ -557,6 +570,12 @@ fn ungroup(s: &mut Session, p: &Value) -> Result { }; let children = group.children.clone(); let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(id))?; + // Ungrouping the only root layer when it is an empty group would leave the document + // with no layers at all; `layer.delete` refuses to drop the last layer, so refuse here + // too instead (#11118). + if parent.is_empty() && children.is_empty() && doc.layers.len() == 1 { + return Err(EngineError::Other("a document must keep at least one layer".into())); + } let sib = if parent.is_empty() { &mut doc.layers } else { doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or(EngineError::NoLayer(id))? }; sib.remove(idx); let top = children.last().map(|l| l.id); @@ -868,9 +887,15 @@ pub fn specs() -> Vec { if all == d.doc.bounds() { return Ok(json!({"changed": false})); } - // One History step named as in Photoshop, not the crop's "Crop". + // One History step named as in Photoshop, not the crop's "Crop". The Background stays a + // Background, unlike a Crop tool crop with Delete Cropped Pixels off (#10867). let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false}); - crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?; + // The crop runs without `Session::execute`, so it passes the session's gate here, as a + // command run on Reveal All's behalf would. + if let Some(gate) = s.authorize { + gate("image.crop", &p)?; + } + crate::analysis_cmds::compound(s, "Reveal All", |s| crate::image_cmds::crop_keeping_background(s, &p))?; Ok(json!({"changed": true})) }), spec!( @@ -903,13 +928,31 @@ pub fn specs() -> Vec { "Paste Layer Style", &["Layer", "Layer Style"], None, - r##"{"layer":id?} (no layer: every selected layer but the Background)"##, + r##"{"layer":id?,"allowGroups":bool=true} (no layer: every selected layer but the Background; false skips groups, refusing if none remain)"##, |s| { has_layer(s)?; s.style_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no layer style has been copied".into()) }, |s, p| { - let ids = style_targets(s, p, |_| true)?; + let allow_groups = match p.get("allowGroups") { + None | Some(Value::Null) => true, + Some(Value::Bool(value)) => *value, + Some(_) => { + return Err(EngineError::BadParams { + cmd: "layer.layerStyle.pasteLayerStyle".into(), + msg: "`allowGroups` must be true or false".into(), + }); + } + }; + let mut ids = style_targets(s, p, |_| true)?; + if !allow_groups { + // Apply this after resolution: the shared helper keeps single targets verbatim. + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + ids.retain(|id| doc.layer(*id).is_some_and(|layer| !layer.is_group())); + if ids.is_empty() { + return Err(EngineError::Other("none of the selected layers can take this layer style change".into())); + } + } let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { diff --git a/crates/engine/src/cmd/extra_cmds/tests.rs b/crates/engine/src/cmd/extra_cmds/tests.rs index 97243efd58..f73ae41006 100644 --- a/crates/engine/src/cmd/extra_cmds/tests.rs +++ b/crates/engine/src/cmd/extra_cmds/tests.rs @@ -713,6 +713,41 @@ fn ungroup_restores_children_in_order() { assert_eq!(doc(&s).layers.len(), 2); } +#[test] +fn ungroup_the_sole_empty_group_is_refused() { + // #11118: ungrouping the only root layer when it was an empty group emptied the document + // and cleared the active layer, while deleting a layer refuses to drop the last one. + let mut s = session(8); + let child = active(&s).id; + s.execute("layer.groupLayers", json!({})).unwrap(); + let group = active(&s).id; + s.execute("layer.hideLayers", json!({ "layer": child.0 })).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); // the group is now empty + let bg = doc(&s).layers.iter().find(|l| !l.is_group()).unwrap().id; + s.execute("layer.hideLayers", json!({ "layer": bg.0 })).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); // the empty group is the sole layer + assert_eq!(doc(&s).layers.len(), 1); + assert!(doc(&s).layers[0].is_group()); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute("layer.ungroupLayers", json!({ "layer": group.0 })).is_err(), "ungrouping the sole empty group must be refused, not empty the document"); + assert_eq!(doc(&s).layers.len(), 1, "the group is left in place"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "a refused edit records no history step"); + assert_eq!(s.active().unwrap().active_layer, Some(group), "the group stays active"); +} + +#[test] +fn ungroup_an_empty_group_next_to_other_layers_removes_it() { + // The control for the refusal above: an empty group that is not the only root layer still + // ungroups, and the group simply disappears. + let mut s = session(8); + let group = LayerId(s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert!(doc(&s).layers.iter().any(|l| l.id == group)); + s.execute("layer.ungroupLayers", json!({ "layer": group.0 })).unwrap(); + assert!(doc(&s).layers.iter().all(|l| l.id != group), "the empty group is gone"); + assert_eq!(doc(&s).layers.len(), 2, "nothing arrived in its place"); + assert_eq!(s.active().unwrap().active_layer, doc(&s).top_layer(), "an active layer survives"); +} + #[test] fn reveal_all_grows_the_canvas() { let mut s = session(8); diff --git a/crates/engine/src/cmd/face_cmds.rs b/crates/engine/src/cmd/face_cmds.rs index d48df95b84..0ae0af3d35 100644 --- a/crates/engine/src/cmd/face_cmds.rs +++ b/crates/engine/src/cmd/face_cmds.rs @@ -36,7 +36,7 @@ pub fn faces_in(doc: &Document, active: Option, all_layers: bool, min_s surf.read_rgba_into(area, &mut px); px } - None => photocraft_compose::render(doc, area).px, + None => photocraft_compose::render(doc, area).into_px(), }; let found = detect(&px, w, h, min_score)?; let (x0, y0) = (area.x0 as f32, area.y0 as f32); diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index efdd3ad8bf..4f555c2a61 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -28,29 +28,40 @@ mod paths; mod processing; mod save; -pub use batch_run::OutputClaims; -pub(crate) use batch_run::{BatchInput, batch_inputs, batch_keeps_step, batch_save_point, process_files}; +pub use batch_naming::plan as plan_batch_names; #[cfg(test)] -pub(crate) use batch_run::{MAX_SUBFOLDER_DEPTH, list_images_tree}; -use batch_run::{batch, same_format}; +pub(crate) use batch_run::MAX_SUBFOLDER_DEPTH; +use batch_run::batch; +pub use batch_run::same_format as batch_same_format; +use batch_run::same_format; +#[cfg(not(target_arch = "wasm32"))] +pub use batch_run::validate_flat_outputs; +pub use batch_run::{BatchInput, OutputClaims, encode_batch_error_log, list_images_tree}; +pub(crate) use batch_run::{batch_inputs, batch_keeps_step, batch_save_point, process_files}; pub use export::{LayerExportReport, SkippedExportLayer, bake_cube, export_layers_report_with, export_layers_with}; use export::{color_lookup_tables, layers_to_files, push_unique}; use info::file_info; pub use info::{CAMERA_FIELDS, camera_data, read_file_info, write_file_info}; #[cfg(test)] use info::{exposure_text, find_element}; -pub use open::{PLACE_EMBEDDED, open_bytes_as, place_bytes, raw_defaults}; -use open::{close_all, close_others, open_as, place, revert}; -pub(crate) use open::{import, tag_exr_colorspace}; +pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; +use open::{approve_memory, close_all, close_others, open_as, place, revert}; +pub(crate) use open::{import, import_with_options, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] use paths::openable_name; -pub use paths::{extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, same_file, saves_in_place, template_name, untitled_name}; -pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, portable_device_name, read_file, sanitize, stem, write_file}; +pub use paths::{ + extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, portable_device_name, same_destination, same_file, saves_in_place, + template_name, untitled_name, +}; +pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, read_file, sanitize, stem, write_file}; use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatten_all_masks, image_processor, load_files_into_stack}; use save::save_a_copy; pub(crate) use save::{SaveOpts, encode, save_doc}; -pub use save::{dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, tga_bits_param, tiff_compression_param, tiff_would_write_layers}; +pub use save::{ + dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, pcraft_full_composite_param, tga_bits_param, tiff_bool_param, tiff_compression_param, + tiff_would_write_layers, +}; mod batch_naming; @@ -65,6 +76,10 @@ pub(crate) fn native(_: &Session) -> std::result::Result<(), String> { if cfg!(target_arch = "wasm32") { Err("not available on the web (no file system)".into()) } else { Ok(()) } } +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + pub(crate) fn native_doc(s: &Session) -> std::result::Result<(), String> { native(s)?; has_doc(s) @@ -114,6 +129,10 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a st p.get(key).and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("missing \"{key}\"") }) } +pub(crate) fn u64_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("missing or invalid \"{key}\"") }) +} + pub(crate) fn f64_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) } @@ -241,7 +260,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - 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)}"##, + 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),"tiffTransparency":bool? (TIFF: Save Transparency; off composites over white; unset, kept unless the document has alpha or spot channels),"tiffPyramid":bool=false (TIFF: Save Image Pyramid, half-size copies after the image),"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),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, native_doc, save_a_copy ), @@ -250,16 +269,30 @@ pub fn specs() -> Vec { "Open As…", &["File"], Some("Cmd+Alt+Shift+O"), - r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format)}"##, + r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format),"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, native, open_as ), + // One-shot permission for one file, not something to replay later: never journaled or + // recorded in an action, since replaying it in another session/on another file would + // grant memory use nobody approved (#10897). It also releases the queued source on both + // answers, so it must not be replayed as a step that expects a missing preflight. + CommandSpec { + id: "file.approveMemory", + label: "Approve High Memory PSD Import", + menu: &[], + shortcut: None, + params: r##"{"id":u64,"approve":bool=true,"background":bool=true}"##, + enabled: always, + run: approve_memory, + journal: false, + }, spec!( "file.placeEmbedded", "Place Embedded…", &["File"], None, - r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?}"##, + r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, native_doc, |s, p| place(s, p, false) ), @@ -268,7 +301,7 @@ pub fn specs() -> Vec { "Place Linked…", &["File"], None, - r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##, + r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, native_doc, |s, p| place(s, p, true) ), @@ -304,7 +337,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - 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)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; Save/Save As marks the result),"source":"folder|open"="folder" (open: run on live open documents),"input":folder|[paths] (folder source),"includeSubfolders":bool=false (up to 32 levels and 100000 files, outputs are flat under output),"output":folder (optional for open source),"format":"same|png|jpg|psd|tiff|…"="same","naming":string? (tokens: {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; the naming template determines the whole filename),"startingSerial":1..4294967295=1,"stopOnError":bool=false,"errorLog":path? (blank disables it),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":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"}…]?} → {files, errors, stopped?, skipped?, errorLog?, documents?} (folder batches report per-file job progress and support cancellation)"##, native, batch ), @@ -313,7 +346,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,"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)"##, + 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"?,"tiffTransparency":bool?,"tiffPyramid":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)"##, native, image_processor ), @@ -341,7 +374,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?,"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}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":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}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/cmd/file_cmds/batch_naming.rs b/crates/engine/src/cmd/file_cmds/batch_naming.rs index 8e14bcf428..89b5203409 100644 --- a/crates/engine/src/cmd/file_cmds/batch_naming.rs +++ b/crates/engine/src/cmd/file_cmds/batch_naming.rs @@ -9,9 +9,67 @@ fn bad(message: impl Into) -> EngineError { enum Part<'a> { Literal(&'a str), - Name, - Serial, - Date, + Name(NameCase), + Serial(usize), + Letter(bool), + Date(DateFormat), + Ext(bool), +} + +#[derive(Clone, Copy)] +enum NameCase { + AsIs, + Upper, + Title, +} + +#[derive(Clone, Copy)] +enum DateFormat { + Iso, + Mmddyy, + Mmdd, + Yyyymmdd, + Yymmdd, + Yyddmm, + Ddmmyy, +} + +#[derive(Clone, Copy)] +struct Date { + year: u32, + month: u32, + day: u32, +} + +impl Date { + fn from_iso(value: &str) -> Option { + Some(Self { year: value.get(..4)?.parse().ok()?, month: value.get(5..7)?.parse().ok()?, day: value.get(8..10)?.parse().ok()? }) + } + + fn format(self, kind: DateFormat) -> String { + let (year, month, day, short) = (self.year, self.month, self.day, self.year % 100); + match kind { + DateFormat::Iso => format!("{year:04}-{month:02}-{day:02}"), + DateFormat::Mmddyy => format!("{month:02}{day:02}{short:02}"), + DateFormat::Mmdd => format!("{month:02}{day:02}"), + DateFormat::Yyyymmdd => format!("{year:04}{month:02}{day:02}"), + DateFormat::Yymmdd => format!("{short:02}{month:02}{day:02}"), + DateFormat::Yyddmm => format!("{short:02}{day:02}{month:02}"), + DateFormat::Ddmmyy => format!("{day:02}{month:02}{short:02}"), + } + } +} + +fn letters(mut serial: u32, upper: bool) -> String { + let mut bytes = Vec::new(); + while serial > 0 { + serial -= 1; + let letter = b'a' + (serial % 26) as u8; + bytes.push(if upper { letter.to_ascii_uppercase() } else { letter }); + serial /= 26; + } + bytes.reverse(); + String::from_utf8(bytes).unwrap_or_default() } fn parse(template: &str) -> Result>> { @@ -25,10 +83,25 @@ fn parse(template: &str) -> Result>> { } let end = rest.find('}').ok_or_else(|| bad("naming: unclosed token"))?; parts.push(match &rest[1..end] { - "name" => Part::Name, - "serial" => Part::Serial, - "date" => Part::Date, - token => return Err(bad(format!("naming: unknown token {{{token}}}; use {{name}}, {{serial}} or {{date}}"))), + "name" => Part::Name(NameCase::AsIs), + "NAME" => Part::Name(NameCase::Upper), + "Name" => Part::Name(NameCase::Title), + "serial" => Part::Serial(3), + "letter" => Part::Letter(false), + "LETTER" => Part::Letter(true), + "date" => Part::Date(DateFormat::Iso), + "date:mmddyy" => Part::Date(DateFormat::Mmddyy), + "date:mmdd" => Part::Date(DateFormat::Mmdd), + "date:yyyymmdd" => Part::Date(DateFormat::Yyyymmdd), + "date:yymmdd" => Part::Date(DateFormat::Yymmdd), + "date:yyddmm" => Part::Date(DateFormat::Yyddmm), + "date:ddmmyy" => Part::Date(DateFormat::Ddmmyy), + "ext" => Part::Ext(false), + "EXT" => Part::Ext(true), + token => match token.strip_prefix("serial:").and_then(|value| value.parse::().ok()) { + Some(width) if (1..=4).contains(&width) => Part::Serial(width), + _ => return Err(bad(format!("naming: unknown token {{{token}}}"))), + }, }); rest = &rest[end + 1..]; } @@ -36,14 +109,35 @@ fn parse(template: &str) -> Result>> { Ok(parts) } -fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String { +fn expand(parts: &[Part<'_>], name: &str, ext: &str, serial: u32, date: Date) -> String { let mut out = String::new(); - for part in parts { + for (index, part) in parts.iter().enumerate() { match part { Part::Literal(s) => out.push_str(s), - Part::Name => out.push_str(name), - Part::Serial => out.push_str(&format!("{serial:03}")), - Part::Date => out.push_str(date), + Part::Name(NameCase::AsIs) => out.push_str(name), + Part::Name(NameCase::Upper) => out.push_str(&name.to_uppercase()), + Part::Name(NameCase::Title) => { + let mut chars = name.chars(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.push_str(&chars.as_str().to_lowercase()); + } + } + Part::Serial(width) => out.push_str(&format!("{serial:0width$}")), + Part::Letter(upper) => out.push_str(&letters(serial, *upper)), + Part::Date(kind) => out.push_str(&date.format(*kind)), + Part::Ext(upper) => { + // The reference app adds the separator only for the final extension component. + // A literal dot in the template already supplies that separator. + if !parts[index + 1..].iter().any(|part| !matches!(part, Part::Literal(""))) && !out.ends_with('.') { + out.push('.'); + } + if *upper { + out.push_str(&ext.to_uppercase()); + } else { + out.push_str(ext); + } + } } } out @@ -51,7 +145,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String { /// An absent or empty template preserves the old filename path exactly. An explicit template /// generates one component per input; the export format supplies the extension separately. -pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result>> { +pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result>> { let start = match params.get("startingSerial") { None => 1, Some(n) => { @@ -70,30 +164,23 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V return Err(bad("naming must be at most 255 bytes")); } let parts = parse(template)?; - let has_serial = parts.iter().any(|p| matches!(p, Part::Serial)); + let has_serial = parts.iter().any(|p| matches!(p, Part::Serial(_) | Part::Letter(_))); let timestamp = crate::analysis_cmds::now_iso(); - let date = timestamp.get(..10).unwrap_or(""); - if date.is_empty() && parts.iter().any(|p| matches!(p, Part::Date)) { - return Err(bad("naming: the current UTC date is unavailable")); - } + let date = Date::from_iso(×tamp).ok_or_else(|| bad("naming: the current UTC date is unavailable"))?; let mut outputs = Vec::with_capacity(inputs.len()); - for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() { + for (index, BatchInput { path, .. }) in inputs.iter().enumerate() { let serial = if has_serial { u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))? } else { start }; - let base = expand(&parts, &stem(path), serial, date); - if base.is_empty() || base.ends_with(['.', ' ']) { - return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space")); - } let ext = if format == "same" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; - let name = format!("{base}.{ext}"); - if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { - return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters")); + let name = expand(&parts, &stem(path), &ext, serial, date); + if name.is_empty() || name.ends_with(['.', ' ']) || name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) { + return Err(bad("naming must produce a nonempty filename of at most 255 bytes without trailing spaces, path separators or reserved characters")); } - // From subfolders ("Include All Subfolders"), each result keeps its subfolder. - outputs.push(join(&join(output, rel_dir), &name)); + // Photoshop Batch writes all subfolder results into the destination itself. + outputs.push(join(output, &name)); } // Renaming within the source folder could replace a later input before it is read. Resolve // existing paths so aliases such as `source/./001.png` cannot bypass the check. The default @@ -102,7 +189,9 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V { let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|i| std::fs::canonicalize(&i.path).ok()).collect(); for output in &outputs { - if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) { + let identity = super::paths::destination_identity(std::path::Path::new(output)) + .ok_or_else(|| bad(format!("naming output path cannot be resolved safely: {output}")))?; + if sources.contains(&identity) { return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template"))); } } @@ -117,7 +206,19 @@ mod tests { #[test] fn substituted_names_are_literal_and_serials_do_not_wrap_or_truncate() { let parts = parse("{date}_{name}_{serial}").unwrap(); - assert_eq!(expand(&parts, "photo{serial}", 7, "2026-10-13"), "2026-10-13_photo{serial}_007"); - assert_eq!(expand(&parts, "photo", 1000, "2026-10-13"), "2026-10-13_photo_1000"); + let date = Date { year: 2026, month: 10, day: 13 }; + assert_eq!(expand(&parts, "photo{serial}", "png", 7, date), "2026-10-13_photo{serial}_007"); + assert_eq!(expand(&parts, "photo", "png", 1000, date), "2026-10-13_photo_1000"); + let parts = parse("{NAME}_{Name}_{serial:1}_{letter}{LETTER}_{date:mmddyy}.{EXT}").unwrap(); + assert_eq!(expand(&parts, "my Photo", "png", 27, date), "MY PHOTO_My photo_27_aaAA_101326.PNG"); + } + + #[test] + fn extension_component_only_adds_a_dot_at_the_end() { + let date = Date { year: 2026, month: 11, day: 1 }; + for (template, expected) in [("{name}{ext}", "a.png"), ("{name}{ext}{serial}", "apng001"), ("{name}{serial}", "a001"), ("{name}{ext}{ext}", "apng.png")] + { + assert_eq!(expand(&parse(template).unwrap(), "a", "png", 1, date), expected, "{template}"); + } } } diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 0506b9dded..93e3e75a4a 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -1,7 +1,7 @@ use super::open::import_at_raw_ppi; -use super::paths::{extension, file_name, join, list_images, read_file, stem}; +use super::paths::{extension, file_name, join, list_images, read_file, stem, write_file}; use super::processing::parse_steps; -use super::save::{SaveOpts, save_doc}; +use super::save::{SaveOpts, encode, save_doc, save_doc_as}; use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param}; /// Inputs of a batch command: `"input"` is a folder or an array of files. @@ -16,16 +16,18 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result> { /// One input of a batch run: the file, and the folder it sits in relative to the input folder /// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`. #[derive(Clone, Debug, PartialEq)] -pub(crate) struct BatchInput { +pub struct BatchInput { pub path: String, pub rel_dir: String, } /// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders"). +#[cfg(not(target_arch = "wasm32"))] pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32; /// A batch over subfolders lists at most this many files; a larger tree is an error, not a run /// that silently stops partway. +#[cfg(not(target_arch = "wasm32"))] pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000; /// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch, @@ -47,9 +49,9 @@ pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result Result> { +pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { use std::path::{Path, PathBuf}; let canon = |p: &Path| std::fs::canonicalize(p).ok(); let root = Path::new(dir); @@ -62,12 +64,8 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result = vec![(root.to_path_buf(), String::new(), 0)]; while let Some((folder, rel, depth)) = stack.pop() { let shown = folder.to_string_lossy().into_owned(); - // The top folder must be readable; a subfolder that isn't is skipped. - let files = match list_images(&shown) { - Ok(f) => f, - Err(e) if depth == 0 => return Err(e), - Err(_) => continue, - }; + // Fail before writing any output if a reached input folder cannot be read. + let files = list_images(&shown)?; if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES { return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") }); } @@ -75,17 +73,20 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result= MAX_SUBFOLDER_DEPTH { continue; } - let Ok(rd) = std::fs::read_dir(&folder) else { continue }; - let mut subs: Vec<(String, PathBuf)> = rd - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p))) - .collect(); + let rd = std::fs::read_dir(&folder).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + let mut subs: Vec<(String, PathBuf)> = Vec::new(); + for entry in rd { + let path = entry.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?.path(); + if path.is_dir() + && let Some(name) = path.file_name() + { + subs.push((name.to_string_lossy().into_owned(), path.clone())); + } + } subs.sort(); // Pushed in reverse so the stack pops them in name order. for (name, sub) in subs.into_iter().rev() { - let Some(c) = canon(&sub) else { continue }; + let c = canon(&sub).ok_or_else(|| EngineError::Other(format!("{}: cannot resolve the subfolder", sub.display())))?; if skip.as_ref() == Some(&c) || !seen.insert(c) { continue; } @@ -97,7 +98,7 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result Result> { +pub fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result> { Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } @@ -142,7 +143,9 @@ pub(crate) fn process_tree( /// codec formats without a writer in this build. Those are saved as PSD, the format Photoshop's /// Save As offers for a raw. An input whose file name has no extension (a dot in a folder name /// doesn't count) is saved as PNG. -pub(super) fn same_format(path: &str) -> String { +/// The format Batch's "Save in the same format" writes for `path`: its own, or PSD for a file +/// PhotoCraft only opens. +pub fn same_format(path: &str) -> String { const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm", "xcf", "xcfgz", "gz", "psdt"]; let ext = extension(path).unwrap_or_else(|| "png".into()); @@ -176,6 +179,7 @@ fn same_format_takes_the_file_names_extension() { assert_eq!(same_format("/in/.hidden"), "png"); } +/// Run one source through its action and output pipeline while finalizing only that document's owned history scope. fn process_outputs<'a>( s: &Session, outputs: impl IntoIterator, @@ -184,22 +188,16 @@ fn process_outputs<'a>( ) -> 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 action_prefs = s.prefs().actions.clone(); let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = OutputClaims::default(); + let run = |scratch: &mut Session, out: &str| { + scratch.prefs.edit(|prefs| prefs.actions = action_prefs.clone()); + f(scratch, out) + }; for (path, out) in outputs { - let r = (|| -> Result<()> { - written.check(&out).map_err(EngineError::Other)?; - let bytes = read_file(path)?; - let mut scratch = Session::new(); - let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; - scratch.add_document(doc, Some(path.to_string())); - if !f(&mut scratch, &out)? { - let d = scratch.active().ok_or(EngineError::NoDocument)?; - save_doc(&d.doc, &out, save)?; - } - Ok(()) - })(); + let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None }, &run); match r { Ok(()) => { written.record(&out, path); @@ -211,6 +209,248 @@ fn process_outputs<'a>( json!({"files": files, "errors": errors}) } +struct ProcessOptions<'a> { + format: Option<&'a str>, + written: Option<&'a OutputClaims>, + ctx: Option<&'a crate::jobs::JobCtx>, +} + +fn process_one(raw_ppi: f64, path: &str, out: &str, save: SaveOpts, options: ProcessOptions<'_>, f: &dyn Fn(&mut Session, &str) -> Result) -> Result<()> { + if let Some(written) = options.written { + written.check(out).map_err(EngineError::Other)?; + } + let bytes = read_file(path)?; + let mut scratch = Session::new(); + let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; + scratch.add_document(doc, Some(path.to_string())); + if let Some(ctx) = options.ctx { + ctx.publish_batch_preview(path, scratch.active().map(|state| state.doc.clone())); + } + let saved_by_action = f(&mut scratch, out)?; + if saved_by_action { + // The action's saved snapshot is already written: a cancel or Esc arriving now stops the + // inputs after this one, not this result. + return Ok(()); + } + if let Some(ctx) = options.ctx { + ctx.check()?; + if ctx.batch_interrupt_requested() { + return Err(EngineError::Cancelled); + } + } + let d = scratch.active().ok_or(EngineError::NoDocument)?; + if let Some(format) = options.format { + let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + if let Some(ctx) = options.ctx { + save_batch_doc(&d.doc, out, &ext, save, ctx)?; + } else { + save_doc_as(&d.doc, out, &ext, save)?; + } + } else { + save_doc(&d.doc, out, save)?; + } + Ok(()) +} + +/// Encode before the Batch write checkpoint. Esc accepted during an action or encode therefore +/// leaves the current destination untouched; the worker owns the write once the checkpoint passes. +fn save_batch_doc(doc: &Document, out: &str, format: &str, save: SaveOpts, ctx: &crate::jobs::JobCtx) -> Result<()> { + let (bytes, _) = encode(doc, format, save)?; + ctx.batch_before_write()?; + write_file(out, &bytes)?; + Ok(()) +} + +#[derive(Clone, Debug)] +pub(super) struct BatchPolicy { + pub(super) stop_on_error: bool, + pub(super) error_log: Option, +} + +impl BatchPolicy { + fn parse(p: &Value, cmd: &str) -> Result { + let stop_on_error = match p.get("stopOnError") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"stopOnError\" must be true or false".into() }), + }; + let error_log = match p.get("errorLog") { + None | Some(Value::Null) => None, + Some(Value::String(path)) if path.trim().is_empty() => None, + Some(Value::String(path)) => Some(path.clone()), + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"errorLog\" must be a file path string".into() }), + }; + Ok(Self { stop_on_error, error_log }) + } +} + +/// Photoshop-compatible Batch log encoding, shared by the desktop command and CLI. +pub fn encode_batch_error_log<'a>(errors: impl IntoIterator) -> Vec { + let mut body = String::from("Batch Start\r\n\r\n"); + for (input, reason) in errors { + body.push_str(&format!("File: \"{input}\"\r\nError: {reason}\r\n\r\n")); + } + body.push_str("Batch End\r\n"); + let mut bytes = vec![0xff, 0xfe]; + for word in body.encode_utf16() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes +} + +fn write_error_log(path: &str, errors: &[Value]) -> Result<()> { + let bytes = encode_batch_error_log(errors.iter().map(|error| { + (error.get("file").or_else(|| error.get("document")).and_then(Value::as_str).unwrap_or("?"), error.get("error").and_then(Value::as_str).unwrap_or("?")) + })); + super::paths::write_file(path, &bytes).map_err(|error| EngineError::Other(format!("error log: {error}"))) +} + +fn error_log_failure(path: Option<&str>, errors: &[Value]) -> Option { + path.and_then(|path| write_error_log(path, errors).err().map(|error| error.to_string())) +} + +fn batch_outcome(files: Vec, errors: Vec, stopped: Option<(String, Vec)>, error_log: Option<&str>) -> Value { + let mut result = json!({"files": files, "errors": errors}); + if let Some((at, skipped)) = stopped { + result["stopped"] = json!(at); + result["skipped"] = json!(skipped); + } + if let Some(path) = error_log { + result["errorLog"] = json!(path); + } + result +} + +fn validate_error_log(policy: &BatchPolicy, inputs: impl IntoIterator, outputs: impl IntoIterator) -> Result<()> { + let Some(log) = &policy.error_log else { return Ok(()) }; + let conflicts = |path: String| super::paths::same_destination(log, &path); + if inputs.into_iter().chain(outputs).any(conflicts) { + return Err(EngineError::BadParams { cmd: "file.automate.batch".into(), msg: "\"errorLog\" must not replace an input or batch output".into() }); + } + Ok(()) +} + +/// A flat Batch result must not replace another input before it is opened. Compare resolved +/// identities once for the whole plan, so aliases and large folder batches stay safe and linear. +#[cfg(not(target_arch = "wasm32"))] +pub fn validate_flat_outputs(outputs: &[(String, String)]) -> Result<()> { + let sources: std::collections::HashMap<_, _> = + outputs.iter().enumerate().filter_map(|(index, (input, _))| std::fs::canonicalize(input).ok().map(|identity| (identity, index))).collect(); + for (index, (_, output)) in outputs.iter().enumerate() { + let identity = super::paths::destination_identity(std::path::Path::new(output)) + .ok_or_else(|| EngineError::BadParams { cmd: "file.automate.batch".into(), msg: format!("output path cannot be resolved safely: {output}") })?; + if sources.get(&identity).is_some_and(|&source_index| source_index != index) { + return Err(EngineError::BadParams { + cmd: "file.automate.batch".into(), + msg: format!("output would replace another input file: {output}; choose a different destination or naming template"), + }); + } + } + Ok(()) +} + +pub(super) fn process_batch_outputs( + raw_ppi: f64, + outputs: Vec<(String, String)>, + format: &str, + save: SaveOpts, + policy: &BatchPolicy, + ctx: &crate::jobs::JobCtx, + f: &dyn Fn(&mut Session, &str) -> Result, +) -> Result { + let total = outputs.len(); + let mut files = Vec::new(); + let mut errors = Vec::new(); + let mut stopped = None; + let mut done = 0; + let mut interrupted = Vec::new(); + for (index, (path, out)) in outputs.iter().enumerate() { + if ctx.cancelled() { + break; + } + if ctx.batch_take_interrupt() && !ctx.batch_wait_for_decision() { + ctx.publish_batch_preview(path, None); + ctx.cancel(); + break; + } + ctx.progress(index as f32 / total.max(1) as f32, &format!("{} of {total}: {}", index + 1, file_name(path))); + let result = process_one(raw_ppi, path, out, save, ProcessOptions { format: Some(format), written: None, ctx: Some(ctx) }, f); + // An input that failed on its own (not because it was stopped) stays a failure with its + // reason, even when a cancel or Esc lands while it runs. + let failure = + result.as_ref().err().filter(|error| !matches!(error, EngineError::Cancelled)).map(|error| json!({"file": path, "error": error.to_string()})); + if ctx.cancelled() { + ctx.publish_batch_preview(path, None); + // A cancel that lands while the result is written still leaves that file written. + if result.is_ok() { + files.push(out.clone()); + done = index + 1; + } else if let Some(failure) = failure { + errors.push(failure); + done = index + 1; + } else { + done = index; + } + break; + } + if ctx.batch_take_interrupt() { + let continue_next = ctx.batch_wait_for_decision(); + ctx.publish_batch_preview(path, None); + match failure { + Some(failure) => { + errors.push(failure); + done = index + 1; + } + None if continue_next => { + interrupted.push(path.clone()); + done = index + 1; + } + None => {} + } + if continue_next { + continue; + } + ctx.cancel(); + break; + } + ctx.batch_after_write(); + ctx.publish_batch_preview(path, None); + done = index + 1; + match result { + Ok(()) => { + files.push(out.clone()); + } + Err(error) => { + errors.push(json!({"file": path, "error": error.to_string()})); + if policy.stop_on_error { + stopped = Some(path.clone()); + break; + } + } + } + } + // No checkpoint follows the last input: a later Esc has nothing to interrupt. + ctx.batch_close_interrupts(); + let log_failure = error_log_failure(policy.error_log.as_deref(), &errors); + let cancelled = ctx.cancelled(); + if !cancelled { + ctx.progress(1.0, ""); + } + let mut skipped = interrupted; + skipped.extend(outputs.iter().skip(done).map(|(path, _)| path.clone())); + let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped.clone())), policy.error_log.as_deref().filter(|_| log_failure.is_none())); + if !skipped.is_empty() { + result["skipped"] = json!(skipped); + } + if cancelled { + result["cancelled"] = json!(true); + } + if let Some(error) = log_failure { + result["errorLogError"] = json!(error); + } + Ok(result) +} + /// The output paths a batch run has written, so a later input whose output name matches an /// earlier one's (`a.png` and `a.jpg` saved as JPEG, or the same name in two input folders) is /// reported instead of silently replacing that result (#420, #422). Names are compared ignoring @@ -244,8 +484,17 @@ 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) } +/// Execute the selected source set with per-document action scopes and restore the caller's library on every exit. pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; + let action_name = s + .action_history + .as_ref() + .map(|group| group.name.clone()) + .or_else(|| s.actions.playback_stack.first().cloned()) + .or_else(|| p.get("action").and_then(Value::as_str).map(str::to_string)) + .or_else(|| p.get("action").and_then(|action| action.get("name")).and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| "Batch".into()); 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 / @@ -254,8 +503,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { 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(); - let inputs = batch_inputs_tree(p, cmd, &output)?; + let policy = BatchPolicy::parse(p, cmd)?; + let source = match p.get("source") { + None | Some(Value::Null) => "folder", + Some(Value::String(source)) if matches!(source.as_str(), "folder" | "open") => source.as_str(), + _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"source\" must be \"folder\" or \"open\"".into() }), + }; + let output = if source == "folder" { + str_param(p, "output", cmd)?.to_string() + } else { + match p.get("output") { + None | Some(Value::Null) => String::new(), + Some(Value::String(path)) => path.clone(), + Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"output\" must be a folder path".into() }), + } + }; + let inputs = if source == "folder" { batch_inputs_tree(p, cmd, &output)? } else { Vec::new() }; let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); // Steps that call another action play it from the caller's actions; the playback stack comes // along so an action that batches itself is caught as a recursive call. @@ -275,9 +538,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { // decoded and saved even with no steps) to the playback tree it is in and // hands the count on, so a batch of batches can't multiply past the limit. A top-level batch // is not limited by it. + let input_count = if source == "open" { s.documents().len() } else { inputs.len() }; let mut playback_steps = 0; if s.actions.playing > 0 { - playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(inputs.len() as u64)); + playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(input_count as u64)); if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS { return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() }); } @@ -290,52 +554,479 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { playback_steps, ..Default::default() }; + if source == "open" { + let save = SaveOpts::from_params(s, p, cmd)?; + // A top-level open-document batch owns its lazy per-document scope. A batch + // inside an action shares that action's scope and must not finish it early. + let owns_history = s.action_history.is_none() && s.prefs().actions.create_single_history_state; + if owns_history { + crate::actions_cmds::begin_document_history(s, &action_name); + } + let previous = p.get("calledActions").is_some_and(|value| !value.is_null()).then(|| std::mem::replace(&mut s.actions.list, actions.list.clone())); + let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save); + if owns_history { + crate::actions_cmds::finish_document_history(s); + } + if let Some(previous) = previous { + s.actions.list = previous; + } + return result; + } let names = batch_naming::plan(&inputs, &output, &format, p)?; let save = SaveOpts::from_params(s, p, cmd)?; let authorize = s.authorize; + let raw_ppi = s.prefs().raw_defaults.resolution; + let action_prefs = s.prefs().actions.clone(); + let outputs: Vec<(String, String)> = match names { + Some(names) => inputs.iter().map(|input| input.path.clone()).zip(names).collect(), + None => inputs + .iter() + .map(|input| { + let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let out = join(&output, &format!("{}.{ext}", stem(&input.path))); + (input.path.clone(), out) + }) + .collect(), + }; + #[cfg(not(target_arch = "wasm32"))] + validate_flat_outputs(&outputs)?; + validate_error_log(&policy, outputs.iter().map(|(input, _)| input.clone()), outputs.iter().map(|(_, output)| output.clone()))?; // Each input runs in its own scratch session, so what the played steps spend is carried from // one input to the next (failed or not) and back to this session, not restarted per input. - let carried = std::cell::Cell::new(playback_steps); - let carried = &carried; - let run = move |scratch: &mut Session, out: &str| { - scratch.authorize = authorize; - scratch.actions = actions.clone(); - scratch.actions.playback_steps = carried.get(); - let result = (|| { - // The document as the last recorded save found it, written once the action has run so a - // failing step still writes nothing. + let was_playing = s.actions.playing > 0; + #[cfg(test)] + let history_observer = s.batch_history_observer; + crate::jobs::run( + s, + "Batch", + false, + move |ctx| { + let carried = std::cell::Cell::new(playback_steps); + let run = |scratch: &mut Session, out: &str| { + scratch.authorize = authorize; + scratch.actions = actions.clone(); + scratch.actions.playback_steps = carried.get(); + scratch.prefs.edit(|prefs| prefs.actions = action_prefs.clone()); + let owns_history = scratch.action_history.is_none() && scratch.prefs().actions.create_single_history_state; + if owns_history { + crate::actions_cmds::begin_document_history(scratch, &action_name); + } + let result = (|| { + // A recorded save marks a result. If the action later closes or switches + // documents, write the saved snapshot instead of the new active document. + let source = scratch.active().and_then(|state| state.path.as_deref()).unwrap_or("").to_string(); + let encode_format = if format == "same" { same_format(&source) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + let mut saved: Option> = None; + for (id, params) in &steps { + ctx.check()?; + if ctx.batch_interrupt_requested() { + return Err(EngineError::Cancelled); + } + if batch_save_point(id) { + let state = scratch.active_mut().ok_or(EngineError::NoDocument)?; + saved = Some(state.doc.clone()); + // The destination save is performed by Batch; subsequent recorded + // Close steps see Save / Save As as saved, but Save a Copy as changed. + if id != "file.saveACopy" { + state.saved_revision = state.revision; + } + continue; + } + let result = scratch.execute(id, params.clone())?; + ctx.publish_batch_preview(&source, scratch.active().map(|state| state.doc.clone())); + if let Some(error) = crate::actions_cmds::nested_failure(id, &result) { + return Err(EngineError::Other(error)); + } + } + match saved { + Some(doc) if scratch.active().is_none_or(|active| active.doc.id != doc.id) => { + ctx.check()?; + save_batch_doc(&doc, out, &encode_format, save, ctx)?; + Ok(true) + } + _ => Ok(false), + } + })(); + if owns_history { + crate::actions_cmds::finish_document_history(scratch); + } + #[cfg(test)] + if let Some(observe) = history_observer { + observe(scratch); + } + carried.set(scratch.actions.playback_steps); + result + }; + let result = process_batch_outputs(raw_ppi, outputs, &format, save, &policy, ctx, &run); + Ok((result, carried.get())) + }, + move |s, (result, carried)| { + if was_playing { + s.actions.playback_steps = carried; + } + result + }, + ) +} + +/// Source: Opened Files runs on the live documents, so edits remain in their histories. +/// Documents are identified before playback; a step that closes or reorders tabs cannot make +/// the next input silently become a different document. +fn batch_open_documents( + s: &mut Session, + steps: &[(String, Value)], + p: &Value, + output: &str, + format: &str, + policy: &BatchPolicy, + save: SaveOpts, +) -> Result { + let originals: Vec<(u64, String, String)> = s + .documents() + .iter() + .map(|state| { + let path = state.path.clone().unwrap_or_else(|| format!("{}.psd", state.doc.name)); + (state.doc.id.0, state.doc.name.clone(), path) + }) + .collect(); + if originals.is_empty() { + return Err(EngineError::NoDocument); + } + let previous = s.active().map(|state| state.doc.id.0); + let virtual_inputs: Vec = originals.iter().map(|(_, _, path)| BatchInput { path: path.clone(), rel_dir: String::new() }).collect(); + let planned = if output.is_empty() { + None + } else { + let named = batch_naming::plan(&virtual_inputs, output, format, p)?; + Some(match named { + Some(paths) => paths, + None => virtual_inputs + .iter() + .map(|input| { + let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + join(output, &format!("{}.{ext}", stem(&input.path))) + }) + .collect(), + }) + }; + // As for a folder: no result may replace another open document's file. + #[cfg(not(target_arch = "wasm32"))] + if let Some(paths) = &planned { + let plan: Vec<(String, String)> = originals.iter().map(|(_, _, path)| path.clone()).zip(paths.iter().cloned()).collect(); + validate_flat_outputs(&plan)?; + } + validate_error_log(policy, originals.iter().map(|(_, _, path)| path.clone()), planned.iter().flat_map(|paths| paths.iter().cloned()))?; + let mut files = Vec::new(); + let mut errors = Vec::new(); + let mut stopped = None; + let mut done = 0; + for (index, (id, name, path)) in originals.iter().enumerate() { + // The document a recorded Save / Save As marked as saved, with its earlier saved + // revision: when Batch then writes nothing, it reads as changed again. + let mut marked: Option<(u64, u64)> = None; + let result = (|| -> Result<()> { + let position = s.documents().iter().position(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?; + s.set_active(position); 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()); + for (command, params) in steps { + if batch_save_point(command) { + let state = s.active_mut().ok_or(EngineError::NoDocument)?; + saved = Some(state.doc.clone()); + // With no destination nothing is written, so the document stays changed. + if command != "file.saveACopy" && planned.is_some() { + if marked.is_none() { + marked = Some((state.doc.id.0, state.saved_revision)); + } + state.saved_revision = state.revision; + } 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) { + let response = s.execute(command, params.clone())?; + if let Some(error) = crate::actions_cmds::nested_failure(command, &response) { return Err(EngineError::Other(error)); } } - // 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 Some(paths) = &planned else { return Ok(()) }; + let out = paths.get(index).ok_or_else(|| EngineError::Other("batch output plan is incomplete".into()))?; + let doc = match saved { + Some(saved) if s.active().is_some_and(|state| state.doc.id == saved.id) => s.active().ok_or(EngineError::NoDocument)?.doc.clone(), + Some(saved) => saved, + None => s.documents().iter().find(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?.doc.clone(), + }; + let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; + save_doc_as(&doc, out, &ext, save)?; + files.push(out.clone()); + Ok(()) })(); - carried.set(scratch.actions.playback_steps); - result - }; - let r = match names { - 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), - }; - if s.actions.playing > 0 { - s.actions.playback_steps = carried.get(); + done = index + 1; + if result.is_err() + && let Some((doc, revision)) = marked + && let Some(position) = s.documents().iter().position(|state| state.doc.id.0 == doc) + { + s.set_active(position); + if let Some(state) = s.active_mut() { + state.saved_revision = revision; + } + } + if let Err(error) = result { + errors.push(json!({"document": name, "error": error.to_string()})); + if policy.stop_on_error { + stopped = Some(name.clone()); + break; + } + } + } + if let Some(id) = previous + && let Some(position) = s.documents().iter().position(|state| state.doc.id.0 == id) + { + s.set_active(position); + } + let log_failure = error_log_failure(policy.error_log.as_deref(), &errors); + let skipped = originals.iter().skip(done).map(|(_, name, _)| name.clone()).collect(); + let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped)), policy.error_log.as_deref().filter(|_| log_failure.is_none())); + if let Some(error) = log_failure { + result["errorLogError"] = json!(error); + } + result["documents"] = json!(originals.len()); + Ok(result) +} + +#[cfg(test)] +mod action_history_tests { + use super::*; + use crate::actions_cmds::Action; + use std::cell::RefCell; + + #[derive(Debug)] + struct Observed { + id: photocraft_doc::DocId, + past: usize, + label: String, + name: String, + undo_name: String, + } + + thread_local! { static OBSERVED: RefCell> = const { RefCell::new(Vec::new()) }; } + + /// Inspect the real scratch boundary without retaining or changing the scratch session. + fn observe(s: &Session) { + assert!(s.action_history.is_none(), "Batch must release the scratch scope on every exit"); + let st = s.active().unwrap(); + let mut history = st.history.clone(); + let (before, _) = history.undo(st.doc.clone()).unwrap(); + let undo_name = before.layer(st.active_layer.unwrap()).unwrap().name.clone(); + let (after, _) = history.redo(before).unwrap(); + assert_eq!(after.as_ref(), st.doc.as_ref(), "redo restores exact document and layer identities"); + let name = st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone(); + OBSERVED.with(|seen| { + seen.borrow_mut().push(Observed { id: st.doc.id, past: st.history.past_len(), label: st.history.undo_label().unwrap().into(), name, undo_name }) + }); + } + + /// Batch and droplets keep one independent named entry per input, including nested and stopped runs. + #[test] + fn batch_and_droplet_document_history_boundaries() { + let dir = tempfile::tempdir().unwrap(); + let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![255; 48]).unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let paths: Vec<_> = ["a.png", "b.png"] + .iter() + .map(|name| { + let path = dir.path().join(name); + std::fs::write(&path, &bytes).unwrap(); + path + }) + .collect(); + for (case, single, nested, stopped, droplet) in [ + ("batch", true, false, false, false), + ("off", false, false, false, false), + ("nested", true, true, false, false), + ("stopped", true, false, true, false), + ("droplet", true, false, false, true), + ] { + OBSERVED.with(|seen| seen.borrow_mut().clear()); + let mut s = Session::new(); + s.batch_history_observer = Some(observe); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + let mut steps = vec![("layer.renameLayer".into(), json!({"name":"Prefix"}))]; + if stopped { + steps.push(("actions.stopMessage".into(), json!({"message":"Stop here"}))); + } + steps.push(("layer.renameLayer".into(), json!({"name":"Final"}))); + let action_steps = if nested { + s.actions.list.push(Action::new("Child", steps)); + vec![("actions.play".to_string(), json!({"action":"Child"}))] + } else { + steps + }; + let output = dir.path().join(case); + let result = if droplet { + let path = dir.path().join("named.pcdroplet"); + s.execute("file.automate.createDroplet", json!({"path":path,"name":"Named","steps":action_steps,"output":output,"shim":false})).unwrap(); + s.execute("file.automate.runDroplet", json!({"droplet":path,"input":paths})).unwrap() + } else { + s.execute("file.automate.batch", json!({"action":"Named","steps":action_steps,"input":paths,"output":output})).unwrap() + }; + assert_eq!(result["errors"].as_array().unwrap().len(), if stopped { 2 } else { 0 }, "{case}: {result}"); + OBSERVED.with(|seen| { + let seen = seen.borrow(); + assert_eq!(seen.len(), 2, "{case}"); + assert_ne!(seen[0].id, seen[1].id, "inputs have separate editing identities"); + for entry in seen.iter() { + assert_eq!(entry.past, if single { 1 } else { 2 }, "{case}: {entry:?}"); + if single { + assert_eq!(entry.label, "Action: Named", "{case}"); + } + assert_eq!(entry.name, if stopped { "Prefix" } else { "Final" }, "{case}"); + if !single { + assert_eq!(entry.undo_name, "Prefix", "OFF undoes only the last step"); + } else { + assert_ne!(entry.undo_name, "Prefix", "ON undoes the whole play"); + } + } + }); + } + } + + #[test] + /// Batch over open documents must preserve an enclosing action scope and finish its own document scopes. + fn opened_document_batch_keeps_top_level_and_parent_history_boundaries() { + for single in [false, true] { + for parent in [false, true] { + let mut s = Session::new(); + for _ in 0..2 { + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + } + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + let before: Vec<_> = s.documents().iter().map(|st| (st.doc.clone(), st.history.past_len())).collect(); + let params = json!({"source":"open","action":"Opened batch","steps":[ + ["layer.renameLayer",{"name":"Prefix"}], ["layer.renameLayer",{"name":"Final"}] + ]}); + if parent { + s.actions.list.push(Action::new( + "Parent", + vec![ + ("layer.renameLayer".into(), json!({"name":"Before batch"})), + ("file.automate.batch".into(), params), + ("layer.renameLayer".into(), json!({"name":"After batch"})), + ], + )); + let result = s.execute("actions.play", json!({"action":"Parent"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + } else { + let result = s.execute("file.automate.batch", params).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + } + assert!(s.action_history.is_none()); + for (index, (original, past)) in before.iter().enumerate() { + s.set_active(index); + let st = s.active().unwrap(); + let after = st.doc.clone(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if parent && index == 1 { "After batch" } else { "Final" }); + let steps = if parent && index == 1 { 4 } else { 2 }; + assert_eq!(st.history.past_len(), past + if single { 1 } else { steps }); + if single { + assert_eq!(st.history.undo_label(), Some(if parent { "Action: Parent" } else { "Action: Opened batch" })); + } + assert!(s.undo()); + if single { + assert_eq!(s.active().unwrap().doc.as_ref(), original.as_ref()); + } else { + assert_ne!(s.active().unwrap().doc.as_ref(), original.as_ref()); + } + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } + } + } + + #[test] + /// A Batch failure restores the library and leaves the caller's active history scope owned by the caller. + fn opened_document_batch_error_restores_library_without_finishing_parent() { + for parent in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":true})).unwrap(); + s.actions.list.push(Action::new("Original", vec![])); + let library = serde_json::to_value(&s.actions.list).unwrap(); + let before = s.active().unwrap().doc.clone(); + if parent { + crate::actions_cmds::begin_document_history(&mut s, "Parent"); + } + let result = s + .execute( + "file.automate.batch", + json!({ + "source":"open","action":"Broken","calledActions":[Action::new("Foreign", vec![])], + "steps":[["layer.renameLayer",{"name":"Prefix"}],["layer.renameLayer",{"name":false}]] + }), + ) + .unwrap(); + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(serde_json::to_value(&s.actions.list).unwrap(), library); + if parent { + assert_eq!(s.action_history.as_ref().unwrap().name, "Parent"); + s.execute("layer.renameLayer", json!({"name":"Parent tail"})).unwrap(); + crate::actions_cmds::finish_document_history(&mut s); + } else { + assert!(s.action_history.is_none()); + } + let st = s.active().unwrap(); + let after = st.doc.clone(); + assert_eq!(st.history.undo_label(), Some(if parent { "Action: Parent" } else { "Action: Broken" })); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } + + thread_local! { static CANCEL_CONTEXT: RefCell> = const { RefCell::new(None) }; } + + fn cancel_after_prefix(id: &str, params: &Value) -> Result<()> { + if id == "layer.renameLayer" && params["name"] == "Cancel here" { + CANCEL_CONTEXT.with(|ctx| ctx.borrow().as_ref().unwrap().cancel()); + return Err(EngineError::Cancelled); + } + Ok(()) + } + + #[test] + /// Cancellation after a completed edit retains an undoable prefix without leaving a Batch-owned scope open. + fn cancelled_folder_batch_finalizes_its_completed_prefix() { + let dir = tempfile::tempdir().unwrap(); + let image = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![255; 48]).unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let input = dir.path().join("input.png"); + std::fs::write(&input, bytes).unwrap(); + let mut s = Session::new(); + s.execute("actions.setHistory", json!({"singleState":true})).unwrap(); + let ctx = crate::jobs::JobCtx::new(); + s.set_inline_job_ctx(Some(ctx.clone())); + CANCEL_CONTEXT.with(|slot| *slot.borrow_mut() = Some(ctx.clone())); + OBSERVED.with(|seen| seen.borrow_mut().clear()); + s.authorize = Some(cancel_after_prefix); + s.batch_history_observer = Some(observe); + let result = s + .execute( + "file.automate.batch", + json!({"action":"Cancelled batch","input":[input],"output":dir.path().join("out"), + "steps":[["layer.renameLayer",{"name":"Prefix"}],["layer.renameLayer",{"name":"Cancel here"}]] + }), + ) + .unwrap(); + CANCEL_CONTEXT.with(|slot| *slot.borrow_mut() = None); + assert!(ctx.cancelled()); + assert_eq!(result["cancelled"], true, "{result}"); + assert!(result["files"].as_array().unwrap().is_empty(), "{result}"); + OBSERVED.with(|seen| { + let seen = seen.borrow(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].past, 1); + assert_eq!(seen[0].label, "Action: Cancelled batch"); + assert_eq!(seen[0].name, "Prefix"); + assert_ne!(seen[0].undo_name, "Prefix"); + }); } - Ok(r) } diff --git a/crates/engine/src/cmd/file_cmds/export.rs b/crates/engine/src/cmd/file_cmds/export.rs index 80213dba16..1f7f9677ff 100644 --- a/crates/engine/src/cmd/file_cmds/export.rs +++ b/crates/engine/src/cmd/file_cmds/export.rs @@ -44,6 +44,7 @@ pub struct LayerExportReport { pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); + let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); // Callers outside `Session::execute` (headless workspace exports) carry the session's gate // into `encode`, so a PSD embeds no file a linked smart object names. @@ -72,6 +73,24 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(& only.visible = true; only.clipped = false; one.layers = vec![only]; + if trim { + let mut bounds = Rect::EMPTY; + let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> { + let width = band.rect.width() as usize; + for (i, rgba) in band.px.iter().enumerate() { + if rgba[3] > 0.0 && width > 0 { + let x = band.rect.x0 + (i % width) as i32; + let y = band.rect.y0 + (i / width) as i32; + bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1)); + } + } + Ok(()) + }); + if !bounds.is_empty() && bounds != one.bounds() { + crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?; + } + } + let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name)); let (bytes, _warnings) = encode(&one, &name, save)?; write(&name, &bytes)?; diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index efc44e880f..1397702318 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -5,7 +5,13 @@ use super::{ }; pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { - import_result(name, bytes).map(|r| r.document) + import_with_options(name, bytes, &photocraft_io::ImportOptions::default()) +} + +/// [`import`] with the caller's import settings. Footage passes its PSD/layered-TIFF memory limit, +/// so a frame import cannot bypass the admission the rest of the open path applies. +pub(crate) fn import_with_options(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { + import_result_with(name, bytes, opts).map(|r| r.document) } /// [`import`] for a file that becomes a document of its own (Revert, Load Files into Stack; @@ -23,8 +29,15 @@ pub(super) fn import_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Resul } fn import_result(name: &str, bytes: &[u8]) -> Result { + import_result_with(name, bytes, &photocraft_io::ImportOptions::default()) +} + +/// The one import path every caller shares: allocation checkpoint, native `.pcraft` and format +/// dispatch through `photocraft_io::import_with_options`, typed memory refusals, and the +/// preview-only refusal wherever the import came from. +fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { crate::allocation::checkpoint("importing document")?; - let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; + let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. // Open has its own warning-preserving path; never silently place or process a thumbnail. if r.preview_only { @@ -109,12 +122,30 @@ fn raw_resolution(ppi: f64, warnings: &[String], doc: &mut Document) { } pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { + open_bytes_as_with_options(s, name, bytes, as_ext, path, false) +} + +/// [`open_bytes_as`] with the caller's high-memory approval for these bytes. +/// +/// A one-shot approved estimate is deliberately not forwarded: these bytes may be a fresh copy +/// whose estimate was never approved, so approval applies only through the boolean flag. +pub fn open_bytes_as_with_options( + s: &mut Session, + name: &str, + bytes: &[u8], + as_ext: Option<&str>, + path: Option, + approve_high_memory: bool, +) -> Result { crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { Some(ext) => format!("{}.{}", stem(name), ext.trim_start_matches('.')), None => name.to_string(), }; - let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; + let mut opts = crate::jobs::import_options(s.prefs()); + opts.psd_high_memory_approved = approve_high_memory; + opts.psd_approved_estimate = None; + let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| crate::error::io_open_error(&decode_name, e))?; let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); raw_defaults(s, &r.warnings, &mut doc); @@ -139,9 +170,69 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.openAs")?.to_string(); - let bytes = read_file(&path)?; let as_ext = p.get("as").or_else(|| p.get("format")).and_then(Value::as_str); - open_bytes_as(s, &path, &bytes, as_ext, Some(path.clone())) + let approve_high_memory = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.openAs".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; + let bytes = read_file(&path)?; + open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory) +} + +/// Answers a pending high-memory preflight: `approve` false cancels it; true rechecks the hard +/// limit and starts the approved open. The preflight is removed either way, so a refusal or a +/// later failure releases the source it held. +pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { + let id = crate::file_cmds::u64_param(p, "id", "file.approveMemory")?; + let approve = match p.get("approve") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.approveMemory".into(), msg: "expected boolean for \"approve\"".into() }); + } + }; + let _background = match p.get("background") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.approveMemory".into(), msg: "expected boolean for \"background\"".into() }); + } + }; + if !approve { + s.cancel_pending_preflight(id) + .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; + return Ok(json!({"id": id, "approved": false, "cancelled": true})); + } + // Taking the preflight releases the source bytes it held: approval, refusal and a later + // failure all end the wait. The one-shot authorization is bound to this source and estimate; + // the import re-checks both against the handle it actually decodes. + let preflight = s + .take_pending_preflight(id) + .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; + // The resolved policy is the platform's: desktop uses its measured adaptive budget, the web + // its 1 GiB automatic / 2 GiB hard product cap. Explicit consent is available on both; the + // hard ceiling is never raised by an approval. While the scratch disk is spilling, tile + // pixels may use its room too; this fast refusal grants the whole room (the pending prompt + // does not keep the tile share), and the import re-checks the exact split before decoding. + let budget = photocraft_io::memory::resolve_budget_with_scratch(photocraft_io::memory::MemorySnapshot::current(), None, u64::MAX); + if preflight.required > budget.hard_limit { + return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit }); + } + let started = s.start_open_approved_with_background( + &preflight.name, + preflight.source, + preflight.confirm_damage, + Some(preflight.required), + Some(preflight.fingerprint), + _background, + )?; + match started { + crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), + crate::jobs::Started::Job(job_id) => Ok(json!({"id": id, "approved": true, "job": job_id.0})), + } } // ---------- place ---------- @@ -159,8 +250,17 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; + let mut opts = crate::jobs::import_options(s.prefs()); + opts.psd_high_memory_approved = approve; + opts.psd_approved_estimate = None; + let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| crate::error::io_open_error(name, e))?; if imported.preview_only { return Err(EngineError::Other(format!( "{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" @@ -214,6 +314,9 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option Result { let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" }; + if p.get("approveHighMemory").is_some_and(|v| !v.is_null() && !v.is_boolean()) { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected boolean for approveHighMemory".into() }); + } let path = str_param(p, "path", cmd)?.to_string(); let bytes = read_file(&path)?; // The link is stored absolute: a relative one would later be taken from the document's diff --git a/crates/engine/src/cmd/file_cmds/paths.rs b/crates/engine/src/cmd/file_cmds/paths.rs index 840609d826..928325e92f 100644 --- a/crates/engine/src/cmd/file_cmds/paths.rs +++ b/crates/engine/src/cmd/file_cmds/paths.rs @@ -40,12 +40,19 @@ pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] pub fn list_images(dir: &str) -> Result> { let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; - let mut out: Vec = rd - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.is_file() && openable_name(&p.to_string_lossy())) - .map(|p| p.to_string_lossy().into_owned()) - .collect(); + let mut out = Vec::new(); + for entry in rd { + let path = entry.map_err(|e| EngineError::Other(format!("{dir}: {e}")))?.path(); + if !openable_name(&path.to_string_lossy()) { + continue; + } + match std::fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => out.push(path.to_string_lossy().into_owned()), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} // broken link or entry removed during traversal + Err(error) => return Err(EngineError::Other(format!("{}: {error}", path.display()))), + } + } out.sort(); Ok(out) } @@ -181,6 +188,95 @@ pub fn same_file(a: impl AsRef, b: impl AsRef) false } +/// Resolve a future output path component by component. Existing symlinks are followed before +/// applying a later `..`, while components not created yet are kept so a new `x/../file` still +/// resolves to the file it would reach after `x` is created. +#[cfg(not(target_arch = "wasm32"))] +pub fn destination_identity(path: &std::path::Path) -> Option { + use std::path::{Component, PathBuf}; + let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) }; + let mut resolved = PathBuf::new(); + for component in absolute.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + resolved.pop(); + } + Component::Normal(_) => { + resolved.push(component.as_os_str()); + match std::fs::symlink_metadata(&resolved) { + Ok(_) => resolved = std::fs::canonicalize(&resolved).ok()?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return None, + } + } + Component::Prefix(_) | Component::RootDir => resolved.push(component.as_os_str()), + } + } + Some(resolved) +} + +/// Probe the actual destination directory. macOS volumes and Windows directories may use either +/// case behavior; the OS name alone cannot tell whether two future names will alias. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(super) fn case_sensitive_destination(path: &std::path::Path) -> Option { + let parent = path.ancestors().skip(1).find(|ancestor| ancestor.is_dir())?; + let probe = tempfile::Builder::new().prefix(".PhotoCraftCaseProbe-").tempfile_in(parent).ok()?; + let name = probe.path().file_name()?.to_str()?; + let alternate = parent.join(name.replacen("PhotoCraft", "photocraft", 1)); + match std::fs::metadata(&alternate) { + Ok(_) => Some(!same_file(probe.path(), &alternate)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(true), + Err(_) => None, + } +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn resolved_paths_match(a: &std::path::Path, b: &std::path::Path, case_sensitive: impl FnOnce() -> Option) -> bool { + if a == b { + return true; + } + match (a.to_str(), b.to_str()) { + (Some(a_text), Some(b_text)) if a_text.to_lowercase() == b_text.to_lowercase() => !case_sensitive().unwrap_or(false), + _ => false, + } +} + +#[cfg(all(test, any(target_os = "macos", target_os = "windows")))] +#[test] +fn identical_resolved_paths_conflict_on_a_case_sensitive_volume() { + let path = std::path::Path::new("/future/result.png"); + assert!(resolved_paths_match(path, path, || Some(true))); + assert!(!resolved_paths_match(path, std::path::Path::new("/future/other.png"), || panic!("unrelated names must not probe the volume"))); +} + +/// Whether two paths would name the same destination even before the file exists. This is used +/// when a batch error log must not be allowed to replace a result that will be written later. +pub fn same_destination(a: impl AsRef, b: impl AsRef) -> bool { + let (a, b) = (a.as_ref(), b.as_ref()); + if same_file(a, b) { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + { + match (destination_identity(a), destination_identity(b)) { + (Some(a), Some(b)) => { + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + resolved_paths_match(&a, &b, || case_sensitive_destination(&a)) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + a == b + } + } + _ => true, + } + } + #[cfg(target_arch = "wasm32")] + false +} + /// A path as command results report it: `/` between components, whatever the platform. /// /// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute` @@ -212,7 +308,7 @@ pub(crate) fn sanitize(name: &str) -> String { /// `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 { +pub fn portable_device_name(name: String) -> String { if !is_windows_device_name(&name) { return name; } diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index fff62dc7f4..681a466366 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -9,7 +9,8 @@ pub fn tiff_would_write_layers(doc: &Document) -> bool { } /// 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. +/// bits, DPX bits, DDS compression and mipmaps, PSD previews, the PSD merged image and the +/// `.pcraft` full-size composite. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. @@ -25,6 +26,11 @@ pub(crate) struct SaveOpts { pub dpx_bits: Option, /// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none. pub tiff_compression: Option, + /// TIFF: Save Transparency (`"tiffTransparency": bool`); unset, the transparency is kept + /// unless the document has alpha or spot channels ([`photocraft_io::ExportOptions::tiff_transparency`]). + pub tiff_transparency: Option, + /// TIFF: Save Image Pyramid (`"tiffPyramid": bool`); unset, off. + pub tiff_pyramid: bool, /// DDS: the compression and whether to write the mip chain (`"ddsCompression"`, /// `"ddsMipmaps"`); unset, uncompressed without mipmaps. pub dds: photocraft_io::DdsOptions, @@ -33,6 +39,10 @@ pub(crate) struct SaveOpts { /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility). pub psd_maximize_compatibility: bool, + /// `.pcraft`: request the full-size composite (`"pcraftFullComposite"`; unset, Preferences › + /// File Handling › Save Full-Size Composite in .pcraft Files, off by default). The save may + /// omit it; check the manifest's `full_composite` member. + pub pcraft_full_composite: bool, } impl Default for SaveOpts { @@ -44,9 +54,12 @@ impl Default for SaveOpts { tga_bits: None, dpx_bits: None, tiff_compression: None, + tiff_transparency: None, + tiff_pyramid: false, dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, + pcraft_full_composite: false, } } } @@ -56,7 +69,8 @@ impl SaveOpts { /// `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). + /// Maximize Compatibility checkbox both start on). The `.pcraft` full-size composite comes from + /// `pcraftFullComposite`, or without it from the File Handling preference. pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { let file_handling = &s.prefs().file_handling; Ok(SaveOpts { @@ -66,9 +80,12 @@ impl SaveOpts { tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, tiff_compression: tiff_compression_param(p, cmd)?, + tiff_transparency: tiff_bool_param(p, "tiffTransparency", cmd)?, + tiff_pyramid: tiff_bool_param(p, "tiffPyramid", cmd)?.unwrap_or(false), 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, + pcraft_full_composite: pcraft_full_composite_param(p, cmd)?.unwrap_or(file_handling.pcraft_full_composite), }) } @@ -98,6 +115,18 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + match p.get("pcraftFullComposite") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_bool() + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`pcraftFullComposite` must be true or false, got {v}") }), + } +} + /// The TIFF compression in `p` (`"tiffCompression": "none" | "lzw" | "zip"`, Photoshop's TIFF /// Options; absent or null: none). Anything else is a bad parameter of command `cmd` (#5301). pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result> { @@ -113,6 +142,17 @@ pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(v) => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`{key}` must be true or false, 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`]. @@ -175,9 +215,11 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R let save = save.into(); let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, + tiff_transparency: save.tiff_transparency, tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, + pcraft_full_composite: save.pcraft_full_composite, // Linked files a PSD embeds are resolved against the document being worked on. document_path: crate::doc_path::current(), // An untrusted session's stored paths are document data: embed none of them. @@ -191,6 +233,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R if let Some(c) = save.tiff_compression { opts.encode.tiff_compression = c; } + opts.encode.tiff_pyramid = save.tiff_pyramid; opts.encode.dds = save.dds; if let Some(q) = save.quality { let q = jpeg_quality(q); @@ -211,6 +254,13 @@ pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into) -> Ok(warnings) } +/// Save Batch bytes in the chosen format even when its naming rules omit or replace the suffix. +pub(crate) fn save_doc_as(doc: &Document, path: &str, format: &str, save: impl Into) -> Result> { + let (bytes, warnings) = encode(doc, format, save)?; + write_file(path, &bytes)?; + Ok(warnings) +} + pub(super) fn save_a_copy(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 7eb770d844..804e6a01ca 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -31,6 +31,622 @@ fn composite(s: &Session, x: i32, y: i32) -> [f32; 4] { b.px[0] } +fn batch_log(path: &str) -> String { + let bytes = std::fs::read(path).unwrap(); + assert!(bytes.starts_with(&[0xff, 0xfe]), "Batch log needs a UTF-16 LE BOM"); + let (pairs, remainder) = bytes[2..].as_chunks::<2>(); + assert!(remainder.is_empty(), "UTF-16 bytes must have even length"); + let words: Vec = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect(); + String::from_utf16(&words).unwrap() +} + +#[test] +fn batch_stop_on_error_reports_skipped_inputs_and_writes_the_error_log() { + let dir = tmp("batch-stop-error-log"); + let good = png(&dir, "good.png", 8, 6, "#00ff00"); + let missing = join(&dir, "missing.png"); + let output = join(&dir, "out"); + let log = join(&dir, "errors.txt"); + let result = Session::new() + .execute("file.automate.batch", json!({"steps": [], "input": [missing, good], "output": output, "stopOnError": true, "errorLog": log})) + .unwrap(); + assert_eq!(result["files"], json!([]), "{result}"); + assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}"); + assert_eq!(result["stopped"], json!(missing), "{result}"); + assert_eq!(result["skipped"], json!([good]), "{result}"); + let text = batch_log(&log); + assert!(text.starts_with("Batch Start\r\n\r\n") && text.contains("missing.png") && text.contains("File: \"") && text.contains("Error: "), "{text}"); + assert!(text.ends_with("Batch End\r\n"), "{text}"); + assert!(!std::path::Path::new(&join(&output, "good.png")).exists()); +} + +#[test] +fn batch_writes_a_utf16_error_log_even_when_every_file_succeeds() { + let dir = tmp("batch-clean-log"); + let input = png(&dir, "good.png", 8, 6, "#00ff00"); + let output = join(&dir, "out"); + let log = join(&dir, "errors.txt"); + let result = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(result["errorLog"], json!(log)); + assert_eq!(batch_log(&log), "Batch Start\r\n\r\nBatch End\r\n"); +} + +#[test] +fn batch_cancelled_during_an_action_does_not_save_that_input() { + let dir = tmp("batch-cancel-current"); + let input = png(&dir, "source.png", 8, 6, "#ff0000"); + let out = join(&dir, "out/source.png"); + let ctx = crate::jobs::JobCtx::new(); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + let result = super::batch_run::process_batch_outputs(72.0, vec![(input, out.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| { + ctx.cancel(); + Ok(false) + }) + .unwrap(); + assert_eq!(result["files"], json!([]), "{result}"); + assert_eq!(result["cancelled"], json!(true), "{result}"); + assert!(!std::path::Path::new(&out).exists()); +} + +/// An action that saved and then closed its document has its saved snapshot written by the time +/// it returns: a cancel landing then counts that file as written, not skipped. +#[test] +fn batch_cancel_after_a_saved_snapshot_is_written_keeps_that_file() { + let dir = tmp("batch-cancel-after-snapshot"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let first_out = join(&dir, "out/first.png"); + let ctx = crate::jobs::JobCtx::new(); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + let outputs = vec![(first, first_out.clone()), (second.clone(), join(&dir, "out/second.png"))]; + let result = super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &ctx, &|s, out| { + save_doc(&s.active().unwrap().doc, out, None).unwrap(); + ctx.cancel(); + Ok(true) + }) + .unwrap(); + assert_eq!(result["files"], json!([first_out]), "{result}"); + assert_eq!(result["skipped"], json!([second]), "{result}"); + assert_eq!(result["cancelled"], json!(true), "{result}"); +} + +/// A cancel that lands while an input fails on its own keeps that failure and its reason; only +/// the inputs after it are skipped. +#[test] +fn batch_cancel_during_a_failing_input_keeps_its_error() { + let dir = tmp("batch-cancel-failing"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let ctx = crate::jobs::JobCtx::new(); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + let outputs = vec![(first.clone(), join(&dir, "out/first.png")), (second.clone(), join(&dir, "out/second.png"))]; + let result = super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &ctx, &|_, _| { + ctx.cancel(); + Err(EngineError::Other("boom".into())) + }) + .unwrap(); + assert_eq!(result["errors"], json!([{"file": first, "error": "boom"}]), "{result}"); + assert_eq!(result["skipped"], json!([second]), "{result}"); + assert_eq!(result["cancelled"], json!(true), "{result}"); +} + +/// Esc during an input that fails on its own: Continue records the failure (not an interrupted +/// input) and goes on with the next one. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_interruption_during_a_failing_input_keeps_its_error() { + use std::time::Duration; + + let dir = tmp("batch-interrupt-failing"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let second_out = join(&dir, "out/second.png"); + let ctx = crate::jobs::JobCtx::new(); + let worker_ctx = ctx.clone(); + let outputs = vec![(first.clone(), join(&dir, "out/first.png")), (second, second_out.clone())]; + let worker = std::thread::spawn(move || { + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &worker_ctx, &|_, path| { + if path.ends_with("first.png") { + assert!(worker_ctx.request_batch_interrupt()); + return Err(EngineError::Other("boom".into())); + } + Ok(false) + }) + }); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(ctx.decide_batch_interrupt(true)); + let result = worker.join().unwrap().unwrap(); + assert_eq!(result["errors"], json!([{"file": first, "error": "boom"}]), "{result}"); + assert_eq!(result["files"], json!([second_out]), "{result}"); + assert!(result.get("skipped").is_none(), "{result}"); +} + +/// Once the last input is handled, Esc has nothing to interrupt and is refused, so the desktop +/// never waits for a question that will not come. +#[test] +fn batch_refuses_esc_after_its_last_input() { + let dir = tmp("batch-late-esc"); + let input = png(&dir, "source.png", 8, 6, "#ff0000"); + let ctx = crate::jobs::JobCtx::new(); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + let result = + super::batch_run::process_batch_outputs(72.0, vec![(input, join(&dir, "out/source.png"))], "png", SaveOpts::default(), &policy, &ctx, &|_, _| { + Ok(false) + }) + .unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert!(!ctx.request_batch_interrupt()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_interruption_continues_with_the_original_next_output() { + use std::sync::mpsc; + use std::time::Duration; + + let dir = tmp("batch-interrupt-continue"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let first_out = join(&dir, "out/first_001.png"); + let second_out = join(&dir, "out/second_002.png"); + let ctx = crate::jobs::JobCtx::new(); + let worker_ctx = ctx.clone(); + let (opened_tx, opened_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let first_action = std::sync::atomic::AtomicBool::new(true); + let worker = std::thread::spawn(move || { + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + super::batch_run::process_batch_outputs( + 72.0, + vec![(first, first_out.clone()), (second, second_out.clone())], + "png", + SaveOpts::default(), + &policy, + &worker_ctx, + &|_, _| { + if first_action.swap(false, std::sync::atomic::Ordering::AcqRel) { + let _ = opened_tx.send(()); + let _ = resume_rx.recv_timeout(Duration::from_secs(10)); + } + Ok(false) + }, + ) + .map(|result| (result, first_out, second_out)) + }); + opened_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let preview = ctx.batch_preview().expect("the real opened input is published before its action"); + assert_eq!(preview.path, join(&dir, "first.png")); + assert_eq!((preview.document.size.width, preview.document.size.height), (8, 6)); + assert!(ctx.request_batch_interrupt(), "Esc is accepted while the first action runs"); + resume_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(ctx.batch_waiting_for_decision(), "the worker stopped before writing"); + assert!(!std::path::Path::new(&join(&dir, "out/first_001.png")).exists()); + assert!(ctx.decide_batch_interrupt(true)); + let (result, first_out, second_out) = worker.join().unwrap().unwrap(); + assert_eq!(result["files"], json!([second_out]), "Continue keeps the original serial: {result}"); + assert!(!std::path::Path::new(&first_out).exists()); + assert!(std::path::Path::new(&second_out).exists()); + assert!(ctx.batch_preview().is_none(), "the preview closes after the last input"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_stop_after_interruption_preserves_prior_outputs_and_never_saves_current() { + use std::sync::mpsc; + use std::time::Duration; + + let dir = tmp("batch-interrupt-stop"); + let first = png(&dir, "first.png", 8, 6, "#ff0000"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let third = png(&dir, "third.png", 8, 6, "#0000ff"); + let out = join(&dir, "out"); + let first_out = join(&out, "first.png"); + let second_out = join(&out, "second.png"); + let third_out = join(&out, "third.png"); + let ctx = crate::jobs::JobCtx::new(); + let worker_ctx = ctx.clone(); + let (opened_tx, opened_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + super::batch_run::process_batch_outputs( + 72.0, + vec![(first, first_out), (second, second_out), (third, third_out)], + "png", + SaveOpts::default(), + &policy, + &worker_ctx, + &|_, path| { + if path.ends_with("second.png") { + let _ = opened_tx.send(()); + let _ = resume_rx.recv_timeout(Duration::from_secs(10)); + } + Ok(false) + }, + ) + }); + opened_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(ctx.request_batch_interrupt()); + resume_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(ctx.batch_waiting_for_decision()); + assert!(ctx.decide_batch_interrupt(false)); + let result = worker.join().unwrap().unwrap(); + assert_eq!(result["files"], json!([join(&out, "first.png")]), "{result}"); + assert_eq!(result["cancelled"], true, "{result}"); + assert_eq!(result["skipped"], json!([join(&dir, "second.png"), join(&dir, "third.png")]), "{result}"); + assert!(std::path::Path::new(&join(&out, "first.png")).exists()); + assert!(!std::path::Path::new(&join(&out, "second.png")).exists()); + assert!(!std::path::Path::new(&join(&out, "third.png")).exists()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_continue_keeps_errors_before_and_after_the_interruption_in_one_log() { + use std::time::Duration; + + let dir = tmp("batch-interrupt-error-log"); + let second = png(&dir, "second.png", 8, 6, "#00ff00"); + let fourth = png(&dir, "fourth.png", 8, 6, "#0000ff"); + let before = join(&dir, "missing-before.png"); + let after = join(&dir, "missing-after.png"); + let out = join(&dir, "out"); + let log = join(&dir, "errors.txt"); + let ctx = crate::jobs::JobCtx::new(); + let decider_ctx = ctx.clone(); + let decider = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + if !decider_ctx.batch_waiting_for_decision() { + decider_ctx.cancel(); + return false; + } + decider_ctx.decide_batch_interrupt(true) + }); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: Some(log.clone()) }; + let result = super::batch_run::process_batch_outputs( + 72.0, + vec![ + (before.clone(), join(&out, "missing-before.png")), + (second.clone(), join(&out, "second.png")), + (after.clone(), join(&out, "missing-after.png")), + (fourth, join(&out, "fourth.png")), + ], + "png", + SaveOpts::default(), + &policy, + &ctx, + &|_, path| { + if path.ends_with("second.png") { + assert!(ctx.request_batch_interrupt()); + } + Ok(false) + }, + ) + .unwrap(); + assert!(decider.join().unwrap()); + assert_eq!(result["errors"].as_array().map(Vec::len), Some(2), "{result}"); + assert_eq!(result["skipped"], json!([second]), "{result}"); + assert_eq!(result["files"], json!([join(&out, "fourth.png")]), "{result}"); + let text = batch_log(&log); + assert!(text.contains("missing-before.png") && text.contains("missing-after.png"), "{text}"); + assert!(!text.contains("second.png"), "a skipped input is not an error: {text}"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_continue_between_inputs_still_processes_the_next_input() { + use std::time::Duration; + + let dir = tmp("batch-interrupt-between-inputs"); + let input = png(&dir, "next.png", 8, 6, "#ff0000"); + let output = join(&dir, "out/next.png"); + let ctx = crate::jobs::JobCtx::new(); + assert!(ctx.request_batch_interrupt()); + let decider_ctx = ctx.clone(); + let decider = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + if !decider_ctx.batch_waiting_for_decision() { + decider_ctx.cancel(); + return false; + } + decider_ctx.decide_batch_interrupt(true) + }); + let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None }; + let result = + super::batch_run::process_batch_outputs(72.0, vec![(input, output.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| Ok(false)).unwrap(); + assert!(decider.join().unwrap()); + assert_eq!(result["files"], json!([output]), "the unopened input is still next: {result}"); + assert!(result.get("skipped").is_none(), "{result}"); +} + +#[test] +fn batch_error_log_cannot_replace_an_input_or_result() { + let dir = tmp("batch-error-log-collision"); + let input = png(&dir, "source.png", 8, 6, "#ff0000"); + let original = std::fs::read(&input).unwrap(); + let output = join(&dir, "out"); + for log in [input.clone(), join(&output, "source.png"), join(&output, "./source.png"), join(&output, "../out/source.png")] { + let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err(); + assert!(error.to_string().contains("errorLog"), "{error}"); + assert_eq!(std::fs::read(&input).unwrap(), original); + assert!(!std::path::Path::new(&join(&output, "source.png")).exists()); + } +} + +#[test] +fn batch_naming_rejects_an_input_alias_through_a_missing_parent() { + let dir = tmp("batch-missing-parent-alias"); + let first = png(&dir, "a.png", 8, 6, "#ff0000"); + let second = png(&dir, "b.png", 8, 6, "#00ff00"); + let original = std::fs::read(&second).unwrap(); + let output = join(&dir, "missing/.."); + let error = + Session::new().execute("file.automate.batch", json!({"steps": [], "input": [first, second], "output": output, "naming": "b.{ext}"})).unwrap_err(); + assert!(error.to_string().contains("replace an input"), "{error}"); + assert_eq!(std::fs::read(&second).unwrap(), original); + assert!(!std::path::Path::new(&join(&dir, "missing")).exists()); +} + +#[test] +fn flat_batch_cannot_replace_a_later_unread_input() { + let dir = tmp("batch-flat-input-alias"); + let input_png = png(&dir, "a.png", 8, 6, "#0000ff"); + let mut s = session(8, 6, 8); + save_doc(doc(&s), &join(&dir, "a.jpg"), None).unwrap(); + let before = std::fs::read(&input_png).unwrap(); + let result = s.execute("file.automate.batch", json!({"steps": [], "input": dir, "output": dir, "format": "png"})); + assert!(result.as_ref().is_err_and(|e| e.to_string().contains("input")), "{result:?}"); + assert_eq!(std::fs::read(&input_png).unwrap(), before); +} + +#[cfg(unix)] +#[test] +fn batch_error_log_rejects_a_symlink_parent_alias() { + use std::os::unix::fs::symlink; + let dir = tmp("batch-symlink-parent-alias"); + let source_dir = join(&dir, "in"); + std::fs::create_dir_all(&source_dir).unwrap(); + let input = png(&source_dir, "result.png", 8, 6, "#ff0000"); + let other = join(&dir, "other"); + std::fs::create_dir_all(join(&other, "child")).unwrap(); + symlink(join(&other, "child"), join(&dir, "link")).unwrap(); + let output = other; + let log = join(&dir, "link/../result.png"); + let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err(); + assert!(error.to_string().contains("errorLog"), "{error}"); + assert!(!std::path::Path::new(&join(&output, "result.png")).exists()); +} + +#[test] +fn batch_error_log_rejects_an_uncreated_output_alias() { + let dir = tmp("batch-error-log-future-alias"); + let input = png(&dir, "source.png", 8, 6, "#ff0000"); + let output = join(&dir, "new-output"); + std::fs::create_dir_all(join(&output, "alias")).unwrap(); + let log = join(&output, "alias/../source.png"); + assert!(same_destination(&log, join(&output, "source.png"))); + let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err(); + assert!(error.to_string().contains("errorLog"), "{error}"); + assert!(!std::path::Path::new(&join(&dir, "new-output/source.png")).exists()); +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_distinct_case_destinations_are_not_the_same_file() { + let dir = tmp("batch-case-sensitive-destinations"); + assert!(!same_destination(join(&dir, "Image.png"), join(&dir, "image.png"))); +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +#[test] +fn unicode_case_variants_follow_destination_volume() { + let dir = tmp("batch-unicode-case-destinations"); + let existing = join(&dir, "Ä-probe"); + std::fs::write(&existing, b"probe").unwrap(); + let volume_aliases_case = same_file(&existing, join(&dir, "ä-probe")); + assert_eq!(same_destination(join(&dir, "Ä.png"), join(&dir, "ä.png")), volume_aliases_case); +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +#[test] +fn future_folder_case_variants_follow_the_nearest_existing_ancestor() { + let dir = tmp("batch-future-folder-case"); + let existing = join(&dir, "Ä-probe"); + std::fs::write(&existing, b"probe").unwrap(); + let volume_aliases_case = same_file(&existing, join(&dir, "ä-probe")); + let first = join(&dir, "new/nested/Image.png"); + let second = join(&dir, "new/nested/image.png"); + assert_eq!(super::paths::case_sensitive_destination(std::path::Path::new(&first)), Some(!volume_aliases_case)); + assert_eq!(same_destination(first, second), volume_aliases_case); + assert!(!std::path::Path::new(&join(&dir, "new")).exists()); +} + +#[test] +fn batch_open_source_edits_each_live_document_and_restores_the_active_one() { + let dir = tmp("batch-open-documents"); + let mut session = Session::new(); + session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap(); + session.execute("file.new", json!({"name": "Second", "width": 12, "height": 5})).unwrap(); + session.set_active(0); + let result = session + .execute( + "file.automate.batch", + json!({"source": "open", "steps": [["image.imageRotation.90cw", {}]], "output": dir, "format": "png", "naming": "{serial:2}_{NAME}.{EXT}"}), + ) + .unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert_eq!(result["documents"], 2); + assert_eq!(result["files"].as_array().map(Vec::len), Some(2), "{result}"); + assert_eq!(session.active_index(), Some(0)); + assert_eq!((session.documents()[0].doc.size.width, session.documents()[0].doc.size.height), (6, 8)); + assert_eq!((session.documents()[1].doc.size.width, session.documents()[1].doc.size.height), (5, 12)); + assert!(std::path::Path::new(&join(&dir, "01_FIRST.PNG")).is_file()); + assert!(std::path::Path::new(&join(&dir, "02_SECOND.PNG")).is_file()); +} + +#[test] +fn batch_open_source_exports_later_edits_if_saved_document_stays_active() { + let dir = tmp("batch-open-save-point"); + let mut session = Session::new(); + session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap(); + session.execute("file.new", json!({"name": "Second", "width": 12, "height": 5})).unwrap(); + let result = session + .execute( + "file.automate.batch", + json!({ + "source": "open", "steps": [["document.activate", {"document": 1}], ["file.save", {}], ["image.imageRotation.90cw", {}]], + "output": dir, "format": "png", "naming": "{name}.{ext}" + }), + ) + .unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let mut opened = Session::new(); + opened.execute("file.openAs", json!({"path": join(&dir, "First.png"), "as": "png"})).unwrap(); + assert_eq!((doc(&opened).size.width, doc(&opened).size.height), (5, 12), "the still-active saved document includes later edits"); +} + +#[test] +fn batch_open_source_save_as_then_close_ask_writes_the_result() { + let dir = tmp("batch-open-save-close-ask"); + let mut session = Session::new(); + session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap(); + let result = session + .execute( + "file.automate.batch", + json!({"source": "open", "steps": [["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]], "output": dir, "format": "png"}), + ) + .unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(result["files"], json!([join(&dir, "First.png")]), "{result}"); + assert!(session.documents().is_empty()); +} + +#[test] +fn batch_open_source_refuses_a_result_that_replaces_another_open_file() { + let dir = tmp("batch-open-replaces-input"); + let mut session = Session::new(); + // Saved as PNG under its default name, `a.jpg`'s result is the open `a.png`'s file. + for (name, format) in [("a.jpg", "jpg"), ("a.png", "png")] { + let path = png(&dir, name, 8, 6, "#ff0000"); + session.execute("file.openAs", json!({"path": path, "as": format})).unwrap(); + } + let before = std::fs::read(join(&dir, "a.png")).unwrap(); + let error = session + .execute("file.automate.batch", json!({"source": "open", "steps": [["image.imageRotation.90cw", {}]], "output": dir, "format": "png"})) + .unwrap_err(); + assert!(error.to_string().contains("replace another input"), "{error}"); + assert_eq!(std::fs::read(join(&dir, "a.png")).unwrap(), before, "nothing is written before the refusal"); +} + +#[test] +fn batch_open_source_save_step_leaves_an_unwritten_document_changed() { + let mut session = Session::new(); + session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap(); + let steps = json!([["image.adjustments.invert", {}], ["file.save", {}]]); + // No destination: nothing is written. + let result = session.execute("file.automate.batch", json!({"source": "open", "steps": steps})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert!(session.documents()[0].is_dirty(), "a document Batch did not write stays changed"); + // A destination that can't be written: the result's name is taken by a folder, so the path + // resolves on every platform and the write itself fails. + let dir = tmp("batch-open-save-fails"); + std::fs::create_dir_all(join(&dir, "First.png")).unwrap(); + let result = session.execute("file.automate.batch", json!({"source": "open", "steps": steps, "output": dir, "format": "png"})).unwrap(); + assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}"); + assert!(session.documents()[0].is_dirty(), "a failed write leaves the document changed"); +} + +#[cfg(unix)] +#[test] +fn batch_error_log_rejects_a_dangling_link_to_a_planned_output() { + use std::os::unix::fs::symlink; + let dir = tmp("batch-dangling-error-log-alias"); + let input = png(&dir, "source.png", 8, 6, "#ff0000"); + let output = join(&dir, "out"); + let result = join(&output, "source.png"); + let log = join(&dir, "errors.txt"); + symlink(&result, &log).unwrap(); + let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err(); + assert!(error.to_string().contains("errorLog"), "{error}"); + assert!(!std::path::Path::new(&result).exists(), "the batch must not write before rejecting the alias"); +} + +#[test] +fn batch_reports_file_failures_even_if_error_log_cannot_be_written() { + let dir = tmp("batch-error-log-write-fails"); + let mut session = session(8, 6, 8); + let result = session + .execute( + "file.automate.batch", + json!({ + "source": "open", "steps": [["layer.select", {"layer": "does not exist"}]], + "output": dir, "format": "png", "errorLog": dir + }), + ) + .unwrap(); + assert_eq!(result["files"], json!([])); + assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}"); + assert!(result["errorLogError"].as_str().is_some(), "{result}"); +} + +#[cfg(unix)] +#[test] +fn recursive_batch_reports_an_unreadable_subfolder() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("batch-unreadable-subfolder"); + let sub = join(&dir, "locked"); + std::fs::create_dir_all(&sub).unwrap(); + png(&sub, "image.png", 8, 6, "#ff0000"); + std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o000)).unwrap(); + let inaccessible = std::fs::read_dir(&sub).is_err(); + let result = list_images_tree(&dir, "", "file.automate.batch"); + std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap(); + if inaccessible { + assert!(result.is_err(), "an inaccessible input folder must not be omitted"); + } else { + eprintln!("permission regression skipped: this user can read a mode-000 folder"); + assert!(result.is_ok(), "a privileged reader should still process the folder"); + } +} + +#[cfg(unix)] +#[test] +fn folder_listing_reports_metadata_denial_and_skips_broken_links() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tmp("batch-list-metadata-denial"); + let image = png(&dir, "image.png", 8, 6, "#ff0000"); + symlink(join(&dir, "gone.png"), join(&dir, "broken.png")).unwrap(); + assert_eq!(list_images(&dir).unwrap(), std::slice::from_ref(&image), "broken links are not inputs"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).unwrap(); + let denied = std::fs::metadata(&image).is_err(); + let result = list_images(&dir); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + if denied { + assert!(result.is_err(), "metadata denial must not turn a folder of images into an empty batch"); + } else { + eprintln!("metadata denial regression skipped: this user can search a mode-0400 folder"); + assert_eq!(result.unwrap(), [image]); + } +} + #[test] fn close_all_and_close_others() { let mut s = session(8, 8, 8); @@ -86,7 +702,8 @@ fn batch_same_format_saves_camera_raws_as_psd() { let mut files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); files.sort(); assert_eq!(files.len(), 2, "{r}"); - assert!(files[0].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".png"), "{files:?}"); + let suffixes = if naming.is_some() { ("_001", "_002") } else { (".psd", ".png") }; + assert!(files[0].contains("/IMG_0001") && files[0].ends_with(suffixes.0) && files[1].contains("/b") && files[1].ends_with(suffixes.1), "{files:?}"); let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document; assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32)); } @@ -456,6 +1073,37 @@ fn maximize_compatibility_preference_controls_the_psd_merged_image() { } } +/// Preferences › File Handling › Save Full-Size Composite in .pcraft Files decides whether a +/// `.pcraft` save writes `composite/full.png` (off by default); a command's +/// `"pcraftFullComposite"` overrides it. +#[test] +fn full_composite_preference_controls_the_pcraft_full_size_composite() { + let dir = tmp("full-composite"); + let mut s = session(300, 200, 16); + let full = |path: &str| photocraft_format::read_full_composite(&std::fs::read(path).unwrap()).unwrap().is_some(); + let out = join(&dir, "default.pcraft"); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert!(!full(&out), "off by default"); + for on in [true, false] { + s.edit_prefs(|p| p.file_handling.pcraft_full_composite = on); + let out = join(&dir, &format!("{on}.pcraft")); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert_eq!(full(&out), on, "preference {on}"); + let out = join(&dir, &format!("{on} param.pcraft")); + s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": !on})).unwrap(); + assert_eq!(full(&out), !on, "preference {on}, param {}", !on); + } + let out = join(&dir, "null param.pcraft"); + s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": null})).unwrap(); + assert!(!full(&out), "null takes the preference"); + for bad in [json!("true"), json!(1)] { + let out = join(&dir, "bad param.pcraft"); + let err = s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": bad})).unwrap_err().to_string(); + assert!(err.contains("`pcraftFullComposite` must be true or false"), "{bad}: {err}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing is written"); + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); @@ -1159,6 +1807,61 @@ fn layers_to_files_writes_each_visible_layer() { assert_eq!(r["files"].as_array().unwrap().len(), 3); } +#[test] +fn layers_to_files_can_trim_transparent_margins_without_changing_the_document() { + let dir = tmp("layers-trim"); + for depth in [8, 16, 32] { + let mut s = session(12, 11, depth); + s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 2, "width": 4, "height": 5})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + + let before = doc(&s).clone(); + let regular = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": format!("full-{depth}"), "format": "png"})).unwrap(); + let full = std::fs::read(regular["files"][0].as_str().unwrap()).unwrap(); + let full = photocraft_io::import("full.png", &full).unwrap().document; + assert_eq!((full.size.width, full.size.height), (12, 11), "default is unchanged"); + + let trimmed = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": format!("trim-{depth}"), "format": "png", "trim": true})).unwrap(); + let image = std::fs::read(trimmed["files"][0].as_str().unwrap()).unwrap(); + let cropped = photocraft_io::import("trim.png", &image).unwrap().document; + assert_eq!((cropped.size.width, cropped.size.height), (4, 5), "depth {depth}"); + let px = photocraft_compose::render(&cropped, Rect::new(0, 0, 1, 1)).px[0]; + assert!(px[0] > 0.95 && px[1] < 0.05 && px[2] < 0.05, "depth {depth}: {px:?}"); + assert_eq!(*doc(&s), before, "export must not crop the original document"); + } +} + +#[test] +fn streamed_layer_export_respects_trim() { + let mut s = session(12, 11, 8); + s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 2, "width": 4, "height": 5})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let mut encoded = Vec::new(); + let names = super::export_layers_with(&s, &json!({"trim": true, "format": "png"}), |name, bytes| { + encoded.push((name.to_string(), bytes.to_vec())); + Ok(()) + }) + .unwrap(); + assert_eq!(names.len(), encoded.len()); + let cropped = photocraft_io::import(&encoded[0].0, &encoded[0].1).unwrap().document; + assert_eq!((cropped.size.width, cropped.size.height), (4, 5)); +} + +#[test] +fn trimming_an_empty_layer_keeps_a_valid_nonzero_canvas() { + let dir = tmp("layers-trim-empty"); + let mut s = session(6, 7, 8); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let result = s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "png", "trim": true})).unwrap(); + let bytes = std::fs::read(result["files"][0].as_str().unwrap()).unwrap(); + let back = photocraft_io::import("empty.png", &bytes).unwrap().document; + assert_eq!((back.size.width, back.size.height), (6, 7)); +} + #[test] fn load_files_into_stack_and_image_processor_and_batch() { let dir = tmp("batch"); @@ -1441,16 +2144,16 @@ fn batch_naming_serial_keeps_same_named_inputs_and_their_pixels() { ) .unwrap(); assert_eq!(result["errors"], json!([]), "{result}"); - assert_eq!(result["files"], json!([join(&out, "photo_007.png"), join(&out, "photo_008.png")])); - for (name, expected) in [("photo_007.png", [1.0, 0.0, 0.0, 1.0]), ("photo_008.png", [0.0, 0.0, 1.0, 1.0])] { - let saved = photocraft_io::import(name, &std::fs::read(join(&out, name)).unwrap()).unwrap().document; + assert_eq!(result["files"], json!([join(&out, "photo_007"), join(&out, "photo_008")])); + for (name, expected) in [("photo_007", [1.0, 0.0, 0.0, 1.0]), ("photo_008", [0.0, 0.0, 1.0, 1.0])] { + let saved = photocraft_io::import("photo.png", &std::fs::read(join(&out, name)).unwrap()).unwrap().document; let pixel = photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0]; assert_eq!(pixel, expected, "{name}"); } } -/// Two inputs with one output name: the first result is kept and the second is an error, never -/// a silent overwrite listed as written (#422). +/// Batch follows Photoshop's later-file-wins behavior. Image Processor and lens correction +/// keep their existing collision error so their outputs are never silently replaced. #[test] fn batch_reports_inputs_that_share_an_output_name() { let dir = tmp("collide"); @@ -1474,12 +2177,16 @@ fn batch_reports_inputs_that_share_an_output_name() { let r = s.execute(cmd, p).unwrap(); let files = r["files"].as_array().unwrap(); let errors = r["errors"].as_array().unwrap(); - // `a.*` and `B.png`/`b.tif` (names that differ only in case) each collide once. - assert_eq!((files.len(), errors.len()), (2, 2), "{cmd}: {r}"); - assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("not written")), "{r}"); - let mut written: Vec<_> = files.iter().map(|f| f.as_str().unwrap().to_lowercase()).collect(); - written.dedup(); - assert_eq!(written.len(), 2, "each listed file is distinct: {r}"); + if cmd == "file.automate.batch" { + assert_eq!((files.len(), errors.len()), (4, 0), "{cmd}: {r}"); + assert_eq!(files[1], files[2], "the later a.png result replaces a.jpg: {r}"); + } else { + assert_eq!((files.len(), errors.len()), (2, 2), "{cmd}: {r}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("not written")), "{r}"); + let mut written: Vec<_> = files.iter().map(|f| f.as_str().unwrap().to_lowercase()).collect(); + written.dedup(); + assert_eq!(written.len(), 2, "each listed file is distinct: {r}"); + } } // Keeping each file's format, nothing collides. let out = join(&dir, "same"); @@ -1493,10 +2200,10 @@ fn batch_reports_inputs_that_share_an_output_name() { std::fs::write(&droplet, json!({"photocraftDroplet": 1, "name": "d", "action": {"steps": []}, "options": {"format": "png"}}).to_string()).unwrap(); let out = join(&dir, "droplet"); let r = s.execute("file.automate.runDroplet", json!({"droplet": droplet, "input": [join(&input, "a.png"), other], "output": out})).unwrap(); - assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 1), "{r}"); + assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (2, 0), "{r}"); let kept = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document; let px = photocraft_compose::render(&kept, Rect::new(1, 1, 2, 2)).px[0]; - assert!(px[0] > 0.99 && px[1] < 0.01, "the first input's result is kept: {px:?}"); + assert!(px[0] > 0.99 && px[1] > 0.99, "the later input's result replaces the first: {px:?}"); } /// `file.revealInFinder` (the document tab's Reveal, UI-217-6) reveals the clicked document's @@ -1554,7 +2261,7 @@ fn batch_include_all_subfolders() { let out = join(&input, "batch"); let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap(); assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}"); - // With subfolders: every image, in folder order, results kept apart by subfolder; the output + // With subfolders: every image, in folder order, results saved flat; the output // folder (inside the input folder, holding last run's a.png) is not an input. let r = s .execute( @@ -1565,12 +2272,12 @@ fn batch_include_all_subfolders() { assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); let o = out.replace('\\', "/"); - assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]); - let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document; - let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0]; - assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}"); + assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/b.png"), format!("{o}/a.png"), format!("{o}/a.png")]); + let last = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document; + let px = photocraft_compose::render(&last, Rect::new(1, 1, 2, 2)).px[0]; + assert!(px[0] < 0.01 && px[1] < 0.01 && px[2] < 0.01, "the last white input is inverted to black: {px:?}"); std::fs::remove_dir_all(&out).unwrap(); - // A naming template applies within each kept subfolder (output outside the input folder). + // A naming template applies across all inputs, with serials distinguishing the flat outputs. let named = join(&dir, "named"); let r = s .execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"})) @@ -1578,7 +2285,7 @@ fn batch_include_all_subfolders() { assert!(r["errors"].as_array().unwrap().is_empty(), "{r}"); let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect(); let n = named.replace('\\', "/"); - assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]); + assert_eq!(files, vec![format!("{n}/001"), format!("{n}/002"), format!("{n}/003"), format!("{n}/004")]); // Image Processor shares the option (with its results out of the input folder). let out = join(&dir, "jpg"); let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap(); @@ -1691,13 +2398,13 @@ fn batch_naming_keeps_serial_positions_after_errors_and_captures_the_utc_date() assert_eq!(files.len(), 1, "{result}"); let name = file_name(files[0].as_str().unwrap()); assert!( - name == format!("{}_010_good.png", &before[..10]) || name == format!("{}_010_good.png", &after[..10]), + name == format!("{}_010_good", &before[..10]) || name == format!("{}_010_good", &after[..10]), "the missing input still consumes its serial: {name}" ); } #[test] -fn batch_naming_still_reports_collisions_instead_of_overwriting() { +fn batch_naming_later_result_replaces_the_earlier_one() { let dir = tmp("naming-collision"); let red = png(&dir, "red.png", 8, 8, "#ff0000"); let blue = png(&dir, "blue.png", 8, 8, "#0000ff"); @@ -1711,11 +2418,10 @@ fn batch_naming_still_reports_collisions_instead_of_overwriting() { }), ) .unwrap(); - assert_eq!(result["files"], json!([join(&out, "constant.png")])); - assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); - assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written")); - let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant.png")).unwrap()).unwrap().document; - assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [1.0, 0.0, 0.0, 1.0]); + assert_eq!(result["files"], json!([join(&out, "constant"), join(&out, "constant")])); + assert_eq!(result["errors"], json!([]), "{result}"); + let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 0.0, 1.0, 1.0]); } #[test] fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { @@ -1730,7 +2436,7 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { .execute( "file.automate.batch", json!({ - "input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}" + "input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}.{ext}" }), ) .unwrap_err(); @@ -1773,6 +2479,28 @@ fn batch_saves_at_the_recorded_save_so_a_close_after_it_keeps_the_result() { assert!(r["errors"][0]["error"].as_str().unwrap().contains("no active document"), "{r}"); } +/// "…; Save As; Close" with the Close recorded on a clean document (no `Svng`, `"save":"ask"`): +/// Batch performs the Save As, so the document is saved when the Close asks, and every file is +/// written. The Close used to refuse the still-changed scratch document and fail each file. +#[test] +fn batch_save_as_then_close_that_asks_processes_every_file() { + let dir = tmp("batch-save-close-ask"); + let input = vec![png(&dir, "red.png", 8, 8, "#ff0000"), png(&dir, "blue.png", 8, 8, "#0000ff")]; + let out = join(&dir, "out"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]]); + let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + let red = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&red, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0], "the inverted red"); + // A Save a Copy doesn't save the document itself: the Close that asks still refuses it. + let out = join(&dir, "copy"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveACopy", {}], ["file.close", {"save": "ask"}]]); + let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["files"], json!([]), "{r}"); + assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{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. @@ -1918,6 +2646,36 @@ fn save_a_copy_writes_the_tiff_compression_asked_for() { } } +/// #8827: `tiffPyramid` writes reduced-resolution levels after the image and `tiffTransparency` +/// false composites a transparent document over white. Bad values are refused before anything is +/// written. +#[test] +fn save_a_copy_writes_the_tiff_pyramid_and_transparency_asked_for() { + let dir = tmp("tiff-options"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let out = join(&dir, "p.tif"); + s.execute("file.saveACopy", json!({"path": out, "tiffPyramid": true})).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + let sizes: Vec<(u32, u32)> = photocraft_codecs::tiff_info(&bytes).unwrap().pages.iter().map(|p| (p.width, p.height)).collect(); + assert_eq!(sizes, [(200, 100), (100, 50), (50, 25)]); + for (t, alpha) in [(None, true), (Some(true), true), (Some(false), false)] { + let mut p = json!({"path": out}); + if let Some(t) = t { + p["tiffTransparency"] = json!(t); + } + s.execute("file.saveACopy", p).unwrap(); + let img = photocraft_codecs::decode(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(img.layout().has_alpha(), alpha, "{t:?}"); + } + for (key, bad) in [("tiffPyramid", json!("yes")), ("tiffPyramid", json!(1)), ("tiffTransparency", json!("off")), ("tiffTransparency", json!([]))] { + let out = join(&dir, "bad.tif"); + let r = s.execute("file.saveACopy", json!({"path": out, key: bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{key} {bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{key} {bad}: nothing written"); + } +} + #[test] fn sanitize_keeps_windows_device_names_off_the_disk() { assert_eq!(sanitize("nul"), "nul_"); @@ -1934,3 +2692,323 @@ fn sanitize_keeps_windows_device_names_off_the_disk() { assert_eq!(sanitize(name), name); } } + +#[test] +fn approve_memory_validates_parameters_and_unknown_ids() { + let mut s = session(4, 4, 8); + assert!(matches!(s.execute("file.approveMemory", json!({})), Err(EngineError::BadParams { .. }))); + assert!(matches!(s.execute("file.approveMemory", json!({"id": "not_a_number"})), Err(EngineError::BadParams { .. }))); + assert!(matches!(s.execute("file.approveMemory", json!({"id": 12345})), Err(EngineError::BadParams { .. }))); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "approve": false})), Err(EngineError::BadParams { .. })), + "unknown id on cancel must fail" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "approve": "true"})), Err(EngineError::BadParams { .. })), + "wrong approve type string must BadParams" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "approve": 1})), Err(EngineError::BadParams { .. })), + "wrong approve type int must BadParams" + ); + assert!( + matches!(s.execute("file.openAs", json!({"path": "a.psd", "approveHighMemory": "true"})), Err(EngineError::BadParams { .. })), + "wrong approveHighMemory on openAs must BadParams" + ); + assert!( + matches!(s.execute("file.placeEmbedded", json!({"path": "a.psd", "approveHighMemory": 1})), Err(EngineError::BadParams { .. })), + "wrong approveHighMemory on placeEmbedded must BadParams" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "background": "true"})), Err(EngineError::BadParams { .. })), + "wrong background type string must BadParams" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "background": 1})), Err(EngineError::BadParams { .. })), + "wrong background type int must BadParams" + ); +} + +#[test] +fn approve_memory_cancels_and_removes_pending_preflight() { + let mut s = Session::new(); + let id = s + .store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "test.psd".into(), + source: crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![1, 2, 3])), + fingerprint: crate::jobs::SourceFingerprint::of(&crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![1, 2, 3]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + let res = s.execute("file.approveMemory", json!({"id": id, "approve": false})).unwrap(); + assert_eq!(res["approved"], false); + assert_eq!(res["cancelled"], true); + assert!(s.take_pending_preflight(id).is_none(), "preflight removed on cancel"); +} + +#[test] +fn approve_memory_refuses_when_source_changes_before_confirmation() { + let dir = tmp("fingerprint_test"); + let path = join(&dir, "change.psd"); + std::fs::write(&path, b"initial content").unwrap(); + + let mut s = Session::new(); + let src = crate::jobs::OpenSource::Path(path.clone()); + let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); + let id = s + .store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "change.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + // Modify file on disk + std::fs::write(&path, b"changed content longer").unwrap(); + + let res = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})); + assert!(matches!(res, Err(EngineError::SourceChanged { .. })), "changed file must fail identity verification: {res:?}"); +} + +#[test] +fn approve_memory_refuses_when_available_ram_decreases() { + let gb = 1u64 << 30; + let mut s = Session::new(); + let src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])); + let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); + let id = s + .store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "ram_drop.psd".into(), + source: src, + fingerprint: fp, + required: 8 * gb, + soft_limit: 6 * gb, + hard_limit: 9 * gb, + available_ram: Some(12 * gb), + confirm_damage: true, + }) + .unwrap(); + + // Memory drops: only 6 GiB available -> H = 4.5 GiB < 8 GiB + let snap_low = photocraft_io::memory::MemorySnapshot { available_ram: Some(6 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snap_low, || { + let res = s.execute("file.approveMemory", json!({"id": id, "approve": true})); + assert!(res.is_err(), "decreased memory must refuse"); + }); +} + +#[test] +fn pending_preflights_cap_fails_closed() { + let mut s = Session::new(); + let src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])); + let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); + for i in 0..crate::Session::MAX_PENDING_PREFLIGHTS { + assert!( + s.store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: format!("file_{i}.psd"), + source: src.clone(), + fingerprint: fp.clone(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .is_ok() + ); + } + assert!( + s.store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "overflow.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .is_err() + ); +} + +/// A queued high-memory preflight for `source`, with fields a test can read. +fn memory_preflight(name: &str, source: crate::jobs::OpenSource) -> crate::jobs::PendingPsdPreflight { + let fingerprint = crate::jobs::SourceFingerprint::of(&source).unwrap(); + crate::jobs::PendingPsdPreflight { + id: 0, + name: name.into(), + source, + fingerprint, + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + } +} + +#[test] +fn pending_preflights_bound_retained_bytes_and_charge_a_shared_buffer_once() { + let mut s = Session::new(); + let cap = 100; + let a_src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 40])); + let b_src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 40])); + let a_id = s.store_pending_preflight_with_limit(memory_preflight("a.psd", a_src.clone()), cap).unwrap(); + let b_id = s.store_pending_preflight_with_limit(memory_preflight("b.psd", b_src), cap).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80); + // A second reference to the same allocation is already held: queued for free. + let a_again = s.store_pending_preflight_with_limit(memory_preflight("a-again.psd", a_src), cap).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80, "one shared buffer is charged once, not three times"); + // A different buffer would carry the total over the cap: refused, and nothing is charged. + let c_src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 30])); + let err = s.store_pending_preflight_with_limit(memory_preflight("c.psd", c_src.clone()), cap).expect_err("a further 30 bytes must not fit beside 80"); + assert!(err.to_string().contains("waiting for a memory approval"), "{err}"); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80, "a refused preflight adds no charge"); + // Cancelling one released its buffer; the refused one now fits; clearing releases the rest. + assert!(s.cancel_pending_preflight(a_again).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80, "the other live reference still holds the buffer"); + assert!(s.cancel_pending_preflight(a_id).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), 40); + let c_id = s.store_pending_preflight_with_limit(memory_preflight("c.psd", c_src), cap).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 70); + s.clear_pending_preflights(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "clear releases every held buffer"); + assert!(s.cancel_pending_preflight(b_id).is_none()); + assert!(s.cancel_pending_preflight(c_id).is_none()); +} + +#[test] +fn pending_preflights_charge_the_retained_allocation_not_the_visible_length() { + let mut s = Session::new(); + let cap = 100; + // Room for 128 bytes but one byte of contents: what stays resident is the allocation, so a + // cap that fits the contents must still refuse it. + let mut grown = Vec::with_capacity(128); + grown.push(7u8); + let held = grown.capacity() as u64; + assert!(held >= 128, "the buffer really has the larger allocation: {held}"); + let big = std::sync::Arc::new(grown); + let err = s + .store_pending_preflight_with_limit(memory_preflight("big.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::clone(&big))), cap) + .expect_err("the held capacity, not the one byte of contents, must be charged"); + assert!(err.to_string().contains("waiting for a memory approval"), "{err}"); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "a refused preflight adds no charge"); + + // A cap that admits the whole allocation accepts it, and shared clones count once. + let id = s.store_pending_preflight_with_limit(memory_preflight("big.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::clone(&big))), held).unwrap(); + let shared = + s.store_pending_preflight_with_limit(memory_preflight("shared.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::clone(&big))), held).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), held, "one shared allocation is charged once, whatever its length"); + assert!(s.cancel_pending_preflight(shared).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), held, "the other shared reference still holds the allocation"); + assert!(s.cancel_pending_preflight(id).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "cancelling the last reference releases the allocation"); + s.clear_pending_preflights(); + + // A buffer whose length was shrunk to zero still holds its allocation: the held capacity is + // measured, so it is charged even though `len()` says nothing is there. + let mut emptied = Vec::with_capacity(128); + emptied.push(1u8); + emptied.clear(); + assert!(emptied.is_empty(), "the length is zero while the allocation stays"); + let emptied = std::sync::Arc::new(emptied); + let emptied_held = emptied.capacity() as u64; + assert!(emptied_held >= 128, "clearing does not release the allocation: {emptied_held}"); + let emptied_id = s + .store_pending_preflight_with_limit(memory_preflight("emptied.psd", crate::jobs::OpenSource::Bytes(emptied)), emptied_held) + .expect("the allocation fits the cap that admits it"); + assert_eq!(s.pending_preflight_bytes().unwrap(), emptied_held, "an empty length is not zero bytes held"); + s.clear_pending_preflights(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "clear releases every held allocation"); + assert!(s.cancel_pending_preflight(emptied_id).is_none()); +} + +#[test] +fn approve_memory_is_neither_journaled_nor_recorded_in_an_action() { + let mut s = Session::new(); + let id = s.store_pending_preflight(memory_preflight("cancel.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 4])))).unwrap(); + s.execute("actions.record", json!({"name": "Memory approvals"})).unwrap(); + let r = s.execute("file.approveMemory", json!({"id": id, "approve": false})).unwrap(); + assert_eq!((r["approved"].clone(), r["cancelled"].clone()), (json!(false), json!(true))); + s.execute("actions.stop", json!({})).unwrap(); + assert!(s.journal.iter().all(|(command, _)| command != "file.approveMemory"), "one-shot approval must not become a journal entry: {:?}", s.journal); + let action = s.actions.list.first().expect("the recorded action"); + assert!(action.steps.iter().all(|(command, _)| command != "file.approveMemory"), "one-shot approval must not replay from an action: {:?}", action.steps); +} + +#[test] +fn approve_memory_releases_the_preflight_when_the_hard_limit_refuses_it() { + let gb = 1u64 << 30; + let mut s = Session::new(); + let preflight = crate::jobs::PendingPsdPreflight { + required: 8 * gb, + soft_limit: 6 * gb, + hard_limit: 9 * gb, + available_ram: Some(12 * gb), + ..memory_preflight("ram_drop.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 16]))) + }; + let id = s.store_pending_preflight(preflight).unwrap(); + let snap_low = photocraft_io::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + let expected_limit = photocraft_io::memory::resolve_budget(snap_low, None).hard_limit; + photocraft_io::memory::with_mock_memory(snap_low, || { + let err = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})).unwrap_err(); + match &err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, "ram_drop.psd"); + assert_eq!((*required, *limit), (8 * gb, expected_limit)); + } + other => panic!("the hard refusal must keep its fields, got {other}"), + } + }); + assert!(s.take_pending_preflight(id).is_none(), "a hard refusal releases the held source"); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0); +} + +#[test] +fn open_as_and_place_keep_the_typed_hard_budget_fields() { + let gb = 1u64 << 30; + let dir = tmp("hard_budget"); + let path = join(&dir, "big.psd"); + let mut src = Session::new(); + src.execute("file.new", json!({"width": 4, "height": 4, "depth": 8})).unwrap(); + std::fs::write(&path, photocraft_io::export(&src.active().unwrap().doc, "x.psd", &Default::default()).unwrap().bytes).unwrap(); + // An estimate over the resolved hard ceiling is refused, and no approval raises the ceiling. + // The estimate charge is a test seam so the fixture stays tiny. + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + let hard = photocraft_io::memory::resolve_budget(snap, None).hard_limit; + assert!(hard < u64::MAX); + photocraft_io::memory::with_mock_memory(snap, || { + photocraft_io::psd_budget::with_test_estimate_charge(hard + 1, || { + let mut s = Session::new(); + let err = s.execute("file.openAs", json!({"path": path.clone(), "approveHighMemory": true})).unwrap_err(); + match &err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, &path); + assert!(*required > hard && *limit == hard); + } + other => panic!("openAs must keep name/required/limit, got {other}"), + } + let mut s = session(8, 8, 8); + let err = s.execute("file.placeEmbedded", json!({"path": path.clone(), "approveHighMemory": true})).unwrap_err(); + match &err { + EngineError::PsdHardBudget { required, limit, .. } => assert!(*required > hard && *limit == hard), + other => panic!("place must keep required/limit, got {other}"), + } + }); + }); +} diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 35d25119a9..88bbafe878 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -5,6 +5,7 @@ //! transparency. The selection (or the whole canvas) is filled, as one history step ("Fill"). //! Every parameter is validated: a bad one is an error, never a panic. +use photocraft_algo::content_aware::FillOptions; use photocraft_color::BlendMode; use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -132,10 +133,16 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("Content-Aware fill works on layer pixels only".into())); } let adapt = flag(p, "colorAdaptation", true)?; + let scale = flag(p, "scaleAdaptation", false)?; + let mirror = flag(p, "mirror", false)?; + let rotate = flag(p, "rotationAdaptation", false)?; let lid = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; let surf = st.doc.layer(lid).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(1); - content_aware(surf, sel, doc_bounds, adapt, seed)? + // The native Boolean enables quarter-turn candidates, within the existing mosaic cap. + let rotations = if rotate { photocraft_algo::content_aware::rotation_level("full") } else { Vec::new() }; + let options = FillOptions { rotations, scale, mirror, ..content_aware_options(adapt, seed) }; + content_aware(surf, sel, doc_bounds, &options)? } _ => return Err(bad(format!("unknown contents `{contents}`"))), }; @@ -225,13 +232,13 @@ pub(crate) fn blend_color_mask( /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight /// RGBA over the sampling window. Only the selected pixels are used. -fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result { +fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, options: &FillOptions) -> Result { let hole_bounds = sel.content_bounds().intersect(&canvas); if hole_bounds.is_empty() { return Err(EngineError::Other("the selection is outside the canvas".into())); } let is_hole = |x: i32, y: i32| sel.sample_channel(x, y, 0) > 0.0; - let (window, filled) = content_aware_native(surf, hole_bounds, canvas, color_adaptation, seed, is_hole, |_, _| true)?; + let (window, filled) = content_aware_native_with_options(surf, hole_bounds, canvas, options, is_hole, |_, _| true)?; let fmt = surf.format(); let px = filled.chunks_exact(fmt.channels().max(1)).map(|c| to_rgba(&fmt, c)).collect(); Ok(Source::Pixels(window, px)) @@ -251,7 +258,24 @@ pub(crate) fn content_aware_native( is_hole: impl Fn(i32, i32) -> bool, usable: impl Fn(i32, i32) -> bool, ) -> Result<(Rect, Vec)> { - use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; + content_aware_native_with_options(surf, hole_bounds, canvas, &content_aware_options(color_adaptation, seed), is_hole, usable) +} + +/// The adaptation defaults shared by Fill and Content-Aware Crop. +fn content_aware_options(color_adaptation: bool, seed: u64) -> FillOptions { + FillOptions { color_adaptation: photocraft_algo::content_aware::color_level(if color_adaptation { "default" } else { "none" }), seed, ..Default::default() } +} + +/// Fill the bounded sampling window with native channel values and the requested adaptations. +fn content_aware_native_with_options( + surf: &Surface, + hole_bounds: Rect, + canvas: Rect, + options: &FillOptions, + is_hole: impl Fn(i32, i32) -> bool, + usable: impl Fn(i32, i32) -> bool, +) -> Result<(Rect, Vec)> { + use photocraft_algo::content_aware::fill; let window = sampling_window(hole_bounds, canvas); let n = surf.format().channels(); let (w, h) = window_size(window)?; @@ -267,17 +291,10 @@ pub(crate) fn content_aware_native( if !source.iter().any(|s| *s) { return Err(EngineError::Other("Content-Aware fill needs pixels outside the area to fill to sample from".into())); } - let opts = FillOptions { - color_adaptation: color_level(if color_adaptation { "default" } else { "none" }), - rotations: rotation_level("none"), - scale: false, - mirror: false, - seed, - }; if img.len() != len * n { return Err(EngineError::Other("could not read the layer pixels".into())); } - Ok((window, fill(w, h, n, &img, &hole, &source, &opts))) + Ok((window, fill(w, h, n, &img, &hole, &source, options))) } /// Photoshop's Content-Aware sampling window around the selection bounds `hole` @@ -398,7 +415,7 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf .try_clone() .map_err(|e| EngineError::Other(format!("not enough memory while blending a pixel tile ({e}); the document was not changed")))?, }; - blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); + blend_tile(&mut tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, std::sync::Arc::new(tile))); Ok(()) })?; @@ -409,7 +426,8 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf /// The selection's coverage over one tile (its channel 0). pub(crate) struct Mask<'a> { - bytes: &'a [u8], + bytes: Option>, + default: &'a [u8], fmt: photocraft_color::PixelFormat, origin: Rect, } @@ -417,13 +435,14 @@ pub(crate) struct Mask<'a> { impl<'a> Mask<'a> { /// `sel` over the tile at `tc` (its default pixel where it has no tile there). pub(crate) fn of(sel: &'a Surface, tc: photocraft_geom::TileCoord) -> Self { - Mask { bytes: sel.tile(tc).map_or(sel.default_bytes(), |t| t.bytes()), fmt: sel.format(), origin: tc.rect() } + Mask { bytes: sel.tile(tc).map(|t| t.bytes()), default: sel.default_bytes(), fmt: sel.format(), origin: tc.rect() } } pub(crate) fn at(&self, x: i32, y: i32) -> f32 { + let bytes = self.bytes.as_deref().unwrap_or(self.default); let bpp = self.fmt.bytes_per_pixel(); // A default-pixel mask is one pixel long: every position reads it. - let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; - self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) + let i = if bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; + bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) } /// Fully selected over `r`? pub(crate) fn full(&self, r: Rect) -> bool { diff --git a/crates/engine/src/cmd/fill_key_cmds.rs b/crates/engine/src/cmd/fill_key_cmds.rs index 16585a33d6..b2135984e2 100644 --- a/crates/engine/src/cmd/fill_key_cmds.rs +++ b/crates/engine/src/cmd/fill_key_cmds.rs @@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session}; /// The fill-key command ids (routed to the Channels panel target like `edit.fill`). pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"]; -const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##; +const PARAMS: &str = r##"{"layer":id|name?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##; /// The shape or type layer the colour fill keys recolor instead of filling pixels. fn target_live_layer(s: &Session, p: &Value) -> Option { @@ -31,7 +31,7 @@ fn target_live_layer(s: &Session, p: &Value) -> Option { if st.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask { return None; } - let id = if let Some(v) = p.get("layer") { LayerId(v.as_u64()?) } else { st.active_layer? }; + let id = crate::commands::layer_param(s, p).ok()?; let layer = st.doc.layer(id)?; if matches!(layer.content, LayerContent::Shape(_) | LayerContent::Text(_)) { Some(id) } else { None } } @@ -411,4 +411,15 @@ mod tests { } assert_eq!(type_colors(&s), [[1.0, 0.0, 0.0]]); } + + #[test] + fn fill_key_recolors_a_named_nonactive_type_layer() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 1, "y": 5, "text": "Hello", "size": 4, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("edit.fillForeground", json!({"layer": name})).unwrap(); + let LayerContent::Text(text) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!("type layer was replaced") }; + assert_eq!(text.char_runs()[0].style.color.to_rgb(), [1.0, 0.0, 0.0]); + } } diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index 19ac191900..bb93f233cb 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -174,9 +174,10 @@ fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha { return Ok(Some((tc, t.clone()))); } - let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes()); + let src_guard = src_tile.map(|t| t.bytes()); + let src_bytes: &[u8] = src_guard.as_deref().unwrap_or(base.default_bytes()); let mut out = piece.try_default_tile().map_err(oom)?; - let out_bytes = out.bytes_mut(); + let mut out_bytes = out.bytes_mut(); let mut px = [0.0f32; 8]; let mut has_content = false; for y in tr.y0..tr.y1 { @@ -224,6 +225,7 @@ fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha has_content |= non_zero; } } + drop(out_bytes); Ok(has_content.then(|| (tc, Arc::new(out)))) }) .collect::>>()?; diff --git a/crates/engine/src/cmd/frame_cmds.rs b/crates/engine/src/cmd/frame_cmds.rs index c2cd6283a7..9bfcb257de 100644 --- a/crates/engine/src/cmd/frame_cmds.rs +++ b/crates/engine/src/cmd/frame_cmds.rs @@ -124,10 +124,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { Some(Some("ellipse")) => true, Some(_) => return Err(bad("`shape` must be \"rectangle\" or \"ellipse\"".into())), }; - let content = match p.get("layer") { - None | Some(Value::Null) => None, - Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad(format!("`layer` = {v} is not a layer id")))?)), - }; + let content = crate::commands::explicit_layer(s, p)?; let name = p.get("name").and_then(Value::as_str).map(str::to_string); let gid = s.edit("New Frame", |doc, active| { let mask = diff --git a/crates/engine/src/cmd/fx_visibility_cmds.rs b/crates/engine/src/cmd/fx_visibility_cmds.rs index 7911bcd3cc..dadb8f888b 100644 --- a/crates/engine/src/cmd/fx_visibility_cmds.rs +++ b/crates/engine/src/cmd/fx_visibility_cmds.rs @@ -2,7 +2,6 @@ //! hides every effect of that layer; the eye on one effect's row shows or hides that effect. The //! effects keep their settings either way, and each toggle is one history step. -use photocraft_doc::LayerId; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -32,10 +31,7 @@ fn set_visible(s: &mut Session, p: &Value) -> Result { Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("`index` must be an effect position (0, 1, …)"))?), }; let st = s.active().ok_or(EngineError::NoDocument)?; - let id = match p.get("layer") { - None | Some(Value::Null) => st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?, - Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?), - }; + let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer).ok_or_else(|| bad("no layer given and no active layer"))?; 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))); @@ -78,6 +74,7 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests { use super::*; + use photocraft_doc::LayerId; /// A red layer with a drop shadow and a blue colour overlay, and the background (no effects). fn session() -> (Session, u64, u64) { @@ -98,6 +95,23 @@ mod tests { (fx.enabled, fx.items.iter().map(|e| e.enabled()).collect()) } + #[test] + fn named_nonactive_effects_layer_can_be_replayed() { + let (mut s, a, _) = session(); + s.execute("layer.new.layer", json!({"name": "top"})).unwrap(); + let replay = s.active().unwrap().doc.as_ref().clone(); + s.execute("actions.record", json!({"name": "Hide effects"})).unwrap(); + s.execute("layer.setEffectsVisible", json!({"layer": a, "visible": false})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert!(!state(&s, a).0); + let steps = s.execute("actions.get", json!({"action": "Hide effects"})).unwrap()["steps"].clone(); + assert_eq!(steps[0][1]["layer"], "a"); + s.add_document(replay, None); + let played = s.execute("actions.play", json!({"action": "Hide effects"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(!state(&s, a).0, "the recorded name targeted the nonactive layer on replay"); + } + fn steps(s: &Session) -> usize { s.active().unwrap().history.past_len() } diff --git a/crates/engine/src/cmd/history_cmds.rs b/crates/engine/src/cmd/history_cmds.rs index d8bea8607b..cdd1018ff0 100644 --- a/crates/engine/src/cmd/history_cmds.rs +++ b/crates/engine/src/cmd/history_cmds.rs @@ -69,20 +69,20 @@ fn delete_state(s: &mut Session, p: &Value) -> Result { if i == 0 { return Err(bad(CMD, "the oldest state can't be deleted")); } + let rows = crate::actions_cmds::history_rows(s); if non_linear { crate::history_options_cmds::delete_state(s, i, current)?; - let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries(); - return Ok(json!({ "states": entries.len(), "current": entries.last() })); - } - // Step back to the state before `i`; the steps from `i` on are then redo states. - for _ in i..=current { - if !s.undo() { - return Err(EngineError::Other("the history can't step back right now".into())); + } else { + // Step back to the state before `i`; the steps from `i` on are then redo states. + for _ in i..=current { + if !s.undo() { + return Err(EngineError::Other("the history can't step back right now".into())); + } } + s.active_mut().ok_or(EngineError::NoDocument)?.history.clear_redo(); } - let st = s.active_mut().ok_or(EngineError::NoDocument)?; - st.history.clear_redo(); - let entries = st.history.entries(); + crate::actions_cmds::reconcile_history_removal(s, &rows); + let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries(); Ok(json!({ "states": entries.len(), "current": entries.last() })) } @@ -163,6 +163,10 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result { let name = name.unwrap_or_else(|| next_snapshot_name(st)); let id = st.next_snapshot; st.next_snapshot = id.saturating_add(1); + // An action's New Snapshot with "replace existing": a snapshot of that name gives way. + if p.get("replaceExisting").and_then(Value::as_bool) == Some(true) { + st.snapshots.retain(|snap| snap.name != name); + } st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers }); Ok(json!({ "snapshot": id, "name": name })) } @@ -284,7 +288,7 @@ pub fn specs() -> Vec { label: "New Snapshot", menu: &[], shortcut: None, - params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument"} → {snapshot,name}: a named copy of the document for the session (not a history state)"##, + params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument","replaceExisting":bool=false (drop snapshots of the same name first)} → {snapshot,name}: a named copy of the document for the session (not a history state)"##, enabled: has_doc, run: new_snapshot, journal: true, @@ -442,6 +446,31 @@ mod tests { assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}])); } + #[test] + fn replace_existing_snapshot_and_clear_history() { + let mut s = session(); + s.execute("history.newSnapshot", json!({"name": "Result"})).unwrap(); + s.execute("history.newSnapshot", json!({"name": "Other"})).unwrap(); + s.execute("history.newSnapshot", json!({"name": "Result", "replaceExisting": true})).unwrap(); + let opening = json!({"id": 0, "name": s.active().unwrap().doc.name}); + assert_eq!(snapshots(&s), json!([opening, {"id": 2, "name": "Other"}, {"id": 3, "name": "Result"}])); + let kept_snapshots = snapshots(&s); + let history_before = entries(&s); + let pixel = top_pixel(&s); + s.execute("history.clear", json!({})).unwrap(); + assert_eq!(entries(&s).len(), 1, "only the current state is left"); + assert!(s.active().unwrap().history.can_undo(), "the current Clear History remains undoable"); + assert_eq!(top_pixel(&s), pixel, "the document is unchanged"); + assert_eq!(snapshots(&s), kept_snapshots, "opening and named snapshots stay unchanged"); + // Current History menu semantics refuse a second clear; snapshots remain independent. + assert!(s.execute("history.clear", json!({})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(entries(&s), history_before, "Undo restores the cleared states"); + assert_eq!(snapshots(&s), kept_snapshots, "Undo also preserves every snapshot"); + assert_eq!(top_pixel(&s), pixel); + assert!(Session::new().execute("history.clear", json!({})).is_err(), "no document"); + } + #[test] fn select_snapshot_returns_to_it_as_one_undoable_state() { let mut s = session(); @@ -480,7 +509,7 @@ mod tests { } fn tile_bytes(doc: &Document) -> usize { - doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).sum() + doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.len())).sum() } /// A Merged Layers snapshot is a flattened copy only it holds (#4530): it counts in the diff --git a/crates/engine/src/cmd/history_source_cmds.rs b/crates/engine/src/cmd/history_source_cmds.rs index 908e6d11d3..c4c0f5e225 100644 --- a/crates/engine/src/cmd/history_source_cmds.rs +++ b/crates/engine/src/cmd/history_source_cmds.rs @@ -43,7 +43,7 @@ pub enum HistorySource { /// 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 { + match st.source_snapshot.or_else(|| st.history.brush_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), } diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 17f87691c8..cd5af85152 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -191,7 +191,24 @@ fn parse_resample(s: &str) -> Method { } /// The largest raster Image Size resamples to: the decoders' default allocation budget. -const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; +pub(crate) const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + +/// Keep pixel defaults while rejecting an explicit unsupported dimension unit. +fn size_units<'a>(p: &'a Value, cmd: &str, allowed: &[&str]) -> Result<&'a str> { + let Some(value) = p.get("units") else { return Ok("px") }; + let units = value.as_str().filter(|unit| allowed.contains(unit)).ok_or_else(|| bad(cmd, "unsupported `units` for these dimensions"))?; + Ok(units) +} + +/// Resolve a positive dimension before rounding, rejecting invalid or overflowing conversions. +fn scaled_dimension(p: &Value, key: &str, scale: f64, cmd: &str) -> Result> { + let Some(value) = p.get(key).filter(|value| !value.is_null()) else { return Ok(None) }; + let value = value.as_f64().filter(|value| value.is_finite() && *value > 0.0).ok_or_else(|| bad(cmd, format!("`{key}` must be a positive number")))? * scale; + if !value.is_finite() || value <= 0.0 { + return Err(bad(cmd, format!("`{key}` cannot be converted to pixels"))); + } + Ok(Some(value)) +} /// Image → Image Size. fn image_size(s: &mut Session, p: &Value) -> Result { @@ -206,6 +223,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); + if size_units(p, "image.imageSize", &["px", "pt"])? == "pt" && !matches!(resample, Method::None) { + // Recorded points are 1/72 inch, resolved against this document at playback. + let scale = f64::from(d.doc.resolution_dpi) / 72.0; + pw = scaled_dimension(p, "width", scale, "image.imageSize")?; + ph = scaled_dimension(p, "height", scale, "image.imageSize")?; + } // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. if let Some(pc) = p.get("percent") { let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?; @@ -238,6 +261,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); + let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); @@ -254,7 +278,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc(doc, nw, nh, filter); + resample_doc_with_styles(doc, nw, nh, filter, scale_styles); Ok(()) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). @@ -292,6 +316,11 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// `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. pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { + resample_doc_with_styles(doc, nw, nh, filter, true); +} + +/// Resize document content while optionally retaining layer-effect sizes in pixels. +fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) { 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 { @@ -305,7 +334,9 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl }); let k = ((sx + sy) / 2.0) as f32; for_each_layer(&mut doc.layers, &mut |l| { - scale_effects(&mut l.effects, k); + if scale_styles { + 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 @@ -334,10 +365,23 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { /// 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))); + let count = u64::from(w) * u64::from(h); + let operation = if cmd == "image.trim" { + "trim" + } else if cmd == "image.duplicate" { + "duplicate merged" + } else { + "flatten" + }; + let too_large = + || bad(cmd, format!("the document ({w} x {h} px) is too large to {operation} (flatten limit {} pixels)", photocraft_compose::MAX_RENDER_PIXELS)); + if count > photocraft_compose::MAX_RENDER_PIXELS { + return Err(too_large()); + } + let buf = photocraft_compose::flatten(doc); + if buf.px.len() as u64 != count { + return Err(too_large()); } Ok(buf) } @@ -398,7 +442,7 @@ fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) { /// Crops the document to `r` (in current document coordinates). /// A frame that extends past the canvas fills the Background's new area with `extension` (the /// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there. -fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extension: [f32; 4]) -> Result<()> { +pub(crate) fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extension: [f32; 4]) -> Result<()> { // The origin moves to (0, 0); `-i32::MIN` has no i32 value (#959). let (Some(dx), Some(dy)) = (r.x0.checked_neg(), r.y0.checked_neg()) else { return Err(bad(cmd, format!("the crop origin ({}, {}) can't be moved to (0, 0) within the 32-bit coordinate range", r.x0, r.y0))); @@ -513,8 +557,20 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width as f64, d.doc.size.height as f64); let relative = p.get("relative").and_then(Value::as_bool).unwrap_or(false); - let w = p.get("width").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { ow }); - let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh }); + let (w, h) = if size_units(p, "image.canvasSize", &["px", "percent"])? == "percent" { + if relative { + return Err(bad("image.canvasSize", "percentage dimensions require an absolute canvas size")); + } + ( + scaled_dimension(p, "width", ow / 100.0, "image.canvasSize")?.unwrap_or(ow), + scaled_dimension(p, "height", oh / 100.0, "image.canvasSize")?.unwrap_or(oh), + ) + } else { + ( + p.get("width").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { ow }), + p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh }), + ) + }; let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) }; let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32); let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?; @@ -558,6 +614,26 @@ const MAX_CROP_SIDE: i32 = 300_000; /// Image → Crop (to the selection bounds). fn crop(s: &mut Session, p: &Value) -> Result { + crop_with(s, p, false) +} + +/// Image › Reveal All's crop: as `image.crop` with Delete Cropped Pixels off, but the Background +/// stays a Background and an extension takes the background colour (Reveal All keeps its own +/// behaviour; only the Crop tool's was measured, #10867). +pub(crate) fn crop_keeping_background(s: &mut Session, p: &Value) -> Result { + crop_with(s, p, true) +} + +/// A Delete Cropped Pixels off crop turns the Background into a normal layer ("Layer 0", as Layer › +/// New › Layer from Background names it), as in Photoshop: it keeps the pixels past the canvas, +/// and an extension is transparent on every layer (#10867). +fn unlock_background_for_crop(doc: &mut Document) { + if let Some(bg) = doc.background_mut() { + crate::extra_cmds::unlock_background(bg); + } +} + +fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result { let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true); // The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823). let content_aware = match p.get("contentAware") { @@ -565,6 +641,8 @@ fn crop(s: &mut Session, p: &Value) -> Result { Some(Value::Bool(b)) => *b, Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))), }; + // Content-Aware Fill with Delete off wasn't measured: it keeps filling the Background (#10867). + let unlock = !delete && !content_aware && !keep_background; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). let explicit = match ( @@ -596,7 +674,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { } if let Some(a) = crop_angle(p)? { let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?; - return rotated_crop(s, rect, a, delete, content_aware, &target); + return rotated_crop(s, rect, a, delete, content_aware, unlock, &target); } // An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped. let r = explicit @@ -609,6 +687,9 @@ fn crop(s: &mut Session, p: &Value) -> Result { let extension = s.tools.background; s.edit("Crop", |doc, active| { let original = doc.size; + if unlock { + unlock_background_for_crop(doc); + } crop_doc(doc, "image.crop", r, delete, extension)?; // A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped // Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060). @@ -740,8 +821,10 @@ fn crop_angle(p: &Value) -> Result> { /// background colour, other layers keep transparency there and every pixel of theirs), which makes /// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in /// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the -/// rotated image) are then filled from their surroundings, in the same step (#3823). -fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result { +/// rotated image) are then filled from their surroundings, in the same step (#3823). With `unlock` +/// the Background becomes a normal layer before the turn, so it turns like the others, keeping its +/// overhang and leaving the uncovered corners transparent (#10867). +fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, unlock: bool, crop_target: &CropTarget) -> Result { const CMD: &str = "image.crop"; let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; let (w, h) = (f64::from(r.width()), f64::from(r.height())); @@ -804,6 +887,12 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar Ok(()) })?; } + if unlock { + s.edit("Crop", |doc, _| { + unlock_background_for_crop(doc); + Ok(()) + })?; + } crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?; // Color Sampler points follow the crop's origin shift too (the rotation moved them above). s.set_sampler_map(crop_target.sampler_map(target)); @@ -970,7 +1059,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":number,"height":number,"units":"px|pt"="px" (points use the document's current resolution),"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"scaleStyles":bool=true (scale layer effects with the pixels),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), @@ -978,7 +1067,7 @@ pub fn specs() -> Vec { "image.canvasSize", "Canvas Size…", ["Image"], - r##"{"width":px,"height":px,"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##, + r##"{"width":number,"height":number,"units":"px|percent"="px" (percent uses each current side; absolute sizes only),"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##, has_doc, canvas_size ), @@ -986,7 +1075,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, has_doc, crop ), diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index 3061ce69c0..fc4ce0bcf9 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -486,6 +486,17 @@ fn extending_the_canvas_keeps_the_backgrounds_hidden_pixels() { }) .unwrap(); s.execute("image.crop", json!({"x": 50, "y": 0, "width": 50, "height": 100, "deleteCroppedPixels": false})).unwrap(); + // That crop makes the Background "Layer 0" now (#10867); a Background with pixels past + // the canvas is still in documents saved before, so lock it back into one. + s.edit("relock", |doc, _| { + let l = doc.layers.first_mut().unwrap(); + l.name = "Background".into(); + l.locks.transparency = true; + l.locks.position = true; + Ok(()) + }) + .unwrap(); + assert!(doc(s).background().is_some()); s.tools.background = [0.0, 0.0, 0.0, 1.0]; }; let mut s = Session::new(); @@ -961,3 +972,30 @@ fn duplicate_document() { assert_eq!(doc(&s).layers.len(), 1); assert!(doc(&s).name.ends_with("copy")); } + +#[test] +fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap(); + let initial_layers = doc(&s).layers.len(); + let initial_bounds = doc(&s).bounds(); + let initial_rev = s.active().unwrap().revision; + let initial_docs = s.documents().len(); + + let trim_res = s.execute("image.trim", json!({"basedOn": "topLeft"})); + assert!(trim_res.is_err(), "trim on too-large document must return error"); + let err = trim_res.unwrap_err().to_string(); + assert!(err.contains("too large to trim"), "error should explain document is too large: {err}"); + assert_eq!(doc(&s).layers.len(), initial_layers, "layer count unchanged after failed trim"); + assert_eq!(doc(&s).bounds(), initial_bounds, "canvas size unchanged after failed trim"); + assert_eq!(s.active().unwrap().revision, initial_rev, "revision unchanged after failed trim"); + + let dup_res = s.execute("image.duplicate", json!({"mergedOnly": true})); + assert!(dup_res.is_err(), "duplicate merged on too-large document must return error"); + let dup_err = dup_res.unwrap_err().to_string(); + assert!(dup_err.contains("too large to duplicate merged"), "error should explain document is too large: {dup_err}"); + assert_eq!(s.documents().len(), initial_docs, "document count unchanged after failed duplicate"); + assert_eq!(doc(&s).layers.len(), initial_layers, "layer count unchanged after failed duplicate"); + assert_eq!(doc(&s).bounds(), initial_bounds, "canvas size unchanged after failed duplicate"); + assert_eq!(s.active().unwrap().revision, initial_rev, "revision unchanged after failed duplicate"); +} diff --git a/crates/engine/src/cmd/layer_copy_cmds.rs b/crates/engine/src/cmd/layer_copy_cmds.rs index 631cceac65..2a4ce26493 100644 --- a/crates/engine/src/cmd/layer_copy_cmds.rs +++ b/crates/engine/src/cmd/layer_copy_cmds.rs @@ -118,6 +118,10 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document) *surf = surf.convert(f); }); } + // Type keeps its pixels and takes the destination's point size, as in the reference + // application (24 pt at 72 ppi becomes 5.76 pt at 300 ppi, with no transform). Left at the + // source's, a copy re-laid out at the destination's resolution on its next edit (#9560). + crate::canvas_geom::retarget_type_resolution(copies, dest.resolution_dpi); let new_canvas = copies.bounds() != dest.bounds(); prepare_shapes(&mut copies.layers, dest, new_canvas); Ok(()) diff --git a/crates/engine/src/cmd/layer_copy_cmds/tests.rs b/crates/engine/src/cmd/layer_copy_cmds/tests.rs index b3200b16ef..f3176af0af 100644 --- a/crates/engine/src/cmd/layer_copy_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_copy_cmds/tests.rs @@ -369,3 +369,134 @@ fn a_copied_group_cannot_nest_the_destination_past_the_group_depth_limit() { assert!(e.to_string().contains("deeper than"), "{e}"); assert_eq!(*doc(&s, 1), before, "nothing was copied"); } + +/// The type layer `id` of document `i`. +fn text(s: &Session, i: usize, id: LayerId) -> &photocraft_doc::TextLayer { + match &doc(s, i).layer(id).unwrap().content { + LayerContent::Text(t) => t, + other => panic!("not type: {other:?}"), + } +} + +/// Bounds and summed alpha of a layer's rendered pixels. +fn ink(s: &Session, i: usize, id: LayerId) -> (Rect, f64) { + let surf = doc(s, i).layer(id).unwrap().surface().unwrap(); + let b = surf.content_bounds(); + let a = surf.read_region(b).chunks(surf.channels()).map(|px| f64::from(*px.last().unwrap())).sum(); + (b, a) +} + +/// Document 0 at `from` ppi with a 24 pt "H" (leading 30 pt, baseline shift 2 pt, first-line +/// indent 6 pt) and document 1 at `to` ppi; the type layer is selected in document 0. +fn type_docs(from: u32, to: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": from})).unwrap(); + let t = LayerId( + s.execute("type.create", json!({"text": "H", "size": 24, "leading": 30, "baselineShift": 2, "x": 128, "y": 100})).unwrap()["layer"].as_u64().unwrap(), + ); + s.execute("type.setStyle", json!({"layer": t.0, "firstLineIndent": 6})).unwrap(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": to})).unwrap(); + s.set_active(0); + s.execute("layer.select", json!({"layer": t.0})).unwrap(); + (s, t) +} + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() <= 1e-4 * b.abs().max(1.0) +} + +/// Checks a copy of `src` (document 0) as `dst` (document 1) at `pt` points (the reference +/// application's rewritten size for 24 pt), then that an edit keeps that size and the pixels. +fn check_rewritten(s: &mut Session, src: LayerId, dst: LayerId, pt: f32, label: &str) { + let k = pt / 24.0; + let (from, to) = (text(s, 0, src), text(s, 1, dst)); + assert_eq!(to.transform, from.transform, "{label}: no compensating transform"); + assert!(close(to.size_pt, pt), "{label}: {} pt", to.size_pt); + let run = &to.runs[0].style; + assert!(close(run.size_pt, pt), "{label}: run {} pt", run.size_pt); + assert!(close(run.leading_pt.unwrap(), 30.0 * k), "{label}: leading {:?}", run.leading_pt); + assert!(close(run.baseline_shift_pt, 2.0 * k), "{label}: baseline shift {}", run.baseline_shift_pt); + assert_eq!(run.tracking, from.runs[0].style.tracking, "{label}: tracking is in 1/1000 em"); + assert!(close(to.paragraphs[0].style.first_line_indent_pt, 6.0 * k), "{label}: indent {}", to.paragraphs[0].style.first_line_indent_pt); + let (b0, a0) = ink(s, 0, src); + assert_eq!(ink(s, 1, dst).0, b0, "{label}: the copy's pixels"); + // Re-laid out at the destination's resolution, the copy renders as the source does. + s.execute("type.edit", json!({"layer": dst.0, "text": "M"})).unwrap(); + s.execute("type.edit", json!({"layer": dst.0, "text": "H"})).unwrap(); + assert!(close(text(s, 1, dst).size_pt, pt), "{label}: an edit keeps {} pt", text(s, 1, dst).size_pt); + let (b1, a1) = ink(s, 1, dst); + assert_eq!(b1, b0, "{label}: after an edit, {b1:?} vs {b0:?}"); + assert!((a1 - a0).abs() <= a0 * 0.005, "{label}: coverage {a1} vs {a0}"); +} + +/// #9560: a type layer copied between documents of different resolutions keeps its pixels and +/// takes the destination's point size, as in the reference application (measured: 24 pt from +/// 72 ppi is 5.76 pt at 300 ppi, from 300 ppi 100 pt at 72 ppi, with no transform; later edits +/// keep that size). It used to keep 24 pt and re-lay out about 4.2 times larger or smaller on +/// its next edit, and then kept 24 pt behind a compensating transform. +#[test] +fn copied_type_takes_the_destinations_point_size_and_keeps_its_pixels() { + for (from, to, pt) in [(72, 300, 5.76), (300, 72, 100.0)] { + let (mut s, t) = type_docs(from, to); + let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap()); + check_rewritten(&mut s, t, id, pt, &format!("copy {from}→{to}")); + } +} + +/// The same through Copy and Paste in Place of the whole type layer. +#[test] +fn pasted_type_takes_the_destinations_point_size_and_keeps_its_pixels() { + for (from, to, pt) in [(72, 300, 5.76), (300, 72, 100.0)] { + let (mut s, t) = type_docs(from, to); + s.execute("edit.copy", json!({})).unwrap(); + s.set_active(1); + let id = LayerId(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap()["layer"].as_u64().unwrap()); + check_rewritten(&mut s, t, id, pt, &format!("paste {from}→{to}")); + } +} + +/// Paragraph text keeps its box (in pixels) and its wrapping: only the point sizes change. +#[test] +fn copied_paragraph_type_keeps_its_box() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 72})).unwrap(); + let t = + LayerId(s.execute("type.create", json!({"text": "Hello wrapping world", "size": 24, "box": [20, 20, 120, 150]})).unwrap()["layer"].as_u64().unwrap()); + s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 300})).unwrap(); + s.set_active(0); + s.execute("layer.select", json!({"layer": t.0})).unwrap(); + let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap()); + assert_eq!(text(&s, 1, id).shape, text(&s, 0, t).shape); + assert!(close(text(&s, 1, id).size_pt, 5.76)); + let (b0, _) = ink(&s, 0, t); + s.execute("type.edit", json!({"layer": id.0, "text": "Hello wrapping world"})).unwrap(); + assert_eq!(ink(&s, 1, id).0, b0, "the same lines after a re-layout"); +} + +/// Equal resolutions change nothing; one that isn't a positive number lays out at 72 ppi, so it +/// is treated as 72 rather than producing an infinite or zero size. +#[test] +fn retargeting_type_survives_bad_resolutions() { + let mut d = Document::new("", photocraft_geom::Size::new(10, 10), ColorMode::Rgb, SampleType::U8); + d.layers.push(photocraft_doc::Layer::new("t", LayerContent::Text(photocraft_doc::TextLayer { text: "H".into(), size_pt: 24.0, ..Default::default() }))); + let size = |d: &Document| match &d.layers[0].content { + LayerContent::Text(t) => t.size_pt, + _ => panic!("not type"), + }; + d.resolution_dpi = 72.0; + crate::canvas_geom::retarget_type_resolution(&mut d, 72.0); + assert_eq!(size(&d), 24.0); + for bad in [0.0, -5.0, f32::NAN, f32::INFINITY] { + crate::canvas_geom::retarget_type_resolution(&mut d, bad); + assert_eq!(size(&d), 24.0, "{bad}"); + d.resolution_dpi = bad; + crate::canvas_geom::retarget_type_resolution(&mut d, 72.0); + assert_eq!(size(&d), 24.0, "from {bad}"); + } + // Absurd ratios stay finite and positive. + d.resolution_dpi = f32::MAX; + crate::canvas_geom::retarget_type_resolution(&mut d, f32::MIN_POSITIVE); + assert!(size(&d).is_finite() && size(&d) > 0.0, "{}", size(&d)); + crate::canvas_geom::retarget_type_resolution(&mut d, f32::MAX); + assert!(size(&d).is_finite() && size(&d) > 0.0, "{}", size(&d)); +} diff --git a/crates/engine/src/cmd/layer_label_cmds.rs b/crates/engine/src/cmd/layer_label_cmds.rs index acb450cdbf..bcc9d17c46 100644 --- a/crates/engine/src/cmd/layer_label_cmds.rs +++ b/crates/engine/src/cmd/layer_label_cmds.rs @@ -1,7 +1,7 @@ //! Undoable layer colour labels. A group applies the label to its existing descendants, //! without changing pixels, visibility, locks or the layer selection. -use photocraft_doc::{LabelColor, LayerId}; +use photocraft_doc::LabelColor; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -18,15 +18,19 @@ fn enabled(s: &Session) -> std::result::Result<(), String> { if st.selected_layers().is_empty() { Err("no layer selected".into()) } else { Ok(()) } } +/// Label an explicit layer or the selected layers, including a group's descendants. fn set_label(s: &mut Session, p: &Value) -> Result { - let p = p.as_object().ok_or_else(|| bad("expected an object"))?; + p.as_object().ok_or_else(|| bad("expected an object"))?; let color = p.get("color").and_then(Value::as_str).and_then(LabelColor::from_id).ok_or_else(|| { bad("`color` must be a layer colour id (none, red, orange, yellow, green, seafoam, blue, indigo, magenta, fuchsia, violet, gray)") })?; let st = s.active().ok_or(EngineError::NoDocument)?; - let roots = match p.get("layer") { - Some(v) => vec![LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be an unsigned integer"))?)], + if p.get("layer") == Some(&Value::Null) { + return Err(bad("`layer` must name a layer when supplied")); + } + let roots = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => st.selected_layers(), }; if roots.is_empty() { @@ -63,13 +67,14 @@ fn set_label(s: &mut Session, p: &Value) -> Result { Ok(json!({"color": color.id(), "layers": targets.iter().map(|(_, id)| id.0).collect::>(), "changed": changed})) } +/// Register the undoable layer-label command and its target options. pub fn specs() -> Vec { vec![CommandSpec { id: ID, label: "Layer Color", menu: &[], shortcut: None, - params: r#"{"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray","layer":id?} (no layer: selected layers; groups include descendants)"#, + params: r#"{"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray","layer":id|name|{"background":true}?} (no layer: selected layers; groups include descendants)"#, enabled, run: set_label, journal: true, diff --git a/crates/engine/src/cmd/layer_label_cmds/tests.rs b/crates/engine/src/cmd/layer_label_cmds/tests.rs index 78fa018e7d..3288c5aebf 100644 --- a/crates/engine/src/cmd/layer_label_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_label_cmds/tests.rs @@ -1,6 +1,6 @@ use super::*; use photocraft_color::SampleType; -use photocraft_doc::{Layer, LayerContent}; +use photocraft_doc::{Layer, LayerContent, LayerId}; fn fixture(depth: u8) -> (Session, [LayerId; 4]) { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/layer_menu_cmds.rs b/crates/engine/src/cmd/layer_menu_cmds.rs index 34a1f914ee..75701d9ddd 100644 --- a/crates/engine/src/cmd/layer_menu_cmds.rs +++ b/crates/engine/src/cmd/layer_menu_cmds.rs @@ -230,7 +230,7 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result { struct Composite<'a>(&'a Document); impl photocraft_algo::segment::Sampler for Composite<'_> { fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { - photocraft_compose::render(self.0, r).px + photocraft_compose::render(self.0, r).into_px() } } photocraft_algo::segment::subject::select_subject(&Composite(&doc), doc.bounds()) diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index 702d65891b..62522ccc39 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -757,11 +757,11 @@ fn applying_a_feathered_mask_preserves_its_soft_composite() { Ok(()) }) .unwrap(); - let before = photocraft_compose::flatten(doc(&s)).px; + let before = photocraft_compose::flatten(doc(&s)).into_px(); assert!(before[15 * 40 + 19][3] > 0.0 && before[15 * 40 + 19][3] < 1.0, "mask must start with a soft edge"); s.execute("layer.layerMask.apply", json!({})).unwrap(); assert!(active(&s).mask.is_none(), "Apply removes the pixel mask"); - let after = photocraft_compose::flatten(doc(&s)).px; + let after = photocraft_compose::flatten(doc(&s)).into_px(); for (a, b) in before.iter().zip(&after) { assert!((a[3] - b[3]).abs() <= 1.0 / 255.0 + 1e-5, "depth {depth}: alpha {a:?} became {b:?}"); } @@ -791,13 +791,13 @@ fn from_transparency_preserves_mask_coverage_outside_the_canvas() { for depth in DEPTHS { let mut s = session(depth, "rgb"); paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]); - let before = photocraft_compose::flatten(doc(&s)).px; + let before = photocraft_compose::flatten(doc(&s)).into_px(); s.execute("layer.translate", json!({"dx": 20})).unwrap(); s.execute("layer.layerMask.fromTransparency", json!({})).unwrap(); let mask = active(&s).mask.as_ref().unwrap(); assert!((mask.value(50, 10) - 0.5).abs() < 1.0 / 255.0 + 1e-6, "off-canvas mask at depth {depth}"); s.execute("layer.translate", json!({"dx": -20})).unwrap(); - let after = photocraft_compose::flatten(doc(&s)).px; + let after = photocraft_compose::flatten(doc(&s)).into_px(); for (a, b) in before.iter().zip(&after) { assert!((a[3] - b[3]).abs() <= 1.0 / 255.0 + 1e-6, "alpha changed at depth {depth}: {a:?} -> {b:?}"); } diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index 52f36b9ffa..f85423c048 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -20,8 +20,10 @@ mod selection; mod structure; use align::{align, distribute}; -pub use damage::{layer_bounds, layers_damage, same_pixels, selection_only_change, step_damage}; -pub(crate) use damage::{note_damage, note_inert_insert, note_insert}; +#[cfg(any(test, debug_assertions))] +pub(crate) use damage::hint_misses; +pub(crate) use damage::note_insert; +pub use damage::{layer_bounds, same_pixels, selection_only_change, step_damage}; use moves::link_groups; pub(crate) use moves::shift_surface; #[cfg(test)] @@ -30,7 +32,7 @@ pub use moves::{move_targets, move_to, moved, moved_target, moves_mask_alone, pr use selection::{doc_state, find_layers, select_all_layers, select_linked}; pub(crate) use selection::{mask_targets, recorded_step, reselect, targets, top_level}; pub use selection::{select, selected, set_selection}; -pub(crate) use structure::{check_group_depth, deletion_neighbours}; +pub(crate) use structure::{check_group_depth, deletion_neighbours, relink_copies}; pub use structure::{delete_selected, duplicate_selected, group_layers, set_visible_selected}; use structure::{link_layers, lock_layers, merge_layers, rename_layer, reverse}; @@ -132,9 +134,15 @@ pub fn specs() -> Vec { s, "vertically" )), - spec!("layer.linkLayers", "Link Layers", &["Layer"], None, "{} (toggles: unlinks when the selection is already one link group)", can_link, |s, _| { - link_layers(s) - }), + spec!( + "layer.linkLayers", + "Link Layers", + &["Layer"], + None, + r#"{"linked":bool?} (omitted: toggle; explicit true links, false unlinks)"#, + can_link, + link_layers + ), spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)), spec!( "layer.new.groupFromLayers", @@ -164,3 +172,6 @@ mod tests; #[cfg(test)] mod layers_panel_parity_tests; + +#[cfg(test)] +mod tile_damage_tests; diff --git a/crates/engine/src/cmd/layer_multi_cmds/align.rs b/crates/engine/src/cmd/layer_multi_cmds/align.rs index e7f77fc8fb..e33b11b24a 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/align.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/align.rs @@ -1,4 +1,4 @@ -use super::damage::layer_bounds; +use super::damage::align_bounds; use super::moves::{move_layers, with_links}; use super::selection::{selected, top_level}; use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json}; @@ -44,7 +44,7 @@ impl Edge { /// Selected top-level layers that have content bounds, bottom-to-top. fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> { - top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect() + top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect() } pub(super) fn align(s: &mut Session, p: &Value, kind: &str) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/damage.rs b/crates/engine/src/cmd/layer_multi_cmds/damage.rs index 129dea0556..8440fab75b 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/damage.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/damage.rs @@ -1,55 +1,60 @@ -use super::{Document, Layer, LayerContent, LayerId, Rect, Session, VideoData}; +use photocraft_color::{PixelFormat, SampleType}; -/// The document pixels an edit of the layers `ids` (moved, shown, hidden or restyled; contents -/// otherwise as they were) can have changed from `before` to `after`, or `None` for anywhere. -pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Option { - let mut out = Rect::EMPTY; - for d in [before, after] { - let canvas = d.bounds(); - for id in ids { - // A layer gone from one side (never for a move) changes who knows what. - 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) }; +use super::{Document, Layer, LayerContent, Rect, Session, VideoData}; + +/// Calls `f` with the rect of every tile that differs between two surfaces of the same format and +/// default pixel: a tile only one side has, or a different copy-on-write tile pointer at the same +/// coordinate. A merge walk of the two sorted tile maps: pointer compares, no pixel reads. Both +/// documents stay alive during the comparison, so tile addresses cannot be reused under it. +/// `false` (and no calls) when the format or default pixel differs: every pixel may have changed. +fn each_changed_tile(a: &photocraft_raster::Surface, b: &photocraft_raster::Surface, mut f: impl FnMut(Rect)) -> bool { + if a.format() != b.format() || a.default_bytes() != b.default_bytes() { + return false; + } + let (mut xs, mut ys) = (a.tiles().peekable(), b.tiles().peekable()); + loop { + match (xs.peek(), ys.peek()) { + (None, None) => return true, + (Some((ca, ta)), Some((cb, tb))) if ca == cb => { + if !std::sync::Arc::ptr_eq(ta, tb) { + f(ca.rect()); + } + xs.next(); + ys.next(); + } + (Some((ca, _)), Some((cb, _))) if ca < cb => { + f(ca.rect()); + xs.next(); + } + (Some((ca, _)), None) => { + f(ca.rect()); + xs.next(); + } + (_, Some((cb, _))) => { + f(cb.rect()); + ys.next(); } } } - 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)))) +/// The pixels that differ between two surfaces, to tile precision: the bounding box of the tiles +/// [`each_changed_tile`] finds (empty when the pixels are identical), or `None` when the format +/// or default pixel differs. +fn surface_damage(a: &photocraft_raster::Surface, b: &photocraft_raster::Surface) -> Option { + let mut out = Rect::EMPTY; + each_changed_tile(a, b, |r| out = out.union(&r)).then_some(out) } -/// 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]) { - let Some(st) = s.active_mut() else { return }; - if before.size != st.doc.size { - return; - } - st.last_damage = layers_damage(before, &st.doc, ids); -} - -/// Whether two surfaces hold identical pixels: same format and default pixel, and the same -/// copy-on-write tile pointers at the same coordinates. Both documents stay alive during the -/// comparison, so tile addresses cannot be reused under it. +/// Whether two surfaces hold identical pixels (see [`surface_damage`]). fn surfaces_equal(a: &photocraft_raster::Surface, b: &photocraft_raster::Surface) -> bool { - a.format() == b.format() - && a.default_pixel() == b.default_pixel() - && a.tile_count() == b.tile_count() - && a.tiles().zip(b.tiles()).all(|((ca, ta), (cb, tb))| ca == cb && std::sync::Arc::ptr_eq(ta, tb)) + surface_damage(a, b) == Some(Rect::EMPTY) } +/// How [`layer_pixels_cmp`] compares a pair of surfaces a layer draws from, given the feather (in +/// pixels) the compositor blurs it by: `true` when the pair counts as equal for the caller. +type SurfaceCmp<'f> = &'f mut dyn FnMut(&photocraft_raster::Surface, &photocraft_raster::Surface, f32) -> bool; + /// Byte blobs kept in `Arc`s (PSD raw blocks, embedded smart-object sources): pointer-identical /// or content-equal (they are small compared to pixels, and undo steps rarely re-parse them). fn blob_equal(x: &std::sync::Arc>, y: &std::sync::Arc>) -> bool { @@ -64,18 +69,18 @@ fn blobs_equal(a: &Option>>, b: &Option, b: &Option) -> bool { +/// Layer masks (also the smart-filter mask): scalars plus the surface, compared by `surf`. +fn masks_cmp(a: &Option, b: &Option, surf: SurfaceCmp) -> bool { match (a, b) { (None, None) => true, (Some(x), Some(y)) => { - x.enabled == y.enabled && x.linked == y.linked && x.density == y.density && x.feather == y.feather && surfaces_equal(&x.surface, &y.surface) + x.enabled == y.enabled && x.linked == y.linked && x.density == y.density && x.feather == y.feather && surf(&x.surface, &y.surface, x.feather) } _ => false, } } -fn texts_equal(a: &photocraft_doc::TextLayer, b: &photocraft_doc::TextLayer) -> bool { +fn texts_cmp(a: &photocraft_doc::TextLayer, b: &photocraft_doc::TextLayer, surf: SurfaceCmp) -> bool { a.text == b.text && a.font_family == b.font_family && a.size_pt == b.size_pt @@ -87,15 +92,15 @@ fn texts_equal(a: &photocraft_doc::TextLayer, b: &photocraft_doc::TextLayer) -> && a.orientation == b.orientation && a.antialias == b.antialias && a.warp == b.warp + && blobs_equal(&a.psd_raw, &b.psd_raw) && match (&a.cache, &b.cache) { (None, None) => true, - (Some(x), Some(y)) => surfaces_equal(x, y), + (Some(x), Some(y)) => surf(x, y, 0.0), _ => false, } - && blobs_equal(&a.psd_raw, &b.psd_raw) } -fn smarts_equal(a: &photocraft_doc::SmartObject, b: &photocraft_doc::SmartObject) -> bool { +fn smarts_cmp(a: &photocraft_doc::SmartObject, b: &photocraft_doc::SmartObject, surf: SurfaceCmp) -> bool { a.filters_enabled == b.filters_enabled && a.transform == b.transform && a.smart_filters == b.smart_filters @@ -109,13 +114,14 @@ fn smarts_equal(a: &photocraft_doc::SmartObject, b: &photocraft_doc::SmartObject (photocraft_doc::SmartSource::Linked { path: x }, photocraft_doc::SmartSource::Linked { path: y }) => x == y, _ => false, } + && blobs_equal(&a.psd_raw, &b.psd_raw) + // The filter mask shapes the filtered result the cache holds: compared exactly. + && masks_cmp(&a.filter_mask, &b.filter_mask, &mut |x, y, _| surfaces_equal(x, y)) && match (&a.cache, &b.cache) { (None, None) => true, - (Some(x), Some(y)) => surfaces_equal(x, y), + (Some(x), Some(y)) => surf(x, y, 0.0), _ => false, } - && blobs_equal(&a.psd_raw, &b.psd_raw) - && masks_equal(&a.filter_mask, &b.filter_mask) } /// Extra alpha/spot channels (the image itself in Multichannel mode). @@ -132,6 +138,13 @@ fn alpha_channels_equal(a: &photocraft_doc::AlphaChannel, b: &photocraft_doc::Al /// (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 { + layer_pixels_cmp(a, b, &mut |x, y, _| surfaces_equal(x, y)) +} + +/// [`layer_pixels_equal`] with the surfaces the layer draws from (its pixels, mask, type and +/// smart-object caches, fill cache) compared by `surf`; everything else compares exactly, before +/// any surface, so `surf` sees a pair only when every scalar the compositor reads is equal. +fn layer_pixels_cmp(a: &Layer, b: &Layer, surf: SurfaceCmp) -> bool { let Layer { id, name: _, @@ -162,27 +175,38 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool { && *opacity == b.opacity && *fill_opacity == b.fill_opacity && *clipped == b.clipped - && masks_equal(mask, &b.mask) && *vector_mask == b.vector_mask && *effects == b.effects && *excluded_channels == b.excluded_channels && *blend_if == b.blend_if && *advanced == b.advanced + && match (fill_cache, &b.fill_cache) { + (None, None) => true, + (Some(x), Some(y)) => x.fill == y.fill, + _ => false, + } && match (content, &b.content) { - (LayerContent::Raster(x), LayerContent::Raster(y)) => surfaces_equal(x, y), + (LayerContent::Raster(_), LayerContent::Raster(_)) => true, // Children are walked separately; only the group's own clipping frame counts. (LayerContent::Group(x), LayerContent::Group(y)) => x.artboard == y.artboard, (LayerContent::Fill(x), LayerContent::Fill(y)) => x == y, (LayerContent::Adjustment(x), LayerContent::Adjustment(y)) => x == y, - (LayerContent::Text(x), LayerContent::Text(y)) => texts_equal(x, y), + (LayerContent::Text(x), LayerContent::Text(y)) => texts_cmp(x, y, &mut |_, _, _| true), (LayerContent::Shape(x), LayerContent::Shape(y)) => x == y, - (LayerContent::Smart(x), LayerContent::Smart(y)) => smarts_equal(x, y), + (LayerContent::Smart(x), LayerContent::Smart(y)) => smarts_cmp(x, y, &mut |_, _, _| true), _ => false, } + // Every scalar is equal: now the surfaces. + && masks_cmp(mask, &b.mask, surf) + && match (content, &b.content) { + (LayerContent::Raster(x), LayerContent::Raster(y)) => surf(x, y, 0.0), + (LayerContent::Text(x), LayerContent::Text(y)) => texts_cmp(x, y, surf), + (LayerContent::Smart(x), LayerContent::Smart(y)) => smarts_cmp(x, y, surf), + _ => true, + } && match (fill_cache, &b.fill_cache) { - (None, None) => true, - (Some(x), Some(y)) => x.fill == y.fill && surfaces_equal(&x.surface, &y.surface), - _ => false, + (Some(x), Some(y)) => surf(&x.surface, &y.surface, 0.0), + _ => true, } } @@ -363,13 +387,24 @@ pub fn selection_only_change(a: &Document, b: &Document) -> bool { /// what `layer.setProps` already reports as unbounded), and video layers. Inserting a copy of a /// 12 MP layer used to recomposite every layer of the document (#2883, P8/P9 in /// `perf/budgets.toml`). +/// +/// A layer whose only change is in the pixels of its surfaces (raster content, mask, type or +/// smart-object cache, fill cache) damages only the tiles that changed ([`tile_damage`]), so +/// undoing a brush dab on a full-canvas layer redraws the dab's tiles, not the canvas (#9329). pub fn step_damage(a: &Document, b: &Document) -> Option { if !doc_pixels_equal(a, b) { return None; } /// 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 { + /// What the groups around a sibling list add to a change inside it: the summed reach of + /// their effects, and whether any lays effects out over its frame. + #[derive(Clone, Copy)] + struct Around { + reach: i32, + spans_frame: bool, + } + fn walk(xs: &[Layer], ys: &[Layer], (ox, oy): (Outer, Outer), around: Around, 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, outer: Outer, canvas: Rect, out: &mut Rect| { @@ -420,7 +455,8 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { if x.clipped != y.clipped || x.excluded_channels != y.excluded_channels { return false; } - if !layer_pixels_equal(x, y) { + let tiles = tile_damage(x, y); + if tiles != Some(Rect::EMPTY) { // Both sides' extents: a move changes pixels where it left and where it landed. // A clipped adjustment reaches as far as its base (#6535). let (Some(bx), Some(by)) = @@ -428,11 +464,27 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { else { return false; }; - *out = if out.is_empty() { bx.union(&by) } else { out.union(&bx).union(&by) }; + let whole = bx.union(&by); + // Only pixels changed: the changed tiles, grown by what the groups around reach, + // never more than the whole extents. Effects laid out over a frame repaint it + // everywhere when it moves (#4053), so then the whole extents. + let frame_moved = around.spans_frame + || (photocraft_compose::effects::spans_frame(y) + && photocraft_compose::effects_frame(x, a.bounds()) != photocraft_compose::effects_frame(y, b.bounds())); + let d = match tiles { + Some(t) if !frame_moved => t.inflate(around.reach).intersect(&whole.inflate(around.reach)).intersect(&b.bounds()), + _ => whole, + }; + *out = out.union(&d); } match (x.children(), y.children()) { (Some(xc), Some(yc)) => { - if !walk(xc, yc, (Some((xs, xk)), Some((ys, yk))), a, b, out) { + let reach = |l: &Layer| if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 }; + let inner = Around { + reach: around.reach.saturating_add(reach(x).max(reach(y))), + spans_frame: around.spans_frame || photocraft_compose::effects::spans_frame(x) || photocraft_compose::effects::spans_frame(y), + }; + if !walk(xc, yc, (Some((xs, xk)), Some((ys, yk))), inner, a, b, out) { return false; } } @@ -442,7 +494,44 @@ pub fn step_damage(a: &Document, b: &Document) -> Option { } } let mut out = Rect::EMPTY; - if walk(&a.layers, &b.layers, (None, None), a, b, &mut out) { Some(out) } else { None } + if walk(&a.layers, &b.layers, (None, None), Around { reach: 0, spans_frame: false }, a, b, &mut out) { Some(out) } else { None } +} + +/// How many tiles that differ between `before` and `after` (in the surfaces of layers both have) +/// `hint` misses: a command's own damage rect that misses a changed tile is too narrow (#4053). +#[cfg(any(test, debug_assertions))] +pub(crate) fn hint_misses(before: &Document, after: &Document, hint: Rect) -> usize { + let mut misses = 0; + for (_, _, y) in after.walk() { + let Some(x) = before.layer(y.id) else { continue }; + layer_pixels_cmp(x, y, &mut |p, q, _| { + each_changed_tile(p, q, |t| misses += usize::from(t.intersect(&hint).is_empty())); + true + }); + } + misses +} + +/// The pixels a change from layer `a` to layer `b` (the same layer on either side of a step) can +/// have changed when only the pixels of its surfaces changed: the tiles that differ +/// ([`surface_damage`]), a mask's grown by its feather, all grown by the layer's own effects. +/// Empty when the layers render identically. `None` when anything else changed (a property, the +/// content kind, a surface's format or default pixel): the caller then takes the layer's whole +/// extents. Children of groups are not compared (the caller walks them). +fn tile_damage(a: &Layer, b: &Layer) -> Option { + let mut tiles = Rect::EMPTY; + let only_pixels = layer_pixels_cmp(a, b, &mut |x, y, feather| { + let Some(r) = surface_damage(x, y) else { return false }; + let r = if feather > 0.0 { photocraft_vector::feather::grow(r, photocraft_vector::feather::feather_sigma(feather)) } else { r }; + tiles = tiles.union(&r); + true + }); + if !only_pixels { + return None; + } + // Effects are equal on both sides here (they are compared before any surface). + let margin = if photocraft_compose::effects::has_effects(b) { photocraft_compose::effects::margin(b) } else { 0 }; + Some(if tiles.is_empty() || margin == 0 { tiles } else { tiles.inflate(margin) }) } /// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds @@ -485,19 +574,6 @@ fn inert_at(layers: &[Layer], k: usize) -> bool { && (l.clipped || layers.get(k + 1).is_none_or(|above| !above.clipped)) } -/// Report that adding layer `id` to the active document changed no pixels when it draws -/// nothing ([`inert_at`]), so the canvas doesn't recomposite every layer for it (#1771). Looks -/// only at the new layer and its neighbour, never at other layers' tiles. -pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { - let Some(st) = s.active_mut() else { return }; - let Some(path) = st.doc.path_of(id) else { return }; - let Some((&k, parent)) = path.split_last() else { return }; - let siblings = if parent.is_empty() { Some(st.doc.layers.as_slice()) } else { st.doc.layer_at(parent).and_then(Layer::children) }; - if siblings.is_some_and(|sib| inert_at(sib, k)) { - st.last_damage = Some(Rect::EMPTY); - } -} - /// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]), /// so the canvas recomposites only where its new layers draw instead of the whole document. /// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695). @@ -506,8 +582,9 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) { st.last_damage = step_damage(before, &st.doc); } -/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their -/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none. +/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or +/// the union of a group's children. Fill and adjustment layers have none. Cheap: Layers list, +/// artboards, copy and slices call it for every layer. pub fn layer_bounds(l: &Layer) -> Option { let r = match &l.content { LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), @@ -515,3 +592,68 @@ pub fn layer_bounds(l: &Layer) -> Option { }; (!r.is_empty()).then_some(r) } + +/// The bounds Align and Distribute move by, in a document of bounds `canvas`: [`layer_bounds`], +/// except that a shape layer is measured over everything it draws, beyond the canvas too (its +/// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that +/// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity +/// stop measures as it renders, and `sample` is the document's sample type (see +/// [`shape_pixel_bounds`]). +pub(crate) fn align_bounds(l: &Layer, canvas: Rect, sample: SampleType) -> Option { + let r = match &l.content { + LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas, sample)).fold(Rect::EMPTY, |a, b| a.union(&b)), + LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas, sample).unwrap_or_else(|| { + let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY); + // A fill that shows everywhere covers the canvas, rendered yet or not. + if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached } + }), + _ => return layer_bounds(l), + }; + (!r.is_empty()).then_some(r) +} + +/// Whether the shape's fill shows everywhere (an inverted path, or below 100 % density), so it has +/// no extent of its own. +fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool { + sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0)) +} + +/// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and +/// stroke bounds stand in. +const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20; + +/// Pixels [`shape_pixel_bounds`] renders at a time. +const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20; + +/// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the +/// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it +/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over +/// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill +/// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and +/// keeps the canvas-clipped measure (`None`). +pub(super) fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option { + if unbounded_fill(sh) { + return None; + } + let reach = photocraft_compose::shape_bounds(sh)?.inflate(1); + if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX { + return photocraft_compose::hard_shape_bounds(sh); + } + // Gradients lay out in the document's frame (as the cached render does); the feather and the + // clip cover the shape's whole reach, beyond the canvas too. + let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas); + // Alpha at the document's own precision, as the document stores it: an 8-bit render drops the + // faint stroke of a 16-bit or float document (the opaque body then aligns by a different + // extent than the document draws), and a float one would find alpha an 8-bit document never + // stores. A band of rows at a time keeps the working copy a few MB however large the shape. + let format = PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true); + let band_rows = i32::try_from((SHAPE_BOUNDS_BAND_PX / u64::from(reach.width().max(1))).max(1)).unwrap_or(i32::MAX); + let mut bounds = Rect::EMPTY; + let mut top = reach.y0; + while top < reach.y1 { + let bottom = top.saturating_add(band_rows).min(reach.y1); + bounds = bounds.union(&shape.render(format, Rect::new(reach.x0, top, reach.x1, bottom)).content_bounds()); + top = bottom; + } + (!bounds.is_empty()).then_some(bounds) +} diff --git a/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs b/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs index 3982eb9cff..f58ea7d6ae 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/layers_panel_parity_tests.rs @@ -242,16 +242,18 @@ fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() { 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 { + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); 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"); + assert!(s.active().unwrap().doc.selection.is_none(), "{cmd}: the selection went into the mask (#8801)"); } // An explicit path wins over the selection, as with the option on. + s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap(); for selected in [true, false] { if !selected { s.execute("select.deselect", json!({})).unwrap(); diff --git a/crates/engine/src/cmd/layer_multi_cmds/moves.rs b/crates/engine/src/cmd/layer_multi_cmds/moves.rs index 822e0f6ad1..1377e0a406 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/moves.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/moves.rs @@ -1,6 +1,5 @@ -use super::damage::note_damage; use super::selection::{reselect, same_named_targets, selected, top_level}; -use super::structure::check_group_depth; +use super::structure::{check_group_depth, relink_copies}; use super::{Document, EngineError, Layer, LayerContent, LayerId, Result, Session, Value, bad, int_i32, json}; /// A Layers-panel drag of the selected layer records its drop target by name (and which of @@ -151,7 +150,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } let mask_target = p.get("target").and_then(Value::as_str) == Some("mask"); let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - let ids = s.edit("Move", |doc, _| { + s.edit("Move", |doc, _| { let (alone, rest): (Vec, Vec) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone)); let mut ids = move_targets(doc, &rest); if ids.is_empty() && alone.is_empty() { @@ -181,7 +180,6 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { } Ok(ids) })?; - note_damage(s, &before, &ids); Ok(Value::Null) } @@ -303,16 +301,63 @@ pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option Option { + if doc.layer(id)?.artboard().is_some() { + return None; + } + doc.layer(doc.artboard_of(id)?)?.artboard().map(|board| board.rect) +} + +/// Where a dropped layer came from: the rect of the board it sat on, and whether its position was +/// locked there (by its own lock or one on a group or board around it). +#[derive(Clone, Copy)] +struct Origin { + board: Option, + pinned: bool, +} + +/// The moves that keep each of `ids` where it sat on its board after a drop into another board. +/// Boards share one canvas, so a layer re-parented without moving keeps its canvas position and +/// lands outside (and is clipped by) the board it was dropped on. `from[i]` is where `ids[i]` was +/// before the drop; the layer shifts by the offset between the two boards' origins. A layer that +/// stays on its board, joins one from outside any board, leaves for the top level, or has its +/// position locked stays where it is. The lock is read on both sides: a lock the layer had from a +/// group or board it left no longer shows on it once it has left (#10489 review). +fn board_offsets(doc: &Document, ids: &[LayerId], from: &[Origin]) -> Vec<(LayerId, i32, i32)> { + ids.iter() + .zip(from) + .filter_map(|(&id, src)| { + let (board, dst) = (src.board.as_ref()?, board_rect_of(doc, id)?); + let (dx, dy) = (dst.x0.checked_sub(board.x0)?, dst.y0.checked_sub(board.y0)?); + let locks = doc.effective_locks(id); + ((dx != 0 || dy != 0) && !src.pinned && !(locks.position || locks.all)).then_some((id, dx, dy)) + }) + .collect() +} + /// 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. +/// (`clip_group::settle`). A layer dropped on a different artboard keeps its place on its board +/// (see [`board_offsets`]). 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) }; + // Read before the layers are lifted out: afterwards the source board, and any lock the layer + // had from the groups around it, are out of reach. A copy is a new layer, so only its own + // locks (which it keeps) can pin it. + let from: Vec = moved_ids + .iter() + .map(|&id| { + let locks = doc.effective_locks(id); + Origin { board: board_rect_of(doc, id), pinned: !copy && (locks.position || locks.all) } + }) + .collect(); 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))? }); @@ -334,8 +379,21 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: & siblings.splice(at..at, moved); } check_group_depth(doc, "Reorder Layer")?; + // A copy is a new layer: it starts unlinked from its original, as a Duplicate does + // (#11217); copies of a whole linked set stay linked to each other under a fresh group. + if copy { + relink_copies(doc, &copies)?; + } + // The layers now sit in their new parents; carry each across to its new board. Copies keep the + // order of `moved_ids`, so `from` lines up with either list. + let landed: &[LayerId] = if copy { &copies } else { moved_ids }; + let shifts = board_offsets(doc, landed, &from); + if !shifts.is_empty() { + let snapshot = doc.clone(); + move_layers(doc, &snapshot, &shifts)?; + } crate::background_cmds::keep_background_at_bottom(background, doc)?; - crate::clip_group::settle(doc, if copy { &copies } else { moved_ids }, &before, false); + crate::clip_group::settle(doc, landed, &before, false); Ok(copies) } @@ -343,6 +401,38 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: & /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.moveTo"; + let indexed = p.get("target").is_some_and(Value::is_object); + let resolved; + let p = if let Some(Value::Object(selector)) = p.get("target") { + let index = selector + .get("index") + .filter(|_| selector.len() == 1) + .and_then(Value::as_u64) + .ok_or_else(|| bad(CMD, "target reference needs one non-negative index"))?; + if p.get("position").is_some_and(|value| !value.is_null()) { + return Err(bad(CMD, "an indexed target already defines its drop position")); + } + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let (target, position) = crate::commands::recorded_drop(doc, index)?; + let mut fields = p.as_object().cloned().ok_or_else(|| bad(CMD, "params must be an object"))?; + fields.insert("target".into(), json!(target.0)); + fields.insert("position".into(), json!(position)); + if !fields.contains_key("layer") && !fields.contains_key("layers") { + match selected(s).as_slice() { + [layer] => { + fields.insert("layer".into(), json!(layer.0)); + } + layers if !layers.is_empty() => { + fields.insert("layers".into(), json!(layers.iter().map(|layer| layer.0).collect::>())); + } + _ => {} + } + } + resolved = Value::Object(fields); + &resolved + } else { + p + }; let pos = p.get("position").and_then(Value::as_str).unwrap_or("above"); if !matches!(pos, "above" | "below" | "into") { return Err(bad(CMD, "position must be above, below, or into")); @@ -379,7 +469,7 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } } let moved_ids = top_level(doc, &requested); - if moved_ids.len() == 1 && moved_ids[0] == target && batch.is_none() && !copy { + if moved_ids.len() == 1 && moved_ids[0] == target && (batch.is_none() || indexed) && !copy { // Preserve single-layer drag-to-self semantics. return Ok(Value::Null); } @@ -396,6 +486,26 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, "target is not a group")); } + if indexed + && !copy + && let [source] = moved_ids.as_slice() + { + let source_path = doc.path_of(*source).ok_or(EngineError::NoLayer(*source))?; + if let Some((&at, parent)) = source_path.split_last() { + let unchanged = match pos { + "below" => target_path.split_last().is_some_and(|(&to, target_parent)| parent == target_parent && at.checked_add(1) == Some(to)), + "into" => { + parent == target_path.as_slice() + && doc.layer(target).and_then(Layer::children).is_some_and(|children| at.checked_add(1) == Some(children.len())) + } + _ => false, + }; + if unchanged { + return Ok(Value::Null); + } + } + } + let original_active = st.active_layer; 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| { diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index 8f66dfab7c..e5e361fb1c 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -133,12 +133,29 @@ pub(super) fn same_named_targets(doc: &photocraft_doc::Document, name: &str, mov /// (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 == "select.loadSelection" + && let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) + { + return recorded_source_layer(s, p, layer_id); + } + if crate::actions_record::needs_source_layer(id) { + // An explicit layer supplies content, even when it is the sole selection. + return p; + } if id == "layer.select" { return recorded_selection(s, p); } if id == "layer.moveTo" { return recorded_move(s, p, pre_selected); } + if id == "edit.transform" { + // Multi-layer transforms carry the set of ids separately. A single-layer transform + // uses the ordinary `layer` target, which should follow the sole selected layer when + // the action is replayed in a document with different ids. + if p.get("layers").is_some_and(Value::is_array) { + return recorded_transform(s, p, pre_selected); + } + } 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], @@ -154,6 +171,47 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio p } +/// ⌘-clicking another layer's thumbnail loads that layer's transparency. The step refers to the +/// layer the way the reference application records it, by name (or as the Background), so it +/// replays in another document and saves to `.atn`. A layer that the name would not find (a +/// same-named layer above it) keeps its id. +fn recorded_source_layer(s: &Session, mut p: Value, id: LayerId) -> Value { + let Some(doc) = s.active().map(|st| &st.doc) else { return p }; + let Some(layer) = doc.layer(id) else { return p }; + let reference = if doc.background().is_some_and(|b| b.id == id) { + json!({"background": true}) + } else if doc.walk().into_iter().rev().find(|(_, _, l)| l.name == layer.name).is_some_and(|(_, _, l)| l.id == id) { + json!({"name": layer.name}) + } else { + return p; + }; + if let Some(fields) = p.as_object_mut() { + fields.insert("layer".into(), reference); + } + p +} + +/// A Free Transform of several layers lists their ids (the box's session keeps them while it is +/// open). When they are the layers the selection gave it, the step drops `layers`, so it replays on +/// the layers selected then, in any document, rather than on these ids (#6501). +fn recorded_transform(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value { + let Some(st) = s.active() else { return p }; + let Some(listed) = p.get("layers").and_then(Value::as_array) else { return p }; + let Some(listed) = listed.iter().map(|v| v.as_u64().map(LayerId)).collect::>>() else { return p }; + let selected = pre_selected.map_or_else(|| st.selected_layers(), <[LayerId]>::to_vec); + let mut moved = top_level(&st.doc, &listed); + let mut targets = crate::transform_cmds::targets_of(&st.doc, &selected, st.active_layer); + moved.sort_unstable(); + moved.dedup(); + targets.sort_unstable(); + if moved == targets + && let Some(fields) = p.as_object_mut() + { + fields.remove("layers"); + } + p +} + fn selection_target(s: &Session, p: &Value) -> Result { let Some(name) = p.get("name") else { if p.get("occurrence").is_some() { @@ -193,6 +251,20 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { + if let Some(selector @ Value::Object(fields)) = p.get("layer") + && (fields.contains_key("index") || fields.contains_key("ordinal")) + && crate::commands::recorded_layer(s, selector)?.is_none() + { + if p.get("name").is_some() || p.get("occurrence").is_some() { + return Err(bad("layer.select", "a layer reference cannot be combined with name or occurrence")); + } + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); + if !matches!(mode, "replace" | "toggle" | "range" | "add" | "remove") { + return Err(bad("layer.select", format!("unknown mode `{mode}`"))); + } + // A group-end index leaves the active layer, anchor and full selection unchanged. + return Ok(json!({"selected": selected(s).iter().map(|id| id.0).collect::>()})); + } let id = selection_target(s, p)?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/layer_multi_cmds/structure.rs b/crates/engine/src/cmd/layer_multi_cmds/structure.rs index 70aa512223..c6cd8c1520 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/structure.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/structure.rs @@ -3,13 +3,28 @@ use super::{Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Re // ---------- structural commands ---------- -pub(super) fn link_layers(s: &mut Session) -> Result { +/// Set an explicit link state idempotently, or toggle when omitted, with singleton cleanup and Undo. +pub(super) fn link_layers(s: &mut Session, p: &Value) -> Result { + let requested = match p.get("linked") { + None => None, + Some(Value::Bool(value)) => Some(*value), + Some(_) => return Err(bad("layer.linkLayers", "`linked` must be true or false")), + }; let sel = selected(s); - let linked = s.edit("Link Layers", |doc, _| { - let groups: Vec> = sel.iter().map(|id| doc.layer(*id).and_then(|l| l.link_group)).collect(); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let groups = sel.iter().map(|id| doc.layer(*id).map(|l| l.link_group).ok_or(EngineError::NoLayer(*id))).collect::>>()?; + let first = groups.first().copied().flatten(); + let same_group = first.is_some() && groups.iter().all(|group| *group == first); + // A recorded Link/Unlink is an operation, not the UI's state-dependent toggle. + // An explicit repeat must neither allocate a group nor add an undo entry. + let linked_group = same_group && doc.walk().iter().filter(|(_, _, layer)| layer.link_group == first).take(2).count() == 2; + if requested == Some(true) && linked_group || requested == Some(false) && groups.iter().all(Option::is_none) { + return Ok(json!({"linked": requested == Some(true)})); + } + let label = if requested == Some(false) { "Unlink Layers" } else { "Link Layers" }; + let linked = s.edit(label, |doc, _| { // Already one link group (or a single linked layer): unlink, like Photoshop's toggle. - let first = groups.first().copied().flatten(); - let unlink = first.is_some() && groups.iter().all(|g| *g == first); + let unlink = requested.map_or(same_group, |linked| !linked) || sel.len() < 2; // A loaded file can store any id up to u64::MAX, so the new id must not be a bare // max + 1 (#961). Unlinking needs no new id at all. let next = if unlink { None } else { Some(doc.unused_link_group().ok_or_else(|| bad("layer.linkLayers", "no free link group id is left"))?) }; @@ -272,6 +287,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul } copies.push(nid); } + relink_copies(doc, &copies)?; let a = new_active.or(copies.last().copied()); *active = a; Ok((copies, a)) @@ -281,6 +297,49 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul Ok(json!({"layers": out, "layer": active.map(|l| l.0)})) } +/// Link groups of fresh copies, as the reference application does (measured, #11217): a copy of +/// one layer alone (Duplicate, Layer via Copy) joins its original's link group. With several +/// layers copied, the copies of several layers of one link group are linked to each other under a +/// group of their own, as `layer_copy_cmds::adapt_copies` remaps copies landing in another +/// document, and a copy that is the only one from its link group is unlinked. `copies` are the +/// copies just inserted in `doc` (the layers in copied groups are remapped with them); the +/// originals keep their groups. +pub(crate) fn relink_copies(doc: &mut Document, copies: &[LayerId]) -> Result<()> { + // One layer copied on its own keeps the link group it was copied with. + if let [only] = copies + && doc.layer(*only).is_some_and(|l| !matches!(l.content, LayerContent::Group(_))) + { + return Ok(()); + } + // Every copied layer with the link group it was copied with. An explicit stack rather than + // recursion: documents can nest deeply. + let mut linked: Vec<(LayerId, u64)> = Vec::new(); + let mut stack: Vec<&Layer> = copies.iter().filter_map(|id| doc.layer(*id)).collect(); + while let Some(l) = stack.pop() { + if let Some(group) = l.link_group { + linked.push((l.id, group)); + } + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter()); + } + } + let mut groups: Vec = linked.iter().map(|(_, group)| *group).collect(); + groups.sort_unstable(); + groups.dedup(); + for group in groups { + let members: Vec = linked.iter().filter(|(_, g)| *g == group).map(|(id, _)| *id).collect(); + // The id is picked after the previous group's copies took theirs, so no two share one. + let fresh = + if members.len() < 2 { None } else { Some(doc.unused_link_group().ok_or_else(|| EngineError::Other("no free link group id is left".into()))?) }; + for id in members { + if let Some(l) = doc.layer_mut(id) { + l.link_group = fresh; + } + } + } + Ok(()) +} + /// Show/hide every selected layer in one step. pub fn set_visible_selected(s: &mut Session, visible: bool) -> Result { let sel = selected(s); diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index e03081a598..0d27dab4a3 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -1,3 +1,6 @@ +use photocraft_color::SampleType; + +use super::damage::{align_bounds, shape_pixel_bounds}; use super::*; #[test] @@ -386,9 +389,6 @@ fn clipped_adjustment_in_pass_through_clip_base_damages_its_group() { 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); @@ -639,6 +639,86 @@ fn align_each_edge() { } } +/// #8660: a shape partly off the canvas aligns by everything it draws, not the part on the +/// canvas, so one click aligns the layers and a second moves nothing. +#[test] +fn align_uses_a_shape_layers_full_extent_off_the_canvas() { + let mut s = session(8); + let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds(), SampleType::U8).unwrap(); + let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap()); + let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds(), SampleType::U8); + assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render"); + // Everything else keeps the cheap measure of the cached render (no re-render per layer). + assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas"); + let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50)); + select_all(&mut s, &[shape, pixels]); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50)); + assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30)); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing"); + // Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer. + s.execute("layer.align.horizontalCenters", json!({})).unwrap(); + assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30)); + assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50)); + // A feathered shape is measured by its soft edge too, which reaches past the path. + let soft = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 50, 40, 20], "fill": "#00ff00"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("feather", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { + sh.feather = 4.0; + } + Ok(()) + }) + .unwrap(); + // Measured as Align does, in the document's frame: the feather off the canvas still counts. + let b = drawn(&s, soft); + assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}"); + // A fill that shows everywhere keeps the canvas-clipped measure. + s.edit("invert", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) { + sh.feather = 0.0; + sh.path.inverted = true; + } + Ok(()) + }) + .unwrap(); + let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") }; + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), SampleType::U8), None); + // Without a cached render yet, such a fill still measures as the canvas, not as empty. + let mut uncached = doc(&s).layer(soft).unwrap().clone(); + if let LayerContent::Shape(sh) = &mut uncached.content { + sh.cache = None; + } + assert_eq!(align_bounds(&uncached, doc(&s).bounds(), SampleType::U8), Some(doc(&s).bounds())); +} + +/// Review of #8779: shape extents are measured at the document's own alpha precision. A faint +/// stroke (0.001 opacity) is stored by a 16-bit or float document, so it counts in the extent +/// and Align moves the opaque body as the document draws it; an 8-bit document stores it as +/// nothing, so it does not widen the measure. +#[test] +fn align_bounds_follow_the_documents_alpha_precision() { + for (depth, sample, expected) in [ + (8, SampleType::U8, Rect::new(60, 60, 120, 120)), + (16, SampleType::U16, Rect::new(50, 50, 130, 130)), + (32, SampleType::F32, Rect::new(50, 50, 130, 130)), + ] { + let mut s = session(depth); + let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [60, 60, 60, 60], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("faint stroke", |doc, _| { + if let Some(LayerContent::Shape(sh)) = doc.layer_mut(id).map(|l| &mut l.content) { + sh.stroke = Some(photocraft_doc::ShapeStroke { width: 20.0, opacity: 0.001, ..Default::default() }); + } + Ok(()) + }) + .unwrap(); + assert_eq!(doc(&s).pixel_format().sample, sample); + let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("shape") }; + assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), sample), Some(expected), "depth {depth}"); + assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}"); + } +} + #[test] fn align_single_layer_to_selection() { for depth in [8, 16, 32] { @@ -1376,6 +1456,93 @@ fn inspect_reports_selection() { assert_eq!(v["layers"][0]["selected"], true); } +fn link_group(s: &Session, id: LayerId) -> Option { + doc(s).layer(id).unwrap().link_group +} + +/// A and B linked (red blocks), C unlinked: (session, A, B, C, A and B's link group). +fn linked_pair() -> (Session, LayerId, LayerId, LayerId, Option) { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5)); + let b = rect_layer(&mut s, Rect::new(10, 0, 15, 5)); + let c = rect_layer(&mut s, Rect::new(20, 0, 25, 5)); + select_all(&mut s, &[a, b]); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + let group = link_group(&s, a); + assert!(group.is_some() && link_group(&s, b) == group); + (s, a, b, c, group) +} + +/// #11217: a copy of one linked layer alone joins its original's link group; copied together with +/// an unlinked layer it is unlinked (both measured in the reference application). The originals +/// stay linked. +#[test] +fn duplicating_one_linked_layer_keeps_the_copy_in_its_link_group() { + let (mut s, a, b, c, group) = linked_pair(); + // One layer, and with an unlinked layer beside it (several selected). + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let copy = LayerId(s.execute("layer.duplicate", json!({"inPlace": true})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(link_group(&s, copy), group, "the lone copy joins the originals' link group"); + select_all(&mut s, &[a, c]); + let r = s.execute("layer.duplicate", json!({})).unwrap(); + let copies: Vec = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2); + assert!(copies.iter().all(|id| link_group(&s, *id).is_none()), "a lone copy of a group has nothing to stay linked to"); + assert_eq!((link_group(&s, a), link_group(&s, b), link_group(&s, c)), (group, group, None), "the originals keep their links"); +} + +/// #11217 review: copy-dragging layers in the panel goes through `apply_move`, which must +/// relink its copies the way Duplicate does: a lone copy joins its original's link group, a +/// copied pair is linked to each other under a fresh group, not to the originals. +#[test] +fn copy_dragging_linked_layers_relinks_the_copies() { + let (mut s, a, b, c, group) = linked_pair(); + let r = s.execute("layer.moveTo", json!({"layer": a.0, "target": c.0, "position": "above", "copy": true})).unwrap(); + let copy = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(link_group(&s, copy), group, "the lone copy-dragged copy joins the originals' link group"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links"); + + let r = s.execute("layer.moveTo", json!({"layers": [a.0, b.0], "target": c.0, "position": "above", "copy": true})).unwrap(); + let copies: Vec = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2); + let fresh = link_group(&s, copies[0]); + assert!(fresh.is_some() && fresh != group && link_group(&s, copies[1]) == fresh, "the copied pair is linked to each other under a fresh group"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links"); +} + +/// #11217 review: copies of a linked pair stay linked to each other under a new group, not the +/// originals', which keep theirs. +#[test] +fn duplicating_a_linked_pair_links_the_copies_to_each_other() { + let (mut s, a, b, _, group) = linked_pair(); + select_all(&mut s, &[a, b]); + let r = s.execute("layer.duplicate", json!({})).unwrap(); + let copies: Vec = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2); + let fresh = link_group(&s, copies[0]); + assert!(fresh.is_some() && fresh != group, "a group of their own: {fresh:?} vs {group:?}"); + assert_eq!(link_group(&s, copies[1]), fresh, "the copies are linked to each other"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep theirs"); + // Moving a copy moves its partner copy only. + let (a0, b0) = (bounds(&s, a), bounds(&s, b)); + s.execute("layer.translate", json!({"layer": copies[0].0, "dx": 3})).unwrap(); + assert_eq!(bounds(&s, copies[1]), b0.translate(3, 0)); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a0, b0)); +} + +/// #11217: Layer via Copy of one linked layer (no selection) joins its original's link group too +/// (measured): the same rule as `layer.duplicate`, on its own code path in `layer_via`. +#[test] +fn layer_via_copy_of_one_linked_layer_keeps_the_copy_in_its_link_group() { + let (mut s, a, b, _, group) = linked_pair(); + let layers = doc(&s).layers.len(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let copy = LayerId(s.execute("layer.new.layerViaCopy", json!({})).unwrap()["layer"].as_u64().unwrap()); + assert_eq!(doc(&s).layers.len(), layers + 1, "only the active layer is copied"); + assert_eq!(link_group(&s, copy), group, "the lone copy joins the originals' link group"); + assert_eq!((link_group(&s, a), link_group(&s, b)), (group, group), "the originals keep their links"); +} + /// #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] diff --git a/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs new file mode 100644 index 0000000000..c7acbff4a0 --- /dev/null +++ b/crates/engine/src/cmd/layer_multi_cmds/tile_damage_tests.rs @@ -0,0 +1,321 @@ +//! #9329 PR A: a pixel change damages only the tiles that changed, and command rects are hints +//! checked against them. + +use super::*; +use photocraft_geom::TILE_SIZE; + +fn session(w: u32, h: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + s +} + +fn doc(s: &Session) -> std::sync::Arc { + s.active().unwrap().doc.clone() +} + +fn damage(s: &Session) -> Option { + s.active().unwrap().last_damage +} + +/// The composite of `after` equals `before`'s outside `damage`. +fn unchanged_outside(before: &Document, after: &Document, damage: Rect) -> bool { + let (x, y) = (photocraft_compose::render(before, before.bounds()), photocraft_compose::render(after, after.bounds())); + let w = before.bounds().width() as i32; + x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) +} + +/// The tiles `r` overlaps, as one rect. +fn tiles_of(r: Rect) -> Rect { + let down = |v: i32| v.div_euclid(TILE_SIZE) * TILE_SIZE; + Rect::new(down(r.x0), down(r.y0), down(r.x1 - 1) + TILE_SIZE, down(r.y1 - 1) + TILE_SIZE) +} + +/// A new pixel layer with `rects` filled opaque, through one plain edit. +fn layer_with(s: &mut Session, rects: &[(Rect, [f32; 4])]) -> LayerId { + let id = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + for (r, px) in rects { + surf.fill_rect(*r, px); + } + Ok(()) + }) + .unwrap(); + id +} + +fn full_layer(s: &mut Session) -> LayerId { + let canvas = doc(s).bounds(); + layer_with(s, &[(canvas, [0.4, 0.5, 0.6, 1.0])]) +} + +/// One plain edit of layer `id`'s pixels, returning the documents on either side. +fn paint(s: &mut Session, id: LayerId, r: Rect, px: [f32; 4]) -> (std::sync::Arc, std::sync::Arc) { + let before = doc(s); + s.edit("paint", |doc, _| { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(r, &px); + Ok(()) + }) + .unwrap(); + (before, doc(s)) +} + +#[test] +fn a_fill_on_a_selection_damages_only_its_tiles() { + let mut s = session(1024, 768); + full_layer(&mut s); + let sel = Rect::new(300, 300, 440, 410); + s.execute("select.rect", json!({"x": sel.x0, "y": sel.y0, "width": sel.width(), "height": sel.height()})).unwrap(); + let before = doc(&s); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let d = damage(&s).expect("a bounded fill"); + assert!(!d.is_empty() && tiles_of(sel).contains_rect(&d), "{d:?}"); + assert!(d.size().area() < doc(&s).bounds().size().area() / 4, "{d:?}"); + assert!(unchanged_outside(&before, &doc(&s), d)); +} + +#[test] +fn undo_of_a_brush_dab_on_a_full_layer_redraws_only_its_tiles() { + let mut s = session(1024, 768); + full_layer(&mut s); + let four_tiles = 4 * (TILE_SIZE as u64).pow(2); + let before = doc(&s); + s.execute("paint.stroke", json!({"points": [[500, 400]], "size": 60, "color": "#ff0000"})).unwrap(); + let painted = doc(&s); + let d = damage(&s).expect("the dab"); + assert!(!d.is_empty() && d.size().area() <= four_tiles, "dab {d:?}"); + assert!(unchanged_outside(&before, &painted, d)); + assert!(s.undo()); + let d = damage(&s).expect("undo is bounded"); + assert!(!d.is_empty() && d.size().area() <= four_tiles, "undo {d:?}"); + assert!(unchanged_outside(&painted, &doc(&s), d)); + assert!(s.redo()); + let d = damage(&s).expect("redo is bounded"); + assert!(!d.is_empty() && d.size().area() <= four_tiles, "redo {d:?}"); + assert!(unchanged_outside(&before, &doc(&s), d)); +} + +/// The damage `note_damage` used to report after a move: both extents of the moved layer. +#[test] +fn a_move_still_damages_both_extents() { + let mut s = session(1024, 768); + let r = Rect::new(100, 100, 200, 200); + let id = layer_with(&mut s, &[(r, [1.0, 0.0, 0.0, 1.0])]); + let before = doc(&s); + s.execute("layer.translate", json!({"layer": id.0, "dx": 300, "dy": 50})).unwrap(); + let after = doc(&s); + let canvas = after.bounds(); + let extents = |d: &Document| photocraft_compose::change_bounds(d.layer(id).unwrap(), canvas).unwrap(); + let both = extents(&before).union(&extents(&after)); + assert_eq!(both, r.union(&r.translate(300, 50))); + assert_eq!(damage(&s), Some(both)); + assert!(unchanged_outside(&before, &after, both)); +} + +#[test] +fn set_props_damage_is_unchanged_without_note_damage() { + let r = Rect::new(100, 100, 200, 200); + for props in [json!({"opacity": 0.5}), json!({"fill": 0.5}), json!({"blend": "multiply"}), json!({"visible": false})] { + let mut s = session(1024, 768); + let id = layer_with(&mut s, &[(r, [1.0, 0.0, 0.0, 0.8])]); + let before = doc(&s); + let mut p = props.clone(); + p["layer"] = json!(id.0); + s.execute("layer.setProps", p).unwrap(); + assert_eq!(damage(&s), Some(r), "{props}"); + assert!(unchanged_outside(&before, &doc(&s), r), "{props}"); + } +} + +#[test] +fn a_mask_edit_damages_its_tiles_grown_by_the_feather() { + let mut s = session(1024, 768); + let id = full_layer(&mut s); + let feather = 10.0; + s.edit("mask", |doc, _| { + doc.layer_mut(id).unwrap().mask = Some(photocraft_doc::LayerMask { feather, ..photocraft_doc::LayerMask::reveal_all() }); + Ok(()) + }) + .unwrap(); + let before = doc(&s); + let hole = Rect::new(600, 400, 610, 410); + s.edit("mask paint", |doc, _| { + doc.layer_mut(id).unwrap().mask.as_mut().unwrap().surface.fill_rect(hole, &[0.0]); + Ok(()) + }) + .unwrap(); + let after = doc(&s); + let d = step_damage(&before, &after).unwrap(); + let grown = photocraft_vector::feather::grow(tiles_of(hole), photocraft_vector::feather::feather_sigma(feather)); + assert_eq!(d, grown.intersect(&after.bounds())); + assert!(d.contains_rect(&tiles_of(hole)) && d != after.bounds(), "{d:?}"); + assert!(unchanged_outside(&before, &after, d)); +} + +#[test] +fn tile_damage_grows_by_the_layer_effect_margin() { + let mut s = session(1024, 768); + let (a, b) = (Rect::new(50, 50, 150, 150), Rect::new(600, 400, 700, 500)); + let id = layer_with(&mut s, &[(a, [1.0, 0.0, 0.0, 1.0]), (b, [1.0, 0.0, 0.0, 1.0])]); + s.edit("fx", |doc, _| { + doc.layer_mut(id).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + Ok(()) + }) + .unwrap(); + let m = photocraft_compose::effects::margin(doc(&s).layer(id).unwrap()); + assert!(m > 0); + let (before, after) = paint(&mut s, id, Rect::new(640, 440, 660, 460), [0.0, 0.0, 0.0, 0.0]); + let d = step_damage(&before, &after).unwrap(); + let whole = photocraft_compose::change_bounds(after.layer(id).unwrap(), after.bounds()).unwrap(); + assert_eq!(d, tiles_of(b).inflate(m).intersect(&whole)); + assert!(d.x0 < tiles_of(b).x0 && d.y0 < tiles_of(b).y0, "the shadow reaches past the tile: {d:?}"); + assert!(unchanged_outside(&before, &after, d)); +} + +#[test] +fn a_dab_inside_a_group_with_a_drop_shadow_damages_the_shadow() { + let mut s = session(1024, 768); + let child = layer_with(&mut s, &[(Rect::new(600, 400, 700, 500), [1.0, 0.0, 0.0, 1.0])]); + s.execute("layer.select", json!({"layer": child.0})).unwrap(); + let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("fx", |doc, _| { + doc.layer_mut(g).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + Ok(()) + }) + .unwrap(); + assert!(photocraft_compose::effects::margin(doc(&s).layer(g).unwrap()) > 0); + let dab = Rect::new(640, 440, 660, 460); + let (before, after) = paint(&mut s, child, dab, [0.0, 0.0, 1.0, 1.0]); + let d = damage(&s).expect("a bounded dab"); + assert!(d.contains_rect(&dab), "{d:?}"); + assert!(unchanged_outside(&before, &after, d), "the group's shadow changed outside {d:?}"); +} + +#[test] +fn painting_a_clip_base_under_a_styled_clipped_layer_is_covered() { + let mut s = session(1024, 768); + let base = layer_with(&mut s, &[(Rect::new(500, 300, 800, 600), [0.0, 1.0, 0.0, 1.0])]); + let top = full_layer(&mut s); + s.edit("clip", |doc, _| { + let l = doc.layer_mut(top).unwrap(); + l.clipped = true; + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + Ok(()) + }) + .unwrap(); + let (before, after) = paint(&mut s, base, Rect::new(300, 200, 340, 240), [0.0, 0.0, 1.0, 1.0]); + let d = damage(&s).expect("a bounded base edit"); + assert!(unchanged_outside(&before, &after, d), "the clipped layer changed outside {d:?}"); + let (before, after) = paint(&mut s, base, Rect::new(640, 440, 660, 460), [0.0, 0.0, 0.0, 0.0]); + let d = damage(&s).expect("a bounded base edit"); + assert!(unchanged_outside(&before, &after, d), "the clipped layer changed outside {d:?}"); +} + +/// #4053: effects laid out over the layer's frame repaint it everywhere when it grows. +#[test] +fn a_frame_spanning_effect_still_redraws_its_whole_frame() { + use photocraft_doc::{Effect, FxCommon, Gradient}; + let mut s = session(1024, 768); + let id = layer_with(&mut s, &[(Rect::new(100, 100, 700, 200), [1.0, 0.0, 0.0, 1.0])]); + s.edit("fx", |doc, _| { + doc.layer_mut(id).unwrap().effects.items.push(Effect::GradientOverlay { + common: FxCommon::new(photocraft_doc::BlendMode::Normal, 1.0), + gradient: Gradient::default(), + dither: false, + interpolation: None, + }); + Ok(()) + }) + .unwrap(); + let (before, after) = paint(&mut s, id, Rect::new(700, 100, 800, 200), [0.0, 1.0, 0.0, 1.0]); + let d = step_damage(&before, &after).unwrap(); + let extents = |d: &Document| photocraft_compose::change_bounds(d.layer(id).unwrap(), d.bounds()).unwrap(); + let whole = extents(&before).union(&extents(&after)); + assert!(whole.contains_rect(&Rect::new(100, 100, 800, 200)), "{whole:?}"); + assert_eq!(d, whole); + assert!(unchanged_outside(&before, &after, d)); +} + +#[test] +fn refreshing_type_layers_damages_only_those_layers() { + let mut s = session(1500, 1000); + let one = LayerId(s.execute("type.create", json!({"x": 100, "y": 100, "text": "One", "size": 20})).unwrap()["layer"].as_u64().unwrap()); + let two = LayerId(s.execute("type.create", json!({"x": 1000, "y": 800, "text": "Two", "size": 20})).unwrap()["layer"].as_u64().unwrap()); + let before = doc(&s); + let busy = s.refresh_type_layers(&[(before.id, one)]); + assert!(busy.is_empty()); + let after = doc(&s); + let d = damage(&s).expect("a bounded refresh"); + let bounds = |d: &Document, id: LayerId| photocraft_compose::change_bounds(d.layer(id).unwrap(), d.bounds()).unwrap(); + assert!(bounds(&before, one).union(&bounds(&after, one)).contains_rect(&d), "{d:?}"); + assert!(d.intersect(&bounds(&after, two)).is_empty(), "{d:?}"); + assert!(unchanged_outside(&before, &after, d)); +} + +#[test] +fn a_hint_that_misses_a_changed_tile_is_counted() { + let mut s = session(1024, 768); + let id = full_layer(&mut s); + paint(&mut s, id, Rect::new(600, 400, 620, 420), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(s.damage_misses, 0); + s.hint_damage(Rect::new(0, 0, 10, 10)); + assert_eq!(s.damage_misses, 1); + assert_eq!(damage(&s), Some(Rect::EMPTY)); + // A hint covering the change is not a miss. + paint(&mut s, id, Rect::new(600, 400, 620, 420), [0.0, 1.0, 0.0, 1.0]); + s.hint_damage(Rect::new(600, 400, 620, 420)); + assert_eq!(s.damage_misses, 1); + assert_eq!(damage(&s), Some(Rect::new(600, 400, 620, 420))); +} + +#[test] +fn painting_command_hints_cover_their_changed_tiles() { + let mut s = session(600, 400); + let mut rects = Vec::new(); + for by in 0..20 { + for bx in 0..30 { + let v = ((bx * 7 + by * 13) % 17) as f32 / 17.0; + rects.push((Rect::from_xywh(bx * 20, by * 20, 20, 20), [v, 1.0 - v, 0.5 * v + 0.2, 1.0])); + } + } + rects.push((Rect::new(430, 280, 470, 320), [0.1, 0.1, 0.1, 1.0])); + for y in 292..309 { + for x in 442..459 { + if (x - 450) * (x - 450) + (y - 300) * (y - 300) <= 64 { + rects.push((Rect::from_xywh(x, y, 1, 1), [0.9, 0.05, 0.05, 1.0])); + } + } + } + layer_with(&mut s, &rects); + let ty = s.execute("type.create", json!({"x": 40, "y": 380, "text": "Hi", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + let mut steps: Vec<(&str, serde_json::Value, Option)> = vec![ + ("paint.stroke", json!({"points": [[100, 100], [300, 120]], "size": 20, "color": "#2040ff"}), None), + ("paint.backgroundEraser", json!({"points": [[120, 300], [200, 320]], "size": 20}), None), + ("paint.cloneStamp", json!({"points": [[400, 100]], "offset": [-100, 0], "size": 30, "hardness": 100}), None), + ("paint.healingBrush", json!({"points": [[420, 200]], "source": [300, 200], "size": 30, "hardness": 100}), None), + ("paint.redEye", json!({"x": 450, "y": 300}), None), + ("paint.patch", json!({"offset": [60, 0]}), Some(Rect::new(60, 250, 100, 290))), + ("paint.remove", json!({"points": [[500, 100], [540, 140]], "size": 24}), None), + ("paint.contentAwareMove", json!({"offset": [100, -50], "structure": 3, "color": 4}), Some(Rect::new(300, 300, 340, 340))), + ("paint.patternStamp", json!({"points": [[200, 200]], "size": 16, "hardness": 100, "pattern": "Checkerboard", "scale": 100, "aligned": true}), None), + ]; + steps.push(("type.setStyle", json!({"layer": ty, "size": 40}), None)); + for (cmd, p, sel) in steps { + if let Some(r) = sel { + s.execute("select.rect", json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()})).unwrap(); + } else if doc(&s).selection.is_some() { + s.execute("select.deselect", json!({})).unwrap(); + } + if cmd != "type.setStyle" { + let layer = doc(&s).layers.iter().find(|l| l.surface().is_some() && !doc(&s).is_background(l.id)).unwrap().id; + s.select_layer(layer).unwrap(); + } + let before = doc(&s); + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + assert_eq!(s.damage_misses, 0, "{cmd}: its hint missed a changed tile"); + assert!(!same_pixels(&before, &doc(&s)), "{cmd} changed nothing"); + let d = damage(&s).unwrap_or_else(|| doc(&s).bounds()); + assert!(unchanged_outside(&before, &doc(&s), d), "{cmd}: pixels changed outside {d:?}"); + } +} diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index dddac7ab62..82726f8369 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -4,25 +4,42 @@ use super::{BlendMode, Document, EngineError, Layer, LayerId, LayerMask, Result, /// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that /// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays /// in another document; a name that matches no layer exactly matches regardless of case, so -/// `"background"` finds the Background). `None` when the param is absent. +/// `"background"` finds that name). A recorded `{"name":"Layer"}` uses the same lookup. +/// `{"background":true}` targets the actual Background, independently of its name. `None` +/// when the param is absent. pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> { + let named = |name: &str| { + let d = s.active().ok_or(EngineError::NoDocument)?; + let layers = d.doc.walk(); + let mut top_down = layers.iter().rev(); + top_down + .clone() + .find(|(_, _, l)| l.name == name) + .or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase())) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) + }; match p.get("layer") { Some(Value::Number(n)) => { n.as_u64().map(|id| Some(LayerId(id))).ok_or_else(|| EngineError::Other(format!("layer id {n} is not a non-negative integer"))) } - Some(Value::String(name)) => { + Some(Value::String(name)) => named(name), + Some(Value::Object(selector)) + if selector.len() == 1 + && let Some(name) = selector.get("name").and_then(Value::as_str) => + { + named(name) + } + // A group end is no layer: fail rather than fall back to the selection. + Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => { + super::recorded_layer(s, v)?.map(Some).ok_or_else(|| EngineError::Other("a group end is not a layer".into())) + } + Some(Value::Object(selector)) if selector.len() == 1 && selector.get("background") == Some(&Value::Bool(true)) => { let d = s.active().ok_or(EngineError::NoDocument)?; - let layers = d.doc.walk(); - let mut top_down = layers.iter().rev(); - top_down - .clone() - .find(|(_, _, l)| l.name == *name) - .or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase())) - .map(|(_, _, l)| Some(l.id)) - .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) + d.doc.background().map(|l| Some(l.id)).ok_or_else(|| EngineError::Other("the document has no Background layer".into())) } Some(Value::Null) | None => Ok(None), - Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), + Some(other) => Err(EngineError::Other(format!("`layer` must be an id, a name, {{\"name\":string}} or {{\"background\":true}}, not {other}"))), } } @@ -63,7 +80,7 @@ pub fn blend_from_str(s: &str) -> Option { /// The active selection as the mask of a layer being created, or None without a selection. /// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on -/// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it. +/// the selected area only (#1250). [`mask_new_layer`] then drops the selection (#8801). pub(crate) fn selection_mask(doc: &Document) -> Result> { doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose() } @@ -88,9 +105,10 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool /// 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, default_mask: bool) -> Result<()> { +/// layer mask ([`selection_mask`]) and is dropped, as Reveal Selection drops it (#5251, #8801); +/// 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: &mut 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"))?), @@ -101,9 +119,13 @@ 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 = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?; - } + None => match selection_mask(doc)? { + Some(mask) => { + l.mask = Some(mask); + doc.selection = None; + } + None => l.mask = default_mask.then(LayerMask::reveal_all).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?, + }, } Ok(()) } diff --git a/crates/engine/src/cmd/layer_references.rs b/crates/engine/src/cmd/layer_references.rs new file mode 100644 index 0000000000..3a07ad63a1 --- /dev/null +++ b/crates/engine/src/cmd/layer_references.rs @@ -0,0 +1,79 @@ +//! Recorded layer references use the full stack, including hidden group-end entries. + +use super::{Document, EngineError, LayerId, Result, Session, Value}; + +#[derive(Clone, Copy)] +enum Entry { + Layer(LayerId), + GroupEnd(LayerId), +} + +/// Bottom-to-top recording order, independent of collapsed Layers panel rows. +fn stack(doc: &Document) -> Vec { + let mut pending: Vec<_> = doc.layers.iter().rev().map(|layer| (layer, false)).collect(); + let mut out = Vec::new(); + while let Some((layer, visited)) = pending.pop() { + if !visited && let Some(children) = layer.children() { + out.push(Entry::GroupEnd(layer.id)); + pending.push((layer, true)); + pending.extend(children.iter().rev().map(|child| (child, false))); + } else { + out.push(Entry::Layer(layer.id)); + } + } + out +} + +fn bad(message: impl Into) -> EngineError { + EngineError::Other(message.into()) +} + +/// A Background occupies index zero; without it the first stack entry is one. +fn at_index(doc: &Document, index: u64) -> Result { + let first = u64::from(doc.background().is_none()); + let entry = index.checked_sub(first).and_then(|n| usize::try_from(n).ok()).and_then(|n| stack(doc).get(n).copied()); + entry.ok_or_else(|| bad(format!("no layer at stack index {index}"))) +} + +/// Resolve one typed selector; a group-end index succeeds without selecting a layer. +pub(crate) fn recorded_layer(s: &Session, selector: &Value) -> Result> { + let fields = selector.as_object().filter(|fields| fields.len() == 1).ok_or_else(|| bad("layer reference needs one index or ordinal"))?; + let st = s.active().ok_or(EngineError::NoDocument)?; + if let Some(index) = fields.get("index") { + let index = index.as_u64().ok_or_else(|| bad("layer index must be a non-negative integer"))?; + return Ok(match at_index(&st.doc, index)? { + Entry::Layer(id) => Some(id), + Entry::GroupEnd(_) => None, + }); + } + let ordinal = fields.get("ordinal").and_then(Value::as_str).ok_or_else(|| bad("layer reference needs an index or ordinal"))?; + let rows: Vec<_> = stack(&st.doc).into_iter().filter_map(|entry| if let Entry::Layer(id) = entry { Some(id) } else { None }).collect(); + let last = rows.len().checked_sub(1).ok_or_else(|| bad("the document has no layers"))?; + let current = st.active_layer.and_then(|id| rows.iter().position(|layer| *layer == id)); + let target = match ordinal { + "Frnt" => last, + "Back" => 0, + "Trgt" => current.ok_or_else(|| bad("no active layer"))?, + "Frwr" => current.ok_or_else(|| bad("no active layer"))?.saturating_add(1).min(last), + "Bckw" => current.ok_or_else(|| bad("no active layer"))?.saturating_sub(1), + _ => return Err(bad(format!("unsupported layer ordinal {ordinal}"))), + }; + rows.get(target).copied().map(Some).ok_or_else(|| bad("no layer there")) +} + +/// Convert a pre-move stack boundary to an existing layer.moveTo drop position. +pub(crate) fn recorded_drop(doc: &Document, index: u64) -> Result<(LayerId, &'static str)> { + if index == 0 { + if doc.background().is_some() { + return Err(bad("cannot move a layer below the Background")); + } + return doc.layers.first().map(|layer| (layer.id, "below")).ok_or_else(|| bad("the document has no layers")); + } + match at_index(doc, index)? { + Entry::GroupEnd(id) => Ok((id, "below")), + Entry::Layer(id) => { + let position = if doc.layer(id).is_some_and(|layer| layer.is_group()) { "into" } else { "below" }; + Ok((id, position)) + } + } +} diff --git a/crates/engine/src/cmd/lens_cmds.rs b/crates/engine/src/cmd/lens_cmds.rs index 66a5ccfcf6..1192281744 100644 --- a/crates/engine/src/cmd/lens_cmds.rs +++ b/crates/engine/src/cmd/lens_cmds.rs @@ -254,10 +254,7 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect) // ---------- commands ---------- fn target(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(v) => Ok(LayerId(v)), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), - } + crate::commands::layer_param(s, p) } fn camera_info(doc: &Document) -> Option { @@ -285,8 +282,11 @@ fn raw_enabled(s: &Session) -> std::result::Result<(), String> { /// Runs `f` on the target layer (pixel layer edited in place inside the selection; smart object /// gets a smart filter with `stored` params). -fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: bool, f: &dyn Fn(&Surface, Rect) -> Surface) -> Result { - let id = target(s, &stored)?; +fn run_filter(s: &mut Session, cmd: &str, label: &str, p: &Value, stored: Value, float_bg: bool, f: &dyn Fn(&Surface, Rect) -> Surface) -> Result { + let state = s.active().ok_or(EngineError::NoDocument)?; + let _working = photocraft_raster::memory::try_reserve_operation(state.doc.size.area().saturating_mul(64)).map_err(EngineError::Other)?; + let id = target(s, p)?; + let colors = crate::color_channels::target(s, p)?.is_some(); let mut stored = stored; if let Value::Object(m) = &mut stored { m.remove("layer"); @@ -306,7 +306,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } // A Background layer gains transparency when the edge mode exposes it (as in Photoshop). - if float_bg && background { + if float_bg && background && !colors { l.locks.position = false; l.locks.transparency = false; l.name = "Layer 0".into(); @@ -332,7 +332,7 @@ fn lens_correction(s: &mut Session, p: &Value) -> Result { 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))?; + run_filter(s, LENS, "Lens Correction", p, stored.clone(), matches!(lc.edge, EdgeMode::Transparency), &|surf, canvas| lens::correct(surf, canvas, &lc))?; Ok(json!({"layer": id.0, "profile": lc.profile.as_ref().map(|p| p.name.clone()), "angle": lc.angle, "scale": lc.scale, "params": stored, "ms": t0.ms()})) } @@ -349,7 +349,7 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result { stored["layer"] = l.clone(); } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); - let id = run_filter(s, WIDE, "Adaptive Wide Angle", stored.clone(), true, &|surf, canvas| { + let id = run_filter(s, WIDE, "Adaptive Wide Angle", p, stored.clone(), true, &|surf, canvas| { let tris = mesh.triangles(); if canvas == frame { photocraft_algo::warp::warp_triangles(surf, canvas, &mesh.verts, &tris, interp) @@ -417,7 +417,7 @@ fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result { } } let t0 = Stopwatch::start(); - let id = run_filter(s, RAW, "Camera Raw Filter", p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?; + let id = run_filter(s, RAW, "Camera Raw Filter", p, p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?; Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.ms()})) } diff --git a/crates/engine/src/cmd/mask_view_cmds/tests.rs b/crates/engine/src/cmd/mask_view_cmds/tests.rs index ce9bffa744..82179a5456 100644 --- a/crates/engine/src/cmd/mask_view_cmds/tests.rs +++ b/crates/engine/src/cmd/mask_view_cmds/tests.rs @@ -44,7 +44,7 @@ fn modes_toggle_and_show_in_inspect_without_history() { fn a_mask_view_never_alters_pixels() { let (mut s, masked, _) = session(); let before = s.active().unwrap().doc.clone(); - let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).px; + let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).into_px(); let composite = px(&s); for mode in ["gray", "overlay", "toggleOverlay", "toggleGray", "off"] { s.execute(ID, json!({"layer": masked, "mode": mode})).unwrap(); diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 413b768846..3043f080e6 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -12,7 +12,7 @@ use photocraft_algo::transform::{Homography, Interp}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; -use photocraft_geom::Affine; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -186,14 +186,12 @@ fn with_released_movement_locks(l: &mut Layer, f: impl FnOnce(&mut Layer) -> /// The flattened image as straight RGBA (over white when `opaque`). fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> { let buf = photocraft_compose::flatten(doc); - if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px } + if opaque { buf.over_background([1.0, 1.0, 1.0]).into_px() } else { buf.into_px() } } /// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format. fn single_layer(doc: &mut Document, active: &mut Option, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) { - doc.mode = mode; - doc.depth = SampleType::U8; - let fmt = doc.pixel_format(); + let fmt = storage_format(mode); // Reuse one format-sized pixel buffer instead of allocating a Vec for every pixel. let mut pixel = vec![0.0; fmt.channels()]; let mut data = Vec::new(); @@ -201,14 +199,28 @@ fn single_layer(doc: &mut Document, active: &mut Option, mode: ColorMod photocraft_raster::from_rgba_into(&fmt, *q, &mut pixel); data.extend_from_slice(&pixel); } - let mut l = Layer::raster(name, fmt); + let mut surf = Surface::new(fmt); + surf.write_region(doc.bounds(), &data); + single_layer_surface(doc, active, mode, surf, name, background); +} + +/// The pixel format of an 8-bit document in `mode`. +fn storage_format(mode: ColorMode) -> PixelFormat { + Document::pixel_format_for(mode, SampleType::U8) +} + +/// [`single_layer`] with the layer's pixels already in a surface of [`storage_format`]`(mode)`. +fn single_layer_surface(doc: &mut Document, active: &mut Option, mode: ColorMode, pixels: Surface, name: &str, background: bool) { + doc.mode = mode; + doc.depth = SampleType::U8; + let mut l = Layer::raster(name, doc.pixel_format()); if background { l.locks.transparency = true; l.locks.position = true; } // A new raster layer always has pixels. if let Some(surf) = l.surface_mut() { - surf.write_region(doc.bounds(), &data); + *surf = pixels; } *active = Some(l.id); doc.layers = vec![l]; @@ -411,14 +423,70 @@ fn color_table(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The colour table painting on `p`'s target must stay inside: an Indexed Color document's, +/// when the target is layer pixels (masks and channels are gray, not indexed). +pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option { + let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; + doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed) +} + +/// The colour `c` paints as in a document with `table` (see [`keep_in_color_table`]). +pub(crate) fn table_color(table: &ColorTable, c: [f32; 4]) -> [f32; 4] { + let mut px = [c]; + if !table.colors.is_empty() { + quantize::quantize(&mut px, 1, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + } + let [c] = px; + c +} + +/// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest +/// colour-table entry, as the Indexed Color conversion does with no dither, so painting never +/// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas. +pub(crate) fn keep_in_color_table(table: &ColorTable, pre: &Surface, surf: &mut Surface, r: Rect) { + if table.colors.is_empty() { + return; + } + let fmt = surf.format(); + let n = fmt.channels(); + let mut enc = [0.0f32; 8]; + for tc in r.tiles() { + let t = tc.rect().intersect(&r); + if t.is_empty() || !surf.has_tiles_in(t) { + continue; + } + let (before, mut data) = (pre.read_region(t), surf.read_region(t)); + let (mut changed, mut px) = (Vec::new(), Vec::new()); + for (i, (q, b)) in data.chunks_exact(n).zip(before.chunks_exact(n)).enumerate() { + if q != b { + changed.push(i); + px.push(photocraft_raster::to_rgba(&fmt, q)); + } + } + if px.is_empty() { + continue; + } + let w = px.len(); + quantize::quantize(&mut px, w, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from)); + let mut mapped = changed.into_iter().zip(px).peekable(); + for (i, q) in data.chunks_exact_mut(n).enumerate() { + if let Some((_, c)) = mapped.next_if(|(j, _)| *j == i) { + photocraft_raster::from_rgba_into(&fmt, c, &mut enc); + q.iter_mut().zip(enc).for_each(|(d, v)| *d = v); + } + } + surf.write_region(t, &data); + } +} + // ---------- 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; +/// Bytes per pixel the Bitmap conversion holds besides the document: its 8-bit gray + alpha +/// result. The composite is converted in bands of [`photocraft_compose::BAND_PIXELS`] (#8477). +const BITMAP_WORK_BYTES_PER_PX: u64 = 2; /// Bitmap's Custom Pattern: a pattern's threshold map (see /// [`quantize::custom_pattern_thresholds`]) with its width and height. @@ -463,12 +531,9 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { }; (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")?; - } + // Besides the (resampled) document, the conversion holds its result at the new size: budget + // both before allocating. + 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") { @@ -504,15 +569,33 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { 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(); 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); + let mut strips = match &custom { + Some((map, mw, mh)) => quantize::BitmapStrips::custom(w, map, *mw, *mh), + None => quantize::BitmapStrips::new(w, method), + }; + // Band by band, top to bottom: a full-canvas f32 composite of a 30000 px square image is + // 14 GB, and allocating it (and its copies) aborted the app (#8477). + let fmt = storage_format(ColorMode::Bitmap); + let n = fmt.channels(); + let mut out = Surface::new(fmt); + let (mut gray, mut data) = (Vec::new(), Vec::new()); + let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> std::result::Result<(), std::convert::Infallible> { + gray.clear(); + gray.extend(band.px.iter().map(|q| { + // Over white, as the flattened image. + let a = q[3]; + photocraft_color::convert::rgb_to_gray([q[0] * a + (1.0 - a), q[1] * a + (1.0 - a), q[2] * a + (1.0 - a)]) + })); + strips.convert(&mut gray); + data.resize(gray.len() * n, 0.0); + for (g, px) in gray.iter().zip(data.chunks_exact_mut(n)) { + photocraft_raster::from_rgba_into(&fmt, [*g, *g, *g, 1.0], px); + } + out.write_region(band.rect, &data); + Ok(()) + }); + single_layer_surface(doc, active, ColorMode::Bitmap, out, "Background", true); Ok(()) })?; let size = s.active().ok_or(EngineError::NoDocument)?.doc.size; diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 21836ff5ae..4d3c97b02b 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -525,15 +525,13 @@ fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() { #[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. + // 100 x 100 px at 1 ppi to 1000 ppi is 100000 x 100000 px: 10 gigapixels. The resampled + // document and the conversion's 8-bit result at that size are 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(); + let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 1000})).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); @@ -554,6 +552,108 @@ fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() } } +#[test] +fn bitmap_budgets_a_conversion_at_the_document_resolution_too() { + // #8477: without a resample nothing was budgeted, and a huge document's full-size f32 + // working buffers aborted the app. 60000 x 60000 px (3.6 gigapixels, a plain white + // background shares one tile) needs more than the 8 GiB budget at any depth: refused. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 60_000, "height": 60_000, "mode": "grayscale"})).unwrap(); + let before = s.active().unwrap().history.past_len(); + let e = s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap_err().to_string(); + assert!(e.contains("megapixels"), "{e}"); + assert_eq!(s.active().unwrap().history.past_len(), before); + assert_eq!(doc(&s).mode, ColorMode::Grayscale); +} + +/// The Bitmap conversion of `d` the pre-#8477 way, from one full-canvas composite over white. +fn bitmap_oracle(d: &Document, method: Option, custom: Option<(&[f32], usize, usize)>) -> Vec { + let px = photocraft_compose::flatten(d).over_background([1.0; 3]).into_px(); + let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); + let w = d.size.width as usize; + match (method, custom) { + (_, Some((map, mw, mh))) => quantize::to_bitmap_custom(&mut gray, w, map, mw, mh), + (Some(m), None) => quantize::to_bitmap(&mut gray, w, m), + (None, None) => {} + } + gray +} + +fn bitmap_pixels(d: &Document) -> Vec { + let surf = d.layers[0].surface().unwrap(); + let mut out = Vec::new(); + for y in 0..d.size.height as i32 { + for x in 0..d.size.width as i32 { + let p = surf.pixel(x, y); + assert_eq!(p[1], 1.0, "({x}, {y}) is opaque"); + out.push(p[0]); + } + } + out +} + +#[test] +fn bitmap_in_bands_matches_one_full_canvas_conversion() { + // #8477: the composite is converted in bands (32768 px wide: 256 rows each, so 300 rows are + // two). Diffusion carries its error across the band edge, and pattern, halftone and custom + // pattern stay anchored to the image's rows: the result equals one full-canvas conversion. + let (w, h) = (32_768u32, 300u32); + let mut s = session(w, h, 8, "grayscale"); + paint(&mut s, |x, y| { + let v = ((x % 509) as f32 / 509.0 * 0.6 + (y as f32 / 300.0) * 0.4).fract(); + [v, v, v, 1.0] + }); + add_pattern(&mut s, "Cells", 3, 2, &[10, 200, 90, 250, 150, 40]); + let cases = [ + ("threshold", None, Some(BitmapMethod::Threshold)), + ("pattern", None, Some(BitmapMethod::Pattern)), + ("diffusion", None, Some(BitmapMethod::Diffusion)), + ("halftone", Some(53.0), None), + ("customPattern", None, None), + ]; + for (method, freq, m) in cases { + let before = doc(&s).clone(); + let mut p = json!({"method": method}); + if let Some(f) = freq { + p["frequency"] = json!(f); + p["angle"] = json!(30); + p["shape"] = json!("diamond"); + } + if method == "customPattern" { + p["pattern"] = json!("Cells"); + } + s.execute("image.mode.bitmap", p.clone()).unwrap(); + let want = match method { + "halftone" => { + let cell = (before.resolution_dpi / 53.0).max(2.0); + bitmap_oracle(&before, Some(BitmapMethod::Halftone { cell, angle: 30.0, shape: HalftoneShape::Diamond }), None) + } + "customPattern" => { + let (map, mw, mh) = custom_pattern_map(&s, "t", &p).unwrap(); + bitmap_oracle(&before, None, Some((&map, mw, mh))) + } + _ => bitmap_oracle(&before, m, None), + }; + // Grayscale stores 0/1 the way `from_rgba` turns the old RGBA output into gray. + let want: Vec = want.iter().map(|g| photocraft_raster::from_rgba(&storage_format(ColorMode::Bitmap), [*g, *g, *g, 1.0])[0]).collect(); + let got = bitmap_pixels(doc(&s)); + let diff = got.iter().zip(&want).filter(|(a, b)| (**a - **b).abs() > 1e-6).count(); + assert_eq!(diff, 0, "{method}: {diff} of {} pixels differ", got.len()); + s.execute("edit.undo", json!({})).unwrap(); + } +} + +#[test] +fn bitmap_lays_a_transparent_composite_over_white() { + // Transparent pixels flatten over white before the conversion: an empty canvas is white, + // 25%-opaque black is light (75% gray) and thresholds to white, 75%-opaque black to black. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 3, "height": 1, "mode": "grayscale", "background": "transparent"})).unwrap(); + paint(&mut s, |x, _| [0.0, 0.0, 0.0, [0.0, 0.25, 0.75][x as usize]]); + s.execute("image.mode.bitmap", json!({"method": "threshold"})).unwrap(); + assert_eq!(bitmap_pixels(doc(&s)), [1.0, 1.0, 0.0]); +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); @@ -630,7 +730,7 @@ fn duotone_to_colour_modes_keeps_the_ink_colours() { s.execute("image.mode.duotone", cfg.clone()).unwrap(); let before = s.active().unwrap().doc.clone(); let gray: Vec = (0..16).map(|x| before.layers[0].surface().unwrap().pixel(x, 1)[0]).collect(); - let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).px; + let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).into_px(); s.execute(&format!("image.mode.{mode}"), json!({})).unwrap(); let d = doc(&s); assert!(d.duotone.is_none(), "{ctx}"); @@ -749,3 +849,95 @@ fn color_table_rejects_an_invalid_transparent_index_without_editing() { s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap(); assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0)); } + +/// A white 16×16 document in Indexed Color with the two-entry black/white table (#8669's repro). +fn black_white_indexed(transparency: bool) -> Session { + let mut s = Session::new(); + let background = if transparency { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 16, "height": 16, "background": background})).unwrap(); + paint(&mut s, |x, _| { + if x < 2 { + [0.0; 4] + } else if x < 4 { + [0.0, 0.0, 0.0, 1.0] + } else { + [1.0; 4] + } + }); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "transparency": transparency, "dither": "none"})).unwrap(); + s +} + +#[test] +fn brush_stroke_stays_in_indexed_color_table() { + let mut s = black_white_indexed(false); + assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[0, 0, 0], [255, 255, 255]]); + s.execute("paint.stroke", json!({"points": [[8, 8, 1]], "size": 8, "color": "#ff0000", "opacity": 1, "flow": 1})).unwrap(); + // Red's nearest entry is black. + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); + // Soft edges and partial opacity blend, and the blends map back onto the table too. + s.execute("paint.stroke", json!({"points": [[4, 12], [14, 12]], "size": 6, "hardness": 0.0, "color": "#808080", "opacity": 0.5})).unwrap(); + let table = doc(&s).color_table.clone().unwrap(); + assert!(colours(doc(&s)).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(doc(&s))); + assert_eq!(doc(&s).mode, ColorMode::Indexed); +} + +#[test] +fn brush_stroke_keeps_indexed_transparency_binary() { + let mut s = black_white_indexed(true); + let table = doc(&s).color_table.clone().unwrap(); + assert!(table.transparent.is_some()); + s.execute("paint.stroke", json!({"points": [[0, 8], [15, 8]], "size": 8, "hardness": 0.0, "color": "#ff0000", "opacity": 0.7})).unwrap(); + let d = doc(&s); + let surf = d.layers[0].surface().unwrap(); + for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) { + let a = surf.rgba(x, y)[3]; + assert!(a == 0.0 || a == 1.0, "alpha {a} at ({x},{y})"); + } + assert!(colours(d).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(d)); +} + +#[test] +fn live_brush_preview_matches_indexed_commit() { + let mut s = black_white_indexed(false); + let params = json!({"points": [[4, 8], [12, 8]], "size": 7, "hardness": 0.0, "color": "#ff0000", "opacity": 0.6, "smoothing": 0.0, "seed": 1}); + let preview = crate::brush_cmds::LiveStroke::begin(&s, ¶ms).unwrap(); + let table = doc(&s).color_table.clone().unwrap(); + assert!(colours(&preview.doc).iter().all(|c| table.colors.contains(c)), "preview off the table: {:?}", colours(&preview.doc)); + s.execute("paint.stroke", params).unwrap(); + let (live, committed) = (preview.doc.layers[0].surface().unwrap(), doc(&s).layers[0].surface().unwrap()); + for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) { + assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})"); + } +} + +#[test] +fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() { + let mut s = black_white_indexed(false); + // An off-table pixel inside the stroke's bounds but away from its dabs (Paint Bucket can + // still leave one) is not the stroke's to remap. + s.edit("setup", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let px = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]); + surf.write_region(Rect::new(13, 2, 14, 3), &px); + Ok(()) + }) + .unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 2], [14, 14]], "size": 2, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap(); + let surf = doc(&s).layers[0].surface().unwrap(); + assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn pencil_auto_erase_knows_the_indexed_foreground() { + let mut s = black_white_indexed(false); + let pencil = json!({"points": [[8, 8]], "size": 3, "color": "#ff0000", "autoErase": true}); + s.execute("paint.pencil", pencil.clone()).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]); + // Red painted as black, so a stroke starting there auto-erases to the (white) background. + let preview = crate::brush_cmds::LiveStroke::begin_with(&s, "paint.pencil", &pencil).unwrap(); + assert_eq!(preview.doc.layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]); + s.execute("paint.pencil", pencil).unwrap(); + assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]); +} diff --git a/crates/engine/src/cmd/ops.rs b/crates/engine/src/cmd/ops.rs index c106af6aac..b34aab7623 100644 --- a/crates/engine/src/cmd/ops.rs +++ b/crates/engine/src/cmd/ops.rs @@ -2,9 +2,35 @@ use super::enabled::check_all_unlocked; use super::layer_params::{layer_param, mask_at_depth}; use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels}; +/// Bring to Front / Send to Back of a layer already there: nothing to do, which isn't an error +/// (a Photoshop action plays it without complaint). +const ALREADY_THERE: &str = "the layer is already at the front or back"; + +/// Is layer `id` already as far as Arrange can take it in direction `dir` (up when positive)? True +/// at the top or bottom of its stack, or just above the Background, which stays at the bottom. The +/// Background itself is not: arranging it is refused outright. +pub(crate) fn arrange_at_end(doc: &Document, id: LayerId, dir: i32) -> bool { + let Some(path) = doc.path_of(id) else { return false }; + let Some((&idx, parent)) = path.split_last() else { return false }; + let len = if parent.is_empty() { + doc.layers.len() + } else { + match doc.layer_at(parent).and_then(Layer::children) { + Some(children) => children.len(), + None => return false, + } + }; + let bg = if parent.is_empty() { crate::background_cmds::background_id(doc) } else { None }; + if bg == Some(id) { + return false; + } + let floor = usize::from(bg.is_some()); + if dir > 0 { idx.saturating_add(1) >= len } else { idx <= floor } +} + pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { let id = layer_param(s, p)?; - s.edit("Arrange", |doc, _| { + let edited = s.edit("Arrange", |doc, _| { // The Background stays at the bottom: it doesn't move and nothing goes below it. let bg = crate::background_cmds::background_id(doc); if bg == Some(id) { @@ -29,12 +55,16 @@ pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { moved = true; } if !moved { - return Err(EngineError::Other("layer can't move further".into())); + let end = matches!(delta, i32::MAX | i32::MIN); + return Err(EngineError::Other(if end { ALREADY_THERE } else { "layer can't move further" }.into())); } crate::clip_group::settle(doc, &[id], &before, false); Ok(()) - })?; - Ok(Value::Null) + }); + match edited { + Err(EngineError::Other(m)) if m == ALREADY_THERE => Ok(json!({"moved": false})), + r => r.map(|_| Value::Null), + } } /// Add (`Some`) or remove (`None`) a layer mask. Adding acts on every selected layer that can take diff --git a/crates/engine/src/cmd/paint_cmds.rs b/crates/engine/src/cmd/paint_cmds.rs index b0cf8e571b..37cac96a23 100644 --- a/crates/engine/src/cmd/paint_cmds.rs +++ b/crates/engine/src/cmd/paint_cmds.rs @@ -85,10 +85,11 @@ fn bucket(s: &mut Session, p: &Value) -> Result { }; // A foreground picked as CMYK fills a CMYK document with the picked inks (#4196). let inks = s.tools.picked_cmyk([c[0], c[1], c[2]]).filter(|_| c[3] >= 1.0 && pattern.is_none()); - let filled = s.edit("Paint Bucket", |doc, active| { + let layer = if p.get("layer").is_some() { Some(crate::commands::layer_param(s, p)?) } else { s.active().and_then(|st| st.active_layer) }; + let filled = s.edit("Paint Bucket", |doc, _| { let area = doc.bounds(); let sel = doc.selection.clone(); - let (surf, lock) = crate::channel_cmds::target_surface(doc, *active, p)?; + let (surf, lock) = crate::channel_cmds::target_surface(doc, layer, p)?; let ok = if all && region.is_none() { false } else if let Some((tile, scale, angle, phase)) = &pattern { @@ -159,10 +160,11 @@ fn gradient(s: &mut Session, p: &Value) -> Result { let opacity = opacity(p, "paint.gradient")?; let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal); let dither = b(p, "dither", true); // Photoshop dithers gradients by default (reduces banding). - s.edit("Gradient", |doc, active| { + let layer = if p.get("layer").is_some() { Some(crate::commands::layer_param(s, p)?) } else { s.active().and_then(|st| st.active_layer) }; + s.edit("Gradient", |doc, _| { let sel = doc.selection.clone(); let area = sel.as_ref().map(|m| m.content_bounds()).filter(|r| !r.is_empty()).unwrap_or_else(|| doc.bounds()).intersect(&doc.bounds()); - let (surf, lock) = crate::channel_cmds::target_surface(doc, *active, p)?; + let (surf, lock) = crate::channel_cmds::target_surface(doc, layer, p)?; paint_gradient(surf, area, from, to, shape, &stops, reverse, opacity, blend, dither, sel.as_ref(), lock); Ok(()) })?; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 7d78bacccc..f5d644dae7 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -69,6 +69,19 @@ pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> { check(cmd, value, 0) } +/// `p[key]` as CMYK inks 0..1 (`[c, m, y, k]`), the values a colour was picked as, or `None` +/// when absent or null. A CMYK document stores exactly these instead of what the colour's sRGB +/// stand-in converts back to (#4196, #8568). +pub(crate) fn cmyk_param(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let inks: Option> = + v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect()); + match inks.as_deref() { + Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])), + _ => Err(bad(cmd, format!("`{key}` must be [c, m, y, k] numbers 0..1"))), + } +} + pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> { Ok(match p.get(key) { Some(Value::Array(a)) if a.len() >= 3 => { diff --git a/crates/engine/src/cmd/path_edit_cmds.rs b/crates/engine/src/cmd/path_edit_cmds.rs index d06b0fb29c..fce3a28c11 100644 --- a/crates/engine/src/cmd/path_edit_cmds.rs +++ b/crates/engine/src/cmd/path_edit_cmds.rs @@ -10,7 +10,7 @@ //! type layer also has a vector mask). Knots are addressed by `subpath` and `knot` index, as //! listed by `path.info`, and the result is `{name, path}` in the compact JSON form. -use photocraft_doc::{LayerContent, Path}; +use photocraft_doc::{LayerContent, Path, PathOp}; use photocraft_geom::Point; use photocraft_vector::edit::{self, Handle}; use serde_json::{Value, json}; @@ -48,7 +48,15 @@ fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> { /// Applies `f` to the targeted path in one undoable step labelled `label`. fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result { - let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned(); + // A malformed target is an error, never a silent edit of the Work Path or the active layer. + let name = match p.get("name") { + Some(v) => v.as_str().ok_or_else(|| bad(cmd, "`name` must be a path name, \"work\" or \"layer\""))?, + None => "work", + } + .to_owned(); + if name == "layer" && p.get("layer").is_some_and(|v| !v.is_u64()) { + return Err(bad(cmd, "`layer` must be a layer id")); + } let layer = if name == "layer" { Some(layer_id(s, p)?) } else { None }; // Type on a path: its own path, the text laid along it again (#9114). A vector mask on the // layer stays what `"layer"` edits, as with `path.info`, unless `"typePath": true`. @@ -166,6 +174,70 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// Merge Shape Components on a path (the Pen's and Path Selection's Path Operations menu): the +/// components' operations are baked into plain combined outlines, as for a shape layer. +fn merge_components(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.mergeShapeComponents"; + let tol = match p.get("tolerance") { + Some(v) => v.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad(CMD, "`tolerance` must be a number"))?.clamp(0.05, 10.0), + None => 0.1, + }; + let mask = p.get("name").and_then(Value::as_str) == Some("layer") + && layer_id(s, p).ok().and_then(|id| s.active()?.doc.layer(id).map(|l| !matches!(l.content, LayerContent::Shape(_)))).unwrap_or(false); + edit_path(s, CMD, p, "Merge Shape Components", |path| { + let mut merged = crate::vector_cmds::bake_path(path, tol).map_err(|e| e.to_string())?; + // Components that cancel out cover nothing, but an empty vector mask reveals the whole + // layer (hides it when inverted): flip it so the mask still shows what it did. + if mask && merged.is_empty() && !path.is_empty() { + merged.inverted = !path.inverted; + } + *path = merged; + Ok(()) + }) +} + +/// The Path Operations menu (or + / −) with path components selected: the components holding +/// `subpaths` take `op`, the way Photoshop changes the operation of the selected components. A +/// component's operation lives on its first subpath; the subpaths joined to it keep theirs. The +/// first component is what the others combine with and always draws as Combine +/// (`Path::effective_op`), so it keeps its operation; naming only it is refused. +fn set_operation(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.setOperation"; + let op = p + .get("op") + .and_then(Value::as_str) + .and_then(crate::vector_cmds::op_from) + .filter(|op| *op != PathOp::Join) + .ok_or_else(|| bad(CMD, "`op` must be \"combine\", \"subtract\", \"intersect\" or \"exclude\""))?; + let subpaths: Vec = p + .get("subpaths") + .and_then(Value::as_array) + .filter(|a| !a.is_empty()) + .ok_or_else(|| bad(CMD, "`subpaths` must list subpath indices"))? + .iter() + .map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "each subpath is an index"))) + .collect::>()?; + edit_path(s, CMD, p, "Path Operation", |path| { + let components = path.components(); + let mut heads = Vec::with_capacity(subpaths.len()); + for i in &subpaths { + let c = components.iter().find(|c| c.contains(i)).ok_or_else(|| format!("no subpath {i} (the path has {})", path.subpaths.len()))?; + if c.start != 0 { + heads.push(c.start); + } + } + if heads.is_empty() { + return Err("the first component always combines: it is the shape the others act on".into()); + } + for h in heads { + if let Some(sp) = path.subpaths.get_mut(h) { + sp.op = op; + } + } + Ok(()) + }) +} + pub fn specs() -> Vec { const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path, a vector mask, or else type on a path's Type Path),"typePath":bool=false (with "layer": the Type Path even when the type layer has a vector mask)"#; let spec = |id: &'static str, label: &'static str, params: String, run: fn(&mut Session, &Value) -> Result| CommandSpec { @@ -210,12 +282,24 @@ pub fn specs() -> Vec { r#""subpath":i,"knot":j (its neighbours are joined, keeping their handles; a subpath's last anchor removes the subpath; the path's only anchor is refused) (the result also has subpathRemoved:bool)"#.into(), delete_anchor, ), + spec( + "path.mergeShapeComponents", + "Merge Shape Components", + r#""tolerance":px=0.1 (bakes the components' path operations into plain combined outlines, traced from the exact coverage)"#.into(), + merge_components, + ), + spec( + "path.setOperation", + "Path Operation", + r#""subpaths":[i,…],"op":"combine|subtract|intersect|exclude" (the shape components holding these subpaths take the operation; the first component always combines and keeps its own; the Path Operations menu with components selected)"#.into(), + set_operation, + ), ] } #[cfg(test)] mod tests { - use photocraft_doc::{LayerId, ShapeLayer}; + use photocraft_doc::{LayerId, PathOp, ShapeLayer}; use serde_json::json; use crate::Session; @@ -413,6 +497,214 @@ mod tests { assert_eq!(s.active().unwrap().history.entries().len(), steps); } + /// Coverage of `path` at the pixel `(x, y)`. + fn covered(path: &photocraft_doc::Path, x: i32, y: i32) -> f32 { + photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0] + } + + /// A 10..60 square with a front 25..45 square cut out of it (Subtract Front Shape), then + /// Merge Shape Components: plain combined outlines covering the same area, in one step. + #[test] + fn merge_shape_components_bakes_a_paths_operations() { + let mut s = session(); + s.execute("path.set", json!({"path": square()})).unwrap(); + let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]}); + s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap(); + let before = work(&s); + let r = s.execute("path.mergeShapeComponents", json!({})).unwrap(); + assert_eq!(r["name"], json!("work")); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Merge Shape Components")); + let merged = work(&s); + assert_eq!(merged.subpaths.len(), 2, "the outline and its hole"); + for (x, y) in [(15, 15), (35, 35), (55, 50), (5, 5)] { + assert!((covered(&merged, x, y) - covered(&before, x, y)).abs() < 0.01, "({x}, {y})"); + } + assert_eq!((covered(&merged, 35, 35), covered(&merged, 15, 15)), (0.0, 1.0), "the hole stays a hole"); + assert!(s.undo()); + assert_eq!(work(&s), before); + // A saved path and a vector mask merge the same way; an inverted mask stays inverted. + s.execute("path.set", json!({"name": "Mug", "path": square()})).unwrap(); + s.execute("path.mergeShapeComponents", json!({"name": "Mug"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap(); + s.execute("layer.vectorMask.edit", json!({"layer": id, "path": {"subpaths": square()["subpaths"], "inverted": true}})).unwrap(); + s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap(); + let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap(); + assert!(vm.path.inverted && !vm.path.subpaths.is_empty()); + } + + #[test] + fn merge_shape_components_on_a_shape_layer_rerenders_it() { + let mut s = session(); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "subtract"})).unwrap(); + s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap(); + let st = s.active().unwrap(); + let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") }; + assert_eq!(covered(path, 35, 35), 0.0); + assert!(cache.is_some()); + } + + /// Mask components that cancel out (a square minus itself) cover nothing. Merged, the mask + /// must still hide (or, inverted, reveal) what it did, not turn into an empty revealing mask. + #[test] + fn merging_a_vector_mask_to_nothing_keeps_what_it_shows() { + for inverted in [false, true] { + let mut s = session(); + let id = s.active().unwrap().active_layer.unwrap().0; + let mut cancelled = square(); + cancelled["subpaths"].as_array_mut().unwrap().push(json!({"closed": true, "op": "subtract", "knots": [[10, 10], [60, 10], [60, 60], [10, 60]]})); + cancelled["inverted"] = json!(inverted); + s.execute("layer.vectorMask.add", json!({"layer": id, "path": cancelled})).unwrap(); + let mask = |s: &Session| s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap(); + let shown = |s: &Session| photocraft_vector::vector_mask_values(&mask(s), photocraft_geom::Rect::from_xywh(0, 0, 120, 100)); + let before = shown(&s); + assert!(before.iter().all(|v| *v == if inverted { 1.0 } else { 0.0 }), "inverted {inverted}"); + s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap(); + assert!(mask(&s).path.is_empty()); + assert_eq!(shown(&s), before, "inverted {inverted}"); + } + } + + /// A hole cut with Combine by mistake becomes a hole: the component takes Subtract Front Shape, + /// in one step, on the Work Path, a shape layer (re-rendered) and a vector mask alike. + #[test] + fn set_operation_changes_existing_components() { + let mut s = session(); + let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]}); + s.execute("path.set", json!({"path": square()})).unwrap(); + s.execute("path.set", json!({"path": hole, "op": "combine"})).unwrap(); + assert_eq!(covered(&work(&s), 35, 35), 1.0); + s.execute("path.setOperation", json!({"subpaths": [1], "op": "subtract"})).unwrap(); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Path Operation")); + assert_eq!(covered(&work(&s), 35, 35), 0.0); + assert_eq!(work(&s).subpaths[1].op, PathOp::Subtract); + assert!(s.undo()); + assert_eq!(work(&s).subpaths[1].op, PathOp::Combine); + + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "combine"})).unwrap(); + s.execute("path.setOperation", json!({"name": "layer", "layer": id, "subpaths": [1], "op": "subtract"})).unwrap(); + let st = s.active().unwrap(); + let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") }; + assert_eq!(covered(path, 35, 35), 0.0); + assert!(cache.is_some()); + } + + /// A component's operation is its first subpath's: naming a joined subpath changes the head, + /// and the joined subpaths stay joined. + #[test] + fn set_operation_on_a_joined_subpath_changes_its_component() { + let mut s = session(); + let mut p = square(); + let subs = p["subpaths"].as_array_mut().unwrap(); + subs.push(json!({"closed": true, "op": "combine", "knots": [[70, 10], [110, 10], [110, 50], [70, 50]]})); + subs.push(json!({"closed": true, "op": "join", "knots": [[80, 20], [80, 40], [100, 40], [100, 20]]})); + s.execute("path.set", json!({"path": p})).unwrap(); + s.execute("path.setOperation", json!({"subpaths": [2], "op": "exclude"})).unwrap(); + let ops: Vec<_> = work(&s).subpaths.iter().map(|sp| sp.op).collect(); + assert_eq!(ops, [PathOp::Combine, PathOp::Exclude, PathOp::Join]); + } + + /// The first component always draws as Combine: naming it with others changes only the + /// others, and naming only it is refused without a History step (feedback on #7648). + #[test] + fn set_operation_leaves_the_first_component_combining() { + let mut s = session(); + let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]}); + s.execute("path.set", json!({"path": square()})).unwrap(); + s.execute("path.set", json!({"path": hole, "op": "combine"})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + let e = s.execute("path.setOperation", json!({"subpaths": [0], "op": "subtract"})).unwrap_err().to_string(); + assert!(e.contains("first component"), "{e}"); + assert_eq!(s.active().unwrap().history.entries().len(), steps); + s.execute("path.setOperation", json!({"subpaths": [0, 1], "op": "subtract"})).unwrap(); + let ops: Vec<_> = work(&s).subpaths.iter().map(|sp| sp.op).collect(); + assert_eq!(ops, [PathOp::Combine, PathOp::Subtract]); + assert_eq!((covered(&work(&s), 35, 35), covered(&work(&s), 15, 15)), (0.0, 1.0)); + } + + #[test] + fn bad_set_operation_params_change_nothing() { + let mut s = session(); + s.execute("path.set", json!({"path": square()})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + for (p, msg) in [ + (json!({"subpaths": [0]}), "`op`"), + (json!({"subpaths": [0], "op": "join"}), "`op`"), + (json!({"subpaths": [0], "op": 3}), "`op`"), + (json!({"op": "subtract"}), "`subpaths`"), + (json!({"subpaths": [], "op": "subtract"}), "`subpaths`"), + (json!({"subpaths": [-1], "op": "subtract"}), "index"), + (json!({"subpaths": [7], "op": "subtract"}), "no subpath 7"), + (json!({"subpaths": [0], "op": "subtract", "name": "Nope"}), ""), + (json!({"subpaths": [0], "op": "subtract", "name": "layer", "layer": "x"}), "`layer`"), + ] { + let e = s.execute("path.setOperation", p.clone()).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + assert_eq!(work(&s).subpaths[0].op, PathOp::Combine); + } + + /// Rejected merges name the problem and leave every path as it was, including the one a + /// malformed target would otherwise have fallen back to (the Work Path, the active layer). + #[test] + fn bad_merge_params_change_nothing() { + let mut s = session(); + s.execute("path.set", json!({"path": square()})).unwrap(); + let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]}); + s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap(); + let state = |s: &Session| { + let d = s.active().unwrap(); + (d.doc.work_path.clone(), d.doc.paths.clone(), d.doc.layer(LayerId(id)).unwrap().vector_mask.clone(), d.history.entries().len()) + }; + let before = state(&s); + for (p, msg) in [ + (json!({"tolerance": "fine"}), "tolerance"), + (json!({"name": 5}), "`name`"), + (json!({"name": "nope"}), "no path named"), + (json!({"name": "layer", "layer": "x"}), "`layer`"), + (json!({"name": "layer", "layer": 999}), "999"), + ] { + let e = s.execute("path.mergeShapeComponents", p.clone()).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + assert!(state(&s) == before, "{p}: nothing changed"); + } + } + + /// Tracing an open subpath's fill would close it, or drop a straight one or a lone + /// anchor (a dot under a round or square cap) entirely: the merge + /// is refused and the path, its stroked layer and the history stay as they were. + #[test] + fn merge_refuses_open_subpaths() { + for open in [json!({"closed": false, "knots": [[10, 10], [60, 60]]}), json!({"closed": false, "knots": [[70, 70]]})] { + let mut s = session(); + let mut mixed = square(); + mixed["subpaths"].as_array_mut().unwrap().push(open); + s.execute("path.set", json!({"path": mixed})).unwrap(); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.edit", json!({"layer": id, "path": mixed})).unwrap(); + let state = |s: &Session| { + let d = s.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &d.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") }; + (d.doc.work_path.clone(), sh.path.clone(), d.history.entries().len()) + }; + let before = state(&s); + for (cmd, p) in [ + ("path.mergeShapeComponents", json!({})), + ("path.mergeShapeComponents", json!({"name": "layer", "layer": id})), + ("layer.combineShapes.mergeShapeComponents", json!({"layer": id})), + ] { + let e = s.execute(cmd, p.clone()).unwrap_err().to_string(); + assert!(e.contains("open subpaths"), "{cmd} {p}: {e}"); + assert!(state(&s) == before, "{cmd} {p}: nothing changed"); + } + } + } + #[test] fn bad_params_fail_without_an_edit() { let mut s = session(); diff --git a/crates/engine/src/cmd/pattern_cmds.rs b/crates/engine/src/cmd/pattern_cmds.rs index 4bc972556a..d1a93b42d4 100644 --- a/crates/engine/src/cmd/pattern_cmds.rs +++ b/crates/engine/src/cmd/pattern_cmds.rs @@ -25,17 +25,21 @@ pub struct PatternLibrary { /// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose /// id is already in the library), which its id, name and size don't show. pub pixels_rev: u64, + /// Saved patterns held as a small preview until used, by id: their full size. The preset + /// store reads the pixels ([`full`]); everything that paints goes through [`resolve`]. + pub stored: std::collections::HashMap, } impl Default for PatternLibrary { fn default() -> Self { - PatternLibrary { items: builtin(), pixels_rev: 0 } + PatternLibrary { items: builtin(), pixels_rev: 0, stored: Default::default() } } } impl PatternLibrary { /// Add `pat`, or replace the pattern with its id (marking the pixels changed). pub fn upsert(&mut self, pat: Pattern) { + self.stored.remove(&pat.id); match self.items.iter_mut().find(|q| q.id == pat.id) { Some(q) => { *q = pat; @@ -132,24 +136,91 @@ fn has_pattern(s: &Session) -> std::result::Result<(), String> { if s.patterns.items.is_empty() && s.active().is_none_or(|d| d.doc.patterns.is_empty()) { Err("no patterns".into()) } else { Ok(()) } } -/// Finds a pattern by id or name: the active document's first, then the library. An empty key -/// picks the first library pattern (Photoshop's default). -pub fn resolve(s: &Session, key: &str) -> Option { - if key.is_empty() { - // The Patterns panel's selection, else the first library pattern. - return crate::presets::patterns::current(s).or(s.patterns.items.first()).cloned(); +/// `p` with its full pixels: a saved pattern held as a preview is read from the preset store. +pub fn full(s: &Session, p: &Pattern) -> std::result::Result { + if !s.patterns.stored.contains_key(&p.id) { + return Ok(p.clone()); } - if let Some(d) = s.active() + let st = s.preset_store.as_ref().ok_or_else(|| format!("pattern \"{}\" is not loaded: no preset store", p.display_name()))?; + let mut f = st.load_pattern(&p.id).map_err(|e| format!("pattern \"{}\" could not be loaded: {e}", p.display_name()))?; + f.name = p.name.clone(); + Ok(f) +} + +/// The library pattern `key` names (the panel's selection or the first for an empty key). +fn library_pattern<'a>(s: &'a Session, key: &str) -> Option<&'a Pattern> { + if key.is_empty() { + return crate::presets::patterns::current(s).or(s.patterns.items.first()); + } + photocraft_doc::pattern::find(&s.patterns.items, key, key) +} + +/// The id of the pattern [`resolve`] finds, without reading a saved pattern's pixels: for checks +/// and lookups that don't paint. +pub fn resolve_id(s: &Session, key: &str) -> Option { + if !key.is_empty() + && let Some(d) = s.active() + && let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key) + { + return Some(p.id.clone()); + } + library_pattern(s, key).map(|p| p.id.clone()) +} + +/// Finds a pattern by id or name: the active document's first, then the library. An empty key +/// picks the first library pattern (Photoshop's default). `None` also when a saved pattern's +/// pixels can't be read (see [`resolve_param`] for the reason). +pub fn resolve(s: &Session, key: &str) -> Option { + if !key.is_empty() + && let Some(d) = s.active() && let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key) { return Some(p.clone()); } - photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned() + full(s, library_pattern(s, key)?).ok() } pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result { let key = p.get("pattern").and_then(Value::as_str).unwrap_or(""); - resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))) + resolve_or_named(s, key, p).map_err(|e| bad(cmd, e)).and_then(|pat| paintable(cmd, pat)) +} + +/// The pattern `key` names, or with a `patternName` in `p` (an imported action's pattern recorded +/// with both an id and a name) the pattern with id `key`: the active document's, then the +/// library's, by id alone, and only when that id is installed nowhere the one `patternName` +/// names, as the reference application plays such a step. The error says why: no such pattern, +/// or a saved library pattern whose pixels can't be read. +pub(crate) fn resolve_or_named(s: &Session, key: &str, p: &Value) -> std::result::Result { + use photocraft_doc::pattern::find; + let name = p.get("patternName").and_then(Value::as_str).filter(|n| !n.is_empty()); + // `find` with an empty name looks up the id alone; both empty find nothing. + let in_doc = |id: &str, name: &str| s.active().and_then(|d| find(&d.doc.patterns, id, name)).map(|p| (p, false)); + let by_key = |k: &str| in_doc(k, k).or_else(|| library_pattern(s, k).map(|p| (p, true))); + let found = match name { + // The name is a name: another pattern whose id happens to equal it doesn't count. + Some(n) => in_doc(key, "") + .or_else(|| find(&s.patterns.items, key, "").map(|p| (p, true))) + .or_else(|| in_doc("", n)) + .or_else(|| find(&s.patterns.items, "", n).map(|p| (p, true))), + None => by_key(key), + }; + match found { + Some((pat, false)) => Ok(pat.clone()), + Some((pat, true)) => full(s, pat), + None => Err(match name { + Some(n) => format!("no pattern \"{n}\" (id \"{key}\"; see pattern.list)"), + None => format!("no pattern \"{key}\" (see pattern.list)"), + }), + } +} + +/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as +/// transparent, so a command that would use one says so up front. +fn paintable(cmd: &str, pat: Pattern) -> Result { + match pat.paint_problem() { + Some(why) => Err(bad(cmd, format!("pattern \"{}\" would paint nothing: {why}", pat.display_name()))), + None => Ok(pat), + } } /// Copies `pat` into the document's patterns unless it is already there (by id). @@ -168,41 +239,70 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => { let key = if id.is_empty() { name } else { id }; let pat = resolve(s, &key).ok_or_else(|| bad("layer.layerStyle.patternOverlay", format!("no pattern \"{key}\" (see pattern.list)")))?; + let pat = paintable("layer.layerStyle.patternOverlay", pat)?; Ok((Effect::PatternOverlay { common, name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase }, Some(pat))) } other => Ok((other, None)), } } -fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value { +fn info(p: &Pattern, size: (u32, u32), in_doc: bool, in_library: bool) -> Value { let f = p.surface.format(); json!({ - "id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height, + "id": p.id, "name": p.display_name(), "storedName": p.name, "width": size.0, "height": size.1, "mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha, "inDocument": in_doc, "inLibrary": in_library, + "paintable": p.is_paintable(), "paintProblem": p.paint_problem(), }) } fn list(s: &mut Session, _: &Value) -> Result { let doc_pats: Vec = s.active().map(|d| d.doc.patterns.clone()).unwrap_or_default(); - let mut out: Vec = s.patterns.items.iter().map(|p| info(p, doc_pats.iter().any(|d| d.id == p.id), true)).collect(); - out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, true, false))); + let size = |p: &Pattern| s.patterns.stored.get(&p.id).copied().unwrap_or((p.width, p.height)); + let mut out: Vec = s.patterns.items.iter().map(|p| info(p, size(p), doc_pats.iter().any(|d| d.id == p.id), true)).collect(); + out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, (p.width, p.height), true, false))); Ok(Value::Array(out)) } -/// The visible composite over `r` as a surface in the document's pixel format (with alpha). -fn composite_surface(doc: &Document, r: Rect) -> Surface { - let buf = photocraft_compose::render(doc, r); +/// Pixels composited at a time by [`composite_surface`]: a band's render and conversion buffers +/// stay a few tens of MB however large the pattern. +const BAND_PIXELS: u32 = 1 << 20; + +/// The visible composite over `r` as a surface in the document's pixel format (with alpha), and +/// whether every pixel of it is opaque. +fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) { + composite_surface_in_bands(doc, r, (BAND_PIXELS / r.width().max(1)).max(1)) +} + +/// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a +/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full +/// `f32` copy) from holding several full-size copies at once. Opacity is judged on the stored +/// samples, as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as 255. +fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) { let df = doc.pixel_format(); let fmt = PixelFormat::new(df.mode, df.sample, true); let n = fmt.channels(); - let mut px = vec![0.0f32; buf.px.len() * n]; - for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { - photocraft_raster::from_rgba_into(&fmt, *p, o); - } + let (width, height) = (r.width(), r.height()); let mut s = Surface::new(fmt); - s.write_region(Rect::new(0, 0, r.width() as i32, r.height() as i32), &px); - s + let mut opaque = true; + let mut px = Vec::new(); + let mut top = 0; + while top < height { + let rows = band_rows.min(height - top); + let buf = photocraft_compose::render(doc, Rect::new(r.x0, r.y0 + top as i32, r.x1, r.y0 + (top + rows) as i32)); + px.clear(); + px.resize(buf.px.len() * n, 0.0f32); + for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) { + photocraft_raster::from_rgba_into(&fmt, *p, o); + } + let band = Rect::new(0, top as i32, width as i32, (top + rows) as i32); + s.write_region(band, &px); + if opaque { + opaque = s.read_region(band).chunks_exact(n).all(|q| q[n - 1] >= 1.0); + } + top += rows; + } + (s, opaque) } /// The name Define Pattern gives a pattern without one: the active document's name without its @@ -219,6 +319,11 @@ pub fn default_name(s: &Session) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base) } +/// Longest edge Define Pattern accepts. Its area is limited too, to what the compositor holds +/// (`Pattern::working_bytes`): 8000 x 8000 at 8 bits, and fewer pixels at 16 bits and float, +/// where the working copy is larger per pixel. What is accepted always paints. +const MAX_DEFINE_EDGE: u32 = 8000; + fn define(s: &mut Session, p: &Value) -> Result { let cmd = "edit.definePattern"; let st = s.active().ok_or(EngineError::NoDocument)?; @@ -234,14 +339,20 @@ fn define(s: &mut Session, p: &Value) -> Result { if r.is_empty() { return Err(bad(cmd, "the area is empty")); } - if r.width() > 4000 || r.height() > 4000 { - return Err(bad(cmd, "patterns are limited to 4000 × 4000 px")); + if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE { + return Err(bad(cmd, "patterns are limited to 8000 × 8000 px")); } - let mut surf = composite_surface(doc, r); + // A pattern the compositor could not hold would define but paint nothing: 8 bits go to the + // edge limit, 16-bit documents to 33,554,432 px in all, float to 16,777,216 (4096 × 4096). + let sample = doc.pixel_format().sample; + let max_px = photocraft_doc::pattern::max_paint_pixels(sample); + if u64::from(r.width()) * u64::from(r.height()) > max_px { + return Err(bad(cmd, format!("patterns in a {}-bit document are limited to {max_px} px in all", sample.bytes() * 8))); + } + let (mut surf, opaque) = composite_surface(doc, r); // Opaque patterns drop their alpha channel (as Photoshop does). - let fmt = surf.format(); - let px = surf.read_region(Rect::new(0, 0, r.width() as i32, r.height() as i32)); - if px.chunks_exact(fmt.channels()).all(|q| q[fmt.channels() - 1] >= 1.0) { + if opaque { + let fmt = surf.format(); surf = surf.convert(PixelFormat::new(fmt.mode, fmt.sample, false)); } let name = match p.get("name").and_then(Value::as_str) { @@ -250,7 +361,7 @@ fn define(s: &mut Session, p: &Value) -> Result { }; let pat = Pattern::new(name, surf, r.width(), r.height()); let id = pat.id.clone(); - let out = info(&pat, false, true); + let out = info(&pat, (pat.width, pat.height), false, true); if !s.patterns.items.iter().any(|q| q.id == id) { s.patterns.items.push(pat); } @@ -285,6 +396,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { fn delete(s: &mut Session, p: &Value) -> Result { let i = library_index(s, "pattern.delete", p)?; let pat = s.patterns.items.remove(i); + s.patterns.stored.remove(&pat.id); Ok(json!({"deleted": pat.id})) } @@ -300,6 +412,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut ids = Vec::new(); for pat in pats { ids.push(pat.id.clone()); + s.patterns.stored.remove(&pat.id); s.patterns.upsert(pat); } Ok(json!({"imported": ids, "count": ids.len()})) @@ -312,7 +425,7 @@ fn export(s: &mut Session, p: &Value) -> Result { Some(keys) => { keys.iter().filter_map(Value::as_str).map(|k| resolve(s, k).ok_or_else(|| bad(cmd, format!("no pattern \"{k}\"")))).collect::>()? } - None => s.patterns.items.clone(), + None => s.patterns.items.iter().map(|p| full(s, p).map_err(|e| bad(cmd, e))).collect::>()?, }; let bytes = photocraft_io::pattern_map::write_pat(&pats).map_err(EngineError::Other)?; crate::file_cmds::write_file(path, &bytes)?; @@ -348,11 +461,30 @@ pub(crate) fn insert_fill_layer( let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase }; let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill)); crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?; + // Like Photoshop, an empty pixel layer selected becomes the fill layer instead of getting one + // above it; it keeps its place, opacity and blending, and a name the user gave it. + if let Some(id) = *active + && crate::stamp_cmds::is_empty_pixel_layer(doc, id) + { + let target = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if is_default_layer_name(&target.name) { + target.name = l.name; + } + target.content = l.content; + target.mask = l.mask; + target.vector_mask = l.vector_mask; + return Ok(id); + } let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); Ok(id) } +/// A name new layers get ("Layer 3"), so not one the user chose. +fn is_default_layer_name(name: &str) -> bool { + name.strip_prefix("Layer ").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) +} + /// `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)?; @@ -369,15 +501,15 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result { return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint")); }; // Brush textures are grey: luminance of the composite over white. - let mut px = vec![[0.0f32; 4]; n]; - pat.surface.read_rgba_into(pat.rect(), &mut px); - let lum: Vec = px - .iter() - .map(|q| { + // Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel. + let mut lum: Vec = Vec::new(); + lum.try_reserve_exact(n).map_err(|_| bad("brush.texturePattern", "not enough memory for the pattern"))?; + for_each_band(&pat, pat.width, pat.height, |band| { + lum.extend(band.iter().map(|q| { let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2]; l * q[3] + (1.0 - q[3]) - }) - .collect(); + })); + }); let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum); let t = &mut s.tools.brush.texture; t.pattern = photocraft_paint::Pattern::Tile(tile); @@ -393,13 +525,25 @@ pub fn average_color(p: &Pattern) -> Color { // A pattern too large to paint is averaged over its paintable top-left corner. let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32; let (w, h) = (p.width.min(side), p.height.min(side)); - let mut px = vec![[0.0f32; 4]; w as usize * h as usize]; - p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px); - let n = px.len().max(1) as f32; - let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]); + let mut s = [0.0f32; 3]; + for_each_band(p, w, h, |band| s = band.iter().fold(s, |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]])); + let n = (u64::from(w) * u64::from(h)).max(1) as f32; Color::rgb(s[0] / n, s[1] / n, s[2] / n) } +/// Calls `f` with the straight RGBA pixels of the top-left `w × h` corner of `p`, a band of rows +/// at a time in row-major order. +fn for_each_band(p: &Pattern, w: u32, h: u32, mut f: impl FnMut(&[[f32; 4]])) { + let mut band = Vec::new(); + for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) { + let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h); + band.clear(); + band.resize(w as usize * (bottom - top) as usize, [0.0f32; 4]); + p.surface.read_rgba_into(Rect::new(0, top as i32, w as i32, bottom as i32), &mut band); + f(&band); + } +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -467,7 +611,7 @@ pub fn specs() -> Vec { label: "Pattern…", menu: &["Layer", "New Fill Layer"], shortcut: None, - params: r##"{"pattern":id|name?=first library pattern,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phase":[x,y]?,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, + params: r##"{"pattern":id|name?=first library pattern,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phase":[x,y]?,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##, enabled: has_doc, journal: true, run: new_fill_layer, @@ -551,6 +695,128 @@ mod tests { } } + /// Review of the 8000 px cap: compositing a band of rows at a time gives the pixels and the + /// opacity of one pass, at every depth, whatever the band height. + #[test] + fn banded_compositing_matches_a_single_pass() { + for depth in [8u64, 16, 32] { + for transparent in [false, true] { + let mut s = Session::new(); + let background = if transparent { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": background})).unwrap(); + s.execute("select.rect", json!({"x": 3, "y": 4, "width": 20, "height": 13})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let doc = &s.active().unwrap().doc; + let area = Rect::new(2, 1, 38, 29); + let all = Rect::new(0, 0, 36, 28); + let (whole, whole_opaque) = composite_surface_in_bands(doc, area, 28); + for band_rows in [1, 5, 27] { + let (banded, opaque) = composite_surface_in_bands(doc, area, band_rows); + assert_eq!(opaque, whole_opaque, "depth {depth}, transparent {transparent}, bands of {band_rows}"); + assert_eq!(banded.read_region(all), whole.read_region(all), "depth {depth}, transparent {transparent}, bands of {band_rows}"); + } + assert_eq!(whole_opaque, !transparent); + } + } + } + + /// Review of the banded composite: opacity is the stored samples', so an alpha an 8-bit + /// document stores as 255 is opaque and a float document's 0.999 is not. + #[test] + fn alpha_that_stores_as_opaque_counts_as_opaque() { + for (depth, opaque) in [(8u64, true), (32, false)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("layer.setProps", json!({"opacity": 0.999})).unwrap(); + let (_, all_opaque) = composite_surface_in_bands(&s.active().unwrap().doc, Rect::new(0, 0, 8, 8), 3); + assert_eq!(all_opaque, opaque, "depth {depth}"); + } + } + + /// Review of #8902: what Define Pattern accepts, the compositor paints. Areas over + /// 4096 x 4096, past the compositor's whole-copy cap, define and fill at 8 and 16 bits (held + /// at the pattern's own depth); a depth that has no smaller exact form is refused up front + /// with its limit, instead of defining a pattern that paints nothing. + #[test] + fn defined_patterns_over_4096_squared_paint_or_are_refused() { + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": depth})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); + assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable()), "depth {depth}"); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "depth {depth}: {:?}", [r, g, b, a]); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 32})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("32-bit") && e.contains("16777216"), "{e}"); + assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "nothing was defined"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 6000, "height": 5600, "depth": 16})).unwrap(); + let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string(); + assert!(e.contains("16-bit") && e.contains("33554432"), "{e}"); + } + + /// A pattern of the largest area Define Pattern accepts at 8 bits is paintable: held at 4 + /// bytes a pixel, 256 MB, as the compositor's cap allows, and it renders. + #[test] + fn an_8000_by_8000_pattern_is_defined_and_paints() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap(); + let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap(); + assert_eq!((p.width, p.height), (8000, 8000)); + assert!(p.is_paintable() && p.working_bytes() == 256_000_000); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Largest"})).unwrap(); + let [r, g, b, a] = pixel(&s, 5, 5); + assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]); + } + + /// Review of #8902: a pattern too large to paint is named by every command that would use it + /// and by pattern.list, instead of rendering as transparency with no message. + #[test] + fn a_pattern_that_would_paint_nothing_is_reported() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap(); + // A 32-bit 5000 x 5000 pattern (declared only: 400 MB as a working copy, past the cap). + let surface = photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA32F); + s.patterns.items.push(Pattern { id: "huge".into(), name: "Huge".into(), width: 5000, height: 5000, surface }); + let list = s.execute("pattern.list", json!({})).unwrap(); + let huge = list.as_array().unwrap().iter().find(|p| p["name"] == "Huge").unwrap().clone(); + assert_eq!(huge["paintable"], json!(false)); + assert!(huge["paintProblem"].as_str().unwrap().contains("32 bits"), "{huge}"); + for (id, params) in [ + ("layer.newFillLayer.pattern", json!({"pattern": "Huge"})), + ("layer.layerStyle.patternOverlay", json!({"pattern": "Huge"})), + ("edit.fill", json!({"contents": "pattern", "pattern": "Huge"})), + ("brush.texturePattern", json!({"pattern": "Huge"})), + ] { + let e = s.execute(id, params).expect_err(id).to_string(); + assert!(e.contains("Huge") && e.contains("would paint nothing"), "{id}: {e}"); + } + let e = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Huge"})).unwrap_err().to_string(); + assert!(e.contains("would paint nothing") && e.contains("5000 × 5000"), "{e}"); + assert!(list.as_array().unwrap().iter().any(|p| p["name"] != "Huge" && p["paintable"] == json!(true)), "built-in patterns paint"); + } + + #[test] + fn define_pattern_accepts_edges_over_4000_up_to_8000() { + for (w, ok) in [(4100, true), (8000, true), (8001, false)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": 2, "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#336699"})).unwrap(); + let r = s.execute("edit.definePattern", json!({})); + assert_eq!(r.is_ok(), ok, "width {w}: {r:?}"); + } + } + #[test] fn pattern_fill_layer_renders_at_every_depth_and_undoes() { for depth in [8u64, 16, 32] { @@ -642,6 +908,6 @@ mod tests { let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) }; s.patterns.items.push(huge); let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err(); - assert!(e.to_string().contains("too large"), "{e}"); + assert!(e.to_string().contains("would paint nothing") && e.to_string().contains("65536 × 65536"), "{e}"); } } diff --git a/crates/engine/src/cmd/pattern_fill_cmds.rs b/crates/engine/src/cmd/pattern_fill_cmds.rs index a599ca1ec0..9c422341fd 100644 --- a/crates/engine/src/cmd/pattern_fill_cmds.rs +++ b/crates/engine/src/cmd/pattern_fill_cmds.rs @@ -37,7 +37,7 @@ fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option) let (mut scale, mut angle, mut link, mut phase, mut pat) = (*scale, *angle, *link, *phase, None); if let Some(v) = p.get("pattern").filter(|v| !v.is_null()) { let key = v.as_str().filter(|k| !k.is_empty()).ok_or_else(|| bad("`pattern` is a pattern id or name"))?; - pat = Some(crate::pattern_cmds::resolve(s, key).ok_or_else(|| bad(format!("no pattern \"{key}\" (see pattern.list)")))?); + pat = Some(crate::pattern_cmds::resolve_or_named(s, key, p).map_err(bad)?); } if let Some(v) = num("scale", SCALE_RANGE)? { scale = v / 100.0; @@ -67,8 +67,8 @@ fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option) /// The target layer and the fill `p` gives it, checked against the active document. fn target(s: &Session, p: &Value) -> Result<(photocraft_doc::LayerId, Fill, Fill, Option)> { - if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) { - return Err(bad("params must be an object and layer an unsigned id")); + if !p.is_object() { + return Err(bad("params must be an object")); } let st = s.active().ok_or(EngineError::NoDocument)?; if let Some(document) = p.get("document") @@ -139,7 +139,7 @@ pub fn specs() -> Vec { label: "Pattern Fill…", menu: &[], shortcut: None, - params: r##"{"layer":id? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##, + params: r##"{"layer":id|name? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), run: set, journal: true, @@ -165,6 +165,14 @@ mod tests { f.clone() } + #[test] + fn named_nonactive_pattern_fill_can_be_edited() { + let (mut s, id) = session(8); + let name = s.active().unwrap().doc.layer(id).unwrap().name.clone(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + assert_eq!(s.execute(SET, json!({"layer": name, "scale": 125})).unwrap()["layer"], id.0); + } + #[test] fn edit_keeps_masks_is_one_step_and_undoes_at_every_depth() { for depth in [8, 16, 32] { @@ -197,6 +205,77 @@ mod tests { } } + #[test] + fn an_empty_pixel_layer_becomes_the_fill_other_layers_get_one_above() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + let empty = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().doc.layers.len(); + let p = json!({"pattern": "Checkerboard"}); + let shown = preview(&s, CREATE, &p).unwrap(); + let id = s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(id, empty.0, "the empty layer itself is filled"); + assert_eq!(s.active().unwrap().doc.layers.len(), count, "no layer is added"); + assert!(matches!(fill(&s, empty), Fill::Pattern { .. })); + assert!(s.active().unwrap().doc.layer(empty).unwrap().name.starts_with("Pattern Fill"), "a default name follows the content"); + assert_eq!(*s.active().unwrap().doc, shown, "the preview shows what OK commits"); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + // A name the user gave the layer is kept. + s.execute("layer.renameLayer", json!({"layer": empty.0, "name": "Sky"})).unwrap(); + s.execute(CREATE, json!({"pattern": "Checkerboard"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(empty).unwrap().name, "Sky"); + s.undo(); + s.undo(); + // A layer with pixels (the white Background) keeps them: the fill goes above it. + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + let id = LayerId(s.execute(CREATE, json!({"pattern": "Checkerboard"})).unwrap()["layer"].as_u64().unwrap()); + assert_ne!(id, bg); + assert!(matches!(s.active().unwrap().doc.layer(bg).unwrap().content, LayerContent::Raster(_))); + assert_eq!(s.active().unwrap().doc.layers.len(), count + 1); + } + + /// Not empty: a layer opaque only through its default pixel (no tiles stored, as KRA imports + /// it) and a blank video layer. Both get a fill above and keep what they had. + #[test] + fn a_default_pixel_or_video_layer_is_not_taken_for_an_empty_one() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + let opaque = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let l = doc.layer_mut(opaque).unwrap(); + let fmt = l.surface().unwrap().format(); + l.content = LayerContent::Raster(photocraft_raster::Surface::with_default(fmt, &[0.2, 0.4, 0.6, 1.0])); + let video = LayerId(s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap()["layer"].as_u64().unwrap()); + for old in [opaque, video] { + s.execute("layer.select", json!({"layer": old.0})).unwrap(); + let before = s.active().unwrap().doc.layer(old).unwrap().clone(); + let count = s.active().unwrap().doc.layers.len(); + let id = LayerId(s.execute(CREATE, json!({"pattern": "Checkerboard"})).unwrap()["layer"].as_u64().unwrap()); + assert_ne!(id, old, "a fill above {old:?}"); + assert_eq!(s.active().unwrap().doc.layers.len(), count + 1); + assert_eq!(*s.active().unwrap().doc.layer(old).unwrap(), before, "{old:?} keeps its pixels and video"); + } + } + + /// An empty layer stays empty through a mode change (its default pixel stays transparent). + #[test] + fn an_empty_layer_is_still_the_fill_after_a_mode_change() { + for mode in ["image.mode.cmyk", "image.mode.lab"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + let empty = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute(mode, json!({})).unwrap(); + s.execute("layer.select", json!({"layer": empty})).unwrap(); + let count = s.active().unwrap().doc.layers.len(); + let id = s.execute(CREATE, json!({"pattern": "Checkerboard"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(id, empty, "{mode}"); + assert_eq!(s.active().unwrap().doc.layers.len(), count, "{mode}"); + } + } + #[test] fn preview_matches_what_the_command_commits() { let (mut s, id) = session(8); diff --git a/crates/engine/src/cmd/photo_cmds.rs b/crates/engine/src/cmd/photo_cmds.rs index 2aa2a383b8..9dd7da094b 100644 --- a/crates/engine/src/cmd/photo_cmds.rs +++ b/crates/engine/src/cmd/photo_cmds.rs @@ -874,7 +874,9 @@ fn mask_unselected(sel: &Surface, area: Rect, w: usize, h: usize, rgba: &mut [[f true } +/// Validate the selected layer, then open cropped scan photos without editing the source document. fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { + crop_ready(s)?; let st = s.active().ok_or(EngineError::NoDocument)?; let doc = st.doc.clone(); let fmt = doc.pixel_format(); @@ -942,13 +944,18 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result { // ---------- specs ---------- -/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents. -fn not_indexed(s: &Session) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; +/// Crop needs a selected, visible, pixel-unlocked layer, including inherited state. +fn crop_ready(s: &Session) -> Result<()> { + let st = s.active().ok_or(EngineError::NoDocument)?; if st.doc.mode == ColorMode::Indexed { - return Err("not available for Indexed Color documents".into()); + return Err(EngineError::Other("not available for Indexed Color documents".into())); } - Ok(()) + let id = st.active_layer.ok_or_else(|| EngineError::Other("no layer selected".into()))?; + let path = st.doc.path_of(id).ok_or(EngineError::NoLayer(id))?; + if !st.doc.shown_at(&path) { + return Err(EngineError::Other("the selected layer is hidden".into())); + } + crate::commands::check_pixels_unlocked(&st.doc, id) } fn always(_: &Session) -> std::result::Result<(), String> { @@ -983,7 +990,7 @@ pub fn specs() -> Vec { menu: &["File", "Automate"], shortcut: None, params: "{} → one new document per photo found on the scan (inside the selection when there is one)", - enabled: not_indexed, + enabled: |s| crop_ready(s).map_err(|e| e.to_string()), run: crop_and_straighten, journal: true, }, diff --git a/crates/engine/src/cmd/pick_cmds.rs b/crates/engine/src/cmd/pick_cmds.rs index c40e15e9b8..76a9e8b773 100644 --- a/crates/engine/src/cmd/pick_cmds.rs +++ b/crates/engine/src/cmd/pick_cmds.rs @@ -26,6 +26,12 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec if x.checked_add(1).is_none() || y.checked_add(1).is_none() { return Vec::new(); } + // Off the canvas nothing is drawn (only artboard documents show layers over the pasteboard, + // #5712), so pixels hanging past the edge and fill layers can't be hit there: a pasteboard + // click with Auto-Select clears the layer selection instead of picking them (#10665). + if !doc.has_artboards() && !doc.bounds().contains(x, y) { + return Vec::new(); + } let rows = doc.walk(); let mut out = Vec::new(); for (path, _, l) in rows.iter().rev() { @@ -114,13 +120,15 @@ fn pick(s: &mut Session, p: &Value) -> Result { /// Layers whose content touches document rect `r`, top to bottom: visible, not fully locked, not /// the locked Background (as Select › All Layers). A layer on an artboard counts only where the -/// board shows it. With `group`, each counts as its outermost group (deduplicated). +/// board shows it, and without artboards only where the canvas does (#10665). With `group`, each +/// counts as its outermost group (deduplicated). pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec { let mut out = Vec::new(); + let canvas = doc.bounds(); + let r = if doc.has_artboards() { r } else { r.intersect(&canvas) }; if r.is_empty() { return out; } - let canvas = doc.bounds(); for (path, _, l) in doc.walk().iter().rev() { if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) || doc.is_background(l.id) @@ -447,6 +455,30 @@ mod tests { assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err()); } + /// #10665: off the canvas nothing is drawn, so a fill layer or pixels hanging past the edge + /// are neither picked there nor caught by a marquee that touches only the pasteboard. + #[test] + fn nothing_is_hit_off_the_canvas() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 40, "background": "transparent"})).unwrap(); + let fill = s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.new.layer", json!({"name": "Off"})).unwrap(); + let off = s.active().unwrap().active_layer.unwrap(); + s.edit("paint past the edge", |doc, _| { + doc.layer_mut(off).unwrap().surface_mut().unwrap().fill_rect(Rect::new(-20, 0, 10, 40), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let pick = |s: &mut Session, x: i32, y: i32| s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"].clone(); + assert_eq!(pick(&mut s, 5, 5), off.0, "on the canvas"); + assert_eq!(pick(&mut s, 20, 5), fill, "the fill layer covers the canvas"); + assert_eq!(pick(&mut s, -10, 5), Value::Null, "the pasteboard, over Off's hidden pixels and the fill"); + assert_eq!(s.execute("layer.pickAt", json!({"x": -10, "y": 5, "list": true})).unwrap()["layers"], json!([])); + let in_rect = |s: &mut Session, x: f64| s.execute("layer.selectInRect", json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap()["layers"].clone(); + assert_eq!(in_rect(&mut s, -15.0), json!([]), "a marquee on the pasteboard only"); + assert_eq!(in_rect(&mut s, -3.0), json!([off.0, fill]), "a marquee reaching onto the canvas"); + } + /// A hide-all mask at density 0 hides nothing (the compositor applies density through /// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through. #[test] diff --git a/crates/engine/src/cmd/plugin_cmds/tests.rs b/crates/engine/src/cmd/plugin_cmds/tests.rs index 5f93ce7909..44b129bc5c 100644 --- a/crates/engine/src/cmd/plugin_cmds/tests.rs +++ b/crates/engine/src/cmd/plugin_cmds/tests.rs @@ -133,6 +133,23 @@ fn example_invert_matches_builtin_invert_and_round_trips_the_registry() { std::fs::remove_dir_all(&dir).ok(); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn installing_a_file_that_is_not_wasm_names_what_it_is() { + // #10362: the Plug-ins > Install Plug-in dialog pointed at an old Photoshop plug-in (.8bt, a + // native Windows module) failed with a message that said nothing about that file. `path` goes + // through `registry::load_file`, so this is the exact user path. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Old Plug-in.8bt"); + std::fs::write(&path, b"MZ\x90\x00\x03\0").unwrap(); + let e = Session::new().execute("plugin.install", json!({"path": path.to_str().unwrap()})).unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("Windows") && msg.contains("8bf"), "{msg}"); + assert!(msg.contains("WebAssembly"), "{msg}"); + // The hint is static text: no path the person did not choose is added to the message. + assert!(!msg.contains(dir.path().to_str().unwrap()), "{msg}"); +} + #[test] fn params_are_validated_and_passed() { let bytes = wat_plugin("test.cmds.grey", r#"{"amount":{"type":"number","min":0,"max":1,"default":1}}"#, GREY); diff --git a/crates/engine/src/cmd/preset_import_cmds.rs b/crates/engine/src/cmd/preset_import_cmds.rs index 417f5623a1..07f7f50eb0 100644 --- a/crates/engine/src/cmd/preset_import_cmds.rs +++ b/crates/engine/src/cmd/preset_import_cmds.rs @@ -117,14 +117,30 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { ) } -fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io::abr_map::AbrImport) -> Result { - // Re-importing a file replaces its group instead of duplicating it. - if p.get("replace").and_then(Value::as_bool).unwrap_or(true) { - s.tools.presets.retain(|x| x.builtin || x.group != group); +/// `name`, or `name (2)`, `name (3)`… when `taken` says it is in use. +fn free_group(name: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(name) { + return name.to_string(); } + (2..=u32::MAX).map(|i| format!("{name} ({i})")).find(|g| !taken(g)).unwrap_or_else(|| name.to_string()) +} + +fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io::abr_map::AbrImport) -> Result { + // A file whose name matches a group already in the library becomes a new group beside it, as + // in Photoshop, unless `replace` asks to drop that group's own presets first: the group name + // is only the file name, and the library is outside document history (#10640). + let group = if p.get("replace").and_then(Value::as_bool).unwrap_or(false) { + s.tools.presets.retain(|x| x.builtin || x.group != group); + group + } else { + // One pass over the library, not one per numbered candidate. + let groups: std::collections::HashSet<&str> = s.tools.presets.iter().map(|x| x.group.as_str()).collect(); + free_group(&group, |g| groups.contains(g)) + }; let mut taken = UniqueNames::new(s.tools.presets.iter().map(|x| x.name.as_str())); let mut names = Vec::with_capacity(imp.presets.len()); for mut preset in imp.presets { + preset.group.clone_from(&group); preset.name = taken.unique(&preset.name); names.push(preset.name.clone()); s.tools.presets.push(preset); @@ -243,7 +259,7 @@ pub fn specs() -> Vec { label: "Import Brushes…", menu: &[], shortcut: None, - params: r##"{"path":".abr file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name),"select":bool=false (make the first imported preset current)} → {group, imported, count, folders (nested folders kept from the file), version, warnings}"##, + params: r##"{"path":".abr file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=false (first delete the non-built-in presets of a group of the same name; false imports beside it as (2)),"select":bool=false (make the first imported preset current)} → {group, imported, count, folders (nested folders kept from the file), version, warnings}"##, enabled: always, run: import_abr, journal: true, @@ -263,7 +279,7 @@ pub fn specs() -> Vec { label: "Import Gradients…", menu: &[], shortcut: None, - params: r##"{"path":".grd file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name)}"##, + params: r##"{"path":".grd file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=false (first delete a group of the same name; false imports beside it as (2))} → {group, imported, count, warnings}"##, enabled: always, run: import_grd, journal: true, @@ -362,9 +378,13 @@ fn import_grd(s: &mut Session, p: &Value) -> Result { } let names: Vec = items.iter().map(|g| g.name.clone()).collect(); let st = &mut s.presets; - if p.get("replace").and_then(Value::as_bool).unwrap_or(true) { + // As with brushes: a new group beside one of the same name unless `replace` (#10640). + let group = if p.get("replace").and_then(Value::as_bool).unwrap_or(false) { st.gradients.retain(|g| g.name != group); - } + group + } else { + free_group(&group, |g| st.gradients.iter().any(|x| x.name == g)) + }; st.gradients.push(Group::new(&group, items)); s.presets_changed(); Ok(json!({"group": group, "imported": names, "count": names.len(), "warnings": warnings})) @@ -406,8 +426,8 @@ mod tests { assert_eq!(s.tools.presets.len(), before + 2); assert!(s.tools.presets.iter().filter(|p| p.group == "Legacy").all(|p| !p.builtin)); assert!(matches!(s.tools.brush.tip, photocraft_paint::TipShape::Sampled(_))); - // Re-import replaces the group. - s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy"})).unwrap(); + // Re-import with `replace` replaces the group. + s.execute("brush.presets.importAbr", json!({"data": data, "group": "Legacy", "replace": true})).unwrap(); assert_eq!(s.tools.presets.len(), before + 2); // Imported presets paint. s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); @@ -426,6 +446,40 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// Importing a file named like an existing group keeps that group's brushes, edited or moved + /// in ones included: the new brushes become "Kit (2)", "Kit (3)"… Only `replace` drops them (#10640). + #[test] + fn reimport_keeps_the_groups_brushes_unless_replace() { + let mut s = Session::new(); + let data = photocraft_paint::tile::b64_encode(&abr_v2()); + let builtin = s.tools.presets.iter().filter(|p| p.builtin).count(); + s.execute("brush.presets.importAbr", json!({"data": data, "group": "Kit"})).unwrap(); + // The user's own brush moved into the group. + let mut own = s.tools.presets.iter().find(|p| p.group == "Kit").unwrap().clone(); + own.name = "My Ink".into(); + s.tools.presets.push(own); + let r = s.execute("brush.presets.importAbr", json!({"data": data, "group": "Kit"})).unwrap(); + assert_eq!(r["group"], "Kit (2)"); + let in_group = |s: &Session, g: &str| s.tools.presets.iter().filter(|p| p.group == g).count(); + assert_eq!((in_group(&s, "Kit"), in_group(&s, "Kit (2)")), (3, 2)); + let r = s.execute("brush.presets.importAbr", json!({"data": data, "group": "Kit", "replace": false})).unwrap(); + assert_eq!(r["group"], "Kit (3)"); + assert!(s.tools.presets.iter().any(|p| p.name == "My Ink" && p.group == "Kit")); + // `replace` drops only that group's non-built-in presets. + let r = s.execute("brush.presets.importAbr", json!({"data": data, "group": "Kit", "replace": true})).unwrap(); + assert_eq!(r["group"], "Kit"); + assert_eq!((in_group(&s, "Kit"), in_group(&s, "Kit (2)"), in_group(&s, "Kit (3)")), (2, 2, 2)); + assert!(!s.tools.presets.iter().any(|p| p.name == "My Ink")); + assert_eq!(s.tools.presets.iter().filter(|p| p.builtin).count(), builtin); + } + + #[test] + fn free_group_numbers_past_taken_names() { + let taken = ["Kit", "Kit (2)"]; + assert_eq!(free_group("Set", |g| taken.contains(&g)), "Set"); + assert_eq!(free_group("Kit", |g| taken.contains(&g)), "Kit (3)"); + } + #[test] fn bad_params_fail_gracefully() { let mut s = Session::new(); @@ -472,10 +526,14 @@ mod tests { let g = s.presets.gradients.iter().find(|g| g.name == "Fire").unwrap(); assert_eq!(g.items[0].stops, vec![(0.0, StopColor::Foreground), (1.0, StopColor::Rgb([1.0, 0.0, 0.0]))]); assert_eq!(g.items[0].opacity, vec![(0.0, 1.0), (1.0, 0.0)]); - // Re-import replaces the group. + // Re-import adds a group beside it (#10640); with `replace` it replaces the group. let n = s.presets.gradients.len(); - s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire"})).unwrap(); - assert_eq!(s.presets.gradients.len(), n); + let r = s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire"})).unwrap(); + assert_eq!(r["group"], "Fire (2)"); + assert_eq!(s.presets.gradients.len(), n + 1); + s.execute("gradient.presets.importGrd", json!({"data": data, "group": "Fire", "replace": true})).unwrap(); + assert_eq!(s.presets.gradients.len(), n + 1); + assert_eq!(s.presets.gradients.iter().filter(|g| g.name == "Fire").count(), 1); for p in [json!({}), json!({"data": "@@"}), json!({"data": photocraft_paint::tile::b64_encode(b"8BGR\0\x03")}), json!({"path": "/nope.grd"})] { assert!(s.execute("gradient.presets.importGrd", p.clone()).is_err(), "{p}"); } diff --git a/crates/engine/src/cmd/print_cmds.rs b/crates/engine/src/cmd/print_cmds.rs index 37b4e78e92..e8b0064e2b 100644 --- a/crates/engine/src/cmd/print_cmds.rs +++ b/crates/engine/src/cmd/print_cmds.rs @@ -250,8 +250,8 @@ enum SpoolError { /// Sends the job and says where it went and through what. A named print server takes the IPP /// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the -/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had -/// no route either. A route that exists but fails (an unknown printer, a refused job) is an +/// Windows spooler) when it is there, or the app's spooler when CUPS lists no printer for `lp`; +/// else CUPS over IPP; and the app's spooler when that had no route either. A route that exists but fails (an unknown printer, a refused job) is an /// error, never a fall-through. fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> { let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other); @@ -261,7 +261,19 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - } if let Some(name) = system_spooler() { require_windows_pdf()?; - return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other); + // lpstat is CUPS, not a Windows driver, so it runs outside SPOOLER. A listing that + // can't finish in time can't tell, so lp gets the job as it did before the check. + let route = match name { + "lp" => lp_printers(LP_LISTING_TIMEOUT).map_or_else(|| Some(lp.clone()), |list| lp_route(lp, &list)), + _ => Some(lp.clone()), + }; + return match (route, spooler) { + (Some(lp), _) => with_spooler(|| craft_print::submit(&job.pdf, &lp)).map(|m| (m, name.to_string())).map_err(other), + // CUPS lists no printer. IPP would ask the same CUPS, or (it ignores client.conf) + // another server whose printer the user never chose, so only the portal is left. + (None, Some(sp)) => app(sp), + (None, None) => Err(other("no printers found; set one up in the system's printer settings, or print to PDF")), + }; } } match cups_spool(job) { @@ -274,6 +286,78 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) - } } +/// How long a print job waits for CUPS' printer list (`lpstat`, whose `-e` browses the network). +const LP_LISTING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The most a printer listing may print; a longer one counts as no answer. +const LP_LISTING_CAP: u64 = 1 << 20; + +/// CUPS' printers as `craft_print::printers` lists them, or `None` when that can't be told: an +/// `lpstat` can't start, outlasts `timeout`, prints more than `LP_LISTING_CAP`, or `lpstat -e` +/// fails (CUPS unreachable, or listing forbidden by policy). `lpstat -p -d`'s own status is not +/// a failure: it exits 1 whenever there are no queues, as on a host with only driverless +/// printers. Each `lpstat` is killed and reaped at the deadline, so a hung one never outlives +/// the job. +fn lp_printers(timeout: std::time::Duration) -> Option> { + let deadline = std::time::Instant::now() + timeout; + let (_, queues) = output_by(craft_print::lpstat_command(), deadline)?; + // `lpstat -e`, as craft-print runs it (its builder is private): the driverless printers. + let mut driverless = std::process::Command::new("lpstat"); + driverless.arg("-e").env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "Craft"); + let (listed, available) = output_by(driverless, deadline)?; + listed.then(|| craft_print::printers_parsed(&queues, Some(&available))) +} + +/// Whether `command` succeeded, and its standard output; `None` when it can't start, prints more +/// than `LP_LISTING_CAP`, or is still running at `deadline`, when it is killed and reaped. The +/// output is read on its own thread, so a full pipe can't stall the wait, and that thread ends +/// when the process does. +fn output_by(mut command: std::process::Command, deadline: std::time::Instant) -> Option<(bool, String)> { + use std::io::Read as _; + use std::process::Stdio; + let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let mut stdout = child.stdout.take()?; + let (sender, receiver) = std::sync::mpsc::channel(); + let reader = std::thread::Builder::new().name("print-listing".into()).spawn(move || { + let mut out = Vec::new(); + // One byte over the cap tells an oversized listing apart; stopping closes the pipe. + let _ = sender.send((&mut stdout).take(LP_LISTING_CAP + 1).read_to_end(&mut out).map(|_| out)); + }); + let status = reader.ok().and_then(|_| { + loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)), + _ => break None, + } + } + }); + let Some(status) = status else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let out = receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())).ok()?.ok()?; + let fits = u64::try_from(out.len()).is_ok_and(|n| n <= LP_LISTING_CAP); + fits.then(|| (status.success(), String::from_utf8_lossy(&out).into_owned())) +} + +/// The job for `lp`, or `None` when no printer was asked for and CUPS lists none: `lp` has +/// nowhere to send it, and the portal still may (a Flatpak bundles `lp`). A printer asked for by +/// name always goes to `lp`, which knows it even where CUPS won't list it, and the portal could +/// not honour the name. With no printer asked for, no default and one printer, that printer, as +/// `resolve_printer` picks for IPP. +fn lp_route(lp: &craft_print::Job, printers: &[craft_print::Printer]) -> Option { + let mut lp = lp.clone(); + if lp.printer.is_none() + && !printers.iter().any(|p| p.default) + && let [only] = printers + { + lp.printer = Some(only.name.clone()); + } + (lp.printer.is_some() || !printers.is_empty()).then_some(lp) +} + /// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command /// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the /// browser. @@ -905,11 +989,11 @@ fn with_driver_printable(p: &Value) -> Value { fn driver_printable(p: &Value) -> Option { let printer = printer_name(p).ok()?; - let settings = printer_settings(p).ok()?; - let pg = with_spooler(|| craft_print::rgb::page(&printer, settings.as_ref())).ok()?; - // Only for the paper the driver is set to: a print laid out on another paper keeps the - // fixed margin (and `print_windows` refuses it), as before. let (pw, ph) = paper(p, "file.print").ok()?; + // The page `print_windows` prints on, turned as it turns it for the layout's orientation. + let (_, pg) = settings_for_layout(&printer, printer_settings(p).ok()?, (pw, ph)).ok()?; + // Only for the paper the driver is set to, either way round: a print laid out on another + // paper keeps the fixed margin (and `print_windows` refuses it), as before. if (pg.paper.0 - pw).abs() > 2.0 || (pg.paper.1 - ph).abs() > 2.0 { return None; } diff --git a/crates/engine/src/cmd/print_cmds/tests.rs b/crates/engine/src/cmd/print_cmds/tests.rs index 6208b53ec5..177d1305ba 100644 --- a/crates/engine/src/cmd/print_cmds/tests.rs +++ b/crates/engine/src/cmd/print_cmds/tests.rs @@ -678,6 +678,14 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n", ).unwrap(); std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + // `lp` takes a job only when CUPS lists a printer (`lp_route`), so the fake CUPS lists its queues. + let lpstat = dir.path().join("lpstat"); + std::fs::write( + &lpstat, + b"#!/bin/sh\nif [ \"$1\" = -e ]; then printf 'Office\\nRefuse\\n'; else printf 'printer Office is idle.\\nprinter Refuse is idle.\\n'; fi\n", + ) + .unwrap(); + std::fs::set_permissions(&lpstat, std::fs::Permissions::from_mode(0o700)).unwrap(); let result = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "cmd::print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) .env(CHILD, dir.path()) @@ -972,6 +980,50 @@ fn a_preferred_app_spooler_takes_the_job() { assert!(std::path::Path::new(&out).exists()); } +#[cfg(unix)] +#[test] +fn a_printer_listing_past_its_deadline_is_killed_and_reaped() { + let soon = || std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut echo = std::process::Command::new("sh"); + echo.args(["-c", "echo printer Office is idle."]); + assert_eq!(output_by(echo, soon()), Some((true, "printer Office is idle.\n".to_string()))); + let mut failed = std::process::Command::new("sh"); + failed.args(["-c", "exit 1"]); + assert_eq!(output_by(failed, soon()), Some((false, String::new())), "a failed listing is told apart from an empty one"); + let mut flood = std::process::Command::new("sh"); + flood.args(["-c", "head -c 2000000 /dev/zero"]); + assert_eq!(output_by(flood, soon()), None, "an oversized listing is no answer"); + // A hung lpstat: given up at the deadline, and killed, so nothing is left waiting on it. + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let mut hung = std::process::Command::new("sh"); + hung.arg("-c").arg(format!("echo $$ > '{}'; exec sleep 30", pid_file.display())); + let started = std::time::Instant::now(); + assert_eq!(output_by(hung, started + std::time::Duration::from_millis(300)), None); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "the deadline must cut the wait short"); + let pid = std::fs::read_to_string(&pid_file).unwrap(); + // `kill -0` fails for a process that no longer exists, on every Unix. + let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).stderr(std::process::Stdio::null()).status().unwrap().success(); + assert!(!alive, "the hung process must be killed and reaped"); + assert_eq!(output_by(std::process::Command::new("/nonexistent/lpstat"), soon()), None); +} + +#[test] +fn lp_takes_the_job_only_when_cups_lists_a_printer_and_picks_the_only_one() { + let p = |name: &str, default: bool| craft_print::Printer { name: name.into(), default, two_sided: None, description: String::new() }; + let job = craft_print::Job::default(); + // No printers: no lp route, so the portal still gets the job. + assert!(lp_route(&job, &[]).is_none()); + assert_eq!(lp_route(&job, &[p("Office", false)]).and_then(|j| j.printer), Some("Office".into())); + assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", true)]).and_then(|j| j.printer), None, "lp picks the default itself"); + assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", false)]).and_then(|j| j.printer), None); + let asked = craft_print::Job { printer: Some("Lab".into()), ..Default::default() }; + assert_eq!(lp_route(&asked, &[p("Office", false)]).and_then(|j| j.printer), Some("Lab".into())); + // A named printer goes to lp even when CUPS lists none: listing can be forbidden while + // printing is not, and the portal can't honour a name. + assert_eq!(lp_route(&asked, &[]).and_then(|j| j.printer), Some("Lab".into())); +} + #[cfg(not(target_os = "windows"))] mod cups { use super::*; diff --git a/crates/engine/src/cmd/rasterize_style_cmds.rs b/crates/engine/src/cmd/rasterize_style_cmds.rs index db8af142be..55a637d5cb 100644 --- a/crates/engine/src/cmd/rasterize_style_cmds.rs +++ b/crates/engine/src/cmd/rasterize_style_cmds.rs @@ -69,9 +69,12 @@ fn bake_one(s: &mut Session, id: LayerId, key: &str) -> Result { doc.layers = stack; let n = fmt.channels(); data = vec![0.0f32; buf.px.len() * n]; - for (p, out) in buf.px.iter().zip(data.chunks_exact_mut(n)) { - from_rgba_into(&fmt, *p, out); - } + // Separate CMYK through the document's profile, the one the render read it with. + photocraft_color::convert::with_cmyk_space(photocraft_compose::cmyk_space(doc).as_ref(), || { + for (p, out) in buf.px.iter().zip(data.chunks_exact_mut(n)) { + from_rgba_into(&fmt, *p, out); + } + }); } let mut px = Surface::new(fmt); if !area.is_empty() { diff --git a/crates/engine/src/cmd/redeye_cmds.rs b/crates/engine/src/cmd/redeye_cmds.rs index eefd1a37ba..66d0221d21 100644 --- a/crates/engine/src/cmd/redeye_cmds.rs +++ b/crates/engine/src/cmd/redeye_cmds.rs @@ -4,7 +4,6 @@ //! runs on a window around each click plus the mask-filter halo. use photocraft_algo::redeye::{self, MAX_POINTS, MAX_WINDOW}; -use photocraft_doc::LayerId; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into}; use serde_json::{Value, json}; @@ -223,10 +222,7 @@ fn red_eye(s: &mut Session, p: &Value) -> Result { } let pupil = pupil_raw.clamp(1.0, 100.0); let darken = darken.clamp(0.0, 100.0); - let id = match p.get("layer").and_then(Value::as_u64) { - Some(id) => Some(LayerId(id)), - None => s.active().and_then(|d| d.active_layer), - }; + let id = if p.get("layer").is_some() { Some(crate::commands::layer_param(s, p)?) } else { s.active().and_then(|d| d.active_layer) }; let (damage, total) = s.edit("Red Eye", |doc, active| { let canvas = doc.bounds(); let sel = doc.selection.clone(); @@ -243,9 +239,7 @@ fn red_eye(s: &mut Session, p: &Value) -> Result { } Ok((damage, total)) })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(damage); - } + s.hint_damage(damage); Ok(json!({ "damage": [damage.x0, damage.y0, damage.width(), damage.height()], "pixels": total })) } diff --git a/crates/engine/src/cmd/render_cmds.rs b/crates/engine/src/cmd/render_cmds.rs index cc27304fdc..a6ee1585ea 100644 --- a/crates/engine/src/cmd/render_cmds.rs +++ b/crates/engine/src/cmd/render_cmds.rs @@ -6,7 +6,7 @@ //! can verify them without screenshots. use photocraft_algo::render2::{self, FlameSpec, FrameSpec, Prim, TreeSpec}; -use photocraft_doc::{Layer, LayerContent, LayerId}; +use photocraft_doc::{Layer, LayerContent}; use photocraft_geom::Rect; use photocraft_raster::Surface; use serde_json::{Map, Value, json}; @@ -102,7 +102,7 @@ fn flame_paths(s: &Session, p: &Value) -> Option>> { /// Draws `prims` onto the target layer as one undo step. fn draw(s: &mut Session, label: &str, p: &Value, prims: Vec, extra: Value) -> Result { let want_new = p.get("newLayer").and_then(Value::as_bool).unwrap_or(false); - let target = p.get("layer").and_then(Value::as_u64).map(LayerId); + let target = p.get("layer").map(|_| crate::commands::layer_param(s, p)).transpose()?; let count = prims.len(); let (layer, created, used) = s.edit(label, |doc, active| { let id = target.or(*active); diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index da9183169f..2f94d273d5 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -169,10 +169,7 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option LayerId(id), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, - }; + let id = crate::commands::layer_param(s, p)?; Ok((Stroke { brush, points: pts }, Some(id))) } @@ -193,9 +190,7 @@ fn run_stroke( // large layer would dominate the cost of a dab. f(&pre, surf, pre.selection.as_ref(), lock) })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(dmg); - } + s.hint_damage(dmg); Ok(dmg) } @@ -222,6 +217,8 @@ mod sampling; pub use clone::{LiveRetouch, clone_preview, clone_preview_periodic}; use clone::{blend_param, clamp_samples, clone_stamp}; pub(crate) use dab::DabEffect; +#[cfg(test)] +use dab::DabTool; pub use dab::LiveDab; use dab::dab_cmd; #[cfg(test)] diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index 1e4ae8ca0e..6a8a6e2b05 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -179,6 +179,7 @@ pub struct LiveRetouch { renderer: photocraft_paint::StrokeRenderer, pre: std::sync::Arc, pre_surf: Surface, + colors: Option, id: Option, params: Value, paint: LivePaint, @@ -229,6 +230,8 @@ impl LiveRetouch { /// 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 captured = crate::channel_cmds::inject_target(s, cmd, p.clone()); + let p = &captured; let st = s.active().ok_or(EngineError::NoDocument)?; let (pre, base) = (st.doc.clone(), (st.doc.id, st.revision)); let mut doc = (*pre).clone(); @@ -249,6 +252,7 @@ impl LiveRetouch { renderer, pre, pre_surf, + colors: crate::color_channels::target(s, p)?, id, params: p.clone(), paint, @@ -311,6 +315,15 @@ impl LiveRetouch { /// Invalid coordinates reject the whole batch without changing the preview. /// Only the position and pressure of `pts` are used: the commands take no tilt or times. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + let damage = self.push_inner(pts)?; + if let Some(colors) = self.colors { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + crate::color_channels::restrict_region(&self.pre_surf, surf, colors, damage)?; + } + Ok(damage) + } + + fn push_inner(&mut self, pts: &[StrokePoint]) -> Result { crate::brush_cmds::check_coords(pts, &self.cmd)?; let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); diff --git a/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs b/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs index 6e01926e3f..dc36026ce9 100644 --- a/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/cmd/retouch_cmds/content_aware_move.rs @@ -529,9 +529,7 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(damage); - } + s.hint_damage(damage); let mode = if plan.mode == Mode::Move { "move" } else { "extend" }; let mut out = json!({ "damage": damage_json(damage), "offset": [dx, dy], "mode": mode }); if plan.map.is_some() { @@ -555,6 +553,7 @@ const PREVIEW_MARGIN: i32 = 48; /// 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 { + colors: Option, doc: std::sync::Arc, plan: Plan, params: Value, @@ -568,11 +567,14 @@ 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 = if self.is_coarse() { + run_scaled(&mut work, sel, &self.plan, &self.params, self.scale, ctx)? + } else { + run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?.1 + }; + if let (Some(colors), Some(id)) = (self.colors, self.plan.target.id) { + crate::channel_cmds::restrict_to_color(&self.doc, &mut work, id, colors)?; } - let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?; Ok((work, damage)) } @@ -587,6 +589,7 @@ impl ContentAwarePreview { /// resolution. pub fn refine(&self) -> Option { self.is_coarse().then(|| ContentAwarePreview { + colors: self.colors, doc: self.doc.clone(), plan: Plan { margin: MAX_MARGIN, ..self.plan }, params: self.params.clone(), @@ -598,12 +601,14 @@ impl ContentAwarePreview { /// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors /// 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 captured = crate::channel_cmds::inject_target(s, CMD, p.clone()); + let p = &captured; let plan = plan(s, p, PREVIEW_MARGIN)?; 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(), scale }) + Ok(ContentAwarePreview { colors: crate::color_channels::target(s, p)?, 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 @@ -770,10 +775,12 @@ mod tests { // 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); + // Just over four times the preview budget still averages each isolated 1/255 + // sample below half an 8-bit level, reproducing the loss without a 0.77 MP solve. + let mut s = textured(600, 600); s.edit("faint selection", |doc, _| { let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8); - let r = Rect::new(20, 20, 1120, 720); + let r = Rect::new(20, 20, 536, 536); 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); @@ -783,6 +790,7 @@ mod tests { let p = json!({"offset": [40, 40]}); let job = preview(&s, &p).unwrap(); assert!(job.is_coarse()); + assert!(job.scale < 0.51, "the sparse mask must actually be averaged down"); let (got, damage) = job.run(&JobCtx::new()).unwrap(); assert!(!damage.is_empty()); let b = got.bounds(); diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index 7ff5a600e6..e51ac10021 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -125,7 +125,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result { /// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and /// turned into its per-dab effect, for the commit and the live preview alike. #[derive(Clone, Debug, PartialEq)] -enum DabTool { +pub(super) enum DabTool { Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, Sponge { saturate: bool, vibrance: bool }, Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 }, @@ -168,7 +168,7 @@ impl DabNeighborhood { impl DabTool { /// The tool for `cmd` and its history label. - fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { + pub(super) fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { Ok(match cmd { "paint.dodge" | "paint.burn" => { let burn = cmd == "paint.burn"; @@ -216,7 +216,7 @@ impl DabTool { } /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. - fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { let sp = spacing; match self.clone() { Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), @@ -380,11 +380,16 @@ pub struct LiveDab { /// The active document with the stroke so far. pub doc: std::sync::Arc, cmd: String, - renderer: photocraft_paint::StrokeRenderer, + ctx: photocraft_paint::BrushContext, + generator: photocraft_paint::dynamics::DabGenerator, + /// Only the current batch is retained; sequential effects don't use accumulated coverage. + pending: Vec, + dual_pending: Vec, done: usize, effect: DabEffect, neighborhood: DabNeighborhood, pre_surf: Surface, + colors: Option, work: WorkTiles, id: Option, params: Value, @@ -449,11 +454,18 @@ impl LiveDab { neighborhood: DabNeighborhood, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { + let captured = crate::channel_cmds::inject_target(s, cmd, p.clone()); + let p = &captured; let st = s.active().ok_or(EngineError::NoDocument)?; let base = (st.doc.id, st.revision); let doc = (*st.doc).clone(); let mut live = Self::with_neighborhood_on(doc, cmd, p, stroke, id, neighborhood, effect)?; live.base = Some(base); + live.colors = crate::color_channels::target(s, p)?; + if let Some(colors) = live.colors { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut live.doc), live.id, &live.params)?; + crate::color_channels::restrict_region(&live.pre_surf, surf, colors, live.bounds)?; + } Ok(live) } @@ -480,21 +492,28 @@ impl LiveDab { neighborhood: DabNeighborhood, effect: impl FnOnce(&Surface) -> DabEffect, ) -> Result { + let colors = crate::color_channels::explicit(p, crate::channel_cmds::color_count(&doc))?; let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); let effect = effect(&pre_surf); - // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). - let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); + // Generate the same dabs as `apply_dab_stroke` (zoom 1), without a StrokeRenderer: + // its accumulated coverage isn't used here, and each effect rasterizes its own footprint. + let ctx = photocraft_paint::BrushContext::new(&stroke.brush); + let generator = photocraft_paint::dynamics::DabGenerator::new(&stroke.brush, 1.0); let mut live = Self { doc: std::sync::Arc::new(doc), cmd: cmd.into(), - renderer, + ctx, + generator, + pending: Vec::new(), + dual_pending: Vec::new(), done: 0, effect, neighborhood, work: WorkTiles::new(pre_surf.channels()), pre_surf, + colors, id, params: p.clone(), opacity: stroke.brush.opacity, @@ -523,11 +542,13 @@ impl LiveDab { self.bounds } - /// Run the dabs the renderer has generated since the last call on the working copy; returns - /// the rectangle they changed. + /// Run this batch's primary dabs in order, rasterizing each footprint exactly once. fn run_new_dabs(&mut self) -> Rect { - let ctx = self.renderer.ctx.clone(); - let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); + let ctx = &self.ctx; + let mut new = std::mem::take(&mut self.pending); + // As in `apply_dab_stroke`, sequential effects consume only primary footprints. Keep + // generating dual dabs so the generator's state advances just as it did in StrokeRenderer. + self.dual_pending.clear(); let mut dmg = Rect::EMPTY; for (k, d) in new.iter().enumerate() { let r = ctx.dab_rect(d, false); @@ -535,11 +556,13 @@ impl LiveDab { continue; } let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); self.work.write(&self.pre_surf, ®ion.crop(r)); dmg = dmg.union(&r); } - self.done += new.len(); + self.done = self.done.saturating_add(new.len()); + new.clear(); + self.pending = new; dmg } @@ -554,21 +577,32 @@ impl LiveDab { /// Invalid coordinates reject the whole batch without changing the preview. /// Only the position and pressure of `pts` are used: the commands take no tilt or times. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + let damage = self.push_inner(pts)?; + if let Some(colors) = self.colors { + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + crate::color_channels::restrict_region(&self.pre_surf, surf, colors, damage)?; + } + Ok(damage) + } + + fn push_inner(&mut self, pts: &[StrokePoint]) -> Result { crate::brush_cmds::check_coords(pts, &self.cmd)?; let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); - self.renderer.push(&pts); + self.generator.push(&pts, &mut self.pending, &mut self.dual_pending); let dmg = self.run_new_dabs(); - let ctx = self.renderer.ctx.clone(); + let ctx = &self.ctx; // The finishing dabs, run on a scratch copy of the working pixels. let mut tail = None; if self.preview_tail { - let ds = self.renderer.tail_dabs(); + let mut generator = self.generator.clone(); + let (mut ds, mut dual) = (Vec::new(), Vec::new()); + generator.finish(&mut ds, &mut dual); let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); if !r.is_empty() { let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo())); for (k, d) in ds.iter().enumerate() { - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + (self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k))); } tail = Some((r, region.crop(r))); } @@ -605,7 +639,7 @@ impl LiveDab { if now != tool || stroke.brush != *brush || id != self.id { return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke")); } - self.renderer.finish(); + self.generator.finish(&mut self.pending, &mut self.dual_pending); let dmg = self.run_new_dabs(); let shown = dmg.union(&self.tail); if !shown.is_empty() { @@ -614,6 +648,9 @@ impl LiveDab { } self.bounds = self.bounds.union(&dmg); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + if let Some(colors) = self.colors { + crate::color_channels::restrict_region(&self.pre_surf, surf, colors, shown)?; + } let surface = surf.clone(); Ok(crate::brush_cmds::LiveCommit { mixer: None, diff --git a/crates/engine/src/cmd/retouch_cmds/patch.rs b/crates/engine/src/cmd/retouch_cmds/patch.rs index 557c173b4b..1cfcd3bfca 100644 --- a/crates/engine/src/cmd/retouch_cmds/patch.rs +++ b/crates/engine/src/cmd/retouch_cmds/patch.rs @@ -49,10 +49,7 @@ pub(super) fn target_layer(s: &Session, p: &Value) -> Result> { if crate::channel_cmds::is_channel_target(p) { return Ok(None); } - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(Some(LayerId(id))), - None => Ok(Some(s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?)), - } + crate::commands::layer_param(s, p).map(Some) } /// Integer drag offset, rejecting non-finite and absurd values. @@ -233,6 +230,8 @@ pub(super) fn patch(s: &mut Session, p: &Value) -> Result { /// exact, the colour fit approximate. `max_cells` caps the coarse grid, `min_step` sets a floor on /// the coarsening (the view's zoom-out factor: finer detail wouldn't show). pub fn preview(s: &Session, p: &Value, max_cells: u64, min_step: u32) -> Result<(Document, Rect)> { + let captured = crate::channel_cmds::inject_target(s, CMD, p.clone()); + let p = &captured; let plan = plan(s, p)?; let d = s.active().ok_or(EngineError::Other("no document open".into()))?; let pixels = u64::from(plan.area.width() + 4) * u64::from(plan.area.height() + 4); @@ -243,6 +242,9 @@ pub fn preview(s: &Session, p: &Value, max_cells: u64, min_step: u32) -> Result< let sel = d.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to patch first"))?; let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, plan.id, p)?; let dmg = patch_surface(surf, sel, &plan, lock, step); + if let (Some(colors), Some(id)) = (crate::color_channels::target(s, p)?, plan.id) { + crate::channel_cmds::restrict_to_color(&d.doc, &mut doc, id, colors)?; + } Ok((doc, dmg)) } @@ -296,9 +298,7 @@ fn patch_content_aware(s: &mut Session, p: &Value) -> Result { *crate::channel_cmds::target_surface(doc, target.id, &p)?.0 = surf; Ok(()) })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(damage); - } + s.hint_damage(damage); Ok(json!({ "damage": damage_json(damage), "offset": [dx, dy], "contentAware": true })) }, ) diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index f13afc112e..06b5bd17e7 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -21,7 +21,7 @@ const STREAM_PHASE_Y: u64 = 102; pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> { has_pixel_layer(s)?; - if crate::pattern_cmds::resolve(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) } + if crate::pattern_cmds::resolve_id(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 @@ -96,9 +96,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } })?; - if let Some(state) = s.active_mut() { - state.last_damage = Some(dmg); - } + s.hint_damage(dmg); Ok(json!({ "damage": damage_json(dmg), "phase": [st.phase.0, st.phase.1], diff --git a/crates/engine/src/cmd/retouch_cmds/remove.rs b/crates/engine/src/cmd/retouch_cmds/remove.rs index a7283d390b..777b1131f9 100644 --- a/crates/engine/src/cmd/retouch_cmds/remove.rs +++ b/crates/engine/src/cmd/retouch_cmds/remove.rs @@ -252,15 +252,11 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result { *active = Some(r.1); Ok(r) })?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(damage); - } + s.hint_damage(damage); return Ok(json!({ "damage": damage_json(damage), "layer": nid.0 })); } let damage = s.edit(label, |doc, _| replace(doc, Target { id, all_layers: false }, &p, &out, &hole))?; - if let Some(st) = s.active_mut() { - st.last_damage = Some(damage); - } + s.hint_damage(damage); Ok(json!({ "damage": damage_json(damage) })) }, ) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index a1329064d2..25ebcc8a97 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -1851,6 +1851,104 @@ fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() { assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's"); s.execute("edit.undo", json!({})).unwrap(); assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(layer_pixels(&s), want, "{cmd} {depth} {hardness}: redo restores the live commit"); + s.execute("edit.undo", json!({})).unwrap(); + } + } + } +} + +/// Compare every displayed batch and the finishing dabs with the former StrokeRenderer path, +/// including features whose coverage is unnecessary to sequential effects (dual tips/Wet Edges). +#[test] +fn live_dab_batches_match_the_accumulating_renderer() { + use photocraft_paint::{GrayTile, StrokeRenderer, TipShape}; + + for depth in DEPTHS { + let mut s = session(200, 90, depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, |x, y| { + let mut c = texture(x, y); + c[3] = 0.25 + ((x + y) % 7) as f32 * 0.1; + c + }); + let id = s.active().unwrap().active_layer.unwrap(); + let pre = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone(); + for case in ["soft", "hard", "sampled", "dynamics", "smoothing"] { + s.tools.brush = BrushSettings::default(); + match case { + "sampled" => s.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(9, 7, |x, y| ((x * 3 + y) % 11) as f32 / 10.0)), + "dynamics" => { + let b = &mut s.tools.brush; + b.scattering.enabled = true; + b.scattering.scatter.jitter = 0.5; + b.scattering.count = 2; + b.shape_dynamics.enabled = true; + b.shape_dynamics.angle.jitter = 0.7; + b.shape_dynamics.size.jitter = 0.3; + b.dual_brush.enabled = true; + b.dual_brush.size = 12.0; + b.wet_edges = true; + b.noise = true; + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = photocraft_paint::Pattern::Tile(GrayTile::from_fn(3, 3, |x, y| (x + y) as f32 / 4.0)); + } + "smoothing" => s.tools.brush.smoothing.amount = 0.65, + _ => {} + } + let points = [ + StrokePoint::new(-5.0, 30.0, 0.3), + StrokePoint::new(70.0, 40.0, 1.0), + StrokePoint::new(150.0, 55.0, 0.6), + StrokePoint::new(60.0, 35.0, 0.8), + StrokePoint::new(61.0, 35.5, 0.2), + ]; + for (cmd, extra) in dab_cases() { + let mut p = dab_opts(&[[-5.0, 30.0]], if case == "hard" { 100 } else { 0 }, &extra); + p["points"] = json!([[-5.0, 30.0, 0.3]]); + p["opacity"] = json!(57); + p["flow"] = json!(63); + let (stroke, _) = parse_brush(&s, &p, cmd).unwrap(); + let (tool, _) = DabTool::parse(&s, cmd, &p, &stroke).unwrap(); + let mut effect = tool.effect(pre.format(), stroke.brush.spacing); + let mut reference = StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); + // A fixed region containing all dabs plus their read neighbourhoods. + let orig = Region::read(&pre, Rect::new(-128, -128, 384, 256)); + let mut work = orig.clone(); + let mut shown = pre.clone(); + let mut done = 0; + let mut live = LiveDab::begin(&s, cmd, &p).unwrap(); + let mut bounds = Rect::EMPTY; + let mut render_new = |reference: &StrokeRenderer| { + let mut damage = Rect::EMPTY; + for (i, dab) in reference.dabs().iter().enumerate().skip(done) { + let fp = reference.ctx.footprint(dab, i); + effect(&mut work, &fp); + damage = damage.union(&fp.rect); + } + done = reference.dabs().len(); + if !damage.is_empty() { + photocraft_paint::retouch::mix_back(&mut shown, &orig, &work, damage, stroke.brush.opacity, None, false); + } + bounds = bounds.union(&damage); + (bounds, shown.read_region(bounds)) + }; + for (batch_index, batch) in std::iter::once(&points[..1]).chain(points[1..].chunks(2)).enumerate() { + reference.push(batch); + if batch_index > 0 { + live.push(batch).unwrap(); + } + let (bounds, expected) = render_new(&reference); + let actual = live.doc.layer(id).unwrap().surface().unwrap(); + assert!(actual.read_region(bounds) == expected, "{cmd} {depth} {case} batch={batch_index}"); + } + reference.finish(); + let (bounds, expected) = render_new(&reference); + let commit = live.into_commit(&s).unwrap(); + assert_eq!(commit.bounds, bounds, "{cmd} {depth} {case}: final damage"); + assert!(commit.surface.read_region(bounds) == expected, "{cmd} {depth} {case}: finishing dabs"); } } } diff --git a/crates/engine/src/cmd/select_extra_cmds.rs b/crates/engine/src/cmd/select_extra_cmds.rs index 8ca012266d..55e2ec10d9 100644 --- a/crates/engine/src/cmd/select_extra_cmds.rs +++ b/crates/engine/src/cmd/select_extra_cmds.rs @@ -40,7 +40,7 @@ struct CompositeSampler<'a>(&'a Document); impl Sampler for CompositeSampler<'_> { fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { - photocraft_compose::render(self.0, r).px + photocraft_compose::render(self.0, r).into_px() } } @@ -102,7 +102,7 @@ pub fn isolation_shows(doc: &Document, isolated: &[LayerId], id: LayerId) -> boo /// 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]> { +pub(crate) 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); let unknown = || bad(cmd, "`reference` must be one of center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight or [x, y]"); diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 362e889fa9..024f6b088b 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -13,6 +13,11 @@ use crate::{EngineError, Result, Session}; mod modify_window; +#[cfg(test)] +mod color_range_performance; +#[cfg(test)] +mod color_range_window_tests; + fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } @@ -45,15 +50,18 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM } /// RGBA pixels the tools sample: the composite, or the active layer. -fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> { +fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?; - let area = d.doc.bounds(); + let area = window.map_or(d.doc.bounds(), |r| r.intersect(&d.doc.bounds())); // Match the decoders' raster budget before either the layer or composite path allocates. let len = area.size().area(); let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); } + if area.is_empty() { + return Ok((area, Vec::new())); + } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => { @@ -62,7 +70,7 @@ fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> surf.read_rgba_into(area, &mut px); Ok((area, px)) } - _ => Ok((area, photocraft_compose::render(&d.doc, area).px)), + _ => Ok((area, photocraft_compose::render(&d.doc, area).into_px())), } } @@ -149,31 +157,6 @@ fn parse_color(p: &Value) -> [f32; 3] { } } -/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point -/// and where it was picked (area-local, for Localized Color Clusters). -fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { - let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; - let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; - let w = area.width() as usize; - let mut out = Vec::with_capacity(pts.len().min(4096)); - for pt in pts { - let xy = pt.as_array().and_then(|a| match a.as_slice() { - [x, y] => Some((x.as_f64()?, y.as_f64()?)), - _ => None, - }); - let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else { - return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])"))); - }; - let (xi, yi) = (x.floor() as i32, y.floor() as i32); - let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None }; - let Some(&[r, g, b, _]) = q else { - return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); - }; - out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); - } - Ok(out) -} - /// Composite RGB (the compositor's output profile for the document) → CIE Lab D50. fn lab_transform(s: &Session) -> Result> { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -346,6 +329,23 @@ impl ColorRangeSamples { } } +/// Replace refines the existing selection; no output outside its nonzero bounds survives. +fn color_range_area(doc: &Document, m: SelectionMode) -> Rect { + let canvas = doc.bounds(); + let Some(selection) = doc.selection.as_ref().filter(|_| m == SelectionMode::Replace) else { return canvas }; + // content_bounds describes differences from the default, not implicit selected pixels. + if selection.default_pixel().first().is_some_and(|v| *v != 0.0) { + return canvas; + } + // content_bounds orders raw tile coordinates by their differences. Reject coordinates + // outside the pixel-addressable range before those differences can overflow. + let valid = (i32::MIN / photocraft_geom::TILE_SIZE)..=(i32::MAX / photocraft_geom::TILE_SIZE); + if selection.tiles().any(|(tile, _)| !valid.contains(&tile.tx) || !valid.contains(&tile.ty)) { + return canvas; + } + selection.content_bounds().intersect(&canvas) +} + fn color_range(s: &mut Session, p: &Value) -> Result { const CMD: &str = "select.colorRange"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; @@ -357,17 +357,16 @@ fn color_range(s: &mut Session, p: &Value) -> Result { if b(p, "detectFaces", false) { return Err(bad("detectFaces: face detection isn't available yet; select without it".into())); } + let area = color_range_area(&s.active().ok_or(EngineError::NoDocument)?.doc, selection_mode); let (area, mut mask) = match preset { "sampledColors" => { - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. // Like Photoshop's, they may sample anywhere on the canvas: an active selection only // limits the result (scope_color_range), not where colours are picked (#5244). - let plus = eyedropper_samples(p, "points", area, &px, &bad)?; - let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; - let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?; - let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]); + let query = ColorRangeSamples::new(s, p)?; + let mask = query.coverage(&px, w, 1.0, [area.x0 as f32, area.y0 as f32]); (area, mask) } "reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => { @@ -379,11 +378,11 @@ fn color_range(s: &mut Session, p: &Value) -> Result { "blues" => 240.0, _ => 300.0, }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::hue_range(&px, center)) } "skinTones" => { - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::skin_tones(&px, fuzz(40.0).min(200.0))) } "highlights" | "midtones" | "shadows" => { @@ -406,7 +405,7 @@ fn color_range(s: &mut Session, p: &Value) -> Result { .filter(|(l, h)| l <= h) .ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?, }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0)) } "outOfGamut" => { @@ -458,16 +457,16 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result { if canvas.size().area() > MAX_MODIFY_PIXELS as u64 { return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() }); } + // Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach at most ceil(r). + let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize }; // Keep both mask conversion and tiled replacement within the affected region. The // zero halo makes internal window edges inert; nonzero edges can only be canvas edges, // so the existing Apply effect at canvas bounds padding still has the same meaning. - let area = if matches!(op, "expand" | "contract" | "border") { modify_window::area(doc, r) } else { canvas }; + let area = modify_window::area(doc, reach as f32); let m = sel::mask_from_surface(doc.selection.as_ref(), area); let (w, h) = (area.width() as usize, area.height() as usize); - // Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach r. A margin past that - // keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both - // readings agree there. - let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize }; + // A margin past the reach keeps the padded edge out of reach. A selection clear of the + // canvas edge needs none: both readings agree there. let pad = if touches_edge(&m, w, h) { reach.saturating_add(2) } else { 0 }; let grown = |n: usize| pad.checked_mul(2).and_then(|p| n.checked_add(p)); let (Some(pw), Some(ph)) = (grown(w), grown(h)) else { @@ -545,7 +544,7 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec { /// the selected pixels' colour range widened by the tolerance. fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result { let tol = f(p, "tolerance", 32.0) / 255.0; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false))?; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?; let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); for (q, v) in px.iter().zip(&m) { @@ -656,16 +655,7 @@ fn polygon_rows(pts: &[(f32, f32)], area: Rect) -> Rect { /// whole-canvas combine gives: Replace and Intersect are empty outside the band, and Add and /// Subtract keep the old selection there. pub(crate) fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: SelectionMode) -> Option { - let inside = sel::combine(old, mask, band, m); - let Some(old) = old.filter(|_| matches!(m, SelectionMode::Add | SelectionMode::Subtract)) else { return inside }; - if band.is_empty() { - return Some(old.clone()); - } - let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |w| w.read_region(band)); - let mut s = old.clone(); - s.write_region(band, &values); - s.prune(); - (s.tile_count() > 0).then_some(s) + sel::combine_band(old, mask, band, m) } /// Keep dense feather masks local to the drawn shape. Clip before inflating: pixels outside diff --git a/crates/engine/src/cmd/selection_cmds/color_range_performance.rs b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs new file mode 100644 index 0000000000..639b7af4c6 --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/color_range_performance.rs @@ -0,0 +1,70 @@ +//! Opt-in release comparison; full-canvas buffers belong on a CI runner. +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Layer, LayerContent, Size}; +use std::time::Instant; + +fn reference(s: &mut Session, p: &Value) { + let (canvas, px) = sample_pixels(s, true, None).unwrap(); + // Previous path: eyedropper colour came from the full composite already in memory. + let [r, g, b, _] = px[1500 * canvas.width() as usize + 2000]; + let point = sel::RangeSample { color: [r, g, b], at: Some((2000.0, 1500.0)) }; + let query = ColorRangeSamples::with_samples(s, p, canvas, &[point], &[]).unwrap(); + let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [0.0, 0.0]); + drop(px); + scope_color_range(&mut mask, &s.active().unwrap().doc, canvas, SelectionMode::Replace); + set_selection(s, "Color Range", canvas, mask, SelectionMode::Replace).unwrap(); +} + +#[test] +#[ignore = "24 MP release comparison: run on GitHub Actions"] +fn color_range_release_comparison() { + let mut doc = Document::new("Color Range performance", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8); + let mut pixels = Surface::with_default(PixelFormat::RGBA8, &[0.65, 0.3, 0.2, 1.0]); + pixels.fill_rect(Rect::new(3000, 2000, 3600, 2400), &[0.2, 0.3, 0.65, 1.0]); + doc.layers.push(Layer::new("image", LayerContent::Raster(pixels))); + let p = json!({"points": [[2000, 1500]], "fuzziness": 40, "localized": true, "range": 80}); + // Small, 1%, 4%, and full selections: include dense fallback cost in the comparison. + for bounds in [Rect::new(2975, 1975, 3025, 2025), Rect::new(2700, 1800, 3300, 2200), Rect::new(2400, 1600, 3600, 2400), doc.bounds()] { + let mut selection = Surface::new(PixelFormat::GRAY8); + selection.fill_rect(bounds, &[128.0 / 255.0]); + doc.selection = Some(selection); + let mut old_times = Vec::new(); + let mut new_times = Vec::new(); + let mut expected = None; + // One warmup pair, followed by three alternating measured pairs. + for round in 0..4 { + for old in if round % 2 == 0 { [true, false] } else { [false, true] } { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + let now = Instant::now(); + if old { + reference(&mut session, &p); + } else { + session.execute("select.colorRange", p.clone()).unwrap(); + } + let elapsed = now.elapsed().as_secs_f64() * 1000.0; + if round > 0 { + if old { old_times.push(elapsed) } else { new_times.push(elapsed) } + } + // Outside timing, compare every stored sample over the whole canvas. + let actual = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()); + if let Some(ref expected) = expected { + assert_eq!(&actual, expected); + } else { + expected = Some(actual); + } + } + } + old_times.sort_by(f64::total_cmp); + new_times.sort_by(f64::total_cmp); + let (old, new) = (old_times[1], new_times[1]); + println!( + "24 MP Color Range {}x{}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x rgba_bytes={} exact=true", + bounds.width(), + bounds.height(), + old / new, + bounds.size().area() * 16 + ); + } +} diff --git a/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs new file mode 100644 index 0000000000..d14ecc7e31 --- /dev/null +++ b/crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs @@ -0,0 +1,211 @@ +use super::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Size; +use serde_json::json; + +fn fixture(depth: SampleType, p3: bool) -> Session { + let mut s = Session::new(); + let mut doc = Document::with_background("color range", Size::new(64, 48), ColorMode::Rgb, depth, photocraft_doc::Color::WHITE); + if p3 { + doc.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes()); + } else { + doc.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); + } + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(0, 0, 64, 48), &[0.92, 0.92, 0.92, 1.0]); + bg.fill_rect(Rect::new(7, 8, 22, 23), &[0.85, 0.12, 0.08, 1.0]); + bg.fill_rect(Rect::new(35, 8, 50, 23), &[0.12, 0.72, 0.18, 1.0]); + bg.fill_rect(Rect::new(7, 29, 22, 44), &[0.12, 0.20, 0.86, 1.0]); + bg.fill_rect(Rect::new(35, 29, 50, 44), &[0.72, 0.48, 0.31, 1.0]); + s.add_document(doc, None); + s +} + +fn coverage_at(s: &Session, x: i32, y: i32) -> f32 { + s.active().and_then(|d| d.doc.selection.as_ref()).map_or(0.0, |m| m.sample_channel(x, y, 0)) +} + +fn full_sampled_reference(s: &Session, params: &Value) -> Vec { + let doc = &s.active().unwrap().doc; + let canvas = doc.bounds(); + let (_, px) = sample_pixels(s, b(params, "sampleAllLayers", true), None).unwrap(); + // Reproduce the previous command: read eyedropper colours from the full composite. + let samples = |key: &str| -> Vec { + params + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|point| { + let x = point[0].as_f64().unwrap().floor() as usize; + let y = point[1].as_f64().unwrap().floor() as usize; + let pixel = px[y * canvas.width() as usize + x]; + sel::RangeSample { color: [pixel[0], pixel[1], pixel[2]], at: Some((x as f32, y as f32)) } + }) + .collect() + }; + let query = ColorRangeSamples::with_samples(s, params, canvas, &samples("points"), &samples("subtractPoints")).unwrap(); + let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [canvas.x0 as f32, canvas.y0 as f32]); + if b(params, "invert", false) { + mask.iter_mut().for_each(|v| *v = 1.0 - *v); + } + scope_color_range(&mut mask, doc, canvas, mode(params)); + sel::mask_from_surface(sel::combine(doc.selection.as_ref(), &mask, canvas, mode(params)).as_ref(), canvas) +} + +#[test] +fn sampled_color_cropped_results_match_full_canvas_for_depths_profiles_and_sample_options() { + for (depth, p3) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|depth| [false, true].map(|p3| (depth, p3))) { + for params in [ + json!({"points": [[40, 15]], "fuzziness": 12}), + json!({"points": [[40, 15]], "subtractPoints": [[10, 15]], "order": ["-", "+"], "fuzziness": 18}), + json!({"points": [[40, 15]], "localized": true, "range": 80, "invert": true}), + json!({"points": [[10, 15]], "localized": true, "range": 25}), + json!({"points": [[40, 15]], "sampleAllLayers": false, "fuzziness": 0}), + ] { + let mut s = fixture(depth, p3); + s.execute("select.rect", json!({"x": 3, "y": 5, "width": 22, "height": 25})).unwrap(); + let expected = full_sampled_reference(&s, ¶ms); + s.execute("select.colorRange", params.clone()).unwrap(); + let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()); + assert_eq!(actual, expected, "depth={depth:?} p3={p3} params={params}"); + } + } +} + +#[test] +fn range_presets_are_scoped_and_non_replace_modes_keep_full_canvas_semantics() { + for preset in ["reds", "yellows", "greens", "cyans", "blues", "magentas", "highlights", "midtones", "shadows", "skinTones"] { + let mut s = fixture(SampleType::U8, false); + s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap(); + s.execute("select.colorRange", json!({"select": preset})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 0.0, "preset {preset} escaped selection"); + } + let mut s = fixture(SampleType::U8, false); + s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap(); + s.execute("select.colorRange", json!({"points": [[40, 15]], "fuzziness": 0, "mode": "add"})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 0.0); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(coverage_at(&s, 42, 15), 1.0); +} + +#[test] +fn window_defaults_and_hostile_tiles_fall_back_and_empty_selection_is_safe() { + let canvas = Rect::new(0, 0, 64, 48); + let mut doc = Document::new("window", Size::new(64, 48), ColorMode::Rgb, SampleType::U8); + doc.selection = Some(Surface::with_default(PixelFormat::GRAY8, &[1.0])); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas); + doc.selection = Some(Surface::new(PixelFormat::GRAY8)); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), Rect::new(0, 0, 0, 0)); + use photocraft_geom::TileCoord; + use std::sync::Arc; + let mut hostile = Surface::new(PixelFormat::GRAY8); + let tile = hostile.solid_tile(&[1.0]); + hostile.put_tiles([(TileCoord::new(i32::MIN, i32::MIN), Arc::clone(&tile)), (TileCoord::new(i32::MAX, i32::MAX), tile)]); + doc.selection = Some(hostile); + assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas); + assert_eq!(color_range_area(&doc, SelectionMode::Add), canvas); + doc.selection = Some(Surface::new(PixelFormat::GRAY8)); + let mut s = Session::new(); + s.add_document(doc, None); + assert!(sample_pixels(&s, true, Some(color_range_area(&s.active().unwrap().doc, SelectionMode::Replace))).unwrap().1.is_empty()); + let err = s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": -1})).unwrap_err(); + assert!(err.to_string().contains("tonalRange")); + for params in [json!({"points": [[64, 0]]}), json!({"points": "invalid"}), json!({"localized": true}), json!({"order": ["+"]})] { + assert!(s.execute("select.colorRange", params).is_err()); + } + s.execute("select.colorRange", json!({"points": [[1, 1]], "invert": true})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn sparse_large_selection_samples_only_its_clipped_window() { + let mut doc = Document::new("sparse", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8); + let selection = sel::mask_to_surface(&vec![1.0; 24 * 20], Rect::new(2990, 1990, 3014, 2010)); + doc.selection = Some(selection); + let mut s = Session::new(); + s.add_document(doc, None); + let win = color_range_area(&s.active().unwrap().doc, SelectionMode::Replace); + assert_eq!(win, Rect::new(2990, 1990, 3014, 2010)); + let (area, pixels) = sample_pixels(&s, true, Some(win)).unwrap(); + assert_eq!(pixels.len(), area.size().area() as usize); + s.execute("select.colorRange", json!({"select": "highlights"})).unwrap(); +} + +#[test] +fn cropped_range_matches_full_canvas_combinations_with_soft_holes_and_clipped_masks() { + let canvas = Rect::new(0, 0, 64, 48); + for case in 0..6 { + for selection_mode in ["replace", "add", "subtract", "intersect"] { + for preset in ["sampledColors", "reds", "greens", "blues", "highlights", "midtones", "shadows", "skinTones"] { + for invert in [false, true] { + let mut s = fixture(SampleType::U16, true); + let mut selection = match case { + 0 => Surface::new(PixelFormat::GRAY8), + 1 => Surface::with_default(PixelFormat::GRAY8, &[128.0 / 255.0]), + _ => sel::mask_to_surface(&vec![128.0 / 255.0; 30 * 30], Rect::new(-5, 5, 25, 35)), + }; + if case == 3 { + selection.fill_rect(Rect::new(8, 10, 15, 20), &[0.0]); + } + if case == 5 { + selection = Surface::new(PixelFormat::GRAY8); + } + if case == 4 { + selection = sel::mask_to_surface(&[1.0; 4], Rect::new(-8, -8, -6, -6)); + } + let original = (case != 0).then_some(selection); + let mut prior = s.active().unwrap().doc.as_ref().clone(); + prior.selection = original; + let before = sel::mask_from_surface(prior.selection.as_ref(), canvas); + let mut unselected = prior.clone(); + unselected.selection = None; + let mut full = Session::new(); + full.add_document(unselected, None); + let params = json!({"select": preset, "points": [[40, 15]], "fuzziness": 40, "invert": invert}); + full.execute("select.colorRange", params.clone()).unwrap(); + let mut mask = sel::mask_from_surface(full.active().unwrap().doc.selection.as_ref(), canvas); + let m = SelectionMode::parse(selection_mode); + scope_color_range(&mut mask, &prior, canvas, m); + let expected = sel::combine(prior.selection.as_ref(), &mask, canvas, m); + s.edit("prior selection", |doc, _| { + doc.selection = prior.selection.clone(); + Ok(()) + }) + .unwrap(); + let mut params = params; + params["mode"] = json!(selection_mode); + s.execute("select.colorRange", params).unwrap(); + let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas); + assert_eq!(actual, sel::mask_from_surface(expected.as_ref(), canvas), "case {case}, {selection_mode}, {preset}, invert {invert}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), actual); + } + } + } + } +} + +#[test] +fn cropped_samples_match_full_composite_with_translucent_layers_and_active_layer_sampling() { + use photocraft_doc::{Layer, LayerContent}; + for all_layers in [false, true] { + let mut s = fixture(SampleType::F32, true); + s.edit("overlay", |doc, _| { + let mut pixels = Surface::new(PixelFormat::RGBA32F); + pixels.fill_rect(Rect::new(0, 0, 64, 48), &[0.3, 0.1, 0.6, 0.35]); + doc.layers.push(Layer::new("overlay", LayerContent::Raster(pixels))); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 5, "y": 6, "width": 20, "height": 30})).unwrap(); + let params = json!({"points": [[40, 15], [10, 15]], "subtractPoints": [[40, 35]], "sampleAllLayers": all_layers, "localized": true, "range": 70}); + let expected = full_sampled_reference(&s, ¶ms); + s.execute("select.colorRange", params).unwrap(); + assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()), expected); + } +} diff --git a/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs b/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs index 4f8fe8fce6..f26cd4c739 100644 --- a/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs @@ -17,13 +17,17 @@ fn full_canvas_reference(doc: &Document, op: &str, radius: f32, at_bounds: bool) let canvas = doc.bounds(); let m = sel::mask_from_surface(doc.selection.as_ref(), canvas); let (w, h) = (canvas.width() as usize, canvas.height() as usize); - let pad = if touches_edge(&m, w, h) { radius.ceil() as usize + 2 } else { 0 }; + let reach = if op == "feather" { sel::feather_reach(radius) } else { radius.ceil() as usize }; + let pad = if touches_edge(&m, w, h) { reach + 2 } else { 0 }; let (pw, ph) = (w + 2 * pad, h + 2 * pad); let padded = if pad == 0 { m } else { pad_mask(&m, w, h, pad, op == "border" || at_bounds) }; let out = match op { "expand" => sel::expand(&padded, pw, ph, radius), "contract" => sel::contract(&padded, pw, ph, radius), - _ => sel::border(&padded, pw, ph, radius), + "border" => sel::border(&padded, pw, ph, radius), + "smooth" => sel::smooth(&padded, pw, ph, radius), + "feather" => sel::feather(&padded, pw, ph, radius), + _ => panic!("unknown modify operation: {op}"), }; let out = if pad == 0 { out } else { crop_mask(&out, pw, pad, w, h) }; sel::combine(None, &out, canvas, SelectionMode::Replace) @@ -36,9 +40,11 @@ fn sparse_36mp_command_only_materializes_the_selection_halo() { let doc = document(6000, 6000, selection); assert_eq!(area(&doc, 5.0), Rect::new(2994, 2994, 3056, 3056)); assert_eq!(area(&doc, 5.0).size().area(), 62 * 62); - let mut session = Session::new(); - session.add_document(doc, None); - for op in ["expand", "contract", "border"] { + assert_eq!(area(&doc, sel::feather_reach(5.0) as f32), Rect::new(2984, 2984, 3066, 3066)); + assert_eq!(area(&doc, sel::feather_reach(5.0) as f32).size().area(), 82 * 82); + for op in ["expand", "contract", "border", "smooth", "feather"] { + let mut session = Session::new(); + session.add_document(doc.clone(), None); session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap(); assert!(session.active().unwrap().doc.selection.is_some()); } @@ -71,8 +77,11 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() { selection.write_pixel(-10, -10, &[1.0]); } let doc = document(75, 61, selection); - for radius in [1.0, 5.0, 9.5] { - for op in ["expand", "contract", "border"] { + for radius in [0.1, 1.0, 1.5, 5.0, 9.5, 25.0] { + for op in ["expand", "contract", "border", "smooth", "feather"] { + if radius < 1.0 && op != "feather" { + continue; + } for at_bounds in [false, true] { let expected = full_canvas_reference(&doc, op, radius, at_bounds); let mut session = Session::new(); @@ -91,21 +100,79 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() { } } +#[test] +fn cropped_blurs_preserve_soft_masks_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat { sample: depth, ..PixelFormat::GRAY8 }; + for default in [0.0, 0.25] { + let mut selection = Surface::with_default(format, &[default]); + for y in 21..39 { + for x in 28..47 { + selection.write_pixel(x, y, &[((x * 13 + y * 7) % 17) as f32 / 16.0]); + } + } + let mut doc = Document::new("soft modify", Size::new(83, 67), ColorMode::Rgb, depth); + doc.selection = Some(selection); + for op in ["smooth", "feather"] { + for radius in [1.0, 2.5, 9.5] { + for at_bounds in [false, true] { + let expected = full_canvas_reference(&doc, op, radius, at_bounds); + let mut session = Session::new(); + session.add_document(doc.clone(), None); + session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap(); + assert_eq!( + sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), + sel::mask_from_surface(expected.as_ref(), doc.bounds()), + "{depth:?} default {default} {op} radius {radius} bounds {at_bounds}" + ); + } + } + } + } + } +} + +#[test] +fn cropped_blurs_preserve_single_pixel_and_thin_canvases() { + for (w, h) in [(1, 1), (1, 19), (23, 1)] { + let mut selection = Surface::new(PixelFormat::GRAY8); + selection.write_pixel(w as i32 / 2, h as i32 / 2, &[1.0]); + let doc = document(w, h, selection); + for op in ["smooth", "feather"] { + for radius in [1.0, 9.5] { + for at_bounds in [false, true] { + let expected = full_canvas_reference(&doc, op, radius, at_bounds); + let mut session = Session::new(); + session.add_document(doc.clone(), None); + session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap(); + assert_eq!( + sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), + sel::mask_from_surface(expected.as_ref(), doc.bounds()), + "{w}x{h} {op} radius {radius} bounds {at_bounds}" + ); + } + } + } + } +} + #[test] fn cropped_replacement_keeps_undo_and_redo() { let canvas = Rect::new(0, 0, 75, 61); let selection = sel::mask_to_surface(&vec![1.0; 10 * 10], Rect::new(30, 25, 40, 35)); let doc = document(75, 61, selection); let before = sel::mask_from_surface(doc.selection.as_ref(), canvas); - let mut session = Session::new(); - session.add_document(doc, None); - session.execute("select.modify.expand", json!({"radius": 5})).unwrap(); - let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas); - assert_ne!(before, after); - session.execute("edit.undo", json!({})).unwrap(); - assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before); - session.execute("edit.redo", json!({})).unwrap(); - assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after); + for op in ["expand", "smooth", "feather"] { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap(); + let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas); + assert_ne!(before, after, "{op}"); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before, "{op}"); + session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after, "{op}"); + } } /// Isolate command-window savings: both paths use the optimized algorithm kernels, while @@ -114,43 +181,46 @@ fn cropped_replacement_keeps_undo_and_redo() { #[ignore = "36 MP release benchmark; run on GitHub Actions"] fn sparse_modify_release_comparison() { use std::time::Instant; - let selection = sel::mask_to_surface(&vec![1.0; 50 * 50], Rect::new(3000, 3000, 3050, 3050)); - let doc = document(6000, 6000, selection); - for radius in [3.0, 50.0] { - for op in ["expand", "contract", "border"] { - let reference = full_canvas_reference(&doc, op, radius, false); - let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds()); - let mut old_times = Vec::new(); - let mut new_times = Vec::new(); - for round in 0..3 { - for old in if round % 2 == 0 { [true, false] } else { [false, true] } { - let mut session = Session::new(); - session.add_document(doc.clone(), None); - let now = Instant::now(); - if old { - let result = full_canvas_reference(&doc, op, radius, false); - session - .edit("Modify selection", |doc, _| { - doc.selection = result; - Ok(()) - }) - .unwrap(); - } else { - session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap(); + for (kind, bounds) in [("sparse", Rect::new(3000, 3000, 3050, 3050)), ("dense", Rect::new(1000, 1000, 5000, 5000)), ("full", Rect::new(0, 0, 6000, 6000))] { + let selection = sel::mask_to_surface(&vec![1.0; bounds.size().area() as usize], bounds); + let doc = document(6000, 6000, selection); + let ops: &[&str] = if kind == "sparse" { &["expand", "contract", "border", "smooth", "feather"] } else { &["smooth", "feather"] }; + for radius in [3.0, 50.0] { + for &op in ops { + let reference = full_canvas_reference(&doc, op, radius, false); + let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds()); + let mut old_times = Vec::new(); + let mut new_times = Vec::new(); + for round in 0..3 { + for old in if round % 2 == 0 { [true, false] } else { [false, true] } { + let mut session = Session::new(); + session.add_document(doc.clone(), None); + let now = Instant::now(); + if old { + let result = full_canvas_reference(&doc, op, radius, false); + session + .edit("Modify selection", |doc, _| { + doc.selection = result; + Ok(()) + }) + .unwrap(); + } else { + session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap(); + } + let elapsed = now.elapsed().as_secs_f64() * 1000.0; + if old { + old_times.push(elapsed) + } else { + new_times.push(elapsed) + } + assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected); } - let elapsed = now.elapsed().as_secs_f64() * 1000.0; - if old { - old_times.push(elapsed) - } else { - new_times.push(elapsed) - } - assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected); } + old_times.sort_by(f64::total_cmp); + new_times.sort_by(f64::total_cmp); + let (old, new) = (old_times[1], new_times[1]); + println!("36 MP {kind} command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new); } - old_times.sort_by(f64::total_cmp); - new_times.sort_by(f64::total_cmp); - let (old, new) = (old_times[1], new_times[1]); - println!("36 MP command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new); } } } @@ -165,7 +235,7 @@ fn corrupt_tile_coordinates_do_not_panic_while_finding_the_window() { let doc = document(75, 61, selection); // Without the guard, content_bounds overflows while sorting these coordinates. assert_eq!(area(&doc, 5.0), doc.bounds()); - for op in ["expand", "contract", "border"] { + for op in ["expand", "contract", "border", "smooth", "feather"] { let mut session = Session::new(); session.add_document(doc.clone(), None); session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap(); diff --git a/crates/engine/src/cmd/selection_cmds/tests.rs b/crates/engine/src/cmd/selection_cmds/tests.rs index 0b28681172..4cb1b62cbe 100644 --- a/crates/engine/src/cmd/selection_cmds/tests.rs +++ b/crates/engine/src/cmd/selection_cmds/tests.rs @@ -60,7 +60,7 @@ fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { None, ); for all_layers in [false, true] { - assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample")); + assert!(sample_pixels(&s, all_layers, None).unwrap_err().to_string().contains("too large to sample")); } assert!(!s.active().unwrap().history.can_undo()); } @@ -275,7 +275,7 @@ fn color_range_prepared_query_samples_visible_layers_not_the_active_layer() { clear_selection(&mut s); let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0}); let query = ColorRangeSamples::new(&s, &p).unwrap(); - let (area, px) = sample_pixels(&s, all_layers).unwrap(); + let (area, px) = sample_pixels(&s, all_layers, None).unwrap(); let shown = query.coverage(&px, area.width() as usize, 1.0, [0.0, 0.0]); s.execute("select.colorRange", p).unwrap(); assert_eq!(shown[7 * 40 + 7], coverage(&s, 7, 7)); diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index f19813e3ab..6cb8fccaed 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -70,6 +70,11 @@ pub fn encode_source(doc: &Document) -> Result> { /// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format). pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result { + if photocraft_io::svg::is_svg(bytes) { + return photocraft_io::svg::import_svg_bounded_images(file_name, bytes, 24_000_000) + .map(|imported| imported.document) + .map_err(|e| other(format!("can't read SVG smart object contents: {e}"))); + } crate::file_cmds::import(file_name, bytes).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}"))) } @@ -102,6 +107,10 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { fn has_smart_filters(s: &Session) -> std::result::Result<(), String> { if active_smart(s)?.smart_filters.is_empty() { Err("the smart object has no smart filters".into()) } else { Ok(()) } } +/// Delete Filter Mask with `ifPresent` needs only a smart object. +pub(crate) fn smart_layer_active(s: &Session) -> std::result::Result<(), String> { + active_smart(s).map(|_| ()) +} fn has_filter_mask(s: &Session) -> std::result::Result<(), String> { active_smart(s)?.filter_mask.as_ref().map(|_| ()).ok_or_else(|| "the smart object has no filter mask".into()) } @@ -141,17 +150,22 @@ use contents::{edit_contents, export_contents, is_smart_child, path_param, repla use filters::smart_mut; pub(crate) use filters::{add_smart_filter, filter_mask_paintable, filter_mask_surface, refresh_after_filter_mask_edit}; use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters}; -use placing::{convert, detach_psd, shift_layer, via_copy}; +pub(crate) use placing::detach_psd; +use placing::{convert, shift_layer, via_copy}; pub use placing::{layer_to_smart, layers_to_smart, placement}; pub(crate) use placing::{set_placement, shift_smart, transform_placement}; +#[cfg(test)] +pub(crate) use rendering::DISPLAY_NATIVE_HASH_BYTES; use rendering::refresh_or_fail; pub(crate) use rendering::source_box; +#[cfg(test)] +use rendering::{NativeDigests, blend_surfaces, place, render_in}; pub use rendering::{apply_smart_filters, purge_stack_cache, refresh, refresh_layer, render, render_below_filter, stack_cache_bytes, stack_image}; #[cfg(test)] -use rendering::{blend_surfaces, place, render_in}; -pub use source::{SourceImage, source_bytes, source_decode_count, source_image, svg_source_image}; +use source::svg_source_image_in; +pub use source::{SourceImage, purge_source_cache, source_bytes, source_cache_bytes, source_decode_count, source_image, svg_source_image}; use source::{authorize_source, authorized_source_bytes}; -pub(crate) use source::{restore_unrendered, take_caches}; +pub(crate) use source::{restore_unrendered, take_caches, vector_scale}; const SO: &[&str] = &["Layer", "Smart Objects"]; const SF: &[&str] = &["Layer", "Smart Filter"]; @@ -265,12 +279,25 @@ pub fn specs() -> Vec { }) } ), - spec!("layer.smartFilter.deleteFilterMask", "Delete Filter Mask", SF, r##"{"layer":id?}"##, has_filter_mask, |s, p| { - edit_filters(s, p, "Delete Filter Mask", |sm| { - sm.filter_mask = None; - Ok(()) - }) - }), + spec!( + "layer.smartFilter.deleteFilterMask", + "Delete Filter Mask", + SF, + r##"{"layer":id?,"ifPresent":bool=false (true: a smart object without a filter mask is left as it is, as a Photoshop action's step needs where Photoshop added the mask by default)}"##, + has_filter_mask, + |s, p| { + if p.get("ifPresent").and_then(Value::as_bool) == Some(true) { + let id = layer_param(s, p)?; + if smart(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?.filter_mask.is_none() { + return Ok(Value::Null); + } + } + edit_filters(s, p, "Delete Filter Mask", |sm| { + sm.filter_mask = None; + Ok(()) + }) + } + ), spec!( "layer.smartFilter.disableFilterMask", "Disable Filter Mask", diff --git a/crates/engine/src/cmd/smart_cmds/contents.rs b/crates/engine/src/cmd/smart_cmds/contents.rs index 07a009bf0e..cce8b7002a 100644 --- a/crates/engine/src/cmd/smart_cmds/contents.rs +++ b/crates/engine/src/cmd/smart_cmds/contents.rs @@ -177,6 +177,11 @@ pub(crate) fn on_close(s: &mut Session, index: usize) { let _ = commit_child(s, index); } s.smart_links.retain(|l| l.child != id && l.parent != id); + // Decoded sources are shared by content hash across documents, so the closing document's + // can't be told apart: drop them all. Other documents decode again on their next re-render. + if s.docs.get(index).is_some_and(|st| st.doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)))) { + super::purge_source_cache(); + } } pub(super) fn is_smart_child(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/cmd/smart_cmds/placing.rs b/crates/engine/src/cmd/smart_cmds/placing.rs index e0472e844d..847ad700db 100644 --- a/crates/engine/src/cmd/smart_cmds/placing.rs +++ b/crates/engine/src/cmd/smart_cmds/placing.rs @@ -59,7 +59,7 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) { /// Forget PSD placed-layer data that no longer describes the smart object (its source changed): /// PSD export then writes fresh placed-layer blocks and embeds the new source. -pub(super) fn detach_psd(l: &mut Layer) { +pub(crate) fn detach_psd(l: &mut Layer) { if let LayerContent::Smart(sm) = &mut l.content { sm.psd_raw = None; } @@ -197,8 +197,8 @@ pub fn layers_to_smart(doc: &Document, name: &str, layers: Vec) -> Result } pub(super) fn convert(s: &mut Session, p: &Value) -> Result { - let ids = match p.get("layer").and_then(Value::as_u64) { - Some(id) => vec![LayerId(id)], + let ids = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => crate::layer_multi_cmds::selected(s), }; let new_id = s.edit("Convert to Smart Object", |doc, active| { diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index a5dd8e8f1b..ab862d5545 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -1,9 +1,12 @@ use super::filters::smart; -use super::source::{SourceImage, cache_get, cache_key, cache_put, source_bytes, source_image, source_surface, svg_source_image, vector_scale}; +use super::source::{ + SourceImage, cache_get, cache_key, cache_put, convert_svg_source, source_bytes, source_image, source_surface_from_buffer, svg_source_image_in, vector_scale, +}; use super::{ Affine, Arc, BlendMode, Document, EngineError, Homography, JobCtx, Layer, LayerContent, LayerId, PixelFormat, Rect, Result, SmartFilter, SmartObject, SmartSource, Stage, StageKey, Surface, Value, decode_source, other, stack_cache, }; +use std::sync::Mutex; /// Layer › Smart Objects › Stack Mode: the source's layers (or, when it holds a single group, the /// group's layers) combined per pixel with `mode`. Cached like [`source_image`], keyed by mode. @@ -20,6 +23,15 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc all => all, }; let bounds = doc.bounds(); + // An SVG opens with one layer per shape, and every visible one gets its own float frame: + // they may take 1 GiB together, the SVG import's own budget, before any is rendered. + if photocraft_io::svg::is_svg(bytes) { + let visible = u64::try_from(layers.iter().filter(|l| l.visible).count()).unwrap_or(u64::MAX); + let frame_bytes = doc.size.area().saturating_mul(16); + if visible.saturating_mul(frame_bytes) > 1 << 30 { + return Err(EngineError::Other("the SVG has too many shapes for Stack Mode at this size".into())); + } + } let frames: Vec> = layers .iter() .filter(|l| l.visible) @@ -27,7 +39,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc let mut one = l.clone(); one.blend = BlendMode::Normal; one.clipped = false; - photocraft_compose::render_layer(&one, bounds).px + photocraft_compose::render_layer(&one, bounds).into_px() }) .collect(); if frames.is_empty() { @@ -47,7 +59,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc photocraft_doc::StackMode::Variance => Stat::Variance, }; let buf = photocraft_compose::Buffer { rect: bounds, px: photocraft_algo::stack::combine(&frames, stat) }; - let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds }; + let img = SourceImage { surface: Arc::new(source_surface_from_buffer(&doc, &buf, fmt)?), bounds }; cache_put(key, img.clone()); Ok(img) } @@ -153,12 +165,13 @@ fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect } /// The source placed in document pixels, before any smart filter. -pub(super) fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result { +pub(super) fn place(name: &str, bytes: &[u8], doc: &Document, sm: &SmartObject) -> Result { + let fmt = doc.pixel_format(); // A vector source is rendered at the placement's scale, and the placement is divided by // that scale so the rendered pixels land where the source units would. if sm.stack_mode.is_none() && sm.warp.is_none() && photocraft_io::svg::is_svg(bytes) { let k = vector_scale(sm); - let img = svg_source_image(bytes, fmt, k)?; + let img = svg_source_image_in(bytes, doc, k)?; return Ok(match &sm.perspective { Some(p) => { let [h0, h1, h2, h3, h4, h5, h6, h7, h8] = *p; @@ -173,6 +186,8 @@ pub(super) fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject }); } let img = match sm.stack_mode { + Some(mode) if photocraft_io::svg::is_svg(bytes) => convert_svg_source(stack_image(name, bytes, PixelFormat::RGBA32F, mode)?, doc)?, + None if photocraft_io::svg::is_svg(bytes) => svg_source_image_in(bytes, doc, 1.0)?, Some(mode) => stack_image(name, bytes, fmt, mode)?, None => source_image(name, bytes, fmt)?, }; @@ -183,17 +198,103 @@ pub(super) fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject }) } +#[cfg(test)] +thread_local! { pub(crate) static DISPLAY_NATIVE_HASH_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +/// BLAKE3 digests of native vector sources, remembered per source allocation so explicit +/// refreshes and warp source frames of shared contents hash a (up to 16 MiB) source once. +/// +/// Entries hold a [`Weak`](std::sync::Weak), never a strong reference: the source buffer is freed +/// as soon as the document drops it, and only the small `Arc` header stays allocated until the +/// entry is pruned. That header pinning is what makes the address a sound identity: no other +/// `Arc` can be allocated there while the entry exists, and an `Arc` with a live `Weak` cannot be +/// mutated in place (`Arc::get_mut` fails, `Arc::make_mut` moves to a new allocation), so a +/// remembered digest always describes the bytes at that address. At most [`Self::CAPACITY`] +/// entries are kept, least recently used first out, and dead entries are pruned on every lookup. +pub(super) struct NativeDigests { + pub(super) entries: Vec<(std::sync::Weak>, [u8; 32])>, + capacity: usize, +} + +impl NativeDigests { + const CAPACITY: usize = 32; + + /// An empty cache holding at most `capacity` digests. + pub(super) const fn new(capacity: usize) -> Self { + Self { entries: Vec::new(), capacity } + } + + /// The remembered digest of exactly this allocation, refreshing its recency. + pub(super) fn get(&mut self, bytes: &Arc>) -> Option<[u8; 32]> { + self.entries.retain(|(weak, _)| weak.strong_count() > 0); + let index = self.entries.iter().position(|(weak, _)| std::ptr::eq(weak.as_ptr(), Arc::as_ptr(bytes)))?; + let entry = self.entries.remove(index); + let digest = entry.1; + self.entries.push(entry); + Some(digest) + } + + /// Remember `digest` (computed from `bytes`), evicting the least recently used entry. + pub(super) fn insert(&mut self, bytes: &Arc>, digest: [u8; 32]) { + if self.get(bytes).is_some() || self.capacity == 0 { + return; + } + if self.entries.len() >= self.capacity { + self.entries.remove(0); + } + self.entries.push((Arc::downgrade(bytes), digest)); + } +} + +static NATIVE_DIGESTS: Mutex = Mutex::new(NativeDigests::new(NativeDigests::CAPACITY)); + +/// The BLAKE3 digest of a native source, hashed only the first time this allocation is seen. +/// The lock is not held while hashing, so other renders never wait on a 16 MiB hash. +fn native_digest(bytes: &Arc>) -> [u8; 32] { + let lock = || NATIVE_DIGESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(digest) = lock().get(bytes) { + return digest; + } + #[cfg(test)] + DISPLAY_NATIVE_HASH_BYTES.with(|count| count.set(count.get().saturating_add(bytes.len()))); + let digest = *blake3::hash(bytes).as_bytes(); + lock().insert(bytes, digest); + digest +} + +/// Preview data is untrusted after reload. Keep it on the bounded SVG path and +/// never reuse it after a source replacement, even if a command retained the field. +pub(super) fn display_source(sm: &SmartObject, bytes: &Arc>) -> Arc> { + sm.vector_preview + .as_ref() + .filter(|preview| { + if bytes.len() > 16 * 1024 * 1024 || !photocraft_io::vector_preview::is_native_source(bytes) { + return false; + } + preview.source_hash == native_digest(bytes) + && !preview.svg.is_empty() + && preview.svg.len() <= 16 * 1024 * 1024 + && photocraft_io::svg::is_svg(&preview.svg) + }) + .map_or_else(|| bytes.clone(), |preview| preview.svg.clone()) +} + /// The box of a smart object's source in source pixels (what its warp's `us`/`vs` span), or `None` /// when the source can't be read. +/// +/// An SVG (or a native source's SVG preview) without Stack Mode is rendered by the bounded +/// whole-drawing path, so its box only needs the canvas bounds: no editable layer caches are +/// built, and a drawing whose layer caches exceed the budget still gets a warp frame. Stack Mode +/// keeps the strict layered import it renders with. pub(crate) fn source_box(doc: &Document, sm: &SmartObject) -> Option<[f64; 4]> { let (name, bytes) = source_bytes(&doc.metadata, &sm.source)?; + let bytes = display_source(sm, &bytes); let fmt = doc.pixel_format(); - let img = match sm.stack_mode { - Some(mode) => stack_image(&name, &bytes, fmt, mode), - None => source_image(&name, &bytes, fmt), - } - .ok()?; - let r = img.bounds; + let r = match sm.stack_mode { + None if photocraft_io::svg::is_svg(&bytes) => photocraft_io::svg::bounded_canvas_bounds(&bytes, 24_000_000).ok()?, + Some(mode) => stack_image(&name, &bytes, fmt, mode).ok()?.bounds, + None => source_image(&name, &bytes, fmt).ok()?.bounds, + }; Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]) } @@ -213,13 +314,20 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul crate::jobs::authorize_render_command(&filter.command, &filter.params)?; } let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) }; + let bytes = display_source(sm, &bytes); let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); - let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas); + let mut placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas); + if photocraft_io::svg::is_svg(&bytes) { + let mut h = blake3::Hasher::new(); + h.update(&placed_key); + h.update(&crate::color_cmds::document_profile(doc).to_bytes()); + placed_key = *h.finalize().as_bytes(); + } let placed = || -> Result { if let Some(stage) = stack_cache::get(&placed_key) { return Ok(stage); } - let surface = place(&name, &bytes, fmt, sm)?; + let surface = place(&name, &bytes, doc, sm)?; // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let stage = Stage { extent: canvas.union(&surface.content_bounds()), surface }; stack_cache::put(placed_key, &stage); diff --git a/crates/engine/src/cmd/smart_cmds/source.rs b/crates/engine/src/cmd/smart_cmds/source.rs index d7d895b28d..7385c21da9 100644 --- a/crates/engine/src/cmd/smart_cmds/source.rs +++ b/crates/engine/src/cmd/smart_cmds/source.rs @@ -1,3 +1,7 @@ +use std::sync::PoisonError; + +use photocraft_geom::TILE_SIZE; + use super::{ Arc, ColorMode, Document, Layer, LayerContent, LayerId, Metadata, Mutex, PixelFormat, Rect, Result, SampleType, Session, SmartObject, SmartSource, Surface, base_name, decode_source, json, other, @@ -72,22 +76,35 @@ struct CacheEntry { key: CacheKey, image: SourceImage, decodes: u32, + /// The surface's real tile bytes, counted once when it is cached. + bytes: usize, } /// Decoded source composites by content hash, most recent last. Re-filtering or re-transforming /// a smart object never decodes its contents again. static SOURCE_CACHE: Mutex> = Mutex::new(Vec::new()); +#[cfg(not(target_arch = "wasm32"))] const CACHE_ENTRIES: usize = 32; +#[cfg(target_arch = "wasm32")] +const CACHE_ENTRIES: usize = 4; +/// Accounted tile bytes; a source larger than this on its own isn't kept. +#[cfg(not(target_arch = "wasm32"))] const CACHE_BYTES: usize = 1 << 30; +#[cfg(target_arch = "wasm32")] +const CACHE_BYTES: usize = 384 << 20; + +fn cache() -> std::sync::MutexGuard<'static, Vec> { + SOURCE_CACHE.lock().unwrap_or_else(PoisonError::into_inner) +} pub(super) fn cache_key(bytes: &[u8], fmt: PixelFormat) -> CacheKey { (*blake3::hash(bytes).as_bytes(), fmt) } pub(super) fn cache_get(key: &CacheKey) -> Option { - let mut c = SOURCE_CACHE.lock().ok()?; + let mut c = cache(); let i = c.iter().position(|e| e.key == *key)?; let e = c.remove(i); let img = e.image.clone(); @@ -96,14 +113,22 @@ pub(super) fn cache_get(key: &CacheKey) -> Option { } pub(super) fn cache_put(key: CacheKey, image: SourceImage) { - let Ok(mut c) = SOURCE_CACHE.lock() else { return }; + cache_put_within(key, image, CACHE_BYTES); +} + +/// [`cache_put`] with `limit` as the byte budget. +fn cache_put_within(key: CacheKey, image: SourceImage, limit: usize) { + let bytes = super::stack_cache::surface_bytes(&image.surface); + let mut c = cache(); let decodes = match c.iter().position(|e| e.key == key) { Some(i) => c.remove(i).decodes + 1, None => 1, }; - c.push(CacheEntry { key, image, decodes }); - let size = |e: &CacheEntry| e.image.bounds.width() as usize * e.image.bounds.height() as usize * e.key.1.channels() * 4; - while c.len() > CACHE_ENTRIES || (c.len() > 1 && c.iter().map(size).sum::() > CACHE_BYTES) { + if bytes > limit { + return; + } + c.push(CacheEntry { key, image, decodes, bytes }); + while c.len() > CACHE_ENTRIES || c.iter().map(|e| e.bytes).sum::() > limit { c.remove(0); } } @@ -111,7 +136,21 @@ pub(super) fn cache_put(key: CacheKey, image: SourceImage) { /// How many times these contents were decoded at this pixel format (0 = never / evicted). pub fn source_decode_count(bytes: &[u8], fmt: PixelFormat) -> u32 { let key = cache_key(bytes, fmt); - SOURCE_CACHE.lock().ok().and_then(|c| c.iter().find(|e| e.key == key).map(|e| e.decodes)).unwrap_or(0) + cache().iter().find(|e| e.key == key).map(|e| e.decodes).unwrap_or(0) +} + +/// Bytes held by cached source composites. +pub fn source_cache_bytes() -> usize { + cache().iter().map(|e| e.bytes).sum() +} + +/// Drop every cached source composite (Edit › Purge › All, closing a document with smart +/// objects). Returns the bytes released. +pub fn purge_source_cache() -> usize { + let mut c = cache(); + let freed = c.iter().map(|e| e.bytes).sum(); + c.clear(); + freed } pub(crate) fn take_caches(layers: &mut [Layer]) -> Vec<(LayerId, Surface)> { @@ -155,15 +194,82 @@ pub(super) fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelForm s } -pub(super) fn source_surface(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { +/// A source's pixels in `fmt` from a flattened `buf` (the stack-mode combine, which has no +/// document to composite). +pub(super) fn source_surface_from_buffer(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { + let raster = |f| buffer_to_surface(buf, f); + convert_source(src, fmt, raster) +} + +/// `src` composited straight into `fmt` in blocks, without the full-size RGBA buffer +/// [`photocraft_compose::flatten`] allocates (16 bytes a pixel on top of the surface). +pub(super) fn flattened_source(src: &Document, fmt: PixelFormat) -> Result { + let b = src.bounds(); + if u64::from(b.width()) * u64::from(b.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(other(format!("the smart object's {}×{} contents are too large to render", b.width(), b.height()))); + } + // The banded compositor composites CMYK in inks and Lab in L, a and b: not the same pixels as + // the RGBA buffer, so those modes keep the buffer path. + let native = |m| matches!(m, ColorMode::Cmyk | ColorMode::Lab); + if native(src.mode) || native(fmt.mode) { + return source_surface_from_buffer(src, &photocraft_compose::flatten(src), fmt); + } + convert_source(src, fmt, |f| composite_in_blocks(src, f)) +} + +/// Pixels per block of [`composite_in_blocks`]: the compositor's own bands are 8 Mpx, which for +/// most sources is the whole image again (a band and its f32 conversion are 32 bytes a pixel). +const BLOCK_PIXELS: u64 = 1 << 20; + +/// [`photocraft_compose::flatten_to_surface`] of `src`, composited a block at a time so the float +/// composite and its conversion never cover more than [`BLOCK_PIXELS`]. Blocks are whole tiles, +/// so each block's tiles move into the result as they are: nothing is allocated outside the +/// block, and nothing is copied. +fn composite_in_blocks(src: &Document, fmt: PixelFormat) -> Surface { + let b = src.bounds(); + let (bw, bh) = block_size(b.width()); + let mut out = Surface::new(fmt); + // On the tile grid even if the bounds aren't, so no tile is shared by two blocks. + let mut y = b.y0 - b.y0.rem_euclid(TILE_SIZE); + while y < b.y1 { + let y1 = y.saturating_add(bh); + let mut x = b.x0 - b.x0.rem_euclid(TILE_SIZE); + while x < b.x1 { + let x1 = x.saturating_add(bw); + let block = Rect::new(x, y, x1, y1).intersect(&b); + if !block.is_empty() { + let mut part = photocraft_compose::composite_to_surface(src, block, fmt, None); + out.put_tiles(part.take_tiles(block)); + } + x = x1; + } + y = y1; + } + out.prune(); + out +} + +/// Width and height of a block of [`composite_in_blocks`] for a `width`-pixel source: whole tiles, +/// the full width (up to [`BLOCK_PIXELS`] over one row of tiles) and as many rows of tiles as fit. +fn block_size(width: u32) -> (i32, i32) { + let tile = TILE_SIZE.unsigned_abs() as u64; + let across = u64::from(width.max(1)).div_ceil(tile) * tile; + let bw = across.min((BLOCK_PIXELS / tile / tile).max(1) * tile); + let bh = (BLOCK_PIXELS / bw / tile).max(1) * tile; + (i32::try_from(bw).unwrap_or(TILE_SIZE), i32::try_from(bh).unwrap_or(TILE_SIZE)) +} + +/// Runs the gray and 32-bit conversions a source needs to reach `fmt`; `raster` yields the +/// source's composite at a pixel format. +fn convert_source(src: &Document, fmt: PixelFormat, raster: impl FnOnce(PixelFormat) -> Surface) -> 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)); + return Ok(raster(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())))]; + doc.layers = vec![Layer::new("source", LayerContent::Raster(raster(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)?; @@ -189,15 +295,54 @@ pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result Result { + use photocraft_color::{ColorMode, SampleType}; + + let fmt = doc.pixel_format(); + let profile = crate::color_cmds::document_profile(doc); + // An exact built-in sRGB destination needs no CMS conversion. Keep the direct render + // path (and its depth-specific cache) without approximating custom profile equality. + if doc.mode == ColorMode::Rgb && profile.to_bytes().as_ref() == photocraft_cms::Builtin::Srgb.profile().to_bytes().as_ref() { + return svg_source_image(bytes, fmt, scale); + } + let mut h = blake3::Hasher::new(); + h.update(b"svg-icc\0"); + h.update(bytes); + h.update(&scale.to_bits().to_le_bytes()); + h.update(&profile.to_bytes()); + let key = (*h.finalize().as_bytes(), fmt); + if let Some(image) = cache_get(&key) { + return Ok(image); + } + let source = svg_source_image(bytes, PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), scale)?; + let image = convert_svg_source(source, doc)?; + cache_put(key, image.clone()); + Ok(image) +} + +/// Convert an sRGB SVG rendering into the document's profile and pixel format (relative +/// colorimetric, black-point compensation), keeping its bounds. +pub(super) fn convert_svg_source(source: SourceImage, doc: &Document) -> Result { + use photocraft_cms::{Builtin, Intent, Transform}; + let fmt = doc.pixel_format(); + let profile = crate::color_cmds::document_profile(doc); + let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true) + .map_err(|e| other(format!("can't convert the SVG smart object's colours: {e}")))?; + let surface = crate::color_cmds::convert_surface(&source.surface, photocraft_color::ColorMode::Rgb, fmt, &transform).convert(fmt); + Ok(SourceImage { surface: Arc::new(surface), bounds: source.bounds }) +} + /// The scale a vector source is rasterised at for its placement: the larger axis scale of the /// transform in 1/16 steps (so nudges reuse the cache), between 1/16 and 64. -pub(super) fn vector_scale(sm: &SmartObject) -> f64 { +pub(crate) fn vector_scale(sm: &SmartObject) -> f64 { let [a, b, c, d, ..] = sm.transform.m; let k = (a * a + b * b).sqrt().max((c * c + d * d).sqrt()); if !k.is_finite() { @@ -213,8 +358,49 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result SourceImage { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, side, side), &[0.2, 0.4, 0.6, 1.0]); + SourceImage { surface: Arc::new(s), bounds: Rect::new(0, 0, side, side) } + } + + #[test] + fn a_source_larger_than_the_limit_is_returned_but_not_cached() { + let key = cache_key(b"oversize", PixelFormat::RGBA8); + let img = image(256); + let bytes = super::super::stack_cache::surface_bytes(&img.surface); + cache_put_within(key, img.clone(), bytes - 1); + assert!(cache_get(&key).is_none(), "{bytes} bytes against a limit of {}", bytes - 1); + cache_put_within(key, img, bytes); + assert!(cache_get(&key).is_some()); + } + + #[test] + fn blocks_are_whole_tiles_within_their_pixel_budget_however_wide_the_source() { + for width in [1u32, 255, 4096, 5000, 300_000, u32::MAX] { + let (bw, bh) = block_size(width); + assert!(bw > 0 && bh > 0 && bw % TILE_SIZE == 0 && bh % TILE_SIZE == 0, "{width}: {bw}×{bh}"); + assert!(u64::from(bw.unsigned_abs()) * u64::from(bh.unsigned_abs()) <= BLOCK_PIXELS, "{width}: {bw}×{bh}"); + } + assert_eq!(block_size(4096), (4096, 256)); + assert_eq!(block_size(1000), (1024, 1024)); + assert_eq!(block_size(300_000), (4096, 256)); + } + + #[test] + fn accounting_counts_the_real_tile_bytes() { + let img = image(256); + let bytes = super::super::stack_cache::surface_bytes(&img.surface); + // 8-bit RGBA is 4 bytes a pixel (plus the default pixel), not the 16 of an f32 estimate. + assert!((256 * 256 * 4..256 * 256 * 5).contains(&bytes), "{bytes}"); + } +} diff --git a/crates/engine/src/cmd/smart_cmds/stack_cache.rs b/crates/engine/src/cmd/smart_cmds/stack_cache.rs index b155e5282c..372d30eaab 100644 --- a/crates/engine/src/cmd/smart_cmds/stack_cache.rs +++ b/crates/engine/src/cmd/smart_cmds/stack_cache.rs @@ -30,7 +30,10 @@ struct Entry { static STAGES: Mutex> = Mutex::new(Vec::new()); const ENTRIES: usize = 64; /// Accounted tile bytes; a stage larger than this on its own isn't kept. +#[cfg(not(target_arch = "wasm32"))] pub const BUDGET: usize = 768 << 20; +#[cfg(target_arch = "wasm32")] +pub const BUDGET: usize = 256 << 20; /// The first stage: what placing `bytes` (the source contents) through the smart object's /// transform, warp or perspective gives at pixel format `fmt` on `canvas`. @@ -88,8 +91,9 @@ pub fn purge() -> usize { freed } -fn surface_bytes(s: &Surface) -> usize { +/// Bytes a surface holds: its default pixel and every tile. +pub(super) fn surface_bytes(s: &Surface) -> usize { // Tiles shared with the document (or another stage) are charged anyway: the cache is what // keeps them alive once the document moves on. - s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.bytes().len())) + s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.len())) } diff --git a/crates/engine/src/cmd/smart_cmds/tests.rs b/crates/engine/src/cmd/smart_cmds/tests.rs index 594d8ac3dd..6e8ce37f9c 100644 --- a/crates/engine/src/cmd/smart_cmds/tests.rs +++ b/crates/engine/src/cmd/smart_cmds/tests.rs @@ -2,7 +2,9 @@ use super::*; mod filter_mask_paint; mod selection; +mod source_flatten; mod stack_cache; +mod svg_profile; const DEPTHS: [u64; 3] = [8, 16, 32]; const W: i32 = 64; @@ -108,7 +110,7 @@ fn paint(s: &mut Session) { } fn flat(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { @@ -420,6 +422,13 @@ fn selection_becomes_the_filter_mask() { s.execute("layer.smartFilter.deleteFilterMask", json!({})).unwrap(); assert!(active_smart(&s).filter_mask.is_none()); assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask")); + // A Photoshop action deletes the mask Photoshop adds by default: with `ifPresent`, no mask + // is no step and no error. A layer that isn't a smart object still refuses. + let steps = s.active().unwrap().history.past_len(); + assert_eq!(s.execute("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})).unwrap(), Value::Null); + assert_eq!(s.active().unwrap().history.past_len(), steps); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.execute("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})).is_err()); } /// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so @@ -1311,3 +1320,80 @@ fn clouds_and_fibers_move_with_the_smart_object() { } } } + +/// The native-source digest cache keys by allocation without keeping sources alive, never +/// returns a digest for different bytes, and stays within its capacity. +#[test] +fn native_digest_cache_is_bounded_weak_and_never_stale() { + let mut cache = NativeDigests::new(4); + let source = Arc::new(b"%PDF-1.7\nfirst".to_vec()); + let digest = *blake3::hash(&source).as_bytes(); + assert_eq!(cache.get(&source), None); + cache.insert(&source, digest); + assert_eq!(cache.get(&source), Some(digest)); + assert_eq!(Arc::strong_count(&source), 1, "the cache must not own the source"); + // Equal bytes in another allocation are a different identity: verified separately. + let copy = Arc::new(source.as_ref().clone()); + assert_eq!(cache.get(©), None); + // A dropped source's entry is pruned; a new source of the same length is never served + // the old digest, even if the allocator reuses the buffer. + drop(copy); + let address = Arc::as_ptr(&source); + drop(source); + let replacement = Arc::new(b"%PDF-1.7\nother".to_vec()); + assert_ne!(Arc::as_ptr(&replacement), address, "the entry pins the old header address"); + assert_eq!(cache.get(&replacement), None); + assert!(cache.entries.is_empty(), "dead entries are pruned"); + // Capacity is a hard bound with least-recently-used eviction. + let live: Vec>> = (0..10u8).map(|n| Arc::new(vec![n; 8])).collect(); + for bytes in &live { + cache.insert(bytes, *blake3::hash(bytes).as_bytes()); + assert!(cache.entries.len() <= 4); + } + assert!(live[..6].iter().all(|bytes| cache.get(bytes).is_none())); + assert!(live[6..].iter().all(|bytes| cache.get(bytes) == Some(*blake3::hash(bytes).as_bytes()))); + assert!(live.iter().all(|bytes| Arc::strong_count(bytes) == 1)); + let mut disabled = NativeDigests::new(0); + disabled.insert(&live[0], [0; 32]); + assert!(disabled.entries.is_empty()); +} + +/// Commands edit sources copy-on-write. The cache keys by allocation, so it is only sound if a +/// remembered allocation can never be changed in place. A live `Weak` guarantees that: +/// `Arc::get_mut` refuses and `Arc::make_mut` moves the bytes to a new allocation (a miss), both +/// for a uniquely held source and for one shared with another document. Lookups also refresh +/// recency, so a source in active use is not evicted by newer one-off reads. +#[test] +fn native_digest_cache_misses_after_copy_on_write_and_keeps_recently_used_sources() { + let mut cache = NativeDigests::new(2); + let mut unique = Arc::new(b"%PDF-1.7\nunique".to_vec()); + let before = *blake3::hash(&unique).as_bytes(); + cache.insert(&unique, before); + assert!(Arc::get_mut(&mut unique).is_none(), "a remembered allocation cannot be edited in place"); + let old_address = Arc::as_ptr(&unique); + Arc::make_mut(&mut unique).extend_from_slice(b" edited"); + assert_ne!(Arc::as_ptr(&unique), old_address, "copy-on-write moved the edited bytes"); + assert_eq!(cache.get(&unique), None, "the edited source must be hashed again"); + // The entry for the old allocation is dead (its bytes are gone) and is pruned. + assert!(cache.entries.is_empty()); + let mut shared = Arc::new(b"%PDF-1.7\nshared".to_vec()); + let other_document = Arc::clone(&shared); + let digest = *blake3::hash(&shared).as_bytes(); + cache.insert(&shared, digest); + Arc::make_mut(&mut shared).push(b'!'); + assert_eq!(cache.get(&shared), None); + assert_eq!(cache.get(&other_document), Some(digest), "the untouched copy keeps its digest"); + assert_eq!(Arc::strong_count(&other_document), 1, "only the other document owns it"); + // Recency: `get` moves an entry to the back, so the next insert evicts the other one. + let newer = Arc::new(vec![1u8; 4]); + cache.insert(&newer, [1; 32]); + assert_eq!(cache.get(&other_document), Some(digest)); + let newest = Arc::new(vec![2u8; 4]); + cache.insert(&newest, [2; 32]); + assert_eq!(cache.get(&other_document), Some(digest), "a recently read source survives eviction"); + assert_eq!(cache.get(&newer), None, "the least recently used entry was evicted"); + // A remembered source's buffer is released with its last owner: only a dead Weak remains. + let weak = Arc::downgrade(&newest); + drop(newest); + assert!(weak.upgrade().is_none()); +} diff --git a/crates/engine/src/cmd/smart_cmds/tests/filter_mask_paint.rs b/crates/engine/src/cmd/smart_cmds/tests/filter_mask_paint.rs index cae9f91409..054c615bea 100644 --- a/crates/engine/src/cmd/smart_cmds/tests/filter_mask_paint.rs +++ b/crates/engine/src/cmd/smart_cmds/tests/filter_mask_paint.rs @@ -86,6 +86,35 @@ fn a_shown_filter_mask_takes_the_paint() { assert_eq!(s.active().unwrap().channel_view, before); } +#[test] +fn a_shown_filter_mask_wins_over_color_selection_for_cut_and_clear() { + for depth in DEPTHS { + for cmd in ["edit.cut", "edit.clear"] { + let mut s = session(depth); + paint(&mut s); + convert(&mut s); + s.execute("filter.blur.gaussianBlur", json!({"radius":3})).unwrap(); + stroke(&mut s, Some("filterMask")).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + let layer_mask = s.active().unwrap().doc.layer(layer).unwrap().mask.clone(); + let before = active_smart(&s).filter_mask.unwrap(); + s.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + s.execute("view.layerMask", json!({"mode":"gray","mask":"filter"})).unwrap(); + s.tools.background = [0.25, 0.25, 0.25, 1.0]; + let result = s.execute(cmd, json!({})).unwrap(); + if cmd == "edit.cut" { + assert_eq!(result["target"], "filterMask"); + assert_eq!(s.clipboard.as_ref().unwrap().surface.pixel(8, 20)[0], 0.0); + } + assert!((active_smart(&s).filter_mask.unwrap().value(8, 20) - 0.25).abs() < 0.005); + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().mask, layer_mask, "{cmd} leaves the layer mask alone"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(active_smart(&s).filter_mask.unwrap(), before, "undo {cmd} {depth}"); + } + } +} + #[test] fn copying_the_filter_mask_never_copies_the_layer_mask() { for depth in DEPTHS { diff --git a/crates/engine/src/cmd/smart_cmds/tests/source_flatten.rs b/crates/engine/src/cmd/smart_cmds/tests/source_flatten.rs new file mode 100644 index 0000000000..6d3f3a9e23 --- /dev/null +++ b/crates/engine/src/cmd/smart_cmds/tests/source_flatten.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::cmd::smart_cmds::source::{flattened_source, source_surface_from_buffer}; + +/// A source document of `mode`/`depth` with a textured, partly transparent block on a layer. +fn source(mode: ColorMode, depth: SampleType) -> Document { + let mut doc = Document::new("source", Size::new(W as u32, H as u32), mode, depth); + doc.layers = vec![Layer::new("block", LayerContent::Raster(block(&doc.pixel_format(), Rect::new(4, 3, 50, 40))))]; + doc +} + +fn block(fmt: &PixelFormat, r: Rect) -> Surface { + let mut s = Surface::new(*fmt); + let mut data = Vec::new(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let v = ((x * 7 + y * 13) % 23) as f32 / 22.0; + let a = if (x + y) % 5 == 0 { 0.5 } else { 1.0 }; + data.extend(photocraft_raster::from_rgba(fmt, [v, 1.0 - v, ((x * y) % 7) as f32 / 6.0, a])); + } + } + s.write_region(r, &data); + s +} + +/// Everything the smart object cache would hold, bit for bit. +fn pixels(s: &Surface, bounds: Rect) -> Vec { + s.read_region(bounds).iter().map(|v| v.to_bits()).collect() +} + +/// The composite through the full-size RGBA buffer, the way sources were rendered before. +fn through_buffer(doc: &Document, fmt: PixelFormat) -> Surface { + source_surface_from_buffer(doc, &photocraft_compose::flatten(doc), fmt).unwrap() +} + +fn same(label: &str, doc: &Document, fmt: PixelFormat) { + let (old, new) = (through_buffer(doc, fmt), flattened_source(doc, fmt).unwrap()); + assert_eq!(new.format(), old.format(), "{label}: format"); + assert!(pixels(&old, doc.bounds()) == pixels(&new, doc.bounds()), "{label}: pixels differ"); +} + +#[test] +fn rgb_sources_match_at_every_depth() { + for depth in SampleType::ALL { + let doc = source(ColorMode::Rgb, depth); + same(&format!("rgb {depth:?}"), &doc, doc.pixel_format()); + } +} + +#[test] +fn a_gray_source_in_an_rgb_document_and_back_matches() { + let gray = source(ColorMode::Grayscale, SampleType::U8); + same("gray into rgb", &gray, PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true }); + let rgb = source(ColorMode::Rgb, SampleType::U8); + same("rgb into gray", &rgb, PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::U8, alpha: true }); +} + +#[test] +fn an_8_bit_source_in_a_32_bit_document_matches() { + let doc = source(ColorMode::Rgb, SampleType::U8); + same("8 into 32", &doc, PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true }); +} + +/// The banded compositor composites CMYK in inks and Lab in L, a and b, and lands up to 16 and 1 +/// steps (8-bit) off the RGBA buffer on visible pixels, so these sources keep the buffer path. +#[test] +fn a_cmyk_source_matches() { + let doc = source(ColorMode::Cmyk, SampleType::U8); + same("cmyk", &doc, doc.pixel_format()); +} + +#[test] +fn a_lab_source_matches() { + let doc = source(ColorMode::Lab, SampleType::U8); + same("lab", &doc, doc.pixel_format()); +} + +#[test] +fn a_layered_source_with_a_mask_blend_mode_and_effect_matches() { + for depth in [SampleType::U8, SampleType::F32] { + let mut doc = source(ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut top = Layer::new("top", LayerContent::Raster(block(&fmt, Rect::new(20, 10, 60, 46)))); + top.blend = BlendMode::Multiply; + top.opacity = 0.7; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(30, 0, 64, 48), &[0.25]); + top.mask = Some(mask); + doc.layers.push(top); + let mut shadowed = Layer::new("shadowed", LayerContent::Raster(block(&fmt, Rect::new(8, 20, 24, 36)))); + shadowed.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + shadowed.effects.enabled = true; + doc.layers.push(shadowed); + same(&format!("layered {depth:?}"), &doc, fmt); + } +} + +#[test] +fn a_source_past_the_render_limit_is_refused_not_emptied() { + let doc = Document::new("huge", Size::new(60_000, 60_000), ColorMode::Rgb, SampleType::U8); + assert!(flattened_source(&doc, doc.pixel_format()).is_err()); +} + +#[test] +fn an_rgb_source_in_a_cmyk_or_lab_document_matches() { + let doc = source(ColorMode::Rgb, SampleType::U8); + for mode in [ColorMode::Cmyk, ColorMode::Lab] { + same(&format!("rgb into {mode:?}"), &doc, PixelFormat { mode, sample: SampleType::U8, alpha: true }); + } +} + +/// Blocks are 256 rows of the full width at 4096 px, and split at x = 4096 at 5000 px: the shadow +/// and mask cross the block edges both ways. +#[test] +fn effects_across_block_edges_match() { + for (width, depth) in [(4096, SampleType::U8), (4096, SampleType::F32), (5000, SampleType::U8)] { + let mut doc = Document::new("wide", Size::new(width, 700), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let x0 = if width > 4096 { 3800 } else { 100 }; + let mut base = Layer::new("base", LayerContent::Raster(block(&fmt, Rect::new(x0, 200, x0 + 800, 560)))); + let mut glow = photocraft_doc::Effect::default_drop_shadow(); + if let photocraft_doc::Effect::DropShadow(d) = &mut glow { + d.size = 40.0; + d.distance = 30.0; + } + base.effects.items.push(glow); + base.effects.enabled = true; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(0, 240, width as i32, 270), &[0.3]); + base.mask = Some(mask); + doc.layers = vec![base]; + same(&format!("blocks {width} {depth:?}"), &doc, fmt); + } +} diff --git a/crates/engine/src/cmd/smart_cmds/tests/stack_cache.rs b/crates/engine/src/cmd/smart_cmds/tests/stack_cache.rs index 9465fb8387..d74eb3ec45 100644 --- a/crates/engine/src/cmd/smart_cmds/tests/stack_cache.rs +++ b/crates/engine/src/cmd/smart_cmds/tests/stack_cache.rs @@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface { let doc = &s.active().unwrap().doc; let sm = active_smart(s); let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap(); - apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap() + apply_smart_filters(&place(&name, &bytes, doc, &sm).unwrap(), &sm, doc.bounds()).unwrap() } #[test] diff --git a/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs b/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs new file mode 100644 index 0000000000..ebe17ae996 --- /dev/null +++ b/crates/engine/src/cmd/smart_cmds/tests/svg_profile.rs @@ -0,0 +1,166 @@ +use super::*; +use photocraft_cms::Builtin; + +const SVG: &[u8] = br##""##; + +#[test] +fn svg_builtin_srgb_keeps_direct_render_at_each_depth() { + let svg = br##""##; + for depth in DEPTHS { + let s = session(depth); + let mut doc = (*s.active().unwrap().doc).clone(); + for explicit_profile in [false, true] { + doc.icc_profile = explicit_profile.then(|| Builtin::Srgb.profile().to_bytes()); + let expected = svg_source_image(svg, doc.pixel_format(), 1.0).unwrap(); + let actual = svg_source_image_in(svg, &doc, 1.0).unwrap(); + assert_eq!(actual.bounds, expected.bounds); + assert_eq!(actual.surface, expected.surface, "sRGB at {depth}-bit, explicit={explicit_profile}"); + } + } +} + +#[test] +fn svg_smart_rerender_checks_embedded_raster_dimensions() { + let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + header.extend_from_slice(&32768u32.to_be_bytes()); + header.extend_from_slice(&32768u32.to_be_bytes()); + header.extend_from_slice(&[8, 6, 0, 0, 0]); + let data = header.iter().map(|byte| format!("%{byte:02X}")).collect::(); + let svg = + format!(""); + assert!(svg_source_image(svg.as_bytes(), PixelFormat::RGBA32F, 1.0).unwrap_err().to_string().contains("decoded pixel budget")); + let s = session(8); + let smart = SmartObject::new(SmartSource::Embedded { file_name: "Clipboard.svg".into(), bytes: Arc::new(svg.into_bytes()) }, Affine::IDENTITY, None); + assert!(render(&s.active().unwrap().doc, &smart).unwrap_err().to_string().contains("decoded pixel budget")); +} + +/// An SVG with many shapes opens as editable layers for its contents and Stack Mode, as +/// File › Open does, under the SVG import's own retained-pixel budget. +#[test] +fn svg_smart_contents_and_stack_open_many_shapes_as_layers() { + let svg = format!("{}", "".repeat(400)); + assert_eq!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap().layers.len(), 400); + assert!(stack_image("Clipboard.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).is_ok()); +} + +/// Stack Mode renders a float frame per layer: an SVG whose shapes would need more than 1 GiB of +/// them (40 full-HD shapes, 1.3 GB) is refused before any is rendered, while its contents open. +#[test] +fn svg_stack_mode_refuses_frames_past_its_budget() { + let svg = format!("{}", "".repeat(40)); + assert_eq!(decode_source("Many.svg", svg.as_bytes()).unwrap().layers.len(), 40); + let error = stack_image("Many.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).unwrap_err(); + assert!(error.to_string().contains("Stack Mode"), "{error}"); +} + +/// An ordinary layout (a 1920 × 1080 SVG with 12 shapes) placed as a smart object: Replace +/// Contents with another like it and Convert to Layers both work through their commands. +#[test] +fn ordinary_layered_svg_contents_replace_and_convert_to_layers() { + let rects: String = (0..12).map(|i| format!("", i * 150)).collect(); + let svg = format!("{rects}"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1920, "height": 1080})).unwrap(); + crate::file_cmds::place_bytes(&mut s, "Layout.svg", svg.clone().into_bytes(), None, &json!({"smartObject": true})).unwrap(); + let dir = std::env::temp_dir().join(format!("pcraft-svg-layout-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Layout 2.svg"); + std::fs::write(&path, svg.replace("#336699", "#993366")).unwrap(); + s.execute("layer.smartObjects.replaceContents", json!({"path": path.to_str().unwrap()})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let r = s.execute("layer.smartObjects.convertToLayers", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + let group = doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap(); + assert_eq!(group.children().map(<[Layer]>::len), Some(12), "the 12 shapes become 12 layers"); +} + +/// Convert to Layers on an SVG scaled to fill a 4K document: four full-canvas shapes (33 MP of +/// shape caches, as many as four full-canvas layers anywhere) unpack. +#[test] +fn convert_to_layers_unpacks_full_canvas_shapes_in_a_4k_document() { + let svg = format!("{}", "".repeat(4)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 3840, "height": 2160})).unwrap(); + let placed = crate::file_cmds::place_bytes(&mut s, "Full.svg", svg.into_bytes(), None, &json!({"smartObject": true})).unwrap(); + let id = LayerId(placed["layer"].as_u64().unwrap()); + let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().content else { panic!("smart") }; + sm.transform = Affine { m: [10.0, 0.0, 0.0, 10.0, 0.0, 0.0] }; + s.execute("layer.smartObjects.convertToLayers", json!({"layer": id.0})).unwrap(); +} + +/// An SVG with an embedded image the bounded resolver can't read (an unknown MIME type) places +/// and renders the rest of the drawing, as it did before the bounded render. +#[test] +fn svg_with_an_unreadable_embedded_image_still_places() { + let svg = ""; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let placed = crate::file_cmds::place_bytes(&mut s, "Logo.svg", svg.as_bytes().to_vec(), None, &json!({"smartObject": true})).unwrap(); + let doc = &s.active().unwrap().doc; + let LayerContent::Smart(sm) = &doc.layer(LayerId(placed["layer"].as_u64().unwrap())).unwrap().content else { panic!("smart") }; + assert_eq!(sm.cache.as_ref().unwrap().rgba(6, 4), [1.0, 0.0, 0.0, 1.0]); +} + +/// Warp setup needs only the source frame, found with the bounded parse and the exact canvas +/// limit; Stack Mode and Edit Contents build the SVG's layers. +#[test] +fn svg_warp_frame_uses_bounded_bounds() { + // 256 × 256 × 400 shapes = 26,214,400 pixels of layer caches, over the 24 MP budget. + let svg = format!( + "{}", + "".repeat(400) + ); + let s = session(8); + let doc = s.active().unwrap().doc.clone(); + let mut smart = + SmartObject::new(SmartSource::Embedded { file_name: "Clipboard.svg".into(), bytes: Arc::new(svg.clone().into_bytes()) }, Affine::IDENTITY, None); + let bounds = [0.0, 0.0, 256.0, 256.0]; + assert_eq!(source_box(&doc, &smart), Some(bounds)); + smart.warp = Some(photocraft_geom::warp::Warp::custom(photocraft_geom::warp::BezierMesh::identity(bounds, 1, 1), bounds)); + assert_eq!(source_box(&doc, &smart), Some(bounds), "a warped SVG keeps its frame"); + let warped = render(&doc, &smart).unwrap().unwrap(); + assert!((warped.pixel(10, 10)[2] - 0x99 as f32 / 255.0).abs() < 0.01, "the bounded warp render draws the SVG"); + assert_eq!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap().layers.len(), 400); + smart.warp = None; + smart.stack_mode = Some(photocraft_doc::StackMode::Mean); + assert_eq!(source_box(&doc, &smart), Some(bounds), "Stack Mode gets the layered import's frame"); + // The canvas limit is exact: 24,000,000 pixels gets a frame, one more row does not. + let canvas = |h: u32| format!(""); + let frame = |svg: String| { + source_box(&doc, &SmartObject::new(SmartSource::Embedded { file_name: "Big.svg".into(), bytes: Arc::new(svg.into_bytes()) }, Affine::IDENTITY, None)) + }; + assert_eq!(frame(canvas(6000)), Some([0.0, 0.0, 4000.0, 6000.0])); + assert_eq!(frame(canvas(6001)), None); +} + +#[test] +fn svg_smart_render_cache_tracks_destination_profile_and_transform() { + for depth in DEPTHS { + let s = session(depth); + let srgb = (*s.active().unwrap().doc).clone(); + let mut linear = srgb.clone(); + linear.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "clipboard.svg".into(), bytes: Arc::new(SVG.to_vec()) }, Affine::IDENTITY, None); + let original = render(&srgb, &smart).unwrap().unwrap(); + let corrected = render(&linear, &smart).unwrap().unwrap(); + assert!((original.pixel(3, 3)[0] - 128.0 / 255.0).abs() < 0.01); + assert!((corrected.pixel(3, 3)[0] - 0.216).abs() < 0.01, "linear profile at {depth}-bit"); + assert_eq!(render(&srgb, &smart).unwrap().unwrap(), original, "profile-specific placed cache"); + smart.transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 4.0, 2.0] }; + let scaled = render(&linear, &smart).unwrap().unwrap(); + assert!((scaled.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "scale must keep corrected colours"); + assert_eq!(scaled.format(), linear.pixel_format()); + let bounds = [0.0, 0.0, 8.0, 8.0]; + smart.warp = Some(photocraft_geom::warp::Warp::custom(photocraft_geom::warp::BezierMesh::identity(bounds, 1, 1), bounds)); + let warped = render(&linear, &smart).unwrap().unwrap(); + assert!((warped.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "warp must keep corrected colours"); + linear.layers = vec![Layer::new("Saved SVG", LayerContent::Smart(smart.clone()))]; + let saved = encode_source(&linear).unwrap(); + let reopened = decode_source("saved.pcraft", &saved).unwrap(); + let LayerContent::Smart(restored) = &reopened.layers[0].content else { panic!("smart object lost on save") }; + assert_eq!(restored.source, smart.source); + assert_eq!(restored.transform, smart.transform); + let rerendered = render(&reopened, restored).unwrap().unwrap(); + assert!((rerendered.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "saved source keeps ICC and warp"); + } +} diff --git a/crates/engine/src/cmd/smart_cmds/unpack.rs b/crates/engine/src/cmd/smart_cmds/unpack.rs index af7510da8d..e6ce545dc8 100644 --- a/crates/engine/src/cmd/smart_cmds/unpack.rs +++ b/crates/engine/src/cmd/smart_cmds/unpack.rs @@ -89,6 +89,12 @@ fn place(s: &Session, doc: &Document, sm: &SmartObject, layers: &mut [Layer]) -> } let h = placement(sm); let affine = sm.perspective.is_none().then_some(sm.transform); + if let SmartSource::Embedded { bytes, .. } = &sm.source + && photocraft_io::svg::is_svg(bytes) + && let Some(transform) = affine + { + check_destination_shapes(layers, transform, doc.size, doc.pixel_format().bytes_per_pixel()).map_err(other)?; + } for l in layers { released(l, |l| crate::transform_cmds::transform_layer(None, photocraft_doc::Locks::default(), l, &h, affine, Interp::Bicubic))?; crate::transform_cmds::refresh_text(doc, l); @@ -186,3 +192,32 @@ pub(super) fn convert_to_layers(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"layer": new_id.0, "group": group, "discardedSmartFilters": discarded})) } + +/// Preflight transformed SVG shape caches before an operation refreshes their raster caches: +/// together they may take 1 GiB in the document's pixel format, the budget the SVG import +/// itself keeps its layer caches under (several full-canvas shapes in a 24 MP document fit). +fn check_destination_shapes( + layers: &[Layer], + transform: photocraft_geom::Affine, + size: photocraft_geom::Size, + bytes_per_pixel: usize, +) -> std::result::Result<(), String> { + const MAX_CACHE_BYTES: u64 = 1 << 30; + let max_pixels = MAX_CACHE_BYTES / u64::try_from(bytes_per_pixel.max(1)).unwrap_or(u64::MAX); + let canvas = photocraft_geom::Rect::from_size(size); + let mut pixels = 0u64; + let mut pending: Vec<&Layer> = layers.iter().collect(); + while let Some(layer) = pending.pop() { + pending.extend(layer.children().into_iter().flatten()); + let LayerContent::Shape(shape) = &layer.content else { continue }; + let mut shape = shape.clone(); + crate::vector_cmds::transform_shape(&mut shape, &transform); + let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, canvas); + let area = compiled.bounds().map_or(0, |bounds| bounds.intersect(&canvas).size().area()); + pixels = pixels.checked_add(area).ok_or("The unpacked shape caches exceed the smart-object cache budget")?; + if pixels > max_pixels { + return Err("The unpacked shape caches exceed the smart-object cache budget".into()); + } + } + Ok(()) +} diff --git a/crates/engine/src/cmd/smart_svg_cmds.rs b/crates/engine/src/cmd/smart_svg_cmds.rs new file mode 100644 index 0000000000..f84c16d5ab --- /dev/null +++ b/crates/engine/src/cmd/smart_svg_cmds.rs @@ -0,0 +1,455 @@ +//! Memory-only SVG smart-object handoff. The shell owns editor selection and temporary files; +//! commands own optimistic concurrency, bounded rendering and one shared-content transaction. + +use base64::Engine as _; +use std::sync::Arc; + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId, SmartObject, SmartSource}; +use photocraft_geom::{Rect, Size}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +const EXPORT: &str = "layer.smartObjects.exportSvg"; +const UPDATE: &str = "layer.smartObjects.updateSvg"; +const UPDATE_VECTOR: &str = "layer.smartObjects.updateVectorSource"; +pub const MAX_VECTOR_SOURCE_BYTES: usize = 16 << 20; +const MAX_PIXELS: u64 = 24_000_000; +const MAX_FRAME_XML_BYTES: usize = 64 << 20; + +/// A `BadParams` error attributed to command `cmd`. +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +/// Read the `layer` param from an object; when it is absent, updates (`required`) fail +/// rather than falling back to the active layer. +fn layer(s: &Session, p: &Value, cmd: &str, required: bool) -> Result { + if !p.is_object() { + return Err(bad(cmd, "parameters must be an object")); + } + match p.get("layer") { + Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad(cmd, "`layer` must be an integer layer id")), + None if required => Err(bad(cmd, "missing `layer`")), + None => crate::commands::layer_param(s, p), + } +} + +/// Borrow the smart object on layer `id`; errors if the layer is missing or not a smart object. +fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> { + match &doc.layer(id).ok_or(EngineError::NoLayer(id))?.content { + LayerContent::Smart(sm) => Ok(sm), + _ => Err(EngineError::Other("the layer is not a smart object".into())), + } +} + +/// Borrow embedded bytes from the object or PSD table without following a linked disk path. +fn embedded<'a>(doc: &'a Document, sm: &'a SmartObject) -> Result<(String, &'a [u8])> { + let (name, bytes) = match &sm.source { + SmartSource::Embedded { file_name, bytes } => (file_name.clone(), bytes.as_slice()), + SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path) + .ok_or_else(|| EngineError::Other("external editing requires embedded vector contents".into()))?, + }; + validate_source(bytes)?; + Ok((name, bytes)) +} + +/// Reject unsupported magic bytes and oversized native-vector payloads before handoff. +fn validate_source(bytes: &[u8]) -> Result<()> { + if bytes.is_empty() || bytes.len() > MAX_VECTOR_SOURCE_BYTES || source_extension(bytes).is_none() { + return Err(EngineError::Other("external editing requires SVG, PDF-compatible AI, PDF or EPS contents within 16 MiB".into())); + } + Ok(()) +} + +/// Identify supported vector formats from their payload rather than a filename suffix. +fn source_extension(bytes: &[u8]) -> Option<&'static str> { + if photocraft_io::svg::is_svg(bytes) { + Some("svg") + } else if bytes.starts_with(b"%PDF-") { + Some("pdf") + } else if bytes.starts_with(b"%!PS-Adobe-") || bytes.starts_with(&[0xc5, 0xd0, 0xd3, 0xc6]) { + Some("eps") + } else { + None + } +} + +/// Recover the bounded original PSD source-pixel frame for the conversion viewport. +fn source_size(sm: &SmartObject) -> Option<(f64, f64)> { + source_size_with(sm, preview_frame) +} + +#[cfg(test)] +thread_local! { static PREVIEW_FRAME_CHECKS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +/// Reading source geometry never rasterizes an old preview or decodes its embedded images. +fn preview_frame(preview: &photocraft_doc::VectorPreview, source: &[u8]) -> Option<(f64, f64)> { + if source.len() > MAX_VECTOR_SOURCE_BYTES || !photocraft_io::vector_preview::is_native_source(source) { + return None; + } + preview_frame_hash(preview, *blake3::hash(source).as_bytes()) +} + +/// Read the frame recorded in preview metadata, accepted only for `source_hash`; the caller +/// supplies a digest it has verified against the actual source bytes. +fn preview_frame_hash(preview: &photocraft_doc::VectorPreview, source_hash: [u8; 32]) -> Option<(f64, f64)> { + #[cfg(test)] + PREVIEW_FRAME_CHECKS.with(|count| count.set(count.get() + 1)); + photocraft_io::vector_preview::frame_metadata(preview, source_hash) +} + +/// Prefer the embedded source's preview frame via `frame`, else the PSD placed descriptor's +/// stored size, rejected when non-finite or over 16384 px per side or [`MAX_PIXELS`]. +fn source_size_with(sm: &SmartObject, frame: impl FnOnce(&photocraft_doc::VectorPreview, &[u8]) -> Option<(f64, f64)>) -> Option<(f64, f64)> { + if let SmartSource::Embedded { bytes, .. } = &sm.source + && let Some(preview) = sm.vector_preview.as_ref() + && let Some(frame) = frame(preview, bytes) + { + return Some(frame); + } + let placed = photocraft_io::smart_map::parse_sold(sm.psd_raw.as_ref()?.as_slice())?; + photocraft_io::smart_map::stored_size(&placed.descriptor) + .filter(|(w, h)| w.is_finite() && h.is_finite() && *w <= 16384.0 && *h <= 16384.0 && w * h <= MAX_PIXELS as f64) +} + +/// Shared by all three commands: needs an active document with no background job and the +/// layer composite (not a mask, quick mask or single channel) as the edit target. +fn enabled(s: &Session) -> std::result::Result<(), String> { + let state = s.active().ok_or("no document open")?; + if s.job_on(state.doc.id).is_some() { + return Err("the document has a running background job".into()); + } + if s.tools.mask_targeted + || state.doc.quick_mask.is_some() + || state.channel_view.layer_mask.is_some() + || state.channel_view.target != crate::channel_cmds::ChannelTarget::Composite + { + return Err("SVG contents editing requires the layer composite target".into()); + } + Ok(()) +} + +/// Return every layer sharing `id`'s contents, which change together. Errors if those contents +/// are open in the internal editor, any instance is fully or pixel locked, or any instance +/// lacks valid embedded vector bytes. +fn check_instances(s: &Session, id: LayerId) -> Result> { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let contents = smart(doc, id)?.contents_id; + if s.smart_links.iter().any(|link| link.parent == doc.id && link.contents_id == contents) { + return Err(EngineError::Other("these smart-object contents are already open in PhotoCraft".into())); + } + let ids: Vec<_> = + doc.walk().into_iter().filter_map(|(_, _, l)| matches!(&l.content, LayerContent::Smart(sm) if sm.contents_id == contents).then_some(l.id)).collect(); + for id in &ids { + let locks = doc.effective_locks(*id); + if locks.all || locks.pixels { + return Err(EngineError::Other("a shared smart-object instance is locked".into())); + } + embedded(doc, smart(doc, *id)?)?; + } + Ok(ids) +} + +/// Export a bounded source and optimistic identity entirely in memory. +fn export(s: &mut Session, p: &Value) -> Result { + let id = layer(s, p, EXPORT, false)?; + check_instances(s, id)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let sm = smart(doc, id)?; + let (name, bytes) = embedded(doc, sm)?; + let mut extension = source_extension(bytes).ok_or_else(|| bad(EXPORT, "unsupported vector source"))?; + if extension != "svg" && name.to_ascii_lowercase().ends_with(".ai") { + extension = "ai"; + } + let frame = source_size(sm); + if extension != "svg" && sm.psd_raw.is_some() && frame.is_none() { + return Err(bad(EXPORT, "the embedded vector has no supported PSD source-pixel frame")); + } + let mut result = json!({"document":doc.id.0,"fingerprint":blake3::hash(bytes).to_hex().to_string(),"contentsId":sm.contents_id.0,"extension":extension,"sourceSize":frame}); + if extension == "svg" { + result["svg"] = json!(std::str::from_utf8(bytes).map_err(|_| bad(EXPORT, "SVG contents must be UTF-8"))?); + } else { + result["sourceBase64"] = json!(base64::engine::general_purpose::STANDARD.encode(bytes)); + } + Ok(result) +} + +/// Mirror the renderer's mesh vertices before it allocates destination tiles. Without a warp, +/// a homography has corner extrema only when its denominator keeps one nonzero sign. +fn placed_bounds(sm: &SmartObject, size: Size) -> Result { + let (mut w, mut h) = (f64::from(size.width), f64::from(size.height)); + let warp = sm.warp.as_ref().filter(|warp| !warp.is_identity()); + if sm.warp.is_none() && sm.stack_mode.is_none() { + let scale = crate::smart_cmds::vector_scale(sm); + w = (w * scale).ceil() / scale; + h = (h * scale).ceil() / scale; + } + let (nx, ny) = if warp.is_some() { (((w / 4.0).ceil() as usize).clamp(1, 512), ((h / 4.0).ceil() as usize).clamp(1, 512)) } else { (1, 1) }; + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + let mut sign = None; + for j in 0..=ny { + for i in 0..=nx { + let (x, y) = (w * i as f64 / nx as f64, h * j as f64 / ny as f64); + let (x, y) = warp.map_or((x, y), |warp| warp.map(x, y)); + let (x, y) = if let Some(m) = sm.perspective { + let denominator = m[6] * x + m[7] * y + m[8]; + let positive = denominator > 0.0; + if !denominator.is_finite() || denominator.abs() < 1e-9 || sign.is_some_and(|prev| prev != positive) { + return Err(bad(UPDATE, "SVG placement crosses an unbounded perspective")); + } + sign = Some(positive); + ((m[0] * x + m[1] * y + m[2]) / denominator, (m[3] * x + m[4] * y + m[5]) / denominator) + } else { + let [a, b, c, d, e, f] = sm.transform.m; + (a * x + c * y + e, b * x + d * y + f) + }; + if !x.is_finite() || !y.is_finite() || x.abs() > 1_000_000.0 || y.abs() > 1_000_000.0 { + return Err(bad(UPDATE, "SVG placement exceeds the supported coordinate range")); + } + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + // Unwarped vectors rasterise at a quantised scale. Include its rounded source-pixel + // edges and the inverse sampler's border, as well as the mesh renderer's one-pixel border. + let padding = if warp.is_some() { 1 } else { 4 }; + Ok(Rect::new(x0.floor() as i32 - padding, y0.floor() as i32 - padding, x1.ceil() as i32 + padding, y1.ceil() as i32 + padding)) +} + +/// Validate identity and rendering limits before one undoable shared-content replacement. +fn update(s: &mut Session, p: &Value) -> Result { + update_contents(s, p, false) +} + +/// Native bytes and their bounded display preview are committed in the same transaction. +fn update_vector(s: &mut Session, p: &Value) -> Result { + update_contents(s, p, true) +} + +/// Shared SVG (`native == false`) and AI/PDF/EPS save-back with the default frame-metadata budget. +fn update_contents(s: &mut Session, p: &Value, native: bool) -> Result { + update_contents_with_frame_budget(s, p, native, MAX_FRAME_XML_BYTES) +} + +/// Bound metadata parsing separately from the destination render budget. The explicit budget +/// also lets tests exercise exhaustion with small authored fixtures. +fn update_contents_with_frame_budget(s: &mut Session, p: &Value, native: bool, frame_xml_bytes: usize) -> Result { + let id = layer(s, p, UPDATE, true)?; + let document = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad(UPDATE, "`document` must be an integer document id"))?; + if s.active().map(|state| state.doc.id.0) != Some(document) { + return Err(bad(UPDATE, "the smart object's parent document is no longer active")); + } + let svg = p.get(if native { "previewSvg" } else { "svg" }).and_then(Value::as_str).ok_or_else(|| bad(UPDATE, "`svg` must be an SVG string"))?; + let expected = p + .get("fingerprint") + .and_then(Value::as_str) + .filter(|hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(|| bad(UPDATE, "`fingerprint` must be a BLAKE3 hex digest"))?; + let contents = p.get("contentsId").and_then(Value::as_u64).map(SmartContentsId).ok_or_else(|| bad(UPDATE, "`contentsId` must be an integer"))?; + let ids = check_instances(s, id)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let target = smart(doc, id)?; + let (name, original) = embedded(doc, target)?; + let original_hash = blake3::hash(original); + if target.contents_id != contents || !original_hash.to_hex().as_str().eq_ignore_ascii_case(expected) { + return Err(bad(UPDATE, "smart-object contents changed since external editing started")); + } + for instance in &ids { + let (_, bytes) = embedded(doc, smart(doc, *instance)?)?; + // Shared instances normally borrow the same immutable payload. Its fingerprint was + // checked above; don't re-hash a large source once per layer. + let matches = (bytes.len() == original.len() && std::ptr::eq(bytes.as_ptr(), original.as_ptr())) + || blake3::hash(bytes).to_hex().as_str().eq_ignore_ascii_case(expected); + if !matches { + return Err(bad(UPDATE, "shared smart-object instances have conflicting contents")); + } + } + let replacement_bytes = if native { + if !photocraft_io::vector_preview::is_native_source(original) { + return Err(bad(UPDATE_VECTOR, "the original source is not AI, PDF or EPS")); + } + let extension = p.get("extension").and_then(Value::as_str).ok_or_else(|| bad(UPDATE_VECTOR, "missing source extension"))?; + let original_extension = if name.to_ascii_lowercase().ends_with(".ai") { "ai" } else { source_extension(original).unwrap_or("") }; + if extension != original_extension || !matches!(extension, "ai" | "pdf" | "eps") { + return Err(bad(UPDATE_VECTOR, "save-back must keep the original vector format")); + } + let encoded = p.get("sourceBase64").and_then(Value::as_str).ok_or_else(|| bad(UPDATE_VECTOR, "missing native source bytes"))?; + if encoded.len() > MAX_VECTOR_SOURCE_BYTES.div_ceil(3) * 4 { + return Err(bad(UPDATE_VECTOR, "native source exceeds 16 MiB")); + } + let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| bad(UPDATE_VECTOR, "invalid native source encoding"))?; + validate_source(&bytes)?; + let family = source_extension(&bytes); + if !match extension { + "pdf" => family == Some("pdf"), + "eps" => family == Some("eps"), + "ai" => matches!(family, Some("pdf" | "eps")), + _ => false, + } { + return Err(bad(UPDATE_VECTOR, "saved bytes do not match the original vector format")); + } + bytes + } else { + // updateSvg replaces SVG contents; an AI, PDF or EPS source keeps its own format. + if photocraft_io::vector_preview::is_native_source(original) { + return Err(bad(UPDATE, "the original source is AI, PDF or EPS: use layer.smartObjects.updateVectorSource")); + } + svg.as_bytes().to_vec() + }; + if original == replacement_bytes { + return Ok(json!({"layer":id.0,"contentsId":contents.0,"fingerprint":expected,"updated":false})); + } + // Persisted smart filters are executable input too. A source replacement replays them + // without Session::execute, so authorize every filter that render_in will apply, across + // all shared instances, before parsing/rendering any replacement. Desktop sessions keep + // their normal user-selected file access; untrusted automation must not inherit it. + if let Some(authorize) = s.authorize { + for instance in &ids { + let sm = smart(doc, *instance)?; + if sm.filters_enabled { + for filter in sm.smart_filters.iter().filter(|filter| filter.visible) { + authorize(&filter.command, &filter.params)?; + } + } + } + } + if svg.is_empty() || svg.len() > MAX_VECTOR_SOURCE_BYTES || !photocraft_io::svg::is_svg(svg.as_bytes()) { + return Err(bad(UPDATE, "SVG contents must contain a valid SVG document within 16 MiB")); + } + // Save-back can intentionally remove the last drawable object. The bounded importer + // accepts an empty drawing so deleting the final object preserves transparency. A native + // save's SVG becomes the stored display preview, so it is checked as strictly as stored + // previews are on load and PSD export: an embedded image it can't read refuses it. + let import = if native { photocraft_io::svg::import_svg_preview_with_work } else { photocraft_io::svg::import_svg_bounded_with_work }; + let (imported, _) = import(&name, svg.as_bytes(), 4 * MAX_PIXELS).map_err(|error| bad(UPDATE, format!("can't import edited SVG contents: {error}")))?; + let size = imported.document.size; + if native { + // Shared instances normally borrow one immutable source and preview allocation. The + // check is pure in those bytes and the claimed hash, so run it once per identity while + // `doc` stays borrowed (no allocation can be freed and reused). Every instance's own + // frame, including a differing PSD fallback, is still compared. + let mut frames = std::collections::HashMap::new(); + let mut remaining_xml = frame_xml_bytes; + let mut exhausted = false; + for instance in &ids { + let frame = source_size_with(smart(doc, *instance)?, |preview, bytes| { + let key = (bytes.as_ptr() as usize, bytes.len(), preview.svg.as_ptr() as usize, preview.svg.len(), preview.source_hash); + *frames.entry(key).or_insert_with(|| { + let Some(remaining) = remaining_xml.checked_sub(preview.svg.len()) else { + exhausted = true; + return None; + }; + remaining_xml = remaining; + // Every shared source was compared with these actual bytes above. Reuse + // their verified digest, not the preview's untrusted claimed digest. + preview_frame_hash(preview, *original_hash.as_bytes()) + }) + }); + if exhausted { + return Err(bad(UPDATE_VECTOR, "original vector preview metadata exceeds the 64 MiB validation budget")); + } + if let Some((w, h)) = frame + && (f64::from(size.width) != f64::from((w as f32).max(1.0).round()) || f64::from(size.height) != f64::from((h as f32).max(1.0).round())) + { + return Err(bad(UPDATE_VECTOR, "the saved preview must keep the original source-pixel frame")); + } + } + } + if size.area() > MAX_PIXELS { + return Err(bad(UPDATE, "SVG contents exceed the 24 million pixel limit")); + } + let source = SmartSource::Embedded { + file_name: if native || photocraft_io::svg::is_svg(original) { name } else { "Artwork.svg".into() }, + bytes: Arc::new(replacement_bytes.clone()), + }; + let vector_preview = + native.then(|| photocraft_doc::VectorPreview { source_hash: *blake3::hash(&replacement_bytes).as_bytes(), svg: Arc::new(svg.as_bytes().to_vec()) }); + let mut replacements: Vec = Vec::with_capacity(ids.len()); + let mut pixels = 0u64; + for instance in ids { + let mut replacement = doc.layer(instance).cloned().ok_or(EngineError::NoLayer(instance))?; + let LayerContent::Smart(sm) = &mut replacement.content else { return Err(EngineError::NoLayer(instance)) }; + let bounds = placed_bounds(sm, size)?; + let extent = if sm.filters_enabled && sm.smart_filters.iter().any(|filter| filter.visible) { bounds.union(&doc.bounds()) } else { bounds }; + pixels = pixels.checked_add(extent.size().area()).ok_or_else(|| bad(UPDATE, "SVG placement cache budget exceeded"))?; + if pixels > MAX_PIXELS { + return Err(bad(UPDATE, "SVG placement caches exceed the 24 million pixel limit")); + } + sm.source = source.clone(); + sm.vector_preview = vector_preview.clone(); + replacements.push(replacement); + } + // Finish every fallible render before committing any document state. + for replacement in &mut replacements { + if let Some(preview) = vector_preview.as_ref() { + // This exact SVG was fully validated above. Render only a disposable display + // clone, avoiding a full native-payload hash for every shared instance. Only its + // cache is copied back: committed sources, previews and placement stay native. + let mut display = replacement.clone(); + let LayerContent::Smart(sm) = &mut display.content else { return Err(EngineError::NoLayer(display.id)) }; + sm.source = SmartSource::Embedded { file_name: "Artwork.svg".into(), bytes: preview.svg.clone() }; + sm.vector_preview = None; + if !crate::smart_cmds::refresh_layer(doc, &mut display)? { + return Err(bad(UPDATE, "SVG contents could not be rendered")); + } + let LayerContent::Smart(rendered) = display.content else { return Err(EngineError::NoLayer(display.id)) }; + let LayerContent::Smart(sm) = &mut replacement.content else { return Err(EngineError::NoLayer(replacement.id)) }; + sm.cache = rendered.cache; + } else if !crate::smart_cmds::refresh_layer(doc, replacement)? { + return Err(bad(UPDATE, "SVG contents could not be rendered")); + } + crate::smart_cmds::detach_psd(replacement); + } + s.edit(if native { "Update Vector Contents" } else { "Update SVG Contents" }, |doc, _| { + for replacement in replacements { + let id = replacement.id; + *doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = replacement; + } + Ok(()) + })?; + Ok(json!({"layer":id.0,"contentsId":contents.0,"fingerprint":blake3::hash(&replacement_bytes).to_hex().to_string(),"updated":true})) +} + +/// Menu-less export/update commands the shell uses for external editor save-back; none is +/// journaled. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: EXPORT, + label: "Export SVG Contents", + menu: &[], + shortcut: None, + params: r#"{"layer":id?} → {document,svg?|sourceBase64?,extension,sourceSize,fingerprint,contentsId}"#, + enabled, + run: export, + journal: false, + }, + CommandSpec { + id: UPDATE_VECTOR, + label: "Update Vector Contents", + menu: &[], + shortcut: None, + params: r#"{"document":id,"layer":id,"sourceBase64":"native bytes","extension":"ai|pdf|eps","previewSvg":"bounded display SVG","fingerprint":"original BLAKE3 hex","contentsId":id}"#, + enabled, + run: update_vector, + journal: false, + }, + CommandSpec { + id: UPDATE, + label: "Update SVG Contents", + menu: &[], + shortcut: None, + params: r#"{"document":id,"layer":id,"svg":"","fingerprint":"original BLAKE3 hex","contentsId":id} → {layer,contentsId,fingerprint,updated}"#, + enabled, + run: update, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/smart_svg_cmds/tests.rs b/crates/engine/src/cmd/smart_svg_cmds/tests.rs new file mode 100644 index 0000000000..18963ce374 --- /dev/null +++ b/crates/engine/src/cmd/smart_svg_cmds/tests.rs @@ -0,0 +1,701 @@ +use super::*; +use photocraft_doc::{DocId, Locks, SmartFilter}; +use photocraft_geom::Affine; + +const RED: &str = r#""#; +const BLUE: &str = r#""#; +const GREEN: &str = r#""#; + +fn session(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap(); + let result = crate::file_cmds::place_bytes(&mut s, "Artwork.svg", RED.as_bytes().to_vec(), None, &json!({"smartObject":true})).unwrap(); + let id = LayerId(result["layer"].as_u64().unwrap()); + (s, id) +} + +fn request(s: &mut Session, id: LayerId, svg: &str) -> Value { + let handoff = s.execute(EXPORT, json!({"layer":id.0})).unwrap(); + json!({"document":handoff["document"],"layer":id.0,"svg":svg,"fingerprint":handoff["fingerprint"],"contentsId":handoff["contentsId"]}) +} + +fn source(s: &Session, id: LayerId) -> Vec { + embedded(&s.active().unwrap().doc, smart(&s.active().unwrap().doc, id).unwrap()).unwrap().1.to_vec() +} + +fn fails_without_edit(s: &mut Session, params: Value) { + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + assert!(s.execute(UPDATE, params).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().revision, revision); +} + +#[test] +fn export_and_unchanged_save_have_no_history_step() { + let (mut s, id) = session(8); + let revision = s.active().unwrap().revision; + let params = request(&mut s, id, RED); + let result = s.execute(UPDATE, params).unwrap(); + assert_eq!(result["updated"], false); + assert_eq!(s.active().unwrap().revision, revision); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(id).is_none()); +} + +#[test] +fn deleting_last_svg_object_saves_transparency_and_undo_restores_source() { + let (mut s, id) = session(8); + let before = s.active().unwrap().doc.clone(); + let empty = r#""#; + let params = request(&mut s, id, empty); + s.execute(UPDATE, params).unwrap(); + assert_eq!(source(&s, id), empty.as_bytes()); + assert_eq!(smart(&s.active().unwrap().doc, id).unwrap().cache.as_ref().unwrap().pixel(15, 11), vec![0.0; 4]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); +} + +#[test] +fn valid_updates_repeat_at_all_depths_and_roundtrip_original_svg() { + for depth in [8, 16, 32] { + let (mut s, id) = session(depth); + let original = s.active().unwrap().doc.clone(); + let journal_len = s.journal.len(); + let params = request(&mut s, id, BLUE); + let result = s.execute(UPDATE, params).unwrap(); + assert_eq!(result["updated"], true); + let sm = smart(&s.active().unwrap().doc, id).unwrap(); + assert_eq!(sm.cache.as_ref().unwrap().format(), s.active().unwrap().doc.pixel_format()); + assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(source(&s, id), BLUE.as_bytes()); + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, original); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + let params = request(&mut s, id, GREEN); + s.execute(UPDATE, params).unwrap(); + assert_eq!(source(&s, id), GREEN.as_bytes()); + assert_eq!(s.journal.len(), journal_len, "vector saves must not accumulate full artwork in the replay journal"); + let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); + let loaded_source = &smart(&loaded, id).unwrap().source; + assert!(matches!(loaded_source, SmartSource::Embedded {file_name,bytes} if file_name == "Artwork.svg" && bytes.as_slice() == GREEN.as_bytes())); + } +} + +#[test] +fn shared_instances_keep_placement_filters_masks_and_one_undo_transaction() { + let (mut s, id) = session(8); + let duplicate = { + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let mut layer = doc.layer(id).unwrap().duplicate(); + let LayerContent::Smart(sm) = &mut layer.content else { panic!("smart object") }; + sm.transform = Affine::translate(2.0, 3.0); + sm.smart_filters.push(SmartFilter { + command: "filter.invert".into(), + params: json!({}), + blend: photocraft_color::BlendMode::Normal, + opacity: 0.5, + visible: true, + }); + sm.filters_enabled = false; + sm.filter_mask = Some(photocraft_doc::LayerMask { + surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8), + enabled: true, + linked: true, + density: 0.5, + feather: 1.0, + }); + let duplicate = layer.id; + doc.layers.push(layer); + duplicate + }; + let before = s.active().unwrap().doc.clone(); + let preserved = smart(&before, duplicate).unwrap().clone(); + let revision = s.active().unwrap().revision; + let params = request(&mut s, id, BLUE); + s.execute(UPDATE, params).unwrap(); + assert_eq!(s.active().unwrap().revision, revision + 1); + assert_eq!(source(&s, id), source(&s, duplicate)); + let edited = smart(&s.active().unwrap().doc, duplicate).unwrap(); + assert_eq!(edited.transform, preserved.transform); + assert_eq!(edited.smart_filters, preserved.smart_filters); + assert_eq!(edited.filter_mask, preserved.filter_mask); + assert_eq!(edited.filters_enabled, preserved.filters_enabled); + assert_eq!(edited.contents_id, preserved.contents_id); + assert_eq!(edited.cache.as_ref().unwrap().pixel(4, 4), vec![0.0, 0.0, 1.0, 1.0]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(source(&s, id), BLUE.as_bytes()); +} + +#[test] +fn invalid_oversized_and_external_resources_are_bounded_atomic() { + let (mut s, id) = session(8); + for svg in ["garbage", "", ""] + { + let params = request(&mut s, id, svg); + fails_without_edit(&mut s, params); + } + let huge = " ".repeat(super::MAX_VECTOR_SOURCE_BYTES + 1); + let params = request(&mut s, id, &huge); + fails_without_edit(&mut s, params); + // External images cannot trigger ambient filesystem or network access. + let external = r#""#; + let params = request(&mut s, id, external); + let before = s.active().unwrap().doc.clone(); + match s.execute(UPDATE, params) { + Err(_) => assert_eq!(s.active().unwrap().doc, before), + Ok(_) => assert_eq!(smart(&s.active().unwrap().doc, id).unwrap().cache.as_ref().unwrap().pixel(15, 11), vec![0.0; 4]), + } +} + +#[test] +fn stale_hash_content_identity_wrong_parent_deleted_and_locked_reject() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + for (key, value) in [("fingerprint", json!("0".repeat(64))), ("contentsId", json!(0)), ("document", json!(DocId::fresh().0)), ("layer", json!("wrong"))] { + let mut bad = params.clone(); + bad[key] = value; + fails_without_edit(&mut s, bad); + } + let mut missing = params.clone(); + missing.as_object_mut().unwrap().remove("document"); + fails_without_edit(&mut s, missing); + Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks = Locks { all: true, ..Default::default() }; + fails_without_edit(&mut s, params.clone()); + Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks = Default::default(); + s.execute(UPDATE, params.clone()).unwrap(); + fails_without_edit(&mut s, params.clone()); + Arc::make_mut(&mut s.active_mut().unwrap().doc).remove(id).unwrap(); + fails_without_edit(&mut s, params); +} + +#[test] +fn linked_sources_and_internal_editor_conflicts_reject_without_reads() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + let parent = s.active().unwrap().doc.id; + let contents_id = smart(&s.active().unwrap().doc, id).unwrap().contents_id; + s.smart_links.push(crate::smart_cmds::SmartLink { child: DocId::fresh(), parent, layer: id, contents_id }); + fails_without_edit(&mut s, params.clone()); + assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err()); + s.smart_links.clear(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") }; + sm.source = SmartSource::Linked { path: "C:/not-authorized/source.svg".into() }; + fails_without_edit(&mut s, params); + assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err()); +} + +#[test] +fn inherited_shared_instance_lock_and_unbounded_placement_reject() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + { + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let mut duplicate = doc.layer(id).unwrap().duplicate(); + duplicate.locks.pixels = true; + doc.layers.push(duplicate); + } + fails_without_edit(&mut s, params.clone()); + { + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + doc.layers.pop(); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") }; + sm.transform = Affine { m: [10000.0, 0.0, 0.0, 10000.0, 0.0, 0.0] }; + } + fails_without_edit(&mut s, params); +} + +#[test] +fn diverged_shared_source_is_never_overwritten() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let mut duplicate = doc.layer(id).unwrap().duplicate(); + let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart object") }; + sm.source = SmartSource::Embedded { file_name: "Other.svg".into(), bytes: Arc::new(GREEN.as_bytes().to_vec()) }; + doc.layers.push(duplicate); + fails_without_edit(&mut s, params); +} + +#[test] +fn failed_second_instance_render_does_not_commit_first_instance() { + let (mut s, id) = session(8); + { + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let mut duplicate = doc.layer(id).unwrap().duplicate(); + let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart object") }; + sm.transform = Affine { m: [100.0, 0.0, 0.0, 0.001, 0.0, 0.0] }; + doc.layers.push(duplicate); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") }; + sm.transform = Affine { m: [0.001, 0.0, 0.0, 0.001, 0.0, 0.0] }; + } + let svg = r#""#; + let params = request(&mut s, id, svg); + fails_without_edit(&mut s, params); +} + +#[test] +fn inherited_parent_lock_refuses_content_edits() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let layer = doc.remove(id).unwrap(); + let mut group = + Layer::new("Locked parent", LayerContent::Group(photocraft_doc::Group { children: vec![layer], expanded: true, artboard: None, frame_stroke: None })); + group.locks.pixels = true; + doc.layers.push(group); + fails_without_edit(&mut s, params); +} + +#[test] +fn perspective_and_warp_geometry_survive_an_update() { + for warp in [false, true] { + let (mut s, id) = session(8); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") }; + sm.perspective = Some([1.0, 0.0, 12.0, 0.0, 1.0, 9.0, 0.01, 0.0, 1.0]); + if warp { + sm.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 8.0, 6.0])); + } + let before = sm.clone(); + let params = request(&mut s, id, BLUE); + s.execute(UPDATE, params).unwrap(); + let after = smart(&s.active().unwrap().doc, id).unwrap(); + assert_eq!(after.warp, before.warp); + assert_eq!(after.perspective, before.perspective); + assert_eq!(after.transform, before.transform); + assert_eq!(source(&s, id), BLUE.as_bytes()); + } +} + +#[test] +fn perspective_crossing_zero_is_rejected_before_rendering() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") }; + sm.perspective = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, -0.5, 0.0, 1.0]); + fails_without_edit(&mut s, params); +} + +#[test] +fn bad_params_are_errors_for_both_commands() { + let (mut s, _) = session(8); + for p in [Value::Null, json!([]), json!({"layer":-1}), json!({"layer":"invalid"})] { + assert!(s.execute(EXPORT, p.clone()).is_err()); + fails_without_edit(&mut s, p); + } + fails_without_edit(&mut s, json!({})); +} + +/// Embedded PSD link-table vectors are exported in memory, then atomically replaced by SVG. +/// The original payload and shared identities survive undo and native-format save/reload. +#[test] +fn embedded_pdf_ai_and_eps_update_shared_instances_without_reading_linked_paths() { + for depth in [8, 16, 32] { + for (name, bytes) in [("Logo.ai", b"%PDF-1.6\nowned synthetic payload".as_slice()), ("Logo.eps", b"%!PS-Adobe-3.0 EPSF-3.0\n".as_slice())] { + let (mut s, id) = session(depth); + let duplicate; + { + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let payload = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: "owned-vector".into(), + file_name: name.into(), + bytes: bytes.to_vec(), + }); + doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(payload))); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") }; + sm.source = SmartSource::Linked { path: "owned-vector".into() }; + let layer = doc.layer(id).unwrap().duplicate(); + duplicate = layer.id; + doc.layers.push(layer); + } + let before = s.active().unwrap().doc.clone(); + let exported = s.execute(EXPORT, json!({"layer":id.0})).unwrap(); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(exported["sourceBase64"].as_str().unwrap()).unwrap(), bytes); + assert_eq!(exported["extension"], if name.ends_with("ai") { "ai" } else { "eps" }); + assert_eq!(s.active().unwrap().doc, before); + // updateSvg refuses native sources; they save back in their own format. + let svg_params = + json!({"document":exported["document"],"layer":id.0,"svg":BLUE,"fingerprint":exported["fingerprint"],"contentsId":exported["contentsId"]}); + fails_without_edit(&mut s, svg_params); + let changed = [bytes, b" edited"].concat(); + let params = json!({"document":exported["document"],"layer":id.0,"fingerprint":exported["fingerprint"],"contentsId":exported["contentsId"], + "sourceBase64":base64::engine::general_purpose::STANDARD.encode(&changed),"extension":exported["extension"],"previewSvg":BLUE}); + assert_eq!(s.execute(UPDATE_VECTOR, params.clone()).unwrap()["updated"], true); + for id in [id, duplicate] { + assert_eq!(source(&s, id), changed); + } + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + let before_stale = s.active().unwrap().doc.clone(); + assert!(s.execute(UPDATE_VECTOR, params).is_err(), "stale first-save fingerprint"); + assert_eq!(s.active().unwrap().doc, before_stale); + let serialized = photocraft_format::save_to_bytes(&after, &Default::default()).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&serialized).unwrap(), *after); + } + } +} + +#[test] +fn vector_handoff_never_reads_a_linked_disk_path_or_accepts_disguised_raster() { + let (mut s, id) = session(8); + for source in + [SmartSource::Linked { path: "/dev/zero".into() }, SmartSource::Embedded { file_name: "Logo.ai".into(), bytes: Arc::new(b"not a vector".to_vec()) }] + { + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") }; + sm.source = source; + let before = s.active().unwrap().doc.clone(); + assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn quick_mask_blocks_source_updates_without_mutating_the_document() { + let (mut s, id) = session(8); + let params = request(&mut s, id, BLUE); + s.execute("select.editInQuickMaskMode", json!({})).unwrap(); + fails_without_edit(&mut s, params); +} + +fn native_session(extension: &str, depth: u32) -> (Session, LayerId, Vec) { + let (mut s, id) = session(depth); + let bytes = if extension == "eps" { b"%!PS-Adobe-3.0 EPSF-3.0\noriginal".to_vec() } else { b"%PDF-1.7\noriginal".to_vec() }; + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") }; + sm.source = SmartSource::Embedded { file_name: format!("Logo.{extension}"), bytes: Arc::new(bytes.clone()) }; + (s, id, bytes) +} + +fn native_request(s: &mut Session, id: LayerId, ext: &str, bytes: &[u8]) -> Value { + let handoff = s.execute(EXPORT, json!({"layer":id.0})).unwrap(); + json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"sourceBase64":base64::engine::general_purpose::STANDARD.encode(bytes),"extension":ext,"previewSvg":BLUE}) +} + +#[test] +fn original_vector_formats_save_atomically_and_survive_undo_and_persistence() { + for ext in ["ai", "pdf", "eps"] { + for depth in [8, 16, 32] { + let (mut s, id, original) = native_session(ext, depth); + let before = s.active().unwrap().doc.clone(); + let changed = [original.as_slice(), b" edited"].concat(); + let params = native_request(&mut s, id, ext, &changed); + assert_eq!(s.execute(UPDATE_VECTOR, params.clone()).unwrap()["updated"], true); + let after = s.active().unwrap().doc.clone(); + let sm = smart(&after, id).unwrap(); + assert!(matches!(&sm.source,SmartSource::Embedded {file_name,bytes} if file_name == &format!("Logo.{ext}") && bytes.as_slice() == changed)); + assert_eq!(sm.vector_preview.as_ref().unwrap().source_hash, *blake3::hash(&changed).as_bytes()); + assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + let bundle = photocraft_format::save_to_bytes(&after, &Default::default()).unwrap(); + let reopened = photocraft_format::load_from_bytes(&bundle).unwrap(); + assert_eq!(smart(&reopened, id).unwrap(), sm); + let mut repeated = native_request(&mut s, id, ext, &changed); + let revision = s.active().unwrap().revision; + assert_eq!(s.execute(UPDATE_VECTOR, repeated.clone()).unwrap()["updated"], false); + assert_eq!(s.active().unwrap().revision, revision); + repeated["sourceBase64"] = json!(base64::engine::general_purpose::STANDARD.encode([changed.as_slice(), b" twice"].concat())); + s.execute(UPDATE_VECTOR, repeated).unwrap(); + assert!(s.execute(UPDATE_VECTOR, params).is_err(), "stale session must not overwrite later changes"); + } + } +} + +/// A save from an external editor whose SVG embeds an image PhotoCraft can't read (here a BMP) +/// is accepted with that image left out, as placing the same SVG is. +#[test] +fn saved_svg_with_an_unreadable_embedded_image_still_updates() { + let (mut s, id) = session(8); + let edited = r#""#; + let params = request(&mut s, id, edited); + assert_eq!(s.execute(UPDATE, params).unwrap()["updated"], true); + assert_eq!(source(&s, id), edited.as_bytes()); +} + +/// updateSvg replaces SVG contents only: it never turns an AI, PDF or EPS source into SVG. +#[test] +fn update_svg_refuses_native_sources() { + for ext in ["ai", "pdf", "eps"] { + let (mut s, id, original) = native_session(ext, 8); + let params = request(&mut s, id, BLUE); + fails_without_edit(&mut s, params); + assert_eq!(source(&s, id), original, "{ext}"); + } +} + +#[test] +fn native_saves_reject_wrong_formats_bad_previews_and_locks_without_editing() { + let (mut s, id, original) = native_session("ai", 8); + let changed = [original.as_slice(), b" edited"].concat(); + let valid = native_request(&mut s, id, "ai", &changed); + for (key, value) in [ + ("extension", json!("svg")), + ("sourceBase64", json!("bad base64!")), + ("sourceBase64", json!(base64::engine::general_purpose::STANDARD.encode(RED))), + ("previewSvg", json!("not an SVG")), + ("previewSvg", json!(r#""#)), + // The stored preview must draw completely, as PSD export and reopening require. + ( + "previewSvg", + json!( + r#""# + ), + ), + ("document", json!(0)), + ] { + let mut params = valid.clone(); + params[key] = value; + let before = s.active().unwrap().doc.clone(); + assert!(s.execute(UPDATE_VECTOR, params).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } + Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks.all = true; + assert!(s.execute(UPDATE_VECTOR, valid.clone()).is_err()); + Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks.all = false; + let mut duplicate = s.active().unwrap().doc.layer(id).unwrap().duplicate(); + let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart") }; + sm.transform = Affine::translate(2.0, 3.0); + let duplicate_id = Arc::make_mut(&mut s.active_mut().unwrap().doc).insert_above(Some(id), duplicate); + s.execute(UPDATE_VECTOR, valid).unwrap(); + assert_eq!(source(&s, duplicate_id), changed); + assert!( + Arc::ptr_eq( + &smart(&s.active().unwrap().doc, id).unwrap().vector_preview.as_ref().unwrap().svg, + &smart(&s.active().unwrap().doc, duplicate_id).unwrap().vector_preview.as_ref().unwrap().svg + ), + "shared instances must share the preview allocation" + ); + assert_eq!(smart(&s.active().unwrap().doc, duplicate_id).unwrap().transform, Affine::translate(2.0, 3.0)); +} + +#[test] +fn native_save_keeps_fractional_psd_frame_and_rejects_a_resized_preview() { + let (mut s, id, original) = native_session("ai", 8); + let spec = photocraft_io::smart_map::PlacedSpec { + idnt: "authored", + placed: "authored", + transform: Affine::IDENTITY, + perspective: None, + size: (8.3, 6.2), + dpi: 72.0, + warp: None, + filter_fx: None, + }; + let sold = photocraft_io::smart_map::sold_bytes(None, &spec, &mut vec![]); + let state = s.active_mut().unwrap(); + let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") }; + sm.psd_raw = Some(Arc::new(sold)); + let changed = [original.as_slice(), b" changed"].concat(); + let mut params = native_request(&mut s, id, "ai", &changed); + params["previewSvg"] = json!(BLUE.replace("width=\"8\" height=\"6\"", "width=\"8.3\" height=\"6.2\"")); + s.execute(UPDATE_VECTOR, params).unwrap(); + let before = s.active().unwrap().doc.clone(); + let mut params = native_request(&mut s, id, "ai", &[changed.as_slice(), b" next"].concat()); + params["previewSvg"] = json!(BLUE.replace("width=\"8\" height=\"6\"", "width=\"10\" height=\"6\"")); + assert!(s.execute(UPDATE_VECTOR, params).is_err()); + assert_eq!(s.active().unwrap().doc, before); +} + +#[test] +fn shared_native_instances_check_one_preview_identity_once_and_keep_per_instance_frames() { + let (mut s, id, original) = native_session("ai", 8); + let changed = [original.as_slice(), b" changed"].concat(); + let params = native_request(&mut s, id, "ai", &changed); + s.execute(UPDATE_VECTOR, params).unwrap(); + // 40 shared instances borrow one source and preview allocation; one more has an equal + // preview in its own allocation, so it is a separate identity and checked separately. + let template = s.active().unwrap().doc.layer(id).unwrap().clone(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + for _ in 0..40 { + doc.insert_above(Some(id), template.duplicate()); + } + let mut copied = template.duplicate(); + let LayerContent::Smart(sm) = &mut copied.content else { panic!("smart") }; + let preview = sm.vector_preview.as_mut().unwrap(); + preview.svg = Arc::new(preview.svg.as_ref().clone()); + doc.insert_above(Some(id), copied); + let next = [changed.as_slice(), b" next"].concat(); + let params = native_request(&mut s, id, "ai", &next); + let before = PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get); + s.execute(UPDATE_VECTOR, params).unwrap(); + assert_eq!(PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get) - before, 2, "one check per source and preview identity"); + // An instance without a preview still validates its own PSD frame. + let spec = photocraft_io::smart_map::PlacedSpec { + idnt: "authored", + placed: "authored", + transform: Affine::IDENTITY, + perspective: None, + size: (10.0, 6.0), + dpi: 72.0, + warp: None, + filter_fx: None, + }; + let sold = photocraft_io::smart_map::sold_bytes(None, &spec, &mut vec![]); + let mut framed = s.active().unwrap().doc.layer(id).unwrap().duplicate(); + let LayerContent::Smart(sm) = &mut framed.content else { panic!("smart") }; + sm.vector_preview = None; + sm.psd_raw = Some(Arc::new(sold)); + Arc::make_mut(&mut s.active_mut().unwrap().doc).insert_above(Some(id), framed); + let params = native_request(&mut s, id, "ai", &[next.as_slice(), b" again"].concat()); + let before = s.active().unwrap().doc.clone(); + let error = s.execute(UPDATE_VECTOR, params).unwrap_err().to_string(); + assert!(error.contains("source-pixel frame"), "a differing PSD frame on one shared instance must reject: {error}"); + assert_eq!(s.active().unwrap().doc, before); +} + +/// Distinct old previews share a byte budget even when destination surfaces are tiny. +#[test] +fn distinct_native_preview_metadata_exhausts_budget_without_editing_any_instance() { + let (mut s, id, original) = native_session("ai", 8); + let first_save = [original.as_slice(), b" first"].concat(); + let params = native_request(&mut s, id, "ai", &first_save); + s.execute(UPDATE_VECTOR, params).unwrap(); + let template = s.active().unwrap().doc.layer(id).unwrap().clone(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let mut preview_length = 0; + for n in 0..3 { + let mut instance = template.duplicate(); + let LayerContent::Smart(sm) = &mut instance.content else { panic!("smart") }; + let preview = sm.vector_preview.as_mut().unwrap(); + let svg = RED.replace("", &format!("")); + preview_length = svg.len(); + preview.svg = Arc::new(svg.into_bytes()); + if n == 0 { + let layer = doc.layer_mut(id).unwrap(); + layer.content = instance.content; + } else { + doc.insert_above(Some(id), instance); + } + } + let next = [first_save.as_slice(), b" next"].concat(); + let params = native_request(&mut s, id, "ai", &next); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let checks = PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get); + let error = update_contents_with_frame_budget(&mut s, ¶ms, true, preview_length * 2).unwrap_err().to_string(); + assert!(error.contains("validation budget"), "{error}"); + assert_eq!(PREVIEW_FRAME_CHECKS.with(std::cell::Cell::get) - checks, 2, "exhausted metadata is never parsed"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.execute(UPDATE_VECTOR, params).unwrap()["updated"], true); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); +} + +/// A large opaque native payload is shared data, not work repeated for every display cache. +#[test] +fn native_save_refreshes_shared_caches_without_rehashing_the_native_payload_per_instance() { + let (mut s, id, original) = native_session("eps", 8); + let template = s.active().unwrap().doc.layer(id).unwrap().clone(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + for _ in 1..100 { + doc.insert_above(Some(id), template.duplicate()); + } + let before = s.active().unwrap().doc.clone(); + let mut changed = vec![b' '; MAX_VECTOR_SOURCE_BYTES]; + changed[..original.len()].copy_from_slice(&original); + let params = native_request(&mut s, id, "eps", &changed); + let hashed = crate::smart_cmds::DISPLAY_NATIVE_HASH_BYTES.with(std::cell::Cell::get); + assert_eq!(s.execute(UPDATE_VECTOR, params).unwrap()["updated"], true); + assert_eq!(crate::smart_cmds::DISPLAY_NATIVE_HASH_BYTES.with(std::cell::Cell::get) - hashed, 0, "cache refresh must not hash 16 MiB for every copy"); + let after = s.active().unwrap().doc.clone(); + let target = smart(&after, id).unwrap(); + let SmartSource::Embedded { file_name, bytes } = &target.source else { panic!("native source lost") }; + assert_eq!(file_name, "Logo.eps"); + assert_eq!(bytes.as_slice(), changed.as_slice()); + assert_eq!(target.vector_preview.as_ref().unwrap().source_hash, *blake3::hash(&changed).as_bytes()); + for (_, _, layer) in after.walk() { + let LayerContent::Smart(sm) = &layer.content else { continue }; + let SmartSource::Embedded { bytes: shared, .. } = &sm.source else { panic!("native source lost") }; + assert!(Arc::ptr_eq(shared, bytes)); + assert_eq!(sm.contents_id, target.contents_id); + assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]); + let old = smart(&before, layer.id).unwrap(); + assert_eq!(sm.transform, old.transform); + assert_eq!(sm.smart_filters, old.smart_filters); + assert_eq!(sm.filter_mask, old.filter_mask); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); +} + +/// Explicit refreshes and warp frames of one shared native source verify its preview hash once, +/// not once per call; a replaced source is verified again and a forged preview still fails. +#[test] +fn native_preview_digest_is_reused_per_source_allocation_and_rechecked_after_replacement() { + let (mut s, id, original) = native_session("eps", 8); + let mut changed = vec![b' '; 1024 * 1024]; + changed[..original.len()].copy_from_slice(&original); + let params = native_request(&mut s, id, "eps", &changed); + s.execute(UPDATE_VECTOR, params).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut sm = smart(&doc, id).unwrap().clone(); + // A fresh allocation of the same bytes: nothing about it can be remembered yet. + sm.source = SmartSource::Embedded { file_name: "Logo.eps".into(), bytes: Arc::new(changed.clone()) }; + let hashed = || crate::smart_cmds::DISPLAY_NATIVE_HASH_BYTES.with(std::cell::Cell::get); + let start = hashed(); + let frame = crate::smart_cmds::source_box(&doc, &sm).unwrap(); + for _ in 0..3 { + assert_eq!(crate::smart_cmds::source_box(&doc, &sm), Some(frame)); + assert_eq!(crate::smart_cmds::render(&doc, &sm).unwrap().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0], "the verified preview is drawn"); + } + assert_eq!(hashed() - start, changed.len(), "one verification for one source allocation"); + // Different bytes in a new allocation never inherit the verified digest. + let mut other = changed.clone(); + other[original.len()] = b'%'; + sm.source = SmartSource::Embedded { file_name: "Logo.eps".into(), bytes: Arc::new(other) }; + let before = hashed(); + assert!(crate::smart_cmds::render(&doc, &sm).is_err(), "a stale preview must not draw a different source"); + assert_eq!(hashed() - before, changed.len()); +} + +#[test] +fn native_source_replacement_never_uses_a_forged_or_stale_preview() { + let (mut s, id, original) = native_session("ai", 8); + let changed = [original.as_slice(), b" changed"].concat(); + let params = native_request(&mut s, id, "ai", &changed); + s.execute(UPDATE_VECTOR, params).unwrap(); + let mut sm = smart(&s.active().unwrap().doc, id).unwrap().clone(); + sm.vector_preview.as_mut().unwrap().svg = Arc::new(b"not SVG".to_vec()); + assert!(crate::smart_cmds::render(&s.active().unwrap().doc, &sm).is_err()); + sm.vector_preview.as_mut().unwrap().svg = Arc::new(BLUE.as_bytes().to_vec()); + sm.source = SmartSource::Embedded { file_name: "Changed.ai".into(), bytes: Arc::new(b"%PDF-1.7\nnot the same source".to_vec()) }; + assert!(crate::smart_cmds::render(&s.active().unwrap().doc, &sm).is_err()); +} + +#[test] +fn native_update_authorizes_filters_on_every_shared_instance_before_editing() { + let (mut s, id, original) = native_session("ai", 8); + let mut duplicate = s.active().unwrap().doc.layer(id).unwrap().duplicate(); + let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart") }; + sm.smart_filters.push(SmartFilter { + command: "image.adjustments.colorLookup".into(), + params: json!({"file":"/not-authorized/private.cube"}), + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + Arc::make_mut(&mut s.active_mut().unwrap().doc).insert_above(Some(id), duplicate); + let params = native_request(&mut s, id, "ai", &[original.as_slice(), b" changed"].concat()); + s.authorize = + Some(|command, _| if command == "image.adjustments.colorLookup" { Err(EngineError::Other("denied stored native filter".into())) } else { Ok(()) }); + let before = s.active().unwrap().doc.clone(); + assert!(s.execute(UPDATE_VECTOR, params).unwrap_err().to_string().contains("denied stored native filter")); + assert_eq!(s.active().unwrap().doc, before); +} diff --git a/crates/engine/src/cmd/smartselect_cmds.rs b/crates/engine/src/cmd/smartselect_cmds.rs index b8beea5a80..adbf9b5f46 100644 --- a/crates/engine/src/cmd/smartselect_cmds.rs +++ b/crates/engine/src/cmd/smartselect_cmds.rs @@ -49,7 +49,7 @@ struct CompositeSampler<'a>(&'a Document); impl Sampler for CompositeSampler<'_> { fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { - photocraft_compose::render(self.0, r).px + photocraft_compose::render(self.0, r).into_px() } } @@ -303,7 +303,8 @@ impl SubjectCache { } fn select_subject(s: &mut Session, p: &Value) -> Result { - let all_layers = b(p, "sampleAllLayers", true); + // The selected layer's pixels; the composite only when no pixel layer is selected. + let all_layers = b(p, "sampleAllLayers", false); let d = s.active().ok_or(EngineError::NoDocument)?; // What `with_doc_sampler` samples: the active layer's pixels, else the composite. let target = d.active_layer.filter(|id| !all_layers && d.doc.layer(*id).and_then(Layer::surface).is_some()); @@ -543,7 +544,7 @@ pub fn specs() -> Vec { "select.subject", "Subject", ["Select"], - r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + r##"{"sampleAllLayers":bool=false,"mode":"replace|add|subtract|intersect"="replace"} (samples the selected layer; the composite when no pixel layer is selected)"##, has_doc, select_subject ), diff --git a/crates/engine/src/cmd/smartselect_cmds/tests.rs b/crates/engine/src/cmd/smartselect_cmds/tests.rs index 8cb19565b5..5fecac7969 100644 --- a/crates/engine/src/cmd/smartselect_cmds/tests.rs +++ b/crates/engine/src/cmd/smartselect_cmds/tests.rs @@ -251,6 +251,23 @@ fn select_subject_centred_object() { assert!(s.active().unwrap().doc.selection.is_none()); } +/// Select Subject looks at the selected layer only; with no layer selected it looks at the +/// whole image. +#[test] +fn select_subject_samples_the_selected_layer_else_the_composite() { + let (mut s, inside) = disc(8); + // A layer above holding only a small square in the corner: the disc is not on it. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.pencil", json!({"points": [[10, 10], [30, 10]], "size": 3, "color": "#000000"})).unwrap(); + s.execute("select.subject", json!({})).unwrap(); + assert!(cov(&s, 100, 80) < 0.5, "the disc on the layer below was selected"); + s.execute("select.deselect", json!({})).unwrap(); + // No layer selected: the whole image, so the disc is found. + s.execute("select.deselectLayers", json!({})).unwrap(); + s.execute("select.subject", json!({})).unwrap(); + assert!(sel_iou(&s, 200, 160, &inside) > 0.9); +} + /// #2865: a repeat on unchanged pixels reuses the last mask (an identical selection, even /// after Deselect); sampling something else or a pixel edit computes it again. #[test] @@ -268,8 +285,8 @@ fn select_subject_repeat_reuses_the_mask_until_pixels_change() { s.execute("select.subject", json!({})).unwrap(); assert!(std::sync::Arc::ptr_eq(&mask, &cached(&s)), "the repeat recomputed the mask"); assert_eq!(sel(&s), first); - // Another sampled target (the layer rather than the composite) is a miss. - s.execute("select.subject", json!({"sampleAllLayers": false})).unwrap(); + // Another sampled target (the composite rather than the layer) is a miss. + s.execute("select.subject", json!({"sampleAllLayers": true})).unwrap(); assert!(!std::sync::Arc::ptr_eq(&mask, &cached(&s))); // A pixel edit drops the entry; the next run recomputes it. s.edit("paint", |doc, _| { @@ -434,8 +451,15 @@ fn refine_edge_outputs_to_a_new_document() { #[test] fn refine_edge_shift_and_contrast() { let mut s = blurred_edge_selected(); - s.execute("select.refineEdge", json!({"shiftEdge": 100})).unwrap(); - assert!(cov(&s, 42, 20) > 0.99); + // Shift Edge leaves a hard selection where it is (selected x < 40), at either end. + for shift in [100, -100] { + s.execute("select.refineEdge", json!({"shiftEdge": shift})).unwrap(); + assert!(cov(&s, 39, 20) > 0.99 && cov(&s, 40, 20) < 0.01, "Shift Edge {shift}% moved a hard edge"); + s.execute("edit.undo", json!({})).unwrap(); + } + // A feathered one moves: -100% pulls its half-covered point inside the old edge. + s.execute("select.refineEdge", json!({"feather": 4, "shiftEdge": -100})).unwrap(); + assert!(cov(&s, 37, 20) < 0.5 && cov(&s, 30, 20) > 0.99); s.execute("edit.undo", json!({})).unwrap(); s.execute("select.refineEdge", json!({"feather": 8, "contrast": 100})).unwrap(); assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0); @@ -643,8 +667,9 @@ fn refine_edge_edits_match_the_tools_and_reach_the_outputs() { // Refinement runs on the edited selection: Shift Edge contracts the brushed bar too. let mut s = two_regions(8); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); - s.execute("select.refineEdge", json!({"shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})).unwrap(); - assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 88) < 0.5 && cov(&s, 60, 72) < 0.5, "the 20 px bar shrank"); + s.execute("select.refineEdge", json!({"feather": 4, "shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})) + .unwrap(); + assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 87) < 0.5 && cov(&s, 60, 72) < 0.5, "the feathered 20 px bar did not shrink at -100% Shift Edge"); // Layer mask and new-layer outputs take the edited selection. let mut s = blurred_edge_selected(); let r = s.execute("select.refineEdge", json!({"output": "layerMask", "edits": [{"tool": "invert"}]})).unwrap(); diff --git a/crates/engine/src/cmd/solid_fill_cmds.rs b/crates/engine/src/cmd/solid_fill_cmds.rs index efa43f6116..5763868d25 100644 --- a/crates/engine/src/cmd/solid_fill_cmds.rs +++ b/crates/engine/src/cmd/solid_fill_cmds.rs @@ -4,7 +4,7 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::{Fill, LayerContent}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param}; +use crate::commands::{CommandSpec, check_pixels_unlocked, cmyk_param, layer_param}; use crate::{EngineError, Result, Session}; pub const SET: &str = "layer.fill.solidColor.set"; @@ -45,8 +45,8 @@ pub fn color_param(value: &Value, original: Color, mode: ColorMode) -> Result Result { - if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) { - return Err(bad("params must be an object and layer an unsigned id")); + if !p.is_object() { + return Err(bad("params must be an object")); } let st = s.active().ok_or(EngineError::NoDocument)?; if let Some(document) = p.get("document") @@ -57,7 +57,13 @@ fn set(s: &mut Session, p: &Value) -> Result { let id = layer_param(s, p)?; let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let LayerContent::Fill(Fill::Solid(original)) = layer.content else { return Err(bad("the target must be a Solid Color Fill layer")) }; - let color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?; + let mut color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?; + // The inks the colour was picked as: a CMYK document keeps them exactly (#8568). + if let Some(k) = cmyk_param(SET, p, "cmyk")? + && st.doc.mode == ColorMode::Cmyk + { + color = Color { mode: ColorMode::Cmyk, c: k, alpha: color.alpha }; + } if color != original { check_pixels_unlocked(&st.doc, id)?; if st.doc.effective_locks(id).transparency && color.alpha != original.alpha { @@ -78,7 +84,7 @@ pub fn specs() -> Vec { label: "Solid Color…", menu: &[], shortcut: None, - params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32}}; RGB values without alpha preserve it; stored Color preserves its model and precision"##, + params: r##"{"layer":id|name? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##, enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()), run: set, journal: true, @@ -89,6 +95,57 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn named_nonactive_solid_fill_can_be_edited() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let name = s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().name.clone(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + assert_eq!(s.execute(SET, json!({"layer": name, "color": "#ff0000"})).unwrap()["layer"], id); + } + + /// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly + /// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to + /// through the document profile. RGB documents ignore the inks. + #[test] + fn cmyk_inks_survive_solid_color_fills_in_a_cmyk_document() { + let inks = [0.5, 0.5, 0.5, 1.0]; + let hex = |k: [f32; 4]| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(k).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + format!("#{r:02x}{g:02x}{b:02x}") + }; + let stored = |s: &Session, id: u64| match s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Fill(Fill::Solid(c)) => c, + _ => panic!("not a solid fill"), + }; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let id = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks), "cmyk": inks})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 }); + // The sRGB stand-in alone does drift: that is what the inks prevent. + let drifted = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks)})).unwrap()["layer"].as_u64().unwrap(); + assert_ne!(stored(&s, drifted).c, inks); + // Editing keeps the new inks too. + let edit = [0.2, 0.3, 0.4, 0.5]; + s.execute(SET, json!({"layer": id, "color": hex(edit), "cmyk": edit})).unwrap(); + assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: edit, alpha: 1.0 }); + // A foreground picked as CMYK seeds a new fill with its inks, as fills do (#4196). + s.execute("tools.setColors", json!({"foreground": hex(edit), "foregroundCmyk": edit})).unwrap(); + let seeded = s.execute("layer.newFillLayer.solidColor", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&s, seeded).c, edit); + for bad in [json!([0.1, 0.2, 0.3]), json!("50,50,50,100"), json!([0.1, 0.2, 0.3, f64::NAN])] { + assert!(s.execute("layer.newFillLayer.solidColor", json!({"cmyk": bad})).is_err(), "{bad}"); + assert!(s.execute(SET, json!({"layer": id, "color": "#000000", "cmyk": bad})).is_err(), "{bad}"); + } + // An RGB document takes the colour, not the inks. + let mut rgb = Session::new(); + rgb.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let id = rgb.execute("layer.newFillLayer.solidColor", json!({"color": "#336699", "cmyk": inks})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(stored(&rgb, id).mode, ColorMode::Rgb); + } + #[test] fn edit_preserves_layer_and_masks_at_all_depths_and_undo_redo() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/cmd/stamp_cmds.rs b/crates/engine/src/cmd/stamp_cmds.rs index b5c6a21120..1f96831f7e 100644 --- a/crates/engine/src/cmd/stamp_cmds.rs +++ b/crates/engine/src/cmd/stamp_cmds.rs @@ -82,11 +82,16 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result { Ok(json!({ "layer": id.0 })) } -/// A plain, unlocked pixel layer with no pixels, mask or clipping: safe to stamp into. -fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool { +/// A plain, unlocked pixel layer with no pixels, mask, clipping or video: safe to stamp into. +/// Pixels it has only through a visible default pixel (no tiles stored) count as pixels. +pub(crate) fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool { let Some(l) = doc.layer(id) else { return false }; let locks = doc.effective_locks(id); - matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty()) + // Only the default pixel's alpha counts: a mode or profile conversion keeps it transparent but + // gives its colour channels other values (no ink in CMYK, a neutral a/b in Lab). + matches!(&l.content, LayerContent::Raster(s) + if s.content_bounds().is_empty() && s.format().alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0)) + && l.video.is_none() && l.visible && l.mask.is_none() && l.vector_mask.is_none() @@ -277,6 +282,45 @@ mod tests { assert_eq!(ids(&s).len(), 5); } + /// A layer opaque only through its default pixel (no tiles, as KRA imports it) and a blank + /// video layer are not empty: the stamp goes into a new layer above them. + #[test] + fn stamp_visible_keeps_default_pixel_and_video_layers() { + let (mut s, ..) = session(8); + s.execute("layer.new.layer", json!({"name": "Opaque"})).unwrap(); + let opaque = s.active().unwrap().active_layer.unwrap(); + let l = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(opaque).unwrap(); + let fmt = l.surface().unwrap().format(); + l.content = LayerContent::Raster(photocraft_raster::Surface::with_default(fmt, &[0.0, 1.0, 0.0, 1.0])); + let video = LayerId(s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap()["layer"].as_u64().unwrap()); + for old in [opaque, video] { + s.execute("layer.select", json!({"layer": old.0})).unwrap(); + let before = s.active().unwrap().doc.layer(old).unwrap().clone(); + let n = ids(&s).len(); + let r = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_ne!(r["layer"].as_u64(), Some(old.0), "a new layer above {old:?}"); + assert_eq!(ids(&s).len(), n + 1); + assert_eq!(*s.active().unwrap().doc.layer(old).unwrap(), before, "{old:?} keeps its pixels and video"); + } + } + + /// An empty layer stays empty through a mode change, though its default pixel's colour + /// bytes change: the stamp still goes into it. + #[test] + fn stamp_visible_fills_an_empty_layer_after_a_mode_change() { + for mode in ["image.mode.cmyk", "image.mode.lab"] { + let (mut s, ..) = session(8); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + s.execute(mode, json!({})).unwrap(); + s.execute("layer.select", json!({"layer": empty.0})).unwrap(); + let n = ids(&s).len(); + let r = s.execute("layer.stampVisible", json!({})).unwrap(); + assert_eq!(r["layer"].as_u64(), Some(empty.0), "{mode}"); + assert_eq!(ids(&s).len(), n, "{mode}"); + } + } + #[test] fn stamp_visible_respects_opacity_blend_and_groups() { let (mut s, bg, _, blue) = session(8); diff --git a/crates/engine/src/cmd/timeline_cmds.rs b/crates/engine/src/cmd/timeline_cmds.rs index 7893ac8d8f..9b6e0623cc 100644 --- a/crates/engine/src/cmd/timeline_cmds.rs +++ b/crates/engine/src/cmd/timeline_cmds.rs @@ -31,6 +31,7 @@ fn has_timeline(s: &Session) -> std::result::Result<(), String> { /// Mutate the document timeline without a history step (navigation / metadata). fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let generation = photocraft_raster::spill::read_error_generation(); let mut doc = (*st.doc).clone(); crate::video_cmds::store(&mut doc); f(&mut doc.timeline); @@ -38,6 +39,8 @@ fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Resu t.clamp(); } crate::video_cmds::sync(&mut doc); + // Storing and syncing video frames bypasses edit_with: apply the same scratch guard. + photocraft_raster::spill::check_since(generation).and_then(|()| doc.scratch_health()).map_err(EngineError::Other)?; st.doc = Arc::new(doc); st.revision += 1; info(s) diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index d1b9690312..48aac9eedd 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -265,6 +265,66 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } +/// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but +/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or +/// only the Background besides it), that layer; the active layer when nothing else is. +pub fn selection_targets(st: &crate::DocState) -> Vec { + targets_of(&st.doc, &st.selected_layers(), st.active_layer) +} + +/// [`selection_targets`] for the layers `selected`, `active` the active one: what an `edit.transform` +/// without `layer` or `layers` moves, so a recorded step can drop a `layers` list that matches it. +pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option) -> Vec { + if selected.len() < 2 { + return active.into_iter().collect(); + } + let ids: Vec = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + if ids.is_empty() { active.into_iter().collect() } else { ids } +} + +/// The layers one box over `targets` moves: `targets` with the layers linked to them, as the Move +/// tool takes them along. In Photoshop transforming one linked layer transforms the whole link +/// group (#11217). A linked Background joins only when it is a target itself: `targets_of` +/// leaves it out of a box over other layers, and a link doesn't bring it back in. +pub fn box_targets(doc: &Document, targets: &[LayerId]) -> Vec { + let mut ids = crate::layer_multi_cmds::move_targets(doc, targets); + ids.retain(|id| targets.contains(id) || !doc.is_background(*id)); + ids +} + +/// The `layers` param: the layers one box transforms together (at most 10000, each in the +/// document), without those a listed group carries. `None` when it isn't given. +fn layers_param(doc: &Document, p: &Value) -> Result>> { + let a = match p.get("layers") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => a, + Some(v) => return Err(bad(format!("`layers` is a non-empty list of at most 10000 layer ids, got {v}"))), + }; + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad(format!("`layers` holds layer ids, got {v}")))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + Ok(Some(crate::layer_multi_cmds::top_level(doc, &ids))) +} + +/// The box Free Transform opens on the layers `ids` together: their frames ([`transform_frame`]) +/// joined, as Show Transform Controls draws it. Empty when none of them has anything to move. +pub fn transform_frame_of(doc: &Document, ids: &[LayerId]) -> Rect { + ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_frame(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b)) +} + +/// [`transform_bounds`] of the layers `ids` together. +pub fn transform_bounds_of(doc: &Document, ids: &[LayerId]) -> Rect { + ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_bounds(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b)) +} + /// 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 @@ -524,8 +584,12 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface for (i, (l, r)) in lp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { let (x, y) = (src.x0 + (i % w) as i32, src.y0 + (i / w) as i32); let k = sel.sample_channel(x, y, 0); - l[n - 1] *= k; - r[n - 1] *= 1.0 - k; + // The selection lifts its share of a pixel's coverage, up to all of it, as Move does + // (`float_cmds::cut_piece`, #7655): a layer's own transparency loaded as the selection + // lifts it whole and leaves no ghost behind (#5595). An opaque pixel splits as k and 1 − k. + let a = l[n - 1]; + l[n - 1] = a.min(k); + r[n - 1] = (a - k).max(0.0); // Likewise an unselected pixel leaves nothing in the lifted surface, so its bounds are the // selected pixels' (what a stretch repeats at the edge, #3773). if l[n - 1] <= 0.0 { @@ -591,6 +655,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0), }; if all_opaque { + drop(tb); return Some((tc, top_tile)); } let all_transparent = match fmt.sample { @@ -608,6 +673,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { 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 { + drop(tb); return Some((tc, top_tile)); } } @@ -690,7 +756,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { /// The relative form (`scaleX`, `scaleY` in percent, `rotate` in degrees clockwise, `dx`, `dy` /// in pixels), as Photoshop records a Free Transform: scale and rotate about the frame's -/// centre, then move. `None` when none of them is given. +/// named reference point (the centre by default), then move. `None` when none of them is given. fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { let num = |k: &str| -> Result> { match p.get(k) { @@ -706,7 +772,7 @@ fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { if sx.abs() < 1e-6 || sy.abs() < 1e-6 { return Err(bad("`scaleX` and `scaleY` must not be 0")); } - let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let [cx, cy] = crate::select_extra_cmds::reference_point("edit.transform", p, rect)?; let (sin, cos) = rot.unwrap_or(0.0).to_radians().sin_cos(); // (x, y) → translate(-c) → scale → rotate → translate(c + d). let (a, b, c, d) = (sx * cos, sx * sin, -sy * sin, sy * cos); @@ -734,6 +800,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { let mut p = if p.is_object() { p.clone() } else { json!({}) }; if p.get("target").is_none() && p.get("layer").is_none() + && p.get("layers").is_none() && let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s)) { m.extend(t); @@ -744,23 +811,48 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; - let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer); - let lone = lone_target(&st.doc, id, p)?; + let many = layers_param(&st.doc, p)?; + if many.is_some() + && (!matches!(p.get("layer"), None | Some(Value::Null)) || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels)) + { + return Err(bad("`layers` moves layers: pass it without `layer` or a mask or channel `target`")); + } + let explicit = crate::commands::explicit_layer(s, p)?; + let id = explicit.or(st.active_layer); + let lone = if many.is_some() { None } else { lone_target(&st.doc, id, p)? }; let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { (_, true) => None, (Some(id), false) => Some(id), + (None, false) if many.is_some() => None, (None, false) => return Err(EngineError::Other("no active layer".into())), }; + let listed = many.is_some(); + // The layers the box moves: those listed, the one named, or else the layer selection (#6501). + let mut ids: Vec = match (many, explicit) { + (Some(ids), _) => ids, + _ if lone.is_some() => Vec::new(), + (None, Some(id)) => vec![id], + (None, None) => selection_targets(st), + }; + if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { + return Err(EngineError::NoLayer(*missing)); + } + // The named layer or the selection takes its linked layers along, as the Move tool does: in + // Photoshop transforming one linked layer transforms the whole link group (#11217), but not a + // linked Background it didn't target ([`box_targets`]). A caller listing `layers` gets + // exactly those. + if !listed { + ids = box_targets(&st.doc, &ids); + } let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { let v: Vec = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); [v[0], v[1], v[2], v[3]] } _ => { - let b = match (lone, id) { - (Some(surf), _) => target_bounds(&st.doc, surf), - (None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?), - (None, None) => Rect::EMPTY, + let b = match lone { + Some(surf) => target_bounds(&st.doc, surf), + None => transform_bounds_of(&st.doc, &ids), }; if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); @@ -790,22 +882,25 @@ fn transform(s: &mut Session, p: &Value) -> Result { (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); let lone = lone.is_some(); + let layers: Vec = ids.iter().map(|id| id.0).collect(); let p = p.clone(); // A background job when started with `Session::start` (#1015): the warp of a large layer runs // on a worker with progress, and can be cancelled. crate::jobs::edit_job( s, "Free Transform", - move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)), - move |()| json!({"rect": rect}), + move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)), + move |()| json!({"rect": rect, "layers": layers}), ) } -/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. +/// The edit of [`transform`]: the layers `ids` (or the lone mask or channel of `id`) and the +/// selection outline. #[allow(clippy::too_many_arguments)] fn transform_doc( doc: &mut Document, id: Option, + ids: &[LayerId], p: &Value, lone: bool, h: &Homography, @@ -834,21 +929,35 @@ fn transform_doc( } return Ok(()); } - let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let is_group = doc.layer(id).is_some_and(Layer::is_group); - let group = group_locks(doc, id); - // Photoshop turns the Background into a normal layer before transforming it. - crate::extra_cmds::background_to_layer(doc, id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let layer_sel = if is_group { None } else { sel.as_ref() }; - check_layer_budget(layer_sel, l, h)?; - let taken = if h.is_identity() { Vec::new() } else { crate::smart_cmds::take_caches(std::slice::from_mut(l)) }; - apply_transform(layer_sel, group, l, h, affine, interp, ctl)?; + if ids.is_empty() { + return Err(EngineError::Other("no active layer".into())); + } + // A group moves whole: the selection doesn't limit what is inside it. + let layer_sel = |l: &Layer| if l.is_group() { None } else { sel.as_ref() }; + // Every layer is priced before any is built, so one that doesn't fit leaves all as they were. + for &id in ids { + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + check_layer_budget(layer_sel(l), l, h)?; + } + let mut taken = Vec::new(); + for &id in ids { + let group = group_locks(doc, id); + // Photoshop turns the Background into a normal layer before transforming it. + crate::extra_cmds::background_to_layer(doc, id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let doc_sel = layer_sel(l); + if !h.is_identity() { + taken.extend(crate::smart_cmds::take_caches(std::slice::from_mut(l))); + } + apply_transform(doc_sel, group, l, h, affine, interp, ctl)?; + } if !h.is_identity() { // Type layers re-render from their new transform. let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - refresh_text(&snapshot, l); + for &id in ids { + if let Some(l) = doc.layer_mut(id) { + refresh_text(&snapshot, l); + } } crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?; // The selection outline moves with the pixels. @@ -865,7 +974,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, @@ -874,3 +983,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod multi_tests; diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs new file mode 100644 index 0000000000..329775e7a3 --- /dev/null +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -0,0 +1,434 @@ +//! Free Transform of several selected layers by one box (#6501). + +use super::*; + +/// A 200 x 200 document: a red 40 x 20 block on "Paint" (x 10..50, y 10..30) and a type layer +/// over it, both selected, the type layer active (as after creating it and ⌘-clicking Paint). +fn two_layers() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let paint = LayerId(s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(paint).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 50, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let text = LayerId(s.execute("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + (s, paint, text) +} + +fn bounds(s: &Session, id: LayerId) -> Rect { + let st = s.active().unwrap(); + transform_bounds(&st.doc, st.doc.layer(id).unwrap()) +} + +fn text_matrix(s: &Session, id: LayerId) -> [f64; 6] { + let st = s.active().unwrap(); + let LayerContent::Text(t) = &st.doc.layer(id).unwrap().content else { panic!("not a type layer") }; + t.transform.m +} + +#[test] +fn free_transform_moves_every_selected_layer_in_one_step() { + let (mut s, paint, text) = two_layers(); + let (p0, t0, m0) = (bounds(&s, paint), bounds(&s, text), text_matrix(&s, text)); + let steps = s.active().unwrap().history.past_len(); + // Before #6501 only the active layer moved, whichever kind it was. + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0, text.0])); + assert_eq!(bounds(&s, paint), p0.translate(30, 40)); + assert_eq!(bounds(&s, text), t0.translate(30, 40)); + let st = s.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "one history step"); + let history = s.execute("document.inspect", json!({})).unwrap()["history"].clone(); + assert_eq!(history.as_array().unwrap().last(), Some(&json!("Free Transform"))); + // The type stays editable: it keeps the transform instead of pixels. + assert_eq!(text_matrix(&s, text)[4..], [m0[4] + 30.0, m0[5] + 40.0]); + s.undo(); + assert_eq!((bounds(&s, paint), bounds(&s, text)), (p0, t0), "and one Undo takes both back"); +} + +#[test] +fn the_default_frame_is_the_selected_layers_together() { + let (mut s, paint, text) = two_layers(); + let both = bounds(&s, paint).union(&bounds(&s, text)); + let m0 = text_matrix(&s, text); + // 200 % about the joined frame's centre: each layer scales about that point, not its own. + let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200})).unwrap(); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from)); + let (cx, cy) = (f64::from(both.x0 + both.x1) / 2.0, f64::from(both.y0 + both.y1) / 2.0); + let p = bounds(&s, paint); + assert_eq!((f64::from(p.x0), f64::from(p.y0)), (2.0 * 10.0 - cx, 2.0 * 10.0 - cy)); + let m = text_matrix(&s, text); + assert!((m[0] - 2.0).abs() < 1e-9 && (m[3] - 2.0).abs() < 1e-9, "{m:?}"); + assert!((m[4] - (2.0 * m0[4] - cx)).abs() < 1e-9 && (m[5] - (2.0 * m0[5] - cy)).abs() < 1e-9, "{m:?}"); +} + +#[test] +fn an_explicit_layer_still_transforms_that_layer_alone() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + let r = s.execute("edit.transform", json!({"layer": paint.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0])); + assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30)); + assert_eq!(bounds(&s, text), t0); +} + +#[test] +fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + // The group and the layer in it: the layer moves once, with its group. + let r = s.execute("edit.transform", json!({"layers": [paint.0, group.0], "matrix": [1, 0, 0, 1, 7, 0]})).unwrap(); + assert_eq!(r["layers"], json!([group.0])); + assert_eq!(bounds(&s, paint), Rect::new(17, 10, 57, 30)); + assert_eq!(bounds(&s, text), t0, "not listed, not moved"); +} + +#[test] +fn the_background_stays_out_of_a_selection_of_several() { + let (mut s, paint, text) = two_layers(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0, text.0])); + let st = s.active().unwrap(); + assert!(st.doc.is_background(bg), "still the Background"); + assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); +} + +#[test] +fn a_selection_limits_the_pixels_of_every_selected_layer() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + // The left half of the block: only those pixels of Paint move; the type moves whole (#2630). + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 100]})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(paint).unwrap().surface().unwrap(); + assert_eq!(surf.content_bounds(), Rect::new(10, 10, 50, 130)); + assert_eq!(surf.sample_channel(20, 15, 3), 0.0, "the selected part left"); + assert_eq!(surf.sample_channel(40, 15, 3), 1.0, "the rest stayed"); + assert_eq!(surf.sample_channel(20, 115, 3), 1.0, "and landed below"); + assert_eq!(bounds(&s, text), t0.translate(0, 100)); +} + +#[test] +fn a_locked_layer_among_them_refuses_the_whole_transform() { + let (mut s, paint, text) = two_layers(); + s.execute("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap(); + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even the unlocked layer"); +} + +#[test] +fn one_layer_past_the_pixel_budget_leaves_every_layer_as_it_was() { + let (mut s, paint, _) = two_layers(); + // A 1 px dot, beside the 40 px block: only the block, stretched, outgrows the test budget. + let dot = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.edit("dot", |doc, _| { + doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 40, 11, 41), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let k = 4e4; + assert!(s.execute("edit.transform", json!({"layers": [dot.0, paint.0], "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision); +} + +#[test] +fn distort_with_type_among_the_layers_is_refused() { + let (mut s, _, _) = two_layers(); + let before = s.active().unwrap().doc.clone(); + let quad = json!({"rect": [0, 0, 100, 100], "quad": [[0, 0], [100, 0], [120, 100], [-20, 100]]}); + assert!(s.execute("edit.transform", quad).is_err()); + assert!(s.active().unwrap().doc == before); +} + +#[test] +fn transform_again_repeats_on_every_selected_layer() { + let (mut s, paint, text) = two_layers(); + let (p0, t0) = (bounds(&s, paint), bounds(&s, text)); + let both = p0.union(&t0); + let r = [both.x0, both.y0, both.x1, both.y1].map(f64::from); + let quad = [[r[0] + 10.0, r[1]], [r[2] + 10.0, r[1]], [r[2] + 10.0, r[3]], [r[0] + 10.0, r[3]]]; + // Nearest neighbour: no resampling fringe to blur the bounds. + s.execute("edit.transform", json!({"rect": r, "quad": quad, "interpolation": "nearest"})).unwrap(); + s.execute("edit.transform.again", json!({})).unwrap(); + assert_eq!(bounds(&s, paint), p0.translate(20, 0)); + assert_eq!(bounds(&s, text), t0.translate(20, 0)); +} + +#[test] +fn transform_again_on_a_copy_copies_and_moves_the_active_layer_only() { + let (mut s, paint, text) = two_layers(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap(); + let (p1, t1) = (bounds(&s, paint), bounds(&s, text)); + s.execute("edit.transform.againCopy", json!({})).unwrap(); + let st = s.active().unwrap(); + let copy = st.active_layer.unwrap(); + assert!(copy != paint && copy != text); + assert_eq!(bounds(&s, copy), t1.translate(10, 0), "the copy of the active type layer moved on"); + assert_eq!((bounds(&s, paint), bounds(&s, text)), (p1, t1), "the originals stayed"); +} + +#[test] +fn a_null_layer_beside_layers_counts_as_absent() { + let (mut s, paint, text) = two_layers(); + let t0 = bounds(&s, text); + let r = s.execute("edit.transform", json!({"layers": [paint.0], "layer": null, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(r["layers"], json!([paint.0])); + assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30)); + assert_eq!(bounds(&s, text), t0); +} + +#[test] +fn bad_layers_params_are_errors_not_panics() { + let (mut s, paint, _) = two_layers(); + let before = s.active().unwrap().doc.clone(); + let m = json!([1, 0, 0, 1, 5, 0]); + for p in [ + json!({"layers": [], "matrix": m}), + json!({"layers": "all", "matrix": m}), + json!({"layers": [paint.0, "x"], "matrix": m}), + json!({"layers": [-1], "matrix": m}), + json!({"layers": [987_654], "matrix": m}), + json!({"layers": [u64::MAX], "matrix": m}), + json!({"layers": (0..10_001).collect::>(), "matrix": m}), + json!({"layers": [paint.0], "layer": paint.0, "matrix": m}), + json!({"layers": [paint.0], "target": "quickMask", "matrix": m}), + json!({"layers": [paint.0], "target": {"channel": 0}, "matrix": m}), + ] { + assert!(s.execute("edit.transform", p.clone()).is_err(), "{p}"); + } + assert!(s.active().unwrap().doc == before); +} + +#[test] +fn straighten_layer_rotates_every_selected_layer() { + // Photoshop's Straighten Layer turns all the selected layers, not just the active one; it + // goes through `edit.transform` without a layer, so it takes the selection like Free Transform. + let (mut s, paint, text) = two_layers(); + let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text)); + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert_eq!(r["cropped"], false); + assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too"); + assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + assert!(s.undo()); + assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back"); + + // With one layer selected, only that layer turns. + s.execute("layer.select", json!({"layer": paint.0})).unwrap(); + s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap(); + s.execute("image.analysis.straightenLayer", json!({})).unwrap(); + assert!(bounds(&s, paint) != p0); + assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put"); +} + +#[test] +fn a_recorded_transform_of_the_selected_layers_replays_on_the_selection_in_another_document() { + let (mut s, paint, text) = two_layers(); + s.execute("actions.record", json!({"name": "Move both"})).unwrap(); + // What the canvas box commits: the ids it opened on, listed (#6501). + s.execute("edit.transform", json!({"layers": [paint.0, text.0], "matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let step = s.actions.list[0].steps[0].1.clone(); + assert!(step.get("layers").is_none(), "the step plays on the selection, not on these ids: {step}"); + // Another document, its ids shifted by an extra layer under the two that get selected. + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let spare = LayerId(s.execute("layer.new.layer", json!({"name": "Spare"})).unwrap()["layer"].as_u64().unwrap()); + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + assert_ne!((a, b), (paint, text), "the ids differ from the recorded ones"); + s.edit("paint", |doc, _| { + for (id, x) in [(spare, 100), (a, 10), (b, 60)] { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[0.0, 0.0, 1.0, 1.0]); + } + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let (a0, b0, spare0) = (bounds(&s, a), bounds(&s, b), bounds(&s, spare)); + let r = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a0.translate(30, 40), b0.translate(30, 40)), "the selected layers moved"); + assert_eq!(bounds(&s, spare), spare0, "the unselected layer stayed"); +} + +/// A 200 x 200 document with three 20 x 20 blocks, "A" (x 10..30), "B" (x 60..80) and "C" +/// (x 120..140), all at y 10..30. A and B are linked; C isn't. Only A is selected, and active. +fn linked_layers() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let mut ids = Vec::new(); + for (name, x) in [("A", 10), ("B", 60), ("C", 120)] { + let id = LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + ids.push(id); + } + let (a, b, c) = (ids[0], ids[1], ids[2]); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + (s, a, b, c) +} + +fn moved_layers(r: &Value) -> Vec { + let mut ids: Vec = serde_json::from_value(r["layers"].clone()).unwrap(); + ids.sort_unstable(); + ids +} + +/// `got` is `want` give or take a pixel of resampling on each edge. +fn assert_near(got: Rect, want: Rect) { + let close = [(got.x0, want.x0), (got.y0, want.y0), (got.x1, want.x1), (got.y1, want.y1)].iter().all(|(g, w)| (g - w).abs() <= 1); + assert!(close, "{got:?} is not {want:?}"); +} + +/// A 200 % scale of A and B about the centre (45, 20) of their joined frame. +fn scaled_pair(s: &Session, a: LayerId, b: LayerId) { + assert_near(bounds(s, a), Rect::new(-25, 0, 15, 40)); + assert_near(bounds(s, b), Rect::new(75, 0, 115, 40)); +} + +#[test] +fn transforming_one_linked_layer_transforms_its_link_group() { + // Before #11217 only A scaled; Photoshop scales every layer linked to it, by the same box. + let (mut s, a, b, c) = linked_layers(); + let c0 = bounds(&s, c); + let r = s.execute("edit.transform", json!({"layer": a.0, "scaleX": 200, "scaleY": 200, "interpolation": "nearest"})).unwrap(); + assert_eq!(moved_layers(&r), [a.0, b.0]); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [10.0, 10.0, 80.0, 30.0], "the frame covers the link group"); + scaled_pair(&s, a, b); + assert_eq!(bounds(&s, c), c0, "an unlinked layer stays put"); + s.undo(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(10, 10, 30, 30), Rect::new(60, 10, 80, 30)), "one Undo takes both back"); +} + +#[test] +fn transforming_the_selected_linked_layer_transforms_its_link_group() { + let (mut s, a, b, c) = linked_layers(); + let c0 = bounds(&s, c); + // No `layer`: the layer selection (A alone) is the target. + let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200, "interpolation": "nearest"})).unwrap(); + assert_eq!(moved_layers(&r), [a.0, b.0]); + scaled_pair(&s, a, b); + assert_eq!(bounds(&s, c), c0); +} + +#[test] +fn listed_layers_do_not_pull_in_their_linked_layers() { + let (mut s, a, b, _) = linked_layers(); + let r = s.execute("edit.transform", json!({"layers": [a.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!(moved_layers(&r), [a.0]); + assert_eq!(bounds(&s, a), Rect::new(15, 10, 35, 30)); + assert_eq!(bounds(&s, b), Rect::new(60, 10, 80, 30), "listed layers are exactly the ones moved"); +} + +#[test] +fn moving_a_linked_layer_by_free_transform_moves_each_linked_layer_once() { + let (mut s, a, b, c) = linked_layers(); + let c0 = bounds(&s, c); + // A and B both selected, and linked: each moves by the offset once, not once per link. + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap(); + assert_eq!(moved_layers(&r), [a.0, b.0]); + assert_eq!(bounds(&s, a), Rect::new(40, 50, 60, 70)); + assert_eq!(bounds(&s, b), Rect::new(90, 50, 110, 70)); + assert_eq!(bounds(&s, c), c0); + // And from A alone, through the active layer. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("edit.transform", json!({"dx": -30, "dy": -40})).unwrap(); + assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(10, 10, 30, 30), Rect::new(60, 10, 80, 30))); +} + +#[test] +fn a_locked_linked_layer_refuses_the_transform_of_its_link_group() { + // As with several selected layers: one that can't move leaves every layer as it was. + let (mut s, a, b, _) = linked_layers(); + s.execute("layer.setProps", json!({"layer": b.0, "locks": {"position": true}})).unwrap(); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision); +} + +/// The Background (white, 200 x 200) linked to "A", a red block at x 10..30, y 10..30; A is +/// selected, and active. +fn background_linked_to_a_layer() -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("paint", |doc, _| { + doc.layer_mut(a).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": bg.0})).unwrap(); + s.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + (s, bg, a) +} + +#[test] +fn a_linked_background_stays_out_of_a_transform_of_its_partner() { + // #11217 review: the link group widening must not pull in a Background the transform didn't + // target, any more than a selection of several does. + let (mut s, bg, a) = background_linked_to_a_layer(); + for p in [json!({"matrix": [1, 0, 0, 1, 5, 0]}), json!({"layer": a.0, "matrix": [1, 0, 0, 1, 5, 0]})] { + let r = s.execute("edit.transform", p.clone()).unwrap(); + assert_eq!(moved_layers(&r), [a.0], "{p}"); + let st = s.active().unwrap(); + assert!(st.doc.is_background(bg), "{p}: still the Background"); + assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200), "{p}: and where it was"); + } + assert_eq!(bounds(&s, a), Rect::new(20, 10, 40, 30), "A moved both times"); +} + +#[test] +fn a_transform_of_a_linked_background_still_transforms_it() { + let (mut s, bg, a) = background_linked_to_a_layer(); + let r = s.execute("edit.transform", json!({"layer": bg.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert!(moved_layers(&r).contains(&bg.0), "the targeted Background moves: {r}"); + let st = s.active().unwrap(); + assert!(!st.doc.is_background(bg), "turned into a normal layer first, as in Photoshop"); + assert_eq!(bounds(&s, a), Rect::new(15, 10, 35, 30), "with the layer linked to it"); +} + +#[test] +fn a_recorded_transform_of_other_layers_than_the_selection_keeps_their_ids() { + let (mut s, paint, _) = two_layers(); + s.execute("actions.record", json!({"name": "Paint only"})).unwrap(); + s.execute("edit.transform", json!({"layers": [paint.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps[0].1["layers"], json!([paint.0])); +} diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index f8ff204257..00274b7072 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -345,7 +345,6 @@ fn cached_gradient_fill() -> Session { let mut s = session(); s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); s.edit("cache", |doc, active| { let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); let l = doc.layer_mut(active.unwrap()).unwrap(); @@ -367,7 +366,7 @@ fn off_from_fresh(s: &Session) -> f32 { let st = s.active().unwrap(); let mut fresh: Document = (*st.doc).clone(); fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None; - let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px); + let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).into_px(), photocraft_compose::render(&fresh, fresh.bounds()).into_px()); a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) } @@ -908,3 +907,64 @@ fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() { assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame"); } } + +/// Free Transform of a layer's own transparency (⌘-click its thumbnail) takes all of it and leaves +/// no ghost where it was, as Photoshop does and as Move already did (#5595). Photoshop 2025, an +/// anti-aliased 60 px ellipse moved 150 px with Free Transform: no pixel left behind. +#[test] +fn transforming_a_layers_own_transparency_leaves_no_ghost() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 300, "height": 120, "background": "transparent"})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 20, "width": 60, "height": 60, "ellipse": true, "antiAlias": true})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#cc2222"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap(); + let alpha_sum = |s: &Session, area: Rect| -> f32 { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(area).as_chunks::<4>().0.iter().map(|p| p[3]).sum() + }; + let (old, new) = (Rect::new(0, 0, 110, 120), Rect::new(110, 0, 300, 120)); + let before = alpha_sum(&s, old); + assert!(before > 2000.0, "the ellipse: {before}"); + s.execute("edit.transform", json!({"dx": 150, "dy": 0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let left = surf.read_region(old).as_chunks::<4>().0.iter().filter(|p| p[3] > 0.0).count(); + assert_eq!(left, 0, "pixels left behind at the old spot"); + let moved = alpha_sum(&s, new); + assert!((moved - before).abs() < 1.0, "all the coverage moved: {before} → {moved}"); + // An opaque pixel half selected still splits half and half. + let mut s = session(); + s.edit("half", |doc, _| { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(10, 10, 30, 20), &[0.5]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"dx": 40, "dy": 0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let rest = surf.read_region(Rect::new(15, 15, 16, 16)); + assert!((rest[3] - 0.5).abs() < 0.01, "half the opaque pixel stays: {rest:?}"); +} + +#[test] +fn split_selected_lifts_up_to_the_selection_and_leaves_the_rest() { + // One half-covered pixel under a partial selection: the lift takes min(a, k), the rest keeps + // max(a − k, 0), and the two add back up to the pixel's coverage. + let gray_f32 = PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false); + let a = 0.5_f32; + for k in [0.25_f32, 0.5, 0.75] { + let mut surf = Surface::new(PixelFormat::RGBA32F); + surf.fill_rect(Rect::new(3, 4, 4, 5), &[1.0, 0.0, 0.0, a]); + let mut sel = Surface::new(gray_f32); + sel.fill_rect(Rect::new(3, 4, 4, 5), &[k]); + let (lifted, rest) = split_selected(&surf, &sel).unwrap(); + let (l, r) = (lifted.sample_channel(3, 4, 3), rest.sample_channel(3, 4, 3)); + assert!((l - a.min(k)).abs() < 1e-6, "k = {k}: lifted {l}, want {}", a.min(k)); + assert!((r - (a - k).max(0.0)).abs() < 1e-6, "k = {k}: left {r}, want {}", (a - k).max(0.0)); + assert!((l + r - a).abs() < 1e-6, "k = {k}: lifted {l} + left {r} != {a}"); + } +} diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index 1e103cf89f..6206a17fc9 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -24,10 +24,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { } fn layer_id(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(LayerId(id)), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), - } + crate::commands::layer_param(s, p) } fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -694,8 +691,8 @@ fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m } Ok((r, damage)) })?; - if let Some(st) = s.active_mut() { - st.last_damage = damage; + if let Some(d) = damage { + s.hint_damage(d); } Ok(r) } @@ -764,8 +761,8 @@ pub(crate) fn with_text_targets( } Ok(damage) })?; - if let Some(st) = s.active_mut() { - st.last_damage = damage; + if let Some(d) = damage { + s.hint_damage(d); } Ok(ids) } diff --git a/crates/engine/src/cmd/type_extra_cmds.rs b/crates/engine/src/cmd/type_extra_cmds.rs index d8c11a52d6..566912b21c 100644 --- a/crates/engine/src/cmd/type_extra_cmds.rs +++ b/crates/engine/src/cmd/type_extra_cmds.rs @@ -1,6 +1,7 @@ //! The Type menu: anti-aliasing, orientation, point ⇄ paragraph conversion, Create Work Path, -//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles, Paste Lorem Ipsum, Update All -//! Text Layers, missing-font replacement and the default type styles. +//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles (with the Numerals submenu), +//! Paste Lorem Ipsum, Update All Text Layers, missing-font replacement and the default type +//! styles. //! //! Everything edits the typed [`TextLayer`] model and re-renders through `type_cmds::refresh`, //! so the PSD `TySh` data (and with it warp, anti-aliasing and the OpenType flags) is regenerated @@ -502,6 +503,88 @@ fn warp_text(s: &mut Session, p: &Value) -> Result { // ---------- OpenType toggles ---------- +/// OpenType figure tags Type › OpenType › Numerals clears or sets (`lnum`/`onum`/`pnum`/`tnum`). +pub const NUMERAL_FEATURE_TAGS: [&str; 4] = ["lnum", "onum", "pnum", "tnum"]; + +/// One item of Type › OpenType › Numerals (Adobe figure-style radio set). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NumeralsStyle { + Default, + Lining, + Oldstyle, + Proportional, + Tabular, + ProportionalLining, + TabularLining, + ProportionalOldstyle, + TabularOldstyle, +} + +impl NumeralsStyle { + /// Command-id suffix after `type.openType.numerals.`. + pub const fn id(self) -> &'static str { + match self { + Self::Default => "default", + Self::Lining => "lining", + Self::Oldstyle => "oldstyle", + Self::Proportional => "proportional", + Self::Tabular => "tabular", + Self::ProportionalLining => "proportionalLining", + Self::TabularLining => "tabularLining", + Self::ProportionalOldstyle => "proportionalOldstyle", + Self::TabularOldstyle => "tabularOldstyle", + } + } + + /// Menu label for this numerals style. + pub const fn label(self) -> &'static str { + match self { + Self::Default => "Default", + Self::Lining => "Lining", + Self::Oldstyle => "Oldstyle", + Self::Proportional => "Proportional", + Self::Tabular => "Tabular", + Self::ProportionalLining => "Proportional Lining", + Self::TabularLining => "Tabular Lining", + Self::ProportionalOldstyle => "Proportional Oldstyle", + Self::TabularOldstyle => "Tabular Oldstyle", + } + } + + /// Parse a `type.openType.numerals.*` suffix. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|style| style.id() == id) + } + + /// Every Numerals menu item, in menu order. + pub const ALL: [Self; 9] = [ + Self::Default, + Self::Lining, + Self::Oldstyle, + Self::Proportional, + Self::Tabular, + Self::ProportionalLining, + Self::TabularLining, + Self::ProportionalOldstyle, + Self::TabularOldstyle, + ]; + + /// OpenType tags this style turns on (the other [`NUMERAL_FEATURE_TAGS`] are cleared). + pub const fn tags(self) -> &'static [&'static str] { + match self { + Self::Default => &[], + Self::Lining => &["lnum"], + Self::Oldstyle => &["onum"], + Self::Proportional => &["pnum"], + Self::Tabular => &["tnum"], + Self::ProportionalLining => &["pnum", "lnum"], + Self::TabularLining => &["tnum", "lnum"], + Self::ProportionalOldstyle => &["pnum", "onum"], + Self::TabularOldstyle => &["tnum", "onum"], + } + } +} + /// Is the OpenType toggle `tag` on for a character style? (`liga`/`dlig` have dedicated fields.) pub fn feature_on(st: &photocraft_doc::text::CharStyle, tag: &str) -> bool { match tag { @@ -511,6 +594,36 @@ pub fn feature_on(st: &photocraft_doc::text::CharStyle, tag: &str) -> bool { } } +/// The character style at character `ci` of `t`: its run's, or past the end the last run's. +fn style_at(t: &TextLayer, ci: usize) -> Option { + let at = byte_index(&t.text, ci); + let mut end = 0usize; + let mut last = None; + for run in t.char_runs() { + end = end.saturating_add(run.len); + if at < end { + return Some(run.style); + } + last = Some(run.style); + } + last +} + +/// Is the OpenType toggle `tag` on at character `ci` of `t`? Past the end it reads the last run. +pub fn feature_on_at(t: &TextLayer, ci: usize, tag: &str) -> bool { + style_at(t, ci).is_some_and(|st| feature_on(&st, tag)) +} + +/// Which Numerals menu item matches `st`'s figure tags? `None` when the mix is not a menu item. +pub fn numerals_style_of(st: &photocraft_doc::text::CharStyle) -> Option { + NumeralsStyle::ALL.into_iter().find(|style| NUMERAL_FEATURE_TAGS.iter().all(|tag| feature_on(st, tag) == style.tags().contains(tag))) +} + +/// Numerals style at character `ci` of `t` (same run pick as [`feature_on_at`]). +pub fn numerals_style_at(t: &TextLayer, ci: usize) -> Option { + style_at(t, ci).and_then(|st| numerals_style_of(&st)) +} + fn set_feature(st: &mut photocraft_doc::text::CharStyle, tag: &str, on: bool) { match tag { "liga" => st.ligatures = on, @@ -524,13 +637,37 @@ fn set_feature(st: &mut photocraft_doc::text::CharStyle, tag: &str, on: bool) { } } +fn apply_numerals(st: &mut photocraft_doc::text::CharStyle, style: NumeralsStyle) { + let on = style.tags(); + for tag in NUMERAL_FEATURE_TAGS { + set_feature(st, tag, on.contains(&tag)); + } +} + +fn set_numerals(s: &mut Session, p: &Value, style: NumeralsStyle) -> Result { + with_text_targets(s, p, "Change OpenType Numerals", |t, (a, b)| { + style_range(t, a.min(b), a.max(b), &|st| apply_numerals(st, style)); + Ok(()) + })?; + Ok(json!({"numerals": style.id()})) +} + fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result { let ids = format_targets(s, p)?; let st = s.active().ok_or(EngineError::NoDocument)?; let representative = st.active_layer.filter(|id| ids.contains(id)).or_else(|| ids.first().copied()); - let cur = representative - .and_then(|id| st.doc.layer(id)) - .is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if t.char_runs().first().is_some_and(|r| feature_on(&r.style, tag)))); + // The toggle direction follows the style where the edit starts: the range start on a single + // layer, else the first character. + let start_char = if p.get("layers").is_none() || p.get("layer").is_some() { + p.get("range").and_then(Value::as_array).filter(|r| r.len() == 2).map_or(0, |r| { + let a = r.first().and_then(Value::as_u64).unwrap_or(0); + let b = r.get(1).and_then(Value::as_u64).unwrap_or(a); + usize::try_from(a.min(b)).unwrap_or(usize::MAX) + }) + } else { + 0 + }; + let cur = representative.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if feature_on_at(t, start_char, tag))); let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur); with_text_targets(s, p, "Change OpenType Feature", |t, (a, b)| { style_range(t, a.min(b), a.max(b), &|st| set_feature(st, tag, on)); @@ -770,6 +907,41 @@ pub fn specs() -> Vec { spec!("type.openType.ornaments", "Ornaments", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ornm")), spec!("type.openType.ordinals", "Ordinals", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ordn")), spec!("type.openType.fractions", "Fractions", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "frac")), + spec!("type.openType.numerals.default", "Default", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals( + s, + p, + NumeralsStyle::Default + )), + spec!("type.openType.numerals.lining", "Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| { + set_numerals(s, p, NumeralsStyle::Lining) + }), + spec!("type.openType.numerals.oldstyle", "Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| { + set_numerals(s, p, NumeralsStyle::Oldstyle) + }), + spec!("type.openType.numerals.proportional", "Proportional", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals( + s, + p, + NumeralsStyle::Proportional + )), + spec!("type.openType.numerals.tabular", "Tabular", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| { + set_numerals(s, p, NumeralsStyle::Tabular) + }), + spec!("type.openType.numerals.proportionalLining", "Proportional Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| { + set_numerals(s, p, NumeralsStyle::ProportionalLining) + }), + spec!("type.openType.numerals.tabularLining", "Tabular Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals( + s, + p, + NumeralsStyle::TabularLining + )), + spec!("type.openType.numerals.proportionalOldstyle", "Proportional Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| { + set_numerals(s, p, NumeralsStyle::ProportionalOldstyle) + }), + spec!("type.openType.numerals.tabularOldstyle", "Tabular Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals( + s, + p, + NumeralsStyle::TabularOldstyle + )), spec!("type.createWorkPath", "Create Work Path", &["Type"], LP, has_text, create_work_path), spec!("type.convertToShape", "Convert to Shape", &["Type"], LP, has_text, convert_to_shape), spec!("type.rasterizeTypeLayer", "Rasterize Type Layer", &["Type"], LP, has_text, |s, p| s.execute("type.rasterize", p.clone())), diff --git a/crates/engine/src/cmd/type_extra_cmds/tests.rs b/crates/engine/src/cmd/type_extra_cmds/tests.rs index 8ae504cfa1..4008f82001 100644 --- a/crates/engine/src/cmd/type_extra_cmds/tests.rs +++ b/crates/engine/src/cmd/type_extra_cmds/tests.rs @@ -243,6 +243,138 @@ fn opentype_toggles_store_features() { assert!(feature_on(&back.char_runs()[0].style, "swsh") && !back.char_runs()[0].style.ligatures); } +/// Is `tag` on at character `ci` of an ASCII text layer? +fn on_at(s: &Session, id: LayerId, ci: usize, tag: &str) -> bool { + let mut end = 0; + text(s, id) + .char_runs() + .iter() + .find(|r| { + end += r.len; + ci < end + }) + .is_some_and(|r| feature_on(&r.style, tag)) +} + +#[test] +fn numerals_are_registered_with_their_menu_path() { + for style in NumeralsStyle::ALL { + let spec = crate::commands::find(&format!("type.openType.numerals.{}", style.id())).unwrap(); + assert_eq!(spec.label, style.label()); + assert_eq!(spec.menu, ["Type", "OpenType", "Numerals"]); + } + assert!(crate::commands::find("type.openType.numerals.unknown").is_none()); +} + +#[test] +fn numerals_replace_figure_tags_and_keep_other_features() { + let mut s = session(8); + let id = text_layer(&mut s, "012345"); + let before = text(&s, id).char_runs()[0].style.clone(); + s.execute("type.openType.fractions", json!({"on": true})).unwrap(); + s.execute("type.openType.numerals.tabularLining", json!({})).unwrap(); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::TabularLining)); + assert!(on_at(&s, id, 0, "tnum") && on_at(&s, id, 0, "lnum") && !on_at(&s, id, 0, "onum") && !on_at(&s, id, 0, "pnum")); + assert!(feature_on(&text(&s, id).char_runs()[0].style, "frac"), "non-figure features stay"); + s.execute("type.openType.numerals.proportionalOldstyle", json!({})).unwrap(); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::ProportionalOldstyle)); + assert!(on_at(&s, id, 0, "pnum") && on_at(&s, id, 0, "onum") && !on_at(&s, id, 0, "tnum") && !on_at(&s, id, 0, "lnum")); + s.execute("type.openType.numerals.default", json!({})).unwrap(); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Default)); + for tag in NUMERAL_FEATURE_TAGS { + assert!(!on_at(&s, id, 0, tag), "{tag}"); + } + let st = text(&s, id).char_runs()[0].style.clone(); + assert!(st.ligatures, "liga untouched"); + assert_eq!((st.size_pt, st.font_family.clone(), st.color), (before.size_pt, before.font_family, before.color)); +} + +#[test] +fn numerals_range_edits_only_those_characters() { + let mut s = session(8); + let id = text_layer(&mut s, "abcdef"); + s.execute("type.openType.numerals.lining", json!({"range": [2, 5]})).unwrap(); + let on: Vec = (0..6).map(|i| on_at(&s, id, i, "lnum")).collect(); + assert_eq!(on, [false, false, true, true, true, false]); + assert_eq!(numerals_style_at(text(&s, id), 2), Some(NumeralsStyle::Lining)); + assert_eq!(numerals_style_at(text(&s, id), 0), Some(NumeralsStyle::Default)); + s.execute("type.openType.numerals.tabular", json!({"range": [2, 5]})).unwrap(); + assert!(on_at(&s, id, 3, "tnum") && !on_at(&s, id, 3, "lnum")); + assert!(!on_at(&s, id, 0, "tnum")); +} + +#[test] +fn numerals_apply_to_every_listed_layer() { + let mut s = session(8); + let a = text_layer(&mut s, "one"); + let b = text_layer(&mut s, "two"); + s.execute("type.openType.numerals.oldstyle", json!({"layers": [a.0, b.0]})).unwrap(); + assert!(on_at(&s, a, 0, "onum") && on_at(&s, b, 2, "onum")); + s.execute("type.openType.numerals.default", json!({"layers": [a.0, b.0]})).unwrap(); + assert!(!on_at(&s, a, 0, "onum") && !on_at(&s, b, 0, "onum")); +} + +#[test] +fn numerals_are_one_undo_step_each() { + let mut s = session(8); + let id = text_layer(&mut s, "123"); + s.execute("type.openType.numerals.lining", json!({})).unwrap(); + s.execute("type.openType.numerals.tabularOldstyle", json!({})).unwrap(); + assert!(s.undo()); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Lining)); + assert!(s.undo()); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Default)); + assert!(s.redo()); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Lining)); +} + +#[test] +fn numerals_survive_a_pcraft_round_trip() { + let mut s = session(8); + let id = text_layer(&mut s, "Il36"); + s.execute("type.openType.numerals.proportionalLining", json!({})).unwrap(); + let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + let doc = photocraft_format::load_from_bytes(&bytes).unwrap(); + let Some(LayerContent::Text(t)) = doc.layer(id).map(|l| &l.content) else { panic!("text layer lost") }; + assert_eq!(numerals_style_of(&t.char_runs()[0].style), Some(NumeralsStyle::ProportionalLining)); +} + +#[test] +fn numerals_fail_gracefully_on_bad_params() { + let mut s = session(8); + let bg = s.active().unwrap().active_layer.unwrap(); + let id = text_layer(&mut s, "abc"); + let cmd = "type.openType.numerals.tabularLining"; + assert!(s.execute(cmd, json!({"layer": 987_654})).is_err(), "unknown layer"); + assert!(s.execute(cmd, json!({"layer": bg.0})).is_err(), "not a type layer"); + assert!(s.execute(cmd, json!({"layers": ["x"]})).is_err()); + s.execute(cmd, json!({"range": [5, 2]})).unwrap(); + s.execute(cmd, json!({"range": [50, 90]})).unwrap(); + s.execute(cmd, json!({"range": [u64::MAX, 0]})).unwrap(); + s.execute(cmd, json!({"range": "all"})).unwrap(); + assert!(s.execute(cmd, json!({"layer": id.0, "range": [0, 1]})).is_ok()); + assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::TabularLining)); +} + +#[test] +fn numerals_tabular_changes_inter_digit_widths() { + let mut s = session(8); + let r = s.execute("type.create", json!({"x": 20, "y": 70, "text": "1111111111", "font": "Inter", "size": 36, "color": "#000000"})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + let width = |s: &Session| { + let t = text(s, id).clone(); + let mut e = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + let layout = e.layout(&t, 72.0); + layout.lines.first().map_or(0.0, |line| line.x1 - line.x0) + }; + let plain = width(&s); + s.execute("type.openType.numerals.tabular", json!({})).unwrap(); + let tab = width(&s); + assert!((tab - plain).abs() > 0.5, "tnum changes Inter's digit advances: {plain} → {tab}"); + s.execute("type.openType.numerals.default", json!({})).unwrap(); + assert!((width(&s) - plain).abs() < 0.5, "default restores the advances"); +} + #[test] fn lorem_update_fonts_and_default_styles() { let mut s = session(16); diff --git a/crates/engine/src/cmd/type_mask_cmds.rs b/crates/engine/src/cmd/type_mask_cmds.rs index 3a4b162a32..f09cc80122 100644 --- a/crates/engine/src/cmd/type_mask_cmds.rs +++ b/crates/engine/src/cmd/type_mask_cmds.rs @@ -8,7 +8,7 @@ use photocraft_algo::selection::{self as sel, SelectionMode}; use photocraft_doc::text::AntiAlias; -use photocraft_doc::{Document, LayerContent, LayerId, TextLayer}; +use photocraft_doc::{Document, LayerContent, TextLayer}; use photocraft_geom::Rect; use serde_json::{Value, json}; @@ -87,10 +87,7 @@ fn create_selection(s: &mut Session, p: &Value) -> Result { Some(v) => v.as_bool().ok_or_else(|| bad("removeLayer must be true or false"))?, }; let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - let layer = match p.get("layer") { - None | Some(Value::Null) => None, - Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("layer must be a layer id"))?)), - }; + let layer = crate::commands::explicit_layer(s, p)?; if remove && layer.is_none() { return Err(bad("removeLayer needs a layer")); } @@ -144,7 +141,7 @@ pub fn specs() -> Vec { label: "Type Mask", menu: &[], shortcut: None, - params: r##"{"layer":id? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##, + params: r##"{"layer":id|name? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##, enabled: has_doc, journal: true, run: create_selection, @@ -154,6 +151,7 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests { use super::*; + use photocraft_doc::LayerId; fn session() -> Session { let mut s = Session::new(); @@ -165,6 +163,16 @@ mod tests { s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds()) } + #[test] + fn a_named_type_layer_can_make_a_selection_for_action_replay() { + let mut s = session(); + let id = s.execute("type.create", json!({"text": "I", "x": 40, "y": 120, "size": 48})).unwrap()["layer"].as_u64().unwrap(); + let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute(CMD, json!({"layer": name})).unwrap(); + assert!(bounds(&s).is_some()); + } + #[test] fn typed_text_selects_its_glyphs_without_a_layer() { let mut s = session(); diff --git a/crates/engine/src/cmd/type_spell_cmds.rs b/crates/engine/src/cmd/type_spell_cmds.rs index d42500c722..46e007d290 100644 --- a/crates/engine/src/cmd/type_spell_cmds.rs +++ b/crates/engine/src/cmd/type_spell_cmds.rs @@ -4,7 +4,8 @@ //! (`photocraft_text::spell`, SCOWL size 50) plus the user dictionary in the preferences //! (`userDictionary`). Headless, it lists misspellings with suggestions and changes, changes all, //! or adds words to the dictionary; the UI's dialog drives the same actions. Offsets are -//! character indices. +//! character indices. An action records Check Spelling as one `{"allLayers"}` step with its +//! dialog on, and records none of the corrections. //! //! `type.insertText` inserts a string (a glyph) into a type layer at a character position or //! over a range. @@ -79,14 +80,14 @@ fn accepted(s: &Session, p: &Value) -> HashSet { set } -fn layers_param(s: &Session, p: &Value) -> Vec { - let Some(st) = s.active() else { return Vec::new() }; +fn layers_param(s: &Session, p: &Value) -> Result> { + let st = s.active().ok_or(EngineError::NoDocument)?; let all = type_layers(&st.doc); if p.get("allLayers").and_then(Value::as_bool) == Some(false) || p.get("layer").is_some() { - let active = layer_param(s, p).ok(); - all.into_iter().filter(|id| Some(*id) == active).collect() + let active = layer_param(s, p)?; + Ok(all.into_iter().filter(|id| *id == active).collect()) } else { - all + Ok(all) } } @@ -96,7 +97,7 @@ fn list(s: &Session, p: &Value) -> Result { let ok = accepted(s, p); let max = p.get("suggestions").and_then(Value::as_u64).unwrap_or(5) as usize; let mut out = Vec::new(); - for id in layers_param(s, p) { + for id in layers_param(s, p)? { let Some(t) = text(&st.doc, id) else { continue }; let found = dict.misspellings(t, &ok); // Convert every start/end byte offset to a character offset in one pass over the @@ -117,7 +118,7 @@ fn list(s: &Session, p: &Value) -> Result { /// Replaces one occurrence (verified to still be `word` when given). fn change(s: &mut Session, p: &Value) -> Result { let cmd = "edit.checkSpelling"; - let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `layer`"))?); + let id = layer_param(s, p)?; let start = p.get("start").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `start`"))? as usize; let end = p.get("end").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `end`"))? as usize; let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string(); @@ -147,7 +148,7 @@ fn change_all(s: &mut Session, p: &Value) -> Result { let word = p.get("word").and_then(Value::as_str).filter(|w| !w.is_empty()).ok_or_else(|| bad(cmd, "`changeAll` needs `word`"))?.to_string(); let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`changeAll` needs `replace`"))?.to_string(); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers_param(s, p) + let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers_param(s, p)? .into_iter() .filter_map(|id| { let t = text(&doc, id)?; @@ -203,6 +204,18 @@ fn remove_word(s: &mut Session, p: &Value) -> Result { Ok(json!({ "removed": removed, "userDictionary": s.prefs().user_dictionary })) } +/// Whether an action records this call: only Check Spelling itself (no `action`), which the +/// dialog runs when it closes. Its corrections, dictionary edits and lookups are not recorded. +pub(crate) fn records(p: &Value) -> bool { + p.get("action").is_none_or(Value::is_null) +} + +/// The step a recorded Check Spelling becomes: `{"allLayers"}`, `false` when it checked one layer. +pub(crate) fn recorded_step(p: &Value) -> Value { + let one_layer = p.get("allLayers").and_then(Value::as_bool) == Some(false) || p.get("layer").is_some(); + json!({ "allLayers": !one_layer }) +} + fn check_spelling(s: &mut Session, p: &Value) -> Result { match p.get("action").and_then(Value::as_str).unwrap_or("list") { "list" | "check" => list(s, p), @@ -270,7 +283,7 @@ pub fn specs() -> Vec { label: "Check Spelling…", menu: &["Edit"], shortcut: None, - params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer","start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"}"#, + params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id|name? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer"? (default: the active layer),"start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"} (an action records only a call without "action", as {"allLayers"} with its dialog on)"#, enabled: has_type, journal: true, run: check_spelling, @@ -314,6 +327,27 @@ mod tests { text(&s.active().unwrap().doc, LayerId(id)).unwrap().to_string() } + #[test] + fn missing_named_spelling_layer_is_an_error_for_list_and_change_all() { + let (mut s, _) = session("Teh"); + for action in ["list", "changeAll"] { + let result = s.execute("edit.checkSpelling", json!({"action": action, "layer": "Removed type", "word": "Teh", "replace": "The"})); + assert!(result.is_err(), "{action} silently accepted a missing named layer: {result:?}"); + } + } + + #[test] + fn named_layer_list_and_change_all_resolve_a_nonactive_layer() { + let (mut s, id) = session("Teh"); + let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let listed = s.execute("edit.checkSpelling", json!({"action": "list", "layer": name})).unwrap(); + assert_eq!(listed["count"], 1); + let changed = s.execute("edit.checkSpelling", json!({"action": "changeAll", "layer": name, "word": "Teh", "replace": "The"})).unwrap(); + assert_eq!(changed["changed"], 1); + assert_eq!(text_of(&s, id), "The"); + } + #[test] fn all_locked_layer_rejects_insert_text_and_spelling_change_without_history() { // #4188: Lock All stopped type.edit (#3132) but not these two text edits. @@ -369,6 +403,69 @@ mod tests { assert_eq!(r["correct"], false); } + #[test] + fn change_without_layer_edits_the_active_type_layer() { + let (mut s, id) = session("Teh end"); + let r = s.execute("edit.checkSpelling", json!({"action": "change", "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap(); + assert_eq!((r["layer"].as_u64(), r["end"].as_u64()), (Some(id), Some(3))); + assert_eq!(text_of(&s, id), "The end"); + } + + #[test] + fn recording_keeps_check_spelling_with_its_dialog_and_not_its_corrections() { + let (mut s, id) = session("Teh brwon end"); + s.execute("actions.record", json!({"name": "Spell"})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": "list", "allLayers": true})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": "changeAll", "word": "brwon", "replace": "brown"})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": "suggest", "word": "recieve"})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": "addToDictionary", "word": "Photocraft"})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": "removeFromDictionary", "word": "Photocraft"})).unwrap(); + // The dialog closing. + s.execute("edit.checkSpelling", json!({"allLayers": true, "suggestions": 8})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(text_of(&s, id), "The brown end"); + let action = &s.actions.list[0]; + assert_eq!(action.steps, vec![("edit.checkSpelling".to_string(), json!({"allLayers": true}))]); + assert_eq!(action.dialogs.iter().copied().collect::>(), [0]); + } + + #[test] + fn recorded_check_of_one_layer_records_all_layers_off() { + let (mut s, id) = session("Teh end"); + s.execute("actions.record", json!({"name": "One"})).unwrap(); + s.execute("edit.checkSpelling", json!({"allLayers": false})).unwrap(); + s.execute("edit.checkSpelling", json!({"layer": id})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let steps: Vec<&Value> = s.actions.list[0].steps.iter().map(|(_, p)| p).collect(); + assert_eq!(steps, [&json!({"allLayers": false}), &json!({"allLayers": false})]); + assert_eq!(s.actions.list[0].dialogs.len(), 2); + } + + /// `"action": null` runs Check Spelling itself, so it is recorded as one. + #[test] + fn a_null_action_is_recorded_as_check_spelling() { + let (mut s, _) = session("Teh end"); + s.execute("actions.record", json!({"name": "Null"})).unwrap(); + s.execute("edit.checkSpelling", json!({"action": null})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps, [("edit.checkSpelling".to_string(), json!({"allLayers": true}))]); + } + + #[test] + fn played_check_spelling_without_its_dialog_changes_nothing() { + let (mut s, id) = session("Teh end"); + s.execute("actions.record", json!({"name": "Spell"})).unwrap(); + s.execute("edit.checkSpelling", json!({"allLayers": true})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let history = s.active().unwrap().history.past_len(); + let r = s.execute("actions.play", json!({"action": "Spell"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(r["ran"], 1); + assert_eq!(text_of(&s, id), "Teh end"); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + #[test] fn disabled_without_type() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/type_styles_cmds.rs b/crates/engine/src/cmd/type_styles_cmds.rs index 4cbb2092dc..9d6e68f091 100644 --- a/crates/engine/src/cmd/type_styles_cmds.rs +++ b/crates/engine/src/cmd/type_styles_cmds.rs @@ -42,21 +42,21 @@ fn text_of(doc: &Document, id: LayerId) -> Option<&TextLayer> { } /// Type layers a style command acts on: `layer`, `layers`, else the selected type layers. -fn targets(s: &Session, p: &Value) -> Vec { - let Some(st) = s.active() else { return Vec::new() }; - let ids: Vec = if let Some(id) = p.get("layer").and_then(Value::as_u64) { - vec![LayerId(id)] +fn targets(s: &Session, p: &Value) -> Result> { + let Some(st) = s.active() else { return Ok(Vec::new()) }; + let ids: Vec = if let Some(id) = crate::commands::explicit_layer(s, p)? { + vec![id] } else if let Some(a) = p.get("layers").and_then(Value::as_array) { a.iter().filter_map(Value::as_u64).map(LayerId).collect() } else { st.selected_layers() }; - ids.into_iter().filter(|id| text_of(&st.doc, *id).is_some()).collect() + Ok(ids.into_iter().filter(|id| text_of(&st.doc, *id).is_some()).collect()) } fn has_target(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if targets(s, &Value::Null).is_empty() { Err("select a type layer".into()) } else { Ok(()) } + if targets(s, &Value::Null).is_ok_and(|targets| !targets.is_empty()) { Ok(()) } else { Err("select a type layer".into()) } } fn byte_at(text: &str, ci: usize) -> usize { @@ -111,6 +111,7 @@ fn perturbed_char() -> CharStyle { features: vec![FontFeature { tag: "\u{1}".into(), value: 9 }], variations: vec![FontVariation { axis: "\u{1}".into(), value: -1.0 }], language: Some("\u{1}".into()), + rotate_roman: true, style_sheet: None, } } @@ -316,7 +317,7 @@ type Target = (LayerId, Option<(usize, usize)>); /// Target list with byte ranges (`range` applies only to a single layer). fn target_ranges(s: &Session, p: &Value) -> Result> { let st = s.active().ok_or(EngineError::NoDocument)?; - let ids = targets(s, p); + let ids = targets(s, p)?; if ids.is_empty() { return Err(EngineError::Other("no type layer to apply the style to".into())); } @@ -333,7 +334,7 @@ fn target_ranges(s: &Session, p: &Value) -> Result> { /// run's byte range. fn sample(s: &Session, p: &Value) -> Option<(CharStyle, ParagraphStyle, LayerId, (usize, usize))> { let st = s.active()?; - let id = *targets(s, p).first()?; + let id = *targets(s, p).ok()?.first()?; let t = text_of(&st.doc, id)?; let (a, _) = range_of(&t.text, p); let mut at = 0; @@ -429,6 +430,9 @@ fn new_style(s: &mut Session, p: &Value, paragraph: bool) -> Result { let (mut ca, mut pa) = parse_attrs(cmd, p.get("attrs").unwrap_or(&Value::Null))?; // With a type layer targeted, the style starts from the text's formatting (as Photoshop). let from_text = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); + if from_text { + crate::commands::explicit_layer(s, p)?; + } if from_text && let Some((run, para, _, _)) = sample(s, p) { if paragraph { let base_c = st.resolve_char(None, None, FAMILY); diff --git a/crates/engine/src/cmd/type_styles_cmds/tests.rs b/crates/engine/src/cmd/type_styles_cmds/tests.rs index 6f6f735ff0..aba2f0fb5f 100644 --- a/crates/engine/src/cmd/type_styles_cmds/tests.rs +++ b/crates/engine/src/cmd/type_styles_cmds/tests.rs @@ -73,6 +73,15 @@ fn character_style_lifecycle() { assert_eq!(run_at(&layer(&s, id), 7).style_sheet, Some(cs as u32)); } +#[test] +fn creating_a_style_rejects_an_unmatched_named_source_layer() { + let (mut s, _) = session("Hello"); + for id in ["type.characterStyle.new", "type.paragraphStyle.new"] { + let error = s.execute(id, json!({"name": "From missing", "layer": "Missing", "fromSelection": true})).unwrap_err(); + assert!(error.to_string().contains("no layer named"), "{error}"); + } +} + #[test] fn new_style_from_selection_and_redefine() { let (mut s, id) = session("Title"); diff --git a/crates/engine/src/cmd/variables_cmds.rs b/crates/engine/src/cmd/variables_cmds.rs index 9c89c4756e..c56ee0f6c0 100644 --- a/crates/engine/src/cmd/variables_cmds.rs +++ b/crates/engine/src/cmd/variables_cmds.rs @@ -19,6 +19,14 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document".into()) } +fn has_data_sets(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or_else(|| "no document".to_string())?; + if st.doc.variables.data_sets.is_empty() { + return Err("no data sets in document".into()); + } + Ok(()) +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -488,6 +496,9 @@ macro_rules! spec { ($id:expr, $label:expr, $menu:expr, $params:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: has_doc, journal: true, run: $run } }; + ($id:expr, $label:expr, $menu:expr, $params:expr, $enabled:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: $enabled, journal: true, run: $run } + }; } pub fn specs() -> Vec { @@ -511,6 +522,7 @@ pub fn specs() -> Vec { "Apply Data Set…", &["Image"], "{name|index} → {applied, index}: sets layer visibility/text/pixels from the data set (one history step)", + has_data_sets, |s, p| apply_data_set(s, p) ), spec!( @@ -525,6 +537,7 @@ pub fn specs() -> Vec { "Data Sets as Files…", &["File", "Export"], "{dir, format?:png, dataSets?[names], naming?:\"{name}|{index}|{document}\"} → {files,count}: apply each data set and export the flattened document; colliding filenames are rejected before writing (include {index} to disambiguate)", + has_data_sets, |s, p| export_as_files(s, p) ), CommandSpec { diff --git a/crates/engine/src/cmd/variables_cmds/tests.rs b/crates/engine/src/cmd/variables_cmds/tests.rs index ec5b728e71..1f7fa5cb12 100644 --- a/crates/engine/src/cmd/variables_cmds/tests.rs +++ b/crates/engine/src/cmd/variables_cmds/tests.rs @@ -652,3 +652,20 @@ fn exported_data_sets_keep_the_character_style() { assert_eq!((got.weight, got.italic, got.underline), (style.weight, style.italic, style.underline)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn data_set_commands_require_data_sets_to_be_enabled() { + let mut s = Session::new(); + assert!(!s.is_enabled("file.export.dataSetsAsFiles")); + assert!(!s.is_enabled("image.applyDataSet")); + + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + // Document exists but has no data sets: commands remain disabled + assert!(!s.is_enabled("file.export.dataSetsAsFiles")); + assert!(!s.is_enabled("image.applyDataSet")); + + // Once data sets are defined, commands become enabled + s.execute("image.variables.dataSets", json!({"dataSets": [{"name": "Set 1", "values": []}]})).unwrap(); + assert!(s.is_enabled("file.export.dataSetsAsFiles")); + assert!(s.is_enabled("image.applyDataSet")); +} diff --git a/crates/engine/src/cmd/vector_clipboard.rs b/crates/engine/src/cmd/vector_clipboard.rs new file mode 100644 index 0000000000..2a61f8b5e3 --- /dev/null +++ b/crates/engine/src/cmd/vector_clipboard.rs @@ -0,0 +1,236 @@ +//! Source-backed Paste modes. Preparation and geometry conversion adapt Skyfuller1303's +//! PR #3409, commit 897f20072762fdf0eb688adcdb8d8cda7b38ce66, to the current clipboard API. + +use std::sync::Arc; + +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, NamedPath, Path}; +use photocraft_geom::Affine; +use serde_json::{Value, json}; + +use super::edit_cmds::Clip; +use crate::{EngineError, Result, Session}; + +const MAX_SOURCE_BYTES: usize = 16 * 1024 * 1024; +const MAX_CACHE_PIXELS: u64 = 24_000_000; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Mode { + Pixels, + Path, + Shape, +} + +fn bad(message: &str) -> EngineError { + EngineError::BadParams { cmd: "edit.paste".into(), msg: message.into() } +} + +/// Omission alone selects the old command routes; malformed explicit modes must not disappear. +pub(super) fn mode(p: &Value) -> Result> { + match p.get("as") { + None => Ok(None), + Some(Value::String(value)) => match value.as_str() { + "pixels" => Ok(Some(Mode::Pixels)), + "path" => Ok(Some(Mode::Path)), + "shape" => Ok(Some(Mode::Shape)), + _ => Err(bad("`as` must be pixels, path or shape")), + }, + Some(_) => Err(bad("`as` must be pixels, path or shape")), + } +} + +/// Imported data is temporary; only original bytes and their raster mirror survive in Clip. +fn prepare(bytes: &[u8]) -> Result<(Document, Vec)> { + if bytes.is_empty() || bytes.len() > MAX_SOURCE_BYTES || !photocraft_io::svg::is_svg(bytes) { + return Err(bad("SVG clipboard source must be valid markup between 1 byte and 16 MiB")); + } + let mut imported = photocraft_io::svg::import_svg_bounded("Clipboard.svg", bytes, MAX_CACHE_PIXELS) + .map_err(|e| EngineError::Other(format!("can't import clipboard SVG: {e}")))?; + if imported.document.layers.is_empty() { + return Err(bad("the SVG contains no drawable objects")); + } + imported.document.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); + Ok((imported.document, imported.warnings)) +} + +pub(super) fn from_svg(bytes: &[u8]) -> Result { + let _ = prepare(bytes)?; + let image = crate::smart_cmds::svg_source_image(bytes, PixelFormat::RGBA32F, 1.0)?; + let mut owned = crate::allocation::capacity(bytes.len(), "copying SVG clipboard source")?; + owned.extend_from_slice(bytes); + Ok(Clip { + svg: Some(Arc::new(owned)), + components: None, + surface: (*image.surface).clone(), + bounds: image.bounds, + layers: None, + resolution: None, + source: None, + }) +} + +/// Explicit modes do not fall through to new-document, mask, channel or in-place operations. +pub(super) fn validate(s: &Session, p: &Value, in_place: bool) -> Result<[f64; 2]> { + if in_place || ["inPlace", "clampToCanvas", "document", "layer", "layers", "path"].iter().any(|key| p.get(key).is_some()) { + return Err(bad("explicit Paste modes do not support these placement or target options")); + } + if p.get("target").is_some_and(|v| v.as_str() != Some("pixels")) { + return Err(bad("explicit Paste requires the layer composite target")); + } + let state = s.active().ok_or(EngineError::NoDocument)?; + if s.tools.mask_targeted + || state.doc.quick_mask.is_some() + || state.channel_view.layer_mask.is_some() + || state.channel_view.target != crate::channel_cmds::ChannelTarget::Composite + || !crate::layer_copy_cmds::layered(state.doc.mode) + { + return Err(bad("explicit Paste requires a layered document and the layer composite target")); + } + if s.job_on(state.doc.id).is_some() { + return Err(bad("the destination has a running background job")); + } + match p.get("center") { + None => Ok([f64::from(state.doc.size.width) / 2.0, f64::from(state.doc.size.height) / 2.0]), + Some(Value::Array(a)) if a.len() == 2 => { + let x = a.first().and_then(Value::as_f64); + let y = a.get(1).and_then(Value::as_f64); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() && x.abs() <= 1e7 && y.abs() <= 1e7 => Ok([x, y]), + _ => Err(bad("`center` must contain two finite coordinates within ±10 million pixels")), + } + } + Some(_) => Err(bad("`center` must contain two finite coordinates within ±10 million pixels")), + } +} + +/// Extracting a child must not discard a clipping mask or a group's nonneutral composition. +fn neutral_group(layer: &Layer) -> bool { + layer.opacity == 1.0 + && layer.fill_opacity == 1.0 + && layer.mask.is_none() + && layer.vector_mask.is_none() + && layer.effects.items.is_empty() + && !layer.clipped + && layer.advanced.is_default() + && layer.blend_if.is_default() + && layer.excluded_channels == 0 + && matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough) +} + +fn paths(doc: &Document) -> Result { + let mut result: Option = None; + for (_, _, layer) in doc.walk() { + match &layer.content { + LayerContent::Group(_) if neutral_group(layer) => {} + LayerContent::Shape(shape) if layer.mask.is_none() && layer.vector_mask.is_none() && !layer.clipped => { + if let Some(path) = &mut result { + if path.fill_rule != shape.path.fill_rule || path.inverted != shape.path.inverted { + return Err(bad("the SVG paths have incompatible fill rules")); + } + path.subpaths.extend(shape.path.subpaths.clone()); + } else { + result = Some(shape.path.clone()); + } + } + _ => return Err(bad("the SVG contains rasterized or clipped objects without a supported editable path")), + } + } + result.filter(|p| !p.is_empty()).ok_or_else(|| bad("the SVG contains no editable path")) +} + +fn shape(doc: &Document) -> Result<&Layer> { + let mut found = None; + for (_, _, layer) in doc.walk() { + match &layer.content { + LayerContent::Group(_) if neutral_group(layer) => {} + LayerContent::Shape(_) if found.is_none() && layer.mask.is_none() && layer.vector_mask.is_none() && !layer.clipped => found = Some(layer), + _ => return Err(bad("Shape requires one editable shape without clipping or group blending")), + } + } + found.ok_or_else(|| bad("the SVG contains no editable shape")) +} + +/// Preflight only the destination caches that adaptation/translation will actually render. +fn shape_budget(layer: &Layer, destination: &Document, dx: i32, dy: i32) -> Result<()> { + let LayerContent::Shape(shape) = &layer.content else { + return Err(bad("the source is not an editable shape")); + }; + // Adaptation can refresh at the source position before translation refreshes the final cache. + for transform in [Affine::IDENTITY, Affine::translate(f64::from(dx), f64::from(dy))] { + let mut shape = shape.clone(); + crate::vector_cmds::transform_shape(&mut shape, &transform); + photocraft_vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(bad)?; + let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, destination.bounds()); + if compiled.bounds().is_some_and(|r| r.intersect(&destination.bounds()).size().area() > MAX_CACHE_PIXELS) { + return Err(bad("the pasted shape cache exceeds 24 megapixels; use Pixels")); + } + } + Ok(()) +} + +/// Editable insertions may bypass a locked active group, but cannot modify a locked ancestor. +pub(super) fn layer_target(s: &Session) -> Result<()> { + let state = s.active().ok_or(EngineError::NoDocument)?; + let parent = state + .active_layer + .and_then(|id| state.doc.path_of(id)) + .map(|mut path| { + path.pop(); + path + }) + .unwrap_or_default(); + let locks = state.doc.locks_at(&parent); + if locks.all || locks.pixels || locks.position || locks.transparency { + return Err(bad("the destination group is locked")); + } + Ok(()) +} + +/// Prepare all fallible work before a single insertion, retaining the original clipboard source. +pub(super) fn paste(s: &mut Session, p: &Value, mode: Mode) -> Result { + let center = validate(s, p, false)?; + let bytes = s.clipboard.as_ref().and_then(|clip| clip.svg.clone()).ok_or_else(|| bad("this Paste mode requires original vector clipboard source"))?; + let (mut copies, warnings) = prepare(&bytes)?; + let state = s.active().ok_or(EngineError::NoDocument)?; + let destination = state.doc.clone(); + let (dx, dy) = ((center[0] - f64::from(copies.size.width) / 2.0).round(), (center[1] - f64::from(copies.size.height) / 2.0).round()); + if !dx.is_finite() || !dy.is_finite() || dx < f64::from(i32::MIN) || dx > f64::from(i32::MAX) || dy < f64::from(i32::MIN) || dy > f64::from(i32::MAX) { + return Err(bad("the paste offset is not addressable")); + } + let (dx, dy) = (dx as i32, dy as i32); + if mode == Mode::Path { + let path = paths(&copies)?.transform(&Affine::translate(f64::from(dx), f64::from(dy))); + photocraft_vector::flatten::validate_path(&path).map_err(bad)?; + let name = (1..=destination.paths.len().saturating_add(1)) + .map(|i| format!("Pasted Path {i}")) + .find(|name| !destination.paths.iter().any(|p| &p.name == name)) + .ok_or_else(|| bad("no free path name"))?; + s.edit("Paste Path", |doc, _| { + doc.paths.push(NamedPath { name: name.clone(), path, psd_raw: None }); + Ok(()) + })?; + return Ok(json!({"as":"path","path":name,"offset":[dx,dy],"warnings":warnings})); + } + layer_target(s)?; + copies.layers = if mode == Mode::Shape { + let layer = shape(&copies)?; + shape_budget(layer, &destination, dx, dy)?; + vec![layer.duplicate()] + } else { + let image = crate::smart_cmds::svg_source_image(&bytes, PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), 1.0)?; + copies.depth = SampleType::F32; + vec![Layer::new("Pasted SVG", LayerContent::Raster((*image.surface).clone()))] + }; + crate::layer_copy_cmds::adapt_copies(s, &mut copies, &destination)?; + crate::layer_copy_cmds::translate_copies(&mut copies, &destination, dx, dy); + let layer = copies.layers.pop().ok_or_else(|| bad("the SVG contains no paste output"))?; + let id = s.edit("Paste", |doc, active| { + let id = crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); + *active = Some(id); + if mode == Mode::Pixels { + doc.selection = None; + } + Ok(id) + })?; + Ok(json!({"as":if mode==Mode::Shape{"shape"}else{"pixels"},"layer":id.0,"offset":[dx,dy],"warnings":warnings})) +} diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 5a53d5ae07..f331a30335 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -69,19 +69,19 @@ mod paths; mod shape; mod vector_mask; -pub(crate) use params::{layer_id, nums, pt}; +pub(crate) use params::{layer_id, nums, op_from, pt}; pub use params::{parse_path, parse_stroke, path_json}; pub use paths::STROKE_PATH_TOOLS; use paths::{ - clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_transform, paths_list, - resolve_path, select_to_work_path, + clipping_path_clear, clipping_path_set, path_delete, path_fill, path_rename, path_set, path_stroke, path_to_selection, path_to_selection_with_mode, + path_transform, paths_list, resolve_path, select_to_work_path, }; pub(crate) use paths::{is_work, path_mut}; +pub(crate) use shape::{bake_path, map_path, map_shape, shift_shape, with_shape}; use shape::{ can_paste_shape_fill, can_paste_shape_stroke, combine_shapes, copy_shape_fill, copy_shape_stroke, has_shape_fill, has_shape_stroke, merge_components, paste_shape_fill, paste_shape_stroke, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize, }; -pub(crate) use shape::{map_path, map_shape, shift_shape, with_shape}; pub use shape::{preview_shape_stroke, refresh_shape, shape_path, transform_shape, translate_vectors}; use vector_mask::{toggle_vector_mask, vector_mask_add, vector_mask_delete, vector_mask_edit, vector_mask_info, vector_mask_rasterize, with}; @@ -98,7 +98,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),"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}"## + 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),"arrowStart":bool=false,"arrowEnd":bool=false,"arrowWidth":px=52,"arrowLength":px=50,"arrowConcavity":0..100=0 (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 @@ -108,7 +108,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?,"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"## + 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),"arrowStart":bool?,"arrowEnd":bool?,"arrowWidth":px?,"arrowLength":px?,"arrowConcavity":0..100? (line arrows),"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 @@ -209,6 +209,27 @@ pub fn specs() -> Vec { run: path_to_selection, journal: true, }, + // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. + CommandSpec { + id: "path.addToSelection", + label: "Add Path to Selection", + menu: &[], + shortcut: Some("Cmd+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "add"), + journal: true, + }, + CommandSpec { + id: "path.subtractFromSelection", + label: "Subtract Path from Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "subtract"), + journal: true, + }, spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), spec!( "path.fill", diff --git a/crates/engine/src/cmd/vector_cmds/params.rs b/crates/engine/src/cmd/vector_cmds/params.rs index 6deb13186a..bab083c162 100644 --- a/crates/engine/src/cmd/vector_cmds/params.rs +++ b/crates/engine/src/cmd/vector_cmds/params.rs @@ -1,13 +1,10 @@ use super::{ - Color, EngineError, Fill, FillRule, GradientStyle, Knot, LayerId, LineCap, LineJoin, LiveShape, Path, PathOp, Point, Result, Session, ShapeStroke, - StrokeAlign, Subpath, Value, bad, json, + Color, Fill, FillRule, GradientStyle, Knot, LayerId, LineCap, LineJoin, LiveShape, Path, PathOp, Point, Result, Session, ShapeStroke, StrokeAlign, Subpath, + Value, bad, json, }; pub(crate) fn layer_id(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(LayerId(id)), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), - } + crate::commands::layer_param(s, p) } pub(super) fn f64p(p: &Value, k: &str) -> Option { @@ -72,7 +69,7 @@ fn hex(c: &Color) -> String { if c.alpha < 1.0 { format!("#{:02x}{:02x}{:02x}{:02x}", q(r), q(g), q(b), q(c.alpha)) } else { format!("#{:02x}{:02x}{:02x}", q(r), q(g), q(b)) } } -pub(super) fn op_from(s: &str) -> Option { +pub(crate) fn op_from(s: &str) -> Option { Some(match s.to_ascii_lowercase().as_str() { "combine" | "add" | "union" => PathOp::Combine, "subtract" | "minus" => PathOp::Subtract, @@ -360,13 +357,51 @@ pub(super) fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&L } "line" => { let (pf, pto, pw) = match prev { - Some(LiveShape::Line { from, to, weight }) => (Some(*from), Some(*to), *weight), + Some(LiveShape::Line { from, to, weight }) | Some(LiveShape::ArrowLine { from, to, weight, .. }) => (Some(*from), Some(*to), *weight), _ => (None, None, 1.0), }; - LiveShape::Line { - from: nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?, - to: nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?, - weight: f64p(p, "weight").unwrap_or(pw).max(0.0), + let from = nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?; + let to = nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?; + let weight = f64p(p, "weight").unwrap_or(pw).max(0.0); + let arrow_fields = ["arrowStart", "arrowEnd", "arrowWidth", "arrowLength", "arrowConcavity"]; + let is_arrow = matches!(prev, Some(LiveShape::ArrowLine { .. })) || arrow_fields.iter().any(|k| p.get(*k).is_some()); + if is_arrow { + let (ps, pe, pwidth, plen, pc) = match prev { + Some(LiveShape::ArrowLine { start, end, width, length, concavity, .. }) => (*start, *end, *width, *length, *concavity), + _ => (false, false, 52.0, 50.0, 0.0), + }; + let dim = |key: &str, fallback: f64, min: f64, max: f64| -> Result { + let value = match p.get(key) { + Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be numeric")))?, + None => fallback, + }; + // Preserve scaled arrowhead dimensions when editing other properties. + let preserved_scaled_size = p.get(key).is_none() && matches!(key, "arrowWidth" | "arrowLength") && value.is_finite(); + let value = if preserved_scaled_size { value.max(min) } else { value }; + if !value.is_finite() || (!preserved_scaled_size && !(min..=max).contains(&value)) { + return Err(bad(cmd, format!("`{key}` must be within {min}..={max}"))); + } + Ok(value) + }; + // Reject invalid flag types rather than silently using defaults. + let flag = |key: &str, fallback: bool| -> Result { + match p.get(key) { + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be a boolean"))), + None => Ok(fallback), + } + }; + LiveShape::ArrowLine { + from, + to, + weight, + start: flag("arrowStart", ps)?, + end: flag("arrowEnd", pe)?, + width: dim("arrowWidth", pwidth, 1.0, 4096.0)?, + length: dim("arrowLength", plen, 1.0, 4096.0)?, + concavity: dim("arrowConcavity", pc, 0.0, 100.0)?, + } + } else { + LiveShape::Line { from, to, weight } } } o => return Err(bad(cmd, format!("unknown kind `{o}`"))), diff --git a/crates/engine/src/cmd/vector_cmds/paths.rs b/crates/engine/src/cmd/vector_cmds/paths.rs index 167cb9d2a6..7c7f550a29 100644 --- a/crates/engine/src/cmd/vector_cmds/paths.rs +++ b/crates/engine/src/cmd/vector_cmds/paths.rs @@ -79,26 +79,32 @@ pub(super) fn path_set(s: &mut Session, p: &Value) -> Result { let name = p.get("name").and_then(Value::as_str).map(str::to_string); let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?; s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| { - let merge = |old: Option<&Path>, new: Path| match (old, append) { - (Some(o), Some(op)) => { - let mut o = o.clone(); - o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| { - sp.op = op; - sp - })); - o - } - _ => new, + let merge = |old: Option<&Path>, new: Path| -> Result { + let merged = match (old, append) { + (Some(o), Some(op)) => { + let mut o = o.clone(); + o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| { + sp.op = op; + sp + })); + o + } + _ => new, + }; + // Appending small Pen components must not bypass the complete path's bounds and + // tessellation budget. Validate before changing the document or its history. + vector::flatten::validate_path(&merged).map_err(|e| bad(CMD, e))?; + Ok(merged) }; if is_work(name.as_deref()) { - doc.work_path = Some(merge(doc.work_path.as_ref(), path)); + doc.work_path = Some(merge(doc.work_path.as_ref(), path)?); } else { let n = name.clone().unwrap_or_default(); match doc.paths.iter_mut().find(|q| q.name == n) { Some(q) => { - q.path = merge(Some(&q.path), path); + q.path = merge(Some(&q.path), path)?; } - None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }), + None => doc.paths.push(NamedPath { name: n, path: merge(None, path)?, psd_raw: None }), } } Ok(()) @@ -365,6 +371,17 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": selected })) } +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// a `mode` in the params is overridden, the rest is forwarded and validated as usual. +pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { + let mut q = match p { + Value::Object(_) => p.clone(), + _ => json!({}), + }; + q["mode"] = json!(mode); + path_to_selection(s, &q) +} + pub(super) fn select_to_work_path(s: &mut Session, p: &Value) -> Result { let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0); let d = s.active().ok_or(EngineError::NoDocument)?; @@ -586,6 +603,7 @@ pub(super) fn path_stroke(s: &mut Session, p: &Value) -> Result { let lines = vector::flatten_path(&path, 0.1); let id = layer_id(s, p)?; let bg = s.tools.background; + let channels = crate::color_channels::target(s, p)?.is_some(); let dmg = s.edit("Stroke Path", |doc, _| { let sel = doc.selection.clone(); let locks = doc.effective_locks(id); @@ -597,7 +615,7 @@ pub(super) fn path_stroke(s: &mut Session, p: &Value) -> Result { let lock = locks.transparency; let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Stroke Path needs a pixel layer".into()))?; let mut brush = brush.clone(); - if brush.erase && lock { + if brush.erase && (lock || channels) { // Locked transparency (e.g. the Background): the Eraser paints the background colour (#76). brush.erase = false; brush.color = bg; diff --git a/crates/engine/src/cmd/vector_cmds/shape.rs b/crates/engine/src/cmd/vector_cmds/shape.rs index 789fd8f40b..33e1bfe3cf 100644 --- a/crates/engine/src/cmd/vector_cmds/shape.rs +++ b/crates/engine/src/cmd/vector_cmds/shape.rs @@ -80,7 +80,7 @@ fn live_kind(l: &LiveShape) -> &'static str { LiveShape::Ellipse { .. } => "ellipse", LiveShape::Polygon { star_ratio, .. } if *star_ratio < 1.0 => "star", LiveShape::Polygon { .. } => "polygon", - LiveShape::Line { .. } => "line", + LiveShape::Line { .. } | LiveShape::ArrowLine { .. } => "line", } } @@ -148,12 +148,61 @@ pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) } } +/// Returns arrow-local scales when the transformed axes remain perpendicular. +fn arrow_axis_scales(a: &Affine, from: [f64; 2], to: [f64; 2]) -> Option<(f64, f64)> { + let [m0, m1, m2, m3, _, _] = a.m; + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + let old_length = dx.hypot(dy); + if !old_length.is_finite() || old_length <= 1e-12 { + return None; + } + let (ux, uy) = (dx / old_length, dy / old_length); + let (ax, ay) = (m0 * ux + m2 * uy, m1 * ux + m3 * uy); + let (px, py) = (-m0 * uy + m2 * ux, -m1 * uy + m3 * ux); + let axial = ax.hypot(ay); + let perpendicular = px.hypot(py); + if !axial.is_finite() || !perpendicular.is_finite() || axial <= 1e-12 || perpendicular <= 1e-12 { + return None; + } + let dot = ax * px + ay * py; + (dot.abs() <= 1e-10 * axial * perpendicular).then_some((axial, perpendicular)) +} + /// 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) { + // Check original arrow dimensions before transforming the path. + // Parameters already clamped by arrow_line cannot faithfully + // regenerate the transformed geometry. + if let Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, .. }) = sh.live.as_ref() + && (*start || *end) + { + let original_length = (to[0] - from[0]).hypot(to[1] - from[1]); + + if !weight.is_finite() + || *weight < 0.1 + || !width.is_finite() + || *width < 1.0 + || !length.is_finite() + || *length < 1.0 + || !original_length.is_finite() + || original_length < 1.0 + { + sh.live = None; + } + } + sh.path = sh.path.transform(a); let [m0, m1, m2, m3, tx, ty] = a.m; let axis_aligned = m1.abs() < 1e-12 && m2.abs() < 1e-12 && m0 > 0.0 && m3 > 0.0; + // The path has already received the exact affine transform. Drop live arrow + // parameters if later edits would regenerate a different, unskewed head. + if let Some(LiveShape::ArrowLine { from, to, .. }) = sh.live.as_ref() + && (to[0] - from[0]).hypot(to[1] - from[1]) > 1e-12 + && arrow_axis_scales(a, *from, *to).is_none() + { + sh.live = None; + } sh.live = match (sh.live.take(), axis_aligned) { (Some(l), true) => { let r = |r: [f64; 4]| [r[0] * m0 + tx, r[1] * m3 + ty, r[2] * m0, r[3] * m3]; @@ -167,6 +216,22 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } } + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + let old_len = dx.hypot(dy); + let new_len = (dx * m0).hypot(dy * m3); + let perpendicular_scale = if old_len > 0.0 && new_len > 0.0 { m0 * m3 * old_len / new_len } else { m0.min(m3) }; + LiveShape::ArrowLine { + from: pt(from), + to: pt(to), + weight: weight * perpendicular_scale, + start, + end, + width: width * perpendicular_scale, + length: length * if old_len > 0.0 { new_len / old_len } else { m0.min(m3) }, + concavity, + } + } 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 @@ -195,11 +260,61 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { } LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }), LiveShape::Line { from, to, weight } => Some(LiveShape::Line { from: pt(from), to: pt(to), weight: weight * k }), + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + // Flips and quarter-turns can have different axial and perpendicular + // scales; using the smaller axis for both loses arrowhead length. + let (axial_scale, perpendicular_scale) = arrow_axis_scales(a, from, to).unwrap_or((k, k)); + Some(LiveShape::ArrowLine { + from: pt(from), + to: pt(to), + weight: weight * perpendicular_scale, + start, + end, + width: width * perpendicular_scale, + length: length * axial_scale, + concavity, + }) + } _ => None, } } + // Arbitrary rotations and other orthogonal-axis affine transforms also + // preserve arrow geometry and should keep their editable live parameters. + (Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity }), false) => { + arrow_axis_scales(a, from, to).map(|(axial_scale, perpendicular_scale)| { + let pt = |p: [f64; 2]| [m0 * p[0] + m2 * p[1] + tx, m1 * p[0] + m3 * p[1] + ty]; + LiveShape::ArrowLine { + from: pt(from), + to: pt(to), + weight: weight * perpendicular_scale, + start, + end, + width: width * perpendicular_scale, + length: length * axial_scale, + concavity, + } + }) + } _ => None, }; + // Keep the exact transformed outline. Remove live arrow parameters + // when minimum-size constraints would alter its regenerated geometry. + if let Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, .. }) = sh.live.as_ref() + && (*start || *end) + { + let line_length = (to[0] - from[0]).hypot(to[1] - from[1]); + if !weight.is_finite() + || *weight < 0.1 + || !width.is_finite() + || *width < 1.0 + || !length.is_finite() + || *length < 1.0 + || !line_length.is_finite() + || line_length < 1.0 + { + sh.live = None; + } + } sh.psd_raw = None; } @@ -360,7 +475,23 @@ pub(super) 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", "cornerRadius", "from", "to", "weight"]; + let geom_keys = [ + "kind", + "rect", + "radii", + "radius", + "sides", + "starRatio", + "cornerRadius", + "from", + "to", + "weight", + "arrowStart", + "arrowEnd", + "arrowWidth", + "arrowLength", + "arrowConcavity", + ]; 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`"))?; @@ -443,13 +574,27 @@ pub(super) fn merge_components(s: &mut Session, p: &Value) -> Result { /// Bakes `sh`'s path operations into plain combined outlines, traced from the exact coverage. fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> { + sh.path = bake_path(&sh.path, tol)?; + sh.live = None; + sh.psd_raw = None; + Ok(()) +} + +/// `path` with its path operations baked into plain combined outlines, traced from the exact +/// coverage within `tol` px (Merge Shape Components). An empty area gives an empty path; an +/// inverted path (a vector mask's) stays inverted around the merged area. An open subpath is +/// refused: tracing its fill would close it, or drop a straight one or a lone anchor (a dot under +/// a round or square cap), losing the stroked geometry. +pub(crate) fn bake_path(path: &Path, tol: f64) -> Result { + if path.subpaths.iter().any(|sp| !sp.closed && !sp.knots.is_empty()) { + return Err(EngineError::Other("Merge Shape Components needs closed paths: close or delete the open subpaths first".into())); + } // Trace at 4× resolution so the fitted outline follows the anti-aliased edge closely. const K: f64 = 4.0; - let up = sh.path.transform(&Affine::scale(K)); + let up = Path { inverted: false, ..path.transform(&Affine::scale(K)) }; let r = vector::fill_rasterizer(&up, vector::DEFAULT_TOLERANCE); let Some(b) = r.pixel_bounds() else { - sh.path.subpaths.clear(); - return Ok(()); + return Ok(Path { subpaths: Vec::new(), ..path.clone() }); }; let b = b.inflate(2); if u64::from(b.width()) * u64::from(b.height()) > 400_000_000 { @@ -457,10 +602,7 @@ fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> { } let cov = r.render(b); let traced = vector::trace::trace_mask(&cov, b, 0.0, tol * K); - sh.path = traced.transform(&Affine::scale(1.0 / K)); - sh.live = None; - sh.psd_raw = None; - Ok(()) + Ok(Path { inverted: path.inverted, ..traced.transform(&Affine::scale(1.0 / K)) }) } /// The shape layers Layer › Combine Shapes merges into one: with none of the `narrow` params @@ -561,3 +703,138 @@ pub(super) fn selection_to_shape(s: &mut Session, p: &Value) -> Result { } shape_create(s, &q) } + +#[cfg(test)] +mod arrow_affine_regression_tests { + use super::*; + + #[test] + fn forty_five_degree_rotation_retains_live_arrow_and_geometry() { + let live = + LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 0.0], weight: 8.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 50.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + let mut a = Affine::translate(0.0, 0.0); + let c = std::f64::consts::FRAC_PI_4.cos(); + let s = std::f64::consts::FRAC_PI_4.sin(); + a.m = [c, s, -s, c, 0.0, 0.0]; + transform_shape(&mut sh, &a); + let Some(LiveShape::ArrowLine { width, length, .. }) = sh.live.as_ref() else { + panic!("Arrow must remain parametrically editable after 45-degree rotation"); + }; + assert!((*width - 31.0).abs() < 1e-9); + assert!((*length - 31.0).abs() < 1e-9); + let rebuilt = vector::shapes::live_path(sh.live.as_ref().unwrap()); + assert_eq!(sh.path.subpaths.len(), rebuilt.subpaths.len()); + for (existing, generated) in sh.path.subpaths.iter().zip(rebuilt.subpaths.iter()) { + assert_eq!(existing.knots.len(), generated.knots.len()); + for (a, b) in existing.knots.iter().zip(generated.knots.iter()) { + assert!((a.anchor.x - b.anchor.x).abs() < 1e-8); + assert!((a.anchor.y - b.anchor.y).abs() < 1e-8); + } + } + } + + #[test] + fn subpixel_scale_keeps_transformed_path_but_drops_unreproducible_live_parameters() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 0.0], weight: 5.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 0.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + let mut a = Affine::translate(0.0, 0.0); + a.m = [0.01, 0.0, 0.0, 0.01, 0.0, 0.0]; + + let expected_path = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected_path); + assert!(sh.live.is_none()); + } + + #[test] + fn headless_arrow_survives_nonuniform_diagonal_scale() { + let live = + LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 100.0], weight: 5.0, start: false, end: false, width: 52.0, length: 50.0, concavity: 0.0 }; + + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + + let expected_path = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected_path); + assert!(sh.live.is_none()); + } + + #[test] + fn zero_length_headless_arrow_survives_scale() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [0.0, 0.0], weight: 5.0, start: false, end: false, width: 52.0, length: 50.0, concavity: 0.0 }; + + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + + let expected_path = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected_path); + assert!(matches!(sh.live, Some(LiveShape::ArrowLine { start: false, end: false, .. }))); + } + + #[test] + fn headless_short_arrow_remains_editable_after_scaling() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [0.5, 0.0], weight: 0.05, start: false, end: false, width: 31.0, length: 31.0, concavity: 0.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + + let mut a = Affine::translate(0.0, 0.0); + a.m = [0.5, 0.0, 0.0, 0.5, 0.0, 0.0]; + + let expected = sh.path.transform(&a); + transform_shape(&mut sh, &a); + + assert_eq!(sh.path, expected); + assert!(matches!(sh.live, Some(LiveShape::ArrowLine { start: false, end: false, .. }))); + } + + #[test] + fn scale_above_minimum_still_keeps_live_arrow_editable() { + let live = LiveShape::ArrowLine { from: [0.0, 0.0], to: [100.0, 0.0], weight: 5.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 0.0 }; + let mut sh = ShapeLayer { path: vector::shapes::live_path(&live), live: Some(live), ..ShapeLayer::default() }; + let mut a = Affine::translate(0.0, 0.0); + a.m = [0.5, 0.0, 0.0, 0.5, 0.0, 0.0]; + + transform_shape(&mut sh, &a); + + let Some(LiveShape::ArrowLine { width, length, .. }) = sh.live else { + panic!("Ordinary scaling must preserve editable arrow parameters"); + }; + + assert!((width - 15.5).abs() < 1e-9); + assert!((length - 15.5).abs() < 1e-9); + } + + #[test] + fn reflected_horizontal_arrow_uses_axial_scale_for_head_length() { + let mut a = Affine::translate(0.0, 0.0); + a.m = [-2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + let (axial, perpendicular) = arrow_axis_scales(&a, [0.0, 0.0], [100.0, 0.0]).unwrap(); + assert!((axial - 2.0).abs() < 1e-10); + assert!((perpendicular - 1.0).abs() < 1e-10); + } + + #[test] + fn anisotropic_diagonal_arrow_cannot_remain_parametric() { + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + assert!(arrow_axis_scales(&a, [0.0, 0.0], [100.0, 100.0]).is_none()); + } + + #[test] + fn uniform_scale_preserves_diagonal_arrow_axes() { + let mut a = Affine::translate(0.0, 0.0); + a.m = [2.0, 0.0, 0.0, 2.0, 0.0, 0.0]; + let (axial, perpendicular) = arrow_axis_scales(&a, [0.0, 0.0], [100.0, 100.0]).unwrap(); + assert!((axial - 2.0).abs() < 1e-10); + assert!((perpendicular - 2.0).abs() < 1e-10); + } +} diff --git a/crates/engine/src/cmd/vector_cmds/tests.rs b/crates/engine/src/cmd/vector_cmds/tests.rs index 0b5b89aae9..b659cbd656 100644 --- a/crates/engine/src/cmd/vector_cmds/tests.rs +++ b/crates/engine/src/cmd/vector_cmds/tests.rs @@ -1238,3 +1238,147 @@ fn triangles_and_polygons_take_a_corner_radius_kept_through_edits_and_scaling() 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)); } + +#[test] +fn arrowhead_flags_reject_wrong_types_without_mutation() { + let mut s = session(160, 120, 8); + let id = s + .execute( + "shape.create", + json!({ + "kind": "line", + "from": [10, 20], + "to": [120, 20], + "arrowStart": true, + "arrowEnd": false + }), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + + let original_doc = s.active().unwrap().doc.clone(); + let original_history = s.active().unwrap().history.past_len(); + + for key in ["arrowStart", "arrowEnd"] { + for invalid in [json!("true"), json!(null), json!(1), json!([])] { + let mut create = json!({ + "kind": "line", + "from": [10, 40], + "to": [120, 40] + }); + create[key] = invalid.clone(); + + assert!(s.execute("shape.create", create).is_err(), "{key} must reject {invalid}"); + + let mut edit = json!({"layer": id}); + edit[key] = invalid.clone(); + + assert!(s.execute("shape.edit", edit).is_err(), "{key} edit must reject {invalid}"); + + assert_eq!(s.active().unwrap().doc, original_doc); + assert_eq!(s.active().unwrap().history.past_len(), original_history); + } + } + + // Valid boolean values remain accepted. + s.execute( + "shape.edit", + json!({ + "layer": id, + "arrowStart": false, + "arrowEnd": true + }), + ) + .unwrap(); +} + +#[test] +fn arrowhead_dimensions_reject_invalid_values_without_mutation() { + let mut s = session(160, 120, 8); + let id = s + .execute( + "shape.create", + json!({ + "kind": "line", + "from": [10, 20], + "to": [120, 20], + "arrowEnd": true + }), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + + let original_doc = s.active().unwrap().doc.clone(); + let original_history = s.active().unwrap().history.past_len(); + + for (key, invalids) in [ + ("arrowWidth", [json!("52"), json!(null), json!([]), json!(0), json!(4097)]), + ("arrowLength", [json!("50"), json!(null), json!([]), json!(0), json!(4097)]), + ("arrowConcavity", [json!("0"), json!(null), json!([]), json!(-1), json!(101)]), + ] { + for invalid in invalids { + let mut create = json!({ + "kind": "line", + "from": [10, 40], + "to": [120, 40] + }); + create[key] = invalid.clone(); + assert!(s.execute("shape.create", create).is_err(), "{key} create must reject {invalid}"); + assert_eq!(s.active().unwrap().doc, original_doc, "{key} create changed document"); + assert_eq!(s.active().unwrap().history.past_len(), original_history, "{key} create changed history"); + + let mut edit = json!({"layer": id}); + edit[key] = invalid.clone(); + assert!(s.execute("shape.edit", edit).is_err(), "{key} edit must reject {invalid}"); + assert_eq!(s.active().unwrap().doc, original_doc, "{key} edit changed document"); + assert_eq!(s.active().unwrap().history.past_len(), original_history, "{key} edit changed history"); + } + } +} + +#[test] +fn headless_diagonal_arrow_preserves_geometry_after_nonuniform_scale() { + let mut s = session(400, 400, 8); + + let created = s + .execute( + "shape.create", + json!({ + "kind": "line", + "from": [40.0, 40.0], + "to": [140.0, 140.0], + "weight": 5.0, + "arrowStart": false, + "arrowEnd": false + }), + ) + .unwrap(); + + let id = created["layer"].as_u64().unwrap(); + + let transformed = s + .execute( + "shape.edit", + json!({ + "layer": id, + "transform": [2.0, 0.0, 0.0, 1.0, 0.0, 0.0] + }), + ) + .unwrap(); + + assert_ne!(transformed["path"], created["path"]); + assert!(transformed["live"].is_null()); + + let before = s.active().unwrap().doc.clone(); + + // Editing live parameters must not regenerate a different shape. + assert!(s.execute("shape.edit", json!({"layer": id, "weight": 8.0}),).is_err()); + + assert_eq!(s.active().unwrap().doc, before); + + let info = s.execute("shape.info", json!({"layer": id})).unwrap(); + + assert_eq!(info["path"], transformed["path"]); +} diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 3aec55b837..d5540c1f79 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -263,10 +263,90 @@ type ImportFrames = fn( /// Frame storage one footage import may keep (whole tiles, all frames together). const MAX_FOOTAGE_BYTES: u64 = 2 << 30; +/// The footage frame limit this import applies: the production cap, or a test scope's lowered +/// cap so a regression can reach the refusal without keeping gibibytes of frames. +fn footage_byte_limit() -> u64 { + #[cfg(test)] + if let Some(limit) = limits::max_footage_bytes() { + return limit; + } + MAX_FOOTAGE_BYTES +} + +/// Import options for footage files. A test scope may lower the PSD/layered-TIFF memory limit, +/// which the user's preferences do not expose, to reach an admission refusal on a small file. +fn footage_import_options(s: &Session) -> photocraft_io::ImportOptions { + #[cfg(test)] + if let Some(limit) = limits::psd_max_pixel_bytes() { + return photocraft_io::ImportOptions { psd_max_pixel_bytes: limit, ..crate::jobs::import_options(s.prefs()) }; + } + crate::jobs::import_options(s.prefs()) +} + +/// Per-thread test overrides for the footage import limits. A scope restores the previous values +/// on drop, and the thread-local keeps parallel tests from lowering each other's limits. +#[cfg(test)] +mod limits { + use std::cell::Cell; + + #[derive(Clone, Copy, Default)] + struct Overrides { + psd_max_pixel_bytes: Option, + max_footage_bytes: Option, + } + + thread_local! { + static CURRENT: Cell = const { Cell::new(Overrides { psd_max_pixel_bytes: None, max_footage_bytes: None }) }; + } + + /// Restores the previous overrides when dropped. + pub(crate) struct Scoped(Overrides); + + impl Scoped { + /// Lowers the PSD/layered-TIFF memory admission limit for the current thread. + pub(crate) fn psd_max_pixel_bytes(limit: u64) -> Self { + Self::set(Overrides { psd_max_pixel_bytes: Some(limit), ..Default::default() }) + } + + /// Lowers the retained footage-frame limit for the current thread. + pub(crate) fn max_footage_bytes(limit: u64) -> Self { + Self::set(Overrides { max_footage_bytes: Some(limit), ..Default::default() }) + } + + fn set(next: Overrides) -> Self { + Self(CURRENT.with(|current| current.replace(next))) + } + } + + impl Drop for Scoped { + fn drop(&mut self) { + CURRENT.with(|current| current.set(self.0)); + } + } + + pub(super) fn psd_max_pixel_bytes() -> Option { + CURRENT.with(|current| current.get().psd_max_pixel_bytes) + } + + pub(super) fn max_footage_bytes() -> Option { + CURRENT.with(|current| current.get().max_footage_bytes) + } +} + +/// The engine error for a failed page of a multi-page TIFF. A memory refusal keeps its typed form, +/// so the UI, control channel and agents still get the required and limit fields they need to +/// offer approval or explain the hard limit; any other failure keeps the page's text. +fn tiff_page_error(display: &std::path::Path, name: &str, page: usize, error: photocraft_io::IoError) -> EngineError { + match error { + photocraft_io::IoError::PsdApprovalRequired { .. } | photocraft_io::IoError::PsdImportBudget { .. } => crate::error::io_open_error(name, error), + error => EngineError::Other(format!("`{}` page {}: {error}", display.display(), page + 1)), + } +} + /// 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 { +fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft_io::ImportOptions) -> Result { let p = std::path::Path::new(path); let is_img = |f: &std::path::Path| { matches!( @@ -292,6 +372,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result, surface: Surface| -> std::result::Result<(), String> { // A frame of fully transparent pixels stores no tiles, so the count needs its own limit. if frames.len() >= crate::timeline_cmds::MAX_DURATION { @@ -299,8 +380,8 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result MAX_FOOTAGE_BYTES { - return Err(format!("the footage needs more than {} MiB of frames", MAX_FOOTAGE_BYTES >> 20)); + if kept_bytes > max_footage_bytes { + return Err(format!("the footage needs more than {} MiB of frames", max_footage_bytes >> 20)); } frames.push(surface); Ok(()) @@ -330,7 +411,35 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result 1 { + // A damaged or looping page chain, or one past the 10,000-directory limit, lists + // only its first pages: refuse it rather than silently import part of the file. + if !complete { + return Err(EngineError::Other(format!( + "`{}`: only the first {} pages could be read (its page list is damaged or too long), so not every page would be imported", + f.display(), + pages.len() + ))); + } + for page in pages { + // Same admission policy, caller limit and import settings as any other TIFF + // page import: a multi-page TIFF is not a way around the memory budget. + let doc = photocraft_io::import_tiff_page_with_options(&name, &bytes, Some(page), opts, &photocraft_raster::Interrupt::NONE) + .map_err(|e| tiff_page_error(f, &name, page, e))? + .document; + // The page is flattened before admission of the next one, so the retained + // frames of every page count against the footage limit, as GIF and WebP + // frames do. + keep(&mut frames, crate::file_cmds::flattened(&doc, fmt)).map_err(|msg| EngineError::Other(format!("`{}`: {msg}", f.display())))?; + } + continue; + } + } + let mut doc = crate::file_cmds::import_with_options(&name, &bytes, opts)?; // A Targa's alpha opens as a channel (as in Photoshop); a frame's alpha is transparency. if f.extension().is_some_and(|e| e.eq_ignore_ascii_case("tga")) { photocraft_io::tga_alpha_channel_to_transparency(&mut doc); @@ -350,7 +459,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; + let Footage { frames, fps: footage_fps } = load_frames(&path, fmt, &footage_import_options(s))?; // An explicit `fps` wins, then the rate the footage implies, then 30. let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); @@ -387,7 +496,7 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?.frames; + let frames = load_frames(&path, fmt, &footage_import_options(s))?.frames; edit_video(s, "Replace Footage", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -423,7 +532,7 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { }; // The footage path is stored in the document. let file = crate::doc_path::resolve(&path).map_err(|why| crate::doc_path::refusal("the footage", &path, why))?; - let frames = load_frames(&file.to_string_lossy(), fmt)?.frames; + let frames = load_frames(&file.to_string_lossy(), fmt, &footage_import_options(s))?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -431,6 +540,15 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { }) } +/// The pages of a TIFF that are pages of the document: full-resolution directories of the main +/// chain (not SubIFDs, thumbnails or masks), by index into [`photocraft_codecs::tiff_info`]'s +/// pages, and whether the walk listed every directory. Empty when the file can't be listed. +fn tiff_pages(bytes: &[u8]) -> (Vec, bool) { + let Ok(info) = photocraft_codecs::tiff_info(bytes) else { return (Vec::new(), true) }; + let pages = info.pages.iter().enumerate().filter(|(_, p)| p.parent.is_none() && p.kind == photocraft_codecs::TiffPageKind::Page).map(|(i, _)| i).collect(); + (pages, info.complete) +} + /// Import › Video Frames to Layers: load frames from a file/sequence and add each as its own layer. fn frames_to_layers(s: &mut Session, p: &Value) -> Result { let path = p @@ -439,7 +557,7 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?.frames; + let frames = load_frames(&path, fmt, &footage_import_options(s))?.frames; let n = frames.len(); s.edit("Video Frames to Layers", |doc, active| { let mut above = *active; diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index 7e8c05a9b4..f2acb0bd0f 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -69,6 +69,237 @@ fn tmpdir(name: &str) -> String { d.to_string_lossy().into_owned() } +/// A classic little-endian TIFF with one 8-bit gray page per `(w, h, level, NewSubfileType)`, +/// chained in order (NewSubfileType 1 marks a reduced-resolution copy, such as a thumbnail). +fn write_tiff(path: &str, pages: &[(u32, u32, u8, u32)]) { + let mut f = b"II".to_vec(); + f.extend_from_slice(&42u16.to_le_bytes()); + f.extend_from_slice(&0u32.to_le_bytes()); + let mut next_field = 4; + for &(w, h, level, kind) in pages { + let pixels_at = f.len() as u32; + f.extend(std::iter::repeat_n(level, (w * h) as usize)); + if f.len() % 2 == 1 { + f.push(0); + } + let ifd_at = f.len() as u32; + f[next_field..next_field + 4].copy_from_slice(&ifd_at.to_le_bytes()); + let entries: [(u16, u16, u32); 10] = + [(254, 4, kind), (256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, pixels_at), (277, 3, 1), (278, 4, h), (279, 4, w * h)]; + f.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, ty, v) in entries { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&ty.to_le_bytes()); + f.extend_from_slice(&1u32.to_le_bytes()); + f.extend_from_slice(&v.to_le_bytes()); + } + next_field = f.len(); + f.extend_from_slice(&0u32.to_le_bytes()); + } + std::fs::write(path, f).unwrap(); +} + +/// A real layered TIFF from the exporter: two 8×6 raster layers and Photoshop layer data in tag +/// 37724, in one full-resolution page. +fn layered_tiff_bytes() -> Vec { + let mut doc = Document::new("layered", photocraft_geom::Size::new(8, 6), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + for name in ["bottom", "top"] { + let mut surface = Surface::new(fmt); + surface.write_interleaved(photocraft_geom::Rect::new(0, 0, 8, 6), &vec![96u8; 8 * 6 * fmt.bytes_per_pixel()]); + doc.layers.push(Layer::new(name, LayerContent::Raster(surface))); + } + let bytes = + photocraft_io::export(&doc, "layered.tif", &photocraft_io::ExportOptions { tiff_layers: true, ..Default::default() }).expect("a layered TIFF").bytes; + assert_eq!(bytes.get(0..2), Some(&b"II"[..]), "the TIFF exporter writes little-endian files here"); + assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 42, "a classic TIFF"); + bytes +} + +/// [`layered_tiff_bytes`] extended to two pages by appending a copy of its first directory as the +/// next one. The copied directory keeps the original tag and pixel offsets, so both pages carry +/// genuine layer data without hand-written Photoshop blocks; the file stays small, and the second +/// page is never decoded. +fn write_layered_multipage_tiff(path: &str) { + let bytes = layered_tiff_bytes(); + let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); + let first_ifd = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + let count = u16_at(first_ifd) as usize; + let dir_len = 2 + count * 12 + 4; + let mut f = bytes.clone(); + if f.len() % 2 == 1 { + f.push(0); + } + let second_ifd = f.len(); + f.extend_from_slice(&bytes[first_ifd..first_ifd + dir_len]); + let second_next = second_ifd + 2 + count * 12; + f[second_next..second_next + 4].copy_from_slice(&0u32.to_le_bytes()); + let first_next = first_ifd + 2 + count * 12; + f[first_next..first_next + 4].copy_from_slice(&u32::try_from(second_ifd).expect("a small TIFF").to_le_bytes()); + std::fs::write(path, f).expect("write the two-page TIFF"); +} + +/// #6379: Video Frames to Layers takes every page of a multi-page TIFF, one layer each, in +/// order, as it takes a GIF's frames; a thumbnail is not a page. A single-page TIFF is one frame. +#[test] +fn video_frames_to_layers_takes_every_tiff_page() { + let dir = tmpdir("tiff-pages"); + let path = format!("{dir}/pages.tif"); + write_tiff(&path, &[(2, 2, 9, 1), (6, 4, 51, 0), (3, 5, 204, 0)]); + let mut s = session(); + let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 2, "two pages, the thumbnail left out"); + let doc = &s.active().unwrap().doc; + for (name, (w, h), level) in [("Frame 1", (6, 4), 51.0), ("Frame 2", (3, 5), 204.0)] { + let l = doc.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("{name}")); + let surf = l.surface().unwrap(); + let b = surf.content_bounds(); + assert_eq!((b.width(), b.height()), (w, h), "{name}"); + let px = surf.read_region(photocraft_geom::Rect::new(b.x0, b.y0, b.x0 + 1, b.y0 + 1)); + assert!((px[0] * 255.0 - level).abs() < 0.6 && px[3] == 1.0, "{name}: {px:?}"); + } + let single = format!("{dir}/single.tif"); + write_tiff(&single, &[(4, 4, 120, 0)]); + let mut s = session(); + assert_eq!(s.execute("file.import.videoFramesToLayers", json!({"path": single})).unwrap()["layers"], 1); +} + +/// #6379 review: a TIFF whose page chain loops back (or is cut short, or runs past the directory +/// limit) lists only its first pages. The import is refused, and the document is left as it was, +/// instead of reporting part of the file as all of it. +#[test] +fn video_frames_to_layers_refuses_a_tiff_with_an_incomplete_page_list() { + let dir = tmpdir("tiff-loop"); + let path = format!("{dir}/loop.tif"); + write_tiff(&path, &[(2, 2, 51, 0), (2, 2, 204, 0)]); + // The last directory's next-IFD offset points back to the first directory. + let mut f = std::fs::read(&path).unwrap(); + let first: [u8; 4] = f[4..8].try_into().unwrap(); + let end = f.len(); + f[end - 4..].copy_from_slice(&first); + std::fs::write(&path, f).unwrap(); + let mut s = session(); + let before = s.active().unwrap().doc.layers.len(); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("only the first 2 pages"), "{err}"); + assert_eq!(s.active().unwrap().doc.layers.len(), before, "nothing imported"); +} + +/// #10897 review: a page refused by the PSD memory admission must reach the caller typed, so the +/// UI, control channel and agents can offer approval or explain the hard limit; a multi-page TIFF +/// is not a way to get a text-only refusal. Every other page failure keeps its file and page text. +#[test] +fn tiff_page_errors_keep_memory_refusals_typed_and_other_context() { + let display = std::path::Path::new("/tmp/footage/pages.tif"); + let name = "/tmp/footage/pages.tif"; + + let refusal = photocraft_io::IoError::PsdApprovalRequired { required: 9_000, soft_limit: 8_000, hard_limit: 16_000, available_ram: Some(4_000) }; + match tiff_page_error(display, name, 1, refusal) { + EngineError::PsdApprovalRequired { name: named, required, soft_limit, hard_limit, available_ram } => { + assert_eq!(named, name); + assert_eq!((required, soft_limit, hard_limit, available_ram), (9_000, 8_000, 16_000, Some(4_000))); + } + other => panic!("page 2 approval refusal lost its fields: {other:?}"), + } + + let hard = photocraft_io::IoError::PsdImportBudget { required: 9_000, limit: 8_000 }; + match tiff_page_error(display, name, 2, hard) { + EngineError::PsdHardBudget { name: named, required, limit } => { + assert_eq!(named, name); + assert_eq!((required, limit), (9_000, 8_000)); + } + other => panic!("page 3 hard refusal lost its fields: {other:?}"), + } + + let broken = photocraft_io::IoError::Unsupported("corrupt page".into()); + let err = tiff_page_error(display, name, 0, broken); + assert!(matches!(err, EngineError::Other(_)), "{err:?}"); + let msg = err.to_string(); + assert!(msg.contains("pages.tif") && msg.contains("page 1") && msg.contains("corrupt page"), "{msg}"); +} + +/// #10897 review: a multi-page TIFF page with Photoshop layer data is admitted like a PSD. A tiny +/// explicit caller limit must reach the caller as the typed `PsdHardBudget`, with the file name, +/// required bytes and limit intact, and the document must be left untouched. A helper-only test +/// would still pass if the page loop turned that refusal into text. +#[test] +fn video_frames_to_layers_keeps_a_layered_tiff_page_refusal_typed() { + let dir = tmpdir("tiff-typed"); + let path = format!("{dir}/layered.tif"); + write_layered_multipage_tiff(&path); + let mut s = session(); + let before_layers = s.active().unwrap().doc.layers.len(); + let before_active = s.active().unwrap().active_layer; + let _limit = super::limits::Scoped::psd_max_pixel_bytes(1); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err(); + match err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, path); + assert_eq!(limit, 1); + assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}"); + } + other => panic!("the layered page's refusal lost its typed fields: {other:?}"), + } + assert_eq!(s.active().unwrap().doc.layers.len(), before_layers, "no partial frame layers"); + assert_eq!(s.active().unwrap().active_layer, before_active, "the active layer is unchanged"); +} + +/// #10897 review: every page of a multi-page TIFF goes through the footage `keep` budget, as a +/// GIF's frames do. Before, the pages were pushed straight into `frames`, so many large pages could +/// keep more than the cap without an error. A thread-local test scope lowers the cap, so the +/// regression needs a few tiles of memory rather than gibibytes. +#[test] +fn video_frames_to_layers_enforces_the_footage_limit_on_every_tiff_page() { + let dir = tmpdir("tiff-budget"); + let path = format!("{dir}/pages.tif"); + let mut s = session(); + let fmt = s.active().unwrap().doc.pixel_format(); + let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * fmt.bytes_per_pixel() as u64; + let budget = 1u64 << 20; + assert!(tile < budget, "one page must fit in the lowered budget"); + // One more page than fits in the budget: only the accumulation can refuse it. + let pages = usize::try_from(budget / tile + 1).expect("a small page count"); + write_tiff(&path, &vec![(16, 16, 40, 0); pages]); + let before = s.active().unwrap().doc.layers.len(); + let _limit = super::limits::Scoped::max_footage_bytes(budget); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("pages.tif") && err.contains("more than 1 MiB"), "{err}"); + assert_eq!(s.active().unwrap().doc.layers.len(), before, "nothing imported"); +} + +/// #10897 review: a single-page layered TIFF does not take the multi-page branch, so it falls +/// through to the ordinary single-file import. That import must carry the footage import settings, +/// including the PSD/layered-TIFF memory limit the caller lowered; before, it called the default +/// import and ignored the limit, so this refusal was never typed (and a caller limit could be +/// bypassed). The same file imports as one frame when the limit is not lowered. +#[test] +fn video_frames_to_layers_uses_the_footage_limit_for_a_single_page_layered_tiff() { + let dir = tmpdir("tiff-single-typed"); + let path = format!("{dir}/layered.tif"); + std::fs::write(&path, layered_tiff_bytes()).expect("write the layered TIFF"); + let mut s = session(); + let before_layers = s.active().unwrap().doc.layers.len(); + let before_active = s.active().unwrap().active_layer; + { + let _limit = super::limits::Scoped::psd_max_pixel_bytes(1); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err(); + match err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, path); + assert_eq!(limit, 1); + assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}"); + } + other => panic!("the single-page layered TIFF's refusal lost its typed fields: {other:?}"), + } + } + assert_eq!(s.active().unwrap().doc.layers.len(), before_layers, "no partial frame layers"); + assert_eq!(s.active().unwrap().active_layer, before_active, "the active layer is unchanged"); + // The same page imports normally under the budget the footage command always applied. + let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 1, "one page is one frame"); + assert_eq!(s.active().unwrap().doc.layers.len(), before_layers + 1); +} + #[test] fn new_video_layer_from_image_sequence() { let dir = tmpdir("seq"); @@ -153,7 +384,7 @@ fn animated_gif_keeps_transparency() { // Each frame has a transparent index and is cleared before the next (disposal 2). let packed: Vec = gif.windows(4).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| w[3]).collect(); assert_eq!(packed, [0x09, 0x09]); - let px = photocraft_compose::flatten(&photocraft_io::import("t.gif", &gif).unwrap().document).px; + let px = photocraft_compose::flatten(&photocraft_io::import("t.gif", &gif).unwrap().document).into_px(); assert_eq!(px[0][3], 0.0, "the corner stays transparent"); assert_eq!(px[3 * 8 + 3], [1.0, 0.0, 0.0, 1.0], "the square stays red"); } @@ -306,8 +537,13 @@ fn tga_sequence_frames_keep_their_transparency() { let dir = tmpdir("tga"); write_tga(&format!("{dir}/f01.tga")); write_tga(&format!("{dir}/f02.tga")); - let frames = - load_frames(&dir, photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true)).unwrap().frames; + let frames = load_frames( + &dir, + photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true), + &photocraft_io::ImportOptions::default(), + ) + .unwrap() + .frames; assert_eq!(frames.len(), 2); for f in &frames { assert_eq!(f.pixel(2, 6), vec![1.0, 0.0, 0.0, 1.0]); diff --git a/crates/engine/src/cmd/warp_cmds.rs b/crates/engine/src/cmd/warp_cmds.rs index c5c04132d5..c2f434fb1e 100644 --- a/crates/engine/src/cmd/warp_cmds.rs +++ b/crates/engine/src/cmd/warp_cmds.rs @@ -362,10 +362,7 @@ const MAX_WARP_SIDE: u32 = 100_000; fn target(s: &Session, cmd: &str, p: &Value) -> Result<(LayerId, Rect)> { let st = s.active().ok_or(EngineError::NoDocument)?; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(v) => LayerId(v), - None => st.active_layer.ok_or(EngineError::Other("no active layer".into()))?, - }; + let id = crate::commands::layer_param(s, p)?; let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { diff --git a/crates/engine/src/cmd/web_cmds.rs b/crates/engine/src/cmd/web_cmds.rs index 470edd3067..3bd4f5a6c3 100644 --- a/crates/engine/src/cmd/web_cmds.rs +++ b/crates/engine/src/cmd/web_cmds.rs @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Save for Web (Legacy)…", &["File", "Export"], Some("Cmd+Alt+Shift+S"), - r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, + r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"folder":folder? (whole image, named after the document; ignored with "path" or "dir"),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/cmd/web_cmds/optimizer.rs b/crates/engine/src/cmd/web_cmds/optimizer.rs index 14e5fd311d..2186f3b70a 100644 --- a/crates/engine/src/cmd/web_cmds/optimizer.rs +++ b/crates/engine/src/cmd/web_cmds/optimizer.rs @@ -128,21 +128,34 @@ pub fn optimize( let mut color_table = Vec::new(); let mut transparent_index = None; let mut indices = Vec::new(); + // True colour (PNG-24, WebP): straight RGBA when transparency is on and a pixel is + // translucent, else RGB over the matte. + let true_color = || { + let alpha = st.transparency && region().any(|p| p[3] < 1.0); + let k = if alpha { 4 } else { 3 }; + let mut data = Vec::with_capacity(w * h * k); + for p in region() { + let q = if alpha { *p } else { over(p) }; + data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); + } + let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; + (data, layout) + }; + let rgba = |data: &[u8], layout: photocraft_codecs::ChannelLayout| -> Vec { + if layout == photocraft_codecs::ChannelLayout::Rgba { + data.to_vec() + } else { + data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() + } + }; + let meta = || photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; let (bytes, colors) = match st.format { WebFormat::Png24 => { - let alpha = st.transparency && region().any(|p| p[3] < 1.0); - let k = if alpha { 4 } else { 3 }; - let mut data = Vec::with_capacity(w * h * k); - for p in region() { - let q = if alpha { *p } else { over(p) }; - data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]); - } + let (data, layout) = true_color(); if preview { - look = if alpha { data.clone() } else { data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() }; + look = rgba(&data, layout); } - let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb }; - let meta = photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() }; - let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta); + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta()); let opts = photocraft_codecs::EncodeOptions { png_interlaced: st.interlaced, embed_icc: icc.is_some(), @@ -151,6 +164,29 @@ pub fn optimize( }; (photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &opts).map_err(e)?, None) } + WebFormat::Webp => { + let (data, layout) = true_color(); + let icc = icc.filter(|_| st.embed_icc); + // WebP holds EXIF (the metadata choice's "All" passes it in), so it rides along with the XMP. + let webp_meta = photocraft_codecs::Metadata { exif: exif.map(<[u8]>::to_vec), ..meta() }; + let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(webp_meta); + let opts = photocraft_codecs::EncodeOptions { + webp_lossless: st.lossless, + webp_quality: st.quality.min(100), + embed_icc: icc.is_some(), + embed_metadata: exif.is_some() || xmp.is_some(), + ..Default::default() + }; + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &opts).map_err(e)?; + if preview { + // Lossless shows the source; lossy shows the compression artefacts. + look = match (!st.lossless).then(|| photocraft_codecs::decode(&bytes).ok()).flatten() { + Some(img) => img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(), + None => rgba(img.data(), layout), + }; + } + (bytes, None) + } WebFormat::Jpeg => { let mut rgb = Vec::with_capacity(w * h * 3); for p in region() { diff --git a/crates/engine/src/cmd/web_cmds/save_web.rs b/crates/engine/src/cmd/web_cmds/save_web.rs index 1e263c2c25..cac6a15e5c 100644 --- a/crates/engine/src/cmd/web_cmds/save_web.rs +++ b/crates/engine/src/cmd/web_cmds/save_web.rs @@ -158,7 +158,7 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) { let mut m = settings.clone(); if let Some(o) = m.as_object_mut() { - for k in ["path", "dir", "trustedSliceHtml"] { + for k in ["path", "dir", "folder", "trustedSliceHtml"] { o.remove(k); } } @@ -172,16 +172,20 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u if let Some(authorize) = s.authorize { authorize(cmd, p)?; } + // Settings are checked first, so a refused export leaves a floating selection where it is. + let st = WebSettings::from_params(p, cmd)?; // Direct platform calls must export the moved selection as it appears on the canvas, // just as Session::execute does before dispatching the native command. crate::float_cmds::before_command(s, cmd)?; - let st = WebSettings::from_params(p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); let (wdoc, sx, sy) = web_document(&doc, p, &st)?; let buf = photocraft_compose::flatten(&wdoc); let bw = wdoc.size.width as usize; - let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()); + // An untagged document is treated as sRGB (see [`web_document`]); with Embed Color Profile + // on, the file says so, as Photoshop's does (#9325). + let srgb = (st.embed_icc && wdoc.icc_profile.is_none()).then(|| photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()).or(srgb.as_deref()); let xmp = web_xmp(&doc, st.metadata); let exif = web_exif(&doc, st.metadata); let dpi = wdoc.resolution_dpi; @@ -199,6 +203,10 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u .collect(); let single = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()); let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()); + // `folder` (with no `path` or `dir`): the whole image into that folder under the document's + // own name, so an action recorded with the suggested name saves each document it is played + // on under that document's name, as in Photoshop (#5305). + let folder = p.get("folder").and_then(Value::as_str).filter(|_| single.is_none() && dir.is_none()); let mut last_web = p.clone(); // A trusted-markup decision applies to this export only, never to another document opened // with the dialog's remembered settings. @@ -206,12 +214,22 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u last.remove("trustedSliceHtml"); } // Estimate only (the dialog's annotations): the whole image, or each chosen slice. - if single.is_none() && dir.is_none() { + if single.is_none() && dir.is_none() && folder.is_none() { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; return Ok( json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), ); } + if let Some(folder) = folder { + let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; + let name = format!("{}.{}", portable_generated_stem(slices::base_name(&doc)), o.ext); + write(&web_derived_path(folder, &name, native_paths)?, &o.bytes)?; + remember_web_settings(s, cmd, &last_web); + s.file_menu.last_web = Some(last_web); + crate::automate_cmds::fire_event(s, "export"); + let path = join(folder, &name); + return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings})); + } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; diff --git a/crates/engine/src/cmd/web_cmds/settings.rs b/crates/engine/src/cmd/web_cmds/settings.rs index a819cf6864..053604a389 100644 --- a/crates/engine/src/cmd/web_cmds/settings.rs +++ b/crates/engine/src/cmd/web_cmds/settings.rs @@ -10,6 +10,9 @@ pub enum WebFormat { Png24, Jpeg, Wbmp, + /// WebP, lossy (our VP8 encoder) or lossless. Not in Photoshop's legacy dialog, but the + /// format people prepare web images in now (#2415, #6391). + Webp, } impl WebFormat { @@ -20,6 +23,7 @@ impl WebFormat { "png24" | "png-24" | "png" | "png32" => WebFormat::Png24, "jpeg" | "jpg" => WebFormat::Jpeg, "wbmp" => WebFormat::Wbmp, + "webp" => WebFormat::Webp, _ => return None, }) } @@ -29,6 +33,7 @@ impl WebFormat { WebFormat::Png8 | WebFormat::Png24 => "png", WebFormat::Jpeg => "jpg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", } } pub fn indexed(self) -> bool { @@ -68,8 +73,10 @@ pub struct WebSettings { /// Web Snap: palette entries within this distance (0..=1 of the channel range) snap to the /// web-safe cube. pub web_snap: f32, - /// JPEG quality 0..=100. + /// JPEG and lossy WebP quality 0..=100. pub quality: u8, + /// WebP: lossless instead of lossy at `quality`. + pub lossless: bool, pub progressive: bool, pub optimized: bool, pub embed_icc: bool, @@ -92,6 +99,7 @@ impl Default for WebSettings { interlaced: false, web_snap: 0.0, quality: 60, + lossless: false, progressive: false, optimized: true, embed_icc: false, @@ -123,7 +131,7 @@ impl WebSettings { } let s = |k: &str| p.get(k).and_then(Value::as_str); if let Some(f) = s("format") { - st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp)")))?; + st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp|webp)")))?; } if let Some(k) = s("palette") { st.palette = match k { @@ -148,7 +156,13 @@ impl WebSettings { if let Some(a) = p.get("ditherAmount").and_then(Value::as_f64) { st.dither_amount = (a as f32 / 100.0).clamp(0.0, 1.0); } - if let Some(b) = p.get("transparency").and_then(Value::as_bool) { + // An on/off option given as anything but true or false is an error, not its default. + let flag = |k: &str| match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{k}` must be true or false"))), + }; + if let Some(b) = flag("transparency")? { st.transparency = b; } match s("matte") { @@ -159,11 +173,12 @@ impl WebSettings { for (k, slot) in [ ("interlaced", &mut st.interlaced), ("progressive", &mut st.progressive), + ("lossless", &mut st.lossless), ("optimized", &mut st.optimized), ("embedIcc", &mut st.embed_icc), ("convertToSrgb", &mut st.convert_to_srgb), ] { - if let Some(b) = p.get(k).and_then(Value::as_bool) { + if let Some(b) = flag(k)? { *slot = b; } } @@ -217,6 +232,7 @@ impl WebSettings { WebFormat::Png24 => "png24", WebFormat::Jpeg => "jpeg", WebFormat::Wbmp => "wbmp", + WebFormat::Webp => "webp", }; let palette = match self.palette { PaletteKind::Exact => "exact", @@ -242,7 +258,7 @@ impl WebSettings { WebMetadata::All => "all", }; let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2]))); - let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); + let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "lossless": self.lossless, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb}); if let Some(table) = &self.color_table { p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::>()); } diff --git a/crates/engine/src/cmd/web_cmds/tests.rs b/crates/engine/src/cmd/web_cmds/tests.rs index cdc7d576e7..e887b92930 100644 --- a/crates/engine/src/cmd/web_cmds/tests.rs +++ b/crates/engine/src/cmd/web_cmds/tests.rs @@ -201,7 +201,7 @@ fn estimates_every_format_at_every_depth() { for depth in [8, 16, 32] { let mut s = session(depth); let mut sizes = Vec::new(); - for f in ["gif", "png8", "png24", "jpeg", "wbmp"] { + for f in ["gif", "png8", "png24", "jpeg", "wbmp", "webp"] { let r = s.execute("file.export.saveForWebLegacy", json!({"format": f, "colors": 16})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(64), Some(48)), "{f}"); assert!(r["bytes"].as_u64().unwrap() > 20, "{f}"); @@ -299,6 +299,107 @@ fn jpeg_quality_progressive_and_metadata() { assert!(decode(&lo).meta.xmp.is_none()); } +/// WebP (#2415, #6391): lossy at the chosen quality or lossless, keeping translucency unless +/// transparency is off, with the chosen metadata and an optional colour profile. +#[test] +fn webp_lossy_lossless_transparency_and_metadata() { + let dir = tmp("webp"); + let mut s = session(16); + s.execute("file.fileInfo", json!({"copyright": "© Example", "description": "secret notes"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "srgb"})).unwrap(); + let path = |name: &str| format!("{dir}/{name}"); + let mut export = |name: &str, p: Value| { + let mut p = p; + p["format"] = json!("webp"); + p["path"] = json!(path(name)); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + std::fs::read(path(name)).unwrap() + }; + let lo = export("lo.webp", json!({"quality": 5})); + let hi = export("hi.webp", json!({"quality": 95, "embedIcc": true})); + let lossless = export("lossless.webp", json!({"lossless": true, "metadata": "none"})); + let opaque = export("opaque.webp", json!({"transparency": false, "matte": "#00ff00"})); + for bytes in [&lo, &hi, &lossless, &opaque] { + assert_eq!((&bytes[0..4], &bytes[8..12]), (&b"RIFF"[..], &b"WEBP"[..])); + } + assert!(lo.len() < hi.len(), "quality drives the size: {} vs {}", lo.len(), hi.len()); + let has = |b: &[u8], fourcc: &[u8]| b.windows(4).any(|w| w == fourcc); + assert!(has(&hi, b"VP8 ") && has(&hi, b"ALPH"), "lossy keeps translucency in an alpha chunk"); + assert!(has(&lossless, b"VP8L") && !has(&lossless, b"VP8 ")); + assert!(!has(&opaque, b"ALPH"), "transparency off composites over the matte"); + + // Lossless is the PNG-24 export's pixels exactly. + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": path("same.png")})).unwrap(); + let (a, b) = (decode(&path("lossless.webp")), decode(&path("same.png"))); + let rgba = |i: &photocraft_codecs::Image| i.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(); + assert_eq!(rgba(&a), rgba(&b)); + // The transparent corner stays transparent; with transparency off it is the matte. + assert_eq!(rgba(&a)[3], 0); + let o = rgba(&decode(&path("opaque.webp"))); + assert!(o[3] == 255 && o[1] > 200 && o[0] < 60, "{:?}", &o[..4]); + + let meta = decode(&path("hi.webp")); + let xmp = meta.meta.xmp.unwrap_or_default(); + assert!(xmp.contains("Example") && !xmp.contains("secret"), "copyright only"); + assert!(meta.icc.is_some(), "Embed Color Profile"); + assert!(decode(&path("lo.webp")).icc.is_none()); + assert!(a.meta.xmp.is_none(), "metadata none"); +} + +/// Sizes WebP cannot hold are an error, not a crash. +#[test] +fn webp_too_large_is_an_error() { + let mut s = session(8); + for lossless in [false, true] { + let r = s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "width": 20000, "height": 2})); + assert!(r.is_err(), "lossless {lossless}: {r:?}"); + } +} + +#[test] +fn platform_writer_checks_settings_before_dropping_floating_pixels() { + let mut s = session(8); + s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap(); + s.execute("select.float", json!({"dx": 32})).unwrap(); + let rev = s.active().unwrap().revision; + let params = json!({"format": "webp", "path": "moved.webp", "lossless": "yes"}); + let mut output = Vec::new(); + let refused = save_for_web_with_writer(&mut s, ¶ms, &mut |_, bytes| { + output.extend_from_slice(bytes); + Ok(()) + }); + assert!(refused.unwrap_err().to_string().contains("lossless")); + assert!(output.is_empty()); + assert!(s.file_menu.last_web.is_none()); + assert_eq!(s.active().unwrap().revision, rev, "the document is untouched"); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some(), "a refused export must not commit a floating move"); +} + +/// An on/off option that isn't true or false is refused (`"lossless": "true"` would otherwise +/// write lossy WebP), and the refused export writes and remembers nothing. +#[test] +fn non_boolean_flags_are_refused_without_changing_anything() { + let dir = tmp("flags"); + let mut s = session(8); + let out = format!("{dir}/out.webp"); + let flags = ["transparency", "interlaced", "progressive", "lossless", "optimized", "embedIcc", "convertToSrgb"]; + for k in flags { + for v in [json!("true"), json!(1), json!([true])] { + let rev = s.active().unwrap().revision; + let mut p = json!({"format": "webp", "path": out}); + p[k] = v.clone(); + let err = s.execute("file.export.saveForWebLegacy", p).unwrap_err().to_string(); + assert!(err.contains(k), "{k}: {v}: {err}"); + assert_eq!(s.active().unwrap().revision, rev, "{k}: the document is untouched"); + assert!(!std::path::Path::new(&out).exists(), "{k}: nothing written"); + assert!(s.file_menu.last_web.is_none(), "{k}: nothing remembered"); + } + } + // null is the same as leaving it out. + s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "path": out, "lossless": null})).unwrap(); + assert!(std::path::Path::new(&out).exists()); +} + #[test] fn slices_export_with_html_table() { let dir = tmp("slices"); @@ -614,6 +715,7 @@ fn generator_naming_grammar() { fn image_assets_needs_the_generator_preference() { let dir = tmp("generator_pref"); let mut s = session(8); + let doc = s.active().unwrap().doc.id; let id = s.active().unwrap().active_layer.unwrap(); s.execute("layer.renameLayer", json!({"layer": id.0, "name": "logo.png"})).unwrap(); assert!(!s.prefs().plug_ins.generator_enabled, "off by default, as in Photoshop"); @@ -624,19 +726,20 @@ fn image_assets_needs_the_generator_preference() { assert!(s.is_enabled("file.generate.imageAssets")); assert_eq!(s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap()["enabled"], true); s.active_mut().unwrap().path = Some(format!("{dir}/doc.psd")); - assert!(crate::automate_cmds::document_saved(&mut s, 0).is_some(), "assets are written on save"); + assert!(crate::automate_cmds::document_saved(&mut s, doc).is_some(), "assets are written on save"); // Turning it off again takes the menu item away and stops the export, even for a document // left switched on. s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": false})).unwrap(); assert!(!s.is_enabled("file.generate.imageAssets")); - assert!(crate::automate_cmds::document_saved(&mut s, 0).is_none(), "no assets while Generator is off"); + assert!(crate::automate_cmds::document_saved(&mut s, doc).is_none(), "no assets while Generator is off"); } #[test] fn image_assets_are_generated_now_and_on_save() { let dir = tmp("assets"); let mut s = session(8); + let doc = s.active().unwrap().doc.id; // Preferences ▸ Plug-ins ▸ Enable Generator: the feature is opt-in. s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); let id = s.active().unwrap().active_layer.unwrap(); @@ -651,12 +754,12 @@ fn image_assets_are_generated_now_and_on_save() { // On save: written to -assets next to the file. let saved = format!("{dir}/doc.psd"); s.active_mut().unwrap().path = Some(saved.clone()); - let r = crate::automate_cmds::document_saved(&mut s, 0).unwrap(); + let r = crate::automate_cmds::document_saved(&mut s, doc).unwrap(); assert_eq!(r["dir"], json!(format!("{dir}/doc-assets"))); assert!(std::path::Path::new(&format!("{dir}/doc-assets/logo.png")).is_file()); // Off: nothing on save. assert_eq!(s.execute("file.generate.imageAssets", json!({})).unwrap()["enabled"], false); - assert!(crate::automate_cmds::document_saved(&mut s, 0).is_none()); + assert!(crate::automate_cmds::document_saved(&mut s, doc).is_none()); // A default layer adds variants. let s2 = session(8); let doc = s2.active().unwrap().doc.clone(); @@ -673,6 +776,82 @@ fn image_assets_are_generated_now_and_on_save() { assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +/// A Save Document script that closes an earlier clean tab (#11121): the asset generation that +/// follows re-resolves the saved document's identity, so the shifted list neither skips A's +/// outputs nor exports some other tab's. +#[test] +fn a_save_script_closing_another_tab_still_generates_the_saved_documents_assets() { + let dir = tmp("script_close_tab"); + let mut s = Session::new(); + // Document B first (tab 0), then document A with an eligible layer (tab 1, active). + s.execute("file.new", json!({"width": 8, "height": 8, "name": "b"})).unwrap(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "a"})).unwrap(); + let a = s.active().unwrap().doc.id; + s.edit("logo", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("logo.png", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 12, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/a.psd")); + s.execute("file.scripts.scriptEventsManager", json!({"enabled": true, "add": {"event": "saveDocument", "steps": [["file.close", {"document": 0}]]}})) + .unwrap(); + + let r = crate::automate_cmds::document_saved(&mut s, a).expect("A survived the script, so its assets generate"); + assert_eq!(r["files"].as_array().unwrap().len(), 1, "{r}"); + assert!(std::path::Path::new(&format!("{dir}/a-assets/logo.png")).is_file(), "A's asset is written although the script closed B"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.documents()[0].doc.id, a, "the survivor is A, and A's own assets were written"); +} + +/// The sibling condition (#11121): when the script closes the saved document itself, the +/// continuation must generate nothing — least of all the replacement tab's assets. +#[test] +fn a_save_script_closing_the_saved_document_generates_no_replacement_assets() { + let dir = tmp("script_close_saved"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "a"})).unwrap(); + let a = s.active().unwrap().doc.id; + s.edit("logo", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("logo.png", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 12, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/a.psd")); + s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap(); + s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); + s.execute("file.new", json!({"width": 64, "height": 48, "name": "b"})).unwrap(); + s.edit("logo2", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("logo2.png", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 12, 12), &photocraft_raster::from_rgba(&fmt, [0.0, 1.0, 0.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/b.psd")); + s.execute("file.generate.imageAssets", json!({"dir": dir})).unwrap(); + // Both tabs have a path and an eligible layer: whichever tab the continuation mistakes the + // saved one for would produce a file. + // The script closes the saved document (A, tab 0); B shifts up into its place. + s.execute("file.scripts.scriptEventsManager", json!({"enabled": true, "add": {"event": "saveDocument", "steps": [["file.close", {"document": 0}]]}})) + .unwrap(); + + assert!(crate::automate_cmds::document_saved(&mut s, a).is_none(), "the saved document is gone, so nothing generates"); + assert!(!std::path::Path::new(&format!("{dir}/a-assets")).exists()); + assert!(!std::path::Path::new(&format!("{dir}/b-assets/logo2.png")).exists(), "the replacement tab's assets are not exported"); +} + /// Photoshop's Generator exports an artboard named like an asset at the board's size, transparent /// edges included (generator-assets renderer: the canvas bounds are the artboard's), with no /// preference. Layers inside the board are still trimmed to their pixels. @@ -859,6 +1038,9 @@ fn web_settings_from_params() { assert!((st.web_snap - 0.5).abs() < 1e-6); assert!(WebSettings::from_params(&json!({"preset": "Nope"}), "x").is_err()); assert!(WebSettings::from_params(&json!({"palette": "weird"}), "x").is_err()); + let webp = WebSettings::from_params(&json!({"format": "webp", "lossless": true, "quality": 70}), "x").unwrap(); + assert_eq!((webp.format, webp.lossless, webp.quality), (WebFormat::Webp, true, 70)); + assert_eq!(WebSettings::from_params(&webp.to_params(), "x").unwrap(), webp); for p in PRESETS { let st = preset_settings(p).unwrap(); assert_eq!(WebSettings::from_params(&st.to_params(), "x").unwrap(), st, "{p} round-trips through params"); @@ -871,7 +1053,7 @@ fn previews_show_the_optimised_pixels() { let doc = s.active().unwrap().doc.clone(); let (wd, _, _) = web_document(&doc, &json!({}), &WebSettings::default()).unwrap(); let buf = photocraft_compose::flatten(&wd); - for f in ["jpeg", "gif", "png8", "png24", "wbmp"] { + for f in ["jpeg", "gif", "png8", "png24", "wbmp", "webp"] { let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap(); let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap(); assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}"); @@ -1092,6 +1274,33 @@ fn save_for_web_all_writes_the_photos_exif() { assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo"); } +/// WebP holds EXIF in both modes: "All" keeps an EXIF-only photo's camera data (no XMP needed to +/// trigger the metadata), every other choice leaves it out. +#[test] +fn save_for_web_webp_all_keeps_an_exif_only_photos_exif() { + let dir = tmp("webp_exif"); + let mut s = session(8); + s.edit("exif", |doc, _| { + doc.metadata.exif = Some(std::sync::Arc::new(camera_exif())); + Ok(()) + }) + .unwrap(); + for lossless in [false, true] { + for metadata in ["all", "none", "copyright", "allExceptCameraInfo"] { + let path = format!("{dir}/{metadata}-{lossless}.webp"); + s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "metadata": metadata, "path": path})).unwrap(); + let meta = decode(&path).meta; + if metadata == "all" { + let exif = meta.exif.unwrap_or_else(|| panic!("EXIF kept (lossless {lossless})")); + assert_eq!(photocraft_algo::exif::read(&exif).make.as_deref(), Some("TestMake"), "lossless {lossless}"); + assert!(meta.xmp.is_none(), "the photo had no XMP"); + } else { + assert!(meta.exif.is_none(), "{metadata} leaves the camera data out (lossless {lossless})"); + } + } + } +} + #[test] fn quick_export_all_writes_the_photos_exif() { let dir = tmp("quick_exif"); @@ -1168,3 +1377,43 @@ fn quick_export_exr_preserves_linear_hdr_and_alpha() { s.execute("file.export.quickExport", json!({"path": format!("{dir}/explicit.exr")})).unwrap(); assert_eq!(decode(&format!("{dir}/explicit.exr")).dimensions(), (2, 1)); } + +/// #9325: an untagged document is treated as sRGB, and with Embed Color Profile on the file +/// says so, as Photoshop's Save for Web does; off, it still carries none. +#[test] +fn embed_color_profile_tags_an_untagged_document_srgb() { + let mut s = session(8); + assert!(s.active().unwrap().doc.icc_profile.is_none(), "a new document is untagged"); + let dir = tmp("assumed-icc"); + let profile = |path: &str| decode(path).icc.map(|b| photocraft_cms::Profile::parse(&b).unwrap().description); + for (format, ext) in [("jpeg", "jpg"), ("png24", "png")] { + let on = format!("{dir}/on.{ext}"); + s.execute("file.export.saveForWebLegacy", json!({"format": format, "embedIcc": true, "path": on})).unwrap(); + assert_eq!(profile(&on).as_deref(), Some(photocraft_cms::Builtin::Srgb.description()), "{format}"); + let off = format!("{dir}/off.{ext}"); + s.execute("file.export.saveForWebLegacy", json!({"format": format, "embedIcc": false, "path": off})).unwrap(); + assert_eq!(profile(&off), None, "{format}"); + } +} + +/// #5305: `folder` writes the whole image there under the document's own name, so an action +/// recorded with the suggested name saves each document it is played on under that name; it is +/// a destination, never a remembered setting. +#[test] +fn folder_saves_under_the_documents_own_name() { + let dir = tmp("folder_own_name"); + let mut s = session(8); + let r = s.execute("file.export.saveForWebLegacy", json!({"folder": dir, "format": "png24"})).unwrap(); + let first = format!("{dir}/web.png"); + assert_eq!(r["files"], json!([first])); + assert_eq!(decode(&first).dimensions(), (64, 48)); + assert!(s.prefs().dialogs.get("file.export.saveForWebLegacy").is_some_and(|m| m.get("folder").is_none())); + s.execute("file.new", json!({"width": 8, "height": 4, "name": "second.psd"})).unwrap(); + let r = s.execute("file.export.saveForWebLegacy", json!({"folder": dir, "format": "jpeg"})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/second.jpg")])); + assert_eq!(decode(&format!("{dir}/second.jpg")).dimensions(), (8, 4)); + // A `path` wins over `folder`. + let r = s.execute("file.export.saveForWebLegacy", json!({"path": format!("{dir}/named.png"), "folder": dir, "format": "png24"})).unwrap(); + assert_eq!(r["files"], json!([format!("{dir}/named.png")])); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/color_channels.rs b/crates/engine/src/color_channels.rs new file mode 100644 index 0000000000..c19c7e389c --- /dev/null +++ b/crates/engine/src/color_channels.rs @@ -0,0 +1,609 @@ +//! Selected native colour components, shared by commands and live painting. +use std::collections::BTreeSet; + +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +use crate::{EngineError, Result, Session, channel_cmds::ChannelTarget}; + +mod tone; + +/// A bounded, cheap-to-copy set of native colour components (never transparency). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ColorChannels(u64); + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Restriction { + pub colors: ColorChannels, + pub layer: photocraft_doc::LayerId, +} + +impl ColorChannels { + pub fn contains(self, index: usize) -> bool { + index < 64 && self.0 & (1u64.checked_shl(index as u32).unwrap_or(0)) != 0 + } + pub fn indices(self) -> impl Iterator { + (0..64).filter(move |i| self.contains(*i)) + } + pub fn len(self) -> usize { + self.0.count_ones() as usize + } + pub fn is_empty(self) -> bool { + self.0 == 0 + } + pub fn from_indices(indices: impl IntoIterator, count: usize) -> Result { + let mut bits = 0; + for i in indices { + if i >= count || i >= 64 { + return Err(EngineError::Other("colour channel index is out of range".into())); + } + bits |= 1u64.checked_shl(i as u32).unwrap_or(0); + } + if bits == 0 { + return Err(EngineError::Other("select at least one colour channel".into())); + } + Ok(Self(bits)) + } + pub fn params(self) -> Value { + json!({"colors": self.indices().collect::>()}) + } +} + +/// An explicit colour target, checked before command dispatch and allocation. +pub fn explicit(p: &Value, count: usize) -> Result> { + let Some(t) = p.get("target") else { return Ok(None) }; + if (t.get("colors").is_some() || t.get("color").is_some()) && t.as_object().is_none_or(|o| o.len() != 1) { + return Err(EngineError::Other("a colour target must contain exactly one of color or colors".into())); + } + if let Some(v) = t.get("colors") { + let a = v.as_array().filter(|a| a.len() <= 64).ok_or_else(|| EngineError::Other("target.colors must be an array of colour indices".into()))?; + let indices = a + .iter() + .map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| EngineError::Other("colour indices must be nonnegative integers".into()))) + .collect::>>()?; + return ColorChannels::from_indices(indices, count).map(Some); + } + if let Some(v) = t.get("color") { + let i = v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| EngineError::Other("colour index must be a nonnegative integer".into()))?; + return ColorChannels::from_indices([i], count).map(Some); + } + Ok(None) +} + +pub fn selected(s: &Session) -> Option { + let st = s.active()?; + st.channel_view.color_selection(crate::channel_cmds::color_count(&st.doc)) +} + +pub fn target(s: &Session, p: &Value) -> Result> { + let count = s.active().map_or(0, |st| crate::channel_cmds::color_count(&st.doc)); + if p.get("target").is_some() { explicit(p, count) } else { Ok(selected(s)) } +} + +/// Protect unsupported operations before either the UI or automation can edit a whole layer. +pub(crate) fn refusal(s: &Session, id: &str, p: &Value) -> Option { + let colors = match target(s, p) { + Ok(Some(c)) => c, + Ok(None) => return None, + Err(e) => return Some(e.to_string()), + }; + if crate::channel_cmds::routed(id) && !matches!(id, "edit.copy" | "edit.copyMerged") { + let layer = crate::commands::layer_param(s, p).ok().and_then(|id| s.active().and_then(|st| st.doc.layer(id))); + if layer.is_none() && id.starts_with("filter.render.") { + return Some("Select an existing pixel layer before rendering into colour channels".into()); + } + if let Some(layer) = layer + && !matches!(layer.content, photocraft_doc::LayerContent::Raster(_)) + { + return Some("Rasterize the layer before editing colour channels".into()); + } + } + if id == "image.adjustments.hdrToning" { + return Some("HDR Toning requires the composite channel".into()); + } + if crate::channel_cmds::routed(id) && p.get("newLayer").and_then(Value::as_bool) == Some(true) { + return Some("Creating a new layer requires the composite channel".into()); + } + if colors.len() < 2 { + return None; + } + if id.starts_with("edit.transform") + || matches!( + id, + "layer.translate" + | "select.float" + | "edit.freeTransform" + | "edit.contentAwareScale" + | "edit.puppetWarp" + | "edit.perspectiveWarp" + | "layer.smartObjects.puppetWarp" + | "layer.smartObjects.perspectiveWarp" + ) + { + return Some("Move and transforms on several colour channels are not implemented yet".into()); + } + if id == "filter.cameraRaw" { + return Some("Camera Raw requires the composite or one colour channel".into()); + } + None +} + +/// Restore unselected encoded samples, visiting only changed tiles. This preserves signed/HDR +/// floats and NaN payloads, and never round-trips untouched samples through a colour conversion. +pub fn restrict_surface(before: &Surface, after: &mut Surface, colors: ColorChannels) -> Result<()> { + restrict_surface_impl(before, after, colors, None) +} + +/// Write back selected samples within a known document/preview extent, including changed defaults. +pub fn restrict_surface_in(before: &Surface, after: &mut Surface, colors: ColorChannels, extent: photocraft_geom::Rect) -> Result<()> { + restrict_surface_impl(before, after, colors, Some(extent)) +} + +fn restrict_surface_impl(before: &Surface, after: &mut Surface, colors: ColorChannels, extent: Option) -> Result<()> { + if before.format() != after.format() || before.default_bytes() != after.default_bytes() { + let original = before.format(); + let warped = after.format(); + if original.mode == warped.mode && original.sample == warped.sample { + // Geometric filters add transparency to their output. A colour-only edit retains + // the original layout and copies native selected bytes, without a colour round trip. + let bytes = original.sample.bytes(); + let changed_default = colors.indices().any(|channel| { + let offset = channel.saturating_mul(bytes); + let range = offset..offset.saturating_add(bytes); + before.default_bytes().get(range.clone()) != after.default_bytes().get(range) + }); + let mut coords: BTreeSet<_> = before.tiles().map(|(c, _)| *c).chain(after.tiles().map(|(c, _)| *c)).collect(); + if changed_default { + let area = extent.ok_or_else(|| EngineError::Other("a changed channel default needs a bounded writeback extent".into()))?; + if u64::from(area.width()) * u64::from(area.height()) > 268_435_456 { + return Err(EngineError::Other("colour-channel writeback exceeds the materialization limit".into())); + } + coords.extend(area.tiles()); + } + let mut merged = before.clone(); + for coord in coords { + let source = after.tile(coord); + let source_bytes = source.map(|t| t.bytes()); + let dest = merged.try_tile_mut(coord).map_err(|e| EngineError::Other(e.to_string()))?; + let rect = coord.rect(); + let width = rect.width() as usize; + if width == 0 { + return Err(EngineError::Other("invalid colour-channel tile bounds".into())); + } + for (i, pixel) in dest.bytes_mut().chunks_exact_mut(original.bytes_per_pixel()).enumerate() { + if extent.is_some_and(|area| !area.contains(rect.x0.saturating_add((i % width) as i32), rect.y0.saturating_add((i / width) as i32))) { + continue; + } + let offset = i.saturating_mul(warped.bytes_per_pixel()); + let input = + source_bytes.as_deref().and_then(|t| t.get(offset..offset.saturating_add(warped.bytes_per_pixel()))).unwrap_or(after.default_bytes()); + for channel in colors.indices() { + let offset = channel.saturating_mul(bytes); + let range = offset..offset.saturating_add(bytes); + let input = input.get(range.clone()).ok_or_else(|| EngineError::Other("invalid warped colour channel".into()))?; + let output = pixel.get_mut(range).ok_or_else(|| EngineError::Other("invalid destination colour channel".into()))?; + output.copy_from_slice(input); + } + } + } + merged.prune(); + *after = merged; + return Ok(()); + } + return Err(EngineError::Other("a colour-channel edit cannot change the pixel format".into())); + } + let coords: BTreeSet<_> = after + .tiles() + .filter(|(c, t)| before.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))) + .map(|(c, _)| *c) + .chain(before.tiles().filter(|(c, _)| after.tile(**c).is_none()).map(|(c, _)| *c)) + .collect(); + restrict_coords(before, after, colors, coords)?; + after.prune(); + Ok(()) +} + +/// Live painting restores only the just-damaged tiles, rather than revisiting the whole stroke. +pub fn restrict_region(before: &Surface, after: &mut Surface, colors: ColorChannels, damage: photocraft_geom::Rect) -> Result<()> { + if before.format() != after.format() { + return Err(EngineError::Other("a colour-channel edit cannot change the pixel format".into())); + } + let coords = damage + .tiles() + .filter(|c| match (before.tile(*c), after.tile(*c)) { + (None, None) => false, + (Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b), + _ => true, + }) + .collect(); + restrict_coords(before, after, colors, coords) +} + +fn restrict_coords(before: &Surface, after: &mut Surface, colors: ColorChannels, coords: BTreeSet) -> Result<()> { + if before.default_bytes() != after.default_bytes() { + return Err(EngineError::Other("a colour-channel edit cannot change unstored pixels".into())); + } + let fmt = after.format(); + let bytes = fmt.sample.bytes(); + let stride = fmt.bytes_per_pixel(); + if stride == 0 { + return Err(EngineError::Other("invalid pixel format".into())); + } + for c in coords { + let old = before.tile(c); + let old_bytes = old.map(|t| t.bytes()); + let tile = after.try_tile_mut(c).map_err(|e| EngineError::Other(e.to_string()))?; + for (i, pixel) in tile.bytes_mut().chunks_exact_mut(stride).enumerate() { + let original = old_bytes.as_deref().and_then(|t| t.get(i * stride..(i + 1) * stride)).unwrap_or(before.default_bytes()); + for (ch, (out, src)) in pixel.chunks_exact_mut(bytes).zip(original.chunks_exact(bytes)).enumerate() { + if !colors.contains(ch) { + out.copy_from_slice(src); + } + } + } + let empty = tile.content_bounds(before.default_bytes()).is_none(); + if empty { + after.take_tiles(c.rect()); + } + } + Ok(()) +} + +pub(crate) fn from_view(view: &crate::channel_cmds::ChannelView, count: usize) -> Option { + let ChannelTarget::Color(k) = view.target else { return None }; + ColorChannels::from_indices(std::iter::once(k).chain(view.selected_colors.iter().copied()), count).ok() +} + +/// Copy one native component as grayscale tone (CMYK stores ink, opposite to the panel). +pub fn extract(surface: &Surface, component: usize) -> Result { + extract_region(surface, component, None) +} + +/// Extract only the tiles an operation can read. The plane keeps the source default outside. +pub(crate) fn extract_region(surface: &Surface, component: usize, region: Option) -> Result { + use photocraft_color::{ColorMode, PixelFormat, read_sample}; + let fmt = surface.format(); + if component >= fmt.mode.color_channels() { + return Err(EngineError::Other("invalid colour component".into())); + } + let tone = |v: f32| if fmt.mode == ColorMode::Cmyk { 1.0 - v } else { v }; + let default = read_sample(surface.default_bytes(), fmt.sample, component); + let mut out = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, fmt.sample, false), &[tone(default)]); + use rayon::prelude::*; + let tiles: Vec<_> = surface.tiles().filter(|(coord, _)| region.is_none_or(|r| !coord.rect().intersect(&r).is_empty())).collect(); + let done = tiles + .into_par_iter() + .map(|(coord, tile)| { + let mut dest = out.try_default_tile().map_err(|e| EngineError::Other(e.to_string()))?; + for (src, dst) in tile.bytes().chunks_exact(fmt.bytes_per_pixel()).zip(dest.bytes_mut().chunks_exact_mut(fmt.sample.bytes())) { + photocraft_color::write_sample(dst, fmt.sample, 0, tone(read_sample(src, fmt.sample, component))); + } + Ok((*coord, std::sync::Arc::new(dest))) + }) + .collect::>>()?; + out.put_tiles(done); + + Ok(out) +} + +/// Write one component without re-encoding any of the other components or transparency. +pub fn scatter(surface: &mut Surface, plane: &Surface, component: usize, bounds: photocraft_geom::Rect) -> Result<()> { + scatter_selected(surface, plane, component, bounds, None) +} + +pub(crate) fn scatter_selected( + surface: &mut Surface, + plane: &Surface, + component: usize, + bounds: photocraft_geom::Rect, + selection: Option<&Surface>, +) -> Result<()> { + use photocraft_color::{ColorMode, read_sample, write_sample}; + let fmt = surface.format(); + if component >= fmt.mode.color_channels() { + return Err(EngineError::Other("invalid colour component".into())); + } + let plane_format = plane.format(); + if plane_format.mode != ColorMode::Grayscale || plane_format.sample != fmt.sample || plane_format.alpha { + return Err(EngineError::Other("a colour plane must be grayscale without alpha at the destination depth".into())); + } + let transparent = fmt.alpha && surface.default_pixel().last().is_some_and(|a| *a == 0.0); + let mut coords: BTreeSet<_> = surface.tiles().map(|(c, _)| *c).chain(plane.tiles().map(|(c, _)| *c)).collect(); + let old_default = read_sample(surface.default_bytes(), fmt.sample, component); + let old_tone = if fmt.mode == ColorMode::Cmyk { 1.0 - old_default } else { old_default }; + if !transparent && plane.default_pixel().first().is_some_and(|v| *v != old_tone) { + if u64::from(bounds.width()) * u64::from(bounds.height()) > 268_435_456 { + return Err(EngineError::Other("colour-channel edit exceeds the materialization limit".into())); + } + coords.extend(bounds.tiles()); + } + let selected_tiles: Option> = selection + .filter(|mask| mask.default_pixel().first().is_none_or(|v| *v <= 0.0)) + .map(|mask| mask.tiles().filter(|(_, tile)| tile.content_bounds(mask.default_bytes()).is_some()).map(|(coord, _)| *coord).collect()); + use rayon::prelude::*; + let done = coords + .into_par_iter() + .filter(|c| !c.rect().intersect(&bounds).is_empty() && selected_tiles.as_ref().is_none_or(|tiles| tiles.contains(c))) + .map(|coord| { + let area = coord.rect().intersect(&bounds); + let mut dest = match surface.tile(coord) { + Some(t) => t.try_clone(), + None => surface.try_default_tile(), + } + .map_err(|e| EngineError::Other(e.to_string()))?; + let src = plane.tile(coord); + let src_bytes = src.map(|t| t.bytes()); + let default = plane.default_bytes(); + let stride = fmt.bytes_per_pixel(); + let size = photocraft_geom::TILE_SIZE as usize; + for (i, pixel) in dest.bytes_mut().chunks_exact_mut(stride).enumerate() { + let x = coord.rect().x0.saturating_add((i % size) as i32); + let y = coord.rect().y0.saturating_add((i / size) as i32); + if !area.contains(x, y) || selection.is_some_and(|m| m.sample_channel(x, y, 0) <= 0.0) { + continue; + } + let offset = i * fmt.sample.bytes(); + let bytes = src_bytes.as_deref().and_then(|t| t.get(offset..offset + fmt.sample.bytes())).unwrap_or(default); + let v = read_sample(bytes, fmt.sample, 0); + let v = if fmt.mode == ColorMode::Cmyk { 1.0 - v } else { v }; + write_sample(pixel, fmt.sample, component, v); + } + Ok((coord, std::sync::Arc::new(dest))) + }) + .collect::>>()?; + surface.put_tiles(done); + + surface.prune(); + Ok(()) +} + +pub(crate) fn adjust( + surface: &mut Surface, + colors: ColorChannels, + adjustment: &photocraft_doc::Adjustment, + selection: Option<&Surface>, + bounds: photocraft_geom::Rect, +) -> Result<()> { + use photocraft_doc::Adjustment; + if !matches!( + adjustment, + Adjustment::BrightnessContrast { .. } + | Adjustment::Levels { .. } + | Adjustment::Curves { .. } + | Adjustment::Exposure { .. } + | Adjustment::Invert + | Adjustment::Posterize { .. } + | Adjustment::Threshold { .. } + ) { + // Colour adjustments such as Channel Mixer read the original other components. + let before = surface.clone(); + crate::pixels::adjust_surface(surface, adjustment, selection, surface.format().mode).map_err(EngineError::Other)?; + return restrict_surface_in(&before, surface, colors, bounds); + } + tone::adjust(surface, colors, adjustment, selection, bounds) +} + +/// Map a native tone record to the scalar RGB oracle without losing its combined record. +fn scalar_adjustment(adjustment: &photocraft_doc::Adjustment, k: usize) -> photocraft_doc::Adjustment { + use photocraft_doc::{Adjustment, adjust::ToneSpace}; + match adjustment { + Adjustment::Levels { master, per_channel, black, .. } => { + let channel = if k == 3 { black.clone() } else { per_channel.get(k).cloned().unwrap_or_default() }; + Adjustment::Levels { + master: master.clone(), + per_channel: [channel.clone(), channel.clone(), channel], + space: ToneSpace::Rgb, + black: Default::default(), + } + } + Adjustment::Curves { master, per_channel, black, pencil, .. } => { + let channel = if k == 3 { black.clone() } else { per_channel.get(k).cloned().unwrap_or_default() }; + Adjustment::Curves { + master: master.clone(), + per_channel: [channel.clone(), channel.clone(), channel], + space: ToneSpace::Rgb, + black: Vec::new(), + pencil: *pencil, + } + } + other => other.clone(), + } +} + +#[cfg(test)] +fn adjust_reference( + surface: &mut Surface, + colors: ColorChannels, + adjustment: &photocraft_doc::Adjustment, + selection: Option<&Surface>, + bounds: photocraft_geom::Rect, +) -> Result<()> { + use photocraft_doc::Adjustment; + for k in colors.indices() { + let scalar = scalar_adjustment(adjustment, k); + let region = selection.filter(|mask| mask.default_pixel().first().is_none_or(|v| *v <= 0.0)).map(|mask| mask.content_bounds().intersect(&bounds)); + let mut plane = extract_region(surface, k, region)?; + if matches!(adjustment, Adjustment::Invert) + && surface.format().mode == photocraft_color::ColorMode::Lab + && k > 0 + && surface.format().sample != photocraft_color::SampleType::U8 + { + let mirror = 256.0 / 255.0; + let old = plane.clone(); + let weight = selection.map_or(1.0, |s| s.default_pixel().first().copied().unwrap_or(0.0)).clamp(0.0, 1.0); + let value = old.default_pixel().first().copied().unwrap_or(0.0); + plane = Surface::with_default(old.format(), &[value + (mirror - value.clamp(0.0, 1.0) - value) * weight]); + let coords: BTreeSet<_> = + old.tiles().map(|(coord, _)| *coord).chain(selection.into_iter().flat_map(|mask| mask.tiles().map(|(coord, _)| *coord))).collect(); + for coord in coords { + let r = coord.rect(); + let mut values = crate::allocation::read_region(&old, r, "inverting native Lab chroma")?; + for (i, v) in values.iter_mut().enumerate() { + let x = r.x0.saturating_add((i % photocraft_geom::TILE_SIZE as usize) as i32); + let y = r.y0.saturating_add((i / photocraft_geom::TILE_SIZE as usize) as i32); + let weight = selection.map_or(1.0, |s| s.sample_channel(x, y, 0)).clamp(0.0, 1.0); + *v += (mirror - v.clamp(0.0, 1.0) - *v) * weight; + } + plane.try_write_region(r, &values)?; + } + } else { + adjust_plane( + &mut plane, + &scalar, + selection, + photocraft_compose::adjust::Transfer::for_document(surface.format().mode, surface.format().sample), + surface.format().mode == photocraft_color::ColorMode::Rgb, + )?; + } + scatter_selected(surface, &plane, k, region.unwrap_or(bounds), selection)?; + } + Ok(()) +} + +/// Scalar planes have independent tiles. Full-layer tone edits must not serialize all pixels on +/// the UI thread; use the existing scalar oracle on one tile per worker and commit atomically. +#[cfg(test)] +fn adjust_plane( + plane: &mut Surface, + adjustment: &photocraft_doc::Adjustment, + selection: Option<&Surface>, + transfer: photocraft_compose::adjust::Transfer, + rgb_document: bool, +) -> Result<()> { + if selection.is_some() { + return crate::pixels::adjust_mask_with_transfer(plane, adjustment, selection, transfer, rgb_document).map_err(EngineError::Other); + } + use rayon::prelude::*; + let format = plane.format(); + let default = plane.default_pixel(); + let mut out = Surface::with_default(format, &default); + crate::pixels::adjust_mask_with_transfer(&mut out, adjustment, None, transfer, rgb_document).map_err(EngineError::Other)?; + let tiles: Vec<_> = plane.tiles().collect(); + let done = tiles + .into_par_iter() + .map(|(coord, tile)| { + let mut one = Surface::with_default(format, &default); + one.put_tiles([(*coord, (*tile).clone())]); + crate::pixels::adjust_mask_with_transfer(&mut one, adjustment, None, transfer, rgb_document).map_err(EngineError::Other)?; + Ok(one.take_tiles(coord.rect())) + }) + .collect::>>()?; + out.put_tiles(done.into_iter().flatten()); + *plane = out; + Ok(()) +} + +/// A selected set has a real combined record even in Lab, whose normal composite record aliases +/// Lightness. Parse that record as grayscale and retain only explicitly addressed native records. +pub(crate) fn tone_adjustment( + kind: &str, + p: &Value, + mut adjustment: photocraft_doc::Adjustment, + mode: photocraft_color::ColorMode, + colors: ColorChannels, +) -> Result { + use photocraft_doc::Adjustment; + if mode != photocraft_color::ColorMode::Lab { + return Ok(adjustment); + } + let mut neutral = p.clone(); + if let Some(o) = neutral.as_object_mut() { + o.retain(|k, _| !matches!(k.as_str(), "red" | "green" | "blue" | "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b")); + } + let combined = crate::adjust_params::from_params(kind, &neutral, None, photocraft_color::ColorMode::Grayscale)?; + let defaults = crate::adjust_params::default_for(kind, mode)?; + match (&mut adjustment, combined, defaults) { + ( + Adjustment::Levels { master, per_channel, .. }, + Adjustment::Levels { master: group, .. }, + Adjustment::Levels { master: neutral, per_channel: base, .. }, + ) => { + if colors.len() == 1 { + *master = neutral; + if let Some(k) = colors.indices().next() + && p.get(crate::adjust_params::channel_keys(photocraft_doc::adjust::ToneSpace::Lab).get(k).copied().unwrap_or("")).is_none() + && let Some(out) = per_channel.get_mut(k) + { + *out = group; + } + } else { + *master = group; + if p.get("lightness").is_none() + && let (Some(out), Some(original)) = (per_channel.first_mut(), base.first()) + { + *out = original.clone(); + } + } + } + ( + Adjustment::Curves { master, per_channel, .. }, + Adjustment::Curves { master: group, .. }, + Adjustment::Curves { master: neutral, per_channel: base, .. }, + ) => { + if colors.len() == 1 { + *master = neutral; + if let Some(k) = colors.indices().next() + && p.get(crate::adjust_params::channel_keys(photocraft_doc::adjust::ToneSpace::Lab).get(k).copied().unwrap_or("")).is_none() + && let Some(out) = per_channel.get_mut(k) + { + *out = group; + } + } else { + *master = group; + if p.get("lightness").is_none() + && let (Some(out), Some(original)) = (per_channel.first_mut(), base.first()) + { + *out = original.clone(); + } + } + } + _ => {} + } + Ok(adjustment) +} + +#[cfg(test)] +mod extraction_tests { + use super::*; + #[test] + fn added_warp_alpha_keeps_the_original_layout_and_unselected_nan_payloads() { + let format = photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::F32, false); + let payload = f32::from_bits(0x7fc0_1234); + let mut before = Surface::new(format); + before.write_pixel(0, 0, &[0.2, payload, 0.8]); + let mut warped = Surface::new(photocraft_color::PixelFormat { alpha: true, ..format }); + warped.write_pixel(0, 0, &[0.9, 0.0, 0.1, 0.4]); + restrict_surface(&before, &mut warped, ColorChannels::from_indices([0, 2], 3).unwrap()).unwrap(); + assert_eq!(warped.format(), format); + assert_eq!(warped.sample_channel(0, 0, 0), 0.9); + assert_eq!(warped.sample_channel(0, 0, 1).to_bits(), payload.to_bits()); + assert_eq!(warped.sample_channel(0, 0, 2), 0.1); + } + + #[test] + fn geometric_writeback_materializes_changed_defaults_only_within_a_bounded_extent() { + let format = photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::F32, false); + let before = Surface::with_default(format, &[0.3, 0.5, 0.7]); + let mut warped = Surface::new(photocraft_color::PixelFormat { alpha: true, ..format }); + let colors = ColorChannels::from_indices([0, 2], 3).unwrap(); + let original = warped.clone(); + assert!(restrict_surface(&before, &mut warped, colors).is_err()); + assert_eq!(warped, original); + assert!(restrict_surface_in(&before, &mut warped, colors, photocraft_geom::Rect::new(0, 0, i32::MAX, i32::MAX)).is_err()); + assert_eq!(warped, original); + restrict_surface_in(&before, &mut warped, colors, photocraft_geom::Rect::new(1, 1, 3, 3)).unwrap(); + assert_eq!(warped.format(), format); + assert_eq!(warped.pixel(2, 2), vec![0.0, 0.5, 0.0]); + assert_eq!(warped.pixel(0, 0), vec![0.3, 0.5, 0.7]); + assert_eq!(warped.pixel(3, 3), vec![0.3, 0.5, 0.7]); + } + #[test] + fn region_extraction_copies_only_the_required_tiles() { + let mut source = Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::F32, true)); + source.fill_rect(photocraft_geom::Rect::new(0, 0, 512, 512), &[0.2, 0.4, 0.6, 1.0]); + let plane = extract_region(&source, 0, Some(photocraft_geom::Rect::new(0, 0, 16, 16))).unwrap(); + assert_eq!(plane.tile_count(), 1); + assert_eq!(plane.pixel(8, 8)[0], source.pixel(8, 8)[0]); + assert_eq!(plane.pixel(300, 300)[0], 0.0); + } +} diff --git a/crates/engine/src/color_channels/tone.rs b/crates/engine/src/color_channels/tone.rs new file mode 100644 index 0000000000..c64b1debba --- /dev/null +++ b/crates/engine/src/color_channels/tone.rs @@ -0,0 +1,295 @@ +//! One native-tile pass for selected scalar adjustments. Integer tables are evaluated once +//! through the existing compositor oracle; float tiles retain its signed/HDR formulae. +use std::{collections::BTreeSet, sync::Arc}; + +use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, write_sample}; +use photocraft_compose::{Buffer, adjust}; +use photocraft_doc::Adjustment; +use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_raster::Surface; +use rayon::prelude::*; + +use super::{ColorChannels, scalar_adjustment}; +use crate::{EngineError, Result}; + +struct Component { + index: usize, + scalar: Adjustment, + table: Option>, + lab_invert: bool, +} + +struct IntegerTable { + adjusted: Vec, + encoded: Vec<[u8; 4]>, +} + +/// Evaluate independent tones together, cloning each destination tile once. Untouched native +/// samples and the surface default retain their original bytes, including NaN payloads. +pub(super) fn adjust(surface: &mut Surface, colors: ColorChannels, adjustment: &Adjustment, selection: Option<&Surface>, bounds: Rect) -> Result<()> { + let fmt = surface.format(); + if colors.indices().any(|k| k >= fmt.mode.color_channels()) { + return Err(EngineError::Other("invalid colour component".into())); + } + let mut coords: BTreeSet<_> = surface.tiles().map(|(c, _)| *c).chain(selection.into_iter().flat_map(|s| s.tiles().map(|(c, _)| *c))).collect(); + let selected_default = selection.map_or(1.0, |s| s.default_pixel().first().copied().unwrap_or(0.0)).clamp(0.0, 1.0); + let transfer = adjust::Transfer::for_document(fmt.mode, fmt.sample); + let mode = if fmt.mode == ColorMode::Rgb { ColorMode::Rgb } else { ColorMode::Grayscale }; + let depth = match (fmt.sample, adjustment) { + (SampleType::F32, _) | (SampleType::U16, Adjustment::Threshold { .. } | Adjustment::Posterize { .. }) => Some(fmt.sample), + _ => None, + }; + // Building all 65,536 U16 entries amortizes well on large edits. Tiny selections instead + // use tile-local buffers, avoiding a full table's setup cost for a handful of pixels. + let area = selection.filter(|s| s.default_pixel().first().is_none_or(|v| *v <= 0.0)).map_or(bounds, |s| s.content_bounds().intersect(&bounds)); + let use_table = fmt.sample == SampleType::U8 || (fmt.sample == SampleType::U16 && u64::from(area.width()) * u64::from(area.height()) >= 65_536); + let mut components: Vec = Vec::new(); + for index in colors.indices() { + let scalar = scalar_adjustment(adjustment, index); + let lab_invert = fmt.mode == ColorMode::Lab && index > 0 && fmt.sample != SampleType::U8 && matches!(adjustment, Adjustment::Invert); + let shared = components.iter().find(|c| c.scalar == scalar && c.lab_invert == lab_invert).and_then(|c| c.table.clone()); + let table = shared.or_else(|| { + use_table.then(|| { + let count = if fmt.sample == SampleType::U8 { 256 } else { 65_536 }; + let mut buf = Buffer { + rect: Rect::from_xywh(0, 0, count, 1), + px: (0..count) + .map(|i| { + let v = i as f32 / (count - 1) as f32; + [v, v, v, 1.0] + }) + .collect(), + }; + adjust::apply_in(&scalar, &mut buf, transfer, depth, mode); + Arc::new(IntegerTable { adjusted: buf.px.iter().map(|p| p[0]).collect(), encoded: Vec::new() }) + }) + }); + let mut component = Component { index, scalar, table, lab_invert }; + if let Some(table) = &component.table + && table.encoded.is_empty() + { + let quantum = (table.adjusted.len() - 1) as f32; + let encoded = (0..table.adjusted.len()) + .map(|i| { + let tone = input(i as f32 / quantum, fmt); + let adjusted = table.adjusted.get((tone * quantum).round() as usize).copied().unwrap_or(tone); + let mut encoded = [0; 4]; + write_sample(&mut encoded, fmt.sample, 0, output(tone, adjusted, 1.0, &component, fmt)); + encoded + }) + .collect(); + component.table = Some(Arc::new(IntegerTable { adjusted: table.adjusted.clone(), encoded })); + } + let transparent = fmt.alpha && surface.default_pixel().last().is_some_and(|a| *a == 0.0); + if selected_default > 0.0 && !transparent { + let tone = input(read_sample(surface.default_bytes(), fmt.sample, index), fmt); + let mut buf = Buffer { rect: Rect::from_xywh(0, 0, 1, 1), px: vec![[tone, tone, tone, 1.0]] }; + adjust::apply_in(&component.scalar, &mut buf, transfer, depth, mode); + let value = output(tone, buf.px.first().map_or(tone, |p| p[0]), selected_default, &component, fmt); + let mut encoded = surface.default_bytes().to_vec(); + write_sample(&mut encoded, fmt.sample, index, value); + if encoded != surface.default_bytes() { + if u64::from(bounds.width()) * u64::from(bounds.height()) > 268_435_456 { + return Err(EngineError::Other("colour-channel edit exceeds the materialization limit".into())); + } + coords.extend(bounds.tiles()); + } + } + components.push(component); + } + let sparse = selection.filter(|s| s.default_pixel().first().is_none_or(|v| *v <= 0.0)); + let done = coords + .into_par_iter() + .filter(|c| !c.rect().intersect(&area).is_empty() && sparse.is_none_or(|s| s.tile(*c).is_some())) + .map(|coord| { + let tile_rect = coord.rect(); + let region = tile_rect.intersect(&area); + let mut dest = surface.tile(coord).map_or_else(|| surface.try_default_tile(), |t| t.try_clone()).map_err(|e| EngineError::Other(e.to_string()))?; + // Float/short-region work stays bounded to one tile per worker. Each component's + // existing formula is called on a buffer, with no full-layer extraction or scatter. + let mut buffers: Vec<_> = components + .iter() + .map(|c| { + if c.table.is_some() || c.lab_invert { + return None; + } + Some(Buffer { rect: region, px: Vec::with_capacity(region.width() as usize * region.height() as usize) }) + }) + .collect(); + if buffers.iter().any(Option::is_some) { + for (i, pixel) in dest.bytes().chunks_exact(fmt.bytes_per_pixel()).enumerate() { + let x = tile_rect.x0.saturating_add((i % TILE_SIZE as usize) as i32); + let y = tile_rect.y0.saturating_add((i / TILE_SIZE as usize) as i32); + if !region.contains(x, y) { + continue; + } + for (component, buffer) in components.iter().zip(&mut buffers) { + if let Some(buffer) = buffer { + let v = input(read_sample(pixel, fmt.sample, component.index), fmt); + buffer.px.push([v, v, v, 1.0]); + } + } + } + for (component, buffer) in components.iter().zip(&mut buffers) { + if let Some(buffer) = buffer { + adjust::apply_in(&component.scalar, buffer, transfer, depth, mode); + } + } + } + let size = TILE_SIZE as usize; + let mut coverage = [1.0; TILE_SIZE as usize]; + for (row, pixels) in dest.bytes_mut().chunks_exact_mut(size * fmt.bytes_per_pixel()).enumerate() { + let y = tile_rect.y0.saturating_add(row as i32); + if y < region.y0 || y >= region.y1 { + continue; + } + if let Some(mask) = selection { + mask.sample_row_strided(y, tile_rect.x0, 1, 0, &mut coverage); + } + for (column, (pixel, weight)) in pixels.chunks_exact_mut(fmt.bytes_per_pixel()).zip(&coverage).enumerate() { + let x = tile_rect.x0.saturating_add(column as i32); + let weight = weight.clamp(0.0, 1.0); + if !region.contains(x, y) || weight <= 0.0 { + continue; + } + for (c, buf) in components.iter().zip(&buffers) { + // A fully selected integer sample is just an exact native-code lookup. + // No per-pixel float conversion or intermediate scalar encoding is needed. + if weight == 1.0 + && let Some(table) = &c.table + { + let start = c.index * fmt.sample.bytes(); + if let Some(dest) = pixel.get_mut(start..start + fmt.sample.bytes()) { + let index = match fmt.sample { + SampleType::U8 => dest.first().copied().unwrap_or(0) as usize, + SampleType::U16 => dest.first().zip(dest.get(1)).map_or(0, |(&a, &b)| u16::from_ne_bytes([a, b]) as usize), + SampleType::F32 => 0, + }; + if let Some(encoded) = table.encoded.get(index).and_then(|v| v.get(..dest.len())) { + dest.copy_from_slice(encoded); + } + } + continue; + } + let tone = input(read_sample(pixel, fmt.sample, c.index), fmt); + let adjusted = if let Some(table) = &c.table { + let quantum = (table.adjusted.len() - 1) as f32; + table.adjusted.get((tone * quantum).round() as usize).copied().unwrap_or(tone) + } else { + let i = (y - region.y0) as usize * region.width() as usize + (x - region.x0) as usize; + buf.as_ref().and_then(|b| b.px.get(i)).map_or(tone, |p| p[0]) + }; + write_sample(pixel, fmt.sample, c.index, output(tone, adjusted, weight, c, fmt)); + } + } + } + Ok((coord, Arc::new(dest))) + }) + .collect::>>()?; + surface.put_tiles(done); + surface.prune(); + Ok(()) +} + +/// Match scalar-plane encoding, including CMYK's tone/ink complement at integer depths. +fn input(value: f32, fmt: PixelFormat) -> f32 { + if fmt.mode != ColorMode::Cmyk { + return value; + } + let mut encoded = [0; 4]; + write_sample(&mut encoded, fmt.sample, 0, 1.0 - value); + read_sample(&encoded, fmt.sample, 0) +} + +/// Match the scalar oracle's selection mixing and intermediate plane quantization exactly. +fn output(tone: f32, adjusted: f32, weight: f32, component: &Component, fmt: PixelFormat) -> f32 { + let value = if component.lab_invert { + tone + (256.0 / 255.0 - tone.clamp(0.0, 1.0) - tone) * weight + } else { + // Match the scalar compositor: full coverage keeps the edit even over huge HDR values. + photocraft_color::convert::rgb_to_gray([tone * (1.0 - weight) + adjusted * weight; 3]) + }; + let mut encoded = [0; 4]; + write_sample(&mut encoded, fmt.sample, 0, value); + let value = read_sample(&encoded, fmt.sample, 0); + if fmt.mode == ColorMode::Cmyk { 1.0 - value } else { value } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace}; + + #[test] + fn full_coverage_keeps_threshold_over_extreme_float_channels() { + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let bounds = Rect::from_xywh(0, 0, 2, 1); + let mut source = Surface::new(fmt); + source.fill_rect(bounds, &[1e30, -2e30, 1e30, 0.625]); + let mut mask = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false)); + mask.fill_rect(Rect::from_xywh(0, 0, 1, 1), &[1.0]); + let colors = ColorChannels::from_indices([0, 2], 3).unwrap(); + for selection in [None, Some(&mask)] { + let mut actual = source.clone(); + adjust(&mut actual, colors, &Adjustment::Threshold { level: 0.5 }, selection, bounds).unwrap(); + let pixel = actual.pixel(0, 0); + assert_eq!(pixel, vec![1.0, -2e30, 1.0, 0.625]); + if selection.is_some() { + assert_eq!(actual.pixel(1, 0), source.pixel(1, 0)); + } + } + } + + #[test] + fn fused_tiles_match_scalar_oracle_at_all_depths_and_modes() { + let adjustments = [ + Adjustment::Invert, + Adjustment::Threshold { level: 128.0 / 255.0 }, + Adjustment::Posterize { levels: 7 }, + Adjustment::BrightnessContrast { brightness: 17.0, contrast: -21.0, legacy: false }, + Adjustment::BrightnessContrast { brightness: -12.0, contrast: 31.0, legacy: true }, + Adjustment::Exposure { exposure: 0.7, offset: -0.07, gamma: 1.3 }, + Adjustment::Levels { + master: LevelsChannel { gamma: 1.4, ..Default::default() }, + per_channel: std::array::from_fn(|i| LevelsChannel { out_white: 0.7 + i as f32 * 0.1, ..Default::default() }), + black: LevelsChannel { gamma: 0.8, ..Default::default() }, + space: ToneSpace::Cmyk, + }, + Adjustment::Curves { + master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 0.6 }], + per_channel: Default::default(), + black: Vec::new(), + pencil: false, + space: ToneSpace::Lab, + }, + ]; + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat::new(mode, sample, true); + let mut source = Surface::with_default(fmt, &vec![0.3; fmt.channels()]); + let bounds = Rect::from_xywh(-128, -128, 512, 256); + let data: Vec<_> = (0..32 * 24 * fmt.channels()) + .map(|i| { + let v = (i * 37 % 257) as f32 / 256.0; + if sample == SampleType::F32 { v * 3.0 - 0.7 } else { v } + }) + .collect(); + source.write_region(Rect::from_xywh(-3, 5, 32, 24), &data); + let mut mask = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false)); + mask.fill_rect(Rect::from_xywh(-2, 6, 17, 13), &[0.37]); + let mut holes = Surface::with_default(mask.format(), &[0.63]); + holes.fill_rect(Rect::from_xywh(2, 8, 7, 5), &[0.0]); + let colors = ColorChannels::from_indices([0, mode.color_channels() - 1], mode.color_channels()).unwrap(); + for selection in [None, Some(&mask), Some(&holes)] { + for adjustment in &adjustments { + let mut expected = source.clone(); + let mut actual = source.clone(); + super::super::adjust_reference(&mut expected, colors, adjustment, selection, bounds).unwrap(); + adjust(&mut actual, colors, adjustment, selection, bounds).unwrap(); + assert!(expected == actual, "{mode:?} {sample:?} {adjustment:?} selection={}", selection.is_some()); + } + } + } + } + } +} diff --git a/crates/engine/src/command_hints.rs b/crates/engine/src/command_hints.rs new file mode 100644 index 0000000000..2cc210ea25 --- /dev/null +++ b/crates/engine/src/command_hints.rs @@ -0,0 +1,144 @@ +//! "Did you mean …?" hints for unknown command ids and protocol method names (#3674), so an agent +//! that guesses a name gets something to correct from instead of re-listing the whole registry. +//! Only computed on the error path; the registry is small enough to scan. + +use crate::commands; + +/// Names that are documented elsewhere and can't be found by spelling alone: a protocol method +/// mistaken for the engine command that does the same thing. +const ALIASES: &[(&str, &str)] = &[("engine.commands", "command.list"), ("document.undo", "edit.undo"), ("document.redo", "edit.redo")]; + +/// At most this many suggestions per error. +const MAX_SUGGESTIONS: usize = 3; + +/// Up to [`MAX_SUGGESTIONS`] `candidates` close to `name`, best first: a different case or a few +/// typos (edit distance), then names that `name` is the start of (`filter.blur.gaussian`), the +/// same last segment in another namespace (`document.undo` → `edit.undo`), and the same namespace +/// with a longer last segment (`image.size` → `image.imageSize`). Deterministic: within a tier +/// the closest spelling wins, then the name. +pub fn near_matches<'a>(name: &str, candidates: impl IntoIterator) -> Vec<&'a str> { + let lower = name.to_lowercase(); + let max = (lower.chars().count() / 4).clamp(1, 4); + let dotted = lower.split_once('.'); + let tail = |s: &str| s.rsplit('.').next().map(str::to_owned).unwrap_or_default(); + let name_tail = tail(&lower); + let long_tail = name_tail.chars().count() >= 3; + let mut scored: Vec<(usize, usize, &'a str)> = candidates + .into_iter() + .filter(|c| *c != name) + .filter_map(|c| { + let cl = c.to_lowercase(); + if let Some(d) = photocraft_text::spell::edit_distance(&lower, &cl, max) { + return Some((0, d, c)); + } + let (namespace, _) = dotted?; + let cl_tail = tail(&cl); + let tier = if cl.starts_with(&lower) { + 1 + } else if long_tail && cl_tail == name_tail { + 2 + } else if long_tail && cl.split_once('.').is_some_and(|(ns, _)| ns == namespace) && cl_tail.contains(&name_tail) { + 3 + } else { + return None; + }; + Some((tier, photocraft_text::spell::edit_distance(&lower, &cl, 64).unwrap_or(usize::MAX), c)) + }) + .collect(); + scored.sort_unstable(); + scored.dedup(); + scored.into_iter().take(MAX_SUGGESTIONS).map(|(.., c)| c).collect() +} + +/// `"; did you mean `a`?"` / `"; did you mean `a`, `b` or `c`?"`, or `""` for no suggestions. +pub fn did_you_mean(names: &[&str]) -> String { + let quoted: Vec = names.iter().map(|n| format!("`{n}`")).collect(); + match quoted.split_last() { + None => String::new(), + Some((last, [])) => format!("; did you mean {last}?"), + Some((last, rest)) => format!("; did you mean {} or {last}?", rest.join(", ")), + } +} + +/// The hint appended to "unknown command `id`": registered ids close to `id`, or `""`. +pub fn unknown_command_hint(id: &str) -> String { + let mut names: Vec<&str> = ALIASES.iter().filter(|(from, _)| *from == id).map(|(_, to)| *to).collect(); + for n in near_matches(id, commands::command_specs().iter().map(|s| s.id)) { + if names.len() < MAX_SUGGESTIONS && !names.contains(&n) { + names.push(n); + } + } + did_you_mean(&names) +} + +/// The error for `engine.execute` given `id`, a control-protocol method rather than an engine +/// command (shared by the headless server and the desktop control server). +pub fn protocol_method_as_command(id: &str) -> String { + format!("{}; `{id}` is a protocol method: send it as the request's method, not through engine.execute", crate::EngineError::UnknownCommand(id.into())) +} + +/// The error for an unknown protocol request `method`, given the server's `methods`: an engine +/// command id points at `engine.execute`, anything else gets the nearest method names. +pub fn unknown_method(method: &str, methods: &[&str]) -> String { + if commands::find(method).is_some() { + return format!("unknown method `{method}`; `{method}` is an engine command: run it with engine.execute {{\"command\": \"{method}\"}}"); + } + format!("unknown method `{method}`{}", did_you_mean(&near_matches(method, methods.iter().copied()))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{EngineError, Session}; + + fn unknown(id: &str) -> String { + EngineError::UnknownCommand(id.into()).to_string() + } + + #[test] + fn protocol_listing_method_points_at_command_list() { + assert_eq!(unknown("engine.commands"), "unknown command `engine.commands`; did you mean `command.list`?"); + let mut s = Session::new(); + let e = s.execute("engine.commands", json!({})).unwrap_err().to_string(); + assert!(e.contains("did you mean `command.list`"), "{e}"); + assert!(s.disabled_reason("engine.commands").unwrap().contains("`command.list`")); + } + + #[test] + fn near_misses_suggest_registered_ids() { + // Typo, case, and the same verb in another namespace. + assert!(unknown("edit.undoo").contains("did you mean `edit.undo`"), "{}", unknown("edit.undoo")); + assert!(unknown("Edit.Undo").contains("did you mean `edit.undo`"), "{}", unknown("Edit.Undo")); + let e = unknown("document.undo"); + assert!(e.contains("did you mean `edit.undo`"), "{e}"); + assert!(unknown("document.redo").contains("did you mean `edit.redo`")); + // A namespace prefix, a truncated id, a shorter last segment. + assert!(unknown("layer.new").contains("`layer.new.layer`"), "{}", unknown("layer.new")); + assert!(unknown("filter.blur.gaussian").contains("did you mean `filter.blur.gaussianBlur`")); + assert!(unknown("image.size").contains("did you mean `image.imageSize`"), "{}", unknown("image.size")); + assert!(e.len() < 200, "a few suggestions, not a listing: {e}"); + // Nothing close: the plain error. + assert_eq!(unknown("zzzz.qqqq"), "unknown command `zzzz.qqqq`"); + assert_eq!(unknown(""), "unknown command ``"); + } + + #[test] + fn suggestions_are_capped_and_ordered() { + let c = ["a.bc", "a.bd", "a.be", "a.bf", "x.y"]; + assert_eq!(near_matches("a.bb", c), ["a.bc", "a.bd", "a.be"]); + assert_eq!(did_you_mean(&[]), ""); + assert_eq!(did_you_mean(&["a"]), "; did you mean `a`?"); + assert_eq!(did_you_mean(&["a", "b", "c"]), "; did you mean `a`, `b` or `c`?"); + } + + #[test] + fn protocol_method_hints() { + let methods = ["engine.execute", "doc.inspect", "doc.open", "methods"]; + assert_eq!(unknown_method("doc.inspct", &methods), "unknown method `doc.inspct`; did you mean `doc.inspect`?"); + assert!(unknown_method("edit.undo", &methods).contains("engine.execute {\"command\": \"edit.undo\"}")); + assert_eq!(unknown_method("zzzz", &methods), "unknown method `zzzz`"); + assert!(protocol_method_as_command("doc.inspect").contains("`doc.inspect` is a protocol method")); + } +} diff --git a/crates/engine/src/crop_fill.rs b/crates/engine/src/crop_fill.rs index e67ff25844..339da83ce9 100644 --- a/crates/engine/src/crop_fill.rs +++ b/crates/engine/src/crop_fill.rs @@ -75,7 +75,7 @@ impl Coverage { fn holds(&self, x: f64, y: f64) -> bool { let Some(surf) = &self.held else { return false }; let format = surf.format(); - if !format.alpha || !(x.is_finite() && y.is_finite()) { + if !(format.alpha && x.is_finite() && y.is_finite()) { return false; } let a = surf.channels().saturating_sub(1); diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 1c9bc88874..049e126a47 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -157,7 +157,7 @@ fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32 }) .unwrap(); let doc = &s.active().unwrap().doc; - photocraft_compose::render(doc, doc.bounds()).px + photocraft_compose::render(doc, doc.bounds()).into_px() } #[test] @@ -202,12 +202,12 @@ fn smart_displace_keeps_a_relative_map_path() { 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; + let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).into_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) + (sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).into_px()) }; let (params, displaced) = state(&s); assert_eq!(params["mapPath"], json!(path)); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a3a4af7327..afdf86f4fa 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -67,7 +67,7 @@ impl DocState { } /// Pixel bytes of the document, the tiles only its history holds and the tiles only its - /// History panel snapshots hold. + /// History panel snapshots hold; embedded smart object contents count too. pub fn memory_bytes(&self) -> usize { let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect(); self.history.pixel_bytes(&self.doc).saturating_add(self.history.extra_bytes(&self.doc, &snaps)) diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 635089f7a2..0abdb6302a 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,8 +1,10 @@ use super::LayerId; +use crate::command_hints; -#[derive(Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, thiserror::Error)] pub enum EngineError { - #[error("unknown command `{0}`")] + /// Displays with the nearest registered ids, if any ([`command_hints`], #3674). + #[error("unknown command `{id}`{hint}", id = .0, hint = command_hints::unknown_command_hint(.0))] UnknownCommand(String), #[error("command `{0}` is not available right now: {1}")] Disabled(String, String), @@ -16,6 +18,17 @@ pub enum EngineError { /// ids are not positions (#5058). #[error("no such layer {}; {known}", id.0)] UnknownLayer { id: LayerId, known: String }, + /// Opening a PSD/PSB file requires explicit high-memory confirmation. + #[error("opening “{name}” requires explicit memory approval: {required} bytes required (soft limit {soft_limit}, hard limit {hard_limit})")] + PsdApprovalRequired { name: String, required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, + /// The file's estimated memory cannot be admitted within the hard ceiling, which no approval + /// can raise. The requirement may equal the ceiling when the estimate saturated. + #[error("cannot open “{name}”: its estimated memory requirement of {required} bytes cannot be admitted within the {limit} byte hard limit on this machine")] + PsdHardBudget { name: String, required: u64, limit: u64 }, + /// The file behind an opened handle is not the one whose memory estimate was approved. + /// The document is discarded and is not opened. + #[error("“{name}” changed since it was checked; open it again to approve the new file")] + SourceChanged { name: String }, #[error("{0}")] Other(String), /// A background job was cancelled (see [`jobs`]); nothing changed. @@ -25,6 +38,33 @@ pub enum EngineError { pub type Result = std::result::Result; +impl EngineError { + /// Whether an approval-required error also carries the unknown-capacity warning: capacity was + /// unknown when the estimate was made and the estimate is above the warning threshold. A + /// fresh measurement or a known capacity never sets it, and no number is invented for a + /// capacity that could not be measured. + pub fn uncertain_capacity_warning(&self) -> bool { + match self { + Self::PsdApprovalRequired { required, available_ram, .. } => photocraft_io::memory::uncertain_capacity_warning(*required, *available_ram), + _ => false, + } + } +} + +/// Maps an io open/import failure to the engine's typed errors, preserving the memory fields the +/// UI, control channel and agents need to answer or explain it. +pub(crate) fn io_open_error(name: &str, error: photocraft_io::IoError) -> EngineError { + use photocraft_io::IoError; + match error { + IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => { + EngineError::PsdApprovalRequired { name: name.to_string(), required, soft_limit, hard_limit, available_ram } + } + IoError::PsdImportBudget { required, limit } => EngineError::PsdHardBudget { name: name.to_string(), required, limit }, + IoError::SourceChanged => EngineError::SourceChanged { name: name.to_string() }, + error => EngineError::Other(format!("{name}: {error}")), + } +} + impl From for EngineError { fn from(error: photocraft_raster::AllocationError) -> Self { Self::Other(format!("not enough memory for this operation ({error}); the document was not changed")) diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 4d8db05213..9e73d10811 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -241,16 +241,14 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result 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)?; - let layer = match p.get("layer").and_then(Value::as_u64) { - Some(l) => photocraft_doc::LayerId(l), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, - }; + let layer = crate::commands::layer_param(s, p)?; let label = preset(id).map_or(fp.label(), |(name, _)| name).to_string(); let msg = label.clone(); let mut params = p.clone(); if let Value::Object(m) = &mut params { m.remove("__kind"); } + let colors = crate::color_channels::target(s, p)?; let id = id.to_string(); let p = p.clone(); let layer_id = layer.0; @@ -261,6 +259,8 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result &label, move |doc, _, ctx| { let filter = |surf: &photocraft_raster::Surface, fp: &FilterParams, area, bounds, sel: Option<&photocraft_raster::Surface>, extent| { + let _working = photocraft_raster::memory::try_reserve_operation(algo::working_bytes(fp, area, bounds, surf.format().channels())) + .map_err(EngineError::Other)?; ctx.stage(0.0, 1.0, &msg, |ctl| algo::apply_in_with(surf, fp, area, bounds, sel, extent, ctl)).ok_or(EngineError::Cancelled) }; let selection = doc.selection.clone(); @@ -274,7 +274,7 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(layer), &p)? { let content = doc_bounds.union(&surf.content_bounds()); let area = algo::output_area(&fp, content, bounds, sel_bounds); - *surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?; + *surf = filter(surf, &fp, area, bounds, selection.as_ref(), algo::separable::edge_extent(doc_bounds, content))?; return Ok(fp.clone()); } // Filters that make pixels transparent (Color to Alpha) turn the Background into a @@ -323,10 +323,33 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result } _ => return Err(EngineError::Other("not a pixel layer".into())), }; + if let Some(colors) = colors { + if fp.makes_transparency() { + return Err(EngineError::Other("this filter requires composite channels".into())); + } + // A grayscale plane cannot distinguish an opaque black border from its zero + // default. Keep the native surface's extent when finding the filter support. + let content = surf.content_bounds(); + let extent = doc_bounds.union(&content); + let opaque_default = !surf.format().alpha || surf.default_pixel().last().is_some_and(|alpha| *alpha > 0.0); + let support = if opaque_default { extent } else { content }; + let mut area = algo::output_area(&fp, support, bounds, sel_bounds).intersect(&extent); + // Colour-only writes cannot create opacity, so generators need not materialize + // invisible samples across empty parts of a transparent layer. + if !opaque_default && fp.renders_from_nothing() { + area = area.intersect(&content); + } + // Keep the full native input context: filters can read transparency as a depth + // or texture map, and colour-valued settings belong to the source colour model. + let before = surf.clone(); + *surf = filter(surf, &fp, area, bounds, selection.as_ref(), extent)?; + crate::color_channels::restrict_surface_in(&before, surf, colors, extent)?; + return Ok(fp); + } let before = locks.transparency.then(|| surf.clone()); let content = surf.content_bounds(); let area = algo::output_area(&fp, content, bounds, sel_bounds); - *surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?; + *surf = filter(surf, &fp, area, bounds, selection.as_ref(), algo::separable::edge_extent(doc_bounds, content))?; if let Some(before) = &before { keep_alpha(before, surf, &|| ctx.cancelled())?; } @@ -376,6 +399,8 @@ pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_ } } s.write_region(*r, &v); + photocraft_raster::scratch::give(o); + photocraft_raster::scratch::give(v); true }) .reduce(|| true, |a, b| a && b); @@ -477,8 +502,17 @@ pub fn specs() -> Vec { has_filterable_layer(s)?; last_filter(s).map(|_| ()).ok_or_else(|| "no filter applied yet".into()) }, - run: |s, _| { - let (id, params) = last_filter(s).ok_or_else(|| EngineError::Other("no filter applied yet".into()))?; + run: |s, p| { + let (id, mut params) = last_filter(s).ok_or_else(|| EngineError::Other("no filter applied yet".into()))?; + if let Some(target) = p.get("target") { + if params.is_null() { + params = json!({}); + } + if let Some(object) = params.as_object_mut() { + object.insert("target".into(), target.clone()); + } + } + crate::param_check::check(crate::commands::find(&id).ok_or_else(|| EngineError::Other("unknown saved filter".into()))?, ¶ms)?; run_filter(s, &id, ¶ms) }, journal: true, @@ -493,6 +527,7 @@ pub fn last_filter(s: &Session) -> Option<(String, Value)> { let (id, mut params) = s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned()?; if let Value::Object(m) = &mut params { m.remove("layer"); + m.remove("target"); } Some((id, params)) } diff --git a/crates/engine/src/filters/tests.rs b/crates/engine/src/filters/tests.rs index 5b3f3a3703..4d21d2dfdf 100644 --- a/crates/engine/src/filters/tests.rs +++ b/crates/engine/src/filters/tests.rs @@ -79,6 +79,28 @@ const ALL: [&str; 24] = [ "filter.other.offset", ]; +#[test] +fn enormous_global_filter_is_refused_before_allocating_and_preserves_document() { + let mut s = Session::new(); + // A sparse document exercises admission without allocating a giant background first. + s.add_document( + photocraft_doc::Document::new("large", photocraft_doc::Size::new(300000, 300000), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8), + None, + ); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("one pixel", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let err = s.execute("filter.distort.twirl", json!({})).unwrap_err().to_string(); + assert!(err.contains("working memory"), "{err}"); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().doc, before); +} + #[test] fn every_filter_command_runs_changes_pixels_and_undoes() { for id in ALL { @@ -363,6 +385,7 @@ fn smart_object_records_smart_filter() { smart_filters: vec![], cache: Some(cache), psd_raw: None, + vector_preview: None, filters_enabled: true, filter_mask: None, warp: None, diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 2744524a85..6cedd4a022 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -158,12 +158,19 @@ pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> { (rule.accepts(value), rule.expected()) } else { match key.as_str() { - "layer" => (value.as_u64().is_some(), "an unsigned layer id".into()), + "layer" => (value.as_u64().is_some() || value.is_string(), "an unsigned layer id or layer name".into()), "coalesce" => (value.is_string(), "a string".into()), "target" => ( value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "filterMask" | "quickMask")) - || value.as_object().is_some_and(|o| o.len() == 1 && o.get("channel").and_then(Value::as_u64).is_some()), - "pixels, mask, filterMask, quickMask or {\"channel\":index}".into(), + || value.as_object().is_some_and(|o| { + o.len() == 1 + && (o.get("channel").and_then(Value::as_u64).is_some() + || o.get("color").and_then(Value::as_u64).is_some() + || o.get("colors") + .and_then(Value::as_array) + .is_some_and(|a| !a.is_empty() && a.len() <= 64 && a.iter().all(|v| v.as_u64().is_some()))) + }), + "pixels, mask, filterMask, quickMask, {\"channel\":index}, {\"color\":index} or {\"colors\":[indices]}".into(), ), _ => return Err(bad(format!("unknown parameter `{key}`"))), } diff --git a/crates/engine/src/filters/validation/tests.rs b/crates/engine/src/filters/validation/tests.rs index 408a55b365..ebfede162e 100644 --- a/crates/engine/src/filters/validation/tests.rs +++ b/crates/engine/src/filters/validation/tests.rs @@ -138,7 +138,7 @@ fn core_filter_choices_booleans_seeds_and_shared_fields_are_validated() { let result = s.execute(id, json!({"seed":u32::MAX})).unwrap(); assert_eq!(result["filter"]["seed"], json!(u32::MAX)); } - for (key, value) in [("layer", json!("2")), ("target", json!("typo")), ("target", json!({"channel":-1})), ("coalesce", json!(false))] { + for (key, value) in [("layer", json!(false)), ("target", json!("typo")), ("target", json!({"channel":-1})), ("coalesce", json!(false))] { rejected(&mut s, "filter.blur.gaussianBlur", key, value); } } @@ -209,7 +209,7 @@ fn strict_extended_filters_reject_bad_fields_without_editing() { } } for id in STRICT_EXTENDED { - for (key, value) in [("radus", json!(3)), ("layer", json!("2")), ("target", json!("typo")), ("coalesce", json!(false))] { + for (key, value) in [("radus", json!(3)), ("layer", json!(false)), ("target", json!("typo")), ("coalesce", json!(false))] { rejected(&mut s, id, key, value); } for value in [json!([]), json!(false), json!(3), json!("params")] { diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 1afc11220e..963e04e729 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -307,8 +307,8 @@ fn shear(p: &Value) -> FilterParams { fn lens_flare(p: &Value) -> FilterParams { FilterParams::LensFlare { brightness: f(p, "brightness", 100.0).clamp(10.0, 300.0), - center_x: f(p, "centerX", 0.5).clamp(-0.5, 1.5), - center_y: f(p, "centerY", 0.5).clamp(-0.5, 1.5), + center_x: f(p, "centerX", 0.5), + center_y: f(p, "centerY", 0.5), lens: choice(p, "lens", "zoom", LENS_TYPES, LensType::Zoom), } } @@ -725,7 +725,7 @@ pub fn specs() -> Vec { "filter.render.lensFlare", "Lens Flare…", ["Filter", "Render"], - r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"}"## + r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"} (normalized centre: 0..1 is the canvas; agents and actions may pass values outside it, which place the flare off the canvas unclamped)"## ), cmd!( "filter.render.lightingEffects", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 490568e87c..60db1cbb45 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -19,6 +19,35 @@ fn session() -> Session { s } +/// Recorded Lens Flare centers can be beyond the canvas, without clipping to its edge. +#[test] +fn lens_flare_keeps_off_canvas_centers_and_native_defaults() { + let Some(FilterParams::LensFlare { center_x, center_y, .. }) = params_for("filter.render.lensFlare", &json!({"centerX": 5.0, "centerY": -5.0})) else { + panic!("Lens Flare") + }; + assert_eq!((center_x, center_y), (5.0, -5.0)); + let Some(FilterParams::LensFlare { center_x, center_y, .. }) = params_for("filter.render.lensFlare", &json!({})) else { panic!("Lens Flare") }; + assert_eq!((center_x, center_y), (0.5, 0.5)); +} + +/// Extreme centers and malformed inputs remain bounded command executions with finite pixels. +#[test] +fn lens_flare_extreme_centers_do_not_panic_or_corrupt_pixels() { + for lens in ["zoom", "prime35", "prime105", "moviePrime"] { + for p in [ + json!({"centerX": 1e300, "centerY": -1e300, "lens": lens}), + json!({"centerX": 1e38, "centerY": -1e38, "lens": lens}), + json!({"centerX": null, "centerY": [], "lens": lens}), + json!({"centerX": 5, "centerY": -5, "brightness": -100, "lens": lens}), + ] { + let mut s = session(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("filter.render.lensFlare", p.clone()))); + assert!(result.is_ok(), "{p}"); + assert!(pixels(&s).iter().all(|v| v.is_finite()), "non-finite pixels for {p}"); + } + } +} + fn pixels(s: &Session) -> Vec { let d = s.active().unwrap(); d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 32)) diff --git a/crates/engine/src/hidden_target.rs b/crates/engine/src/hidden_target.rs index 977d642f21..c8bcbf54db 100644 --- a/crates/engine/src/hidden_target.rs +++ b/crates/engine/src/hidden_target.rs @@ -52,7 +52,7 @@ pub fn refusal(s: &Session, id: &str, params: &Value) -> Option<&'static str> { return None; } let st = s.active()?; - let layer = params.get("layer").and_then(Value::as_u64).map(LayerId).or(st.active_layer)?; + let layer = crate::commands::layer_param(s, ¶ms).ok()?; (st.doc.layer(layer).is_some() && !shown(&st.doc, layer)).then_some(HIDDEN) } @@ -113,4 +113,19 @@ mod tests { s.execute("channel.new", json!({})).unwrap(); s.execute("paint.stroke", json!({"points": [[10, 10], [20, 20]], "target": {"channel": 0}})).unwrap(); } + + #[test] + fn named_destinations_cannot_bypass_hidden_layer_protection() { + let (mut s, hidden) = session(); + s.execute("layer.setProps", json!({"layer":hidden,"name":"hidden","visible":false})).unwrap(); + s.execute("layer.new.layer", json!({"name":"visible"})).unwrap(); + for command in ["paint.stroke", "paint.pencil", "paint.bucket", "paint.gradient"] { + let params = json!({"layer":"hidden","target":{"colors":[0,2]},"points":[[8,8]],"from":[0,0],"to":[16,16],"x":8,"y":8}); + let before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute(command, params).unwrap_err().to_string().contains(HIDDEN)); + assert_eq!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(hidden)), before.layer(photocraft_doc::LayerId(hidden))); + assert_eq!(s.active().unwrap().history.past_len(), steps); + } + } } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index bf3560eb9f..b6bb17bcd8 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -14,13 +14,18 @@ //! On wasm `start` runs inline (same API, written once). //! - [`Session::cancel_job`] sets the flag; the algorithms check it per tile, row band or //! iteration, and the result is discarded. The document is never touched by a worker. +//! - A job with an effect outside the session (a save replacing its file) calls +//! [`JobCtx::commit`] right before it. From then on it can't be cancelled: every cancel +//! path ([`Session::try_cancel_job`], [`Session::cancel_all_jobs`], closing its document, +//! `jobs.cancel`) leaves it running and says so ([`CancelRefused::Committing`]), so nothing +//! reports a job as cancelled after its effect has happened. //! - A panic in the worker becomes an error; the document is unchanged. //! - While a job runs on a document, commands that would edit that document are disabled with a //! message naming the job (queries and document-independent commands still run). use std::any::Any; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; +use std::sync::{Arc, Condvar, Mutex, PoisonError, Weak}; use photocraft_doc::{DocId, Document, LayerId}; use photocraft_raster::Interrupt; @@ -84,13 +89,51 @@ pub struct JobCtx { #[derive(Default)] struct Shared { - cancel: AtomicBool, + /// [`CANCEL_REQUESTED`], [`WORK_FINISHED`] and [`COMMITTED`] bits. One atomic orders a cancel + /// against [`JobCtx::commit`] and against the worker finishing, so exactly one of them wins. + state: AtomicU8, /// `f32` bits of the progress fraction. Non-negative floats order like their bits, so /// `fetch_max` keeps progress monotonic even with several reporting threads. progress: AtomicU32, message: Mutex, + batch: Mutex, + batch_decision: Condvar, + batch_preview: Mutex>, + batch_preview_generation: std::sync::atomic::AtomicU64, } +/// The current folder Batch input, published as an immutable snapshot for the desktop canvas. +/// It is never added to the live session and cannot receive commands or saves. +#[derive(Clone, Debug)] +pub struct BatchPreview { + pub path: String, + pub document: Arc, + pub generation: u64, +} + +/// The desktop Batch Esc handshake. The worker alone enters `Waiting` at a pre-save +/// checkpoint; only the UI supplies a decision. Programmatic cancellation uses the separate +/// atomic flag above and wakes a waiting worker without asking a question. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum BatchInterrupt { + #[default] + Processing, + Requested, + Waiting, + Continue, + Stop, + Saving, + /// Every input has been handled; Esc is no longer accepted. + Closed, +} + +/// Cancelled before it committed. +const CANCEL_REQUESTED: u8 = 1; +/// The worker has sent its result; a Batch no longer accepts a cancel. +const WORK_FINISHED: u8 = 2; +/// Past [`JobCtx::commit`]: the job runs to the end and can no longer be cancelled. +const COMMITTED: u8 = 4; + impl std::fmt::Debug for JobCtx { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("JobCtx").field("progress", &self.fraction()).field("cancelled", &self.cancelled()).finish() @@ -118,12 +161,173 @@ impl JobCtx { /// Has the job been cancelled? Long loops check this and stop early. pub fn cancelled(&self) -> bool { - self.shared.cancel.load(Ordering::Relaxed) + self.shared.state.load(Ordering::Acquire) & CANCEL_REQUESTED != 0 } - /// Ask the job to stop (idempotent). + /// Ask the job to stop (idempotent). Does nothing once it has [committed](Self::commit). pub fn cancel(&self) { - self.shared.cancel.store(true, Ordering::Relaxed); + let _ = self.try_cancel(); + } + + /// Cancel unless the job has [committed](Self::commit): true if it is cancelled (now or + /// before), false if it is past its point of no return and will run to the end. + pub fn try_cancel(&self) -> bool { + let r = self.shared.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & COMMITTED == 0).then_some(state | CANCEL_REQUESTED)); + if r.is_ok() { + self.wake_batch_worker(); + } + r.is_ok() + } + + /// The job's point of no return, for work with an effect outside the session (a save calls + /// it right before replacing the file). `Ok`: no cancel can stop the job any more; it must + /// run to the end, and its result is applied even if its document was closed meanwhile. + /// `Err(Cancelled)`: it was cancelled first, and nothing may be done. + pub fn commit(&self) -> Result<()> { + self.check()?; + self.shared + .state + .try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & CANCEL_REQUESTED == 0).then_some(state | COMMITTED)) + .map(|_| ()) + .map_err(|_| EngineError::Cancelled) + } + + /// Has the job passed [`Self::commit`]? + pub fn committed(&self) -> bool { + self.shared.state.load(Ordering::Acquire) & COMMITTED != 0 + } + + /// Wake a Batch worker waiting for the Esc decision, so it sees a cancel. + fn wake_batch_worker(&self) { + let _phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + self.shared.batch_decision.notify_all(); + } + + /// Accept a cancellation only while the worker has not finished its result. One atomic state + /// orders a late cancel against completion, including the interval before the UI polls. + fn cancel_if_running(&self) -> bool { + let accepted = self + .shared + .state + .try_update(Ordering::AcqRel, Ordering::Acquire, |state| { + (state & (CANCEL_REQUESTED | WORK_FINISHED | COMMITTED) == 0).then_some(state | CANCEL_REQUESTED) + }) + .is_ok(); + if accepted { + self.wake_batch_worker(); + } + accepted + } + + /// Request the reference application's Esc decision for an interactive Batch. A save that + /// passed its final write checkpoint cannot be interrupted; the caller may try the next + /// input. This does not affect the direct `jobs.cancel` automation API. + pub fn request_batch_interrupt(&self) -> bool { + if self.cancelled() || self.shared.state.load(Ordering::Acquire) & WORK_FINISHED != 0 { + return false; + } + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase != BatchInterrupt::Processing { + return false; + } + *phase = BatchInterrupt::Requested; + true + } + + /// The worker has stopped before writing the current input and awaits Continue or Stop. + pub fn batch_waiting_for_decision(&self) -> bool { + *self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Waiting + } + + /// Answer a waiting interactive Batch question. `continue_next` skips the current input. + pub fn decide_batch_interrupt(&self, continue_next: bool) -> bool { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase != BatchInterrupt::Waiting { + return false; + } + *phase = if continue_next { BatchInterrupt::Continue } else { BatchInterrupt::Stop }; + self.shared.batch_decision.notify_all(); + true + } + + /// Whether an action should stop before its next step because Esc was requested. + pub fn batch_interrupt_requested(&self) -> bool { + *self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Requested + } + + /// Move an accepted Esc request to the question at a worker checkpoint. + pub fn batch_take_interrupt(&self) -> bool { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase != BatchInterrupt::Requested { + return *phase == BatchInterrupt::Waiting; + } + *phase = BatchInterrupt::Waiting; + true + } + + /// Wait for the UI decision without polling. Closing the session or `jobs.cancel` wakes this + /// worker; the caller then reports its partial result. + pub fn batch_wait_for_decision(&self) -> bool { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + while *phase == BatchInterrupt::Waiting && !self.cancelled() { + phase = self.shared.batch_decision.wait(phase).unwrap_or_else(PoisonError::into_inner); + } + let continue_next = *phase == BatchInterrupt::Continue && !self.cancelled(); + *phase = BatchInterrupt::Processing; + continue_next + } + + /// Mark the start of an input's output write. The same lock orders Esc against this + /// checkpoint: a requested interruption never writes that input. + pub(crate) fn batch_before_write(&self) -> Result<()> { + self.check()?; + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase == BatchInterrupt::Requested { + *phase = BatchInterrupt::Waiting; + return Err(EngineError::Cancelled); + } + if self.cancelled() { + return Err(EngineError::Cancelled); + } + *phase = BatchInterrupt::Saving; + Ok(()) + } + + /// The worker has passed its last checkpoint. A pending request is dropped (nothing is left to + /// interrupt) and later requests return false. + pub(crate) fn batch_close_interrupts(&self) { + *self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) = BatchInterrupt::Closed; + self.shared.batch_decision.notify_all(); + } + + /// A completed write allows Esc on the next input. + pub(crate) fn batch_after_write(&self) { + let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner); + if *phase == BatchInterrupt::Saving { + *phase = BatchInterrupt::Processing; + } + } + + /// Publish the current input's document after it opens or an action step changes it. + pub fn publish_batch_preview(&self, path: &str, document: Option>) { + let mut preview = self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner); + if preview.as_ref().zip(document.as_ref()).is_some_and(|(shown, next)| shown.path == path && Arc::ptr_eq(&shown.document, next)) + || (preview.is_none() && document.is_none()) + { + return; + } + let generation = self.shared.batch_preview_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1); + *preview = document.map(|document| BatchPreview { path: path.to_string(), document, generation }); + } + + /// A snapshot for rendering, if the current input still has a document open. + pub fn batch_preview(&self) -> Option { + self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + #[cfg(not(target_arch = "wasm32"))] + fn finish_work(&self) { + self.shared.state.fetch_or(WORK_FINISHED, Ordering::Release); } /// `Err(Cancelled)` once cancelled, for `?` between stages. @@ -151,6 +355,53 @@ impl JobCtx { } } +/// Why [`Session::try_cancel_job`] left a job running. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CancelRefused { + /// No such job is running. + NotRunning, + /// A print belongs to the system print queue once it has started. + Printing, + /// Past its [commit](JobCtx::commit): it is writing its result (a save replacing its file) + /// and will report its real outcome when it ends. + Committing, + /// A Batch already asked to stop, or past its last input: it ends on its own and reports + /// the outputs it wrote. + Finishing, +} + +impl CancelRefused { + /// The scripting name (`jobs.cancel`'s `notCancelled[].reason`). + pub fn as_str(self) -> &'static str { + match self { + Self::NotRunning => "notRunning", + Self::Printing => "printing", + Self::Committing => "committing", + Self::Finishing => "finishing", + } + } +} + +/// What a cancel of several jobs did ([`Session::cancel_all_jobs`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CancelReport { + pub cancelled: Vec, + /// The jobs left running, and why. + pub refused: Vec<(JobId, CancelRefused)>, +} + +impl CancelReport { + /// The `jobs.cancel` reply: `{cancelled: n}`, with `notCancelled: [{job, reason}]` when a + /// job was left running. + pub fn to_json(&self) -> Value { + let mut v = json!({"cancelled": self.cancelled.len()}); + if !self.refused.is_empty() { + v["notCancelled"] = self.refused.iter().map(|(id, why)| json!({"job": id.0, "reason": why.as_str()})).collect(); + } + v + } +} + /// Final state of a job. #[derive(Clone, Debug, PartialEq)] pub enum JobOutcome { @@ -170,6 +421,11 @@ pub struct JobEvent { pub label: String, pub document: Option, pub outcome: JobOutcome, + /// The typed failure behind a failed `outcome`: a hard-budget refusal keeps its + /// name/required/limit, an approval request keeps its limits, a changed source keeps its + /// kind. `None` for done, cancelled and panics without a typed error. Not serialized; + /// `JobInfo::error` remains the display string on the wire. + pub error: Option, } /// A job's state for the UI, the control channel and MCP (`jobs.list`). @@ -187,11 +443,15 @@ pub struct JobInfo { pub progress: f32, pub message: String, pub elapsed_ms: f64, - /// The command's result (done) or the error (failed). + /// The command's result (done), including partial outputs from a cancelled batch. #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// The typed failure behind `error`, for callers in this process (not serialized: the wire + /// form is the message, as before). + #[serde(skip)] + pub error_kind: Option, } type Payload = Box; @@ -222,13 +482,14 @@ struct Running { /// later must not pick up an edit made before it. recorded_by: Option, pre_selected: Option>, + before: crate::actions_record::Before, doc: Option, /// The document as the job saw it: applying fails if it was edited meanwhile. snapshot: Option>, ctx: JobCtx, started_ms: f64, coalesce: Option, - color_restrict: Option, + color_restrict: Option, filter_mask_layer: Option, /// The document's layer selection when the job started: applying targets it, not whatever /// got selected meanwhile (a Type click does that without editing the document, #7366). @@ -273,10 +534,21 @@ impl Running { elapsed_ms: now_ms() - self.started_ms, result: None, error: None, + error_kind: None, } } } +/// A cancelled job's worker that may still be unwinding. +#[cfg(not(target_arch = "wasm32"))] +struct Draining { + handle: std::thread::JoinHandle<()>, + doc: Option, + /// Its document closed: the worker still holds that document's snapshot, so the scratch + /// pool is released again once it exits (the release at close came too early, #10479). + release: bool, +} + /// The session's job table (see the module docs). #[derive(Default)] pub struct Jobs { @@ -292,7 +564,7 @@ pub struct Jobs { recent: Vec, /// Workers of cancelled jobs that may still be unwinding (native). #[cfg(not(target_arch = "wasm32"))] - draining: Vec>, + draining: Vec, /// The context inline job work runs under (see [`Session::set_inline_job_ctx`]). inline: Option, } @@ -330,16 +602,29 @@ fn independent_of_document(id: &str) -> bool { PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id) } +/// Imports the file at `path`, taking its identity from the very handle it decodes. +/// +/// `expected` is the identity approved for this file, if any. It is checked against the opened +/// handle before decoding and again when the memory preflight pauses for approval, so the +/// document opened is the approved file even if the path is replaced meanwhile. `opened` receives +/// the handle's identity as soon as it is known, including when the import stops for approval. #[cfg(not(target_arch = "wasm32"))] fn import_path( name: &str, path: &str, opts: &photocraft_io::ImportOptions, + expected: Option<&SourceFingerprint>, ctl: &photocraft_raster::Interrupt, + opened: &mut Option, ) -> std::result::Result<(photocraft_io::ImportResult, bool), photocraft_io::IoError> { + let expected = match expected { + None => None, + Some(SourceFingerprint::Path { identity, .. }) => Some(identity), + Some(SourceFingerprint::Bytes { .. }) => return Err(photocraft_io::IoError::SourceChanged), + }; // Bounded reads, and a clear error for a file larger than memory (#375); a PSD/PSB is - // parsed from the open file instead of being read whole. - photocraft_io::import_file_for_open_with(name, std::path::Path::new(path), opts, ctl) + // streamed from the open file instead of being read whole. + photocraft_io::import_file_for_open_verified(name, std::path::Path::new(path), expected, opts, ctl, opened) } #[cfg(target_arch = "wasm32")] @@ -347,7 +632,9 @@ fn import_path( _: &str, path: &str, _: &photocraft_io::ImportOptions, + _: Option<&SourceFingerprint>, _: &photocraft_raster::Interrupt, + _: &mut Option, ) -> std::result::Result<(photocraft_io::ImportResult, bool), photocraft_io::IoError> { Err(photocraft_io::IoError::Read(format!("cannot open paths on the web: {path}"))) } @@ -367,6 +654,18 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + // The job reads the active document it captured; only that document's pixels gate it. + let source = s.active().map(|d| d.doc.clone()); + let work = move |ctx: &JobCtx| -> Result { + photocraft_compose::discard_unreadable_pixel_caches(); + let health = || source.as_deref().map_or(Ok(()), photocraft_doc::Document::scratch_health).map_err(EngineError::Other); + health()?; + let generation = photocraft_raster::spill::read_error_generation(); + let value = work(ctx)?; + photocraft_raster::spill::check_since(generation).map_err(EngineError::Other)?; + health()?; + Ok(value) + }; let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); let _authorization = AuthorizationScope::enter(authorization); // The job's document: stored paths it follows are resolved against it on any thread. @@ -406,6 +705,7 @@ pub fn run( let _document = crate::doc_path::scope(document.as_deref()); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged")))); + wctx.finish_work(); // The receiver is gone when the job was cancelled: nothing to report. let _ = tx.send(r); }); @@ -424,6 +724,7 @@ pub fn run( journal: true, recorded_by: None, pre_selected: None, + before: crate::actions_record::Before::capture(s), doc, snapshot, ctx, @@ -514,9 +815,80 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor photocraft_io::ImportOptions { max_group_depth: prefs.file_handling.rasterize_svg_groups_deeper_than as usize, demosaic: prefs.raw_defaults.demosaic.method(), + ..Default::default() } } +/// Fingerprint verifying that a source has not changed between preflight and approval. +/// +/// A path's fingerprint is the [`photocraft_io::FileIdentity`] of an opened handle, never a path +/// stat made before a separate open: the importer takes it from the very handle it decodes +/// (`photocraft_io::import_file_for_open_verified`), so a path replaced or rewritten between +/// the estimate and the approval is refused with [`EngineError::SourceChanged`]. The Unix +/// identity includes the inode change time and nanoseconds, so restoring a modification time +/// cannot hide a same-size rewrite. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SourceFingerprint { + Bytes { len: usize, identity: usize }, + Path { path: String, identity: photocraft_io::FileIdentity }, +} + +impl SourceFingerprint { + /// Fingerprint of `source`. + /// + /// An in-memory source's identity is its shared buffer. A path is inspected here for + /// discovery only; the decode path re-reads its identity from the handle it opens. Fails + /// closed when the identity data cannot be read. + pub fn of(source: &OpenSource) -> Result { + match source { + OpenSource::Bytes(b) => Ok(SourceFingerprint::Bytes { len: b.len(), identity: Arc::as_ptr(b) as usize }), + OpenSource::Path(p) => { + Ok(Self::path(p.clone(), photocraft_io::FileIdentity::of_path(std::path::Path::new(p)).map_err(|e| EngineError::Other(format!("{p}: {e}")))?)) + } + } + } + + /// Fingerprint of the file identity read from an opened handle for `path`. + pub fn path(path: String, identity: photocraft_io::FileIdentity) -> Self { + SourceFingerprint::Path { path, identity } + } + + /// The handle identity a path fingerprint compares against, if this is one. + pub fn file_identity(&self) -> Option<&photocraft_io::FileIdentity> { + match self { + SourceFingerprint::Path { identity, .. } => Some(identity), + SourceFingerprint::Bytes { .. } => None, + } + } + + /// Whether both fingerprints capture the same source identity (and, for a path, the same path). + pub fn matches(&self, other: &Self) -> bool { + self == other + } + + /// Re-reads `source`'s fingerprint and compares it with this one; an unreadable source does not match. + pub fn matches_source(&self, source: &OpenSource) -> bool { + match Self::of(source) { + Ok(cur) => self.matches(&cur), + Err(_) => false, + } + } +} + +/// A pending high-memory PSD preflight request waiting for user confirmation before pixel decoding. +#[derive(Clone, Debug)] +pub struct PendingPsdPreflight { + pub id: u64, + pub name: String, + pub source: OpenSource, + pub fingerprint: SourceFingerprint, + pub required: u64, + pub soft_limit: u64, + pub hard_limit: u64, + pub available_ram: Option, + pub confirm_damage: bool, +} + 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 @@ -532,6 +904,137 @@ impl Session { self.dispatch(id, params, true) } + pub const MAX_PENDING_PREFLIGHTS: usize = 32; + + /// Most bytes of file data pending approvals may keep alive together. + /// + /// This is a retention bound for the *sources* of files waiting for an answer, not a pixel + /// budget and not an admission limit: only an [`OpenSource::Bytes`] source holds data (a path + /// streams from its file once opened), the allocation's capacity is charged rather than its + /// contents, and one shared buffer counts once however many preflights refer to it. 1 GiB is + /// chosen conservatively: it is below the 2 GiB web hard + /// product cap, so even a web session that queues several large dropped files cannot spend + /// the whole hard budget on data it has not decoded, and a native session keeps a comparable + /// amount out of the adaptive allowance. A file that would exceed the cap cannot become + /// pending: it is refused with an error instead of being decoded, and the caller can answer + /// or cancel queued prompts and try again, rather than holding an unbounded amount of memory + /// for unanswered prompts. The record count + /// ([`Self::MAX_PENDING_PREFLIGHTS`]) stays as a separate bound, so many small files are + /// bounded too. + pub const MAX_PENDING_PREFLIGHT_BYTES: u64 = 1 << 30; + + /// Queues an approval prompt, assigning its id, and enforces both the pending-count and + /// retained-byte limits. + pub fn store_pending_preflight(&mut self, preflight: PendingPsdPreflight) -> Result { + self.store_pending_preflight_with_limit(preflight, Self::MAX_PENDING_PREFLIGHT_BYTES) + } + + /// [`Self::store_pending_preflight`] with an explicit retention cap (tests use a tiny one so + /// the boundary is exercised without holding a gigabyte). + pub(crate) fn store_pending_preflight_with_limit(&mut self, mut preflight: PendingPsdPreflight, byte_limit: u64) -> Result { + if self.pending_preflights.len() >= Self::MAX_PENDING_PREFLIGHTS { + return Err(EngineError::Other("too many pending high-memory preflights waiting for confirmation".into())); + } + let held = self.pending_preflight_bytes_with(&preflight.source)?; + if held > byte_limit { + return Err(EngineError::Other(format!( + "too much memory is already held by files waiting for a memory approval ({held} bytes, limit {byte_limit}); answer or cancel one of them and try again" + ))); + } + loop { + self.next_pending_preflight = self.next_pending_preflight.checked_add(1).unwrap_or(1); + if self.next_pending_preflight != 0 && !self.pending_preflights.iter().any(|p| p.id == self.next_pending_preflight) { + break; + } + } + preflight.id = self.next_pending_preflight; + self.pending_preflights.push(preflight); + Ok(self.next_pending_preflight) + } + + /// Bytes held alive by the queued preflights, counting an `Arc>` shared by several of + /// them once. Computed from the queue, so approving, cancelling, refusing or clearing always + /// releases the charge; a rejected preflight never adds one. + pub fn pending_preflight_bytes(&self) -> Result { + Self::retained_source_bytes(self.pending_preflights.iter().map(|p| &p.source)) + } + + /// [`Self::pending_preflight_bytes`] as if `incoming` were queued too, so accepting it can be + /// checked before the list is changed. + fn pending_preflight_bytes_with(&self, incoming: &OpenSource) -> Result { + let mut sources: Vec<&OpenSource> = self.pending_preflights.iter().map(|p| &p.source).collect(); + sources.push(incoming); + Self::retained_source_bytes(sources) + } + + /// Total retained capacity of distinct in-memory sources, counting a shared buffer once. + /// + /// Capacity, not length, is charged because a truncated `Vec` keeps its allocation; overflow + /// is an error rather than a wrapped total. + fn retained_source_bytes<'a>(sources: impl IntoIterator) -> Result { + let mut seen: Vec = Vec::new(); + let mut total: u64 = 0; + for source in sources { + let OpenSource::Bytes(bytes) = source else { continue }; + let identity = Arc::as_ptr(bytes) as usize; + // Distinct live allocations cannot share an address, so identity dedupes clones of + // one buffer without under-counting two different files with equal contents. + if seen.contains(&identity) { + continue; + } + seen.push(identity); + // The allocation is what stays resident, not its contents: a `Vec` that once held a + // large file keeps that capacity after being truncated, so charging `len()` would + // under-count the memory a pending prompt holds. Overflow is an error, never a wrap. + let held = u64::try_from(bytes.capacity()).unwrap_or(u64::MAX); + total = total.checked_add(held).ok_or_else(|| EngineError::Other("held file size overflow while counting pending memory approvals".into()))?; + } + Ok(total) + } + + /// Removes and returns the pending preflight with `id`, releasing the source it held. + pub fn take_pending_preflight(&mut self, id: u64) -> Option { + let i = self.pending_preflights.iter().position(|p| p.id == id)?; + Some(self.pending_preflights.remove(i)) + } + + /// Cancels the pending preflight with `id` by removing it; `None` means no such prompt. + pub fn cancel_pending_preflight(&mut self, id: u64) -> Option { + self.take_pending_preflight(id) + } + + /// Drops every pending preflight, releasing all retained source buffers. + pub fn clear_pending_preflights(&mut self) { + self.pending_preflights.clear(); + } + + /// Starts an open with high-memory approval already granted, on a background worker. + /// `expected_fingerprint`, when given, must still match the decoded source. + pub fn start_open_approved( + &mut self, + name: &str, + source: OpenSource, + confirm_damage: bool, + approved_estimate: Option, + expected_fingerprint: Option, + ) -> Result { + self.start_open_approved_with_background(name, source, confirm_damage, approved_estimate, expected_fingerprint, true) + } + + /// [`Self::start_open_approved`] with an explicit choice between a background worker and + /// inline work. + pub fn start_open_approved_with_background( + &mut self, + name: &str, + source: OpenSource, + confirm_damage: bool, + approved_estimate: Option, + expected_fingerprint: Option, + background: bool, + ) -> Result { + self.start_open_inner(name, source, confirm_damage, true, approved_estimate, expected_fingerprint, background) + } + /// 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. @@ -542,76 +1045,175 @@ impl Session { /// result is `{held, name, warnings, color}` (`color` the question): take it with /// [`Session::take_held_open`] once answered. pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result { - self.start_open_inner(name, source, false) + self.start_open_inner(name, source, false, false, None, None, true) } /// Interactive opening: hold a damaged PSD until the user answers a damage /// confirmation, without changing import or subsequent save behaviour. /// Headless callers keep using [`Self::start_open`]. pub fn start_open_for_user(&mut self, name: &str, source: OpenSource) -> Result { - self.start_open_inner(name, source, true) + self.start_open_for_user_with_background(name, source, true) + } + + /// Interactive open with an explicit background/inline choice: damaged PSD content and + /// high-memory preflights wait for the user instead of opening non-interactively. + pub fn start_open_for_user_with_background(&mut self, name: &str, source: OpenSource, background: bool) -> Result { + self.start_open_inner(name, source, true, false, None, None, background) } /// Decodes a file in a job and optionally holds damaged PSD content for /// interactive confirmation while leaving headless opens non-interactive. - fn start_open_inner(&mut self, name: &str, source: OpenSource, confirm_damage: bool) -> Result { + // Keep the worker and apply closures together with their execution options. + #[allow(clippy::too_many_arguments)] + fn start_open_inner( + &mut self, + name: &str, + source: OpenSource, + confirm_damage: bool, + approve_high_memory: bool, + approved_estimate: Option, + expected_fingerprint: Option, + background: bool, + ) -> Result { let label = format!("Opening {name}"); let name_w = name.to_string(); let name_a = name.to_string(); - let opts = import_options(self.prefs()); - self.start_job( + let mut opts = import_options(self.prefs()); + opts.psd_high_memory_approved = approve_high_memory; + opts.psd_approved_estimate = approved_estimate; + let test_memory_scope = photocraft_io::memory::TestMemoryScope::capture(); + enum WorkResult { + Decoded(Box<(photocraft_io::ImportResult, bool)>), + PreflightNeeded { required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option, source: OpenSource, fingerprint: SourceFingerprint }, + } + self.start_job_with_spawn( OPEN_JOB, json!({"name": name}), &label, false, + background, move |ctx| { + let _test_memory = test_memory_scope.enter(); ctx.progress(0.0, "Reading"); - let imported = match source { + + // An in-memory source's identity is its shared buffer, known before decoding. A + // path's identity can only come from the handle the verified import opens, so + // that import compares it against `expected_fingerprint` itself, before reading + // any pixel and again after its memory preflight. + let initial_fingerprint = match &source { + OpenSource::Bytes(_) => Some(SourceFingerprint::of(&source)?), + OpenSource::Path(_) => None, + }; + if let Some(expected) = &expected_fingerprint + && let Some(current) = &initial_fingerprint + && !expected.matches(current) + { + return Err(EngineError::SourceChanged { name: name_w.clone() }); + } + + let outcome = match source { OpenSource::Bytes(bytes) => { ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)) + let res = ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)); + match res { + Ok(imported) => WorkResult::Decoded(Box::new(imported)), + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { + // The buffer cannot change while it is decoded, so the identity + // captured before reading is still exact. + let fingerprint = initial_fingerprint.clone().ok_or_else(|| EngineError::Other("missing source identity".into()))?; + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: OpenSource::Bytes(bytes), fingerprint } + } + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + return Err(EngineError::PsdApprovalRequired { name: name_w.clone(), required, soft_limit, hard_limit, available_ram }); + } + Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), + Err(e) => return Err(crate::error::io_open_error(&name_w, e)), + } + } + OpenSource::Path(p) => { + let mut opened = None; + let res = ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, expected_fingerprint.as_ref(), ctl, &mut opened)); + match res { + Ok(imported) => WorkResult::Decoded(Box::new(imported)), + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { + // This identity is the one read from the handle that was + // estimated, and the verified import refused a handle that is + // not the approved file or changed while preflighting. + let identity = opened.ok_or_else(|| EngineError::Other(format!("{p}: the file's identity could not be read")))?; + let fingerprint = SourceFingerprint::path(p.clone(), identity); + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: OpenSource::Path(p), fingerprint } + } + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + return Err(EngineError::PsdApprovalRequired { name: name_w.clone(), required, soft_limit, hard_limit, available_ram }); + } + Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), + Err(e) => return Err(crate::error::io_open_error(&name_w, e)), + } + } + }; + + match &outcome { + WorkResult::PreflightNeeded { .. } => Ok(outcome), + WorkResult::Decoded(imported) => { + ctx.progress(0.95, "Preparing layers"); + warm_layer_bounds(&imported.0.document, || ctx.check())?; + if crate::type_extra_cmds::has_type_layers(&imported.0.document) { + ctx.progress(0.97, "Loading fonts"); + let _ = photocraft_text::shared(); + ctx.check()?; + } + Ok(outcome) } - // A PSD/PSB is decoded straight from the file, which is never read whole. - OpenSource::Path(p) => ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, ctl)), } - .map_err(|e| match e { - photocraft_io::IoError::Cancelled => EngineError::Cancelled, - e => EngineError::Other(e.to_string()), - })?; - ctx.progress(0.95, "Preparing layers"); - warm_layer_bounds(&imported.0.document, || ctx.check())?; - // Type layers need the shared text engine, whose first use waits for the - // 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.0.document) { - ctx.progress(0.97, "Loading fonts"); - let _ = photocraft_text::shared(); - ctx.check()?; - } - Ok(imported) }, - move |s, (mut r, damaged): (photocraft_io::ImportResult, bool)| { - // 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)); - let damaged = confirm_damage && damaged; - let question = s.color.open_question(&r.document).filter(|_| !raw); - if damaged || question.is_some() { - let held = s.hold_open(r.document, r.source_read_only); - return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question, "damaged": damaged})); + move |s, outcome| match outcome { + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source, fingerprint } => { + // The warning follows the capacity measured for this estimate: an unknown + // capacity carries no fabricated number, and above the threshold it also + // says the process or tab may run out of memory. The UI shows both. + let capacity_unknown = available_ram.is_none(); + let uncertain_capacity = photocraft_io::memory::uncertain_capacity_warning(required, available_ram); + let id = s.store_pending_preflight(PendingPsdPreflight { + id: 0, + name: name_a.clone(), + source, + fingerprint, + required, + soft_limit, + hard_limit, + available_ram, + confirm_damage, + })?; + Ok(json!({ + "pendingPreflight": id, + "name": name_a, + "estimated": required, + "softLimit": soft_limit, + "hardLimit": hard_limit, + "availableRam": available_ram, + "capacityUnknown": capacity_unknown, + "uncertainCapacityWarning": uncertain_capacity, + })) } - 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; + WorkResult::Decoded(imported) => { + let (mut r, damaged) = *imported; + crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); + crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); + let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let damaged = confirm_damage && damaged; + let question = s.color.open_question(&r.document).filter(|_| !raw); + if damaged || question.is_some() { + let held = s.hold_open(r.document, r.source_read_only); + return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question, "damaged": damaged})); + } + let (index, color) = s.open_document(r.document, None); + 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, "docId": id.0, "name": name_a, "warnings": r.warnings, "color": color})) } - Ok(json!({"document": index, "docId": id.0, "name": name_a, "warnings": r.warnings, "color": color})) }, ) } @@ -630,9 +1232,27 @@ impl Session { work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { - self.jobs.spawn = true; + self.start_job_with_spawn(command, params, label, lock_document, true, work, apply) + } + + // Keep the worker and apply closures together with their execution options. + /// [`Self::start_job`] with an explicit `spawn` flag: when false (or on wasm) the work runs + /// inline and its result is returned directly; otherwise it becomes a background job. + #[allow(clippy::too_many_arguments)] + pub fn start_job_with_spawn( + &mut self, + command: &str, + params: Value, + label: &str, + lock_document: bool, + spawn: bool, + work: impl FnOnce(&JobCtx) -> Result + Send + 'static, + apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, + ) -> Result { + let prev_spawn = self.jobs.spawn; + self.jobs.spawn = spawn && !cfg!(target_arch = "wasm32"); let r = run(self, label, lock_document, work, apply); - self.jobs.spawn = false; + self.jobs.spawn = prev_spawn; let pending = self.jobs.pending.take(); match (r, pending) { (Err(e), Some(job)) => { @@ -672,7 +1292,17 @@ impl Session { } } #[cfg(not(target_arch = "wasm32"))] - self.jobs.draining.retain(|h| !h.is_finished()); + { + let mut release = false; + self.jobs.draining.retain(|d| { + let done = d.handle.is_finished(); + release |= done && d.release; + !done + }); + if release { + photocraft_raster::scratch::release(); + } + } std::mem::take(&mut self.jobs.events) } @@ -687,36 +1317,89 @@ impl Session { match self.jobs.recent.iter().rev().find(|j| j.id == id) { Some(j) if j.state == "done" => Ok(j.result.clone().unwrap_or(Value::Null)), Some(j) if j.state == "cancelled" => Err(EngineError::Cancelled), - Some(j) => Err(EngineError::Other(j.error.clone().unwrap_or_else(|| "the job failed".into()))), + // A typed failure keeps its fields for a caller that answers or explains it; the + // display string stays available through `jobs.list`. + Some(j) => Err(j.error_kind.clone().unwrap_or_else(|| EngineError::Other(j.error.clone().unwrap_or_else(|| "the job failed".into())))), None => Err(EngineError::Other(format!("no job {}", id.0))), } } - /// Cancel job `id`: it stops at its next check, its result is discarded and the document - /// stays unchanged. The document is unlocked at once. False if no such job is running. + /// Cancel job `id`: ordinary jobs stop at their next check, discard their result and leave + /// the document unchanged; the document is unlocked at once. A batch finishes the current + /// input and reports outputs already written. False if no such job is running or it + /// can't be cancelled (see [`Self::try_cancel_job`]). pub fn cancel_job(&mut self, id: JobId) -> bool { - let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return false }; + self.try_cancel_job(id).is_ok() + } + + /// [`Self::cancel_job`], saying why a job was not cancelled. + pub fn try_cancel_job(&mut self, id: JobId) -> std::result::Result<(), CancelRefused> { + let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return Err(CancelRefused::NotRunning) }; + // Earlier batch outputs are already on disk. Let the worker stop after its current + // input and return the completed and skipped lists. + if self.jobs.running[i].command == "file.automate.batch" { + return if self.jobs.running[i].ctx.cancel_if_running() { Ok(()) } else { Err(CancelRefused::Finishing) }; + } // A print worker can already have handed the job to the OS. Discarding its result // would falsely report cancellation; removal belongs to the system print queue. if matches!(self.jobs.running[i].command.as_str(), "file.print" | "file.printOneCopy") { - return false; + return Err(CancelRefused::Printing); + } + // Past its commit (a save replacing its file) the job runs to the end and reports its + // real outcome; calling it cancelled would be false. + if !self.jobs.running[i].ctx.try_cancel() { + return Err(CancelRefused::Committing); } let job = self.jobs.running.remove(i); - job.ctx.cancel(); self.end_job(&job, JobOutcome::Cancelled); #[cfg(not(target_arch = "wasm32"))] - if let Some(h) = job.handle { - self.jobs.draining.push(h); + if let Some(handle) = job.handle { + self.jobs.draining.push(Draining { handle, doc: job.doc, release: false }); } - true + Ok(()) } - /// Cancel every running job (e.g. before quitting). - pub fn cancel_all_jobs(&mut self) { + /// Ask an interactive Batch worker to stop its current input at the next safe checkpoint. + /// Headless clients keep using [`Self::cancel_job`] for a direct partial result. + pub fn request_batch_interrupt_job(&self, id: JobId) -> bool { + self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.request_batch_interrupt()) + } + + /// Whether a Batch worker has acknowledged Esc and is waiting for the desktop decision. + pub fn batch_interrupt_waiting(&self, id: JobId) -> bool { + self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.batch_waiting_for_decision()) + } + + /// Continue with the next input or stop a Batch paused by the desktop Esc key. + pub fn decide_batch_interrupt_job(&self, id: JobId, continue_next: bool) -> bool { + self.jobs + .running + .iter() + .find(|job| job.id == id && job.command == "file.automate.batch") + .is_some_and(|job| job.ctx.decide_batch_interrupt(continue_next)) + } + + /// The current read-only folder Batch document shown by the desktop while work runs. + pub fn batch_preview(&self, id: JobId) -> Option { + self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").and_then(|job| job.ctx.batch_preview()) + } + + /// Cancel every running job (e.g. before quitting). Prints and jobs past their commit keep + /// running; the report lists them. + pub fn cancel_all_jobs(&mut self) -> CancelReport { let ids: Vec = self.jobs.running.iter().map(|r| r.id).collect(); + self.cancel_each(ids) + } + + fn cancel_each(&mut self, ids: Vec) -> CancelReport { + let mut report = CancelReport::default(); for id in ids { - self.cancel_job(id); + match self.try_cancel_job(id) { + Ok(()) => report.cancelled.push(id), + Err(why) => report.refused.push((id, why)), + } } + report } /// Is any job running? (Cheap: the UI asks every frame.) @@ -753,8 +1436,15 @@ impl Session { /// cancel latency with it). Native only; returns at once on wasm. pub fn join_cancelled_jobs(&mut self) { #[cfg(not(target_arch = "wasm32"))] - for h in self.jobs.draining.drain(..) { - let _ = h.join(); + { + let mut release = false; + for d in self.jobs.draining.drain(..) { + let _ = d.handle.join(); + release |= d.release; + } + if release { + photocraft_raster::scratch::release(); + } } } @@ -767,12 +1457,17 @@ impl Session { Some(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", job.label)) } - /// Cancel the jobs editing document `doc` (it is closing). - pub(crate) fn cancel_jobs_on(&mut self, doc: DocId) { + /// Cancel the jobs editing document `doc` (it is closing). A job past its commit keeps + /// running and its result is still applied (see [`JobCtx::commit`]). + pub(crate) fn cancel_jobs_on(&mut self, doc: DocId) -> CancelReport { let ids: Vec = self.jobs.running.iter().filter(|r| r.doc == Some(doc)).map(|r| r.id).collect(); - for id in ids { - self.cancel_job(id); + let report = self.cancel_each(ids); + // Workers cancelled now, or earlier and still unwinding, hold the closing document. + #[cfg(not(target_arch = "wasm32"))] + for d in self.jobs.draining.iter_mut().filter(|d| d.doc == Some(doc)) { + d.release = true; } + report } /// Run the command `id`: inline, or (with `background`) as a job when it is one. @@ -780,6 +1475,7 @@ impl Session { // The offer belongs to this one dispatch. Early errors drop it locally, including // authorization, validation, floating-selection and admission failures. let live_commit = self.live_commit.take(); + self.sync_scratch_state(); let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; // Params are named fields: anything but an object (or null, meaning none) is a caller // mistake, not "use every default". @@ -799,8 +1495,18 @@ impl Session { // (#3089), as Transform Again records its transform (#1440). With no filter yet it falls // through to its own precondition. if id == "filter.lastFilter" - && let Some((filter, p)) = crate::filters::last_filter(self).filter(|(f, _)| f != id) + && let Some((filter, mut p)) = crate::filters::last_filter(self).filter(|(f, _)| f != id) { + for key in ["target", "layer"] { + if let Some(value) = params.get(key) { + if p.is_null() { + p = serde_json::json!({}); + } + if let Some(object) = p.as_object_mut() { + object.insert(key.into(), value.clone()); + } + } + } return self.dispatch(&filter, p, background); } if id == "file.new" { @@ -841,11 +1547,13 @@ impl Session { // 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 }; + let mut record_before = crate::actions_record::Before::capture(self); // A replayable command is recorded as one step, so recording pauses while it runs: an // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; let before = self.active().map(|st| st.doc.clone()); + let docs_before: Vec<_> = self.docs.iter().map(|st| st.doc.id).collect(); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -860,8 +1568,12 @@ impl Session { self.coalesce_request = None; self.color_restrict = None; self.filter_mask_layer = None; + #[cfg(any(test, debug_assertions))] + { + self.hint_before = None; + } let pending = self.jobs.pending.take(); - match (r, pending) { + let out = match (r, pending) { (Err(e), Some(job)) => { job.ctx.cancel(); Err(e) @@ -873,13 +1585,14 @@ impl Session { job.journal = spec.journal; job.recorded_by = self.actions.recording.map(|_| self.actions.take); job.pre_selected = pre_selected.clone(); + job.before = std::mem::take(&mut record_before); let jid = job.id; self.jobs.running.push(job); Ok(Started::Job(jid)) } (Ok(v), None) => { - if let Some(before) = before { - self.add_relayout_damage(&before); + if let Some(before) = &before { + self.add_relayout_damage(before); } if let Some((doc_id, layer_id)) = new_fx_target && let Some(st) = self.active_mut() @@ -892,10 +1605,19 @@ impl Session { st.fx_collapsed.push(layer_id); } } - self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref()); + self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref(), &record_before); Ok(Started::Done(v)) } + }; + // A closed document's freed buffers went to the scratch pool (`before` and the action + // recording snapshot kept it alive until here). Drop both before handing the pool back + // to the OS (#10479). Compare ids since Split/Merge Channels open replacement documents. + if docs_before.iter().any(|id| !self.docs.iter().any(|st| st.doc.id == *id)) { + drop(before); + drop(record_before); + photocraft_raster::scratch::release(); } + out } /// Grows the damage a command reported for the pixels it changed by what effects laid out @@ -930,7 +1652,15 @@ 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, pre_selected: Option<&[LayerId]>) { + fn after_command( + &mut self, + id: &str, + params: Value, + journal: bool, + nested_from: Option, + pre_selected: Option<&[LayerId]>, + before: &crate::actions_record::Before, + ) { // A layer-mask view ends when another layer becomes active (#196). if let Some(st) = self.active_mut() { crate::mask_view_cmds::fix(st); @@ -944,36 +1674,72 @@ impl Session { if let Some(from) = nested_from.filter(|_| crate::actions_cmds::replayable(id)) { self.journal.truncate(from); } - if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { - let params = match id { - _ if self.actions.recording.is_none() => params, - "layer.duplicate" => crate::artboard_cmds::recorded_duplicate(self, crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected)), - "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), - _ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected), - }; - self.journal.push((id.to_string(), params)); + let typed = crate::actions_record::typed_layer(self, id, ¶ms); + let params = match id { + _ if self.actions.recording.is_none() => params, + "layer.duplicate" => crate::artboard_cmds::recorded_duplicate(self, crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected)), + "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), + "edit.checkSpelling" if crate::type_spell_cmds::records(¶ms) => crate::type_spell_cmds::recorded_step(¶ms), + _ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected), + }; + let params = crate::actions_cmds::recorded_params(self, id, params); + let params = crate::actions_record::portable(self, id, params, before); + match crate::actions_record::fold_keystroke(self, id, ¶ms, self.typed_layer) { + crate::actions_record::Keystroke::Folded => {} + crate::actions_record::Keystroke::None => { + if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + self.journal.push((id.to_string(), params)); + } + } } + self.typed_layer = typed; } /// Apply (or report the failure of) a job whose worker has delivered `r`. fn finish_job(&mut self, mut job: Running, r: Result) { let apply = job.apply.take(); - let outcome = match r { - Err(EngineError::Cancelled) => JobOutcome::Cancelled, - Err(e) => JobOutcome::Failed(e.to_string()), - Ok(_) if job.ctx.cancelled() => JobOutcome::Cancelled, + // Keep the typed error beside its display text, so `wait_job` and `JobEvent` can still + // report a hard budget's required/limit or an approval request's limits. + let (outcome, error) = match r { + Err(EngineError::Cancelled) => (JobOutcome::Cancelled, None), + Err(e) => (JobOutcome::Failed(e.to_string()), Some(e)), + Ok(_) if job.ctx.cancelled() && job.command != "file.automate.batch" => (JobOutcome::Cancelled, None), Ok(payload) => match self.apply_job(&job, apply, payload) { - Ok(v) => JobOutcome::Done(v), - Err(EngineError::Cancelled) => JobOutcome::Cancelled, - Err(e) => JobOutcome::Failed(e.to_string()), + Ok(mut v) => { + // A batch may take its final cancellation snapshot before the worker + // finishes. A cancellation accepted during that interval still counts. + if job.command == "file.automate.batch" + && job.ctx.cancelled() + && let Some(result) = v.as_object_mut() + { + result.insert("cancelled".into(), Value::Bool(true)); + } + (JobOutcome::Done(v), None) + } + Err(EngineError::Cancelled) => (JobOutcome::Cancelled, None), + Err(e) => (JobOutcome::Failed(e.to_string()), Some(e)), }, }; - self.end_job(&job, outcome); + self.end_job_with_error(&job, outcome, error); } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, filter_mask_layer, layers, command, params, journal, recorded_by, pre_selected, .. } = - job; + let Running { + label, + doc, + snapshot, + coalesce, + color_restrict, + filter_mask_layer, + layers, + command, + params, + journal, + recorded_by, + pre_selected, + before, + .. + } = 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); @@ -982,6 +1748,10 @@ impl Session { } let prev_active = self.active; let target = match doc { + // A job past its commit has done its work outside the session (a save wrote its + // file); a closed document doesn't undo that, so its result still applies, to no + // document. + Some(d) if job.ctx.committed() && !self.documents().iter().any(|st| st.doc.id == *d) => None, Some(d) => { let i = self.documents().iter().position(|st| st.doc.id == *d).ok_or_else(|| EngineError::Other(format!("`{label}`: the document was closed")))?; @@ -1030,7 +1800,7 @@ 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), pre_selected.as_deref()); + self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref(), before); if foreign && let Some((start, _)) = self.actions.recording.as_mut() { *start = self.journal.len(); } @@ -1054,10 +1824,19 @@ impl Session { } fn end_job(&mut self, job: &Running, outcome: JobOutcome) { + self.end_job_with_error(job, outcome, None); + } + + /// Records a finished job's terminal state (done, failed or cancelled) and optional typed + /// error in the recent-job list, invalidating any live preview. + fn end_job_with_error(&mut self, job: &Running, outcome: JobOutcome, error_kind: Option) { // Every terminal path invalidates a preview, including worker errors, cancellation // and discarded results whose document was closed or changed. self.live_commit = None; let (state, result, error) = match &outcome { + JobOutcome::Done(v) if job.command == "file.automate.batch" && v.get("cancelled").and_then(Value::as_bool) == Some(true) => { + ("cancelled", Some(v.clone()), None) + } JobOutcome::Done(v) => ("done", Some(v.clone()), None), JobOutcome::Failed(e) => ("failed", None, Some(e.clone())), JobOutcome::Cancelled => ("cancelled", None, None), @@ -1066,6 +1845,7 @@ impl Session { info.state = state; info.result = result; info.error = error; + info.error_kind = error_kind.clone(); if state == "done" { info.progress = 1.0; } @@ -1074,7 +1854,7 @@ impl Session { let extra = self.jobs.recent.len() - RECENT; self.jobs.recent.drain(..extra); } - self.jobs.events.push(JobEvent { id: job.id, command: job.command.clone(), label: job.label.clone(), document: job.doc, outcome }); + self.jobs.events.push(JobEvent { id: job.id, command: job.command.clone(), label: job.label.clone(), document: job.doc, outcome, error: error_kind }); } } @@ -1100,19 +1880,17 @@ pub fn specs() -> Vec { params: r##"{"job":u64? (default: every running job)}"##, enabled: |_| Ok(()), run: |s, p| match p.get("job") { - None | Some(Value::Null) => { - let n = s.jobs.running.len(); - s.cancel_all_jobs(); - Ok(json!({"cancelled": n.saturating_sub(s.jobs.running.len())})) - } + None | Some(Value::Null) => Ok(s.cancel_all_jobs().to_json()), Some(v) => { let id = v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: "jobs.cancel".into(), msg: "`job` must be a job id".into() })?; - if s.cancel_job(JobId(id)) { - Ok(json!({"cancelled": 1})) - } else if s.job(JobId(id)).is_some() { - Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into())) - } else { - Err(EngineError::Other(format!("no running job {id}"))) + match s.try_cancel_job(JobId(id)) { + Ok(()) => Ok(json!({"cancelled": 1})), + // Not an error: the job is fine and will report how it ended. + Err(why @ (CancelRefused::Committing | CancelRefused::Finishing)) => { + Ok(CancelReport { cancelled: Vec::new(), refused: vec![(JobId(id), why)] }.to_json()) + } + Err(CancelRefused::Printing) => Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into())), + Err(CancelRefused::NotRunning) => Err(EngineError::Other(format!("no running job {id}"))), } } }, diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 0974a5c17a..109a008826 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -422,6 +422,216 @@ fn jobs_cancel_command_and_bad_params() { assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0); } +/// A fake save of the active document to `target`, gated by the test: it waits for `go`, calls +/// [`JobCtx::commit`] and reports whether it got through, then waits for `finish` and replaces +/// the file. `done` disconnects once the worker has returned. +struct GatedSave { + id: JobId, + go: std::sync::mpsc::Sender<()>, + committed: std::sync::mpsc::Receiver, + finish: std::sync::mpsc::Sender<()>, + done: std::sync::mpsc::Receiver<()>, +} + +impl GatedSave { + fn start(s: &mut Session, target: &std::path::Path) -> Self { + let (go, go_rx) = std::sync::mpsc::channel(); + let (committed_tx, committed) = std::sync::mpsc::channel(); + let (finish, finish_rx) = std::sync::mpsc::channel::<()>(); + let (done_tx, done) = std::sync::mpsc::channel::<()>(); + let target = target.to_path_buf(); + let id = job(s + .start_job( + "file.save", + json!({"path": target}), + "Saving w.psd", + true, + move |ctx| { + let _done = done_tx; + go_rx.recv().ok(); + let r = ctx.commit(); + committed_tx.send(r.is_ok()).ok(); + r?; + finish_rx.recv().ok(); + std::fs::write(&target, b"new").map_err(|e| EngineError::Other(e.to_string()))?; + Ok(target.display().to_string()) + }, + |_, path: String| Ok(json!({"path": path})), + ) + .unwrap()); + Self { id, go, committed, finish, done } + } + + /// Let the worker reach its commit; true if it committed. + fn commit(&self) -> bool { + self.go.send(()).unwrap(); + self.committed.recv_timeout(Duration::from_secs(60)).unwrap() + } + + /// Let a committed worker write the file. + fn finish(&self) { + self.finish.send(()).unwrap(); + } + + /// Block until the worker has returned. + fn join(&self) { + assert!(matches!(self.done.recv_timeout(Duration::from_secs(60)), Err(std::sync::mpsc::RecvTimeoutError::Disconnected))); + } +} + +/// A fresh directory for a [`GatedSave`] test (on every platform, unlike the Unix-only +/// fixtures' `tmp_dir`). +fn save_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-engine-gated-save-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A job on the active document that runs until it is cancelled. +fn open_job(s: &mut Session) -> JobId { + job(s + .start_job( + "test.spin", + json!({}), + "Spinning", + true, + |ctx| loop { + ctx.check()?; + std::thread::sleep(Duration::from_millis(1)); + }, + |_, ()| Ok(json!(null)), + ) + .unwrap()) +} + +fn index_of(s: &Session, doc: photocraft_doc::DocId) -> usize { + s.documents().iter().position(|st| st.doc.id == doc).unwrap() +} + +#[test] +fn cancelling_a_save_before_it_commits_stops_it_without_touching_the_target() { + let dir = save_dir("cancel-before-commit"); + let target = dir.join("w.psd"); + std::fs::write(&target, b"old").unwrap(); + let mut s = session(64, 64); + let save = GatedSave::start(&mut s, &target); + assert_eq!(s.execute("jobs.cancel", json!({"job": save.id.0})).unwrap(), json!({"cancelled": 1})); + assert_eq!(wait_event(&mut s, save.id).outcome, JobOutcome::Cancelled); + // The worker learns it lost at its commit point and writes nothing. + assert!(!save.commit(), "commit after a cancel must fail"); + save.join(); + assert_eq!(std::fs::read(&target).unwrap(), b"old", "target untouched"); + assert!(s.job(save.id).is_none()); +} + +#[test] +fn cancelling_a_committed_save_is_refused_and_it_reports_its_real_outcome() { + let dir = save_dir("cancel-after-commit"); + let target = dir.join("w.psd"); + std::fs::write(&target, b"old").unwrap(); + let mut s = session(64, 64); + let save = GatedSave::start(&mut s, &target); + assert!(save.commit()); + // Every single-job entry point says it was not cancelled. + assert!(!s.cancel_job(save.id)); + assert_eq!(s.try_cancel_job(save.id), Err(CancelRefused::Committing)); + assert_eq!( + s.execute("jobs.cancel", json!({"job": save.id.0})).unwrap(), + json!({"cancelled": 0, "notCancelled": [{"job": save.id.0, "reason": "committing"}]}) + ); + assert!(s.job(save.id).is_some(), "still running, document still locked"); + assert!(s.poll_jobs().is_empty(), "no Cancelled event"); + save.finish(); + let e = wait_event(&mut s, save.id); + assert_eq!(e.outcome, JobOutcome::Done(json!({"path": target.display().to_string()}))); + save.join(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); +} + +#[test] +fn cancel_all_cancels_open_jobs_and_reports_a_committed_save_it_left_running() { + let dir = save_dir("cancel-all-mixed"); + let (committed_target, open_target) = (dir.join("a.psd"), dir.join("c.psd")); + std::fs::write(&committed_target, b"old").unwrap(); + std::fs::write(&open_target, b"old").unwrap(); + let mut s = session(64, 64); + let committed = GatedSave::start(&mut s, &committed_target); + assert!(committed.commit()); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let spinning = open_job(&mut s); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let open = GatedSave::start(&mut s, &open_target); + + let report = s.cancel_all_jobs(); + assert_eq!(report.cancelled, vec![spinning, open.id]); + assert_eq!(report.refused, vec![(committed.id, CancelRefused::Committing)]); + assert_eq!(report.to_json(), json!({"cancelled": 2, "notCancelled": [{"job": committed.id.0, "reason": "committing"}]})); + // `jobs.cancel {}` again: only the committed save is left, and it is still not cancelled. + assert_eq!(s.execute("jobs.cancel", json!({})).unwrap(), json!({"cancelled": 0, "notCancelled": [{"job": committed.id.0, "reason": "committing"}]})); + + // One poll reports both cancellations. + let ended: Vec<_> = s.poll_jobs().into_iter().map(|e| (e.id, e.outcome)).collect(); + assert_eq!(ended, vec![(spinning, JobOutcome::Cancelled), (open.id, JobOutcome::Cancelled)]); + assert!(!open.commit()); + open.join(); + assert_eq!(std::fs::read(&open_target).unwrap(), b"old"); + committed.finish(); + assert!(matches!(wait_event(&mut s, committed.id).outcome, JobOutcome::Done(_))); + committed.join(); + assert_eq!(std::fs::read(&committed_target).unwrap(), b"new"); + assert!(s.jobs().is_empty()); +} + +#[test] +fn closing_documents_cancels_open_saves_but_a_committed_save_finishes() { + let dir = save_dir("cancel-on-close-mixed"); + let (committed_target, open_target) = (dir.join("a.psd"), dir.join("c.psd")); + std::fs::write(&committed_target, b"old").unwrap(); + std::fs::write(&open_target, b"old").unwrap(); + let mut s = session(64, 64); + let a = s.active().unwrap().doc.id; + let committed = GatedSave::start(&mut s, &committed_target); + assert!(committed.commit()); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let b = s.active().unwrap().doc.id; + let spinning = open_job(&mut s); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let c = s.active().unwrap().doc.id; + let open = GatedSave::start(&mut s, &open_target); + + assert_eq!(s.cancel_jobs_on(a).refused, vec![(committed.id, CancelRefused::Committing)]); + s.close(index_of(&s, a)); + s.close(index_of(&s, c)); + assert_eq!(wait_event(&mut s, open.id).outcome, JobOutcome::Cancelled); + assert!(!open.commit()); + open.join(); + assert_eq!(std::fs::read(&open_target).unwrap(), b"old", "the open save's target is untouched"); + // The other document's job is not affected. + assert!(s.job(spinning).is_some()); + // The committed save outlives its document and reports what it did, not "document closed". + committed.finish(); + assert_eq!(wait_event(&mut s, committed.id).outcome, JobOutcome::Done(json!({"path": committed_target.display().to_string()}))); + committed.join(); + assert_eq!(std::fs::read(&committed_target).unwrap(), b"new"); + assert!(s.cancel_job(spinning)); + assert_eq!(s.documents().iter().map(|st| st.doc.id).collect::>(), vec![b]); +} + +#[test] +fn commit_and_cancel_race_with_exactly_one_winner() { + for _ in 0..200 { + let ctx = JobCtx::default(); + let other = ctx.clone(); + let t = std::thread::spawn(move || other.commit().is_ok()); + let cancelled = ctx.try_cancel(); + let committed = t.join().unwrap(); + assert_ne!(cancelled, committed, "exactly one of commit and cancel wins"); + assert_eq!(ctx.cancelled(), cancelled); + assert_eq!(ctx.committed(), committed); + } +} + #[test] fn non_job_commands_still_finish_inline_with_start() { let mut s = session(16, 16); @@ -543,6 +753,174 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() { assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); } +#[test] +fn cancelled_batch_job_returns_its_partial_result() { + let mut s = Session::new(); + let id = job(s + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + |ctx| { + while !ctx.cancelled() { + std::thread::sleep(Duration::from_millis(2)); + } + Ok(json!({"files": ["done.png"], "skipped": ["later.png"], "cancelled": true})) + }, + |_, result| Ok(result), + ) + .unwrap()); + assert!(s.cancel_job(id)); + let event = wait_event(&mut s, id); + let JobOutcome::Done(result) = &event.outcome else { panic!("batch result was discarded: {:?}", event.outcome) }; + assert_eq!(result["files"], json!(["done.png"])); + assert_eq!(result["skipped"], json!(["later.png"])); + assert_eq!(result["cancelled"], true); + let info = s.jobs_with_recent().into_iter().find(|info| info.id == id).unwrap(); + assert_eq!(info.state, "cancelled"); + assert_eq!(info.result.unwrap()["files"], json!(["done.png"])); + assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled))); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn programmatic_cancel_wakes_a_batch_waiting_for_the_desktop_decision() { + let mut s = Session::new(); + let id = job(s + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + |ctx| { + while !ctx.batch_take_interrupt() { + ctx.check()?; + std::thread::yield_now(); + } + let continued = ctx.batch_wait_for_decision(); + Ok(json!({"continued": continued, "files": [], "cancelled": ctx.cancelled()})) + }, + |_, result| Ok(result), + ) + .unwrap()); + assert!(s.request_batch_interrupt_job(id)); + let deadline = Instant::now() + Duration::from_secs(10); + while !s.batch_interrupt_waiting(id) && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(s.batch_interrupt_waiting(id)); + assert!(s.cancel_job(id), "jobs.cancel remains direct and wakes the worker"); + let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("batch partial result was lost") }; + assert_eq!(result["continued"], false); + assert_eq!(result["cancelled"], true); +} + +#[test] +fn accepted_batch_cancellation_after_result_snapshot_is_reported() { + let mut s = Session::new(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (resume_tx, resume_rx) = std::sync::mpsc::channel(); + let id = job(s + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + move |ctx| { + let cancelled = ctx.cancelled(); + ready_tx.send(()).unwrap(); + resume_rx.recv().unwrap(); + Ok(json!({"files": ["done.png"], "cancelled": cancelled})) + }, + |_, result| Ok(result), + ) + .unwrap()); + ready_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(s.cancel_job(id)); + resume_tx.send(()).unwrap(); + let event = wait_event(&mut s, id); + let JobOutcome::Done(result) = event.outcome else { panic!("batch result was discarded") }; + assert_eq!(result["cancelled"], true); + assert_eq!(result["files"], json!(["done.png"])); + let info = s.jobs_with_recent().into_iter().find(|info| info.id == id).unwrap(); + assert_eq!(info.state, "cancelled"); +} + +#[test] +fn cancel_all_counts_an_accepted_batch_cancellation() { + let mut s = Session::new(); + let id = job(s + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + |ctx| { + while !ctx.cancelled() { + std::thread::sleep(Duration::from_millis(2)); + } + Ok(json!({"files": [], "cancelled": true})) + }, + |_, result| Ok(result), + ) + .unwrap()); + assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 1); + assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0, "a repeated request is not a new cancellation"); + assert_eq!(s.execute("jobs.cancel", json!({"job": id.0})).unwrap()["cancelled"], 0, "a repeated batch request is not a print error"); + assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_))); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_finished_batch_worker_rejects_late_cancellation() { + let mut s = Session::new(); + let id = job(s + .start_job("file.automate.batch", json!({}), "Batch", false, |_| Ok(json!({"files": ["done.png"], "cancelled": false})), |_, result| Ok(result)) + .unwrap()); + let started = Instant::now(); + while !s.jobs.running.iter().any(|running| running.id == id && running.handle.as_ref().is_some_and(|handle| handle.is_finished())) { + assert!(started.elapsed() < Duration::from_secs(10), "batch worker did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } + assert!(!s.cancel_job(id), "a result already sent cannot be cancelled"); + let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("completed batch result was lost") }; + assert_eq!(result["files"], json!(["done.png"])); + assert_eq!(result["cancelled"], false); +} + +/// A flat scanline EXR opened from a path goes through the band decoder in the open job and +/// arrives as the same 32-bit document the whole-file decoder makes; cancelling adds nothing. +#[cfg(any(unix, windows))] +#[test] +fn an_exr_path_open_decodes_in_bands_and_cancels_cleanly() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; + let values: Vec = (0..64 * 48 * 3).map(|i| [0.25, 3.0, -0.5][i % 3]).collect(); + let image = Image::from_f32(64, 48, ChannelLayout::Rgb, &values).unwrap().convert(ChannelLayout::Rgb, SampleType::F16); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: ExrCompression::Zip16, ..Default::default() }).unwrap(); + let path = std::env::temp_dir().join(format!("photocraft-open-job-{}.exr", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let reference = photocraft_io::import("band.exr", &bytes).unwrap().document; + let mut s = Session::new(); + let id = job(s.start_open("band.exr", OpenSource::Path(path.to_string_lossy().into_owned())).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + let doc = &s.documents()[0].doc; + assert_eq!((doc.size, doc.mode, doc.depth), (reference.size, reference.mode, reference.depth)); + use photocraft_doc::LayerContent; + let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&doc.layers[0].content, &reference.layers[0].content) else { panic!("raster background") }; + assert_eq!(a.to_interleaved(reference.bounds()), r.to_interleaved(reference.bounds())); + + let id = job(s.start_open("again.exr", OpenSource::Path(path.to_string_lossy().into_owned())).unwrap()); + s.cancel_job(id); + let outcome = wait_event(&mut s, id).outcome; + assert!(matches!(outcome, JobOutcome::Cancelled | JobOutcome::Done(_)), "{outcome:?}"); + if outcome == JobOutcome::Cancelled { + assert_eq!(s.documents().len(), 1, "a cancelled open adds no document"); + } + std::fs::remove_file(path).unwrap(); +} + /// Ask When Opening: a file whose profile doesn't match is decoded but not added; the shell takes /// it once the prompt is answered. Without Ask it opens as usual. #[test] @@ -782,3 +1160,207 @@ fn every_job_terminal_path_drops_a_live_commit_stroke() { assert!(s.live_commit.is_none(), "terminal path {outcome} retained the preview"); } } + +#[cfg(unix)] +fn tmp_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-engine-jobs-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A tiny real PSD fixture, so an admission test never allocates anything near a threshold. +fn tiny_psd() -> Vec { + super::psd_open_fixture::make_psd(false) +} + +#[cfg(target_pointer_width = "64")] +#[test] +fn approved_open_forwards_the_flag_and_the_bound_estimate() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + // Known 8 GiB: S = 4 GiB, H = 6 GiB. The test charge puts the estimate just over S. + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snapshot, || { + photocraft_io::psd_budget::with_test_estimate_charge(4 * gb + 1, || { + let mut s = Session::new(); + let started = s.start_open_for_user_with_background("tiny.psd", OpenSource::Bytes(Arc::clone(&bytes)), false).unwrap(); + let v = match started { + Started::Done(v) => v, + Started::Job(id) => s.wait_job(id).unwrap(), + }; + let id = v["pendingPreflight"].as_u64().unwrap_or_else(|| panic!("expected a memory preflight: {v}")); + assert!(v["estimated"].as_u64().unwrap() > 4 * gb, "the seam's charge is part of the estimate: {v}"); + assert!(s.documents().is_empty()); + // Approving forwards the one-shot flag and estimate through the real import path. + let r = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})).unwrap(); + assert_eq!(r["approved"], json!(true), "{r}"); + assert!(r["result"].get("document").is_some(), "the approved import opened the document: {r}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "approval releases the held source bytes"); + }); + }); +} + +#[cfg(target_pointer_width = "64")] +#[test] +fn approval_is_bound_to_the_approved_estimate() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snapshot, || { + photocraft_io::psd_budget::with_test_estimate_charge(4 * gb + 1, || { + let mut s = Session::new(); + let source = OpenSource::Bytes(Arc::clone(&bytes)); + let fingerprint = SourceFingerprint::of(&source).unwrap(); + // Consent given for a much smaller estimate does not cover this file. + let err = s + .start_open_approved_with_background("tiny.psd", source, false, Some(1 << 20), Some(fingerprint), false) + .expect_err("a larger estimate needs fresh consent"); + assert!(matches!(&err, EngineError::PsdApprovalRequired { .. }), "got {err:?}"); + assert!(s.documents().is_empty()); + }); + }); +} + +#[cfg(target_pointer_width = "64")] +#[test] +fn unknown_capacity_metadata_never_invents_available_ram() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snapshot, || { + // Above both the 2 GiB unknown-capacity allowance and the 8 GiB warning threshold. + photocraft_io::psd_budget::with_test_estimate_charge(8 * gb + 1, || { + let mut s = Session::new(); + let started = s.start_open_for_user_with_background("unknown.psd", OpenSource::Bytes(Arc::clone(&bytes)), false).unwrap(); + let v = match started { + Started::Done(v) => v, + Started::Job(id) => s.wait_job(id).unwrap(), + }; + assert!(v["availableRam"].is_null(), "no number is invented for an unmeasured capacity: {v}"); + assert_eq!(v["capacityUnknown"], json!(true), "{v}"); + assert_eq!(v["uncertainCapacityWarning"], json!(true), "{v}"); + let id = v["pendingPreflight"].as_u64().unwrap(); + assert!(s.take_pending_preflight(id).is_some()); + }); + }); +} + +#[test] +fn an_open_reports_a_typed_hard_budget_refusal() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + // A measured 4 GiB gives a finite hard limit; a charge over it is refused. + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + let hard = photocraft_io::memory::resolve_budget(snapshot, None).hard_limit; + assert!(hard < u64::MAX, "the test needs a finite hard limit"); + photocraft_io::memory::with_mock_memory(snapshot, || { + photocraft_io::psd_budget::with_test_estimate_charge(hard + 1, || { + let mut s = Session::new(); + let err = s.start_open_for_user_with_background("over.psd", OpenSource::Bytes(Arc::clone(&bytes)), false).unwrap_err(); + match &err { + EngineError::PsdHardBudget { required, limit, .. } => assert!(*required > hard && *limit == hard), + other => panic!("the job must keep the hard-budget fields, got {other:?}"), + } + assert!(s.documents().is_empty()); + }); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_failed_job_keeps_its_typed_error_for_wait_and_events() { + let mut s = Session::new(); + let started = s + .start_job( + "test.hardBudget", + json!({}), + "Test hard budget", + false, + |_ctx| -> Result<()> { Err(EngineError::PsdHardBudget { name: "too-big.psd".into(), required: 9000, limit: 4000 }) }, + |_, _| Ok(Value::Null), + ) + .unwrap(); + let id = job(started); + let err = s.wait_job(id).expect_err("the job fails"); + assert!( + matches!(&err, EngineError::PsdHardBudget { name, required, limit } if name == "too-big.psd" && *required == 9000 && *limit == 4000), + "wait_job must not flatten the typed refusal: {err:?}" + ); + let event = s.poll_jobs().into_iter().find(|e| e.id == id).expect("the job event"); + assert!( + matches!(&event.error, Some(EngineError::PsdHardBudget { required, limit, .. }) if *required == 9000 && *limit == 4000), + "the event carries the typed refusal: {event:?}" + ); +} + +#[cfg(unix)] +#[test] +fn a_replaced_path_is_refused_with_the_source_identity_reason() { + let dir = tmp_dir("replaced"); + let path = dir.join("source.psd"); + let bytes = tiny_psd(); + std::fs::write(&path, &bytes).unwrap(); + let source = OpenSource::Path(path.to_string_lossy().into_owned()); + let fingerprint = SourceFingerprint::of(&source).unwrap(); + // A different inode with identical content and length: only the identity differs. + let replacement = dir.join("replacement.psd"); + std::fs::write(&replacement, &bytes).unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + let mut s = Session::new(); + let err = s + .start_open_approved_with_background("source.psd", source.clone(), false, Some(1024), Some(fingerprint.clone()), false) + .expect_err("the handle is not the approved file"); + assert!(matches!(&err, EngineError::SourceChanged { .. }), "the inline refusal must name the identity, not a parser failure: {err:?}"); + assert!(s.documents().is_empty()); + // The same check on the worker path: the job fails with the identity reason, not a decode error. + let started = s.start_open_approved_with_background("source.psd", source, false, Some(1024), Some(fingerprint), true).unwrap(); + let Started::Job(id) = started else { panic!("expected a background job") }; + let err = s.wait_job(id).expect_err("the worker's handle is not the approved file"); + assert!(matches!(&err, EngineError::SourceChanged { .. }), "the background refusal must name the identity: {err:?}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn a_same_size_rewrite_with_a_restored_mtime_is_refused_with_the_identity_reason() { + let dir = tmp_dir("rewrite"); + let path = dir.join("source.psd"); + let bytes = tiny_psd(); + std::fs::write(&path, &bytes).unwrap(); + let source = OpenSource::Path(path.to_string_lossy().into_owned()); + let fingerprint = SourceFingerprint::of(&source).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(10)); + // Same inode, same length, same bytes, restored modification time: only ctime differs. + std::fs::write(&path, &bytes).unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap(); + let mut s = Session::new(); + let err = s + .start_open_approved_with_background("source.psd", source, false, Some(1024), Some(fingerprint), false) + .expect_err("the rewrite is not the file whose estimate was approved"); + assert!(matches!(&err, EngineError::SourceChanged { .. }), "a restored mtime must not hide the rewrite: {err:?}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn unix_fingerprint_detects_a_same_size_rewrite_with_a_restored_mtime() { + let dir = tmp_dir("ctime"); + let path = dir.join("source.psd"); + std::fs::write(&path, b"AAAAAAAAAA").unwrap(); + let before = SourceFingerprint::of(&OpenSource::Path(path.to_string_lossy().into_owned())).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(10)); + std::fs::write(&path, b"BBBBBBBBBB").unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap(); + let after = SourceFingerprint::of(&OpenSource::Path(path.to_string_lossy().into_owned())).unwrap(); + let (b, a) = (before.file_identity().unwrap(), after.file_identity().unwrap()); + assert_eq!((b.len, b.modified, b.ino), (a.len, a.modified, a.ino), "same length, modification time and inode"); + assert_ne!((b.ctime_secs, b.ctime_nanos), (a.ctime_secs, a.ctime_nanos), "the Unix identity carries ctime, so a restored mtime cannot hide a rewrite"); + assert_ne!(before, after, "a same-size rewrite with a restored mtime is a different source"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index 643ed46057..1406e0a384 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -49,6 +49,18 @@ pub struct KysSet { pub windows: bool, } +/// The set name in the root `filename` attribute (`$$$/.../{name}={name}`). The name may itself +/// contain `/` or `=`, so split on the key prefix and the doubled name, not on the last separator. +fn filename_to_name(f: &str) -> &str { + let Some(rest) = f.strip_prefix("$$$/") else { return f.rsplit(['=', '/']).next().unwrap_or(f).trim() }; + let rest = rest.strip_prefix("FileName/Presets/KeyboardCustomization/").unwrap_or(rest); + let mid = rest.len() / 2; + if rest.len() % 2 == 1 && rest.is_char_boundary(mid) && rest.as_bytes()[mid] == b'=' && rest[..mid] == rest[mid + 1..] { + return rest[..mid].trim(); + } + rest.split_once('=').map_or(rest, |(_, n)| n).trim() +} + /// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows /// without a name or a shortcut are skipped. pub fn parse(text: &str) -> Result { @@ -61,7 +73,7 @@ pub fn parse(text: &str) -> Result { if root.tag_name().name() != "photoshop-keyboard-shortcuts" { return Err("not a keyboard shortcut set (.kys): no root".into()); } - let name = root.attribute("filename").map(|f| f.rsplit(['=', '/']).next().unwrap_or(f).trim()).filter(|n| !n.is_empty()).map(str::to_string); + let name = root.attribute("filename").map(filename_to_name).filter(|n| !n.is_empty()).map(str::to_string); let mut set = KysSet { name, ..Default::default() }; for node in root.children().filter(|n| n.is_element()) { match node.tag_name().name() { @@ -119,6 +131,7 @@ pub fn to_ours(set: &KysSet, sc: &str) -> Option { } let (mods, key) = match sc.strip_suffix("++") { Some(m) => (m, "+"), + None if sc == "+" => ("", "+"), None => sc.rsplit_once('+').unwrap_or(("", sc)), }; let mods: Vec<&str> = mods @@ -262,6 +275,7 @@ pub fn photoshop_key(sc: &str) -> Option { // The key is the last part; `Cmd++` ends in an empty part, so take it from the string. let (mods, key) = match n.strip_suffix("++") { Some(m) => (m, "+"), + None if n == "+" => ("", "+"), None => n.rsplit_once('+').unwrap_or(("", n.as_str())), }; let has = |m: &str| mods.split('+').any(|x| x == m); @@ -309,6 +323,13 @@ pub fn write(name: &str, entries: &[Entry]) -> String { mod tests { use super::*; + #[test] + fn set_name_with_separators_round_trips() { + for n in ["Retouch 1/2", "a=b", "x/y=z"] { + assert_eq!(parse(&write(n, &[])).unwrap().name.as_deref(), Some(n)); + } + } + const SET: &str = "\u{feff} \tCmd+L @@ -323,6 +344,28 @@ mod tests { Candidate { id: id.into(), label: label.into(), default: default.map(str::to_string) } } + #[test] + fn a_bare_plus_key_is_kept_when_saving() { + assert_eq!(photoshop_key("+").as_deref(), Some("+")); + assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++")); + let e = Entry { id: "tools.zoom".into(), label: "Zoom Tool".into(), tool: true, keys: vec!["+".into()] }; + assert!(write("Mine", &[e]).contains(">+")); + } + + #[test] + fn a_bare_plus_command_survives_a_windows_set_with_a_ctrl_row() { + let xml = "\ + Ctrl+C\ + +\ + "; + let set = parse(xml).unwrap(); + assert!(set.windows); + assert_eq!(to_ours(&set, "+").as_deref(), Some("+")); + let p = plan([c("edit.copy", "Copy", Some("Cmd+C")), c("view.zoomIn", "Zoom In", None)], &set); + assert_eq!(p.set.get("view.zoomIn").map(String::as_str), Some("+")); + assert!(p.unreadable.is_empty()); + } + #[test] fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() { let set = parse(SET).unwrap(); diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index e1a7f3755c..176f08b264 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -47,6 +47,9 @@ fn color_of(p: &Value, k: &str) -> Option { let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map_or(0.0, |v| f32::from(v) / 255.0); (s.len() >= 6).then_some(Color::rgb(h(0), h(2), h(4))) } + Some(v @ Value::Object(_)) => serde_json::from_value::(v.clone()) + .ok() + .filter(|c| c.c.iter().all(|v| v.is_finite()) && c.alpha.is_finite() && (0.0..=1.0).contains(&c.alpha)), _ => None, } } @@ -66,9 +69,16 @@ fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> { None => stops, } } +/// The gradient of a stroke or overlay's params. Bad `stops` are ignored here, as bad values of +/// any other key are; the commands refuse them first ([`check_gradient_params`]). fn gradient(p: &Value) -> Gradient { + let stops = match stops_param(p, "layer.layerStyle").ok().flatten() { + Some(stops) => stops, + None => gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), + }; Gradient { - stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), + stops, + opacity_stops: opacity_stops_param(p, "layer.layerStyle").ok().flatten().unwrap_or_default(), 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).clamp(0.1, 1.5), @@ -77,6 +87,61 @@ fn gradient(p: &Value) -> Gradient { } } +/// `Err` when `stops` or `opacityStops` are present but malformed, so a command refuses them +/// instead of silently keeping the old gradient. +pub fn check_gradient_params(p: &Value, cmd: &str) -> Result<()> { + stops_param(p, cmd)?; + opacity_stops_param(p, cmd)?; + Ok(()) +} + +/// Most stops a gradient parameter may carry. +const MAX_STOPS: usize = 256; + +/// Explicit colour stops (#5177): `"stops": [[position 0..1, "#rrggbb" | [r, g, b]], …]`, at +/// least 2 and at most [`MAX_STOPS`], returned sorted by position. `None` without the key. +fn stops_param(p: &Value, cmd: &str) -> Result>> { + let Some(v) = p.get("stops") else { return Ok(None) }; + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + let a = v.as_array().ok_or_else(|| bad("\"stops\" must be an array of [position, colour]".into()))?; + if a.len() < 2 { + return Err(bad("\"stops\" needs at least 2 stops".into())); + } + if a.len() > MAX_STOPS { + return Err(bad(format!("\"stops\": at most {MAX_STOPS} stops"))); + } + let mut stops = Vec::with_capacity(a.len()); + for (i, s) in a.iter().enumerate() { + let t = s.get(0).and_then(Value::as_f64).filter(|t| t.is_finite()).ok_or_else(|| bad(format!("\"stops\"[{i}] must be [position 0..1, colour]")))?; + let c = s.get(1).and_then(|c| color_of(&json!({"c": c}), "c")).ok_or_else(|| bad(format!("\"stops\"[{i}]: no colour (\"#rrggbb\" or [r, g, b])")))?; + stops.push((t.clamp(0.0, 1.0) as f32, c)); + } + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + Ok(Some(stops)) +} + +/// Explicit transparency stops: `"opacityStops": [[position 0..1, opacity 0..100], …]`; an empty +/// array means fully opaque. `None` without the key. +fn opacity_stops_param(p: &Value, cmd: &str) -> Result>> { + let Some(v) = p.get("opacityStops") else { return Ok(None) }; + let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg }; + let a = v.as_array().ok_or_else(|| bad("\"opacityStops\" must be an array of [position, opacity]".into()))?; + if a.len() > MAX_STOPS { + return Err(bad(format!("\"opacityStops\": at most {MAX_STOPS} stops"))); + } + if a.len() == 1 { + return Err(bad("\"opacityStops\" needs at least 2 stops (or none)".into())); + } + let mut stops = Vec::with_capacity(a.len()); + for (i, s) in a.iter().enumerate() { + let num = |k: usize| s.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()); + let (t, o) = num(0).zip(num(1)).ok_or_else(|| bad(format!("\"opacityStops\"[{i}] must be [position 0..1, opacity 0..100]")))?; + stops.push((t.clamp(0.0, 1.0) as f32, (o / 100.0).clamp(0.0, 1.0) as f32)); + } + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + Ok(Some(stops)) +} + /// Factory defaults for each effect kind, in the command's parameter units /// (what the Layer Style dialog starts a new effect from). pub fn effect_defaults(kind: &str) -> Value { @@ -572,11 +637,18 @@ fn fresh_effect(kind: &str) -> Option { }) } -/// Edits an existing gradient in place: only the end colours and geometry. -/// Intermediate stops, opacity stops, alignment and offset survive a round trip. +/// Edits an existing gradient in place: explicit `stops` / `opacityStops` replace the lists; +/// otherwise only the end colours and geometry change, and intermediate stops, opacity stops, +/// alignment and offset survive a round trip. fn overlay_gradient(g: &mut Gradient, p: &Value) { + if let Some(stops) = stops_param(p, "layer.layerStyle").ok().flatten() { + g.stops = stops; + } + if let Some(os) = opacity_stops_param(p, "layer.layerStyle").ok().flatten() { + g.opacity_stops = os; + } // A method rebuilds the gradient from its end colours (the stops between are the old curve). - if p.get("method").and_then(Value::as_str).is_some_and(|m| photocraft_io::GradientMethod::from_name(m).is_some()) { + if p.get("stops").is_none() && p.get("method").and_then(Value::as_str).is_some_and(|m| photocraft_io::GradientMethod::from_name(m).is_some()) { let ends = (g.stops.first().map(|s| s.1), g.stops.last().map(|s| s.1)); let from = color_of(p, "from").or(ends.0).unwrap_or(Color::BLACK); let to = color_of(p, "to").or(ends.1).unwrap_or(Color::WHITE); @@ -708,7 +780,7 @@ fn overlay_stroke(s: &mut StrokeFx, p: &Value) { }; } overlay_bool(p, "overprint", &mut s.overprint); - if p.get("from").is_some() { + if p.get("from").is_some() || p.get("stops").is_some() { s.paint = FxPaint::Gradient(gradient(p)); } else if let Some(c) = color_of(p, "color") { s.paint = FxPaint::Color(c); @@ -916,7 +988,7 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { let mut pats = Vec::new(); for e in entries { let kind = e.get("kind").and_then(Value::as_str).ok_or_else(|| bad(CMD, "each effect needs a `kind` string"))?; - let params = match e.get("params") { + let mut params = match e.get("params") { None | Some(Value::Null) => json!({}), Some(Value::Object(_)) => e.get("params").cloned().unwrap_or_default(), Some(_) => return Err(bad(CMD, "`params` must be an object")), @@ -927,10 +999,27 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { if kind_of(&fx) != kind { return Err(bad(CMD, format!("effect snapshot is a {}, not `{kind}`", kind_of(&fx)))); } + if let Effect::GradientOverlay { gradient, interpolation, .. } = &fx { + let stops = interpolation.as_ref().map_or(&gradient.stops, |s| &s.stops); + if exact_stops(¶ms).as_ref() == Some(stops) + && let Some(o) = params.as_object_mut() + { + o.remove("stops"); + } + if opacity_stops_param(¶ms, CMD).ok().flatten().as_ref() == Some(&gradient.opacity_stops) + && let Some(o) = params.as_object_mut() + { + o.remove("opacityStops"); + } + } + check_gradient_params(¶ms, CMD)?; overlay_effect(&mut fx, ¶ms); fx } - None => effect_from_params(kind, ¶ms).ok_or_else(|| bad(CMD, format!("unknown effect {kind}")))?, + None => { + check_gradient_params(¶ms, CMD)?; + effect_from_params(kind, ¶ms).ok_or_else(|| bad(CMD, format!("unknown effect {kind}")))? + } }; let (fx, pat) = crate::pattern_cmds::resolve_effect(s, fx)?; if let Some(pat) = pat { @@ -960,6 +1049,7 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result { fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?; + check_gradient_params(p, &format!("layer.layerStyle.{kind}"))?; let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?; let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); @@ -1068,7 +1158,7 @@ pub fn specs() -> Vec { style_cmd!( "gradientOverlay", "Gradient Overlay…", - r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted on new effects: straight stops; edits retain the recorded method),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"from":"#rrggbb","to":"#rrggbb","stops":[[0..1,"#rrggbb"],.]? (2..256 source stops),"opacityStops":[[0..1,0..100],.]?,"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted on new effects: straight stops; edits retain the recorded method),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!( "patternOverlay", @@ -1167,3 +1257,15 @@ fn delete_effect(s: &mut Session, p: &Value, effect: &Value) -> Result { #[cfg(test)] mod tests; + +fn exact_stops(p: &Value) -> Option> { + p.get("stops")? + .as_array()? + .iter() + .map(|s| { + let t = s.get(0)?.as_f64()? as f32; + let color = color_of(&json!({"c":s.get(1)?}), "c")?; + Some((t, color)) + }) + .collect() +} diff --git a/crates/engine/src/layer_style/gradient_overlay.rs b/crates/engine/src/layer_style/gradient_overlay.rs index 8736597e08..ee88cb447d 100644 --- a/crates/engine/src/layer_style/gradient_overlay.rs +++ b/crates/engine/src/layer_style/gradient_overlay.rs @@ -66,6 +66,9 @@ pub(super) fn overlay(gradient: &mut Gradient, interpolation: &mut Option>(), vec![0.25, 1.0]); + assert!(gradient.opacity_stops.is_empty(), "[] means opaque"); + s.execute("layer.layerStyle.gradientOverlay", json!({"stops": stops})).unwrap(); + // The dialog's path: the snapshot plus a geometry edit keeps the three stops. + let fx = serde_json::to_value(&effects(&s)[0]).unwrap(); + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "gradientOverlay", "fx": fx, "params": {"angle": 45}}]})).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() }; + assert_eq!((gradient.stops.len(), gradient.angle), (3, 45.0)); + let e = s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "gradientOverlay", "params": {"stops": [[0, "#ff0000"]]}}]})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); + // A gradient stroke takes stops too. + s.execute("layer.layerStyle.stroke", json!({"size": 4, "stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]]})).unwrap(); + assert!(effects(&s).iter().any(|e| matches!(e, Effect::Stroke(st) if matches!(&st.paint, FxPaint::Gradient(g) if g.stops.len() == 3)))); + // Malformed lists are errors and change nothing. + for bad in [ + json!({"stops": "red"}), + json!({"stops": [[0, "#ff0000"]]}), + json!({"stops": [[0, "#ff0000"], ["x", "#0000ff"]]}), + json!({"stops": [[0, "#ff0000"], [1, 7]]}), + json!({"stops": [[0, "#ff0000"], [f64::NAN, "#0000ff"]]}), + json!({"stops": vec![json!([0, "#ff0000"]); 257]}), + json!({"opacityStops": [[0, 100]]}), + json!({"opacityStops": [[0, "x"], [1, 1]]}), + ] { + let e = s.execute("layer.layerStyle.gradientOverlay", bad.clone()).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{bad}: {e}"); + } + let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() }; + assert_eq!(gradient.stops.len(), 3, "a refused edit keeps the gradient"); + // Positions outside 0..1 are clamped. + s.execute("layer.layerStyle.gradientOverlay", json!({"stops": [[-1, "#000000"], [2, "#ffffff"]]})).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() }; + assert_eq!(gradient.stops.iter().map(|s| s.0).collect::>(), vec![0.0, 1.0]); +} + #[test] fn gradient_overlay_method_matches_photoshop() { // Photoshop 27.11, Classic Gradient Overlay #000000 → #ffffff (Smoothness 100 %): 0.037 at @@ -574,3 +624,18 @@ fn double_stroke_bands_render_and_survive_an_edit() { s.execute("layer.layerStyle.replace", json!({"layer": id.0, "effects": entries})).unwrap(); assert_eq!(px(&mut s, 24, 14), before, "both strokes render identically after the round trip"); } + +#[test] +fn unchanged_dense_imported_stops_roundtrip_without_edit_limit_regression() { + let mut s = session(); + let gradient = Gradient { stops: (0..300).map(|i| (i as f32 / 299.0, Color::rgb(i as f32 / 777.0, 0.123456, 0.9))).collect(), ..Gradient::default() }; + let fx = Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: gradient.clone(), dither: false, interpolation: None }; + let stops: Vec<_> = gradient.stops.iter().map(|(t, c)| json!([t, c])).collect(); + s.execute("layer.layerStyle.replace", json!({"effects":[{"kind":"gradientOverlay","fx":fx,"params":{"stops":stops}}]})).unwrap(); + let doc = s.active().unwrap(); + let saved = &doc.doc.layer(doc.active_layer.unwrap()).unwrap().effects.items[0]; + assert_eq!(saved, &fx); + let mut edited = stops; + edited[1] = json!([0.01, "#ffffff"]); + assert!(s.execute("layer.layerStyle.replace", json!({"effects":[{"kind":"gradientOverlay","fx":fx,"params":{"stops":edited}}]})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c88ae3bfaf..321480e479 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,12 +7,14 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod actions_record; pub mod adjust_params; mod allocation; pub mod build_info; mod canvas_geom; mod channel_clip; mod clip_group; +pub mod color_channels; mod crop_fill; pub mod display_color; pub mod doc_path; @@ -28,6 +30,7 @@ pub mod kys; pub mod layer_place; pub mod layer_style; pub mod link_watch; +pub mod malloc_tuning; pub mod new_layer; mod param_check; mod pixels; @@ -35,6 +38,7 @@ pub mod prefs; pub mod preset_store; pub mod presets; pub mod proof_sim; +mod scratch; pub mod select_mask_edits; pub mod shell_commands; pub mod shortcut_sets; @@ -138,6 +142,7 @@ pub use cmd::select_extra_cmds; pub use cmd::selection_cmds; pub use cmd::slice_cmds; pub use cmd::smart_cmds; +pub use cmd::smart_svg_cmds; pub use cmd::smartselect_cmds; pub use cmd::solid_fill_cmds; pub use cmd::stamp_cmds; @@ -184,6 +189,8 @@ pub(crate) fn active_layer_of(s: &Session) -> std::result::Result<&photocraft_do #[derive(Default)] pub struct Session { docs: Vec, + /// Admitted document identities in opening order, independent of visual-tab reordering. + open_document_order: Vec, active: Option, /// Monotonic counter, bumped whenever a different document becomes active (including a /// switch away and back inside one pass). UI work that must not cross documents (control @@ -194,8 +201,17 @@ pub struct Session { pub tools: ToolState, /// Log of executed commands (for action recording and debugging). pub journal: Vec<(String, Value)>, + /// Text layer changed by the last journal entry, for folding Type tool keystrokes. + typed_layer: Option<(photocraft_doc::DocId, LayerId)>, /// Coalescing key of the command being executed. coalesce_request: Option, + /// The outer action's original document/history states, independent of command transactions. + action_history: Option, + /// Weak leases expire when every copy of a suspended continuation is abandoned. + paused_action_history: Vec>, + /// Tests observe the actual scratch-session boundary without retaining it or changing output. + #[cfg(test)] + batch_history_observer: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, /// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options @@ -213,7 +229,7 @@ pub struct Session { /// Quick Mask Options (colour, opacity, colour indicates) used when entering Quick Mask. pub quick_mask_options: channel_cmds::QuickMaskOptions, /// Colour channel the running command may change (a single colour channel is targeted). - color_restrict: Option, + color_restrict: Option, /// Smart object whose filter mask the running command edits (`"target":"filterMask"`): each /// edit re-renders it, in the same history step. filter_mask_layer: Option, @@ -264,6 +280,9 @@ pub struct Session { pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>, /// The stamps being taken on a worker thread ([`Session::start_link_scan`]). pub link_scan: Option, + /// Each open document's linked smart objects' files that were not there when last looked at + /// ([`Session::missing_links`]). + link_missing: std::collections::HashSet<(u64, String)>, /// Select Subject's last result, reused while the pixels are unchanged (#2865). subject_cache: smartselect_cmds::SubjectCache, /// Object Selection's Object Finder results (#4087), shared by the hover preview and the @@ -277,6 +296,22 @@ pub struct Session { /// document, source read-only)`. The shell takes them with [`Session::take_held_open`]. held_opens: Vec<(u64, Document, bool)>, next_held_open: u64, + pub(crate) pending_preflights: Vec, + next_pending_preflight: u64, + /// Shared native memory supervision, owned and stopped by the last session using it. + scratch_runtime: Option, + /// [`photocraft_raster::spill::epoch`] when History's byte limits were last applied: a + /// scratch disk that pauses or resumes changes the limit ([`Session::sync_scratch_state`]). + scratch_epoch: u64, + /// The document before the latest [`Session::edit`] of the running command, which + /// [`Session::hint_damage`] checks a hint against. Set by `edit`, taken by `hint_damage`, + /// dropped when the command ends. Debug and test builds only. + #[cfg(any(test, debug_assertions))] + hint_before: Option>, + /// Damage hints that missed a tile their edit changed ([`Session::hint_damage`]): each one a + /// command reporting too narrow a redraw area (#4053). Counted in debug and test builds only; + /// tests read it. + pub damage_misses: usize, } #[cfg(test)] @@ -288,3 +323,5 @@ mod tests; #[cfg(test)] mod fx_mode_tests; + +pub mod command_hints; diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 453af7806b..893933fd80 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -68,6 +68,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let (doc, files) = (st.doc.id.0, resolved_files(st)); let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); + // Drop a look still running on a worker thread. It stamped the files before this one, and + // applying it later would replace this look's result. + s.link_scan = None; s.apply_link_stamps(doc, now) } @@ -93,8 +96,10 @@ impl Session { } let Some(st) = self.active() else { return }; let (doc, files) = (st.doc.id.0, resolved_files(st)); - // Nothing to look at: no links, or none that is followed. + // Nothing to look at: no links, or none that is followed. None of them is listed as + // missing either, so a link added back isn't shown missing before its first look. if files.iter().all(|(_, file, _)| file.is_none()) { + self.link_missing.retain(|(d, _)| *d != doc); return; } let slot: LinkScan = Arc::default(); @@ -108,10 +113,41 @@ impl Session { } } + /// The active document's linked files that were not there at the last look (the Layers + /// panel's Missing filter). A path that is not followed ([`crate::doc_path`]) is never looked + /// at, so it is not listed. + pub fn missing_links(&self) -> HashSet { + let Some(doc) = self.active().map(|d| d.doc.id.0) else { return HashSet::new() }; + self.link_missing.iter().filter(|(d, _)| *d == doc).map(|(_, p)| p.clone()).collect() + } + + /// Remember which of document `doc`'s files in `now` were not there, and forget the files of + /// closed documents. The web app has no files to look at, so it lists none. + fn note_missing_links(&mut self, doc: u64, now: &[(String, Vec, Option)]) { + let open: HashSet = self.documents().iter().map(|d| d.doc.id.0).collect(); + self.link_missing.retain(|(d, _)| *d != doc && open.contains(d)); + if cfg!(target_arch = "wasm32") { + return; + } + let document = self.active().and_then(|st| st.path.clone()); + for (path, _, stamp) in now { + if stamp.is_none() && crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)).is_ok() { + self.link_missing.insert((doc, path.clone())); + } + } + } + /// Apply a finished look (`None` while there is none, or it is still running). pub fn poll_link_scan(&mut self) -> Option> { let (doc, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?; self.link_scan = None; + // A relink or embed while the look ran: it stamped files the document no longer links in + // that way, so drop it; the next look takes the current links. + if let Some(st) = self.active().filter(|st| st.doc.id.0 == doc) + && !linked_files(&st.doc).iter().map(|(p, ids)| (p, ids)).eq(stamps.iter().map(|(p, ids, _)| (p, ids))) + { + return Some(Ok(json!({"updated": []}))); + } Some(self.apply_link_stamps(doc, stamps)) } @@ -122,6 +158,7 @@ impl Session { if self.active().map(|d| d.doc.id.0) != Some(doc) { return Ok(json!({"updated": []})); } + self.note_missing_links(doc, &now); // A background job holds the document: refreshing now would replace the snapshot it works // on and its result would be discarded. The stamps stay as they were, so the change is // seen again by the first look after the job ends. @@ -282,6 +319,124 @@ mod tests { assert!(is(centre(&s), BLUE)); } + #[test] + fn a_look_lists_the_linked_files_that_are_not_there() { + let d = dir("missing"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, _) = placed(&path); + let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone(); + assert!(s.missing_links().is_empty(), "nothing looked at yet"); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + assert!(s.missing_links().is_empty()); + std::fs::remove_file(&path).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + assert_eq!(s.missing_links(), [stored.clone()].into()); + // Only the document's own files: another one in front lists none. + s.execute("file.new", json!({"width": 10, "height": 10})).unwrap(); + assert!(s.missing_links().is_empty()); + s.set_active(0); + assert_eq!(s.missing_links(), [stored].into()); + std::fs::write(&path, png(10, 10, "#0000ff")).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + assert!(s.missing_links().is_empty(), "the file is back"); + } + + #[test] + fn a_reopened_document_lists_no_file_missing_before_it_closed() { + let d = dir("reopen"); + let (path, saved) = (d.join("art.png"), d.join("doc.pcraft")); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, _) = placed(&path); + s.execute("file.saveACopy", json!({"path": saved.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().doc.id; + std::fs::remove_file(&path).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + assert_eq!(s.missing_links().len(), 1); + s.close(0); + std::fs::write(&path, png(10, 10, "#0000ff")).unwrap(); + let bytes = std::fs::read(&saved).unwrap(); + crate::file_cmds::open_bytes_as(&mut s, "doc.pcraft", &bytes, None, Some(saved.to_string_lossy().into_owned())).unwrap(); + assert_eq!(s.active().unwrap().doc.id, id, "the saved document keeps its id"); + assert!(s.missing_links().is_empty(), "nothing looked at since it opened"); + } + + #[test] + fn a_look_started_before_update_changed_does_not_replace_its_result() { + let d = dir("stale"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, _) = placed(&path); + s.start_link_scan(); + let t0 = std::time::Instant::now(); + while s.link_scan.as_ref().is_some_and(|l| l.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none()) { + assert!(t0.elapsed().as_secs() < 10, "the look never finished"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + std::fs::remove_file(&path).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + assert_eq!(s.missing_links().len(), 1); + let _ = s.poll_link_scan(); + assert_eq!(s.missing_links().len(), 1, "the earlier look found the file, but it is gone"); + } + + #[test] + fn a_scan_finished_after_close_cannot_change_a_reopened_document() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("art.png"); + let saved = directory.path().join("doc.pcraft"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, layer) = placed(&path); + s.execute("file.saveACopy", json!({"path": saved.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().doc.id; + let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone(); + let scan = std::sync::Arc::new(std::sync::Mutex::new(None)); + s.link_scan = Some(scan.clone()); + s.close(0); + let bytes = std::fs::read(&saved).unwrap(); + crate::file_cmds::open_bytes_as(&mut s, "doc.pcraft", &bytes, None, Some(saved.to_string_lossy().into_owned())).unwrap(); + assert_eq!(s.active().unwrap().doc.id, id); + *scan.lock().unwrap() = Some((id.0, vec![(stored, vec![layer], None)])); + assert!(s.poll_link_scan().is_none()); + assert!(s.missing_links().is_empty()); + } + + #[test] + fn a_document_without_links_lists_none_missing() { + let d = dir("unlinked"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, id) = placed(&path); + std::fs::remove_file(&path).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + assert_eq!(s.missing_links().len(), 1); + s.execute("layer.delete", json!({"layer": id})).unwrap(); + s.start_link_scan(); + assert!(s.missing_links().is_empty(), "the last linked layer is gone"); + } + + #[test] + fn a_look_at_links_that_changed_meanwhile_is_dropped() { + let d = dir("relinked"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, layer) = placed(&path); + let doc = s.active().unwrap().doc.id.0; + let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone(); + let inject = |s: &mut Session, file: &str| { + let scan = std::sync::Arc::new(std::sync::Mutex::new(Some((doc, vec![(file.to_string(), vec![layer], None)])))); + s.link_scan = Some(scan); + }; + // Taken before a relink: its file is no longer linked. + inject(&mut s, &d.join("before-relink.png").to_string_lossy()); + assert_eq!(s.poll_link_scan().unwrap().unwrap(), json!({"updated": []})); + assert!(s.missing_links().is_empty(), "a file no longer linked is not listed"); + // Taken of the current links: applied. + inject(&mut s, &stored); + s.poll_link_scan().unwrap().unwrap(); + assert_eq!(s.missing_links(), [stored].into()); + } + #[test] fn a_background_look_updates_changed_files() { let d = dir("scan"); diff --git a/crates/engine/src/malloc_tuning.rs b/crates/engine/src/malloc_tuning.rs new file mode 100644 index 0000000000..f9c5947c47 --- /dev/null +++ b/crates/engine/src/malloc_tuning.rs @@ -0,0 +1,81 @@ +//! glibc malloc tuning for the native binaries (#8743). +//! +//! With glibc's defaults, the memory of a closed document stays with the process: freeing a large +//! buffer raises glibc's dynamic mmap threshold (up to 32 MiB), so later tile buffers come from the +//! heap, whose freed pages glibc keeps for reuse instead of giving them back. A session that opened +//! and closed a 5400 × 7200 document held ~540 MiB afterwards; with `MALLOC_TRIM_THRESHOLD_` set it +//! went back to ~20 MiB, and the peak was lower too. Setting the threshold explicitly turns the +//! dynamic adjustment off (mallopt(3)), so large buffers are mapped and unmapped on their own and +//! the heap top is trimmed as it is freed. Mapping fresh pages on every call is slow, so the large +//! short-lived buffers come from `photocraft_raster::scratch` instead, which reuses them and +//! releases them when a document closes or the pool goes idle (#10479). +//! +//! glibc reads its tunables once, when the process starts, and changing the environment from Rust +//! needs `unsafe` (and comes too late), so [`apply`] re-executes the binary with the variable set: +//! the same process (PID), arguments, `argv[0]` and environment, on Linux with glibc only. A value +//! the user or a package already set, for this or another malloc tunable, is left alone, so setting +//! `MALLOC_TRIM_THRESHOLD_` to anything opts out. Programs PhotoCraft starts inherit the variable. + +use std::ffi::OsString; + +/// The glibc environment variable [`apply`] sets. +pub const TRIM_THRESHOLD_VAR: &str = "MALLOC_TRIM_THRESHOLD_"; + +/// Its value: glibc's own default trim threshold (128 KiB), now fixed instead of dynamic. +pub const TRIM_THRESHOLD: &str = "131072"; + +/// glibc malloc variables that mean someone tuned malloc themselves. +const MALLOC_VARS: [&str; 5] = [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX"]; + +/// Whether a start-up should re-execute with [`TRIM_THRESHOLD_VAR`] set, given the environment: +/// only when no malloc variable or `glibc.malloc.*` tunable is set (which also ends the re-exec). +pub fn needs_restart(var: impl Fn(&str) -> Option) -> bool { + if MALLOC_VARS.iter().any(|v| var(v).is_some()) { + return false; + } + !var("GLIBC_TUNABLES").is_some_and(|t| t.to_string_lossy().contains("glibc.malloc.")) +} + +/// Re-execute this binary with [`TRIM_THRESHOLD_VAR`] set when [`needs_restart`] says so. Call it +/// first in `main`, before any thread starts. It returns only when nothing needed to change or the +/// re-exec failed, and then the process carries on with glibc's defaults. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn apply() { + use std::os::unix::process::CommandExt; + if !needs_restart(|k| std::env::var_os(k)) { + return; + } + let Ok(exe) = std::env::current_exe() else { return }; + let mut args = std::env::args_os(); + let mut cmd = std::process::Command::new(exe); + if let Some(argv0) = args.next() { + cmd.arg0(argv0); + } + // `exec` replaces the process and only returns on failure. + let err = cmd.args(args).env(TRIM_THRESHOLD_VAR, TRIM_THRESHOLD).exec(); + eprintln!("PhotoCraft: could not restart with {TRIM_THRESHOLD_VAR} set ({err}); memory of closed documents may not be returned to the system"); +} + +/// Nothing to tune off glibc. +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn apply() {} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) + } + + #[test] + fn restarts_only_without_malloc_tuning() { + assert!(needs_restart(env(&[]))); + assert!(needs_restart(env(&[("HOME", "/home/a"), ("GLIBC_TUNABLES", "glibc.cpu.hwcaps=-AVX2")]))); + assert!(!needs_restart(env(&[(TRIM_THRESHOLD_VAR, TRIM_THRESHOLD)])), "the re-executed process stops"); + assert!(!needs_restart(env(&[(TRIM_THRESHOLD_VAR, "")])), "any value opts out"); + assert!(!needs_restart(env(&[("MALLOC_MMAP_THRESHOLD_", "1048576")]))); + assert!(!needs_restart(env(&[("MALLOC_ARENA_MAX", "2")]))); + assert!(!needs_restart(env(&[("GLIBC_TUNABLES", "glibc.malloc.trim_threshold=0")]))); + } +} diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index 4cba7f0c52..b102907ccc 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -58,7 +58,6 @@ pub fn run(s: &mut Session, p: &Value) -> Result { *active = Some(id); Ok(id) })?; - crate::layer_multi_cmds::note_inert_insert(s, id); Ok(json!({ "layer": id.0 })) } diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 3f6b074153..a66a67b927 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -83,6 +83,11 @@ fn tile_rects(r: Rect) -> impl Iterator { const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds"; +/// Refuse adjustments that would materialize too much previously unstored pixel data. +pub(crate) fn check_adjustment_tile_budget(area: Rect, stored: usize, format: &photocraft_color::PixelFormat) -> Result<(), String> { + if unstored_bytes(area, stored as u64, format) > MAX_UNSTORED_BYTES { Err(TOO_SPARSE.into()) } else { Ok(()) } +} + /// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the /// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale). @@ -115,6 +120,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf let tr = tc.rect().intersect(&r); let out = adjusted(&one, tr, adj, selection, mode); one.write_region(tr, &out); + photocraft_raster::scratch::give(out); one.take_tiles(tc.rect()) }) .collect(); @@ -131,7 +137,39 @@ mod tests; /// the mask's default value), and without a selection the default changes too, since a mask /// reaches past the canvas: Invert turns a reveal-all mask into a hide-all one (#780). pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) -> Result<(), String> { - let mode = photocraft_color::ColorMode::Grayscale; + adjust_mask_with_transfer(s, adj, selection, adjust::Transfer::for_document(photocraft_color::ColorMode::Grayscale, s.format().sample), false) +} + +/// A native component is scalar, but Exposure must retain its source document's tone transfer. +pub(crate) fn adjust_mask_with_transfer( + s: &mut Surface, + adj: &Adjustment, + selection: Option<&Surface>, + transfer: adjust::Transfer, + rgb_document: bool, +) -> Result<(), String> { + // RGB32 Curves extend their endpoint slopes. Other native planes retain scalar tone rules, + // rather than treating a replicated grayscale buffer as a complete Lab or CMYK colour. + let mode = if rgb_document { photocraft_color::ColorMode::Rgb } else { photocraft_color::ColorMode::Grayscale }; + if let Some(sel) = selection + && sel.default_pixel().first().is_some_and(|v| *v > 0.0) + { + // A selection can store holes in an otherwise selected default. Its stored bounds + // do not bound the edit: adjust the default and the union of explicitly stored tiles. + let fmt = s.format(); + let unit = Rect::from_xywh(0, 0, 1, 1); + let blank = Surface::with_default(fmt, &s.default_pixel()); + let mask_default = Surface::with_default(sel.format(), &sel.default_pixel()); + let mut out = Surface::with_default(fmt, &adjusted_with_transfer(&blank, unit, adj, Some(&mask_default), mode, transfer)); + let stored: std::collections::BTreeSet<_> = s.tiles().map(|(c, _)| *c).chain(sel.tiles().map(|(c, _)| *c)).collect(); + for coord in stored { + let r = coord.rect(); + out.try_write_region(r, &adjusted_with_transfer(s, r, adj, selection, mode, transfer)).map_err(|e| e.to_string())?; + } + out.prune(); + *s = out; + return Ok(()); + } if let Some(sel) = selection { let r = sel.content_bounds(); // Tile by tile, and only where the selection stores something (it is unselected, so @@ -150,8 +188,9 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface tile_rects(r).collect() }; for t in &tiles { - let out = adjusted(s, *t, adj, Some(sel), mode); + let out = adjusted_with_transfer(s, *t, adj, Some(sel), mode, transfer); s.write_region(*t, &out); + photocraft_raster::scratch::give(out); } if !tiles.is_empty() { s.prune(); @@ -160,12 +199,14 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface } let fmt = s.format(); let unit = Rect::from_xywh(0, 0, 1, 1); - let mut out = Surface::with_default(fmt, &adjusted(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode)); + let mut out = Surface::with_default(fmt, &adjusted_with_transfer(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode, transfer)); // Everything the mask doesn't store reads as its default, which `out` already holds adjusted. // So only the stored tiles are adjusted, not the (possibly huge) rectangle around them. let stored: Vec = s.tiles().map(|(c, _)| c.rect()).collect(); for r in stored { - out.write_region(r, &adjusted(s, r, adj, None, mode)); + let data = adjusted_with_transfer(s, r, adj, None, mode, transfer); + out.write_region(r, &data); + photocraft_raster::scratch::give(data); } out.prune(); *s = out; @@ -174,6 +215,17 @@ pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface /// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded. fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec { + adjusted_with_transfer(s, r, adj, selection, mode, adjust::Transfer::for_document(mode, s.format().sample)) +} + +fn adjusted_with_transfer( + s: &Surface, + r: Rect, + adj: &Adjustment, + selection: Option<&Surface>, + mode: photocraft_color::ColorMode, + transfer: adjust::Transfer, +) -> Vec { let fmt = s.format(); if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) { return adjusted_channels(s, r, selection, |ch| adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), ch)); @@ -190,7 +242,10 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, } let n = fmt.channels(); let raw = s.read_region(r); - let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; + let mut px = photocraft_raster::scratch::take_cap(raw.len() / n.max(1)); + px.extend(raw.chunks_exact(n).map(|p| to_rgba(&fmt, p))); + photocraft_raster::scratch::give(raw); + let mut buf = Buffer { rect: r, px }; let orig = buf.clone(); // 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip); // integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and @@ -201,7 +256,7 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, (photocraft_color::SampleType::U16, Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) => Some(fmt.sample), _ => None, }; - adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); + adjust::apply_in(adj, &mut buf, transfer, depth, mode); mix_through_selection(&fmt, r, &orig.px, &buf.px, selection) } @@ -268,7 +323,7 @@ pub(crate) fn mix_through_selection( selection: Option<&Surface>, ) -> Vec { let w = (r.width() as usize).max(1); - let mut out = Vec::with_capacity(orig.len() * fmt.channels()); + let mut out = photocraft_raster::scratch::take_cap(orig.len() * fmt.channels()); // The selection's coverage (channel 0) one row at a time, each of its tiles looked up once // per row rather than once per pixel. let mut coverage = vec![1.0f32; w]; @@ -277,7 +332,9 @@ pub(crate) fn mix_through_selection( sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage); } for ((a, o), k) in edited_row.iter().zip(orig_row).zip(&coverage) { - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + // `o·(1−k) + a·k` keeps the edit exact at full coverage, even over huge 32-bit + // values whose difference from it would round the edit away (#9483). + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] * (1.0 - k) + a[c] * k); let mut enc = [0.0f32; 8]; let m = photocraft_raster::from_rgba_into(fmt, mixed, &mut enc); out.extend_from_slice(&enc[..m]); @@ -427,6 +484,7 @@ fn edit_tiles( None => {} } let Some(src) = current.map(|t| &**t).or(blank.as_ref()) else { return Ok(None) }; + let source = src.try_bytes().map_err(|e| crate::EngineError::Other(format!("could not read pixels while {purpose}: {e}")))?; let mut out: Option = None; let (mut px, mut enc) = ([0.0f32; 8], [0u8; 32]); let (Some(px), Some(enc)) = (px.get_mut(..n), enc.get_mut(..bpp)) else { return Ok(None) }; @@ -434,7 +492,7 @@ fn edit_tiles( let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize; for x in tr.x0..tr.x1 { let i = (row + (x - tc.tx * TILE_SIZE) as usize) * bpp; - let Some(old) = src.bytes().get(i..i + bpp) else { continue }; + let Some(old) = source.get(i..i + bpp) else { continue }; for (c, v) in px.iter_mut().enumerate() { *v = photocraft_color::read_sample(old, fmt.sample, c); } @@ -446,10 +504,15 @@ fn edit_tiles( continue; } if out.is_none() { - out = Some(src.try_clone().map_err(oom)?); + let mut tile = surf.try_default_tile().map_err(oom)?; + tile.bytes_mut().copy_from_slice(&source); + out = Some(tile); } - if let Some(dst) = out.as_mut().and_then(|t| t.bytes_mut().get_mut(i..i + bpp)) { - dst.copy_from_slice(enc); + if let Some(tile) = out.as_mut() { + let mut bytes = tile.bytes_mut(); + if let Some(dst) = bytes.get_mut(i..i + bpp) { + dst.copy_from_slice(enc); + } } } } diff --git a/crates/engine/src/pixels/tests.rs b/crates/engine/src/pixels/tests.rs index 10596fbca9..f5330dfaa4 100644 --- a/crates/engine/src/pixels/tests.rs +++ b/crates/engine/src/pixels/tests.rs @@ -292,7 +292,7 @@ fn mixing_through_a_selection_matches_sampling_it_per_pixel() { let mut expected = Vec::new(); for (i, (a, o)) in edited.iter().zip(&orig).enumerate() { let k = sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] * (1.0 - k) + a[c] * k); let mut enc = [0.0f32; 8]; let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); expected.extend_from_slice(&enc[..m]); @@ -448,8 +448,23 @@ fn an_opaque_layer_of_distant_blocks_is_refused_not_half_adjusted() { } #[test] -fn a_selection_with_a_nonzero_default_over_distant_blocks_is_refused() { - let mut m = Surface::with_default(PixelFormat::GRAY8, &[0.5]); +fn a_selection_with_a_nonzero_default_over_distant_blocks_visits_only_stored_tiles() { + let mut m = Surface::with_default(PixelFormat::GRAY8, &[0.2]); let sel = distant_blocks(PixelFormat::GRAY8, 1.0); - assert!(adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).is_err()); + adjust_mask(&mut m, &Adjustment::Invert, Some(&sel)).unwrap(); + // The infinite selected default changes without materializing the grid between the holes. + assert!((m.default_pixel()[0] - 0.8).abs() < 0.01); + assert!((m.pixel(2_000_000_005, 2_000_000_005)[0] - 0.35).abs() < 0.01); + assert_eq!(m.tile_count(), 2); +} + +/// #9483: a destructive edit with full coverage writes its result exactly, even over huge 32-bit +/// values (`o + (a − o)·k` gave 0 for a 0.5 result over 1e8, and 0.125 for 0.1 over 1e6). +#[test] +fn full_coverage_keeps_the_edit_over_huge_hdr_values() { + let fmt = PixelFormat::RGBA32F; + for (o, a) in [(1.0e8_f32, 0.5_f32), (1.0e6, 0.1), (-1.0e6, 0.9)] { + let out = mix_through_selection(&fmt, Rect::new(0, 0, 1, 1), &[[o, o, o, 1.0]], &[[a, a, a, 1.0]], None); + assert_eq!(out[..3], [a, a, a], "{a} over {o}"); + } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 38401ff468..e8d8126e2f 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -295,7 +295,8 @@ pub struct Interface { pub show_bounding_box_when_dragging_layer: bool, /// Windows and Linux: use the system's title bar and window buttons instead of PhotoCraft's /// own one-row title bar (tiling window managers, desktops that draw their own decorations; - /// #1271, #1316). Read when the app starts. macOS always uses the system's. + /// #1271, #1316). The integrated title bar remains the default on Linux and Windows; + /// the system frame is opt-in. Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, /// Show the active document's file name in the title bar and the OS window title. The tabs /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). @@ -449,7 +450,7 @@ pub use shortcuts::{ reserved_shortcut, }; use store::read_lenient; -pub use store::{InvalidPref, PREFS_VERSION, PrefsStore}; +pub use store::{InvalidPref, PREFS_VERSION, PrefsStore, read_section}; pub use validate::{HIDDEN_UNTIL_IMPLEMENTED, LINUX_ONLY, SECTIONS, choices, gpu_adapter_matches, is_color, is_hidden, parse_hex, range}; use validate::{check_value, get_path, keyed, set_path}; @@ -492,6 +493,9 @@ pub struct FileHandling { pub exr_aces_cg: bool, pub ask_before_saving_layered_tiff: bool, pub maximize_psd_compatibility: Ask, + /// A `.pcraft` save also writes the flattened document at its canvas size as + /// `composite/full.png`. Off by default. + pub pcraft_full_composite: bool, /// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed. pub rasterize_svg_groups_deeper_than: u32, pub recent_file_count: u32, @@ -524,6 +528,7 @@ impl Default for FileHandling { exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, + pcraft_full_composite: false, rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32, recent_file_count: 20, recent_files: Vec::new(), @@ -562,12 +567,53 @@ impl Default for Export { /// [`Performance::memory_usage_mb`]'s default. pub const DEFAULT_MEMORY_USAGE_MB: u32 = 8192; +/// The most Memory Usage the web build honours, in MB: a wasm module's linear memory tops out at +/// 4 GiB, so a larger allowance (the desktop default is 8 GiB) would never trim anything. The +/// effect cache is capped at it on every 32-bit build ([`SMALL_ADDRESS_SPACE`]). +const WASM_MEMORY_USAGE_CAP_MB: u32 = 1536; + +/// A 32-bit address space (wasm32, 32-bit Windows): an effect cache budget beyond +/// [`WASM_MEMORY_USAGE_CAP_MB`] would never evict before allocation fails. +const SMALL_ADDRESS_SPACE: bool = cfg!(target_pointer_width = "32"); + +/// The Memory Usage a build honours for a `stored` preference: capped on the web build (`web`). +/// Separate from the target check so native tests exercise both. +fn honoured_memory_usage_mb(stored: u32, web: bool) -> u32 { + if web { stored.min(WASM_MEMORY_USAGE_CAP_MB) } else { stored } +} + +/// The smallest Effect Cache the Preferences dialog and `prefs.set` accept, in MB. +const EFFECT_CACHE_MIN_MB: u32 = 16; + +/// The largest Effect Cache the Preferences dialog and `prefs.set` accept, in MB: on a build with +/// a 32-bit address space (`small`) the cap there, so the field shows the budget the cache gets. +fn effect_cache_max_mb(small: bool) -> u32 { + if small { WASM_MEMORY_USAGE_CAP_MB } else { 65536 } +} + +/// The nearest Effect Cache the field accepts to a `stored` preference, in MB, on a build with a +/// 32-bit address space (`small`) or not. Separate from the target check so native tests exercise +/// both. +fn accepted_effect_cache_mb(stored: u32, small: bool) -> u32 { + stored.clamp(EFFECT_CACHE_MIN_MB, effect_cache_max_mb(small)) +} + +/// The layer-effect cache budget a build honours for a `stored` preference, in bytes: within the +/// range the field accepts, so a hand-edited 0 reports 16 MB, not 0. Separate from the target +/// check so native tests exercise both. +fn effect_cache_budget_bytes_for(stored: u32, small: bool) -> u64 { + u64::from(accepted_effect_cache_mb(stored, small)) << 20 +} + #[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), and the result of one transform. pub memory_usage_mb: u32, + /// Percent of physical RAM for the complete process; None preserves the legacy MB limit. + #[serde(default)] + pub memory_usage_percent: Option, /// Undo steps kept per document (History panel states). pub history_states: u32, /// Cache Levels, 1–8: the highest cache level the Histogram panel counts a large document at @@ -617,10 +663,41 @@ impl Performance { self.rendering_mode.unwrap_or_else(|| if !self.use_gpu || self.gpu_backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto }) } + /// [`Performance::memory_usage_mb`] as the build can honour it: capped on the web, where the + /// stored preference (a saved 8192 still loads) is larger than the module's address space. + pub fn effective_memory_usage_mb(&self) -> u32 { + honoured_memory_usage_mb(self.memory_usage_mb, cfg!(target_arch = "wasm32")) + } + /// Pixel memory a document and its History may hold (Memory Usage), in bytes: beyond it /// the oldest history states are dropped. pub fn history_budget_bytes(&self) -> usize { - (self.memory_usage_mb as usize).saturating_mul(1 << 20) + (self.effective_memory_usage_mb() as usize).saturating_mul(1 << 20) + } + + /// The byte limit History enforces now: none (only History States) while the scratch disk + /// is spilling, since tiles only History holds go to disk instead; otherwise + /// [`Self::history_budget_bytes`]. + pub fn history_cap_bytes(&self) -> usize { + if photocraft_raster::spill::enabled() { 0 } else { self.history_budget_bytes() } + } + + /// Pixel bytes kept in memory before tiles move to the scratch disk: half of Memory Usage + /// (the rest is for the GPU canvas, caches and the app). `PHOTOCRAFT_TILE_BUDGET_MB` + /// overrides it (tests, stress runs). + pub fn tile_budget_bytes(&self) -> usize { + let bytes = std::env::var("PHOTOCRAFT_TILE_BUDGET_MB") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|mb| mb.saturating_mul(1 << 20)) + .unwrap_or_else(|| u64::from(self.effective_memory_usage_mb()).saturating_mul(1 << 20) / 2); + usize::try_from(bytes).unwrap_or(usize::MAX) + } + + /// The layer-effect cache budget the build honours, in bytes: [`Performance::effect_cache_mb`], + /// capped at 1536 MB on 32-bit builds (the web and 32-bit Windows). + pub fn effect_cache_budget_bytes(&self) -> usize { + usize::try_from(effect_cache_budget_bytes_for(self.effect_cache_mb, SMALL_ADDRESS_SPACE)).unwrap_or(usize::MAX) } } @@ -629,6 +706,7 @@ impl Default for Performance { fn default() -> Self { Self { memory_usage_mb: DEFAULT_MEMORY_USAGE_MB, + memory_usage_percent: Some(70), history_states: 50, cache_levels: 4, cache_tile_size: 8192, @@ -658,11 +736,15 @@ pub struct ScratchDisk { pub struct ScratchDisks { /// Disks for spilling tiles once the memory budget is reached (in priority order). pub disks: Vec, + /// Largest the scratch file may grow, in MB; 0 = no limit. Reaching it pauses spilling + /// like a full disk (History's byte limit applies again). At least max(1 GB, 5 % of the + /// volume) is always left free, whatever this is set to. + pub size_limit_mb: u32, } impl Default for ScratchDisks { fn default() -> Self { - Self { disks: vec![ScratchDisk { path: "(system temp)".into(), enabled: true }] } + Self { disks: vec![ScratchDisk { path: "(system temp)".into(), enabled: true }], size_limit_mb: 0 } } } @@ -1003,6 +1085,19 @@ impl Default for Integrations { } } +/// Actions panel Playback Options. These are saved independently of an action library. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Actions { + pub create_single_history_state: bool, +} + +impl Default for Actions { + fn default() -> Self { + Self { create_single_history_state: true } + } +} + /// The History panel's History Options… (not part of Edit › Preferences in Photoshop either). /// Photoshop's snapshot options come with #3634. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -1087,6 +1182,7 @@ impl LayerMaskOverlay { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Preferences { + pub actions: Actions, pub general: General, pub interface: Interface, pub workspace: Workspace, @@ -1132,6 +1228,9 @@ pub struct Preferences { /// as the user changes it and restored at launch when Workspace › Remember Workspace /// Changes is on. JSON owned by the shell. pub panel_layout: Value, + /// The Color panel's options (what it shows, its ramp, Web colors, which chip it edits), + /// kept across restarts as Photoshop keeps them. JSON owned by the shell. + pub color_panel: Value, /// The last choices of dialogs that remember them across restarts, by command id (Edit › /// Fill…: `"edit.fill"` → its params). JSON owned by the shell. pub dialogs: BTreeMap, @@ -1160,6 +1259,9 @@ pub struct Preferences { /// targets that differ from their defaults, saved as the user toggles them and restored at /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. pub view_extras: Value, + /// Recently Used Colors (`"#rrggbb"`, newest first), shown by the shape Fill and Stroke + /// popovers and kept between sessions as in Photoshop. Owned by the shell. + pub recent_colors: Vec, /// 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. @@ -1303,9 +1405,12 @@ impl Session { /// linearity, the transform and layer-effect cache budgets and the text engine's /// missing-glyph protection. pub fn apply_prefs(&mut self) { + self.scratch_runtime.get_or_insert_with(crate::scratch::Runtime::default); + crate::scratch::configure(self.prefs()); + self.scratch_epoch = photocraft_raster::spill::epoch(); let n = self.prefs().performance.history_states.max(1) as usize; - let bytes = self.prefs().performance.history_budget_bytes(); - let budget = self.prefs().performance.effect_cache_mb as usize; + let bytes = self.prefs().performance.history_cap_bytes(); + let budget = self.prefs().performance.effect_cache_budget_bytes(); let non_linear = self.prefs().history_options.allow_non_linear_history; for st in &mut self.docs { st.history.max_states = n; @@ -1313,13 +1418,32 @@ impl Session { st.history.non_linear = non_linear; 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); + photocraft_compose::set_effect_cache_budget(budget); + crate::transform_cmds::set_budget(u64::from(self.prefs().performance.effective_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); } + /// Re-apply History's byte limit after the scratch disk paused (a write, create or + /// free-space failure) or resumed since it was last applied: while spilling is paused, + /// History again bounds the pixel memory it holds, as with no scratch disk. Runs before + /// every command; a single atomic load when nothing changed. + pub(crate) fn sync_scratch_state(&mut self) { + let epoch = photocraft_raster::spill::epoch(); + if self.scratch_epoch == epoch { + return; + } + self.scratch_epoch = epoch; + let bytes = self.prefs().performance.history_cap_bytes(); + for st in &mut self.docs { + if st.history.max_bytes != bytes { + st.history.max_bytes = bytes; + st.trim_history(); + } + } + } + /// Everything persisted as one JSON document: the preferences plus `colorSettings`. pub fn prefs_to_json(&self) -> String { serde_json::to_string_pretty(&self.prefs_value()).unwrap_or_default() @@ -1377,7 +1501,11 @@ impl Session { let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); let mut invalid = Vec::new(); - let prefs: Preferences = read_lenient(v, &[], &mut invalid); + let mut prefs: Preferences = read_lenient(v, &[], &mut invalid); + // An Effect Cache the field no longer accepts (above the 32-bit cap, saved before the cap + // or by a 64-bit build; a hand-edited 0) loads as the nearest value it does. Otherwise + // Apply from another Preferences tab, which validates every value, would reject it. + prefs.performance.effect_cache_mb = accepted_effect_cache_mb(prefs.performance.effect_cache_mb, SMALL_ADDRESS_SPACE); if let Some(c) = color { self.color.settings = read_lenient(c, &["colorSettings"], &mut invalid); photocraft_compose::psblend::set_text_gamma(self.color.settings.blend_text_gamma); @@ -1450,6 +1578,9 @@ impl Session { } else { next.set(path, value)?; } + if next.actions.create_single_history_state != self.prefs().actions.create_single_history_state && crate::actions_cmds::history_policy_in_use(self) { + return Err("cannot change action history settings during playback".into()); + } // Old `prefs.set interface.theme` clients (by path or inside a section object) still // select a visible theme. if path == "interface.theme" || next.interface.theme != self.prefs().interface.theme { diff --git a/crates/engine/src/prefs/cmds.rs b/crates/engine/src/prefs/cmds.rs index 982bf209ab..2c15505fab 100644 --- a/crates/engine/src/prefs/cmds.rs +++ b/crates/engine/src/prefs/cmds.rs @@ -56,8 +56,16 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { Ok(Value::Object(out)) } +/// Validate policy-changing resets before mutating either preferences or color settings. fn prefs_reset(s: &mut Session, p: &Value) -> Result { let path = p.get("path").or_else(|| p.get("section")).and_then(Value::as_str); + if !path.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.")) { + let mut candidate = s.prefs().clone(); + candidate.reset(path).map_err(|e| bad("prefs.reset", e))?; + if candidate.actions.create_single_history_state != s.prefs().actions.create_single_history_state && crate::actions_cmds::history_policy_in_use(s) { + return Err(bad("prefs.reset", "cannot change action history settings during playback")); + } + } match path { Some("colorSettings") => s.color.settings = Default::default(), // One colour setting: `Preferences` has no `colorSettings`, so take the default diff --git a/crates/engine/src/prefs/store.rs b/crates/engine/src/prefs/store.rs index 6ed7aa4a7c..07f0d8260f 100644 --- a/crates/engine/src/prefs/store.rs +++ b/crates/engine/src/prefs/store.rs @@ -75,6 +75,16 @@ where } } +/// One section of a preferences file (`performance`, …) read as [`Session::load_prefs_json`] +/// reads it: a value of the wrong type keeps its default and the rest still loads (#6611). +/// Startup reads the graphics settings with it before a session exists. +pub fn read_section(v: Value) -> T +where + T: Default + Serialize + for<'de> Deserialize<'de>, +{ + read_lenient(v, &[], &mut Vec::new()) +} + /// [`read_lenient`]'s walk: lay `file` over the object at `at` inside `root`, key by key, so that /// `root` always reads (`fits`). fn lay_over( diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6f17a21970..f10df0d5be 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -1,5 +1,21 @@ use super::*; +#[test] +fn system_title_bar_defaults_to_integrated_and_preserves_saved_choices() { + let default = false; + assert_eq!(Interface::default().system_title_bar, default); + for saved in [json!({}), json!({"interface": {}})] { + let prefs: Preferences = serde_json::from_value(saved).unwrap(); + assert_eq!(prefs.interface.system_title_bar, default); + } + for saved in [false, true] { + let prefs: Preferences = serde_json::from_value(json!({"interface": {"systemTitleBar": saved}})).unwrap(); + assert_eq!(prefs.interface.system_title_bar, saved); + let restored: Preferences = serde_json::from_value(prefs.to_json()).unwrap(); + assert_eq!(restored.interface.system_title_bar, saved); + } +} + fn session() -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); @@ -1033,3 +1049,106 @@ fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() { assert!(s.load_prefs_json(&deep).is_ok()); assert!(s.load_prefs_json(r#"{"interface": {"theme": 3, "uiScale": [1]}, "workspaces": {"x": 1}, "menus": 1}"#).is_ok()); } + +/// A saved Memory Usage is kept as stored, and means the same on desktop; only the web build +/// caps what it honours. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_history_budget_is_the_stored_memory_usage_on_native() { + for mb in [0u32, 1, 1536, 8192, 65536, u32::MAX] { + let p = Performance { memory_usage_mb: mb, ..Performance::default() }; + assert_eq!(p.effective_memory_usage_mb(), mb); + assert_eq!(p.history_budget_bytes(), (mb as usize) << 20, "{mb} MB"); + } + let mut s = Session::new(); + s.load_prefs_json(r#"{"performance": {"memoryUsageMb": 8192}}"#).unwrap(); + assert_eq!(s.prefs().performance.memory_usage_mb, 8192); + assert_eq!(s.prefs().performance.history_budget_bytes(), 8192 << 20); +} + +/// The web build honours Memory Usage up to its cap: below and at it as stored, above it the cap. +#[test] +fn the_web_build_caps_memory_usage() { + for (stored, honoured) in [ + (0, 0), + (1024, 1024), + (WASM_MEMORY_USAGE_CAP_MB, WASM_MEMORY_USAGE_CAP_MB), + (WASM_MEMORY_USAGE_CAP_MB + 1, WASM_MEMORY_USAGE_CAP_MB), + (8192, WASM_MEMORY_USAGE_CAP_MB), + (u32::MAX, WASM_MEMORY_USAGE_CAP_MB), + ] { + assert_eq!(honoured_memory_usage_mb(stored, true), honoured, "web, {stored} MB"); + assert_eq!(honoured_memory_usage_mb(stored, false), stored, "desktop, {stored} MB"); + } +} + +/// The effect cache budget is the stored MB in bytes, at least 16 MB (a hand-edited 0 included), +/// at most 1536 MB on a 32-bit address space (the web, 32-bit Windows) and 65536 MB elsewhere, +/// and never wraps: on wasm32 an uncapped 4096 MB shifted into `usize` was 0. Both kinds of +/// build are checked on every target, in `u64` so the expectations themselves can't overflow a +/// 32-bit `usize`. +#[test] +fn the_effect_cache_budget_is_capped_on_32_bit_builds_and_never_wraps() { + const MB: u64 = 1 << 20; + let cap = WASM_MEMORY_USAGE_CAP_MB; + for (stored, small_mb, large_mb) in [ + (0, 16, 16), + (15, 16, 16), + (16, 16, 16), + (768, 768, 768), + (cap, cap, cap), + (cap + 1, cap, cap + 1), + (4096, cap, 4096), + (65536, cap, 65536), + (u32::MAX, cap, 65536), + ] { + assert_eq!(effect_cache_budget_bytes_for(stored, true), u64::from(small_mb) * MB, "32-bit, {stored} MB"); + assert_eq!(effect_cache_budget_bytes_for(stored, false), u64::from(large_mb) * MB, "64-bit, {stored} MB"); + } + let p = Performance { effect_cache_mb: 4096, ..Performance::default() }; + let expected = effect_cache_budget_bytes_for(4096, cfg!(target_pointer_width = "32")); + assert_eq!(u64::try_from(p.effect_cache_budget_bytes()).ok(), Some(expected), "fits this target's usize"); +} + +/// The Effect Cache field accepts only what the build honours, so the dialog never shows a value +/// the cache doesn't get. +#[test] +fn the_effect_cache_range_matches_the_honoured_budget() { + assert_eq!(effect_cache_max_mb(true), WASM_MEMORY_USAGE_CAP_MB); + assert_eq!(effect_cache_max_mb(false), 65536); + for small in [true, false] { + let max = effect_cache_max_mb(small); + assert_eq!(effect_cache_budget_bytes_for(max, small), u64::from(max) << 20, "small = {small}: the largest accepted value is honoured"); + } + assert_eq!(effect_cache_budget_bytes_for(EFFECT_CACHE_MIN_MB, false), u64::from(EFFECT_CACHE_MIN_MB) << 20, "the smallest accepted value is honoured"); + let small = cfg!(target_pointer_width = "32"); + assert_eq!(range("performance.effectCacheMb"), Some((f64::from(EFFECT_CACHE_MIN_MB), f64::from(effect_cache_max_mb(small))))); + assert!(check_value("performance.effectCacheMb", &json!(effect_cache_max_mb(small))).is_ok()); + assert!(check_value("performance.effectCacheMb", &json!(effect_cache_max_mb(small) + 1)).is_err()); + assert!(Performance::default().effect_cache_mb <= effect_cache_max_mb(true), "the default fits the 32-bit cap"); +} + +/// An Effect Cache the field no longer accepts loads as the nearest value it does: a 32-bit build +/// took up to 65536 MB before its 1536 MB cap, and Apply from another Preferences tab sends every +/// value, so the old one rejected the whole change. On every target: the helper for both kinds of +/// build, and the load and Apply paths for this one. +#[test] +fn an_effect_cache_above_the_cap_loads_clamped_and_does_not_block_apply() { + let cap = WASM_MEMORY_USAGE_CAP_MB; + for (stored, small_mb, large_mb) in [(0, 16, 16), (768, 768, 768), (cap + 1, cap, cap + 1), (4096, cap, 4096), (65536, cap, 65536), (u32::MAX, cap, 65536)] + { + assert_eq!(accepted_effect_cache_mb(stored, true), small_mb, "32-bit, {stored} MB"); + assert_eq!(accepted_effect_cache_mb(stored, false), large_mb, "64-bit, {stored} MB"); + } + let max = effect_cache_max_mb(SMALL_ADDRESS_SPACE); + for (saved, loaded) in [(u64::from(max) + 1, max), (u64::from(u32::MAX), max), (0, EFFECT_CACHE_MIN_MB), (768, 768)] { + let mut s = session(); + let invalid = s.load_prefs_json(&json!({"performance": {"effectCacheMb": saved}}).to_string()).unwrap(); + assert!(invalid.is_empty(), "{saved} MB reads, so it isn't reported: {invalid:?}"); + assert_eq!(s.execute("prefs.get", json!({"path": "performance.effectCacheMb"})).unwrap(), json!(loaded), "{saved} MB"); + let mut values = s.execute("prefs.get", json!({"path": ""})).unwrap(); + values["fileHandling"]["recentFileCount"] = json!(7); + s.execute("prefs.set", json!({"path": "", "value": values})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"path": "fileHandling.recentFileCount"})).unwrap(), json!(7), "{saved} MB"); + } +} diff --git a/crates/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index 37d0fdf022..5fb31253b1 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -33,14 +33,13 @@ pub const SECTIONS: [(&str, &str); 18] = [ /// They still load, save and round-trip through `prefs.get` / `prefs.set` unchanged. /// /// When you implement one, remove it here; the `prefs_usage` test fails while a listed -/// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere. +/// preference is read anywhere outside the prefs model (`prefs.rs` except its `impl Session` +/// blocks), and while an unlisted one is read nowhere. pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", - "performance.effectCacheMb", "performance.legacyCompositing", - "scratchDisks.disks", "unitsAndRulers.columnWidth", "unitsAndRulers.gutter", "plugIns.showExtensionPanels", @@ -119,10 +118,11 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), "performance.memoryUsageMb" => (256.0, 1_048_576.0), "performance.gpuMemoryMb" => (0.0, 1_048_576.0), + "performance.memoryUsagePercent" => (1.0, 95.0), "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), - "performance.effectCacheMb" => (16.0, 65536.0), + "performance.effectCacheMb" => (f64::from(super::EFFECT_CACHE_MIN_MB), f64::from(super::effect_cache_max_mb(super::SMALL_ADDRESS_SPACE))), "transparencyAndGamut.gamutWarningOpacity" => (1.0, 100.0), "unitsAndRulers.columnWidth" | "unitsAndRulers.gutter" => (0.0, 10_000.0), "unitsAndRulers.printResolution" | "unitsAndRulers.screenResolution" | "rawDefaults.resolution" => (1.0, 10_000.0), @@ -231,7 +231,7 @@ pub(super) fn check_value(path: &str, v: &Value) -> std::result::Result<(), Stri seen.push(theme); } } - if path == "performance.renderingMode" && v.is_null() { + if matches!(path, "performance.renderingMode" | "performance.memoryUsagePercent") && v.is_null() { return Ok(()); // Legacy policy, resolved from useGpu and gpuBackend. } if let Some(c) = choices(path) { diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 92ad78e5dd..cc93556412 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -35,14 +35,17 @@ //! changes asynchronously. Gradient presets, including imported `.grd` groups, are small and persist with //! the preferences document (`presets.gradients`). //! +//! The pattern library (imported `.pat` and defined patterns, not the built-ins) lives there too: +//! `patterns.json` plus one `.pat` file per pattern, see [`patterns`]. +//! //! The Actions list (`actions.json`, see [`crate::actions_cmds`]) lives in the same directory. //! A missing file is an empty list. A corrupt or oversized file is skipped with a warning. //! Headless sessions have no store, so their actions stay in memory. //! -//! So do the patterns the user defined or imported (`patterns.pat`, Photoshop's own pattern file -//! format, #9317): the whole user library in its order, rewritten when its ids, names or order -//! change. The built-in patterns are regenerated on start and never written. Like `actions.json`, -//! a file that fails to load is warned about and never overwritten. +//! Patterns use `patterns.json` and lazily loaded per-pattern assets. On first startup a +//! valid legacy `patterns.pat` is migrated by writing assets before the atomic index commit. +//! Legacy and corrupt data are preserved; an existing index is authoritative even when corrupt. +//! A failed migration leaves the legacy library usable and retries at the next startup. use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Read; @@ -54,6 +57,9 @@ use serde::{Deserialize, Serialize}; use crate::Session; +mod patterns; +pub use patterns::{Loaded as LoadedPattern, MAX_PATTERN_BYTES, PATTERNS_FILE, PREVIEW_SIDE as PATTERN_PREVIEW_SIDE}; + /// Group file extension. pub const GROUP_EXT: &str = "pcbrushes"; /// Tip file extension (inside [`TIPS_DIR`]). @@ -73,7 +79,7 @@ pub const ACTIONS_FILE: &str = "actions.json"; /// Largest Actions file read or written. pub const MAX_ACTIONS_BYTES: u64 = 16 << 20; /// The user's pattern library (`.pat`). -pub const PATTERNS_FILE: &str = "patterns.pat"; +pub const LEGACY_PATTERNS_FILE: &str = "patterns.pat"; /// Largest pattern library read or written. pub const MAX_PATTERNS_BYTES: u64 = 256 << 20; /// The whole store never grows beyond this; further groups are skipped with a warning. @@ -560,13 +566,14 @@ pub struct PresetStore { actions_rev: u64, /// `actions.json` exists but failed to load: left on disk untouched, so it is never written. actions_skipped: bool, - /// [`patterns_key`] of the user patterns last written (or loaded). - patterns_key: u64, - /// `patterns.pat` exists but failed to load: left on disk untouched, so it is never written. - patterns_skipped: bool, + /// The pattern files and index (`patterns.json`). + patterns: patterns::PatternFiles, warnings: Vec, /// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load). cache: Mutex, + /// The stored pattern last read in full, so painting with it again doesn't re-read its file + /// (a texture can be tens of megabytes). One: the pattern in use. + pattern_cache: Mutex>, } /// A loaded store: the presets on disk plus what the session should hide. @@ -582,8 +589,8 @@ pub struct Opened { pub warnings: Vec, /// Recorded actions (`actions.json`). Empty when the file is missing or unreadable. pub actions: Vec, - /// User patterns (`patterns.pat`), in library order. Empty when the file is missing or unreadable. - pub patterns: Vec, + /// Imported and defined patterns (`patterns.json`), in library order; large ones as previews. + pub patterns: Vec, } /// Load a store (any thread; the desktop app does this in the background at start). @@ -660,9 +667,7 @@ pub fn open(backend: Box) -> Opened { let loaded_actions = load_actions(backend.as_ref(), &mut warnings); let actions_skipped = loaded_actions.is_none(); let actions = loaded_actions.unwrap_or_default(); - let loaded_patterns = load_patterns(backend.as_ref(), &mut warnings); - let patterns_skipped = loaded_patterns.is_none(); - let patterns = loaded_patterns.unwrap_or_default(); + let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings); let mut store = PresetStore { backend, synced_rev: None, @@ -672,10 +677,10 @@ pub fn open(backend: Box) -> Opened { index: None, actions_rev: 0, actions_skipped, - patterns_key: patterns_key(&patterns, 0), - patterns_skipped, + patterns: pattern_files, warnings: Vec::new(), cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }), + pattern_cache: Mutex::new(None), }; let mut presets = Vec::new(); for (file, size, g) in parsed { @@ -734,40 +739,6 @@ pub fn open(backend: Box) -> Opened { Opened { store, presets, hidden_builtins, order, warnings, actions, patterns } } -/// `patterns.pat`: a missing file is no patterns. Anything else unreadable is a warning. -/// The saved patterns; `None` when `patterns.pat` exists but could not be loaded. -fn load_patterns(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option> { - let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERNS_BYTES) { - Ok(b) => b, - Err(e) if missing_file(&e) => return Some(Vec::new()), - Err(e) => { - warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); - return None; - } - }; - match photocraft_io::pattern_map::read_pat(&bytes) { - Ok(p) => Some(p), - Err(e) => { - warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); - None - } - } -} - -/// What decides whether the user patterns need rewriting: their ids, names, sizes and order, and -/// the library's [`pixels_rev`](crate::pattern_cmds::PatternLibrary::pixels_rev) (pixels replaced -/// under the same id). -fn patterns_key(patterns: &[photocraft_doc::Pattern], pixels_rev: u64) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for p in patterns { - (&p.id, &p.name, p.width, p.height).hash(&mut h); - } - patterns.len().hash(&mut h); - pixels_rev.hash(&mut h); - h.finish() -} - /// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. /// The saved actions; `None` when `actions.json` exists but could not be loaded. fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option> { @@ -1097,6 +1068,29 @@ impl PresetStore { Ok(()) } + /// Write the pattern library's changes (see [`patterns`]). + pub fn sync_patterns(&mut self, library: &crate::pattern_cmds::PatternLibrary) { + let changed = self.patterns.pixels_rev != library.pixels_rev; + patterns::sync(self.backend.as_ref(), &mut self.patterns, library, &mut self.warnings); + let cache = self.pattern_cache.get_mut().unwrap_or_else(PoisonError::into_inner); + if changed || cache.as_ref().is_some_and(|p| !self.patterns.index.iter().any(|e| e.id == p.id)) { + *cache = None; + } + } + + /// The full pixels of a stored pattern the library holds as a preview: the last one read is + /// kept, so only switching patterns reads a file. + pub fn load_pattern(&self, id: &str) -> Result { + let mut cache = self.pattern_cache.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(p) = cache.as_ref().filter(|p| p.id == id) { + return Ok(p.clone()); + } + let entry = self.patterns.index.iter().find(|e| e.id == id).ok_or_else(|| format!("pattern {id} is not in the preset store"))?; + let p = patterns::load_full(self.backend.as_ref(), entry)?; + *cache = Some(p.clone()); + Ok(p) + } + /// Write the Actions list when `rev` differs from the last load or write. /// An oversized list is warned once and not retried. An I/O error is warned and retried /// on the next command. @@ -1130,49 +1124,6 @@ impl PresetStore { } } -impl PresetStore { - /// Write the user patterns (the library minus the built-ins) when they changed since the last - /// load or write. An oversized library is warned once per change and not written. An I/O - /// error is warned and retried on the next command. - pub fn sync_patterns(&mut self, library: &[photocraft_doc::Pattern], pixels_rev: u64) { - let builtins = crate::pattern_cmds::builtin(); - let user: Vec = library.iter().filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)).cloned().collect(); - let key = patterns_key(&user, pixels_rev); - if self.patterns_key == key { - return; - } - self.patterns_key = key; - if self.patterns_skipped { - self.warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged")); - return; - } - if user.is_empty() { - if let Err(e) = self.backend.remove(PATTERNS_FILE) { - self.warnings.push(format!("Couldn't save patterns: {e}")); - // Try again after the next command. - self.patterns_key = key.wrapping_add(1); - } - return; - } - let bytes = match photocraft_io::pattern_map::write_pat(&user) { - Ok(b) => b, - Err(e) => { - self.warnings.push(format!("Couldn't save patterns: {e}")); - return; - } - }; - if bytes.len() as u64 > MAX_PATTERNS_BYTES { - self.warnings.push(format!("patterns: the pattern library is too large to save ({} MB limit)", MAX_PATTERNS_BYTES >> 20)); - return; - } - if let Err(e) = self.backend.write(PATTERNS_FILE, &bytes) { - self.warnings.push(format!("Couldn't save patterns: {e}")); - // Try again after the next command. - self.patterns_key = key.wrapping_add(1); - } - } -} - fn builtin_names() -> &'static [String] { static NAMES: std::sync::OnceLock> = std::sync::OnceLock::new(); NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect()) @@ -1190,21 +1141,27 @@ impl Session { /// deleted built-ins, then sync. Returns the load warnings. pub fn attach_preset_store(&mut self, opened: Opened) -> Vec { let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk, patterns } = opened; - // Saved patterns join the library after the built-ins, and a saved built-in (replaced by - // an import or renamed) takes the shipped one's place; a pattern defined or imported - // before the store finished loading keeps its place (and the same id isn't added twice). let builtins = crate::pattern_cmds::builtin(); - for p in patterns { - match self.patterns.items.iter_mut().find(|q| q.id == p.id) { - Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => *q = p, - // The earlier copy wins; other pixels than the saved ones must still be written, - // which its id, name and size don't show. + for l in patterns { + let id = l.pattern.id.clone(); + match self.patterns.items.iter_mut().find(|q| q.id == id) { + Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => { + *q = l.pattern; + if let Some(size) = l.full { + self.patterns.stored.insert(id, size); + } + } Some(q) => { - if q.surface != p.surface { + if q.surface != l.pattern.surface { self.patterns.pixels_rev += 1; } } - None => self.patterns.items.push(p), + None => { + if let Some(size) = l.full { + self.patterns.stored.insert(id, size); + } + self.patterns.items.push(l.pattern); + } } } let lib = &mut self.tools.presets; @@ -1266,7 +1223,7 @@ impl Session { } } st.sync_actions(&self.actions.list, actions_rev); - st.sync_patterns(&self.patterns.items, self.patterns.pixels_rev); + st.sync_patterns(&self.patterns); } } diff --git a/crates/engine/src/preset_store/patterns.rs b/crates/engine/src/preset_store/patterns.rs new file mode 100644 index 0000000000..ff535ebcf6 --- /dev/null +++ b/crates/engine/src/preset_store/patterns.rs @@ -0,0 +1,380 @@ +//! The pattern library in the preset store: imported (`.pat`) and defined patterns survive a +//! restart, as Photoshop keeps its Patterns panel. +//! +//! - `pattern-.pat`: one pattern per file (a one-pattern Photoshop `.pat`), written once +//! when the pattern enters the library and deleted when it leaves it; +//! - `pattern-.preview.pat`: the same pattern shrunk to at most [`PREVIEW_SIDE`] px, for +//! patterns larger than that; +//! - `patterns.json`: the library order, names and full sizes (renaming rewrites only this file). +//! +//! Opening the store reads the index and the previews, never a large pattern's pixels: texture +//! libraries run to gigabytes (Debaser's colour libraries hold twenty 4500 × 4500 px patterns per +//! file). Such a pattern joins the library as its preview, with its full size in +//! [`crate::pattern_cmds::PatternLibrary::stored`], and its pixels are read when it is used +//! ([`super::PresetStore::load_pattern`]). +//! +//! Built-in patterns are never written. A pattern file or index that fails to load is left on +//! disk untouched and never overwritten or deleted; a missing index is an empty library. + +use std::collections::HashSet; + +use photocraft_doc::Pattern; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +use super::{PresetBackend, missing_file}; +use crate::pattern_cmds::PatternLibrary; + +/// Library order and names. +pub const PATTERNS_FILE: &str = "patterns.json"; +/// Largest `patterns.json` read or written. +pub const MAX_PATTERN_INDEX_BYTES: u64 = 4 << 20; +/// Largest pattern file read or written. +pub const MAX_PATTERN_BYTES: u64 = 256 << 20; +/// Most patterns loaded. +pub const MAX_PATTERNS: usize = 10_000; +/// Patterns larger than this (either side) are loaded as a preview of this size until used. +pub const PREVIEW_SIDE: u32 = 128; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub(super) struct Entry { + pub id: String, + pub name: String, + pub file: String, + #[serde(default)] + pub width: u32, + #[serde(default)] + pub height: u32, +} + +#[derive(Serialize, Deserialize)] +struct IndexFile { + version: u32, + patterns: Vec, +} + +/// The store's view of the pattern files it owns. +#[derive(Default)] +pub(super) struct PatternFiles { + /// The index as last loaded or written. + pub index: Vec, + /// `patterns.json` exists but failed to load: never written. + pub index_skipped: bool, + /// Pattern files that failed to load: never written or deleted. + pub skipped: HashSet, + /// Patterns that couldn't be written (too large, I/O error): not retried this session, so + /// one failure is one warning. + pub failed: HashSet, + pub pixels_rev: u64, + pub pending_removals: HashSet, +} + +/// A stored pattern as loaded: the pattern (a preview when `full` is set) and its full size. +pub struct Loaded { + pub pattern: Pattern, + pub full: Option<(u32, u32)>, +} + +fn hash(id: &str) -> u64 { + id.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + +/// The file a pattern is stored in (its id hashed: ids are UUIDs or content hashes, but may hold +/// any character). +fn file_name(id: &str) -> String { + format!("pattern-{:016x}.pat", hash(id)) +} + +fn preview_name(id: &str) -> String { + format!("pattern-{:016x}.preview.pat", hash(id)) +} + +#[cfg(test)] +pub(super) fn file_name_for_test(id: &str) -> String { + file_name(id) +} + +fn is_large(width: u32, height: u32) -> bool { + width.max(height) > PREVIEW_SIDE +} + +/// `p` shrunk to at most [`PREVIEW_SIDE`] px (nearest sample: a swatch, not a resample). +fn preview(p: &Pattern) -> Pattern { + let (w, h) = (p.width.max(1), p.height.max(1)); + let step = f64::from(w.max(h)) / f64::from(PREVIEW_SIDE); + let pw = ((f64::from(w) / step).round() as u32).clamp(1, PREVIEW_SIDE); + let ph = ((f64::from(h) / step).round() as u32).clamp(1, PREVIEW_SIDE); + let fmt = p.surface.format(); + let mut data = Vec::with_capacity(pw as usize * ph as usize * fmt.channels()); + for y in 0..ph { + for x in 0..pw { + let sx = ((f64::from(x) * step) as u32).min(w - 1); + let sy = ((f64::from(y) * step) as u32).min(h - 1); + data.extend(p.surface.pixel(sx as i32, sy as i32)); + } + } + let mut surface = Surface::new(fmt); + surface.write_region(Rect::new(0, 0, pw as i32, ph as i32), &data); + Pattern { id: p.id.clone(), name: p.name.clone(), width: pw, height: ph, surface } +} + +fn read_one(backend: &dyn PresetBackend, file: &str) -> Result { + let mut v = photocraft_io::pattern_map::read_pat(&backend.read(file, MAX_PATTERN_BYTES)?)?; + if v.is_empty() { Err("no pattern in the file".to_string()) } else { Ok(v.swap_remove(0)) } +} + +/// A stored pattern's full pixels (named and identified as in `entry`). +pub(super) fn load_full(backend: &dyn PresetBackend, entry: &Entry) -> Result { + let mut p = read_one(backend, &entry.file)?; + p.id = entry.id.clone(); + p.name = entry.name.clone(); + Ok(p) +} + +/// Load the stored patterns, in library order: small ones whole, large ones as previews. +pub(super) fn load(backend: &dyn PresetBackend, warnings: &mut Vec) -> (Vec, PatternFiles) { + let mut files = PatternFiles::default(); + let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERN_INDEX_BYTES) { + Ok(b) => b, + Err(e) if missing_file(&e) => return migrate_legacy(backend, warnings), + Err(e) => { + warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let index = match serde_json::from_slice::(&bytes) { + Ok(f) if f.version == 1 => f.patterns, + Ok(f) => { + warnings.push(format!("patterns: {PATTERNS_FILE} skipped: unsupported version {}", f.version)); + files.index_skipped = true; + return (Vec::new(), files); + } + Err(e) => { + warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let mut ids = HashSet::new(); + let valid = index.len() <= MAX_PATTERNS && index.iter().all(|e| e.file == file_name(&e.id) && ids.insert(e.id.clone())); + if !valid { + warnings + .push("patterns: invalid/duplicate index entries; preserve files and recover patterns.json before saving (legacy PAT is not reimported)".into()); + files.index_skipped = true; + return (Vec::new(), files); + } + let mut patterns = Vec::new(); + let mut seen = HashSet::new(); + for e in index.into_iter().take(MAX_PATTERNS) { + // Only generated names: an edited index can't reach other files. + if e.file != file_name(&e.id) || !seen.insert(e.id.clone()) { + warnings.push(format!("patterns: entry \"{}\" skipped: bad file name", e.name)); + continue; + } + let loaded = if is_large(e.width, e.height) { + // The preview; without one (a failed write), the full file once to make it. + match read_one(backend, &preview_name(&e.id)) { + Ok(mut p) => { + p.id = e.id.clone(); + p.name = e.name.clone(); + Ok(Loaded { pattern: p, full: Some((e.width, e.height)) }) + } + Err(_) => load_full(backend, &e).map(|p| Loaded { pattern: preview(&p), full: Some((p.width, p.height)) }), + } + } else { + load_full(backend, &e).map(|p| Loaded { pattern: p, full: None }) + }; + match loaded { + Ok(l) => { + patterns.push(l); + files.index.push(e); + } + Err(err) => { + warnings.push(format!("patterns: {} (\"{}\") skipped: {err}", e.file, e.name)); + files.skipped.insert(e.file.clone()); + // Kept in the index, so a later write doesn't forget the file. + files.index.push(e); + } + } + } + (patterns, files) +} + +/// Write what changed: new patterns' files (and previews), the index when the order or a name +/// changed, and delete the files of patterns that left the library. Failures are warnings. +pub(super) fn sync(backend: &dyn PresetBackend, files: &mut PatternFiles, library: &PatternLibrary, warnings: &mut Vec) { + files.pending_removals.retain(|f| match backend.remove(f) { + Ok(()) => false, + Err(e) => { + warnings.push(format!("Couldn't delete pattern file {f}: {e}")); + true + } + }); + let builtins = crate::pattern_cmds::builtin(); + let pixels_changed = files.pixels_rev != library.pixels_rev; + let mut want: Vec = library + .items + .iter() + .filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)) + .map(|p| { + let (width, height) = library.stored.get(&p.id).copied().unwrap_or((p.width, p.height)); + Entry { id: p.id.clone(), name: p.name.clone(), file: file_name(&p.id), width, height } + }) + .collect(); + // Unreadable files stay listed (at the end) until the library takes their pattern back. + for e in &files.index { + if files.skipped.contains(&e.file) && !want.iter().any(|w| w.id == e.id) { + want.push(e.clone()); + } + } + if want == files.index && !pixels_changed { + return; + } + if files.index_skipped { + warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged")); + files.index = want; + return; + } + let have: HashSet<&str> = files.index.iter().map(|e| e.id.as_str()).collect(); + let mut written = Vec::with_capacity(want.len()); + for e in want { + // Already on disk (a preview in the library included: its pixels never left the disk). + if (have.contains(e.id.as_str()) && !pixels_changed) || files.skipped.contains(&e.file) || library.stored.contains_key(&e.id) { + written.push(e); + continue; + } + let Some(p) = library.items.iter().find(|p| p.id == e.id) else { continue }; + match write_pattern(backend, p, &e, have.contains(e.id.as_str())) { + Ok(()) => written.push(e), + Err(err) => { + warnings.push(err); + files.failed.insert(e.id); + return; + } + } + } + if written.len() > MAX_PATTERNS { + warnings.push("patterns: index count limit exceeded".into()); + return; + } + let index = IndexFile { version: 1, patterns: written }; + let bytes = match serde_json::to_vec_pretty(&index) { + Ok(b) => b, + Err(e) => { + warnings.push(format!("Couldn't save patterns: {e}")); + return; + } + }; + if bytes.len() as u64 > MAX_PATTERN_INDEX_BYTES { + warnings.push("patterns: index byte limit exceeded".into()); + return; + } + if let Err(e) = backend.write(PATTERNS_FILE, &bytes) { + warnings.push(format!("Couldn't save patterns: {e}")); + return; + } + // Files of patterns that left the library (written by an earlier index) are deleted only + // once the new index no longer names them. + let keep: HashSet<&str> = index.patterns.iter().map(|e| e.file.as_str()).collect(); + for e in &files.index { + if !keep.contains(e.file.as_str()) && !files.skipped.contains(&e.file) { + for f in [e.file.clone(), preview_name(&e.id)] { + if let Err(err) = backend.remove(&f) { + warnings.push(format!("Couldn't delete pattern file {f}: {err}")); + files.pending_removals.insert(f); + } + } + } + } + files.index = index.patterns; + files.pixels_rev = library.pixels_rev; +} + +fn write_pattern(backend: &dyn PresetBackend, p: &Pattern, e: &Entry, owned: bool) -> Result<(), String> { + let bytes = photocraft_io::pattern_map::write_pat(std::slice::from_ref(p)).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + if bytes.len() as u64 > MAX_PATTERN_BYTES { + return Err(format!("pattern \"{}\" is too large to save ({} MB limit)", p.name, MAX_PATTERN_BYTES >> 20)); + } + if is_large(p.width, p.height) { + // The preview first: a full file without one still loads (by making it). + let small = photocraft_io::pattern_map::write_pat(&[preview(p)]).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + write_asset(backend, &preview_name(&e.id), &small, owned).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + } + write_asset(backend, &e.file, &bytes, owned).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name)) +} + +/// The index itself is the atomic migration commit. Never consult stale PAT when it exists, +/// including when corrupt. An interrupted asset write leaves the legacy bytes authoritative. +fn migrate_legacy(backend: &dyn PresetBackend, warnings: &mut Vec) -> (Vec, PatternFiles) { + let mut files = PatternFiles::default(); + let bytes = match backend.read(super::LEGACY_PATTERNS_FILE, super::MAX_PATTERNS_BYTES) { + Ok(b) => b, + Err(e) if missing_file(&e) => return (Vec::new(), files), + Err(e) => { + warnings.push(format!("patterns: legacy file preserved: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let patterns = match photocraft_io::pattern_map::read_pat(&bytes) { + Ok(p) => p, + Err(e) => { + warnings.push(format!("patterns: legacy file preserved: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + if patterns.len() > MAX_PATTERNS || patterns.iter().any(|p| !ids.insert(p.id.clone()) || !names.insert(file_name(&p.id))) { + warnings.push("patterns: legacy migration refused duplicate IDs, file hash collision or count limit; legacy preserved".into()); + files.index_skipped = true; + return (patterns.into_iter().map(|pattern| Loaded { pattern, full: None }).collect(), files); + } + let migration = (|| -> Result<(), String> { + let entries: Vec = + patterns.iter().map(|p| Entry { id: p.id.clone(), name: p.name.clone(), file: file_name(&p.id), width: p.width, height: p.height }).collect(); + let index = serde_json::to_vec_pretty(&IndexFile { version: 1, patterns: entries.clone() }).map_err(|e| e.to_string())?; + if index.len() as u64 > MAX_PATTERN_INDEX_BYTES { + return Err("index exceeds size limit".into()); + } + for (p, e) in patterns.iter().zip(&entries) { + // Never overwrite unknown files, even if a name collides. Identical orphan assets + // from an interrupted migration can be reused; the legacy file stays untouched. + let full = photocraft_io::pattern_map::write_pat(std::slice::from_ref(p))?; + if full.len() as u64 > MAX_PATTERN_BYTES { + return Err("pattern exceeds size limit".into()); + } + write_migration_asset(backend, &e.file, &full)?; + if is_large(p.width, p.height) { + let small = photocraft_io::pattern_map::write_pat(&[preview(p)])?; + write_migration_asset(backend, &preview_name(&p.id), &small)?; + } + } + // Atomic backend write last: no partial or failed migration can become authoritative. + backend.write(PATTERNS_FILE, &index)?; + Ok(()) + })(); + match migration { + Ok(()) => load(backend, warnings), + Err(e) => { + warnings.push(format!("patterns: migration not committed, legacy remains usable: {e}")); + files.index_skipped = true; + (patterns.into_iter().map(|pattern| Loaded { pattern, full: None }).collect(), files) + } + } +} +fn write_migration_asset(backend: &dyn PresetBackend, name: &str, bytes: &[u8]) -> Result<(), String> { + match backend.read(name, MAX_PATTERN_BYTES) { + Ok(existing) if existing == bytes => Ok(()), + Ok(_) => Err(format!("unrecognized existing asset {name} preserved")), + Err(e) if missing_file(&e) => backend.write(name, bytes), + Err(e) => Err(format!("existing asset {name} preserved: {e}")), + } +} + +fn write_asset(backend: &dyn PresetBackend, name: &str, bytes: &[u8], owned: bool) -> Result<(), String> { + if owned { backend.write(name, bytes) } else { write_migration_asset(backend, name, bytes) } +} diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index e0080aa4cb..7f0ead6c6b 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -518,10 +518,21 @@ fn missing_corrupt_or_unknown_tips_fail_when_used_without_panicking() { // Deleted behind the store's back. let k0 = key(&t, "Big 0"); mem.remove(&tip_file(&k0)).unwrap(); + t.execute("tools.setBrush", json!({"preset": "Hard Round"})).unwrap(); + let broken = t.tools.presets.iter_mut().find(|p| p.name == "Big 0").unwrap(); + broken.captures.color = true; + broken.brush.color = [1.0, 0.0, 0.0, 1.0]; + t.tools.foreground = [0.0, 0.0, 1.0, 1.0]; let before = t.tools.brush.clone(); + let preset_before = t.tools.current_preset.clone(); + let foreground_before = t.tools.foreground; + let revision_before = t.tools.brush_rev; assert!(t.execute("tools.setBrush", json!({"preset": "Big 0"})).is_err()); assert!(t.execute("paint.stroke", json!({"points": [[1, 1], [9, 9]], "preset": "Big 0"})).is_err()); assert_eq!(t.tools.brush, before); + assert_eq!(t.tools.current_preset, preset_before); + assert_eq!(t.tools.foreground, foreground_before); + assert_eq!(t.tools.brush_rev, revision_before); // Corrupt, or a different bitmap under the same name. let k1 = key(&t, "Big 1"); mem.write(&tip_file(&k1), b"PCTIP1\x08garbage").unwrap(); @@ -704,6 +715,129 @@ fn an_actions_file_that_failed_to_load_is_not_overwritten() { assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty()); } +/// A large pattern opens as a small preview (texture libraries run to gigabytes) and is read at +/// full size when it is used; its preview file goes with it on delete. +#[test] +fn large_patterns_load_on_use() { + let dir = TempDir::new("patterns-large"); + let (mut s, _) = session(&dir); + s.execute("file.new", json!({"width": 300, "height": 200})).unwrap(); + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + s.execute("select.rect", json!({"x": 250, "y": 150, "width": 50, "height": 50})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); + assert_eq!(dir.files().iter().filter(|f| f.ends_with(".preview.pat")).count(), 1); + + let (mut t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + let held = t.patterns.items.iter().find(|p| p.name == "Big").unwrap(); + assert!(held.width <= PATTERN_PREVIEW_SIDE && held.height <= PATTERN_PREVIEW_SIDE, "held as a preview: {}x{}", held.width, held.height); + let listed = t.execute("pattern.list", json!({})).unwrap(); + let big = listed.as_array().unwrap().iter().find(|p| p["name"] == "Big").unwrap(); + assert_eq!((big["width"].as_u64(), big["height"].as_u64()), (Some(300), Some(200)), "the list reports the full size"); + // Checks that only need the pattern's id (Pattern Stamp's enabled state) don't read its file. + let id = t.patterns.items.iter().find(|p| p.name == "Big").unwrap().id.clone(); + let file = dir.0.join(patterns::file_name_for_test(&id)); + let moved = file.with_extension("moved"); + std::fs::rename(&file, &moved).unwrap(); + assert_eq!(crate::pattern_cmds::resolve_id(&t, "Big"), Some(id.clone())); + assert!(crate::pattern_cmds::resolve(&t, "Big").is_none(), "the full pixels come from the file"); + std::fs::rename(&moved, &file).unwrap(); + let full = crate::pattern_cmds::resolve(&t, "Big").unwrap(); + assert_eq!((full.width, full.height), (300, 200)); + assert!(full.surface.pixel(280, 180)[1] < 0.01 && full.surface.pixel(10, 10)[1] > 0.99, "full pixels"); + // Once read, it is kept: using it again doesn't read the file. + std::fs::rename(&file, &moved).unwrap(); + assert_eq!(crate::pattern_cmds::resolve(&t, "Big").map(|p| p.width), Some(300)); + std::fs::rename(&moved, &file).unwrap(); + // A Pattern Fill layer gets the full pattern into the document. + t.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + t.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); + let doc_pat = &t.active().unwrap().doc.patterns[0]; + assert_eq!((doc_pat.width, doc_pat.height), (300, 200)); + // Exporting the library writes full pixels too. + let out = dir.0.join("export.pat"); + t.execute("pattern.export", json!({"path": out.to_string_lossy()})).unwrap(); + let back = photocraft_io::pattern_map::read_pat(&std::fs::read(&out).unwrap()).unwrap(); + assert!(back.iter().any(|p| p.name == "Big" && p.width == 300)); + std::fs::remove_file(&out).unwrap(); + + t.execute("pattern.delete", json!({"pattern": "Big"})).unwrap(); + assert!(!dir.files().iter().any(|f| f.starts_with("pattern-")), "{:?}", dir.files()); +} + +/// A corrupt pattern file is skipped with a warning and left on disk; the others load, and later +/// saves never delete or overwrite it. A corrupt index is never overwritten. +#[test] +fn corrupt_pattern_files_are_skipped_and_kept() { + let dir = TempDir::new("patterns-corrupt"); + let (mut s, _) = session(&dir); + define_pattern(&mut s, "Grit", "#202020", 8); + define_pattern(&mut s, "Paper", "#f0e0c0", 12); + let grit = s.patterns.items.iter().find(|p| p.name == "Grit").unwrap().id.clone(); + let file = dir.0.join(patterns::file_name_for_test(&grit)); + std::fs::write(&file, b"not a pattern").unwrap(); + + let (mut t, w) = session(&dir); + assert!(w.iter().any(|w| w.contains("Grit")), "{w:?}"); + assert_eq!(user_patterns(&t), [("Paper".to_string(), 12)]); + define_pattern(&mut t, "Ink", "#000000", 4); + assert_eq!(std::fs::read(&file).unwrap(), b"not a pattern", "the unreadable file is left alone"); + let index = std::fs::read_to_string(dir.0.join(PATTERNS_FILE)).unwrap(); + assert!(index.contains("Grit") && index.contains("Ink"), "{index}"); + + std::fs::write(dir.0.join(PATTERNS_FILE), b"{").unwrap(); + let (mut u, w) = session(&dir); + assert!(w.iter().any(|w| w.contains(PATTERNS_FILE)), "{w:?}"); + assert!(user_patterns(&u).is_empty()); + define_pattern(&mut u, "Late", "#808080", 4); + assert_eq!(std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap(), b"{", "an unreadable index is never overwritten"); +} + +/// Imported and defined patterns survive a restart: one file each, written once, plus the index; +/// a rename rewrites only the index, a delete removes the file. +#[test] +fn patterns_survive_a_restart() { + let dir = TempDir::new("patterns"); + let (mut s, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + define_pattern(&mut s, "Grit", "#202020", 8); + define_pattern(&mut s, "Paper", "#f0e0c0", 12); + let pattern_files = |d: &TempDir| d.files().into_iter().filter(|f| f.starts_with("pattern-")).collect::>(); + assert_eq!(pattern_files(&dir).len(), 2); + assert!(dir.files().contains(&PATTERNS_FILE.to_string())); + assert!(!dir.files().iter().any(|f| f.contains("Checkerboard")), "built-ins are never written"); + + let (mut t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(user_patterns(&t), [("Grit".to_string(), 8), ("Paper".to_string(), 12)]); + // The pixels come back too. + let paper = t.patterns.items.iter().find(|p| p.name == "Paper").unwrap(); + let px = paper.surface.pixel(3, 3); + assert!((px[0] - 240.0 / 255.0).abs() < 0.01, "{px:?}"); + + let before = std::fs::metadata(dir.0.join(&pattern_files(&dir)[0])).unwrap().modified().unwrap(); + t.execute("pattern.rename", json!({"pattern": "Grit", "name": "Toner"})).unwrap(); + assert_eq!(std::fs::metadata(dir.0.join(&pattern_files(&dir)[0])).unwrap().modified().unwrap(), before, "a rename rewrites only the index"); + t.execute("pattern.delete", json!({"pattern": "Paper"})).unwrap(); + assert_eq!(pattern_files(&dir).len(), 1, "the deleted pattern's file is gone"); + + let (u, _) = session(&dir); + assert_eq!(user_patterns(&u), [("Toner".to_string(), 8)]); +} + +fn define_pattern(s: &mut Session, name: &str, color: &str, width: u32) { + s.execute("file.new", json!({"width": width, "height": 6})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + s.execute("edit.definePattern", json!({"name": name})).unwrap(); +} + +fn user_patterns(s: &Session) -> Vec<(String, u32)> { + let builtins: Vec = crate::pattern_cmds::builtin().into_iter().map(|p| p.id).collect(); + s.patterns.items.iter().filter(|p| !builtins.contains(&p.id)).map(|p| (p.name.clone(), p.width)).collect() +} + /// #9317: a defined or imported pattern survives a restart, as in Photoshop; the built-in /// patterns are never written, and deleting the last user pattern removes the file. #[test] @@ -719,7 +853,13 @@ fn user_patterns_survive_a_restart() { assert!(dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files()); // Only the user's pattern is in the file. let bytes = std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap(); - let saved = photocraft_io::pattern_map::read_pat(&bytes).unwrap(); + let index: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let saved: Vec = index["patterns"] + .as_array() + .unwrap() + .iter() + .map(|e| photocraft_io::pattern_map::read_pat(&std::fs::read(dir.0.join(e["file"].as_str().unwrap())).unwrap()).unwrap().remove(0)) + .collect(); assert_eq!(saved.iter().map(|p| p.name.as_str()).collect::>(), ["Green Tile"]); drop(s); @@ -736,7 +876,10 @@ fn user_patterns_survive_a_restart() { assert!(u.patterns.items.iter().any(|p| p.id == id && p.name == "Moss")); t.execute("pattern.delete", json!({"pattern": id})).unwrap(); t.sync_preset_store(); - assert!(!dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files()); + assert!( + serde_json::from_slice::(&std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap()).unwrap()["patterns"].as_array().unwrap().is_empty(), + "empty authoritative index prevents resurrection" + ); } #[test] @@ -827,7 +970,13 @@ fn a_failed_patterns_removal_is_retried() { backend.fail.store(false, std::sync::atomic::Ordering::SeqCst); s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); s.sync_preset_store(); - assert!(!backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE), "the removal is retried"); + assert!(!backend.mem.files.lock().unwrap().keys().any(|f| f.starts_with("pattern-")), "owned asset removal is retried"); + assert!( + serde_json::from_slice::(backend.mem.files.lock().unwrap().get(PATTERNS_FILE).unwrap()).unwrap()["patterns"] + .as_array() + .unwrap() + .is_empty() + ); } /// #9318 review: an imported `.pat` that replaces a built-in pattern's pixels (same id) is saved @@ -840,7 +989,13 @@ fn an_imported_builtin_replacement_survives_a_restart() { let builtin = s.patterns.items[0].id.clone(); s.execute("pattern.import", json!({"data": pat_bytes(&builtin, [0.9, 0.1, 0.4, 1.0])})).unwrap(); s.sync_preset_store(); - let saved = photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap(); + let index: serde_json::Value = serde_json::from_slice(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap(); + let saved: Vec = index["patterns"] + .as_array() + .unwrap() + .iter() + .map(|e| photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(e["file"].as_str().unwrap()).unwrap()).unwrap().remove(0)) + .collect(); assert_eq!(saved.iter().map(|p| p.id.as_str()).collect::>(), [builtin.as_str()], "only the replaced built-in"); let mut t = Session::new(); t.attach_preset_store(open(Box::new(mem.clone()))); @@ -870,3 +1025,114 @@ fn an_early_same_id_import_wins_and_is_saved() { u.attach_preset_store(open(Box::new(mem.clone()))); assert_eq!(u.patterns.items.iter().find(|p| p.id == id).unwrap().surface, early, "the early import's pixels were saved"); } +#[derive(Clone)] +struct MigrationFail { + mem: MemBackend, + fail_name: String, +} +impl PresetBackend for MigrationFail { + fn list(&self) -> Result, String> { + self.mem.list() + } + fn read(&self, n: &str, m: u64) -> Result, String> { + self.mem.read(n, m) + } + fn write(&self, n: &str, b: &[u8]) -> Result<(), String> { + if n == self.fail_name { Err("injected migration write failure".into()) } else { self.mem.write(n, b) } + } + fn remove(&self, n: &str) -> Result<(), String> { + self.mem.remove(n) + } +} +fn legacy_store() -> (MemBackend, Vec, String) { + let mem = MemBackend::default(); + let id = "6f1c2c1e-0000-4000-8000-000000005176".to_string(); + let bytes = photocraft_paint::tile::b64_decode(&pat_bytes(&id, [0.2, 0.6, 0.8, 1.0])).unwrap(); + mem.write(LEGACY_PATTERNS_FILE, &bytes).unwrap(); + (mem, bytes, id) +} +#[test] +fn legacy_migration_commits_last_and_never_resurrects_renames_or_deletions() { + let (mem, old, id) = legacy_store(); + let mut s = Session::new(); + assert!(s.attach_preset_store(open(Box::new(mem.clone()))).is_empty()); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_ok()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); + s.execute("pattern.rename", json!({"pattern":id,"name":"Migrated"})).unwrap(); + s.sync_preset_store(); + let mut t = Session::new(); + t.attach_preset_store(open(Box::new(mem.clone()))); + assert_eq!(t.patterns.items.iter().find(|p| p.id == id).unwrap().name, "Migrated"); + t.execute("pattern.delete", json!({"pattern":id})).unwrap(); + t.sync_preset_store(); + for _ in 0..2 { + let mut u = Session::new(); + u.attach_preset_store(open(Box::new(mem.clone()))); + assert!(!u.patterns.items.iter().any(|p| p.id == id)); + } + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); +} +#[test] +fn interrupted_migration_preserves_legacy_and_reuses_only_identical_orphans() { + for fail_index in [false, true] { + let (mem, old, id) = legacy_store(); + let file = patterns::file_name_for_test(&id); + let fail_name = if fail_index { PATTERNS_FILE.to_string() } else { file.clone() }; + let opened = open(Box::new(MigrationFail { mem: mem.clone(), fail_name })); + assert!(!opened.warnings.is_empty()); + assert!(opened.patterns.iter().any(|p| p.pattern.id == id), "legacy remains usable"); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err(), "no commit marker on failure"); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); + let retry = open(Box::new(mem.clone())); + assert!(retry.warnings.is_empty(), "{:?}", retry.warnings); + assert!(retry.patterns.iter().any(|p| p.pattern.id == id)); + } + let (mem, old, id) = legacy_store(); + let file = patterns::file_name_for_test(&id); + mem.write(&file, b"unknown content").unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert_eq!(mem.read(&file, 100).unwrap(), b"unknown content"); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); +} +#[test] +fn malformed_authoritative_index_never_falls_back_to_stale_legacy() { + let (mem, _, id) = legacy_store(); + drop(open(Box::new(mem.clone()))); + for bytes in [b"{".as_slice(), b"{\"version\":1,\"patterns\":[{\"id\":\"x\",\"name\":\"bad\",\"file\":\"../secret\"}]}".as_slice()] { + mem.write(PATTERNS_FILE, bytes).unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert!(opened.patterns.is_empty()); + assert_eq!(mem.read(PATTERNS_FILE, 1 << 20).unwrap(), bytes); + assert!(!opened.patterns.iter().any(|p| p.pattern.id == id)); + } + let mem = MemBackend::default(); + mem.write(LEGACY_PATTERNS_FILE, b"broken PAT").unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, 20).unwrap(), b"broken PAT"); +} +#[test] +fn migration_preserves_duplicate_names_but_refuses_duplicate_ids() { + let (mem, _, id) = legacy_store(); + let bytes = mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(); + let mut pats = photocraft_io::pattern_map::read_pat(&bytes).unwrap(); + let mut other = pats[0].clone(); + other.id = "6f1c2c1e-0000-4000-8000-000000005177".into(); + pats.push(other); + mem.write(LEGACY_PATTERNS_FILE, &photocraft_io::pattern_map::write_pat(&pats).unwrap()).unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(opened.warnings.is_empty()); + assert_eq!(opened.patterns.len(), 2); + let mem = MemBackend::default(); + pats[1].id = id; + let old = photocraft_io::pattern_map::write_pat(&pats).unwrap(); + mem.write(LEGACY_PATTERNS_FILE, &old).unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); +} diff --git a/crates/engine/src/presets/patterns.rs b/crates/engine/src/presets/patterns.rs index 2425dd0900..a1d9ff2bf0 100644 --- a/crates/engine/src/presets/patterns.rs +++ b/crates/engine/src/presets/patterns.rs @@ -162,7 +162,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { } "move" => { let key = req_str(p, "preset", CMD)?; - let id = crate::pattern_cmds::resolve(s, key).map(|q| q.id).ok_or_else(|| bad(CMD, format!("no pattern \"{key}\"")))?; + let id = crate::pattern_cmds::resolve_id(s, key).ok_or_else(|| bad(CMD, format!("no pattern \"{key}\"")))?; let mut q = p.clone(); q["preset"] = json!(id); edit_groups(&mut s.presets.pattern_groups, "move", &q, CMD)? diff --git a/crates/engine/src/scratch.rs b/crates/engine/src/scratch.rs new file mode 100644 index 0000000000..6e21d9c1d7 --- /dev/null +++ b/crates/engine/src/scratch.rs @@ -0,0 +1,146 @@ +//! Native tile storage configuration, kept out of the preference data model. + +use crate::prefs::Preferences; + +/// A web session has no filesystem or memory-supervision thread. +#[cfg(target_arch = "wasm32")] +#[derive(Default)] +pub(crate) struct Runtime; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use native::Runtime; + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + // Weak ownership: the process-wide cache cannot keep a session's worker alive. + pub(crate) struct Runtime { + _worker: Arc, + } + + impl Default for Runtime { + fn default() -> Self { + static SHARED: OnceLock>> = OnceLock::new(); + let mut shared = SHARED.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let worker = shared.upgrade().unwrap_or_else(|| { + let worker = Arc::new(Worker::start()); + *shared = Arc::downgrade(&worker); + worker + }); + Self { _worker: worker } + } + } + + struct Worker { + stop: Arc<(Mutex, Condvar)>, + thread: Option>, + } + + impl Worker { + fn start() -> Self { + let stop = Arc::new((Mutex::new(false), Condvar::new())); + let flag = stop.clone(); + let thread = std::thread::Builder::new() + .name("pc-memory-residency".into()) + .spawn(move || { + let _maintenance = photocraft_raster::spill::background_maintenance(); + let mut measured = Instant::now(); + loop { + let (lock, wake) = &*flag; + let stopped = lock.lock().unwrap_or_else(PoisonError::into_inner); + let (stopped, _) = wake.wait_timeout_while(stopped, Duration::from_millis(100), |stop| !*stop).unwrap_or_else(PoisonError::into_inner); + if *stopped { + break; + } + drop(stopped); + if measured.elapsed() >= Duration::from_secs(2) { + photocraft_raster::memory::refresh(); + measured = Instant::now(); + } + photocraft_raster::spill::maintain_budget(); + } + }) + .ok(); + // If spawning fails, raster's synchronous fallback still enforces the budget. + Self { stop, thread } + } + } + + impl Drop for Worker { + fn drop(&mut self) { + let (lock, wake) = &*self.stop; + *lock.lock().unwrap_or_else(PoisonError::into_inner) = true; + wake.notify_all(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn workers_stop_join_and_can_restart() { + for _ in 0..3 { + let worker = Worker::start(); + assert!(worker.thread.is_some()); + let stop = worker.stop.clone(); + let began = Instant::now(); + drop(worker); + assert!(*stop.0.lock().unwrap()); + assert!(began.elapsed() < Duration::from_secs(2)); + } + } + + #[test] + fn sessions_share_supervision_without_static_ownership() { + let a = Runtime::default(); + let b = Runtime::default(); + assert!(Arc::ptr_eq(&a._worker, &b._worker)); + let weak = Arc::downgrade(&a._worker); + drop((a, b)); + // Other engine tests can hold the shared runtime concurrently; a private worker + // proves the same ownership contract without depending on their timing. + let worker = Arc::new(Worker::start()); + let private = Arc::downgrade(&worker); + drop(worker); + assert!(private.upgrade().is_none()); + drop(weak); + } + } +} + +pub(crate) fn configure(prefs: &Preferences) { + let budget = photocraft_raster::memory::configure(photocraft_raster::memory::Policy { + percent: prefs.performance.memory_usage_percent, + // The web build's cap applies here too (effective_memory_usage_mb). + absolute_bytes: u64::from(prefs.performance.effective_memory_usage_mb()).saturating_mul(1 << 20), + }); + #[cfg(not(target_arch = "wasm32"))] + { + let dir = prefs + .scratch_disks + .disks + .iter() + .find(|d| d.enabled && !d.path.trim().is_empty()) + .map(|d| if d.path.trim() == "(system temp)" { std::env::temp_dir() } else { std::path::PathBuf::from(d.path.trim()) }); + // An unusable or full directory pauses eviction without dropping resident pixels and + // is retried with backoff; History's byte limit applies meanwhile (sync_scratch_state). + // The reason is exposed in ui.inspect and the application's status bar. + let tiles = if std::env::var_os("PHOTOCRAFT_TILE_BUDGET_MB").is_some() { + prefs.performance.tile_budget_bytes() + } else { + usize::try_from(budget.tile_bytes).unwrap_or(usize::MAX) + }; + let limit = prefs.scratch_disks.size_limit_mb; + photocraft_raster::spill::set_size_cap((limit > 0).then(|| u64::from(limit).saturating_mul(1 << 20))); + let _ = photocraft_raster::spill::configure(dir.as_deref(), tiles); + } + #[cfg(target_arch = "wasm32")] + let _ = (prefs, budget); +} diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs index 9fd29a2757..4285491eb7 100644 --- a/crates/engine/src/select_mask_edits.rs +++ b/crates/engine/src/select_mask_edits.rs @@ -239,7 +239,9 @@ fn invert(base: Option<&Surface>, canvas: Rect) -> Option { if r.is_empty() { continue; } - let inverted: Vec = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + let read = b.read_region(r); + let inverted: Vec = read.chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + photocraft_raster::scratch::give(read); s.write_region(r, &inverted); } } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 1ae07b35a8..3e6bc4abc7 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,3 +1,5 @@ +use crate::{EngineError, Result}; + use super::history::fix_selection; use super::{DocState, Document, Session, channel_cmds, smart_cmds}; @@ -98,6 +100,35 @@ impl Session { Some(to) } + /// Resolve a Photoshop action's offset in opening order, returning its current tab index. + /// Tab dragging changes `docs`, but must not change which document an imported action edits. + /// Offsets wrap at either end; native zero keeps the active document. + pub(crate) fn document_at_offset(&self, offset: i64) -> Result { + let id = self.active().ok_or(EngineError::NoDocument)?.doc.id; + let active = self.open_document_order.iter().position(|&open| open == id).ok_or(EngineError::NoDocument)?; + // Widen before addition so even extreme signed offsets wrap without overflow. + let target = (active as i128 + i128::from(offset)).rem_euclid(self.open_document_order.len() as i128) as usize; + let id = self.open_document_order.get(target).ok_or(EngineError::NoDocument)?; + self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument) + } + + /// A recorded index is one-based opening order, even after tabs are dragged. + pub(crate) fn document_at_index(&self, index: u64) -> Result { + let id = index + .checked_sub(1) + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| self.open_document_order.get(index)) + .ok_or_else(|| EngineError::Other(format!("no document at opening index {index}")))?; + self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument) + } + + /// Relative opening-order distance between two open documents, for portable action steps. + pub(crate) fn document_offset_between(&self, from: photocraft_doc::DocId, to: photocraft_doc::DocId) -> Option { + let from = self.open_document_order.iter().position(|&id| id == from)?; + let to = self.open_document_order.iter().position(|&id| id == to)?; + i64::try_from(to).ok()?.checked_sub(i64::try_from(from).ok()?) + } + /// Add a document (from File → New, an import, etc.) and make it active. /// Preserve its identity unless another open document already owns it. /// Identity-dependent callers must read the admitted document at the returned index. @@ -106,9 +137,10 @@ impl Session { while self.docs.iter().any(|st| st.doc.id == doc.id) { doc.id = photocraft_doc::DocId::fresh(); } + self.open_document_order.push(doc.id); let mut st = DocState::new(doc, path); st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize; - st.history.max_bytes = self.prefs.get().performance.history_budget_bytes(); + st.history.max_bytes = self.prefs.get().performance.history_cap_bytes(); st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history; self.docs.push(st); let i = self.docs.len() - 1; @@ -141,8 +173,11 @@ impl Session { self.cancel_jobs_on(id); self.subject_cache.forget_doc(id); self.object_finder.forget_doc(id); + self.link_missing.retain(|(d, _)| *d != id.0); + self.link_scan = None; } let d = self.docs.remove(index); + self.open_document_order.retain(|&id| id != d.doc.id); self.active = if self.docs.is_empty() { None } else { diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 69c389f0c6..3577f5e1df 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -3,6 +3,7 @@ use super::{ Arc, Document, EngineError, LayerId, Result, Session, Value, brush_cmds, channel_cmds, commands, hidden_target, jobs, layer_multi_cmds, retouch_cmds, smart_cmds, }; +use crate::color_channels; impl Session { /// Run a command by id with JSON params. Returns a JSON result. @@ -95,7 +96,10 @@ impl Session { /// may run. pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result { let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone())); - if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) { + if let Some(why) = color_channels::refusal(self, id, &run_params) { + return Err(EngineError::Disabled(id.into(), why)); + } + if let Err(why) = self.explicit_layer_enabled(spec, params)?.unwrap_or_else(|| self.precondition(spec, &run_params)) { return Err(EngineError::Disabled(id.to_string(), why)); } if let Some(why) = self.job_conflict(id, spec.journal) { @@ -127,26 +131,50 @@ impl Session { /// [`Self::disabled_reason`] for a call with `params`. pub fn disabled_reason_with(&self, id: &str, params: &Value) -> Option { - let Some(s) = commands::find(id) else { return Some(format!("unknown command `{id}`")) }; + let Some(s) = commands::find(id) else { return Some(EngineError::UnknownCommand(id.into()).to_string()) }; self.precondition(s, &channel_cmds::inject_target(self, id, params.clone())).err().or_else(|| self.job_conflict(id, s.journal)) } /// The command's own precondition for a call with `params` (their target filled in). fn precondition(&self, spec: &commands::CommandSpec, params: &Value) -> std::result::Result<(), String> { + if let Some(why) = color_channels::refusal(self, spec.id, params) { + return Err(why); + } if self.names_existing_layer(spec.id, params) { return Ok(()); } + // A recorded invalid comp index succeeds without an edit, even with no comps. + // Ordinary Apply and references by id/name retain the command's usual precondition. + if spec.id == "layerComp.apply" + && params + .get("comp") + .and_then(Value::as_object) + .is_some_and(|fields| fields.len() == 1 && fields.get("index").is_some_and(|index| index.as_u64().is_some() || index.as_i64().is_some())) + { + return self.active().map(|_| ()).ok_or_else(|| "no document open".into()); + } // `ifPresent`: with no Background there is nothing to do, which isn't an error. if spec.id == "layer.new.layerFromBackground" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) { return self.active().map(|_| ()).ok_or_else(|| "no document open".into()); } + if spec.id == "layer.smartFilter.deleteFilterMask" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) { + return smart_cmds::smart_layer_active(self); + } + // Recorded Unlink can be repeated on a single, already-unlinked layer. + // Keep the menu toggle's two-layer requirement when no state is supplied. + if spec.id == "layer.linkLayers" && params.get("linked").and_then(Value::as_bool) == Some(false) { + let d = self.active().ok_or_else(|| "no document open".to_string())?; + return if d.selected_layers().is_empty() { Err("no layer selected".into()) } else { Ok(()) }; + } channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self)) } /// Whether `id` is a layer command whose `"layer"` param names an existing layer, so it needs no /// active layer (#2457). Matches how `layer_param` resolves the layer when the command runs. fn names_existing_layer(&self, id: &str, params: &Value) -> bool { - matches!(id, "layer.hideLayers" | "layer.showLayers" | "layer.renameLayer") + (matches!(id, "layer.hideLayers" | "layer.showLayers" | "layer.renameLayer") + || (id == "layer.setProps" + && params.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce"))))) && params.get("layer").and_then(Value::as_u64).is_some_and(|l| self.active().is_some_and(|st| st.doc.layer(LayerId(l)).is_some())) } @@ -155,19 +183,43 @@ impl Session { /// than on the active one, which stays active (#466). A script, action or agent can then /// Replace Contents, Rasterize or Merge Down a layer without selecting it first. `None` for /// other commands, or without such a layer. - pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Option> { + pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Result>> { if !spec.params.contains("\"layer\":id") { - return None; + return Ok(None); + } + // An ATN group-end index is a valid no-op only for Select. Let that command validate + // its other parameters and preserve the current selection. + if spec.id == "layer.select" + && let Some(selector @ Value::Object(fields)) = params.get("layer") + && (fields.contains_key("index") || fields.contains_key("ordinal")) + && commands::recorded_layer(self, selector)?.is_none() + { + return Ok(None); + } + let target = match commands::explicit_layer(self, params) { + Ok(Some(target)) => target, + Ok(None) => return Ok(None), + // Read-only queries keep their own parameter-error contract; recorded commands + // must identify a missing named receiver before checking the active layer. + Err(_) if !spec.journal => return Ok(None), + Err(error) => return Err(error), + }; + let Some(d) = self.active_mut() else { return Ok(None) }; + if d.doc.layer(target).is_none() { + return Ok(None); } - let target = LayerId(params.get("layer")?.as_u64()?); - let d = self.active_mut()?; - d.doc.layer(target)?; let active = d.active_layer.replace(target); - let r = self.precondition(spec, params); + // The checks see the resolved id: a relative ordinal (`Frwr`, `Bckw`) would otherwise + // resolve again from the swapped-in active layer and land one layer further. + let mut resolved = params.clone(); + if let Some(fields) = resolved.as_object_mut() { + fields.insert("layer".into(), Value::from(target.0)); + } + let r = self.precondition(spec, &resolved); if let Some(d) = self.active_mut() { d.active_layer = active; } - Some(r) + Ok(Some(r)) } /// Apply an undoable edit to the active document. @@ -191,28 +243,42 @@ impl Session { /// [`Self::edit`], recording a history state only when `record` is set. An unrecorded change /// still marks the document modified and leaves the redo states alone. pub(crate) fn edit_with(&mut self, label: &str, record: bool, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + if record { + crate::actions_cmds::before_action_edit(self); + } // Every mutation path (linked-file refresh, transform, place) re-renders smart objects // through this entry; the requesting session's authority must reach those renders. let _authorization = crate::jobs::render_authorization_scope(self.authorize); let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); + photocraft_compose::discard_unreadable_pixel_caches(); + let scratch_generation = photocraft_raster::spill::read_error_generation(); let restrict = self.color_restrict; let filter_mask_layer = self.filter_mask_layer; 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(); + // Only this document's pixels gate its edits: an unreadable tile elsewhere (another + // open document) does not block it. + before.scratch_health().map_err(EngineError::Other)?; // Selecting layers is not a step, so the state this edit leaves behind targets what was // selected just before it (#1356): undoing a stroke keeps the painted layer active. let prior = st.layer_target(); + let previous_active_layer = st.active_layer; let mut doc = (*before).clone(); let mut active = st.active_layer; let r = f(&mut doc, &mut active)?; - if let (Some(k), Some(id)) = (restrict, active) { - channel_cmds::restrict_to_color(&before, &mut doc, id, k); + if let Some(restrict) = restrict { + channel_cmds::restrict_to_color(&before, &mut doc, restrict.layer, restrict.colors)?; } if let Some(id) = filter_mask_layer { smart_cmds::refresh_after_filter_mask_edit(&mut doc, id)?; } + if let Err(error) = photocraft_raster::spill::check_since(scratch_generation).and_then(|()| before.scratch_health()).and_then(|()| doc.scratch_health()) + { + st.active_layer = previous_active_layer; + return Err(EngineError::Other(error)); + } st.doc = Arc::new(doc); st.active_layer = active; fix_selection(st); @@ -248,6 +314,8 @@ impl Session { // report its own, tighter damage after this. let damage = layer_multi_cmds::step_damage(&before, &st.doc); let keep_clean = st.keeps_clean(&before); + #[cfg(any(test, debug_assertions))] + let hint_before = before.clone(); let key = self.coalesce_request.clone(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let layers = st.layer_target(); @@ -268,6 +336,10 @@ impl Session { st.bump_revision(keep_clean); st.last_damage = damage; self.samplers_removed += samplers_removed; + #[cfg(any(test, debug_assertions))] + { + self.hint_before = Some(hint_before); + } if damage.is_none_or(|d| !d.is_empty()) { // Free the mask early; a stale entry would only miss (it is keyed by pixels). self.subject_cache = Default::default(); @@ -275,4 +347,26 @@ impl Session { } Ok(r) } + + /// A command's own rect for the pixels its latest [`Session::edit`] changed: sub-tile precise + /// for a brush dab, where the edit's own damage ([`layer_multi_cmds::step_damage`]) has tile + /// precision. The redraw area becomes the intersection of the two, so a hint can only narrow + /// it (or bound a change `step_damage` can't). In debug and test builds, a hint that misses a + /// tile the edit changed is counted in [`Session::damage_misses`] and logged; it never panics. + pub fn hint_damage(&mut self, rect: photocraft_geom::Rect) { + #[cfg(any(test, debug_assertions))] + if let Some(before) = self.hint_before.take() + && let Some(st) = self.active() + && st.doc.id == before.id + { + let misses = layer_multi_cmds::hint_misses(&before, &st.doc, rect); + if misses > 0 { + let step = st.history.undo_label().unwrap_or("?"); + eprintln!("warning: `{step}` hinted damage {rect:?} that misses {misses} changed tile(s); the canvas may not redraw them"); + self.damage_misses += misses; + } + } + let Some(st) = self.active_mut() else { return }; + st.last_damage = Some(st.last_damage.map_or(rect, |d| d.intersect(&rect))); + } } diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index b6e81e64bc..c5f9cd301c 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -2,11 +2,17 @@ use super::documents::fix_active; use super::{Arc, DocState, Document, LayerId, LayerTarget, Session, channel_cmds, layer_multi_cmds}; impl Session { + /// Undo a document step or restore Clear History's rows and source without editing pixels. pub fn undo(&mut self) -> bool { // A background job is computing from the current state: it must not move under it. if self.active_job().is_some() { return false; } + // Undo Clear History only restores metadata. It must not establish the new document + // baseline that an actual edit after Clear History would create. + if !self.active().is_some_and(|st| st.history.is_cleared()) { + crate::actions_cmds::before_action_edit(self); + } let keep_visibility = !self.visibility_undoable(); let Some(st) = self.active_mut() else { return false }; st.coalesce = None; @@ -47,10 +53,12 @@ impl Session { } } + /// Restore the next document state and its selection/visibility bookkeeping through the ordinary history path. pub fn redo(&mut self) -> bool { if self.active_job().is_some() { return false; } + crate::actions_cmds::before_action_edit(self); let keep_visibility = !self.visibility_undoable(); let Some(st) = self.active_mut() else { return false }; st.coalesce = None; diff --git a/crates/engine/src/session/layers.rs b/crates/engine/src/session/layers.rs index b8ea5ea89d..2e6009d16f 100644 --- a/crates/engine/src/session/layers.rs +++ b/crates/engine/src/session/layers.rs @@ -1,5 +1,5 @@ use super::history::fix_selection; -use super::{Arc, EngineError, LayerId, Result, Session, channel_cmds, type_cmds}; +use super::{Arc, EngineError, LayerId, Result, Session, channel_cmds, layer_multi_cmds, type_cmds}; impl Session { /// Replace the active document's active layer without creating a history step. @@ -83,7 +83,8 @@ impl Session { if clean { st.saved_revision = st.revision; } - st.last_damage = None; + // Only the refreshed layers' caches changed: their changed tiles. + st.last_damage = layer_multi_cmds::step_damage(&snapshot, &st.doc); } } busy diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 61c33bb8cf..3b52c03a03 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -7,6 +7,7 @@ /// Shell commands: id, label, menu path, default shortcut. pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("layer.selectUnderCursor", "Select Layer Under Cursor", &[], None), ("ui.symmetryTransform", "Transform Symmetry", &[], None), // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. ("tool.reset", "Reset Tool", &[], None), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a55e696e17..599d0ce2d6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -265,8 +265,9 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() { assert_eq!(mask.value(10, 10), 1.0, "{id} at {depth}-bit: the mask hides the selected area"); assert_eq!(mask.value(2, 2), 0.0, "{id} at {depth}-bit: the mask reveals outside the selection"); assert!(mask.linked && mask.enabled, "{id}"); - // The selection stays, as after Layer › Layer Mask › Reveal Selection. - assert!(d.doc.selection.is_some(), "{id} dropped the selection"); + // The selection went into the mask, so Photoshop drops it, as Layer › Layer Mask › + // Reveal Selection does (#5251, #8801). + assert!(d.doc.selection.is_none(), "{id} at {depth}-bit kept the selection"); // The white canvas is untouched outside the selection. assert_eq!(px(&mut s, 2, 2), vec![1.0, 1.0, 1.0, 1.0], "{id} at {depth}-bit changed pixels outside the selection"); if blackens { @@ -274,6 +275,29 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() { } s.execute("edit.undo", json!({})).unwrap(); assert!(s.active().unwrap().doc.layer(layer).is_none(), "{id}: undo keeps the layer"); + let sel = s.active().unwrap().doc.selection.as_ref().unwrap_or_else(|| panic!("{id}: undo does not bring the selection back")); + assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "{id}: undo restores the same selection"); + } + } +} + +#[test] +fn an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer() { + // #8801: the selection that masked a new fill or adjustment layer is gone, so the next + // pixel edit on another layer is not limited to it. + for (id, params) in [("layer.newFillLayer.solidColor", json!({"color": "#000000"})), ("layer.newAdjustmentLayer.invert", json!({}))] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#3366cc"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute(id, params).unwrap(); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + s.execute("layer.selectBottom", json!({})).unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("image.adjustments.desaturate", json!({})).unwrap(); + for x in [5, 35] { + let p = px(&mut s, x, 10); + assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: ({x}, 10) is not grey: {p:?}"); } } } @@ -351,6 +375,7 @@ fn new_fill_and_adjustment_layers_take_the_active_path_as_their_vector_mask() { let want = if path_name == "work" { d.doc.work_path.clone().unwrap() } else { d.doc.paths[0].path.clone() }; assert_eq!(vm.path, want, "{id}: the vector mask is the path"); assert!(l.mask.is_none(), "{id}: the path wins over the selection"); + assert!(d.doc.selection.is_some(), "{id}: an unused selection stays"); for (x, y) in [(2, 2), (42, 32), (30, 30)] { assert_eq!(px(&mut s, x, y), vec![1.0, 1.0, 1.0, 1.0], "{id} ({path_name}) at {depth}-bit changed pixels outside the path at ({x}, {y})"); } @@ -433,6 +458,34 @@ fn marquee_dragged_past_the_canvas_stops_at_its_edge() { assert_eq!(bounds(&mut s), Value::Null); } +#[test] +fn subtract_clears_selection_when_only_disjoint_off_canvas_pixels_remain() { + use photocraft_geom::Rect; + use photocraft_raster::Surface; + let mut s = session_with_doc(); // 64 × 48 + let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8); + // Simulate a moved sparse selection: one remnant on either side of the canvas and one + // in-canvas region that the subtract marquee will remove. The off-canvas bounds straddle + // the canvas, so testing only their bounding-box intersection would incorrectly keep it. + selection.fill_rect(Rect::new(-4, 10, 0, 14), &[1.0]); + selection.fill_rect(Rect::new(20, 10, 24, 14), &[1.0]); + selection.fill_rect(Rect::new(64, 10, 68, 14), &[1.0]); + Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(selection); + + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap(); + + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn subtract_clears_selection_with_nonzero_default_when_canvas_is_empty() { + use photocraft_raster::Surface; + let mut s = session_with_doc(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0])); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 64, "height": 48, "mode": "subtract"})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + #[test] fn marquee_steps_are_named_after_their_tool() { // #513: the elliptical marquee shares `select.rect` but records its own step name. @@ -506,6 +559,41 @@ fn undo_and_redo_restore_the_targeted_layers() { assert_eq!(target(&s), copies); } +#[test] +fn selection_inverse_preserves_coverage_history_and_empty_selection() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 530, "height": 270, "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 250, "y": 10, "width": 30, "height": 30, "ellipse": true})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let area = before.bounds(); + let original = before.selection.as_ref().unwrap().to_interleaved(area); + s.execute("select.inverse", json!({})).unwrap(); + let after = s.active().unwrap(); + let inverted = after.doc.selection.as_ref().unwrap(); + assert_eq!(inverted.to_interleaved(area), original.iter().map(|b| 255 - b).collect::>()); + assert_eq!(inverted.sample_channel(530, 269, 0), 0.0); + assert_eq!(inverted.sample_channel(529, 270, 0), 0.0); + assert_eq!(after.last_damage, Some(photocraft_geom::Rect::EMPTY)); + assert_eq!(before.selection.as_ref().unwrap().to_interleaved(area), original); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original); + assert!(s.redo()); + s.execute("select.inverse", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().to_interleaved(area), original); + + s.execute("select.all", json!({})).unwrap(); + s.execute("select.inverse", json!({})).unwrap(); + let empty = s.active().unwrap().doc.selection.as_ref().unwrap(); + assert_eq!(empty.tile_count(), 0); + // Keep Some(empty), as before: the next Inverse selects the whole canvas again. + s.execute("select.inverse", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), area); + } + let mut s = Session::new(); + assert!(s.execute("select.inverse", json!({})).is_err()); +} + #[test] fn selection_modes() { let mut s = session_with_doc(); @@ -593,6 +681,10 @@ fn arrange_and_group() { let names: Vec = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect(); assert_eq!(names, ["Background", "B", "A"]); assert!(s.execute("layer.arrange.bringForward", json!({})).is_err()); + // Bring to Front of the front layer does nothing: no error (an action plays it), no step. + let steps = s.active().unwrap().history.past_len(); + assert_eq!(s.execute("layer.arrange.bringToFront", json!({})).unwrap(), json!({"moved": false})); + assert_eq!(s.active().unwrap().history.past_len(), steps); s.execute("layer.groupLayers", json!({})).unwrap(); let doc = s.execute("document.inspect", json!({})).unwrap(); assert_eq!(doc["layers"][0]["kind"], "Group"); @@ -722,7 +814,7 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { .unwrap(); let render = |s: &Session| { let d = &s.active().unwrap().doc; - photocraft_compose::render(d, d.bounds()).px + photocraft_compose::render(d, d.bounds()).into_px() }; // Give the layer the cached-pixels state a PSD import produces (the cache is what Image // › Transform & friends rewrite in place, without touching the Fill spec). @@ -809,7 +901,7 @@ fn group_effect_maps_follow_child_visibility_and_opacity() { .unwrap(); let render = |s: &Session| { let d = &s.active().unwrap().doc; - photocraft_compose::render(d, d.bounds()).px + photocraft_compose::render(d, d.bounds()).into_px() }; let mut prev = render(&s); // populate the effect-map cache type Edit = (&'static str, fn(&mut photocraft_doc::Layer)); @@ -1574,11 +1666,10 @@ 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. +/// #7103: Photoshop's Select Document by offset (previous is -1, next +1) walks opening order +/// from the active document, wrapping at both ends. Bad params preserve the active document. #[test] -fn document_activate_by_offset_walks_the_tabs() { +fn document_activate_by_offset_walks_opening_order() { let mut s = Session::new(); for name in ["a", "b", "c"] { s.execute("file.new", json!({"width": 2, "height": 2, "name": name})).unwrap(); @@ -1596,17 +1687,17 @@ fn document_activate_by_offset_walks_the_tabs() { 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 (offset, name) in [(2, "a"), (-2, "c"), (i64::MAX, "c"), (i64::MIN, "c")] { + s.execute("document.activate", json!({"document":1})).unwrap(); + s.execute("document.activate", json!({"offset":offset})).unwrap(); + assert_eq!(active(&s), name, "{offset}"); + } + s.execute("document.activate", json!({"document":1})).unwrap(); 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}), @@ -1614,11 +1705,60 @@ fn document_activate_by_offset_walks_the_tabs() { 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". + // Dragging "c" first changes tab indices, not the opening order a, b, c. s.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); - s.execute("document.activate", json!({"offset": -2})).unwrap(); + assert_eq!(active(&s), "b"); + assert_eq!(s.execute("document.activate", json!({"offset":-2})).unwrap(), json!({"document":0})); + assert_eq!(active(&s), "c", "wrapping follows opening order, not tab position"); + s.execute("document.activate", json!({"document":2})).unwrap(); + assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 1})); + assert_eq!(active(&s), "a"); + assert_eq!(s.execute("document.activate", json!({"offset": 2})).unwrap(), json!({"document": 0})); assert_eq!(active(&s), "c"); + // Absolute indices and the returned document index remain in visual-tab order. + s.execute("document.activate", json!({"document": 2})).unwrap(); + assert_eq!(active(&s), "b"); + assert_eq!(s.execute("document.activate", json!({"offset": 0})).unwrap(), json!({"document": 2})); + s.execute("document.activate", json!({"document": 0})).unwrap(); + assert_eq!(active(&s), "c"); +} + +#[test] +fn document_activate_opening_order_survives_close_and_reopen_with_persisted_ids() { + let mut s = Session::new(); + // Persisted IDs can be non-monotonic or duplicated: opening order cannot be sorted by ID. + for (name, id) in [("a", 300), ("b", 100), ("c", 300)] { + let mut doc = Document::new(name, photocraft_geom::Size::new(2, 2), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8); + doc.id = photocraft_doc::DocId(id); + s.add_document(doc, None); + } + let a = s.documents()[0].doc.id; + let b = s.documents()[1].doc.id; + let c = s.documents()[2].doc.id; + assert_ne!(a, c, "admission replaces an already-open persisted ID"); + s.move_document(2, 0).unwrap(); // Visual tabs c, a, b; opening order a, b, c. + assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 2})); + assert_eq!(s.active().unwrap().doc.id, b); + let closed = s.close(2).unwrap(); // Closing the middle opening leaves a, c. + assert_eq!(closed.doc.id, b); + s.set_active(0); // c is visually first, but a is still its predecessor. + assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 1})); + assert_eq!(s.active().unwrap().doc.id, a); + s.add_document((*closed.doc).clone(), None); // Reopened b goes last, even with the same ID. + assert_eq!(s.active().unwrap().doc.id, b); + assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 0})); + assert_eq!(s.active().unwrap().doc.id, c); + assert!(s.close(99).is_none(), "invalid close must leave both orders alone"); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 2})); + assert_eq!(s.active().unwrap().doc.id, b); + while !s.documents().is_empty() { + s.close(0).unwrap(); + } + s.execute("file.new", json!({"width": 2, "height": 2, "name": "fresh"})).unwrap(); + assert_eq!(s.execute("document.activate", json!({"offset": 0})).unwrap(), json!({"document": 0})); + assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 0})); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 0})); + assert_eq!(s.active().unwrap().doc.name, "fresh"); } /// #2166, measured in Photoshop 25.1: a layer mask and the layer pixels keep separate diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs new file mode 100644 index 0000000000..df643b2608 --- /dev/null +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -0,0 +1,789 @@ +//! Recorded identifiers must match Photoshop, even when a local round trip would accept a typo. + +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::json; + +/// A small marked document makes canvas translation and image resampling distinguishable. +fn sized_document(width: u32, height: u32, dpi: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":width,"height":height,"background":"white"})).unwrap(); + s.execute("image.imageSize", json!({"resolution":dpi,"resample":"none"})).unwrap(); + s.edit("Mark", |doc, _| { + doc.layers[0].surface_mut().unwrap().write_region(photocraft_geom::Rect::from_xywh(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s +} + +/// Replay saved bytes, so the geometry witness covers import, export and execution together. +fn replay_saved(s: &mut Session, action: &Action) { + let (bytes, _) = atn_cmds::export("Identifiers", &[action], &[], false).unwrap(); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, action.steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); +} + +/// Percent canvas dimensions use each playback document and leave its pixels unscaled. +#[test] +fn recorded_canvas_percent_resolves_at_playback_and_keeps_an_omitted_side() { + for both_sides in [false, true] { + let mut d = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 200.0 }) + .with("Hrzn", enm("HrzL", "Left")) + .with("Vrtc", enm("VrtL", "Top ")); + if both_sides { + d = d.with("Hght", D::UnitFloat { unit: *b"#Prc", value: 200.0 }); + } + let action = recorded(AtnEvent::Code(*b"CnvS"), d); + assert_eq!(action.steps[0].0, "image.canvasSize", "recorded canvas percentages must map"); + assert_eq!(action.steps[0].1["units"], "percent"); + assert!(matches!(saved(&action).descriptor.unwrap().get("Wdth"), Some(D::UnitFloat {unit,value}) if unit==b"#Prc" && *value==200.0)); + for (width, height) in [(40, 30), (10, 15)] { + let mut s = sized_document(width, height, 72); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(width * 2, if both_sides { height * 2 } else { height })); + assert_eq!(s.execute("document.pixel", json!({"x":0,"y":0})).unwrap(), json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(s.execute("document.pixel", json!({"x":1,"y":0})).unwrap(), json!([1.0, 1.0, 1.0, 1.0]), "canvas expansion must not stretch the mark"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(width, height)); + } + } +} + +/// A missing resampler means print-size editing, which the native point-size resize cannot express. +#[test] +fn review_point_resize_without_interpolation_stays_kept() { + for key in ["Wdth", "Hght"] { + for interpolation in [None, Some(D::Boolean(false)), Some(enm("wrong", "Nrst"))] { + let mut d = Descriptor::new("null").with(key, D::UnitFloat { unit: *b"#Rlt", value: 72.0 }); + if let Some(value) = interpolation { + d = d.with("Intr", value); + } + let action = recorded(AtnEvent::Code(*b"ImgS"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP, "point sizes without a valid Intr must stay unsupported"); + assert_eq!(saved(&action).descriptor, Some(d)); + } + } +} + +/// Null optional values have the same export semantics as omitted values. +#[test] +fn review_point_resize_exports_with_null_optional_values() { + for extras in [json!({"resolution":null}), json!({"percent":null}), json!({"resolution":null,"percent":null})] { + let mut params = json!({"width":72,"units":"pt","resample":"nearest","scaleStyles":false}); + params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone()); + let action = Action::new("Recorded", vec![("image.imageSize".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Points", &[&action], &[], false).expect("null values must not prevent point-size export"); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].1, json!({"width":72,"units":"pt","resample":"nearest","scaleStyles":false})); + } +} + +/// The native default resampler must be explicit in a physical-size recording. +#[test] +fn review_point_resize_exports_the_default_resampler() { + for extras in [json!({}), json!({"resample":null})] { + let mut params = json!({"width":72,"units":"pt","scaleStyles":false}); + params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone()); + let action = Action::new("Recorded", vec![("image.imageSize".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Points", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty(), "a point-size export must include its native default resampler"); + assert_eq!(imported.actions[0].steps[0].1["resample"], "bicubic"); + let mut expected = sized_document(40, 20, 144); + expected.execute("image.imageSize", params).unwrap(); + let mut actual = sized_document(40, 20, 144); + actual.actions.list = imported.actions; + let result = actual.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let (a, b) = (&actual.active().unwrap().doc, &expected.active().unwrap().doc); + assert_eq!(a.size, b.size); + assert_eq!(a.layers[0].surface().unwrap().read_region(a.bounds()), b.layers[0].surface().unwrap().read_region(b.bounds())); + } +} + +/// Invalid metadata must keep the whole Copy/Cut event, before a layer or source pixels can change. +#[test] +fn review_copy_cut_opacity_is_validated_before_any_step_runs() { + for copy in [true, false] { + for opacity in [D::Double(-1.0), D::Double(101.0), D::Double(f64::NAN), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 50.0 }] { + let d = Descriptor::new("null") + .with("Nw ", D::Descriptor(Descriptor::new("Lyr ").with("Opct", opacity))) + .with("Usng", enm("UsrM", "Slct")) + .with("Cpy ", D::Boolean(copy)); + let action = recorded(AtnEvent::Code(*b"Mk "), d); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP, "invalid opacity must keep Copy/Cut before mutation"); + let mut s = sized_document(8, 6, 72); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + s.actions.list = vec![action]; + let result = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + } + for opacity in [0.0, 50.0, 100.0] { + let d = Descriptor::new("null") + .with("Nw ", D::Descriptor(Descriptor::new("Lyr ").with("Opct", D::Double(opacity)))) + .with("Usng", enm("UsrM", "Slct")) + .with("Cpy ", D::Boolean(copy)); + let action = recorded(AtnEvent::Code(*b"Mk "), d); + assert!(action.steps.iter().all(|(id, _)| id != atn_cmds::PHOTOSHOP_STEP)); + assert_eq!(action.steps.iter().find(|(id, _)| id == "layer.setProps").unwrap().1["opacity"], opacity / 100.0); + } + } +} + +/// Every named layer anchor must affect actual playback geometry, not just the saved parameters. +#[test] +fn review_layer_transform_uses_the_recorded_anchor() { + for depth in [8, 16, 32] { + for (code, fx, fy) in + [("Qcs0", 0, 0), ("Qcs4", 1, 0), ("Qcs1", 2, 0), ("Qcs7", 0, 1), ("Qcsa", 1, 1), ("Qcs5", 2, 1), ("Qcs3", 0, 2), ("Qcs6", 1, 2), ("Qcs2", 2, 2)] + { + let d = Descriptor::new("null") + .with("FTcs", enm("QCSt", code)) + .with( + "Ofst", + D::Descriptor( + Descriptor::new("Ofst") + .with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }) + .with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }), + ), + ) + .with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) + .with("Hght", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) + .with("Intr", enm("Intp", "Nrst")); + let action = recorded(AtnEvent::Code(*b"Trnf"), d); + let mut s = Session::new(); + s.execute("file.new", json!({"width":64,"height":64,"depth":depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("Mark", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(20, 20, 40, 40), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + replay_saved(&mut s, &action); + let state = s.active().unwrap(); + let bounds = state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!(bounds, photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), "{code}, depth {depth}"); + // Measured in the reference application: Qcs6 keeps the bottom centre fixed, Qcs1 the + // top-right corner. + match code { + "Qcs6" => assert_eq!(bounds, photocraft_geom::Rect::new(25, 30, 35, 40), "depth {depth}"), + "Qcs1" => assert_eq!(bounds, photocraft_geom::Rect::new(30, 20, 40, 30), "depth {depth}"), + _ => {} + } + s.execute("edit.undo", json!({})).unwrap(); + if code == "Qcs0" { + s.edit("Mark rotation", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(22, 30, &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"rotate":90,"reference":"topLeft","interpolation":"nearest"})).unwrap(); + let state = s.active().unwrap(); + assert_eq!( + state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), + photocraft_geom::Rect::new(0, 20, 20, 40) + ); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().rgba(9, 22), [0.0, 1.0, 0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + assert!(s.execute("edit.transform", json!({"scaleX":50,"reference":"unknown"})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +/// Physical dimensions retain points and resolve current dpi before rounding or style scaling. +#[test] +fn recorded_image_points_use_current_resolution_and_keep_style_scaling() { + use photocraft_color::{BlendMode, Color}; + use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, StrokeFx, StrokePosition}; + for dpi in [72, 144] { + for scale_styles in [None, Some(false), Some(true)] { + let mut d = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Rlt", value: 72.0 }) + .with("CnsP", D::Boolean(true)) + .with("Intr", enm("Intp", "Nrst")); + if let Some(value) = scale_styles { + d = d.with("scaleStyles", D::Boolean(value)); + } + let action = recorded(AtnEvent::Code(*b"ImgS"), d); + assert_eq!(action.steps[0].0, "image.imageSize", "recorded point dimensions must map"); + assert_eq!(action.steps[0].1["units"], "pt"); + assert_eq!(action.steps[0].1["width"], 72); + assert!(matches!(saved(&action).descriptor.unwrap().get("Wdth"), Some(D::UnitFloat {unit,value}) if unit==b"#Rlt" && *value==72.0)); + let mut s = sized_document(40, 20, dpi); + s.edit("Style", |doc, _| { + doc.layers[0].effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + replay_saved(&mut s, &action); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.size, photocraft_geom::Size::new(dpi, dpi / 2)); + let Effect::Stroke(stroke) = &doc.layers[0].effects.items[0] else { panic!("stroke lost") }; + assert_eq!(stroke.size, if scale_styles.unwrap_or(false) { 20.0 * dpi as f32 / 40.0 } else { 20.0 }); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(40, 20)); + } + } + let d = Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Rlt", value: 0.36 }).with("Intr", enm("Intp", "Nrst")); + let action = recorded(AtnEvent::Code(*b"ImgS"), d); + let mut s = sized_document(40, 20, 300); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(2, 1), "round pixels after point conversion"); +} + +/// The layered false/false recording writes a class and explicit layer-retaining choices. +#[test] +fn recorded_mode_uses_a_class_and_retains_layers_with_false_flags() { + let d = Descriptor::new("null").with("T ", D::Class(class("Grys"))).with("Fltt", D::Boolean(false)).with("Rstr", D::Boolean(false)); + let action = recorded(AtnEvent::Code(*b"CnvM"), d); + assert_eq!(action.steps, vec![("image.mode.grayscale".into(), json!({}))]); + let written = saved(&action).descriptor.unwrap(); + assert!(matches!(written.get("T "),Some(D::Class(c)) if c.class_id.is("Grys")), "mode target must save as a class"); + assert_eq!(written.get("Fltt"), Some(&D::Boolean(false))); + assert_eq!(written.get("Rstr"), Some(&D::Boolean(false))); + let mut s = sized_document(4, 2, 72); + s.execute("layer.new.layer", json!({"name":"Second"})).unwrap(); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.mode, photocraft_doc::ColorMode::Grayscale); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); +} + +/// Unsupported dimension combinations and destructive mode choices must not become different edits. +#[test] +fn unrepresented_units_and_mode_choices_stay_verbatim() { + let point = || Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Rlt", value: 72.0 }); + let mode = || Descriptor::new("null").with("T ", D::Class(class("Grys"))); + let canvas = || Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 200.0 }); + for (event, d) in [ + (*b"CnvM", mode().with("Fltt", D::Boolean(true))), + (*b"CnvM", mode().with("Rstr", D::Boolean(true))), + (*b"CnvM", mode().with("Rstr", D::Integer(0))), + (*b"CnvS", canvas().with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 40.0 })), + (*b"CnvS", canvas().with("Rltv", D::Boolean(true))), + (*b"ImgS", point().with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 40.0 })), + (*b"ImgS", point().with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: 144.0 })), + (*b"ImgS", point().with("CnsP", D::Boolean(false))), + ] { + let action = recorded(AtnEvent::Code(event), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } +} + +/// Explicit false layer flags mean the same thing as omitted flags for a bit-depth conversion. +#[test] +fn review_10702_depth_conversion_accepts_false_layer_flags() { + for (depth, command) in [(8, "image.mode.bits8"), (16, "image.mode.bits16"), (32, "image.mode.bits32")] { + for flags in [vec![], vec!["Fltt"], vec!["Rstr"], vec!["Fltt", "Rstr"]] { + let mut d = Descriptor::new("null").with("Dpth", D::Integer(depth)); + for key in flags { + d = d.with(key, D::Boolean(false)); + } + let action = recorded(AtnEvent::Code(*b"CnvM"), d); + assert_eq!(action.steps, vec![(command.into(), json!({}))]); + let mut s = sized_document(2, 1, 72); + s.execute("layer.new.layer", json!({"name":"Retained"})).unwrap(); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + assert_eq!(s.active().unwrap().doc.depth.bits(), depth as u32); + } + } + for key in ["Fltt", "Rstr"] { + for value in [D::Boolean(true), D::Integer(0), D::Text(UnicodeString::new_nul("false"))] { + let d = Descriptor::new("null").with("Dpth", D::Integer(16)).with(key, value); + let action = recorded(AtnEvent::Code(*b"CnvM"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } + } +} + +/// A recorded name is resolved at playback, including names that collide with native sentinels. +#[test] +fn review_10702_transparency_names_resolve_at_playback() { + use photocraft_doc::LayerId; + use photocraft_geom::Rect; + + for name in ["Subject", "merged", ""] { + let d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }, + ]), + ); + let action = recorded(AtnEvent::Code(*b"setd"), d.clone()); + assert_eq!(action.steps[0].0, "select.loadSelection"); + let mut s = sized_document(2, 1, 72); + for alpha in [[1.0, 0.0], [0.0, 1.0]] { + let id = LayerId(s.execute("layer.new.layer", json!({"name":"Placeholder"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Fixture", |doc, _| { + let layer = doc.layer_mut(id).unwrap(); + layer.name = name.into(); + layer.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 2, 1), &[1.0, 0.0, 0.0, alpha[0], 1.0, 0.0, 0.0, alpha[1]]); + Ok(()) + }) + .unwrap(); + } + s.execute("layer.new.layer", json!({"name":"Active"})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#ffffff"})).unwrap(); + s.actions.list = vec![action.clone()]; + let played = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let doc = &s.active().unwrap().doc; + let selected = doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]); + assert_eq!(selected, vec![0.0, 1.0], "{name:?}: use the topmost named layer, not the active layer or merged image"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.selection.is_none()); + assert_eq!(saved(&action).descriptor, Some(d)); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![0.0, 1.0]); + for layer in [json!({"name":"Missing"}), json!({"name":1}), json!({"name":name,"extra":true})] { + assert!(s.execute("select.loadSelection", json!({"channel":"transparency","layer":layer})).is_err()); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![0.0, 1.0]); + } + for sentinel in ["merged", ""] { + let native = Action::new("Recorded", vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":sentinel}))]); + let reloaded = recorded(AtnEvent::Code(*b"setd"), saved(&native).descriptor.unwrap()); + let (id, params) = &reloaded.steps[0]; + s.execute(id, params.clone()).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); + } + } +} + +/// A group's transparency loads the whole canvas, as ⌘-clicking its thumbnail always did (the +/// group's combined transparency is not composited yet), rather than failing. +#[test] +fn group_transparency_sources_load_the_canvas() { + use photocraft_doc::LayerId; + use photocraft_geom::Rect; + + for name in ["Group", "merged", ""] { + let mut s = sized_document(2, 1, 72); + let child = LayerId(s.execute("layer.new.layer", json!({"name":"Child"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Child pixels", |doc, _| { + doc.layer_mut(child).unwrap().surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 2, 1), &[1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0]); + Ok(()) + }) + .unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({"name":"Group"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Group name", |doc, _| { + doc.layer_mut(group).unwrap().name = name.into(); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({"name":"Active"})).unwrap(); + s.execute("edit.fill", json!({"color":"#ffffff"})).unwrap(); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":"Child"})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![1.0, 0.0]); + let descriptor = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }, + ]), + ); + let action = recorded(AtnEvent::Code(*b"setd"), descriptor.clone()); + let (id, params) = &action.steps[0]; + let result = s.execute(id, params.clone()); + let doc = &s.active().unwrap().doc; + let alpha = doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]); + assert!(result.is_ok(), "group {name:?} returned {result:?}"); + assert_eq!(alpha, vec![1.0, 1.0], "group {name:?}"); + // ⌘-click on the group's thumbnail (by id) loads the same. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":group.0})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); + assert_eq!(saved(&action).descriptor, Some(descriptor)); + + // The group's explicit mask remains a valid source. + s.execute("select.loadSelection", json!({"channel":"transparency","layer":"Child"})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({"layer":group.0})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + s.execute("select.loadSelection", json!({"channel":"mask","layer":params["layer"]})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![1.0, 0.0]); + } +} + +/// New unit validation must fail before editing, while no-resample still ignores size fields. +#[test] +fn native_units_validate_before_edit_and_no_resample_remains_resolution_only() { + let mut s = sized_document(40, 20, 300); + for (id, params, refuse_export) in [ + ("image.canvasSize", json!({"width":200,"units":"unknown"}), true), + ("image.canvasSize", json!({"width":"200","units":"percent"}), true), + ("image.canvasSize", json!({"width":200,"units":"percent","relative":true}), true), + ("image.imageSize", json!({"width":72,"units":1}), true), + ("image.imageSize", json!({"width":"72","units":"pt"}), true), + // A finite recorded point value is representable; overflow depends on playback dpi. + ("image.imageSize", json!({"width":1e308,"units":"pt"}), false), + ] { + let before = s.execute("document.inspect", json!({})).unwrap(); + assert!(s.execute(id, params.clone()).is_err(), "{id} {params}"); + assert_eq!(s.execute("document.inspect", json!({})).unwrap(), before); + let action = Action::new("Invalid", vec![(id.into(), params)]); + assert_eq!(atn_cmds::export("Identifiers", &[&action], &[], false).is_err(), refuse_export); + } + let params = json!({"width":1e308,"units":"pt","resolution":144,"resample":"none"}); + s.execute("image.imageSize", params.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(40, 20)); + assert_eq!(s.active().unwrap().doc.resolution_dpi, 144.0); + let action = Action::new("Resolution", vec![("image.imageSize".into(), params)]); + let d = saved(&action).descriptor.unwrap(); + assert!(d.get("Wdth").is_none() && d.get("Hght").is_none()); +} + +fn class(id: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } +} + +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} + +fn blue() -> Descriptor { + Descriptor::new("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0)) +} + +/// Load a reference-shaped descriptor through the public ATN parser. +fn recorded(event: AtnEvent, d: Descriptor) -> Action { + let bytes = atn::write(&AtnFile { + name: UnicodeString::new_nul("Identifiers"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Recorded"), + items: vec![AtnItem { event, descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + encoding: None, + }) + .unwrap(); + atn_cmds::import(&bytes).unwrap().actions.remove(0) +} + +/// Parse the actual saved bytes, so matching import/export mistakes cannot cancel out. +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Identifiers", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), 1); + file.actions[0].items[0].clone() +} + +/// Check the enum type and value in saved bytes independently of the command parameters. +fn enum_value<'a>(d: &'a Descriptor, key: &str, ty: &str) -> &'a Id { + let Some(D::Enumerated { type_id, value }) = d.get(key) else { panic!("missing {key} enumeration") }; + assert_eq!(*type_id, Id::new(ty)); + value +} + +/// Make's opacity belongs to the layer around the adjustment descriptor. +fn adjustment_make(t: Descriptor, opacity: Option) -> Descriptor { + let mut using = Descriptor::new("AdjL").with("Type", D::Descriptor(t)); + if let Some(opacity) = opacity { + using = using.with("Opct", opacity); + } + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("AdjL"))])).with("Usng", D::Descriptor(using)) +} + +/// Ported from #8659: the saved event changes, while legacy imports and pixel results agree. +#[test] +fn recorded_vibrance_uses_the_string_event_and_keeps_legacy_playback() { + let d = Descriptor::new("Vbrn").with("vibrance", D::Integer(47)).with("Strt", D::Integer(-19)); + for depth in [8, 16, 32] { + for event in [AtnEvent::name("vibrance"), AtnEvent::Code(*b"Vbrn")] { + let action = recorded(event, d.clone()); + assert_eq!(action.steps, vec![("image.adjustments.vibrance".into(), json!({"vibrance":47,"saturation":-19}))]); + let written = saved(&action); + assert_eq!(written.event, AtnEvent::name("vibrance"), "Vibrance must save by its string event"); + // Saved with the `vibrance` class the reference application records (#8534); the + // top-level class does not change what it plays. + let mut want = d.clone(); + want.class_id = Descriptor::new("vibrance").class_id; + assert_eq!(written.descriptor, Some(want)); + let create = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#6594c2"})).unwrap(); + s + }; + let mut actual = create(); + replay_saved(&mut actual, &action); + let mut expected = create(); + expected.execute("image.adjustments.vibrance", action.steps[0].1.clone()).unwrap(); + for channel in 0..4 { + assert_eq!( + actual.active().unwrap().doc.layers[0].surface().unwrap().sample_channel(1, 1, channel), + expected.active().unwrap().doc.layers[0].surface().unwrap().sample_channel(1, 1, channel) + ); + } + } + } +} + +/// The reported Gradient Map Make and the metadata-only default both round-trip atomically. +#[test] +fn recorded_adjustment_opacity_keeps_gradient_settings_and_defaults() { + let full = + saved(&Action::new("Recorded", vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#0000ff"],[1,"#ff0000"]]}))])).descriptor.unwrap(); + let default = Descriptor::new("GdMp").with("presetKind", enm("presetKindType", "presetKindDefault")); + for t in [full, default] { + let action = recorded(AtnEvent::Code(*b"Mk "), adjustment_make(t.clone(), Some(D::UnitFloat { unit: *b"#Prc", value: 70.0 }))); + assert_eq!(action.steps.len(), 1); + assert_eq!(action.steps[0].0, "layer.newAdjustmentLayer.gradientMap"); + assert_eq!(action.steps[0].1["opacity"], json!(179.0 / 255.0), "Make must retain surrounding opacity"); + if t.get("Grad").is_some() { + assert_eq!(action.steps[0].1["stops"], json!([[0, "#0000ff"], [1, "#ff0000"]])); + } + let written = saved(&action).descriptor.unwrap(); + let Some(D::Descriptor(using)) = written.get("Usng") else { panic!("missing adjustment metadata") }; + // The recorded 70 % saves back as 70 %, not as the 8-bit level 179/255 (70.196 %). + assert!(matches!(using.get("Opct"), Some(D::UnitFloat { unit, value }) if unit == b"#Prc" && *value == 70.0), "{:?}", using.get("Opct")); + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); + let base = s.active().unwrap().active_layer.unwrap(); + replay_saved(&mut s, &action); + let state = s.active().unwrap(); + assert_eq!(state.doc.layers.len(), 2); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().opacity, 179.0_f32 / 255.0); + assert_eq!(state.doc.layer(base).unwrap().opacity, 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "one undo removes the complete Make"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + } + } +} + +/// Invalid supplied opacity cannot become a silent default or create a layer. +#[test] +fn adjustment_opacity_validation_and_endpoints_are_atomic() { + let t = Descriptor::new("GdMp").with("presetKind", enm("presetKindType", "presetKindDefault")); + for bad in [ + D::UnitFloat { unit: *b"#Pxl", value: 70.0 }, + D::UnitFloat { unit: *b"#Prc", value: -1.0 }, + D::UnitFloat { unit: *b"#Prc", value: 101.0 }, + D::Text(UnicodeString::new_nul("70")), + D::Boolean(false), + D::Double(f64::NAN), + ] { + let action = recorded(AtnEvent::Code(*b"Mk "), adjustment_make(t.clone(), Some(bad))); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + saved(&action); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":3})).unwrap(); + for bad in [json!(null), json!("70"), json!(-0.1), json!(1.1)] { + let before = s.execute("document.inspect", json!({})).unwrap(); + assert!(s.execute("layer.newAdjustmentLayer.gradientMap", json!({"opacity":bad})).is_err()); + assert_eq!(s.execute("document.inspect", json!({})).unwrap(), before); + assert!( + atn_cmds::export("Identifiers", &[&Action::new("Bad", vec![("layer.newAdjustmentLayer.gradientMap".into(), json!({"opacity":bad}))])], &[], false) + .is_err() + ); + } + for opacity in [None, Some(0.0), Some(1.0)] { + let p = opacity.map_or_else(|| json!({}), |opacity| json!({"opacity":opacity})); + s.execute("layer.newAdjustmentLayer.gradientMap", p.clone()).unwrap(); + let state = s.active().unwrap(); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().opacity, opacity.unwrap_or(1.0)); + let action = Action::new("Recorded", vec![("layer.newAdjustmentLayer.gradientMap".into(), p)]); + let reloaded = recorded(AtnEvent::Code(*b"Mk "), saved(&action).descriptor.unwrap()); + assert_eq!(reloaded.steps.len(), 1); + assert_eq!(reloaded.steps[0].0, action.steps[0].0); + match opacity { + None => assert_eq!(reloaded.steps[0].1, json!({})), + Some(opacity) => assert_eq!(reloaded.steps[0].1["opacity"].as_f64(), Some(f64::from(opacity))), + } + s.execute("edit.undo", json!({})).unwrap(); + } +} + +/// Canonical event names and old code imports share the existing gradient parameters. +#[test] +fn gradient_map_writes_the_named_event_and_keeps_legacy_code_imports() { + let stop = |location, color| { + D::Descriptor(Descriptor::new("Clrt").with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)).with("Clr ", D::Descriptor(color))) + }; + let red = Descriptor::new("RGBC").with("Rd ", D::Double(255.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)); + let d = Descriptor::new("GdMp") + .with("Grad", D::Descriptor(Descriptor::new("Grdn").with("Clrs", D::List(vec![stop(0, blue()), stop(4096, red)])))) + .with("Rvrs", D::Boolean(true)) + .with("Dthr", D::Boolean(false)); + for event in [AtnEvent::name("gradientMapEvent"), AtnEvent::Code(*b"GdMp")] { + let action = recorded(event, d.clone()); + assert_eq!(action.steps, vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#0000ff"],[1,"#ff0000"]],"reverse":true,"dither":false}))]); + let written = saved(&action); + assert_eq!(written.event, AtnEvent::name("gradientMapEvent")); + assert!(written.descriptor.unwrap().class_id.is("GdMp")); + } + assert_eq!(recorded(AtnEvent::name("GdMp"), d).steps[0].0, atn_cmds::PHOTOSHOP_STEP); +} + +/// The measured outside identifier changes while inside, centre and unknown values stay safe. +#[test] +fn stroke_outside_writes_otsd_without_changing_inside_or_center() { + for (input, location, canonical) in [("Otsd", "outside", "Otsd"), ("OtsF", "outside", "Otsd"), ("Insd", "inside", "Insd"), ("Cntr", "center", "Cntr")] { + let d = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 2.0 }) + .with("Lctn", enm("StrL", input)) + .with("Clr ", D::Descriptor(blue())); + let action = recorded(AtnEvent::Code(*b"Strk"), d); + assert_eq!(action.steps[0].0, "edit.stroke"); + assert_eq!(action.steps[0].1["location"], location); + assert_eq!(*enum_value(saved(&action).descriptor.as_ref().unwrap(), "Lctn", "StrL"), Id::new(canonical)); + } + let unknown = Descriptor::new("null").with("Lctn", enm("StrL", "futureLocation")); + let action = recorded(AtnEvent::Code(*b"Strk"), unknown.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(unknown)); +} + +/// A group blend uses Photoshop's actual identifier and reaches the native group command. +#[test] +fn pass_through_uses_the_string_identifier_and_replays_on_a_group() { + for input in ["passThrough", "PsTh"] { + let target = D::Reference(vec![ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Group") }]); + let d = Descriptor::new("null").with("null", target).with("T ", D::Descriptor(Descriptor::new("Lyr ").with("Md ", enm("BlnM", input)))); + let action = recorded(AtnEvent::Code(*b"setd"), d); + assert_eq!(action.steps, vec![("layer.setProps".into(), json!({"layer":"Group","blend":"Pass Through"}))]); + let written = saved(&action).descriptor.unwrap(); + let Some(D::Descriptor(props)) = written.get("T ") else { panic!("layer properties lost") }; + assert_eq!(*enum_value(props, "Md ", "BlnM"), Id::new("passThrough")); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.group", json!({"name":"Group"})).unwrap(); + s.execute("layer.setProps", json!({"blend":"Normal"})).unwrap(); + let (id, p) = &action.steps[0]; + s.execute(id, p.clone()).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().blend, photocraft_color::BlendMode::PassThrough); + } +} + +fn canvas() -> Descriptor { + Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 6.0 }) + .with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 4.0 }) + .with("Hrzn", enm("HrzL", "Left")) + .with("Vrtc", enm("VrtL", "Top ")) +} + +/// Foreground and custom extension colours select visibly different new border pixels. +#[test] +fn canvas_extension_identifiers_preserve_foreground_and_custom_color() { + for (input, custom, canonical, expected) in [ + ("FrgC", false, "FrgC", [1.0, 0.0, 0.0, 1.0]), + ("Frgd", false, "FrgC", [1.0, 0.0, 0.0, 1.0]), + ("Clr ", true, "Clr ", [0.0, 0.0, 1.0, 1.0]), + ("Othr", true, "Clr ", [0.0, 0.0, 1.0, 1.0]), + ] { + let mut d = canvas().with("canvasExtensionColorType", enm("canvasExtensionColorType", input)); + if custom { + d = d.with("canvasExtensionColor", D::Descriptor(blue())); + } + let action = recorded(AtnEvent::Code(*b"CnvS"), d); + assert_eq!(action.steps[0].0, "image.canvasSize"); + assert_eq!(*enum_value(saved(&action).descriptor.as_ref().unwrap(), "canvasExtensionColorType", "canvasExtensionColorType"), Id::new(canonical)); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#ff0000","background":"#00ff00"})).unwrap(); + let (id, p) = &action.steps[0]; + s.execute(id, p.clone()).unwrap(); + let pixel: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x":5,"y":1})).unwrap()).unwrap(); + assert_eq!(pixel, expected); + } + for d in [ + canvas().with("canvasExtensionColorType", enm("canvasExtensionColorType", "futureColor")).with("canvasExtensionColor", D::Descriptor(blue())), + canvas().with("canvasExtensionColorType", D::Integer(1)), + canvas().with("canvasExtensionColorType", enm("canvasExtensionColorType", "Clr ")), + ] { + let action = recorded(AtnEvent::Code(*b"CnvS"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } +} + +/// Absolute intent writes the measured code and unknown intent values remain preserved steps. +#[test] +fn absolute_colorimetric_writes_aclr_and_rejects_unknown_intents() { + let profile = || Descriptor::new("null").with("T ", D::Text(UnicodeString::new_nul("sRGB IEC61966-2.1"))); + for input in ["AClr", "AbsC"] { + let action = recorded(AtnEvent::name("convertToProfile"), profile().with("Inte", enm("Inte", input))); + assert_eq!( + action.steps, + vec![("edit.convertToProfile".into(), json!({"profile":"srgb","intent":"absolute","dither":false,"flatten":false,"rasterizePlaced":false}))] + ); + assert_eq!(*enum_value(saved(&action).descriptor.as_ref().unwrap(), "Inte", "Inte"), Id::new("AClr")); + } + for value in [enm("Inte", "futureIntent"), D::Integer(1)] { + let d = profile().with("Inte", value); + let action = recorded(AtnEvent::name("convertToProfile"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } +} + +/// Wrap uses opening order after tab moves, with overflow-safe offsets and valid ATN references. +#[test] +fn document_offsets_wrap_in_opening_order_and_zero_atn_offsets_stay_kept() { + let mut s = Session::new(); + for name in ["a", "b", "c"] { + s.execute("file.new", json!({"width":2,"height":2,"name":name})).unwrap(); + } + s.execute("document.move", json!({"document":2,"to":0})).unwrap(); + for (offset, expected) in [(-2, "c"), (2, "a"), (5, "a"), (-5, "c"), (i64::MIN, "c"), (i64::MAX, "c"), (0, "b")] { + s.execute("document.activate", json!({"document":2})).unwrap(); + s.execute("document.activate", json!({"offset":offset})).unwrap(); + assert_eq!(s.active().unwrap().doc.name, expected, "{offset}"); + } + for offset in [-2, 2] { + let d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])); + let action = recorded(AtnEvent::Code(*b"slct"), d); + assert_eq!(action.steps[0].0, "document.activate"); + s.execute("document.activate", json!({"document":2})).unwrap(); + let (id, p) = &action.steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.name, if offset < 0 { "c" } else { "a" }); + let written = saved(&action).descriptor.unwrap(); + assert!(matches!(written.get("null"),Some(D::Reference(r)) if matches!(r.as_slice(),[ReferenceItem::Offset{offset:o,..}] if *o==offset))); + } + let zero = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset: 0 }])); + let action = recorded(AtnEvent::Code(*b"slct"), zero.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(zero)); + assert!(atn_cmds::export("Identifiers", &[&Action::new("Zero", vec![("document.activate".into(), json!({"offset":0}))])], &[], false).is_err()); +} diff --git a/crates/engine/tests/cmyk_flatten_profile.rs b/crates/engine/tests/cmyk_flatten_profile.rs new file mode 100644 index 0000000000..41739f5326 --- /dev/null +++ b/crates/engine/tests/cmyk_flatten_profile.rs @@ -0,0 +1,192 @@ +//! Flatten Image, Stamp Visible, Background from Layer, Merge Down and Merge Visible keep the +//! inks of a CMYK document tagged with a profile other than the built-in one (#3778). +//! +//! A document whose composite is one plain pixel layer keeps its inks exactly, as in Photoshop +//! (black generation and inks outside the display gamut included). Merges of several layers are +//! composited in RGB and separated back through the profile the layers were read with, so they +//! keep the inks up to that round trip's residual instead of moving every pixel. + +use std::sync::Arc; + +use photocraft_color::convert::{CmykSpace, with_cmyk_space}; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::json; + +const W: i32 = 8; +const H: i32 = 2; + +/// RGB colours whose separations make the first row: mid tones, saturated colours, near-black. +const RGB: [[f32; 3]; 8] = + [[0.8, 0.3, 0.2], [0.1, 0.5, 0.9], [0.5, 0.5, 0.5], [0.08, 0.06, 0.05], [0.95, 0.9, 0.85], [0.9, 0.8, 0.1], [0.2, 0.7, 0.3], [0.6, 0.2, 0.6]]; + +/// Inks no separation produces, for the second row: K only, rich black, solid cyan (outside +/// sRGB), paper, 50 % K, a heavy four-colour mix, and two odd mixes. +const INKS: [[f32; 4]; 8] = [ + [0.0, 0.0, 0.0, 1.0], + [0.75, 0.68, 0.67, 0.9], + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.5], + [0.9, 0.9, 0.9, 0.9], + [0.886, 0.329, 0.0, 0.0], + [0.1, 0.0, 0.6, 0.3], +]; + +fn profile() -> Arc> { + photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { + description: "Test Uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + ..Default::default() + }) + .to_bytes() +} + +/// A one-layer (Background) CMYK document, tagged with `profile()` when `tag`: the first row is +/// separated from [`RGB`] through that profile (a separation read back and separated again a few +/// times, so the profile reproduces it), the second row holds [`INKS`]. +fn document(depth: u32, tag: bool) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap(); + let icc = profile(); + let space = CmykSpace::for_profile(Some(&icc)); + assert!(space.is_some(), "the fixture is not the built-in profile"); + s.edit("tag", |doc, _| { + if tag { + doc.icc_profile = Some(icc.clone()); + } + let bg = doc.layers[0].surface_mut().unwrap(); + let fmt = bg.format(); + with_cmyk_space(if tag { space.as_ref() } else { None }, || { + for (x, rgb) in RGB.iter().enumerate() { + let mut px = photocraft_raster::from_rgba(&fmt, [rgb[0], rgb[1], rgb[2], 1.0]); + for _ in 0..3 { + let back = photocraft_raster::to_rgba(&fmt, &px); + px = photocraft_raster::from_rgba(&fmt, back); + } + bg.write_pixel(x as i32, 0, &px); + } + }); + for (x, ink) in INKS.iter().enumerate() { + bg.write_pixel(x as i32, 1, &[ink[0], ink[1], ink[2], ink[3], 1.0]); + } + Ok(()) + }) + .unwrap(); + s +} + +/// The four ink values of every pixel of the active layer. +fn inks(s: &Session) -> Vec<[f32; 4]> { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let n = surf.channels(); + surf.read_region(Rect::new(0, 0, W, H)).chunks_exact(n).map(|p| [p[0], p[1], p[2], p[3]]).collect() +} + +fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { + a.iter().zip(b).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) +} + +/// Runs `run` on a document and returns how far the inks of `row` (0 = separations, 1 = any +/// inks) moved. +fn drift(depth: u32, tag: bool, row: usize, run: impl FnOnce(&mut Session)) -> f32 { + let mut s = document(depth, tag); + let before = inks(&s); + run(&mut s); + let after = inks(&s); + let r = row * W as usize..(row + 1) * W as usize; + max_diff(&before[r.clone()], &after[r]) +} + +fn exact(what: &str, run: impl Fn(&mut Session)) { + for depth in [8, 16] { + for tag in [true, false] { + for row in [0, 1] { + let d = drift(depth, tag, row, &run); + assert!(d == 0.0, "{depth}-bit {what} (tagged: {tag}, row {row}): inks moved by {d}"); + } + } + } +} + +#[test] +fn flatten_keeps_the_inks_of_a_single_layer_exactly() { + exact("Flatten Image", |s| { + s.execute("layer.flattenImage", json!({})).unwrap(); + }); +} + +#[test] +fn stamp_visible_keeps_the_inks_of_a_single_layer_exactly() { + exact("Stamp Visible", |s| { + s.execute("layer.stampVisible", json!({})).unwrap(); + }); +} + +#[test] +fn background_from_layer_keeps_the_inks_exactly() { + exact("Background from Layer", |s| { + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + s.execute("layer.new.backgroundFromLayer", json!({})).unwrap(); + }); +} + +#[test] +fn flatten_of_a_partly_transparent_layer_still_composites_over_white() { + for depth in [8, 16] { + let mut s = document(depth, true); + s.edit("hole", |doc, _| { + doc.layers[0].surface_mut().unwrap().write_pixel(0, 0, &[0.0, 0.0, 0.0, 1.0, 0.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.flattenImage", json!({})).unwrap(); + let px = inks(&s)[0]; + // Transparent over white paper: no ink, not the hidden K. + assert!(px.iter().all(|v| *v < 0.02), "{depth}-bit: {px:?}"); + } +} + +/// Merges of several layers go through the RGB composite: separations the profile reproduces +/// come back within this (the profile's own RGB round trip, a few levels), where reading with +/// the document's profile and separating with the built-in one moved them by tens of levels. +const ROUND_TRIP: f32 = 10.0 / 255.0; + +#[test] +fn merge_down_separates_through_the_document_profile() { + for depth in [8, 16] { + let d = drift(depth, true, 0, |s| { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.mergeDown", json!({})).unwrap(); + }); + assert!(d <= ROUND_TRIP, "{depth}-bit Merge Down: inks moved by {d}"); + } +} + +#[test] +fn merge_visible_separates_through_the_document_profile() { + for depth in [8, 16] { + let d = drift(depth, true, 0, |s| { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.mergeVisible", json!({})).unwrap(); + }); + assert!(d <= ROUND_TRIP, "{depth}-bit Merge Visible: inks moved by {d}"); + } +} + +/// Rasterize Layer Style bakes the layer through the RGB composite and separates the result +/// back: through the document's profile, the one the layer was read with (#3778), so the inks +/// under the effect-free area come back within the profile's round trip. The drop shadow falls +/// off the canvas, so every pixel checked is the layer's own. +#[test] +fn rasterize_layer_style_separates_through_the_document_profile() { + for depth in [8, 16] { + let d = drift(depth, true, 0, |s| { + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"distance": 40, "size": 0})).unwrap(); + s.execute("layer.rasterize.layerStyle", json!({})).unwrap(); + }); + assert!(d <= ROUND_TRIP, "{depth}-bit Rasterize Layer Style: inks moved by {d}"); + } +} diff --git a/crates/engine/tests/content_aware_fill_atn.rs b/crates/engine/tests/content_aware_fill_atn.rs new file mode 100644 index 0000000000..796982207f --- /dev/null +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -0,0 +1,189 @@ +//! #10694: Content-Aware Fill options survive ATN load, save and playback. + +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const FLAGS: [(&str, &str); 4] = [ + ("contentAwareColorAdaptationFill", "colorAdaptation"), + ("contentAwareScaleFill", "scaleAdaptation"), + ("contentAwareMirrorFill", "mirror"), + ("contentAwareRotateFill", "rotationAdaptation"), +]; +const ROTATION: &str = "contentAwareRotateFill"; + +fn descriptor() -> Descriptor { + Descriptor::new("null").with("Usng", D::Enumerated { type_id: Id::new("FlCn"), value: Id::new("contentAware") }) +} + +fn recorded(event: AtnEvent, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("CAF"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Fill"), + items: vec![AtnItem { event, dictionary: b"Fill".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + encoding: None, + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "CAF"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +/// Match the qualified native fixture, using the action command's default seed. +fn fixture() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().doc.layers[0].id; + s.edit("Asymmetric texture", |doc, _| { + let mut pixels = Vec::with_capacity(64 * 48 * 4); + for y in 0..48 { + for x in 0..64 { + pixels.extend([ + ((37 * x + 11 * y + x * y % 17) % 251) as f32 / 250.0, + ((x * x + 13 * y + 3 * (x / 7)) % 241) as f32 / 240.0, + ((5 * x + 3 * y * y + 19 * (x / 9)) % 239) as f32 / 238.0, + 1.0, + ]); + } + } + doc.layer_mut(id).unwrap().surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 64, 48), &pixels); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 28, "y": 20, "width": 8, "height": 8})).unwrap(); + (s, id) +} + +fn pixels(s: &Session, id: LayerId) -> Vec { + let doc = &s.active().unwrap().doc; + doc.layer(id).unwrap().surface().unwrap().read_region(doc.bounds()) +} + +/// Imported flags retain their values and native pixel results through saved actions. +#[test] +fn typed_flags_survive_saved_descriptors_and_public_playback() { + for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { + for values in [[false, true, true, true], [true, false, false, false], [true, false, false, true]] { + let mut d = descriptor(); + let mut params = json!({"contents": "contentAware"}); + for ((key, param), value) in FLAGS.into_iter().zip(values) { + d = d.with(key, D::Boolean(value)); + params[param] = json!(value); + } + let (mut direct, direct_id) = fixture(); + direct.execute("edit.fill", params.clone()).unwrap(); + let expected = pixels(&direct, direct_id); + if values[1] { + let (mut default, default_id) = fixture(); + default.execute("edit.fill", json!({"contents": "contentAware"})).unwrap(); + assert_ne!(expected, pixels(&default, default_id), "combined options must change the fixture"); + } + if values[3] { + let (mut unrotated, unrotated_id) = fixture(); + let mut off = params.clone(); + off["rotationAdaptation"] = json!(false); + unrotated.execute("edit.fill", off).unwrap(); + assert!(expected != pixels(&unrotated, unrotated_id), "the action fixture must distinguish rotated candidates"); + } + let (mut replay, replay_id) = fixture(); + assert_eq!(load(&mut replay, &recorded(event.clone(), d))["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps, vec![("edit.fill".into(), params)], "CAF flags must import without loss"); + let saved = save(&mut replay); + let parsed = atn::parse(&saved).unwrap(); + let saved_d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + for ((key, _), value) in FLAGS.into_iter().zip(values) { + assert_eq!(saved_d.get(key), Some(&D::Boolean(value))); + } + assert_eq!(saved_d.get(ROTATION), Some(&D::Boolean(values[3]))); + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, replay.actions.list[0].steps); + replay.actions.list = imported.actions; + let played = replay.execute("actions.play", json!({"action": "Fill"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + let actual = pixels(&replay, replay_id); + let difference = actual.iter().zip(&expected).position(|(a, b)| a != b); + assert_eq!(difference, None, "saved CAF playback must match the native options"); + } + } +} + +/// Omission and null retain native defaults; saved native fills explicitly disable rotation. +#[test] +fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { + let mut s = Session::new(); + assert_eq!(load(&mut s, &recorded(AtnEvent::Code(*b"Fl "), descriptor()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1, json!({"contents": "contentAware", "rotationAdaptation": false})); + let saved = save(&mut s); + let imported = atn_cmds::import(&saved).unwrap(); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + for null in [false, true] { + let mut params = json!({"contents": "contentAware"}); + if null { + for (_, param) in FLAGS { + params[param] = Value::Null; + } + } + let action = Action::new("Defaults", vec![("edit.fill".into(), params)]); + let (bytes, skipped) = atn_cmds::export("CAF", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let d = file.actions[0].items[0].descriptor.as_ref().unwrap(); + for (key, _) in FLAGS { + if key != ROTATION { + assert!(d.get(key).is_none()); + } + } + assert_eq!(d.get(ROTATION), Some(&D::Boolean(false)), "the default performs no rotated-source search"); + } + for (_, param) in FLAGS { + for value in [json!(1), json!("false")] { + let mut params = json!({"contents": "contentAware"}); + params[param] = value; + let action = Action::new("Invalid", vec![("edit.fill".into(), params)]); + assert!(atn_cmds::export("CAF", &[&action], &[], false).is_err()); + } + } +} + +/// Malformed flags remain intact as unsupported Photoshop steps. +#[test] +fn malformed_flags_remain_kept_steps() { + for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { + let mut bad = Vec::new(); + for (key, _) in FLAGS { + for value in [D::Integer(0), D::Text(UnicodeString::new_nul("false"))] { + bad.push(descriptor().with(key, value)); + } + } + bad.push(descriptor().with(ROTATION, D::Integer(0))); + for d in bad { + let source = recorded(event.clone(), d); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + let loaded = load(&mut s, &source); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + } +} diff --git a/crates/engine/tests/content_aware_fill_options.rs b/crates/engine/tests/content_aware_fill_options.rs new file mode 100644 index 0000000000..9dd3ca2205 --- /dev/null +++ b/crates/engine/tests/content_aware_fill_options.rs @@ -0,0 +1,136 @@ +//! Content-Aware Fill exposes native scale and mirror adaptation without changing defaults. + +use photocraft_algo::content_aware::{FillOptions, color_level, fill}; +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::Session; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const WIDTH: usize = 64; +const HEIGHT: usize = 48; +const HOLE: Rect = Rect::new(28, 20, 36, 28); + +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": WIDTH, "height": HEIGHT, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().doc.layers[0].id; + s.edit("Asymmetric texture", |doc, _| { + let surface = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + let mut pixels = Vec::with_capacity(WIDTH * HEIGHT * 4); + for y in 0..HEIGHT { + for x in 0..WIDTH { + pixels.extend([ + ((37 * x + 11 * y + x * y % 17) % 251) as f32 / 250.0, + ((x * x + 13 * y + 3 * (x / 7)) % 241) as f32 / 240.0, + ((5 * x + 3 * y * y + 19 * (x / 9)) % 239) as f32 / 238.0, + 1.0, + ]); + } + } + surface.write_region(Rect::from_xywh(0, 0, WIDTH as u32, HEIGHT as u32), &pixels); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": HOLE.x0, "y": HOLE.y0, "width": HOLE.width(), "height": HOLE.height()})).unwrap(); + (s, id) +} + +fn surface(s: &Session, id: LayerId) -> &Surface { + s.active().unwrap().doc.layer(id).unwrap().surface().unwrap() +} + +fn pixels(s: &Session, id: LayerId) -> Vec { + surface(s, id).read_region(s.active().unwrap().doc.bounds()) +} + +fn assert_pixels(actual: &[f32], expected: &[f32], label: &str) { + assert_eq!(actual.len(), expected.len(), "{label}"); + let difference = actual.iter().zip(expected).enumerate().find(|(_, (a, b))| a != b).map(|(i, (a, b))| (i, *a, *b)); + assert_eq!(difference, None, "{label}"); +} + +/// The small canvas is wholly inside the command's sampling window. +fn oracle(s: &Session, id: LayerId, color: bool, scale: bool, mirror: bool) -> Vec { + let input = surface(s, id); + let mut hole = vec![false; WIDTH * HEIGHT]; + for y in HOLE.y0..HOLE.y1 { + for x in HOLE.x0..HOLE.x1 { + hole[y as usize * WIDTH + x as usize] = true; + } + } + let source: Vec<_> = hole.iter().map(|v| !v).collect(); + let options = FillOptions { color_adaptation: color_level(if color { "default" } else { "none" }), scale, mirror, seed: 7, ..Default::default() }; + let filled = fill(WIDTH, HEIGHT, input.channels(), &pixels(s, id), &hole, &source, &options); + // Match the destination's sample encoding at 8/16-bit depth. + let mut expected = input.clone(); + expected.write_region(s.active().unwrap().doc.bounds(), &filled); + expected.read_region(s.active().unwrap().doc.bounds()) +} + +#[test] +fn geometric_options_reach_native_fill_and_undo_as_one_edit() { + for depth in [8, 16, 32] { + for (key, scale, mirror) in [("scaleAdaptation", true, false), ("mirror", false, true)] { + let (mut s, id) = fixture(depth); + let before = pixels(&s, id); + let expected = oracle(&s, id, false, scale, mirror); + let default = oracle(&s, id, false, false, false); + assert_ne!(expected, default, "fixture must distinguish {key} at {depth}-bit"); + let history = s.active().unwrap().history.past_len(); + let mut params = json!({"contents": "contentAware", "colorAdaptation": false, "seed": 7}); + params[key] = json!(true); + s.execute("edit.fill", params).unwrap(); + assert_pixels(&pixels(&s, id), &expected, &format!("{key} at {depth}-bit")); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + for y in 0..HEIGHT { + for x in 0..WIDTH { + if !HOLE.contains(x as i32, y as i32) { + let i = (y * WIDTH + x) * 4; + assert_eq!(&expected[i..i + 4], &before[i..i + 4]); + } + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_pixels(&pixels(&s, id), &before, "undo"); + s.execute("edit.redo", json!({})).unwrap(); + assert_pixels(&pixels(&s, id), &expected, "redo"); + } + } +} + +#[test] +fn omitted_false_and_null_options_keep_existing_fill_defaults() { + for depth in [8, 16, 32] { + for value in [None, Some(json!(false)), Some(Value::Null)] { + let (mut s, id) = fixture(depth); + let expected = oracle(&s, id, true, false, false); + let mut params = json!({"contents": "contentAware", "seed": 7}); + if let Some(value) = value { + params["scaleAdaptation"] = value.clone(); + params["mirror"] = value; + } + s.execute("edit.fill", params).unwrap(); + assert_pixels(&pixels(&s, id), &expected, &format!("default at {depth}-bit")); + } + } +} + +#[test] +fn invalid_geometric_options_fail_before_changing_pixels_or_history() { + for key in ["scaleAdaptation", "mirror"] { + for value in [json!(0), json!("false"), json!([]), json!({})] { + let (mut s, id) = fixture(8); + let before = pixels(&s, id); + let history = s.active().unwrap().history.past_len(); + let mut params = json!({"contents": "contentAware", "seed": 7}); + params[key] = value; + let error = s.execute("edit.fill", params).unwrap_err().to_string(); + assert!(error.contains(key), "{error}"); + assert_pixels(&pixels(&s, id), &before, "invalid option"); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + } + // Pattern's scale remains a numeric placement parameter and is not a CAF Boolean. + let (mut s, _) = fixture(8); + s.execute("edit.fill", json!({"contents": "pattern", "pattern": "Diagonal Lines", "scale": 75})).unwrap(); +} diff --git a/crates/engine/tests/content_aware_rotation.rs b/crates/engine/tests/content_aware_rotation.rs new file mode 100644 index 0000000000..9073425d9c --- /dev/null +++ b/crates/engine/tests/content_aware_rotation.rs @@ -0,0 +1,126 @@ +//! Native Fill rotation enables quarter-turn candidates without changing ordinary Fill or Crop. + +use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::Session; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const W: usize = 64; +const H: usize = 48; +const HOLE: Rect = Rect::new(28, 20, 36, 28); + +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().doc.layers[0].id; + s.edit("Asymmetric texture", |doc, _| { + let mut pixels = Vec::with_capacity(W * H * 4); + for y in 0..H { + for x in 0..W { + pixels.extend([ + ((37 * x + 11 * y + x * y % 17) % 251) as f32 / 250.0, + ((x * x + 13 * y + 3 * (x / 7)) % 241) as f32 / 240.0, + ((5 * x + 3 * y * y + 19 * (x / 9)) % 239) as f32 / 238.0, + 1.0, + ]); + } + } + doc.layer_mut(id).unwrap().surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, W as u32, H as u32), &pixels); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": HOLE.x0, "y": HOLE.y0, "width": HOLE.width(), "height": HOLE.height()})).unwrap(); + (s, id) +} + +fn surface(s: &Session, id: LayerId) -> &Surface { + s.active().unwrap().doc.layer(id).unwrap().surface().unwrap() +} + +fn pixels(s: &Session, id: LayerId) -> Vec { + surface(s, id).read_region(s.active().unwrap().doc.bounds()) +} + +fn assert_pixels(actual: &[f32], expected: &[f32], message: &str) { + assert_eq!(actual.len(), expected.len()); + assert_eq!(actual.iter().zip(expected).position(|(a, b)| a != b), None, "{message}"); +} + +/// Qualify rotation against the same bounded algorithm before checking the command wiring. +fn oracle(s: &Session, id: LayerId, rotate: bool) -> Vec { + let input = surface(s, id); + let mut hole = vec![false; W * H]; + for y in HOLE.y0..HOLE.y1 { + for x in HOLE.x0..HOLE.x1 { + hole[y as usize * W + x as usize] = true; + } + } + let source: Vec<_> = hole.iter().map(|v| !v).collect(); + let options = + FillOptions { color_adaptation: color_level("none"), rotations: if rotate { rotation_level("full") } else { vec![] }, seed: 7, ..Default::default() }; + let output = fill(W, H, input.channels(), &pixels(s, id), &hole, &source, &options); + let mut encoded = input.clone(); + encoded.write_region(s.active().unwrap().doc.bounds(), &output); + encoded.read_region(s.active().unwrap().doc.bounds()) +} + +/// A nonzero rotated/unrotated difference makes an ignored option fail at every native depth. +#[test] +fn rotated_candidates_reach_fill_at_all_depths_and_undo_once() { + assert_eq!(rotation_level("full"), vec![90.0, 180.0, 270.0]); + for depth in [8, 16, 32] { + let (mut s, id) = fixture(depth); + let before = s.active().unwrap().doc.clone(); + let on = oracle(&s, id, true); + let off = oracle(&s, id, false); + assert!(on != off, "the rotation fixture must discriminate at {depth}-bit"); + let history = s.active().unwrap().history.past_len(); + s.execute("edit.fill", json!({"contents": "contentAware", "colorAdaptation": false, "rotationAdaptation": true, "seed": 7})).unwrap(); + assert_pixels(&pixels(&s, id), &on, "rotationAdaptation must reach the native sampler"); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = s.active().unwrap().doc.clone(); + let old = before.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + for y in 0..H { + for x in 0..W { + if !HOLE.contains(x as i32, y as i32) { + let i = (y * W + x) * 4; + assert_eq!(&on[i..i + 4], &old[i..i + 4]); + } + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); + } +} + +/// Omission, false and null preserve old sampling; malformed flags preserve document and redo. +#[test] +fn defaults_and_invalid_rotation_flags_preserve_existing_behavior() { + for value in [None, Some(Value::Null), Some(json!(false))] { + let (mut s, id) = fixture(8); + let expected = oracle(&s, id, false); + let mut p = json!({"contents": "contentAware", "colorAdaptation": false, "seed": 7}); + if let Some(value) = value { + p["rotationAdaptation"] = value; + } + s.execute("edit.fill", p).unwrap(); + assert_pixels(&pixels(&s, id), &expected, "ordinary Fill default"); + } + for value in [json!(1), json!("full"), json!([]), json!({})] { + let (mut s, _) = fixture(8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.past_len(); + let redo = s.active().unwrap().history.redo_label().map(str::to_owned); + assert!(s.execute("edit.fill", json!({"contents": "contentAware", "rotationAdaptation": value})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.active().unwrap().history.redo_label(), redo.as_deref()); + } +} diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs new file mode 100644 index 0000000000..cf1f91b6de --- /dev/null +++ b/crates/engine/tests/convert_profile_atn.rs @@ -0,0 +1,287 @@ +//! Recorded-shaped Convert to Profile flags survive ATN load, save and native playback. + +use std::sync::Arc; + +use photocraft_cms::Builtin; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_geom::{Affine, Size}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const FLAGS: [(&str, &str); 3] = [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")]; + +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("T ", D::Text(UnicodeString::new_nul("sRGB IEC61966-2.1"))) + .with("Inte", D::Enumerated { type_id: Id::new("Inte"), value: Id::new("Clrm") }) + .with("MpBl", D::Boolean(false)) +} + +fn recorded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Profile"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Convert"), + items: vec![AtnItem { + event: AtnEvent::name("convertToProfile"), + dictionary: b"Convert to Profile".to_vec(), + descriptor: Some(d), + ..Default::default() + }], + ..Default::default() + }], + expanded: false, + warnings: vec![], + encoding: None, + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "Profile"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +/// Preserve absolute intent and keep unrepresentable intent settings as complete steps. +#[test] +fn absolute_and_unknown_intents_remain_distinct_with_conversion_flags() { + for code in ["AClr", "AbsC", "futureIntent"] { + let mut d = descriptor(); + d.items.retain(|(key, _)| !key.is("Inte")); + d.items.push((Id::new("Inte"), D::Enumerated { type_id: Id::new("Inte"), value: Id::new(code) })); + let mut s = intent_fixture(); + let result = load(&mut s, &recorded(d.clone())); + let saved = atn::parse(&save(&mut s)).unwrap(); + let saved = saved.actions[0].items[0].descriptor.as_ref().unwrap(); + if code == "futureIntent" { + assert_eq!(result["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved, &d); + } else { + assert_eq!(result["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1["intent"], json!("absolute")); + assert_eq!(saved.get("Inte"), Some(&D::Enumerated { type_id: Id::new("Inte"), value: Id::new("AClr") })); + let mut absolute = intent_fixture(); + absolute.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "absolute", "bpc": false})).unwrap(); + let mut relative = intent_fixture(); + relative.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + assert_ne!(rendered(&absolute), rendered(&relative), "the profile pair must distinguish rendering intents"); + let played = s.execute("actions.play", json!({"action": "Convert"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(rendered(&s), rendered(&absolute)); + // Half media white scales linear 128/255 to 64/255, about 137/255 in sRGB. + // Check the cached pixel before opacity so a shared intent swap cannot pass. + let pixel = s.active().unwrap().doc.layers[1].surface().unwrap().default_pixel(); + assert!(pixel[..3].iter().all(|value| (value - 137.0 / 255.0).abs() <= 1.0 / 255.0), "{pixel:?}"); + } + } +} + +/// A cached placed layer over black separates flatten order, rasterization and U8 dither. +fn fixture() -> Session { + let mut doc = Document::new("Linear source", Size::new(256, 256), ColorMode::Rgb, SampleType::U8); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + doc.layers.push(Layer::new("Black", LayerContent::Raster(Surface::with_default(PixelFormat::RGBA8, &[0.0, 0.0, 0.0, 1.0])))); + let cache = Surface::with_default(PixelFormat::RGBA8, &[128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + let source = SmartSource::Embedded { file_name: "cached.svg".into(), bytes: Arc::new(b"cached appearance must be used".to_vec()) }; + let mut layer = Layer::new("Half placed", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, Some(cache)))); + layer.opacity = 0.5; + doc.layers.push(layer); + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +/// A darker media white makes absolute and relative conversion produce different pixels. +fn intent_fixture() -> Session { + let mut s = fixture(); + let mut profile = Builtin::LinearSrgb.profile().clone(); + profile.white_point = profile.white_point.map(|value| value * 0.5); + Arc::make_mut(&mut s.active_mut().unwrap().doc).icc_profile = Some(profile.with_encoded_bytes().to_bytes()); + s +} + +fn rendered(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() +} + +/// Assert saved typed fields and independent pixel/model effects, not just paired adapters. +#[test] +fn recorded_rgb_flags_survive_save_reimport_and_public_playback() { + for values in [[true, true, true], [true, false, true], [true, false, false], [false, false, false]] { + let mut d = descriptor().with("sdwM", D::Integer(-1)); + let mut params = json!({"profile": "srgb", "intent": "relative", "bpc": false}); + for ((key, param), value) in FLAGS.into_iter().zip(values) { + d = d.with(key, D::Boolean(value)); + params[param] = json!(value); + } + let mut direct = fixture(); + direct.execute("edit.convertToProfile", params.clone()).unwrap(); + let expected = direct.active().unwrap().doc.clone(); + if values[0] { + let mut control = fixture(); + let mut p = params.clone(); + p["dither"] = json!(false); + control.execute("edit.convertToProfile", p).unwrap(); + assert_ne!(rendered(&direct), rendered(&control), "ignored dither cannot pass"); + } + if values[1] { + let mut wrong_order = fixture(); + let mut p = params.clone(); + p["flatten"] = json!(false); + wrong_order.execute("edit.convertToProfile", p).unwrap(); + wrong_order.execute("layer.flattenImage", json!({})).unwrap(); + assert!((rendered(&direct)[0][0] - rendered(&wrong_order)[0][0]).abs() > 0.1, "source-first order fixture"); + } + let mut replay = fixture(); + assert_eq!(load(&mut replay, &recorded(d))["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps, vec![("edit.convertToProfile".into(), params)]); + let saved = save(&mut replay); + let parsed = atn::parse(&saved).unwrap(); + let saved_d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(saved_d.get("sdwM"), Some(&D::Integer(-1))); + for ((key, _), value) in FLAGS.into_iter().zip(values) { + assert_eq!(saved_d.get(key), Some(&D::Boolean(value))); + } + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, replay.actions.list[0].steps); + replay.actions.list = imported.actions; + let played = replay.execute("actions.play", json!({"action": "Convert"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(rendered(&replay), rendered(&direct)); + let actual = &replay.active().unwrap().doc; + assert_eq!(actual.icc_profile, expected.icc_profile); + assert_eq!(actual.layers.len(), if values[1] { 1 } else { 2 }); + assert_eq!(actual.layers.iter().filter(|l| matches!(l.content, LayerContent::Smart(_))).count(), usize::from(!values[1] && !values[2])); + for (actual, expected) in actual.layers.iter().zip(&expected.layers) { + assert_eq!(actual.surface(), expected.surface()); + assert_eq!(actual.opacity, expected.opacity); + } + } +} + +/// Legacy omission and native nulls have the existing false defaults, serialized explicitly. +#[test] +fn defaults_are_explicit_false_and_assign_profile_is_unchanged() { + let mut s = fixture(); + assert_eq!(load(&mut s, &recorded(descriptor()))["unsupported"], json!([])); + let params = &s.actions.list[0].steps[0].1; + for (_, param) in FLAGS { + assert_eq!(params[param], false); + } + let saved = save(&mut s); + s.actions.list = atn_cmds::import(&saved).unwrap().actions; + let mut control = fixture(); + control.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Convert"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(rendered(&s), rendered(&control)); + assert!(matches!(s.active().unwrap().doc.layers[1].content, LayerContent::Smart(_))); + for nulls in [false, true] { + let mut params = json!({"profile": "srgb"}); + if nulls { + for (_, param) in FLAGS { + params[param] = Value::Null; + } + } + let action = Action::new("Defaults", vec![("edit.convertToProfile".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Profile", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&bytes).unwrap(); + let d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + for (key, _) in FLAGS { + assert_eq!(d.get(key), Some(&D::Boolean(false))); + } + assert_eq!(d.get("sdwM"), Some(&D::Integer(-1))); + } + let assign = Action::new("Assign", vec![("edit.assignProfile".into(), json!({"profile": "srgb"}))]); + let (bytes, skipped) = atn_cmds::export("Profile", &[&assign], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn_cmds::import(&bytes).unwrap().actions[0].steps, assign.steps); + let parsed = atn::parse(&bytes).unwrap(); + let d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + for key in ["sdwM", "Dthr", "Fltt", "rasterizePlaced"] { + assert!(d.get(key).is_none()); + } +} + +/// Unrepresented radio values and malformed booleans remain complete kept Photoshop steps. +#[test] +fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() { + let mut descriptors = Vec::new(); + for value in [D::Integer(3), D::Integer(4), D::Integer(2), D::Integer(999), D::Boolean(true), D::Double(2.0)] { + descriptors.push(descriptor().with("sdwM", value)); + } + for (key, _) in FLAGS { + for value in [D::Integer(1), D::Text(UnicodeString::new_nul("true"))] { + descriptors.push(descriptor().with("sdwM", D::Integer(-1)).with(key, value)); + } + } + for d in descriptors { + let bytes = recorded(d); + let original = atn::parse(&bytes).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &bytes)["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + for key in ["dither", "flatten", "rasterizePlaced", "sdwM"] { + for value in [json!(2), json!("false"), json!([]), json!({})] { + let mut params = json!({"profile": "srgb"}); + params[key] = value; + let action = Action::new("Invalid", vec![("edit.convertToProfile".into(), params)]); + assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); + } + } + let action = Action::new("Invalid radio null", vec![("edit.convertToProfile".into(), json!({"profile": "srgb", "sdwM": null}))]); + assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); +} + +/// A step recorded by the reference application (`sdwM` -1, dither on) maps and saves back as recorded. +#[test] +fn reference_recorded_convert_to_profile_maps() { + let d = Descriptor::new("null") + .with( + "null", + D::Reference(vec![photocraft_psd::descriptor::ReferenceItem::Enumerated { + class: photocraft_psd::descriptor::Class { name: UnicodeString::new_nul(""), class_id: Id::new("Dcmn") }, + type_id: Id::new("Ordn"), + value: Id::new("Trgt"), + }]), + ) + .with("T ", D::Text(UnicodeString::new_nul("ProPhoto RGB"))) + .with("Inte", D::Enumerated { type_id: Id::new("Inte"), value: Id::new("AClr") }) + .with("Dthr", D::Boolean(true)) + .with("Fltt", D::Boolean(false)) + .with("rasterizePlaced", D::Boolean(false)) + .with("sdwM", D::Integer(-1)); + let mut s = fixture(); + assert_eq!(load(&mut s, &recorded(d))["unsupported"], json!([])); + assert_eq!( + s.actions.list[0].steps, + vec![( + "edit.convertToProfile".into(), + json!({"profile": "prophoto-compat", "intent": "absolute", "dither": true, "flatten": false, "rasterizePlaced": false}) + )] + ); + let saved = atn::parse(&save(&mut s)).unwrap(); + let saved = saved.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(saved.get("sdwM"), Some(&D::Integer(-1))); + assert_eq!(saved.get("Dthr"), Some(&D::Boolean(true))); +} diff --git a/crates/engine/tests/convert_profile_dither.rs b/crates/engine/tests/convert_profile_dither.rs new file mode 100644 index 0000000000..a8c92e2e5b --- /dev/null +++ b/crates/engine/tests/convert_profile_dither.rs @@ -0,0 +1,229 @@ +//! Opt-in Profile dithering preserves representation, alpha, wide paths and atomic refusal. + +use photocraft_cms::{Builtin, Intent, Transform}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_engine::Session; +use photocraft_geom::{Rect, Size, TileCoord}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const GRAY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 83.0 / 255.0]; + +fn document(size: Size, surface: Surface, depth: SampleType) -> Document { + let mut doc = Document::new("Dither", size, ColorMode::Rgb, depth); + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); + doc.layers.push(Layer::new("Pixels", LayerContent::Raster(surface))); + doc +} + +fn session(doc: Document) -> Session { + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +fn params(dither: Value) -> Value { + json!({"profile": "linear-srgb", "intent": "relative", "bpc": false, "dither": dither}) +} + +fn converted(doc: Document, p: Value) -> Document { + let mut s = session(doc); + s.execute("edit.convertToProfile", p).unwrap(); + (*s.active().unwrap().doc).clone() +} + +fn pixels(doc: &Document) -> &Surface { + doc.layers[0].surface().unwrap() +} + +/// A fractional constant distinguishes prequantization noise from ignored or post-byte noise. +#[test] +fn dither_rounds_fractional_colors_without_bias_or_alpha_noise() { + let transform = Transform::new(Builtin::Srgb.profile(), Builtin::LinearSrgb.profile(), Intent::RelativeColorimetric, false).unwrap(); + let mut fractional = [0.0; 3]; + transform.eval_fast(&GRAY[..3], &mut fractional); + assert!((fractional[0] * 255.0 - 55.04443).abs() < 0.02, "the CMS fixture must have a fractional destination value"); + let before = document(Size::new(256, 256), Surface::with_default(PixelFormat::RGBA8, &GRAY), SampleType::U8); + let ordinary = converted(before.clone(), params(json!(false))); + assert_eq!((pixels(&ordinary).pixel(0, 0)[0] * 255.0).round() as u8, 55); + let mut s = session(before.clone()); + s.execute("edit.convertToProfile", params(json!(true))).unwrap(); + let after = s.active().unwrap().doc.clone(); + assert!(pixels(&after).read_region(after.bounds()) != pixels(&ordinary).read_region(ordinary.bounds()), "dither must change fractional U8 colors"); + let tile = pixels(&after).tile(TileCoord::new(0, 0)).unwrap(); + let mut total = 0u64; + let mut raised = 0; + for px in tile.bytes().as_chunks::<4>().0 { + assert!(px[0] == 55 || px[0] == 56, "dither quantizes neighboring destination values"); + assert_eq!(px[0], px[1]); + assert_eq!(px[0], px[2]); + assert_eq!(px[3], 83, "alpha is copied byte-for-byte"); + total += u64::from(px[0]); + raised += usize::from(px[0] == 56); + } + assert!(raised > 0 && raised < 65536, "ignored dithering cannot pass this fixture"); + assert!((total as f64 / 65536.0 - 55.04443).abs() < 0.02); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active().unwrap().doc, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); +} + +/// Missing and redundant tiles must encode the same finite image, including outside padding. +#[test] +fn implicit_and_materialized_defaults_dither_identically() { + let implicit = Surface::with_default(PixelFormat::RGBA8, &GRAY); + let mut stored = implicit.clone(); + let tile = stored.solid_tile(&GRAY); + stored.put_tiles([(TileCoord::new(0, 0), tile.clone()), (TileCoord::new(-2, 1), tile.clone()), (TileCoord::new(1_000_000, -1_000_000), tile)]); + let size = Size::new(257, 259); + let a = converted(document(size, implicit, SampleType::U8), params(json!(true))); + let b = converted(document(size, stored, SampleType::U8), params(json!(true))); + assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); + assert_eq!(pixels(&a).default_bytes(), pixels(&b).default_bytes()); + for (x, y) in [(-512, 256), (256_000_000, -256_000_000), (300, 300), (256, 259)] { + assert_eq!(pixels(&a).pixel(x, y), pixels(&b).pixel(x, y)); + assert_eq!(pixels(&b).pixel(x, y), pixels(&b).default_pixel()); + } + assert!(pixels(&b).tile_count() <= 6, "no bounding box between distant tiles"); +} + +/// Shared source tiles need independent absolute-coordinate patterns; sparse content survives. +#[test] +fn shared_tiles_and_distant_nondefault_pixels_use_actual_coordinates() { + let mut source = Surface::new(PixelFormat::RGBA8); + let tile = source.solid_tile(&GRAY); + source.put_tiles([(TileCoord::new(0, 0), tile.clone()), (TileCoord::new(1, 0), tile)]); + for (x, y) in [(-512, 0), (256_000_003, 256_000_007)] { + source.write_region(Rect::from_xywh(x, y, 1, 1), &GRAY); + } + let mut independent = source.clone(); + independent.put_tiles([(TileCoord::new(1, 0), independent.solid_tile(&GRAY))]); + let size = Size::new(512, 256); + let a = converted(document(size, source, SampleType::U8), params(json!(true))); + let b = converted(document(size, independent, SampleType::U8), params(json!(true))); + assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); + assert_ne!(pixels(&a).tile(TileCoord::new(0, 0)).unwrap().bytes().to_vec(), pixels(&a).tile(TileCoord::new(1, 0)).unwrap().bytes().to_vec()); + for (x, y) in [(-512, 0), (256_000_003, 256_000_007)] { + let px = pixels(&a).pixel(x, y); + assert!([55.0, 56.0].contains(&(px[0] * 255.0).round())); + assert_eq!((px[3] * 255.0).round() as u8, 83); + assert_eq!(pixels(&a).pixel(x + 1, y), pixels(&a).default_pixel()); + } + assert_eq!(pixels(&a).tile_count(), 4); +} + +/// Defaults, wide documents (including U8 caches), same-profile and mismatched surfaces stay old. +#[test] +fn old_paths_and_integer_endpoints_remain_unchanged() { + let base = document(Size::new(8, 6), Surface::with_default(PixelFormat::RGBA8, &GRAY), SampleType::U8); + let ordinary = converted(base.clone(), params(json!(false))); + for p in [json!({"profile": "linear-srgb", "intent": "relative", "bpc": false}), params(Value::Null)] { + assert_eq!(converted(base.clone(), p), ordinary); + } + for depth in [SampleType::U16, SampleType::F32] { + for sample in [depth, SampleType::U8] { + let format = PixelFormat::new(ColorMode::Rgb, sample, true); + let doc = document(Size::new(8, 6), Surface::with_default(format, &GRAY), depth); + assert_eq!(converted(doc.clone(), params(json!(true))), converted(doc, params(json!(false)))); + } + } + for target in ["srgb", "linear-srgb"] { + let mut doc = base.clone(); + if target != "srgb" { + doc.layers[0].content = LayerContent::Raster(Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, true), &[0.5, 0.3])); + } + let mut p = params(json!(false)); + p["profile"] = json!(target); + let mut q = p.clone(); + q["dither"] = json!(true); + assert_eq!(converted(doc.clone(), p), converted(doc, q)); + } + let mut endpoints = Surface::new(PixelFormat::RGBA8); + endpoints.write_region(Rect::from_xywh(0, 0, 2, 1), &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); + let doc = document(Size::new(2, 1), endpoints, SampleType::U8); + let a = converted(doc.clone(), params(json!(true))); + let b = converted(doc, params(json!(false))); + assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); +} + +/// The alpha index follows channel counts when converting to and from four-color CMYK. +#[test] +fn cmyk_channels_do_not_receive_alpha_or_drop_it() { + for from_cmyk in [false, true] { + let mut doc = document(Size::new(16, 16), Surface::with_default(PixelFormat::RGBA8, &GRAY), SampleType::U8); + let mut p = params(json!(true)); + if from_cmyk { + doc.mode = ColorMode::Cmyk; + doc.icc_profile = Some(Builtin::CoatedCmyk.profile().to_bytes()); + doc.layers[0].content = + LayerContent::Raster(Surface::with_default(PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true), &[0.2, 0.4, 0.6, 0.1, GRAY[3]])); + } else { + p["profile"] = json!("coated-cmyk"); + } + let after = converted(doc, p); + let surface = pixels(&after); + for px in surface.tile(TileCoord::new(0, 0)).unwrap().bytes().chunks_exact(surface.channels()).take(16) { + assert_eq!(*px.last().unwrap(), 83); + } + assert_eq!(surface.channels(), if from_cmyk { 4 } else { 5 }); + } +} + +/// Sparse oversize, aggregate allocation, hostile coordinates and wrong flags preserve history. +#[test] +fn refusals_are_early_and_atomic() { + let surface = Surface::with_default(PixelFormat::RGBA8, &GRAY); + let mut aggregate = document(Size::new(32768, 16384), surface.clone(), SampleType::U8); + for _ in 0..4 { + aggregate.layers.push(Layer::new("Shared input", LayerContent::Raster(surface.clone()))); + } + let mut hostile = surface.clone(); + hostile.put_tiles([(TileCoord::new(i32::MAX, 0), hostile.solid_tile(&GRAY))]); + let mut cases = vec![ + (document(Size::new(1 << 20, 1 << 20), surface.clone(), SampleType::U8), json!(true)), + (aggregate, json!(true)), + (document(Size::new(1, 1), hostile, SampleType::U8), json!(true)), + ]; + for value in [json!(1), json!("false"), json!([]), json!({})] { + cases.push((document(Size::new(8, 6), surface.clone(), SampleType::U8), value)); + } + for (doc, flag) in cases { + let mut s = session(doc); + s.execute("layer.new.layer", json!({"name": "Redo sentinel"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let state = s.active().unwrap(); + let revision = state.revision; + let selection = state.selected_layers.clone(); + let active = state.active_layer; + let history = (state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)); + assert!(s.execute("edit.convertToProfile", params(flag)).is_err()); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.selected_layers, selection); + assert_eq!(state.active_layer, active); + assert_eq!((state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)), history); + assert!(state.history.can_redo()); + } +} + +/// Absent tiles of a transparent layer stay absent: only the stored tile is dithered. +#[test] +fn transparent_layers_stay_sparse() { + let p = json!({"profile": "display-p3", "dither": true}); + let stored = |size: Size| { + let mut surface = Surface::with_default(PixelFormat::RGBA8, &[0.0; 4]); + let tile = surface.solid_tile(&GRAY); + surface.put_tiles([(TileCoord::new(1, 1), tile)]); + document(size, surface, SampleType::U8) + }; + let large = converted(stored(Size::new(1024, 1024)), p.clone()); + assert_eq!(pixels(&large).tile_count(), 1, "a transparent layer must not become dense"); + let small = converted(stored(Size::new(512, 512)), p); + let tile = |doc: &Document| pixels(doc).tile(TileCoord::new(1, 1)).unwrap().bytes().to_vec(); + assert_eq!(tile(&large), tile(&small), "the stored tile is dithered as before"); + assert_eq!(pixels(&large).pixel(700, 700)[3], 0.0); +} diff --git a/crates/engine/tests/convert_profile_options.rs b/crates/engine/tests/convert_profile_options.rs new file mode 100644 index 0000000000..a64d9c88f3 --- /dev/null +++ b/crates/engine/tests/convert_profile_options.rs @@ -0,0 +1,327 @@ +//! Native Convert to Profile can flatten source-space appearance or freeze placed pixels. + +use std::sync::Arc; + +use photocraft_cms::Builtin; +use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{ + Document, Effect, Effects, FxCommon, FxPaint, Layer, LayerContent, LayerId, LayerMask, Path, SmartFilter, SmartObject, SmartSource, StrokeFx, + StrokePosition, Subpath, VectorMask, +}; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_geom::{Affine, Rect, Size}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const SVG: &[u8] = br##""##; + +fn convert_params() -> Value { + json!({"profile": "linear-srgb", "intent": "relative", "bpc": false}) +} + +/// Half-opacity white over black separates source-space blending from linear-space blending. +fn layered(depth: u32, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth, "mode": mode, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({"name": "Half white"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("edit.fill", json!({"contents": "white"})).unwrap(); + s.edit("Layer opacity", |doc, _| { + doc.layer_mut(id).unwrap().opacity = 0.5; + Ok(()) + }) + .unwrap(); + let profile = match mode { + "cmyk" => "coated-cmyk", + "lab" => "lab-d50", + _ => "srgb", + }; + s.execute("edit.assignProfile", json!({"profile": profile})).unwrap(); + s +} + +fn from_document(doc: Document, active: Option) -> Session { + let mut s = Session::new(); + s.add_document(doc, None); + if let Some(id) = active { + s.select_layer(id).unwrap(); + } + s +} + +fn composed(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() +} + +/// The public source-first control must differ from conversion before flattening. +#[test] +fn flatten_uses_source_space_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = layered(depth, "rgb"); + let before = s.active().unwrap().doc.clone(); + let active = s.active().unwrap().active_layer; + let past = s.active().unwrap().history.past_len(); + let mut source_first = from_document((*before).clone(), active); + source_first.execute("layer.flattenImage", json!({})).unwrap(); + source_first.execute("edit.convertToProfile", convert_params()).unwrap(); + let expected = composed(&source_first); + let mut destination_first = from_document((*before).clone(), active); + destination_first.execute("edit.convertToProfile", convert_params()).unwrap(); + destination_first.execute("layer.flattenImage", json!({})).unwrap(); + assert!((expected[0][0] - composed(&destination_first)[0][0]).abs() > 0.1, "order fixture at {depth}-bit"); + + let mut params = convert_params(); + params["flatten"] = json!(true); + s.execute("edit.convertToProfile", params).unwrap(); + assert_eq!(composed(&s), expected, "flatten must precede profile conversion at {depth}-bit"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), 1); + assert!(matches!(after.layers[0].content, LayerContent::Raster(_))); + assert!(after.layers[0].locks.position && after.layers[0].locks.transparency); + assert_eq!(s.active().unwrap().active_layer, Some(after.layers[0].id)); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + assert_eq!(after.icc_profile, source_first.active().unwrap().doc.icc_profile); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().active_layer, active); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); + } +} + +/// Native CMYK and Lab flattening retain the existing compositor and conversion path. +#[test] +fn flatten_keeps_native_source_color_models() { + for mode in ["cmyk", "lab"] { + let mut s = layered(8, mode); + let before = s.active().unwrap().doc.clone(); + let mut control = from_document((*before).clone(), s.active().unwrap().active_layer); + control.execute("layer.flattenImage", json!({})).unwrap(); + control.execute("edit.convertToProfile", convert_params()).unwrap(); + let mut params = convert_params(); + params["flatten"] = json!(true); + s.execute("edit.convertToProfile", params).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + assert_eq!(composed(&s), composed(&control), "source model {mode}"); + } +} + +/// Omitted, false and null options retain the old per-layer conversion behavior. +#[test] +fn default_options_preserve_editable_layers_and_reject_wrong_types() { + let s = layered(8, "rgb"); + let before = s.active().unwrap().doc.clone(); + let active = s.active().unwrap().active_layer; + let mut control = from_document((*before).clone(), active); + control.execute("edit.convertToProfile", convert_params()).unwrap(); + for value in [None, Some(json!(false)), Some(Value::Null)] { + let mut actual = from_document((*before).clone(), active); + let mut params = convert_params(); + if let Some(value) = value { + params["flatten"] = value.clone(); + params["rasterizePlaced"] = value; + } + actual.execute("edit.convertToProfile", params).unwrap(); + assert_eq!(actual.active().unwrap().doc, control.active().unwrap().doc); + assert_eq!(actual.active().unwrap().active_layer, active); + } + for key in ["flatten", "rasterizePlaced"] { + for value in [json!(1), json!("false"), json!([]), json!({})] { + let mut actual = from_document((*before).clone(), active); + let mut params = convert_params(); + params[key] = value; + assert_rejected(&mut actual, params); + } + } +} + +/// Offset pixels with distinct alpha expose a second placement, filter or mask application. +fn shown_pixels() -> Surface { + let mut surface = Surface::new(PixelFormat::RGBA32F); + surface.fill_rect(Rect::new(7, 5, 12, 9), &[0.0, 0.0, 1.0, 0.75]); + surface.fill_rect(Rect::new(12, 7, 15, 11), &[0.0, 1.0, 1.0, 0.5]); + surface +} + +fn embedded() -> SmartSource { + SmartSource::Embedded { file_name: "profile.svg".into(), bytes: Arc::new(SVG.to_vec()) } +} + +/// Keep source, placement and filter data deliberately different from the displayed cache. +fn placed(source: SmartSource, cache: Option, unsupported_filter: bool) -> Layer { + let mut smart = SmartObject::new(source, Affine { m: [2.0, 0.0, 0.0, 2.0, 7.0, 5.0] }, cache); + smart.smart_filters.push(SmartFilter { + command: if unsupported_filter { "psd.unsupportedFilter" } else { "filter.other.offset" }.into(), + params: if unsupported_filter { json!({"name": "Cache sentinel"}) } else { json!({"horizontal": 3, "vertical": 1}) }, + blend: BlendMode::Normal, + opacity: 0.7, + visible: true, + }); + let mut filter_mask = LayerMask::reveal_all(); + filter_mask.surface.fill_rect(Rect::new(7, 5, 10, 11), &[0.25]); + smart.filter_mask = Some(filter_mask); + smart.psd_raw = Some(Arc::new(vec![1, 2, 3, 4])); + let mut layer = Layer::new("Placed", LayerContent::Smart(smart)); + layer.opacity = 0.6; + layer.fill_opacity = 0.8; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(7, 5, 10, 11), &[0.5]); + mask.density = 0.75; + layer.mask = Some(mask); + layer.vector_mask = + Some(VectorMask::new(Path { subpaths: vec![Subpath::polygon(&[(8.0, 4.0), (14.0, 4.0), (14.0, 12.0), (8.0, 12.0)])], ..Default::default() })); + layer.effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 1.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), + overprint: false, + })], + ..Default::default() + }; + for key in [*b"SoLd", *b"SoLE", *b"PlLd", *b"zzzz"] { + layer.psd_blocks.push((key, Arc::new(vec![9, 8, 7]))); + } + layer +} + +/// Nest placed objects under two groups with an ordinary active sibling. +fn smart_scene(children: Vec) -> Session { + let mut doc = Document::new("Placed profile", Size::new(24, 18), ColorMode::Rgb, SampleType::F32); + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); + let ordinary = Layer::raster("Ordinary", PixelFormat::RGBA32F); + let active = ordinary.id; + doc.layers = vec![Layer::group("Outer", vec![Layer::group("Inner", children)]), ordinary]; + from_document(doc, Some(active)) +} + +fn missing_source() -> SmartSource { + SmartSource::Linked { path: "missing-profile-smart-source-7ecb9a.svg".into() } +} + +/// Convert manually substituted cached pixels through the unchanged command path. +fn cached_control(before: &Document, ids: &[LayerId], active: Option) -> Session { + let mut expected = before.clone(); + let format = expected.pixel_format(); + for id in ids { + let LayerContent::Smart(smart) = &expected.layer(*id).unwrap().content else { panic!("smart object missing") }; + let cache = smart.cache.clone().unwrap(); + let layer = expected.layer_mut(*id).unwrap(); + layer.content = LayerContent::Raster(if cache.format() == format { cache } else { cache.convert(format) }); + layer.psd_blocks.retain(|(key, _)| key != b"SoLd" && key != b"SoLE" && key != b"PlLd"); + } + let mut s = from_document(expected, active); + s.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + s +} + +/// Rasterization freezes nested and unselected caches without rebaking masks or transforms. +#[test] +fn rasterize_placed_freezes_displayed_caches_and_restores_models_on_undo() { + let first = placed(missing_source(), Some(shown_pixels()), true); + let mut second = placed(embedded(), Some(shown_pixels()), false); + second.visible = false; + let ids = [first.id, second.id]; + let mut s = smart_scene(vec![first, second]); + s.execute("shape.create", json!({"rect": [0, 0, 3, 3], "fill": "#336699"})).unwrap(); + let active = s.active().unwrap().doc.layers.last().unwrap().id; + s.select_layer(active).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let selection = s.active().unwrap().selected_layers.clone(); + let control = cached_control(&before, &ids, Some(active)); + s.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false, "rasterizePlaced": true})).unwrap(); + let after = s.active().unwrap().doc.clone(); + for id in ids { + assert!(matches!(after.layer(id).unwrap().content, LayerContent::Raster(_)), "rasterizePlaced must replace every placed model"); + assert_eq!(after.layer(id), control.active().unwrap().doc.layer(id)); + assert_eq!(after.layer(id).unwrap().surface().unwrap().rgba(9, 6)[3], 0.75); + assert_eq!(after.layer(id).unwrap().surface().unwrap().rgba(13, 8)[3], 0.5); + } + assert!(after.walk().iter().any(|(_, _, layer)| matches!(layer.content, LayerContent::Shape(_)))); + assert_eq!(composed(&s), composed(&control)); + assert_eq!(s.active().unwrap().active_layer, Some(active)); + assert_eq!(s.active().unwrap().selected_layers, selection); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); +} + +/// A refusal must preserve the document, selection, revision and undo/redo state. +fn assert_rejected(s: &mut Session, params: Value) { + let state = s.active().unwrap(); + let before = state.doc.clone(); + let revision = state.revision; + let active = state.active_layer; + let selection = state.selected_layers.clone(); + let history = + (state.history.past_len(), state.history.can_redo(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)); + assert!(s.execute("edit.convertToProfile", params).is_err()); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.active_layer, active); + assert_eq!(state.selected_layers, selection); + assert_eq!( + (state.history.past_len(), state.history.can_redo(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)), + history + ); +} + +/// Missing or unrenderable visible pixels cannot become a successful blank conversion. +#[test] +fn unavailable_placed_pixels_and_oversize_flattening_fail_atomically() { + for unavailable in [placed(missing_source(), None, false), placed(embedded(), None, true)] { + for (flatten, rasterize) in [(false, true), (true, false), (true, true)] { + let mut s = smart_scene(vec![placed(embedded(), Some(shown_pixels()), false), unavailable.clone()]); + s.execute("layer.new.layer", json!({"name": "Redo sentinel"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().history.can_redo()); + assert_rejected(&mut s, json!({"profile": "display-p3", "flatten": flatten, "rasterizePlaced": rasterize})); + } + } + let mut large = Session::new(); + large.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap(); + assert_rejected(&mut large, json!({"profile": "srgb", "flatten": true})); +} + +/// A missing cache can render from an available source; hidden discarded content needs no read. +#[test] +fn available_uncached_sources_render_and_flatten_skips_hidden_sources() { + let mut layer = Layer::new("Available", LayerContent::Smart(SmartObject::new(embedded(), Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, None))); + let id = layer.id; + let original = smart_scene(vec![layer.clone()]); + let mut rendered = (*original.active().unwrap().doc).clone(); + assert!(smart_cmds::refresh_layer(&rendered, &mut layer).unwrap()); + let LayerContent::Smart(smart) = &layer.content else { panic!("smart object missing") }; + assert!(smart.cache.as_ref().unwrap().rgba(4, 5)[3] > 0.0); + rendered.layer_mut(id).unwrap().content = layer.content; + for flatten in [false, true] { + let active = original.active().unwrap().active_layer; + let mut s = from_document((*original.active().unwrap().doc).clone(), active); + let mut control = if flatten { from_document(rendered.clone(), active) } else { cached_control(&rendered, &[id], active) }; + if flatten { + control.execute("layer.flattenImage", json!({})).unwrap(); + control.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + } + s.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false, "flatten": flatten, "rasterizePlaced": !flatten})) + .unwrap(); + assert_eq!(composed(&s), composed(&control)); + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| !matches!(layer.content, LayerContent::Smart(_)))); + } + for (flatten, rasterize) in [(true, false), (true, true), (false, true)] { + let mut hidden = placed(missing_source(), None, false); + hidden.visible = false; + let mut s = smart_scene(vec![hidden]); + if flatten { + s.execute("edit.convertToProfile", json!({"profile": "srgb", "flatten": true, "rasterizePlaced": rasterize})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + } else { + assert_rejected(&mut s, json!({"profile": "srgb", "rasterizePlaced": true})); + } + } +} diff --git a/crates/engine/tests/image_size_scale_styles.rs b/crates/engine/tests/image_size_scale_styles.rs new file mode 100644 index 0000000000..4ae6cbabd2 --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles.rs @@ -0,0 +1,124 @@ +//! Image Size can preserve layer-style sizes while resampling the document's geometry. + +use std::sync::Arc; + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, Layer, LayerContent, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::Session; +use serde_json::json; + +fn styled_shape(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"rect": [20, 10, 40, 20], "fill": "#ff0000", "stroke": {"width": 4, "color": "#000000"}})).unwrap()["layer"] + .as_u64() + .unwrap(); + let id = LayerId(id); + s.edit("Styles", |doc, _| { + let effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + psd_raw: Some(Arc::new(b"preserved layer style".to_vec())), + ..Default::default() + }; + doc.layer_mut(id).unwrap().effects = effects.clone(); + let children = std::mem::take(&mut doc.layers); + let mut outer = Layer::group("Outer", vec![Layer::group("Inner", children)]); + outer.effects = effects; + doc.layers.push(outer); + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn shape_stroke(s: &Session, id: LayerId) -> f32 { + let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("shape lost") }; + shape.stroke.as_ref().unwrap().width +} + +/// Native omission keeps the old default; false preserves style sizes and raw PSD effects. +#[test] +fn image_size_scale_styles_is_optional_at_every_depth() { + for depth in [8, 16, 32] { + for option in [None, Some(true), Some(false)] { + let (mut s, id) = styled_shape(depth); + let before = s.active().unwrap().doc.clone(); + let group = before.layers[0].id; + let past = s.active().unwrap().history.past_len(); + let mut params = json!({"percent": 50, "resample": "nearest"}); + if let Some(value) = option { + params["scaleStyles"] = json!(value); + } + s.execute("image.imageSize", params).unwrap(); + let after = s.active().unwrap().doc.clone(); + assert_eq!((after.size.width, after.size.height), (40, 20)); + assert_eq!(shape_stroke(&s, id), 2.0, "{depth}/{option:?}: geometric strokes always scale"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + for layer in [group, id] { + let effects = &after.layer(layer).unwrap().effects; + let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; + assert_eq!(stroke.size, if option == Some(false) { 20.0 } else { 10.0 }, "{depth}/{option:?}: layer style stroke"); + if option == Some(false) { + assert_eq!(effects, &before.layer(layer).unwrap().effects, "{depth}: preserve every style setting and raw block"); + } else { + assert!(effects.psd_raw.is_none(), "scaled effects invalidate their old PSD block"); + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, before.size); + assert_eq!(shape_stroke(&s, id), 4.0); + for layer in [group, id] { + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); + } + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, after.size); + assert_eq!(shape_stroke(&s, id), 2.0); + for layer in [group, id] { + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, after.layer(layer).unwrap().effects); + } + } + } +} + +/// Resolution-only and unchanged-size commands leave styles intact even when scaling is enabled. +#[test] +fn image_size_without_pixel_resampling_preserves_styles() { + for mut params in [json!({"percent": 50, "resolution": 144, "resample": "none"}), json!({"percent": 100, "resample": "nearest"})] { + for option in [false, true] { + let (mut s, id) = styled_shape(8); + let before = s.active().unwrap().doc.clone(); + params["scaleStyles"] = json!(option); + s.execute("image.imageSize", params.clone()).unwrap(); + let after = &s.active().unwrap().doc; + assert_eq!(after.size, before.size); + assert_eq!(shape_stroke(&s, id), 4.0); + for layer in [before.layers[0].id, id] { + assert_eq!(after.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); + } + if params["resample"] == "none" { + assert_eq!(after.resolution_dpi, 144.0); + } + } + } +} + +/// Crop's shared resampling path retains its existing style-scaling default. +#[test] +fn crop_target_keeps_scaling_styles() { + let (mut s, id) = styled_shape(8); + let group = s.active().unwrap().doc.layers[0].id; + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 80, "height": 40, "targetWidth": 40, "targetHeight": 20, "resample": "nearest"})).unwrap(); + assert_eq!(shape_stroke(&s, id), 2.0); + for layer in [group, id] { + let effects = &s.active().unwrap().doc.layer(layer).unwrap().effects; + let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; + assert_eq!(stroke.size, 10.0); + } +} diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs new file mode 100644 index 0000000000..4f65bcc34e --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -0,0 +1,172 @@ +//! #10694: Image Size style scaling survives ATN import, playback and saving. + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +fn recorded(event: &str, descriptor: Option) -> Vec { + let event = if event == "ImgS" { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name(event) }; + atn::write(&AtnFile { + name: UnicodeString::new_nul("Styles"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Half"), + items: vec![AtnItem { event, dictionary: b"Image Size".to_vec(), descriptor, ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + encoding: None, + }) + .unwrap() +} + +fn half_size() -> Descriptor { + Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "Styles"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +fn fixture() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "background": "transparent"})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({"name": "Styled"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Style", |doc, _| { + doc.layer_mut(id).unwrap().effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn assert_size_and_style(s: &Session, id: LayerId, scale: bool) { + let doc = &s.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height), (40, 20)); + let Effect::Stroke(stroke) = &doc.layer(id).unwrap().effects.items[0] else { panic!("stroke lost") }; + assert_eq!(stroke.size, if scale { 10.0 } else { 20.0 }); +} + +/// Recorded true, false and omitted flags retain their meaning through save and playback. +#[test] +fn imported_style_scaling_survives_saved_bytes_and_playback() { + for event in ["ImgS", "imageSize"] { + for option in [None, Some(false), Some(true)] { + let mut descriptor = half_size(); + if let Some(value) = option { + descriptor = descriptor.with("scaleStyles", D::Boolean(value)); + } + let (mut s, id) = fixture(); + let loaded = load(&mut s, &recorded(event, Some(descriptor))); + assert_eq!(loaded["unsupported"], json!([])); + let scale = option.unwrap_or(false); + assert_eq!(s.actions.list[0].steps, vec![("image.imageSize".into(), json!({"percent": 50, "scaleStyles": scale}))]); + let saved = save(&mut s); + let parsed = atn::parse(&saved).unwrap(); + // Only scaling needs the key: without it the step keeps style sizes. + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), scale.then_some(&D::Boolean(true))); + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action": "Half"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_size_and_style(&s, id, scale); + } + } +} + +/// Saving native calls writes their effective default instead of changing style scaling. +#[test] +fn native_default_is_written_explicitly_and_keeps_its_behavior() { + for option in [None, Some(json!(false)), Some(json!(true)), Some(Value::Null), Some(json!(1)), Some(json!("false"))] { + let mut params = json!({"percent": 50}); + if let Some(option) = option { + params["scaleStyles"] = option; + } + let scale = params["scaleStyles"].as_bool().unwrap_or(true); + let action = Action::new("Half", vec![("image.imageSize".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Styles", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), scale.then_some(&D::Boolean(true)), "{params}"); + let (mut direct, direct_id) = fixture(); + direct.execute("image.imageSize", params).unwrap(); + assert_size_and_style(&direct, direct_id, scale); + let (mut replay, replay_id) = fixture(); + assert_eq!(load(&mut replay, &bytes)["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps[0].1["scaleStyles"], json!(scale)); + let result = replay.execute("actions.play", json!({"action": "Half"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_size_and_style(&replay, replay_id, scale); + } +} + +/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes. +#[test] +fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { + for event in ["ImgS", "imageSize"] { + let descriptors = [ + None, + Some(Descriptor::new("null")), + Some(Descriptor::new("null").with("scaleStyles", D::Boolean(false))), + Some(Descriptor::new("null").with("scaleStyles", D::Boolean(true))), + Some(half_size().with("scaleStyles", D::Integer(0))), + Some(half_size().with("scaleStyles", D::Integer(1))), + Some(half_size().with("scaleStyles", D::Text(UnicodeString::new_nul("false")))), + ]; + for descriptor in descriptors { + let source = recorded(event, descriptor); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + let loaded = load(&mut s, &source); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps.len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + } +} + +/// A recorded Image Size step without `scaleStyles` (Fit Image records none) saves back identically. +#[test] +fn step_without_scale_styles_saves_back_identically() { + for event in ["ImgS", "imageSize"] { + let fit = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 100.0 }) + .with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 78.0 }) + .with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: 72.0 }) + .with("Intr", D::Enumerated { type_id: Id::new("Intp"), value: Id::new("Bcbc") }); + let source = recorded(event, Some(fit)); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &source)["unsupported"], json!([])); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + } +} diff --git a/crates/engine/tests/it/actions_single_history.rs b/crates/engine/tests/it/actions_single_history.rs new file mode 100644 index 0000000000..00c54274e5 --- /dev/null +++ b/crates/engine/tests/it/actions_single_history.rs @@ -0,0 +1,903 @@ +//! Public playback history semantics, independent of the Actions panel implementation. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap(); + s.actions.list.push(Action::new("Two layers", ["First", "Second"].into_iter().map(|name| ("layer.new.layer".into(), json!({"name":name}))).collect())); + s +} + +/// Start with distinct older source pixels and a nondefault History Brush source. +fn history_control_session(single_state: bool) -> Session { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("edit.fill", json!({"color":"#00ff00"})).unwrap(); + let current = s.active().unwrap().history.past_len(); + s.execute("history.setSource", json!({"state":current})).unwrap(); + s +} + +/// An explicit clear stays undoable; later edits forget it and group from its boundary. +#[test] +fn recorded_clear_preserves_its_boundary_and_grouped_suffix() { + for single_state in [false, true] { + for prefix in [false, true] { + for suffix in [false, true] { + let mut s = history_control_session(single_state); + let past = s.active().unwrap().history.past_len(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + steps.push(("history.clear".into(), json!({}))); + if suffix { + steps.extend([("layer.renameLayer".into(), json!({"name":"Suffix one"})), ("layer.renameLayer".into(), json!({"name":"Suffix two"}))]); + } + s.actions.list = vec![Action::new("Clear", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + if suffix { + assert!(!s.active().unwrap().history.is_cleared()); + assert_eq!(s.active().unwrap().history.past_len(), if single_state { 1 } else { 2 }); + assert!(s.undo()); + if !single_state { + assert!(s.undo()); + } + assert!(!s.undo(), "the next edit really forgot the cleared older states"); + } else { + assert!(s.active().unwrap().history.is_cleared()); + assert_eq!(s.active().unwrap().history.past_len(), 0); + assert!(s.undo(), "Undo Clear History restores rows, not older pixels"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after)); + assert_eq!(s.active().unwrap().history.past_len(), past + usize::from(prefix)); + } + } + } + } +} + +/// Both a surviving old row and an intermediate action row remain usable brush sources. +#[test] +fn recorded_source_change_preserves_selected_pixels_and_grouping() { + for single_state in [false, true] { + for prefix in [false, true] { + for current in [false, true] { + let mut s = history_control_session(single_state); + let past = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.clone(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + let selected = if current { past + usize::from(prefix) } else { 1 }; + steps.extend([ + ("history.setSource".into(), json!({"state":selected})), + ("edit.fill".into(), json!({"color":"#00ffff"})), + ("layer.renameLayer".into(), json!({"name":"After source"})), + ]); + s.actions.list = vec![Action::new("Source", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let added = if single_state { 1 } else { 2 + usize::from(prefix) }; + assert_eq!(s.active().unwrap().history.past_len(), past + added); + let after = s.active().unwrap().doc.clone(); + for _ in 0..added { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + for _ in 0..added { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + s.execute("edit.fill", json!({"contents":"history"})).unwrap(); + let pixel = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + let expected = if !current { + [1.0, 0.0, 0.0, 1.0] + } else if prefix { + [0.0, 0.0, 1.0, 1.0] + } else { + [0.0, 1.0, 0.0, 1.0] + }; + assert_eq!(pixel, expected, "singleState={single_state}, prefix={prefix}, current={current}"); + } + } + } +} + +/// Purge Histories is irreversible; suffix edits keep their grouped Undo/Redo boundary. +#[test] +fn recorded_purge_histories_does_not_restore_discarded_states() { + for single_state in [false, true] { + for prefix in [false, true] { + let mut s = history_control_session(single_state); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + steps.extend([ + ("edit.purge.histories".into(), json!({})), + ("layer.renameLayer".into(), json!({"name":"Suffix one"})), + ("layer.renameLayer".into(), json!({"name":"Suffix two"})), + ]); + s.actions.list = vec![Action::new("Purge", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), if single_state { 1 } else { 2 }); + assert!(s.undo()); + if !single_state { + assert!(s.undo()); + } + assert!(!s.undo(), "Purge Histories cannot be undone"); + assert!(s.redo()); + if !single_state { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +/// Dialog identity protection is independent of the optional history grouping. +#[test] +fn per_step_playback_also_binds_its_dialog_continuation() { + use photocraft_engine::actions_cmds; + let mut s = history_control_session(false); + let action = s.actions.list[0].clone(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).expect("per-step playback still needs a document/history binding"); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + s.execute("layer.renameLayer", json!({"name":"Unrelated"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + assert!(continuation.resume(&mut s).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); +} + +/// Selecting another existing source snapshot invalidates a pause even when its pixels match. +#[test] +fn snapshot_source_identity_is_bound_to_the_dialog_continuation() { + use photocraft_engine::actions_cmds; + for single_state in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let a = s.execute("history.newSnapshot", json!({"name":"Source A"})).unwrap()["snapshot"].clone(); + let b = s.execute("history.newSnapshot", json!({"name":"Source B"})).unwrap()["snapshot"].clone(); + s.execute("history.setSource", json!({"snapshot":a})).unwrap(); + let action = s.actions.list[0].clone(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + s.execute("history.setSource", json!({"snapshot":b})).unwrap(); + assert!(continuation.resume(&mut s).is_err(), "singleState={single_state}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), history); + } +} + +/// Undo Clear History inside playback restores rows, rather than recording another clear baseline. +#[test] +fn in_action_undo_clear_restores_history_and_keeps_grouped_image_undo() { + for single_state in [false, true] { + for prefix in [false, true] { + let mut s = history_control_session(single_state); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let rows = s.active().unwrap().history.entries(); + let mut steps = Vec::new(); + if prefix { + steps.push(("layer.renameLayer".into(), json!({"name":"Prefix"}))); + } + steps.extend([ + ("history.clear".into(), json!({})), + ("edit.undo".into(), json!({})), + ("edit.undo".into(), json!({})), + ("edit.redo".into(), json!({})), + ]); + s.actions.list = vec![Action::new("Undo Clear", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(!s.active().unwrap().history.is_cleared(), "singleState={single_state}, prefix={prefix}"); + assert_eq!(s.active().unwrap().history.past_len(), past + usize::from(prefix)); + if prefix { + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } else { + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), rows); + } + } + } +} + +/// A source held by Clear History becomes active on Undo Clear, with no additional image Undo row. +#[test] +fn cleared_intermediate_source_restores_exact_pixels_after_undo_redo() { + for single_state in [false, true] { + for undo_clear in [false, true] { + let mut s = history_control_session(single_state); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new( + "Cleared Source", + vec![ + ("edit.fill".into(), json!({"color":"#0000ff"})), + ("history.setSource".into(), json!({"state":past+1})), + ("edit.fill".into(), json!({"color":"#00ffff"})), + ("history.clear".into(), json!({})), + ], + )]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().history.is_cleared()); + assert_eq!(s.active().unwrap().history.past_len(), 0); + if undo_clear { + let after = s.active().unwrap().doc.clone(); + assert!(s.undo()); + assert!(!s.active().unwrap().history.is_cleared()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after)); + let added = if single_state { 1 } else { 2 }; + assert_eq!(s.active().unwrap().history.past_len(), past + added); + for _ in 0..added { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + for _ in 0..added { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + // Before Undo Clear the source is the current cyan document. Afterwards it is the + // explicitly selected intermediate blue document, including after image Undo/Redo. + s.execute("edit.fill", json!({"contents":"history"})).unwrap(); + let pixel = s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0); + let expected = if undo_clear { [0.0, 0.0, 1.0, 1.0] } else { [0.0, 1.0, 1.0, 1.0] }; + assert_eq!(pixel, expected, "singleState={single_state}, undoClear={undo_clear}"); + } + } +} + +/// Default playback is one named edit, including the complete Undo and Redo states. +#[test] +fn default_playback_is_one_named_history_state() { + let mut s = session(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + let result = s.execute("actions.play", json!({"action":"Two layers"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Two layers")); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 2); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().history.redo_label(), Some("Action: Two layers")); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + assert_eq!(s.active().unwrap().doc.layers.iter().map(|l| &l.name).collect::>(), after.layers.iter().map(|l| &l.name).collect::>()); +} + +/// Disabling the persisted option retains the existing one-entry-per-step behavior. +#[test] +fn disabled_single_history_keeps_each_step() { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState":false})).unwrap(); + let count = s.active().unwrap().history.past_len(); + let layers = s.active().unwrap().doc.layers.len(); + s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), count + 2); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); +} + +/// Failure commits the completed prefix; an inner play uses its outer action's name and key. +#[test] +fn failures_and_nested_calls_keep_the_outer_history_entry() { + let mut s = session(); + let layers = s.active().unwrap().doc.layers.len(); + s.actions.list.push(Action::new( + "Outer", + vec![ + ("layer.new.layer".into(), json!({"name":"Outer layer"})), + ("actions.play".into(), json!({"action":0})), + ("not.a.command".into(), json!({})), + ("layer.new.layer".into(), json!({"name":"Must not run"})), + ], + )); + let before = s.active().unwrap().history.past_len(); + let result = s.execute("actions.play", json!({"action":"Outer"})).unwrap(); + assert_eq!(result["failed"]["step"], 2); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3); + assert_eq!(s.active().unwrap().history.past_len(), before + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Outer")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers + 3); +} + +/// No-op playback preserves redo, and separate plays never coalesce into one another. +#[test] +fn no_op_and_repeated_plays_preserve_history_boundaries() { + let mut s = session(); + s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(s.undo()); + let entries = s.active().unwrap().history.entries(); + let redo = s.active().unwrap().history.redo_label().map(str::to_owned); + s.actions.list.push(Action::new("Empty", vec![])); + s.execute("actions.play", json!({"action":"Empty"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries(), entries); + assert_eq!(s.active().unwrap().history.redo_label(), redo.as_deref()); + assert!(s.redo()); + let count = s.active().unwrap().history.past_len(); + let layers = s.active().unwrap().doc.layers.len(); + s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); +} + +/// Older preference files default on; an explicit off survives the public save/load path. +#[test] +fn action_history_setting_is_persisted_and_rejects_wrong_types() { + let mut s = Session::new(); + assert!(s.prefs().actions.create_single_history_state); + assert!(s.execute("actions.setHistory", json!({"singleState":"false"})).is_err()); + assert!(s.prefs().actions.create_single_history_state); + s.execute("actions.setHistory", json!({"singleState":false})).unwrap(); + let mut restored = Session::new(); + assert!(restored.load_prefs_json(&s.prefs_to_json()).unwrap().is_empty()); + assert!(!restored.prefs().actions.create_single_history_state); + restored.load_prefs_json("{}").unwrap(); + assert!(restored.prefs().actions.create_single_history_state); +} + +#[test] +fn an_open_document_batch_cannot_change_an_active_history_scope_setting() { + for single in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState": single})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let result = s + .execute( + "file.automate.batch", + json!({"source": "open", "action": "Setting boundary", "steps": [ + ["layer.renameLayer", {"name": "Prefix"}], ["actions.setHistory", {"singleState": false}], ["layer.renameLayer", {"name": "Suffix"}] + ]}), + ) + .unwrap(); + if single { + assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}"); + assert!(result.to_string().contains("cannot change action history settings"), "{result}"); + assert!(s.prefs().actions.create_single_history_state); + assert_eq!(s.active().unwrap().doc.layers[0].name, "Prefix"); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Setting boundary")); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + } else { + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + assert!(!s.prefs().actions.create_single_history_state); + assert_eq!(s.active().unwrap().doc.layers[0].name, "Suffix"); + assert_eq!(s.active().unwrap().history.past_len(), past + 2); + } + s.execute("actions.setHistory", json!({"singleState": !single})).unwrap(); + assert_eq!(s.prefs().actions.create_single_history_state, !single, "the batch scope ended"); + } +} + +#[test] +fn a_real_action_still_refuses_history_setting_changes_with_grouping_on_or_off() { + for single in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState": single})).unwrap(); + s.actions.list = vec![Action::new("Setting", vec![("actions.setHistory".into(), json!({"singleState": !single}))])]; + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(result["failed"]["id"], "actions.setHistory"); + assert_eq!(s.prefs().actions.create_single_history_state, single); + } +} + +#[test] +fn an_accepted_clear_survives_an_action_that_returns_to_empty_captured_history() { + for single in [false, true] { + let mut s = session(); + s.execute("actions.setHistory", json!({"singleState": single})).unwrap(); + let original = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), 0); + assert!(s.execute("history.clear", json!({})).is_err(), "ordinary empty Clear still refuses"); + s.actions.list = vec![Action::new( + "Empty capture Clear", + vec![ + ("edit.fill".into(), json!({"color": "#000000"})), + ("history.setSource".into(), json!({"state": 1})), + ("edit.undo".into(), json!({})), + ("history.clear".into(), json!({})), + ], + )]; + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().history.is_cleared()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &original)); + assert!(s.undo(), "Undo Clear remains available with an empty captured baseline"); + assert!(!s.active().unwrap().history.is_cleared()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &original)); + s.execute("edit.fill", json!({"contents": "history"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 0.0, 1.0], "the exact black source is restored"); + } +} + +/// A command's internal purge must not remove the outer action's only Undo snapshot. +#[test] +fn frame_compound_retains_undo_redo_content_and_layer_targets() { + for single in [true, false] { + let mut s = session(); + s.execute("layer.new.layer", json!({"name":"Subject"})).unwrap(); + s.execute("actions.setHistory", json!({"singleState":single})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let target = s.active().unwrap().active_layer; + let selected = s.active().unwrap().selected_layers.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new("Frame", vec![("layer.new.frameFromLayers".into(), json!({}))])]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_ne!(after.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + if single { + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Frame")); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().active_layer, target); + assert_eq!(s.active().unwrap().selected_layers, selected); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// A real tilted crop's compound transaction remains inside the outer prefix and suffix. +#[test] +fn tilted_crop_between_edits_is_one_action() { + let mut s = session(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new( + "Crop", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("image.crop".into(), json!({"x":0,"y":0,"width":3,"height":3,"angle":10})), + ("layer.new.layer".into(), json!({"name":"Suffix"})), + ], + )]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Crop")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); +} + +/// Command-local rollback removes a refused copy but keeps the action's completed rename. +#[test] +fn again_copy_failure_preserves_the_completed_prefix() { + let mut s = session(); + s.execute("layer.new.layerFromBackground", json!({"ifPresent":true})).unwrap(); + s.execute("edit.transform", json!({"matrix":[1,0,0,1,1,0]})).unwrap(); + s.execute("layer.setProps", json!({"locks":{"position":true}})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = + vec![Action::new("Prefix", vec![("layer.renameLayer".into(), json!({"name":"Completed prefix"})), ("edit.transform.againCopy".into(), json!({}))])]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["failed"]["step"], 1, "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len()); + assert_ne!(after.as_ref(), before.as_ref()); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Prefix")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); +} + +/// A nonempty semantic no-op preserves the original document identity, rows, redo and clean state. +#[test] +fn same_name_rename_does_not_record_or_clear_redo() { + let mut s = session(); + s.execute("layer.new.layer", json!({"name":"Later"})).unwrap(); + assert!(s.undo()); + let st = s.active().unwrap(); + let before = st.doc.clone(); + let entries = st.history.entries(); + let redo: Vec<_> = st.history.redo_labels().map(str::to_string).collect(); + let ids: Vec<_> = (0..entries.len() + redo.len()).map(|i| st.history.row_id(i)).collect(); + let dirty = st.is_dirty(); + let name = st.doc.layer(st.active_layer.unwrap()).unwrap().name.clone(); + s.actions.list = vec![Action::new("No change", vec![("layer.renameLayer".into(), json!({"name":name}))])]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &before)); + assert_eq!(st.history.entries(), entries); + assert_eq!(st.history.redo_labels().collect::>(), redo.iter().map(String::as_str).collect::>()); + assert_eq!((0..ids.len()).map(|i| st.history.row_id(i)).collect::>(), ids); + assert_eq!(st.is_dirty(), dirty); + assert!(s.redo()); +} + +/// Stop commits its prefix, while Allow Continue includes the following step in that same entry. +#[test] +fn stop_message_retains_the_completed_prefix() { + for allow in [false, true] { + let mut s = session(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + s.actions.list = vec![Action::new( + "Stop", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("actions.stopMessage".into(), json!({"message":"Check","allowContinue":allow})), + ("layer.renameLayer".into(), json!({"name":"Suffix"})), + ], + )]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result.get("failed").is_none(), allow, "{result}"); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if allow { "Suffix" } else { "Prefix" }); + assert_eq!(st.history.past_len(), count + 1); + assert_eq!(st.history.undo_label(), Some("Action: Stop")); + let after = st.doc.clone(); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// Resumed dialog edits and subsequent steps join the published prefix, without an active scope while paused. +#[test] +fn suspended_history_resumes_the_same_action() { + use photocraft_engine::actions_cmds; + let mut s = session(); + let action = s.actions.list[0].clone(); + let before = s.active().unwrap().doc.clone(); + let count = s.active().unwrap().history.past_len(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + continuation.resume(&mut s).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Confirmed dialog"})).unwrap(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.new.layer", json!({"name":"Suffix"})).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 1); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Two layers")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + assert!(continuation.resume(&mut s).is_err(), "the old continuation cannot join the finalized history"); +} + +/// An unrelated edit, history purge or document switch during a pause cannot be merged into the action. +#[test] +fn suspended_history_refuses_document_or_history_drift() { + use photocraft_engine::actions_cmds; + for drift in 0..3 { + let mut s = session(); + let action = s.actions.list[0].clone(); + let recording = actions_cmds::begin_playback(&mut s, &action).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Prefix"})).unwrap(); + let continuation = actions_cmds::suspend_history(&mut s).unwrap(); + actions_cmds::finish_playback(&mut s, recording, &action, 0); + match drift { + 0 => { + s.execute("layer.new.layer", json!({"name":"Unrelated"})).unwrap(); + } + 1 => { + s.active_mut().unwrap().history.purge_last(); + } + _ => { + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + } + } + let before = s.active().unwrap().doc.clone(); + let entries = s.active().unwrap().history.entries(); + assert!(continuation.resume(&mut s).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), entries); + } +} + +/// Visibility excluded from history remains excluded when it occurs between grouped edits. +#[test] +fn grouping_preserves_non_undoable_visibility() { + let mut s = session(); + s.execute("history.options", json!({"makeLayerVisibilityChangesUndoable":false})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.actions.list = vec![Action::new( + "Mixed", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("layer.setProps".into(), json!({"visible":false})), + ("layer.renameLayer".into(), json!({"name":"Final"})), + ], + )]; + let count = s.active().unwrap().history.past_len(); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.active().unwrap().history.past_len(), count + 1); + assert!(s.undo()); + let actual = s.active().unwrap().doc.layer(layer).unwrap(); + assert!(!actual.visible); + assert_eq!(actual.name, before.layer(layer).unwrap().name); + assert!(s.redo()); + assert!(!s.active().unwrap().doc.layer(layer).unwrap().visible); + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().name, "Final"); +} + +/// Nonlinear redo states remain ordered, and trimming cannot evict the before-action boundary. +#[test] +fn grouping_preserves_nonlinear_history_and_a_one_state_limit() { + for nonlinear in [false, true] { + let mut s = session(); + s.execute("history.options", json!({"allowNonLinearHistory":nonlinear})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Future"})).unwrap(); + let future = s.active().unwrap().history.row_id(s.active().unwrap().history.past_len()).unwrap(); + assert!(s.undo()); + if !nonlinear { + s.active_mut().unwrap().history.max_states = 1; + } + let before = s.active().unwrap().doc.clone(); + s.actions.list = vec![Action::new("Many", (0..5).map(|i| ("layer.renameLayer".into(), json!({"name":format!("Name{i}")}))).collect())]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Many")); + if !nonlinear { + assert_eq!(s.active().unwrap().history.past_len(), 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref()); + } else { + assert!(s.active().unwrap().history.row_of(future).is_some(), "the existing future row keeps its identity"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.layers.iter().any(|layer| layer.name == "Future")); + } + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// Check both removed ids and the labels attached to every surviving older row. +fn assert_delete_rows(s: &Session, originals: &[(u64, String, std::sync::Arc)], removed: &[usize], case: &str, single_state: bool) { + for (i, (id, label, _)) in originals.iter().enumerate() { + let row = s.active().unwrap().history.row_of(*id); + if removed.contains(&i) { + assert!(row.is_none(), "{case}, singleState={single_state}: deleted row {id} returned"); + } else { + let row = row.unwrap_or_else(|| panic!("{case}: surviving row {id} disappeared")); + let st = s.active().unwrap(); + let labels: Vec<_> = st.history.entries().into_iter().chain(st.history.redo_labels().map(str::to_owned)).collect(); + assert_eq!(&labels[row], label); + } + } +} + +/// Compare Undo with the unchanged oracle for this deletion and history mode. +fn assert_delete_boundary( + s: &Session, + case: &str, + single_state: bool, + prefix: bool, + before: &std::sync::Arc, + originals: &[(u64, String, std::sync::Arc)], +) { + let boundary = if case == "linear-past" { + &originals[0].2 + } else if case == "nonlinear-redo" { + // Non-linear history appends the retained future after the original current. + &originals[3].2 + } else if single_state { + before + } else if case == "nonlinear-past" && prefix { + &originals[3].2 + } else { + &originals[4].2 + }; + assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref(), "{case}, singleState={single_state}, prefix={prefix}"); +} + +/// Play the real Delete State command, retaining identities of the older rows it did not delete. +fn recorded_delete_case(case: &str) { + for single_state in [false, true] { + for prefix in [false, true] { + if !prefix && case != "nonlinear-past" { + continue; + } + let nonlinear = case.starts_with("nonlinear"); + let mut s = history_control_session(single_state); + s.execute("history.options", json!({"allowNonLinearHistory":nonlinear})).unwrap(); + s.execute("edit.fill", json!({"color":"#0000ff"})).unwrap(); + s.execute("edit.fill", json!({"color":"#00ffff"})).unwrap(); + let originals: Vec<_> = (0..=s.active().unwrap().history.past_len()) + .map(|i| { + let st = s.active().unwrap(); + (st.history.row_id(i).unwrap(), st.history.entries()[i].clone(), st.history.row_doc(i, &st.doc).unwrap()) + }) + .collect(); + let source = if case.ends_with("past") { 1 } else { 4 }; + s.execute("history.setSource", json!({"state":source})).unwrap(); + if case == "nonlinear-redo" { + assert!(s.undo()); + assert!(s.undo()); + } + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + if case.ends_with("current") { + steps.push(("history.setSource".into(), json!({"state":past + 1}))); + } + if case == "nonlinear-redo" { + steps.push(("history.selectState".into(), json!({"state":3}))); + } + let deleted = match case { + "linear-past" | "nonlinear-past" => 1, + "nonlinear-redo" => 4, + _ => past + 1, + }; + steps.extend([ + ("history.deleteState".into(), json!({"state":deleted})), + ("edit.fill".into(), json!({"color":"#ffff00"})), + ("edit.fill".into(), json!({"color":"#ff00ff"})), + ]); + s.actions.list = vec![Action::new("Delete state", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{case}, singleState={single_state}: {result}"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers[0].surface().unwrap().rgba(0, 0), [1.0, 0.0, 1.0, 1.0]); + let removed: Vec<_> = match case { + "linear-past" => (1..5).collect(), + "nonlinear-past" => vec![1], + "nonlinear-redo" => vec![4], + _ => Vec::new(), + }; + assert_delete_rows(&s, &originals, &removed, case, single_state); + let count = if single_state { 1 } else { 2 }; + if single_state { + assert_eq!(s.active().unwrap().history.past_len(), originals.len() - removed.len()); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Action: Delete state")); + } + for _ in 0..count { + assert!(s.undo()); + } + assert_delete_boundary(&s, case, single_state, prefix, &before, &originals); + for _ in 0..count { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + for i in removed { + assert!(s.active().unwrap().history.row_of(originals[i].0).is_none()); + } + assert!(s.execute("edit.fill", json!({"contents":"history"})).is_err(), "the selected deleted source must stay unresolved"); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +#[test] +fn recorded_linear_past_deletion_keeps_its_boundary_and_suffix() { + recorded_delete_case("linear-past"); +} + +#[test] +fn recorded_linear_current_deletion_keeps_surviving_rows() { + recorded_delete_case("linear-current"); +} + +#[test] +fn recorded_nonlinear_past_deletion_keeps_only_surviving_rows() { + recorded_delete_case("nonlinear-past"); +} + +#[test] +fn recorded_nonlinear_current_deletion_keeps_surviving_rows() { + recorded_delete_case("nonlinear-current"); +} + +#[test] +fn recorded_nonlinear_redo_deletion_keeps_only_surviving_rows() { + recorded_delete_case("nonlinear-redo"); +} + +/// Purge Undo removes one predecessor, while native snapshots and legitimate older rows remain. +fn recorded_user_purge_undo_case(suffix: bool) { + for single_state in [false, true] { + for prefix in [false, true] { + let mut s = history_control_session(single_state); + let old: Vec<_> = (0..=s.active().unwrap().history.past_len()) + .map(|i| { + let st = s.active().unwrap(); + (st.history.row_id(i).unwrap(), st.history.row_doc(i, &st.doc).unwrap()) + }) + .collect(); + let removed = if prefix { 2 } else { 1 }; + s.execute("history.setSource", json!({"state":removed})).unwrap(); + let mut steps = Vec::new(); + if prefix { + steps.push(("edit.fill".into(), json!({"color":"#0000ff"}))); + } + steps.extend([("edit.purge.undo".into(), json!({})), ("history.newSnapshot".into(), json!({"name":"Purge boundary"}))]); + if suffix { + steps.extend([("edit.fill".into(), json!({"color":"#ffff00"})), ("edit.fill".into(), json!({"color":"#ff00ff"}))]); + } + s.actions.list = vec![Action::new("Purge Undo", steps)]; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "singleState={single_state}, prefix={prefix}, suffix={suffix}: {result}"); + let st = s.active().unwrap(); + let after = st.doc.clone(); + let boundary = st.snapshots.last().unwrap().doc.clone(); + let snapshot = st.snapshots.last().unwrap().id; + assert_eq!(boundary.layers[0].surface().unwrap().rgba(0, 0), if prefix { [0.0, 0.0, 1.0, 1.0] } else { [0.0, 1.0, 0.0, 1.0] }); + assert!(st.history.row_of(old[removed].0).is_none(), "Purge Undo's removed predecessor returned"); + assert!(st.history.row_of(old[0].0).is_some()); + let count = if single_state { 1 } else { 2 }; + if suffix { + for _ in 0..count { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref()); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.as_ref(), old[if prefix { 1 } else { 0 }].1.as_ref(), "the purged pixels cannot be undone back into existence"); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.as_ref(), boundary.as_ref()); + if suffix { + for _ in 0..count { + assert!(s.redo()); + } + } + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + assert!(s.active().unwrap().history.row_of(old[removed].0).is_none()); + assert!(s.execute("edit.fill", json!({"contents":"history"})).is_err()); + assert_eq!(s.active().unwrap().doc.as_ref(), after.as_ref()); + s.execute("edit.fill", json!({"contents":"history", "snapshot":snapshot})).unwrap(); + assert_eq!( + s.active().unwrap().doc.layers[0].surface().unwrap().rgba(0, 0), + boundary.layers[0].surface().unwrap().rgba(0, 0), + "native snapshots outlive Purge Undo" + ); + } + } +} + +#[test] +fn recorded_user_purge_undo_does_not_restore_its_removed_predecessor() { + recorded_user_purge_undo_case(false); +} + +#[test] +fn recorded_user_purge_undo_groups_only_the_later_suffix() { + recorded_user_purge_undo_case(true); +} diff --git a/crates/engine/tests/it/affinity.rs b/crates/engine/tests/it/affinity.rs index 2d56fbf986..06c4bf4e66 100644 --- a/crates/engine/tests/it/affinity.rs +++ b/crates/engine/tests/it/affinity.rs @@ -12,7 +12,10 @@ use serde_json::json; fn fixture() -> Vec { let mut src = Session::new(); src.execute("file.new", json!({"width": 2, "height": 1, "background": "#dc283c"})).unwrap(); - let png = photocraft_io::export(&src.active().unwrap().doc, "preview.png", &Default::default()).unwrap().bytes; + // Affinity's previews carry no colour profile. + let mut opts = photocraft_io::ExportOptions::default(); + opts.encode.embed_icc = false; + let png = photocraft_io::export(&src.active().unwrap().doc, "preview.png", &opts).unwrap().bytes; // Observed v12 preview envelope, without a native object graph. let mut bytes = vec![0; 72]; bytes[..4].copy_from_slice(b"\x00\xffKA"); diff --git a/crates/engine/tests/it/atn_classic_gradient.rs b/crates/engine/tests/it/atn_classic_gradient.rs new file mode 100644 index 0000000000..709b7a7d90 --- /dev/null +++ b/crates/engine/tests/it/atn_classic_gradient.rs @@ -0,0 +1,317 @@ +//! Synthetic measured-layout recordings through public ATN load/save and playback. +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::json; + +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} +fn pct(value: f64) -> D { + D::UnitFloat { unit: *b"#Prc", value } +} +fn point(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y })) +} +fn color(t: i32, rgb: [f64; 3]) -> D { + let c = Descriptor::new("RGBC").with("Rd ", D::Double(rgb[0])).with("Grn ", D::Double(rgb[1])).with("Bl ", D::Double(rgb[2])); + D::Descriptor( + Descriptor::new("Clrt").with("Clr ", D::Descriptor(c)).with("Type", enm("Clry", "UsrS")).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50)), + ) +} +fn opacity(t: i32, value: f64) -> D { + D::Descriptor(Descriptor::new("TrnS").with("Opct", pct(value)).with("Lctn", D::Integer(t)).with("Mdpn", D::Integer(50))) +} +fn fixture(style: &str, method: &str, changed: bool) -> Descriptor { + let gradient = Descriptor::new("Grdn") + .with("Nm ", D::Text(UnicodeString::new_nul("Recorded gradient"))) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![color(0, [0.0, 32.0, 255.0]), color(4096, [255.0, 224.0, 0.0])])) + .with("Trns", D::List(vec![opacity(0, 20.0), opacity(4096, 80.0)])); + let mut d = Descriptor::new("null").with("From", point(1.0, 1.0)).with("T ", point(6.0, 2.0)); + if changed { + d = d.with("Opct", pct(54.5)).with("Md ", enm("BlnM", "Mltp")); + } + d = d.with("Type", enm("GrdT", style)); + if changed { + d = d.with("Rvrs", D::Boolean(true)).with("UsMs", D::Boolean(true)); + } + d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", method)).with("Grad", D::Descriptor(gradient)) +} +fn recording(event: AtnEvent, d: Descriptor) -> Vec { + let file = AtnFile { + name: UnicodeString::new_nul("Set"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Gradient"), + items: vec![AtnItem { event, descriptor: Some(d), dictionary: b"Gradient".to_vec(), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + atn::write(&file).unwrap() +} +fn imported(d: Descriptor) -> atn_cmds::Imported { + atn_cmds::import(&recording(AtnEvent::Code(*b"Grdn"), d)).unwrap() +} +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Set", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions[0].items[0].clone() +} +fn replace(d: &mut Descriptor, key: &str, v: D) { + d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 = v; +} +fn obj<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Descriptor { + let D::Descriptor(value) = &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 else { panic!("not an object") }; + value +} + +#[test] +fn both_event_ids_and_top_level_classes_keep_complete_unchanged_descriptors() { + for class in ["null", "Grdn"] { + for style in ["Lnr ", "Rdl ", "Angl", "Rflc", "Dmnd"] { + for method in ["Gcls", "Perc", "Lnr ", "Smoo"] { + for changed in [false, true] { + for event in [AtnEvent::Code(*b"Grdn"), AtnEvent::name("gradientClassEvent")] { + let mut d = fixture(style, method, changed); + d.class_id = Id::new(class); + let loaded = atn_cmds::import(&recording(event, d.clone())).unwrap(); + assert!(loaded.unsupported.is_empty(), "{style} {method}"); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, "paint.gradient"); + assert_eq!(saved(action).descriptor.unwrap().to_bytes(), d.to_bytes()); + assert_eq!(action.steps[0].1["dither"], false, "omitted Dthr is off, unlike native default"); + assert_eq!(action.steps[0].1["opacity"], if changed { json!(54.5) } else { json!(100) }); + assert_eq!(action.steps[0].1["transparency"], if changed { json!([[0, 20], [1, 80]]) } else { json!([]) }); + } + } + } + } + } +} + +#[test] +fn explicit_defaults_key_order_id_encoding_and_flags_are_preserved() { + let mut d = fixture("Lnr ", "Gcls", false); + d.items.push((Id::Str(b"Opct".to_vec()), pct(100.0))); + d.items.push((Id::new("Dthr"), D::Boolean(false))); + d.items.push((Id::new("Rvrs"), D::Boolean(false))); + d.items.reverse(); + let mut loaded = imported(d.clone()); + assert!(loaded.unsupported.is_empty()); + loaded.actions[0].disabled.insert(0); + loaded.actions[0].dialogs.insert(0); + let item = saved(&loaded.actions[0]); + assert_eq!(item.descriptor.unwrap().to_bytes(), d.to_bytes()); + assert!(!item.enabled); + assert!(item.with_dialog); +} + +#[test] +fn supported_edits_replace_stale_source_and_export_recorded_default_omissions() { + let original = fixture("Rdl ", "Perc", true); + let mut loaded = imported(original.clone()); + let p = &mut loaded.actions[0].steps[0].1; + p["opacity"] = json!(100); + p["mode"] = json!("Normal"); + p["reverse"] = json!(false); + p["dither"] = json!(true); + p["transparency"] = json!([]); + p["from"] = json!([2, 3]); + let item = saved(&loaded.actions[0]); + let d = item.descriptor.unwrap(); + assert_ne!(d.to_bytes(), original.to_bytes()); + let keys: Vec<&[u8]> = d.items.iter().map(|(k, _)| k.as_bytes()).collect(); + assert_eq!(keys, vec![b"From".as_slice(), b"T ", b"Type", b"Dthr", b"gradientsInterpolationMethod", b"Grad"]); + let back = imported(d); + assert!(back.unsupported.is_empty()); + assert_eq!(back.actions[0].steps[0].1["from"], json!([2, 3])); + assert_eq!(back.actions[0].steps[0].1["dither"], true); + assert_eq!(back.actions[0].steps[0].1["transparency"], json!([])); +} + +#[test] +fn malformed_unknown_and_unrepresentable_descriptors_stay_whole() { + let base = fixture("Lnr ", "Gcls", true); + let mut bad = Vec::new(); + let mut d = base.clone(); + d.class_id = Id::new("UnknownGradient"); + bad.push(d); + let mut d = base.clone(); + d.items.retain(|(k, _)| !k.is("Grad")); + bad.push(d); + let mut d = base.clone(); + d.items.retain(|(k, _)| !k.is("gradientsInterpolationMethod")); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "Type", enm("GrdT", "Unknown")); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "Type", enm("wrong", "Lnr ")); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "Opct", D::Double(54.5)); + bad.push(d); + let mut d = base.clone(); + d.items.push((Id::new("Dthr"), D::Integer(1))); + bad.push(d); + let mut d = base.clone(); + d.items.push((Id::Str(b"Opct".to_vec()), pct(100.0))); + bad.push(d); + let mut d = base.clone(); + d.items.push((Id::new("unknownSetting"), D::Boolean(true))); + bad.push(d); + let mut d = base.clone(); + replace(&mut d, "gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "unknown")); + bad.push(d); + let mut d = base.clone(); + replace(obj(&mut d, "From"), "Hrzn", D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }); + bad.push(d); + let mut d = base.clone(); + replace(obj(&mut d, "From"), "Hrzn", pct(1.0)); + bad.push(d); + for (key, value) in [("Mdpn", D::Integer(25)), ("Type", enm("Clry", "FrgC")), ("Lctn", D::Integer(-1))] { + let mut d = base.clone(); + let D::List(stops) = &mut obj(&mut d, "Grad").items.iter_mut().find(|(k, _)| k.is("Clrs")).unwrap().1 else { panic!() }; + let D::Descriptor(stop) = &mut stops[0] else { panic!() }; + replace(stop, key, value); + bad.push(d); + } + let mut d = base.clone(); + let D::List(stops) = &mut obj(&mut d, "Grad").items.iter_mut().find(|(k, _)| k.is("Clrs")).unwrap().1 else { panic!() }; + let D::Descriptor(stop) = &mut stops[0] else { panic!() }; + replace(obj(stop, "Clr "), "Rd ", D::Double(0.5)); + bad.push(d); + for d in bad { + let bytes = d.to_bytes(); + let loaded = imported(d); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), bytes); + } +} + +#[test] +fn unsupported_native_targets_and_damaged_origins_are_refused() { + let loaded = imported(fixture("Lnr ", "Gcls", false)); + for (key, value) in [ + ("target", json!("mask")), + ("gradient", json!("current preset")), + ("method", json!("unknown")), + ("style", json!("unknown")), + ("style", json!(42)), + ("__atnClassicGradient", json!("not base64")), + ("__atnClassicGradient", json!("A".repeat(128 * 1024 + 1))), + ("stops", json!([[0, "foreground"], [1, "background"]])), + ("stops", json!([[0, "#000000"]])), + ("stops", json!([[0.1, "#000000"], [1, "#ffffff"]])), + ] { + let mut action = loaded.actions[0].clone(); + action.steps[0].1[key] = value; + assert!(atn_cmds::export("S", &[&action], &[], false).is_err(), "{key}"); + let (_, skipped) = atn_cmds::export("S", &[&action], &[], true).unwrap(); + assert_eq!(skipped.len(), 1, "{key}"); + } +} + +#[test] +fn colour_and_transparency_stop_counts_have_supported_and_refused_boundaries() { + for key in ["Clrs", "Trns"] { + for count in [1, 2, 64, 65] { + let mut d = fixture("Lnr ", "Gcls", true); + let stops = (0..count) + .map(|i| { + let t = i * 4096 / (count - 1).max(1); + if key == "Clrs" { color(t, [32.0, 96.0, 224.0]) } else { opacity(t, 50.0) } + }) + .collect(); + replace(obj(&mut d, "Grad"), key, D::List(stops)); + let bytes = d.to_bytes(); + let loaded = imported(d); + assert_eq!(loaded.unsupported.is_empty(), (2..=64).contains(&count), "{key} {count}"); + assert_eq!(saved(&loaded.actions[0]).descriptor.unwrap().to_bytes(), bytes); + } + } +} + +#[test] +fn omitted_transparency_and_dither_use_recorded_defaults_during_playback() { + for depth in [8, 16, 32] { + for dither in [false, true] { + let mut d = fixture("Lnr ", "Gcls", false); + d.class_id = Id::new("Grdn"); + if dither { + d.items.push((Id::new("Dthr"), D::Boolean(true))); + } + let loaded = imported(d); + assert!(loaded.unsupported.is_empty()); + let mut actual = Session::new(); + let mut control = Session::new(); + for s in [&mut actual, &mut control] { + s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"background":"#204060"})).unwrap(); + } + actual.actions.list = loaded.actions; + actual.execute("actions.play", json!({"action":0})).unwrap(); + // The descriptor contains non-opaque Trns, but omitted UsMs disables them. + // Dthr=false is explicit, overriding the native command's true default. + control + .execute( + "paint.gradient", + json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100, + "stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":dither}), + ) + .unwrap(); + let a = actual.active().unwrap().doc.layers[0].surface().unwrap(); + let b = control.active().unwrap().doc.layers[0].surface().unwrap(); + for y in 0..4 { + for x in 0..8 { + assert_eq!(a.pixel(x, y), b.pixel(x, y), "{depth} dither={dither}"); + } + } + } + } +} + +#[test] +fn recorded_gradients_match_native_pixels_and_undo_at_each_depth() { + for class in ["null", "Grdn"] { + for depth in [8, 16, 32] { + for (style, native_style) in [("Lnr ", "linear"), ("Rdl ", "radial"), ("Angl", "angle"), ("Rflc", "reflected"), ("Dmnd", "diamond")] { + for (method, native_method) in [("Gcls", "classic"), ("Perc", "perceptual"), ("Lnr ", "linear"), ("Smoo", "smooth")] { + let mut descriptor = fixture(style, method, true); + descriptor.class_id = Id::new(class); + let loaded = imported(descriptor); + assert!(loaded.unsupported.is_empty()); + let mut actual = Session::new(); + let mut control = Session::new(); + for s in [&mut actual, &mut control] { + s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"background":"#204060"})).unwrap(); + } + let before = actual.active().unwrap().doc.clone(); + actual.actions.list = loaded.actions; + actual.execute("actions.play", json!({"action":0})).unwrap(); + control + .execute( + "paint.gradient", + json!({"from":[1,1],"to":[6,2],"style":native_style,"method":native_method,"smoothness":100, + "stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[[0,20],[1,80]],"opacity":54.5,"mode":"Multiply","reverse":true,"dither":false}), + ) + .unwrap(); + let pixels = |s: &Session| s.active().unwrap().doc.layers[0].surface().unwrap().clone(); + let (a, b) = (pixels(&actual), pixels(&control)); + for y in 0..4 { + for x in 0..8 { + assert_eq!(a.pixel(x, y), b.pixel(x, y), "{depth} {style} {method} ({x},{y})"); + } + } + assert_ne!(a.pixel(3, 1), before.layers[0].surface().unwrap().pixel(3, 1), "non-vacuous paint"); + assert!(actual.undo()); + assert_eq!(actual.active().unwrap().doc, before); + } + } + } + } +} diff --git a/crates/engine/tests/it/atn_content_aware_fill.rs b/crates/engine/tests/it/atn_content_aware_fill.rs new file mode 100644 index 0000000000..bb175549b4 --- /dev/null +++ b/crates/engine/tests/it/atn_content_aware_fill.rs @@ -0,0 +1,423 @@ +//! Recorded CAF settings use the native operation; unsupported descriptors stay whole. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const CMD: &str = "edit.contentAwareFill"; + +fn enm(key: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(key), value: Id::new(value) } +} + +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("cafSamplingRegion", enm("cafSamplingRegion", "cafSamplingRegionRectangular")) + .with("cafSampleAllLayers", D::Boolean(false)) + .with("cafColorAdaptationLevel", enm("cafColorAdaptationLevel", "cafColorAdaptationDefault")) + .with("cafRotationAmount", enm("cafRotationAmount", "cafRotationAmountNone")) + .with("cafScale", D::Boolean(false)) + .with("cafMirror", D::Boolean(false)) + .with("cafOutput", enm("cafOutput", "cafOutputToNewLayer")) +} + +fn params() -> Value { + json!({"sampling":"rectangular","sampleAllLayers":false,"colorAdaptation":"default", + "rotationAdaptation":"none","scale":false,"mirror":false,"output":"new"}) +} + +fn item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::name("cafWorkspace"), dictionary: b"Content-Aware Fill".to_vec(), descriptor: Some(d), ..Default::default() } +} + +fn file(item: AtnItem) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item], ..Default::default() }], + ..Default::default() + } +} + +fn load(d: Descriptor) -> Action { + let loaded = import(&atn::write(&file(item(d))).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + loaded.actions.into_iter().next().unwrap() +} + +fn exported(p: Value) -> Option { + let a = Action::new("A", vec![(CMD.into(), p)]); + let bytes = export("S", &[&a], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() +} + +/// Each field retains its native meaning, recorded type and position, including false booleans. +#[test] +fn caf_settings_roundtrip_seven_fields_and_boolean_choices() { + let base = descriptor(); + let mut cases = Vec::new(); + for bits in 0..8 { + let mut d = base.clone(); + let mut p = params(); + for (bit, index, param) in [(0, 1, "sampleAllLayers"), (1, 4, "scale"), (2, 5, "mirror")] { + let on = bits & (1 << bit) != 0; + d.items[index].1 = D::Boolean(on); + p[param] = json!(on); + } + cases.push((d, p)); + } + let mut current = base.clone(); + current.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer"); + let mut p = params(); + p["output"] = json!("current"); + cases.push((current, p)); + for (d, p) in cases { + let action = load(d.clone()); + assert_eq!(action.steps, vec![(CMD.into(), p.clone())]); + assert_eq!(exported(p).unwrap(), item(d.clone())); + let bytes = export("S", &[&action], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], item(d)); + } +} + +/// Unedited source identity, order and flags survive; damaged preservation metadata is refused. +#[test] +fn caf_source_order_and_flags_survive_without_reviving_stale_settings() { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded CAF"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let a = &imported.actions[0]; + assert!(a.steps[0].1.get("_atnCafDescriptor").is_some()); + let bytes = export("S", &[a], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], source); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnCafDescriptor"] = json!("not base64"); + assert!(exported(damaged).is_none()); + let invalid = descriptor().with("extra", D::Boolean(true)); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnCafDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes())); + assert!(exported(damaged).is_none(), "valid base64 cannot hide an unrepresented source field"); +} + +/// Representable edits export their new values canonically, retaining step flags without skips. +#[test] +fn caf_edited_settings_export_without_stale_wire_metadata() { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded CAF"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let a = &imported.actions[0]; + assert!(a.steps[0].1.get("_atnCafDescriptor").is_some()); + for (param, index, value, wire) in [ + ("sampleAllLayers", 1, json!(true), D::Boolean(true)), + ("scale", 4, json!(true), D::Boolean(true)), + ("mirror", 5, json!(true), D::Boolean(true)), + ("output", 6, json!("current"), enm("cafOutput", "cafOutputToCurrentLayer")), + ] { + let mut edited = a.clone(); + edited.steps[0].1[param] = value.clone(); + let mut expected_params = params(); + expected_params[param] = value; + let mut canonical = descriptor(); + canonical.items[index].1 = wire; + let mut expected = source.clone(); + expected.descriptor = Some(canonical); + for skip in [false, true] { + let (bytes, skipped) = export("S", &[&edited], &[], skip).unwrap(); + assert!(skipped.is_empty(), "a supported {param} edit must not be skipped"); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items, vec![expected.clone()]); + let reloaded = import(&bytes).unwrap(); + assert!(reloaded.unsupported.is_empty()); + assert_eq!(reloaded.actions[0].steps[0].1, expected_params); + } + } +} + +/// Missing defaults, malformed/repeated fields and unrepresented workspace state stay complete. +#[test] +fn caf_unrepresented_descriptors_are_kept_and_never_partially_play() { + let base = descriptor(); + let mut cases = vec![Descriptor::new("null"), base.clone().with("extra", D::Boolean(true))]; + for i in 0..base.items.len() { + let mut missing = base.clone(); + missing.items.remove(i); + cases.push(missing); + let mut repeated = base.clone(); + repeated.items.push(repeated.items[i].clone()); + cases.push(repeated); + let mut wrong = base.clone(); + wrong.items[i].1 = D::Integer(0); + cases.push(wrong); + } + for (i, key, value) in [ + (0, "cafSamplingRegion", "cafSamplingRegionAuto"), + (0, "cafSamplingRegion", "cafSamplingRegionCustom"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationNone"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationHigh"), + (2, "cafColorAdaptationLevel", "cafColorAdaptationVeryHigh"), + (2, "cafColorAdaptationLevel", "unknown"), + (3, "cafRotationAmount", "cafRotationAmountLow"), + (3, "cafRotationAmount", "cafRotationAmountMedium"), + (3, "cafRotationAmount", "cafRotationAmountHigh"), + (3, "cafRotationAmount", "cafRotationAmountFull"), + (3, "cafRotationAmount", "unknown"), + (6, "cafOutput", "cafOutputToDuplicateLayer"), + ] { + let mut d = base.clone(); + d.items[i].1 = enm(key, value); + cases.push(d); + } + let mut wrong_class = base.clone(); + wrong_class.items[0].1 = enm("unrelatedEnum", "cafSamplingRegionRectangular"); + cases.push(wrong_class); + for d in cases { + let source = item(d); + let imported = import(&atn::write(&file(source.clone())).unwrap()).unwrap(); + assert_eq!(imported.unsupported.len(), 1); + assert_eq!(imported.actions[0].steps[0].0, "actions.photoshopStep"); + let saved = export("S", &[&imported.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0], source); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(imported.actions); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// Known native defaults become explicit values; unsupported defaults/options cannot disappear. +#[test] +fn caf_native_exports_preserve_effective_defaults_and_refuse_unrepresented_options() { + for p in [json!({"sampling":"rectangular","output":"new"}), json!({"sampling":"rectangular","output":"new","seed":1,"target":"pixels"}), params()] { + let a = load(exported(p).unwrap().descriptor.unwrap()); + assert_eq!(a.steps, vec![(CMD.into(), params())]); + } + let mut current = params(); + current["output"] = json!("current"); + for p in [json!({"sampling":"rectangular"}), current.clone()] { + let a = load(exported(p).unwrap().descriptor.unwrap()); + assert_eq!(a.steps, vec![(CMD.into(), current.clone())]); + } + for (key, value) in [ + ("sampling", json!("auto")), + ("sampling", json!("custom")), + ("sampling", json!("unknown")), + ("output", json!("duplicate")), + ("colorAdaptation", json!("none")), + ("colorAdaptation", json!("high")), + ("colorAdaptation", json!("veryHigh")), + ("rotationAdaptation", json!("low")), + ("rotationAdaptation", json!("medium")), + ("rotationAdaptation", json!("high")), + ("rotationAdaptation", json!("full")), + ("sampleAllLayers", json!(0)), + ("scale", Value::Null), + ("mirror", json!("true")), + ("seed", json!(2)), + ("layer", json!(1)), + ("margin", json!(10)), + ("area", json!([0, 0, 4, 4])), + ("channel", json!(0)), + ("samplingStrokes", json!([])), + ("fillEdits", json!([])), + ("target", json!("mask")), + ("_atnCafDescriptor", Value::Null), + ] { + let mut p = params(); + p[key] = value; + assert!(exported(p.clone()).is_none(), "{p}"); + } + for p in [json!({}), json!({"output":"new"}), json!([])] { + assert!(exported(p.clone()).is_none(), "unrepresented native default: {p}"); + } +} + +fn session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap(); + s.execute("edit.fill", json!({"contents":"foreground"})).unwrap(); + s +} + +/// Compare the complete small fixture, including pixels outside the selected fill area. +fn assert_same_pixels(actual: &Surface, expected: &Surface, depth: u32, output: &str) { + for y in 0..8 { + for x in 0..8 { + assert_eq!(actual.rgba(x, y), expected.rgba(x, y), "{depth}/{output}: pixel{x},{y}"); + } + } +} + +/// Native and imported fills produce the same real pixels, source/selection state and Undo/Redo. +#[test] +fn caf_action_matches_native_pixels_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let mut s = session(depth); + let before = s.active().unwrap().doc.clone(); + let source = s.active().unwrap().active_layer.unwrap(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.push(load(descriptor())); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + assert_eq!(result["ran"], 1); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.selection, before.selection); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + assert_eq!(after.layer(source), before.layer(source), "new output keeps its source unchanged"); + let id = s.active().unwrap().active_layer.unwrap(); + let pixels = after.layer(id).unwrap().surface().unwrap(); + let center = pixels.rgba(3, 3); + assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: hole was not filled: {center:?}"); + assert_eq!(pixels.rgba(0, 0)[3], 0.0, "new output is transparent outside the selection"); + let mut native = session(depth); + native.execute(CMD, params()).unwrap(); + let expected = &native.active().unwrap().doc; + assert_eq!(after.layers.len(), expected.layers.len()); + for (a, b) in after.layers.iter().zip(&expected.layers) { + let (a, b) = (a.surface().unwrap(), b.surface().unwrap()); + assert_same_pixels(a, b, depth, "new"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc, after); + + // Current Layer keeps one layer and matches the ordinary native operation as well. + let mut current = session(depth); + let mut p = params(); + p["output"] = json!("current"); + current.execute(CMD, p).unwrap(); + assert_eq!(current.active().unwrap().doc.layers.len(), 1); + let center = current.active().unwrap().doc.layers[0].surface().unwrap().rgba(3, 3); + assert!(center.iter().all(|v| (*v - 1.0).abs() < 0.01), "{depth}: native Current Layer control failed: {center:?}"); + let mut recorded = session(depth); + let before = recorded.active().unwrap().doc.clone(); + let source = recorded.active().unwrap().active_layer.unwrap(); + let past = recorded.active().unwrap().history.past_len(); + let mut d = descriptor(); + d.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer"); + recorded.actions.list.push(load(d)); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len()); + assert_eq!(after.selection, before.selection); + assert_eq!(recorded.active().unwrap().active_layer, Some(source)); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + let expected = current.active().unwrap().doc.layers[0].surface().unwrap(); + let actual = after.layer(source).unwrap().surface().unwrap(); + assert_same_pixels(actual, expected, depth, "current"); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc, before); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc, after); + } +} + +/// A translucent active layer over white makes layer-only and composite samples differ. +fn multilayer_session(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"white"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Translucent sample"})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.edit("Translucent sample pixels", |doc, _| { + let bounds = doc.bounds(); + doc.layer_mut(layer).unwrap().surface_mut().unwrap().write_region(bounds, &[0.0, 0.0, 1.0, 0.5].repeat(64)); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":3,"y":3,"width":2,"height":2})).unwrap(); + s +} + +/// Current Layer preserves both sampling choices; distinct native controls detect a forced false. +#[test] +fn caf_current_layer_sampling_matches_distinct_multilayer_controls() { + for depth in [8, 16, 32] { + let mut native_centers = Vec::new(); + for all_layers in [false, true] { + let mut native = multilayer_session(depth); + let mut p = params(); + p["output"] = json!("current"); + p["sampleAllLayers"] = json!(all_layers); + native.execute(CMD, p).unwrap(); + let native_id = native.active().unwrap().active_layer.unwrap(); + let expected = native.active().unwrap().doc.layer(native_id).unwrap().surface().unwrap(); + native_centers.push(expected.rgba(3, 3)); + + let mut recorded = multilayer_session(depth); + let before = recorded.active().unwrap().doc.clone(); + let source = recorded.active().unwrap().active_layer.unwrap(); + let past = recorded.active().unwrap().history.past_len(); + let mut d = descriptor(); + d.items[1].1 = D::Boolean(all_layers); + d.items[6].1 = enm("cafOutput", "cafOutputToCurrentLayer"); + recorded.actions.list.push(load(d)); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}/{all_layers}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), 2); + assert_eq!(after.selection, before.selection); + assert_eq!(recorded.active().unwrap().active_layer, Some(source)); + // One `Action:` entry when the play changed the document, none when it didn't: a fill + // from the uniform layer alone can rewrite it with the same pixels (#10944). + let changed = usize::from(after != before); + assert_eq!(recorded.active().unwrap().history.past_len(), past + changed); + let actual = after.layer(source).unwrap().surface().unwrap(); + assert_same_pixels(actual, expected, depth, "current multilayer"); + assert_eq!(actual.rgba(0, 0), before.layer(source).unwrap().surface().unwrap().rgba(0, 0)); + for layer in before.layers.iter().filter(|layer| layer.id != source) { + assert_eq!(after.layer(layer.id), Some(layer)); + } + if changed == 1 { + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc, before); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc, after); + } + } + assert_ne!(native_centers[0], native_centers[1], "{depth}: fixture must distinguish layer-only from composite sampling"); + } +} + +/// The adapter cannot manufacture a selection or turn a refused native operation into an edit. +#[test] +fn caf_action_without_selection_fails_without_an_edit() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.push(load(descriptor())); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); +} diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs index 7b72a6ff70..8a015f1497 100644 --- a/crates/engine/tests/it/atn_crystallize_cell_size.rs +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -24,7 +24,7 @@ fn fixture(depth: u32) -> Session { /// 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 + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Fractions remain valid native edits; strict integer ATN conversion must refuse them. diff --git a/crates/engine/tests/it/atn_direct_adjustments.rs b/crates/engine/tests/it/atn_direct_adjustments.rs new file mode 100644 index 0000000000..8f59433b9e --- /dev/null +++ b/crates/engine/tests/it/atn_direct_adjustments.rs @@ -0,0 +1,699 @@ +//! Recorded direct Gradient Map and Replace Color forms from #10603 / #10607. +use photocraft_engine::{Session, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::json; + +fn enumeration(kind: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(kind), value: Id::new(value) } +} + +fn color(red: f64, green: f64, blue: f64) -> D { + D::Descriptor(Descriptor::new("RGBC").with("Rd ", D::Double(red)).with("Grn ", D::Double(green)).with("Bl ", D::Double(blue))) +} + +fn gradient() -> Descriptor { + let stop = |location, rgb| { + D::Descriptor( + Descriptor::new("Clrt").with("Clr ", rgb).with("Type", enumeration("Clry", "UsrS")).with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)), + ) + }; + let opacity = |location| { + D::Descriptor( + Descriptor::new("TrnS").with("Opct", D::UnitFloat { unit: *b"#Prc", value: 100.0 }).with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)), + ) + }; + Descriptor::new("null").with("gradientsInterpolationMethod", enumeration("gradientInterpolationMethodType", "Gcls")).with( + "Grad", + D::Descriptor( + Descriptor::new("Grdn") + .with("Nm ", D::Text(UnicodeString::new_nul("Recorded gradient"))) + .with("GrdF", enumeration("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(vec![stop(0, color(0.0, 0.0, 255.0)), stop(4096, color(255.0, 0.0, 0.0))])) + .with("Trns", D::List(vec![opacity(0), opacity(4096)])), + ), + ) +} + +fn replace() -> Descriptor { + let lab = || D::Descriptor(Descriptor::new("LbCl").with("Lmnc", D::Double(40.0)).with("A ", D::Double(30.0)).with("B ", D::Double(40.0))); + Descriptor::new("null") + .with("Fzns", D::Integer(40)) + .with("Mnm ", lab()) + .with("Mxm ", lab()) + .with("H ", D::Integer(30)) + .with("Strt", D::Integer(0)) + .with("Lght", D::Integer(0)) + .with("colorModel", D::Integer(0)) +} + +fn bytes(event: [u8; 4], descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Direct adjustments"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Recorded"), + items: vec![AtnItem { event: AtnEvent::Code(event), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + ..Default::default() + }) + .unwrap() +} + +fn load(event: [u8; 4], descriptor: Descriptor, command: &str) -> Session { + let mut s = Session::new(); + let bytes = bytes(event, descriptor); + let report = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(report["unsupported"], json!([]), "recorded {} must map to {command}", String::from_utf8_lossy(&event)); + assert_eq!(s.actions.list[0].steps[0].0, command); + s +} + +#[test] +fn recorded_grmp_imports_as_direct_gradient_map() { + let s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap"); + assert_eq!(s.actions.list[0].steps[0].1["stops"], json!([[0, [0, 0, 1]], [1, [1, 0, 0]]])); +} + +#[test] +fn recorded_rplc_imports_as_direct_replace_color() { + let s = load(*b"RplC", replace(), "image.adjustments.replaceColor"); + assert_eq!(s.actions.list[0].steps[0].1["hue"], json!(30)); +} + +fn saved(s: &Session) -> AtnItem { + let refs = s.actions.list.iter().collect::>(); + let (bytes, skipped) = atn_cmds::export("Direct adjustments", &refs, &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0) +} + +/// Edit a known nested fixture object without replacing its surrounding descriptor. +fn object<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Descriptor { + match &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 { + D::Descriptor(d) => d, + _ => panic!("not an object"), + } +} + +/// Expose a fixture list so negative cases change only the intended wire setting. +fn list<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Vec { + match &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 { + D::List(l) => l, + _ => panic!("not a list"), + } +} + +/// Locate the first color stop for fractional-color and unsupported-setting controls. +fn first_stop(d: &mut Descriptor) -> &mut Descriptor { + match &mut list(object(d, "Grad"), "Clrs")[0] { + D::Descriptor(d) => d, + _ => panic!("not a stop"), + } +} + +/// Saving retains the measured wire forms, including source colors that hex would quantize. +#[test] +fn direct_forms_save_exact_descriptors_including_fractional_rgb() { + let mut fractional = gradient(); + object(first_stop(&mut fractional), "Clr ").items[0].1 = D::Double(73.1234567); + let mut defaults = gradient(); + object(first_stop(&mut defaults), "Clr ").items[2].1 = D::Double(0.0); + let g = object(&mut defaults, "Grad"); + let D::Descriptor(stop) = &mut list(g, "Clrs")[1] else { panic!("stop") }; + object(stop, "Clr ").items[1].1 = D::Double(255.0); + object(stop, "Clr ").items[2].1 = D::Double(255.0); + let mut implicit_classic = gradient(); + implicit_classic.items.remove(0); + for descriptor in [defaults, gradient(), fractional, implicit_classic] { + let s = load(*b"GrMp", descriptor.clone(), "image.adjustments.gradientMap"); + let item = saved(&s); + assert_eq!(item.event, AtnEvent::Code(*b"GrMp")); + assert_eq!(item.descriptor, Some(descriptor)); + } + let mut changed = replace(); + object(&mut changed, "Mnm ").items[0].1 = D::Double(20.125); + changed.items[0].1 = D::Integer(130); + changed.items[3].1 = D::Integer(-120); + changed.items[4].1 = D::Integer(45); + changed.items[5].1 = D::Integer(-20); + for descriptor in [replace(), changed] { + let s = load(*b"RplC", descriptor.clone(), "image.adjustments.replaceColor"); + let item = saved(&s); + assert_eq!(item.event, AtnEvent::Code(*b"RplC")); + assert_eq!(item.descriptor, Some(descriptor)); + } +} + +fn fixture(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":1,"mode":mode,"depth":depth})).unwrap(); + s.edit("original pixels", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = surface.format(); + let data = [[0.0, 0.0, 0.0, 1.0], [0.3, 0.8, 0.2, 1.0], [0.7, 0.2, 0.6, 0.4], [1.0, 1.0, 1.0, 1.0]] + .into_iter() + .flat_map(|p| photocraft_raster::from_rgba(&format, p)) + .collect::>(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 4, 1), &data); + Ok(()) + }) + .unwrap(); + s +} + +fn raw(s: &Session) -> Vec { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) +} + +fn play(s: &mut Session) { + let report = s.execute("actions.play", json!({"action":"Recorded","set":"Direct adjustments"})).unwrap(); + assert_eq!(report["ran"], 1, "{report}"); + assert!(report.get("failed").is_none(), "{report}"); +} + +/// Execute fractional stops at every depth while preserving unselected samples and Undo. +#[test] +fn fractional_gradient_changes_pixels_and_preserves_selection_and_undo() { + let mut descriptor = gradient(); + object(first_stop(&mut descriptor), "Clr ").items[0].1 = D::Double(73.125); + for depth in [8, 16, 32] { + let imported = atn_cmds::import(&bytes(*b"GrMp", descriptor.clone())).unwrap(); + assert!(imported.unsupported.is_empty()); + let mut direct = fixture("rgb", depth); + direct.execute("select.rect", json!({"x":0,"y":0,"width":3,"height":1})).unwrap(); + let original = raw(&direct); + direct.execute("image.adjustments.gradientMap", imported.actions[0].steps[0].1.clone()).unwrap(); + let expected = raw(&direct); + assert_ne!(expected, original); + assert_eq!(&expected[12..], &original[12..], "outside the selection"); + let mut replay = fixture("rgb", depth); + replay.execute("select.rect", json!({"x":0,"y":0,"width":3,"height":1})).unwrap(); + replay.actions.list = imported.actions; + play(&mut replay); + assert_eq!(raw(&replay), expected); + assert!(replay.undo()); + assert_eq!(raw(&replay), original); + if depth == 32 { + assert!((expected[0] - 73.125 / 255.0).abs() < 1e-6, "fractional color must not be rounded to hex"); + } + } +} + +fn set_lab_pixels(s: &mut Session) { + s.edit("known Lab pixels", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surface.write_region( + photocraft_doc::Rect::from_xywh(0, 0, 4, 1), + &[ + 0.4, + 128.0 / 255.0, + 128.0 / 255.0, + 1.0, + 0.5, + 128.0 / 255.0, + 128.0 / 255.0, + 1.0, + 0.6, + 128.0 / 255.0, + 128.0 / 255.0, + 1.0, + 0.95, + 128.0 / 255.0, + 128.0 / 255.0, + 1.0, + ], + ); + Ok(()) + }) + .unwrap(); +} + +/// Different box widths must select different pixels even when their midpoint is identical. +#[test] +fn lab_boxes_with_the_same_midpoint_have_different_real_effects() { + // Independent mask anchors: at zero fuzziness a neutral Lab50 pixel is + // inside both boxes, Lab40/60 only the wide one, and Lab95 is outside both. + for depth in [8, 16, 32] { + let mut results = Vec::new(); + for (minimum, maximum) in [(49, 51), (39, 61)] { + let mut s = fixture("lab", depth); + set_lab_pixels(&mut s); + let before = raw(&s); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[minimum,0,0],"labMaximum":[maximum,0,0],"fuzziness":0,"lightness":100})).unwrap(); + let after = raw(&s); + assert!(after[4] > 0.99, "Lab50 must become white: {after:?}"); + assert!((after[12] - before[12]).abs() < 0.005, "Lab95 stays outside"); + if minimum == 49 { + assert!((after[0] - before[0]).abs() < 0.005); + } else { + assert!(after[0] > 0.99 && after[8] > 0.99, "wide box includes Lab40/60"); + } + results.push(after); + assert!(s.undo()); + assert_eq!(raw(&s), before); + } + assert_ne!(results[0], results[1], "collapsing a box to its midpoint is incorrect"); + } +} + +/// Conflicting or invalid Lab settings leave both pixels and history untouched. +#[test] +fn invalid_lab_inputs_are_rejected_before_any_edit() { + let mut s = fixture("rgb", 32); + for patch in [ + json!({"labMaximum":null}), + json!({"labMinimum":[0,0]}), + json!({"labMinimum":[1e100,0,0]}), + json!({"labMinimum":[50.0000001,0,0],"labMaximum":[50,0,0]}), + json!({"color":"#ff0000"}), + json!({"points":[]}), + json!({"localized":false}), + json!({"target":"mask"}), + json!({"hue":"30"}), + json!({"layer":999999}), + ] { + let mut p = json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"lightness":100}); + p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone()); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("image.adjustments.replaceColor", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().doc, before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{p}"); + } +} + +fn kept(event: [u8; 4], d: Descriptor) { + let imported = atn_cmds::import(&bytes(event, d.clone())).unwrap(); + assert_eq!(imported.unsupported.len(), 1, "{d:?}"); + let mut s = Session::new(); + s.actions.list = imported.actions; + assert_eq!(saved(&s).descriptor, Some(d)); +} + +/// Unsupported interpolation, transparency and wire types survive as complete kept steps. +#[test] +fn unsupported_gradient_settings_and_wire_forms_remain_kept() { + let mut midpoint = gradient(); + first_stop(&mut midpoint).items[3].1 = D::Integer(30); + let mut transparency = gradient(); + let D::Descriptor(opacity) = &mut list(object(&mut transparency, "Grad"), "Trns")[0] else { panic!("opacity") }; + opacity.items[0].1 = D::UnitFloat { unit: *b"#Prc", value: 50.0 }; + let mut interpolation = gradient(); + interpolation.items[0].1 = enumeration("gradientInterpolationMethodType", "Lnr "); + let mut order = gradient(); + list(object(&mut order, "Grad"), "Clrs").reverse(); + let mut duplicate = gradient(); + duplicate.items.push(duplicate.items[0].clone()); + let mut unknown = gradient(); + unknown.items.push((Id::new("extra"), D::Boolean(true))); + for d in [midpoint, transparency, interpolation, order, duplicate, unknown] { + kept(*b"GrMp", d); + } + let mut f = atn::parse(&bytes(*b"GrMp", gradient())).unwrap(); + f.actions[0].items[0].event = AtnEvent::name("GrMp"); + assert_eq!(atn_cmds::import(&atn::write(&f).unwrap()).unwrap().unsupported.len(), 1); +} + +/// Invalid recorded boxes stay preserved, and edited or unrepresentable native settings cannot save lossily. +#[test] +fn invalid_replace_descriptors_and_lossy_exports_are_refused() { + let mut model = replace(); + model.items[6].1 = D::Integer(1); + let mut inverted = replace(); + object(&mut inverted, "Mnm ").items[0].1 = D::Double(80.0); + let mut missing = replace(); + missing.items.remove(0); + let mut unknown = replace(); + unknown.items.push((Id::new("extra"), D::Boolean(true))); + let mut duplicate = replace(); + duplicate.items.push(duplicate.items[0].clone()); + for d in [model, inverted, missing, unknown, duplicate] { + kept(*b"RplC", d); + } + let mut s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap"); + s.actions.list[0].steps[0].1["stops"][0][1][0] = json!(0.2); + assert!(atn_cmds::export("Direct adjustments", &[&s.actions.list[0]], &[], false).is_err(), "stale color metadata must not fall through to GdMp"); + for p in [json!({"color":"#ff0000"}), json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"hue":0.5})] { + let a = photocraft_engine::actions_cmds::Action::new("Native", vec![("image.adjustments.replaceColor".into(), p)]); + assert!(atn_cmds::export("Direct adjustments", &[&a], &[], false).is_err()); + } +} + +/// Playback respects bounded and implicitly selected masks at every depth, including an offset selection and Undo. +#[test] +fn recorded_replace_color_clamps_shifts_and_plays_the_lab_box_at_every_depth() { + for (depth, inverse) in [8, 16, 32].into_iter().flat_map(|depth| [(depth, false), (depth, true)]) { + let mut d = replace(); + for key in ["Mnm ", "Mxm "] { + let o = object(&mut d, key); + o.items[0].1 = D::Double(if key == "Mnm " { 39.0 } else { 61.0 }); + o.items[1].1 = D::Double(0.0); + o.items[2].1 = D::Double(0.0); + } + d.items[0].1 = D::Integer(-10); + d.items[3].1 = D::Integer(0); + d.items[5].1 = D::Integer(250); + let imported = atn_cmds::import(&bytes(*b"RplC", d)).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].1["fuzziness"], 0); + assert_eq!(imported.actions[0].steps[0].1["lightness"], 100); + let mut s = fixture("lab", depth); + set_lab_pixels(&mut s); + s.execute("select.rect", json!({"x":1,"y":0,"width":1,"height":1})).unwrap(); + if inverse { + let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[1.0]); + mask.fill_rect(photocraft_doc::Rect::new(1, 0, 2, 1), &[0.0]); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(mask); + } + let before = raw(&s); + s.actions.list = imported.actions; + play(&mut s); + let after = raw(&s); + if inverse { + assert!(after[0] > 0.99 && after[8] > 0.99); + assert_eq!(&after[4..8], &before[4..8], "the selection hole must stay unchanged"); + } else { + assert!(after[4] > 0.99); + assert_eq!(&after[..4], &before[..4], "pixels before the selection must stay unchanged"); + assert_eq!(&after[8..], &before[8..], "pixels after the selection must stay unchanged"); + } + assert!(s.undo()); + assert_eq!(raw(&s), before); + let descriptor = saved(&s).descriptor.unwrap(); + assert_eq!(descriptor.get("Fzns"), Some(&D::Integer(0))); + assert_eq!(descriptor.get("Lght"), Some(&D::Integer(100))); + } +} + +/// Matching uses original Lab/CMYK channels and tagged RGB rather than display-converted samples. +#[test] +fn lab_matching_uses_native_channels_and_the_embedded_rgb_profile() { + use photocraft_cms::{Builtin, Transform, TransformOptions}; + for depth in [8, 16, 32] { + for (mode, profile) in [("rgb", Builtin::DisplayP3), ("gray", Builtin::SGray), ("cmyk", Builtin::CoatedCmyk), ("lab", Builtin::LabD50)] { + let mut s = fixture(mode, depth); + s.edit("explicit profile", |doc, _| { + doc.icc_profile = Some(profile.profile().clone().with_encoded_bytes().to_bytes()); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let format = surface.format(); + let before = raw(&s); + // Independent direct transform from this explicitly chosen source profile, + // not the command's profile resolver or its new matching helper. + let t = Transform::with_options(profile.profile(), Builtin::LabD50.profile(), TransformOptions::default()).unwrap(); + let mut lab = [[0.0, 0.0, 0.0, 1.0]; 4]; + t.convert_f32(&before, format.channels(), lab.as_flattened_mut(), 4, true); + let center = [lab[1][0] as f64 * 100.0, lab[1][1] as f64 * 255.0 - 128.0, lab[1][2] as f64 * 255.0 - 128.0]; + let lo = center.map(|v| v - 0.2); + let hi = center.map(|v| v + 0.2); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":lo,"labMaximum":hi,"fuzziness":0,"lightness":100})).unwrap(); + let after = raw(&s); + let changed = after.chunks_exact(format.channels()).nth(1).unwrap(); + let changed = photocraft_raster::to_rgba(&format, changed); + assert!(changed[..3].iter().all(|v| *v > 0.97), "{mode}/{depth}: {changed:?}"); + assert_eq!(&after[..format.channels()], &before[..format.channels()], "black is outside {mode}/{depth}"); + assert!(s.undo()); + assert_eq!(raw(&s), before); + if mode == "rgb" { + let mut srgb = fixture("rgb", depth); + let before_srgb = raw(&srgb); + srgb.execute("image.adjustments.replaceColor", json!({"labMinimum":lo,"labMaximum":hi,"fuzziness":0,"lightness":100})).unwrap(); + assert_eq!(raw(&srgb), before_srgb, "the P3 sample must not be mistaken for an sRGB sample"); + } + } + } +} + +/// Full and selected edits agree across tile edges, including the final partial tiles and Undo. +#[test] +fn lab_replace_crosses_tile_edges_without_changing_unselected_native_pixels() { + let tile = photocraft_geom::TILE_SIZE; + let area = photocraft_doc::Rect::new(0, 0, tile + 4, tile + 3); + let window = photocraft_doc::Rect::new(tile - 1, tile - 1, tile + 2, tile + 2); + let width = area.width() as usize; + for depth in [8, 16, 32] { + for selected in ["none", "window", "outside"] { + let mut s = fixture("lab", depth); + s.edit("Known Lab tiles", |doc, active| { + doc.size = area.size(); + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(area, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + if selected != "none" { + let default = if selected == "outside" { 1.0 } else { 0.0 }; + let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[default]); + mask.fill_rect(window, &[1.0 - default]); + doc.selection = Some(mask); + } + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let original = raw(&s); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap(); + let after = raw(&s); + for (i, (pixel, original)) in after.as_chunks::<4>().0.iter().zip(original.as_chunks::<4>().0).enumerate() { + let inside = window.contains((i % width) as i32, (i / width) as i32); + let changed = selected == "none" || (selected == "window") == inside; + if changed { + assert!(pixel[0] > 0.99, "{depth}/{selected} pixel {i}: {pixel:?}"); + } else { + assert_eq!(pixel, original, "{depth}/{selected} pixel {i}"); + } + assert_eq!(pixel[3], original[3]); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +/// Matching stored pixels change without materializing gaps whose default does not change. +#[test] +fn lab_replace_sparse_pixels_preserve_unstored_gaps() { + let tile = photocraft_geom::TILE_SIZE; + let far = photocraft_doc::Rect::new(4 * tile, 4 * tile, 4 * tile + 1, 4 * tile + 1); + let second = far.translate(2 * tile, 0); + let gap = far.translate(tile, 0); + for (alpha, opacity) in [(true, 0.0), (true, 1.0), (false, 1.0)] { + let mut s = fixture("lab", 32); + s.edit("Two distant Lab pixels", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = photocraft_doc::PixelFormat { alpha, ..surface.format() }; + let channels = format.channels(); + *surface = photocraft_raster::Surface::with_default(format, &[0.25, 128.0 / 255.0, 128.0 / 255.0, opacity][..channels]); + for rect in [far, second] { + surface.fill_rect(rect, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]); + } + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + let original = before.layer(layer).unwrap().surface().unwrap(); + assert_eq!(original.tile_count(), 2); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap(); + let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap(); + for rect in [far, second] { + assert!(surface.read_region(rect)[0] > 0.99); + } + assert_eq!(surface.read_region(gap), original.read_region(gap)); + assert_eq!(surface.default_pixel(), original.default_pixel()); + assert_eq!(surface.tile_count(), 2); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Opaque defaults remain visible when the surface has no tiles or only a small exception. +#[test] +fn lab_replace_includes_visible_default_pixels_with_and_without_alpha() { + let tile = photocraft_geom::TILE_SIZE; + let far = photocraft_doc::Rect::new(2 * tile + 1, 2 * tile + 1, 2 * tile + 2, 2 * tile + 2); + for depth in [8, 16, 32] { + for alpha in [false, true] { + for exception in [None, Some(photocraft_doc::Rect::new(1, 0, 2, 1)), Some(far)] { + let mut s = fixture("lab", depth); + s.edit("Opaque default Lab", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let format = photocraft_doc::PixelFormat { alpha, ..surface.format() }; + let channels = format.channels(); + *surface = photocraft_raster::Surface::with_default(format, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]); + if let Some(rect) = exception { + surface.fill_rect(rect, &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]); + } else { + assert!(surface.content_bounds().is_empty()); + } + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let original = raw(&s); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap(); + for (i, (pixel, original)) in raw(&s).chunks_exact(3 + usize::from(alpha)).zip(original.chunks_exact(3 + usize::from(alpha))).enumerate() { + if exception == Some(photocraft_doc::Rect::new(1, 0, 2, 1)) && i == 1 { + assert_eq!(pixel, original); + } else { + assert!(pixel[0] > 0.99, "{depth}/{alpha}/{exception:?} pixel {i}: {pixel:?}"); + } + } + if exception == Some(far) { + let layer = s.active().unwrap().active_layer.unwrap(); + let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap(); + assert!(surface.read_region(far.translate(-tile, 0))[0] > 0.99, "off-canvas defaults inside the edit extent change too"); + assert_eq!(surface.read_region(far), before.layer(layer).unwrap().surface().unwrap().read_region(far)); + assert_eq!(surface.read_region(far.translate(2, 2)), surface.default_pixel(), "pixels outside the extent stay unchanged"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } + } +} + +/// Selection islands and holes retain their coverage over unstored opaque pixels. +#[test] +fn lab_replace_selection_controls_opaque_gap_pixels() { + let tile = photocraft_geom::TILE_SIZE; + let far = photocraft_doc::Rect::new(2 * tile, 2 * tile, 2 * tile + 1, 2 * tile + 1); + let gap = far.translate(-tile, -tile); + for implicit in [false, true] { + let mut s = fixture("lab", 32); + s.edit("Selected opaque gap", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + *surface = photocraft_raster::Surface::with_default(surface.format(), &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + surface.fill_rect(far, &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + let coverage = if implicit { 1.0 } else { 0.0 }; + let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[coverage]); + mask.fill_rect(gap, &[1.0 - coverage]); + doc.selection = Some(mask); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + let original = before.layer(layer).unwrap().surface().unwrap(); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap(); + let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap(); + let canvas_pixel = photocraft_doc::Rect::new(0, 0, 1, 1); + for (rect, changed) in [(gap, !implicit), (canvas_pixel, implicit)] { + if changed { + assert!(surface.read_region(rect)[0] > 0.99); + } else { + assert_eq!(surface.read_region(rect), original.read_region(rect)); + } + } + assert_eq!(surface.read_region(far), original.read_region(far)); + assert_eq!(surface.default_pixel(), original.default_pixel()); + if !implicit { + assert_eq!(surface.tile_count(), 2, "only the selected gap tile is added"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Existing RGB replacement and ordinary gradient export retain their native behavior. +#[test] +fn legacy_rgb_replace_color_and_ordinary_gradient_exports_are_unchanged() { + let mut s = fixture("rgb", 32); + let before = raw(&s); + let mut expected = before.as_chunks::<4>().0.to_vec(); + photocraft_algo::tone::replace_color(&mut expected, [0.3, 0.8, 0.2], 40.0, 60.0, 20.0, -10.0); + s.execute("image.adjustments.replaceColor", json!({"color":[0.3,0.8,0.2],"fuzziness":40,"hue":60,"saturation":20,"lightness":-10})).unwrap(); + assert_eq!(raw(&s), expected.as_flattened()); + let action = photocraft_engine::actions_cmds::Action::new( + "Ordinary", + vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#000000"],[1,"#ffffff"]]}))], + ); + let (b, _) = atn_cmds::export("Ordinary", &[&action], &[], false).unwrap(); + assert_eq!( + atn::parse(&b).unwrap().actions[0].items[0].event, + AtnEvent::name("gradientMapEvent"), + "an ordinary Gradient Map saves as the string event (#10702)" + ); +} + +/// Replacement preserves alpha and refuses locked or profile-incompatible surfaces before editing. +#[test] +fn lab_replace_preserves_alpha_and_rejects_locked_or_mismatched_surfaces() { + let p = json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"lightness":100}); + let mut s = fixture("rgb", 32); + let before = raw(&s); + s.execute("image.adjustments.replaceColor", p.clone()).unwrap(); + for (a, b) in raw(&s).as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) { + assert_eq!(a[3], b[3]); + } + assert!(s.undo()); + s.edit("pixel lock", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.pixels = true; + Ok(()) + }) + .unwrap(); + let original = s.active().unwrap().doc.clone(); + assert!(s.execute("image.adjustments.replaceColor", p.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, original); + let mut mismatch = fixture("rgb", 32); + mismatch + .edit("inconsistent synthetic mode", |doc, _| { + doc.mode = photocraft_doc::ColorMode::Cmyk; + Ok(()) + }) + .unwrap(); + let original = mismatch.active().unwrap().doc.clone(); + assert!(mismatch.execute("image.adjustments.replaceColor", p).is_err()); + assert_eq!(mismatch.active().unwrap().doc, original); +} + +/// Fractional selection mixing changes RGB while preserving each F32 alpha bit exactly. +#[test] +fn lab_replace_fractional_selection_preserves_f32_alpha_bits() { + for coverage in [53.0 / 255.0, 127.0 / 255.0] { + let mut s = fixture("rgb", 32); + s.edit("fractional alpha and selection", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_region(photocraft_doc::Rect::new(2, 0, 3, 1), &[0.7, 0.2, 0.6, 0.7]); + doc.selection = Some(photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[coverage])); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let original = raw(&s); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"lightness":100})).unwrap(); + let after = raw(&s); + assert_ne!(&after[8..11], &original[8..11]); + for (after, before) in after.as_chunks::<4>().0.iter().zip(original.as_chunks::<4>().0) { + assert_eq!(after[3].to_bits(), before[3].to_bits(), "coverage {coverage}"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// A zero shift must not alter native ink values through an unnecessary RGB separation. +#[test] +fn an_unchanged_lab_tone_shift_preserves_native_cmyk_samples() { + for depth in [8, 16, 32] { + let mut s = fixture("cmyk", depth); + s.edit("nontrivial native CMYK", |doc, active| { + doc.layer_mut(active.unwrap()) + .unwrap() + .surface_mut() + .unwrap() + .write_region(photocraft_doc::Rect::from_xywh(1, 0, 1, 1), &[0.2, 0.3, 0.4, 0.25, 1.0]); + Ok(()) + }) + .unwrap(); + let before = raw(&s); + s.execute("image.adjustments.replaceColor", json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"hue":0,"saturation":0,"lightness":0})) + .unwrap(); + assert_eq!(raw(&s), before, "an unchanged tone operation must not re-separate CMYK, depth {depth}"); + } +} diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs new file mode 100644 index 0000000000..59b484022a --- /dev/null +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -0,0 +1,522 @@ +//! Measured layer-style action steps retain their settings and use the existing native commands. +use photocraft_doc::{Document, Effect, LayerId}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const SCALE: &str = "layer.layerStyle.scaleEffects"; +const CREATE: &str = "layer.layerStyle.createLayer"; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::default(), class_id: Id::new(id) } +} + +fn scale(value: f64) -> Descriptor { + Descriptor::new("null").with("Scl ", D::UnitFloat { unit: *b"#Prc", value }) +} + +fn create() -> Descriptor { + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with( + "Usng", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + ) +} + +fn item(id: &str, descriptor: Descriptor) -> AtnItem { + let (event, name) = if id == SCALE { (AtnEvent::name("scaleEffectsEvent"), "Scale Effects") } else { (AtnEvent::Code(*b"Mk "), "Make") }; + AtnItem { event, dictionary: name.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() } +} + +fn file(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + } +} + +fn action(items: Vec) -> Action { + let loaded = import(&atn::write(&file(items)).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + loaded.actions.into_iter().next().unwrap() +} + +fn exported(id: &str, params: Value) -> Option { + let a = Action::new("A", vec![(id.into(), params)]); + let bytes = export("S", &[&a], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() +} + +/// Wire bytes also compare preserved NaN payloads, whose floating values are not reflexively equal. +fn same_item_wire(mut actual: AtnItem, mut expected: AtnItem) { + assert_eq!(actual.descriptor.take().map(|d| d.to_bytes()), expected.descriptor.take().map(|d| d.to_bytes())); + assert_eq!(actual, expected); +} + +/// The direct control must actually scale the known one-pixel shadow, not merely accept the call. +fn scaled_shadow(s: &Session, source: LayerId, percent: f64) { + let shadow = s + .active() + .unwrap() + .doc + .layer(source) + .unwrap() + .effects + .items + .iter() + .find_map(|effect| match effect { + Effect::DropShadow(shadow) => Some(shadow), + _ => None, + }) + .unwrap(); + let factor = percent as f32 / 100.0; + assert_eq!((shadow.distance, shadow.size), (factor, factor)); +} + +/// A small painted source with two effects makes a real conversion differ from a blank layer. +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Source"})).unwrap(); + let source = s.active().unwrap().active_layer.unwrap(); + s.edit("Synthetic pixels", |doc, _| { + let pixels: Vec = + (0..8).flat_map(|y| (0..8).flat_map(move |x| if (2..6).contains(&x) && (2..6).contains(&y) { [0.2, 0.5, 0.9, 1.0] } else { [0.0; 4] })).collect(); + let bounds = doc.bounds(); + doc.layer_mut(source).unwrap().surface_mut().unwrap().write_region(bounds, &pixels); + Ok(()) + }) + .unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"blend":"normal","distance":1,"size":1,"opacity":60})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color":"#ff8000","opacity":50,"add":true})).unwrap(); + (s, source) +} + +/// Compare each complete raster and its style/stack state without assuming cross-session IDs. +fn equivalent(actual: &Session, expected: &Session) { + let a = &actual.active().unwrap().doc; + let b = &expected.active().unwrap().doc; + assert_eq!(a.layers.len(), b.layers.len()); + assert_eq!(a.selection, b.selection); + for (a, b) in a.layers.iter().zip(&b.layers) { + assert_eq!((&a.name, a.clipped, a.blend, a.opacity), (&b.name, b.clipped, b.blend, b.opacity)); + assert_eq!(a.effects, b.effects); + let (a, b) = (a.surface().unwrap(), b.surface().unwrap()); + assert_eq!(a.content_bounds(), b.content_bounds()); + let bounds = a.content_bounds().union(&b.content_bounds()); + assert_eq!(a.read_region(bounds), b.read_region(bounds)); + } +} + +/// Undo the expected playback history edits, then redo the complete document state. +fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) { + for _ in 0..edits { + assert!(s.undo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), before); + for _ in 0..edits { + assert!(s.redo()); + } + assert_eq!(s.active().unwrap().doc.as_ref(), after); +} + +/// Playback must produce actual effect layers, not the blank layer the old Make branch creates. +#[test] +fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth() { + for depth in [8, 16, 32] { + let (mut native, native_source) = fixture(depth); + let native_before = native.active().unwrap().doc.clone(); + native.execute(CREATE, json!({})).unwrap(); + let native_after = native.active().unwrap().doc.clone(); + assert_eq!(native_after.layers.len(), native_before.layers.len() + 2); + assert!(native_after.layer(native_source).unwrap().effects.items.is_empty()); + undo_redo(&mut native, &native_before, &native_after, 1); + + let (mut recorded, source) = fixture(depth); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(CREATE, create())])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 2, "{depth}: Create Layers must split two effects, not create one blank layer"); + assert!(after.layer(source).unwrap().effects.items.is_empty()); + let at = after.layers.iter().position(|l| l.id == source).unwrap(); + assert!(after.layers[at - 1].name.ends_with("Drop Shadow") && !after.layers[at - 1].clipped); + assert!(after.layers[at + 1].name.ends_with("Color Overlay") && after.layers[at + 1].clipped); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); + } +} + +/// The recorded whole-percent limits and interior values match native effect scaling at all depths. +#[test] +fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() { + for single_state in [false, true] { + for depth in [8, 16, 32] { + for percent in [1.0, 100.0, 200.0, 1000.0] { + let (mut native, native_source) = fixture(depth); + let native_past = native.active().unwrap().history.past_len(); + native.execute(SCALE, json!({"scale":percent})).unwrap(); + scaled_shadow(&native, native_source, percent); + let edits = native.active().unwrap().history.past_len() - native_past; + let (mut recorded, _) = fixture(depth); + recorded.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(SCALE, scale(percent))])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}"); + let after = recorded.active().unwrap().doc.clone(); + // Grouped playback omits the unchanged 100% scale; per-step mode keeps native rows. + let playback_edits = if single_state { usize::from(percent != 100.0) } else { edits }; + assert_eq!(recorded.active().unwrap().history.past_len(), past + playback_edits, "singleState={single_state}, depth={depth}, scale={percent}"); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, playback_edits); + } + } + } +} + +/// Scaling then splitting keeps native pixels with one grouped or two separately reversible edits. +#[test] +fn scale_then_create_layers_matches_native_sequence() { + for single_state in [false, true] { + let (mut native, _) = fixture(16); + native.execute(SCALE, json!({"scale":200})).unwrap(); + native.execute(CREATE, json!({})).unwrap(); + let (mut recorded, _) = fixture(16); + recorded.execute("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())])); + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 2); + assert!(result.get("failed").is_none()); + let edits = if single_state { 1 } else { 2 }; + assert_eq!(recorded.active().unwrap().history.past_len(), past + edits); + let after = recorded.active().unwrap().doc.clone(); + equivalent(&recorded, &native); + if !single_state { + // Per-step playback must also preserve the native intermediate scaled-effects state. + assert!(native.undo()); + assert!(recorded.undo()); + equivalent(&recorded, &native); + assert!(native.redo()); + assert!(recorded.redo()); + } + undo_redo(&mut recorded, &before, &after, edits); + } +} + +/// Unedited wire identity/order/flags survive; a supported scale edit exports its new value. +#[test] +fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() { + for (id, mut d) in [(SCALE, scale(100.0)), (CREATE, create())] { + d.name = UnicodeString::new_nul("Recorded style step"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(id, d); + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let a = action(vec![source.clone()]); + assert_eq!(a.steps[0].0, id); + let bytes = export("S", &[&a], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); + let mut damaged = a.steps[0].1.clone(); + damaged["_atnLayerStyleDescriptor"] = json!("not base64"); + assert!(exported(id, damaged).is_none()); + } + let mut d = scale(100.0); + d.name = UnicodeString::new_nul("Original"); + let mut a = action(vec![item(SCALE, d)]); + a.steps[0].1["scale"] = json!(200); + for skip in [false, true] { + let (bytes, skipped) = export("S", &[&a], &[], skip).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(scale(200.0))); + } +} + +/// Native defaults are explicit; unrepresented targets, options and damaged metadata cannot vanish. +#[test] +fn layer_style_native_exports_are_strict_and_make_defaults_explicit() { + assert_eq!(exported(SCALE, json!({})).unwrap().descriptor, Some(scale(100.0))); + let mut canonical_create = create(); + // The shared canonical class constructor writes nul-terminated display names; + // imported empty names remain separate wire data in the round-trip fixtures. + for (_, value) in &mut canonical_create.items { + let D::Reference(parts) = value else { panic!("fixture must contain references") }; + for part in parts { + let class = match part { + ReferenceItem::Class(class) | ReferenceItem::Property { class, .. } | ReferenceItem::Enumerated { class, .. } => class, + _ => panic!("fixture must contain class/property/target references"), + }; + class.name = UnicodeString::new_nul(""); + } + } + assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(canonical_create)); + for percent in [1, 100, 200, 1000] { + assert_eq!(exported(SCALE, json!({"scale":percent})).unwrap().descriptor, Some(scale(f64::from(percent)))); + } + for value in [json!(0), json!(1001), json!(1.5), Value::Null, json!("100"), json!(true)] { + assert!(exported(SCALE, json!({"scale":value})).is_none()); + } + for id in [SCALE, CREATE] { + for key in ["layer", "layers", "target", "unknown"] { + let mut p = json!({}); + p[key] = json!(1); + assert!(exported(id, p).is_none(), "{id}/{key}"); + } + let invalid = if id == SCALE { scale(100.0) } else { create() }.with("extra", D::Boolean(true)); + let mut p = if id == SCALE { json!({"scale":200}) } else { json!({}) }; + p["_atnLayerStyleDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes())); + assert!(exported(id, p).is_none()); + } + assert!(exported(CREATE, json!({"scale":100})).is_none()); +} + +/// Malformed style references remain whole and cannot fall through to ordinary blank-layer creation. +#[test] +fn layer_style_malformed_records_stay_kept_without_an_edit() { + let mut cases = vec![(SCALE, Descriptor::new("null")), (CREATE, Descriptor::new("null").with("Usng", create().items[1].1.clone()))]; + for value in [D::Integer(100), D::Double(100.0), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 100.0 }] { + cases.push((SCALE, Descriptor::new("null").with("Scl ", value))); + } + for value in [0.0, 1001.0, 1.5, f64::NAN, f64::INFINITY] { + cases.push((SCALE, scale(value))); + } + for (id, d) in [(SCALE, scale(100.0)), (CREATE, create())] { + cases.push((id, d.clone().with("extra", D::Boolean(true)))); + let mut duplicate = d.clone(); + duplicate.items.push(duplicate.items[0].clone()); + cases.push((id, duplicate)); + } + for null in [ + D::Integer(1), + D::Reference(vec![ReferenceItem::Class(class("Chnl"))]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]), + ] { + let mut d = create(); + d.items[0].1 = null; + cases.push((CREATE, d)); + } + for using in [ + D::Integer(1), + D::Boolean(false), + D::Descriptor(Descriptor::new("unknown")), + D::Reference(vec![]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("wrong") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("wrong"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Al ") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Trgt") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") }, + ]), + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ReferenceItem::Class(class("Lyr ")), + ]), + ] { + let mut d = create(); + d.items[1].1 = using; + cases.push((CREATE, d)); + } + for (id, d) in cases { + let source = item(id, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{id}: {source:?}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// A miskeyed effects reference must be kept, rather than playing as an ordinary blank layer. +#[test] +fn miskeyed_effects_reference_stays_whole_without_a_blank_layer_edit() { + let mut d = create(); + d.items[1].0 = Id::new("Usin"); + let source = item(CREATE, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions.clone()); + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + // Play before checking import classification: the old path actually creates a blank layer. + assert!( + s.active().unwrap().doc == before, + "miskeyed Lefx reference changed the document: layers {} -> {}, history {} -> {}", + before.layers.len(), + s.active().unwrap().doc.layers.len(), + past, + s.active().unwrap().history.past_len() + ); + assert_eq!(s.active().unwrap().history.past_len(), past); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some()); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1); + same_item_wire(saved.into_iter().next().unwrap(), source); +} + +/// A normal layer Make, including class-only Make, still creates a blank layer without splitting fx. +#[test] +fn ordinary_layer_make_remains_a_legitimate_control() { + for using in [None, Some(D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary")))))] { + let mut d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])); + if let Some(u) = using { + d = d.with("Usng", u); + } + let a = action(vec![item(CREATE, d)]); + assert_eq!(a.steps[0].0, "layer.new.layer"); + let (mut s, source) = fixture(8); + let before = s.active().unwrap().doc.clone(); + s.actions.list.push(a); + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + undo_redo(&mut s, &before, &after, 1); + } +} + +/// Every typed container must retain nested effects intent without changing the document or history. +#[test] +fn nested_effects_references_stay_whole_without_a_blank_layer_edit() { + let reference = create().items.pop().unwrap().1; + let nested = Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Before"))).with("data", reference.clone()); + let mut deepest = reference.clone(); + // Root descriptor depth0, Usng value depth1, options descriptor depth2, data value depth3. + for _ in 3..MAX_DEPTH { + deepest = D::List(vec![D::Boolean(false), deepest]); + } + let cases = [ + ("reference", reference.clone()), + ("descriptor", D::Descriptor(nested.clone())), + ("global object", D::GlobalObject(nested.clone())), + ("list", D::List(vec![D::Boolean(false), reference])), + ("object array", D::ObjectArray(ObjectArray { prefix: 16, body: nested.clone() })), + ( + "mixed containers", + D::List(vec![D::Boolean(false), D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested })))]), + ), + ("parser depth limit", deepest), + ]; + for (label, value) in cases { + for key in ["Usng", "Usin"] { + let d = Descriptor::new("null") + .with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])) + .with(key, D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone()))); + let source = item(CREATE, d); + let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap(); + let (mut s, _) = fixture(8); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + s.actions.list.extend(loaded.actions.clone()); + let result = s.execute("actions.play", json!({"action": 0})).unwrap(); + // Playback precedes classification so the baseline exposes the actual blank-layer edit. + assert!( + s.active().unwrap().doc == before, + "{label}/{key}: nested Lefx changed the document: layers {} -> {}, history {} -> {}", + before.layers.len(), + s.active().unwrap().doc.layers.len(), + past, + s.active().unwrap().history.past_len() + ); + assert_eq!(s.active().unwrap().history.past_len(), past, "{label}/{key}"); + assert_eq!(result["ran"], 0, "{label}/{key}"); + assert!(result.get("failed").is_some(), "{label}/{key}"); + assert_eq!(loaded.unsupported.len(), 1, "{label}/{key}"); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep", "{label}/{key}"); + let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty(), "{label}/{key}"); + let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items; + assert_eq!(saved.len(), 1, "{label}/{key}"); + same_item_wire(saved.into_iter().next().unwrap(), source); + } + } +} + +/// Native layer options still play with Lefx as a name or in synthetic opaque metadata. +#[test] +fn ordinary_layer_make_native_export_matches_native_playback() { + let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true}); + let opaque = D::List(vec![D::RawData(create().to_bytes()), D::Alias(b"Lefx".to_vec()), D::Path(b"Lefx".to_vec()), D::Text(UnicodeString::new_nul("Lefx"))]); + for metadata in [None, Some(opaque)] { + let mut source = exported("layer.new.layer", params.clone()).unwrap(); + if let Some(value) = metadata { + let D::Descriptor(options) = &mut source.descriptor.as_mut().unwrap().items[1].1 else { panic!("native layer options") }; + options.items.push((Id::new("data"), value)); + } + let a = action(vec![source]); + assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone())); + let (mut native, _) = fixture(8); + native.execute("layer.new.layer", params.clone()).unwrap(); + let (mut recorded, source) = fixture(8); + let before = recorded.active().unwrap().doc.clone(); + let past = recorded.active().unwrap().history.past_len(); + recorded.actions.list.push(a); + let result = recorded.execute("actions.play", json!({"action": 0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none()); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), before.layers.len() + 1); + assert_eq!(after.layer(source), before.layer(source)); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + equivalent(&recorded, &native); + undo_redo(&mut recorded, &before, &after, 1); + } +} diff --git a/crates/engine/tests/it/atn_newer_events.rs b/crates/engine/tests/it/atn_newer_events.rs new file mode 100644 index 0000000000..788e5aab57 --- /dev/null +++ b/crates/engine/tests/it/atn_newer_events.rs @@ -0,0 +1,1213 @@ +//! #10700: recorded Link and Unlink must not replay as a state-dependent toggle. +use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject, SmartSource}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::json; +use std::sync::Arc; + +fn fixture(groups: &[Option], selected: &[usize]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 2})).unwrap(); + let st = s.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + doc.layers = groups + .iter() + .enumerate() + .map(|(i, group)| { + let mut layer = Layer::raster(format!("Layer {i}"), doc.pixel_format()); + layer.link_group = *group; + layer + }) + .collect(); + let ids: Vec<_> = selected.iter().map(|i| doc.layers[*i].id).collect(); + s.restore_layer_selection(ids.last().copied(), &ids, ids.first().copied()).unwrap(); + s +} + +fn groups(s: &Session) -> Vec> { + s.active().unwrap().doc.layers.iter().map(|layer| layer.link_group).collect() +} + +fn target() -> D { + target_class("Lyr ") +} + +fn target_class(class_id: &str) -> D { + ordinal_target(class_id, "Trgt") +} + +fn ordinal_target(class_id: &str, ordinal: &str) -> D { + D::Reference(vec![ReferenceItem::Enumerated { + class: Class { name: UnicodeString::new_nul(""), class_id: Id::new(class_id) }, + type_id: Id::new("Ordn"), + value: Id::new(ordinal), + }]) +} + +fn file(event: &str, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Links"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![AtnItem { + event: match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::name(event), + }, + descriptor: Some(descriptor), + ..Default::default() + }], + ..Default::default() + }], + warnings: vec![], + ..Default::default() + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> serde_json::Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn play(s: &mut Session) { + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert!(report.get("failed").is_none(), "{report}"); + assert_eq!(report["ran"], 1, "{report}"); +} + +fn saved_item(s: &mut Session) -> AtnItem { + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + atn::parse(&bytes).unwrap().actions[0].items[0].clone() +} + +#[test] +fn explicit_links_are_idempotent_and_omitted_state_still_toggles() { + let mut s = fixture(&[None, None], &[0, 1]); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + s.execute("layer.linkLayers", json!({"linked": true})).unwrap(); + let linked = s.active().unwrap().doc.clone(); + assert!(groups(&s)[0].is_some()); + assert_eq!(groups(&s)[0], groups(&s)[1]); + let history = s.active().unwrap().history.past_len(); + s.execute("layer.linkLayers", json!({"linked": true})).unwrap(); + assert_eq!(s.active().unwrap().doc, linked); + assert_eq!(s.active().unwrap().history.past_len(), history); + s.execute("layer.linkLayers", json!({})).unwrap(); + assert_eq!(groups(&s), [None, None]); + s.execute("layer.linkLayers", json!({})).unwrap(); + assert!(groups(&s).iter().all(Option::is_some)); +} + +#[test] +fn unlink_cleans_singletons_preserves_other_groups_and_undoes() { + for initial in [vec![Some(77), Some(77), Some(77), Some(93), Some(93)], vec![Some(77), None, Some(77), Some(93), Some(93)]] { + let mut s = fixture(&initial, &[0, 1]); + let before = s.active().unwrap().doc.clone(); + s.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(groups(&s), [None, None, None, Some(93), Some(93)]); + let history = s.active().unwrap().history.past_len(); + s.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + let mut one = fixture(&[Some(77), Some(77)], &[0]); + one.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(groups(&one), [None, None]); + let history = one.active().unwrap().history.past_len(); + one.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(one.active().unwrap().history.past_len(), history); + assert!(!one.is_enabled("layer.linkLayers"), "the UI toggle still requires two unlinked layers"); +} + +/// Explicit Link cleans an orphan without touching a real group's unselected member. +#[test] +fn explicit_link_cleans_a_singleton_but_preserves_a_linked_pair() { + for initial in [vec![Some(77)], vec![Some(77), None], vec![Some(77), Some(77)]] { + let mut s = fixture(&initial, &[0]); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let linked = initial == [Some(77), Some(77)]; + assert_eq!(s.execute("layer.linkLayers", json!({"linked": true})).unwrap()["linked"], linked); + if linked { + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } else { + assert!(groups(&s).iter().all(Option::is_none)); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +#[test] +fn invalid_link_state_fails_without_editing() { + let mut s = fixture(&[None, None], &[0, 1]); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + for invalid in [json!(null), json!("false"), json!(0), json!([]), json!({})] { + assert!(s.execute("layer.linkLayers", json!({"linked": invalid})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + for active in [None, Some(LayerId(u64::MAX))] { + let st = s.active_mut().unwrap(); + st.active_layer = active; + st.selected_layers = active.into_iter().collect(); + assert!(s.execute("layer.linkLayers", json!({"linked": false})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + assert!(Session::new().execute("layer.linkLayers", json!({"linked": false})).is_err()); +} + +#[test] +fn atn_link_events_keep_descriptors_and_replay_the_requested_operation() { + // Link Layers keeps the measured toggle (main's Group F mapping); Unlink is explicit. + { + let (event, linked) = ("unlinkSelectedLayers", false); + for initial in [vec![None, None, None, Some(93), Some(93)], vec![Some(77), Some(77), Some(77), Some(93), Some(93)]] { + let descriptor = Descriptor::new("null").with("null", target()); + let mut s = fixture(&initial, &[0, 1]); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, [("layer.linkLayers".into(), json!({"linked": linked}))]); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + assert_eq!(parsed.actions[0].items[0].descriptor, Some(descriptor)); + let mut replay = fixture(&initial, &[0, 1]); + assert_eq!(load(&mut replay, &bytes)["unsupported"], json!([])); + play(&mut replay); + if linked { + let result = groups(&replay); + assert!(result[0].is_some()); + assert_eq!(result[0], result[1]); + assert_eq!(&result[2..], &[initial[2], Some(93), Some(93)]); + } else { + assert_eq!(groups(&replay), [None, None, None, Some(93), Some(93)]); + } + let before = replay.active().unwrap().doc.clone(); + let history = replay.active().unwrap().history.past_len(); + play(&mut replay); + assert_eq!(replay.active().unwrap().doc, before); + assert_eq!(replay.active().unwrap().history.past_len(), history); + } + } +} + +#[test] +fn unsupported_link_descriptors_are_kept_and_ambiguous_exports_are_refused() { + for event in ["linkSelectedLayers", "unlinkSelectedLayers"] { + for descriptor in [ + Descriptor::new("null").with("null", D::Reference(vec![])), + Descriptor::new("null").with("null", D::Integer(1)), + Descriptor::new("null").with("null", target_class("Dcmn")), + Descriptor::new("null").with("null", target()).with("extra", D::Boolean(true)), + Descriptor::new("null").with("null", target()).with("null", target()), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + assert_eq!(parsed.actions[0].items[0].descriptor, Some(descriptor)); + } + } + // `{}` is the measured Link Layers toggle and exports (main's Group F mapping). + for params in [json!({"linked": null}), json!({"linked": true, "layer": "Layer 0"}), json!({"linked": false, "layers": [1, 2]})] { + let action = Action::new("A", vec![("layer.linkLayers".into(), params)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +const TARGET_EVENTS: &[(&str, &str)] = &[ + ("reverse", "layer.arrange.reverse"), + ("selectLinkedLayers", "layer.selectLinkedLayers"), + ("disableLayerStyle", "layer.layerStyle.clear"), + ("placedLayerMakeCopy", "layer.smartObjects.newSmartObjectViaCopy"), + ("placedLayerConvertToLayers", "layer.smartObjects.convertToLayers"), + ("placedLayerConvertToEmbedded", "layer.smartObjects.convertToEmbedded"), + ("placedLayerUpdateAllModified", "layer.smartObjects.updateAllModifiedContent"), + ("rasterizeAll", "layer.rasterize.allLayers"), + ("addBlankVideoLayer", "layer.videoLayers.newBlankVideoLayer"), + ("isolateLayers", "select.isolateLayers"), + ("textLoadDefaultTypeStyles", "type.loadDefaultTypeStyles"), + ("textSaveDefaultTypeStyles", "type.saveDefaultTypeStyles"), + ("recordMeasurements", "image.analysis.recordMeasurements"), + ("CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos"), +]; + +fn targeted(event: &str, explicit: bool) -> Descriptor { + let d = Descriptor::new(event); + if explicit { d.with("null", target()) } else { d } +} + +fn load_targeted(s: &mut Session, event: &str, id: &str, explicit: bool) { + assert_eq!(load(s, &file(event, targeted(event, explicit)))["unsupported"], json!([]), "{event}"); + let p = if explicit { json!({}) } else { json!({"_atnNoTarget": true}) }; + assert_eq!(s.actions.list[0].steps, [(id.into(), p)]); +} + +#[test] +fn target_events_keep_empty_and_explicit_target_descriptors_distinct() { + for &(event, id) in TARGET_EVENTS { + for explicit in [false, true] { + let mut s = Session::new(); + load_targeted(&mut s, event, id, explicit); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let item = &atn::parse(&bytes).unwrap().actions[0].items[0]; + assert_eq!(item.event, AtnEvent::name(event)); + assert_eq!(item.descriptor, Some(targeted("null", explicit)), "{event}"); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + } + } + // Reference lines197–199 permits writing an empty descriptor for an absent one. + let mut f = atn::parse(&file("reverse", Descriptor::new("null"))).unwrap(); + f.actions[0].items[0].descriptor = None; + let mut s = Session::new(); + assert_eq!(load(&mut s, &atn::write(&f).unwrap())["unsupported"], json!([])); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(Descriptor::new("null"))); +} + +#[test] +fn bare_link_events_keep_their_operation_and_empty_keys() { + // Link Layers keeps the measured toggle (main's Group F mapping); Unlink is explicit. + { + let (event, linked) = ("unlinkSelectedLayers", false); + let initial = if linked { None } else { Some(77) }; + let mut s = fixture(&[initial, initial], &[0, 1]); + assert_eq!(load(&mut s, &file(event, Descriptor::new(event)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1, json!({"linked": linked, "_atnNoTarget": true})); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(Descriptor::new("null"))); + let before = s.active().unwrap().doc.clone(); + play(&mut s); + assert_ne!(s.active().unwrap().doc, before); + let after = s.active().unwrap().doc.clone(); + assert!(groups(&s).iter().all(|group| group.is_some() == linked)); + assert_eq!(groups(&s)[0], groups(&s)[1]); + play(&mut s); + assert_eq!(s.active().unwrap().doc, after); + } +} + +#[test] +fn target_events_keep_unknown_targets_and_refuse_native_options() { + for &(event, id) in TARGET_EVENTS { + for descriptor in [ + Descriptor::new(event).with("null", D::Integer(1)), + Descriptor::new(event).with("null", target_class("Dcmn")), + targeted(event, true).with("null", target()), + targeted(event, true).with("extra", D::Boolean(true)), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(descriptor)); + } + for p in [json!({"layer": 1}), json!({"on": true}), json!({"_atnNoTarget": false}), json!({"_atnNoTarget": "true"})] { + let action = Action::new("A", vec![(id.into(), p)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err(), "{event}"); + } + } + for linked in [false, true] { + let action = Action::new("A", vec![("layer.linkLayers".into(), json!({"linked": linked, "_atnNoTarget": false}))]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +fn scan_fixture() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 340, "background": "#fafafa"})).unwrap(); + let st = s.active_mut().unwrap(); + let id = st.active_layer.unwrap(); + let surface = Arc::make_mut(&mut st.doc).layer_mut(id).unwrap().surface_mut().unwrap(); + for (x, y, w, h, color) in [(40, 60, 160, 110, [0.6, 0.1, 0.1, 1.0]), (290, 180, 150, 120, [0.1, 0.2, 0.6, 1.0])] { + surface.fill_rect(photocraft_doc::Rect::new(x, y, x + w, y + h), &color); + } + s.execute("select.rect", json!({"x": 270, "y": 160, "width": 190, "height": 160})).unwrap(); + s +} + +fn assert_cropped_scan(s: &Session) { + assert_eq!(s.documents().len(), 2); + let doc = &s.documents()[1].doc; + assert!((i64::from(doc.size.width) - 150).abs() <= 4); + assert!((i64::from(doc.size.height) - 120).abs() <= 4); + let color = doc.layers[0].surface().unwrap().rgba(doc.size.width as i32 / 2, doc.size.height as i32 / 2); + assert!(color[2] > 0.5 && color[0] < 0.2, "{color:?}"); +} + +#[test] +fn crop_parent_creates_the_selected_photo_without_changing_the_scan() { + for explicit in [false, true] { + let mut s = scan_fixture(); + let source = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + load_targeted(&mut s, "CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos", explicit); + assert!(s.is_enabled("file.automate.cropAndStraightenPhotos")); + play(&mut s); + assert_cropped_scan(&s); + assert_eq!(s.documents()[0].doc, source); + assert_eq!(s.documents()[0].history.past_len(), history); + } +} + +#[test] +fn crop_parent_refuses_ineligible_layers_without_document_or_history_changes() { + for case in ["missing", "stale", "hidden", "hidden-parent", "pixels", "locked-parent", "indexed"] { + let mut s = scan_fixture(); + let st = s.active_mut().unwrap(); + let id = st.active_layer.unwrap(); + let doc = Arc::make_mut(&mut st.doc); + match case { + "missing" => st.active_layer = None, + "stale" => st.active_layer = Some(LayerId::fresh()), + "hidden" => doc.layer_mut(id).unwrap().visible = false, + "pixels" => doc.layer_mut(id).unwrap().locks.pixels = true, + "indexed" => doc.mode = photocraft_doc::ColorMode::Indexed, + _ => { + let mut group = Layer::group("Parent", std::mem::take(&mut doc.layers)); + group.visible = case != "hidden-parent"; + group.locks.all = case == "locked-parent"; + doc.layers.push(group); + } + } + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert!(!s.is_enabled("file.automate.cropAndStraightenPhotos"), "{case}"); + load_targeted(&mut s, "CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos", false); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{case}: {report}"); + assert_eq!(report["failed"]["id"], "file.automate.cropAndStraightenPhotos", "{case}: {report}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + assert!(!Session::new().is_enabled("file.automate.cropAndStraightenPhotos")); +} + +#[test] +fn crop_parent_without_photos_fails_without_creating_documents() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 80, "background": "white"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + load_targeted(&mut s, "CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos", false); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{report}"); + assert!(report["failed"]["error"].as_str().unwrap().contains("no photos found"), "{report}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + +#[test] +fn crop_parent_runs_once_and_the_kept_helper_stops_the_duplicate_crop_tail() { + let mut f = atn::parse(&file("CropPhotosAuto0001", Descriptor::new("cjsp"))).unwrap(); + let helper = Descriptor::new("null").with("Vl ", D::List([0.0, 0.0, 150.0, 0.0, 150.0, 120.0, 0.0, 120.0].into_iter().map(D::Double).collect())); + f.actions[0].items.push(AtnItem { event: AtnEvent::name("CropPhotos0001"), descriptor: Some(helper.clone()), ..Default::default() }); + let tail = Action::new("Tail", vec![("image.duplicate".into(), json!({})), ("image.crop".into(), json!({"x": 1, "y": 1, "width": 10, "height": 10}))]); + let (bytes, skipped) = atn_cmds::export("Tail", &[&tail], &[], false).unwrap(); + assert!(skipped.is_empty()); + f.actions[0].items.extend(atn::parse(&bytes).unwrap().actions[0].items.clone()); + let mut s = scan_fixture(); + let source = s.active().unwrap().doc.clone(); + let report = load(&mut s, &atn::write(&f).unwrap()); + assert_eq!(report["unsupported"].as_array().unwrap().len(), 1); + assert_eq!( + s.actions.list[0].steps.iter().map(|step| step.0.as_str()).collect::>(), + ["file.automate.cropAndStraightenPhotos", "actions.photoshopStep", "image.duplicate", "image.crop"] + ); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[1].descriptor, Some(helper)); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 1, "{report}"); + assert_eq!(report["failed"]["step"], 1); + assert_eq!(report["failed"]["id"], "actions.photoshopStep"); + assert_cropped_scan(&s); + assert_eq!(s.documents()[0].doc, source); +} + +/// Two sparse marks leave a transparent gap where the converted board's background is observable. +fn artboard_fixture() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 12, "height": 10, "background": "transparent"})).unwrap(); + let st = s.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let mut child = Layer::raster("Pixels", doc.pixel_format()); + let surface = child.surface_mut().unwrap(); + for rect in [photocraft_doc::Rect::new(2, 2, 3, 3), photocraft_doc::Rect::new(5, 5, 6, 6)] { + surface.fill_rect(rect, &[1.0, 0.0, 0.0, 1.0]); + } + let group = Layer::group("Group", vec![child]); + let id = group.id; + doc.layers = vec![group]; + s.restore_layer_selection(Some(id), &[id], Some(id)).unwrap(); + s +} + +/// The reference's ordered settings keys, with fractional RGB that must not be rounded to hex. +fn artboard_descriptor(background: i32, guides: Vec, flag: D) -> Descriptor { + Descriptor::new("null").with("null", target()).with("artboard", D::Descriptor(artboard_settings(Some(background), guides))).with("changeBackground", flag) +} + +/// Background types 1..3 still record the last custom RGB value; absent type means white. +fn artboard_settings(background: Option, guides: Vec) -> Descriptor { + let color = Descriptor::new("RGBC").with("Rd ", D::Double(63.75)).with("Grn ", D::Double(127.5)).with("Bl ", D::Double(191.25)); + let board = Descriptor::new("artboard") + .with("guideIDs", D::List(guides)) + .with("artboardPresetName", D::Text(UnicodeString::new_nul("iPhone 14"))) + .with("Clr ", D::Descriptor(color)); + match background { + Some(n) => board.with("artboardBackgroundType", D::Integer(n)), + None => board, + } +} + +/// Conversion preserves the group's geometry/content while applying every admitted setting and Undo. +#[test] +fn artboard_recorded_flags_and_guides_roundtrip_without_resizing() { + for (background, expected) in [(1, [1.0, 1.0, 1.0, 1.0]), (2, [0.0, 0.0, 0.0, 1.0]), (3, [0.0; 4]), (4, [0.25, 0.5, 0.75, 1.0])] { + for flag in [None, Some(0), Some(1), Some(2), Some(3), Some(-1), Some(7)] { + let mut s = artboard_fixture(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.horizontal.push(3.0); + Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.vertical.push(4.0); + let before = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let mut d = artboard_descriptor(background, vec![D::Integer(2), D::Integer(99999)], D::Integer(flag.unwrap_or(1))); + if flag.is_none() { + d.items.pop(); + } + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].0, "layer.new.artboardFromGroup"); + assert_eq!(s.actions.list[0].steps[0].1["color"], json!([0.25, 0.5, 0.75])); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + play(&mut s); + let doc = &s.active().unwrap().doc; + let board = doc.layer(id).unwrap().artboard().unwrap(); + assert_eq!(board.rect, photocraft_doc::Rect::new(2, 2, 6, 6), "preset is a tag, not a request to resize the group"); + assert_eq!(board.preset, "iPhone 14"); + assert_eq!(doc.size, before.size); + assert_eq!(doc.layer(id).unwrap().children(), before.layer(id).unwrap().children()); + assert_eq!(doc.guides, before.guides); + let pixel = photocraft_compose::render(doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; + let applied = flag.is_none_or(|n| n == 1); + for (actual, expected) in pixel.into_iter().zip(if applied { expected } else { [1.0; 4] }) { + assert!((actual - expected).abs() < 0.005, "background {background}, flag {flag:?}: {pixel:?}"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +/// The target-only call uses the existing menu behavior without synthesizing settings. +#[test] +fn artboard_target_only_form_keeps_existing_native_group_behavior() { + let mut s = artboard_fixture(); + let before = s.active().unwrap().doc.clone(); + let d = Descriptor::new("null").with("null", target()); + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, [("layer.new.artboardFromGroup".into(), json!({}))]); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + play(&mut s); + assert!(s.active().unwrap().doc.layers[0].artboard().is_some()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); +} + +/// A conversion selects its result, so a following Delete cannot remove another selected layer. +#[test] +fn artboard_from_group_replaces_the_previous_layer_selection() { + let mut s = artboard_fixture(); + let group = s.active().unwrap().active_layer.unwrap(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let other = Layer::raster("Other", doc.pixel_format()); + let id = other.id; + doc.layers.push(other); + s.restore_layer_selection(Some(group), &[id, group], Some(id)).unwrap(); + load(&mut s, &file("artboardFromLayerGroupEvent", Descriptor::new("null").with("null", target()))); + play(&mut s); + assert_eq!(s.active().unwrap().selected_layers(), vec![group]); + s.execute("layer.delete", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(id).is_some()); + assert!(s.active().unwrap().doc.layer(group).is_none()); +} + +/// A missing custom color means White; an explicit white color remains Custom through save/load. +#[test] +fn artboard_partial_custom_background_preserves_the_document_variant() { + for params in [json!({"background":"custom"}), json!({"background":"custom","color":[1,1,1]})] { + let mut s = artboard_fixture(); + s.execute("layer.new.artboardFromGroup", params.clone()).unwrap(); + let expected = s.active().unwrap().doc.clone(); + assert!(s.undo()); + let action = Action::new("A", vec![("layer.new.artboardFromGroup".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Links", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + play(&mut s); + assert_eq!(s.active().unwrap().doc, expected); + } +} + +/// Preserve fractional wire values exactly while playback uses normalized native RGB. +#[test] +fn artboard_fractional_colors_survive_import_save_and_playback() { + let rgb = [7.97, 31.88, 127.6]; + for event in ["artboardFromLayerGroupEvent", "editArtboardEvent"] { + let mut s = artboard_fixture(); + let mut d = if event == "editArtboardEvent" { + s.execute("layer.new.artboardFromGroup", json!({})).unwrap(); + artboard_edit_descriptor(4, 8) + } else { + artboard_descriptor(4, vec![], D::Integer(1)) + }; + let D::Descriptor(board) = &mut d.items.iter_mut().find(|(key, _)| key.is("artboard")).unwrap().1 else { unreachable!() }; + board.items.iter_mut().find(|(key, _)| key.is("Clr ")).unwrap().1 = + D::Descriptor(["Rd ", "Grn ", "Bl "].into_iter().zip(rgb).fold(Descriptor::new("RGBC"), |color, (key, n)| color.with(key, D::Double(n)))); + assert_eq!(load(&mut s, &file(event, d.clone()))["unsupported"], json!([])); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let (id, mut changed) = s.actions.list[0].steps[0].clone(); + changed["color"][0] = 0.5.into(); + assert!(atn_cmds::export("Links", &[&Action::new("Changed", vec![(id, changed)])], &[], false).is_err()); + let before = s.active().unwrap().doc.clone(); + play(&mut s); + let pixel = photocraft_compose::render(&s.active().unwrap().doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; + for (actual, expected) in pixel.into_iter().zip(rgb.map(|n| n as f32 / 255.0)) { + assert!((actual - expected).abs() < 0.005, "{event}: {pixel:?}"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Unproved settings remain byte-preserving kept steps and cannot partially edit the document. +#[test] +fn artboard_unmeasured_types_and_extra_settings_stay_whole() { + for d in [ + artboard_descriptor(1, vec![D::Double(7.0)], D::Integer(1)), + artboard_descriptor(1, vec![], D::Boolean(true)), + artboard_descriptor(5, vec![], D::Integer(1)), + artboard_descriptor(1, vec![], D::Integer(1)).with("extra", D::Boolean(true)), + Descriptor::new("null"), + ] { + let mut s = artboard_fixture(); + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let report = s.execute("actions.play", json!({"action":"A","set":"Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{report}"); + assert_eq!(report["failed"]["id"], "actions.photoshopStep"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + for p in [json!({"layer":1}), json!({"preset":7}), json!({"guideIDs":[7.5]}), json!({"changeBackground":false})] { + let action = Action::new("A", vec![("layer.new.artboardFromGroup".into(), p)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +/// Omitted type means white rather than the color-only native call's custom background. +#[test] +fn artboard_omitted_type_defaults_to_white_and_invalid_native_settings_are_atomic() { + let mut s = artboard_fixture(); + let before = s.active().unwrap().doc.clone(); + let d = + Descriptor::new("null").with("null", target()).with("artboard", D::Descriptor(artboard_settings(None, vec![]))).with("changeBackground", D::Integer(1)); + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let mut changed = s.actions.list[0].steps[0].1.clone(); + changed["background"] = "black".into(); + assert!(atn_cmds::export("Links", &[&Action::new("A", vec![("layer.new.artboardFromGroup".into(), changed)])], &[], false).is_err()); + play(&mut s); + assert_eq!(photocraft_compose::render(&s.active().unwrap().doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0], [1.0; 4]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + let history = s.active().unwrap().history.past_len(); + for p in [ + json!({"guideIDs":[7.5]}), + json!({"guideIDs":false}), + json!({"changeBackground":false}), + json!({"changeBackground":2147483648i64}), + json!({"preset":7}), + ] { + assert!(s.execute("layer.new.artboardFromGroup", p).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} + +/// Recorded geometry deliberately differs from the target: flag 8 changes only its background. +fn artboard_edit_descriptor(background: i32, change_sizes: i32) -> Descriptor { + let rect = Descriptor::new("classFloatRect") + .with("Top ", D::Double(40.5)) + .with("Left", D::Double(60.25)) + .with("Btom", D::Double(340.5)) + .with("Rght", D::Double(260.25)); + let mut board = Descriptor::new("artboard").with("artboardRect", D::Descriptor(rect)); + board.items.extend(artboard_settings(Some(background), vec![D::Integer(99999)]).items); + Descriptor::new("null") + .with("null", target()) + .with("artboard", D::Descriptor(board)) + .with("changeSizes", D::Integer(change_sizes)) + .with("changeBackground", D::Integer(1)) +} + +/// Background edits preserve all wire fields, target geometry, children and guides, with one Undo. +#[test] +fn artboard_background_edit_roundtrips_without_replaying_recorded_geometry() { + for (kind, expected) in [(1, [1.0; 4]), (2, [0.0, 0.0, 0.0, 1.0]), (3, [0.0; 4]), (4, [0.25, 0.5, 0.75, 1.0])] { + let mut s = artboard_fixture(); + let initial_background = if kind == 1 { "black" } else { "white" }; + s.execute("layer.new.artboardFromGroup", json!({"background":initial_background})).unwrap(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.horizontal.push(3.0); + let before = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + assert_eq!( + before.layer(id).unwrap().artboard().unwrap().background, + if kind == 1 { photocraft_doc::ArtboardBackground::Black } else { photocraft_doc::ArtboardBackground::White } + ); + let history = s.active().unwrap().history.past_len(); + let d = artboard_edit_descriptor(kind, 8); + assert_eq!(load(&mut s, &file("editArtboardEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].0, "layer.artboard.set"); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + play(&mut s); + let doc = &s.active().unwrap().doc; + let board = doc.layer(id).unwrap().artboard().unwrap(); + assert_eq!(board.rect, before.layer(id).unwrap().artboard().unwrap().rect); + assert_eq!(board.preset, before.layer(id).unwrap().artboard().unwrap().preset); + assert_eq!(doc.layer(id).unwrap().children(), before.layer(id).unwrap().children()); + assert_eq!(doc.size, before.size); + assert_eq!(doc.guides, before.guides); + let pixel = photocraft_compose::render(doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; + for (actual, expected) in pixel.into_iter().zip(expected) { + assert!((actual - expected).abs() < 0.005, "{kind}: {pixel:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Unmeasured geometry modes remain whole; edited native resize options cannot be silently omitted. +#[test] +fn artboard_edit_refuses_unmeasured_modes_and_lossy_native_exports() { + let mut s = artboard_fixture(); + for d in [artboard_edit_descriptor(1, 4), artboard_edit_descriptor(5, 8), artboard_edit_descriptor(1, 8).with("extra", D::Integer(1))] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert_eq!(load(&mut s, &file("editArtboardEvent", d.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let result = s.execute("actions.play", json!({"set":"Links","action":"A"})).unwrap(); + assert_eq!(result["ran"], 0); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + load(&mut s, &file("editArtboardEvent", artboard_edit_descriptor(4, 8))); + let params = s.actions.list[0].steps[0].1.clone(); + for (key, value) in [("rect", json!([0, 0, 100, 100])), ("preset", json!("iPhone 14")), ("changeSizes", json!(4)), ("changeBackground", json!(0))] { + let mut edited = params.clone(); + edited[key] = value; + assert!(atn_cmds::export("Links", &[&Action::new("A", vec![("layer.artboard.set".into(), edited)])], &[], false).is_err()); + } +} + +#[test] +fn reverse_linked_selection_and_clear_style_events_do_their_edits() { + for explicit in [false, true] { + let mut s = fixture(&[None, None, None], &[0, 1, 2]); + let before = s.active().unwrap().doc.clone(); + let ids: Vec<_> = before.layers.iter().map(|layer| layer.id).rev().collect(); + load_targeted(&mut s, "reverse", "layer.arrange.reverse", explicit); + play(&mut s); + assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.id).collect::>(), ids); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + + let mut s = fixture(&[Some(77), Some(77), None], &[0]); + let ids: Vec<_> = s.active().unwrap().doc.layers[..2].iter().map(|layer| layer.id).collect(); + load_targeted(&mut s, "selectLinkedLayers", "layer.selectLinkedLayers", explicit); + play(&mut s); + assert_eq!(s.active().unwrap().selected_layers(), ids); + + let mut s = fixture(&[None], &[0]); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + assert_eq!(before.layers[0].effects.items.len(), 1); + load_targeted(&mut s, "disableLayerStyle", "layer.layerStyle.clear", explicit); + play(&mut s); + assert!(s.active().unwrap().doc.layers[0].effects.items.is_empty()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +fn smart_target_scene(event: &str, bytes: &[u8], replacement: &[u8], path: &str) -> Session { + let mut s = smart_fixture(bytes); + if matches!(event, "placedLayerConvertToEmbedded" | "placedLayerUpdateAllModified") { + s.execute("layer.smartObjects.convertToLinked", json!({"path": path})).unwrap(); + } + if event == "placedLayerUpdateAllModified" { + std::fs::write(path, replacement).unwrap(); + } + s +} + +fn assert_smart_target_edit(event: &str, s: &Session, original_id: photocraft_doc::SmartContentsId, pixels: &[[f32; 4]], bytes: &[u8]) { + match event { + "placedLayerMakeCopy" => { + assert_ne!(active_smart(s).contents_id, original_id); + assert_eq!(s.active().unwrap().doc.walk().iter().filter(|(_, _, layer)| matches!(layer.content, LayerContent::Smart(_))).count(), 2); + } + "placedLayerConvertToLayers" => assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| !matches!(layer.content, LayerContent::Smart(_)))), + "placedLayerConvertToEmbedded" => assert!(matches!(&active_smart(s).source, SmartSource::Embedded { bytes: actual, .. } if actual.as_slice() == bytes)), + "placedLayerUpdateAllModified" => assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|px| *px == [1.0, 0.0, 0.0, 1.0])), + _ => unreachable!(), + } + if event != "placedLayerUpdateAllModified" { + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px, pixels); + } +} + +#[test] +fn smart_target_events_copy_unpack_embed_and_refresh_real_contents() { + let original = png("#0000ff"); + let replacement = png("#ff0000"); + for &(event, id) in &TARGET_EVENTS[3..7] { + for explicit in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("linked.png"); + let mut s = smart_target_scene(event, &original, &replacement, path.to_str().unwrap()); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before).into_px(); + let contents_id = active_smart(&s).contents_id; + load_targeted(&mut s, event, id, explicit); + play(&mut s); + assert_smart_target_edit(event, &s, contents_id, &pixels, &original); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +#[test] +fn rasterize_all_event_visits_nested_and_top_level_smart_layers() { + for explicit in [false, true] { + let mut s = smart_fixture(&png("#0000ff")); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({})).unwrap(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let nested = doc.remove(first).unwrap(); + doc.layers.push(Layer::group("Nested", vec![nested])); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before).into_px(); + load_targeted(&mut s, "rasterizeAll", "layer.rasterize.allLayers", explicit); + play(&mut s); + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| !matches!(layer.content, LayerContent::Smart(_)))); + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px, pixels); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn video_and_isolate_events_use_current_document_state() { + for explicit in [false, true] { + let mut s = fixture(&[None], &[0]); + let before = s.active().unwrap().doc.clone(); + load_targeted(&mut s, "addBlankVideoLayer", "layer.videoLayers.newBlankVideoLayer", explicit); + play(&mut s); + let st = s.active().unwrap(); + let video = st.doc.layer(st.active_layer.unwrap()).unwrap().video.as_ref().unwrap(); + assert_eq!(video.frames.len(), 1); + assert_eq!(video.fps, 30.0); + assert!(video.frames[0].content_bounds().is_empty()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + + let mut s = fixture(&[None, None, None], &[0, 2]); + let before = s.active().unwrap().doc.clone(); + let ids = s.active().unwrap().selected_layers(); + let history = s.active().unwrap().history.past_len(); + load_targeted(&mut s, "isolateLayers", "select.isolateLayers", explicit); + play(&mut s); + assert_eq!(s.active().unwrap().isolated_layers, ids); + play(&mut s); + assert!(s.active().unwrap().isolated_layers.is_empty()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} + +fn active_text(s: &Session) -> &photocraft_doc::TextLayer { + let st = s.active().unwrap(); + let LayerContent::Text(text) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected Type") }; + text +} + +#[test] +fn type_default_events_save_then_restore_changed_styles() { + for explicit in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.execute("type.create", json!({"text": "Hi", "size": 14})).unwrap(); + assert!(s.type_defaults.is_none()); + load_targeted(&mut s, "textSaveDefaultTypeStyles", "type.saveDefaultTypeStyles", explicit); + play(&mut s); + assert_eq!(s.type_defaults.as_ref().unwrap().0.size_pt, 14.0); + s.execute("type.setStyle", json!({"size": 28})).unwrap(); + assert_eq!(active_text(&s).char_runs()[0].style.size_pt, 28.0); + let before = s.active().unwrap().doc.clone(); + load_targeted(&mut s, "textLoadDefaultTypeStyles", "type.loadDefaultTypeStyles", explicit); + play(&mut s); + assert_eq!(active_text(&s).char_runs()[0].style.size_pt, 14.0); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn measurement_event_records_actual_selected_area() { + for explicit in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + load_targeted(&mut s, "recordMeasurements", "image.analysis.recordMeasurements", explicit); + play(&mut s); + assert_eq!(s.analysis.log.len(), 1); + assert_eq!(s.analysis.log[0].values["source"], "Selection"); + assert_eq!(s.analysis.log[0].values["area"].as_f64(), Some(4.0)); + assert_eq!(s.active().unwrap().doc, before); + } +} + +fn class_reference(class_id: &str) -> D { + D::Reference(vec![ReferenceItem::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(class_id) })]) +} + +fn make_type_descriptor() -> Descriptor { + Descriptor::new("Mk ").with("null", class_reference("contentLayer")).with("Usng", target_class("textLayer")) +} + +#[test] +fn make_type_shape_roundtrips_and_converts_actual_glyphs_with_undo() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 128, "height": 80, "background": "white"})).unwrap(); + let id = LayerId(s.execute("type.create", json!({"text": "oil", "size": 24, "x": 8, "y": 48})).unwrap()["layer"].as_u64().unwrap()); + Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().psd_blocks.push((*b"TySh", Arc::new(vec![1, 2, 3]))); + let before = s.active().unwrap().doc.clone(); + assert_eq!(load(&mut s, &file("Mk ", make_type_descriptor()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, [("type.convertToShape".into(), json!({}))]); + let saved = saved_item(&mut s); + assert_eq!(saved.event, AtnEvent::Code(*b"Mk ")); + let mut canonical = make_type_descriptor(); + canonical.class_id = Id::new("null"); + assert_eq!(saved.descriptor, Some(canonical.clone())); + assert_eq!(load(&mut s, &file("Mk ", canonical))["unsupported"], json!([])); + play(&mut s); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("Type was not converted to Shape") }; + assert!(!shape.path.subpaths.is_empty()); + assert!(!layer.psd_blocks.iter().any(|(key, _)| key == b"TySh")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); +} + +#[test] +fn make_type_shape_keeps_wrong_references_and_preserves_fill_mappings() { + for descriptor in [ + Descriptor::new("null").with("null", class_reference("contentLayer")), + Descriptor::new("null").with("null", class_reference("contentLayer")).with("Usng", target_class("Lyr ")), + Descriptor::new("null").with("null", class_reference("contentLayer")).with("Usng", ordinal_target("textLayer", "Frnt")), + make_type_descriptor().with("extra", D::Boolean(true)), + make_type_descriptor().with("Usng", target_class("textLayer")), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file("Mk ", descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor, Some(descriptor)); + } + for (id, p) in [("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})), ("layer.newFillLayer.pattern", json!({"pattern": "Grid"}))] { + let action = Action::new("A", vec![(id.into(), p)]); + let (bytes, skipped) = atn_cmds::export("Links", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let mut s = Session::new(); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].0, id); + } + let action = Action::new("A", vec![("type.convertToShape".into(), json!({"layer": 1}))]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); +} + +fn radius_descriptor(radius: f64) -> Descriptor { + Descriptor::new("shapeBlur").with("Rds ", D::UnitFloat { unit: *b"#Pxl", value: radius }) +} + +fn impulse_fixture() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 9, "height": 9, "background": "transparent"})).unwrap(); + s.edit("impulse fixture", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_doc::Rect::new(4, 4, 5, 5), &[1.0, 1.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn radius_only_shape_blur_roundtrips_and_changes_pixels_including_default() { + for radius in [None, Some(5.0)] { + let mut s = impulse_fixture(); + let d = radius.map_or_else(|| Descriptor::new("shapeBlur"), radius_descriptor); + assert_eq!(load(&mut s, &file("shapeBlur", d.clone()))["unsupported"], json!([])); + let saved = saved_item(&mut s); + assert_eq!(saved.event, AtnEvent::name("shapeBlur")); + let mut canonical = d; + canonical.class_id = Id::new("null"); + assert_eq!(saved.descriptor, Some(canonical.clone())); + assert_eq!(load(&mut s, &file("shapeBlur", canonical))["unsupported"], json!([])); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before).into_px(); + play(&mut s); + let blurred = photocraft_compose::flatten(&s.active().unwrap().doc).into_px(); + assert_ne!(blurred, pixels); + let mut direct = impulse_fixture(); + direct.execute("filter.blur.shapeBlur", json!({"radius": radius.unwrap_or(10.0)})).unwrap(); + assert_eq!(blurred, photocraft_compose::flatten(&direct.active().unwrap().doc).px); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn shape_blur_radius_boundaries_keep_their_value_without_running_large_kernels() { + for radius in [5.0, 10.0, 1000.0] { + let mut s = Session::new(); + let mut canonical = radius_descriptor(radius); + assert_eq!(load(&mut s, &file("shapeBlur", canonical.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1["radius"].as_f64(), Some(radius)); + canonical.class_id = Id::new("null"); + assert_eq!(saved_item(&mut s).descriptor, Some(canonical)); + } +} + +#[test] +fn shape_blur_keeps_custom_assets_and_invalid_radius_forms_whole() { + let custom = Descriptor::new("customShape").with("Nm ", D::Text(UnicodeString::new_nul("Star"))).with("Idnt", D::Text(UnicodeString::new_nul("asset-id"))); + for descriptor in [ + radius_descriptor(4.0), + radius_descriptor(1001.0), + radius_descriptor(f64::NAN), + radius_descriptor(f64::INFINITY), + Descriptor::new("shapeBlur").with("Rds ", D::UnitFloat { unit: *b"#Prc", value: 10.0 }), + radius_descriptor(10.0).with("customShape", D::Descriptor(custom)).with("keyOriginType", D::Integer(9)), + radius_descriptor(10.0).with("extra", D::Boolean(true)), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file("shapeBlur", descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor.unwrap().to_bytes(), descriptor.to_bytes()); + } + for p in [json!({"radius": 0}), json!({"radius": 1001}), json!({"radius": null}), json!({"radius": 10, "shape": "circle"}), json!({"target": "mask"})] { + let action = Action::new("A", vec![("filter.blur.shapeBlur".into(), p)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +#[test] +fn link_descriptor_headers_normalize_without_changing_meaningful_keys() { + for event in ["linkSelectedLayers", "unlinkSelectedLayers"] { + let mut s = Session::new(); + let descriptor = Descriptor::new(event).with("null", target()); + assert_eq!(load(&mut s, &file(event, descriptor))["unsupported"], json!([])); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + assert_eq!(parsed.actions[0].items[0].descriptor, Some(Descriptor::new("null").with("null", target()))); + } +} + +const SMART_PATHS: &[(&str, &str)] = &[ + ("placedLayerExportContents", "layer.smartObjects.exportContents"), + ("placedLayerConvertToLinked", "layer.smartObjects.convertToLinked"), + ("placedLayerRelinkToFile", "layer.smartObjects.relinkToFile"), + ("placedLayerReplaceContents", "layer.smartObjects.replaceContents"), +]; + +fn path_value(path: &str) -> D { + let units: Vec<_> = path.encode_utf16().chain(std::iter::once(0)).collect(); + let mut bytes = b"txtu".to_vec(); + bytes.extend_from_slice(&u32::try_from(12 + units.len() * 2).unwrap().to_le_bytes()); + bytes.extend_from_slice(&u32::try_from(units.len()).unwrap().to_le_bytes()); + bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes())); + D::Path(bytes) +} + +fn smart_path_descriptor(event: &str, path: &str) -> Descriptor { + if event == "placedLayerConvertToLinked" { + Descriptor::new("null").with("null", target()).with("Usng", path_value(path)) + } else { + Descriptor::new("null").with("null", path_value(path)) + } +} + +fn png(color: &str) -> Vec { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "background": color})).unwrap(); + photocraft_io::export(&s.active().unwrap().doc, "png", &Default::default()).unwrap().bytes +} + +fn smart_fixture(bytes: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut s, "original.png", bytes.to_vec(), None, &json!({})).unwrap(); + s +} + +fn active_smart(s: &Session) -> &SmartObject { + let st = s.active().unwrap(); + let LayerContent::Smart(smart) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected a Smart Object") }; + smart +} + +fn assert_smart_file_output(event: &str, s: &Session, path: &str, original: &[u8], replacement: &[u8]) { + match event { + "placedLayerExportContents" => assert_eq!(std::fs::read(path).unwrap().as_slice(), original), + "placedLayerConvertToLinked" => { + assert_eq!(std::fs::read(path).unwrap().as_slice(), original); + assert!(matches!(&active_smart(s).source, SmartSource::Linked { path: actual } if actual == path)); + } + "placedLayerRelinkToFile" => assert!(matches!(&active_smart(s).source, SmartSource::Linked { path: actual } if actual == path)), + "placedLayerReplaceContents" => { + assert!(matches!(&active_smart(s).source, SmartSource::Embedded { bytes, .. } if bytes.as_slice() == replacement)); + } + _ => unreachable!(), + } +} + +#[test] +fn smart_paths_roundtrip_and_play_real_files_on_the_current_target() { + let original = png("#0000ff"); + let replacement = png("#ff0000"); + for &(event, id) in SMART_PATHS { + for header in ["null", event] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("contents Ω.png"); + let path = path.to_str().unwrap(); + let replacing = matches!(event, "placedLayerRelinkToFile" | "placedLayerReplaceContents"); + if replacing { + std::fs::write(path, &replacement).unwrap(); + } + let canonical = smart_path_descriptor(event, path); + let mut recorded = canonical.clone(); + recorded.class_id = Id::new(header); + let mut s = smart_fixture(&original); + if replacing { + let hint = i32::MAX; + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| layer.id.0 != hint as u64)); + recorded = recorded.with("LyrI", D::Integer(hint)); + } + assert_eq!(load(&mut s, &file(event, recorded))["unsupported"], json!([]), "{event}"); + assert_eq!(s.actions.list[0].steps, [(id.to_string(), json!({"path": path}))]); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + // Reference #10603, docs/atn.md:197–201: normalize the descriptor class, + // and omit session LyrI. All meaningful event/path keys remain identical. + assert_eq!(parsed.actions[0].items[0].descriptor, Some(canonical)); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + let before = s.active().unwrap().doc.clone(); + let before_smart = active_smart(&s).clone(); + play(&mut s); + assert_eq!(active_smart(&s).transform, before_smart.transform); + assert_smart_file_output(event, &s, path, &original, &replacement); + if replacing { + assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|px| *px == [1.0, 0.0, 0.0, 1.0])); + } + if event == "placedLayerExportContents" { + assert_eq!(s.active().unwrap().doc, before); + } else { + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } + } +} + +#[test] +fn smart_paths_keep_missing_unusable_and_unrepresented_descriptors_whole() { + for &(event, _) in SMART_PATHS { + let key = if event == "placedLayerConvertToLinked" { "Usng" } else { "null" }; + for descriptor in [ + Descriptor::new("null"), + Descriptor::new("null").with(key, path_value("")), + Descriptor::new("null").with(key, path_value("a\0b")), + Descriptor::new("null").with(key, D::Text(UnicodeString::new("/tmp/file.png"))), + Descriptor::new("null").with(key, D::Path(b"txtu".to_vec())), + Descriptor::new("null").with("Usng", path_value("/tmp/file.png")), + smart_path_descriptor(event, "/tmp/file.png").with("extra", D::Integer(1)), + smart_path_descriptor(event, "/tmp/file.png").with("LyrI", D::Boolean(true)), + smart_path_descriptor(event, "/tmp/file.png").with("LyrI", D::Integer(-1)), + smart_path_descriptor(event, "/tmp/file.png").with("LyrI", D::LargeInteger(i64::from(i32::MAX) + 1)), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(descriptor)); + } + } +} + +#[test] +fn smart_path_exports_refuse_unrepresentable_native_params() { + for &(_, id) in SMART_PATHS { + for params in [json!({}), json!({"path": ""}), json!({"path": "a\0b"}), json!({"path": 3}), json!({"path": "/tmp/a.png", "layer": 1})] { + let action = Action::new("A", vec![(id.into(), params)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } + } +} + +#[test] +fn unusable_smart_file_at_playback_fails_without_changing_the_document() { + let original = png("#0000ff"); + let dir = tempfile::tempdir().unwrap(); + for &(event, id) in SMART_PATHS { + let mut s = smart_fixture(&original); + assert_eq!(load(&mut s, &file(event, smart_path_descriptor(event, dir.path().to_str().unwrap())))["unsupported"], json!([])); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{event}: {report}"); + assert_eq!(report["failed"]["id"], id, "{event}: {report}"); + assert!(report["failed"]["error"].as_str().is_some_and(|error| !error.is_empty()), "{event}: {report}"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs new file mode 100644 index 0000000000..0e35280eb7 --- /dev/null +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -0,0 +1,511 @@ +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn class(name: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(name) } +} + +fn ordinal(name: &str) -> ReferenceItem { + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new(name) } +} + +fn load(s: &mut Session, reference: ReferenceItem) { + let descriptor = Descriptor::new("null").with("null", D::Reference(vec![reference])); + s.actions.list = vec![imported(AtnEvent::Code(*b"slct"), descriptor)]; +} + +fn bytes(event: AtnEvent, descriptor: Descriptor) -> Vec { + let file = AtnFile { + name: UnicodeString::new_nul("References"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Select"), + items: vec![AtnItem { event, descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + atn::write(&file).unwrap() +} + +fn imported(event: AtnEvent, descriptor: Descriptor) -> Action { + let mut result = atn_cmds::import(&bytes(event, descriptor)).unwrap(); + assert!(result.unsupported.is_empty(), "{:?}", result.unsupported); + result.actions.remove(0) +} + +/// Check actual saved reference kinds/codes, not only a matching import/export implementation. +fn roundtrip(action: &Action, reference: &ReferenceItem, target: Option<&ReferenceItem>) { + let (data, skipped) = atn_cmds::export("References", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&data).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("null"), Some(&D::Reference(vec![reference.clone()]))); + if let Some(target) = target { + assert_eq!(descriptor.get("T "), Some(&D::Reference(vec![target.clone()]))); + } + assert_eq!(atn_cmds::import(&data).unwrap().actions[0].steps, action.steps); +} + +fn replay(s: &mut Session, action: &Action) { + s.actions.list = vec![action.clone()]; + let result = s.execute("actions.play", json!({"action":"Select"})).unwrap(); + assert_eq!(result["ran"], 1, "{result}"); + assert!(result.get("failed").is_none(), "{result}"); +} + +fn layer(s: &Session, name: &str) -> u64 { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, layer)| layer.name == name).unwrap().2.id.0 +} + +fn selection(s: &Session) -> Value { + let st = s.active().unwrap(); + json!({"ids":st.selected_layers().iter().map(|id|id.0).collect::>(),"active":st.active_layer.map(|id|id.0),"anchor":st.layer_anchor.map(|id|id.0),"revision":st.revision}) +} + +/// The measured stack has a collapsed group and an optional structural Background. +fn scene(background: bool) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":if background {"white"} else {"transparent"}})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Base"})).unwrap(); + for name in ["L1", "L2", "Gchild"] { + s.execute("layer.new.layer", json!({"name":name})).unwrap(); + } + s.execute("layer.groupLayers", json!({"name":"G"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"L3"})).unwrap(); + assert_eq!(s.active().unwrap().doc.background().is_some(), background); + s +} + +fn index(class_name: &str, index: u32) -> ReferenceItem { + ReferenceItem::Index { class: class(class_name), index } +} + +fn select(reference: ReferenceItem) -> Action { + imported(AtnEvent::Code(*b"slct"), Descriptor::new("null").with("null", D::Reference(vec![reference]))) +} + +fn moved(reference: ReferenceItem, target: ReferenceItem) -> Action { + imported(AtnEvent::Code(*b"move"), Descriptor::new("null").with("null", D::Reference(vec![reference])).with("T ", D::Reference(vec![target]))) +} + +fn fixture() -> (Session, u64, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + s.execute("layer.new.layer", json!({"name": "L1"})).unwrap(); + let l2 = s.execute("layer.new.layer", json!({"name": "L2"})).unwrap()["layer"].as_u64().unwrap(); + let child = s.execute("layer.new.layer", json!({"name": "Gchild"})).unwrap()["layer"].as_u64().unwrap(); + let group = s.execute("layer.groupLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.new.layer", json!({"name": "L3"})).unwrap(); + (s, l2, child, group) +} + +/// Recorded navigation enters collapsed groups; native keyboard navigation follows visible rows. +#[test] +fn recorded_forward_enters_a_collapsed_group() { + let (mut s, l2, child, group) = fixture(); + s.execute("layer.select", json!({"layer": l2})).unwrap(); + s.execute("layer.selectAbove", json!({})).unwrap(); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, group); + s.execute("layer.select", json!({"layer": l2})).unwrap(); + load(&mut s, ordinal("Frwr")); + s.execute("actions.play", json!({"action": "Select"})).unwrap(); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, child); +} + +/// Indexes count group ends; recorded navigation visits collapsed children and retains its form. +#[test] +fn indexes_and_ordinals_use_the_recorded_stack_with_or_without_background() { + for background in [false, true] { + let mut s = scene(background); + let first = u32::from(!background); + let expected = [Some("Base"), Some("L1"), Some("L2"), None, Some("Gchild"), Some("G"), Some("L3")]; + for (offset, name) in expected.into_iter().enumerate() { + s.execute("layer.select", json!({"layer":layer(&s,"L2")})).unwrap(); + let reference = index("Lyr ", first + offset as u32); + let action = select(reference.clone()); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, layer(&s, name.unwrap_or("L2"))); + } + for (code, expected) in [("Trgt", "L2"), ("Frwr", "Gchild"), ("Bckw", "L1"), ("Frnt", "L3"), ("Back", "Base")] { + s.execute("layer.select", json!({"layer":layer(&s,"L2")})).unwrap(); + let reference = ordinal(code); + let action = select(reference.clone()); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, layer(&s, expected)); + } + for invalid in [first + 7, u32::MAX].into_iter().chain((!background).then_some(0)) { + let action = select(index("Lyr ", invalid)); + let before = selection(&s); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(selection(&s), before); + } + } +} + +/// An end marker is a true no-op, including multi-selection, anchor and add/remove modifiers. +#[test] +fn group_end_does_not_replace_the_current_selection() { + let mut s = scene(true); + s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap(); + for modifier in [None, Some("addToSelection"), Some("removeFromSelection"), Some("addToSelectionContinuous")] { + let mut d = Descriptor::new("null").with("null", D::Reference(vec![index("Lyr ", 3)])); + if let Some(modifier) = modifier { + d = d.with("selectionModifier", D::Enumerated { type_id: Id::new("selectionModifierType"), value: Id::new(modifier) }); + } + let action = imported(AtnEvent::Code(*b"slct"), d); + let before = selection(&s); + replay(&mut s, &action); + assert_eq!(selection(&s), before); + } + for extras in [json!({"name":"L1"}), json!({"occurrence":0}), json!({"mode":"invalid"})] { + let mut params = json!({"layer":{"index":3}}); + params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone()); + let before = selection(&s); + assert!(s.execute("layer.select", params).is_err()); + assert_eq!(selection(&s), before); + } +} + +/// Move indexes name pre-move boundaries; the two self boundaries preserve selection/history. +#[test] +fn indexed_moves_follow_the_measured_boundary_table() { + for target in 0..=6 { + let mut s = scene(true); + let reference = index("Lyr ", 2); + let destination = index("Lyr ", target); + let action = moved(reference.clone(), destination.clone()); + roundtrip(&action, &reference, Some(&destination)); + let before = selection(&s); + if target == 0 { + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + assert_eq!(selection(&s), before); + continue; + } + replay(&mut s, &action); + let expected = match target { + 1 => vec!["Base", "L2", "L1", "G", "L3"], + 2 | 3 => vec!["Base", "L1", "L2", "G", "L3"], + 4 | 5 => vec!["Base", "L1", "G", "L3"], + _ => vec!["Base", "L1", "G", "L2", "L3"], + }; + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.iter().map(|l| l.name.as_str()).collect::>(), expected, "target {target}"); + let children = doc.layer(photocraft_doc::LayerId(layer(&s, "G"))).unwrap().children().unwrap(); + assert_eq!( + children.iter().map(|l| l.name.as_str()).collect::>(), + match target { + 4 => vec!["L2", "Gchild"], + 5 => vec!["Gchild", "L2"], + _ => vec!["Gchild"], + } + ); + if matches!(target, 2 | 3) { + assert_eq!(selection(&s), before); + } + } + let mut s = scene(false); + let action = moved(index("Lyr ", 3), index("Lyr ", 0)); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc.layers[0].name, "L2"); + let mut s = scene(true); + for invalid in [7, u32::MAX] { + let action = moved(index("Lyr ", 2), index("Lyr ", invalid)); + let doc = s.active().unwrap().doc.clone(); + let state = selection(&s); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + assert_eq!(selection(&s), state); + } +} + +/// A target-layer move keeps the complete selected source set and remains one undoable edit. +#[test] +fn indexed_move_preserves_multiple_selected_sources() { + let mut s = scene(true); + s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap(); + let before = selection(&s); + let action = moved(ordinal("Trgt"), index("Lyr ", 6)); + roundtrip(&action, &ordinal("Trgt"), Some(&index("Lyr ", 6))); + replay(&mut s, &action); + let after = selection(&s); + for key in ["ids", "active", "anchor"] { + assert_eq!(after[key], before[key], "{key}"); + } + assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.name.as_str()).collect::>(), ["Base", "G", "L1", "L2", "L3"]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.name.as_str()).collect::>(), ["Base", "L1", "L2", "G", "L3"]); +} + +/// Nested empty groups have distinct end/header boundaries, and cycle refusals are atomic. +#[test] +fn nested_empty_group_boundaries_reparent_at_the_correct_level() { + for target in [1, 2, 3] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("layer.new.group", json!({"name":"Inner"})).unwrap(); + s.execute("layer.groupLayers", json!({"name":"Outer"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Top"})).unwrap(); + let outer = photocraft_doc::LayerId(layer(&s, "Outer")); + let inner = photocraft_doc::LayerId(layer(&s, "Inner")); + let action = moved(index("Lyr ", 5), index("Lyr ", target)); + replay(&mut s, &action); + let doc = &s.active().unwrap().doc; + let parent = doc.path_of(photocraft_doc::LayerId(layer(&s, "Top"))).unwrap(); + assert_eq!( + parent.len(), + match target { + 1 => 1, + 2 => 2, + _ => 3, + } + ); + assert_eq!(doc.layer(outer).unwrap().children().unwrap().len(), if target == 2 { 2 } else { 1 }); + assert_eq!(doc.layer(inner).unwrap().children().unwrap().len(), usize::from(target == 3)); + let action = moved(index("Lyr ", 5), index("Lyr ", 4)); + let before = doc.clone(); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Typed document indexes use opening order while numeric document indexes still address tabs. +#[test] +fn document_indexes_survive_tab_moves_close_and_reopen_with_old_ids() { + let mut s = Session::new(); + for name in ["A", "B", "C"] { + s.execute("file.new", json!({"width":2,"height":2,"name":name})).unwrap(); + } + let reopened = (*s.documents()[0].doc).clone(); + s.execute("document.move", json!({"document":2,"to":0})).unwrap(); + s.execute("document.activate", json!({"document":0})).unwrap(); + assert_eq!(s.active().unwrap().doc.name, "C"); + let reference = index("Dcmn", 2); + let action = select(reference.clone()); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc.name, "B"); + s.execute("file.close", json!({"document":1})).unwrap(); + s.add_document(reopened, None); + for (n, expected) in [(1, "B"), (2, "C"), (3, "A")] { + replay(&mut s, &select(index("Dcmn", n))); + assert_eq!(s.active().unwrap().doc.name, expected); + } + for invalid in [0, u32::MAX, 4] { + let before = s.active_index(); + let action = select(index("Dcmn", invalid)); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active_index(), before); + } +} + +fn comp_scene() -> (Session, u32, u32) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + let dead = s.execute("layerComp.new", json!({"name":"Deleted"})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.hideLayers", json!({})).unwrap(); + let first = s.execute("layerComp.new", json!({"name":"First"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layer.showLayers", json!({})).unwrap(); + let second = s.execute("layerComp.new", json!({"name":"Second"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layerComp.delete", json!({"comp":dead})).unwrap(); + (s, first, second) +} + +/// Comp ids, panel indexes and unusual measured ordinals stay distinct after reordering. +#[test] +fn comp_forms_keep_their_wire_kind_and_resolve_at_playback() { + let (mut s, first, second) = comp_scene(); + for reordered in [false, true] { + if reordered { + s.execute("layerComp.reorder", json!({"comp":second,"index":0})).unwrap(); + } + let panel_first = if reordered { second } else { first }; + let panel_second = if reordered { first } else { second }; + let current = panel_second; + let mut cases = vec![ + (index("compsClass", 1), panel_first), + (index("compsClass", 2), panel_second), + (ReferenceItem::Identifier { class: class("compsClass"), id: first }, first), + ]; + for (code, expected) in [("Trgt", current), ("Nxt ", current), ("Prvs", current), ("Frst", panel_first), ("Lst ", panel_first), ("Al ", panel_first)] { + cases.push(( + ReferenceItem::Enumerated { class: class("compsClass"), type_id: Id::new("Ordn"), value: Id::Code(code.as_bytes().try_into().unwrap()) }, + expected, + )); + } + for (reference, expected) in cases { + s.execute("layerComp.apply", json!({"comp":current})).unwrap(); + s.execute(if current == second { "layer.hideLayers" } else { "layer.showLayers" }, json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.last_applied_comp, Some(current)); + let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc.last_applied_comp, Some(expected)); + assert_eq!(s.active().unwrap().doc.layers[0].visible, expected == second); + } + } +} + +/// Invalid comp indexes succeed without edits; unmeasured layer/comp reference forms stay kept. +#[test] +fn invalid_comp_indexes_and_unsupported_references_preserve_state() { + let (mut s, _, _) = comp_scene(); + for invalid in [0, 3, u32::MAX] { + let reference = index("compsClass", invalid); + let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); + roundtrip(&action, &reference, None); + let before = s.active().unwrap().doc.clone(); + let state = selection(&s); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(selection(&s), state); + } + let before = s.active().unwrap().doc.clone(); + s.execute("layerComp.apply", json!({"comp":{"index":-1}})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.execute("layerComp.apply", json!({"comp":{"id":u32::MAX}})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + let negative = Action::new("Select", vec![("layerComp.apply".into(), json!({"comp":{"index":-1}}))]); + let (saved, _) = atn_cmds::export("References", &[&negative], &[], false).unwrap(); + assert_eq!( + atn::parse(&saved).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("null"), + Some(&D::Reference(vec![index("compsClass", u32::MAX)])) + ); + for (event, reference) in [ + (AtnEvent::Code(*b"slct"), ReferenceItem::Identifier { class: class("Lyr "), id: 2 }), + (AtnEvent::Code(*b"slct"), ReferenceItem::Offset { class: class("Lyr "), offset: 1 }), + (AtnEvent::name("applyComp"), ReferenceItem::Offset { class: class("compsClass"), offset: 0 }), + (AtnEvent::name("applyComp"), ReferenceItem::Enumerated { class: class("compsClass"), type_id: Id::new("wrong"), value: Id::new("Trgt") }), + (AtnEvent::Code(*b"slct"), ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Frwr") }), + ] { + let d = Descriptor::new("null").with("null", D::Reference(vec![reference])); + let data = bytes(event, d.clone()); + let result = atn_cmds::import(&data).unwrap(); + assert_eq!(result.unsupported.len(), 1); + assert_eq!(result.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (saved, _) = atn_cmds::export("References", &[&result.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor, Some(d)); + } + for offset in [-1, 0, 1] { + let d = Descriptor::new("null") + .with("null", D::Reference(vec![ordinal("Trgt")])) + .with("T ", D::Reference(vec![ReferenceItem::Offset { class: class("Lyr "), offset }])); + assert_eq!(atn_cmds::import(&bytes(AtnEvent::Code(*b"move"), d)).unwrap().unsupported.len(), 1); + } +} + +/// Reject integers outside the ATN reference format before they can enter a recording. +#[test] +fn unrepresentable_comp_indexes_are_rejected_before_recording() { + let (mut s, _, _) = comp_scene(); + for valid in [json!(i32::MIN), json!(u32::MAX)] { + s.execute("layerComp.apply", json!({"comp":{"index":valid}})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + let state = selection(&s); + let history = s.active().unwrap().history.past_len(); + s.execute("actions.record", json!({"name":"Index bounds"})).unwrap(); + let mut accepted = Vec::new(); + for invalid in [json!(u64::from(u32::MAX) + 1), json!(u64::MAX), json!(i64::from(i32::MIN) - 1), json!(i64::MIN)] { + if s.execute("layerComp.apply", json!({"comp":{"index":invalid}})).is_ok() { + accepted.push(invalid); + } + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(selection(&s), state); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + s.execute("actions.stop", json!({})).unwrap(); + assert!(accepted.is_empty(), "unrepresentable indexes were accepted: {accepted:?}"); + assert!(s.actions.list.last().unwrap().steps.is_empty()); +} + +/// Valid extreme indexes survive recording and export as ATN Index references. +#[test] +fn accepted_comp_boundary_indexes_record_and_export() { + let (mut s, _, _) = comp_scene(); + s.execute("actions.record", json!({"name":"Boundary indexes"})).unwrap(); + for valid in [json!(i32::MIN), json!(u32::MAX)] { + s.execute("layerComp.apply", json!({"comp":{"index":valid}})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let action = s.actions.list.last().unwrap(); + assert_eq!(action.steps.len(), 2); + let (saved, skipped) = atn_cmds::export("References", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&saved).unwrap(); + assert_eq!(parsed.actions[0].items.len(), 2); + for (item, expected) in parsed.actions[0].items.iter().zip([i32::MIN as u32, u32::MAX]) { + assert_eq!(item.descriptor.as_ref().unwrap().get("null"), Some(&D::Reference(vec![index("compsClass", expected)]))); + } +} + +/// A recorded invalid index is a no-op even with zero comps; ordinary Apply stays disabled. +#[test] +fn empty_comp_list_index_is_noop() { + let mut s = Session::new(); + assert!(!s.is_enabled_with("layerComp.apply", &json!({"comp":{"index":0}}))); + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + assert!(s.active().unwrap().doc.layer_comps.is_empty()); + assert!(!s.is_enabled("layerComp.apply")); + for invalid in [0, 1, u32::MAX] { + let reference = index("compsClass", invalid); + let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); + roundtrip(&action, &reference, None); + let before = s.active().unwrap().doc.clone(); + let state = selection(&s); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(selection(&s), state); + } + s.execute("layerComp.apply", json!({"comp":{"index":-1}})).unwrap(); + assert!(!s.is_enabled("layerComp.apply")); + for params in [json!({}), json!({"comp":{"id":1}}), json!({"comp":{"index":false}})] { + assert!(s.execute("layerComp.apply", params).is_err()); + } +} + +/// A group-end index names no layer, so a command acting on a layer fails and changes nothing +/// rather than falling back to the selected layers. +#[test] +fn group_end_index_never_targets_the_selection() { + let mut s = scene(true); + s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute("layer.translate", json!({"layer":{"index":3},"dx":1,"dy":0})).is_err()); + let offset = Descriptor::new("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 1.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }); + let action = + imported(AtnEvent::Code(*b"move"), Descriptor::new("null").with("null", D::Reference(vec![index("Lyr ", 3)])).with("T ", D::Descriptor(offset))); + s.actions.list = vec![action]; + let result = s.execute("actions.play", json!({"action":"Select"})).unwrap(); + assert!(result.get("failed").is_some(), "{result}"); + assert_eq!(s.active().unwrap().history.past_len(), past); + assert_eq!(s.active().unwrap().doc, doc); +} + +/// A relative ordinal is resolved once: the precondition checked on the named layer sees that +/// layer, not the one past it (here a type layer that would refuse a colour-channel edit). +#[test] +fn forward_ordinal_precondition_checks_the_resolved_layer() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"transparent"})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"A"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"B"})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#000000"})).unwrap(); + s.execute("type.create", json!({"text":"C","size":2})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"C"})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"A")})).unwrap(); + s.execute("channel.target", json!({"colors":[0]})).unwrap(); + let b = photocraft_doc::LayerId(layer(&s, "B")); + let before = s.active().unwrap().doc.layer(b).cloned(); + s.execute("edit.clear", json!({"layer":{"ordinal":"Frwr"}})).unwrap(); + assert_ne!(s.active().unwrap().doc.layer(b).cloned(), before); +} diff --git a/crates/engine/tests/it/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs index b6bde88f7a..0ff38f2036 100644 --- a/crates/engine/tests/it/atn_ripple_amount.rs +++ b/crates/engine/tests/it/atn_ripple_amount.rs @@ -24,7 +24,7 @@ fn fixture(depth: u32) -> Session { /// 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 + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Fractions are valid native edits, but the existing integer event cannot carry them. diff --git a/crates/engine/tests/it/atn_selective_color.rs b/crates/engine/tests/it/atn_selective_color.rs index 454846f59e..92a23aff5e 100644 --- a/crates/engine/tests/it/atn_selective_color.rs +++ b/crates/engine/tests/it/atn_selective_color.rs @@ -37,7 +37,7 @@ fn doc_bounds() -> photocraft_doc::Rect { } fn pixels(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } fn check_cases(id: &str, cases: &[Value], require_effect: bool) { diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs new file mode 100644 index 0000000000..fb6e605d4d --- /dev/null +++ b/crates/engine/tests/it/atn_shear.rs @@ -0,0 +1,258 @@ +//! Public load/save/playback witnesses for the measured Shear action mapping. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{Imported, export, import}, +}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn desc(class: &str) -> Descriptor { + Descriptor::new(class) +} +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} +/// Four-byte recorded IDs retain their code form; longer IDs exercise string-event lookup. +fn item(event: &str, label: &str, descriptor: Option) -> AtnItem { + let event = match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::name(event), + }; + AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor, ..Default::default() } +} +fn one_action(items: Vec) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("Saves"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + ..Default::default() + } +} +fn b64(bytes: &[u8]) -> String { + photocraft_engine::paint::tile::b64_encode(bytes) +} +fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported { + import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap() +} +/// Save one real native action and parse its public output, rather than calling a private converter. +fn export_item(id: &str, params: &Value, set: &str) -> Option { + let action = Action::new("A", vec![(id.into(), params.clone())]); + let bytes = export(set, &[&action], &[], false).ok()?.0; + atn::parse(&bytes).ok()?.actions.first()?.items.first().cloned() +} + +/// The measured Shear list uses ordered horizontal/vertical doubles inside point objects. +fn shear_descriptor(points: &[[f64; 2]], undefined: &str) -> Descriptor { + let list = points.iter().map(|p| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(p[0])).with("Vrtc", D::Double(p[1])))).collect(); + desc("null").with("ShrP", D::List(list)).with("UndA", enm("UndA", undefined)).with("ShrS", D::Integer(0)).with("ShrE", D::Integer(points.len() as i32 - 1)) +} + +/// Both event identities, changed curves and missing-key defaults save every measured setting in order. +#[test] +fn shear_steps_roundtrip_curves_defaults_and_flags() { + let factory = shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "WrpA"); + for event in ["Shr ", "shear"] { + for d in [factory.clone(), shear_descriptor(&[[-32.0, 1.0], [16.0, 64.5], [0.0, 128.0]], "RptE")] { + let loaded = mapped_recording(event, d.clone()); + assert!(loaded.unsupported.is_empty()); + let action = &loaded.actions[0]; + assert_eq!(action.steps[0].0, "filter.distort.shear"); + let bytes = export("S", &[action], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0].event, AtnEvent::Code(*b"Shr ")); + assert_eq!(saved.actions[0].items[0].descriptor, Some(d)); + assert_eq!(import(&bytes).unwrap().actions[0].steps, action.steps); + } + } + let loaded = mapped_recording("Shr ", desc("null")); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].1, json!({"points":[[0,0],[1,0]],"undefinedAreas":"wrap"})); + assert_eq!(export_item("filter.distort.shear", &loaded.actions[0].steps[0].1, "S").unwrap().descriptor, Some(factory)); + let mut step = item("Shr ", "Shear", Some(shear_descriptor(&[[0.0, 1.0], [0.0, 128.0]], "RptE"))); + step.enabled = false; + step.with_dialog = true; + step.dialog_options = 2; // The wire mode for showing the enabled dialog toggle. + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(saved.actions[0].items[0], step); +} + +/// Clamp hostile coordinates, but retain finite fractional wire values that division by127 cannot invert. +#[test] +fn shear_clamps_points_and_preserves_fractional_wire_values() { + let raw = shear_descriptor(&[[-10000.0, -1000.0], [10000.0, 1000.0]], "WrpA"); + let loaded = mapped_recording("Shr ", raw); + assert!(loaded.unsupported.is_empty()); + let p = &loaded.actions[0].steps[0].1; + assert_eq!(p["points"], json!([[0, -1], [1, 63.0 / 64.0]])); + assert_eq!(export_item("filter.distort.shear", p, "S").unwrap().descriptor, Some(shear_descriptor(&[[-64.0, 1.0], [63.0, 128.0]], "WrpA"))); + + let d = shear_descriptor(&[[0.0, 1.0], [7.97, 4.975609756097561], [0.0, 128.0]], "RptE"); + let loaded = mapped_recording("shear", d.clone()); + assert!(loaded.unsupported.is_empty()); + let mut p = loaded.actions[0].steps[0].1.clone(); + assert!(p.get("_atnShearPoints").is_some(), "the measured inverse loses a wire bit"); + assert_eq!(export_item("filter.distort.shear", &p, "S").unwrap().descriptor, Some(d)); + let mut edited = p.clone(); + let position = p["points"][1][0].as_f64().unwrap(); + edited["points"][1][0] = json!(position + 1e-12); + assert_ne!(edited["points"][1][0], p["points"][1][0]); + assert_eq!(edited["points"][1][0].as_f64().unwrap() as f32, position as f32); + assert!(export_item("filter.distort.shear", &edited, "S").is_none(), "stored wire metadata rejects even an edit below native precision"); + p["points"][1][1] = json!(0.25); + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "stale wire metadata must not replace a native edit"); +} + +/// The adapter's engineering budget admits its boundary and preserves larger recordings intact. +#[test] +fn shear_point_budget_preserves_recordings_and_refuses_oversized_native_exports() { + const LIMIT: usize = 4096; + for count in [LIMIT, LIMIT + 1] { + let grid: Vec<_> = (0..count).map(|i| [0.0, 1.0 + 127.0 * i as f64 / (count - 1) as f64]).collect(); + let label = if count == LIMIT { "Shear" } else { "Budget curve" }; + let mut source = item("Shr ", label, Some(shear_descriptor(&grid, "WrpA"))); + source.enabled = false; + let loaded = import(&atn::write(&one_action(vec![source.clone()])).unwrap()).unwrap(); + let action = &loaded.actions[0]; + if count == LIMIT { + assert!(loaded.unsupported.is_empty()); + assert_eq!(action.steps[0].0, "filter.distort.shear"); + assert_eq!(action.steps[0].1["points"].as_array().unwrap().len(), LIMIT); + } else { + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(action.steps[0].0, "actions.photoshopStep"); + } + let saved = export("S", &[action], &[], false).unwrap().0; + assert!(atn::parse(&saved).unwrap().actions[0].items[0] == source, "{count}-point item changed on save"); + + let native = json!({"points":grid.iter().map(|p| [(p[1] - 1.0) / 127.0, p[0] / 64.0]).collect::>()}); + let before = native.clone(); + assert_eq!(export_item("filter.distort.shear", &native, "S").is_some(), count == LIMIT); + assert_eq!(native, before, "export never edits the caller's curve"); + if count > LIMIT { + let metadata = json!({"points":[[0,0],[1,0]],"_atnShearPoints":grid}); + assert!(export_item("filter.distort.shear", &metadata, "S").is_none()); + } + } +} + +/// Unmeasured subranges, malformed points and repeated settings remain whole, including their flags. +#[test] +fn shear_unrepresentable_steps_stay_kept_without_partial_playback() { + let full = shear_descriptor(&[[0.0, 1.0], [8.0, 64.5], [0.0, 128.0]], "WrpA"); + let mut missing_end = full.clone(); + missing_end.items.pop(); // Missing ShrE means1, not the supplied list's last index2. + let mut invalid_start = full.clone(); + invalid_start.items[2].1 = D::Integer(1); + let mut invalid_end = full.clone(); + invalid_end.items[3].1 = D::Integer(100); + let mut wrong_type = full.clone(); + wrong_type.items[2].1 = D::Double(0.0); + for d in [ + missing_end, + invalid_start, + invalid_end, + wrong_type, + full.clone().with("ShrS", D::Integer(0)), + full.clone().with("extra", D::Boolean(true)), + desc("null").with("ShrP", D::List(vec![])), + desc("null").with("ShrP", D::List(vec![D::Integer(7)])), + desc("null").with("ShrP", D::List(vec![D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(0.0)))])), + desc("null").with("UndA", enm("UndA", "Trns")), + desc("null").with("UndA", enm("Other", "WrpA")), + shear_descriptor(&[[0.0, 128.0], [0.0, 1.0]], "WrpA"), + shear_descriptor(&[[0.0, 1.0], [8.0, 1.0], [0.0, 128.0]], "WrpA"), + ] { + let step = item("Shr ", "Shear", Some(d)); + let loaded = import(&atn::write(&one_action(vec![step.clone()])).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1, "{step:?}"); + let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], step); + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let name = loaded.actions[0].name.clone(); + s.actions.list.extend(loaded.actions); + let report = s.execute("actions.play", json!({"action":name})).unwrap(); + assert_eq!(report["ran"], 0); + assert!(report.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); + } +} + +/// Native exports follow points-before-amount and refuse settings that the measured curve cannot carry. +#[test] +fn shear_native_exports_keep_effective_defaults_and_refuse_lossy_options() { + for p in [ + json!({}), + json!({"amount":25}), + json!({"points":[[0,0],[1,0.5]],"amount":-80,"undefinedAreas":"repeat"}), + json!({"points":[[0,0],[0.009,0.125],[1,0]]}), + ] { + let item = export_item("filter.distort.shear", &p, "S").unwrap(); + let loaded = mapped_recording("Shr ", item.descriptor.unwrap()); + assert!(loaded.unsupported.is_empty()); + let steps = &loaded.actions[0].steps; + assert_eq!( + photocraft_engine::filters_ext::params_for("filter.distort.shear", &steps[0].1), + photocraft_engine::filters_ext::params_for("filter.distort.shear", &p) + ); + } + for p in [ + json!({"points":[]}), + json!({"points":[[0,0],[1,"bad"]]}), + json!({"points":[[1,0],[0,0]]}), + json!({"points":[[0,0],[1,1]]}), + json!({"amount":100}), + json!({"undefinedAreas":"transparent"}), + json!({"target":"mask"}), + json!({"layer":1}), + json!({"other":true}), + json!({"points":[[0,0],[1,0]],"_atnShearPoints":[[0,1],[8,128]]}), + ] { + assert!(export_item("filter.distort.shear", &p, "S").is_none(), "{p} exported"); + } +} + +/// A constant measured curve shifts pixels right by two, with independent wrap/repeat border and Undo oracles. +#[test] +fn shear_action_moves_real_pixels_and_undoes_at_each_depth() { + for depth in [8, 16, 32] { + for edge in ["WrpA", "RptE"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.edit("Pattern", |doc, _| { + let surface = doc.layers[0].surface_mut().unwrap(); + let data: Vec = (0..8).flat_map(|y| (0..32).flat_map(move |x| [x as f32 / 31.0, y as f32 / 7.0, (x % 3) as f32 / 2.0, 1.0])).collect(); + surface.write_region(photocraft_geom::Rect::new(0, 0, 32, 8), &data); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before); + let bytes = atn::write(&one_action(vec![item("Shr ", "Shear", Some(shear_descriptor(&[[8.0, 1.0], [8.0, 128.0]], edge)))])).unwrap(); + let loaded = s.execute("actions.load", json!({"data":b64(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + let played = s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let output = photocraft_compose::flatten(&s.active().unwrap().doc); + assert_ne!(output, pixels); + for y in 0..8 { + for x in 0..32 { + let source = if edge == "WrpA" { (x - 2i32).rem_euclid(32) } else { (x - 2i32).max(0) }; + for (got, want) in output.get(x, y).into_iter().zip(pixels.get(source, y)) { + assert!((got - want).abs() < 1e-5, "depth{depth} {edge} ({x},{y}):{got} != {want}"); + } + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} diff --git a/crates/engine/tests/it/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs index 742359cf38..3034fb2f0a 100644 --- a/crates/engine/tests/it/atn_stroke_width.rs +++ b/crates/engine/tests/it/atn_stroke_width.rs @@ -23,7 +23,7 @@ fn fixture(depth: u32) -> Session { /// 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 + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Direct execution, native playback and strict binary ATN playback must agree exactly. diff --git a/crates/engine/tests/it/atn_type_path.rs b/crates/engine/tests/it/atn_type_path.rs new file mode 100644 index 0000000000..f5b565b849 --- /dev/null +++ b/crates/engine/tests/it/atn_type_path.rs @@ -0,0 +1,183 @@ +//! Recorded Type > Create Work Path preserves its descriptor and uses native glyph outlines. +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds::{export, import}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const COMMAND: &str = "type.createWorkPath"; +const SOURCE: &str = "_atnTypePathDescriptor"; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } +} + +/// Build the measured descriptor independently of the production converter. +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])) + .with("From", D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])) +} + +fn item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(*b"Mk "), dictionary: b"Make".to_vec(), descriptor: Some(d), ..Default::default() } +} + +/// Exercise the public byte importer rather than invoking the converter directly. +fn load(item: AtnItem) -> photocraft_engine::atn_cmds::Imported { + let bytes = atn::write(&AtnFile { + name: UnicodeString::new_nul("Paths"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Path"), items: vec![item], ..Default::default() }], + ..Default::default() + }) + .unwrap(); + import(&bytes).unwrap() +} + +/// Parse public export bytes so assertions inspect the actual saved ATN item. +fn saved(action: &Action) -> AtnItem { + let bytes = export("Paths", &[action], &[], false).unwrap().0; + atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0) +} + +/// Names, key order, ID encoding and dialog flags must survive both Make event aliases. +#[test] +fn descriptor_identity_flags_and_make_alias_roundtrip() { + for named in [false, true] { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded path"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + if named { + source.event = AtnEvent::name("make"); + } + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let loaded = load(source.clone()); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].0, COMMAND); + let result = saved(&loaded.actions[0]); + assert_eq!(result.descriptor.unwrap().to_bytes(), source.descriptor.unwrap().to_bytes()); + assert!(!result.enabled); + assert!(result.with_dialog); + assert_eq!(result.dialog_options, 2); + } +} + +/// Compare enabled canonical and retained-descriptor playback with native output at all depths. +/// Glyph bounds distinguish the older active text layer from the newest layer independently. +#[test] +fn playback_matches_native_path_and_preserves_text_with_undo_redo() { + for noncanonical in [false, true] { + for depth in [8, 16, 32] { + let mut recorded = Session::new(); + recorded.execute("file.new", json!({"width":160,"height":100,"depth":depth})).unwrap(); + let text = recorded.execute("type.create", json!({"x":90,"y":80,"text":"ill","size":30})).unwrap(); + let text = LayerId(text["layer"].as_u64().unwrap()); + let other = recorded.execute("type.create", json!({"x":8,"y":35,"text":"o","size":24})).unwrap(); + let other = LayerId(other["layer"].as_u64().unwrap()); + assert_eq!(recorded.active().unwrap().active_layer, Some(other)); + recorded.select_layer(text).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let glyphs = before.layer(text).unwrap().surface().unwrap().content_bounds(); + let other_glyphs = before.layer(other).unwrap().surface().unwrap().content_bounds(); + let past = recorded.active().unwrap().history.past_len(); + // Obtain the native result on this very document, then restore the starting state. + recorded.execute(COMMAND, json!({})).unwrap(); + let native = recorded.active().unwrap().doc.clone(); + assert!(!native.work_path.as_ref().unwrap().subpaths.is_empty()); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref()); + let mut d = descriptor(); + if noncanonical { + d.items.reverse(); + d.name = UnicodeString::new_nul("Recorded path"); + } + recorded.actions.list = load(item(d)).actions; + if noncanonical { + assert!(recorded.actions.list[0].steps[0].1.get(SOURCE).is_some()); + } + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.as_ref(), native.as_ref()); + // Rendered glyph bounds are an independent oracle for the implicit active target. + let (x0, y0, x1, y1) = after.work_path.as_ref().unwrap().control_bounds().unwrap(); + for (path, ink) in [(x0, glyphs.x0), (y0, glyphs.y0), (x1, glyphs.x1), (y1, glyphs.y1)] { + assert!((path - f64::from(ink)).abs() < 3.0, "path {path}, active glyph bound {ink}"); + } + assert!(x0 > f64::from(other_glyphs.x1), "the other layer's glyphs must not supply the work path"); + assert!(matches!(after.layer(text).unwrap().content, LayerContent::Text(_))); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +/// Unknown keys and unsupported reference chains remain verbatim kept steps, never guessed mappings. +#[test] +fn unsupported_path_forms_are_kept_and_save_without_loss() { + let original = descriptor(); + let mut malformed = vec![ + original.clone().with("extra", D::Boolean(true)), + original.clone().with("From", D::Boolean(false)), + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])), + ]; + for from in [ + D::Reference(vec![ReferenceItem::Name { class: class("TxLr"), name: UnicodeString::new_nul("Text") }]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Al ") }]), + D::Reference(vec![ReferenceItem::Class(class("TxLr"))]), + ] { + let mut d = original.clone(); + d.items.iter_mut().find(|(k, _)| k.is("From")).unwrap().1 = from; + malformed.push(d); + } + for d in malformed { + let source = item(d); + let loaded = load(source.clone()); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + assert_eq!(saved(&loaded.actions[0]), source); + } +} + +/// A missing document or non-text receiver must fail without changing document or history. +#[test] +fn non_text_and_no_document_playback_fail_without_edits() { + for document in [false, true] { + let mut s = Session::new(); + if document { + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + } + let before = s.active().map(|st| (st.doc.clone(), st.history.past_len())); + s.actions.list = load(item(descriptor())).actions; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some(), "{result}"); + assert_eq!(s.active().map(|st| (st.doc.clone(), st.history.past_len())), before); + } +} + +/// Explicit native targets and corrupt wire metadata cannot export as the implicit measured form. +#[test] +fn native_export_refuses_explicit_targets_and_damaged_metadata() { + for params in [json!({"layer":1}), json!({"layers":[1]}), json!({(SOURCE):"bad base64"}), json!({(SOURCE):Value::Null})] { + let action = Action::new("Path", vec![(COMMAND.into(), params)]); + assert!(export("Paths", &[&action], &[], false).is_err()); + } + let action = Action::new("Path", vec![(COMMAND.into(), json!({}))]); + assert_eq!(saved(&action).descriptor.unwrap(), descriptor()); +} diff --git a/crates/engine/tests/it/atn_v12.rs b/crates/engine/tests/it/atn_v12.rs new file mode 100644 index 0000000000..d5bc6d8263 --- /dev/null +++ b/crates/engine/tests/it/atn_v12.rs @@ -0,0 +1,22 @@ +use photocraft_engine::{Session, paint::tile::b64_encode}; +use photocraft_psd::atn; +use serde_json::json; + +/// Legacy descriptor syntax must reach the same commands and parameters as version 16. +#[test] +fn legacy_v12_commands_import_like_v16() { + let bytes = include_bytes!("../../../psd/tests/fixtures/atn/v12-commands.atn"); + let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); + let expected = vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), + ("image.adjustments.levels".into(), json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2, "outBlack": 0, "outWhite": 250})), + ("select.all".into(), json!({})), + ("layer.new.layer".into(), json!({"name": "Legacy layer"})), + ]; + for input in [bytes.as_slice(), modern.as_slice()] { + let mut s = Session::new(); + let result = s.execute("actions.load", json!({"data": b64_encode(input)})).unwrap(); + assert_eq!(result["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, expected); + } +} diff --git a/crates/engine/tests/it/corpus_atn.rs b/crates/engine/tests/it/corpus_atn.rs index afc11cbc2a..f396176f4d 100644 --- a/crates/engine/tests/it/corpus_atn.rs +++ b/crates/engine/tests/it/corpus_atn.rs @@ -1,14 +1,13 @@ //! 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 +//! loads back to the same actions (allowing the specified compound-property event expansion). 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}; +use serde_json::json; fn collect(dir: &Path, out: &mut Vec) { // An unreadable folder fails the test rather than shrinking the corpus. @@ -31,45 +30,77 @@ fn collect(dir: &Path, out: &mut Vec) { 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); - } +/// A compound layer-property command deliberately becomes one Photoshop event per property +/// (#8304). Compare that documented sequence exactly, including its target and every flag; +/// all other commands and action metadata retain their original equality check. +fn exported_action(action: &photocraft_engine::actions_cmds::Action) -> photocraft_engine::actions_cmds::Action { + let mut out = action.clone(); + out.steps.clear(); + out.disabled.clear(); + out.dialogs.clear(); + for (index, (id, params)) in action.steps.iter().enumerate() { + let mut steps = vec![(id.clone(), params.clone())]; + if id == "layer.setProps" + && let Some(object) = params.as_object() + { + let fields = ["opacity", "fill", "blend", "visible", "name"]; + let properties: Vec<_> = fields.into_iter().filter(|key| object.contains_key(*key)).collect(); + if properties.len() > 1 && object.keys().all(|key| key == "layer" || fields.contains(&key.as_str())) { + steps = properties + .into_iter() + .map(|key| { + let mut property = json!({key: object.get(key).unwrap()}); + if let Some(target) = object.get("layer") { + property["layer"] = target.clone(); + } + (id.clone(), property) + }) + .collect(); } - out - }) - .collect() + } + for step in steps { + let next = out.steps.len(); + if action.disabled.contains(&index) { + out.disabled.insert(next); + } + if action.dialogs.contains(&index) { + out.dialogs.insert(next); + } + out.steps.push(step); + } + } + out +} + +#[test] +fn compound_roundtrip_expectation_keeps_properties_targets_flags_and_action_metadata() { + use photocraft_engine::actions_cmds::Action; + let mut source = Action::new( + "Properties", + vec![("layer.setProps".into(), json!({"layer": "Before", "name": "After", "opacity": 0.5, "blend": "Normal"})), ("select.inverse".into(), json!({}))], + ); + source.set = Some("Set".into()); + source.color = "blue".into(); + source.disabled.insert(0); + source.dialogs.insert(0); + source.dialogs.insert(1); + let mut expected = source.clone(); + expected.steps = vec![ + ("layer.setProps".into(), json!({"layer": "Before", "opacity": 0.5})), + ("layer.setProps".into(), json!({"layer": "Before", "blend": "Normal"})), + ("layer.setProps".into(), json!({"layer": "Before", "name": "After"})), + ("select.inverse".into(), json!({})), + ]; + expected.disabled = [0, 1, 2].into_iter().collect(); + expected.dialogs = [0, 1, 2, 3].into_iter().collect(); + assert_eq!(exported_action(&source), expected); + assert_eq!(exported_action(&expected), expected, "single-property steps stay unchanged"); + let mut lost_property = expected.clone(); + lost_property.steps.remove(1); + assert_ne!(exported_action(&source), lost_property, "losing blend must still fail comparison"); + let mut lost_flag = expected.clone(); + lost_flag.disabled.remove(&1); + assert_ne!(exported_action(&source), lost_flag, "losing an expanded flag must still fail comparison"); } #[test] @@ -99,7 +130,7 @@ fn atn_corpus_loads_saves_and_reloads() { 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 first: Vec<_> = s.actions.list.iter().map(exported_action).collect(); let saved = match s.execute("actions.save", json!({"set": loaded["set"]})) { Ok(v) => v, Err(e) => { @@ -110,7 +141,7 @@ fn atn_corpus_loads_saves_and_reloads() { 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(_) if t.actions.list == first => {} Ok(_) => { // Name the first step that differs, so a mapping asymmetry is quick to find. let detail = first diff --git a/crates/engine/tests/it/crop_delete_off_background.rs b/crates/engine/tests/it/crop_delete_off_background.rs new file mode 100644 index 0000000000..96350d4a1c --- /dev/null +++ b/crates/engine/tests/it/crop_delete_off_background.rs @@ -0,0 +1,249 @@ +//! Crop with Delete Cropped Pixels off turns the Background into a normal layer, "Layer 0", as +//! Photoshop does: it keeps the pixels past the canvas, and an extension is transparent on every +//! layer. Delete on keeps the Background, and Image › Reveal All is unchanged (#10867). + +use photocraft_engine::actions_cmds::Action; +use photocraft_engine::{EngineError, Session, atn_cmds}; +use photocraft_geom::Rect; +use photocraft_psd::{atn, descriptor::Value as D}; +use serde_json::{Value, json}; + +const MAGENTA: [f32; 4] = [1.0, 0.0, 1.0, 1.0]; +const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// The issue's test document at a tenth of the size: 80 × 50 px, a white Background and a pixel +/// layer "Quads" covering the canvas in red, with magenta as the background colour. +fn doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 50, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Quads"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#ff00ff"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s +} + +fn d(s: &Session) -> &photocraft_doc::Document { + &s.active().unwrap().doc +} + +fn size(s: &Session) -> (u32, u32) { + (d(s).size.width, d(s).size.height) +} + +fn past(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +fn bottom(s: &Session) -> &photocraft_doc::Layer { + &d(s).layers[0] +} + +fn content(s: &Session, i: usize) -> Rect { + d(s).layers[i].surface().unwrap().content_bounds() +} + +/// The bottom layer is no longer a Background: a normal, unlocked layer named "Layer 0". +fn assert_layer_0(s: &Session, what: &str) { + assert!(d(s).background().is_none(), "{what}: no Background left"); + let l = bottom(s); + assert_eq!(l.name, "Layer 0", "{what}"); + assert!(!l.locks.transparency && !l.locks.position, "{what}: unlocked"); +} + +fn crop(s: &mut Session, mut p: Value, delete: bool) { + p["deleteCroppedPixels"] = json!(delete); + let before = past(s); + s.execute("image.crop", p).unwrap(); + assert_eq!(past(s), before + 1, "one undo step"); +} + +#[test] +fn shrinking_turns_the_background_into_layer_0_keeping_the_hidden_pixels() { + let mut s = doc(); + crop(&mut s, json!({"x": 0, "y": 0, "width": 70, "height": 50}), false); + assert_eq!(size(&s), (70, 50)); + assert_layer_0(&s, "shrink"); + // Both layers keep their 80 px, hidden past the canvas. + assert_eq!(content(&s, 0), Rect::new(0, 0, 80, 50), "Layer 0"); + assert_eq!(content(&s, 1), Rect::new(0, 0, 80, 50), "Quads"); + assert_eq!(bottom(&s).surface().unwrap().rgba(75, 25), WHITE); +} + +#[test] +fn extending_leaves_the_new_area_transparent_on_every_layer() { + let mut s = doc(); + crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), false); + assert_eq!(size(&s), (90, 50)); + assert_layer_0(&s, "extend"); + for i in 0..2 { + let surf = d(&s).layers[i].surface().unwrap(); + assert_eq!(surf.rgba(85, 25)[3], 0.0, "layer {i}: the added strip is transparent"); + } + assert_eq!(bottom(&s).surface().unwrap().rgba(40, 25), WHITE); + assert_eq!(d(&s).layers[1].surface().unwrap().rgba(40, 25), RED); +} + +#[test] +fn a_turned_crop_turns_layer_0_with_the_others() { + // The issue's 6.3° frame (687 × 429 of 800 × 500), centred. + let mut s = doc(); + crop(&mut s, json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3}), false); + assert_eq!(size(&s), (68, 42)); + assert_layer_0(&s, "turned"); + // Every layer turned, keeping its overhang past the canvas. + let canvas = Rect::new(0, 0, 68, 42); + for i in 0..2 { + let c = content(&s, i); + assert!(c.x0 < canvas.x0 && c.y0 < canvas.y0 && c.x1 > canvas.x1 && c.y1 > canvas.y1, "layer {i} keeps its overhang: {c:?}"); + } + // The corners the turn uncovered are transparent, not the background colour. + let surf = bottom(&s).surface().unwrap(); + let c = content(&s, 0); + assert_eq!(surf.rgba(c.x0, c.y0)[3], 0.0, "an uncovered corner of Layer 0"); + assert_eq!(surf.rgba(34, 21), WHITE); +} + +#[test] +fn the_action_steps_turned_crop_unlocks_the_background() { + // The action step `Crop` with `Dlt` false, rect (100,100)–(700,400) and angle 10°, scaled down, + // through a binary .atn: its descriptor carries `Dlt` false, the importer decodes it and + // `actions.play` runs the step. + let params = json!({"x": 10, "y": 10, "width": 60, "height": 30, "angle": 10, "deleteCroppedPixels": false}); + let action = Action::new("Crop", vec![("image.crop".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Crop", &[&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("Dlt "), Some(&D::Boolean(false))); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + let step = &imported.actions[0].steps[0]; + assert_eq!((step.0.as_str(), &step.1["deleteCroppedPixels"]), ("image.crop", &json!(false))); + + let mut s = doc(); + s.actions.list = imported.actions; + let before = past(&s); + s.execute("actions.play", json!({"action": "Crop"})).unwrap(); + assert_eq!(past(&s), before + 1, "one undo step"); + assert_eq!(size(&s), (60, 30)); + assert_layer_0(&s, "action step"); +} + +#[test] +fn content_aware_with_delete_off_keeps_the_background() { + // Not measured in the reference: the Background stays, and the fill goes on it, as before. + let mut s = doc(); + let mut p = json!({"x": 0, "y": 0, "width": 90, "height": 50, "contentAware": true}); + p["deleteCroppedPixels"] = json!(false); + s.execute("image.crop", p).unwrap(); + assert_eq!(size(&s), (90, 50)); + assert_eq!(d(&s).background().map(|l| l.name.as_str()), Some("Background")); +} + +#[test] +fn a_target_size_crop_unlocks_the_background() { + let mut s = doc(); + crop(&mut s, json!({"x": 0, "y": 0, "width": 40, "height": 50, "targetWidth": 20, "targetHeight": 25, "resolution": 150}), false); + assert_eq!(size(&s), (20, 25)); + assert_layer_0(&s, "W x H x Resolution"); +} + +#[test] +fn delete_on_keeps_the_background_and_fills_an_extension() { + for (frame, want) in [ + (json!({"x": 0, "y": 0, "width": 70, "height": 50}), (70, 50)), + (json!({"x": 0, "y": 0, "width": 90, "height": 50}), (90, 50)), + (json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3}), (68, 42)), + ] { + let mut s = doc(); + crop(&mut s, frame.clone(), true); + assert_eq!(size(&s), want, "{frame}"); + let bg = d(&s).background().unwrap_or_else(|| panic!("{frame}: the Background stays")); + assert_eq!(bg.name, "Background", "{frame}"); + } + let mut s = doc(); + crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), true); + assert_eq!(bottom(&s).surface().unwrap().rgba(85, 25), MAGENTA, "the extension takes the background colour"); +} + +#[test] +fn undo_restores_the_background() { + for frame in [json!({"x": 0, "y": 0, "width": 90, "height": 50}), json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3})] { + let mut s = doc(); + let before = bottom(&s).clone(); + crop(&mut s, frame.clone(), false); + assert_layer_0(&s, "cropped"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(size(&s), (80, 50), "{frame}"); + let bg = d(&s).background().unwrap_or_else(|| panic!("{frame}: undo restores the Background")); + assert_eq!(bg.id, before.id, "{frame}"); + assert_eq!(bg.name, "Background", "{frame}"); + assert!(bg.locks.transparency && bg.locks.position, "{frame}"); + let (a, b) = (bg.surface().unwrap(), before.surface().unwrap()); + let r = Rect::new(0, 0, 80, 50); + assert_eq!(a.read_region(r), b.read_region(r), "{frame}: the pixels"); + s.execute("edit.redo", json!({})).unwrap(); + assert_layer_0(&s, "redo"); + } +} + +#[test] +fn reveal_all_keeps_the_background() { + let mut s = revealable(); + let before = past(&s); + s.execute("image.revealAll", json!({})).unwrap(); + assert_eq!(past(&s), before + 1); + assert_eq!(size(&s), (90, 50)); + let bg = d(&s).background().expect("Reveal All keeps the Background"); + assert_eq!(bg.name, "Background"); + assert_eq!(bg.surface().unwrap().rgba(85, 25), MAGENTA, "the extension takes the background colour, as before"); +} + +/// `doc()` with Quads reaching 10 px past the right edge, so Reveal All has something to reveal. +fn revealable() -> Session { + let mut s = doc(); + s.edit("paint past the canvas", |doc, _| { + let quads = doc.layers.last_mut().unwrap(); + quads.surface_mut().unwrap().fill_rect(Rect::new(80, 0, 90, 50), &RED); + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn reveal_alls_crop_passes_the_session_gate() { + // A gate that refuses `image.crop` refuses Reveal All's crop too, before anything changes. + let mut s = revealable(); + s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("image.crop is not allowed".into())) } else { Ok(()) }); + let before = past(&s); + let err = s.execute("image.revealAll", json!({})).unwrap_err(); + assert!(err.to_string().contains("image.crop is not allowed"), "{err}"); + assert_eq!(size(&s), (80, 50)); + assert_eq!(past(&s), before); + assert!(d(&s).background().is_some()); + + // A gate that allows it sees the crop Reveal All runs. + let mut s = revealable(); + // (An `Err`, not an assert: the gate runs inside the command's panic guard.) + s.authorize = Some(|id, p| { + let expected = json!({"x": 0, "y": 0, "width": 90, "height": 50, "deleteCroppedPixels": false}); + if id == "image.crop" && *p != expected { Err(EngineError::Other(format!("unexpected crop {p}"))) } else { Ok(()) } + }); + s.execute("image.revealAll", json!({})).unwrap(); + assert_eq!(size(&s), (90, 50)); +} + +#[test] +fn a_document_without_a_background_has_nothing_to_convert() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 50, "background": "transparent"})).unwrap(); + let names: Vec = d(&s).layers.iter().map(|l| l.name.clone()).collect(); + crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), false); + assert_eq!(size(&s), (90, 50)); + assert!(d(&s).background().is_none()); + assert_eq!(d(&s).layers.iter().map(|l| l.name.clone()).collect::>(), names, "no layer renamed"); +} diff --git a/crates/engine/tests/it/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs index 867acaaf21..12731d7088 100644 --- a/crates/engine/tests/it/empty_selection_combine.rs +++ b/crates/engine/tests/it/empty_selection_combine.rs @@ -91,3 +91,42 @@ fn subtracting_from_an_existing_selection_preserves_other_pixels_and_history() { assert_eq!(coverage(&session, 12, 12), removed); } } + +#[test] +fn marquee_add_subtract_match_dense_combination_across_tiles() { + use photocraft_algo::selection as sel; + use photocraft_color::PixelFormat; + use photocraft_geom::Rect; + use photocraft_raster::Surface; + + // A partial edge tile, a shape crossing a tile boundary, and an untouched distant tile. + let area = Rect::from_xywh(0, 0, 777, 263); + let r = Rect::from_xywh(247, -5, 24, 32); + for depth in [8, 16, 32] { + for ellipse in [false, true] { + for mode in ["add", "subtract"] { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 777, "height": 263, "depth": depth})).unwrap(); + let mut old = Surface::new(PixelFormat::GRAY8); + old.fill_rect(Rect::from_xywh(240, 0, 40, 40), &[0.6]); + old.fill_rect(Rect::from_xywh(768, 256, 9, 7), &[0.4]); + std::sync::Arc::make_mut(&mut session.active_mut().unwrap().doc).selection = Some(old.clone()); + let shape = if ellipse { + sel::ellipse_surface(r, area, true) + } else { + let mut shape = Surface::new(PixelFormat::GRAY8); + shape.fill_rect(r.intersect(&area), &[1.0]); + shape + }; + let expected = sel::combine_surfaces(&old, &shape, area, |a, b| if mode == "add" { a.max(b) } else { a * (1.0 - b) }); + session.execute("select.rect", json!({"x": 247, "y": -5, "width": 24, "height": 32, "ellipse": ellipse, "mode": mode})).unwrap(); + let pixels = |session: &Session| session.active().unwrap().doc.selection.as_ref().unwrap().read_region(area); + assert_eq!(pixels(&session), expected.read_region(area), "depth={depth}, ellipse={ellipse}, mode={mode}"); + assert!(session.undo()); + assert_eq!(pixels(&session), old.read_region(area)); + assert!(session.redo()); + assert_eq!(pixels(&session), expected.read_region(area)); + } + } + } +} diff --git a/crates/engine/tests/it/integration_batch.rs b/crates/engine/tests/it/integration_batch.rs new file mode 100644 index 0000000000..14977beece --- /dev/null +++ b/crates/engine/tests/it/integration_batch.rs @@ -0,0 +1,34 @@ +//! Cross-PR acceptance checks for the merged parity batch: each test pins one fix through the +//! public command surface, so a bad merge between the PRs shows up here. +use photocraft_engine::Session; +use serde_json::json; + +fn session(width: u32, height: u32) -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": width, "height": height})).unwrap(); + session +} + +#[test] +fn a_new_fill_layer_consumes_the_selection_it_took_as_mask() { + let mut s = session(32, 24); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 8})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); +} + +#[test] +fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() { + let mut s = session(4500, 8); + s.execute("edit.definePattern", json!({"name": "Wide"})).unwrap(); + let mut s = session(8001, 8); + assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err()); +} + +#[test] +fn gaussian_blur_accepts_radii_below_one_without_failing() { + for radius in [0.1, 0.3, 0.7, 1.0] { + let mut s = session(24, 16); + s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap(); + } +} diff --git a/crates/engine/tests/it/layer_mask_depth.rs b/crates/engine/tests/it/layer_mask_depth.rs index 7093164a5f..cbe4138532 100644 --- a/crates/engine/tests/it/layer_mask_depth.rs +++ b/crates/engine/tests/it/layer_mask_depth.rs @@ -45,12 +45,10 @@ fn newly_created_layer_masks_follow_document_depth() { assert_eq!(m.value(20, 20), white); if selected { assert_eq!(m.value(0, 0), 0.0); - if command == "layer.layerMask.revealSelection" { - // The selection became the mask: Photoshop drops it, as Hide Selection does. - assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); - } else { - assert_eq!(d.doc.selection, before.selection, "the selection is unchanged"); - } + // The selection became the mask: Photoshop drops it, as Hide Selection does + // (#5251; new fill and adjustment layers too, #8801). + assert!(d.doc.selection.is_none(), "{command}: the selection is dropped"); + assert!(before.selection.is_some()); } else { assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse"); assert_eq!(m.value(-300_000, 300_000), white); @@ -72,7 +70,6 @@ fn soft_mask_edits_match_after_native_and_psd_reopen() { let mut s = session(depth, "rgb"); s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap(); s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); let before = s.active().unwrap().doc.clone(); let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap(); let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap(); @@ -138,7 +135,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() { before.selection.as_ref().unwrap().read_region(before.bounds()), "{depth}-bit coverage is unchanged" ); - let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() }; + // The selection went into the mask, except for the Gradient tool's fill layer. + let kept = if command == "gradient.fill.create" { before.selection.clone() } else { None }; assert_eq!(after.selection, kept, "{command}"); assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask"); } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 25455e539c..4e4a83b874 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,11 +1,21 @@ //! The crate's integration tests, linked into one test binary. +mod actions_single_history; mod affinity; mod artboard_export_names; +mod atn_classic_gradient; +mod atn_content_aware_fill; mod atn_crystallize_cell_size; +mod atn_direct_adjustments; +mod atn_layer_style; +mod atn_newer_events; +mod atn_reference_forms; mod atn_ripple_amount; mod atn_selective_color; +mod atn_shear; mod atn_stroke_width; +mod atn_type_path; +mod atn_v12; mod background_and_mask_depth; mod background_layer_consistency; mod brush_smoothing; @@ -18,6 +28,7 @@ mod convert_keeps_references; mod copy_shape_placement; mod corpus_atn; mod crop_content_aware; +mod crop_delete_off_background; mod current_affinity_artboards; mod duplicate_layer_comps; mod duplicate_slices; @@ -37,6 +48,7 @@ mod guide_layout_limit; mod guide_layout_preview; mod hdr_gamma_direction; mod huge_doc_flatten; +mod integration_batch; mod jpeg_exif_resolution; mod keyboard_shortcut_bulk; mod kra; diff --git a/crates/engine/tests/it/merge_transparent_bounds.rs b/crates/engine/tests/it/merge_transparent_bounds.rs index c796007233..03e9473fb2 100644 --- a/crates/engine/tests/it/merge_transparent_bounds.rs +++ b/crates/engine/tests/it/merge_transparent_bounds.rs @@ -101,7 +101,6 @@ fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() { let (mut s, a, _) = scene(mode, depth); s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap(); let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap()); - s.execute("select.deselect", json!({})).unwrap(); (s, a, fill) }; for &(mode, depth) in CASES { diff --git a/crates/engine/tests/it/photoshop_oracles.rs b/crates/engine/tests/it/photoshop_oracles.rs index 88e63b67fc..435bab8cc1 100644 --- a/crates/engine/tests/it/photoshop_oracles.rs +++ b/crates/engine/tests/it/photoshop_oracles.rs @@ -124,7 +124,7 @@ fn photoshop_oracles_rerendered() { } }; let Ok(merged) = photocraft_io::merged_composite(&file) else { continue }; - let ours = photocraft_compose::flatten(&doc).px; + let ours = photocraft_compose::flatten(&doc).into_px(); let (m, bad) = max_diff(&ours, &merged); let group = name.split(['/', '\\']).next().unwrap_or(".").to_string(); let g = groups.entry(group).or_default(); diff --git a/crates/engine/tests/it/prefs_usage.rs b/crates/engine/tests/it/prefs_usage.rs index eabad21ed8..e216fde40a 100644 --- a/crates/engine/tests/it/prefs_usage.rs +++ b/crates/engine/tests/it/prefs_usage.rs @@ -13,6 +13,9 @@ //! preferences by path; //! - or `self.` in an inherent `impl` of the section's struct in `prefs.rs` (helpers //! such as `TransparencyAndGamut::colors`). +//! +//! The `impl Session` blocks of `prefs.rs` (e.g. `apply_prefs`) are engine code, not the model, +//! so they are scanned like any other source, as the scorecard's prefs audit does. use photocraft_engine::prefs::{HIDDEN_UNTIL_IMPLEMENTED, Preferences}; use std::path::{Path, PathBuf}; @@ -124,6 +127,7 @@ fn every_preference_is_read_or_hidden() { .filter_map(|p| std::fs::read_to_string(&p).ok().map(|t| (p, production(&t)))) .collect(); let mut sources: Vec = files.iter().map(|(_, t)| t.clone()).collect(); + sources.push(inherent_impls(&production(&prefs_rs), "Session")); // A module split into child files (`foo.rs` + `foo/*.rs`) counts as one file too, so a section // named in the parent and a field read in a child still count as a read. for (path, text) in &files { @@ -201,6 +205,10 @@ fn the_scanner_finds_reads() { assert!(has_ident("let fh = &p.file_handling;", "", "file_handling")); assert!(!has_ident("my_file_handling", "", "file_handling")); assert_eq!(snake("recentFileCount"), "recent_file_count"); + let prefs = "pub struct Performance {\n}\nimpl Session {\n fn apply(&self) { self.prefs().performance.effect_cache_mb; }\n}\nimpl Performance {\n fn x(&self) { self.other; }\n}\n"; + let session = inherent_impls(prefs, "Session"); + assert!(has_ident(&session, ".", "effect_cache_mb") && has_ident(&session, "", "performance")); + assert!(!has_ident(&session, ".", "other")); assert_eq!(production("fn a() {}\n#[cfg(test)]\nmod tests {\n p.x.y\n}"), "fn a() {}\n"); } diff --git a/crates/engine/tests/it/smart_psd.rs b/crates/engine/tests/it/smart_psd.rs index 4aeda1519b..542220f34b 100644 --- a/crates/engine/tests/it/smart_psd.rs +++ b/crates/engine/tests/it/smart_psd.rs @@ -469,6 +469,74 @@ fn displace_psd_filter_renders_with_its_embedded_map() { } } +/// #9162: a Photoshop Displace smart filter that names its map file (`DspF`, a `Pth `) without +/// embedding it was skipped as "not supported" on Replace Contents. Opened from PSD, it now reads +/// that file and the replaced contents come out displaced. +#[test] +fn displace_psd_filter_with_only_a_map_path_renders_after_replace_contents() { + use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value}; + let dir = std::env::temp_dir().join(format!("pcraft-9162-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = |name: &str, size: (u32, u32), c: [f32; 4]| { + let d = Document::with_background( + "m", + photocraft_geom::Size::new(size.0, size.1), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(c[0], c[1], c[2], c[3]), + ); + let path = dir.join(name); + std::fs::write(&path, photocraft_io::export(&d, "png", &Default::default()).unwrap().bytes).unwrap(); + path.to_str().unwrap().to_string() + }; + // Red is the horizontal map (full right), green the vertical one (about neutral), as in the + // issue's map. + let map = png("map.png", (12, 8), [1.0, 0.5, 0.5, 1.0]); + let design = png("design.png", (72, 52), [1.0, 0.0, 0.0, 1.0]); + // `DspF` as Photoshop writes a path: `txtu`, sizes (LE), UTF-16LE with a trailing NUL. + let units: Vec = map.encode_utf16().chain([0]).collect(); + let mut pth = b"txtu".to_vec(); + pth.extend((12 + 2 * units.len() as u32).to_le_bytes()); + pth.extend((units.len() as u32).to_le_bytes()); + pth.extend(units.iter().flat_map(|u| u.to_le_bytes())); + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let fltr = Descriptor::new("Dspl") + .with("HrzS", Value::Integer(10)) + .with("VrtS", Value::Integer(10)) + .with("DspM", e("DspM", "StrF")) + .with("UndA", e("UndA", "RptE")) + .with("DspF", Value::Path(pth)); + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Displace..."))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(1148416108)); + 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); + s.edit("displace", |doc, _| { + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") }; + sm.smart_filters.push(photocraft_io::smart_map::filter_from_item(&item)); + Ok(()) + }) + .unwrap(); + let psd = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes; + // The issue's flow: open the PSD, Replace Contents of the smart object. + let mut s2 = Session::new(); + s2.add_document(photocraft_io::import("pc-displace-repro.psd", &psd).unwrap().document, None); + let (sid, _) = smart(&s2.active().unwrap().doc); + let out = s2.execute("layer.smartObjects.replaceContents", json!({"layer": sid, "path": design})).unwrap(); + assert_eq!(out["warnings"], json!([]), "depth {depth}"); + let cache = smart(&s2.active().unwrap().doc).1.cache.unwrap(); + // 10 % of 128 px at full map contrast: the design (x 8..80) moves 12.8 px left. + let alpha = |x: i32| cache.pixel(x, 30)[3]; + assert!(alpha(2) > 0.99 && alpha(75) < 0.01, "depth {depth}: {} {}", alpha(2), alpha(75)); + } + std::fs::remove_dir_all(&dir).ok(); +} + /// 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 diff --git a/crates/engine/tests/it/type_format_targets.rs b/crates/engine/tests/it/type_format_targets.rs index 54e7d3f8af..e5f13ffd48 100644 --- a/crates/engine/tests/it/type_format_targets.rs +++ b/crates/engine/tests/it/type_format_targets.rs @@ -87,8 +87,10 @@ fn selected_type_properties_preserve_each_layers_other_attributes_and_one_histor if indices.contains(&i) { assert_ne!(actual.psd_raw, before[i].psd_raw, "editable PSD data refreshed"); let damage = s.active().unwrap().last_damage.unwrap(); + // The pixels the layer draws, before and after (the cache's tiles, narrowed to the + // layer's extents by the edit's own damage, #9329). for t in [&before[i], &actual] { - let bounds = t.cache.as_ref().unwrap().tile_bounds(); + let bounds = t.cache.as_ref().unwrap().content_bounds(); assert_eq!(damage.intersect(&bounds), bounds); } } diff --git a/crates/engine/tests/memory_prefs.rs b/crates/engine/tests/memory_prefs.rs new file mode 100644 index 0000000000..a21affa91d --- /dev/null +++ b/crates/engine/tests/memory_prefs.rs @@ -0,0 +1,27 @@ +use photocraft_engine::prefs::{Performance, Preferences}; +use serde_json::json; + +#[test] +fn old_preferences_keep_their_absolute_limit() { + let p: Performance = serde_json::from_value(json!({"memoryUsageMb": 2048})).unwrap(); + assert_eq!(p.memory_usage_percent, None); + assert_eq!(p.memory_usage_mb, 2048); + assert_eq!(Performance::default().memory_usage_percent, Some(70)); +} + +#[test] +fn memory_preferences_validate_and_can_restore_absolute_mode() { + let mut p = Preferences::default(); + assert!(p.set("performance.memoryUsagePercent", json!(96)).is_err()); + assert!(p.set("performance.memoryUsagePercent", json!(0)).is_err()); + assert!(p.set("performance.gpuMemoryMb", json!(-1)).is_err()); + p.set("performance.memoryUsagePercent", json!(75)).unwrap(); + assert_eq!(p.performance.memory_usage_percent, Some(75)); + p.set("performance.memoryUsagePercent", json!(null)).unwrap(); + assert_eq!(p.performance.memory_usage_percent, None); + p.set("performance.gpuMemoryMb", json!(512)).unwrap(); + assert_eq!(p.performance.gpu_memory_mb, 512); + // Main's GPU budget: 0 selects the automatic limit. + p.set("performance.gpuMemoryMb", json!(0)).unwrap(); + assert_eq!(p.performance.gpu_memory_mb, 0); +} diff --git a/crates/engine/tests/multi_channel_editing.rs b/crates/engine/tests/multi_channel_editing.rs new file mode 100644 index 0000000000..5780c1318e --- /dev/null +++ b/crates/engine/tests/multi_channel_editing.rs @@ -0,0 +1,1550 @@ +use photocraft_color::SampleType; +use photocraft_engine::{Session, brush_cmds::LiveStroke, channel_cmds::ChannelTarget, color_channels}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::json; + +fn setup(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"mode":mode,"depth":depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents":"white"})).unwrap(); + s.edit("fixture", |doc, active| { + let r = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let n = surf.channels(); + let mut data = Vec::new(); + for y in 0..24 { + for x in 0..32 { + for k in 0..n - 1 { + data.push(((x * 7 + y * 11 + k * 37) % 221 + 15) as f32 / 255.0); + } + data.push(0.65); + } + } + surf.write_region(r, &data); + Ok(()) + }) + .unwrap(); + s.execute("history.setSource", json!({"state":2})).unwrap(); + s +} +fn surface(s: &Session) -> Surface { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone() +} +fn subset(s: &mut Session) { + s.execute("channel.target", json!({"colors":[0,2]})).unwrap(); +} +fn unchanged(before: &Surface, after: &Surface) { + let fmt = before.format(); + let bytes = fmt.sample.bytes(); + let keep: Vec<_> = (0..fmt.channels()).filter(|k| ![0, 2].contains(k)).collect(); + for (c, t) in before.tiles() { + let new = after.tile(*c).unwrap(); + for (a, b) in t.bytes().chunks_exact(fmt.bytes_per_pixel()).zip(new.bytes().chunks_exact(fmt.bytes_per_pixel())) { + for k in &keep { + assert_eq!(&a[k * bytes..(k + 1) * bytes], &b[k * bytes..(k + 1) * bytes], "component {k}"); + } + } + } +} + +#[test] +fn shift_selection_is_color_only_and_all_colors_become_composite() { + let mut s = setup("rgb", 8); + s.execute("channel.target", json!({"channel":"red"})).unwrap(); + s.execute("channel.target", json!({"channel":"blue","extend":true})).unwrap(); + assert_eq!(color_channels::selected(&s).unwrap().indices().collect::>(), vec![0, 2]); + let view = &s.active().unwrap().channel_view; + assert!(view.color_visible(0) && view.color_visible(2) && !view.color_visible(1)); + let revision = s.active().unwrap().revision; + assert!(s.execute("channel.target", json!({"channel":"mask","extend":true})).is_err()); + assert_eq!(s.active().unwrap().revision, revision); + s.execute("channel.target", json!({"channel":"green","extend":true})).unwrap(); + assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite); + s.execute("channel.new", json!({"name":"saved"})).unwrap(); + assert!(s.execute("channel.target", json!({"channel":"saved","extend":true})).is_err()); +} + +#[test] +fn adjustments_filters_and_drawing_preserve_other_encoded_components() { + let commands = [ + ("image.adjustments.invert", json!({})), + ("image.adjustments.threshold", json!({"level":128})), + ("image.adjustments.levels", json!({"gamma":1.7})), + ("image.adjustments.curves", json!({"points":[[0,255],[255,0]]})), + ("filter.blur.gaussianBlur", json!({"radius":2})), + ("paint.stroke", json!({"points":[[8,8],[22,12]],"size":9,"color":"#ffffff"})), + ("paint.pencil", json!({"points":[[8,8],[22,12]],"size":9,"color":"#000000"})), + ("paint.stroke", json!({"points":[[8,8],[22,12]],"size":9,"erase":true})), + ("paint.bucket", json!({"x":10,"y":10,"tolerance":255,"color":"#ffffff"})), + ("paint.gradient", json!({"from":[0,0],"to":[31,0],"colors":["#000000","#ffffff"]})), + ("paint.dodge", json!({"points":[[8,8],[22,12]],"size":9})), + ("paint.burn", json!({"points":[[8,8],[22,12]],"size":9})), + ("paint.blur", json!({"points":[[8,8],[22,12]],"size":9})), + ("paint.sharpen", json!({"points":[[8,8],[22,12]],"size":9})), + ("paint.smudge", json!({"points":[[8,8],[22,12]],"size":9})), + ("paint.magicEraser", json!({"x":10,"y":10,"tolerance":255})), + ("paint.backgroundEraser", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100})), + ("paint.colorReplacement", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff"})), + ("paint.cloneStamp", json!({"points":[[8,8],[22,12]],"size":9,"source":[2,2]})), + ("paint.historyBrush", json!({"points":[[8,8],[22,12]],"size":9})), + ("paint.artHistoryBrush", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":0})), + ("paint.mixerBrush", json!({"points":[[8,8],[22,12]],"size":9,"color":"#ffffff"})), + ]; + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for (cmd, p) in &commands { + let mut s = setup(mode, depth); + subset(&mut s); + let before = surface(&s); + s.execute(cmd, p.clone()).unwrap_or_else(|e| panic!("{mode} {depth} {cmd}: {e}")); + let after = surface(&s); + unchanged(&before, &after); + assert_ne!(before, after, "{mode} {depth} {cmd} must change selected samples"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(surface(&s), before, "undo {cmd}"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(surface(&s), after, "redo {cmd}"); + } + } + } +} + +#[test] +fn threshold_uses_each_selected_native_plane() { + let mut s = setup("rgb", 16); + s.edit("pixel", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.1, 0.95, 0.8, 0.7]); + Ok(()) + }) + .unwrap(); + subset(&mut s); + s.execute("image.adjustments.threshold", json!({"level":128})).unwrap(); + let px = surface(&s).pixel(4, 4); + assert_eq!((px[0], px[2]), (0.0, 1.0)); + assert!((px[1] - 0.95).abs() < 0.0001 && (px[3] - 0.7).abs() < 0.0001); +} + +#[test] +fn explicit_target_wins_and_invalid_sets_are_transactional() { + let mut s = setup("rgb", 32); + s.execute("channel.target", json!({"channel":"green"})).unwrap(); + let before = surface(&s); + s.execute("image.adjustments.invert", json!({"target":{"colors":[0,2]}})).unwrap(); + unchanged(&before, &surface(&s)); + for colors in [json!([]), json!([-1, 2]), json!([0, 3]), json!([0, "red"]), json!(null), json!([u64::MAX]), json!(true)] { + let prior = surface(&s); + assert!(s.execute("image.adjustments.invert", json!({"target":{"colors":colors}})).is_err()); + assert_eq!(surface(&s), prior); + } +} + +#[test] +fn live_brush_and_eraser_match_commit_at_all_native_depths() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for erase in [false, true] { + let mut s = setup(mode, depth); + subset(&mut s); + let before = surface(&s); + let mut p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","erase":erase,"seed":42}); + let live = LiveStroke::begin(&s, &p).unwrap(); + p["seed"] = json!(live.seed); + let st = s.active().unwrap(); + let shown = live.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone(); + unchanged(&before, &shown); + s.offer_live_stroke(live).unwrap(); + let result = s.execute("paint.stroke", p).unwrap(); + assert_eq!(result["fromLive"], true, "reuse {mode} {depth} {erase}"); + assert!( + surface(&s) == shown, + "live pixels differ: {mode} {depth} {erase}; shown tiles {}, committed {}", + shown.tile_count(), + surface(&s).tile_count() + ); + } + } + } +} + +#[test] +fn live_mixer_captures_selected_components_and_matches_commit_and_replay() { + use photocraft_engine::brush_cmds::mixer_live::LiveMixer; + use photocraft_engine::paint::StrokePoint; + + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = setup(mode, depth); + subset(&mut s); + let before = surface(&s); + let params = json!({"points":[[6,7,1]],"size":9,"color":"#ffffff","seed":42,"sampleAllLayers":true}); + let mut live = LiveMixer::begin(&s, ¶ms).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let first = live.doc.layer(id).unwrap().surface().unwrap(); + unchanged(&before, first); + assert_ne!(&before, first, "the initial dab paints {mode} {depth}"); + live.push(&[StrokePoint::new(23.0, 15.0, 0.7)]).unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().clone(); + unchanged(&before, &shown); + // The in-flight stroke keeps its original target even if the panel changes. + s.execute("channel.target", json!({"channel":"composite"})).unwrap(); + let params = live.prepare_commit(&mut s).unwrap(); + assert_eq!(params["target"], json!({"colors":[0,2]})); + assert_eq!(s.execute("paint.mixerBrush", params.clone()).unwrap()["fromLive"], true); + assert_eq!(surface(&s), shown, "displayed pixels are committed {mode} {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(surface(&s), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(surface(&s), shown); + let mut replay = setup(mode, depth); + replay.execute("paint.mixerBrush", params).unwrap(); + assert_eq!(surface(&replay), shown, "replay matches {mode} {depth}"); + } + } +} + +#[test] +fn unsupported_geometric_edits_and_camera_raw_fail_before_modification() { + let mut s = setup("rgb", 8); + subset(&mut s); + for cmd in [ + "layer.translate", + "select.float", + "edit.transform", + "edit.transform.rotate90Cw", + "edit.contentAwareScale", + "edit.puppetWarp", + "edit.perspectiveWarp", + "filter.cameraRaw", + ] { + let before = surface(&s); + assert!(!s.is_enabled(cmd), "{cmd}"); + assert!(s.execute(cmd, json!({"dx":2,"dy":3})).is_err(), "{cmd}"); + assert_eq!(surface(&s), before); + } +} + +#[test] +fn native_float_payloads_survive_unselected_component_restoration() { + // Exercise the low-level helper directly with raw HDR, signed zero and NaN payloads. + let fmt = photocraft_color::PixelFormat::RGBA32F; + let mut before = Surface::new(fmt); + before.fill_rect(Rect::new(0, 0, 1, 1), &[2.0, 0.1, -1.0, 0.5]); + let coord = photocraft_geom::TileCoord { tx: 0, ty: 0 }; + before.tile_mut(coord).bytes_mut()[4..8].copy_from_slice(&0x7fc0_1234u32.to_ne_bytes()); + let mut after = Surface::new(fmt); + after.fill_rect(Rect::new(0, 0, 1, 1), &[0.3, 0.5, 0.7, 0.9]); + let colors = color_channels::ColorChannels::from_indices([0, 2], 3).unwrap(); + color_channels::restrict_surface(&before, &mut after, colors).unwrap(); + assert_eq!(&before.tile(coord).unwrap().bytes()[4..8], &after.tile(coord).unwrap().bytes()[4..8]); + assert_eq!(after.format().sample, SampleType::F32); +} + +#[test] +fn live_retouching_matches_direct_replay_and_preserves_unselected_bytes() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for cmd in ["paint.dodge", "paint.burn", "paint.blur", "paint.sharpen", "paint.smudge", "paint.cloneStamp", "paint.historyBrush"] { + let mut s = setup(mode, depth); + subset(&mut s); + let original = surface(&s); + let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"source":[2,2]}); + let shown = if matches!(cmd, "paint.cloneStamp" | "paint.historyBrush") { + let live = photocraft_engine::retouch_cmds::LiveRetouch::begin(&s, cmd, &p).unwrap(); + let st = s.active().unwrap(); + let shown = live.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone(); + s.offer_live_retouch(live).unwrap(); + shown + } else { + let live = photocraft_engine::retouch_cmds::LiveDab::begin(&s, cmd, &p).unwrap(); + let st = s.active().unwrap(); + let shown = live.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone(); + s.offer_live_dab(live).unwrap(); + shown + }; + unchanged(&original, &shown); + s.execute(cmd, p.clone()).unwrap(); + let committed = surface(&s); + unchanged(&original, &committed); + let mut replay = setup(mode, depth); + subset(&mut replay); + replay.execute(cmd, p).unwrap(); + assert!(committed == surface(&replay), "{mode} {depth} {cmd}: live reuse differs from replay"); + } + } + } +} + +#[test] +fn copy_cut_paste_operate_on_the_whole_selected_set_in_one_history_step() { + let mut s = setup("rgb", 16); + subset(&mut s); + let original = surface(&s); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let inverted = surface(&s); + s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap(); + assert!(surface(&s) == original); + s.execute("edit.undo", json!({})).unwrap(); + assert!(surface(&s) == inverted); + s.execute("edit.cut", json!({})).unwrap(); + unchanged(&inverted, &surface(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(surface(&s) == inverted); +} + +#[test] +fn explicit_layer_restriction_protects_the_layer_actually_edited() { + let mut s = setup("rgb", 32); + let first = s.active().unwrap().active_layer.unwrap(); + let original = surface(&s); + s.execute("layer.new.layer", json!({})).unwrap(); + subset(&mut s); + s.execute("paint.stroke", json!({"layer":first.0,"target":{"colors":[0,2]},"points":[[8,8],[23,12]],"size":9,"color":"#ffffff"})).unwrap(); + let after = s.active().unwrap().doc.layer(first).unwrap().surface().unwrap(); + unchanged(&original, after); + assert!(original != *after); +} + +#[test] +fn background_eraser_on_an_opaque_background_edits_only_selected_channels() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap(); + s.tools.background = [0.0, 0.0, 0.0, 1.0]; + subset(&mut s); + let before = surface(&s); + let id = s.active().unwrap().active_layer.unwrap(); + let p = json!({"points":[[12,12]],"size":12,"hardness":1,"tolerance":100}); + let live = photocraft_engine::eraser_cmds::live_background_eraser(&s, &p).unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().clone(); + s.execute("paint.backgroundEraser", p).unwrap(); + let after = surface(&s); + unchanged(&before, &after); + assert!(before != after, "depth {depth}"); + assert!(shown == after, "preview depth {depth}"); + assert_eq!(before.format(), after.format()); + assert!(s.active().unwrap().doc.is_background(id)); + } +} + +#[test] +fn lab_combined_tone_curve_changes_both_selected_components() { + for command in ["image.adjustments.levels", "image.adjustments.curves"] { + let mut s = setup("lab", 32); + subset(&mut s); + let before = surface(&s).pixel(4, 4); + let p = if command.ends_with("levels") { json!({"gamma":1.8}) } else { json!({"points":[[0,255],[255,0]]}) }; + s.execute(command, p).unwrap(); + let after = surface(&s).pixel(4, 4); + assert_ne!(before[0], after[0]); + assert_ne!(before[2], after[2]); + assert_eq!(before[1], after[1]); + assert_eq!(before[3], after[3]); + } +} + +#[test] +fn lab16_neutral_chroma_survives_inversion_and_off_canvas_samples_are_adjusted() { + let mut s = setup("lab", 16); + s.edit("outside canvas", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(-4, -4, 4, 4), &[0.2, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + Ok(()) + }) + .unwrap(); + subset(&mut s); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let px = surface(&s).pixel(-2, -2); + assert!((px[0] - 0.8).abs() < 1e-4); + assert!((px[2] - 128.0 / 255.0).abs() < 1e-4); + assert!((px[1] - 128.0 / 255.0).abs() < 1e-4); +} + +#[test] +fn explicit_copy_and_singleton_paste_use_the_requested_components() { + let mut s = setup("rgb", 16); + let before = surface(&s); + s.execute("edit.copy", json!({"target":{"colors":[0,2]}})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().components, Some(vec![0, 2])); + s.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + s.execute("edit.copy", json!({"target":{"colors":[1]}})).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().surface.format().mode, photocraft_color::ColorMode::Grayscale); + let layers = s.active().unwrap().doc.layers.len(); + s.execute("edit.pasteSpecial.pasteInPlace", json!({"target":{"colors":[0]}})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); + let after = surface(&s).pixel(5, 5); + let old = before.pixel(5, 5); + assert_eq!(after[0], old[1]); + assert_eq!(after[1], old[1]); + assert_eq!(after[2], old[2]); + assert_eq!(after[3], old[3]); +} + +#[test] +fn paste_into_and_outside_obey_subset_and_pixel_selection() { + for outside in [false, true] { + let mut s = setup("rgb", 16); + subset(&mut s); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let before = surface(&s); + let layers = s.active().unwrap().doc.layers.len(); + s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap(); + let cmd = if outside { "edit.pasteSpecial.pasteOutside" } else { "edit.pasteSpecial.pasteInto" }; + s.execute(cmd, json!({"center":[16,12]})).unwrap(); + let after = surface(&s); + unchanged(&before, &after); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); + let (changed, kept) = if outside { (24, 8) } else { (8, 24) }; + assert_ne!(before.pixel(changed, 10)[0], after.pixel(changed, 10)[0]); + assert_eq!(before.pixel(kept, 10), after.pixel(kept, 10)); + } +} + +#[test] +fn feathered_pixel_selection_constrains_subset_adjust_filter_and_brush() { + for depth in [8, 16, 32] { + for (command, params) in [ + ("image.adjustments.invert", json!({})), + ("filter.blur.gaussianBlur", json!({"radius":2})), + ("paint.stroke", json!({"points":[[0,12],[31,12]],"size":32,"color":"#ffffff","seed":42})), + ] { + let mut s = setup("rgb", depth); + s.execute("select.rect", json!({"x":10,"y":7,"width":12,"height":10})).unwrap(); + s.execute("select.modify.feather", json!({"radius":2})).unwrap(); + let selection = s.active().unwrap().doc.selection.clone().unwrap(); + let before = surface(&s); + subset(&mut s); + s.execute(command, params).unwrap(); + let after = surface(&s); + unchanged(&before, &after); + assert_ne!(before, after, "{command} @{depth}"); + let mut partial = false; + for y in 0..24 { + for x in 0..32 { + let amount = selection.pixel(x, y)[0]; + if amount == 0.0 { + assert_eq!(before.pixel(x, y), after.pixel(x, y), "outside {command}"); + } + if command == "image.adjustments.invert" && amount > 0.0 && amount < 1.0 { + partial = true; + for ch in [0, 2] { + let old = before.pixel(x, y)[ch]; + let expected = old + (1.0 - 2.0 * old) * amount; + let tolerance = if depth == 8 { + 2.0 / 255.0 + } else if depth == 16 { + 2.0 / 65535.0 + } else { + 1e-6 + }; + assert!((after.pixel(x, y)[ch] - expected).abs() < tolerance, "feather @{depth}"); + } + } + } + } + assert!(command != "image.adjustments.invert" || partial); + } + } +} + +#[test] +fn supported_filter_families_and_last_filter_follow_current_set() { + for (command, params) in [ + ("filter.blur.boxBlur", json!({"radius":2})), + ("filter.other.highPass", json!({"radius":2})), + ("filter.noise.median", json!({"radius":2})), + ("filter.noise.addNoise", json!({"amount":30,"seed":42})), + ("filter.stylize.emboss", json!({"height":2})), + ("filter.distort.twirl", json!({"angle":60})), + ("filter.pixelate.mosaic", json!({"cellSize":4})), + ] { + let mut s = setup("rgb", 16); + subset(&mut s); + let before = surface(&s); + s.execute(command, params).unwrap(); + unchanged(&before, &surface(&s)); + assert_ne!(before, surface(&s), "{command}"); + } + let mut s = setup("rgb", 32); + subset(&mut s); + s.execute("filter.noise.addNoise", json!({"amount":30,"seed":42})).unwrap(); + s.execute("channel.target", json!({"channel":"green"})).unwrap(); + let before = surface(&s); + s.execute("filter.lastFilter", json!({})).unwrap(); + let after = surface(&s); + for y in 0..24 { + for x in 0..32 { + for k in [0, 2, 3] { + assert_eq!(before.pixel(x, y)[k].to_bits(), after.pixel(x, y)[k].to_bits(), "last filter component {k}"); + } + } + } + assert_ne!(before, after); +} + +#[test] +fn explicit_clear_and_singleton_cut_override_saved_channel_selection() { + for command in ["edit.clear", "edit.cut"] { + let mut s = setup("rgb", 32); + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + let before = surface(&s); + let alpha = s.active().unwrap().doc.channels[0].surface.clone(); + s.execute(command, json!({"target":{"colors":[0]}})).unwrap(); + let after = surface(&s); + assert_eq!(s.active().unwrap().doc.channels[0].surface, alpha); + assert_ne!(before, after); + for y in 0..24 { + for x in 0..32 { + for k in [1, 2, 3] { + assert_eq!(before.pixel(x, y)[k].to_bits(), after.pixel(x, y)[k].to_bits()); + } + } + } + if command == "edit.cut" { + assert_eq!(s.clipboard.as_ref().unwrap().surface.pixel(8, 8)[0], before.pixel(8, 8)[0]); + } + s.undo(); + assert_eq!(surface(&s), before); + } +} + +#[test] +fn singleton_component_clip_pastes_into_and_outside_without_luminosity_conversion() { + for command in ["edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"] { + let mut s = setup("rgb", 32); + s.edit("flat", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.8, 0.4, 0.2, 0.65]); + Ok(()) + }) + .unwrap(); + s.execute("edit.copy", json!({"target":{"colors":[0]}})).unwrap(); + s.execute("channel.target", json!({"channel":"blue"})).unwrap(); + s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap(); + let before = surface(&s); + s.execute(command, json!({"center":[16,12]})).unwrap(); + let (edited, kept) = if command.ends_with("pasteInto") { (4, 24) } else { (24, 4) }; + assert!((surface(&s).pixel(edited, 12)[2] - 0.8).abs() < 1e-6); + assert_eq!(surface(&s).pixel(kept, 12), before.pixel(kept, 12)); + s.undo(); + assert_eq!(surface(&s), before); + } +} + +#[test] +fn lab_chroma_inversion_materializes_selected_default_pixels_and_preserves_holes() { + for depth in [16, 32] { + for inverse in [false, true] { + let mut s = setup("lab", depth); + s.edit("sparse opaque", |doc, id| { + let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + *surf = Surface::with_default(surf.format(), &[0.2, 0.3, 0.4, 1.0]); + let mut mask = Surface::new(photocraft_color::PixelFormat::GRAY8); + if inverse { + mask.fill_rect(doc.bounds(), &[1.0]); + } + mask.fill_rect(Rect::new(8, 6, 20, 16), &[if inverse { 0.0 } else { 1.0 }]); + doc.selection = Some(mask); + Ok(()) + }) + .unwrap(); + let before = surface(&s); + subset(&mut s); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let after = surface(&s); + for (x, y) in [(0, 0), (12, 10), (31, 23)] { + let selected = Rect::new(8, 6, 20, 16).contains(x, y) != inverse; + let old = before.pixel(x, y); + let new = after.pixel(x, y); + for ch in [1, 3] { + assert_eq!(old[ch].to_bits(), new[ch].to_bits()); + } + if selected { + assert!((new[0] - (1.0 - old[0])).abs() < 2.0 / 65535.0, "depth={depth} inverse={inverse} xy={x},{y} old={old:?} new={new:?}"); + assert!((new[2] - (256.0 / 255.0 - old[2])).abs() < 2.0 / 65535.0); + } else { + assert_eq!(old, new); + } + } + s.undo(); + assert_eq!(surface(&s), before); + } + } +} + +#[test] +fn bucket_and_gradient_edit_the_explicit_layer_and_restrict_that_same_layer() { + for (command, mut params) in [ + ("paint.bucket", json!({"x":10,"y":10,"tolerance":255,"color":"#ffffff"})), + ("paint.gradient", json!({"from":[0,0],"to":[31,0],"colors":["#ffffff","#000000"]})), + ] { + let mut s = setup("rgb", 16); + let id = s.active().unwrap().active_layer.unwrap(); + let before = surface(&s); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color":"#336699"})).unwrap(); + let active = surface(&s); + subset(&mut s); + params["layer"] = json!(id.0); + s.execute(command, params).unwrap(); + assert_eq!(surface(&s), active); + let changed = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap(); + unchanged(&before, changed); + assert_ne!(&before, changed); + s.undo(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().surface().unwrap(), &before); + } +} + +#[test] +fn legacy_cmyk_channel_clips_keep_their_tone_when_pasted_into_a_set_or_single_channel() { + for dest in [json!({"colors":[0,2]}), json!({"channel":{"color":2}})] { + for depth in [8, 16, 32] { + let mut s = setup("cmyk", depth); + s.edit("inks", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.2, 0.4, 0.6, 0.1, 0.65]); + Ok(()) + }) + .unwrap(); + s.execute("channel.target", json!({"channel":{"color":0}})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!((s.clipboard.as_ref().unwrap().surface.pixel(4, 4)[0] - 0.8).abs() < 0.005); + s.execute("channel.target", dest.clone()).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert!((surface(&s).pixel(4, 4)[2] - 0.2).abs() < 0.005, "depth={depth} target={dest}"); + } + } +} + +#[test] +fn cmyk_grayscale_clips_roundtrip_explicit_and_limited_paste_at_every_depth() { + for depth in [8, 16, 32] { + for explicit_copy in [false, true] { + for command in ["edit.paste", "edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"] { + let mut s = setup("cmyk", depth); + s.execute("channel.target", json!({"channel":{"color":0}})).unwrap(); + let before = surface(&s); + s.execute("edit.copy", if explicit_copy { json!({"target":{"color":0}}) } else { json!({}) }).unwrap(); + s.edit("clear cyan", |doc, id| { + let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + let mut raw = surf.read_region(Rect::new(0, 0, 32, 24)); + for p in raw.as_chunks_mut::<5>().0 { + p[0] = 0.93; + } + surf.write_region(Rect::new(0, 0, 32, 24), &raw); + Ok(()) + }) + .unwrap(); + if command != "edit.paste" { + s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap(); + } + s.execute(command, json!({"target":{"color":0},"center":[16,12]})).unwrap(); + let after = surface(&s); + let x = if command.ends_with("pasteOutside") { 20 } else { 4 }; + assert!((before.pixel(x, 8)[0] - after.pixel(x, 8)[0]).abs() < 2.0 / 255.0, "{depth} {explicit_copy} {command}"); + } + } + } +} + +#[test] +fn last_filter_rejects_non_object_journal_params_without_panicking() { + for saved in [json!(17), json!(true), json!([]), json!("bad")] { + let mut s = setup("rgb", 16); + s.journal.push(("filter.blur.gaussianBlur".into(), saved)); + let before = surface(&s); + let p = json!({"target":{"colors":[0,2]},"layer":s.active().unwrap().active_layer.unwrap().0}); + assert!(s.execute("filter.lastFilter", p.clone()).is_err()); + let spec = photocraft_engine::command_specs().iter().find(|s| s.id == "filter.lastFilter").unwrap(); + assert!((spec.run)(&mut s, &p).is_err()); + assert_eq!(surface(&s), before); + } + let mut s = setup("rgb", 16); + s.journal.push(("filter.blur.gaussianBlur".into(), json!(null))); + s.execute("filter.lastFilter", json!({"target":{"color":0}})).unwrap(); +} + +#[test] +fn first_single_channel_retouch_preview_preserves_every_other_sample() { + for cmd in ["paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge", "paint.colorReplacement"] { + let mut s = setup("rgb", 32); + s.execute("channel.target", json!({"channel":{"color":0}})).unwrap(); + let before = surface(&s); + let p = json!({"points":[[6,7,1],[23,15,0.7]],"size":9,"color":"#ffffff","tolerance":100}); + let live = if cmd == "paint.colorReplacement" { + photocraft_engine::brush_cmds::live_color_replacement(&s, &p).unwrap() + } else { + photocraft_engine::retouch_cmds::LiveDab::begin(&s, cmd, &p).unwrap() + }; + let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap(); + for y in 0..24 { + for x in 0..32 { + for k in [1, 2, 3] { + assert_eq!(before.pixel(x, y)[k].to_bits(), shown.pixel(x, y)[k].to_bits(), "{cmd} {x} {y} {k}"); + } + } + } + if cmd != "paint.colorReplacement" { + s.offer_live_dab(live).unwrap(); + } + s.execute(cmd, p).unwrap(); + } +} + +#[test] +fn last_filter_forwards_explicit_target_before_implicit_panel_selection() { + let mut s = setup("rgb", 32); + subset(&mut s); + s.execute("filter.noise.addNoise", json!({"amount":20,"seed":42})).unwrap(); + let before = surface(&s); + s.execute("filter.lastFilter", json!({"target":{"colors":[1]}})).unwrap(); + let after = surface(&s); + assert_ne!(before, after); + for y in 0..24 { + for x in 0..32 { + for k in [0, 2, 3] { + assert_eq!(before.pixel(x, y)[k].to_bits(), after.pixel(x, y)[k].to_bits()); + } + } + } +} + +#[test] +fn selected_channel_paste_keeps_source_document_placement_and_centers_across_documents() { + for target in [None, Some(json!({"color":0})), Some(json!({"colors":[0,2]}))] { + let mut s = setup("rgb", 16); + let source = s.active().unwrap().doc.id; + s.execute("channel.target", json!({"channel":"red"})).unwrap(); + s.execute("select.rect", json!({"x":2,"y":3,"width":4,"height":4})).unwrap(); + let params = target.as_ref().map_or(json!({}), |target| json!({"target":target})); + s.execute("edit.copy", params.clone()).unwrap(); + assert_eq!(s.clipboard.as_ref().unwrap().source, Some(source)); + s.execute("select.deselect", json!({})).unwrap(); + assert_eq!(s.execute("edit.paste", params.clone()).unwrap()["offset"], json!([0, 0]), "same document: {target:?}"); + + s.execute("file.new", json!({"width":40,"height":32,"depth":16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("channel.target", json!({"channel":"red"})).unwrap(); + assert_ne!(s.active().unwrap().doc.id, source); + assert_eq!(s.execute("edit.paste", params).unwrap()["offset"], json!([16, 11]), "another document: {target:?}"); + } +} + +#[test] +fn native_plane_writeback_rejects_incompatible_formats_without_editing() { + let mut dest = surface(&setup("rgb", 16)); + let before = dest.clone(); + for format in [ + photocraft_color::PixelFormat::GRAY8, + photocraft_color::PixelFormat::RGBA8, + photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, SampleType::U16, true), + ] { + let plane = Surface::new(format); + assert!(color_channels::scatter(&mut dest, &plane, 0, Rect::new(0, 0, 32, 24)).is_err()); + assert_eq!(dest, before); + } +} + +#[test] +fn lab_single_channel_tone_parameters_round_trip_without_applying_lightness_twice() { + use photocraft_engine::adjust_params; + for (kind, params) in [("levels", json!({"lightness":{"gamma":2.0}})), ("curves", json!({"lightness":[[0,0],[128,200],[255,255]]}))] { + let mut raw = setup("lab", 32); + let mut canonical = setup("lab", 32); + for session in [&mut raw, &mut canonical] { + session.execute("channel.target", json!({"channel":{"color":0}})).unwrap(); + } + let serialized = adjust_params::to_params(&adjust_params::from_params(kind, ¶ms, None, photocraft_color::ColorMode::Lab).unwrap()); + let command = format!("image.adjustments.{kind}"); + raw.execute(&command, params).unwrap(); + canonical.execute(&command, serialized).unwrap(); + assert_eq!(surface(&raw), surface(&canonical)); + } +} + +fn flat_rgb() -> Session { + let mut s = setup("rgb", 32); + s.edit("flat", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.25, 0.5, 0.25, 1.0]); + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn noise_monochromatic_and_native_halftone_angles_keep_their_meaning_for_subsets() { + let mut mono = flat_rgb(); + let mut color = flat_rgb(); + for s in [&mut mono, &mut color] { + subset(s); + } + mono.execute("filter.noise.addNoise", json!({"amount":50,"seed":42,"monochromatic":true})).unwrap(); + color.execute("filter.noise.addNoise", json!({"amount":50,"seed":42,"monochromatic":false})).unwrap(); + assert_eq!(surface(&mono).pixel(4, 12)[0], surface(&mono).pixel(4, 12)[2]); + assert_ne!(surface(&mono), surface(&color)); + assert_ne!(surface(&color).pixel(4, 12)[0], surface(&color).pixel(4, 12)[2]); + let mut a = flat_rgb(); + let mut b = flat_rgb(); + for s in [&mut a, &mut b] { + subset(s); + } + a.execute("filter.pixelate.colorHalftone", json!({"maxRadius":4,"channel3":0})).unwrap(); + b.execute("filter.pixelate.colorHalftone", json!({"maxRadius":4,"channel3":35})).unwrap(); + let a = surface(&a); + let b = surface(&b); + assert_ne!(a, b); + for y in 0..24 { + for x in 0..32 { + assert_eq!(a.pixel(x, y)[0].to_bits(), b.pixel(x, y)[0].to_bits()); + } + } +} + +#[test] +fn blur_reads_opaque_black_pixels_outside_the_canvas() { + let mut composite = flat_rgb(); + let mut partial = flat_rgb(); + for s in [&mut composite, &mut partial] { + s.edit("opaque black border", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(32, 0, 48, 24), &[0.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + } + subset(&mut partial); + let before = surface(&partial); + for s in [&mut composite, &mut partial] { + s.execute("filter.blur.gaussianBlur", json!({"radius":3})).unwrap(); + } + let expected = surface(&composite).pixel(31, 12); + let actual = surface(&partial).pixel(31, 12); + assert!((actual[0] - expected[0]).abs() < 1e-6, "{actual:?} != {expected:?}"); + assert!((actual[2] - expected[2]).abs() < 1e-6); + unchanged(&before, &surface(&partial)); +} + +#[test] +fn small_filter_selections_preserve_shared_tiles_outside_the_selection() { + let mut s = setup("rgb", 32); + s.edit("larger flat image", |doc, id| { + doc.size.width = 512; + doc.size.height = 512; + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 512, 512), &[0.25, 0.5, 0.25, 1.0]); + Ok(()) + }) + .unwrap(); + subset(&mut s); + s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":16})).unwrap(); + let before = surface(&s); + s.execute("filter.noise.addNoise", json!({"amount":50,"seed":42})).unwrap(); + let after = surface(&s); + let distant = photocraft_geom::TileCoord { tx: 1, ty: 1 }; + assert_eq!(before.pixel(300, 300), after.pixel(300, 300)); + assert!(std::sync::Arc::ptr_eq(before.tile(distant).unwrap(), after.tile(distant).unwrap())); +} + +#[test] +fn rgb_component_exposure_uses_the_document_transfer_at_all_depths() { + for depth in [8, 16, 32] { + for colors in [vec![0usize], vec![0, 2]] { + for selection in [false, true] { + let mut composite = setup("rgb", depth); + let mut partial = setup("rgb", depth); + for s in [&mut composite, &mut partial] { + s.edit("midgray", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[0.5, 0.5, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + if selection { + s.execute("select.rect", json!({"x":4,"y":4,"width":20,"height":16})).unwrap(); + } + } + let before = surface(&partial); + partial.execute("channel.target", json!({"colors":colors})).unwrap(); + for s in [&mut composite, &mut partial] { + s.execute("image.adjustments.exposure", json!({"exposure":1})).unwrap(); + } + let expected = surface(&composite); + let actual = surface(&partial); + for (x, y) in [(0, 0), (10, 10)] { + for k in 0..4 { + if colors.contains(&k) { + let tolerance = if depth == 8 { + 1.1 / 255.0 + } else if depth == 16 { + 1.1 / 65535.0 + } else { + 1e-6 + }; + assert!((actual.pixel(x, y)[k] - expected.pixel(x, y)[k]).abs() < tolerance, "RGB{depth} {colors:?} {selection}"); + } else { + assert_eq!(actual.pixel(x, y)[k].to_bits(), before.pixel(x, y)[k].to_bits()); + } + } + } + } + } + } +} + +#[test] +fn color_replacement_live_preview_forwards_explicit_component_and_pixel_targets() { + for target in [json!({"colors":[1]}), json!("pixels")] { + let mut s = flat_rgb(); + subset(&mut s); + let params = json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100,"mode":"luminosity","color":"#ffffff","seed":42,"target":target}); + let live = photocraft_engine::brush_cmds::live_color_replacement(&s, ¶ms).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let preview = live.doc.layer(id).unwrap().surface().unwrap().clone(); + s.execute("paint.colorReplacement", params).unwrap(); + assert_eq!(surface(&s), preview); + } +} + +#[test] +fn symmetry_settings_remain_available_on_non_raster_layers_with_selected_channels() { + let mut s = setup("rgb", 8); + subset(&mut s); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + s.execute("paint.setSymmetry", json!({"mode":"vertical"})).unwrap(); + s.execute("paint.symmetryDisable", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + s.execute("path.set", json!({"name":"work","path":{"subpaths":[{"closed":false,"knots":[[16,0],[16,24]]}]}})).unwrap(); + s.execute("paint.symmetryFromPath", json!({"name":"work"})).unwrap(); + s.execute("paint.symmetryDisable", json!({})).unwrap(); +} + +#[test] +fn remove_cannot_bypass_component_protection_by_creating_a_new_layer() { + for colors in [json!([0]), json!([0, 2])] { + let mut s = setup("rgb", 8); + s.execute("channel.target", json!({"colors":colors})).unwrap(); + let before = s.active().unwrap().doc.clone(); + assert!(s.execute("paint.remove", json!({"points":[[8,8],[22,12]],"size":9,"newLayer":true})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn hdr_toning_cannot_flatten_a_document_when_colour_components_are_targeted() { + let mut s = setup("rgb", 32); + subset(&mut s); + let before = s.active().unwrap().doc.clone(); + assert!(!s.is_enabled("image.adjustments.hdrToning")); + assert!(s.execute("image.adjustments.hdrToning", json!({})).is_err()); + assert_eq!(s.active().unwrap().doc, before); +} + +#[test] +fn sparse_neighbourhood_filters_keep_output_support_separate_from_sampling_extent() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":2048,"height":2048,"depth":32,"background":"transparent"})).unwrap(); + s.edit("small opaque patch", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.2, 0.4, 0.6, 1.0]); + Ok(()) + }) + .unwrap(); + subset(&mut s); + let before = surface(&s); + s.execute("filter.other.highPass", json!({"radius":2})).unwrap(); + let after = surface(&s); + assert!(after.tile_count() <= 4, "materialized {} tiles", after.tile_count()); + assert_eq!(after.pixel(1500, 1500), before.pixel(1500, 1500)); + unchanged(&before, &after); + assert_ne!(before.pixel(16, 16), after.pixel(16, 16)); +} + +#[test] +fn singleton_copy_keeps_scalar_tone_for_alpha_and_mask_clipboard_consumers() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for mask in [false, true] { + let mut s = setup(mode, depth); + s.edit("uniform", |doc, id| { + let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + let mut value = vec![0.3; surf.channels()]; + *value.last_mut().unwrap() = 0.65; + surf.fill_rect(Rect::new(0, 0, 32, 24), &value); + Ok(()) + }) + .unwrap(); + s.execute("edit.copy", json!({"target":{"colors":[0]}})).unwrap(); + if mask { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel":"mask"})).unwrap(); + } else { + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + } + s.execute("edit.paste", json!({})).unwrap(); + let st = s.active().unwrap(); + let dest = if mask { &st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface } else { &st.doc.channels[0].surface }; + let expected = if mode == "cmyk" { 0.7 } else { 0.3 }; + assert!((dest.pixel(8, 8)[0] - expected).abs() < 0.005, "{mode}{depth} mask={mask}"); + } + } + } +} + +#[test] +fn named_layers_resolve_identically_for_painting_and_component_restoration() { + for (command, mut params) in [ + ("paint.stroke", json!({"points":[[8,8],[22,12]],"size":9,"color":"#ffffff","seed":42})), + ("paint.blur", json!({"points":[[8,8],[22,12]],"size":9,"strength":80})), + ("filter.blur.gaussianBlur", json!({"radius":2})), + ("filter.blur.average", json!({})), + ("image.adjustments.equalize", json!({})), + ("image.adjustments.shadowsHighlights", json!({"shadowAmount":50})), + ("filter.lensCorrection", json!({"distortion":20})), + ("paint.magicEraser", json!({"x":10,"y":10,"tolerance":255})), + ("paint.backgroundEraser", json!({"points":[[8,8],[22,12]],"size":9,"tolerance":100})), + ("path.fill", json!({"color":"#ffffff"})), + ("path.stroke", json!({"size":9,"color":"#ffffff"})), + ] { + let mut s = setup("rgb", 32); + let id = s.active().unwrap().active_layer.unwrap(); + let name = s.active().unwrap().doc.layer(id).unwrap().name.clone(); + let before = surface(&s); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color":"#333333"})).unwrap(); + let active = surface(&s); + s.execute("path.set", json!({"path":{"subpaths":[{"closed":true,"knots":[[2,2],[25,2],[25,20],[2,20]]}]}})).unwrap(); + subset(&mut s); + params["layer"] = json!(name); + s.execute(command, params).unwrap(); + assert!(surface(&s) == active, "{command} must not change the active layer"); + let changed = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap(); + unchanged(&before, changed); + assert!(&before != changed, "{command} must change the named layer"); + } +} + +#[test] +fn patch_and_content_aware_move_previews_protect_the_same_components_as_commit() { + use photocraft_engine::retouch_cmds; + for command in ["paint.patch", "paint.contentAwareMove"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":100,"height":60})).unwrap(); + s.execute("paint.pencil", json!({"points":[[68,30],[74,30]],"size":6,"color":"#ff0000"})).unwrap(); + s.execute("select.lasso", json!({"points":[[60,20],[84,20],[84,40],[60,40]]})).unwrap(); + subset(&mut s); + let id = s.active().unwrap().active_layer.unwrap(); + let before = surface(&s); + let params = json!({"offset":[-40,0]}); + let preview = if command == "paint.patch" { + retouch_cmds::patch_preview(&s, ¶ms, 16384, 1).unwrap().0 + } else { + retouch_cmds::content_aware_move_preview(&s, ¶ms).unwrap().run(&photocraft_engine::jobs::JobCtx::new()).unwrap().0 + }; + unchanged(&before, preview.layer(id).unwrap().surface().unwrap()); + assert_eq!(surface(&s), before, "preview is read-only"); + s.execute(command, params).unwrap(); + assert_eq!(&surface(&s), preview.layer(id).unwrap().surface().unwrap()); + } +} + +#[test] +fn lens_blur_keeps_original_transparency_depth_and_component_noise_streams() { + let mut composite = flat_rgb(); + let mut partial = flat_rgb(); + for s in [&mut composite, &mut partial] { + s.edit("focus alpha", |doc, id| { + let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 16, 24), &[0.2, 0.2, 0.2, 128.0 / 255.0]); + surf.fill_rect(Rect::new(16, 0, 32, 24), &[0.8, 0.2, 0.8, 128.0 / 255.0]); + Ok(()) + }) + .unwrap(); + } + subset(&mut partial); + let before = surface(&partial); + for s in [&mut composite, &mut partial] { + s.execute("filter.blur.lensBlur", json!({"radius":8,"depthMap":"transparency","focalDistance":128})).unwrap(); + } + let expected = surface(&composite).pixel(15, 12); + let actual = surface(&partial).pixel(15, 12); + for k in [0, 2] { + assert!((expected[k] - actual[k]).abs() < 1e-5, "{expected:?} != {actual:?}"); + } + unchanged(&before, &surface(&partial)); + let mut mono = flat_rgb(); + let mut color = flat_rgb(); + for s in [&mut mono, &mut color] { + subset(s); + } + mono.execute("filter.blur.lensBlur", json!({"radius":1,"noise":30,"seed":42,"monochromatic":true})).unwrap(); + color.execute("filter.blur.lensBlur", json!({"radius":1,"noise":30,"seed":42,"monochromatic":false})).unwrap(); + assert_eq!(surface(&mono).pixel(4, 12)[0], surface(&mono).pixel(4, 12)[2]); + assert_ne!(surface(&color).pixel(4, 12)[0], surface(&color).pixel(4, 12)[2]); +} + +#[test] +fn coloured_clouds_and_fibers_keep_native_render_colours_on_selected_channels() { + for command in ["filter.render.clouds", "filter.render.fibers"] { + let mut composite = flat_rgb(); + let mut partial = flat_rgb(); + for s in [&mut composite, &mut partial] { + s.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + s.tools.background = [0.0, 0.0, 1.0, 1.0]; + } + subset(&mut partial); + let before = surface(&partial); + for s in [&mut composite, &mut partial] { + s.execute(command, json!({"seed":42})).unwrap(); + } + let expected = surface(&composite); + let actual = surface(&partial); + unchanged(&before, &actual); + for y in 0..24 { + for x in 0..32 { + for k in [0, 2] { + assert_eq!(actual.pixel(x, y)[k].to_bits(), expected.pixel(x, y)[k].to_bits(), "{command}"); + } + } + } + assert_ne!(actual.pixel(8, 8)[0], actual.pixel(8, 8)[2]); + } +} + +#[test] +fn lighting_effects_keep_the_original_alpha_texture_for_colour_targets() { + let mut composite = flat_rgb(); + let mut partial = flat_rgb(); + for s in [&mut composite, &mut partial] { + s.edit("alpha relief", |doc, id| { + let surf = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(Rect::new(0, 0, 16, 24), &[0.5, 0.5, 0.5, 0.2]); + surf.fill_rect(Rect::new(16, 0, 32, 24), &[0.5, 0.5, 0.5, 0.8]); + Ok(()) + }) + .unwrap(); + } + subset(&mut partial); + let before = surface(&partial); + for s in [&mut composite, &mut partial] { + s.execute("filter.render.lightingEffects", json!({"texture":"alpha","height":100,"gloss":-100,"metallic":-100})).unwrap(); + } + let expected = surface(&composite); + let actual = surface(&partial); + unchanged(&before, &actual); + for y in 0..24 { + for x in 0..32 { + for k in [0, 2] { + assert_eq!(actual.pixel(x, y)[k].to_bits(), expected.pixel(x, y)[k].to_bits()); + } + } + } +} + +#[test] +fn mode_conversion_normalizes_a_now_complete_colour_set_to_composite() { + let mut s = setup("cmyk", 8); + s.execute("channel.target", json!({"colors":[0,1,2]})).unwrap(); + s.execute("image.mode.rgb", json!({})).unwrap(); + assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite); + assert!(color_channels::selected(&s).is_none()); + assert!(s.active().unwrap().channel_view.selected_colors.is_empty()); + assert!(s.is_enabled("layer.translate")); + assert!(s.is_enabled("filter.cameraRaw")); +} + +#[test] +fn colour_only_edits_cannot_create_an_unrestricted_render_layer() { + let mut s = setup("rgb", 8); + subset(&mut s); + let before = s.active().unwrap().doc.clone(); + for command in ["paint.remove", "filter.render.tree", "filter.render.flame", "filter.render.pictureFrame"] { + assert!(s.execute(command, json!({"newLayer":true})).is_err(), "{command}"); + assert_eq!(&*s.active().unwrap().doc, &*before); + } +} + +#[test] +fn selections_with_a_selected_default_and_stored_holes_edit_the_remaining_components() { + for depth in [8, 16, 32] { + let mut s = setup("rgb", depth); + let before = surface(&s); + s.edit("selected default with a hole", |doc, _| { + let mut mask = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); + mask.fill_rect(Rect::new(0, 0, 4, 4), &[0.0]); + doc.selection = Some(mask); + Ok(()) + }) + .unwrap(); + subset(&mut s); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let after = surface(&s); + unchanged(&before, &after); + assert_eq!(before.pixel(1, 1), after.pixel(1, 1)); + for k in [0, 2] { + assert!((after.pixel(10, 10)[k] - (1.0 - before.pixel(10, 10)[k])).abs() < 0.005, "depth {depth} component {k}"); + } + } +} + +#[test] +fn geometric_filters_on_colour_sets_preserve_background_identity_and_linked_masks() { + for (command, params) in [ + ("filter.lensCorrection", json!({"distortion":20})), + ("filter.liquify", json!({"strokes":[{"tool":"forwardWarp","size":12,"points":[[10,10],[15,13]]}]})), + ] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("coloured background and linked mask", |doc, _| { + let layer = doc.layer_mut(id).unwrap(); + layer.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 12, 24), &[0.2, 0.4, 0.6, 1.0]); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(0, 0, 10, 24), &[0.3]); + layer.mask = Some(mask); + Ok(()) + }) + .unwrap(); + let original = s.active().unwrap().doc.layer(id).unwrap().clone(); + let before = surface(&s); + subset(&mut s); + s.execute(command, params).unwrap(); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + assert_eq!(layer.name, original.name); + assert_eq!(layer.locks, original.locks); + assert_eq!(layer.mask, original.mask); + unchanged(&before, layer.surface().unwrap()); + assert!(layer.surface().unwrap() != &before, "{command}"); + } +} + +#[test] +fn colour_adjustments_keep_the_original_cross_component_inputs() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for (cmd, params) in [ + ("image.adjustments.channelMixer", json!({"red":[0,100,0,0]})), + ("image.adjustments.hueSaturation", json!({"saturation":-30})), + ("image.adjustments.vibrance", json!({"vibrance":40})), + ("image.adjustments.colorBalance", json!({"midtones":[20,-10,30]})), + ("image.adjustments.photoFilter", json!({"color":"#ff0000","density":70})), + ] { + let mut control = setup(mode, depth); + let before = surface(&control); + control.execute(cmd, params.clone()).unwrap(); + let composite = surface(&control); + for indices in [vec![0], vec![0, 2]] { + let mut s = setup(mode, depth); + s.execute("channel.target", json!({"colors":indices})).unwrap(); + s.execute(cmd, params.clone()).unwrap(); + let mut expected = composite.clone(); + let colors = color_channels::ColorChannels::from_indices(indices, before.format().mode.color_channels()).unwrap(); + color_channels::restrict_surface(&before, &mut expected, colors).unwrap(); + assert!(surface(&s) == expected, "{mode} {depth} {cmd}"); + } + } + } + } +} + +#[test] +fn named_patch_and_content_aware_move_targets_do_not_edit_the_active_layer() { + for command in ["paint.patch", "paint.contentAwareMove"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":100,"height":60})).unwrap(); + s.execute("layer.new.layer", json!({"name":"other"})).unwrap(); + s.execute("edit.fill", json!({"color":"#ffffff"})).unwrap(); + s.execute("paint.pencil", json!({"points":[[68,30],[74,30]],"size":6,"color":"#ff0000"})).unwrap(); + s.execute("select.lasso", json!({"points":[[60,20],[84,20],[84,40],[60,40]]})).unwrap(); + subset(&mut s); + let before = surface(&s); + s.execute(command, json!({"offset":[-40,0],"layer":"Background"})).unwrap(); + assert!(surface(&s) == before, "{command} must leave the active layer unchanged"); + } +} + +#[test] +fn colour_only_rendering_needs_an_existing_raster_destination() { + let mut s = setup("rgb", 8); + s.execute("select.deselectLayers", json!({})).unwrap(); + subset(&mut s); + let before = s.active().unwrap().doc.clone(); + for cmd in ["filter.render.tree", "filter.render.flame", "filter.render.pictureFrame"] { + assert!(!s.is_enabled(cmd)); + assert!(s.execute(cmd, json!({})).is_err()); + assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc)); + } +} + +#[test] +fn rgb_float_curves_keep_endpoint_extrapolation_in_selected_planes() { + let params = json!({"points":[[0,0],[128,180],[255,255]]}); + for value in [-0.2, 1.5, 2.0] { + for selected in [false, true] { + for indices in [vec![0], vec![0, 2]] { + let mut s = setup("rgb", 32); + s.edit("HDR fixture", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 24), &[value, 0.4, value, 0.7]); + Ok(()) + }) + .unwrap(); + if selected { + s.execute("select.rect", json!({"x":0,"y":0,"width":16,"height":24})).unwrap(); + } + let before = surface(&s); + let mut control = Session::new(); + control.add_document((*s.active().unwrap().doc).clone(), None); + control.select_layer(s.active().unwrap().active_layer.unwrap()).unwrap(); + control.execute("image.adjustments.curves", params.clone()).unwrap(); + let expected = surface(&control); + s.execute("channel.target", json!({"colors":indices})).unwrap(); + s.execute("image.adjustments.curves", params.clone()).unwrap(); + let after = surface(&s); + for (x, y) in [(10, 10), (25, 10)] { + for k in 0..4 { + if indices.contains(&k) { + assert!((after.pixel(x, y)[k] - expected.pixel(x, y)[k]).abs() < 1e-5, "value {value}, selection {selected}, component {k}"); + } else { + assert_eq!(after.pixel(x, y)[k].to_bits(), before.pixel(x, y)[k].to_bits()); + } + } + } + } + } + } +} + +#[test] +fn clear_and_cut_in_mask_view_preserve_pixels_and_clear_only_selected_mask_samples() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for command in ["edit.clear", "edit.cut"] { + let mut s = setup(mode, depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + subset(&mut s); + s.execute("view.layerMask", json!({"mode":"gray"})).unwrap(); + s.execute("tools.setColors", json!({"background":"#000000"})).unwrap(); + s.edit("Feathered selection", |doc, _| { + let mut mask = Surface::new(photocraft_color::PixelFormat::GRAY8); + mask.fill_rect(Rect::new(4, 4, 12, 12), &[0.5]); + doc.selection = Some(mask); + Ok(()) + }) + .unwrap(); + let before = surface(&s); + let layer = s.active().unwrap().active_layer.unwrap(); + let mask = |s: &Session| s.active().unwrap().doc.layer(layer).unwrap().mask.as_ref().unwrap().surface.clone(); + let mask_before = mask(&s); + let steps = s.active().unwrap().history.past_len(); + s.execute(command, json!({})).unwrap(); + assert_eq!(surface(&s), before, "{command} {mode}@{depth}"); + let cleared = mask(&s); + assert!((cleared.sample_channel(8, 8, 0) - (1.0 - 128.0 / 255.0)).abs() < 0.005); + assert_eq!(cleared.sample_channel(20, 20, 0), 1.0); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + if command == "edit.cut" { + let clip = s.clipboard.as_ref().unwrap(); + assert_eq!(clip.surface.format().mode, photocraft_doc::ColorMode::Grayscale); + assert_eq!(clip.surface.sample_channel(8, 8, 0), 1.0); + assert!(clip.components.is_none()); + } + s.undo(); + assert_eq!(mask(&s), mask_before); + assert_eq!(surface(&s), before); + s.redo(); + assert_eq!(mask(&s), cleared); + } + } + } +} + +#[test] +fn refused_mask_cut_and_clear_leave_the_pixels_mask_and_clipboard_unchanged() { + for command in ["edit.cut", "edit.clear"] { + let mut s = setup("rgb", 16); + let before = surface(&s); + assert!(s.execute(command, json!({"target":"mask"})).is_err()); + assert_eq!(surface(&s), before); + s.execute("edit.copy", json!({})).unwrap(); + let clipboard = s.clipboard.as_ref().unwrap().surface.clone(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + subset(&mut s); + s.execute("view.layerMask", json!({"mode":"gray"})).unwrap(); + s.edit("Lock layer", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().locks.all = true; + Ok(()) + }) + .unwrap(); + let doc = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer.unwrap(); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute(command, json!({})).is_err()); + assert_eq!(surface(&s), before); + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().mask, doc.layer(layer).unwrap().mask); + assert_eq!(s.clipboard.as_ref().unwrap().surface, clipboard); + assert_eq!(s.active().unwrap().history.past_len(), steps); + } +} + +#[test] +fn desaturate_honours_explicit_native_targets_over_the_panel_selection() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for target in [json!({"color":1}), json!({"colors":[1]}), json!("pixels")] { + let mut s = setup(mode, depth); + let before = surface(&s); + let mut control = Session::new(); + let st = s.active().unwrap(); + control.add_document((*st.doc).clone(), None); + control.select_layer(st.active_layer.unwrap()).unwrap(); + control.execute("image.adjustments.desaturate", json!({})).unwrap(); + let expected = surface(&control); + subset(&mut s); + s.execute("image.adjustments.desaturate", json!({"target":target})).unwrap(); + let actual = surface(&s); + assert_ne!(actual, before, "{mode}@{depth} {target}"); + for y in 0..24 { + for x in 0..32 { + for k in 0..actual.channels() { + let want = if target == "pixels" || k == 1 { &expected } else { &before }; + assert_eq!( + actual.sample_channel(x, y, k).to_bits(), + want.sample_channel(x, y, k).to_bits(), + "{mode}@{depth} {target} component {k}" + ); + } + } + } + } + } + } +} + +#[test] +fn mask_view_routes_implicit_edits_and_tone_editors_to_the_mask_before_colour_sets() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for view in ["gray", "overlay"] { + for colors in [vec![0], vec![0, 2]] { + let mut s = setup(mode, depth); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"colors":colors})).unwrap(); + s.execute("view.layerMask", json!({"mode":view})).unwrap(); + let before = surface(&s); + let st = s.active().unwrap(); + let layer = st.active_layer.unwrap(); + let descriptor = photocraft_engine::channel_cmds::isolated_curve_channel(st).unwrap(); + assert_eq!(descriptor.gray, Some(photocraft_engine::channel_cmds::GraySurface::LayerMask)); + assert!(descriptor.components.is_none()); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert_eq!(surface(&s), before); + let mask = |s: &Session| s.active().unwrap().doc.layer(layer).unwrap().mask.as_ref().unwrap().surface.clone(); + assert_eq!(mask(&s).sample_channel(8, 8, 0), 0.0); + s.execute("paint.stroke", json!({"points":[[8,8]],"size":6,"hardness":1.0,"color":"#ffffff"})).unwrap(); + assert!(mask(&s).sample_channel(8, 8, 0) > 0.9); + s.execute("filter.blur.gaussianBlur", json!({"radius":2})).unwrap(); + s.execute("image.adjustments.desaturate", json!({})).unwrap(); + assert_eq!(surface(&s), before); + let mask_before = mask(&s); + // An explicit native target can still override the viewed mask. + s.execute("image.adjustments.invert", json!({"target":{"colors":colors}})).unwrap(); + assert_ne!(surface(&s), before); + assert_eq!(mask(&s), mask_before); + s.execute("channel.target", json!({"colors":colors})).unwrap(); + assert!(photocraft_engine::mask_view_cmds::current(s.active().unwrap()).is_none(), "selecting colours leaves mask view"); + s.execute("view.layerMask", json!({"mode":view})).unwrap(); + s.execute("channel.target", json!({"channel":"composite"})).unwrap(); + assert!(photocraft_engine::mask_view_cmds::current(s.active().unwrap()).is_none(), "selecting the composite leaves mask view"); + } + } + } + } +} + +#[test] +fn copy_and_cut_resolve_the_same_layer_for_composite_and_colour_targets() { + for indices in [vec![], vec![0], vec![0, 2]] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":32})).unwrap(); + s.execute("layer.new.layer", json!({"name":"source"})).unwrap(); + s.execute("edit.fill", json!({"color":"#804020"})).unwrap(); + let source_id = s.active().unwrap().active_layer.unwrap(); + let source_before = surface(&s); + s.execute("layer.new.layer", json!({"name":"active"})).unwrap(); + s.execute("edit.fill", json!({"color":"#00ff80"})).unwrap(); + let active_before = surface(&s); + s.execute("tools.setColors", json!({"background":"#000000"})).unwrap(); + if !indices.is_empty() { + s.execute("channel.target", json!({"colors":indices})).unwrap(); + } + s.execute("edit.copy", json!({"layer":"source"})).unwrap(); + let copied = &s.clipboard.as_ref().unwrap().surface; + let expected = if indices.is_empty() { &active_before } else { &source_before }; + assert_eq!(copied.pixel(4, 4)[0], expected.pixel(4, 4)[0], "Copy {indices:?}"); + assert_eq!(surface(&s), active_before); + assert_eq!(s.active().unwrap().doc.layer(source_id).unwrap().surface().unwrap(), &source_before); + s.execute("edit.cut", json!({"layer":"source"})).unwrap(); + let source_after = s.active().unwrap().doc.layer(source_id).unwrap().surface().unwrap(); + let clipboard = &s.clipboard.as_ref().unwrap().surface; + if indices.is_empty() { + assert!(source_after == &source_before); + assert_eq!(surface(&s).pixel(4, 4)[3], 0.0); + assert_eq!(clipboard.pixel(4, 4), active_before.pixel(4, 4)); + } else { + assert!(surface(&s) == active_before); + for k in 0..4 { + let expected = if indices.contains(&k) { 0.0 } else { source_before.pixel(4, 4)[k] }; + assert_eq!(source_after.pixel(4, 4)[k], expected); + } + assert!((clipboard.pixel(4, 4)[0] - source_before.pixel(4, 4)[0]).abs() < 1e-6); + } + } +} + +#[test] +fn named_red_eye_target_does_not_bypass_channel_protection_on_the_active_layer() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":64,"height":64,"depth":32})).unwrap(); + s.execute("layer.new.layer", json!({"name":"other"})).unwrap(); + s.execute("edit.fill", json!({"color":"#404040"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"eye"})).unwrap(); + s.edit("red pupil", |doc, id| { + let mut values = Vec::new(); + for y in 0..64 { + for x in 0..64 { + let distance = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + values.extend(if distance <= 1.2 { + [1.0; 4] + } else if distance <= 8.0 { + [0.95, 0.1, 0.1, 1.0] + } else { + [0.38, 0.28, 0.16, 1.0] + }); + } + } + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().write_region(Rect::new(0, 0, 64, 64), &values); + Ok(()) + }) + .unwrap(); + s.execute("channel.target", json!({"colors":[1,2]})).unwrap(); + let before = surface(&s); + let error = s.execute("paint.redEye", json!({"x":32,"y":32,"layer":"other"})).unwrap_err(); + assert!(error.to_string().contains("no red-eye pixels")); + assert!(surface(&s) == before); +} + +#[test] +fn only_pixel_editing_paint_commands_follow_a_colour_set() { + for id in ["paint.patch", "paint.contentAwareMove", "paint.stroke", "paint.magicEraser"] { + assert!(photocraft_engine::channel_cmds::follows_target(id), "{id}"); + } + for id in ["paint.mixerLoad", "paint.setSymmetry", "paint.symmetryFromPath", "paint.symmetryDisable"] { + assert!(!photocraft_engine::channel_cmds::follows_target(id), "{id}"); + } + // ⌥-click with the Mixer Brush samples a fill layer with a colour set targeted: it edits + // nothing, so it is not refused with "Rasterize the layer". + let mut s = setup("rgb", 8); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap(); + subset(&mut s); + let loaded = s.execute("paint.mixerLoad", json!({"x": 4.0, "y": 4.0})).unwrap(); + assert_eq!(loaded["loaded"], json!(true)); +} diff --git a/crates/engine/tests/paste_style_options.rs b/crates/engine/tests/paste_style_options.rs new file mode 100644 index 0000000000..af0f131dd8 --- /dev/null +++ b/crates/engine/tests/paste_style_options.rs @@ -0,0 +1,266 @@ +//! Paste Layer Style applies group eligibility consistently and preserves the ATN flag. + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{BlendRange, Document, Effect, Layer, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds, layer_multi_cmds}; +use photocraft_geom::Size; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const CMD: &str = "layer.layerStyle.pasteLayerStyle"; +const KEY: &str = "allowPasteFXOnLayerSet"; + +struct Scene { + s: Session, + source: LayerId, + raster: LayerId, + group: LayerId, + other_group: LayerId, + child: LayerId, + background: LayerId, +} + +/// The copied style contains effects and blending data, so a partial copy cannot pass. +fn fixture() -> Scene { + let mut doc = Document::with_background("Style", Size::new(8, 6), ColorMode::Rgb, SampleType::U8, Color::rgb(1.0, 1.0, 1.0)); + let background = doc.layers[0].id; + let mut source = Layer::raster("Source", doc.pixel_format()); + source.effects.items.push(Effect::default_drop_shadow()); + source.blend = BlendMode::Multiply; + source.fill_opacity = 0.6; + source.advanced.blend_interior = true; + source.blend_if.set(0, [BlendRange::from_bytes([12, 24, 230, 240]), BlendRange::FULL]); + source.excluded_channels = 2; + let raster = Layer::raster("Raster", doc.pixel_format()); + let child = Layer::raster("Child", doc.pixel_format()); + let child_id = child.id; + let group = Layer::group("Group", vec![child]); + let other = Layer::group("Other group", vec![]); + let ids = (source.id, raster.id, group.id, other.id); + doc.layers.extend([source, raster, group, other]); + let mut s = Session::new(); + s.add_document(doc, None); + s.select_layer(ids.0).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); + Scene { s, source: ids.0, raster: ids.1, group: ids.2, other_group: ids.3, child: child_id, background } +} + +fn select(scene: &mut Scene, ids: Vec) { + let active = ids.last().copied(); + layer_multi_cmds::set_selection(&mut scene.s, ids, active, active).unwrap(); +} + +fn layer(s: &Session, id: LayerId) -> &Layer { + s.active().unwrap().doc.layer(id).unwrap() +} + +fn assert_style(actual: &Layer, source: &Layer) { + assert_eq!(actual.effects, source.effects); + assert_eq!(actual.blend, source.blend); + assert_eq!(actual.fill_opacity, source.fill_opacity); + assert_eq!(actual.advanced, source.advanced); + assert_eq!(actual.blend_if, source.blend_if); + assert_eq!(actual.excluded_channels, source.excluded_channels); +} + +/// Refusals must not consume style data, modify selection, or remove a redo branch. +fn assert_rejected(s: &mut Session, params: Value) { + let state = s.active().unwrap(); + let before = state.doc.clone(); + let revision = state.revision; + let selected = state.selected_layers.clone(); + let active = state.active_layer; + let history = (state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)); + let clipboard = s.style_clipboard.as_ref().unwrap().effects.clone(); + assert!(s.execute(CMD, params).is_err(), "group restriction must refuse an ineligible target"); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.selected_layers, selected); + assert_eq!(state.active_layer, active); + assert_eq!((state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)), history); + assert_eq!(s.style_clipboard.as_ref().unwrap().effects, clipboard); +} + +/// Every target cardinality uses the same restriction; group selection never expands children. +#[test] +fn false_excludes_explicit_single_and_all_group_targets_atomically() { + for explicit in [false, true] { + let mut scene = fixture(); + let group = scene.group; + select(&mut scene, vec![group]); + let mut params = json!({"allowGroups": false}); + if explicit { + params["layer"] = json!(group.0); + } + assert_rejected(&mut scene.s, params); + } + for background in [false, true] { + let mut scene = fixture(); + let ids = vec![scene.group, if background { scene.background } else { scene.other_group }]; + select(&mut scene, ids); + assert_rejected(&mut scene.s, json!({"allowGroups": false})); + } +} + +/// Mixed selections change only eligible explicit members and create one undoable edit. +#[test] +fn mixed_targets_skip_groups_without_expanding_their_children() { + for selected_child in [false, true] { + let mut scene = fixture(); + let eligible = if selected_child { scene.child } else { scene.raster }; + let group = scene.group; + select(&mut scene, vec![group, eligible]); + let before = scene.s.active().unwrap().doc.clone(); + let history = scene.s.active().unwrap().history.past_len(); + scene.s.execute(CMD, json!({"allowGroups": false})).unwrap(); + assert_style(layer(&scene.s, eligible), layer(&scene.s, scene.source)); + assert_style(layer(&scene.s, group), before.layer(group).unwrap()); + if !selected_child { + assert_eq!(layer(&scene.s, scene.child), before.layer(scene.child).unwrap()); + } + assert_eq!(scene.s.active().unwrap().history.past_len(), history + 1); + let after = scene.s.active().unwrap().doc.clone(); + scene.s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(scene.s.active().unwrap().doc, before); + scene.s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(scene.s.active().unwrap().doc, after); + } +} + +/// Native legacy/default group support and explicit Background conversion remain available. +#[test] +fn defaults_and_background_keep_legacy_behavior_and_wrong_types_preserve_redo() { + for value in [None, Some(Value::Null), Some(json!(true))] { + let mut scene = fixture(); + let child = layer(&scene.s, scene.child).clone(); + let mut p = json!({"layer": scene.group.0}); + if let Some(value) = value { + p["allowGroups"] = value; + } + scene.s.execute(CMD, p).unwrap(); + assert_style(layer(&scene.s, scene.group), layer(&scene.s, scene.source)); + assert_eq!(layer(&scene.s, scene.child), &child); + } + let mut scene = fixture(); + scene.s.execute(CMD, json!({"layer": scene.background.0, "allowGroups": false})).unwrap(); + assert_style(layer(&scene.s, scene.background), layer(&scene.s, scene.source)); + assert!(!scene.s.active().unwrap().doc.is_background(scene.background)); + for value in [json!(0), json!("false"), json!([]), json!({})] { + let mut scene = fixture(); + scene.s.execute("layer.new.layer", json!({})).unwrap(); + scene.s.execute("edit.undo", json!({})).unwrap(); + assert!(scene.s.active().unwrap().history.can_redo()); + assert_rejected(&mut scene.s, json!({"layer": scene.raster.0, "allowGroups": value})); + } +} + +fn recorded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Style"), + expanded: false, + warnings: vec![], + encoding: None, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Paste"), + items: vec![AtnItem { + event: AtnEvent::name("pasteEffects"), + dictionary: b"Paste Layer Style".to_vec(), + descriptor: Some(d), + ..Default::default() + }], + ..Default::default() + }], + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let result = s.execute("actions.save", json!({"set": "Style"})).unwrap(); + assert_eq!(result["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(result["data"].as_str().unwrap()).unwrap() +} + +/// Inspect typed saved fields, then play against selected groups and pixels to expose dropped flags. +#[test] +fn atn_group_flag_survives_saved_descriptor_and_public_playback() { + for allow in [false, true] { + let mut scene = fixture(); + let ids = vec![scene.group, scene.raster]; + select(&mut scene, ids); + let group_before = layer(&scene.s, scene.group).clone(); + let child_before = layer(&scene.s, scene.child).clone(); + let bytes = recorded(Descriptor::new("null").with(KEY, D::Boolean(allow))); + assert_eq!(load(&mut scene.s, &bytes)["unsupported"], json!([])); + assert_eq!(scene.s.actions.list[0].steps, vec![(CMD.into(), json!({"allowGroups": allow}))]); + let saved = save(&mut scene.s); + let parsed = atn::parse(&saved).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get(KEY), Some(&D::Boolean(allow))); + let imported = atn_cmds::import(&saved).unwrap(); + assert_eq!(imported.actions[0].steps, scene.s.actions.list[0].steps); + scene.s.actions.list = imported.actions; + let result = scene.s.execute("actions.play", json!({"action": "Paste"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_style(layer(&scene.s, scene.raster), layer(&scene.s, scene.source)); + if allow { + assert_style(layer(&scene.s, scene.group), layer(&scene.s, scene.source)); + } else { + assert_eq!(layer(&scene.s, scene.group), &group_before); + } + assert_eq!(layer(&scene.s, scene.child), &child_before); + } +} + +/// Omission retains legacy group eligibility and exports its effective Boolean true. +#[test] +fn atn_defaults_are_explicit_and_unsupported_fields_remain_whole_steps() { + let mut scene = fixture(); + assert_eq!(load(&mut scene.s, &recorded(Descriptor::new("null")))["unsupported"], json!([])); + assert_eq!(scene.s.actions.list[0].steps[0].1, json!({"allowGroups": true})); + for value in [None, Some(Value::Null)] { + let mut params = json!({}); + if let Some(value) = value { + params["allowGroups"] = value; + } + let action = Action::new("Defaults", vec![(CMD.into(), params)]); + let (bytes, skipped) = atn_cmds::export("Style", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get(KEY), Some(&D::Boolean(true))); + } + let bad = [ + Descriptor::new("null").with(KEY, D::Integer(1)), + Descriptor::new("null").with(KEY, D::Text(UnicodeString::new_nul("true"))), + Descriptor::new("null").with("null", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new("Lyr ") })), + ]; + for d in bad { + let bytes = recorded(d); + let original = atn::parse(&bytes).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &bytes)["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + } + for (key, value) in [ + ("allowGroups", json!(1)), + ("allowGroups", json!("true")), + ("allowGroups", json!([])), + ("layer", json!(17)), + ("layer", Value::Null), + ("layers", json!([17])), + ("layers", Value::Null), + ] { + let mut params = json!({}); + params[key] = value; + let action = Action::new("Invalid", vec![(CMD.into(), params)]); + assert!(atn_cmds::export("Style", &[&action], &[], false).is_err()); + } +} diff --git a/crates/engine/tests/pen_path_append.rs b/crates/engine/tests/pen_path_append.rs new file mode 100644 index 0000000000..41306ea6a2 --- /dev/null +++ b/crates/engine/tests/pen_path_append.rs @@ -0,0 +1,80 @@ +use photocraft_engine::Session; +use serde_json::json; + +fn session() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + session +} + +#[test] +fn appended_work_and_saved_components_preserve_existing_geometry_and_undo() { + for name in ["work", "Saved drawing"] { + let mut session = session(); + let first = json!({"subpaths": [{"closed": false, "knots": [[20, 20], [80, 20]]}]}); + let second = json!({"subpaths": [{"closed": false, "knots": [[30, 60], [90, 60]]}]}); + session.execute("path.set", json!({"name": name, "path": first})).unwrap(); + let before = session.execute("path.info", json!({"name": name})).unwrap(); + session.execute("path.set", json!({"name": name, "path": second, "op": "combine"})).unwrap(); + let after = session.execute("path.info", json!({"name": name})).unwrap(); + assert_eq!(after["path"]["subpaths"].as_array().unwrap().len(), 2); + assert_eq!(after["path"]["subpaths"][0], before["path"]["subpaths"][0]); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), before); + session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), after); + } +} + +#[test] +fn appending_valid_components_cannot_exceed_total_path_budget() { + let mut session = session(); + let subpath = json!({"closed": false, "knots": []}); + session.execute("path.set", json!({"path": {"subpaths": vec![subpath.clone(); 2_049]}})).unwrap(); + let before = session.active().unwrap().doc.work_path.clone(); + let history = session.active().unwrap().history.entries().len(); + let error = session.execute("path.set", json!({"path": {"subpaths": vec![subpath; 2_048]}, "op": "combine"})).unwrap_err(); + assert!(error.to_string().contains("4096")); + assert_eq!(session.active().unwrap().doc.work_path, before); + assert_eq!(session.active().unwrap().history.entries().len(), history); +} + +#[test] +fn invalid_path_coordinates_leave_work_and_saved_paths_unchanged() { + for name in ["work", "Saved drawing"] { + let mut session = session(); + session.execute("path.set", json!({"name": name, "path": {"subpaths": [{"knots": [[10, 10], [20, 20]]}]}})).unwrap(); + let before = session.execute("path.info", json!({"name": name})).unwrap(); + let history = session.active().unwrap().history.entries().len(); + assert!( + session.execute("path.set", json!({"name": name, "op": "combine", "path": {"subpaths": [{"knots": [[-1.0e308, 10], [1.0e308, 20]]}]}})).is_err() + ); + assert_eq!(session.execute("path.info", json!({"name": name})).unwrap(), before); + assert_eq!(session.active().unwrap().history.entries().len(), history); + } +} + +#[test] +fn large_straight_work_and_saved_paths_append_and_undo_without_shape_coordinate_limits() { + for name in ["work", "Saved drawing"] { + let mut session = session(); + let large = json!({"subpaths": [{"closed": true, "knots": [[0.0, 0.0], [3.0e9, 0.0], [3.0e9, 1.0e9]]}]}); + let before = session.active().unwrap().doc.clone(); + let history = session.active().unwrap().history.entries().len(); + session.execute("path.set", json!({"name":name,"path":large})).unwrap(); + let first = session.execute("path.info", json!({"name":name})).unwrap(); + assert_eq!(first["path"]["subpaths"][0]["knots"][1]["anchor"], json!([3.0e9, 0.0])); + assert_eq!(session.active().unwrap().history.entries().len(), history + 1); + session.execute("path.set", json!({"name":name,"op":"combine","path":{"subpaths":[{"knots":[[10,20],[80,20]]}]}})).unwrap(); + let appended = session.execute("path.info", json!({"name":name})).unwrap(); + assert_eq!(appended["path"]["subpaths"].as_array().unwrap().len(), 2); + assert_eq!(appended["path"]["subpaths"][0], first["path"]["subpaths"][0]); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name":name})).unwrap(), first); + session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(session.active().unwrap().doc.work_path, before.work_path); + assert_eq!(session.active().unwrap().doc.paths, before.paths); + session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(session.execute("path.info", json!({"name":name})).unwrap(), first); + } +} diff --git a/crates/engine/tests/scratch_disk.rs b/crates/engine/tests/scratch_disk.rs new file mode 100644 index 0000000000..55a361133e --- /dev/null +++ b/crates/engine/tests/scratch_disk.rs @@ -0,0 +1,119 @@ +//! Disk-backed current pixels and history: lossless undo, and failed reads never commit. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::{Rect, Size, TileCoord}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::Arc; + +#[test] +fn scratch_pixels_history_and_failed_save_are_transactional() { + let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-{}", std::process::id())); + let mut session = Session::new(); + session.edit_prefs(|p| { + p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }]; + p.performance.memory_usage_mb = 1; + }); + assert!(spill::enabled()); + assert_eq!(session.prefs().performance.history_cap_bytes(), 0); + let mut doc = Document::new("Scratch fixture", Size::new(2048, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + let id = layer.id; + for i in 0..8 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, b) in bytes.iter_mut().enumerate() { + *b = (j as u32).wrapping_mul(17).wrapping_add(i as u32 * 43) as u8; + } + } + doc.layers.push(layer); + session.add_document(doc, None); + session.select_layer(id).unwrap(); + let before = session.active().unwrap().doc.clone(); + session + .edit("Paint", |d, _| { + d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + spill::settle(); + assert!(session.undo()); + assert_eq!(*session.active().unwrap().doc, *before); + assert!(session.redo()); + assert_eq!(session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + spill::settle(); + + // Force all tiles to disk, including the tile the failed edit will touch. + spill::configure(Some(&dir), 1).unwrap(); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + // Damage only this test's scratch file; retain its exact contents to restore the backing. + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + let retained = session.active().unwrap().doc.clone(); + let revision = session.active().unwrap().revision; + let failed = session.edit("Failed paint", |d, active| { + let discarded = Layer::raster("Discarded", PixelFormat::RGBA8); + *active = Some(discarded.id); + d.layers.push(discarded); + d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(1800, 0, 4, 4), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }); + assert!(failed.is_err()); + assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained)); + assert_eq!(session.active().unwrap().revision, revision); + assert_eq!(session.active().unwrap().active_layer, Some(id)); + let mut writer = photocraft_format::PcraftWriter::new(); + assert!(writer.save_zip(&retained, &Default::default()).is_err()); + assert!(photocraft_io::export(&retained, "png", &Default::default()).is_err()); + // No failure or recovery happens during a retry, so only the document's own health + // refuses it. + let generation = spill::read_error_generation(); + assert!(session.edit("Retry paint", |_, _| Ok(())).is_err()); + assert_eq!(spill::read_error_generation(), generation, "an already-failed tile does not change the generation"); + assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained)); + + // Another document is unaffected: it edits, saves and exports while this one is unreadable. + assert!(spill::check_integrity().is_err(), "the process-wide status still reports the failure"); + let mut other = Document::new("Healthy", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut pixels = Layer::raster("Other", PixelFormat::RGBA8); + let other_id = pixels.id; + pixels.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 512, 256), &[0.0, 0.0, 1.0, 1.0]); + other.layers.push(pixels); + session.add_document(other, None); + session.select_layer(other_id).unwrap(); + session + .edit("Paint other", |d, _| { + d.layer_mut(other_id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 8, 8), &[1.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + spill::settle(); + let healthy = session.active().unwrap().doc.clone(); + assert!(healthy.scratch_health().is_ok()); + let (saved, _) = photocraft_format::PcraftWriter::new().save_zip(&healthy, &Default::default()).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&saved).unwrap().layers, healthy.layers); + assert!(photocraft_io::export(&healthy, "png", &Default::default()).is_ok()); + assert!(retained.scratch_health().is_err()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for d in [&before, &retained] { + for (_, tile) in d.layer(id).unwrap().surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + } + assert!(spill::check_integrity().is_ok()); + let (bytes, _) = writer.save_zip(&retained, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded.layers, retained.layers); + drop((session, before, retained, healthy, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/tests/scratch_pause.rs b/crates/engine/tests/scratch_pause.rs new file mode 100644 index 0000000000..3b694f2da2 --- /dev/null +++ b/crates/engine/tests/scratch_pause.rs @@ -0,0 +1,60 @@ +//! A scratch disk that fills up pauses spilling; History's byte limit applies again until it +//! resumes, so undo states cannot pile up in memory unbounded meanwhile. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::{Size, TileCoord}; +use photocraft_raster::spill; +use serde_json::json; + +#[test] +fn paused_scratch_disk_restores_the_history_limit() { + let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-pause-{}", std::process::id())); + let mut session = Session::new(); + session.edit_prefs(|p| { + p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }]; + p.performance.memory_usage_mb = 64; + }); + assert!(spill::enabled()); + assert_eq!(spill::stats().size_cap_bytes, None, "0 MB = no scratch size limit"); + // Preferences › Scratch Disks › Scratch Size Limit becomes the scratch file's cap. + session.edit_prefs(|p| p.scratch_disks.size_limit_mb = 4096); + assert_eq!(spill::stats().size_cap_bytes, Some(4096 << 20)); + session.edit_prefs(|p| p.scratch_disks.size_limit_mb = 0); + assert_eq!(spill::stats().size_cap_bytes, None); + let mut doc = Document::new("Pause fixture", Size::new(1024, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + let id = layer.id; + for i in 0..4 { + for (j, b) in layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut().iter_mut().enumerate() { + *b = (j as u32).wrapping_mul(31).wrapping_add(i as u32 * 7) as u8; + } + } + doc.layers.push(layer); + session.add_document(doc, None); + session.select_layer(id).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, 0, "spilling: only History States bound History"); + + // The disk "fills up": a size limit below what eviction needs pauses spilling. + spill::set_size_cap(Some(1)); + spill::configure(Some(&dir), 1).unwrap_or_default(); + spill::settle(); + assert!(spill::paused() && !spill::enabled(), "{:?}", spill::stats()); + let limit = session.prefs().performance.history_budget_bytes(); + assert!(limit > 0); + session.execute("edit.fill", json!({"color": "#204060"})).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, limit, "a paused disk must restore History's byte limit"); + assert!(session.active().unwrap().memory_bytes() <= limit); + + // Room again: spilling resumes and the next command lifts the limit again. + spill::set_size_cap(None); + assert!(spill::enabled() && !spill::paused(), "{:?}", spill::stats()); + session.execute("edit.fill", json!({"color": "#406080"})).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, 0); + assert!(session.undo()); + assert!(session.undo()); + + drop(session); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/scratch_release.rs b/crates/engine/tests/scratch_release.rs new file mode 100644 index 0000000000..6976f0cb7f --- /dev/null +++ b/crates/engine/tests/scratch_release.rs @@ -0,0 +1,114 @@ +//! One integration-test process keeps the process-wide scratch pool isolated from parallel unit +//! tests (#10479). +use photocraft_engine::Session; +use photocraft_engine::jobs::Started; +use photocraft_raster::scratch; +use serde_json::json; +use std::sync::{Mutex, PoisonError}; + +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Holds the test lock with the pool's idle release off, so a slow runner can't empty the pool +/// between a command and the check after it; the release comes back on when dropped. +struct Quiet { + _serial: std::sync::MutexGuard<'static, ()>, +} + +impl Quiet { + /// Locks the test process and disables idle pool release for this guard's lifetime. + fn new() -> Self { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + scratch::set_idle_release(false); + Quiet { _serial } + } +} + +impl Drop for Quiet { + fn drop(&mut self) { + scratch::set_idle_release(true); + } +} + +#[test] +/// Verifies closing one or all documents releases their scratch buffers. +fn closing_a_document_leaves_none_of_its_buffers_in_the_pool() { + let _q = Quiet::new(); + for close in ["file.close", "file.closeAll"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + // Dispatch holds the active document across the command; its tiles must not land in + // the pool after the release. + s.execute(close, json!({})).unwrap(); + assert_eq!(scratch::retained_bytes(), 0, "{close}"); + } +} + +#[test] +/// Verifies a command that keeps its document does not release unrelated pooled buffers. +fn commands_that_keep_their_documents_keep_the_pool() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap(); + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + assert!(scratch::retained_bytes() > 0); +} + +#[test] +/// Verifies a cancelled worker releases its document buffers after it exits. +fn a_cancelled_job_of_a_closed_document_returns_its_buffers_once_it_exits() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else { + panic!("Gaussian Blur runs as a background job"); + }; + // Closing cancels the job, but its worker still holds the document until it exits. + s.close(0); + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + s.join_cancelled_jobs(); + assert_eq!(scratch::retained_bytes(), 0); +} + +#[test] +/// Verifies cancelling a job before document close still releases its buffers. +fn a_job_cancelled_before_its_document_closes_still_returns_its_buffers() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let Started::Job(id) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else { + panic!("Gaussian Blur runs as a background job"); + }; + // Cancelled first: the worker is already off the running list when the document closes. + assert!(s.cancel_job(id)); + s.close(0); + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + s.join_cancelled_jobs(); + assert_eq!(scratch::retained_bytes(), 0); +} + +/// Verifies polling alone releases buffers after a closed document's cancelled worker exits. +#[test] +fn a_cancelled_job_of_a_closed_document_is_released_by_polling() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else { + panic!("Gaussian Blur runs as a background job"); + }; + s.close(0); + // Seed after close so the close path cannot satisfy the assertion. + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + assert!(scratch::retained_bytes() > 0); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while scratch::retained_bytes() > 0 && std::time::Instant::now() < deadline { + s.poll_jobs(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(scratch::retained_bytes(), 0); +} diff --git a/crates/engine/tests/smart_source_cache.rs b/crates/engine/tests/smart_source_cache.rs new file mode 100644 index 0000000000..ec28b21292 --- /dev/null +++ b/crates/engine/tests/smart_source_cache.rs @@ -0,0 +1,92 @@ +//! #10030: the decoded-source cache counts the bytes it holds, and Purge All and closing a +//! document with smart objects release them. One integration-test process keeps the global +//! cache isolated from parallel unit tests; the tests here take turns on it. +use std::sync::Mutex; + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; +use photocraft_engine::{Session, file_cmds, smart_cmds}; +use serde_json::json; + +static TURN: Mutex<()> = Mutex::new(()); + +const SIDE: u32 = 256; + +fn png() -> Vec { + let px = (0..SIDE * SIDE).flat_map(|i| [(i % SIDE) as u8, (i / SIDE) as u8, ((i * 7) % 251) as u8, 255]); + let img = Image::from_raw(SIDE, SIDE, ChannelLayout::Rgba, SampleType::U8, px.collect()).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap() +} + +/// Every cache `edit.purge.all` clears, emptied. +fn empty_caches() { + smart_cmds::purge_source_cache(); + smart_cmds::purge_stack_cache(); + photocraft_compose::purge_effect_cache(); + photocraft_compose::masks::purge_cache(); + photocraft_compose::shape_split::purge_cache(); +} + +fn session_with_smart_object(bytes: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": SIDE, "height": SIDE})).unwrap(); + file_cmds::place_bytes(&mut s, "art.png", bytes.to_vec(), None, &json!({})).unwrap(); + s +} + +fn decode(s: &Session, bytes: &[u8]) { + smart_cmds::source_image("art.png", bytes, s.active().unwrap().doc.pixel_format()).unwrap(); +} + +#[test] +fn the_cache_counts_real_tile_bytes() { + let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + empty_caches(); + let bytes = png(); + let s = session_with_smart_object(&bytes); + decode(&s, &bytes); + let held = smart_cmds::source_cache_bytes(); + let px = (SIDE * SIDE) as usize; + assert!(held >= 4 * px && held < 5 * px, "{held} bytes for {px} 8-bit RGBA pixels"); + assert_eq!(smart_cmds::purge_source_cache(), held); + assert_eq!(smart_cmds::source_cache_bytes(), 0); +} + +#[test] +fn purge_all_reports_and_empties_the_source_cache() { + let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + empty_caches(); + let bytes = png(); + let mut s = session_with_smart_object(&bytes); + // Nothing else holds anything to purge: only the source cache enables Purge All. + s.execute("edit.purge.histories", json!({})).unwrap(); + s.clipboard = None; + assert!(!s.is_enabled("edit.purge.all")); + decode(&s, &bytes); + assert!(smart_cmds::source_cache_bytes() > 0); + assert!(s.is_enabled("edit.purge.all")); + let purged = s.execute("edit.purge.all", json!({})).unwrap(); + assert!(purged["purged"].as_array().unwrap().contains(&json!("smart object source cache")), "{purged}"); + assert_eq!(smart_cmds::source_cache_bytes(), 0); + assert!(!s.is_enabled("edit.purge.all")); +} + +#[test] +fn closing_a_document_with_a_smart_object_empties_the_source_cache() { + let _turn = TURN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + empty_caches(); + let bytes = png(); + let mut s = session_with_smart_object(&bytes); + decode(&s, &bytes); + assert!(smart_cmds::source_cache_bytes() > 0); + s.execute("file.close", json!({})).unwrap(); + assert_eq!(smart_cmds::source_cache_bytes(), 0); + + // A document without smart objects leaves it alone. + let mut plain = Session::new(); + plain.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let s = session_with_smart_object(&bytes); + decode(&s, &bytes); + plain.execute("file.close", json!({})).unwrap(); + assert!(smart_cmds::source_cache_bytes() > 0); + empty_caches(); +} diff --git a/crates/engine/tests/smart_source_peak.rs b/crates/engine/tests/smart_source_peak.rs new file mode 100644 index 0000000000..ea74b88e0f --- /dev/null +++ b/crates/engine/tests/smart_source_peak.rs @@ -0,0 +1,50 @@ +//! #10030: decoding a smart object's source (what its first re-render does) composites it in +//! tile-aligned blocks, so the peak memory growth stays a small multiple of the source's pixels, not the 32 +//! bytes a pixel the full-size RGBA buffer and its f32 copy took on top of the decoded document. +//! +//! The one test here runs in its own process: it reads the process's peak RSS (`VmHWM`, Linux +//! only) and nothing else may allocate meanwhile. + +#![cfg(target_os = "linux")] + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +fn peak_rss_bytes() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|l| l.starts_with("VmHWM:"))?; + let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kib * 1024) +} + +const SIDE: u32 = 4096; + +fn png() -> Vec { + let px = (0..SIDE * SIDE).flat_map(|i| { + let (x, y) = (i % SIDE, i / SIDE); + [(x * 5 + y) as u8, ((y * 7) ^ x) as u8, ((x * y) % 251) as u8, 255 - ((x / 64 + y / 64) % 4 * 40) as u8] + }); + let img = Image::from_raw(SIDE, SIDE, ChannelLayout::Rgba, SampleType::U8, px.collect()).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap() +} + +#[test] +fn decoding_a_smart_object_source_stays_within_16_bytes_a_source_pixel() { + let bytes = png(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let fmt = s.active().unwrap().doc.pixel_format(); + // Writing 5 resets the peak RSS to the current RSS; skip where the kernel doesn't allow it. + if std::fs::write("/proc/self/clear_refs", "5").is_err() { + return; + } + let Some(before) = peak_rss_bytes() else { return }; + let img = smart_cmds::source_image("big.png", &bytes, fmt).unwrap(); + let grown = peak_rss_bytes().unwrap().saturating_sub(before); + assert_eq!(img.bounds.width(), SIDE); + + let limit = 16 * u64::from(SIDE) * u64::from(SIDE); + eprintln!("peak RSS growth {} MiB for {} px ({:.1} B/px)", grown >> 20, SIDE * SIDE, grown as f64 / f64::from(SIDE * SIDE)); + assert!(grown < limit, "peak RSS grew by {grown} bytes, over {limit} (16 B/px)"); +} diff --git a/crates/engine/tests/vector_paste_options.rs b/crates/engine/tests/vector_paste_options.rs new file mode 100644 index 0000000000..0482a5ab99 --- /dev/null +++ b/crates/engine/tests/vector_paste_options.rs @@ -0,0 +1,372 @@ +//! Explicit Paste modes preserve source kind instead of silently using the ordinary raster route. + +use photocraft_engine::Session; +use serde_json::json; + +/// This uses only the original public command API, so the pre-fix failure is executable. +#[test] +fn raster_clip_cannot_silently_paste_as_path() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "background": "white"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("edit.paste", json!({"as": "path"})).is_err(), "a raster-only clipboard must refuse Paste As Path"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Fill, Layer, LayerContent, Path, Subpath}; +use photocraft_engine::{actions_cmds::Action, atn_cmds, edit_cmds::Clip}; +use photocraft_geom::{Point, Size}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::Value; + +const SVG: &[u8] = br##""##; + +fn scene(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 16, "depth": depth, "background": "transparent"})).unwrap(); + s.clipboard = Some(Clip::from_svg(SVG).unwrap()); + s +} + +fn output(s: &Session) -> &Layer { + let state = s.active().unwrap(); + state.doc.layer(state.active_layer.unwrap()).unwrap() +} + +fn source(s: &Session) -> &Arc> { + s.clipboard.as_ref().unwrap().svg.as_ref().unwrap() +} + +fn assert_curve(path: &Path) { + assert!( + path.subpaths.iter().flat_map(|p| &p.knots).any(|k| k.anchor == Point::new(6.0, 5.0) && k.out_ctrl == Point::new(7.0, 4.0)), + "original cubic controls must survive canvas-centred placement" + ); +} + +/// Refusal keeps document, selection, revision, redo, and the immutable clipboard source. +fn rejected(s: &mut Session, p: Value) -> String { + let state = s.active().unwrap(); + let before = state.doc.clone(); + let revision = state.revision; + let selection = state.selected_layers.clone(); + let active = state.active_layer; + let history = (state.history.past_len(), state.history.redo_label().map(str::to_owned)); + let clip = s.clipboard.clone().unwrap(); + let error = s.execute("edit.paste", p).unwrap_err().to_string(); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.selected_layers, selection); + assert_eq!(state.active_layer, active); + assert_eq!((state.history.past_len(), state.history.redo_label().map(str::to_owned)), history); + let after = s.clipboard.as_ref().unwrap(); + assert_eq!(after.surface, clip.surface); + assert_eq!(after.bounds, clip.bounds); + match (&after.svg, &clip.svg) { + (Some(a), Some(b)) => assert!(Arc::ptr_eq(a, b)), + (None, None) => {} + _ => panic!("refusal changed source availability"), + } + error +} + +/// All modes use original data, even when the supplied raster mirror is deliberately different. +#[test] +fn original_svg_drives_pixels_curves_and_shape_paint_with_one_undo() { + let mut s = scene(8); + s.edit("Existing work path", |doc, _| { + doc.work_path = Some(Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (2.0, 0.0), (1.0, 2.0)])], ..Default::default() }); + Ok(()) + }) + .unwrap(); + let clip = s.clipboard.as_mut().unwrap(); + clip.surface.fill_rect(clip.bounds, &[0.0, 1.0, 0.0, 1.0]); + let raw = source(&s).clone(); + for mode in ["pixels", "path", "shape"] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let result = s.execute("edit.paste", json!({"as":mode})).unwrap(); + assert_eq!(result["offset"], json!([5, 4])); + match mode { + "pixels" => { + assert!(matches!(output(&s).content, LayerContent::Raster(_))); + let px = output(&s).surface().unwrap().pixel(10, 8); + assert!(px[0] > 0.9 && px[1] < 0.1 && px[3] > 0.9, "Pixels must render red source, not green mirror: {px:?}"); + } + "path" => { + assert_eq!(s.active().unwrap().doc.layers.len(), before.layers.len()); + assert_curve(&s.active().unwrap().doc.paths.last().unwrap().path); + assert_eq!(s.active().unwrap().doc.work_path, before.work_path); + } + _ => { + let LayerContent::Shape(shape) = &output(&s).content else { + panic!("Shape must remain editable"); + }; + assert_curve(&shape.path); + assert!(matches!(&shape.fill,Some(Fill::Solid(c)) if c.c[0]>0.9 && c.c[1]<0.1)); + let stroke = shape.stroke.as_ref().unwrap(); + assert_eq!(stroke.width, 1.0); + assert!(matches!(&stroke.paint,Fill::Solid(c) if c.c[2]>0.9)); + } + } + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + assert!(Arc::ptr_eq(source(&s), &raw)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert!(Arc::ptr_eq(source(&s), &raw)); + } + s.execute("edit.paste", json!({})).unwrap(); + let legacy = output(&s).surface().unwrap().pixel(10, 8); + assert!(legacy[1] > 0.9 && legacy[0] < 0.1, "omission keeps the ordinary raster mirror route"); + s.execute("edit.undo", json!({})).unwrap(); + let replaced = String::from_utf8(SVG.to_vec()).unwrap().replace("C2 0", "C3 0"); + s.clipboard = Some(Clip::from_svg(replaced.as_bytes()).unwrap()); + s.execute("edit.paste", json!({"as":"path"})).unwrap(); + assert!(s.active().unwrap().doc.paths.last().unwrap().path.subpaths.iter().flat_map(|p| &p.knots).any(|k| k.out_ctrl == Point::new(8.0, 4.0))); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.paste", json!({"as":"path","center":[7,6]})).unwrap(); + assert!(s.active().unwrap().doc.paths.last().unwrap().path.subpaths.iter().flat_map(|p| &p.knots).any(|k| k.anchor == Point::new(3.0, 3.0))); +} + +/// Native depth/profile adaptation applies to the real source and editable shape caches. +#[test] +fn source_outputs_follow_destination_depth_and_pixels_forces_raster_from_layer_copy() { + for depth in [8, 16, 32] { + for mode in ["pixels", "shape"] { + let mut s = scene(depth); + s.execute("edit.paste", json!({"as":mode})).unwrap(); + let expected = match depth { + 8 => SampleType::U8, + 16 => SampleType::U16, + _ => SampleType::F32, + }; + assert_eq!(output(&s).surface().unwrap().format().sample, expected); + assert_eq!(output(&s).surface().unwrap().format().mode, ColorMode::Rgb); + } + } + let mut s = scene(8); + s.execute("edit.paste", json!({"as":"shape"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.clipboard.as_ref().unwrap().svg.is_none(), "ordinary Copy replaces vector source"); + assert!(s.clipboard.as_ref().unwrap().layers.is_some()); + rejected(&mut s, json!({"as":"path"})); + s.execute("edit.paste", json!({})).unwrap(); + assert!(matches!(output(&s).content, LayerContent::Shape(_)), "omission keeps whole-layer recreation"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.paste", json!({"as":"pixels"})).unwrap(); + assert!(matches!(output(&s).content, LayerContent::Raster(_))); + s.execute("edit.purge.clipboard", json!({})).unwrap(); + assert!(s.clipboard.is_none()); +} + +/// Malformed modes, unsupported placement/targets, and unsupported source kinds fail atomically. +#[test] +fn invalid_modes_targets_and_capabilities_preserve_source_and_history() { + let mut s = scene(8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + for p in [ + json!({"as":"unknown"}), + json!({"as":true}), + json!({"as":null}), + json!({"as":"shape","center":[0]}), + json!({"as":"shape","center":[0,null]}), + json!({"as":"shape","center":[1e20,0]}), + json!({"as":"shape","target":"mask"}), + json!({"as":"shape","clampToCanvas":false}), + json!({"as":"shape","inPlace":false}), + ] { + rejected(&mut s, p); + } + let multi = br##""##; + s.clipboard = Some(Clip::from_svg(multi).unwrap()); + rejected(&mut s, json!({"as":"shape"})); + s.execute("edit.paste", json!({"as":"path"})).unwrap(); + assert_eq!(s.active().unwrap().doc.paths.last().unwrap().path.subpaths.len(), 2); + let opacity = br##""##; + s.clipboard = Some(Clip::from_svg(opacity).unwrap()); + for mode in ["path", "shape"] { + rejected(&mut s, json!({"as":mode})); + } + let mut empty = Session::new(); + empty.clipboard = Some(Clip::from_svg(SVG).unwrap()); + assert!(empty.execute("edit.paste", json!({"as":"pixels"})).is_err()); + assert!(empty.active().is_none()); + let mut locked = scene(8); + locked + .edit("Locked ancestor", |doc, active| { + let child = Layer::raster("Child", doc.pixel_format()); + *active = Some(child.id); + let mut group = Layer::group("Locked", vec![child]); + group.locks.all = true; + doc.layers.push(group); + Ok(()) + }) + .unwrap(); + for mode in ["pixels", "shape"] { + rejected(&mut locked, json!({"as":mode})); + } + locked.clipboard.as_mut().unwrap().svg = None; + rejected(&mut locked, json!({"as":"pixels"})); +} + +/// Existing geometry and cache limits apply before publication, including intermediate caches. +#[test] +fn source_and_destination_work_limits_refuse_before_editing() { + assert!(Clip::from_svg(b"not svg").is_err()); + assert!(Clip::from_svg(&vec![b' '; 16 * 1024 * 1024 + 1]).is_err()); + assert!(Clip::from_svg(br##""##).is_err()); + let mut s = scene(8); + let mut many = String::from(""); + for _ in 0..1025 { + many.push_str(""); + } + many.push_str(""); + s.clipboard = Some(Clip::from_svg(many.as_bytes()).unwrap()); + assert!(rejected(&mut s, json!({"as":"path"})).contains("4096")); + // The original position covers 36MP in this destination; the final position covers zero. + let oversized = br##""##; + let prepared = photocraft_io::svg::import_svg_bounded("control.svg", oversized, 24_000_000).unwrap(); + assert!(prepared.document.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Shape(_))), "the cache fixture must retain actual geometry"); + let mut doc = photocraft_doc::Document::new("Sparse destination", Size::new(7000, 7000), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::raster("Pixels", doc.pixel_format())); + let mut large = Session::new(); + large.add_document(doc, None); + large.clipboard = Some(Clip::from_svg(oversized).unwrap()); + assert!(rejected(&mut large, json!({"as":"shape","center":[-10000,-10000]})).contains("cache exceeds 24 megapixels")); +} + +fn descriptor(class_id: &str) -> Descriptor { + Descriptor::new("null").with("As ", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(class_id) })) +} + +fn recorded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Vector"), + expanded: false, + warnings: vec![], + encoding: None, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Paste"), + items: vec![AtnItem { event: AtnEvent::Code(*b"past"), dictionary: b"Paste".to_vec(), descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} +fn save(s: &mut Session) -> Vec { + let v = s.execute("actions.save", json!({"set":"Vector"})).unwrap(); + assert_eq!(v["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(v["data"].as_str().unwrap()).unwrap() +} + +/// Raw classes survive save/reimport and drive actual source-backed document output. +#[test] +fn atn_classes_roundtrip_and_play_real_modes() { + for (class_id, mode) in [("Pxel", "pixels"), ("Path", "path"), ("shapeClass", "shape")] { + let mut s = scene(8); + assert_eq!(load(&mut s, &recorded(descriptor(class_id)))["unsupported"], json!([])); + // `As Pxel` is what an ordinary Paste records: it keeps the ordinary route. + let params = if mode == "pixels" { json!({}) } else { json!({"as":mode}) }; + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), params)]); + let saved = save(&mut s); + let file = atn::parse(&saved).unwrap(); + if mode != "pixels" { + assert_eq!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("As "), descriptor(class_id).get("As ")); + } + let imported = atn_cmds::import(&saved).unwrap(); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action":"Paste"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + match mode { + "path" => assert_curve(&s.active().unwrap().doc.paths.last().unwrap().path), + "shape" => assert!(matches!(output(&s).content, LayerContent::Shape(_))), + _ => assert!(matches!(output(&s).content, LayerContent::Raster(_))), + } + } + let mut raster = scene(8); + raster.clipboard.as_mut().unwrap().svg = None; + assert_eq!(load(&mut raster, &recorded(descriptor("Pxel")))["unsupported"], json!([])); + assert_eq!(raster.execute("actions.play", json!({"action":"Paste"})).unwrap()["ran"], 1); + assert!(matches!(output(&raster).content, LayerContent::Raster(_))); +} + +/// Unknown classes/types/placement remain complete kept steps; invalid native options refuse export. +#[test] +fn atn_unknown_forms_are_kept_and_legacy_omission_stays_legacy() { + let mut s = scene(8); + assert_eq!(load(&mut s, &recorded(Descriptor::new("null")))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({}))]); + for d in [ + descriptor("unknownClass"), + Descriptor::new("null").with("As ", D::Text(UnicodeString::new_nul("Path"))), + descriptor("Path").with("inPlace", D::Boolean(true)), + descriptor("Path").with("inPlace", D::Integer(0)), + descriptor("Path").with("AntA", D::Enumerated { type_id: Id::new("Wrong"), value: Id::new("Anno") }), + ] { + let original = recorded(d); + let file = atn::parse(&original).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &original)["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(atn::parse(&save(&mut s)).unwrap().actions[0].items[0].descriptor, file.actions[0].items[0].descriptor); + } + for p in [ + json!({"as":null}), + json!({"as":"smartObject"}), + json!({"as":"shape","center":[1,2]}), + json!({"as":"path","target":"mask"}), + json!({"as":"pixels","inPlace":false}), + ] { + let action = Action::new("Bad", vec![("edit.paste".into(), p)]); + assert!(atn_cmds::export("Vector", &[&action], &[], false).is_err()); + } +} + +fn recorded_pixels_paste(in_place: bool) -> Descriptor { + let d = Descriptor::new("null").with("AntA", D::Enumerated { type_id: Id::new("Annt"), value: Id::new("Anno") }); + let d = if in_place { d.with("inPlace", D::Boolean(true)) } else { d }; + d.with("As ", descriptor("Pxel").get("As ").unwrap().clone()) +} + +/// A recorded ordinary Paste (`AntA Anno`, `As Pxel`) keeps whole-layer recreation of a copied layer. +#[test] +fn recorded_pixels_paste_keeps_the_ordinary_route() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 16, "background": "transparent"})).unwrap(); + s.clipboard = Some(Clip::from_svg(SVG).unwrap()); + s.execute("edit.paste", json!({"as":"shape"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(load(&mut s, &recorded(recorded_pixels_paste(false)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({}))]); + let layers = s.active().unwrap().doc.layers.len(); + let result = s.execute("actions.play", json!({"action":"Paste"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().doc.layers.len() > layers); + assert!(matches!(output(&s).content, LayerContent::Shape(_)), "a copied shape layer pastes as a shape layer"); +} + +/// A recorded Paste in Place (`inPlace true`, `AntA Anno`, `As Pxel`) plays as Paste in Place. +#[test] +fn recorded_pixels_paste_in_place_maps() { + let mut s = Session::new(); + assert_eq!(load(&mut s, &recorded(recorded_pixels_paste(true)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.pasteSpecial.pasteInPlace".into(), json!({}))]); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 4267746633..a04875e443 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -15,6 +15,7 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } +craft-pcraft-format = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 8cd349efff..a454416dff 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -35,7 +35,7 @@ pub mod meta; -use std::io::{self, Write}; +use std::io::{self, Seek, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -56,6 +56,15 @@ pub trait AtomicFs { let _ = progress; self.write_new(tmp, bytes) } + /// Create `tmp` (it must not exist), let `write` fill it as it serializes, and flush it + /// ([`sync_file`]). By default the bytes are collected in memory and handed to + /// [`write_new`](Self::write_new) (test doubles); [`RealFs`] streams them into the file, so the + /// whole file never sits in memory (#6607). + fn write_new_stream(&self, tmp: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + let mut bytes = io::Cursor::new(Vec::new()); + write(&mut bytes)?; + self.write_new(tmp, bytes.get_ref()) + } /// Rename `from` over `to`, replacing it. fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; /// Remove a (temporary) file. @@ -74,6 +83,13 @@ pub trait AtomicFs { } } +/// A stream [`atomic_write_stream`]'s writer fills: the temporary file (or, in tests, memory). +pub trait WriteSeek: Write + Seek {} +impl WriteSeek for T {} + +/// Fills the temporary file of a streaming atomic write, starting at its beginning. +pub type StreamWriter<'a> = dyn FnMut(&mut dyn WriteSeek) -> io::Result<()> + 'a; + /// The real file system. #[derive(Debug, Clone, Copy, Default)] pub struct RealFs; @@ -95,6 +111,16 @@ impl AtomicFs for RealFs { } sync_file(&f) } + fn write_new_stream(&self, tmp: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + let f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; + let mut out = io::BufWriter::new(f); + // `AlreadyExists` means a temporary name collided, which makes the caller retry with + // another name and leave this one alone: from the writer, once the file is ours, it is + // a plain failure (the partial file is removed, the writer is not run again). + write(&mut out).map_err(|e| if e.kind() == io::ErrorKind::AlreadyExists { io::Error::other(e) } else { e })?; + let f = out.into_inner().map_err(io::IntoInnerError::into_error)?; + sync_file(&f) + } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { std::fs::rename(from, to) } @@ -207,6 +233,75 @@ pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, byt /// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]). pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { + replace_with(fs, retry, path, &mut |tmp| fs.write_new_progress(tmp, bytes, progress)) +} + +/// [`atomic_write`] for a file serialized as it is written (#6607): `write` fills the temporary +/// file beside `path` from its start (it may seek back to patch lengths), then the file is +/// flushed and renamed over `path` with [`atomic_write`]'s retries and errors. If `write` fails, +/// the temporary file is removed and `path` is left as it was. `write` runs once (again only if +/// a temporary name collided before anything was written). +pub fn atomic_write_stream(path: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + atomic_write_stream_with(&RealFs, RenameRetry::platform(), path, write) +} + +/// [`atomic_write_stream`] reporting the fraction written to `progress` as it goes: the furthest +/// position reached over `expected_len` (an estimate; capped below 1.0 until `write` is done). +pub fn atomic_write_stream_progress(path: &Path, expected_len: u64, progress: &dyn Fn(f32), write: &mut StreamWriter<'_>) -> io::Result<()> { + atomic_write_stream_with(&RealFs, RenameRetry::platform(), path, &mut |w| { + let mut counted = Progress { w, pos: 0, reached: 0, reported: 0, expected: expected_len.max(1), progress }; + write(&mut counted)?; + progress(1.0); + Ok(()) + }) +} + +/// [`atomic_write_stream`] through an explicit file system and retry policy. +pub fn atomic_write_stream_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + replace_with(fs, retry, path, &mut |tmp| fs.write_new_stream(tmp, write)) +} + +/// A stream reporting how far it has got (see [`atomic_write_stream_progress`]), every +/// [`WRITE_CHUNK`] bytes. +struct Progress<'a, 'p> { + w: &'a mut dyn WriteSeek, + /// Tracked here: asking a buffered file for its position flushes it. + pos: u64, + reached: u64, + reported: u64, + expected: u64, + progress: &'p dyn Fn(f32), +} + +impl Write for Progress<'_, '_> { + fn write(&mut self, b: &[u8]) -> io::Result { + let n = self.w.write(b)?; + self.pos += n as u64; + let at = self.pos; + if at > self.reached { + self.reached = at; + if at - self.reported >= WRITE_CHUNK as u64 { + self.reported = at; + (self.progress)((at as f64 / self.expected as f64).min(0.999) as f32); + } + } + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + self.w.flush() + } +} + +impl Seek for Progress<'_, '_> { + fn seek(&mut self, to: io::SeekFrom) -> io::Result { + self.pos = self.w.seek(to)?; + Ok(self.pos) + } +} + +/// The steps every atomic write shares: `write_tmp` fills a fresh temporary file beside the +/// target, which is then renamed over it. +fn replace_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, write_tmp: &mut dyn FnMut(&Path) -> io::Result<()>) -> io::Result<()> { let target = resolve_symlink(path).map_err(|e| context(e, format!("cannot save {}", path.display())))?; let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; let dir = match target.parent() { @@ -223,7 +318,7 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & let mut tmp = None; for _ in 0..8 { let candidate = dir.join(temp_name(&name.to_string_lossy())); - match fs.write_new_progress(&candidate, bytes, progress) { + match write_tmp(&candidate) { Ok(()) => { tmp = Some(candidate); break; @@ -399,6 +494,40 @@ fn context(e: io::Error, msg: String) -> io::Error { const UNCHANGED: &str = "the original file was not changed"; +/// Marks the error of a Windows replacement that moved the original to its backup name and then +/// could neither complete the save nor put the original back: the original survives only at that +/// backup, so the destination is not the unchanged original and must never be reported as such +/// (#11178). `rename_error` recognizes the marker and passes the error through untouched. +const RECOVERY_UNRESOLVED: &str = "could not restore the original"; + +/// The error for that unresolved Windows replacement state: names the recovery copy and says the +/// destination is currently missing, instead of the usual `UNCHANGED` reassurance (#11178). +/// When the restoration failed because the backup itself is gone (`NotFound`), nothing survives +/// at that name and saying so would be a second false comfort after the lost original. +/// Its kind is deliberately not `PermissionDenied`: the retry loop must not pick a fresh backup +/// name over the unresolved recovery and forget where the original is. +#[cfg(any(windows, test))] +pub(crate) fn unresolved_recovery_error(dest: &Path, completion: &io::Error, restoration: &io::Error, recovery: &Path) -> io::Error { + let msg = if restoration.kind() == io::ErrorKind::NotFound { + format!( + "cannot save {}: the previous version moved to its backup name but {RECOVERY_UNRESOLVED} from it \ + (completing the save failed: {completion}; the backup {} no longer exists: {restoration}), \ + so the destination currently holds no file under that name — check whether it was moved", + dest.display(), + recovery.display() + ) + } else { + format!( + "cannot save {}: the previous version moved to its backup name but {RECOVERY_UNRESOLVED} from it \ + (completing the save failed: {completion}; restoring the backup failed: {restoration}); \ + your document survives at {}, recover it from there before saving again", + dest.display(), + recovery.display() + ) + }; + io::Error::other(msg) +} + /// 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 { @@ -420,6 +549,12 @@ fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error { } fn rename_error(e: io::Error, path: &Path) -> io::Error { + // A Windows replacement that moved the original to its backup and failed to restore it is + // already fully described (with the recovery location); never append the `UNCHANGED` + // reassurance, which would be false in that state (#11178). + if e.to_string().contains(RECOVERY_UNRESOLVED) { + return e; + } let msg = if is_cross_device(&e) { format!("cannot save {}: the temporary file is on a different volume than the destination ({e}); {UNCHANGED}", path.display()) } else if e.kind() == io::ErrorKind::PermissionDenied { diff --git a/crates/format/src/atomic/meta.rs b/crates/format/src/atomic/meta.rs index 10008faa9f..a93a1d124b 100644 --- a/crates/format/src/atomic/meta.rs +++ b/crates/format/src/atomic/meta.rs @@ -12,7 +12,7 @@ //! | extended attributes (Finder tags, `user.*`) | `copy_xattrs` | `copy_xattrs` | — | //! | "downloaded from the internet" | `com.apple.quarantine`, an xattr | — | `Zone.Identifier`, a named stream kept by [`replace_existing`] | //! | access-control list | `copy_acl` (`exacl`) | not carried over (see below) | DACL, kept by [`replace_existing`] | -//! | creation time | not carried over (#6603) | — | kept by [`replace_existing`] | +//! | creation time | `copy_created` (#9388) | — | kept by [`replace_existing`] | //! //! Everything here except [`replace_existing`] is **best effort and never touches the file's //! bytes**: a save that cannot carry a piece of metadata across is still a save, because a @@ -22,7 +22,7 @@ //! //! Not carried over, and why: Linux POSIX ACLs live in `system.posix_acl_access`, which //! `listxattr` does not report, so copying the listed attributes misses them; they would need -//! their own step. macOS creation time needs `setattrlist`, which has no safe wrapper here. +//! their own step. use std::io; use std::path::Path; @@ -59,6 +59,13 @@ pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { #[cfg(not(unix))] let attributes = Ok(()); + // The creation time before the ACL: it needs the destination open for writing, which a + // copied ACL denying its owner write access would refuse. + #[cfg(target_os = "macos")] + let created = copy_created(from, to); + #[cfg(not(target_os = "macos"))] + let created = Ok(()); + #[cfg(target_os = "macos")] let acl = copy_acl(from, to); #[cfg(not(target_os = "macos"))] @@ -67,7 +74,35 @@ pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { // A save over a 0600 file must not widen it to whatever the process default is. let permissions = std::fs::metadata(from).and_then(|m| std::fs::set_permissions(to, m.permissions())); - attributes.and(acl).and(permissions) + attributes.and(acl).and(created).and(permissions) +} + +/// The creation time ("Created" in Finder) of the file being replaced, onto the file replacing +/// it: the rename leaves a new file born at the time of the save, where Photoshop keeps the +/// original's (#9388). The rename keeps the time set here. +#[cfg(target_os = "macos")] +fn copy_created(from: &Path, to: &Path) -> io::Result<()> { + let old = std::fs::File::open(from)?; + let new = std::fs::OpenOptions::new().write(true).open(to)?; + copy_created_file(&old, &new) +} + +/// [`copy_created`] through two open files rather than their paths, for the automation write +/// path's capability-checked handles (see [`copy_xattrs`]). `to` must be open for writing. +/// Nothing to do outside macOS: Windows keeps the time in [`replace_existing`], and Linux has no +/// settable creation time. +pub fn copy_created_file(from: &std::fs::File, to: &std::fs::File) -> io::Result<()> { + #[cfg(target_os = "macos")] + { + use std::os::macos::fs::FileTimesExt; + let created = from.metadata()?.created()?; + to.set_times(std::fs::FileTimes::new().set_created(created)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (from, to); + Ok(()) + } } /// [`copy_metadata`]'s extended-attribute step, by path: open both files and hand the @@ -200,6 +235,40 @@ pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { } } +/// What the recovery ladder leaves behind after a `ReplaceFileW` answered +/// `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` (the destination's old content now lives at the backup +/// name and the replacement still at its temporary name). +#[cfg(any(windows, test))] +#[derive(Debug)] +pub(crate) enum MoveReplacement2 { + /// The replacement was renamed onto the destination: the save completed with its metadata. + Completed, + /// Completion failed but the original was put back on the destination: the save did not + /// happen and the destination still holds the user's file. Carries the completion error. + Restored(io::Error), + /// Neither completion nor restoration succeeded: the original survives only at `recovery` + /// and the destination is currently missing. The caller must report that location and must + /// not claim an unchanged destination (#11178). + Unresolved { completion: io::Error, restoration: io::Error, recovery: std::path::PathBuf }, +} + +/// The recovery ladder of [`MoveReplacement2`], kept free of the Windows call so the branch is +/// testable on every platform as branch coverage (#11178): first finish the save (`complete`, +/// the rename of the replacement onto the destination); if that fails, put the original back +/// (`restore`, the rename of the backup onto the destination). +#[cfg(any(windows, test))] +pub(crate) fn recover_after_1177( + complete: impl FnOnce() -> io::Result<()>, + restore: impl FnOnce() -> io::Result<()>, + recovery: std::path::PathBuf, +) -> MoveReplacement2 { + let Err(completion) = complete() else { return MoveReplacement2::Completed }; + match restore() { + Ok(()) => MoveReplacement2::Restored(completion), + Err(restoration) => MoveReplacement2::Unresolved { completion, restoration, recovery }, + } +} + /// `ReplaceFileW`, with the plain rename as the fallback. See [`replace_existing`]. /// /// **A backup name is not optional.** `ReplaceFile` without one deletes the replaced file before @@ -214,7 +283,8 @@ pub fn replace_existing(from: &Path, to: &Path) -> io::Result<()> { /// replacement keeps its name but has already inherited the destination's streams and /// attributes, and the destination's old content is at the backup name. Finishing the rename /// by hand completes the save *with* its metadata; if even that fails, the backup goes back to -/// the destination so the user's file is where they left it. +/// the destination so the user's file is where they left it — and if even *that* fails, the +/// error names the backup, because the original survives nowhere else (#11178). /// - Every other error leaves the replaced and replacement files under their own names, so a /// plain rename still saves the document — without the metadata, which is where this started. #[cfg(windows)] @@ -238,17 +308,28 @@ fn windows_replace(from: &Path, to: &Path) -> io::Result<()> { remove_backup(backup.as_deref()); Ok(()) } - Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match std::fs::rename(from, to) { - Ok(()) => { - remove_backup(backup.as_deref()); - Ok(()) - } - Err(rename_failed) => { - if let Some(backup) = backup.as_deref() { - let _ = std::fs::rename(backup, to); + Err(e) if e.raw() as i32 == MOVE_REPLACEMENT_2 => match backup.clone() { + // `backup_str` came from `backup`, so it is `Some` here; if that ever changed, leave + // the destination alone rather than guess where the original went (#11178). + None => Err(io::Error::other(format!( + "cannot save {}: the replacement moved the original to an unknown backup name and cannot report where", + to.display() + ))), + Some(backup_path) => match recover_after_1177(|| std::fs::rename(from, to), || std::fs::rename(&backup_path, to), backup_path.clone()) { + MoveReplacement2::Completed => { + remove_backup(Some(&backup_path)); + Ok(()) } - Err(rename_failed) - } + // The original is back where the user left it; the completion error stands, and + // a `PermissionDenied` one may still be retried (the temporary file is intact). + MoveReplacement2::Restored(completion) => Err(completion), + // Unresolved: the destination is missing and the original survives only at the + // backup. Report the recovery location and stop the retry loop from picking a + // fresh backup name over it (#11178). + MoveReplacement2::Unresolved { completion, restoration, recovery } => { + Err(super::unresolved_recovery_error(to, &completion, &restoration, &recovery)) + } + }, }, Err(_) => { remove_backup(backup.as_deref()); diff --git a/crates/format/src/atomic/meta/tests.rs b/crates/format/src/atomic/meta/tests.rs index 834e87e66e..b9571ee93b 100644 --- a/crates/format/src/atomic/meta/tests.rs +++ b/crates/format/src/atomic/meta/tests.rs @@ -4,7 +4,9 @@ //! [`crate::atomic::atomic_write`] and can only run on Windows, so they are `#[cfg(windows)]` //! and are covered by the Windows CI job, not by a developer on a Mac. +use super::{MoveReplacement2, recover_after_1177}; use crate::atomic::atomic_write; +use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -308,3 +310,82 @@ mod windows { assert_no_temp(&d.0); } } + +// ---------------------------------------------------------------- MOVE_REPLACEMENT_2 ladder (#11178) +// +// The Windows `ReplaceFileW` cannot be made to return `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` on +// demand, so these exercise the recovery ladder with injected completion/restoration outcomes on +// every platform: branch coverage of the ladder, not a reproduction of a real Windows incident. + +/// The ladder finishing the rename completes the save; the restoration is never attempted. +#[test] +fn the_move_replacement_2_ladder_completes_the_save_when_the_rename_works() { + let r = recover_after_1177(|| Ok(()), || panic!("restoration must not run after a completed save"), PathBuf::from("doc.bak")); + assert!(matches!(r, MoveReplacement2::Completed)); +} + +/// Completion fails but the original is restored to the destination: the caller keeps the plain +/// completion error (which truthfully says the destination still holds the original), not a +/// recovery report. +#[test] +fn the_move_replacement_2_ladder_restores_the_original_when_completion_fails() { + let r = recover_after_1177(|| Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked")), || Ok(()), PathBuf::from("doc.bak")); + match r { + MoveReplacement2::Restored(e) => assert_eq!(e.kind(), io::ErrorKind::PermissionDenied, "the completion error stands"), + other => panic!("expected Restored, got {other:?}"), + } +} + +/// Completion fails and the restoration fails too: the ladder must surface the unresolved state +/// with the exact recovery location, so the caller can point the user at the surviving original +/// instead of falsely promising an unchanged destination (#11178). +#[test] +fn the_move_replacement_2_ladder_keeps_the_recovery_location_when_both_renames_fail() { + let recovery = PathBuf::from("/tmp/doc.bak.pcsave.tmp"); + let r = recover_after_1177( + || Err(io::Error::new(io::ErrorKind::PermissionDenied, "locked")), + || Err(io::Error::new(io::ErrorKind::PermissionDenied, "still locked")), + recovery.clone(), + ); + match r { + MoveReplacement2::Unresolved { completion, restoration, recovery: got } => { + assert_eq!(got, recovery, "the exact backup path is carried out"); + assert!(completion.to_string().contains("locked") && restoration.to_string().contains("locked"), "{completion}; {restoration}"); + } + other => panic!("expected Unresolved, got {other:?}"), + } +} + +/// The unresolved error names the recovery location and never carries the `UNCHANGED` +/// reassurance, whose kind is deliberately not retryable so a permission retry cannot forget it. +#[test] +fn the_unresolved_recovery_error_names_the_backup_and_is_not_retried() { + let e = crate::atomic::unresolved_recovery_error( + Path::new("/docs/report.psd"), + &io::Error::new(io::ErrorKind::PermissionDenied, "locked"), + &io::Error::new(io::ErrorKind::PermissionDenied, "still locked"), + Path::new("/docs/.report.psd.9.pcsave.tmp"), + ); + let msg = e.to_string(); + assert!(msg.contains(".report.psd.9.pcsave.tmp"), "the backup path is named: {msg}"); + assert!(msg.contains("recover"), "the user is told to recover the document: {msg}"); + assert!(!msg.contains("the original file was not changed"), "no false reassurance: {msg}"); + assert_ne!(e.kind(), io::ErrorKind::PermissionDenied, "the retry loop must stop, not pick a fresh backup name over the unresolved recovery"); +} + +/// When the restoration failed because the backup itself is gone, the error must not promise a +/// recovery copy that does not exist (#11178). +#[test] +fn a_vanished_backup_is_reported_as_gone_rather_than_as_a_recovery_copy() { + let e = crate::atomic::unresolved_recovery_error( + Path::new("/docs/report.psd"), + &io::Error::new(io::ErrorKind::PermissionDenied, "locked"), + &io::Error::from_raw_os_error(2), // `ENOENT` / `ERROR_FILE_NOT_FOUND`: the backup is gone. + Path::new("/docs/.report.psd.9.pcsave.tmp"), + ); + let msg = e.to_string(); + assert!(msg.contains(".report.psd.9.pcsave.tmp") && msg.contains("no longer exists"), "the vanished backup is named: {msg}"); + assert!(msg.contains("no file under that name"), "{msg}"); + assert!(!msg.contains("survives at"), "no recovery promise for a vanished backup: {msg}"); + assert!(!msg.contains("the original file was not changed"), "{msg}"); +} diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index bc26ae6d0a..ef2a5475f7 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -148,6 +148,119 @@ fn reports_write_progress() { assert_no_temp(&d.0); } +/// Writes "head", a 4-byte placeholder, `len` bytes, then patches the placeholder by seeking back. +fn patched_writer(len: usize) -> impl FnMut(&mut dyn WriteSeek) -> io::Result<()> { + move |w| { + w.write_all(b"head")?; + let at = w.stream_position()?; + w.write_all(&[0; 4])?; + w.write_all(&vec![9u8; len])?; + w.seek(io::SeekFrom::Start(at))?; + w.write_all(&(len as u32).to_be_bytes())?; + w.seek(io::SeekFrom::End(0))?; + Ok(()) + } +} + +fn patched_bytes(len: usize) -> Vec { + let mut v = b"head".to_vec(); + v.extend_from_slice(&(len as u32).to_be_bytes()); + v.extend(std::iter::repeat_n(9u8, len)); + v +} + +/// #6607: a streamed save replaces the file with what its writer wrote, seeks included, and leaves +/// no temporary file. +#[test] +fn streamed_write_replaces_and_creates() { + let d = TempDir::new("stream"); + let p = d.0.join("doc.psd"); + atomic_write_stream(&p, &mut patched_writer(3)).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), patched_bytes(3)); + atomic_write_stream(&p, &mut patched_writer(100_000)).unwrap(); + assert!(std::fs::read(&p).unwrap() == patched_bytes(100_000)); + assert_no_temp(&d.0); +} + +/// #6607: a writer that fails halfway (a serializer error, a full disk) leaves the original as it +/// was, with the same message as a buffered save. +#[test] +fn streamed_write_failure_halfway_keeps_original() { + let d = TempDir::new("stream-fail"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let e = atomic_write_stream(&p, &mut |w| { + w.write_all(&[1; 70_000])?; + Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device")) + }) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::StorageFull); + assert!(e.to_string().contains("the disk is full") && e.to_string().contains("not changed"), "{e}"); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} + +/// A writer failing with `AlreadyExists` (from whatever it reads) is a failed save, not a +/// temporary name collision: it runs once and its partial file is removed. +#[test] +fn streamed_write_failing_with_already_exists_runs_once() { + let d = TempDir::new("stream-exists"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let runs = Cell::new(0); + let e = atomic_write_stream(&p, &mut |w| { + runs.set(runs.get() + 1); + w.write_all(&[1; 70_000])?; + Err(io::Error::new(io::ErrorKind::AlreadyExists, "injected")) + }) + .unwrap_err(); + assert_eq!(runs.get(), 1); + assert!(e.to_string().contains("not changed"), "{e}"); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} + +/// A streamed save renames like a buffered one: retried while locked, the original kept when the +/// lock outlasts the retries. +#[test] +fn streamed_write_retries_a_locked_rename() { + let d = TempDir::new("stream-locked"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() }; + atomic_write_stream_with(&fs, RenameRetry { attempts: 3, first_delay_ms: 1 }, &p, &mut patched_writer(10)).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), patched_bytes(10)); + assert_eq!(fs.renames.get(), 3); + let fs = Faulty { locked_renames: Cell::new(10), ..Default::default() }; + let e = atomic_write_stream_with(&fs, RenameRetry { attempts: 1, first_delay_ms: 1 }, &p, &mut patched_writer(20)).unwrap_err(); + assert!(e.to_string().contains("locked"), "{e}"); + assert_eq!(std::fs::read(&p).unwrap(), patched_bytes(10)); + assert_no_temp(&d.0); +} + +/// A streamed save reports the furthest position over the expected length, chunk by chunk, and +/// 1.0 once the writer is done. +#[test] +fn streamed_write_reports_progress() { + let d = TempDir::new("stream-progress"); + let p = d.0.join("big.psd"); + let len = WRITE_CHUNK * 2 + 123; + let seen = std::sync::Mutex::new(Vec::new()); + let mut write = |w: &mut dyn WriteSeek| { + for chunk in patched_bytes(len).chunks(WRITE_CHUNK / 4) { + w.write_all(chunk)?; + } + Ok(()) + }; + atomic_write_stream_progress(&p, len as u64 + 8, &|f| seen.lock().unwrap().push(f), &mut write).unwrap(); + let seen = seen.into_inner().unwrap(); + assert_eq!(seen.len(), 3, "two chunks, then done: {seen:?}"); + assert!(seen.windows(2).all(|w| w[0] < w[1]), "monotonic: {seen:?}"); + assert_eq!(seen.last().copied(), Some(1.0)); + assert!(std::fs::read(&p).unwrap() == patched_bytes(len)); + assert_no_temp(&d.0); +} + #[test] fn creates_missing_parent_folders() { let d = TempDir::new("parent"); @@ -185,6 +298,33 @@ fn rename_failure_keeps_original() { assert_no_temp(&d.0); } +/// The unresolved Windows recovery state (branch coverage, #11178): the error names the backup +/// where the original survives, `rename_error` passes it through without the false `UNCHANGED` +/// promise, and `retry_rename` does not retry it into a fresh backup name that would forget it. +#[test] +fn an_unresolved_recovery_reports_the_backup_and_is_not_retried() { + let unresolved = || { + unresolved_recovery_error( + Path::new("/docs/report.psd"), + &io::Error::new(io::ErrorKind::PermissionDenied, "completion locked"), + &io::Error::new(io::ErrorKind::PermissionDenied, "restoration locked"), + Path::new("/docs/.report.psd.7.pcsave.tmp"), + ) + }; + let reported = rename_error(unresolved(), Path::new("/docs/report.psd")); + let msg = reported.to_string(); + assert!(msg.contains(".report.psd.7.pcsave.tmp") && msg.contains("recover"), "the recovery location survives reporting: {msg}"); + assert!(!msg.contains(UNCHANGED), "the destination is not the unchanged original in this state: {msg}"); + let calls = Cell::new(0); + let e = retry_rename(RenameRetry { attempts: 7, first_delay_ms: 0 }, || { + calls.set(calls.get() + 1); + Err(unresolved()) + }) + .unwrap_err(); + assert_eq!(calls.get(), 1, "the retry loop must not run again over an unresolved recovery"); + assert!(!e.to_string().contains(UNCHANGED), "{e}"); +} + #[test] fn cross_device_rename_errors_clearly_and_never_writes_in_place() { let d = TempDir::new("exdev"); @@ -481,3 +621,45 @@ fn a_directory_flush_the_file_system_cannot_do_still_saves() { let fs = Faulty { fail_sync_dir: Some(Ok(5)), ..Default::default() }; assert!(atomic_write_with(&fs, NO_RETRY, &p, b"newer").is_err()); } + +/// #9388: on macOS a save keeps the replaced file's creation time ("Created" in Finder), as +/// Photoshop's does; the rename would otherwise leave a file born at the time of the save. +#[cfg(target_os = "macos")] +#[test] +fn keeps_the_original_creation_time() { + use std::os::macos::fs::FileTimesExt; + let d = TempDir::new("created"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + // A creation time well in the past, so a reset to "now" can't pass for it. + let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000); + std::fs::OpenOptions::new().write(true).open(&p).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap(); + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), born); + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), born, "the creation time survives the save"); + assert_no_temp(&d.0); +} + +/// #9401 review: a replaced file whose ACL denies its owner writing still keeps its creation +/// time. The ACL lands on the temporary file too, so the time has to be set before it is. +#[cfg(target_os = "macos")] +#[test] +fn keeps_the_creation_time_under_a_deny_write_acl() { + use std::os::macos::fs::FileTimesExt; + let d = TempDir::new("created-acl"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000); + std::fs::OpenOptions::new().write(true).open(&p).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap(); + let Ok(user) = std::env::var("USER") else { return }; + let deny = exacl::AclEntry::deny_user(&user, exacl::Perm::WRITE, None); + if exacl::setfacl(&[&p], &[deny], None).is_err() { + return; // a file system without ACLs: nothing to assert + } + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&p).unwrap().created().unwrap(), born, "the creation time survives a deny-write ACL"); + let _ = exacl::setfacl(&[&p], &[], None); + assert_no_temp(&d.0); +} diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 7b379451dd..2ab8676c66 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -451,7 +451,7 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) { } // Anything at the sidecar's path (even a broken one) keeps the bundle. let has_sidecar = std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok(); - let parts = [path.clone(), path.join("tiles"), path.join("blobs")]; + let parts = [path.clone(), path.join(craft_pcraft_format::TILES_DIR), path.join(craft_pcraft_format::BLOBS_DIR)]; // A committed bundle is recoverable work: keep exactly what `list_recovery` lists. if !has_sidecar && sidecarless_entry(recovery_dir, &path).is_none() && parts.iter().all(|p| untouched_for(p, min_age, true)) { let _ = std::fs::remove_dir_all(&path); diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index a093bd10e0..8ea8c5cc3e 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -13,7 +13,7 @@ use photocraft_raster::{Surface, Tile, decode_pixel, encode_pixel}; use crate::manifest::{ ChannelM, CompAppearanceM, CompStateM, ContentM, DocM, EffectsM, FillCacheM, Hash, LayerCompM, LayerM, MaskM, MetadataM, NamedPathM, PatternM, - SmartSourceM, SurfaceM, TileRef, + SmartSourceM, SurfaceM, TileRef, VectorPreviewM, }; use crate::{FormatError, Result}; @@ -45,6 +45,8 @@ pub(crate) trait Fetch { /// Canonical (little-endian) tile bytes of the given expected length. fn tile(&mut self, hash: &str, len: usize) -> Result>; fn blob(&mut self, hash: &str) -> Result>>; + /// Fetch an optional display payload without allocating beyond its stricter limit. + fn blob_bounded(&mut self, hash: &str, limit: usize) -> Result>>; } pub(crate) fn hex(bytes: &[u8]) -> String { @@ -79,9 +81,7 @@ fn key4(s: &str) -> Result<[u8; 4]> { unhex(s)?.try_into().map_err(|_| FormatError::corrupt(format!("bad 4-byte key `{s}`"))) } -pub(crate) fn is_valid_hash(h: &str) -> bool { - h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) -} +pub(crate) use craft_pcraft_format::is_valid_hash; /// Convert native-endian sample bytes to little-endian in place (and back: /// the swap is an involution). @@ -108,7 +108,7 @@ fn surface_m(s: &Surface, sink: &mut dyn Sink) -> SurfaceM { let mut dp = vec![0u8; f.bytes_per_pixel()]; encode_pixel(&f, &s.default_pixel(), &mut dp); swap_to_le(&mut dp, f.sample); - SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef { tx: c.tx, ty: c.ty, hash: sink.tile(f, t) }).collect() } + SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef::new(c.tx, c.ty, sink.tile(f, t))).collect() } } fn opt_blob(b: &Option>>, sink: &mut dyn Sink) -> Option { @@ -157,7 +157,16 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { psd_raw: opt_blob(&s.psd_raw, sink), path: s.path.clone(), stroke: s.stroke.clone(), - live: s.live.clone(), + // Older PhotoCraft versions cannot deserialize ArrowLine. + // The saved path preserves the arrow's visual appearance. + live: match &s.live { + Some(photocraft_doc::LiveShape::ArrowLine { .. }) => None, + other => other.clone(), + }, + arrow_live: match &s.live { + Some(arrow @ photocraft_doc::LiveShape::ArrowLine { .. }) => Some(arrow.clone()), + _ => None, + }, density: s.density, feather: s.feather, }, @@ -167,6 +176,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { SmartSource::Embedded { file_name, bytes } => SmartSourceM::Embedded { file_name: file_name.clone(), blob: sink.blob(bytes) }, SmartSource::Linked { path } => SmartSourceM::Linked { path: path.clone() }, }, + vector_preview: s.vector_preview.as_ref().map(|preview| VectorPreviewM { source_hash: preview.source_hash, svg: sink.blob(&preview.svg) }), transform: s.transform, smart_filters: s.smart_filters.clone(), cache: s.cache.as_ref().map(|c| surface_m(c, sink)), @@ -357,7 +367,7 @@ impl Loader<'_> { if let Some(tile) = self.tiles.get(&f.sample).and_then(|m| m.get(&t.hash)) { // One hash names one byte string, so a cached tile of another length means // the same hash was referenced from formats of different pixel sizes. - if tile.bytes().len() != len { + if tile.len() != len { return Err(FormatError::corrupt(format!("tile {} is referenced with different sizes", t.hash))); } s.put_tiles([(c, tile.clone())]); @@ -477,43 +487,62 @@ impl Loader<'_> { warp: warp.clone(), }) } - ContentM::Shape { fill, cache, psd_raw, path, stroke, live, density, feather } => LayerContent::Shape(ShapeLayer { + ContentM::Shape { fill, cache, psd_raw, path, stroke, live, arrow_live, density, feather } => LayerContent::Shape(ShapeLayer { path: path.clone(), fill: fill.clone(), stroke: stroke.clone(), - live: live.clone(), + live: arrow_live.as_ref().filter(|shape| matches!(shape, photocraft_doc::LiveShape::ArrowLine { .. })).cloned().or_else(|| 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 { - contents_id: self.contents_id(*contents_id, source), - source: match source { - SmartSourceM::Embedded { file_name, blob } => SmartSource::Embedded { file_name: file_name.clone(), bytes: self.fetch.blob(blob)? }, - SmartSourceM::Linked { path } => SmartSource::Linked { path: path.clone() }, - }, - transform: *transform, - smart_filters: smart_filters.clone(), - cache: self.opt_surface(cache)?, - psd_raw: self.opt_blob(psd_raw)?, - filters_enabled: *filters_enabled, - filter_mask: match filter_mask { - Some(mm) => Some(LayerMask { - surface: self.surface(&mm.surface)?, - enabled: mm.enabled, - linked: mm.linked, - density: mm.density, - feather: mm.feather, - }), - None => None, - }, - warp: warp.clone().filter(|w| w.mesh.as_ref().is_none_or(|m| m.is_valid())), - stack_mode: *stack_mode, - perspective: perspective.filter(|p| p.iter().all(|v| v.is_finite())), - }) - } + ContentM::Smart { + contents_id, + source, + vector_preview, + transform, + smart_filters, + cache, + psd_raw, + filters_enabled, + filter_mask, + warp, + stack_mode, + perspective, + } => LayerContent::Smart(SmartObject { + contents_id: self.contents_id(*contents_id, source), + source: match source { + SmartSourceM::Embedded { file_name, blob } => SmartSource::Embedded { file_name: file_name.clone(), bytes: self.fetch.blob(blob)? }, + SmartSourceM::Linked { path } => SmartSource::Linked { path: path.clone() }, + }, + vector_preview: match vector_preview { + // A display-only preview must not prevent recovery of the native source + // and cached pixels if its optional blob is missing or damaged. + Some(preview) => { + self.fetch.blob_bounded(&preview.svg, 16 << 20).ok().map(|svg| photocraft_doc::VectorPreview { source_hash: preview.source_hash, svg }) + } + None => None, + }, + transform: *transform, + smart_filters: smart_filters.clone(), + cache: self.opt_surface(cache)?, + psd_raw: self.opt_blob(psd_raw)?, + filters_enabled: *filters_enabled, + filter_mask: match filter_mask { + Some(mm) => Some(LayerMask { + surface: self.surface(&mm.surface)?, + enabled: mm.enabled, + linked: mm.linked, + density: mm.density, + feather: mm.feather, + }), + None => None, + }, + warp: warp.clone().filter(|w| w.mesh.as_ref().is_none_or(|m| m.is_valid())), + stack_mode: *stack_mode, + perspective: perspective.filter(|p| p.iter().all(|v| v.is_finite())), + }), }; let mask = match &m.mask { Some(mm) => { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 992e925ae9..e759e99ae9 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) +//! composite/full.png optional flattened composite at the canvas size (caller-rendered) //! save.lock directory bundles only: held while a save runs (empty) //! ``` //! @@ -40,7 +41,7 @@ use std::path::Path; use photocraft_doc::Document; use photocraft_raster::Rgba8Image; -pub use atomic::{atomic_write, atomic_write_progress}; +pub use atomic::{atomic_write, atomic_write_progress, atomic_write_stream, atomic_write_stream_progress}; #[cfg(not(target_arch = "wasm32"))] pub use autosave::RecoveryStore; pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, recover}; @@ -50,7 +51,7 @@ pub use read::read_file; pub use store::{ByteSource, DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats, read_entry}; /// File extension of the native format. -pub const EXTENSION: &str = "pcraft"; +pub use craft_pcraft_format::EXTENSION; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -76,6 +77,20 @@ impl FormatError { } } +impl From for FormatError { + fn from(e: craft_pcraft_format::Error) -> Self { + use craft_pcraft_format::Error; + match e { + Error::Corrupt(m) => FormatError::Corrupt(m), + Error::Unsupported(m) => FormatError::Unsupported(m), + Error::TooNew { found, supported, .. } => FormatError::TooNew { found, supported }, + Error::LimitExceeded(m) => FormatError::LimitExceeded(m), + Error::Json(e) => FormatError::Json(e), + other => FormatError::Corrupt(other.to_string()), + } + } +} + pub type Result = std::result::Result; /// Previews to embed. The format crate cannot render (it sits beside the @@ -84,6 +99,12 @@ pub type Result = std::result::Result; pub struct SaveOptions { pub thumbnail: Option, pub composite: Option, + /// The flattened document at its canvas size, for other apps that read `.pcraft` files. A ZIP + /// save leaves it out when it would take the archive past 4 GiB or 65,535 entries, and every + /// save leaves it out when it cannot be encoded. + pub full_composite: Option, + /// The ICC profile of `full_composite`'s pixels, embedded in its PNG. + pub full_composite_icc: Option>, } /// Limits applied while loading untrusted bundles. @@ -117,12 +138,29 @@ pub const MAX_CANVAS_SIDE: u32 = 300_000; /// canvas with no content allocates nothing for its area. pub const MAX_CANVAS_PIXELS: u64 = MAX_CANVAS_SIDE as u64 * MAX_CANVAS_SIDE as u64; +/// Byte budgets of a load in the browser build. A wasm32 instance has at most 4 GiB of linear +/// memory, which the editor, the file's own bytes and the loaded document's composites share, so +/// the desktop defaults (1 GiB a blob, 16 GiB in all) would let a small, highly compressible +/// bundle exhaust it before any budget is reached. +#[cfg(target_arch = "wasm32")] +pub const BROWSER_MAX_BLOB_BYTES: usize = 512 << 20; +/// See [`BROWSER_MAX_BLOB_BYTES`]. +#[cfg(target_arch = "wasm32")] +pub const BROWSER_MAX_TOTAL_BYTES: u64 = 1 << 30; + impl Default for LoadOptions { fn default() -> Self { + #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] + let mut limits = craft_pcraft_format::Limits::default(); + #[cfg(target_arch = "wasm32")] + { + limits.max_blob_bytes = limits.max_blob_bytes.min(BROWSER_MAX_BLOB_BYTES); + limits.max_total_bytes = limits.max_total_bytes.min(BROWSER_MAX_TOTAL_BYTES); + } LoadOptions { - max_manifest_bytes: 256 << 20, - max_blob_bytes: 1 << 30, - max_total_bytes: 16 << 30, + max_manifest_bytes: limits.max_manifest_bytes, + max_blob_bytes: limits.max_blob_bytes, + max_total_bytes: limits.max_total_bytes, preserve_ids: true, max_canvas_side: MAX_CANVAS_SIDE, max_canvas_pixels: MAX_CANVAS_PIXELS, @@ -197,3 +235,14 @@ pub fn read_thumbnail(bytes: &[u8]) -> Result>> { None => Ok(None), } } + +/// The embedded full-size composite (PNG bytes): `None` when the manifest has no +/// `full_composite` member. A member that names any entry other than `composite/full.png` is +/// refused. +pub fn read_full_composite(bytes: &[u8]) -> Result>> { + let src = store::ZipSource::new(bytes)?; + match store::read_full_composite_entry(&src, &LoadOptions::default())? { + Some(p) => Ok(Some(src.get(p, bytes.len())?)), + None => Ok(None), + } +} diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 0b1e08b706..5d725b6bf2 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -14,24 +14,13 @@ use photocraft_doc::{ use photocraft_geom::{Affine, Size}; use serde::{Deserialize, Serialize}; -/// Current manifest version written by this build. -pub const FORMAT_VERSION: u32 = 1; +pub use craft_pcraft_format::{FORMAT_VERSION, Hash, TileRef}; -/// Hex-encoded BLAKE3 hash (64 chars). -pub type Hash = String; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Manifest { - pub format_version: u32, - /// Free-form writer identification, e.g. `photocraft-format 0.1.0`. - pub generator: String, - pub document: DocM, - /// Optional previews present in the bundle. - #[serde(default)] - pub thumbnail: Option, - #[serde(default)] - pub composite: Option, -} +/// The manifest: craft-pcraft-format's envelope (`format_version`, `generator`, the optional +/// `thumbnail` and `composite` previews) around the document [`DocM`]. A save with the full-size +/// composite adds a `full_composite` member after the envelope; [`crate::read_full_composite`] +/// reads it. +pub type Manifest = craft_pcraft_format::Manifest; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DocM { @@ -154,13 +143,6 @@ pub struct NamedPathM { pub psd_raw: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TileRef { - pub tx: i32, - pub ty: i32, - pub hash: Hash, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SurfaceM { pub format: PixelFormat, @@ -300,6 +282,10 @@ pub enum ContentM { stroke: Option, #[serde(default)] live: Option, + /// Arrow parameters stored separately for backward compatibility. + /// Older PhotoCraft versions ignore this optional field. + #[serde(default, skip_serializing_if = "Option::is_none")] + arrow_live: Option, /// The path's density as the fill's mask; older files have none (100 %). #[serde(default = "full_density")] density: f32, @@ -312,6 +298,8 @@ pub enum ContentM { #[serde(default)] contents_id: Option, source: SmartSourceM, + #[serde(default, skip_serializing_if = "Option::is_none")] + vector_preview: Option, transform: Affine, smart_filters: Vec, cache: Option, @@ -330,6 +318,13 @@ pub enum ContentM { }, } +/// The native source remains separate from this optional display-only SVG blob. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VectorPreviewM { + pub source_hash: [u8; 32], + pub svg: Hash, +} + fn full_density() -> f32 { 1.0 } diff --git a/crates/format/src/migrate.rs b/crates/format/src/migrate.rs index d24581275e..429b5bc281 100644 --- a/crates/format/src/migrate.rs +++ b/crates/format/src/migrate.rs @@ -1,43 +1,29 @@ //! Manifest migrations. Each step upgrades the raw JSON from version `n` to //! `n + 1`, so old bundles keep loading as the schema evolves. -use serde_json::Value; +use crate::FormatError; -use crate::manifest::FORMAT_VERSION; -use crate::{FormatError, Result}; - -pub(crate) type Step = fn(&mut Value) -> Result<()>; +pub(crate) type Step = craft_pcraft_format::Step; /// `STEPS[i]` upgrades version `i + 1` to `i + 2`. Empty while version 1 is -/// the only version. +/// the only version. `craft_pcraft_format::read_manifest_with` runs them on load. pub(crate) const STEPS: &[Step] = &[]; -/// Upgrade `v` in place to `target` using `steps`. -pub(crate) fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result { - let found = v.get("format_version").and_then(Value::as_u64).ok_or_else(|| FormatError::corrupt("manifest has no format_version"))?; - let found = u32::try_from(found).map_err(|_| FormatError::corrupt("format_version out of range"))?; - if found == 0 { - return Err(FormatError::corrupt("format_version 0 is invalid")); - } - if found > target { - return Err(FormatError::TooNew { found, supported: target }); - } - for ver in found..target { - let step = steps.get(ver as usize - 1).ok_or_else(|| FormatError::Unsupported(format!("no migration from format {ver}")))?; - step(v)?; - v["format_version"] = Value::from(ver + 1); - } - Ok(found) -} - -pub(crate) fn migrate(v: &mut Value) -> Result { - migrate_with(v, STEPS, FORMAT_VERSION) -} - #[cfg(test)] mod tests { use super::*; - use serde_json::json; + use crate::Result; + use crate::manifest::FORMAT_VERSION; + use serde_json::{Value, json}; + + /// Upgrade `v` in place to `target` using `steps`, with craft-pcraft-format's runner. + fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result { + craft_pcraft_format::migrate(v, steps, target) + } + + fn migrate(v: &mut Value) -> Result { + migrate_with(v, STEPS, FORMAT_VERSION) + } fn rename_title(v: &mut Value) -> Result<()> { let t = v["document"]["title"].take(); diff --git a/crates/format/src/read.rs b/crates/format/src/read.rs index 8c72a20406..b71e3c8030 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -6,7 +6,10 @@ //! to 4 GiB in one `ReadFile`, and some file systems (exFAT, network redirectors) reject a read //! that large with `ERROR_INVALID_PARAMETER` ("The parameter is incorrect", #375). //! - **A clear out-of-memory error.** The buffer is reserved up front with `try_reserve_exact`, -//! and a file larger than the memory available says how much it needed. +//! and a file larger than the memory available says how much it needed. A file that grows past +//! its stat length while being read grows the buffer with `try_reserve` too. +//! - **A total limit.** No read returns more than [`MAX_OPEN_BYTES`], so a file that keeps growing +//! while it is read cannot keep an open or a save from finishing. //! //! On `wasm32` there is no file system and every call returns the `std` "unsupported" error. @@ -16,13 +19,24 @@ use std::path::Path; /// Largest single read: big enough that a 5 GB file takes under a hundred reads. pub const READ_CHUNK: usize = 64 << 20; -/// Read all of `path`, in reads of at most [`READ_CHUNK`] bytes. +/// Most bytes [`read_file`] and [`read_all`] return. Far above any document memory can hold (a +/// PSB can run to tens of gigabytes), it only bounds a file that never stops growing; a file over it +/// is a `FileTooLarge` error, never a truncated read. +pub const MAX_OPEN_BYTES: u64 = 1 << 38; + +/// Read all of `path`, in reads of at most [`READ_CHUNK`] bytes and at most [`MAX_OPEN_BYTES`] in +/// all. Anything but a regular file is an `InvalidInput` error. pub fn read_file(path: &Path) -> io::Result> { read_file_with(path, READ_CHUNK) } /// [`read_file`] with a chosen read size (tests use small ones to exercise the loop). pub fn read_file_with(path: &Path, chunk: usize) -> io::Result> { + read_path(path, chunk, MAX_OPEN_BYTES) +} + +/// [`read_file_with`] with a chosen total limit (tests use small ones). +fn read_path(path: &Path, chunk: usize, max: u64) -> io::Result> { #[cfg(unix)] let (mut f, metadata) = { use rustix::fs::{Mode, OFlags, open}; @@ -45,31 +59,73 @@ pub fn read_file_with(path: &Path, chunk: usize) -> io::Result> { (f, metadata) }; let len = metadata.len(); - read_all_with(&mut f, len, chunk) + read_all_with(&mut f, len, chunk, max) } /// Read `reader` to its end in reads of at most [`READ_CHUNK`] bytes, for an already open file /// (a capability-scoped handle, say). `size_hint` (the file's length) sizes the buffer up front. +/// More than [`MAX_OPEN_BYTES`] in all is a `FileTooLarge` error. pub fn read_all(reader: &mut impl Read, size_hint: u64) -> io::Result> { - read_all_with(reader, size_hint, READ_CHUNK) + read_all_with(reader, size_hint, READ_CHUNK, MAX_OPEN_BYTES) } -fn read_all_with(reader: &mut impl Read, size_hint: u64, chunk: usize) -> io::Result> { - // The hint only sizes the buffer: a file that grows or shrinks while being read is still read - // to its end. +/// Read `reader` to its end in reads of at most `chunk` bytes, failing once it has given more +/// than `max` bytes. The hint only sizes the buffer: a file that grows or shrinks while being +/// read is still read to its end, within `max`. Past the hint the buffer grows only after a probe +/// read has found more bytes, and fallibly, so a file read exactly to its stat length is never +/// over-allocated and one that outgrows memory is an `OutOfMemory` error, not an abort. +fn read_all_with(reader: &mut impl Read, size_hint: u64, chunk: usize, max: u64) -> io::Result> { + if size_hint > max { + return Err(too_large(max)); + } let mut buf = buffer_for(size_hint)?; - let chunk = u64::try_from(chunk.max(1)).unwrap_or(u64::MAX); - while reader.take(chunk).read_to_end(&mut buf)? > 0 {} + let chunk = chunk.max(1); + // One byte over `max` is enough to tell a file over the limit from one exactly at it. + let mut reader = reader.take(max.saturating_add(1)); + loop { + if buf.len() == buf.capacity() { + let mut probe = [0; 4096]; + let n = loop { + match reader.read(&mut probe) { + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + r => break r?, + } + }; + if n == 0 { + break; + } + let needed = buf.len() as u64 + n as u64; + buf.try_reserve(n.max(chunk)).map_err(|_| out_of_memory(needed))?; + buf.extend_from_slice(&probe[..n]); + } + let room = u64::try_from((buf.capacity() - buf.len()).min(chunk)).unwrap_or(u64::MAX); + if (&mut reader).take(room).read_to_end(&mut buf)? == 0 && buf.len() < buf.capacity() { + break; + } + } + if buf.len() as u64 > max { + return Err(too_large(max)); + } Ok(buf) } +/// The error for a file over the `max` bytes a read may return. +fn too_large(max: u64) -> io::Error { + io::Error::new(io::ErrorKind::FileTooLarge, format!("the file is larger than {} and was not read", size_text(max))) +} + +/// The `OutOfMemory` error for a read that needed `len` bytes. +fn out_of_memory(len: u64) -> io::Error { + io::Error::new(io::ErrorKind::OutOfMemory, format!("not enough memory to read the whole file ({})", size_text(len))) +} + /// An empty buffer with room for `len` bytes, or an `OutOfMemory` error saying how much was needed. pub fn buffer_for(len: u64) -> io::Result> { let mut buf = Vec::new(); let reserved = usize::try_from(len).ok().and_then(|n| buf.try_reserve_exact(n).ok()); match reserved { Some(()) => Ok(buf), - None => Err(io::Error::new(io::ErrorKind::OutOfMemory, format!("not enough memory to read the whole file ({})", size_text(len)))), + None => Err(out_of_memory(len)), } } @@ -115,6 +171,36 @@ mod tests { } } + /// Nothing over the total limit is returned (#8601): a file larger than it, a reader that + /// gives more than it whatever its hint says, and one that never ends are `FileTooLarge` + /// errors, while a file exactly at the limit is read whole. + #[test] + fn reads_stop_at_the_total_limit() { + let data: Vec = (0..10_000u32).map(|i| (i % 251) as u8).collect(); + let p = temp("limit", &data); + assert_eq!(read_path(&p, 7, data.len() as u64).unwrap(), data, "exactly at the limit"); + assert_eq!(read_path(&p, 7, data.len() as u64 - 1).unwrap_err().kind(), io::ErrorKind::FileTooLarge); + for hint in [0, 100, data.len() as u64] { + let e = read_all_with(&mut std::io::Cursor::new(&data), hint, 64, 5_000).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::FileTooLarge, "hint {hint}"); + } + let e = read_all_with(&mut std::io::Cursor::new(&data), 1 << 40, 64, 5_000).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::FileTooLarge, "a hint over the limit fails before reading"); + let e = read_all_with(&mut std::io::repeat(1), 0, 1 << 20, 3 << 20).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::FileTooLarge, "a reader that never ends"); + assert!(e.to_string().contains("3.15 MB"), "{e}"); + } + + /// A file read exactly to its stat length keeps the buffer it reserved: the end is found by a + /// probe read, without growing the buffer first. + #[test] + fn an_exact_hint_is_not_over_allocated() { + let data = vec![7u8; 100_000]; + let buf = read_all_with(&mut std::io::Cursor::new(&data), data.len() as u64, 4096, MAX_OPEN_BYTES).unwrap(); + assert_eq!(buf, data); + assert_eq!(buf.capacity(), data.len()); + } + #[test] fn empty_and_missing_files() { assert!(read_file(&temp("empty", b"")).unwrap().is_empty()); diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 97e285a573..976c52cc70 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -15,23 +15,108 @@ use crate::manifest::{ContentM, FORMAT_VERSION, Hash, LayerM, Manifest}; use crate::zip::{ZipReader, ZipWriter}; 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"; +use craft_pcraft_format::{BLOBS_DIR, COMPOSITE_DIR, TILES_DIR, blob_path, tile_path}; +// `SAVE_LOCK` is the file a directory save holds an exclusive lock on for its whole transaction (#4957). +pub(crate) use craft_pcraft_format::{COMPOSITE, MANIFEST, SAVE_LOCK, THUMB}; +/// The flattened document at its canvas size, a PNG in [`COMPOSITE_DIR`], named by the manifest's +/// `full_composite` member. +pub(crate) const FULL_COMPOSITE: &str = "composite/full.png"; +/// The manifest member that names [`FULL_COMPOSITE`]. +const FULL_COMPOSITE_MEMBER: &str = "full_composite"; +/// Bytes a ZIP bundle may hold, central directory included, and entries it may hold: the +/// format's 32-bit offsets and 16-bit counts ([`ZipWriter`] has no ZIP64). +const ZIP_LIMIT: (u64, usize) = (u32::MAX as u64, u16::MAX as usize); /// 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") -} -fn blob_path(h: &str) -> String { - format!("blobs/{h}.zst") +/// The bytes an [`Object`] stores, owned by (or shared with) the compressor's source so one +/// [`Compressor`] can be reused for objects of any lifetime. +enum Source { + Tile(Arc), + Blob(Arc>), + /// A little-endian copy of a tile (big-endian hosts only). + Owned(Vec), } -fn compress(data: &[u8]) -> Vec { - ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) +struct SourceReader { + source: Source, + pos: usize, +} + +impl SourceReader { + fn len(&self) -> usize { + match &self.source { + Source::Tile(t) => t.len(), + Source::Blob(b) => b.len(), + Source::Owned(v) => v.len(), + } + } +} + +/// Copies the source bytes from `pos` into `buf`, returning how many were copied. +fn read_from(all: &[u8], pos: usize, buf: &mut [u8]) -> usize { + let rest = all.get(pos..).unwrap_or(&[]); + let n = rest.len().min(buf.len()); + if let (Some(dst), Some(src)) = (buf.get_mut(..n), rest.get(..n)) { + dst.copy_from_slice(src); + } + n +} + +impl Read for SourceReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + // A tile's bytes are borrowed only for this call, so its scratch-disk backing may + // page out between reads (a later read reloads it). + let n = match &self.source { + Source::Tile(t) => read_from(&t.bytes(), self.pos, buf), + Source::Blob(b) => read_from(b, self.pos, buf), + Source::Owned(v) => read_from(v, self.pos, buf), + }; + self.pos += n; + Ok(n) + } +} + +/// A reusable zstd compressor. `ruzstd` allocates a block buffer, a ~1 MiB suffix table and a +/// block output buffer per frame; keeping the [`FrameCompressor`](ruzstd::encoding::FrameCompressor) +/// between objects reuses the first two (its matcher resets itself on every `compress`). +/// Output is byte-identical to `compress_to_vec` at the same level. +struct Compressor { + inner: ruzstd::encoding::FrameCompressor, ruzstd::encoding::MatchGeneratorDriver>, +} + +impl Compressor { + fn new() -> Self { + Self { inner: ruzstd::encoding::FrameCompressor::new(ruzstd::encoding::CompressionLevel::Fastest) } + } + + fn run(&mut self, source: Source) -> Vec { + let reader = SourceReader { source, pos: 0 }; + // Room for the worst case (raw blocks: 3 bytes per 128 KiB, plus frame header and + // checksum), so the output never regrows; shrunk afterwards to what it used. Untouched + // pages cost nothing. + let len = reader.len(); + let mut drain = Vec::new(); + if drain.try_reserve_exact(len.saturating_add(len / 16384).saturating_add(64)).is_err() { + drain = Vec::new(); + } + self.inner.set_source(reader); + self.inner.set_drain(drain); + self.inner.compress(); + self.inner.take_source(); + let mut out = self.inner.take_drain().unwrap_or_default(); + out.shrink_to_fit(); + out + } + + fn object(&mut self, obj: &Object) -> Vec { + let source = match obj { + Object::Tile(tile, _) if cfg!(target_endian = "little") => Source::Tile(Arc::clone(tile)), + Object::Tile(tile, sample) => Source::Owned(tile_le(tile, *sample).to_vec()), + Object::Blob(blob) => Source::Blob(Arc::clone(blob)), + }; + self.run(source) + } } /// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the @@ -59,16 +144,31 @@ fn hash_bytes(b: &[u8]) -> Hash { } fn png(img: &Rgba8Image) -> Result> { - let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels.clone()) - .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))?; + png_tagged(img, None) +} + +/// [`png`] with `icc` embedded. +fn png_tagged(img: &Rgba8Image, icc: Option<&[u8]>) -> Result> { + let mut pixels = Vec::new(); + pixels.try_reserve_exact(img.pixels.len()).map_err(|_| FormatError::LimitExceeded("not enough memory for PNG pixels".into()))?; + pixels.extend_from_slice(&img.pixels); + let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, pixels) + .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))? + .with_icc(icc.map(<[u8]>::to_vec)); photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) } fn previews(opts: &SaveOptions) -> Result)>> { - let (composite, thumbnail) = beside( - opts.thumbnail.is_some() && opts.composite.is_some(), - || opts.composite.as_ref().map(png).transpose(), - || opts.thumbnail.as_ref().map(png).transpose(), + let (full, (composite, thumbnail)) = beside( + opts.full_composite.is_some() && (opts.thumbnail.is_some() || opts.composite.is_some()), + || opts.full_composite.as_ref().map(|img| png_tagged(img, opts.full_composite_icc.as_deref())).transpose(), + || { + beside( + opts.thumbnail.is_some() && opts.composite.is_some(), + || opts.composite.as_ref().map(png).transpose(), + || opts.thumbnail.as_ref().map(png).transpose(), + ) + }, ); let mut out = Vec::new(); if let Some(t) = thumbnail? { @@ -77,6 +177,10 @@ fn previews(opts: &SaveOptions) -> Result)>> { if let Some(t) = composite? { out.push((COMPOSITE, t)); } + // A full-size composite that cannot be encoded is left out; the save goes on without it. + if let Ok(Some(t)) = full { + out.push((FULL_COMPOSITE, t)); + } Ok(out) } @@ -149,64 +253,64 @@ pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &st // Loading // --------------------------------------------------------------------------- -/// Deepest JSON nesting a manifest may have: each group level adds three (the layer object, its -/// content object and the `children` array), with headroom for the document and leaf layer fields. -/// The saver checks the same bound, so every bundle it writes loads again. -const MAX_MANIFEST_DEPTH: usize = 3 * (convert::MAX_GROUP_DEPTH + 1) + 64; +/// The limits a manifest is checked against: craft-pcraft-format's item limit, the JSON nesting +/// of [`convert::MAX_GROUP_DEPTH`] nested groups ([`craft_pcraft_format::manifest_depth`]) and at +/// most `max_manifest_bytes` bytes. The saver checks the same nesting and items, so every bundle +/// it writes loads again. +fn manifest_limits(max_manifest_bytes: usize) -> craft_pcraft_format::Limits { + let mut limits = craft_pcraft_format::Limits::default(); + limits.max_manifest_bytes = max_manifest_bytes; + limits.max_depth = craft_pcraft_format::manifest_depth(convert::MAX_GROUP_DEPTH); + limits +} -/// Most JSON items (arrays and objects opened, plus elements and members after a comma) a -/// manifest may hold. Parsing builds a `serde_json::Value` tree of tens of bytes per item, so -/// `[0,0,…]` costs about 16× its size in memory; a typical manifest spends 20 bytes or more per -/// item, so this still admits manifests of about 80 MiB. -const MAX_MANIFEST_ITEMS: usize = 4 << 20; +/// The manifest as a save writes it: `manifest`, followed by a `full_composite` member naming +/// [`FULL_COMPOSITE`] when `full_composite` is set. Without it the bytes are `manifest`'s own. +fn manifest_json(manifest: &Manifest, full_composite: bool) -> Result> { + #[derive(serde::Serialize)] + struct WithFullComposite<'a> { + #[serde(flatten)] + manifest: &'a Manifest, + full_composite: &'static str, + } + // serde_json writes NaN and the infinities as `null`, which the manifest's float fields + // refuse on load (issue #1101): refuse the save instead, naming the value. + crate::finite::check(manifest).map_err(|e| match e { + crate::finite::Problem::NonFinite { path } => FormatError::NonFinite { path }, + crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")), + })?; + let json = if full_composite { + serde_json::to_vec_pretty(&WithFullComposite { manifest, full_composite: FULL_COMPOSITE })? + } else { + serde_json::to_vec_pretty(manifest)? + }; + // The nesting and the items only: a save checks no manifest size. + craft_pcraft_format::check_manifest(&json, &manifest_limits(usize::MAX))?; + Ok(json) +} -/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] or holding more than -/// [`MAX_MANIFEST_ITEMS`] items with a linear scan (no recursion), so parsing with serde_json's -/// recursion limit disabled can't exhaust the stack, nor tiny values the memory, on hostile input. -fn check_manifest_depth(json: &[u8]) -> Result<()> { - let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false); - for &b in json { - if in_str { - match b { - _ if escaped => escaped = false, - b'\\' => escaped = true, - b'"' => in_str = false, - _ => {} - } - continue; - } - match b { - b'"' => in_str = true, - b'[' | b'{' => { - depth += 1; - if depth > MAX_MANIFEST_DEPTH { - return Err(FormatError::LimitExceeded(format!("manifest JSON nested deeper than {MAX_MANIFEST_DEPTH} levels"))); - } - } - b']' | b'}' => depth = depth.saturating_sub(1), - _ => {} - } - if matches!(b, b'[' | b'{' | b',') { - items += 1; - if items > MAX_MANIFEST_ITEMS { - return Err(FormatError::LimitExceeded(format!("manifest JSON holds more than {MAX_MANIFEST_ITEMS} values"))); - } - } +/// The entry the manifest's `full_composite` member names: `None` when the member is missing or +/// `null`. A member that names any entry other than [`FULL_COMPOSITE`] is refused. +pub(crate) fn read_full_composite_entry(src: &dyn ByteSource, opts: &LoadOptions) -> Result> { + let raw = src.get(MANIFEST, opts.max_manifest_bytes)?; + let manifest = craft_pcraft_format::parse_manifest(&raw, &manifest_limits(opts.max_manifest_bytes))?; + match manifest.get(FULL_COMPOSITE_MEMBER) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(entry)) if entry == FULL_COMPOSITE => Ok(Some(FULL_COMPOSITE)), + Some(other) => Err(FormatError::corrupt(format!("`{FULL_COMPOSITE_MEMBER}` names {other}, not {FULL_COMPOSITE:?}"))), } - Ok(()) } pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result { let raw = src.get(MANIFEST, opts.max_manifest_bytes)?; - // serde_json stops at 128 levels (about 40 nested groups); the bounded check replaces that limit. - check_manifest_depth(&raw)?; - let mut de = serde_json::Deserializer::from_slice(&raw); - de.disable_recursion_limit(); - let mut v = ::deserialize(&mut de)?; - de.end()?; - crate::migrate::migrate(&mut v)?; - let layers = take_array(&mut v, "/document/layers"); - let mut m: Manifest = serde_json::from_value(v)?; + // serde_json stops at 128 levels (about 40 nested groups); `read_manifest_with` checks the + // nesting and the items in one pass, then parses without that limit. + let limits = manifest_limits(opts.max_manifest_bytes); + let mut layers = Vec::new(); + let mut m: Manifest = craft_pcraft_format::read_manifest_with(&raw, &limits, crate::migrate::STEPS, FORMAT_VERSION, |mut document| { + layers = take_array(&mut document, "/layers"); + Ok::<_, FormatError>(serde_json::from_value(document)?) + })?; m.document.layers = layers_from_json(layers)?; // A number past f32's range decodes as infinity, which no save could write back (#6862). if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) { @@ -263,11 +367,10 @@ impl LoadFetch<'_> { impl Fetch for LoadFetch<'_> { fn tile(&mut self, hash: &str, len: usize) -> Result> { - if !is_valid_hash(hash) { + let Some(path) = tile_path(hash) else { return Err(FormatError::corrupt(format!("invalid tile hash `{hash}`"))); - } + }; self.account(len)?; - let path = tile_path(hash); let z = self.src.get(&path, len + len / 8 + 4096)?; let data = decompress(&z, len, &path)?; if hash_bytes(&data) != hash { @@ -277,17 +380,31 @@ impl Fetch for LoadFetch<'_> { } fn blob(&mut self, hash: &str) -> Result>> { + self.blob_bounded(hash, self.opts.max_blob_bytes) + } + + /// A content-addressed blob of at most `limit` bytes (never more than `max_blob_bytes`): + /// read once, decompressed within the limit, counted against the load's memory budget and + /// checked against its hash, then shared. Optional vector previews pass a 16 MiB limit. + fn blob_bounded(&mut self, hash: &str, limit: usize) -> Result>> { + let limit = limit.min(self.opts.max_blob_bytes); if let Some(b) = self.blobs.get(hash) { - return Ok(b.clone()); + return if b.len() <= limit { Ok(b.clone()) } else { Err(FormatError::LimitExceeded("optional preview blob exceeds its limit".into())) }; } - if !is_valid_hash(hash) { + let Some(path) = blob_path(hash) else { return Err(FormatError::corrupt(format!("invalid blob hash `{hash}`"))); - } - let path = blob_path(hash); - let z = self.src.get(&path, self.opts.max_blob_bytes)?; + }; + // A new blob is retained beside every earlier one, so it may inflate to no more than + // what the load's total budget has left: checking the total only after decompressing + // would let each of several blobs allocate a full `max_blob_bytes` first. + let left = usize::try_from(self.opts.max_total_bytes.saturating_sub(self.total)).unwrap_or(usize::MAX); + let limit = limit.min(left); + // The stored zstd frame of incompressible data is a little larger than the data, so a + // blob right at `limit` must still be read; `decompress_bounded` enforces `limit`. + let z = self.src.get(&path, limit.saturating_add(limit / 8).saturating_add(4096))?; // Blob length is not in the manifest; the frame header bounds it and // we cap at max_blob_bytes. - let data = decompress_bounded(&z, self.opts.max_blob_bytes, None, &path)?; + let data = decompress_bounded(&z, limit, None, &path)?; self.account(data.len())?; if hash_bytes(&data) != hash { return Err(FormatError::corrupt(format!("{path}: content does not match its hash"))); @@ -400,13 +517,32 @@ impl Sink for Collect<'_> { } } -fn tile_le(t: &Tile, sample: SampleType) -> std::borrow::Cow<'_, [u8]> { +/// A tile's bytes in little-endian order: the tile itself (kept in memory while this lives), or +/// a byte-swapped copy on big-endian machines. +enum LeBytes<'a> { + Native(photocraft_raster::TileBytes<'a>), + Swapped(Vec), + Borrowed(&'a [u8]), +} + +impl std::ops::Deref for LeBytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + match self { + LeBytes::Native(b) => b, + LeBytes::Swapped(b) => b, + LeBytes::Borrowed(b) => b, + } + } +} + +fn tile_le(t: &Tile, sample: SampleType) -> LeBytes<'_> { if cfg!(target_endian = "big") { let mut b = t.bytes().to_vec(); swap_to_le(&mut b, sample); - std::borrow::Cow::Owned(b) + LeBytes::Swapped(b) } else { - std::borrow::Cow::Borrowed(t.bytes()) + LeBytes::Native(t.bytes()) } } @@ -508,25 +644,49 @@ struct FileSignature { struct Prepared { manifest: Vec, + /// What `manifest` encodes, for a ZIP save that has to leave the full-size composite out. + model: Manifest, + /// Whether `manifest` names the full-size composite. + full_composite: bool, objects: BTreeMap, stats: SaveStats, } +/// Refuse to write pixels of `doc` that are unreadable now, or that may have been read as a +/// placeholder because some tile failed or recovered since `generation` (the save's start). +/// Other documents' scratch failures do not block this save. +fn scratch_ok(doc: &Document, generation: u64) -> Result<()> { + doc.scratch_health().map_err(FormatError::corrupt)?; + photocraft_raster::spill::check_since(generation).map_err(FormatError::corrupt) +} + +impl Prepared { + /// Takes the full-size composite out of the manifest when `previews` does not hold it. + fn match_previews(&mut self, previews: &[(&str, Vec)]) -> Result<()> { + if self.full_composite && !previews.iter().any(|(name, _)| *name == FULL_COMPOSITE) { + self.full_composite = false; + self.manifest = manifest_json(&self.model, false)?; + self.stats.manifest_bytes = self.manifest.len(); + } + Ok(()) + } +} + enum Object { Tile(Arc, SampleType), Blob(Arc>), } impl Object { - fn compressed(&self) -> Vec { - compress(&self.content()) + fn compressed(&self, compressor: &mut Compressor) -> Vec { + compressor.object(self) } /// The bytes this object stores (tiles little-endian). - fn content(&self) -> std::borrow::Cow<'_, [u8]> { + fn content(&self) -> LeBytes<'_> { match self { Object::Tile(tile, sample) => tile_le(tile, *sample), - Object::Blob(blob) => std::borrow::Cow::Borrowed(blob.as_slice()), + Object::Blob(blob) => LeBytes::Borrowed(blob.as_slice()), } } } @@ -597,7 +757,7 @@ fn bundle_root(dir: &Path) -> Result { std::fs::create_dir_all(&dir)?; let root = std::fs::canonicalize(&dir)?; plain_directory_or_missing(&root)?; - for sub in ["tiles", "blobs", "composite"] { + for sub in [TILES_DIR, BLOBS_DIR, COMPOSITE_DIR] { let path = root.join(sub); plain_directory_or_missing(&path)?; if !path.exists() { @@ -617,7 +777,7 @@ fn bundle_root(dir: &Path) -> Result { fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> { let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?; - if parent != root && parent != root.join("tiles") && parent != root.join("blobs") && parent != root.join("composite") { + if parent != root && parent != root.join(TILES_DIR) && parent != root.join(BLOBS_DIR) && parent != root.join(COMPOSITE_DIR) { return Err(unsafe_bundle_entry(path)); } plain_directory_or_missing(parent)?; @@ -774,9 +934,9 @@ impl BundleGc { let flags = gc_directory_flags(); let root = open(root, flags, Mode::empty()).map_err(std::io::Error::from)?; - let tiles = openat(&root, "tiles", flags, Mode::empty()).map_err(std::io::Error::from)?; - let blobs = openat(&root, "blobs", flags, Mode::empty()).map_err(std::io::Error::from)?; - let composite = openat(&root, "composite", flags, Mode::empty()).map_err(std::io::Error::from)?; + let tiles = openat(&root, TILES_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?; + let blobs = openat(&root, BLOBS_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?; + let composite = openat(&root, COMPOSITE_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?; Ok(Self { root, tiles, blobs, composite }) } @@ -785,9 +945,10 @@ impl BundleGc { let (handle, name) = match relative { THUMB => (&self.root, THUMB), COMPOSITE => (&self.composite, "preview.png"), + FULL_COMPOSITE => (&self.composite, "full.png"), _ => match relative.split_once('/') { - Some(("tiles", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.tiles, name), - Some(("blobs", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.blobs, name), + Some((TILES_DIR, name)) if name.strip_suffix(craft_pcraft_format::OBJECT_SUFFIX).is_some_and(is_valid_hash) => (&self.tiles, name), + Some((BLOBS_DIR, name)) if name.strip_suffix(craft_pcraft_format::OBJECT_SUFFIX).is_some_and(is_valid_hash) => (&self.blobs, name), _ => return Err(FormatError::Unsupported(format!("invalid bundle cleanup target: {relative}"))), }, }; @@ -850,7 +1011,8 @@ type Compressed<'a> = (&'a String, Arc>); fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result>> { let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) }; if threads < 2 || objects.len() < 2 { - return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed()))).collect()); + let mut compressor = Compressor::new(); + return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed(&mut compressor)))).collect()); } let next = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|sc| { @@ -858,10 +1020,11 @@ fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result Result { + doc.scratch_health().map_err(FormatError::corrupt)?; + let generation = photocraft_raster::spill::read_error_generation(); + let prepared = self.prepare_inner(doc, opts); + if let Err(error) = photocraft_raster::spill::check_since(generation) { + // Failed reads may have hashed display placeholders. Never reuse those hashes + // when the original backing becomes readable and the same writer retries. + self.hash_cache.clear(); + return Err(FormatError::corrupt(error)); + } + prepared + } + + fn prepare_inner(&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), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() }; let document = convert::doc_m(doc, &mut c); - let manifest = Manifest { - format_version: FORMAT_VERSION, - generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), - document, - thumbnail: opts.thumbnail.as_ref().map(|_| THUMB.to_owned()), - composite: opts.composite.as_ref().map(|_| COMPOSITE.to_owned()), - }; - // serde_json writes NaN and the infinities as `null`, which the manifest's float fields - // refuse on load (issue #1101): refuse the save instead, naming the value. - crate::finite::check(&manifest).map_err(|e| match e { - crate::finite::Problem::NonFinite { path } => FormatError::NonFinite { path }, - crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")), - })?; - let manifest = serde_json::to_vec_pretty(&manifest)?; - check_manifest_depth(&manifest)?; + let mut model = Manifest::new(format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), document); + model.thumbnail = opts.thumbnail.as_ref().map(|_| THUMB.to_owned()); + model.composite = opts.composite.as_ref().map(|_| COMPOSITE.to_owned()); + let full_composite = opts.full_composite.is_some(); + let manifest = manifest_json(&model, full_composite)?; let stats = SaveStats { tiles_total: c.tiles.len(), blobs_total: c.blobs.len(), manifest_bytes: manifest.len(), ..Default::default() }; + let invalid = |h: &str| FormatError::corrupt(format!("invalid object hash `{h}`")); let mut objects = BTreeMap::new(); for (h, (t, s)) in c.tiles { - objects.insert(tile_path(&h), Object::Tile(t, s)); + objects.insert(tile_path(&h).ok_or_else(|| invalid(&h))?, Object::Tile(t, s)); } for (h, b) in c.blobs { - objects.insert(blob_path(&h), Object::Blob(b)); + objects.insert(blob_path(&h).ok_or_else(|| invalid(&h))?, Object::Blob(b)); } - Ok(Prepared { manifest, objects, stats }) + doc.scratch_health().map_err(FormatError::corrupt)?; + Ok(Prepared { manifest, model, full_composite, objects, stats }) } /// Save as a ZIP bundle in memory. pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec, SaveStats)> { + self.save_zip_within(doc, opts, ZIP_LIMIT) + } + + /// [`Self::save_zip`] with `limit` (bytes, entries) in place of [`ZIP_LIMIT`]: an archive that + /// the full-size composite would take past either is saved without that composite. + fn save_zip_within(&mut self, doc: &Document, opts: &SaveOptions, limit: (u64, usize)) -> Result<(Vec, SaveStats)> { + let generation = photocraft_raster::spill::read_error_generation(); // Another thread encodes the previews while the document is collected and its new // objects are compressed on all cores (zstd dominates a full save). let (previews, prepared) = beside( - opts.thumbnail.is_some() || opts.composite.is_some(), + opts.thumbnail.is_some() || opts.composite.is_some() || opts.full_composite.is_some(), || previews(opts), || -> Result<_> { let p = self.prepare(doc, opts)?; @@ -949,23 +1124,12 @@ impl PcraftWriter { Ok((p, fresh)) }, ); - let (p, mut fresh) = prepared?; - let previews = previews?; + let (mut p, mut fresh) = prepared?; + let mut previews = previews?; + p.match_previews(&previews)?; let mut stats = p.stats; - let mut next = HashMap::with_capacity(p.objects.len()); - let mut cached_bytes = 0usize; - // The archive is sized for every entry up front (#7689): previews, then reused and fresh - // objects at their compressed sizes. - let entries = - std::iter::once((MANIFEST, p.manifest.len())).chain(previews.iter().map(|(name, data)| (*name, data.len()))).chain(p.objects.keys().map(|path| { - let size = self.compressed.get(path).or_else(|| fresh.get(path)).map_or(0, |data| data.len()); - (path.as_str(), size) - })); - let mut z = ZipWriter::with_entries(entries)?; - z.add(MANIFEST, &p.manifest)?; - for (name, data) in &previews { - z.add(name, data)?; - } + let mut entries = Vec::with_capacity(p.objects.len()); + let mut compressor: Option = None; for (path, obj) in &p.objects { let (key, data) = match self.compressed.remove_entry(path) { Some((key, data)) => { @@ -979,15 +1143,59 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - (None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed()))) + (None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed(compressor.get_or_insert_with(Compressor::new))))) } }; + entries.push((path, key, data)); + } + // A STORE entry takes a 30-byte local header plus its name and data, and a 46-byte central + // directory record plus its name again; the archive ends with a 22-byte end record. The + // full-size composite is left out rather than fail a save that fits without it. + let stored = |name: &str, len: usize| 76 + 2 * name.len() as u64 + len as u64; + let total = 22 + + stored(MANIFEST, p.manifest.len()) + + previews.iter().map(|(name, data)| stored(name, data.len())).sum::() + + entries.iter().map(|(path, _, data)| stored(path, data.len())).sum::(); + let mut manifest = p.manifest; + if (total > limit.0 || 1 + previews.len() + entries.len() > limit.1) + && let Some(at) = previews.iter().position(|(name, _)| *name == FULL_COMPOSITE) + { + previews.remove(at); + manifest = manifest_json(&p.model, false)?; + stats.manifest_bytes = manifest.len(); + } + // The archive is sized for every entry up front (#7689): previews, then reused and fresh + // objects at their compressed sizes. + let sizes = std::iter::once((MANIFEST, manifest.len())) + .chain(previews.iter().map(|(name, data)| (*name, data.len()))) + .chain(entries.iter().map(|(path, _, data)| (path.as_str(), data.len()))); + let mut z = match ZipWriter::with_entries(sizes) { + Ok(z) => z, + Err(FormatError::LimitExceeded(_)) if previews.iter().any(|(name, _)| *name == FULL_COMPOSITE) => { + previews.retain(|(name, _)| *name != FULL_COMPOSITE); + manifest = manifest_json(&p.model, false)?; + stats.manifest_bytes = manifest.len(); + let sizes = std::iter::once((MANIFEST, manifest.len())) + .chain(previews.iter().map(|(name, data)| (*name, data.len()))) + .chain(entries.iter().map(|(path, _, data)| (path.as_str(), data.len()))); + ZipWriter::with_entries(sizes)? + } + Err(e) => return Err(e), + }; + z.add(MANIFEST, &manifest)?; + for (name, data) in &previews { + z.add(name, data)?; + } + let mut next = HashMap::with_capacity(entries.len()); + let mut cached_bytes = 0usize; + for (path, key, data) in entries { z.add(path, &data)?; if data.capacity() <= self.compressed_budget.saturating_sub(cached_bytes) { cached_bytes += data.capacity(); next.insert(key.unwrap_or_else(|| path.clone()), data); } } + scratch_ok(doc, generation)?; self.compressed = next; Ok((z.finish()?, stats)) } @@ -1002,6 +1210,7 @@ impl PcraftWriter { /// 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 scratch_generation = photocraft_raster::spill::read_error_generation(); 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). @@ -1009,11 +1218,12 @@ impl PcraftWriter { 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. - for path in [MANIFEST, THUMB, COMPOSITE] { + for path in [MANIFEST, THUMB, COMPOSITE, FULL_COMPOSITE] { validate_bundle_file(dir, &dir.join(path))?; } - let p = self.prepare(doc, opts)?; + let mut p = self.prepare(doc, opts)?; let previews = previews(opts)?; + p.match_previews(&previews)?; let mut stats = p.stats; if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) { self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() }); @@ -1026,6 +1236,7 @@ impl PcraftWriter { None => 0, }; let recheck = self.rolling_recheck(&p.objects); + let mut compressor: Option = None; for (path, obj) in &p.objects { let object_path = dir.join(path); // bundle_root and list_objects validated the fixed parent directories once. Check @@ -1060,7 +1271,9 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed(), fs)?; + let compressed = obj.compressed(compressor.get_or_insert_with(Compressor::new)); + scratch_ok(doc, scratch_generation)?; + write_bundle_file(dir, &object_path, &compressed, fs)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -1085,21 +1298,22 @@ impl PcraftWriter { // 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"] { + for sub in [TILES_DIR, BLOBS_DIR] { fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?; } } #[cfg(not(windows))] - for sub in ["tiles", "blobs"] { + for sub in [TILES_DIR, BLOBS_DIR] { let folder = dir.join(sub); if folder.is_dir() { confirm_flushed(&folder, &folder)?; } } + scratch_ok(doc, scratch_generation)?; 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] { + for stale in [THUMB, COMPOSITE, FULL_COMPOSITE] { if !previews.iter().any(|(n, _)| *n == stale) { let _ = remove_bundle_file(dir, &dir.join(stale), &gc); } @@ -1246,35 +1460,102 @@ fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() { i.pixels.iter_mut().enumerate().for_each(|(j, p)| *p = (j as u8).wrapping_mul(k)); i }; - let (thumb, composite) = (image(4, 3, 7), image(9, 5, 13)); - for (t, c) in [(true, true), (true, false), (false, true), (false, false)] { - let opts = SaveOptions { thumbnail: t.then(|| thumb.clone()), composite: c.then(|| composite.clone()) }; + let (thumb, composite, full) = (image(4, 3, 7), image(9, 5, 13), image(600, 3, 5)); + for mask in 0..8u8 { + let (t, c, f) = (mask & 1 != 0, mask & 2 != 0, mask & 4 != 0); + let opts = SaveOptions { + thumbnail: t.then(|| thumb.clone()), + composite: c.then(|| composite.clone()), + full_composite: f.then(|| full.clone()), + ..Default::default() + }; let (bytes, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap(); let zip = ZipReader::new(&bytes).unwrap(); let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect(); let mut want = vec![MANIFEST]; want.extend(t.then_some(THUMB)); want.extend(c.then_some(COMPOSITE)); + want.extend(f.then_some(FULL_COMPOSITE)); assert_eq!(names[..want.len()], want[..]); assert!(names[want.len()..].iter().all(|n| n.starts_with("tiles/") || n.starts_with("blobs/")), "{names:?}"); - for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref())] { + for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref()), (FULL_COMPOSITE, opts.full_composite.as_ref())] { assert_eq!(zip.read_by_name(name, usize::MAX).ok(), img.map(|i| png(i).unwrap()), "{name}"); } + assert_eq!(crate::read_full_composite(&bytes).unwrap(), opts.full_composite.as_ref().map(|i| png(i).unwrap())); } - let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()) }; + let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()), ..Default::default() }; assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); - let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }) }; + let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }), ..Default::default() }; assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); + // A full-size composite that cannot be encoded is left out, and the save goes on without it. + let bad = + SaveOptions { thumbnail: Some(thumb.clone()), full_composite: Some(Rgba8Image { width: 600, height: 3, pixels: vec![0; 5] }), ..Default::default() }; + let (bytes, stats) = PcraftWriter::new().save_zip(&doc, &bad).unwrap(); + let zip = ZipReader::new(&bytes).unwrap(); + assert!(zip.read_by_name(FULL_COMPOSITE, usize::MAX).is_err()); + assert_eq!(zip.read_by_name(THUMB, usize::MAX).unwrap(), png(&thumb).unwrap()); + assert_eq!(crate::read_full_composite(&bytes).unwrap(), None); + assert_eq!(stats.manifest_bytes, zip.read_by_name(MANIFEST, usize::MAX).unwrap().len()); + let dir = std::env::temp_dir().join(format!("pcraft-bad-full-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + PcraftWriter::new().save_dir(&doc, &dir, &bad).unwrap(); + assert!(!dir.join(FULL_COMPOSITE).exists()); + assert!(!std::fs::read_to_string(dir.join(MANIFEST)).unwrap().contains("full_composite")); + std::fs::remove_dir_all(&dir).unwrap(); let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()) }; + let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()), full_composite: Some(full.clone()), ..Default::default() }; PcraftWriter::new().save_dir(&doc, &dir, &opts).unwrap(); assert_eq!(std::fs::read(dir.join(THUMB)).unwrap(), png(&thumb).unwrap()); assert_eq!(std::fs::read(dir.join(COMPOSITE)).unwrap(), png(&composite).unwrap()); + assert_eq!(std::fs::read(dir.join(FULL_COMPOSITE)).unwrap(), png(&full).unwrap()); + // A later save without it removes the file once the new manifest is in place. + PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions { full_composite: None, ..opts }).unwrap(); + assert!(!dir.join(FULL_COMPOSITE).exists()); + assert!(dir.join(COMPOSITE).exists()); std::fs::remove_dir_all(&dir).unwrap(); } +#[cfg(test)] +#[test] +fn zip_save_leaves_the_full_composite_out_past_the_zip_limit() { + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + let mut doc = Document::new("limit", Size::new(300, 200), photocraft_color::ColorMode::Rgb, SampleType::U8); + let mut surface = Surface::new(doc.pixel_format()); + surface.write_pixel(7, 9, &[0.2, 0.4, 0.6, 1.0]); + doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface))); + let mut full = Rgba8Image::new(300, 200); + full.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = (i % 251) as u8); + let opts = SaveOptions { + thumbnail: Some(Rgba8Image::new(4, 3)), + composite: Some(Rgba8Image::new(9, 6)), + full_composite: Some(full.clone()), + ..Default::default() + }; + let (whole, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap(); + // The whole archive, central directory included, and how many entries it holds: what the + // limit compares. + let bytes = whole.len() as u64; + let count = ZipReader::new(&whole).unwrap().entries.len(); + let (fits, _) = PcraftWriter::new().save_zip_within(&doc, &opts, (bytes, count)).unwrap(); + assert_eq!(fits, whole, "an archive at the limit keeps the composite"); + for limit in [(bytes - 1, count), (bytes, count - 1)] { + let (saved, stats) = PcraftWriter::new().save_zip_within(&doc, &opts, limit).unwrap(); + let zip = ZipReader::new(&saved).unwrap(); + let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect(); + assert!(!names.contains(&FULL_COMPOSITE), "{limit:?}: {names:?}"); + assert!(names.contains(&THUMB) && names.contains(&COMPOSITE), "{limit:?}: {names:?}"); + let manifest = zip.read_by_name(MANIFEST, usize::MAX).unwrap(); + assert_eq!(stats.manifest_bytes, manifest.len()); + assert!(!String::from_utf8(manifest).unwrap().contains("full_composite")); + assert_eq!(crate::load_from_bytes(&saved).unwrap(), doc); + } +} + #[cfg(all(test, unix))] #[test] fn gc_pinned_directory_handles_resist_subdirectory_swap() { @@ -1410,3 +1691,71 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { #[cfg(all(test, windows))] mod durability_tests; + +#[cfg(test)] +#[test] +fn reused_compressor_matches_compress_to_vec_and_round_trips() { + let mut state = 0x9e37_79b9_u32; + let mut noise = |n: usize| -> Vec { + (0..n) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 24) as u8 + }) + .collect() + }; + let inputs: Vec> = vec![ + Vec::new(), + vec![7], + b"hello hello hello hello hello".to_vec(), + noise(1000), + (0..300_000u32).map(|i| (i / 7 % 251) as u8).collect(), + noise(300_000), + vec![0; 262_144], + // A repeat after a big one proves the matcher state is reset between objects. + b"hello hello hello hello hello".to_vec(), + ]; + let mut compressor = Compressor::new(); + for data in inputs { + let expected = ruzstd::encoding::compress_to_vec(data.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let got = compressor.object(&Object::Blob(Arc::new(data.clone()))); + assert_eq!(got, expected, "len {}", data.len()); + assert_eq!(decompress(&got, data.len(), "test").unwrap(), data); + } +} + +#[cfg(test)] +#[test] +fn an_incompressible_blob_at_its_limit_still_loads() { + struct One(String, Vec); + impl ByteSource for One { + fn get(&self, path: &str, max: usize) -> Result> { + if path != self.0 || self.1.len() > max { + return Err(FormatError::LimitExceeded(format!("{path} over {max}"))); + } + Ok(self.1.clone()) + } + } + let mut x = 0x9e37_79b9_u32; + let data: Vec = (0..64 * 1024) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect(); + let z = Compressor::new().object(&Object::Blob(Arc::new(data.clone()))); + assert!(z.len() > data.len(), "the fixture must not compress"); + let hash = hash_bytes(&data); + let src = One(blob_path(&hash).unwrap(), z); + let mut fetch = LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }; + assert_eq!(fetch.blob_bounded(&hash, data.len()).unwrap().as_slice(), data.as_slice()); + assert!(LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }.blob_bounded(&hash, data.len() - 1).is_err()); + // What the load's total budget has left bounds the decompression too. + let opts = crate::LoadOptions { max_total_bytes: 10 * data.len() as u64, ..Default::default() }; + let spent = |total: u64| LoadFetch { src: &src, opts, total, blobs: HashMap::new() }; + assert!(spent(9 * data.len() as u64).blob_bounded(&hash, usize::MAX).is_ok()); + let e = spent(9 * data.len() as u64 + 1).blob_bounded(&hash, usize::MAX).unwrap_err(); + assert!(matches!(e, FormatError::LimitExceeded(ref m) if m.contains(&format!("exceeds {} bytes", data.len() - 1))), "{e}"); +} diff --git a/crates/format/tests/it/autosave.rs b/crates/format/tests/it/autosave.rs index 26be1c9d76..6eeb48d66f 100644 --- a/crates/format/tests/it/autosave.rs +++ b/crates/format/tests/it/autosave.rs @@ -34,19 +34,26 @@ fn background_save_outcomes_report_failure_then_recovery() { let root = temp_dir("retry-failed-write"); let blocked = root.join("recovery"); std::fs::write(&blocked, b"not a directory").unwrap(); - let saver = Autosaver::new(&blocked, "doc"); + // Not dropped on a failure: dropping joins the worker, which waits for a write in progress, + // so a stuck write would hold the test past its own bound until nextest kills it. + let saver = std::mem::ManuallyDrop::new(Autosaver::new(&blocked, "doc")); let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8)); saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap(); + // The bound only turns a worker that never reports into a failure instead of a hang. A real + // write flushes every tile to disk and took over 30 s on a busy Windows CI runner (normally + // 2-3 s), so it is generous, and still under the 10 minutes after which nextest's `ci` + // profile kills a test. + const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5 * 60); let receive = |saver: &Autosaver| { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let deadline = std::time::Instant::now() + PATIENCE; while std::time::Instant::now() < deadline { if let Some(outcome) = saver.take_completed().into_iter().next() { return outcome; } std::thread::sleep(std::time::Duration::from_millis(10)); } - panic!("autosave worker did not report its completed write within 30 seconds"); + panic!("autosave worker did not report its completed write within {PATIENCE:?}"); }; let (revision, result) = receive(&saver); assert_eq!(revision, 7); @@ -60,7 +67,7 @@ fn background_save_outcomes_report_failure_then_recovery() { result.unwrap(); assert_eq!(list_recovery(&blocked).len(), 1); assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc); - drop(saver); + drop(std::mem::ManuallyDrop::into_inner(saver)); std::fs::remove_dir_all(root).unwrap(); } diff --git a/crates/format/tests/it/common/mod.rs b/crates/format/tests/it/common/mod.rs index c58b542750..ce0381ff7c 100644 --- a/crates/format/tests/it/common/mod.rs +++ b/crates/format/tests/it/common/mod.rs @@ -174,6 +174,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { "Smart", LayerContent::Smart(SmartObject { contents_id: SmartContentsId::fresh(), + vector_preview: None, source: SmartSource::Embedded { file_name: "inner.png".into(), bytes: blob(11, 2000) }, transform: Affine::scale(0.5), smart_filters: vec![SmartFilter { @@ -203,6 +204,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { "Linked", LayerContent::Smart(SmartObject { contents_id: SmartContentsId::fresh(), + vector_preview: None, source: SmartSource::Linked { path: "/tmp/linked.psd".into() }, transform: Affine::IDENTITY, smart_filters: vec![], diff --git a/crates/format/tests/it/roundtrip.rs b/crates/format/tests/it/roundtrip.rs index 32ef503139..062a755bf0 100644 --- a/crates/format/tests/it/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -216,6 +216,13 @@ fn nesting_beyond_the_load_limit_is_refused_at_save() { assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}"); } +/// craft-pcraft-format's default manifest nesting limit is computed from its own copy of the +/// group depth, so the two must stay equal. +#[test] +fn group_depth_matches_craft_pcraft_format() { + assert_eq!(MAX_GROUP_DEPTH, craft_pcraft_format::MAX_GROUP_DEPTH); +} + #[test] fn empty_document_roundtrips() { let doc = Document::new("empty", photocraft_doc::Size::new(1, 1), ColorMode::Rgb, SampleType::U8); @@ -490,7 +497,7 @@ fn previews_embedded_and_readable() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let mut thumb = Rgba8Image::new(4, 3); thumb.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8); - let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(thumb.clone()) }; + let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(thumb.clone()), ..Default::default() }; let bytes = save_to_bytes(&doc, &opts).unwrap(); let png = read_thumbnail(&bytes).unwrap().unwrap(); let img = photocraft_codecs::decode(&png).unwrap(); @@ -502,6 +509,45 @@ fn previews_embedded_and_readable() { assert!(read_thumbnail(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap().is_none()); } +/// The full-size composite is an optional manifest member: a bundle without it (every file saved +/// before it was written) opens as before, at the same format version. +#[test] +fn bundles_without_the_full_composite_still_open() { + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let full = Rgba8Image::new(doc.size.width, doc.size.height); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), full_composite: Some(full), ..Default::default() }; + let bytes = save_to_bytes(&doc, &opts).unwrap(); + assert!(read_full_composite(&bytes).unwrap().is_some()); + assert_eq!(read_manifest(&bytes).unwrap().format_version, 1); + // Rewrite the bundle as an older writer left it: no member in the manifest, no entry. + let r = zip::ZipReader::new(&bytes).unwrap(); + let mut manifest: serde_json::Value = serde_json::from_slice(&r.read_by_name("manifest.json", usize::MAX).unwrap()).unwrap(); + assert!(manifest.as_object_mut().unwrap().remove("full_composite").is_some()); + let mut z = zip::ZipWriter::new(); + z.add("manifest.json", &serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + for e in r.entries.iter().filter(|e| e.name != "manifest.json" && e.name != "composite/full.png") { + z.add(&e.name, &r.read(e, usize::MAX).unwrap()).unwrap(); + } + let older = z.finish().unwrap(); + assert_eq!(read_full_composite(&older).unwrap(), None); + // A member that names any other entry is refused, and the document still opens. + manifest["full_composite"] = serde_json::Value::from("thumb.png"); + let mut z = zip::ZipWriter::new(); + z.add("manifest.json", &serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + for e in r.entries.iter().filter(|e| e.name != "manifest.json") { + z.add(&e.name, &r.read(e, usize::MAX).unwrap()).unwrap(); + } + let other = z.finish().unwrap(); + assert!(read_full_composite(&other).is_err()); + assert_eq!(load_from_bytes(&other).unwrap(), doc); + assert_eq!(load_from_bytes(&older).unwrap(), load_from_bytes(&bytes).unwrap()); + assert_eq!(load_from_bytes(&older).unwrap(), doc); + // A save without it writes no member at all, as older writers did. + let plain = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let text = zip::ZipReader::new(&plain).unwrap().read_by_name("manifest.json", usize::MAX).unwrap(); + assert!(!String::from_utf8(text).unwrap().contains("full_composite")); +} + #[test] fn manifest_is_human_readable_json() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); @@ -524,6 +570,22 @@ fn save_is_deterministic() { assert_eq!(a, b); } +/// A bundle that is read and saved again gives the same bytes, previews included. +#[test] +fn save_load_save_gives_the_same_bytes() { + let mut preview = Rgba8Image::new(4, 3); + preview.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8); + for opts in [SaveOptions::default(), SaveOptions { thumbnail: Some(preview.clone()), composite: Some(preview.clone()), ..Default::default() }] { + for mode in MODES { + for depth in SampleType::ALL { + let first = save_to_bytes(&rich_doc(mode, depth), &opts).unwrap(); + let again = save_to_bytes(&load_from_bytes(&first).unwrap(), &opts).unwrap(); + assert!(first == again, "{mode:?} {depth:?}: the second save differs"); + } + } + } +} + #[test] fn not_pcraft() { assert!(!is_pcraft(b"")); @@ -911,7 +973,7 @@ 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)) }; + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), ..Default::default() }; writer.save_dir(&doc, &dir, &opts).unwrap(); doc.name = "after".into(); writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); @@ -931,7 +993,7 @@ fn failed_manifest_commit_preserves_omitted_previews() { 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)) }; + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), ..Default::default() }; writer.save_dir(&doc, &dir, &opts).unwrap(); let old_doc = doc.clone(); let paths = ["manifest.json", "thumb.png", "composite/preview.png"]; @@ -962,7 +1024,7 @@ fn locked_stale_preview_does_not_fail_committed_save() { 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)) }; + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), ..Default::default() }; 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(); @@ -975,3 +1037,57 @@ fn locked_stale_preview_does_not_fail_committed_save() { assert!(!dir.join("thumb.png").exists()); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn arrow_document_is_backward_compatible() { + use photocraft_doc::{Fill, LiveShape, Path, ShapeLayer, Subpath}; + + let arrow = + LiveShape::ArrowLine { from: [12.0, 64.0], to: [140.0, 64.0], weight: 16.0, start: false, end: true, width: 52.0, length: 50.0, concavity: 20.0 }; + + let path = Path::new(vec![Subpath::polygon(&[(12.0, 56.0), (100.0, 56.0), (100.0, 44.0), (140.0, 64.0), (100.0, 84.0), (100.0, 72.0), (12.0, 72.0)])]); + + let mut doc = Document::new("Arrow compatibility", photocraft_doc::Size::new(256, 256), ColorMode::Rgb, SampleType::U8); + + doc.layers.push(Layer::new( + "Editable arrow", + LayerContent::Shape(ShapeLayer { + path: path.clone(), + fill: Some(Fill::Solid(photocraft_doc::Color::BLACK)), + live: Some(arrow.clone()), + ..Default::default() + }), + )); + + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let manifest = read_manifest(&bytes).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + let saved = &json["document"]["layers"][0]["content"]; + + assert_eq!(saved["kind"], "shape"); + assert!(saved["live"].is_null()); + assert_eq!(saved["arrow_live"]["kind"], "arrowLine"); + assert_eq!(saved["path"], serde_json::to_value(&path).unwrap()); + + // Simulate an old reader that knows no ArrowLine or arrow_live. + #[derive(serde::Deserialize)] + struct LegacyShape { + live: Option, + path: Path, + } + + #[derive(serde::Deserialize)] + #[serde(tag = "kind", rename_all = "camelCase")] + #[allow(dead_code)] + enum LegacyLive { + Line { from: [f64; 2], to: [f64; 2], weight: f64 }, + } + + let legacy: LegacyShape = serde_json::from_value(saved.clone()).unwrap(); + assert!(legacy.live.is_none()); + assert_eq!(legacy.path, path); + + // The new reader restores full parametric editability. + let loaded = load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded, doc); +} diff --git a/crates/format/tests/it/smart_contents.rs b/crates/format/tests/it/smart_contents.rs index ce91f85710..3202214014 100644 --- a/crates/format/tests/it/smart_contents.rs +++ b/crates/format/tests/it/smart_contents.rs @@ -141,3 +141,37 @@ fn extreme_smart_contents_ids_remap_without_poisoning_allocator() { assert!(SmartContentsId::fresh().0 < u64::MAX / 2); assert!(LayerId::fresh().0 < u64::MAX / 2); } + +#[test] +fn native_vector_display_preview_roundtrips_separately_from_original_payload() { + let source = Arc::new(b"%PDF-1.7\noriginal editable source".to_vec()); + let svg = Arc::new(br#""#.to_vec()); + let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source.clone() }, Affine::IDENTITY, None); + smart.vector_preview = Some(photocraft_doc::VectorPreview { source_hash: *blake3::hash(&source).as_bytes(), svg }); + let mut doc = Document::new("native vector", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new("vector", LayerContent::Smart(smart))); + let bytes = save_to_bytes(&doc, &Default::default()).unwrap(); + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + let missing_preview = rewrite_manifest(&bytes, |manifest| { + manifest["document"]["layers"][0]["content"]["vector_preview"]["svg"] = serde_json::json!("0".repeat(64)); + }); + let recovered = load_from_bytes(&missing_preview).unwrap(); + let LayerContent::Smart(recovered_smart) = &recovered.layers[0].content else { panic!("smart object lost") }; + assert!(recovered_smart.vector_preview.is_none(), "an unavailable display preview must not hide the original vector"); + assert_eq!(recovered_smart.source, SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source.clone() }); + let mut oversized = doc.clone(); + let LayerContent::Smart(oversized_smart) = &mut oversized.layers[0].content else { panic!("smart object lost") }; + oversized_smart.vector_preview.as_mut().unwrap().svg = Arc::new(vec![b'x'; (16 << 20) + 1]); + let oversized = save_to_bytes(&oversized, &Default::default()).unwrap(); + let recovered = load_from_bytes(&oversized).unwrap(); + let LayerContent::Smart(recovered_smart) = &recovered.layers[0].content else { panic!("smart object lost") }; + assert!(recovered_smart.vector_preview.is_none(), "an oversized optional preview is ignored before unbounded decompression"); + assert_eq!(recovered_smart.source, SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source.clone() }); + let legacy = rewrite_manifest(&bytes, |manifest| { + manifest["document"]["layers"][0]["content"].as_object_mut().unwrap().remove("vector_preview"); + }); + let back = load_from_bytes(&legacy).unwrap(); + let LayerContent::Smart(smart) = &back.layers[0].content else { panic!("smart object lost") }; + assert!(smart.vector_preview.is_none()); + assert_eq!(smart.source, SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: source }); +} diff --git a/crates/format/tests/scratch_retry.rs b/crates/format/tests/scratch_retry.rs new file mode 100644 index 0000000000..3d8a32f540 --- /dev/null +++ b/crates/format/tests/scratch_retry.rs @@ -0,0 +1,48 @@ +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Size, TileCoord}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn retry_after_failed_hashing_never_reuses_placeholder_hashes() { + let dir = std::env::temp_dir().join(format!("photocraft-format-scratch-retry-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Retry", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + for i in 0..2 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, byte) in bytes.iter_mut().enumerate() { + *byte = (j as u8).wrapping_mul(17).wrapping_add((i * 43) as u8); + } + } + doc.layers.push(layer); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + + let mut writer = photocraft_format::PcraftWriter::new(); + // This save discovers the failure itself, after entering the hash collection stage. + assert!(writer.save_zip(&doc, &Default::default()).is_err()); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for (_, tile) in doc.layers[0].surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + assert!(spill::check_integrity().is_ok()); + let (saved, _) = writer.save_zip(&doc, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&saved).unwrap(); + assert_eq!(loaded.layers, doc.layers); + drop((doc, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/gpu/examples/bench_stack_reuse.rs b/crates/gpu/examples/bench_stack_reuse.rs index 30745c3794..2014cee902 100644 --- a/crates/gpu/examples/bench_stack_reuse.rs +++ b/crates/gpu/examples/bench_stack_reuse.rs @@ -17,7 +17,8 @@ fn render(comp: &mut Compositor, device: &wgpu::Device, queue: &wgpu::Queue, doc fn main() -> Result<(), Box> { let quick = std::env::args().any(|a| a == "--quick"); let size = if quick { Size::new(1024, 768) } else { Size::new(6000, 4000) }; - let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&Default::default()))?; + let adapter = + pollster::block_on(wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()).request_adapter(&Default::default()))?; let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; let format = Compositor::preferred_acc_format(&adapter); println!("adapter={:?}, accumulation={format:?}, size={}x{}", adapter.get_info(), size.width, size.height); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 1a475827e8..91cc2ff662 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -73,14 +73,10 @@ const F_LAYER_SHAPE: u32 = 1048576u; // fx merge: B is the shape; layer = its co @group(1) @binding(6) var map_tex: texture_2d; @group(1) @binding(7) var pat_tex: texture_2d; +// The full-chunk triangle's vertex stage lives in `vertex_shader` (kit.rs): one entry point per +// pipeline build job, so no two threads share a compiled vertex shader. struct VOut { @builtin(position) pos: vec4 }; -@vertex -fn vs(@builtin(vertex_index) vi: u32) -> VOut { - let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); - return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0)); -} - fn local(pos: vec4) -> vec2 { return vec2(floor(pos.xy)); } fn doc_px(p: vec2) -> vec2 { return chunk.origin + p; } @@ -1000,8 +996,21 @@ fn fs_adjmix(in: VOut) -> @location(0) vec4 { 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); + // Fill below 100% in one of the special eight (compose::blend_back, psblend::blend_rgb_fill): + // the result pulled toward the mode's neutral colour; Hard Mix (cb - f(1 - cs)) / (1 - f). + let f = clamp(op.fill, 0.0, 1.0); + let special = f < 1.0 && fill_is_special(op.mode); + var cs = a.rgb; + if (special) { + var neutral = 0.0; + if (op.mode == 5 || op.mode == 6) { neutral = 1.0; } + if (op.mode == 16 || op.mode == 17) { neutral = 0.5; } + cs = vec3(neutral) + (a.rgb - vec3(neutral)) * f; + } + var bl = blend_rgb(op.mode, b.rgb, cs); + if (special && op.mode == 19) { bl = clamp((b.rgb - f * (vec3(1.0) - a.rgb)) / (1.0 - f), vec3(0.0), vec3(1.0)); } + // Keep bounded adjustment results exact at full coverage over HDR backdrops. + return vec4(b.rgb * (1.0 - k) + bl * k, b.a); } // Pass-through group coverage mixes premultiplied colour, then returns straight alpha. @@ -1025,8 +1034,8 @@ fn fs_lerp(in: VOut) -> @location(0) vec4 { return vec4(clamp(pm.rgb / al, vec3(0.0), vec3(1.0)), al); } if ((op.flags & F_BLEND_IF) != 0u) { - // compose::apply_blend_if: "This Layer" is C (the layer's own content with its masks), - // or B itself for an adjustment layer; "Underlying Layer" is A. + // compose::apply_blend_if: "This Layer" is C (the layer's own content with its masks, or an + // adjustment's result before Opacity, #9542), B when p4.y is set; "Underlying Layer" is A. var this_px = b; if (op.p4.y < 0.5) { this_px = textureLoad(tex_c, p, 0); @@ -1080,15 +1089,19 @@ fn blend_if_weight(this_px: vec4, under: vec4) -> f32 { if (px.a <= 0.0) { continue; } - // The CPU gets 8- and 16-bit levels exactly (k / 255 × 255 = k), but GPU arithmetic can - // land a few millionths below, which flips a hard threshold at that level: values that - // close to a level are that level. Real 8/16-bit values off a level are ≥ 1/257 away. - let raw = clamp(px.rgb, vec3(0.0), vec3(1.0)) * 255.0; - let v = select(raw, round(raw), abs(raw - round(raw)) < vec3(1e-4)); + // compose::blend_if_level: a value on a level's 16-bit step (Photoshop's 0..32768, + // round(c × 32768 / 255)) is that level, as Photoshop compares Blend If (#9521); every + // 8-bit level is on its own step, a few millionths of GPU drift included. + let x = clamp(px.rgb, vec3(0.0), vec3(1.0)); + let raw = x * 255.0; + let level = round(raw); + let v = select(raw, level, round(x * 32768.0) == round(level * 32768.0 / 255.0)); if (op.p4.x > 0.5) { k = k * bi_weight(0u, s, v.r) * bi_weight(1u, s, v.r); } else { - let gray = 0.299 * v.r + 0.587 * v.g + 0.114 * v.b; + // compose::blend_if_gray: (30 R + 59 G + 11 B) / 100, rounded half down on whole levels. + let sum = 30.0 * v.r + 59.0 * v.g + 11.0 * v.b; + let gray = select(sum / 100.0, floor((sum + 49.0) / 100.0), all(v == round(v))); k = k * bi_weight(0u, s, gray) * bi_weight(1u, s, v.r) * bi_weight(2u, s, v.g) * bi_weight(3u, s, v.b); } if (k <= 0.0) { @@ -1116,15 +1129,10 @@ fn wrap(i: i32, n: i32) -> i32 { return ((i % n) + n) % n; } -// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin), -// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. -fn pattern_sample(d: vec2) -> vec4 { - let dx = f32(d.x) + 0.5 - op.p3.x; - let dy = f32(d.y) + 0.5 - op.p3.y; - let c = op.p3.z; - let s = op.p3.w; - let u = (dx * c - dy * s) * op.p4.x - 0.5; - let v = (dx * s + dy * c) * op.p4.x - 0.5; +// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul). +fn pattern_tap(u0: f32, v0: f32) -> vec4 { + let u = u0 - 0.5; + let v = v0 - 0.5; let x0 = floor(u); let y0 = floor(v); let fx = u - x0; @@ -1143,6 +1151,28 @@ fn pattern_sample(d: vec2) -> vec4 { acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]); } } + return acc; +} + +// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin), +// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel +// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU. +fn pattern_sample(d: vec2) -> vec4 { + var n = 1; + if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); } + let c = op.p3.z; + let s = op.p3.w; + var acc = vec4(0.0); + for (var j = 0; j < n; j++) { + for (var i = 0; i < n; i++) { + let ox = (f32(i) + 0.5) / f32(n) - 0.5; + let oy = (f32(j) + 0.5) / f32(n) - 0.5; + let dx = f32(d.x) + 0.5 + ox - op.p3.x; + let dy = f32(d.y) + 0.5 + oy - op.p3.y; + acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x); + } + } + acc = acc / f32(n * n); if (acc.a <= 0.0) { return vec4(0.0); } return vec4(acc.rgb / acc.a, min(acc.a, 1.0)); } diff --git a/crates/gpu/src/consts.rs b/crates/gpu/src/consts.rs index 40081a55c8..536aea8a49 100644 --- a/crates/gpu/src/consts.rs +++ b/crates/gpu/src/consts.rs @@ -33,6 +33,9 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30; /// GPU memory pattern textures may hold before the least recently painted are dropped (those the /// current frame paints with always stay). pub const PATTERN_BUDGET: u64 = 256 << 20; +/// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which +/// holds a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`). +pub(crate) const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20; /// A pattern texture no frame has painted with for this many frames is dropped. pub const PATTERN_IDLE_FRAMES: u64 = 240; diff --git a/crates/gpu/src/convert.rs b/crates/gpu/src/convert.rs index 9dbfc81814..1c04f03e28 100644 --- a/crates/gpu/src/convert.rs +++ b/crates/gpu/src/convert.rs @@ -1,6 +1,7 @@ use super::batch::write_tile; use super::textures::{Resident, TexKind}; use super::{Arc, F, HashMap, I, PixelFormat, Rect, SampleType, Surface, TILE_SIZE, Tile, TileCoord, U, fx}; +use photocraft_raster::scratch; /// Distinct tiles converted per parallel batch: bounds the converted bytes held at once (64 tiles /// of a 16-bit page are 32 MiB). @@ -141,6 +142,8 @@ pub(crate) fn convert_tiles( _ => put(t, cs, &bytes.concat()), } } + // The texels are on their way to the GPU: the buffers serve the next batch. + parts.into_iter().for_each(scratch::give); } groups.len() } @@ -157,11 +160,13 @@ pub(crate) fn colour_managed(fmt: &PixelFormat, kind: TexKind) -> bool { /// Tile pixels in the texture's format. `tile = None` gives the default pixel everywhere. pub(crate) 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 mut out = scratch::take_cap(t.len()); + out.extend_from_slice(&t.bytes()); + return out; } let fmt = surface.format(); 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())) + && let Some(out) = rgb_gray16_to_f16(&fmt, &t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, &t.bytes())) { return out; } @@ -179,7 +184,7 @@ fn convert_rows(surface: &Surface, tile: Option<&Arc>, kind: TexKind, rect Some(_) => surface.read_region(rect), None => surface.default_pixel().repeat(n), }; - let mut out = Vec::with_capacity(n * kind.bytes_per_pixel()); + let mut out = scratch::take_cap(n * kind.bytes_per_pixel()); // The previous pixel and its colour. let mut last: Option<(usize, [f32; 4])> = None; for (i, px) in raw.chunks_exact(ch).enumerate() { @@ -209,6 +214,7 @@ fn convert_rows(surface: &Surface, tile: Option<&Arc>, kind: TexKind, rect TexKind::R32F => out.extend(px[0].to_le_bytes()), } } + scratch::give(raw); out } @@ -229,7 +235,7 @@ pub(crate) fn rgb_gray16_to_f16(fmt: &PixelFormat, bytes: &[u8]) -> Option [u8; 2] { let v = px.get(i * 2..i * 2 + 2).map_or(0, |b| u16::from_ne_bytes([b[0], b[1]])); @@ -261,7 +267,7 @@ pub(crate) fn rgb_gray32_to_f16(fmt: &PixelFormat, bytes: &[u8]) -> Option [u8; 2] { let v = px.get(i * 4..i * 4 + 4).map_or(0.0, |b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])); diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index ceff607686..4f265a3202 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -336,8 +336,10 @@ fn bands(r: Rect, rows: i32) -> Vec { let mut out = Vec::new(); let mut y = r.y0; while y < r.y1 { - out.push(Rect::new(r.x0, y, r.x1, (y + rows).min(r.y1))); - y += rows; + // Saturating near the coordinate ceiling: a wrapped edge would never reach `y1`. + let end = y.saturating_add(rows.max(1)).min(r.y1); + out.push(Rect::new(r.x0, y, r.x1, end)); + y = end; } out } @@ -427,12 +429,20 @@ fn fields_in_windows( }) } -/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. +/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. Callers hand the +/// result back with `photocraft_raster::scratch::give` once they have read it. pub(crate) fn shape(doc: &photocraft_doc::Document, layer: &Layer, r: Rect) -> Vec { if r.is_empty() { return Vec::new(); } - par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band)).concat() + let parts = par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band)); + let mut shape = photocraft_raster::scratch::take_cap(parts.iter().map(Vec::len).sum()); + // A band can be a pooled mask (Transparency Shapes Layer off): hand each back once copied. + for part in parts { + shape.extend_from_slice(&part); + photocraft_raster::scratch::give(part); + } + shape } /// Write `v` (row-major over `r`) into the region-sized `shape`. diff --git a/crates/gpu/src/fx/tests.rs b/crates/gpu/src/fx/tests.rs index 1622da28e2..d796288eeb 100644 --- a/crates/gpu/src/fx/tests.rs +++ b/crates/gpu/src/fx/tests.rs @@ -308,3 +308,12 @@ fn shape_tiles_and_group_pins_hold_addresses_not_pixels() { drop(group); assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels"); } + +#[test] +fn bands_end_at_the_coordinate_ceiling() { + let r = Rect::new(0, i32::MAX - 100, 8, i32::MAX); + let bands = super::bands(r, 64); + assert_eq!(bands.len(), 2, "{bands:?}"); + assert_eq!((bands[0].y0, bands[0].y1, bands[1].y1), (i32::MAX - 100, i32::MAX - 36, i32::MAX)); + assert_eq!(super::bands(Rect::new(0, 0, 8, 10), 0).len(), 10, "a zero step still makes progress"); +} diff --git a/crates/gpu/src/fx_passes.rs b/crates/gpu/src/fx_passes.rs index f3f5ce6da3..9472141066 100644 --- a/crates/gpu/src/fx_passes.rs +++ b/crates/gpu/src/fx_passes.rs @@ -60,6 +60,7 @@ impl Compositor { e.region = region; rebuild = false; } + photocraft_raster::scratch::give(v); } if rebuild { let shape = Tex::map(device, "pc_fx_shape", region, MAP32); @@ -108,6 +109,7 @@ impl Compositor { fx::paste(&mut e.shape_cpu, region, damage, &v); e.shape.write_r32(queue, region, damage, &v); staged += v.len() as u64 * 4; + photocraft_raster::scratch::give(v); } // Programs, and the distance fields they read (max reach per field). diff --git a/crates/gpu/src/kit.rs b/crates/gpu/src/kit.rs index 6ac1b76dc3..12755b2dbf 100644 --- a/crates/gpu/src/kit.rs +++ b/crates/gpu/src/kit.rs @@ -1,4 +1,4 @@ -use super::consts::{CHUNK_UNIFORM, OP_UNIFORM, STRIDE}; +use super::consts::{CHUNK_UNIFORM, MAP16, MAP32, OP_UNIFORM, STRIDE}; use super::convert::{lut_texture, words}; use super::textures::{ResKey, ResidentRef}; use super::{Arc, HashMap, I, Kernel, NonZeroU64, Rect}; @@ -127,6 +127,48 @@ pub(crate) fn uniform_entry(binding: u32, size: u64) -> wgpu::BindGroupLayoutEnt /// The compositor's WGSL source (exposed for validation in tests). pub const SHADER: &str = include_str!("compose.wgsl"); +/// One thread's share of the compositor's pipelines: the vertex entry point (in +/// [`vertex_shader`]) and the (kernel, target format) pairs it builds. +pub(crate) struct BuildJob { + pub(crate) vertex: String, + pub(crate) keys: Vec<(Kernel, wgpu::TextureFormat)>, +} + +/// The compositor's pipelines, grouped by the thread that builds them: one job per kernel, and +/// an effect-map kernel's R32F and R16F pipelines in the same job. Each job has its own vertex +/// entry point, so no two jobs share a shader (see `Compositor::build`). +pub(crate) fn build_jobs(acc_format: wgpu::TextureFormat) -> Vec { + let one = |k: Kernel| if k.is_map() { vec![(k, MAP32), (k, MAP16)] } else { vec![(k, acc_format)] }; + let (plain, maps): (Vec, Vec) = Kernel::DRAWN.iter().partition(|k| !k.is_map()); + plain.into_iter().chain(maps).enumerate().map(|(job, k)| BuildJob { vertex: format!("vs_{job}"), keys: one(k) }).collect() +} + +/// The full-chunk triangle's vertex stage, once per build job under that job's name (`vs_0`, +/// `vs_1`, ...): the same code under distinct names, so each job compiles its own vertex shader. +/// +/// A workaround, not a design: wgpu-hal's D3D12 shader cache shares a compiled FXC blob between +/// threads, and FXC's blobs don't count references atomically, so a shared one can be freed +/// while still cached (gfx-rs/wgpu#10547, #9173). Each copy costs one compile of a three-line +/// shader. Once a wgpu release fixes the cache, go back to one `vs` in `compose.wgsl` and let +/// `build_jobs` split map kernels by format again. +pub(crate) fn vertex_shader(jobs: &[BuildJob]) -> String { + let mut s = String::from( + "struct VOut { @builtin(position) pos: vec4 }; +", + ); + for job in jobs { + s.push_str(&format!( + "@vertex fn {}(@builtin(vertex_index) vi: u32) -> VOut {{ + let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); + return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0)); +}} +", + job.vertex + )); + } + s +} + /// Pass-level resources resolved for one page cell: resident pages (texture, mask) and effect /// maps (view and region) per pass. pub(crate) struct Bound { diff --git a/crates/gpu/src/plan/planner.rs b/crates/gpu/src/plan/planner.rs index 88de5a81b4..ef0924640a 100644 --- a/crates/gpu/src/plan/planner.rs +++ b/crates/gpu/src/plan/planner.rs @@ -95,7 +95,8 @@ pub const F_QUANT: u32 = 32768; pub const F_ADD_DIFF: u32 = 16384; /// `Lerp`: B mixed back towards A (the backdrop) by the layer's Blend If weight -/// (`compose::apply_blend_if`), from C ("This Layer", or B itself for an adjustment layer, `p4.y`) +/// (`compose::apply_blend_if`), from C ("This Layer": an adjustment layer's unmixed result; B when +/// `p4.y` is set) /// and A ("Underlying Layer"). The ranges are packed in `p0..p3` (`blend_if_params`); `p4.x` marks /// a one-channel document. pub const F_BLEND_IF: u32 = 524288; @@ -279,13 +280,17 @@ impl<'a> Planner<'a> { /// `compose::apply_blend_if`: `after` (the backdrop with `layer` drawn) mixed back towards /// `before` (the backdrop without it) where the layer's Blend If ranges hide it. "This Layer" - /// is the layer's own content with its masks; an adjustment layer is judged by its result. - /// Consumes both slots. + /// is the layer's own content with its masks; an adjustment layer is judged by its result at + /// full strength, before Opacity and its mask (#9542). Consumes both slots. fn blend_if(&mut self, layer: &'a Layer, before: Slot, after: Slot) -> Result { - let own = match layer.content { - LayerContent::Adjustment(_) => None, - _ => Some(self.content(layer)?), + let own = match &layer.content { + LayerContent::Adjustment(adj) => { + let b = self.retain(before); + self.adjust(adj, b)? + } + _ => self.content(layer)?, }; + let own = Some(own); let mut p = Pass::new(Kernel::Lerp, 0); p.a = Some(before); p.b = Some(after); @@ -398,7 +403,8 @@ impl<'a> Planner<'a> { let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect(); // Below 100% fill a pass-through group renders isolated (as the CPU compositor does), and - // so does one that is the base of a clipping group and holds an adjustment layer. + // so does one that is the base of a clipping group and holds an adjustment layer, or one + // with Blend If (#9554). let clip_base_adjusts = !visible_clipped.is_empty() && matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_)))); if let LayerContent::Group(g) = &layer.content @@ -406,6 +412,7 @@ impl<'a> Planner<'a> { && layer.fill_opacity >= 1.0 && !has_effects(layer) && !clip_base_adjusts + && !photocraft_compose::blend_if_active(layer, self.cx.mode) { let before = self.retain(backdrop); let mut after = self.stack(&g.children, backdrop)?; @@ -467,7 +474,7 @@ impl<'a> Planner<'a> { p.a = Some(before); p.b = Some(adjusted); p.mode = layer.blend; - p.opacity = opacity; + (p.opacity, p.fill) = adjustment_fill(layer, opacity); p.mask = self.mask_use(layer); return Ok(self.emit(p)); } @@ -732,7 +739,8 @@ impl<'a> Planner<'a> { p.a = Some(before); p.b = Some(adjusted); p.mode = layer.blend; - p.opacity = opacity; + // A clipped adjustment takes Fill as coverage in every mode. + (p.opacity, p.fill) = (opacity, 1.0); p.mask = self.mask_use(layer); return Ok(self.emit(p)); } @@ -1411,3 +1419,8 @@ impl<'a> Planner<'a> { self.emit(p) } } + +/// Adjustment Fill belongs inside the special blend modes; other modes use it as coverage. +fn adjustment_fill(layer: &Layer, opacity: f32) -> (f32, f32) { + if layer.fill_opacity < 1.0 && photocraft_compose::psblend::fill_is_special(layer.blend) { (layer.opacity, layer.fill_opacity) } else { (opacity, 1.0) } +} diff --git a/crates/gpu/src/setup.rs b/crates/gpu/src/setup.rs index c5b5c90384..1ce8087ab9 100644 --- a/crates/gpu/src/setup.rs +++ b/crates/gpu/src/setup.rs @@ -1,6 +1,6 @@ use super::batch::tile_grid; -use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; -use super::kit::{Kit, SHADER, tex_entry, uniform_entry}; +use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET}; +use super::kit::{Kit, SHADER, build_jobs, tex_entry, uniform_entry, vertex_shader}; use super::paging::{cell_rect, field_reaches, first_error, grid_rects}; use super::textures::{FxEntry, Resident, Tex, TexKind}; use super::{ @@ -47,8 +47,22 @@ impl Compositor { /// built in parallel, each inside its own error scope on the thread that builds it (wgpu's /// scopes are per thread): on DX12, FXC compiles one shader at a time and the ~30 pipelines /// took about 8 s on one core (#6419). + /// + /// No shader is used by two threads at once (#9173): wgpu-hal's D3D12 backend caches each + /// compiled shader and hands every pipeline a reference to it, but FXC's blobs count + /// references without atomics (gfx-rs/wgpu#10547), so threads sharing one could free it + /// while the cache still held it, and dropping the device then aborted the process. So each + /// job has its own vertex entry point and fragment shaders ([`build_jobs`]), and builds of + /// several compositors take turns. fn build(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> (Self, Option) { + static BUILDING: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _building = BUILDING.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_compose"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let jobs = build_jobs(acc_format); + let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pc_compose_vertex"), + source: wgpu::ShaderSource::Wgsl(vertex_shader(&jobs).into()), + }); let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_compose_uniforms"), entries: &[uniform_entry(0, CHUNK_UNIFORM), uniform_entry(1, OP_UNIFORM)], @@ -63,12 +77,12 @@ impl Compositor { immediate_size: 0, }); // `None` only for kernels without a fragment entry point, which are never drawn. - let pipeline = |k: Kernel, format: wgpu::TextureFormat| { + let pipeline = |k: Kernel, format: wgpu::TextureFormat, vs: &str| { let entry = k.entry()?; Some(device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(entry), layout: Some(&layout), - vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + vertex: wgpu::VertexState { module: &vertex, entry_point: Some(vs), buffers: &[], compilation_options: Default::default() }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), @@ -84,12 +98,16 @@ impl Compositor { }; // Effect maps render to R32Float / R16Float, which some adapters (e.g. GL without float // render targets) can't: then layer effects use the CPU compositor and the rest stays here. - let keys = Kernel::DRAWN.iter().filter(|k| !k.is_map()).map(|&k| (k, acc_format)); - let keys: Vec<_> = keys.chain(Kernel::DRAWN.iter().filter(|k| k.is_map()).flat_map(|&k| [(k, MAP32), (k, MAP16)])).collect(); - let built = fx::par_map(keys, |key| { - let (built, error) = first_error(device, || pipeline(key.0, key.1)); - (key, built, error) + let built = fx::par_map(jobs, |job| { + job.keys + .into_iter() + .map(|key| { + let (built, error) = first_error(device, || pipeline(key.0, key.1, &job.vertex)); + (key, built, error) + }) + .collect::>() }); + let built = built.into_iter().flatten(); let (mut pipelines, mut maps) = (HashMap::new(), Vec::new()); let (mut error, mut map_error) = (None, None); for (key, built, e) in built { @@ -117,8 +135,23 @@ impl Compositor { pipelines.extend(maps); } let dummy = Tex::new(device, "pc_compose_dummy", 1, 1, wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureUsages::TEXTURE_BINDING).view; - let comp = Self { - kit: Kit { pipelines, bgl0, bgl1, dummy }, + let kit = Kit { pipelines, bgl0, bgl1, dummy }; + let comp = Self::from_kit(kit, device.limits().max_texture_dimension_2d, acc_format, map_error.is_none()); + (comp, error) + } + + /// Create an independent compositor on the same device, reusing compiled pipelines. + /// Texture caches, budgets, focus and timing start at their defaults; no document state is + /// shared. Render with the same device and queue as this compositor. + pub fn sibling(&self) -> Self { + let mut sibling = Self::from_kit(self.kit.clone(), self.device_max, self.acc_format, self.effect_maps); + sibling.health = self.health.clone(); + sibling + } + + fn from_kit(kit: Kit, device_max: u32, acc_format: wgpu::TextureFormat, effect_maps: bool) -> Self { + Self { + kit, pool: Vec::new(), residents: HashMap::new(), fx: HashMap::new(), @@ -129,7 +162,7 @@ impl Compositor { pattern_doc: DocId(0), pattern_budget: PATTERN_BUDGET, max_dim: 0, - device_max: device.limits().max_texture_dimension_2d, + device_max, page: PAGE, chunk: CHUNK, resident_budget: RESIDENT_BUDGET, @@ -146,13 +179,12 @@ impl Compositor { admit_freed: 0, fail_allocs: 0, acc_format, - effect_maps: map_error.is_none(), + effect_maps, cmyk: 0, health: None, timer: None, } - .with_texture_limit(device.limits().max_texture_dimension_2d); - (comp, error) + .with_texture_limit(device_max) } fn with_texture_limit(mut self, max: u32) -> Self { @@ -372,19 +404,32 @@ impl Compositor { if s.lut.as_ref().is_some_and(|l| l.len() > 4096) { return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name))); } + if let Some(pat) = &s.pattern { + self.check_pattern(pat)?; + } } } } for pass in &p.passes { - if let Some(pat) = pass.pattern - && (pat.width > self.device_max || pat.height > self.device_max) - { - return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); + if let Some(pat) = pass.pattern { + self.check_pattern(pat)?; } } Ok(()) } + /// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on + /// the CPU compositor. + fn check_pattern(&self, pat: &photocraft_doc::Pattern) -> Result<(), Unsupported> { + if pat.width > self.device_max || pat.height > self.device_max { + return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name))); + } + if u64::from(pat.width) * u64::from(pat.height) * 16 > MAX_PATTERN_TEXTURE_BYTES { + return Err(Unsupported(format!("pattern `{}` too large for a GPU texture; the CPU path paints it", pat.name))); + } + Ok(()) + } + /// Drop GPU textures of a closed document. pub fn forget_doc(&mut self, doc: DocId) { self.residents.retain(|_, r| r.doc != doc); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 281c67421e..09fe6129e7 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -132,6 +132,44 @@ fn half_roundtrip() { } } +/// #9173: no two pipeline build jobs share a shader, so no FXC blob is shared across threads. +#[test] +fn build_jobs_share_no_shader() { + use super::consts::{MAP16, MAP32}; + use super::kit::{build_jobs, vertex_shader}; + let jobs = build_jobs(wgpu::TextureFormat::Rgba32Float); + let mut seen = std::collections::HashSet::new(); + for job in &jobs { + let fragments: std::collections::HashSet<_> = job.keys.iter().filter_map(|k| k.0.entry()).collect(); + for f in fragments { + assert!(seen.insert(f.to_string()), "fragment shader {f} is built by two jobs"); + } + assert!(seen.insert(job.vertex.clone()), "vertex entry {} is shared", job.vertex); + } + // Every drawn pipeline is still built, in the same formats. + let keys: Vec<_> = jobs.iter().flat_map(|job| job.keys.iter().copied()).collect(); + let plain = Kernel::DRAWN.iter().filter(|k| !k.is_map()).count(); + assert_eq!(keys.len(), plain + 2 * (Kernel::DRAWN.len() - plain)); + for k in Kernel::DRAWN { + let formats: Vec<_> = keys.iter().filter(|key| key.0 == k).map(|key| key.1).collect(); + let want = if k.is_map() { vec![MAP32, MAP16] } else { vec![wgpu::TextureFormat::Rgba32Float] }; + assert_eq!(formats, want, "{k:?}"); + } + // The vertex module validates and has every job's entry point. + let src = vertex_shader(&jobs); + let module = wgpu::naga::front::wgsl::parse_str(&src).unwrap_or_else(|e| panic!("{}", e.emit_to_string(&src))); + wgpu::naga::valid::Validator::new(wgpu::naga::valid::ValidationFlags::all(), wgpu::naga::valid::Capabilities::empty()) + .validate(&module) + .unwrap_or_else(|e| panic!("{e:?}")); + for job in &jobs { + assert!(module.entry_points.iter().any(|ep| ep.name == job.vertex && ep.stage == wgpu::naga::ShaderStage::Vertex), "missing {}", job.vertex); + } + // The fragment module has no vertex stage, so the build can't fall back to one shared `vs`. + let compose = wgpu::naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); + let shared: Vec<_> = compose.entry_points.iter().filter(|ep| ep.stage == wgpu::naga::ShaderStage::Vertex).map(|ep| ep.name.as_str()).collect(); + assert!(shared.is_empty(), "compose.wgsl has a vertex entry point every job would share: {shared:?}"); +} + #[test] fn shader_validates() { let module = wgpu::naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); @@ -303,7 +341,7 @@ fn sixteen_and_thirty_two_bit_fast_paths_match_the_general_one() { let data: Vec = (0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) as f32 / u32::MAX as f32 * hdr - (hdr - 1.0) / 4.0).collect(); s.write_region(r, &data); let t = s.tile(TileCoord::new(0, 0)).expect("written").clone(); - let fast = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())).expect("fast path"); + let fast = rgb_gray16_to_f16(&fmt, &t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, &t.bytes())).expect("fast path"); let raw = s.read_region(r); let general: Vec = raw.chunks_exact(fmt.channels()).flat_map(|px| photocraft_raster::to_rgba(&fmt, px).map(|x| f32_to_f16(x).to_le_bytes())).flatten().collect(); diff --git a/crates/gpu/tests/it/device_loss.rs b/crates/gpu/tests/it/device_loss.rs index 3875bd4a89..2e8839de12 100644 --- a/crates/gpu/tests/it/device_loss.rs +++ b/crates/gpu/tests/it/device_loss.rs @@ -12,7 +12,7 @@ use photocraft_raster::Surface; use crate::GPU_LOCK; fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { - let instance = wgpu::Instance::default(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) { Ok(a) => a, Err(e) => { diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 901a21365b..e8ab0b91c4 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -85,6 +85,15 @@ fn hdr_adjustments_preserve_range_and_cpu_parity() { } } +/// Phase timings for diagnosing expensive parity checks; nextest shows them only for the +/// selected slow Windows tests, without changing their timeout or pass criteria. +fn timed(what: &str, run: impl FnOnce() -> T) -> T { + let start = std::time::Instant::now(); + let result = run(); + eprintln!("parity phase {what}: {:?}", start.elapsed()); + result +} + /// The default adapter, or the one wgpu's `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choose: e.g. /// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc` tests D3D12 with FXC, the app's default on Windows, /// where the default adapter may be Vulkan. A backend asked for that can't run the tests fails @@ -126,8 +135,8 @@ fn gpu() -> Option { None } }; - let comp = compositor()?; - let mut paged = compositor()?; + let comp = timed("pipeline creation", compositor)?; + let mut paged = comp.sibling(); paged.set_texture_limit(PAGED_LIMIT); Some(Gpu { device, queue, comp, paged, _lock: lock }) } @@ -136,7 +145,10 @@ fn gpu() -> Option { /// The returned guard holds [`GPU_LOCK`] until the device is dropped. fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue, std::sync::MutexGuard<'static, ()>)> { let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?; + let adapter = pollster::block_on( + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()).request_adapter(&wgpu::RequestAdapterOptions::default()), + ) + .ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; Some((adapter, device, queue, lock)) } @@ -208,10 +220,11 @@ fn worst_diff(cpu: &[[f32; 4]], gpu: &[[f32; 4]]) -> (f32, usize) { /// GPU (whole textures, then pages) vs CPU over `rect`: Err with the worst pixel if over the /// tolerance, else the stats of the unpaged render. fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result { - let cpu = photocraft_compose::render(doc, rect); + let cpu = timed("CPU reference", || photocraft_compose::render(doc, rect)); let mut first = None; for (label, comp) in [("", &mut g.comp), (" (paged)", &mut g.paged)] { - let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect).map_err(|e| format!("{what}{label}: {e}"))?; + let (out, stats) = timed(&format!("{what}{label} GPU render/readback"), || photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect)) + .map_err(|e| format!("{what}{label}: {e}"))?; let worst = worst_diff(&cpu.px, &out); let w = rect.width() as usize; let (x, y) = (rect.x0 + (worst.1 % w) as i32, rect.y0 + (worst.1 / w) as i32); @@ -380,7 +393,7 @@ fn prefix_follows_an_edit_up_the_stack_and_stays_exact() { #[test] fn chunks_keep_their_own_checkpoint_after_a_partial_refresh() { let Some(mut g) = gpu() else { return }; - let mut uncached = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut uncached = g.comp.sibling(); uncached.set_texture_limit(PAGED_LIMIT); uncached.set_composite_cache_budget(0); let mut doc = base_doc(600, 300); @@ -626,6 +639,47 @@ fn special_eight_fill_matches_cpu() { } } +/// The same for adjustment layers (#9501): the GPU's adjustment mix takes Fill inside the special +/// eight's blend, as compose's adjustment branch does, with a mask and Opacity on top. +#[test] +fn special_eight_fill_on_adjustment_layers_matches_cpu() { + let Some(mut g) = gpu() else { return }; + let special = [ + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::VividLight, + BlendMode::LinearLight, + BlendMode::HardMix, + BlendMode::Difference, + ]; + for mode in special { + for fill in [0.3, 0.6] { + let mut d = base_doc(64, 48); + d.layers[0] = noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 5, 1.0); + let mut l = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + l.blend = mode; + l.opacity = 0.7; + l.fill_opacity = fill; + d.layers.push(l); + check(&mut g, &d, &format!("adjustment {mode:?} fill {fill}")); + // CodeRabbit on #9512: clipped to a pixel layer, the adjustment takes Fill as + // coverage on both paths (compose's clipped branch, the planner's `atop`). + let mut d = base_doc(64, 48); + d.layers[0] = noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 5, 1.0); + d.layers.push(noise_layer("base", PixelFormat::RGBA8, Rect::from_xywh(8, 6, 40, 30), 9, 1.0)); + let mut l = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)); + l.blend = mode; + l.opacity = 0.7; + l.fill_opacity = fill; + l.clipped = true; + d.layers.push(l); + check(&mut g, &d, &format!("clipped adjustment {mode:?} fill {fill}")); + } + } +} + #[test] fn xor_rounding_on_half_float_targets() { let Some((_adapter, device, queue, _lock)) = any_device() else { return }; @@ -845,6 +899,18 @@ fn groups_masks_and_clipping() { d.layers.extend([base, clip]); check(&mut g, &d, "clipped Dissolve opacity/fill CPU/GPU parity"); } + // #4921: a clipped Dissolve layer keeps binary coverage at partial opacity/fill. + 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 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 +957,7 @@ fn hard_stop_gradients_match_cpu_or_fall_back() { Ok(out) => out, Err(e) => { assert!(e.0.contains("coincident gradient stops"), "{e}"); - photocraft_compose::render(&d, d.bounds()).px + photocraft_compose::render(&d, d.bounds()).into_px() } }; let worst = worst_diff(&cpu.px, &out); @@ -1259,7 +1325,7 @@ fn growing_layer_keeps_its_resident_tiles() { let what = format!("{fmt:?} dab {i}"); let s = diff_rect(&mut g, &d, d.bounds(), &what).unwrap_or_else(|e| panic!("{e}")); assert!(s.tiles_uploaded <= 2, "{what}: only the dab's tiles upload: {s:?}"); - let mut fresh = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut fresh = g.comp.sibling(); let want = render_to_vec(&mut fresh, &g.device, &g.queue, &d, d.bounds()).unwrap(); assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).unwrap() == want, "{what}: same pixels as a fresh upload"); } @@ -1332,28 +1398,42 @@ fn small_layers_composite_over_their_bounds() { check(&mut g, &big, "small layers across chunks"); } -#[test] -fn many_layers_submit_inside_one_chunk() { +fn many_layers_submit_inside_one_chunk_mode(paged: bool) { // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is // invalid"). 1200 layers need well over 1000 passes in the document's only chunk: each is // masked, so it is drawn in a pass of its own rather than batched with its neighbours. let Some(mut g) = gpu() else { return }; - let mut d = base_doc(64, 48); + // Submission count, not image area, triggers the command-buffer exhaustion. + // Keep all 1200 masked passes and blend modes, with a smaller pixel oracle. + let mut d = base_doc(16, 12); let modes: Vec = BlendMode::layer_modes().collect(); for (i, mode) in modes.iter().copied().cycle().take(1200).enumerate() { - let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); + let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 16, 12), 500 + i as u32, 0.0); l.blend = mode; l.opacity = 0.9; - l.mask = Some(mask(Rect::from_xywh(0, 0, 64, 48), 3000 + i as u32, 1.0)); + l.mask = Some(mask(Rect::from_xywh(0, 0, 16, 12), 3000 + i as u32, 1.0)); d.layers.push(l); } - let stats = diff_rect(&mut g, &d, d.bounds(), "1200 layers").unwrap_or_else(|e| panic!("{e}")); + let cpu = photocraft_compose::render(&d, d.bounds()); + let comp = if paged { &mut g.paged } else { &mut g.comp }; + let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, &d, d.bounds()).unwrap(); + assert!(worst_diff(&cpu.px, &out).0 <= TOL, "1200 layers, paged={paged}"); assert_eq!(stats.layers_batched, 0, "{stats:?}"); assert_eq!(stats.chunks, 1); assert!(stats.flushes > 0, "{stats:?}"); } +#[test] +fn many_layers_submit_inside_one_chunk_whole() { + many_layers_submit_inside_one_chunk_mode(false); +} + +#[test] +fn many_layers_submit_inside_one_chunk_paged() { + many_layers_submit_inside_one_chunk_mode(true); +} + #[test] fn runs_of_plain_layers_blend_in_one_pass() { // Consecutive plain layers are drawn several to a pass (`fs_layerbatch`). Every layer mode at @@ -2175,6 +2255,8 @@ fn blend_if() { // An adjustment judged by its result, and a clipped layer. let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); adj.blend_if.set(e, pair); + // Judged by its result before Opacity (#9542). + adj.opacity = 0.4; d.layers[1] = l.clone(); d.layers[1].blend_if = Default::default(); d.layers.push(adj); @@ -2194,6 +2276,25 @@ fn blend_if() { check(&mut g, &d, "gray blend if"); } +/// A Pass Through group with Blend If renders isolated on the GPU as on the CPU (#9554): an +/// adjustment inside it leaves the layers beneath alone. +#[test] +fn pass_through_group_with_blend_if_is_isolated() { + use photocraft_doc::BlendRange; + let Some(mut g) = gpu() else { return }; + for gated in [true, false] { + let mut d = base_doc(64, 48); + d.layers[0] = noise_layer("bg", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 5, 1.0); + let mut grp = Layer::group("group", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]); + grp.blend = BlendMode::PassThrough; + if gated { + grp.blend_if.set(1, [BlendRange::FULL, BlendRange { black: [0, 0], white: [110, 110] }]); + } + d.layers.push(grp); + check(&mut g, &d, &format!("pass-through group, Blend If {gated}")); + } +} + #[test] fn channel_restrictions() { let Some(mut g) = gpu() else { return }; @@ -2609,6 +2710,27 @@ fn closed_documents_release_their_pattern_textures() { check(&mut g, &a, "the pattern is uploaded again after being forgotten"); } +/// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit +/// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which +/// holds a large 8-bit pattern at a quarter of the texture's size. +#[test] +fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() { + let Some(mut g) = gpu() else { return }; + let mut d = Document::new("big pattern", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + // 100 MB as RGBA8, 400 MB as an RGBA32F texture. + let surface = photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]); + let pat = Pattern { id: "big".into(), name: "big".into(), width: 5000, height: 5000, surface }; + assert!(pat.is_paintable()); + d.layers.push(Layer::new( + "pattern", + LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }), + )); + d.patterns.push(pat); + assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "the GPU path declines it"); + assert_eq!(g.comp.pattern_cache_bytes(), 0, "nothing was uploaded"); + assert!(photocraft_compose::flatten(&d).px.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]), "the CPU compositor paints it"); +} + /// #4971: pattern textures are bounded while their documents stay open, by a byte budget and /// by age. What the current frame paints with is never dropped. #[test] @@ -2688,7 +2810,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() { fn pattern_fill_layers() { let Some(mut g) = gpu() else { return }; let pat = checker_pattern(); - for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] { + for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] { let mut d = base_doc(70, 50); d.patterns.push(pat.clone()); let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) }; @@ -2900,8 +3022,9 @@ fn pages_are_evicted_under_the_budget() { let page = u64::from(g.paged.page_size()).pow(2) * 4; g.paged.set_resident_budget(page); for round in 0..3 { - 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(); + let cpu = timed("eviction CPU reference", || photocraft_compose::flatten(&d)); + let (out, s) = + timed("eviction GPU render/readback", || photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds())).unwrap(); 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.evicted > 0, "round {round}: {s:?}"); @@ -3003,7 +3126,7 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); let Some(g) = gpu() else { return }; let d = big_doc(); - let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut comp = g.comp.sibling(); // Exercise input-page residency itself: prefix hits can legitimately skip those pages. comp.set_composite_cache_budget(0); comp.set_texture_limit(PAGED_LIMIT); @@ -3022,7 +3145,7 @@ fn the_focused_view_stays_resident_after_a_full_refresh() { assert!(s.evicted > 0, "{s:?}"); let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, view).unwrap(); assert_eq!(s.tiles_uploaded, 0, "{s:?}"); - let cpu = photocraft_compose::render(&d, view); + let cpu = timed("focused-view CPU reference", || photocraft_compose::render(&d, view)); assert!(worst_diff(&cpu.px, &out).0 <= TOL); } } @@ -3032,7 +3155,7 @@ fn layer_pages_that_just_miss_borrow_the_effect_map_share() { let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner()); let Some(g) = gpu() else { return }; let d = big_doc(); - let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap(); + let mut comp = g.comp.sibling(); comp.set_texture_limit(PAGED_LIMIT); let need = comp.working_set(&d, d.bounds()); // Pages get three quarters of the budget and effect maps an eighth: at 5/4 of the need the @@ -3085,6 +3208,33 @@ fn pass_through_clipping_base_with_an_adjustment() { check(&mut g, &d, "pass-through clipping base with an adjustment"); } +/// Dissolve's adjustment/effect decisions use document coordinates, even in offset regions +/// and in the paged compositor. The clipped path comes from #4992. +#[test] +fn dissolve_adjustments_and_effects_match_cpu_in_offset_regions() { + let Some(mut g) = gpu() else { return }; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for effects in [false, true] { + let mut d = Document::new("Dissolve", Size::new(300, 280), ColorMode::Rgb, depth); + d.layers.push(noise_layer("base", d.pixel_format(), Rect::new(0, 0, 300, 280), 19, 0.4)); + let mut layer = if effects { + let mut l = noise_layer("effect", d.pixel_format(), Rect::new(-8, 13, 290, 270), 27, 0.6); + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 0.7), color: Color::rgb(0.2, 0.8, 0.4) }]; + l + } else { + Layer::new("adjustment", LayerContent::Adjustment(Adjustment::Invert)) + }; + layer.blend = BlendMode::Dissolve; + layer.opacity = 0.37; + layer.mask = Some(mask(Rect::new(11, 7, 287, 265), 43, 0.0)); + d.layers.push(layer); + let label = format!("Dissolve effects={effects} {depth:?}"); + fx_check(&mut g, &d, &label); + diff_rect(&mut g, &d, Rect::new(17, 23, 281, 269), &label).unwrap(); + } + } +} + /// Transparency Shapes Layer off changes the special eight's blend (the layer's alpha blends as /// Fill, #5692); the GPU has no pass for it and leaves such documents to the CPU. Other modes look /// the same either way and stay on the GPU. @@ -3193,3 +3343,76 @@ fn dissolve_clipped_adjustment_matches_cpu() { diff_rect(&mut g, &d, Rect::new(250, 1, 270, 20), "clipped adjustment offset region").unwrap_or_else(|e| panic!("{e}")); } } + +/// Integration of #9509 and #9512: coverage endpoints, Fill, masks and clipping share the CPU oracle. +#[test] +fn integration_adjustment_fill_opacity_masks_and_hdr_match_cpu() { + let Some(mut g) = gpu() else { return }; + for clipped in [false, true] { + for mode in [BlendMode::Normal, BlendMode::ColorDodge, BlendMode::LinearBurn] { + for fill in [0.0, 0.5, 1.0] { + for opacity in [0.5, 1.0] { + for coverage in [0.5, 1.0] { + let mut d = Document::new("combined adjustment", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + let mut base = Layer::raster("base", PixelFormat::RGBA32F); + base.surface_mut().unwrap().write_pixel(0, 0, &[0.25, 0.5, 0.75, 1.0]); + let look = Adjustment::ColorLookup { + name: "half".into(), + lut: Some(std::sync::Arc::new(vec![0.5; 24])), + size: 2, + tetrahedral: false, + dither: false, + source: None, + }; + let mut layer = Layer::new("adjust", LayerContent::Adjustment(look)); + layer.blend = mode; + layer.clipped = clipped; + layer.fill_opacity = fill; + layer.opacity = opacity; + let mut mask = LayerMask::reveal_all(); + mask.surface = photocraft_raster::Surface::with_default( + PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }, + &[coverage], + ); + layer.mask = Some(mask); + d.layers = vec![base, layer]; + check(&mut g, &d, &format!("combined {mode:?} fill={fill} opacity={opacity} mask={coverage} clipped={clipped}")); + } + } + } + } + } + // The existing GPU harness requires float32 accumulation. The CPU regression separately + // covers 1e8 inputs, beyond the range of half-float source textures on some adapters. + let mut d = Document::new("HDR full coverage", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + let mut base = Layer::raster("HDR", PixelFormat::RGBA32F); + base.surface_mut().unwrap().write_pixel(0, 0, &[1.0e4, 1.0e4, 1.0e4, 1.0]); + let look = Adjustment::ColorLookup { + name: "half".into(), + lut: Some(std::sync::Arc::new(vec![0.5; 24])), + size: 2, + tetrahedral: false, + dither: false, + source: None, + }; + d.layers = vec![base, Layer::new("lookup", LayerContent::Adjustment(look))]; + check(&mut g, &d, "HDR full coverage with bounded result"); +} + +/// Siblings share only immutable GPU objects, not resident pages or document caches. +#[test] +fn sibling_compositors_have_independent_residency_and_configuration() { + let Some(mut g) = gpu() else { return }; + let d = base_doc(300, 300); + let mut sibling = g.comp.sibling(); + assert_eq!(sibling.resident_bytes(), 0); + sibling.set_texture_limit(PAGED_LIMIT); + sibling.set_resident_budget(0); + let (out, _) = photocraft_gpu::render_to_vec_stats(&mut sibling, &g.device, &g.queue, &d, d.bounds()).unwrap(); + assert!(worst_diff(&photocraft_compose::render(&d, d.bounds()).px, &out).0 <= TOL); + assert_eq!(g.comp.resident_bytes(), 0, "the original has not rendered"); + let (out, _) = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).unwrap(); + assert!(worst_diff(&photocraft_compose::render(&d, d.bounds()).px, &out).0 <= TOL); + assert!(g.comp.resident_bytes() > 0); + assert_ne!(g.comp.resident_budget(), sibling.resident_budget()); +} diff --git a/crates/gpu/tests/it/pipeline_errors.rs b/crates/gpu/tests/it/pipeline_errors.rs index 28d7a56791..fc1cd43828 100644 --- a/crates/gpu/tests/it/pipeline_errors.rs +++ b/crates/gpu/tests/it/pipeline_errors.rs @@ -48,3 +48,55 @@ fn a_compositor_with_failed_pipelines_draws_without_panicking() { // Drawing into an unrenderable format can't succeed, but must not panic. let _ = render_to_vec(&mut comp, &device, &queue, &doc, doc.bounds()); } + +/// #9173: the compositor builds its pipelines on several threads (#6419). On Direct3D 12 with FXC, +/// wgpu-hal caches each compiled shader as an `ID3DBlob` whose reference count isn't atomic +/// (gfx-rs/wgpu#10547), so two threads building pipelines from the same shader could free a blob +/// the cache still holds; dropping the device then aborted the process (`null reference +/// produced` in `IUnknown::drop`). Each thread now builds from shaders no other thread uses. +/// +/// Stress test, ignored by default (it builds many compositors). Run it on WARP with FXC, e.g. +/// `WGPU_BACKEND=dx12 WGPU_DX12_COMPILER=fxc WGPU_ADAPTER_NAME="basic render" +/// PC_BUILD_ROUNDS=10 PC_BUILDS_PER_DEVICE=50 +/// cargo test -p photocraft-gpu --test it parallel_compositor_builds -- --ignored`. +/// Windows' full page heap makes the race abort more reliably. +#[test] +#[ignore = "stress test: builds many compositors"] +fn parallel_compositor_builds_leave_the_shader_cache_intact() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let var = |name: &str, default: u32| std::env::var(name).ok().and_then(|v| v.parse::().ok()).filter(|&n| n > 0).unwrap_or(default); + let (rounds, builds) = (var("PC_BUILD_ROUNDS", 4), var("PC_BUILDS_PER_DEVICE", 8)); + for round in 0..rounds { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let Ok(adapter) = pollster::block_on(wgpu::util::initialize_adapter_from_env_or_default(&instance, None)) else { + eprintln!("skipping: no adapter"); + return; + }; + if round == 0 { + let info = adapter.get_info(); + eprintln!("shader cache stress: {:?} ({}), {rounds} devices x {builds} builds", info.backend, info.name); + } + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { + eprintln!("skipping: no device"); + return; + }; + let format = Compositor::preferred_acc_format(&adapter); + // The first build compiles every shader; the rest are cache hits, so their threads take + // and release references to the cached shaders densely. The builds start together, so + // overlapping `Compositor::build` calls are exercised too (the process-wide lock). + std::thread::scope(|scope| { + let handles: Vec<_> = (0..builds) + .map(|_| { + scope.spawn(|| { + let built = Compositor::try_new_with_format(&device, format); + assert!(built.is_ok(), "round {round}: {:?}", built.err()); + }) + }) + .collect(); + for handle in handles { + handle.join().expect("compositor build thread panicked"); + } + }); + // Dropping the device releases every cached blob: a freed one aborts here. + } +} diff --git a/crates/gpu/tests/it/timing.rs b/crates/gpu/tests/it/timing.rs index 76348695c5..68ebc9d0be 100644 --- a/crates/gpu/tests/it/timing.rs +++ b/crates/gpu/tests/it/timing.rs @@ -38,7 +38,7 @@ fn block_on(f: F) -> F::Output { /// A real GPU with timestamp queries, or `None` with the reason printed. fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> { let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; - let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&options)) else { + let Ok(adapter) = block_on(wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()).request_adapter(&options)) else { eprintln!("SKIP gpu timing: no adapter"); return None; }; diff --git a/crates/gpu/tests/it/uniform_limits.rs b/crates/gpu/tests/it/uniform_limits.rs index 90dfa57aed..e562a8ca9d 100644 --- a/crates/gpu/tests/it/uniform_limits.rs +++ b/crates/gpu/tests/it/uniform_limits.rs @@ -20,7 +20,7 @@ fn fills_size(count: usize, size: Size) -> Document { } fn constrained_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { - let instance = wgpu::Instance::default(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) { Ok(adapter) => adapter, Err(error) => { diff --git a/crates/gpu/tests/it/vector_mask.rs b/crates/gpu/tests/it/vector_mask.rs index d0d7e7a012..feb8597b8e 100644 --- a/crates/gpu/tests/it/vector_mask.rs +++ b/crates/gpu/tests/it/vector_mask.rs @@ -6,7 +6,7 @@ use photocraft_doc::{Adjustment, Document, Effect, Layer, LayerContent, LayerMas use photocraft_geom::Rect; fn gpu() -> Option<(wgpu::Device, wgpu::Queue, photocraft_gpu::Compositor)> { - let instance = wgpu::Instance::default(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; // Exact comparison needs 32-bit float targets (see parity.rs); skip on adapters without them. diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 4eb91fb728..1c6a7324d6 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -14,6 +14,8 @@ workspace = true corpus = [] # HEIC/HEIF decode (forwarded to photocraft-codecs; see its `heif` feature). heif = ["photocraft-codecs/heif"] +# Test support for synthetic memory injection, estimate charges and verification across crates. +testgen = [] [dependencies] photocraft-geom = { workspace = true } @@ -40,6 +42,8 @@ blake3 = { version = "1.8", default-features = false, features = ["pure"] } flate2 = { workspace = true } # ora.rs reads OpenRaster's stack.xml (already in the tree through usvg). roxmltree = "0.21" +# Reuse usvg's locked header reader to bound embedded SVG raster data before decoding. +imagesize = "0.13" # SVG import (svg.rs): usvg parses, resvg rasterises what the shape model can't hold. Both are # already in the tree through the UI's icon loader; text shaping needs the fonts feature set. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index cc4b5aa0de..13977e1a61 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -34,7 +34,7 @@ fn run_file(path: &str) { println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light); print_layer_details(doc); dump_filter_effects(&file); - let ours = photocraft_compose::flatten(doc).px; + let ours = photocraft_compose::flatten(doc).into_px(); let merged = photocraft_io::merged_composite(&file).unwrap(); compare_modes(doc, &ours, &merged); } @@ -487,7 +487,7 @@ fn corpus_summary(root: &std::path::Path) { return (name, None); } let Ok(merged) = photocraft_io::merged_composite(&file) else { return (name, None) }; - let ours = photocraft_compose::flatten(&imp.document).px; + let ours = photocraft_compose::flatten(&imp.document).into_px(); let mut m = 0.0f32; let mut bad = 0usize; for (a, b) in ours.iter().zip(merged) { diff --git a/crates/io/src/displace_map.rs b/crates/io/src/displace_map.rs index 48b34c4b01..792c8e72df 100644 --- a/crates/io/src/displace_map.rs +++ b/crates/io/src/displace_map.rs @@ -10,10 +10,14 @@ //! width * height bytes (horizontal map, vertical map), then padding //! ``` //! +//! Photoshop does not always embed the map: a Displace can carry only `DspF`, a `Pth ` to the map +//! file (ag-psd models the filter with no `DspD` at all), and Photoshop reads that file again. Such +//! a filter imports with the path as its `mapPath`, read on re-render like any stored path (#9162). +//! //! The whole `filterFX` item (with the map) is kept verbatim in the params as a template //! ([`TEMPLATE_KEY`]), so the filter re-renders, survives `.pcraft` and is written back to PSD -//! with only the editable settings replaced. Anything else (another mode, a missing or truncated -//! map, unknown enum values) stays an opaque [`super::UNSUPPORTED_FILTER`]. +//! with only the editable settings replaced. Anything else (another mode, a truncated map, no map +//! and no readable path, unknown enum values) stays an opaque [`super::UNSUPPORTED_FILTER`]. use std::sync::Arc; @@ -80,6 +84,35 @@ fn fltr_map(item: &Descriptor) -> Option> { } } +/// The map file `DspF` names, when it is a `Pth `: `txtu`, u32 LE total size (`12 + 2 * units`), +/// u32 LE count of UTF-16 units, then the UTF-16LE path ending in one NUL (ag-psd's layout). A +/// record whose size disagrees with its units, or that lacks the NUL, is not trusted as a path. A +/// Mac alias (`alis`) is not decoded. +fn fltr_path(item: &Descriptor) -> Option { + let Value::Path(b) = get_desc(item, "Fltr")?.get("DspF")? else { return None }; + let u32_at = |i: usize| usize::try_from(u32::from_le_bytes(b.get(i..i.checked_add(4)?)?.try_into().ok()?)).ok(); + if b.get(..4)? != b"txtu" { + return None; + } + let (size, units) = (u32_at(4)?, u32_at(8)?); + if size != 12usize.checked_add(units.checked_mul(2)?)? || b.len() != size { + return None; + } + let text: Vec = b.get(12..)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + let (&0, path) = text.split_last()? else { return None }; + let path = String::from_utf16(path).ok()?; + (!path.is_empty() && !path.contains('\0')).then_some(path) +} + +/// The `DspF` path a Displace reads its map from, only when no `DspD` is embedded at all: a +/// damaged embedded map keeps the filter opaque rather than falling back to the path. +fn path_only(item: &Descriptor) -> Option { + if get_desc(item, "Fltr")?.get(MAP_KEY).is_some() { + return None; + } + fltr_path(item) +} + fn lookup<'a>(table: &[(&[u8], &'a str)], v: &[u8]) -> Option<&'a str> { table.iter().find(|(k, _)| *k == v).map(|(_, s)| *s) } @@ -95,14 +128,17 @@ fn settings(f: &Descriptor) -> Option { })) } -/// `filter.distort.displace` params for a Displace item with an embedded map. +/// `filter.distort.displace` params for a Displace item with an embedded map, or else a map file +/// path (then `mapPath`). pub(super) fn import_params(item: &Descriptor) -> Option { let f = get_desc(item, "Fltr")?; if !f.class_id.is(CLASS) { return None; } let mut params = settings(f)?; - fltr_map(item)?; + if fltr_map(item).is_none() { + params.as_object_mut()?.insert("mapPath".into(), json!(path_only(item)?)); + } // 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 }; @@ -112,7 +148,7 @@ pub(super) fn import_params(item: &Descriptor) -> Option { Some(params) } -/// The template item of `params`, checked to be a Displace filter with a readable map. +/// The template item of `params`, checked to be a Displace filter with a readable map or map path. fn template(params: &J) -> Result { let raw = params.get(TEMPLATE_KEY).ok_or("a Displace map from a file or document is not written to PSD")?; let raw = raw.as_str().ok_or("the Displace PSD data must be hex text")?; @@ -121,8 +157,8 @@ fn template(params: &J) -> Result { } let bytes = from_hex(raw).ok_or("the Displace PSD data is not valid hex")?; let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Displace PSD data is unreadable ({e})"))?; - if !item.class_id.is("filterFX") || int(&item, "filterID") != Some(FILTER_ID) || fltr_map(&item).is_none() { - return Err("the Displace PSD data is not a Displace filter with an embedded map".into()); + if !item.class_id.is("filterFX") || int(&item, "filterID") != Some(FILTER_ID) || (fltr_map(&item).is_none() && path_only(&item).is_none()) { + return Err("the Displace PSD data is not a Displace filter with an embedded map or map file".into()); } Ok(item) } @@ -130,10 +166,18 @@ fn template(params: &J) -> Result { /// The `filterFXList` item: the template with the current scales, fit and undefined areas. pub(super) fn export_item(params: &J) -> Result { let object = params.as_object().ok_or("Displace parameters must be an object")?; - if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["horizontal", "vertical", "fit", "undefinedAreas"].contains(&k.as_str())) { + let mut item = template(params)?; + // The `mapPath` imported from the template's own `DspF` is already in it; another one isn't. + // A path-only template also needs that `mapPath`: without it the filter renders no map while + // the PSD would still name one. + let template_path = path_only(&item); + if template_path.is_some() && params.get("mapPath").and_then(J::as_str) != template_path.as_deref() { + return Err("a Displace that reads its map from a file needs that file as `mapPath` to be written to PSD".into()); + } + let own_path = |k: &str| k == "mapPath" && template_path.is_some(); + if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !own_path(k) && !["horizontal", "vertical", "fit", "undefinedAreas"].contains(&k.as_str())) { return Err(format!("Displace setting `{k}` has no PSD equivalent next to its embedded map")); } - let mut item = template(params)?; let mut f = get_desc(&item, "Fltr").ok_or("the Displace PSD data has no filter")?.clone(); let scale = |k: &str, d: f64| -> Result { let v = params.get(k).map_or(Some(d), J::as_f64).filter(|v| v.is_finite()).ok_or(format!("Displace `{k}` must be a number"))?; @@ -385,6 +429,69 @@ mod tests { } } + /// A `Pth ` value as ag-psd reads it: `txtu`, sizes (LE), UTF-16LE path with a trailing NUL. + fn pth(path: &str) -> Value { + let units: Vec = path.encode_utf16().chain([0]).collect(); + let mut b = b"txtu".to_vec(); + b.extend((12 + 2 * units.len() as u32).to_le_bytes()); + b.extend((units.len() as u32).to_le_bytes()); + b.extend(units.iter().flat_map(|u| u.to_le_bytes())); + Value::Path(b) + } + + /// #9162: Photoshop can save a Displace with only `DspF`, the map file's path, and no + /// embedded map. It imports as Displace reading that file, and writes back unchanged. + #[test] + fn a_map_named_only_by_its_path_imports_as_map_path() { + let with_file = |file: Value| { + let mut i = item(Vec::new()); + let mut f = get_desc(&i, "Fltr").unwrap().clone(); + f.items.retain(|(k, _)| !k.is(MAP_KEY)); + set(&mut f, "DspF", file); + set(&mut i, "Fltr", Value::Descriptor(f)); + i + }; + let src = with_file(pth("C:\\maps\\repro-map.psd")); + let f = filter_from_item(&src); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["mapPath"], json!("C:\\maps\\repro-map.psd")); + assert!(embedded_map(&f.params).is_none()); + assert_eq!(item_for_filter(&f).unwrap(), src, "written back as it was"); + let mut moved = f.clone(); + moved.params["mapPath"] = json!("/elsewhere/map.png"); + assert!(item_for_filter(&moved).is_err(), "another map path has no place in the template"); + let mut no_path = f.clone(); + no_path.params.as_object_mut().unwrap().remove("mapPath"); + assert!(item_for_filter(&no_path).is_err(), "a path-only template needs its mapPath, or the PSD names a map the render never read"); + // A record whose size field disagrees with its units, or without the final NUL. + let mut wrong_size = pth("map.psd"); + if let Value::Path(b) = &mut wrong_size { + b[4] = b[4].wrapping_add(2); + } + let mut no_nul = b"txtu".to_vec(); + no_nul.extend(16u32.to_le_bytes()); + no_nul.extend(2u32.to_le_bytes()); + no_nul.extend([b'a', 0, b'b', 0]); + // Not a path we can read: kept opaque, as before. + for bad in [ + Value::Path(b"txtu".to_vec()), + Value::Path(b"abcd\0\0\0\0\x02\0\0\0a\0\0\0".to_vec()), + Value::Path(b"txtu\0\0\0\0\x09\0\0\0a\0".to_vec()), + pth(""), + wrong_size, + Value::Path(no_nul), + ] { + let src = with_file(bad); + assert_eq!(filter_from_item(&src).command, UNSUPPORTED_FILTER); + } + // A damaged embedded map is never replaced by a readable path. + let mut damaged = item(vec![0; 4]); + let mut fltr = get_desc(&damaged, "Fltr").unwrap().clone(); + set(&mut fltr, "DspF", pth("C:\\maps\\repro-map.psd")); + set(&mut damaged, "Fltr", Value::Descriptor(fltr)); + assert_eq!(filter_from_item(&damaged).command, UNSUPPORTED_FILTER); + } + #[test] fn bad_exports_report_errors() { let ok = filter_from_item(&item(tdta(2, 2, |_| 0, |_| 0, 0))).params; diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 36aff72a3a..0c7f60154f 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -69,9 +69,15 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [ (BlendMode::Luminosity, "luminosity"), ]; -/// A blend-mode enum from either its four-character code or its long string ID. +/// A blend-mode enum from its four-character code, its long string ID, or the layer record's key: +/// Photoshop 2026 writes `vLit`, `lddg`, `lbrn`… as an effect's `Md ` for some modes (#8567). pub(crate) fn blend_from_id(v: &[u8]) -> Option { - BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m) + BLEND_NAMES + .iter() + .chain(&BLEND_LONG_NAMES) + .find(|(_, n)| n.as_bytes() == v) + .map(|(m, _)| *m) + .or_else(|| <[u8; 4]>::try_from(v).ok().and_then(BlendMode::from_psd_key)) } fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode { @@ -122,14 +128,48 @@ fn contour(d: &Descriptor, key: &str) -> Contour { let linear = points.len() == 2 && points[0].input == 0.0 && points[0].output == 0.0 && points[1].input == 1.0 && points[1].output == 1.0; if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { Contour::Linear } else { Contour::Custom { name, points, corners } } } + +/// Indices of the contour points a PSD keeps, in order: all of them up to [`PSD_CONTOUR_POINTS`], +/// else both ends and then, one at a time, the point farthest (in output) from the line between +/// the kept points around it, so peaks and valleys (Ring - Double's two rings) survive. +fn psd_contour_points(points: &[CurvePoint]) -> Vec { + if points.len() <= PSD_CONTOUR_POINTS { + return (0..points.len()).collect(); + } + let mut kept = vec![0, points.len() - 1]; + while kept.len() < PSD_CONTOUR_POINTS { + let mut farthest: Option<(f32, usize)> = None; + for pair in kept.windows(2) { + let &[a, b] = pair else { continue }; + let (Some(pa), Some(pb)) = (points.get(a), points.get(b)) else { continue }; + let span = pb.input - pa.input; + for (i, p) in points.iter().enumerate().take(b).skip(a + 1) { + // Only equal inputs (a vertical step) have no span: inputs a few ulps apart still do. + let t = if span != 0.0 && span.is_finite() { (p.input - pa.input) / span } else { 0.0 }; + let off = (p.output - (pa.output + t * (pb.output - pa.output))).abs(); + if off.is_finite() && farthest.is_none_or(|(f, _)| off > f) { + farthest = Some((off, i)); + } + } + } + let Some((_, i)) = farthest else { break }; + kept.insert(kept.partition_point(|&k| k < i), i); + } + kept +} + +/// The most points Photoshop reads in an effect's contour (`ShpC` › `Crv `): a layer whose effects +/// carry a longer one fails to load ("A problem was encountered reading layer … program error"), +/// and Photoshop then drops all of its effects; 16 loads (#9556). +const PSD_CONTOUR_POINTS: usize = 16; fn contour_value(c: &Contour) -> Value { let (name, points) = match c { 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 pts = points - .iter() - .enumerate() + let pts = psd_contour_points(&points) + .into_iter() + .filter_map(|i| points.get(i).map(|p| (i, p))) .map(|(i, p)| { let d = Descriptor::new("CrPt").with("Hrzn", Value::Double(f64::from(p.input * 255.0))).with("Vrtc", Value::Double(f64::from(p.output * 255.0))); // Photoshop writes no `Cnty` (continuity) on smooth points; a corner carries `Cnty` false. diff --git a/crates/io/src/effects_map/tests.rs b/crates/io/src/effects_map/tests.rs index 3944739fb8..5a43cd194e 100644 --- a/crates/io/src/effects_map/tests.rs +++ b/crates/io/src/effects_map/tests.rs @@ -112,6 +112,47 @@ fn sample_effects() -> Vec { ] } +/// #9556: Photoshop drops every effect of a layer whose contour has more than 16 points. A +/// 29-point contour with two rings is written as 16 points that keep both ends and both peaks; +/// up to 16 points are written as they are. +#[test] +fn psd_contours_keep_at_most_16_points_and_their_peaks() { + let points: Vec = (0..29) + .map(|i| { + let x = i as f32 / 28.0; + // Rings at x = 0.25 and 0.75, small wiggles between. + let y = (1.0 - ((x - 0.25).abs() * 8.0)).max(0.0).max(1.0 - (x - 0.75).abs() * 8.0) + if i % 2 == 0 { 0.0 } else { 0.01 }; + CurvePoint { input: x, output: y.clamp(0.0, 1.0) } + }) + .collect(); + let c = Contour::Custom { name: "Two rings".into(), points: points.clone(), corners: vec![false; 29] }; + let out = crv(&contour_value(&c)); + assert_eq!(out.len(), 16); + assert_eq!(out.first().copied(), Some((0.0, f64::from(points[0].output) * 255.0))); + assert_eq!(out.last().map(|p| p.0), Some(255.0)); + let peak = |x: f32| out.iter().any(|p| ((p.0 / 255.0) as f32 - x).abs() < 0.03 && p.1 > 240.0); + assert!(peak(0.25) && peak(0.75), "both rings kept: {out:?}"); + assert!(out.windows(2).all(|w| w[0].0 < w[1].0), "in order: {out:?}"); + + let short = Contour::Custom { name: "Short".into(), points: points[..16].to_vec(), corners: vec![false; 16] }; + assert_eq!(crv(&contour_value(&short)).len(), 16); +} + +fn crv(v: &Value) -> Vec<(f64, f64)> { + let Value::Descriptor(d) = v else { panic!("ShpC") }; + let Some(Value::List(pts)) = d.get("Crv ") else { panic!("Crv ") }; + pts.iter() + .map(|p| { + let Value::Descriptor(p) = p else { panic!("CrPt") }; + let n = |k: &str| match p.get(k) { + Some(Value::Double(x)) => *x, + _ => panic!("{k}"), + }; + (n("Hrzn"), n("Vrtc")) + }) + .collect() +} + fn approx(a: &[Effect], b: &[Effect]) { // Colours go through 0..255 doubles; compare via re-serialization. assert_eq!(a.len(), b.len()); @@ -198,6 +239,35 @@ fn blend_names_are_unique() { } } +#[test] +fn layer_record_blend_keys_parse() { + for (key, mode) in [ + (b"vLit", BlendMode::VividLight), + (b"lLit", BlendMode::LinearLight), + (b"pLit", BlendMode::PinLight), + (b"hMix", BlendMode::HardMix), + (b"lbrn", BlendMode::LinearBurn), + (b"lddg", BlendMode::LinearDodge), + (b"dkCl", BlendMode::DarkerColor), + (b"lgCl", BlendMode::LighterColor), + (b"fsub", BlendMode::Subtract), + (b"fdiv", BlendMode::Divide), + ] { + assert_eq!(blend_from_id(key), Some(mode), "{}", String::from_utf8_lossy(key)); + } + assert_eq!(blend_from_id(b"zzzz"), None); + // The issue's Color Overlay: Vivid Light stored as `vLit`, not left as Normal. + let key = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) }; + let d = Descriptor::new("null") + .with("masterFXSwitch", Value::Boolean(true)) + .with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", key("vLit")))); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes()); + let (_, fx) = parse_lfx2(&data).unwrap(); + let modes: Vec = fx.iter().filter_map(|e| if let Effect::ColorOverlay { common, .. } = e { Some(common.blend) } else { None }).collect(); + assert_eq!(modes, vec![BlendMode::VividLight]); +} + #[test] fn long_string_blend_ids_parse() { // Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`) @@ -339,3 +409,22 @@ fn lrfx_drop_shadow() { other => panic!("{other:?}"), } } + +/// CodeRabbit on #9590: inputs closer than `f32::EPSILON` but distinct are still a span. A point +/// on the straight line between two such inputs isn't mistaken for a bend and kept in place of a +/// genuine one. +#[test] +fn a_point_on_a_tiny_straight_span_is_not_a_bend() { + let p = |input: f32, output: f32| CurvePoint { input, output }; + // Three distinct inputs a few ulps apart near 0.5, on a straight rise from 0 to 1. + let (a, b) = (0.5_f32, f32::from_bits(0.5_f32.to_bits() + 2)); + let mid = f32::from_bits(0.5_f32.to_bits() + 1); + let mut points = vec![p(0.0, 0.0), p(0.2, 0.0)]; + points.extend([p(a, 0.0), p(mid, 0.5), p(b, 1.0)]); + // Fourteen gentle bends after it, each one a real (if small) peak. + points.extend((0..14).map(|k| p(0.55 + k as f32 * 0.03, if k % 2 == 0 { 1.0 } else { 0.9 }))); + points.push(p(1.0, 1.0)); + let kept = psd_contour_points(&points); + assert_eq!(kept.len(), 16); + assert!(!kept.contains(&3), "the point on the tiny straight span was kept over a real bend: {kept:?}"); +} diff --git a/crates/io/src/exr_native.rs b/crates/io/src/exr_native.rs new file mode 100644 index 0000000000..af76690056 --- /dev/null +++ b/crates/io/src/exr_native.rs @@ -0,0 +1,98 @@ +//! Native OpenEXR blocks go straight into original-depth tiled storage. +use crate::{ImportResult, IoError}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; +use photocraft_raster::{Interrupt, Surface}; +use std::io::{Read, Seek}; + +/// Tiles the decoded image would add must fit the RAM tile budget when no scratch disk can +/// take cold tiles. +fn ram_tile_capacity(resident: u64, budget: u64, growth: u64) -> Result<(), IoError> { + if resident.checked_add(growth).is_none_or(|required| required > budget) { + return Err(IoError::OutOfMemory("decoded image tiles exceed the available RAM budget; enable a scratch disk or close other documents".into())); + } + Ok(()) +} + +/// A scratch disk that was on when the import started must still be on: its tiles may +/// already have moved there. +fn require_scratch(expected: bool) -> Result<(), IoError> { + if expected && !photocraft_raster::spill::enabled() { + return Err(IoError::Read( + photocraft_raster::spill::last_error().unwrap_or_else(|| "scratch storage was disabled while loading; retry the import".into()), + )); + } + Ok(()) +} + +/// Decode a single-part flat scanline EXR from `source` one compression block at a time. +/// `Ok(None)`: the header is not one the band decoder takes (multi-part, deep, tiled, custom +/// chromaticities, over its block limits or malformed by its stricter checks); the caller +/// falls back to the whole-file decoder. Once pixels are being decoded any failure fails the +/// open, so no partial document is published. +pub(crate) fn import(source: R, ctl: &Interrupt<'_>) -> Result, IoError> { + let _metadata = photocraft_raster::memory::try_reserve(32 << 20).map_err(IoError::OutOfMemory)?; + let mut decoder = match photocraft_codecs::exr_stream::Decoder::new(source, &|| ctl.cancelled()) { + Ok(decoder) => decoder, + Err(_) if ctl.cancelled() => return Err(IoError::Cancelled), + Err(_) => return Ok(None), + }; + let info = decoder.info(); + let mode = if info.gray { ColorMode::Grayscale } else { ColorMode::Rgb }; + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let scratch_expected = photocraft_raster::spill::enabled(); + if !scratch_expected { + let budget = photocraft_raster::memory::refresh(); + let side = TILE_SIZE as u64; + let tiles = u64::from(info.width).div_ceil(side).saturating_mul(u64::from(info.height).div_ceil(side)); + ram_tile_capacity( + photocraft_raster::spill::stats().resident_bytes as u64, + budget.tile_bytes, + tiles.saturating_mul(side * side).saturating_mul(fmt.bytes_per_pixel() as u64), + )?; + } + let _working = photocraft_raster::memory::try_reserve(info.working_bytes).map_err(IoError::OutOfMemory)?; + let mut surface = Surface::new(fmt); + let mut done = 0; + loop { + ctl.check().map_err(|_| IoError::Cancelled)?; + require_scratch(scratch_expected)?; + let band = decoder.next_band(&|| ctl.cancelled()).map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; + let Some(band) = band else { + break; + }; + let rect = Rect::from_xywh(band.x as i32, band.y as i32, band.width, band.height); + if !photocraft_raster::spill::enabled() { + let missing = rect.tiles().filter(|c| surface.tile(*c).is_none()).count() as u64; + ram_tile_capacity( + photocraft_raster::spill::stats().resident_bytes as u64, + photocraft_raster::memory::stats().1.tile_bytes, + missing.saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64), + )?; + } + surface.write_interleaved(rect, &band.data); + require_scratch(scratch_expected)?; + done += 1; + ctl.progress(0.05 + 0.9 * done as f32 / info.blocks.max(1) as f32); + } + // Missing alpha is filled with one in every allocated tile. Pruning these opaque + // tiles cannot remove any and would unnecessarily reload the entire scratch store. + if info.has_alpha { + surface.prune(); + } + require_scratch(scratch_expected)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut document = Document::new("", Size::new(info.width, info.height), mode, SampleType::F32); + let mut layer = Layer::new("Background", LayerContent::Raster(surface)); + if !info.has_alpha { + layer.locks.transparency = true; + layer.locks.position = true; + } + document.layers.push(layer); + document.icc_profile = + Some(if mode == ColorMode::Rgb { photocraft_cms::Builtin::LinearSrgb.profile().to_bytes() } else { photocraft_cms::builtin::linear_gray().to_bytes() }); + let warnings = if info.all_half { vec!["16-bit float samples are stored as 32-bit float".into()] } else { Vec::new() }; + ctl.progress(1.0); + Ok(Some(ImportResult { document, warnings, source_read_only: false, preview_only: false })) +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 55f602a6e9..3735514514 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -13,13 +13,62 @@ use crate::{ExportOptions, ExportResult, ImportResult, IoError, TgaBits, XmpEmbe /// Bytes per band when converting or exporting a band of rows at a time. const BAND_BYTES: usize = 32 << 20; +#[cfg(test)] +thread_local! { + /// Called on this thread just before a layered TIFF's composite is decoded. A test proves + /// that admission refused a file without ever reaching that decode. + static TEST_BEFORE_COMPOSITE_DECODE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Runs `f` with `hook` called on this thread just before a layered TIFF's composite is decoded. +#[cfg(test)] +pub(crate) fn with_before_composite_decode(hook: std::rc::Rc, f: impl FnOnce() -> R) -> R { + struct Reset(Option>); + impl Drop for Reset { + fn drop(&mut self) { + TEST_BEFORE_COMPOSITE_DECODE.with(|h| *h.borrow_mut() = self.0.take()); + } + } + let prev = TEST_BEFORE_COMPOSITE_DECODE.with(|h| h.borrow_mut().replace(hook)); + let _reset = Reset(prev); + f() +} + +/// Runs the test hook installed by [`with_before_composite_decode`], if any. +#[cfg(test)] +fn before_composite_decode() { + if let Some(hook) = TEST_BEFORE_COMPOSITE_DECODE.with(|h| h.borrow().clone()) { + hook(); + } +} + +/// No-op outside tests: production decodes have no pre-composite hook. +#[cfg(not(test))] +fn before_composite_decode() {} + /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { + import_flat_with_options(name, bytes, &crate::ImportOptions::default(), &photocraft_raster::Interrupt::NONE) +} + +/// [`import_flat`] with the user's import settings and the job's cancellation. +/// +/// A layered TIFF's composite, layer and retained-image estimate is admitted through +/// [`crate::memory::evaluate_psd_admission`] with these settings; a zero +/// `psd_max_pixel_bytes` therefore means the adaptive budget, and +/// `psd_high_memory_approved`/`psd_approved_estimate` apply to it like to a PSD. The other flat +/// decoders keep their own limits. +pub(crate) fn import_flat_with_options( + name: &str, + bytes: &[u8], + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { // Detected once; every step below branches on it. let format = codecs::detect(bytes); if format == Some(Format::Tiff) { - return import_tiff_page(name, bytes, None); + return import_tiff_page_with_options(name, bytes, None, opts, ctl); } let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in @@ -225,53 +274,315 @@ pub fn tga_alpha_channel_to_transparency(doc: &mut Document) { /// Opens one page of a TIFF or BigTIFF file: `None` is the page Photoshop opens (the first /// full-resolution one), `Some(i)` an index into [`codecs::tiff_info`]'s pages (every IFD and -/// SubIFD). A page with Photoshop layer data opens layered. +/// SubIFD). A page with Photoshop layer data opens layered. Any other page is decoded a group of +/// rows at a time straight into the layer's tiles, so it opens up to 300,000 px a side without +/// a second full-size copy (#8169), when the machine has the memory for it (see +/// [`crate::memory`]). +/// +/// A layered page is admitted by the adaptive budget like a PSD, with +/// [`crate::ImportOptions`]' defaults: it can be refused with [`IoError::PsdApprovalRequired`] +/// or [`IoError::PsdImportBudget`]. Callers that can ask the user or carry cancellation use +/// [`import_tiff_page_with_options`]. pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option) -> Result { + import_tiff_page_with_options(name, bytes, page, &crate::ImportOptions::default(), &photocraft_raster::Interrupt::NONE) +} + +/// [`import_tiff_page`] with the user's import settings and the job's cancellation. +/// +/// A layered page takes the same caller limit and high-memory approval as a PSD +/// (`ImportOptions::psd_*`): its composite, PSD layer model and retained decoded image are +/// estimated from the page's tags and admitted *before* the composite is decoded. A cancelled +/// job stops waiting for the PSD decode gate and between bands. +pub fn import_tiff_page_with_options( + name: &str, + bytes: &[u8], + page: Option, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + let bands = codecs::TiffBands::open(bytes, page, &codecs::Limits::banded())?; + // Photoshop layer data is decoded whole, so the layers can be built from the complete + // composite; the tags are read (not decoded) first, to admit the import before that decode. + if bands.meta.photoshop_layers.as_deref().is_some_and(|l| !l.is_empty()) { + return import_tiff_layered(name, bytes, page, bands, opts, ctl); + } + // Extra samples (alpha and spot channels) are only available from a whole-image decode. + if bands.extra_samples() > 0 { + drop(bands); + return import_tiff_with_extras(name, bytes, page, ctl); + } + tiff_to_document(name, bands.upright(), crate::memory::available, BAND_BYTES, ctl) +} + +/// A layered TIFF page: its layer tag and resources are read into a plan, the plan's estimate is +/// admitted, and only then is the composite decoded. +/// +/// The admission guard is held across the composite decode and the PSD conversion that follows, +/// so nested decodes spend the same attempt's allowance and nothing here is charged twice. +fn import_tiff_layered( + name: &str, + bytes: &[u8], + page: Option, + bands: codecs::TiffBands<'_>, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { + let plan = crate::tiff_layers::preflight(&bands)?; + // A file this machine cannot open is refused here, before any composite pixel is decoded, + // and an eligible one rechecks a fresh measurement after the gate. + let _admission = tiff_admission(plan.required, plan.tiles, opts, ctl)?; + drop(bands); + before_composite_decode(); // The orientation is applied after the layer check: rotating the composite but not the // layers would misalign them, so a layered TIFF keeps its stored orientation. let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() }; + let (mut img, orientation) = match page { + None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)), + Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)), + }; + // The plan already holds both tags, parsed: a layered page drops the decoder's copies + // instead of keeping two of each through the conversion. A flattened fallback still needs + // the resources for its channels. + if plan.is_layered() { + img.meta.photoshop_layers = None; + img.meta.photoshop_resources = None; + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut r = crate::tiff_layers::import_layered(name, &img, plan, ctl)?; + if orientation != 1 { + r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string()); + } + Ok(r) +} + +/// Admits a layered TIFF import for `required` bytes, `spillable` of them tile pixels (see +/// [`crate::memory::evaluate_psd_admission`]), and holds decode admission for it. +/// +/// A nested import of a different file spends the enclosing attempt's remaining allowance: it +/// never resolves a fresh budget, resets the parent or charges the parent's image again. A +/// top-level import is refused before waiting for the gate and rechecked with a fresh +/// measurement once admitted, so a memory drop during the wait is seen before pixels are +/// decoded. Cancellation is checked before an allowance is consumed, so a cancelled nested +/// import never spends its parent's budget. +fn tiff_admission( + required: u64, + spillable: u64, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { + let caller_limit = opts.psd_max_pixel_bytes; + let admit = |snapshot: crate::memory::MemorySnapshot| { + crate::memory::evaluate_psd_admission(required, spillable, caller_limit, opts.psd_high_memory_approved, opts.psd_approved_estimate, snapshot) + }; + if crate::psd_gate::is_holding_admission() { + // A nested, independent TIFF: the same-file continuation never gets here, because + // `import_tiff_layered` admits once and `import_layered` does not admit again. It spends + // the enclosing attempt's remaining allowance, and the caller's explicit bounds still + // apply before that charge (a refused nested import leaves the parent untouched). + ctl.check().map_err(|_| IoError::Cancelled)?; + crate::memory::nested_import_local_bound(required, caller_limit, opts.psd_approved_estimate)?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + crate::psd_gate::consume_attempt_allowance(required)?; + return Ok(guard); + } + admit(crate::memory::MemorySnapshot::current())?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + let outcome = admit(crate::memory::MemorySnapshot::current())?; + crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(required)); + Ok(guard) +} + +/// A flat TIFF page with extra samples, decoded whole: the composite becomes the layer, the +/// extra samples its channels. +fn import_tiff_with_extras(name: &str, bytes: &[u8], page: Option, ctl: &photocraft_raster::Interrupt) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() }; let (img, orientation) = match page { None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)), Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)), }; - if let Some(layers) = img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()) { - let mut r = crate::tiff_layers::import_layered(name, &img, layers)?; - if orientation != 1 { - r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string()); - } - return Ok(r); - } + ctl.check().map_err(|_| IoError::Cancelled)?; image_to_document(name, &img.oriented(orientation)?) } +/// A flat TIFF page as a single-layer document, decoded a group of rows at a time into the +/// layer's tiles: only one group exists beside the document. `available`: the memory this +/// process can still use (see [`crate::memory::available`]); `budget`: the source bytes per core +/// converted at a time ([`BAND_BYTES`]); `ctl`: the job's cancellation, checked per group. +fn tiff_to_document( + name: &str, + mut bands: codecs::TiffBands<'_>, + available: impl FnOnce() -> Option, + budget: usize, + ctl: &photocraft_raster::Interrupt, +) -> Result { + let (w, h) = bands.dimensions(); + let (layout, sample) = (bands.layout(), bands.sample_type()); + let f = document_format(layout, sample); + let fmt = PixelFormat::new(f.mode, f.depth, true); + let bpp = fmt.bytes_per_pixel() as u64; + if needs_memory_check(w, h, layout, sample) { + // The layer's tiles, plus a group as decoded, turned upright, converted and read into a + // worker's scratch buffer. Linux overcommits, so the tiles below are allocated even when + // they can't be backed, and writing them would have the process killed: refuse first. + let group = bands.group_bytes()? as u64; + let group_px = group / (layout.channels() as u64 * sample.bytes() as u64).max(1); + let surface = surface_bytes(w, h, fmt); + let needed = surface.saturating_add(group.saturating_mul(3)).saturating_add(group_px.saturating_mul(bpp)); + // The layer is streamed into tiles a group at a time, so while the scratch disk is + // spilling the tiles it has room for need no RAM; the groups always do. + let needed = needed.saturating_sub(crate::memory::scratch_credit(surface)); + crate::memory::check_fits(w, h, needed, available())?; + } + let mut s = Surface::new(fmt); + let out_of_memory = |e: photocraft_raster::AllocationError| { + let gb = (u64::from(w) * u64::from(h)).saturating_mul(bpp) as f64 / 1e9; + IoError::Unsupported(format!("not enough memory to open this {w} × {h} px image: it needs about {gb:.1} GB ({e})")) + }; + bands.decode(|x, y, group: &Image| -> Result<(), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (gw, gh) = group.dimensions(); + let (x, y) = (x as i32, y as i32); + let rect = |rows: &std::ops::Range| Rect::new(x, y + rows.start as i32, x.saturating_add(gw as i32), y + rows.end as i32); + if layout == f.layout && sample == f.sample { + return s.try_write_interleaved(rect(&(0..gh as usize)), group.data()).map_err(out_of_memory); + } + // Converted in pieces of rows on all cores, then each piece written (on all cores too), a + // round of at most `BAND_BYTES` of source rows per core at a time: a single-strip file is + // one group, the whole image, and converting it at once would hold it converted beside it. + let row = group.data().len() / (gh.max(1) as usize); + for round in conversion_rounds(gh as usize, row, budget) { + let pieces = crate::pixels::par_map(round, |rows| { + let part = group.data().get(rows.start * row..rows.end * row).unwrap_or_default().to_vec(); + Image::from_raw(gw, rows.len() as u32, layout, sample, part).map(|p| (rows, p.convert(f.layout, f.sample))) + }); + for piece in pieces { + let (rows, part) = piece?; + s.try_write_interleaved(rect(&rows), part.data()).map_err(out_of_memory)?; + } + } + Ok(()) + })?; + s.prune(); + let mut warnings: Vec = bands.warnings.iter().map(ToString::to_string).collect(); + warnings.extend(f.warning.clone()); + let meta = std::mem::take(&mut bands.meta); + Ok(single_layer_document(name, (w, h), f, layout.has_alpha(), s, bands.icc.take(), meta, warnings)) +} + +/// Bytes a `w` × `h` px document surface of `fmt` allocates: whole tiles, including the padding +/// of a partly covered edge tile, as [`Surface`] holds them. +fn surface_bytes(w: u32, h: u32, fmt: PixelFormat) -> u64 { + let tiles = |n: u32| u64::from(n).div_ceil(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64); + tiles(w).saturating_mul(tiles(h)).saturating_mul(fmt.bytes_per_pixel() as u64) +} + +/// Bytes a flat page that is decoded whole needs: the decoded image and its extra-sample planes, +/// the band buffers [`image_surface`] converts through when the document format differs from the +/// page's, the layer's tiles and, per extra sample, its channel's tiles and its copied plane. +/// +/// A TIFF whose Photoshop layer tag cannot be read still opens its composite flattened; this +/// bounds that whole-image decode and its conversion before anything is allocated. +/// +/// Every term saturates instead of wrapping: an estimate that overflows `u64` is the +/// `u64::MAX` sentinel, which admission always refuses (see +/// [`crate::memory::MemoryBudget::decide`]) rather than admitting a wrapped-around value. +pub(crate) fn flattened_import_bytes(w: u32, h: u32, layout: ChannelLayout, sample: CSample, extras: usize, scratch: u64) -> u64 { + let f = document_format(layout, sample); + let fmt = PixelFormat::new(f.mode, f.depth, true); + let pixels = u64::from(w).saturating_mul(u64::from(h)); + let in_bps = sample.bytes() as u64; + let out_bps = f.sample.bytes() as u64; + let e = extras as u64; + let decoded = pixels.saturating_mul((layout.channels() as u64).saturating_add(e.saturating_mul(2))).saturating_mul(in_bps); + // A band of rows is copied, normalized to 32-bit floats and converted; a few bands' worth + // are live at once and the scratch pool reuses the rest within its own budget. + let convert = if layout == f.layout && sample == f.sample { 0 } else { (BAND_BYTES as u64).saturating_mul(16) }; + let layer = surface_bytes(w, h, fmt); + let channel = surface_bytes(w, h, PixelFormat::new(ColorMode::Grayscale, f.depth, false)); + let channels = e.saturating_mul(channel.saturating_add(pixels.saturating_mul(out_bps))); + layer.saturating_add(decoded).saturating_add(convert).saturating_add(channels).saturating_add(scratch) +} + +/// Whether a `w` × `h` flat TIFF is checked against free memory before it opens: only the sizes +/// the whole-image decode refused, which only the banded decode opens. Every image that opened +/// before opens as it did, also on a small machine or in a container whose page cache fills. +fn needs_memory_check(w: u32, h: u32, layout: ChannelLayout, sample: CSample) -> bool { + codecs::Limits::default().check(w, h, layout, sample).is_err() +} + +/// `0..rows` of `row` bytes each, split into rounds of about one piece per core, each piece at +/// most about `budget` bytes, in order. +fn conversion_rounds(rows: usize, row: usize, budget: usize) -> Vec>> { + let round = (budget / row.max(1)).max(1).saturating_mul(crate::pixels::bands(rows).len()).max(1); + (0..rows) + .step_by(round) + .map(|start| { + let end = start.saturating_add(round).min(rows); + crate::pixels::bands(end - start).into_iter().map(|r| start + r.start..start + r.end).collect() + }) + .collect() +} + +/// How a decoded image's pixels are kept in a document. +struct DocumentFormat { + mode: ColorMode, + depth: SampleType, + /// The layer's layout (always with alpha) and sample type, as the codec names them. + layout: ChannelLayout, + sample: CSample, + /// What the conversion loses. + warning: Option, +} + +fn document_format(layout: ChannelLayout, sample: CSample) -> DocumentFormat { + let (mode, layout) = match layout { + ChannelLayout::Gray | ChannelLayout::GrayA => (ColorMode::Grayscale, ChannelLayout::GrayA), + ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba), + ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA), + ChannelLayout::Lab | ChannelLayout::LabA => (ColorMode::Lab, ChannelLayout::LabA), + }; + let (depth, sample, warning) = match sample { + CSample::U8 => (SampleType::U8, CSample::U8, None), + CSample::U16 => (SampleType::U16, CSample::U16, None), + CSample::F16 => (SampleType::F32, CSample::F32, Some("16-bit float samples are stored as 32-bit float".to_string())), + CSample::F32 => (SampleType::F32, CSample::F32, None), + }; + DocumentFormat { mode, depth, layout, sample, warning } +} + /// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a /// normal "Layer 0" when it has transparency. pub(crate) fn image_to_document(name: &str, img: &Image) -> Result { // What the decoder noticed (frames or pages left out, data ending early) comes first. let mut warnings: Vec = img.warnings.iter().map(ToString::to_string).collect(); - let (mode, target_layout) = match img.layout() { - ChannelLayout::Gray | ChannelLayout::GrayA => (ColorMode::Grayscale, ChannelLayout::GrayA), - ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba), - ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA), - ChannelLayout::Lab | ChannelLayout::LabA => (ColorMode::Lab, ChannelLayout::LabA), - }; - let (depth, csample) = match img.sample_type() { - CSample::U8 => (SampleType::U8, CSample::U8), - CSample::U16 => (SampleType::U16, CSample::U16), - CSample::F16 => { - warnings.push("16-bit float samples are stored as 32-bit float".to_string()); - (SampleType::F32, CSample::F32) - } - CSample::F32 => (SampleType::F32, CSample::F32), - }; - let (w, h) = img.dimensions(); - let mut doc = Document::new(name, Size::new(w, h), mode, depth); - let mut s = image_surface(img, target_layout, csample)?; + let f = document_format(img.layout(), img.sample_type()); + warnings.extend(f.warning.clone()); + let mut s = image_surface(img, f.layout, f.sample)?; s.prune(); + let mut r = single_layer_document(name, img.dimensions(), f, img.layout().has_alpha(), s, img.icc.clone(), img.meta.clone(), warnings); + r.warnings.extend(crate::tiff_layers::channels_from_extras(&mut r.document, img)); + Ok(r) +} + +/// The document of one layer holding `s`, with the image's profile and metadata. +#[allow(clippy::too_many_arguments)] +fn single_layer_document( + name: &str, + (w, h): (u32, u32), + f: DocumentFormat, + alpha: bool, + s: Surface, + icc: Option>, + meta: codecs::Metadata, + mut warnings: Vec, +) -> ImportResult { + let mut doc = Document::new(name, Size::new(w, h), f.mode, f.depth); // As in Photoshop: an opaque image opens as the locked Background layer, one with // transparency (an alpha channel, or a tRNS chunk the decoder expands to one) as a normal // layer named "Layer 0", since a Background can't hold transparency. - let layer = if img.layout().has_alpha() { + let layer = if alpha { Layer::new("Layer 0", LayerContent::Raster(s)) } else { let mut bg = Layer::new("Background", LayerContent::Raster(s)); @@ -280,21 +591,20 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result(rows.len()); + src.extend_from_slice(rows); + let src = Image::from_raw(w, n, img.layout(), img.sample_type(), src)?; + let part = src.convert(target_layout, csample); let y0 = (i * band) as i32; s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data()); + photocraft_raster::scratch::give(src.into_data()); + photocraft_raster::scratch::give(part.into_data()); } } Ok(s) @@ -334,7 +651,16 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: /// (keeping CMYK / depth exactly) instead of going through the compositor. pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> { let [l] = &doc.layers[..] else { return None }; - let ok = l.visible + match &l.content { + LayerContent::Raster(s) if plain_layer(l, doc.mode) && s.format() == doc.pixel_format() => Some(s), + _ => None, + } +} + +/// A visible, unmasked, normal, fully opaque layer with no effects or blending options: on its +/// own, its pixels are the composite. +fn plain_layer(l: &Layer, mode: ColorMode) -> bool { + l.visible && l.opacity >= 1.0 && l.fill_opacity >= 1.0 && l.mask.is_none() @@ -342,12 +668,8 @@ pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> { && l.effects.items.is_empty() && l.effects.psd_raw.is_none() && matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough) - && photocraft_compose::channel_weights(l, doc.mode).is_none() - && !photocraft_compose::blend_if_active(l, doc.mode); - match (&l.content, ok) { - (LayerContent::Raster(s), true) if s.format() == doc.pixel_format() => Some(s), - _ => None, - } + && photocraft_compose::channel_weights(l, mode).is_none() + && !photocraft_compose::blend_if_active(l, mode) } pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout { @@ -378,45 +700,64 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result, cmyk_target: bool, lab_target: bool) -> Result { + let src = FlatSource::new(doc, warnings, cmyk_target, lab_target)?; let (w, h) = (doc.size.width, doc.size.height); - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); - let n = (w as usize) * (h as usize); - // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it - // goes through the sRGB composite, which flattens nothing when there is a single plain layer. - let single = single_layer(doc); - let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); - let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); - let img = if let Some(s) = native { - // Native path: keep model and depth. The surface's encoded samples are the codec's raw - // native-endian samples, copied a band of rows at a time (alpha dropped when opaque). - let ch = fmt.channels(); - let opaque = opaque_surface(s, canvas); - let layout = layout_for(fmt.mode, !opaque); - let bps = fmt.sample.bytes(); - let keep = if opaque { (ch - 1) * bps } else { ch * bps }; - let mut data = try_buffer(n, keep)?; - let rows = (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32; - let mut y = 0; - while y < h as i32 { - let y1 = y.saturating_add(rows).min(h as i32); - let band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); - if opaque { - for px in band.chunks_exact(ch * bps) { - data.extend_from_slice(&px[..keep]); - } - } else { - data.extend_from_slice(&band); - } - y = y1; + // The single layer's alpha is dropped up front when it is opaque. A composite is rendered + // with alpha, dropped afterwards, in place, when every pixel turned out opaque. + let alpha = src.native.is_none_or(|s| !opaque_surface(s, doc.bounds())); + let bytes = src.sample.bytes(); + let mut data = try_buffer((w as usize) * (h as usize), src.layout(alpha).channels() * bytes)?; + let mut opaque = true; + src.bands(alpha, |_, band, translucent| { + opaque &= !translucent; + data.extend_from_slice(&band); + Ok(()) + })?; + let mut layout = src.layout(alpha); + if src.native.is_none() && opaque { + drop_alpha(&mut data, layout.color_channels() * bytes, bytes); + layout = src.layout(false); + } + Ok(Image::from_raw(w, h, layout, src.sample, data)?.with_icc(src.icc.clone()).with_meta(src.meta())) +} + +/// How a document becomes flat codec pixels, a band of rows at a time: [`document_to_image`] +/// gathers the bands into one image, a streamed PNG export ([`export_png_streamed`]) encodes each +/// band as it is rendered. +struct FlatSource<'a> { + doc: &'a Document, + /// The single plain layer, written in its own model and depth ([`single_layer`]). + native: Option<&'a Surface>, + /// The model of the pixels: the document's for the single layer, else CMYK, gray or RGB. + mode: ColorMode, + sample: CSample, + /// A CMYK composite mixed on ink values by the native CMYK compositor, so opaque pixels keep + /// their exact inks ([`photocraft_compose::cmyk::renders_inks`]). + inks: bool, + /// The CMYK profile any other CMYK composite is converted back through, for a CMYK target. + space: Option>, + icc: Option>, + /// Rows per band; 0 picks them from the size (tests ask for small bands). + rows: i32, +} + +impl<'a> FlatSource<'a> { + /// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels. + fn new(doc: &'a Document, warnings: &mut Vec, cmyk_target: bool, lab_target: bool) -> Result { + let fmt = doc.pixel_format(); + let sample = csample(fmt.sample); + // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it + // goes through the sRGB composite, which flattens nothing when there is a single plain layer. + let single = single_layer(doc); + let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); + let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); + if native.is_some() { + return Ok(Self { doc, native, mode: fmt.mode, sample, inks: false, space: None, icc, rows: 0 }); } - Image::from_raw(w, h, layout, csample(fmt.sample), data)? - } else { if single.is_none() { let count = doc.layer_count(); warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept")); } - let cs = csample(fmt.sample); if fmt.mode == ColorMode::Cmyk && cmyk_target { // A CMYK target is written as CMYK with the document's own profile (#2220), which it // therefore needs; RGB-only targets keep the compositor's own handling (built-in @@ -435,130 +776,141 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target if icc.is_none() { icc = Some(profile.to_bytes().to_vec()); } - let (data, opaque) = cmyk_composite(doc, canvas, cs, n, warnings)?; - Image::from_raw(w, h, layout_for(ColorMode::Cmyk, !opaque), cs, data)? - } else { - let gray = fmt.mode == ColorMode::Grayscale; - if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) { - // RGB-only targets use the sRGB composite directly, without separating CMYK; the - // compositor renders Lab documents in sRGB. Either way the file is tagged sRGB. - warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); - icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + let inks = photocraft_compose::cmyk::renders_inks(doc); + let space = if inks { + None + } else { + // Through RGB and back: the colour model is kept, exact ink values are not. + warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string()); + photocraft_compose::cmyk_space(doc) + }; + return Ok(Self { doc, native: None, mode: ColorMode::Cmyk, sample, inks, space, icc, rows: 0 }); + } + if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) { + // RGB-only targets use the sRGB composite directly, without separating CMYK; the + // compositor renders Lab documents in sRGB. Either way the file is tagged sRGB. + warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); + icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + } + let mode = if fmt.mode == ColorMode::Grayscale { ColorMode::Grayscale } else { ColorMode::Rgb }; + Ok(Self { doc, native: None, mode, sample, inks: false, space: None, icc, rows: 0 }) + } + + fn layout(&self, alpha: bool) -> ChannelLayout { + layout_for(self.mode, alpha) + } + + /// The document's EXIF, XMP, text and resolution. + fn meta(&self) -> codecs::Metadata { + let doc = self.doc; + codecs::Metadata { + exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), + xmp: doc.metadata.xmp.as_deref().map(str::to_owned), + text: doc.metadata.text.clone(), + dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), + ..Default::default() + } + } + + /// Every row of the document, top to bottom, as bands of interleaved native-endian samples + /// in [`Self::layout`]`(alpha)`: `sink(rows, samples, translucent)`, where `translucent` says + /// whether a composite band held a pixel that isn't fully opaque (its alpha is dropped all + /// the same when `alpha` is false). A single layer's band is never translucent without + /// `alpha`: drop it only when [`opaque_surface`] says so. + fn bands(&self, alpha: bool, mut sink: impl FnMut(u32, Vec, bool) -> Result<(), IoError>) -> Result<(), IoError> { + let doc = self.doc; + let (w, h) = (doc.size.width, doc.size.height); + let fmt = doc.pixel_format(); + if let Some(s) = self.native { + // Native path: keep model and depth. The surface's encoded samples are the codec's raw + // native-endian samples, copied a band of rows at a time (alpha dropped when opaque). + let ch = fmt.channels(); + let bps = fmt.sample.bytes(); + let keep = (ch - 1) * bps; + let rows = if self.rows > 0 { self.rows } else { (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32 }; + let mut y = 0; + while y < h as i32 { + let y1 = y.saturating_add(rows).min(h as i32); + let mut band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); + if !alpha { + compact_alpha(&mut band, keep, bps); + } + sink((y1 - y) as u32, band, alpha)?; + y = y1; } - let colors = if gray { 1 } else { 3 }; - // Rendered and quantised in bands (no full-size float composite), with alpha; the - // alpha is dropped afterwards, in place, when every pixel turned out opaque. - let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; - let mut opaque = true; - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[3] >= 1.0); + return Ok(()); + } + let cs = self.sample; + let put = move |out: &mut Vec, v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + let colors = self.layout(false).channels(); + // Bands of quantised samples with alpha; the alpha is dropped here when not wanted. + let mut emit = |rows: u32, parts: Vec>, translucent: bool| -> Result<(), IoError> { + let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum()); + for part in parts { + data.extend_from_slice(&part); + } + if !alpha { + compact_alpha(&mut data, colors * cs.bytes(), cs.bytes()); + } + sink(rows, data, translucent) + }; + if self.inks { + // Mixed on ink values, rendered and quantised in bands. + let rendered = photocraft_compose::cmyk::render_cmyk_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> { + let translucent = band.px.iter().any(|p| p[4] < 1.0 || p[4].is_nan()); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); + for p in band.px.get(range).unwrap_or_default() { + for v in p { + put(&mut out, *v); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); } out }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) + emit(band.rect.height(), parts, translucent) }); - if opaque { - drop_alpha(&mut data, colors * cs.bytes(), cs.bytes()); - } - let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque); - Image::from_raw(w, h, layout, cs, data)? + return rendered.unwrap_or_else(|| Err(IoError::Unsupported("the CMYK compositor can't mix this document on its inks".into()))); } - }; - let meta = codecs::Metadata { - exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), - xmp: doc.metadata.xmp.as_deref().map(str::to_owned), - text: doc.metadata.text.clone(), - dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), - ..Default::default() - }; - Ok(img.with_icc(icc).with_meta(meta)) -} - -/// A CMYK document's composite as interleaved CMYK + alpha samples (`cs`), with whether every -/// pixel came out opaque (the caller then drops the alpha). Stacks the native CMYK compositor -/// handles are mixed on their ink values, so opaque pixels keep their exact inks; any other -/// document is composited in RGB and converted back through its own CMYK profile, as PSD -/// export's merged image is. Rendered and quantised in bands on every core. -fn cmyk_composite(doc: &Document, canvas: Rect, cs: CSample, n: usize, warnings: &mut Vec) -> Result<(Vec, bool), IoError> { - let mut data = try_buffer(n, 5 * cs.bytes())?; - let mut opaque = true; - let put = |out: &mut Vec, v: f32| { - let v = if v.is_finite() { v } else { 0.0 }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), - } - }; - let native = photocraft_compose::cmyk::render_cmyk_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[4] >= 1.0); - let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); - for p in band.px.get(range).unwrap_or_default() { - for v in p { - put(&mut out, *v); - } - } - out - }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) - }); - if native.is_none() { - // Through RGB and back: the colour model is kept, exact ink values are not. - warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string()); - let space = photocraft_compose::cmyk_space(doc); - let to = PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true); - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[3] >= 1.0); + let cmyk = self.mode == ColorMode::Cmyk; + let gray = self.mode == ColorMode::Grayscale; + let space = &self.space; + // Rendered and quantised in bands (no full-size float composite), with alpha. + photocraft_compose::render_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> { + let translucent = band.px.iter().any(|p| p[3] < 1.0 || p[3].is_nan()); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - // The composite came through the document's CMYK profile: convert back through it. + // A CMYK composite came through the document's CMYK profile: convert back through it. photocraft_color::convert::with_cmyk_space(space.as_ref(), || { - let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); - let mut v = [0.0f32; 5]; + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut converted = [0.0; 5]; for p in band.px.get(range).unwrap_or_default() { - photocraft_raster::from_rgba_into(&to, *p, &mut v); - for x in v { - put(&mut out, x); + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(&mut out, *value); + } + continue; + } else if gray { + put(&mut out, photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(&mut out, p[0]); + put(&mut out, p[1]); + put(&mut out, p[2]); } + put(&mut out, p[3]); } out }) }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) - }); + emit(band.rect.height(), parts, translucent) + }) } - if opaque { - drop_alpha(&mut data, 4 * cs.bytes(), cs.bytes()); - } - Ok((data, opaque)) } /// An empty buffer with room for `pixels × bytes_per_pixel` bytes, or an error (not an abort) @@ -570,15 +922,21 @@ pub(crate) fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result, color: usize, alpha: usize) { + compact_alpha(data, color, alpha); + data.shrink_to_fit(); +} + +/// [`drop_alpha`] keeping the allocation (a band about to be handed on). +fn compact_alpha(data: &mut Vec, color: usize, alpha: usize) { let stride = color + alpha; let n = data.len() / stride; for i in 0..n { data.copy_within(i * stride..i * stride + color, i * color); } data.truncate(n * color); - data.shrink_to_fit(); } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). @@ -620,23 +978,47 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu /// [`export_flat`] reporting a coarse step through `ctl` once the composite is rendered; the /// encoder does the rest (#3389). pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - use photocraft_cms::{Builtin, Intent}; if let Some(r) = export_mode_specific(doc, format, opts)? { return Ok(r); } + if format == Format::Png && streams_png(doc) { + return export_png_streamed(doc, opts, ctl, 0); + } let mut warnings = Vec::new(); let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(composite_share(format)); - if opts.xmp == XmpEmbed::None { - // Export As's Metadata: None: the packet lists the text of every type layer and one id - // per placed document (#647). - img.meta.xmp = None; - // Free-form descriptions can contain the same sensitive text as an XMP packet. - img.meta.text.clear(); + strip_xmp(&mut img.meta, opts); + let mut img = prepare_flat(img, format, &mut warnings)?; + if format == Format::Tga { + img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; } + if format == Format::Dds { + img = dds_image(doc, img, &mut warnings)?; + } + if format == Format::Tiff { + warnings.extend(crate::tiff_layers::flat_transparency_and_channels(doc, &mut img, opts.tiff_transparency)); + } + encode_image(&img, format, opts, warnings) +} + +/// Export As's Metadata: None drops the XMP packet, which lists the text of every type layer and +/// one id per placed document (#647), and the free-form descriptions, which can hold the same +/// sensitive text. +fn strip_xmp(meta: &mut codecs::Metadata, opts: &ExportOptions) { + if opts.xmp == XmpEmbed::None { + meta.xmp = None; + meta.text.clear(); + } +} + +/// `img` made storable as `format`: matted over white without alpha support, CMYK converted for a +/// format without CMYK, colours converted to what an untagged file means. Pixel by pixel, so a +/// band of rows gets what the whole image would (and the same warnings). +fn prepare_flat(mut img: Image, format: Format, warnings: &mut Vec) -> Result { + use photocraft_cms::{Builtin, Intent}; if img.layout().has_alpha() && !format.caps().alpha { // Flattened over white, as saving a transparent document without transparency does. img = matte_over_white(&img)?; @@ -665,17 +1047,132 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti img = gray; warnings.push(format!("colours converted to sGray; {format:?} can't embed the document's colour profile")); } + } else if img.icc.is_none() { + // An untagged document is in the default working space for its mode, which is how the + // canvas shows it and what Photoshop embeds for it (#9325): sRGB, and for gray the Dot + // Gain 20 %-compatible working gray (not sGray, which would show the same samples + // darker). A JPEG or PNG with Embed Color Profile then says so instead of leaving + // viewers to guess. The codec still leaves it out when embedding is off. + if img.layout().is_rgb() { + img.icc = Some(Builtin::Srgb.profile().to_bytes().to_vec()); + } else if img.layout().is_gray() { + img.icc = Some(Builtin::DotGain20Compat.profile().to_bytes().to_vec()); + } } - if format == Format::Tga { - img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; + Ok(img) +} + +/// Decoded pixel bytes past which a PNG export is written a band of rows at a time +/// ([`export_png_streamed`]) instead of from one image buffer (about 90 MP of 8-bit RGB). +const STREAM_PNG_BYTES: u64 = 256 << 20; + +/// Whether a PNG export of `doc` streams: its pixels would take more than [`STREAM_PNG_BYTES`]. +fn streams_png(doc: &Document) -> bool { + let fmt = doc.pixel_format(); + doc.size.area().saturating_mul((fmt.channels() * fmt.sample.bytes()) as u64) > STREAM_PNG_BYTES +} + +/// A PNG export rendered, converted and encoded a band of rows at a time, so no full-size image +/// is held: the only way a multi-gigapixel print (a 117504 × 22464 px banner is 7.9 GB of 8-bit +/// RGB) can be written as PNG (#8456). The pixels are the ones [`export_flat`] writes for a +/// smaller document, with these differences: Interlaced is turned off (Adam7 needs the whole +/// image), and a composite whose bottom layer isn't an opaque pixel layer keeps its alpha even +/// when every pixel turns out opaque (that is only known once the last row is written). +/// +/// `rows`: rows per band, 0 to size them to the document. +fn export_png_streamed(doc: &Document, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt, rows: i32) -> Result { + // A wrong guess (a knockout or a blend mode the guess doesn't foresee) shows in a band and + // the export starts again with alpha. + let opaque = match single_layer(doc) { + Some(s) => opaque_surface(s, doc.bounds()), + None => opaque_bottom(doc), + }; + // Progress never goes back: a second pass reports nothing new until it passes the first. + let mut progress = 0.0; + match stream_png(doc, opts, ctl, !opaque, rows, &mut progress)? { + Some(r) => Ok(r), + None => stream_png(doc, opts, ctl, true, rows, &mut progress)?.ok_or_else(|| IoError::Unsupported("PNG export lost its alpha channel".into())), } - if format == Format::Dds { - img = dds_image(doc, img, &mut warnings)?; +} + +/// Whether the bottom layer is a plain pixel layer opaque over the whole canvas (a Background +/// layer): the layers above it add coverage, so the composite is (most likely) opaque too. +fn opaque_bottom(doc: &Document) -> bool { + doc.layers.first().is_some_and(|l| plain_layer(l, doc.mode) && matches!(&l.content, LayerContent::Raster(s) if opaque_surface(s, doc.bounds()))) +} + +/// [`export_png_streamed`] with or without `alpha`, in bands of `rows` rows (0: sized to the +/// document). `None` when a translucent pixel shows up without `alpha`. `progress` is the most +/// reported so far, by this pass or an earlier one. +fn stream_png( + doc: &Document, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + alpha: bool, + rows: i32, + progress: &mut f32, +) -> Result, IoError> { + let format = Format::Png; + let (w, h) = (doc.size.width, doc.size.height); + let mut warnings = Vec::new(); + let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); + let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); + let src = FlatSource { rows, ..FlatSource::new(doc, &mut warnings, cmyk_target, lab_target)? }; + let mut encode = opts.encode.clone(); + let mut late = Vec::new(); + if encode.png_interlaced { + encode.png_interlaced = false; + late.push(format!("written without interlacing: a {w} × {h} px PNG is written a band of rows at a time")); } - if format == Format::Tiff { - warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img)); + // One blank row through the same steps as the bands gives the file's layout, profile and + // metadata (and their warnings) before anything is rendered. + let mut meta = src.meta(); + strip_xmp(&mut meta, opts); + let row = try_buffer(w as usize, src.layout(alpha).channels() * src.sample.bytes()).map(|mut v| { + v.resize(v.capacity(), 0); + v + })?; + let first = prepare_flat(Image::from_raw(w, 1, src.layout(alpha), src.sample, row)?.with_icc(src.icc.clone()).with_meta(meta), format, &mut warnings)?; + let mut fidelity: Vec = Vec::new(); + let mut note = |img: &Image| -> Result<(), IoError> { + for f in codecs::fidelity_warnings_with(img, format, &encode) { + if f.is_fatal() { + return Err(IoError::Unsupported(f.to_string())); + } + let f = f.to_string(); + if !fidelity.contains(&f) { + fidelity.push(f); + } + } + Ok(()) + }; + note(&first)?; + let mut bytes = Vec::new(); + let mut png = codecs::PngRows::new(&mut bytes, &first, h, &encode)?; + let (mut done, mut translucent) = (0u64, false); + let streamed = src.bands(alpha, |rows, data, see_through| { + if see_through && !alpha { + translucent = true; + return Err(IoError::Cancelled); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut band_warnings = Vec::new(); + let band = prepare_flat(Image::from_raw(w, rows, src.layout(alpha), src.sample, data)?.with_icc(src.icc.clone()), format, &mut band_warnings)?; + note(&band)?; + png.push(&band)?; + done += u64::from(rows); + *progress = progress.max(done as f32 / h as f32); + ctl.progress(*progress); + Ok(()) + }); + if translucent { + return Ok(None); } - encode_image(&img, format, opts, warnings) + streamed?; + png.finish()?; + warnings.extend(fidelity); + warnings.extend(late); + Ok(Some(ExportResult { bytes, warnings })) } /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an @@ -1065,3 +1562,345 @@ mod cmyk_band_tests { assert_eq!(first[3], 255); } } + +#[cfg(test)] +mod tiff_banded_tests { + use super::*; + + #[test] + fn a_tiff_main_opened_opens_without_a_memory_check() { + // With no free memory reported, an ordinary small TIFF was refused ("not enough memory"): + // on a small machine or in a container whose page cache filled up. + let img = Image::from_raw(64, 64, ChannelLayout::Rgb, CSample::U8, vec![77; 64 * 64 * 3]).unwrap(); + let bytes = codecs::encode(&img, Format::Tiff, &codecs::EncodeOptions::default()).unwrap(); + let bands = codecs::TiffBands::open(&bytes, None, &codecs::Limits::banded()).unwrap(); + let r = tiff_to_document("small.tif", bands.upright(), || Some(0), BAND_BYTES, &photocraft_raster::Interrupt::NONE).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (64, 64)); + } + + #[test] + fn a_group_converted_in_many_rounds_is_the_whole_image_converted() { + // A budget of a few rows splits each group into many rounds: every row lands where the + // one-shot conversion put it, at every depth the convert branch takes. + for (layout, sample) in [(ChannelLayout::Rgb, CSample::U8), (ChannelLayout::Rgb, CSample::U16), (ChannelLayout::Gray, CSample::F32)] { + let (w, h) = (37u32, 101u32); + let n = (w * h) as usize * layout.channels(); + let px: Vec = (0..n).map(|i| ((i * 37 + i / 7) % 251) as f32 / 250.0).collect(); + let img = Image::from_normalized(w, h, layout, sample, &px).unwrap(); + let bytes = codecs::encode(&img, Format::Tiff, &codecs::EncodeOptions::default()).unwrap(); + let bands = codecs::TiffBands::open(&bytes, None, &codecs::Limits::banded()).unwrap(); + let row = w as usize * layout.channels() * sample.bytes(); + let r = tiff_to_document("rounds.tif", bands, || None, 3 * row, &photocraft_raster::Interrupt::NONE).unwrap(); + let f = document_format(layout, sample); + let want = codecs::decode(&bytes).unwrap().convert(f.layout, f.sample); + let LayerContent::Raster(s) = &r.document.layers[0].content else { panic!("not a raster layer") }; + assert!(s.to_interleaved(r.document.bounds()) == want.data(), "{layout:?} {sample:?}"); + } + } + + #[test] + fn only_sizes_the_whole_image_decode_refused_are_checked() { + use {CSample as S, ChannelLayout as L}; + assert!(!needs_memory_check(6000, 4000, L::Rgb, S::U8)); + assert!(!needs_memory_check(20_000, 20_000, L::Rgba, S::U16)); + assert!(needs_memory_check(40_000, 30_000, L::Rgb, S::U8), "over 2^30 px"); + assert!(needs_memory_check(300_000, 300_000, L::Rgb, S::U8)); + } + + #[test] + fn conversion_rounds_cover_every_row_in_order_within_the_budget() { + let workers = crate::pixels::bands(usize::MAX >> 8).len(); + for (rows, row, budget) in [(0, 30, 100), (1, 30, 100), (1000, 30, 100), (997, 7, 50), (10, 1000, 1), (5000, 3, 1 << 20)] { + let rounds = conversion_rounds(rows, row, budget); + let pieces: Vec<_> = rounds.iter().flatten().cloned().collect(); + assert_eq!(pieces.iter().map(|r| r.len()).sum::(), rows, "{rows} rows"); + assert!(pieces.windows(2).all(|p| p[0].end == p[1].start), "contiguous and in order: {pieces:?}"); + assert!(pieces.first().is_none_or(|p| p.start == 0)); + let most = (budget / row).max(1); + assert!(pieces.iter().all(|p| p.len() <= most), "{rows} x {row} B, budget {budget}: {pieces:?}"); + assert!(rounds.iter().all(|r| r.len() <= workers)); + } + assert_eq!(conversion_rounds(5000, 3, 1 << 20).len(), 1, "a group under the budget is one round"); + } +} + +#[cfg(test)] +mod assumed_profile_tests { + use super::*; + use photocraft_cms::{Builtin, Profile}; + use photocraft_color::Color; + + fn doc(mode: ColorMode) -> Document { + Document::with_background("t", Size::new(16, 12), mode, SampleType::U8, Color::rgba(0.78, 0.39, 0.2, 1.0)) + } + + fn embedded(r: &ExportResult) -> Option { + codecs::decode(&r.bytes).unwrap().icc.map(|b| Profile::parse(&b).unwrap().description) + } + + #[test] + fn streamed_png_keeps_the_same_assumed_profile_and_pixels() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale] { + for embed in [false, true] { + let d = doc(mode); + let mut opts = ExportOptions::default(); + opts.encode.embed_icc = embed; + let whole = export_flat(&d, Format::Png, &opts).unwrap(); + let streamed = export_png_streamed(&d, &opts, &photocraft_raster::Interrupt::NONE, 3).unwrap(); + assert_eq!(embedded(&streamed), embedded(&whole)); + let expected = if mode == ColorMode::Rgb { Builtin::Srgb } else { Builtin::DotGain20Compat }; + assert_eq!(embedded(&streamed), embed.then(|| expected.description().to_string())); + let a = codecs::decode(&whole.bytes).unwrap(); + let b = codecs::decode(&streamed.bytes).unwrap(); + assert_eq!((a.dimensions(), a.layout(), a.sample_type()), (b.dimensions(), b.layout(), b.sample_type())); + assert_eq!(a.data(), b.data()); + } + } + } + + /// #9325: an untagged document is in the profile assumed for its mode, and Photoshop embeds + /// that (sRGB IEC61966-2.1 for a new or untagged RGB document) when the profile is embedded. + #[test] + fn untagged_documents_embed_the_profile_they_are_assumed_to_be_in() { + let opts = ExportOptions::default(); + assert!(opts.encode.embed_icc); + for ext in ["jpg", "png", "tif", "webp"] { + let r = crate::export(&doc(ColorMode::Rgb), ext, &opts).unwrap(); + assert_eq!(embedded(&r).as_deref(), Some(Builtin::Srgb.description()), "{ext}"); + } + // #9342 review: an untagged gray document shows through the default working gray (Dot + // Gain 20 %-compatible), so that is the profile its samples are tagged with, not sGray + // (which would show them darker than the canvas). + let r = crate::export(&doc(ColorMode::Grayscale), "png", &opts).unwrap(); + assert_eq!(embedded(&r).as_deref(), Some(Builtin::DotGain20Compat.description())); + // Embed Color Profile off: still none. + let mut off = ExportOptions::default(); + off.encode.embed_icc = false; + assert_eq!(embedded(&crate::export(&doc(ColorMode::Rgb), "jpg", &off).unwrap()), None); + // A format without profiles gets no warning about one. + let r = crate::export(&doc(ColorMode::Rgb), "bmp", &opts).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("profile")), "{:?}", r.warnings); + // A tagged document keeps its own profile. + let mut p3 = doc(ColorMode::Rgb); + p3.icc_profile = Some(Arc::new(Builtin::DisplayP3.profile().to_bytes().to_vec())); + assert_eq!(embedded(&crate::export(&p3, "jpg", &opts).unwrap()).as_deref(), Some(Builtin::DisplayP3.description())); + } +} + +#[cfg(test)] +mod streamed_png_tests { + use super::*; + use photocraft_raster::Interrupt; + + fn layer(doc: &Document, fills: &[(Rect, &[f32])]) -> Layer { + let mut s = Surface::new(doc.pixel_format()); + for (r, px) in fills { + s.fill_rect(*r, px); + } + Layer::new("Layer", LayerContent::Raster(s)) + } + + fn decoded(bytes: &[u8]) -> Image { + codecs::decode(bytes).unwrap() + } + + /// The bytes ahead of the first IDAT: signature, header, profile and metadata chunks. + fn head(png: &[u8]) -> &[u8] { + &png[..png.windows(4).position(|w| w == b"IDAT").unwrap() - 4] + } + + /// Streamed in bands of `rows`, `doc` gives the pixels, warnings and header chunks of the + /// whole-image export. + fn same_as_whole(doc: &Document, rows: i32, opts: &ExportOptions) -> ExportResult { + let whole = export_flat(doc, Format::Png, opts).unwrap(); + let streamed = export_png_streamed(doc, opts, &Interrupt::NONE, rows).unwrap(); + let (a, b) = (decoded(&whole.bytes), decoded(&streamed.bytes)); + assert_eq!((b.dimensions(), b.layout(), b.sample_type()), (a.dimensions(), a.layout(), a.sample_type())); + assert!(a.data() == b.data(), "{}: pixels differ", doc.name); + assert_eq!(streamed.warnings, whole.warnings); + assert!(head(&streamed.bytes) == head(&whole.bytes), "{}: header chunks differ", doc.name); + streamed + } + + #[test] + fn a_composite_over_an_opaque_bottom_layer_streams_as_rgb() { + let mut doc = Document::new("composite", Size::new(300, 700), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 300.0; + doc.metadata.xmp = Some("".into()); + doc.metadata.text = vec![("Title".into(), "Tent".into())]; + let bottom = layer(&doc, &[(doc.bounds(), &[0.9, 0.5, 0.1, 1.0]), (Rect::new(0, 300, 300, 420), &[0.1, 0.2, 0.8, 1.0])]); + let mut top = layer(&doc, &[(Rect::new(40, 100, 260, 650), &[0.2, 0.7, 0.3, 0.5])]); + top.opacity = 0.7; + top.blend = BlendMode::Multiply; + doc.layers = vec![bottom, top]; + assert!(opaque_bottom(&doc)); + for xmp in [XmpEmbed::All, XmpEmbed::None] { + let r = same_as_whole(&doc, 256, &ExportOptions { xmp, ..Default::default() }); + assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgb); + } + } + + #[test] + fn single_layers_stream_in_their_own_model_and_depth() { + // Transparent RGBA, opaque CMYK (written as sRGB through its profile), 16-bit gray and + // float RGB out of range (written with 16 bits, clipped, with the warning). + let mut rgba = Document::new("rgba", Size::new(130, 333), ColorMode::Rgb, SampleType::U8); + rgba.layers = vec![layer(&rgba, &[(Rect::new(10, 20, 120, 300), &[0.3, 0.6, 0.9, 0.4])])]; + let mut cmyk = Document::new("cmyk", Size::new(97, 211), ColorMode::Cmyk, SampleType::U8); + cmyk.icc_profile = Some(photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes()); + cmyk.layers = vec![layer(&cmyk, &[(cmyk.bounds(), &[0.3, 0.6, 0.1, 0.2, 1.0]), (Rect::new(0, 50, 97, 99), &[0.0, 0.9, 0.9, 0.0, 1.0])])]; + let mut gray = Document::new("gray", Size::new(64, 190), ColorMode::Grayscale, SampleType::U16); + gray.layers = vec![layer(&gray, &[(gray.bounds(), &[0.25, 1.0]), (Rect::new(0, 0, 64, 77), &[0.6123, 1.0])])]; + let mut hdr = Document::new("hdr", Size::new(50, 120), ColorMode::Rgb, SampleType::F32); + hdr.layers = vec![layer(&hdr, &[(hdr.bounds(), &[1.5, 0.25, -0.1, 1.0])])]; + for doc in [&rgba, &cmyk, &gray, &hdr] { + assert!(single_layer(doc).is_some(), "{}", doc.name); + same_as_whole(doc, 37, &ExportOptions::default()); + } + let r = same_as_whole(&hdr, 37, &ExportOptions::default()); + assert!(r.warnings.iter().any(|w| w.contains("clip")), "{:?}", r.warnings); + } + + /// Without an opaque bottom layer the composite keeps its alpha, even when every pixel is + /// opaque (the whole-image export finds that out and drops it). + #[test] + fn a_composite_without_an_opaque_bottom_layer_keeps_its_alpha() { + let mut doc = Document::new("masked", Size::new(80, 300), ColorMode::Rgb, SampleType::U8); + let mut bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]); + bottom.mask = Some(photocraft_doc::LayerMask { + surface: Surface::with_default(PixelFormat::GRAY8, &[1.0]), + enabled: true, + linked: true, + density: 1.0, + feather: 0.0, + }); + doc.layers = vec![bottom, layer(&doc, &[(Rect::new(0, 0, 40, 40), &[1.0, 0.0, 0.0, 1.0])])]; + assert!(!opaque_bottom(&doc)); + let whole = decoded(&export_flat(&doc, Format::Png, &ExportOptions::default()).unwrap().bytes); + let streamed = decoded(&export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::NONE, 256).unwrap().bytes); + assert_eq!((whole.layout(), streamed.layout()), (ChannelLayout::Rgb, ChannelLayout::Rgba)); + assert!(whole.convert(ChannelLayout::Rgba, CSample::U8).data() == streamed.data()); + } + + /// A knockout through an opaque bottom layer makes the guess wrong: the export starts again + /// with alpha and keeps the hole, and its progress never goes back. + #[test] + fn a_wrong_opacity_guess_starts_again_with_alpha() { + // The hole is in the fourth band: three are written before the guess turns out wrong. + let mut doc = Document::new("knockout", Size::new(64, 1000), ColorMode::Rgb, SampleType::U8); + let bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]); + let mut hole = layer(&doc, &[(Rect::new(8, 800, 56, 960), &[1.0, 1.0, 1.0, 1.0])]); + hole.fill_opacity = 0.0; + hole.advanced.knockout = photocraft_doc::Knockout::Deep; + doc.layers = vec![bottom, hole]; + assert!(opaque_bottom(&doc)); + assert!(stream_png(&doc, &ExportOptions::default(), &Interrupt::NONE, false, 256, &mut 0.0).unwrap().is_none()); + let r = same_as_whole(&doc, 256, &ExportOptions::default()); + assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgba); + + let seen = std::sync::Mutex::new(Vec::new()); + let report = |f: f32| seen.lock().unwrap().push(f); + let never = || false; + export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::new(&never, &report), 256).unwrap(); + let seen = seen.into_inner().unwrap(); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "progress went back: {seen:?}"); + assert_eq!(seen.last(), Some(&1.0)); + } + + #[test] + fn interlacing_is_turned_off_with_a_warning() { + let mut doc = Document::new("interlaced", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])]; + let mut opts = ExportOptions::default(); + opts.encode.png_interlaced = true; + let r = export_png_streamed(&doc, &opts, &Interrupt::NONE, 7).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("without interlacing")), "{:?}", r.warnings); + let whole = decoded(&export_flat(&doc, Format::Png, &opts).unwrap().bytes); + assert!(decoded(&r.bytes).data() == whole.data()); + } + + #[test] + fn a_cancelled_stream_stops_with_cancelled() { + let mut doc = Document::new("cancel", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])]; + let yes = || true; + let r = export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::cancel_only(&yes), 7); + assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); + } + + #[test] + fn only_large_documents_stream() { + let doc = |w, h, depth| Document::new("size", Size::new(w, h), ColorMode::Rgb, depth); + assert!(!streams_png(&doc(6000, 4000, SampleType::U8))); + assert!(streams_png(&doc(10_000, 8000, SampleType::U8))); + assert!(streams_png(&doc(8000, 6000, SampleType::U16))); + assert!(streams_png(&doc(117_504, 22_464, SampleType::U8))); + } + + /// A cancelled nested TIFF import never spends its parent's remaining allowance: the + /// cancellation check comes before the charge, and the reentrant guard is taken first. + #[test] + fn a_cancelled_nested_tiff_admission_leaves_the_parent_allowance_untouched() { + let _parent = crate::psd_gate::acquire_decode_admission(&Interrupt::NONE).expect("parent admission"); + crate::psd_gate::set_active_attempt_budget(1000); + let yes = || true; + let cancelled = Interrupt::cancel_only(&yes); + match tiff_admission(100, 0, &crate::ImportOptions::default(), &cancelled) { + Err(IoError::Cancelled) => {} + other => panic!("expected a cancelled nested admission, got {:?}", other.map(|_| ())), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(1000), "a cancelled nested import must not consume the parent's budget"); + } + + /// A nested TIFF import still enforces the caller's explicit bounds, before the parent's + /// allowance is charged; one within those bounds is charged exactly once. + #[test] + fn nested_tiff_admission_enforces_the_callers_local_bounds_before_charging_the_parent() { + let ctl = Interrupt::NONE; + let _parent = crate::psd_gate::acquire_decode_admission(&ctl).expect("parent admission"); + let ample = 1000u64; + crate::psd_gate::set_active_attempt_budget(ample); + + let lowered = crate::ImportOptions { psd_max_pixel_bytes: 99, ..Default::default() }; + match tiff_admission(100, 0, &lowered, &ctl) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), + other => panic!("expected the caller's limit to refuse, got {:?}", other.map(|_| ())), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); + + let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(99), ..Default::default() }; + match tiff_admission(100, 0, &stale, &ctl) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), + other => panic!("expected the approved estimate bound to refuse, got {:?}", other.map(|_| ())), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); + + let allowed = crate::ImportOptions { psd_max_pixel_bytes: 100, psd_high_memory_approved: true, psd_approved_estimate: Some(100), ..Default::default() }; + let guard = tiff_admission(100, 0, &allowed, &ctl).expect("within the caller's bounds"); + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample - 100), "the nested import is charged once"); + drop(guard); + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample - 100), "releasing the nested guard neither refunds nor resets the parent"); + } + + /// A layered TIFF estimate that overflowed `u64` saturates at `u64::MAX`. Unknown native + /// memory has `u64::MAX` as its hard limit, so approval used to admit the sentinel; it is + /// refused before the gate and before any composite is decoded. + #[test] + fn a_saturated_tiff_estimate_is_refused_before_any_decode() { + if cfg!(target_pointer_width = "32") { + return; + } + crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { + for opts in [ + crate::ImportOptions::default(), + crate::ImportOptions { psd_high_memory_approved: true, ..Default::default() }, + crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(u64::MAX), ..Default::default() }, + ] { + match tiff_admission(u64::MAX, 0, &opts, &Interrupt::NONE) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)), + other => panic!("expected a hard refusal for {opts:?}, got {:?}", other.map(|_| ())), + } + } + }); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0a184bda79..9364287344 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,6 +45,8 @@ mod channel_map; pub mod comps_map; pub mod doc_path; pub mod effects_map; +#[cfg(any(unix, windows))] +mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; @@ -53,6 +55,7 @@ pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod duotone_map; mod kra; pub mod linked; +pub mod memory; mod multichannel_map; mod ora; mod palette_map; @@ -63,8 +66,11 @@ pub mod pdf; mod pdn; pub use pcraft_cache::PcraftExportCache; pub mod pixels; +pub mod psd_budget; mod psd_export; +mod psd_gate; mod psd_import; +pub use psd_budget::{DEFAULT_PSD_IMPORT_PIXEL_BYTES, PsdMemoryEstimate, estimate as estimate_psd_memory}; pub mod psd_thumbnail; pub mod raw; pub mod slices_map; @@ -74,6 +80,7 @@ mod text_import; pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +pub mod vector_preview; mod xcf; pub mod xml; @@ -84,8 +91,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, import_webp_frames, - 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_tiff_page_with_options, + 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}; @@ -108,6 +115,23 @@ pub enum IoError { /// PSD parse/write failure. #[error("PSD: {0}")] Psd(#[from] PsdError), + /// The conservative PSD pixel-storage and workspace estimate exceeded its allowance. + #[error("PSD import needs an estimated {required} bytes of pixel memory; limit is {limit} bytes")] + PsdImportBudget { required: u64, limit: u64 }, + /// PSD pixel-storage estimate exceeds the automatic soft limit, but is within the hard limit. + /// Explicit approval is required to proceed. + #[error( + "PSD import needs an estimated {required} bytes of pixel memory (soft limit {soft_limit} bytes, hard limit {hard_limit} bytes); explicit approval required" + )] + PsdApprovalRequired { required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, + /// The file behind the opened handle is not the one whose memory estimate was approved: it + /// was replaced, rewritten or resized since it was checked. Nothing was decoded. + #[error("the file changed since it was checked; approve it again to open the new file")] + SourceChanged, + /// A pixel buffer could not be allocated while reading a file: the file may be fine, but + /// this machine has no memory for it. + #[error("not enough memory: {0}")] + OutOfMemory(String), /// Codec failure. #[error("codec: {0}")] Codec(#[from] CodecError), @@ -148,6 +172,72 @@ pub struct ImportResult { pub preview_only: bool, } +/// Identity of the file behind an opened handle, read from that handle. +/// +/// A one-shot high-memory approval is bound to a file, not to a path: the importer opens the +/// path once, takes this identity from the handle and refuses to decode unless it is the file the +/// estimate was approved for (`import_file_for_open_verified`). The Unix form includes the +/// device, inode, modification time and change time with nanoseconds, so a same-size rewrite +/// cannot hide behind a restored modification timestamp. The identity fails closed when any of +/// these cannot be read (see [`Self::of_handle`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileIdentity { + /// File length in bytes. + pub len: u64, + /// Last modification time. + pub modified: std::time::SystemTime, + /// Device holding the file (Unix). + #[cfg(unix)] + pub dev: u64, + /// Inode of the file (Unix). + #[cfg(unix)] + pub ino: u64, + /// Inode change time, seconds since the Unix epoch (Unix). + #[cfg(unix)] + pub ctime_secs: i64, + /// Nanosecond part of [`Self::ctime_secs`] (Unix). + #[cfg(unix)] + pub ctime_nanos: i64, +} + +impl FileIdentity { + /// Identity of an open file handle. + /// + /// Reads the handle's own metadata, never the path, so replacing the path cannot change what + /// this describes. A metadata field that cannot be read is an error: callers must fail closed + /// rather than decode under an approval they cannot verify. + pub fn of_handle(file: &std::fs::File) -> std::io::Result { + Self::of_metadata(&file.metadata()?) + } + + /// Identity of the file currently at `path`. + /// + /// For discovery only (a fingerprint kept while an estimate is made). The decode path must + /// open the file and compare the identity of that handle, since the path can change between + /// this call and the open. + pub fn of_path(path: &std::path::Path) -> std::io::Result { + Self::of_metadata(&std::fs::metadata(path)?) + } + + /// Builds an identity from metadata already read from an open handle or path. + /// + /// Unix adds the device, inode and inode-change time so a same-size rewrite with a restored + /// modification time is still detected; other platforms compare length and modification time. + fn of_metadata(metadata: &std::fs::Metadata) -> std::io::Result { + let len = metadata.len(); + let modified = metadata.modified()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(Self { len, modified, dev: metadata.dev(), ino: metadata.ino(), ctime_secs: metadata.ctime(), ctime_nanos: metadata.ctime_nsec() }) + } + #[cfg(not(unix))] + { + Ok(Self { len, modified }) + } + } +} + /// The import note for a file whose horizontal and vertical resolutions (`x`, `y`, in pixels per /// inch) differ: a document has one resolution, so it keeps the horizontal one and saves it on /// both axes (#1018). Differences under 0.01 ppi are below what the warning can show. @@ -193,6 +283,68 @@ pub struct ExportResult { pub warnings: Vec, } +/// What [`encode_with`] produced: the file's bytes, or for PSD/PSB the file model, which +/// [`Encoded::write_to`] serializes straight into the file, so a save never holds the whole +/// encoded file next to the model (#6607). Either way the bytes are exactly [`export`]'s. +#[derive(Debug, Clone)] +pub enum Encoded { + /// The encoded file. + Bytes(Vec), + /// A PSD/PSB file, serialized when written. + Psd(Box), +} + +impl Encoded { + /// The file's bytes, serializing a PSD/PSB model in memory. + pub fn into_bytes(self) -> Result, IoError> { + match self { + Encoded::Bytes(b) => Ok(b), + Encoded::Psd(file) => Ok(file.to_bytes()?), + } + } + + /// [`Self::into_bytes`] without giving up the model. + pub fn bytes(&self) -> Result, IoError> { + match self { + Encoded::Bytes(b) => Ok(std::borrow::Cow::Borrowed(b)), + Encoded::Psd(file) => Ok(std::borrow::Cow::Owned(file.to_bytes()?)), + } + } + + /// The exact length [`Self::write_to`] writes. Fails, as the write would, for a PSD/PSB + /// model its writer refuses. + pub fn encoded_len(&self) -> Result { + match self { + Encoded::Bytes(b) => Ok(b.len() as u64), + Encoded::Psd(file) => Ok(file.encoded_len()?), + } + } + + /// Writes the file into `w` from its current position (PSD/PSB seeks back to patch lengths). + /// A PSD/PSB serializer error comes back as `InvalidData`. + pub fn write_to(&self, w: &mut W) -> std::io::Result<()> { + match self { + Encoded::Bytes(b) => w.write_all(b), + Encoded::Psd(file) => file.write_to(w), + } + } +} + +/// Result of [`encode_with`]. +#[derive(Debug, Clone)] +pub struct EncodedExport { + /// The file, ready to write. + pub encoded: Encoded, + /// Human-readable notes about anything approximated or dropped. + pub warnings: Vec, +} + +impl From for EncodedExport { + fn from(r: ExportResult) -> Self { + EncodedExport { encoded: Encoded::Bytes(r.bytes), warnings: r.warnings } + } +} + /// Which part of the document's XMP packet and free-form text a flat export embeds. /// PSD/PSB always keep XMP; `.pcraft` keeps both. Save As and conversions keep the whole packet, as /// Photoshop's Save As does; Export As starts at `None`, because the packet lists the text of @@ -218,6 +370,13 @@ pub struct ExportOptions { /// Save As sets it from its Layers option, which keeps them as Photoshop does. `false` is /// Photoshop's "Discard Layers and Save a Copy". pub tiff_layers: bool, + /// TIFF: Photoshop's "Save Transparency" for the composite (the merged image other readers + /// see; each layer of a layered TIFF keeps its own transparency either way). `Some(true)` + /// stores the transparency as the first extra sample (unassociated alpha), ahead of any + /// alpha and spot channels; `Some(false)` composites the image over white and stores no + /// transparency. `None`, the default, keeps it unless the document has alpha or spot + /// channels, which are then written as with it off. + pub tiff_transparency: Option, /// Which part of the document's XMP packet and free-form text a flat export embeds. /// PSD/PSB always keep XMP; `.pcraft` keeps both. Everything by default, as Save As does; /// Export As offers None. @@ -240,6 +399,11 @@ pub struct ExportOptions { /// PSD/PSB and layered TIFF: read and embed the files linked smart objects name (see /// [`PsdExportOptions::read_linked_files`]). On by default; automation turns it off. pub read_linked_files: bool, + /// `.pcraft`: request the flattened document at its canvas size as `composite/full.png` + /// (Preferences › File Handling › Save Full-Size Composite in .pcraft Files). The save may + /// omit it; check the manifest's `full_composite` member ([`photocraft_format::read_full_composite`] + /// reads it). Off by default. + pub pcraft_full_composite: bool, } impl Default for ExportOptions { @@ -248,6 +412,7 @@ impl Default for ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, + tiff_transparency: None, xmp: XmpEmbed::All, tga_bits: None, image_previews: true, @@ -255,6 +420,7 @@ impl Default for ExportOptions { document_path: None, pcraft_cache: None, read_linked_files: true, + pcraft_full_composite: false, } } } @@ -337,11 +503,27 @@ pub struct ImportOptions { pub max_group_depth: usize, /// The demosaic method camera raws are developed with. pub demosaic: raw::Demosaic, + /// PSD layer/mask/merged pixel allowance, including conversion workspace, and the + /// equivalent allowance for a layered TIFF's composite and retained decoded image. May + /// lower the default cap; 0 uses the adaptive budget. Does not cover metadata or + /// smart-filter caches. + pub psd_max_pixel_bytes: u64, + /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. + pub psd_high_memory_approved: bool, + /// Optional estimate limit for high-memory approval. When set, approval only applies if + /// the fresh preflight estimate does not exceed this amount. + pub psd_approved_estimate: Option, } impl Default for ImportOptions { fn default() -> Self { - ImportOptions { max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, demosaic: raw::Demosaic::default() } + ImportOptions { + max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, + demosaic: raw::Demosaic::default(), + psd_max_pixel_bytes: 0, + psd_high_memory_approved: false, + psd_approved_estimate: None, + } } } @@ -369,11 +551,16 @@ pub fn import_for_open(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result /// [`import_for_open`] with cancellation and progress, shared with [`import_with_options_and_interrupt`]. pub fn import_for_open_with(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { + photocraft_compose::discard_unreadable_pixel_caches(); + let scratch_generation = photocraft_raster::spill::read_error_generation(); ctl.check().map_err(|_| IoError::Cancelled)?; let mut damaged = false; let result = import_stages(name, bytes, ctl, opts, &mut damaged)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); + // The imported document's own tiles, and anything that failed or recovered meanwhile. + photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; + result.document.scratch_health().map_err(IoError::Unsupported)?; Ok((result, damaged)) } @@ -383,28 +570,100 @@ fn import_psd( name: &str, ctl: &photocraft_raster::Interrupt, damaged: &mut bool, - parse: impl FnOnce(&dyn Fn(&PsdFile) -> bool, &dyn Fn(&PsdFile) -> bool) -> photocraft_psd::Result<(PsdFile, Option>>)>, + opts: &ImportOptions, + parse: impl FnOnce( + &dyn Fn(&PsdFile) -> photocraft_psd::Result, + &dyn Fn(&PsdFile) -> bool, + ) -> photocraft_psd::Result<(PsdFile, Option>>)>, ) -> Result { - // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. - // A failed read of the file (one parsed from its source) is a read error, as reading the - // whole file first would report it. - let (file, merged) = - parse(&|f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f), &psd_import::can_recover_layers).map_err(|e| match e { - photocraft_psd::PsdError::Read(msg) => IoError::Read(msg), - e => e.into(), - })?; - // Nesting past the document model's cap could never be saved; reject the file with the - // actionable limit. - psd_import::check_group_depth(&file)?; + let preflight_error = std::cell::RefCell::new(None); + let admission_guard: std::cell::RefCell> = std::cell::RefCell::new(None); + + let parsed = parse( + &|file| { + // Only an import that keeps the layer tree is bound by the group nesting cap. + let depth = if psd_import::can_recover_layers(file) { psd_import::check_group_depth(file) } else { Ok(()) }; + let preflight_check: Result = (|| { + depth?; + let est = psd_budget::estimate(file)?; + + if psd_gate::is_holding_admission() { + // Nested import: it spends the enclosing attempt's remaining allowance and + // never resets or re-charges the parent. Cancellation is checked before the + // charge, and admission is reentrant here, so a cancelled or refused nested + // import leaves the parent's allowance untouched. The caller's explicit + // bounds (a lower allowance and a one-shot estimate) still apply before the + // parent is charged. + ctl.check().map_err(|_| IoError::Cancelled)?; + memory::nested_import_local_bound(est.total_bytes, opts.psd_max_pixel_bytes, opts.psd_approved_estimate)?; + let guard = psd_gate::acquire_decode_admission(ctl)?; + psd_gate::consume_attempt_allowance(est.total_bytes)?; + *admission_guard.borrow_mut() = Some(guard); + return Ok(psd_import::merged_needed(file)); + } + + // 1. Initial admission check BEFORE waiting on or acquiring the gate. + // Invalid or unapproved files fail fast without blocking other decodes or acquiring gate. + memory::evaluate_psd_admission( + est.total_bytes, + est.tile_bytes, + opts.psd_max_pixel_bytes, + opts.psd_high_memory_approved, + opts.psd_approved_estimate, + memory::MemorySnapshot::current(), + )?; + + // 2. Cancellation-aware gate acquisition BEFORE any merged or layer pixel decoding/inflation. + let guard = psd_gate::acquire_decode_admission(ctl)?; + + // 3. Re-evaluate adaptive budget immediately after gate acquisition. + // If available memory dropped while waiting for gate, this returns Err, + // guard is dropped (releasing gate immediately), and error is returned before prompting. + let outcome = memory::evaluate_psd_admission( + est.total_bytes, + est.tile_bytes, + opts.psd_max_pixel_bytes, + opts.psd_high_memory_approved, + opts.psd_approved_estimate, + memory::MemorySnapshot::current(), + )?; + + // Initialize enclosing attempt allowance for any nested decodes. + psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); + + // Retain guard across parse and conversion + *admission_guard.borrow_mut() = Some(guard); + Ok(psd_import::merged_needed(file)) + })(); + + match preflight_check { + Ok(want_merged) => Ok(want_merged), + Err(error) => { + *preflight_error.borrow_mut() = Some(error); + Err(PsdError::LimitExceeded("PSD import preflight refused the file")) + } + } + }, + &psd_import::can_recover_layers, + ); + + if let Some(error) = preflight_error.into_inner() { + return Err(error); + } + let (file, merged) = parsed.map_err(|error| match error { + PsdError::Read(message) => IoError::Read(message), + PsdError::OutOfMemory(message) => IoError::OutOfMemory(message.to_string()), + error => error.into(), + })?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); + // Read before the conversion, which moves the global blocks into the document. let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); - let (mut document, warnings, psd_damaged, read_error) = psd_import::psd_to_document_owned(file, ctl, merged).ok_or(IoError::Cancelled)?; - // The file became unreadable while its layers were read from it (`import_file_for_open_with`): - // the open fails, rather than opening with those layers empty. - if let Some(e) = read_error { - return Err(IoError::Read(e.to_string())); + let (mut document, warnings, psd_damaged, fatal_error) = psd_import::psd_to_document_owned(file, ctl, merged).ok_or(IoError::Cancelled)?; + // Source reads and allocation failures fail the entire open; partial layers are discarded. + if let Some(e) = fatal_error { + return Err(e); } *damaged = psd_damaged; document.name = name.to_string(); @@ -416,37 +675,137 @@ fn import_psd( /// ([`PsdFile::from_source`]) rather than read whole: its channel data is read layer by layer as /// it is decoded, so opening it needs memory for the document, not for the document plus the /// file. A read that fails meanwhile (the file is truncated or rewritten, its drive goes away) -/// fails the open. Other formats are read whole. +/// fails the open. A single-part flat scanline EXR is decoded one compression block at a time +/// into tiles, so its size is bounded by the tile budget (or the scratch disk), not by a +/// whole-file buffer and a decoded image. Other formats are read whole. #[cfg(any(unix, windows))] pub fn import_file_for_open_with( name: &str, path: &std::path::Path, opts: &ImportOptions, ctl: &photocraft_raster::Interrupt, +) -> Result<(ImportResult, bool), IoError> { + let mut identity = None; + import_file_for_open_verified(name, path, None, opts, ctl, &mut identity) +} + +/// [`import_file_for_open_with`] bound to the identity of the file handle it decodes. +/// +/// The path is opened exactly once. `identity_out` is set from that handle before any pixel is +/// read; a PSD/PSB is then streamed from the same handle (not read whole), anything else is read +/// from it within `photocraft_format::MAX_OPEN_BYTES`. When `expected` is given, the import +/// refuses with [`IoError::SourceChanged`] unless the opened file is that one. When the parse +/// stops for a high-memory approval, the identity is read again from a duplicate of the same +/// handle: a file rewritten in place while its estimate was checked is refused, so the approval +/// always refers to the bytes that will be decoded. +/// +/// A missing or unreadable metadata field fails the open instead of decoding under an identity +/// that cannot be verified. +#[cfg(any(unix, windows))] +pub fn import_file_for_open_verified( + name: &str, + path: &std::path::Path, + expected: Option<&FileIdentity>, + opts: &ImportOptions, + ctl: &photocraft_raster::Interrupt, + identity_out: &mut Option, ) -> Result<(ImportResult, bool), IoError> { let read_failed = |e: std::io::Error| IoError::Read(format!("{}: {e}", path.display())); ctl.check().map_err(|_| IoError::Cancelled)?; let source = photocraft_psd::FileSource::open(path).map_err(read_failed)?; + let metadata = source.file().metadata().map_err(read_failed)?; + if !metadata.is_file() { + return Err(IoError::Read(format!("{}: not a regular file", path.display()))); + } + let identity = FileIdentity::of_metadata(&metadata).map_err(read_failed)?; + *identity_out = Some(identity.clone()); + if expected.is_some_and(|expected| expected != &identity) { + return Err(IoError::SourceChanged); + } + // A duplicate of the same open file (not a fresh path lookup): the approval pause re-reads + // the identity from it after parsing, and never from a path that may point elsewhere now. + let watch = source.file().try_clone().map_err(read_failed)?; let mut magic = [0u8; 4]; let psd = !has_extension(name, photocraft_format::EXTENSION) && photocraft_psd::ReadAt::read_at(&source, 0, &mut magic).is_ok() && is_psd(&magic); - if !psd { - drop(source); - let bytes = photocraft_format::read_file(path).map_err(read_failed)?; - return import_for_open_with(name, &bytes, opts, ctl); + let result = if psd { + import_source_with_options(name, photocraft_psd::Source::new(source), ctl, opts) + } else { + // Read from the handle that was just identified, not by opening the path again. + let mut file = source.file(); + // Windows seek_read advances this handle during the format probe. + // Rewind the same verified handle before reading a non-PSD file. + std::io::Seek::rewind(&mut file).map_err(read_failed)?; + // A flat scanline EXR is decoded block by block straight into tiles; other EXR + // layouts, and EXR headers the band decoder refuses, are read whole below. + let mut streamed = None; + if !has_extension(name, photocraft_format::EXTENSION) && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) { + let before = photocraft_raster::spill::read_error_generation(); + if let Some(mut result) = exr_native::import(&mut file, ctl)? { + // A rewrite in place while the blocks were read must not become the document. + if FileIdentity::of_handle(&watch).map_err(read_failed)? != identity { + return Err(IoError::Read(format!("{}: the file changed while it was being opened; open it again", path.display()))); + } + photocraft_raster::spill::check_since(before).map_err(IoError::Read)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + result.document.name = name.to_string(); + streamed = Some(result); + } else { + std::io::Seek::rewind(&mut file).map_err(read_failed)?; + } + } + match streamed { + Some(result) => Ok((result, false)), + None => { + let bytes = photocraft_format::read::read_all(&mut file, photocraft_psd::ReadAt::len(&source)).map_err(read_failed)?; + import_for_open_with(name, &bytes, opts, ctl) + } + } + }; + // An approval pause must describe a file that still has the identity it was estimated with; + // an approved resume must have decoded that file, not a rewrite that raced the decode. + if expected.is_some() || matches!(&result, Err(IoError::PsdApprovalRequired { .. })) { + let after = FileIdentity::of_handle(&watch).map_err(read_failed)?; + if after != identity { + return Err(IoError::SourceChanged); + } + *identity_out = Some(after); } - import_source_for_open_with(name, photocraft_psd::Source::new(source), ctl) + result } /// [`import_file_for_open_with`] for a PSD/PSB in any [`photocraft_psd::ReadAt`] source. pub fn import_source_for_open_with(name: &str, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { + import_source_with_options(name, source, ctl, &ImportOptions::default()) +} + +/// [`import_source_for_open_with`] with custom import options. +pub fn import_source_with_options( + name: &str, + source: photocraft_psd::Source, + ctl: &photocraft_raster::Interrupt, + opts: &ImportOptions, +) -> Result<(ImportResult, bool), IoError> { ctl.check().map_err(|_| IoError::Cancelled)?; let mut damaged = false; - let r = import_psd(name, ctl, &mut damaged, |want, recover| PsdFile::from_source_and_merged_recovering(source, want, recover))?; + let r = import_psd(name, ctl, &mut damaged, opts, |want, recover| PsdFile::from_source_and_merged_preflight(source, want, recover))?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok((r, damaged)) } +/// [`import_source_with_options`] with an explicit PSD pixel limit, for tests that exercise +/// admission without the default allowance. +#[cfg(test)] +pub(crate) fn import_source_with_limit( + name: &str, + source: photocraft_psd::Source, + ctl: &photocraft_raster::Interrupt, + pixel_limit: u64, +) -> Result<(ImportResult, bool), IoError> { + let opts = ImportOptions { psd_max_pixel_bytes: pixel_limit, ..Default::default() }; + import_source_with_options(name, source, ctl, &opts) +} + /// [`import_file_for_open_with`] without the damage flag. #[cfg(any(unix, windows))] pub fn import_file(name: &str, path: &std::path::Path, opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { @@ -464,7 +823,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, ctl, damaged, |want, recover| PsdFile::from_bytes_and_merged_recovering(bytes, want, recover)); + return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); @@ -497,9 +856,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, ctl, damaged, |want, recover| PsdFile::from_bytes_and_merged_recovering(bytes, want, recover)); + return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); } - flat::import_flat(name, bytes) + flat::import_flat_with_options(name, bytes, opts, ctl) } /// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`). @@ -549,10 +908,36 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// layered TIFF, in coarse steps for the other formats) and checks it between stages. A cancelled /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - ctl.check().map_err(|_| IoError::Cancelled)?; - let r = export_stages(doc, name_or_ext, opts, ctl)?; + let r = checked_stages(doc, name_or_ext, opts, ctl)?; + let bytes = r.encoded.into_bytes()?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); + Ok(ExportResult { bytes, warnings: r.warnings }) +} + +/// [`export_with`] up to the point where the file can be written: PSD/PSB stop at the file model, +/// which [`Encoded::write_to`] serializes into the file as it goes (#6607); every other format is +/// encoded. Every pixel is read from the document (scratch included, and checked as +/// [`export_with`] checks it) before this returns: the model holds the encoded channels, never a +/// reference to the document's tiles. Reports progress up to `1.0` once the result is ready. +pub fn encode_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + let r = checked_stages(doc, name_or_ext, opts, ctl)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(1.0); + Ok(r) +} + +/// [`export_stages`] refused when the document's scratch was, or became, unreadable while its +/// pixels were read (previews included): their placeholders must never be saved. +fn checked_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + photocraft_compose::discard_unreadable_pixel_caches(); + doc.scratch_health().map_err(IoError::Unsupported)?; + let scratch_generation = photocraft_raster::spill::read_error_generation(); + let r = export_stages(doc, name_or_ext, opts, ctl)?; + photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; + doc.scratch_health().map_err(IoError::Unsupported)?; + ctl.check().map_err(|_| IoError::Cancelled)?; Ok(r) } @@ -564,14 +949,18 @@ pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, } /// Where a .pcraft save's progress stands once its thumbnail, then its 1024 px composite preview, -/// are rendered (the bundle itself is written after them). +/// then its full-size composite when [`ExportOptions::pcraft_full_composite`] asks for one, are +/// rendered (the bundle itself is written after them). const PCRAFT_THUMBNAIL_DONE: f32 = 0.15; const PCRAFT_PREVIEWS_DONE: f32 = 0.4; +const PCRAFT_FULL_COMPOSITE_DONE: f32 = 0.6; +/// Bounds the optional full-size RGBA8 composite to 256 MiB before PNG and ZIP encoding. +const PCRAFT_FULL_COMPOSITE_PIXELS: u64 = 1 << 26; /// The share of a PSD/PSB export spent converting the document (layers, then the composite) /// before its bytes are assembled: about 2.7 s of 3.0 s for a 24 MP, 5-layer RGB/8 document. const PSD_MODEL_DONE: f32 = 0.9; -fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { +fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { let ext = extension(name_or_ext); if ext == "pdn" { return Err(IoError::Unsupported("PDN is import-only; save as .pcraft to preserve all layers and blend modes".into())); @@ -588,10 +977,10 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & )); } if ext == "ora" { - return ora::export(doc); + return ora::export(doc).map(EncodedExport::from); } if ext == "pdf" { - return pdf::export(doc); + return pdf::export(doc).map(EncodedExport::from); } if ext == photocraft_format::EXTENSION { let doc = named_for_file(doc, name_or_ext); @@ -601,12 +990,19 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & let composite = Some(photocraft_compose::thumbnail(doc.as_ref(), 1024)); ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_PREVIEWS_DONE); - let previews = photocraft_format::SaveOptions { thumbnail, composite }; + let mut full_composite = None; + if opts.pcraft_full_composite { + full_composite = with_span(ctl, PCRAFT_PREVIEWS_DONE, PCRAFT_FULL_COMPOSITE_DONE, |ctl| pcraft_full_composite_with(doc.as_ref(), ctl))?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(PCRAFT_FULL_COMPOSITE_DONE); + } + let full_composite_icc = full_composite.as_ref().map(|_| pcraft_full_composite_icc(doc.as_ref())); + let previews = photocraft_format::SaveOptions { thumbnail, composite, full_composite, full_composite_icc }; 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() }); + return Ok(ExportResult { bytes, warnings: Vec::new() }.into()); } if ext == "psd" || ext == "psb" { let o = PsdExportOptions { @@ -614,8 +1010,12 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & maximize_compatibility: opts.psd_maximize_compatibility, document_path: opts.document_path.clone(), read_linked_files: opts.read_linked_files, + stage_on_scratch_from: Some(psd_export::STAGE_DOCUMENTS_FROM), ..Default::default() }; + // The preview is rendered first, while only the document is in memory: its full-size + // render would otherwise come on top of the finished file model (#6607). + let preview = opts.image_previews.then(|| psd_thumbnail::resource(doc)); let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; @@ -623,8 +1023,8 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & warnings.push("free-form text metadata is not supported by PSD/PSB; it will be dropped".into()); } warnings.extend(tiff_layers::strip_foreign_order_blocks(&mut file)); - if opts.image_previews { - match psd_thumbnail::resource(doc) { + if let Some(preview) = preview { + match preview { // Beside the other resources Photoshop writes ahead of the version info. Ok(r) => { let at = file.resources.iter().position(|r| r.id == photocraft_psd::resources::ids::VERSION_INFO).unwrap_or(file.resources.len()); @@ -635,14 +1035,62 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & } // Never write a header the reader would refuse (e.g. a zero-sized canvas). file.header.validate()?; - let bytes = file.to_bytes()?; - return Ok(ExportResult { bytes, warnings }); + return Ok(EncodedExport { encoded: Encoded::Psd(Box::new(file)), warnings }); } let format = photocraft_codecs::from_extension(&ext).ok_or_else(|| IoError::UnknownFormat(name_or_ext.to_string()))?; if format == photocraft_codecs::Format::Tiff && opts.tiff_layers && tiff_layers::would_write_layers(doc) { - return tiff_layers::export_layered(doc, opts, ctl); + return tiff_layers::export_layered(doc, opts, ctl).map(EncodedExport::from); } - flat::export_flat_with(doc, format, opts, ctl) + flat::export_flat_with(doc, format, opts, ctl).map(EncodedExport::from) +} + +/// The flattened document at its canvas size, in 8-bit RGBA like a `.pcraft` file's two previews, +/// for the bundle's `composite/full.png`. `None` for a canvas past +/// 2^26 pixels, or when its pixels cannot be allocated. +pub fn pcraft_full_composite(doc: &Document) -> Option { + pcraft_full_composite_with(doc, &photocraft_raster::Interrupt::NONE).ok().flatten() +} + +/// The ICC profile of [`pcraft_full_composite`]'s pixels: an RGB document's own profile as stored, +/// a gray document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable +/// profile ([`psd_thumbnail::composite_profile`]). +pub fn pcraft_full_composite_icc(doc: &Document) -> Vec { + let own = doc.icc_profile.as_ref().filter(|bytes| { + doc.mode == photocraft_color::ColorMode::Rgb && photocraft_cms::Profile::parse(bytes).is_ok_and(|p| p.color_space == photocraft_cms::ColorSpace::Rgb) + }); + match own { + Some(bytes) => bytes.to_vec(), + None => psd_thumbnail::composite_profile(doc).to_bytes().to_vec(), + } +} + +/// [`pcraft_full_composite`] rendered in bands: `ctl` gets the fraction of rows done, and a +/// cancelled render fails with [`IoError::Cancelled`]. +fn pcraft_full_composite_with(doc: &Document, ctl: &photocraft_raster::Interrupt) -> Result, IoError> { + let canvas = doc.bounds(); + if canvas.is_empty() || u64::from(canvas.width()) * u64::from(canvas.height()) > PCRAFT_FULL_COMPOSITE_PIXELS { + return Ok(None); + } + let Ok(len) = usize::try_from(u64::from(canvas.width()) * u64::from(canvas.height()) * 4) else { return Ok(None) }; + let mut pixels = Vec::new(); + if pixels.try_reserve_exact(len).is_err() { + return Ok(None); + } + pixels.resize(len, 0); + let mut image = photocraft_raster::Rgba8Image { width: canvas.width(), height: canvas.height(), pixels }; + let row = canvas.width() as usize * 4; + let rows = canvas.height() as f32; + photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let start = (band.rect.y0 - canvas.y0) as usize * row; + let pixels = band.to_rgba8().pixels; + if let Some(dst) = image.pixels.get_mut(start..start + pixels.len()) { + dst.copy_from_slice(&pixels); + } + ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows); + Ok(()) + })?; + Ok(Some(image)) } /// The PSD's merged composite as straight RGBA floats (row-major), converted @@ -717,14 +1165,14 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { let (doc, _) = psd_to_document(&PsdFile { layer_info, ..file.clone() }); // Multichannel documents keep their channels apart (no layer): composite them. if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel { - return Ok(photocraft_compose::flatten(&doc).px); + return Ok(photocraft_compose::flatten(&doc).into_px()); } let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?; let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?; // CMYK reads through the file's own profile, as the compositor reads its layers, so a tagged // file's merged image and its composite are compared in the same colours. let cmyk = photocraft_compose::cmyk_space(&doc); - Ok(photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || photocraft_compose::surface_to_buffer(s, doc.bounds()).px)) + Ok(photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || photocraft_compose::surface_to_buffer(s, doc.bounds()).into_px())) } #[cfg(test)] @@ -768,6 +1216,53 @@ mod tests { assert!(!result.source_read_only); } + /// A nested PSD import still enforces the caller's explicit bounds before it spends the + /// parent's allowance, and one within those bounds is charged exactly once. + #[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))] + #[test] + fn nested_psd_import_enforces_the_callers_local_bounds_before_charging_the_parent() { + let gb = 1u64 << 30; + let bytes = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + let ctl = photocraft_raster::Interrupt::NONE; + let ample = 16 * gb; + let _parent = super::psd_gate::acquire_decode_admission(&ctl).expect("parent admission"); + + // The tiny fixture plus a thread-local charge lands in the approval band; nothing near a + // gigabyte is allocated. + super::psd_budget::with_test_estimate_charge(5 * gb, || { + // What the real nested import is charged, learned from one admitted run. + super::psd_gate::set_active_attempt_budget(ample); + super::import_with_options_and_interrupt("nested.psd", &bytes, &ImportOptions::default(), &ctl).expect("an admitted nested import"); + let required = ample - super::psd_gate::active_attempt_remaining().expect("the parent attempt keeps an allowance"); + assert!(required > 5 * gb, "the test charge is part of the estimate: {required}"); + + let cases = [ + (ImportOptions { psd_max_pixel_bytes: required - 1, ..Default::default() }, "the caller's lower allowance"), + ( + ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required - 1), ..Default::default() }, + "the one-shot approved estimate", + ), + ]; + for (opts, what) in cases { + super::psd_gate::set_active_attempt_budget(ample); + match super::import_with_options_and_interrupt("nested.psd", &bytes, &opts, &ctl) { + Err(super::IoError::PsdImportBudget { required: r, limit }) => { + assert_eq!((r, limit), (required, required - 1), "{what} must refuse with its exact fields") + } + other => panic!("expected {what} to refuse, got {:?}", other.map(|_| ())), + } + assert_eq!(super::psd_gate::active_attempt_remaining(), Some(ample), "{what} must not spend the parent's allowance"); + } + + super::psd_gate::set_active_attempt_budget(ample); + let bounded = + ImportOptions { psd_max_pixel_bytes: required, psd_high_memory_approved: true, psd_approved_estimate: Some(required), ..Default::default() }; + let opened = super::import_with_options_and_interrupt("nested.psd", &bytes, &bounded, &ctl).expect("within the caller's bounds"); + assert!(opened.document.size.area() > 0, "the bounded nested import really opened a document"); + assert_eq!(super::psd_gate::active_attempt_remaining(), Some(ample - required), "the nested import is charged once"); + }); + } + #[test] fn svg_extension_does_not_override_another_format_signature() { let webp_header = b"RIFF\x04\0\0\0WEBP"; @@ -793,6 +1288,68 @@ mod tests { assert!(!super::is_psd(b"\0\xffKA")); } + #[cfg(any(unix, windows))] + #[test] + fn verified_path_import_reads_a_flat_file_from_its_start() { + let dir = std::env::temp_dir().join(format!("photocraft-verified-png-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("verified.png"); + let image = photocraft_codecs::Image::from_u8(2, 3, photocraft_codecs::ChannelLayout::Rgb, vec![64; 18]).unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + let mut identity = None; + let (opened, damaged) = + super::import_file_for_open_verified("verified.png", &path, None, &ImportOptions::default(), &photocraft_raster::Interrupt::NONE, &mut identity) + .unwrap(); + assert_eq!((opened.document.size.width, opened.document.size.height), (2, 3)); + assert!(!damaged); + assert_eq!(identity, Some(super::FileIdentity::of_path(&path).unwrap())); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// The identity a verified open returns is the opened handle's, and a different inode with + /// identical content is refused with the dedicated reason before any pixel is decoded. + #[cfg(unix)] + #[test] + fn verified_import_binds_the_decoded_handle_identity() { + let dir = std::env::temp_dir().join(format!("photocraft-io-verified-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("source.psd"); + let bytes = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let expected = super::FileIdentity::of_path(&path).unwrap(); + let mut opened = None; + let (result, _) = super::import_file_for_open_verified( + "source.psd", + &path, + Some(&expected), + &ImportOptions::default(), + &photocraft_raster::Interrupt::NONE, + &mut opened, + ) + .unwrap(); + assert!(!result.document.layers.is_empty()); + assert_eq!(opened.as_ref(), Some(&expected), "the identity comes from the decoded handle"); + // Replacement with identical bytes but a different inode: not the approved file. + let replacement = dir.join("replacement.psd"); + std::fs::write(&replacement, &bytes).unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + let mut opened = None; + let err = super::import_file_for_open_verified( + "source.psd", + &path, + Some(&expected), + &ImportOptions::default(), + &photocraft_raster::Interrupt::NONE, + &mut opened, + ) + .expect_err("the path now names another file"); + assert!(matches!(&err, super::IoError::SourceChanged), "got {err:?}"); + assert!(opened.is_some(), "the refused handle's identity is still reported"); + let _ = std::fs::remove_dir_all(&dir); + } + /// A common mislabeled download: PNG/JPEG bytes named `.psd` open from the signature, /// as on main and in Photoshop. #[test] diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index dcf7ecd98d..62db468b33 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -40,8 +40,12 @@ impl<'a> Rd<'a> { fn u64(&mut self) -> Option { self.take(8).map(|b| u64::from_be_bytes(b.try_into().unwrap_or([0; 8]))) } + /// Decode a bounded metadata filename without trusting its declared allocation size. fn unicode(&mut self) -> Option { let n = self.u32()? as usize; + if n > 32_768 { + return None; + } // A file name is metadata, never an unbounded allocation. let raw = self.take(n.checked_mul(2)?)?; let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); Some(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()) @@ -95,6 +99,12 @@ pub fn embedded_file_name(meta: &Metadata, uuid: &str) -> Option { }) } +/// Borrow the selected item payload without copying it. +fn item_data(item: &[u8]) -> Option<(String, String, &[u8])> { + let (uuid, name, start, len) = item_header(item)?; + Some((uuid, name, item.get(start..start.checked_add(len)?)?)) +} + /// Every embedded (`liFD`) file in one linked-layer block's data. pub fn parse_linked_files(data: &[u8]) -> Vec { let mut out = Vec::new(); @@ -113,6 +123,34 @@ pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option { find_in_blocks(meta.psd_global_blocks.iter().map(|(_, k, d)| (k, d.as_slice())), uuid) } +/// Borrow only the selected embedded payload, without copying other smart objects or reading disk. +pub fn embedded_payload<'a>(meta: &'a Metadata, uuid: &str) -> Option<(String, &'a [u8])> { + embedded_payload_in_blocks(meta.psd_global_blocks.iter().map(|(_, key, data)| (key, data.as_slice())), uuid) +} + +/// Borrow one selected payload from PSD global blocks without copying unrelated files. +pub fn embedded_payload_in_blocks<'a>(blocks: impl IntoIterator, uuid: &str) -> Option<(String, &'a [u8])> { + if uuid.is_empty() { + return None; + } + for (key, data) in blocks { + if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD") { + continue; + } + let mut r = Rd { d: data, p: 0 }; + while let Some(len) = r.u64().and_then(|n| usize::try_from(n).ok()) { + let Some(item) = r.take(len) else { break }; + if let Some((id, name, bytes)) = item_data(item) + && id == uuid + { + return Some((name, bytes)); + } + r.p = r.p.checked_add(3)? & !3; + } + } + None +} + /// The embedded file for smart-object `uuid` among PSD global blocks given as (key, data). pub fn find_in_blocks<'a>(blocks: impl IntoIterator, uuid: &str) -> Option { if uuid.is_empty() { @@ -597,3 +635,26 @@ mod tests { assert!(select_items(&valid_unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none()); } } + +#[cfg(test)] +mod bounded_payload_tests { + use super::*; + use std::sync::Arc; + + #[test] + fn selected_payload_is_borrowed_and_other_sources_are_not_copied() { + let a = LinkedFile { uuid: "a".into(), file_name: "Other.psb".into(), bytes: vec![1; 1024 * 1024] }; + let b = LinkedFile { uuid: "b".into(), file_name: "Logo.ai".into(), bytes: b"%PDF-1.6".to_vec() }; + let mut data = encode_linked_file(&a); + data.extend(encode_linked_file(&b)); + let mut meta = Metadata::default(); + meta.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(data))); + let (name, bytes) = embedded_payload(&meta, "b").unwrap(); + assert_eq!(name, "Logo.ai"); + assert_eq!(bytes, b.bytes); + let original = meta.psd_global_blocks[0].2.as_slice(); + assert!((original.as_ptr_range()).contains(&bytes.as_ptr())); + assert!(embedded_payload(&meta, "").is_none()); + assert!(embedded_payload(&meta, "/dev/zero").is_none()); + } +} diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs new file mode 100644 index 0000000000..c9b8c54378 --- /dev/null +++ b/crates/io/src/memory.rs @@ -0,0 +1,1084 @@ +//! How much memory this process can still use, so an import too large for the machine is +//! refused with an error instead of ending the app. Linux overcommits: an allocation that can't +//! be backed still succeeds, and the out-of-memory killer ends the process (and every open +//! document) when its pages are written, so a fallible allocation alone never fails there. + +use crate::IoError; + +/// Memory left free beside what an import needs: for the app, its display and the system. +pub const RESERVE: u64 = 512 << 20; + +/// Automatic allowance for native platforms when available memory is unknown. +/// +/// Estimates above it need explicit per-invocation approval, up to the platform's aggregate +/// admission limit (see [`native_platform_cap`]). This is a conservative automatic admission +/// threshold, not a hard ceiling and not a per-buffer allocation bound. +pub const NATIVE_FALLBACK_BUDGET: u64 = 2 << 30; + +/// Automatic allowance for WebAssembly execution. +/// +/// Estimates above it need explicit per-invocation approval, up to +/// [`WASM_MAX_PEAK_BUDGET`]. This is a conservative product threshold, not an assertion that +/// the browser can allocate that much. +pub const WASM_BUDGET: u64 = 1 << 30; + +/// Upper ceiling on the automatic soft import budget when available memory is known. +pub const MAX_SOFT_BUDGET: u64 = 8 << 30; + +/// Estimate above which an import made with unknown available memory carries an extra warning. +/// +/// It is a warning threshold, not a hard ceiling: explicit approval remains possible up to the +/// platform's actual allocation limits, but the process may run out of memory. +pub const UNKNOWN_MEMORY_WARNING_THRESHOLD: u64 = 8 << 30; + +/// Reserve subtracted from commit headroom when known. +pub const COMMIT_RESERVE: u64 = 512 << 20; + +/// Bytes Rust's layout rules allow for one contiguous allocation on this target +/// (`isize::MAX`), not a bound on an import's aggregate peak. +/// +/// A layer plane, a decode buffer or a tile is one contiguous allocation, so each must fit this +/// limit at the point it is allocated. An import's peak is the *sum* of many such buffers and +/// may legitimately exceed this; decode paths enforce their own per-buffer limits +/// (`photocraft-psd::MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`) there. +pub const MAX_SINGLE_ALLOCATION: u64 = isize::MAX as u64; + +/// Architectural maximum linear memory of wasm32: 65,536 pages of 64 KiB (4 GiB). +/// +/// This is the whole address space of one wasm32 instance, shared with the resident document, +/// history, decoder buffers, the allocator, the stack and the runtime. It is an address-space +/// bound, not a promise that 4 GiB can be allocated; browsers may cap linear memory lower. +pub const WASM_LINEAR_MEMORY_LIMIT: u64 = 4u64 << 30; + +/// Largest single Rust allocation on wasm32 (`isize::MAX`): 2 GiB - 1. +/// +/// A per-buffer bound only. Aggregate admission on web uses [`WASM_MAX_PEAK_BUDGET`]; the layout +/// limit itself rejects a larger single buffer, and decoders may apply a tighter cap. +pub const WASM_MAX_SINGLE_ALLOCATION: u64 = (1u64 << 31) - 1; + +/// Conservative product cap for the aggregate peak of one web import: 2 GiB. +/// +/// Chosen by this project, not dictated by the architecture: it sits below both the 4 GiB +/// wasm32 linear-memory limit (leaving room for the runtime, stack, allocator metadata and the +/// resident document) and is reachable only through explicit approval above [`WASM_BUDGET`]. +/// Browsers commonly allow less, and allocation may still fail. Do not read it as a guarantee. +pub const WASM_MAX_PEAK_BUDGET: u64 = 2 << 30; + +/// Conservative product cap for the aggregate peak of a 32-bit native process: 2 GiB. +/// +/// A 32-bit process has a 4 GiB address space, but the kernel/reserved range, runtime, stack, +/// allocator metadata, document and history share it. This is a product choice well below the +/// architectural limit, not an architectural bound. +pub const NATIVE_32BIT_PEAK_CAP: u64 = 2 << 30; + +/// A snapshot of system memory availability, distinguishing physical RAM from swap and commit headroom. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct MemorySnapshot { + /// Currently available physical RAM (A) in bytes, bounded by applicable container or process limits. + /// Free swap is explicitly excluded. A value of `Some(0)` represents zero capacity, not unknown memory. + pub available_ram: Option, + /// Available swap headroom in bytes, if known. + pub swap_free: Option, + /// System or process commit headroom (C) in bytes, if measurable. + pub commit_headroom: Option, +} + +#[cfg(any(test, feature = "testgen"))] +thread_local! { + static MOCK_SNAPSHOT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// A per-attempt provider consulted on every query, unlike the fixed [`MOCK_SNAPSHOT`]. + /// Tests use it to change the measured capacity between admission checks from another + /// thread; it is thread-local, so it never affects unrelated parallel tests. + static MOCK_PROVIDER: std::cell::RefCell MemorySnapshot>>> = const { std::cell::RefCell::new(None) }; + /// Mocked [`scratch_capacity`]: `Some(None)` = spilling off, `Some(Some(n))` = `n` bytes. + static MOCK_SCRATCH: std::cell::Cell>> = const { std::cell::Cell::new(None) }; +} + +/// Tile bytes the scratch disk can take beyond RAM: `None` while spilling is off, paused or +/// unavailable (web), or its room is unknown. See [`photocraft_raster::spill::scratch_capacity`]. +/// +/// Test builds: a mocked memory snapshot without a mocked scratch disk measures no scratch, so a +/// test that mocks RAM keeps the RAM-only policy whatever another test did to the process-wide +/// scratch disk. +pub fn scratch_capacity() -> Option { + #[cfg(any(test, feature = "testgen"))] + { + if let Some(mock) = MOCK_SCRATCH.with(std::cell::Cell::get) { + return mock; + } + if MOCK_SNAPSHOT.with(|m| m.borrow().is_some()) || MOCK_PROVIDER.with(|p| p.borrow().is_some()) { + return None; + } + } + if cfg!(target_arch = "wasm32") { + return None; + } + photocraft_raster::spill::scratch_capacity() +} + +/// How much of `spillable` tile bytes the scratch disk covers on `platform` right now. The web +/// has no scratch disk, so it never gets credit. +fn scratch_credit_for(platform: Platform, spillable: u64) -> u64 { + if platform == Platform::Web || spillable == 0 { + return 0; + } + scratch_capacity().map_or(0, |room| spillable.min(room)) +} + +/// [`scratch_credit_for`] on this build's platform. +pub(crate) fn scratch_credit(spillable: u64) -> u64 { + scratch_credit_for(active_platform(), spillable) +} + +impl MemorySnapshot { + /// Unknown memory measurement. + pub const UNKNOWN: Self = Self { available_ram: None, swap_free: None, commit_headroom: None }; + + /// The current measurement from the shared detector, [`photocraft_raster::memory::system`], + /// which also sizes the tile residency budget. + pub fn current() -> Self { + #[cfg(any(test, feature = "testgen"))] + { + if let Some(provider) = MOCK_PROVIDER.with(|p| p.borrow().clone()) { + return provider(); + } + if let Some(mock) = MOCK_SNAPSHOT.with(|m| *m.borrow()) { + return mock; + } + } + + photocraft_raster::memory::system::current().into() + } +} + +impl From for MemorySnapshot { + /// The shared detector's measurement; an unreadable constraint is already the fail-closed + /// `Some(0)` available RAM, which admission refuses. + fn from(m: photocraft_raster::memory::system::SystemMemory) -> Self { + Self { available_ram: m.available_ram, swap_free: m.swap_free, commit_headroom: m.commit_headroom } + } +} + +/// Resolved memory budgets derived from a [`MemorySnapshot`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MemoryBudget { + /// Automatic admission ceiling (S): imports requiring <= S open without confirmation. + pub soft_limit: u64, + /// Hard refusal ceiling (H): imports requiring > H are refused and cannot be overridden. + pub hard_limit: u64, + /// Currently available physical RAM from the snapshot, if known. + pub available_ram: Option, + /// Set when available memory is unknown: estimates above this threshold still need explicit + /// approval but also carry the uncertain-capacity warning. `None` when capacity is known. + pub warning_threshold: Option, +} + +/// Admission decision for a given required memory estimate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BudgetDecision { + /// Estimated memory <= soft limit: open automatically. + Automatic, + /// Soft limit < estimated memory <= hard limit: requires explicit user/caller confirmation. + RequiresApproval, + /// Estimated memory > hard limit, or the saturated `u64::MAX` overflow sentinel: refused. + Refused, +} + +impl MemoryBudget { + /// Determines the admission decision for an estimated requirement in bytes. + /// + /// `u64::MAX` is the sentinel a format estimate produces when a size overflowed `u64` + /// (`photocraft-io`'s TIFF estimates saturate there rather than wrap). It is not a real + /// requirement, so it is always refused, even when the hard limit is itself `u64::MAX` (the + /// native unknown-memory cap) and even with explicit approval or a matching one-shot + /// estimate. The refusal keeps the estimate and limit as they are; no value is invented. + pub fn decide(&self, required: u64) -> BudgetDecision { + if required == u64::MAX { + return BudgetDecision::Refused; + } + if required <= self.soft_limit { + BudgetDecision::Automatic + } else if required <= self.hard_limit { + BudgetDecision::RequiresApproval + } else { + BudgetDecision::Refused + } + } + + /// Whether an approvable estimate also needs the uncertain-capacity warning. + /// + /// This is only meaningful together with [`BudgetDecision::RequiresApproval`]; it says the + /// budget came from the unknown-memory fallback and the estimate is above + /// [`UNKNOWN_MEMORY_WARNING_THRESHOLD`]. + pub fn requires_uncertainty_warning(&self, required: u64) -> bool { + self.warning_threshold.is_some_and(|threshold| required > threshold) + } +} + +/// Whether an approval-required estimate carries the unknown-capacity warning, given the +/// capacity measured when the estimate was made. +/// +/// A known capacity never carries it (a measured number is reported instead of a guess); when +/// capacity is unknown, an estimate above [`UNKNOWN_MEMORY_WARNING_THRESHOLD`] is still +/// approvable but says the process or tab may run out of memory. Kept separate from +/// [`MemoryBudget`] so a pending approval can carry the flag across a later, fresh measurement. +pub fn uncertain_capacity_warning(required: u64, available_ram: Option) -> bool { + available_ram.is_none() && required > UNKNOWN_MEMORY_WARNING_THRESHOLD +} + +/// Which platform policy to resolve. Tests use this to exercise the web policy on any host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Platform { + Native, + Web, +} + +/// The platform policy selected for this build: Web on wasm32, Native everywhere else. +fn active_platform() -> Platform { + if cfg!(target_arch = "wasm32") { Platform::Web } else { Platform::Native } +} + +/// Aggregate address-space bound for native admission (not a per-buffer allocation bound). +/// +/// A 64-bit process has no practical aggregate address-space limit measurable through safe +/// standard APIs, so this is `u64::MAX`; per-buffer and format limits are enforced where buffers +/// are actually allocated. A 32-bit process uses [`NATIVE_32BIT_PEAK_CAP`]. +pub fn native_platform_cap() -> u64 { + if cfg!(target_pointer_width = "32") { NATIVE_32BIT_PEAK_CAP } else { u64::MAX } +} + +/// Resolves the adaptive hard and soft import budgets from a snapshot and an optional caller limit. +/// +/// The limits are for an import's aggregate peak estimate, not for one allocation: a peak may sum +/// many buffers, each of which is separately bounded where the decoder allocates it. +/// +/// Policy: +/// - Known RAM: H = min(floor(3*A/4), A - 1 GiB, C - reserve if known, [`native_platform_cap`]); +/// S = min(A/2, 8 GiB, H). +/// - Unknown native RAM: S = min(2 GiB, H) where H is [`native_platform_cap`], reduced by known +/// commit headroom; approval above S is possible up to H. 8 GiB is a warning threshold, not H. +/// - Web (wasm32): S = 1 GiB; H = min([`WASM_MAX_PEAK_BUDGET`], caller limit). +/// - <= S: automatic; S < E <= H: explicit approval; > H: refuse, no unlimited override. +/// - The saturated `u64::MAX` overflow sentinel is always refused, even when H is `u64::MAX` +/// (see [`MemoryBudget::decide`]). +/// - Known zero available RAM stays zero (it never becomes unknown/free). +/// - swap is never part of the adaptive budget. +/// - Explicit caller limits may lower admission, never bypass the hard boundary. +pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { + resolve_budget_for(active_platform(), snapshot, caller_limit) +} + +/// [`resolve_budget`] for an import whose estimate includes `spillable` bytes of tile pixels. +/// +/// While the scratch disk is spilling (on and not paused), tiles beyond the residency budget are +/// written to it, so those pixels are not bounded by physical RAM: the part of `spillable` the +/// disk has room for ([`scratch_capacity`], after the Scratch Size Limit and the free-space +/// reserve) is added to both S and H before the caller's explicit limit applies. Equivalently, +/// the non-spillable working memory (decode buffers, conversion copies, merged planes) is +/// admitted against the RAM policy alone, and the whole estimate against RAM plus scratch room. +/// With spilling off or paused, and always on the web, this is exactly [`resolve_budget`]. +pub fn resolve_budget_with_scratch(snapshot: MemorySnapshot, caller_limit: Option, spillable: u64) -> MemoryBudget { + let platform = active_platform(); + resolve_budget_credited(platform, snapshot, caller_limit, scratch_credit_for(platform, spillable)) +} + +/// [`resolve_budget`] for an explicit platform, so tests can exercise the web policy on a native host. +fn resolve_budget_for(platform: Platform, snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { + resolve_budget_credited(platform, snapshot, caller_limit, 0) +} + +/// The policy with `credit` bytes of scratch room added to S and H before the caller's limit. +fn resolve_budget_credited(platform: Platform, snapshot: MemorySnapshot, caller_limit: Option, credit: u64) -> MemoryBudget { + if platform == Platform::Web { + let mut hard = WASM_MAX_PEAK_BUDGET; + if let Some(caller) = caller_limit.filter(|&c| c > 0) { + hard = hard.min(caller); + } + return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None }; + } + + let platform_cap = native_platform_cap(); + let (mut hard, mut soft, warning_threshold) = match snapshot.available_ram { + Some(a) => { + let h_pct = ((a as u128 * 3) / 4) as u64; + let h_reserve = a.saturating_sub(1 << 30); + let mut h = h_pct.min(h_reserve); + + if let Some(c) = snapshot.commit_headroom { + let c_room = c.saturating_sub(COMMIT_RESERVE); + h = h.min(c_room); + } + + h = h.min(platform_cap); + + let s_pct = a / 2; + let s = s_pct.min(MAX_SOFT_BUDGET).min(h); + (h, s, None) + } + None => { + let mut h = platform_cap; + if let Some(c) = snapshot.commit_headroom { + let c_room = c.saturating_sub(COMMIT_RESERVE); + h = h.min(c_room); + } + let s = NATIVE_FALLBACK_BUDGET.min(h); + (h, s, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)) + } + }; + // Tile pixels the scratch disk takes need no RAM (see [`resolve_budget_with_scratch`]). + hard = hard.saturating_add(credit); + soft = soft.saturating_add(credit); + + if let Some(caller) = caller_limit.filter(|&c| c > 0) { + hard = hard.min(caller); + soft = soft.min(hard); + } + + MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram, warning_threshold } +} + +/// Result of evaluating an import admission request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AdmissionOutcome { + pub budget: MemoryBudget, + /// Total authorized memory allowance for the entire attempt (top-level + nested decodes): + /// `min(S if automatic else H if approved, caller cap if any, approved_estimate if present)`. + pub authorized_total: u64, +} + +/// Central evaluation of PSD admission against adaptive budgets and options. +/// +/// Refuses before decoding if: +/// - `required` is the saturated `u64::MAX` overflow sentinel (see [`MemoryBudget::decide`]); +/// - `required > hard_limit` (regardless of approvals: hard ceiling cannot be overridden); +/// - `required > soft_limit` and high memory is not approved; +/// - high memory is approved, but `approved_estimate` is specified and `required > approved_estimate` +/// (preventing unbounded approval for altered/enlarged files). +/// +/// `spillable` is the part of `required` that is tile pixels; while the scratch disk is spilling +/// the limits include the room it has for them (see [`resolve_budget_with_scratch`]). Errors +/// still report the whole `required` (the figure approval binds to) and the effective limits. +pub(crate) fn evaluate_psd_admission( + required: u64, + spillable: u64, + caller_limit: u64, + high_memory_approved: bool, + approved_estimate: Option, + snapshot: MemorySnapshot, +) -> Result { + evaluate_psd_admission_for(active_platform(), required, spillable, caller_limit, high_memory_approved, approved_estimate, snapshot) +} + +/// [`evaluate_psd_admission`] for an explicit platform, so tests can exercise the web policy on a native host. +fn evaluate_psd_admission_for( + platform: Platform, + required: u64, + spillable: u64, + caller_limit: u64, + high_memory_approved: bool, + approved_estimate: Option, + snapshot: MemorySnapshot, +) -> Result { + let limit_opt = (caller_limit > 0).then_some(caller_limit); + let budget = resolve_budget_credited(platform, snapshot, limit_opt, scratch_credit_for(platform, spillable.min(required))); + + let authorized_total = match budget.decide(required) { + BudgetDecision::Automatic => { + let mut cap = budget.soft_limit; + if let Some(caller) = limit_opt { + cap = cap.min(caller); + } + if let Some(appr) = approved_estimate { + cap = cap.min(appr); + } + cap + } + BudgetDecision::RequiresApproval => { + if !high_memory_approved { + return Err(IoError::PsdApprovalRequired { + required, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + }); + } + let mut cap = budget.hard_limit; + if let Some(caller) = limit_opt { + cap = cap.min(caller); + } + if let Some(appr) = approved_estimate { + cap = cap.min(appr); + } + cap + } + BudgetDecision::Refused => return Err(IoError::PsdImportBudget { required, limit: budget.hard_limit }), + }; + + if required > authorized_total { + return Err(IoError::PsdApprovalRequired { + required, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + }); + } + Ok(AdmissionOutcome { budget, authorized_total }) +} + +/// Applies a caller's explicit local bounds to a nested decode. +/// +/// A nested import spends the enclosing attempt's remaining allowance: it never resolves an +/// independent adaptive budget and never resets the parent's. The caller's explicit bounds still +/// apply, because they limit this attempt rather than describe a second budget: +/// `caller_limit > 0` is the lower caller allowance, and `approved_estimate` (when present) is the +/// estimate one-shot consent was given for. Both are checked before the parent is charged, so a +/// refusal leaves its remaining allowance untouched, and the tighter bound is the one reported. +/// +/// A nested attempt cannot ask for fresh consent, so exceeding either bound is a hard refusal +/// ([`IoError::PsdImportBudget`], the form the outer caller limit already uses) rather than a new +/// approval request. +/// +/// The saturated `u64::MAX` overflow sentinel is not covered here (there is no meaningful limit to +/// report when no local bound is set); [`crate::psd_gate::consume_attempt_allowance`] refuses it +/// before the parent is charged. +pub(crate) fn nested_import_local_bound(required: u64, caller_limit: u64, approved_estimate: Option) -> Result<(), IoError> { + let caller = (caller_limit > 0).then_some(caller_limit); + let limit = match (caller, approved_estimate) { + (Some(caller), Some(approved)) => Some(caller.min(approved)), + (caller, approved) => caller.or(approved), + }; + match limit { + Some(limit) if required > limit => Err(IoError::PsdImportBudget { required, limit }), + _ => Ok(()), + } +} + +/// Bytes this process can still use without running out, or `None` where that isn't known. On +/// Linux: the free RAM the kernel can hand out (`MemAvailable`, capped by the process's memory +/// cgroup — v2 `memory.max` or v1 `memory.limit_in_bytes`/`memsw` — and those above it) plus free +/// swap (capped by the cgroup's swap limits). Elsewhere `None`: Windows refuses an allocation past +/// its commit limit, which the importers already report as an error, and macOS pages to disk as it +/// needs. macOS's physical-memory provider feeds the adaptive budget, not this legacy +/// RAM-plus-swap helper; flat and layered TIFF conversion keep their existing behaviour. Measured +/// by [`photocraft_raster::memory::system::available_with_swap`]. +pub fn available() -> Option { + photocraft_raster::memory::system::available_with_swap() +} + +/// Refuses to open a `w` × `h` px image whose layer and decode buffers take `needed` bytes when +/// `available` bytes (see [`available`]) can't hold them with [`RESERVE`] to spare. +pub(crate) fn check_fits(w: u32, h: u32, needed: u64, available: Option) -> Result<(), IoError> { + let Some(available) = available else { return Ok(()) }; + if needed.checked_add(RESERVE).is_some_and(|n| n <= available) { + return Ok(()); + } + let gb = |b: u64| b as f64 / 1e9; + Err(IoError::Unsupported(format!( + "not enough memory to open this {w} × {h} px image: it needs about {:.1} GB and {:.1} GB is free", + gb(needed), + gb(available) + ))) +} + +/// Sets a thread-local mock memory snapshot for testing policy and import admission. +#[cfg(any(test, feature = "testgen"))] +pub fn set_mock_memory(snapshot: Option) { + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = snapshot); +} + +/// Runs a closure with a temporarily injected mock memory snapshot on the current thread. +/// +/// Restores the previous snapshot upon return or panic, supporting nested invocations. +#[cfg(any(test, feature = "testgen"))] +pub fn with_mock_memory(snapshot: MemorySnapshot, f: impl FnOnce() -> R) -> R { + let prev = MOCK_SNAPSHOT.with(|m| { + let old = *m.borrow(); + *m.borrow_mut() = Some(snapshot); + old + }); + + struct Reset { + prev: Option, + } + impl Drop for Reset { + fn drop(&mut self) { + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); + } + } + + let _reset = Reset { prev }; + f() +} + +/// Runs a closure with a per-attempt memory provider on the current thread. +/// +/// Unlike [`with_mock_memory`], the provider is consulted for every +/// [`MemorySnapshot::current`], so a test can change the measured capacity while an import is +/// waiting on the decode gate and observe the post-gate recheck. The provider is thread-local +/// and restored on return or panic, so it never overrides another test's measurement. +#[cfg(any(test, feature = "testgen"))] +pub fn with_mock_memory_provider(provider: std::rc::Rc MemorySnapshot>, f: impl FnOnce() -> R) -> R { + let prev = MOCK_PROVIDER.with(|p| p.borrow_mut().replace(provider)); + + struct Reset { + prev: Option MemorySnapshot>>, + } + impl Drop for Reset { + fn drop(&mut self) { + MOCK_PROVIDER.with(|p| *p.borrow_mut() = self.prev.take()); + } + } + + let _reset = Reset { prev }; + f() +} + +/// Runs a closure with a mocked scratch disk on the current thread: `Some(n)` = spilling with +/// `n` bytes of room, `None` = spilling off or paused. Restored on return or panic, and captured +/// by [`TestMemoryScope`]. +#[cfg(any(test, feature = "testgen"))] +pub fn with_mock_scratch(capacity: Option, f: impl FnOnce() -> R) -> R { + struct Reset(Option>); + impl Drop for Reset { + fn drop(&mut self) { + MOCK_SCRATCH.with(|m| m.set(self.0)); + } + } + let _reset = Reset(MOCK_SCRATCH.with(|m| m.replace(Some(capacity)))); + f() +} + +/// Explicit test-support capture/scope object to propagate the thread-local fixed mock snapshot +/// and the scoped PSD estimate charge across worker threads without modifying global process +/// state. +/// +/// It captures `set_mock_memory`/`with_mock_memory`'s fixed snapshot and +/// [`crate::psd_budget::with_test_estimate_charge`]'s extra charge, so an engine worker that +/// enters the scope makes the same admission estimates as the test thread. The per-attempt +/// provider set by `with_mock_memory_provider` is not captured: that provider is a non-`Send` +/// closure re-consulted on every [`MemorySnapshot::current`], and propagating it to a worker +/// needs an explicitly shared, `Send + Sync` scope designed for that purpose (follow-up work). +/// A worker an import starts therefore sees the fixed snapshot, not a live provider. +/// +/// Capturing is explicit: nothing here changes a thread that never enters the scope, and no +/// global state is written. +/// +/// In production, this is an inert zero-overhead type. +#[derive(Clone, Copy, Debug, Default)] +pub struct TestMemoryScope { + #[cfg(any(test, feature = "testgen"))] + mock: Option, + /// The estimate charge captured with the scope (zero when the test set none). + #[cfg(any(test, feature = "testgen"))] + estimate_charge: u64, + /// The tile-pixel share of that charge. + #[cfg(any(test, feature = "testgen"))] + tile_charge: u64, + /// The mocked scratch disk ([`with_mock_scratch`]), if any. + #[cfg(any(test, feature = "testgen"))] + scratch: Option>, +} + +/// RAII guard that restores the previous thread-local mock memory and estimate charge upon drop. +pub struct TestMemoryGuard { + #[cfg(any(test, feature = "testgen"))] + prev: Option, + #[cfg(any(test, feature = "testgen"))] + prev_charge: (u64, u64), + #[cfg(any(test, feature = "testgen"))] + prev_scratch: Option>, +} + +#[cfg(any(test, feature = "testgen"))] +impl Drop for TestMemoryGuard { + fn drop(&mut self) { + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); + crate::psd_budget::enter_test_estimate_charge(self.prev_charge.0, self.prev_charge.1); + MOCK_SCRATCH.with(|m| m.set(self.prev_scratch)); + } +} + +impl TestMemoryScope { + /// Captures the current thread's fixed mock memory snapshot and estimate charge, if any (see + /// [`TestMemoryScope`] for what is not captured). + /// + /// Never captures actual system RAM as a permanent snapshot so that + /// production memory rechecks remain fresh. + pub fn capture() -> Self { + #[cfg(any(test, feature = "testgen"))] + { + let mock = MOCK_SNAPSHOT.with(|m| *m.borrow()); + let (estimate_charge, tile_charge) = crate::psd_budget::test_estimate_charge(); + Self { mock, estimate_charge, tile_charge, scratch: MOCK_SCRATCH.with(std::cell::Cell::get) } + } + #[cfg(not(any(test, feature = "testgen")))] + { + Self {} + } + } + + /// Enters this captured mock memory scope, returning a RAII guard that + /// restores the thread's previous mock state and estimate charge on drop. + pub fn enter(self) -> TestMemoryGuard { + #[cfg(any(test, feature = "testgen"))] + { + let prev = MOCK_SNAPSHOT.with(|m| { + let old = *m.borrow(); + *m.borrow_mut() = self.mock; + old + }); + let prev_charge = crate::psd_budget::enter_test_estimate_charge(self.estimate_charge, self.tile_charge); + let prev_scratch = MOCK_SCRATCH.with(|m| m.replace(self.scratch)); + TestMemoryGuard { prev, prev_charge, prev_scratch } + } + #[cfg(not(any(test, feature = "testgen")))] + { + TestMemoryGuard {} + } + } + + /// Executes a closure within this captured mock memory scope. + pub fn run(self, f: impl FnOnce() -> R) -> R { + let _guard = self.enter(); + f() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_import_that_does_not_fit_is_refused() { + let gb = 1u64 << 30; + assert!(check_fits(10, 10, 4 * gb, None).is_ok(), "unknown: no check"); + assert!(check_fits(10, 10, 4 * gb, Some(8 * gb)).is_ok()); + assert!(check_fits(10, 10, 4 * gb, Some(4 * gb + RESERVE)).is_ok()); + let e = check_fits(100_000, 100_000, 30 * gb, Some(16 * gb)).unwrap_err().to_string(); + assert!(e.contains("not enough memory") && e.contains("100000 × 100000") && e.contains("32.2 GB"), "{e}"); + assert!(check_fits(10, 10, 4 * gb, Some(4 * gb)).is_err(), "no room for the reserve"); + assert!(check_fits(10, 10, u64::MAX, Some(u64::MAX)).is_err()); + } + + #[test] + fn pure_policy_adheres_to_approved_thresholds() { + if cfg!(target_arch = "wasm32") { + return; // the web policy has its own tests + } + if native_platform_cap() < 24 << 30 { + // This table uses 6-24 GiB limits, above a 32-bit aggregate cap. + return; + } + let gb = 1u64 << 30; + + // 12 GiB RAM: H = min(9 GiB, 11 GiB) = 9 GiB; S = min(6 GiB, 8 GiB, 9 GiB) = 6 GiB. + let snap12 = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + let b12 = resolve_budget(snap12, None); + assert_eq!(b12.soft_limit, 6 * gb); + assert_eq!(b12.hard_limit, 9 * gb); + assert_eq!(b12.warning_threshold, None); + + // 5.80 GiB estimate (5.8 * 1 GiB = 6227702579) + let est_5_8 = (5.80 * (gb as f64)).round() as u64; + assert_eq!(b12.decide(est_5_8), BudgetDecision::Automatic); + assert_eq!(b12.decide(6 * gb), BudgetDecision::Automatic); + assert_eq!(b12.decide(6 * gb + 1), BudgetDecision::RequiresApproval); + assert_eq!(b12.decide(9 * gb), BudgetDecision::RequiresApproval); + assert_eq!(b12.decide(9 * gb + 1), BudgetDecision::Refused); + + // 8 GiB RAM: H = min(6 GiB, 7 GiB) = 6 GiB; S = min(4 GiB, 8 GiB, 6 GiB) = 4 GiB. + let snap8 = MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + let b8 = resolve_budget(snap8, None); + assert_eq!(b8.soft_limit, 4 * gb); + assert_eq!(b8.hard_limit, 6 * gb); + assert_eq!(b8.decide(est_5_8), BudgetDecision::RequiresApproval); + + // 6 GiB RAM: H = min(4.5 GiB, 5 GiB) = 4.5 GiB; S = 3 GiB. + let snap6 = MemorySnapshot { available_ram: Some(6 * gb), swap_free: None, commit_headroom: None }; + let b6 = resolve_budget(snap6, None); + assert_eq!(b6.soft_limit, 3 * gb); + assert_eq!(b6.hard_limit, (4.5 * (gb as f64)) as u64); + assert_eq!(b6.decide(est_5_8), BudgetDecision::Refused); + + // 32 GiB RAM: H = min(24 GiB, 31 GiB) = 24 GiB; S = min(16 GiB, 8 GiB, 24 GiB) = 8 GiB cap. + let snap32 = MemorySnapshot { available_ram: Some(32 * gb), swap_free: None, commit_headroom: None }; + let b32 = resolve_budget(snap32, None); + assert_eq!(b32.soft_limit, 8 * gb); + assert_eq!(b32.hard_limit, 24 * gb); + assert_eq!(b32.warning_threshold, None); + assert!(!b32.requires_uncertainty_warning(10 * gb), "known memory has no unknown-capacity warning"); + + // Commit headroom constraint: C = 5 GiB -> C - 512 MiB = 4.5 GiB. + let snap_commit = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: Some(5 * gb) }; + let b_commit = resolve_budget(snap_commit, None); + assert_eq!(b_commit.hard_limit, 5 * gb - COMMIT_RESERVE); + assert_eq!(b_commit.soft_limit, 5 * gb - COMMIT_RESERVE); + + // Caller limits: lowers admission, cannot raise past H. + let b_caller = resolve_budget(snap12, Some(5 * gb)); + assert_eq!(b_caller.hard_limit, 5 * gb); + assert_eq!(b_caller.soft_limit, 5 * gb); + + let b_caller_high = resolve_budget(snap8, Some(20 * gb)); + assert_eq!(b_caller_high.hard_limit, 6 * gb); + assert_eq!(b_caller_high.soft_limit, 4 * gb); + } + + #[test] + fn zero_available_ram_is_not_unknown() { + if cfg!(target_arch = "wasm32") { + return; + } + let snap_zero = MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + let b_zero = resolve_budget(snap_zero, None); + assert_eq!(b_zero.soft_limit, 0); + assert_eq!(b_zero.hard_limit, 0); + assert_eq!(b_zero.warning_threshold, None); + assert_eq!(b_zero.decide(1), BudgetDecision::Refused); + + let snap_unknown = MemorySnapshot::UNKNOWN; + let b_unknown = resolve_budget(snap_unknown, None); + assert_eq!(b_unknown.soft_limit, NATIVE_FALLBACK_BUDGET.min(native_platform_cap())); + assert_eq!(b_unknown.hard_limit, native_platform_cap()); + assert_eq!(b_unknown.available_ram, None); + assert_eq!(b_unknown.warning_threshold, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)); + assert_eq!(b_unknown.decide(1), BudgetDecision::Automatic); + let expected = if b_unknown.hard_limit > b_unknown.soft_limit { BudgetDecision::RequiresApproval } else { BudgetDecision::Automatic }; + assert_eq!(b_unknown.decide(b_unknown.hard_limit.min(u64::MAX - 1)), expected); + assert_eq!(b_unknown.decide(u64::MAX), BudgetDecision::Refused, "the saturated overflow sentinel is never approved"); + if b_unknown.hard_limit < u64::MAX { + assert_eq!(b_unknown.decide(b_unknown.hard_limit + 1), BudgetDecision::Refused); + } + } + + /// A format estimate that overflows `u64` saturates at `u64::MAX`. Unknown native memory has + /// `u64::MAX` as its hard limit, so without a sentinel rule explicit approval would admit it. + #[test] + fn a_saturated_estimate_is_refused_even_at_the_unknown_native_hard_limit() { + if cfg!(target_pointer_width = "32") { + // A 32-bit aggregate cap already refuses it; the case this test guards is the + // 64-bit unknown-memory cap, where the limit itself is the sentinel. + return; + } + let budget = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, None); + assert_eq!(budget.hard_limit, u64::MAX, "the case that would otherwise admit the sentinel"); + assert_eq!(budget.decide(u64::MAX), BudgetDecision::Refused, "the decision agrees with evaluation"); + assert_eq!(budget.decide(u64::MAX - 1), BudgetDecision::RequiresApproval); + + for (approved, estimate) in [(false, None), (true, None), (true, Some(u64::MAX))] { + match evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, 0, approved, estimate, MemorySnapshot::UNKNOWN) { + Err(IoError::PsdImportBudget { required, limit }) => { + assert_eq!((required, limit), (u64::MAX, u64::MAX), "the typed refusal keeps the estimate and limit unchanged") + } + other => panic!("approved={approved}, estimate={estimate:?}: expected a hard refusal, got {other:?}"), + } + } + + // A caller limit equal to the sentinel, or an approved estimate of it, is no exception. + assert!(matches!( + evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN), + Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX }) + )); + } + + #[test] + fn unknown_memory_is_conservative_but_not_capped_at_eight_gib() { + if cfg!(target_pointer_width = "32") { + return; + } + let gb = 1u64 << 30; + let b = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, None); + assert_eq!(b.soft_limit, NATIVE_FALLBACK_BUDGET); + assert_eq!(b.hard_limit, native_platform_cap()); + + assert_eq!(b.decide(NATIVE_FALLBACK_BUDGET), BudgetDecision::Automatic); + assert_eq!(b.decide(NATIVE_FALLBACK_BUDGET + 1), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(UNKNOWN_MEMORY_WARNING_THRESHOLD), BudgetDecision::RequiresApproval); + assert!(!b.requires_uncertainty_warning(UNKNOWN_MEMORY_WARNING_THRESHOLD)); + assert!(b.requires_uncertainty_warning(UNKNOWN_MEMORY_WARNING_THRESHOLD + 1)); + assert_eq!(b.decide(UNKNOWN_MEMORY_WARNING_THRESHOLD + 1), BudgetDecision::RequiresApproval, "8 GiB is a warning threshold, not a hard ceiling"); + assert_eq!(b.decide(64 * gb), BudgetDecision::RequiresApproval); + assert!(b.requires_uncertainty_warning(64 * gb)); + } + + #[test] + fn unknown_memory_with_known_commit_headroom_stays_conservative() { + if cfg!(target_arch = "wasm32") { + return; + } + let gb = 1u64 << 30; + + let snap_zero = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(0) }; + let b_zero = resolve_budget(snap_zero, None); + assert_eq!(b_zero.hard_limit, 0); + assert_eq!(b_zero.soft_limit, 0); + assert_eq!(b_zero.decide(1), BudgetDecision::Refused); + + let snap_tight = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(gb) }; + let b_tight = resolve_budget(snap_tight, None); + assert_eq!(b_tight.hard_limit, gb - COMMIT_RESERVE); + assert_eq!(b_tight.soft_limit, gb - COMMIT_RESERVE); + + let snap_room = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(4 * gb) }; + let b_room = resolve_budget(snap_room, None); + assert_eq!(b_room.hard_limit, 4 * gb - COMMIT_RESERVE); + assert_eq!(b_room.soft_limit, NATIVE_FALLBACK_BUDGET); + } + + #[test] + fn unknown_memory_admits_approved_imports_above_eight_gib() { + if cfg!(target_pointer_width = "32") { + return; // the 32-bit platform cap is below 8 GiB + } + let gb = 1u64 << 30; + let snap = MemorySnapshot::UNKNOWN; + let required = 9 * gb; + + let err = evaluate_psd_admission_for(Platform::Native, required, 0, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { available_ram: None, .. })); + + let out = evaluate_psd_admission_for(Platform::Native, required, 0, 0, true, Some(required), snap).unwrap(); + assert_eq!(out.authorized_total, required); + assert!(out.budget.requires_uncertainty_warning(required), "the consent warning must carry through admission"); + + let err = evaluate_psd_admission_for(Platform::Native, required, 0, 0, true, Some(required - 1), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { .. }), "a larger estimate needs fresh consent"); + } + + #[test] + fn web_policy_allows_approval_above_one_gib_within_its_own_cap() { + let b = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, None); + assert_eq!(b.soft_limit, WASM_BUDGET); + assert_eq!(b.hard_limit, WASM_MAX_PEAK_BUDGET); + assert!(b.hard_limit < WASM_LINEAR_MEMORY_LIMIT, "the aggregate cap stays below the wasm32 address space"); + const { assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_LINEAR_MEMORY_LIMIT, "a single Rust allocation cannot use all 4 GiB") }; + assert_eq!(b.available_ram, None); + assert_eq!(b.warning_threshold, None); + + assert_eq!(b.decide(WASM_BUDGET), BudgetDecision::Automatic); + assert_eq!(b.decide(WASM_BUDGET + 1), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET + 1), BudgetDecision::Refused); + const { assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_MAX_PEAK_BUDGET, "the aggregate cap must not be the single-allocation layout bound") }; + let out = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET, 0, 0, true, Some(WASM_MAX_PEAK_BUDGET), MemorySnapshot::UNKNOWN).unwrap(); + assert_eq!(out.authorized_total, WASM_MAX_PEAK_BUDGET, "approval may authorize an aggregate above one buffer's layout limit"); + + let capped = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, Some(512 << 20)); + assert_eq!(capped.hard_limit, 512 << 20); + assert_eq!(capped.soft_limit, 512 << 20); + + let no_raise = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, Some(64 << 30)); + assert_eq!(no_raise.hard_limit, WASM_MAX_PEAK_BUDGET); + } + + #[test] + fn web_admission_requires_approval_above_its_automatic_allowance() { + let snap = MemorySnapshot::UNKNOWN; + let approval = 3 * (WASM_BUDGET / 2); + + let out = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET, 0, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, WASM_BUDGET); + + let err = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET + 1, 0, 0, false, None, snap).unwrap_err(); + assert!(matches!( + err, + IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } + if required == WASM_BUDGET + 1 && soft_limit == WASM_BUDGET && hard_limit == WASM_MAX_PEAK_BUDGET && available_ram.is_none() + )); + + let out = evaluate_psd_admission_for(Platform::Web, approval, 0, 0, true, Some(approval), snap).unwrap(); + assert_eq!(out.authorized_total, approval); + + let err = evaluate_psd_admission_for(Platform::Web, approval, 0, 0, true, Some(approval - 1), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { .. })); + + let err = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET + 1, 0, 0, true, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { .. })); + } + + #[test] + fn caller_caps_lower_unknown_memory_admission() { + let b = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, Some(1 << 30)); + assert_eq!(b.hard_limit, 1 << 30); + assert_eq!(b.soft_limit, 1 << 30); + assert_eq!(b.warning_threshold, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)); + + let no_raise = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, Some(64 << 30)); + assert_eq!(no_raise.hard_limit, native_platform_cap().min(64 << 30)); + assert_eq!(no_raise.soft_limit, NATIVE_FALLBACK_BUDGET.min(native_platform_cap())); + } + + #[test] + fn arithmetic_overflow_safety() { + if cfg!(target_arch = "wasm32") { + return; + } + let snap_huge = MemorySnapshot { available_ram: Some(u64::MAX), swap_free: Some(u64::MAX), commit_headroom: Some(u64::MAX) }; + let b = resolve_budget(snap_huge, None); + let expected_hard = (((u64::MAX as u128) * 3) / 4) as u64; + let expected_hard = expected_hard.min(native_platform_cap()); + assert_eq!(b.hard_limit, expected_hard); + assert_eq!(b.soft_limit, MAX_SOFT_BUDGET.min(expected_hard)); + } + + #[test] + fn nested_mock_memory_restores_previous() { + let snap1 = MemorySnapshot { available_ram: Some(100), swap_free: None, commit_headroom: None }; + let snap2 = MemorySnapshot { available_ram: Some(200), swap_free: None, commit_headroom: None }; + with_mock_memory(MemorySnapshot::UNKNOWN, || { + assert_eq!(MemorySnapshot::current(), MemorySnapshot::UNKNOWN); + with_mock_memory(snap1, || { + assert_eq!(MemorySnapshot::current().available_ram, Some(100)); + with_mock_memory(snap2, || { + assert_eq!(MemorySnapshot::current().available_ram, Some(200)); + }); + assert_eq!(MemorySnapshot::current().available_ram, Some(100)); + }); + assert_eq!(MemorySnapshot::current(), MemorySnapshot::UNKNOWN); + }); + } + + #[test] + fn central_admission_evaluation_enforces_bounds() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + // S = 6 GiB, H = 9 GiB + + // Below S: automatic, authorized_total = S (6 GiB) + let out = evaluate_psd_admission(5 * gb, 0, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, 6 * gb); + + // In [S, H]: requires approval + let err = evaluate_psd_admission(7 * gb, 0, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { required, .. } if required == 7 * gb)); + + // In [S, H] with approval and no approved_estimate bound: admitted, authorized_total = H (9 GiB) + let out = evaluate_psd_admission(7 * gb, 0, 0, true, None, snap).unwrap(); + assert_eq!(out.authorized_total, 9 * gb); + + // In [S, H] with approval but required exceeds approved_estimate: requires fresh approval + let err = evaluate_psd_admission(7 * gb, 0, 0, true, Some(6 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { .. })); + + // In [S, H] with approval and required within approved_estimate: admitted, authorized_total = approved_estimate (7 GiB) + let out = evaluate_psd_admission(7 * gb, 0, 0, true, Some(7 * gb), snap).unwrap(); + assert_eq!(out.authorized_total, 7 * gb); + + // Exceeding H: refused even if high_memory_approved is true + let err = evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { .. })); + } + + /// While the scratch disk spills, tile pixels are admitted against RAM plus its room, and + /// everything else against RAM alone. + #[test] + fn spilling_scratch_admits_tile_pixels_beyond_ram() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + // RAM-only: S = 6 GiB, H = 9 GiB. + with_mock_scratch(Some(40 * gb), || { + // 20 GiB of which 18 GiB tiles: 2 GiB non-spillable fits S, so it is automatic. + let out = evaluate_psd_admission(20 * gb, 18 * gb, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, 6 * gb + 18 * gb); + // 8 GiB non-spillable sits in [S, H]: approval is still required, and is bound to + // the whole estimate. + let err = evaluate_psd_admission(26 * gb, 18 * gb, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { required, soft_limit, hard_limit, .. } + if required == 26 * gb && soft_limit == 24 * gb && hard_limit == 27 * gb)); + assert!(evaluate_psd_admission(26 * gb, 18 * gb, 0, true, Some(26 * gb), snap).is_ok()); + assert!(evaluate_psd_admission(26 * gb, 18 * gb, 0, true, Some(26 * gb - 1), snap).is_err()); + // 10 GiB non-spillable is over H: refused even when approved. + let err = evaluate_psd_admission(28 * gb, 18 * gb, 0, true, Some(28 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 28 * gb && limit == 27 * gb)); + // A spillable share claimed beyond the total is clamped to the total. + assert!(evaluate_psd_admission(5 * gb, u64::MAX, 0, false, None, snap).is_ok()); + // The caller's explicit limit still bounds the whole import. + let err = evaluate_psd_admission(20 * gb, 18 * gb, 10 * gb, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { limit, .. } if limit == 10 * gb)); + }); + // Less room than tiles: only the room is credited. + with_mock_scratch(Some(4 * gb), || { + let err = evaluate_psd_admission(20 * gb, 18 * gb, 0, true, Some(20 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { limit, .. } if limit == 13 * gb)); + assert_eq!(resolve_budget_with_scratch(snap, None, 18 * gb).soft_limit, 10 * gb); + }); + } + + /// Spilling off or paused, or the web: the RAM-only policy, unchanged. + #[test] + fn without_spilling_scratch_the_ram_policy_is_unchanged() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + for scratch in [None, Some(0)] { + with_mock_scratch(scratch, || { + assert_eq!(resolve_budget_with_scratch(snap, None, 18 * gb), resolve_budget(snap, None)); + let err = evaluate_psd_admission(20 * gb, 18 * gb, 0, true, Some(20 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { limit, .. } if limit == 9 * gb)); + let err = evaluate_psd_admission(7 * gb, 7 * gb, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, hard_limit, .. } if soft_limit == 6 * gb && hard_limit == 9 * gb)); + }); + } + // A mocked RAM snapshot without a mocked scratch disk never measures the real one. + with_mock_memory(snap, || assert_eq!(scratch_capacity(), None)); + with_mock_scratch(Some(40 * gb), || { + assert_eq!(scratch_credit_for(Platform::Web, 18 * gb), 0); + let err = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET + 1, WASM_BUDGET, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, .. } if soft_limit == WASM_BUDGET)); + let err = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET + 1, WASM_MAX_PEAK_BUDGET, 0, true, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { .. })); + }); + } + + /// A [`TestMemoryScope`] carries the mocked scratch disk to a worker thread. + #[test] + fn test_memory_scope_captures_the_mocked_scratch_disk() { + let scope = with_mock_scratch(Some(123), TestMemoryScope::capture); + std::thread::spawn(move || { + assert_eq!(MOCK_SCRATCH.with(std::cell::Cell::get), None); + let _g = scope.enter(); + assert_eq!(scratch_capacity(), Some(123)); + }) + .join() + .unwrap(); + } + + #[test] + fn known_snapshot_uses_known_memory_policy_on_every_target() { + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(16 * gb), swap_free: None, commit_headroom: None }; + let b = resolve_budget_for(Platform::Native, snap, None); + let expected_hard = (12 * gb).min(native_platform_cap()); + assert_eq!(b.hard_limit, expected_hard); + assert_eq!(b.soft_limit, (8 * gb).min(expected_hard)); + assert_eq!(b.available_ram, Some(16 * gb)); + assert_eq!(b.warning_threshold, None); + } + + #[test] + fn aggregate_cap_is_not_a_single_allocation_bound() { + let cap = native_platform_cap(); + if cfg!(target_pointer_width = "32") { + assert_eq!(cap, NATIVE_32BIT_PEAK_CAP, "32-bit native uses the documented product cap"); + } else { + assert_eq!(cap, u64::MAX, "64-bit native has no measurable aggregate address-space cap"); + assert!(MAX_SINGLE_ALLOCATION < cap, "a per-buffer limit must not become the aggregate limit"); + } + } + + #[test] + fn swap_is_never_part_of_the_adaptive_budget() { + let gb = 1u64 << 30; + let with_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: Some(64 * gb), commit_headroom: None }; + let without_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + assert_eq!(resolve_budget_for(Platform::Native, with_swap, None), resolve_budget_for(Platform::Native, without_swap, None)); + } +} diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 2b6f4b9b54..64fb6e2562 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -26,50 +26,70 @@ const ALPHA_IDENTIFIERS: u16 = 1053; const LAYER_ONLY_RESOURCES: [u16; 4] = [ids::LAYER_STATE, ids::LAYER_GROUP_INFO, 1069, 1072]; /// Value of sample `i` of a big-endian plane. -fn decode(plane: &[u8], i: usize, s: SampleType) -> f32 { - match s { - SampleType::U8 => f32::from(plane[i]) / 255.0, - SampleType::U16 => f32::from(u16::from_be_bytes([plane[2 * i], plane[2 * i + 1]])) / 65535.0, - SampleType::F32 => f32::from_be_bytes([plane[4 * i], plane[4 * i + 1], plane[4 * i + 2], plane[4 * i + 3]]), - } +fn decode(plane: &[u8], i: usize, s: SampleType) -> Option { + let start = i.checked_mul(s.bytes())?; + let bytes = plane.get(start..start.checked_add(s.bytes())?)?; + Some(match s { + SampleType::U8 => f32::from(*bytes.first()?) / 255.0, + SampleType::U16 => f32::from(u16::from_be_bytes(bytes.try_into().ok()?)) / 65535.0, + SampleType::F32 => f32::from_be_bytes(bytes.try_into().ok()?), + }) } /// Fills `doc` (already in Multichannel mode at the file depth) from the merged planes `all`. -pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[String], warnings: &mut Vec) { +pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[String], warnings: &mut Vec) -> Result<(), crate::IoError> { let h = &file.header; let depth = doc.depth; let (w, hh) = (h.width as usize, h.height as usize); - let n = w * hh; - let plane = h.row_bytes() * hh; - let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); + let invalid = || crate::IoError::Unsupported("multichannel pixel size overflow".into()); + let plane = h.row_bytes().checked_mul(hh).ok_or_else(invalid)?; + let pixels = w.checked_mul(hh).ok_or_else(invalid)?; + if pixels.checked_mul(depth.bytes()).ok_or_else(invalid)? != plane { + return Err(crate::IoError::Unsupported("multichannel channel depth does not match its pixel layout".into())); + } let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); for k in 0..usize::from(h.channels) { - let Some(p) = all.get(k * plane..(k + 1) * plane) else { + let start = k.checked_mul(plane).ok_or_else(invalid)?; + let end = start.checked_add(plane).ok_or_else(invalid)?; + if all.get(start..end).is_none() { warnings.push(format!("multichannel: channel {} is missing", k + 1)); break; - }; - let vals: Vec = (0..n).map(|i| 1.0 - decode(p, i, depth)).collect(); - let mut s = Surface::new(fmt); - s.write_region(canvas, &vals); - s.prune(); + } let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Channel {}", k + 1)); - // Without DisplayInfo the channels print in black, as Photoshop shows them. DisplayInfo - // turns alpha-type channels (selected / masked areas) back into alpha channels. - doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) }); + doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, Surface::new(fmt)) }); } if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { crate::channel_map::apply_display_info(&r.data, true, 0, &mut doc.channels); } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::apply_display_info(&r.data, false, 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; + for (k, ch) in doc.channels.iter_mut().enumerate() { + let start = k.checked_mul(plane).ok_or_else(invalid)?; + let p = all.get(start..start.checked_add(plane).ok_or_else(invalid)?).ok_or_else(invalid)?; + // DisplayInfo is known before conversion: alpha channels need no inversion or + // second full-plane readback. One tile row is the only conversion buffer. + for y in (0..hh).step_by(photocraft_geom::TILE_SIZE as usize) { + let bottom = (y + photocraft_geom::TILE_SIZE as usize).min(hh); + let samples = w.checked_mul(bottom - y).ok_or_else(invalid)?; + let len = samples.checked_mul(depth.bytes()).ok_or_else(invalid)?; + let mut bytes = Vec::new(); + bytes.try_reserve_exact(len).map_err(|_| crate::IoError::OutOfMemory("not enough memory for multichannel pixels".into()))?; + for i in y.checked_mul(w).ok_or_else(invalid)?..bottom.checked_mul(w).ok_or_else(invalid)? { + let v = decode(p, i, depth).ok_or_else(invalid)?; + let v = if ch.spot.is_some() { 1.0 - v } else { v }; + match depth { + SampleType::U8 => bytes.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + SampleType::U16 => bytes.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + SampleType::F32 => bytes.extend_from_slice(&v.to_ne_bytes()), + } + } + ch.surface + .try_write_interleaved(Rect::new(0, y as i32, h.width as i32, bottom as i32), &bytes) + .map_err(|e| crate::IoError::OutOfMemory(format!("multichannel pixel allocation: {e}")))?; + } + ch.surface.prune(); } + Ok(()) } /// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane). @@ -204,3 +224,20 @@ pub(crate) fn layerless_file( crate::psd_export::sign_global_blocks(&mut file); file } + +#[cfg(test)] +mod import_allocation_tests { + use super::*; + + #[test] + fn missing_planes_remain_recoverable_without_reading_past_input() { + let file = photocraft_psd::testgen::merged_only(photocraft_psd::Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 3, 2); + let mut doc = Document::new("Partial", photocraft_geom::Size::new(3, 2), ColorMode::Multichannel, SampleType::U8); + let mut warnings = Vec::new(); + import(&file, &[0; 6], &mut doc, &[], &mut warnings).unwrap(); + assert_eq!(doc.channels.len(), 1); + assert_eq!(doc.channels[0].surface.read_region(doc.bounds()), vec![1.0; 6]); + assert_eq!(warnings.len(), 1); + assert!(decode(&[], usize::MAX, SampleType::F32).is_none()); + } +} diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index fb7968fbcc..36ebbc7b0a 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -629,7 +629,7 @@ impl Writer<'_> { LayerContent::Raster(s) => { let r = s.content_bounds(); admit_layer(r)?; - (r, photocraft_compose::surface_to_buffer(s, r).px) + (r, photocraft_compose::surface_to_buffer(s, r).into_px()) } _ => { // Rendered as it shows on its own, before the layer's blending options. @@ -643,7 +643,7 @@ impl Writer<'_> { solo.effects = Default::default(); let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas); admit_layer(r)?; - (r, photocraft_compose::render_layer(&solo, r).px) + (r, photocraft_compose::render_layer(&solo, r).into_px()) } }; if let Some(mask) = layer.mask.as_ref().filter(|m| m.enabled) { diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index eeda1943cc..0b949a4bdf 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -12,7 +12,7 @@ use photocraft_geom::Rect; use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_has_user_mask, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; use photocraft_raster::Surface; -use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample}; +use crate::pixels::{deinterleave, max_sample, psd_depth, sample_for_depth, try_interleave, zero_sample}; /// Global block keys holding patterns. pub const PATTERN_KEYS: [[u8; 4]; 3] = [*b"Patt", *b"Pat2", *b"Pat3"]; @@ -26,28 +26,67 @@ fn psd_mode(m: ColorMode) -> u32 { } } -/// A PSD pattern as a document pattern (indexed patterns become RGB through their palette). +/// A PSD pattern as a document pattern (indexed patterns become RGB through their palette). A +/// malformed pattern, or one that can't be allocated, is `None`; [`try_from_psd`] tells them apart. pub fn from_psd(p: &PsdPattern) -> Option { + try_from_psd(p).ok().flatten() +} + +/// [`from_psd`], with `Ok(None)` for a malformed pattern and `Err` for a failed allocation. +pub fn try_from_psd(p: &PsdPattern) -> Result, String> { + let mut oom = None; + let pattern = pattern_from_psd(p, &mut oom); + match oom { + Some(error) => Err(error), + None => Ok(pattern), + } +} + +fn pattern_from_psd(p: &PsdPattern, oom: &mut Option) -> Option { let (w, h) = (p.width as usize, p.height as usize); - if w == 0 || h == 0 { + if w == 0 || h == 0 || w > 30_000 || h > 30_000 || !matches!(p.depth, 1 | 8 | 16 | 32) { return None; } + let n = w.checked_mul(h)?; + let plane_len = w.checked_mul(usize::from(p.depth))?.div_ceil(8).checked_mul(h)?; + if p.channels.iter().chain(p.alpha.iter()).any(|plane| plane.len() != plane_len) { + return None; + } + fn copy_plane(plane: &[u8], oom: &mut Option) -> Option> { + let mut out = Vec::new(); + reserve(&mut out, plane.len(), oom)?; + out.extend_from_slice(plane); + Some(out) + } + fn reserve(out: &mut Vec, n: usize, oom: &mut Option) -> Option<()> { + out.try_reserve_exact(n).map_err(|e| *oom = Some(e.to_string())).ok() + } // 1-bit patterns: expand to 8-bit grey. let (channels, depth): (Vec>, u16) = if p.depth == 1 { let row = w.div_ceil(8); - let expand = |plane: &Vec| -> Vec { - (0..w * h).map(|i| if plane.get((i / w) * row + (i % w) / 8).is_some_and(|b| b & (0x80 >> (i % w % 8)) != 0) { 0 } else { 255 }).collect() + let expand = |plane: &Vec, oom: &mut Option| -> Option> { + let mut out = Vec::new(); + reserve(&mut out, n, oom)?; + out.extend((0..n).map(|i| if plane.get((i / w) * row + (i % w) / 8).is_some_and(|b| b & (0x80 >> (i % w % 8)) != 0) { 0 } else { 255 })); + Some(out) }; - (p.channels.iter().map(expand).collect(), 8) + (p.channels.iter().map(|plane| expand(plane, oom)).collect::>()?, 8) } else { - (p.channels.clone(), p.depth) + (p.channels.iter().map(|plane| copy_plane(plane, oom)).collect::>()?, p.depth) }; let (mode, channels) = match p.mode { 1 | 8 => (ColorMode::Grayscale, channels), 2 => { let pal = p.palette.as_deref()?; let idx = channels.first()?; - let ch: Vec> = (0..3).map(|c| idx.iter().map(|i| pal.get(usize::from(*i) * 3 + c).copied().unwrap_or(0)).collect()).collect(); + let ch: Vec> = (0..3) + .map(|c| { + let mut out = Vec::new(); + reserve(&mut out, idx.len(), oom)?; + out.extend(idx.iter().map(|i| pal.get(usize::from(*i) * 3 + c).copied().unwrap_or(0))); + Some(out) + }) + .collect::>()?; (ColorMode::Rgb, ch) } 4 => (ColorMode::Cmyk, channels), @@ -69,11 +108,24 @@ pub fn from_psd(p: &PsdPattern) -> Option { fill.push(max_sample(s)); invert.push(false); } - let mut bytes = interleave(&refs, &fill, w * h, s, &invert); + let mut bytes = match try_interleave(&refs, &fill, n, s, &invert) { + Ok(bytes) => bytes, + Err(photocraft_psd::PsdError::OutOfMemory(error)) => { + *oom = Some(error.to_string()); + return None; + } + Err(_) => return None, + }; if mode == ColorMode::Lab && s == SampleType::U16 { crate::pixels::lab16_chroma(&mut bytes, refs.len(), true); } - let surface = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), &bytes); + let mut surface = Surface::new(fmt); + let result = surface.try_write_interleaved(Rect::new(0, 0, w as i32, h as i32), &bytes); + photocraft_raster::scratch::give(bytes); + if let Err(error) = result { + *oom = Some(error.to_string()); + return None; + } Some(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface }) } @@ -110,18 +162,34 @@ pub fn to_psd(p: &Pattern) -> PsdPattern { } } +/// A warning for each of `patterns` too large to paint: such a pattern fills with transparency, +/// so the file says so on opening. +pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec { + patterns.iter().filter_map(|p| Some(format!("pattern \"{}\" would paint nothing: {}", p.display_name(), p.paint_problem()?))).collect() +} + /// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All /// blocks share one memory budget, so repeating a block can't multiply it. pub fn from_global_blocks(doc: &Document) -> Vec { + try_from_global_blocks(doc).unwrap_or_default() +} + +/// [`from_global_blocks`], failing with the allocation error instead of dropping the pattern it +/// hit, so an import reports that the file could not be opened in full. +pub fn try_from_global_blocks(doc: &Document) -> Result, String> { let mut budget = PatternBudget::default(); - doc.metadata - .psd_global_blocks - .iter() - .filter(|(_, k, _)| PATTERN_KEYS.contains(k)) - .filter_map(|(_, _, d)| parse_pattern_block_with(d, &mut budget).ok()) - .flatten() - .filter_map(|p| from_psd(&p)) - .collect() + let mut out = Vec::new(); + for (_, _, d) in doc.metadata.psd_global_blocks.iter().filter(|(_, k, _)| PATTERN_KEYS.contains(k)) { + let patterns = match parse_pattern_block_with(d, &mut budget) { + Ok(patterns) => patterns, + Err(photocraft_psd::PsdError::OutOfMemory(error)) => return Err(error.to_string()), + Err(_) => continue, + }; + for p in patterns { + out.extend(try_from_psd(&p)?); + } + } + Ok(out) } /// Global blocks to write: the raw ones while they still match [`Document::patterns`] (see @@ -278,3 +346,55 @@ mod tests { assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks); } } + +#[cfg(test)] +mod unpaintable_tests { + use super::*; + use photocraft_doc::PixelFormat; + use photocraft_raster::Surface; + + #[test] + fn a_pattern_too_large_to_paint_is_named_on_opening() { + let sparse = |name: &str, format: PixelFormat, side: u32| Pattern { + id: name.into(), + name: name.into(), + width: side, + height: side, + surface: Surface::new(format), + }; + let patterns = + [sparse("Small", PixelFormat::RGBA8, 64), sparse("Huge float", PixelFormat::RGBA32F, 5000), sparse("Largest 8-bit", PixelFormat::RGBA8, 8000)]; + let warnings = unpaintable_warnings(&patterns); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}"); + } +} + +#[cfg(test)] +mod allocation_tests { + use super::*; + + #[test] + fn malformed_dimensions_and_planes_fail_before_conversion() { + let mut p = PsdPattern { + mode: 1, + width: 9, + height: 2, + name: "Bits".into(), + id: "bits".into(), + palette: None, + depth: 1, + channels: vec![vec![0x80, 0, 0, 0x80]], + alpha: None, + }; + let converted = from_psd(&p).unwrap(); + assert_eq!(converted.surface.to_interleaved(converted.rect()), [0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0]); + p.channels[0].pop(); + assert!(from_psd(&p).is_none()); + p.width = u32::MAX; + assert!(from_psd(&p).is_none()); + p.width = 9; + p.depth = 7; + assert!(from_psd(&p).is_none()); + } +} diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 9dff11fcce..63c6742a1c 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -47,19 +47,47 @@ fn invert_sample(bytes: &mut [u8], s: SampleType) { /// are filled with `fill[c]` (a native-endian encoded sample). Channels whose /// `invert[c]` is set are inverted (PSD stores CMYK as `max - ink`). pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { + // Large results come from (and callers may return them to) the scratch pool. + let mut out = photocraft_raster::scratch::take(n * planes.len() * s.bytes(), 0u8); + interleave_into(planes, fill, n, s, invert, &mut out); + out +} + +/// Fallible interleave for file imports. Validate sizes and missing-plane fills before +/// workers write disjoint slices of one reserved output buffer. +pub fn try_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> photocraft_psd::Result> { + use photocraft_psd::PsdError; + let plane_bytes = n.checked_mul(s.bytes()).ok_or(PsdError::LimitExceeded("interleave size overflow"))?; + let len = plane_bytes.checked_mul(planes.len()).ok_or(PsdError::LimitExceeded("interleave size overflow"))?; + if len as u64 > photocraft_psd::compression::MAX_DECODED_BYTES { + return Err(PsdError::LimitExceeded("interleave exceeds MAX_DECODED_BYTES")); + } + if n > 0 && planes.is_empty() { + return Err(PsdError::Invalid("interleave needs at least one channel".into())); + } + for (c, plane) in planes.iter().enumerate() { + if plane.is_none_or(|p| p.len() < plane_bytes) && fill.get(c).is_none_or(|f| f.len() != s.bytes()) { + return Err(PsdError::Invalid("interleave fill has the wrong sample size".into())); + } + } + let mut out = photocraft_raster::scratch::try_take_exact(len, 0u8).ok_or(PsdError::OutOfMemory("not enough memory to interleave PSD pixels"))?; + interleave_into(planes, fill, n, s, invert, &mut out); + Ok(out) +} + +fn interleave_into(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool], out: &mut [u8]) { + if out.is_empty() { + return; + } let bps = s.bytes(); let ch = planes.len(); let row = ch * bps; - let mut out = vec![0u8; n * row]; - if out.is_empty() { - return out; - } let bs = bands(n); let step = bs.first().map_or(n, ExactSizeIterator::len) * row; par_map(bs.into_iter().zip(out.chunks_mut(step)).collect(), |(band, part)| { for (c, plane) in planes.iter().enumerate() { let src = plane.and_then(|p| p.get(band.start * bps..)).unwrap_or_default(); - let inv = src.len() >= bps && invert[c]; + let inv = src.len() >= bps && invert.get(c).copied().unwrap_or(false); let fill = fill.get(c).map(Vec::as_slice).unwrap_or_default(); match s { SampleType::U8 => write_channel(part, ch, c, src, fill, |[v]| [if inv { 255 - v } else { v }]), @@ -74,7 +102,6 @@ pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: Sampl } } }); - out } fn write_channel(out: &mut [u8], ch: usize, c: usize, src: &[u8], fill: &[u8], decode: impl Fn([u8; N]) -> [u8; N]) { @@ -325,6 +352,20 @@ mod tests { } } + #[test] + fn fallible_interleave_checks_sizes_and_missing_plane_fills() { + assert!(try_interleave(&[None], &[vec![0]], usize::MAX, SampleType::F32, &[]).is_err()); + assert!(try_interleave(&[None], &[], 1, SampleType::U8, &[]).is_err()); + assert!(try_interleave(&[], &[], 1, SampleType::U8, &[]).is_err()); + assert!(try_interleave(&[None], &[vec![0]], usize::MAX / 2, SampleType::U8, &[]).is_err()); + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + let plane = vec![1; 1001 * s.bytes()]; + let planes = [Some(plane.as_slice()), None]; + let fill = vec![zero_sample(s), max_sample(s)]; + assert_eq!(try_interleave(&planes, &fill, 1001, s, &[true, false]).unwrap(), interleave(&planes, &fill, 1001, s, &[true, false])); + } + } + #[test] fn interleave_roundtrip_all_depths() { for s in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs new file mode 100644 index 0000000000..a80230322d --- /dev/null +++ b/crates/io/src/psd_budget.rs @@ -0,0 +1,258 @@ +//! Conservative preflight for the main PSD pixel paths. This bounds decoded layer/mask/ +//! merged and pattern pixels, not parser metadata or smart-filter caches (separate limits). +//! +//! Reserve the sum of all possible retained tiles and the largest workspace, without credit +//! for pruning or tile sharing. Reserving before any worker starts makes scheduling irrelevant. + +use photocraft_geom::TILE_SIZE; +use photocraft_psd::{ + PsdFile, + layer::{CHANNEL_REAL_USER_MASK, CHANNEL_USER_MASK, Rect}, +}; + +use crate::IoError; + +/// Default allowance for PSD layer, mask and merged-image pixels, including conversion +/// workspace and tile padding. Other format/parser limits still apply. Callers may lower it. +pub const DEFAULT_PSD_IMPORT_PIXEL_BYTES: u64 = if cfg!(target_arch = "wasm32") { 1 << 30 } else { 2 << 30 }; + +/// Structured memory estimate for a PSD/PSB import. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PsdMemoryEstimate { + /// Bytes required for all possible retained layer, mask, and merged surfaces. + pub retained_bytes: u64, + /// Largest scratch/conversion workspace required during decoding. + pub workspace_bytes: u64, + /// Total peak requirement (retained + workspace). + pub total_bytes: u64, + /// The part of `retained_bytes` that is tile pixels (layer, mask, channel, fallback and + /// pattern surfaces). Tiles can be spilled to the scratch disk while it is on, so admission + /// does not bound them by physical RAM; the merged planes, pattern decodes and the workspace + /// are ordinary memory. + pub tile_bytes: u64, +} + +#[derive(Default)] +struct ImportBudget { + retained: u64, + workspace: u64, + tiles: u64, +} + +#[cfg(any(test, feature = "testgen"))] +thread_local! { + /// Extra bytes added to every estimate on this thread by [`with_test_estimate_charge`]. + static TEST_ESTIMATE_CHARGE: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Extra tile-pixel bytes added by [`with_test_tile_charge`]. + static TEST_TILE_CHARGE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Adds `bytes` to every PSD memory estimate made on this thread while `f` runs. +/// +/// A test seam: an admission-threshold test can use a tiny fixture, so nothing near a gigabyte +/// is ever allocated, while still exercising the real import path with its approval flags and +/// gate ordering. Scoped to the thread and restored on return or panic. +/// +/// The charge lives on the calling thread. A real import that runs on another thread (or a +/// worker the engine starts for it) does not see it unless the caller propagates an explicitly +/// captured scope: [`crate::memory::TestMemoryScope`] captures this charge together with the +/// fixed mock snapshot, so an engine worker that enters that scope makes the same estimates as +/// the test thread. This is the seam engine and UI dev-dependency tests use through the +/// `testgen` feature; it is never process-global. +#[cfg(any(test, feature = "testgen"))] +pub fn with_test_estimate_charge(bytes: u64, f: impl FnOnce() -> R) -> R { + struct Reset(u64); + impl Drop for Reset { + fn drop(&mut self) { + TEST_ESTIMATE_CHARGE.with(|c| c.set(self.0)); + } + } + let prev = TEST_ESTIMATE_CHARGE.with(|c| c.replace(bytes)); + let _reset = Reset(prev); + f() +} + +/// [`with_test_estimate_charge`] for tile pixels: `bytes` are added to every estimate made on +/// this thread while `f` runs, as spillable tile bytes ([`PsdMemoryEstimate::tile_bytes`]), so a +/// scratch-aware admission test needs no huge fixture. Captured by +/// [`crate::memory::TestMemoryScope`] like the plain charge. +#[cfg(any(test, feature = "testgen"))] +pub fn with_test_tile_charge(bytes: u64, f: impl FnOnce() -> R) -> R { + struct Reset(u64); + impl Drop for Reset { + fn drop(&mut self) { + TEST_TILE_CHARGE.with(|c| c.set(self.0)); + } + } + let prev = TEST_TILE_CHARGE.with(|c| c.replace(bytes)); + let _reset = Reset(prev); + f() +} + +/// The calling thread's current (plain, tile) estimate charges, for an explicit scope to capture. +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn test_estimate_charge() -> (u64, u64) { + (TEST_ESTIMATE_CHARGE.with(|c| c.get()), TEST_TILE_CHARGE.with(|c| c.get())) +} + +/// Enters captured estimate charges (an explicit [`crate::memory::TestMemoryScope`] worker) and +/// returns the charges they replaced, to restore after the scope ends. +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn enter_test_estimate_charge(bytes: u64, tiles: u64) -> (u64, u64) { + (TEST_ESTIMATE_CHARGE.with(|c| c.replace(bytes)), TEST_TILE_CHARGE.with(|c| c.replace(tiles))) +} + +/// The test seam's extra (plain, tile) charges; zero in every other build. +fn test_estimate_charge_value() -> (u64, u64) { + #[cfg(any(test, feature = "testgen"))] + { + test_estimate_charge() + } + #[cfg(not(any(test, feature = "testgen")))] + { + (0, 0) + } +} + +fn overflow() -> IoError { + IoError::Unsupported("PSD import pixel memory estimate overflow".into()) +} + +// inflate_checked uses at least 8 KiB per stream, even for a one-pixel plane. +fn inflate_scratch(plane_bytes: u64, channels: u64) -> Result { + mul(plane_bytes.saturating_add(1).clamp(8192, 1 << 20), channels) +} + +fn mul(a: u64, b: u64) -> Result { + a.checked_mul(b).ok_or_else(overflow) +} + +impl ImportBudget { + fn retain(&mut self, bytes: u64) -> Result<(), IoError> { + self.retained = self.retained.checked_add(bytes).ok_or_else(overflow)?; + Ok(()) + } + + fn scratch(&mut self, bytes: u64) { + self.workspace = self.workspace.max(bytes); + } + + fn required(&self) -> Result { + self.retained.checked_add(self.workspace).ok_or_else(overflow) + } + + fn surface(&mut self, rect: Rect, channels: u64, sample: u64) -> Result<(), IoError> { + if rect.is_empty() { + return Ok(()); + } + let tile = i64::from(TILE_SIZE); + // Division must round down for negative offsets: even a 2x2 rect straddling the + // origin occupies four tiles. Widen first, including the exclusive right/bottom edge. + let nx = (i64::from(rect.right) - 1).div_euclid(tile) - i64::from(rect.left).div_euclid(tile) + 1; + let ny = (i64::from(rect.bottom) - 1).div_euclid(tile) - i64::from(rect.top).div_euclid(tile) + 1; + let bytes = mul(mul(mul(nx as u64, ny as u64)?, (TILE_SIZE as u64).pow(2))?, mul(channels, sample)?)?; + self.tile(bytes) + } + + /// Retained tile pixels: spillable to the scratch disk. + fn tile(&mut self, bytes: u64) -> Result<(), IoError> { + self.retain(bytes)?; + self.tiles = self.tiles.checked_add(bytes).ok_or_else(overflow)?; + Ok(()) + } +} + +/// Computes the conservative preflight pixel memory and workspace estimate for `file`. +/// +/// No decoding here: even a compact ZIP stream must be refused before inflation. The sum +/// deliberately counts full decoded planes for every compression method, plus their row +/// buffers, interleave output and float conversion workspace. +pub fn estimate(file: &PsdFile) -> Result { + let mut budget = ImportBudget::default(); + let h = &file.header; + h.validate()?; + let sample = u64::from(h.depth.div_ceil(8)); + let cc = match h.color_mode { + // Unknown modes use the RGB fallback, including its alpha channel. + photocraft_psd::ColorMode::Unknown(_) => 3, + mode => u64::from(mode.color_channels().unwrap_or(3)), + }; + if super::psd_import::can_recover_layers(file) { + for rec in file.layers() { + if !rec.rect.is_empty() && rec.channels.iter().any(|c| c.id >= 0 || c.id == -1) { + let (w, hh) = rec.rect.size()?; + let pixels = mul(w as u64, hh as u64)?; + budget.surface(rec.rect, cc + 1, sample)?; + // Whole ZIP planes + band planes + interleave + temporary float pixels. + // Counting a full layer for each band buffer also covers a single very wide row. + let workspace = mul(pixels, mul(cc + 1, sample * 3 + 4)?)?.checked_add(inflate_scratch(mul(pixels, sample)?, cc + 1)?).ok_or_else(overflow)?; + // RLE row indexes live beside decoded planes. + budget.scratch(workspace.checked_add(mul(hh as u64 + 1, (cc + 1) * 8)?).ok_or_else(overflow)?); + } + if let Some(mask) = rec.layer_mask() { + for (id, rect) in [(CHANNEL_USER_MASK, mask.rect), (CHANNEL_REAL_USER_MASK, mask.real.map_or(mask.rect, |m| m.rect))] { + if rec.channel(id).is_none() || rect.is_empty() { + continue; + } + let (w, hh) = rect.size()?; + budget.surface(rect, 1, sample)?; + // The layer's ZIP decoders have been dropped before record_mask runs. + let pixels = mul(w as u64, hh as u64)?; + budget.scratch(mul(pixels, sample * 3 + 4)?.checked_add(inflate_scratch(mul(pixels, sample)?, 1)?).ok_or_else(overflow)?); + } + } + } + } + if super::psd_import::merged_needed(file) { + let pixels = mul(u64::from(h.width), u64::from(h.height))?; + let channels = u64::from(h.channels); + let canvas = Rect::from_xywh(0, 0, h.width, h.height); + // Multichannel and extra channels become individual grayscale surfaces. Reserving + // their tiles separately matters for images smaller than a tile. + budget.surface(canvas, channels.max(cc + 1), sample.max(1))?; + // A fallback surface can coexist with the layers when none produced usable pixels. + budget.surface(canvas, cc + 1, sample.max(1))?; + // The merged planes stay alive while layers are built. + let merged = mul(mul(h.row_bytes() as u64, u64::from(h.height))?, channels)?; + budget.retain(merged)?; + // Include float expansion/readback and RGBA fallback, even for packed bitmap input. + let converted = mul(pixels, mul(channels.max(cc + 1), sample * 3 + 12)?)?; + budget.scratch(converted.checked_add(inflate_scratch(merged, 1)?).ok_or_else(overflow)?); + } + let mut patterns = photocraft_psd::patterns::PatternBudget::default(); + for block in file.global_blocks.iter().filter(|b| crate::pattern_map::PATTERN_KEYS.contains(&b.key)) { + // Earlier records can decode before a malformed later record discards the block. + let items = photocraft_psd::patterns::pattern_block_memory(&block.data, &mut patterns); + for p in items { + let pixels = mul(u64::from(p.width), u64::from(p.height))?; + let sample = u64::from(p.depth.div_ceil(8)); + let color = if p.mode == 2 { 3 } else { photocraft_psd::patterns::mode_channels(p.mode) as u64 }; + let alpha = u64::from(p.planes > photocraft_psd::patterns::mode_channels(p.mode)); + budget.surface(Rect::from_xywh(0, 0, p.width, p.height), color + alpha, sample)?; + // The block parser returns all planes together. Count them as retained so + // previous converted patterns and later blocks cannot reuse this allowance. + budget.retain(p.decoded_bytes)?; + // Channel clones/bitmap expansion, indexed RGB expansion and interleave. + // Include a source plane during padded channel placement as well. + let converted = mul(pixels, mul((p.planes as u64).max(color + alpha) + color + alpha + 3, sample)?)?; + budget.scratch(converted.checked_add(p.decoded_bytes).ok_or_else(overflow)?); + } + } + let (charge, tile_charge) = test_estimate_charge_value(); + budget.retain(charge)?; + budget.tile(tile_charge)?; + let total_bytes = budget.required()?; + Ok(PsdMemoryEstimate { retained_bytes: budget.retained, workspace_bytes: budget.workspace, total_bytes, tile_bytes: budget.tiles }) +} + +/// Checks that `file`'s estimate does not exceed `limit`. +pub fn check(file: &PsdFile, limit: u64) -> Result { + let est = estimate(file)?; + if est.total_bytes > limit { + return Err(IoError::PsdImportBudget { required: est.total_bytes, limit }); + } + Ok(est) +} + +#[cfg(test)] +mod tests; diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs new file mode 100644 index 0000000000..9e24676716 --- /dev/null +++ b/crates/io/src/psd_budget/tests.rs @@ -0,0 +1,580 @@ +use super::*; +use photocraft_psd::{ColorMode, Compression, Version, testgen}; + +#[test] +fn retained_storage_is_summed_but_workspace_is_reused() { + let mut budget = ImportBudget::default(); + budget.retain(40).unwrap(); + budget.retain(20).unwrap(); + budget.scratch(30); + budget.scratch(10); + assert_eq!(budget.required().unwrap(), 90); + budget.retain(u64::MAX).unwrap_err(); + budget.workspace = u64::MAX; + assert!(budget.required().is_err()); +} + +#[test] +fn tiny_negative_offset_rectangles_pay_for_every_touched_tile() { + let mut aligned = ImportBudget::default(); + aligned.surface(Rect::from_xywh(0, 0, 2, 2), 4, 2).unwrap(); + let mut offset = ImportBudget::default(); + offset.surface(Rect::from_xywh(-1, -1, 2, 2), 4, 2).unwrap(); + assert_eq!(offset.required().unwrap(), aligned.required().unwrap() * 4); + offset.surface(Rect::default(), 4, 2).unwrap(); + assert_eq!(offset.required().unwrap(), aligned.required().unwrap() * 4); +} + +fn estimate(file: &PsdFile) -> u64 { + match check(file, 0).unwrap_err() { + IoError::PsdImportBudget { required, .. } => required, + error => panic!("unexpected error: {error}"), + } +} + +#[test] +fn individually_fitting_layers_cannot_each_spend_the_import_allowance() { + let mut file = testgen::small(Version::Psd, Compression::Zip); + let record = file.layers()[0].clone(); + file.layer_info.as_mut().unwrap().layers = vec![record.clone()]; + let one = estimate(&file); + assert!(check(&file, one).is_ok()); + file.layer_info.as_mut().unwrap().layers = vec![record; 8]; + assert!(matches!(check(&file, one), Err(IoError::PsdImportBudget { .. }))); +} + +#[test] +fn large_off_canvas_layer_is_rejected_without_decoding_its_tiny_data() { + let mut file = testgen::small(Version::Psd, Compression::Zip); + file.header.width = 1; + file.header.height = 1; + file.layers_mut()[0].rect = Rect::from_xywh(-10_000, -10_000, 20_000, 20_000); + assert!(matches!(check(&file, DEFAULT_PSD_IMPORT_PIXEL_BYTES), Err(IoError::PsdImportBudget { .. }))); + // Both ordinary import routes must fail before reading a deliberately missing merged + // section. No enormous decoded plane is ever constructed by this test. + let mut bytes = file.to_bytes().unwrap(); + bytes.truncate(bytes.len() - file.image_data.data.len() - 2); + crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { + // Unknown memory keeps a conservative automatic allowance, so this file is refused + // pending approval (or by the hard platform limit on 32-bit) before any pixel decode. + assert!(matches!(crate::import("huge.psd", &bytes), Err(IoError::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }))); + let source = photocraft_psd::Source::new(bytes); + assert!(matches!( + crate::import_source_for_open_with("huge.psd", source, &photocraft_raster::Interrupt::NONE), + Err(IoError::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }) + )); + }); +} + +#[test] +fn user_mask_bounds_count_even_when_the_layer_is_small() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let before = estimate(&file); + // The generator's first record has a user mask. Modify its parsed mask in place. + if let photocraft_psd::layer::MaskData::Mask(mask) = &mut file.layers_mut()[0].mask { + mask.rect = Rect::from_xywh(0, 0, 20_000, 20_000); + } else { + panic!("fixture needs a parsed mask"); + } + assert!(estimate(&file) > before); + assert!(matches!(check(&file, before), Err(IoError::PsdImportBudget { .. }))); +} + +#[test] +fn merged_bitmap_and_multichannel_expansion_are_budgeted() { + for (mode, depth) in [(ColorMode::Bitmap, 1), (ColorMode::Multichannel, 8), (ColorMode::Rgb, 32)] { + let mut file = testgen::merged_only(Version::Psb, mode, depth, Compression::Zip, 2, 2); + let before = estimate(&file); + assert!(check(&file, before).is_ok()); + file.header.width = 60_000; + file.header.height = 60_000; + assert!(matches!(check(&file, DEFAULT_PSD_IMPORT_PIXEL_BYTES), Err(IoError::PsdImportBudget { .. }))); + } +} + +#[test] +fn a_custom_limit_is_fatal_for_interactive_and_headless_imports() { + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let limit = estimate(&file); + let opts = crate::ImportOptions { psd_max_pixel_bytes: limit, ..Default::default() }; + assert!(crate::import_with_options("small.psd", &bytes, &opts).is_ok()); + let opts = crate::ImportOptions { psd_max_pixel_bytes: limit - 1, ..opts }; + for result in [crate::import_with_options("small.psd", &bytes, &opts), crate::import_for_open("small.psd", &bytes, &opts).map(|(result, _)| result)] { + assert!(matches!(result, Err(IoError::PsdImportBudget { .. }))); + } +} + +#[test] +fn accepted_off_canvas_pixels_and_depths_are_preserved() { + for depth in [8, 16, 32] { + for compression in Compression::ALL { + let file = testgen::layered(Version::Psd, ColorMode::Rgb, depth, compression); + assert!(check(&file, DEFAULT_PSD_IMPORT_PIXEL_BYTES).is_ok()); + let (doc, _) = crate::psd_to_document(&file); + assert_eq!(doc.depth, crate::pixels::sample_for_depth(depth)); + let bytes = file.to_bytes().unwrap(); + let opened = crate::import("small.psd", &bytes).unwrap().document; + let rect = photocraft_geom::Rect::new(-8, -8, 32, 24); + assert_eq!(photocraft_compose::render(&opened, rect), photocraft_compose::render(&doc, rect)); + } + } +} + +#[test] +fn legacy_conversion_reports_a_rejected_import_instead_of_decoding_it() { + let mut file = testgen::small(Version::Psd, Compression::Zip); + file.layers_mut()[0].rect = Rect::from_xywh(0, 0, 20000, 20000); + // Use fallback snapshot to ensure test does not depend on host available RAM + crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { + let (doc, warnings) = crate::psd_to_document(&file); + assert!(doc.layers.is_empty()); + assert!(warnings.iter().any(|warning| warning.contains("pixel memory"))); + }); +} + +fn pattern_block(mode: u32, depth: u16, width: u32, height: u32) -> photocraft_psd::TaggedBlock { + use photocraft_psd::patterns::{PsdPattern, mode_channels, write_pattern_block}; + let len = (width as usize * depth as usize).div_ceil(8) * height as usize; + let p = PsdPattern { + mode, + depth, + width, + height, + name: "Budget pattern".into(), + id: "budget".into(), + palette: (mode == 2).then(|| vec![0; 768]), + channels: vec![vec![0; len]; mode_channels(mode)], + alpha: None, + }; + photocraft_psd::TaggedBlock::new(*b"Patt", write_pattern_block(&[p]).unwrap()) +} + +#[test] +fn pattern_tiles_and_expansion_share_the_import_allowance() { + for (mode, depth) in [(1, 1), (2, 8), (3, 8), (4, 16), (9, 32)] { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let baseline = estimate(&file); + let block = pattern_block(mode, depth, 9, 3); + file.global_blocks.push(block.clone()); + let one = estimate(&file); + assert!(one > baseline); + assert!(check(&file, one).is_ok()); + assert!(matches!(check(&file, one - 1), Err(IoError::PsdImportBudget { .. }))); + let bytes = file.to_bytes().unwrap(); + let opts = crate::ImportOptions { psd_max_pixel_bytes: one - 1, ..Default::default() }; + assert!(matches!(crate::import_with_options("pattern.psd", &bytes, &opts), Err(IoError::PsdImportBudget { .. }))); + assert_eq!(crate::import("pattern.psd", &bytes).unwrap().document.patterns.len(), 1); + file.global_blocks.push(block); + assert!(estimate(&file) > one, "blocks cannot each reuse the allowance"); + } +} + +#[test] +fn malformed_later_pattern_still_counts_earlier_decodes() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let baseline = estimate(&file); + let mut block = pattern_block(1, 1, 9, 3); + // An invalid second pattern makes the real parser discard the block, after decoding + // its first record. The preflight must still account for that transient allocation. + block.data.extend_from_slice(&4u32.to_be_bytes()); + block.data.extend_from_slice(&99u32.to_be_bytes()); + file.global_blocks.push(block); + assert!(estimate(&file) > baseline); +} + +/// An unknown colour mode imports as RGB (`import_format`), layers included, so it must pay +/// for them: a layered file can't dodge the allowance by carrying mode 5. +#[test] +fn an_unknown_color_mode_pays_for_the_layers_it_imports_as_rgb() { + let rgb = testgen::small(Version::Psd, Compression::Raw); + assert!(!rgb.layers().is_empty() && !super::super::psd_import::merged_needed(&rgb), "the fixture must import its layers alone"); + let mut unknown = rgb.clone(); + unknown.header.color_mode = photocraft_psd::ColorMode::Unknown(5); + assert!(unknown.header.validate().is_ok()); + let required = estimate(&unknown); + assert!(required >= estimate(&rgb), "the layers import as RGB and are counted"); + let bytes = unknown.to_bytes().unwrap(); + let opts = crate::ImportOptions { psd_max_pixel_bytes: required - 1, ..Default::default() }; + assert!(matches!(crate::import_with_options("mode5.psd", &bytes, &opts), Err(IoError::PsdImportBudget { .. }))); + let source = photocraft_psd::Source::new(bytes); + let refused = crate::import_source_with_limit("mode5.psd", source, &photocraft_raster::Interrupt::NONE, required - 1); + assert!(matches!(refused, Err(IoError::PsdImportBudget { .. })), "{:?}", refused.map(|(r, _)| r.warnings)); +} + +#[test] +fn adaptive_budget_requires_explicit_approval_in_confirmation_range() { + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let est = estimate(&file); + + // Set mock RAM such that est is strictly between S and H. + // E.g., if A = est * 2 + 1 GiB: + // H = min(75% A, A - 1 GiB) = min(1.5 est + 768 MiB, 2 est) = 1.5 est + 768 MiB > est. + // S = min(50% A, 8 GiB, H) = min(est + 512 MiB, 8 GiB, H). + // Let us set A precisely so S < est <= H: + // If we want S < est, 50% A < est => A < 2 est. + // If A = (est * 16) / 10 (i.e. 1.6 est): + // S = 0.8 est < est. + // H = min(1.2 est, 1.6 est - 1 GiB). + // To ensure saturating A - 1 GiB is not 0, let A = 10 * (1 << 30) (10 GiB): + // S = 5 GiB, H = 7.5 GiB. + // Target estimate est in (5 GiB, 7.5 GiB]. + // With mock memory, we can scale mock RAM: + let gb = 1u64 << 30; + // Set RAM such that soft = est - 1, hard = est + 1000: + // With 12 GiB RAM: S = 6 GiB, H = 9 GiB. + let snap = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap, || { + // Without caller limit, small file is << 6 GiB -> automatically admitted + assert!(crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default()).is_ok()); + + // Now set caller limit to est + 1: + // Then hard = est + 1, soft = est + 1. + let opts = crate::ImportOptions { psd_max_pixel_bytes: est + 1, ..Default::default() }; + assert!(crate::import_with_options("small.psd", &bytes, &opts).is_ok()); + + // Now set caller limit to est - 1: + // Then hard = est - 1 -> refused + let opts = crate::ImportOptions { psd_max_pixel_bytes: est - 1, ..Default::default() }; + assert!(matches!(crate::import_with_options("small.psd", &bytes, &opts), Err(IoError::PsdImportBudget { .. }))); + }); +} + +#[test] +fn explicit_approval_allows_confirmation_range_but_never_bypasses_hard_boundary() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; // the 6 GiB hard limit is above the 32-bit aggregate cap + } + let gb = 1u64 << 30; + + // Scale RAM so that est falls between S and H: + // S = 50% A, H = 75% A. + // We want S < est <= H. + // So 0.50 A < est <= 0.75 A => (4/3) est <= A < 2 est. + // If A = 1.6 * est, then S = 0.8 est, H_pct = 1.2 est. + // To satisfy saturating A - 1 GiB: we test pure policy decision: + let snap = crate::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + let budget = crate::memory::resolve_budget(snap, None); + assert_eq!(budget.soft_limit, 4 * gb); + assert_eq!(budget.hard_limit, 6 * gb); + + // In confirmation range: + let mid = 5 * gb; + assert_eq!(budget.decide(mid), crate::memory::BudgetDecision::RequiresApproval); + + // Over hard limit: + let over = 7 * gb; + assert_eq!(budget.decide(over), crate::memory::BudgetDecision::Refused); +} + +#[test] +fn zero_available_ram_refuses_admission() { + if cfg!(target_arch = "wasm32") { + return; // the web policy ignores a host snapshot; it has its own tests + } + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + + let snap = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap, || { + let opts = crate::ImportOptions::default(); + let res = crate::import_with_options("small.psd", &bytes, &opts); + assert!(matches!(res, Err(IoError::PsdImportBudget { .. }))); + }); +} + +#[test] +fn preflight_rejection_and_approval_files_release_gate() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let mut file = testgen::small(Version::Psd, Compression::Zip); + file.header.width = 1; + file.header.height = 1; + file.layers_mut()[0].rect = Rect::from_xywh(0, 0, 30_000, 30_000); + let bytes = file.to_bytes().unwrap(); + + let gb = 1u64 << 30; + // Set RAM such that this large layer exceeds hard limit + let snap = crate::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap, || { + assert!(!crate::psd_gate::is_holding_admission()); + let res = crate::import_with_options("huge.psd", &bytes, &crate::ImportOptions::default()); + assert!(matches!(res, Err(IoError::PsdImportBudget { .. }))); + // Gate must not be held after rejection + assert!(!crate::psd_gate::is_holding_admission()); + }); + + // Set RAM such that file falls in confirmation range + let est = estimate(&file); + let snap_mid = crate::memory::MemorySnapshot { available_ram: Some(est * 2), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap_mid, || { + assert!(!crate::psd_gate::is_holding_admission()); + let res = crate::import_with_options("mid.psd", &bytes, &crate::ImportOptions::default()); + assert!(matches!(res, Err(IoError::PsdApprovalRequired { .. }))); + // Gate must not be held when requiring approval + assert!(!crate::psd_gate::is_holding_admission()); + }); +} + +#[test] +fn retry_estimate_cap_rejects_altered_or_enlarged_file() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + // S = 6 GiB, H = 9 GiB + + // 1. Within approved_estimate: passes + let res = crate::memory::evaluate_psd_admission(7 * gb, 0, 0, true, Some(7 * gb), snap); + assert!(res.is_ok()); + + // 2. Fresh requirement exceeds approved_estimate: requires fresh approval + let res = crate::memory::evaluate_psd_admission(8 * gb, 0, 0, true, Some(7 * gb), snap); + assert!(matches!(res, Err(IoError::PsdApprovalRequired { required, .. }) if required == 8 * gb)); + + // 3. Exceeds hard limit: refused completely regardless of approval + let res = crate::memory::evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap); + assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit }) if required == 10 * gb && limit == 9 * gb)); +} + +#[test] +fn caller_zero_and_lower_caps_cannot_bypass_hard_boundary() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = crate::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + // S = 4 GiB, H = 6 GiB + + // 0 uses adaptive budget + let b_zero = crate::memory::resolve_budget(snap, Some(0)); + assert_eq!(b_zero.hard_limit, 6 * gb); + assert_eq!(b_zero.soft_limit, 4 * gb); + + // Lower cap lowers both hard and soft + let b_lower = crate::memory::resolve_budget(snap, Some(3 * gb)); + assert_eq!(b_lower.hard_limit, 3 * gb); + assert_eq!(b_lower.soft_limit, 3 * gb); + + // Higher cap cannot raise above hard limit + let b_higher = crate::memory::resolve_budget(snap, Some(20 * gb)); + assert_eq!(b_higher.hard_limit, 6 * gb); + assert_eq!(b_higher.soft_limit, 4 * gb); +} + +#[test] +fn test_memory_scope_propagates_to_worker_thread() { + let gb = 1u64 << 30; + let mock = crate::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(mock, || { + let scope = crate::memory::TestMemoryScope::capture(); + let handle = std::thread::spawn(move || { + scope.run(|| { + assert_eq!(crate::memory::MemorySnapshot::current().available_ram, Some(4 * gb)); + }); + }); + handle.join().unwrap(); + }); +} + +#[test] +fn test_memory_scope_propagates_the_estimate_charge_to_a_worker() { + let gb = 1u64 << 30; + let file = std::sync::Arc::new(testgen::small(Version::Psd, Compression::Zip)); + let bare = estimate(&file); + with_test_estimate_charge(2 * gb, || { + assert_eq!(estimate(&file), bare + 2 * gb, "the charge is part of the estimate on this thread"); + let scope = crate::memory::TestMemoryScope::capture(); + let worker_file = std::sync::Arc::clone(&file); + std::thread::spawn(move || { + scope.run(|| assert_eq!(estimate(&worker_file), bare + 2 * gb, "a captured scope carries the charge to its worker")); + }) + .join() + .unwrap(); + }); + assert_eq!(estimate(&file), bare, "the charge is restored on this thread"); + let detached = std::sync::Arc::clone(&file); + assert_eq!(std::thread::spawn(move || estimate(&detached)).join().unwrap(), bare, "a worker outside the scope never sees it"); +} + +#[test] +fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() { + let gb = 1u64 << 30; + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).unwrap(); + // Simulate top-level attempt authorized total of 5 GB with 2 GB remaining allowance + crate::psd_gate::set_active_attempt_budget(2 * gb); + + // First nested consume fits within 2 GB + assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); + + // Second nested consume exceeds remaining 1 GB + let err = crate::psd_gate::consume_attempt_allowance(2 * gb).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == gb)); + + // Allowance is still 1 GB (failed attempt does not reset or corrupt parent) + assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); +} + +#[test] +fn nested_tiff_layer_import_consumes_composite_and_estimate_without_resetting_parent() { + let gb = 1u64 << 30; + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).unwrap(); + crate::psd_gate::set_active_attempt_budget(3 * gb); + + assert!(crate::psd_gate::is_holding_admission()); + assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(2 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); +} + +/// A real import carries the approval flag and its estimate bound into admission: the fixture is +/// tiny, and a thread-local test charge puts it in the confirmation band without allocating +/// gigabytes. +#[test] +fn real_import_propagates_high_memory_approval_and_its_estimate_bound() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snapshot, || { + let automatic_layers = + crate::import_with_options("approval.psd", &bytes, &crate::ImportOptions::default()).expect("small file is automatic").document.layers.len(); + with_test_estimate_charge(7 * gb, || { + let required = match crate::import_with_options("approval.psd", &bytes, &crate::ImportOptions::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + assert_eq!((soft_limit, hard_limit), (6 * gb, 9 * gb)); + assert_eq!(available_ram, Some(12 * gb)); + required + } + other => panic!("expected an approval request, got {other:?}"), + }; + assert!((7 * gb..=9 * gb).contains(&required), "the charge lands in the approval band: {required}"); + + let approved = crate::ImportOptions { psd_high_memory_approved: true, ..Default::default() }; + assert_eq!( + crate::import_with_options("approval.psd", &bytes, &approved).expect("approved").document.layers.len(), + automatic_layers, + "an approved import decodes the same document as an automatic one" + ); + let bounded = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required), ..Default::default() }; + assert!(crate::import_with_options("approval.psd", &bytes, &bounded).is_ok()); + let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required - 1), ..Default::default() }; + assert!(matches!(crate::import_with_options("approval.psd", &bytes, &stale), Err(IoError::PsdApprovalRequired { .. }))); + let lowered = crate::ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() }; + assert!(matches!( + crate::import_with_options("approval.psd", &bytes, &lowered), + Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1 + )); + }); + }); +} + +/// Available memory can drop while an import waits for the decode gate. The post-gate recheck +/// must then refuse before anything is decoded. A per-attempt provider, shared with this test +/// only, changes the measurement while the worker is blocked; nothing near a gigabyte is +/// allocated, and the real document is never opened. +#[test] +fn memory_drop_while_waiting_for_the_gate_refuses_before_decoding() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + + // Hold the gate so the worker stops after its first admission check. + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE).expect("active decode"); + + let available = Arc::new(AtomicU64::new(32 * gb)); + let prechecked = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + let handle = { + let available = Arc::clone(&available); + let prechecked = Arc::clone(&prechecked); + std::thread::spawn(move || { + let provider = std::rc::Rc::new(move || { + prechecked.store(true, Ordering::SeqCst); + crate::memory::MemorySnapshot { available_ram: Some(available.load(Ordering::SeqCst)), swap_free: None, commit_headroom: None } + }); + let result = + crate::memory::with_mock_memory_provider(provider, || crate::import_with_options("drop.psd", &bytes, &crate::ImportOptions::default())); + tx.send(result.map(|r| r.document.layers.len())).ok(); + }) + }; + + // The provider's first call is the pre-gate admission check. + let deadline = Instant::now() + Duration::from_secs(10); + while !prechecked.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the import never reached its admission check"); + std::thread::yield_now(); + } + // The precheck admitted it; now the machine has nothing left. Release the gate afterwards. + available.store(0, Ordering::SeqCst); + drop(_guard); + + let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding"); + match result { + Err(IoError::PsdImportBudget { required, limit: 0 }) => assert!(required > 0), + other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"), + } + handle.join().expect("worker thread"); +} + +/// A real import counts its tile pixels as spillable: with a spilling scratch disk they are +/// admitted against RAM plus its room, while non-spillable working memory still needs RAM, and +/// with spilling off the RAM-only policy applies unchanged. Nothing near a gigabyte is allocated. +#[test] +fn real_import_admits_tile_pixels_against_scratch_room_only_while_spilling() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let est = estimate(&file); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + let import = |opts: &crate::ImportOptions| crate::import_with_options("scratch.psd", &bytes, opts); + crate::memory::with_mock_memory(snapshot, || { + assert!(super::estimate(&file).unwrap().tile_bytes > 0, "layer surfaces are tile pixels"); + with_test_tile_charge(18 * gb, || { + let charged = super::estimate(&file).unwrap(); + assert_eq!(charged.total_bytes, est + 18 * gb); + // Spilling off (or paused): 18 GiB of tiles is over H = 9 GiB. + crate::memory::with_mock_scratch(None, || { + assert!(matches!(import(&Default::default()), Err(IoError::PsdImportBudget { limit, .. }) if limit == 9 * gb)); + }); + // Spilling with room for every tile: automatic, no approval. + crate::memory::with_mock_scratch(Some(40 * gb), || { + assert!(import(&Default::default()).is_ok()); + }); + // Room for only part of the tiles: the rest must fit RAM. + crate::memory::with_mock_scratch(Some(4 * gb), || { + assert!(matches!(import(&Default::default()), Err(IoError::PsdImportBudget { .. }))); + }); + }); + // Non-spillable memory gets no scratch credit: 7 GiB still asks for approval. + with_test_estimate_charge(7 * gb, || { + crate::memory::with_mock_scratch(Some(40 * gb), || match import(&Default::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, .. }) => { + let tiles = super::estimate(&file).unwrap().tile_bytes; + assert_eq!(soft_limit, 6 * gb + tiles); + assert!(required > soft_limit); + } + other => panic!("expected an approval request, got {other:?}"), + }); + }); + }); +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index c6f8539106..89bf250b4e 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -15,11 +15,14 @@ use crate::blocks; use crate::pixels::{deinterleave, encode_be, psd_depth}; use crate::psd_import::REGENERATED; +mod banded; mod document; mod estimate; mod merged; mod resources; mod smart; +mod stage; +pub(crate) use stage::STAGE_DOCUMENTS_FROM; pub(crate) use document::document_to_psd_interruptible; #[cfg(test)] @@ -28,7 +31,7 @@ pub use document::{document_to_psd, document_to_psd_with}; use document::{document_to_psd_nested, mask_parameters}; pub(crate) use estimate::{fit_psd_size, psd_version, sign_global_blocks}; pub(crate) use resources::{global_block, guides_resource, keep_imported_resource, legacy_name, pascal_names_resource, path_resources, unicode_names_resource}; -use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, masked, size_from_layer, smart_keys, upsert}; +use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, embed_source_hash, masked, size_from_layer, smart_keys, upsert}; /// Blocks that define a layer's content as something other than pixels: fill (`SoCo`, `GdFl`, /// `PtFl`), placed / smart object (`SoLd`, `PlLd`, `SoLE`), type (`TySh`) and shape fill (`vscg`). @@ -41,10 +44,6 @@ const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; /// Room kept under [`PSD_MAX_FILE_SIZE`] for what the save adds after the conversion (the image /// preview resource, a few KB). const PSD_SAVE_HEADROOM: u64 = 1024 * 1024; -/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and -/// the channel planes made from them (or the band parts and the joined buffer) are alive together, -/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`]. -const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 }; /// Options for [`document_to_psd_with`]. #[derive(Debug, Clone)] @@ -74,6 +73,10 @@ pub struct PsdExportOptions { /// off, since a stored path is document data rather than a granted file: such a layer is /// then written as pixels, with a warning. pub read_linked_files: bool, + /// Park each encoded channel on the scratch disk, not in memory, when the document's raw + /// pixel bytes (layers and composite) reach this and spilling is on (`None`: never). The + /// save then holds the file model's structure, not a second copy of its pixels (#5333). + pub stage_on_scratch_from: Option, } impl Default for PsdExportOptions { @@ -86,11 +89,15 @@ impl Default for PsdExportOptions { maximize_compatibility: true, document_path: None, read_linked_files: true, + stage_on_scratch_from: None, } } } -struct Ex { +struct Ex<'c> { + /// The save's interrupt: a layer saved in bands checks it between bands and channels, so a + /// cancelled save stops within a band (#5333). + ctl: Interrupt<'c>, fmt: PixelFormat, /// Document resolution (for generated type-layer data). dpi: f32, @@ -126,6 +133,8 @@ struct Ex { patterns: Vec, /// The layer written as Photoshop's Background (see [`psd_background`]). background: Option, + /// Where encoded channels go instead of memory ([`PsdExportOptions::stage_on_scratch_from`]). + stage: Option>, } /// The layer Photoshop should open as its Background: the bottom top-level layer when it is a @@ -175,7 +184,7 @@ fn q255(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } -impl Ex { +impl Ex<'_> { fn compression(&self) -> Compression { if self.fmt.sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle } } @@ -186,9 +195,13 @@ impl Ex { // A ZIP stream for a zero-sized channel makes readers try to decompress an // invalid image extent (notably psd-tools for 32-bit adjustment layers). let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() }; - ChannelData::encode(id, compression, plane, w, h, depth, self.version) + let c = ChannelData::encode(id, compression, plane, w, h, depth, self.version) .or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version)) - .unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec())) + .unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec())); + match &self.stage { + Some(stage) => stage.channel(c), + None => c, + } } fn empty_channels(&self) -> Vec { @@ -208,11 +221,11 @@ impl Ex { return (PsdRect::default(), self.empty_channels(), warnings); } // A layer record is one dense rectangle, so a few pixels far apart make a box of mostly - // nothing, gathered and then copied again. Past the cap the layer is saved empty, said so. + // nothing, gathered and then copied again. Past `banded_from` the layer is gathered and + // encoded a band of rows at a time instead (#5333). let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64); - if dense > MAX_LAYER_GATHER_BYTES { - warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height())); - return (PsdRect::default(), self.empty_channels(), warnings); + if dense > banded::banded_from() { + return (to_psd_rect(r), self.banded_channels(&s, r), warnings); } // The interleaved gather is the sequential pass for full-canvas layers, so it runs // in row bands on all cores and the band results concatenate in order. @@ -223,6 +236,8 @@ impl Ex { let mut bytes = Vec::with_capacity(row_len * h_of(r) * s.format().bytes_per_pixel()); for part in parts { bytes.extend_from_slice(&part); + // Free gathered bands before allocating channel planes below; pooling them here + // retains another layer-sized allocation throughout deinterleave. } if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 { crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false); @@ -412,6 +427,9 @@ impl Ex { if effects_unchanged(l) == Some(false) { raw.retain(|(k, _)| k != b"lmfx"); } + // Regenerate the private preview from its typed, source-bound value. Never retain a + // stale block after source replacement or rasterization. + raw.retain(|(key, _)| key != &crate::vector_preview::PSD_KEY); let mut regenerated: Vec<([u8; 4], Vec)> = Vec::new(); // `keys` is in the importer's priority order (`psd_raw` came from the first key present), // not file order: Photoshop writes `PlLd` before `SoLd`, and overwriting the first match @@ -521,6 +539,9 @@ impl Ex { } } LayerContent::Smart(sm) => { + if let Some(preview) = self.smart.vector_preview(sm) { + raw.push((crate::vector_preview::PSD_KEY, preview.block.clone())); + } let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d)); if sm.psd_raw.is_some() && parsed.is_none() && !self.plld_edited(sm) { // Placed-layer data that isn't `SoLd` (a CS2 `PlLd` record, or one we can't @@ -796,7 +817,8 @@ impl Ex { /// they are; a linked file is read and embedded). fn smart_source(&mut self, sm: &photocraft_doc::SmartObject, template: Option<&crate::smart_map::Placed>) -> Result { use photocraft_doc::SmartSource; - match &sm.source { + let preview = self.smart.vector_preview(sm).and_then(|preview| Some((preview.document.clone(), preview.frame?))); + let mut source = match &sm.source { // A file of the imported document, or one its placed-layer data names (a missing // link stays a smart object, as in Photoshop). SmartSource::Linked { path } if self.smart.known.contains(path) || template.is_some_and(|t| t.idnt == *path) => { @@ -840,7 +862,9 @@ impl Ex { return Err(format!("the linked file {path} is not read for this export")); } let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?; - let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; + // Regular files only, in bounded reads: a link to a FIFO would block the save + // and one to a device such as `/dev/zero` would never end (#8601). + let bytes = photocraft_format::read_file(&file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); let source = self.embed(sm.contents_id, &name, &bytes, true)?; self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)")); @@ -849,8 +873,17 @@ impl Ex { #[cfg(target_arch = "wasm32")] Err(format!("the linked file {path} can't be read here")) } - SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes, false), + SmartSource::Embedded { file_name, bytes } => { + let identity = std::sync::Arc::as_ptr(bytes).addr(); + let hash = self.smart.embedded_hashes.entry(identity).or_insert_with(|| (bytes.clone(), embed_source_hash(bytes))).1; + self.embed_hashed(sm.contents_id, file_name, bytes, false, hash) + } + }?; + if let Some((preview, frame)) = preview { + source.size = frame; + self.smart.docs.insert(source.uuid.clone(), preview); } + Ok(source) } /// The composite of the source `uuid` (an embedded file of this export or of the imported @@ -896,8 +929,14 @@ impl Ex { /// per contents identity and bytes. Equal bytes in independent objects stay independent. /// `linked`: the bytes were read from a path the document names, so only a file PhotoCraft /// can open is embedded (a link to a text or settings file is written as pixels instead). + #[cfg(not(target_arch = "wasm32"))] fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool) -> Result { - let key = (contents_id, *blake3::hash(bytes).as_bytes(), linked); + self.embed_hashed(contents_id, file_name, bytes, linked, embed_source_hash(bytes)) + } + + /// Reuse conversion only for the same contents identity, actual-byte digest and linked-file policy. + fn embed_hashed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8], linked: bool, hash: [u8; 32]) -> Result { + let key = (contents_id, hash, linked); if let Some(r) = self.smart.converted.get(&key) { return r.clone(); } @@ -1033,3 +1072,6 @@ impl Ex { #[cfg(test)] mod tests; + +#[cfg(test)] +pub(crate) use smart::EMBED_HASHED_SOURCE_BYTES; diff --git a/crates/io/src/psd_export/banded.rs b/crates/io/src/psd_export/banded.rs new file mode 100644 index 0000000000..a2aed97473 --- /dev/null +++ b/crates/io/src/psd_export/banded.rs @@ -0,0 +1,152 @@ +//! Layers too large to gather at once, saved band by band (#5333). +//! +//! A layer record is one dense rectangle, gathered interleaved and split into channel planes, +//! so a layer's save held about twice its dense size; past [`BANDED_FROM`] that was refused and the +//! layer saved without its pixels. Now such a layer is gathered, split and encoded a band of +//! rows at a time. RLE rows pack independently (the row-count table is kept and written ahead of +//! the packed rows) and raw rows join, so those channels are the bytes the whole-layer encoder +//! writes. ZIP with prediction (32-bit) predicts per row and feeds one zlib stream: the same +//! pixels, but not always the same bytes, as zlib packs input it is given in parts differently. +//! With a stage each band's parts go to the scratch disk as they are made (see [`Parts`]). +//! +//! A banded layer is minutes of work on a large save, so it checks the save's interrupt before +//! each band and each channel's part of it: once cancelled it stops there (and doesn't retry +//! raw), leaving an incomplete model that the caller discards before the save's commit point. + +use std::io::Write as _; + +use photocraft_psd::compression::{PlaneLayout, encode_planes, predict}; +use photocraft_psd::{ChannelData, Compression, PsdError}; +use photocraft_raster::Surface; + +use super::Ex; +use super::stage::Parts; +use crate::pixels::{deinterleave, psd_depth}; + +/// Dense bytes of one layer (as gathered) from which it is saved band by band. Below it a layer +/// is gathered whole, which is faster: the peak is about twice this. +pub(super) const BANDED_FROM: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 }; +/// Interleaved bytes gathered per band. A band's planes and encoded parts are alive with it, so a +/// banded layer's save holds about three times this. +const BAND_BYTES: usize = 64 << 20; + +#[cfg(test)] +thread_local! { + /// `(BANDED_FROM, BAND_BYTES)` for this thread's exports, so tests band small layers. + pub(crate) static BANDS_FOR_TESTS: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Bands this thread has gathered and encoded. + pub(crate) static BANDS_DONE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(super) fn banded_from() -> u64 { + #[cfg(test)] + if let Some((from, _)) = BANDS_FOR_TESTS.get() { + return from; + } + BANDED_FROM +} + +fn band_bytes() -> usize { + #[cfg(test)] + if let Some((_, bytes)) = BANDS_FOR_TESTS.get() { + return bytes; + } + BAND_BYTES +} + +/// One channel's encoding state across bands. +struct Channel { + id: i16, + table: Vec, + zip: Option>>, + parts: Parts, +} + +impl Ex<'_> { + /// The channels of `s` over `r` (non-empty), alpha first, encoded band by band. + pub(super) fn banded_channels(&self, s: &Surface, r: photocraft_geom::Rect) -> Vec { + // RLE fails only on a row too long for its count (not within PSD/PSB limits); then the + // layer is written raw, as a channel encoded whole falls back to. + match self.banded_pass(s, r, self.compression()) { + Ok(channels) => channels, + // Cancelled: the caller sees the interrupt and discards the whole model, so don't + // encode the layer again. + Err(_) if self.ctl.cancelled() => self.empty_channels(), + Err(_) => self.banded_pass(s, r, Compression::Raw).unwrap_or_else(|_| self.empty_channels()), + } + } + + fn banded_pass(&self, s: &Surface, r: photocraft_geom::Rect, compression: Compression) -> photocraft_psd::Result> { + let (w, h) = (r.width() as usize, r.height() as usize); + let bpp = s.format().bytes_per_pixel(); + let band_rows = (band_bytes() / (w * bpp).max(1)).max(1); + let depth = psd_depth(self.fmt.sample); + let count = if self.version.is_psb() { 4 } else { 2 }; + let head = if compression == Compression::Rle { h.checked_mul(count).ok_or(PsdError::LimitExceeded("row count overflow"))? } else { 0 }; + let mut channels: Vec = (0..=self.cc) + .map(|k| Channel { + id: k as i16 - 1, + table: Vec::with_capacity(head), + zip: (compression == Compression::ZipPrediction).then(|| flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default())), + parts: Parts::new(self.stage.clone(), head), + }) + .collect(); + let mut invert = vec![self.cmyk; self.cc]; + invert.push(false); + let ctl = self.ctl; + let check = || if ctl.cancelled() { Err(PsdError::Invalid("cancelled".into())) } else { Ok(()) }; + for y in (0..h).step_by(band_rows) { + check()?; + let rows = band_rows.min(h - y); + let top = r.y0 + y as i32; + let pieces = crate::pixels::par_map(crate::pixels::bands(rows), |b| { + s.to_interleaved(photocraft_geom::Rect::new(r.x0, top + b.start as i32, r.x1, top + b.end as i32)) + }); + let mut bytes = Vec::with_capacity(w * rows * bpp); + for piece in pieces { + bytes.extend_from_slice(&piece); + } + if self.fmt.mode == photocraft_color::ColorMode::Lab && self.fmt.sample == photocraft_color::SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false); + } + let mut planes: Vec>> = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert).into_iter().map(Some).collect(); + drop(bytes); + let layout = PlaneLayout { planes: 1, width: w, height: rows, depth, version: self.version }; + // Alpha (planes[cc]) is channel 0, then the colour channels; each on its own thread. + let work: Vec<(&mut Channel, Vec)> = + channels.iter_mut().enumerate().map(|(k, c)| (c, planes[if k == 0 { self.cc } else { k - 1 }].take().unwrap_or_default())).collect(); + let done = crate::pixels::par_map(work, |(c, mut plane)| -> photocraft_psd::Result<()> { + check()?; + match compression { + Compression::Rle => { + let mut packed = encode_planes(Compression::Rle, &plane, &layout)?; + let rest = packed.split_off(rows * count); + c.table.extend_from_slice(&packed); + c.parts.push(rest); + } + Compression::ZipPrediction => { + predict(&mut plane, &layout)?; + let zip = c.zip.as_mut().ok_or_else(|| PsdError::Invalid("banded ZIP channel without its stream".into()))?; + zip.write_all(&plane).map_err(|e| PsdError::Invalid(format!("zlib: {e}")))?; + let out = std::mem::take(zip.get_mut()); + c.parts.push(out); + } + _ => c.parts.push(plane), + } + Ok(()) + }); + done.into_iter().collect::>()?; + #[cfg(test)] + BANDS_DONE.set(BANDS_DONE.get() + 1); + } + channels + .into_iter() + .map(|mut c| { + if let Some(zip) = c.zip.take() { + c.parts.push(zip.finish().map_err(|e| PsdError::Invalid(format!("zlib: {e}")))?); + } + c.parts.finish(c.id, compression, &c.table) + }) + .collect() + } +} diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index c9e68fd028..d2728f9f24 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -67,6 +67,7 @@ fn export_supported_document( let big = doc.size.width > 30_000 || doc.size.height > 30_000; let version = psd_version(doc, opts.force_psb); let mut ex = Ex { + ctl: *ctl, fmt, dpi: doc.resolution_dpi, document: opts.document_path.clone(), @@ -88,6 +89,10 @@ fn export_supported_document( smart: SmartOut::new(doc, depth), patterns: doc.patterns.clone(), background: psd_background(doc), + stage: { + let raw = doc.size.area().saturating_mul(fmt.bytes_per_pixel() as u64).saturating_mul(doc.walk().len() as u64 + 1); + super::stage::Stage::for_document(raw, opts.stage_on_scratch_from) + }, }; add_format_warnings(doc, opts, fmt, big, duotone_inks.is_some(), &mut ex.warnings); index_layer_ids_and_text(doc, &mut ex); @@ -167,14 +172,17 @@ fn export_supported_document( }) .collect(); let previous = (!text_layers.is_empty()).then(|| preserved.iter().find(|(_, key, _)| key == b"Txt2").map(|(_, _, data)| data.to_vec())).flatten(); - // A fresh document of ordinary (Metrics) text has nothing to put in `Txt2`, and Photoshop never - // writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the - // block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the - // one mode EngineData can't carry; #1348). + // A fresh document of ordinary (Metrics) text with no Txt2-only OpenType settings has nothing + // to put in `Txt2`, and Photoshop never writes its empty `<< /0 << >> >>` stub — readers such + // as Photopea reject it (#3514). Keep the block when the file had one (round-trip its pen + // positions/extras), a layer is Optical (#1348), or a layer carries stylistic sets / other + // Txt2-only features (#10308). let txt2 = (previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)) .then(|| photocraft_text::psd::build_txt2_at(&text_layers, previous.as_deref(), ex.dpi)); for (sig, key, data) in preserved { - if txt2.is_some() && key == *b"Txt2" { + // With no type layers left the opened file's `Txt2` describes text that is gone (fonts and + // layout caches, often most of the file): Photoshop drops it, and so does the save (#9563). + if key == *b"Txt2" && (txt2.is_some() || text_layers.is_empty()) { continue; } // A kept layer block (one the import could not read, #2700) describes layers the @@ -305,7 +313,7 @@ struct MergedExport<'a> { fn prepare_merged_planes<'a>( doc: &'a Document, - ex: &mut Ex, + ex: &mut Ex<'_>, opts: &PsdExportOptions, ctl: &Interrupt, fmt: PixelFormat, @@ -329,13 +337,17 @@ fn prepare_merged_planes<'a>( data: planes, stored: None, }); + let image_data = match &ex.stage { + Some(stage) => stage.image(image_data), + None => image_data, + }; ctl.progress(1.0); MergedExport { header, image_data, extra, has_alpha, quick_mask_id, real_merged } } fn append_extra_channels<'a>( doc: &'a Document, - ex: &mut Ex, + ex: &mut Ex<'_>, planes: &mut Vec, has_alpha: bool, sample: SampleType, @@ -385,7 +397,7 @@ fn merged_image(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt, fmt: & (planes, has_alpha) } -fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex) { +fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex<'_>) { // Assign layer ids up front (stable across round trips): keep unique // `psd_id`s, number the rest after them; dividers come after all. { diff --git a/crates/io/src/psd_export/merged.rs b/crates/io/src/psd_export/merged.rs index 94b7bc4014..c70965dda9 100644 --- a/crates/io/src/psd_export/merged.rs +++ b/crates/io/src/psd_export/merged.rs @@ -12,6 +12,17 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, ctl) { return planes; } + merged_planes_in_bands(doc, fmt, cmyk, matte, MERGED_BAND_PIXELS, ctl) +} + +/// Pixels per band of the merged composite: an eighth of [`photocraft_compose::BAND_PIXELS`]. The +/// planes it fills hold the whole image already, so a band's float pixels and their converted +/// parts are the save's peak on top of them (#6607); smaller bands cut that and render the same +/// pixels (bands are whole tiles). +const MERGED_BAND_PIXELS: u64 = photocraft_compose::BAND_PIXELS / 8; + +/// [`merged_planes`] (its composite path) rendered in bands of about `band_pixels`. +fn merged_planes_in_bands(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, band_pixels: u64, ctl: &Interrupt) -> (Vec, bool, bool) { let sample = fmt.sample; let cc = fmt.mode.color_channels(); let bps = sample.bytes(); @@ -25,7 +36,8 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte let space = photocraft_compose::cmyk_space(doc); let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16; let rows = canvas.height().max(1) as f32; - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { + let band_rows = i32::try_from((band_pixels / u64::from(canvas.width().max(1))).max(1)).unwrap_or(i32::MAX); + let _ = photocraft_compose::render_bands(doc, canvas, band_rows, |band| -> Result<(), ()> { if ctl.cancelled() { return Err(()); } @@ -270,4 +282,33 @@ mod tests { } } } + + /// #6607: the merged composite is rendered in smaller bands than the default to keep a save's + /// peak down; the planes are the same however tall the bands are, effects that reach across a + /// band's edge (shadows) and translucent pixels included. + #[test] + fn the_merged_image_is_the_same_in_any_band_height() { + use photocraft_doc::{Effect, Shadow}; + let mut doc = Document::new("bands", photocraft_geom::Size::new(300, 1100), ColorMode::Rgb, SampleType::U8); + let fmt = photocraft_color::PixelFormat::RGBA8; + for (i, y) in [240, 500, 760].into_iter().enumerate() { + let mut l = Layer::raster(format!("blob {i}"), fmt); + let (x, f) = (60 * i as i32, i as f32); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(30 + x, y, 150 + x, y + 40), &[0.8, 0.2 * f, 0.1, 0.6 + 0.2 * f]); + let shadow = |distance: f32, size: f32| match Effect::default_drop_shadow() { + Effect::DropShadow(s) => Effect::DropShadow(Shadow { distance, size, angle: 90.0, use_global_light: false, ..s }), + other => other, + }; + l.effects.items = vec![shadow(24.0, 18.0), shadow(9.0, 40.0)]; + doc.layers.push(l); + } + let dfmt = doc.pixel_format(); + let tall = merged_planes_in_bands(&doc, &dfmt, false, true, photocraft_compose::BAND_PIXELS, &Interrupt::NONE); + assert!(photocraft_compose::band_rows_for(300, (photocraft_compose::BAND_PIXELS / 300) as i32) >= 1100, "one band"); + for band_pixels in [1, 300 * 256, 300 * 512, MERGED_BAND_PIXELS] { + let banded = merged_planes_in_bands(&doc, &dfmt, false, true, band_pixels, &Interrupt::NONE); + assert!(banded == tall, "{band_pixels} px bands changed the merged image"); + } + assert!(tall.1 && tall.2, "the test image has translucent pixels"); + } } diff --git a/crates/io/src/psd_export/smart.rs b/crates/io/src/psd_export/smart.rs index 10833c7033..f1ac7431b6 100644 --- a/crates/io/src/psd_export/smart.rs +++ b/crates/io/src/psd_export/smart.rs @@ -16,6 +16,18 @@ pub(super) fn contents_uuid(contents_id: SmartContentsId, bytes: &[u8]) -> Strin crate::smart_map::uuid_from(identity.finalize().as_bytes()) } +type OwnedSourceHash = (std::sync::Arc>, [u8; 32]); + +#[cfg(test)] +thread_local! { pub(crate) static EMBED_HASHED_SOURCE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +/// The actual payload digest used by the source-conversion cache, separate from preview checks. +pub(super) fn embed_source_hash(bytes: &[u8]) -> [u8; 32] { + #[cfg(test)] + EMBED_HASHED_SOURCE_BYTES.with(|count| count.set(count.get().saturating_add(bytes.len()))); + *blake3::hash(bytes).as_bytes() +} + /// Smart-object state collected while emitting layers, written into the global blocks at the end. #[derive(Default)] pub(super) struct SmartOut { @@ -34,6 +46,8 @@ pub(super) struct SmartOut { /// Sources converted so far, by contents identity, bytes and whether they were read from a /// link (`Err` = can't be embedded). pub(super) converted: std::collections::HashMap<(SmartContentsId, [u8; 32], bool), Result>, + /// Only immutable embedded owners are memoized for this export; pinning prevents address reuse. + pub(super) embedded_hashes: std::collections::HashMap, /// Each placed layer has its own id, even when it duplicates an imported layer. pub(super) placed_used: std::collections::HashSet, /// Original instances reserve their imported placement regardless of layer order. @@ -57,6 +71,8 @@ pub(super) struct SmartOut { /// The unfiltered pixels of each smart object whose filter mask shows: its layer record holds /// the filtered pixels without the mask, as Photoshop's do (`compose::filter_mask`). pub(super) unfiltered: std::collections::HashMap, + /// Source-bound previews shared by private blocks and smart-filter source documents. + pub(super) vector_previews: crate::vector_preview::ExportCache, } /// Whether a smart object's filter mask shows: enabled over at least one visible, enabled filter. @@ -109,6 +125,20 @@ impl SmartOut { SmartOut { depth, globals, known, prune_ok: true, old_fx, linked_contents, placed_owners, unplaced_owners, ..Default::default() } } + /// The validated native preview of `sm`, checked against its embedded bytes or the preserved + /// embedded file it links to (never a disk path). + pub(super) fn vector_preview(&mut self, sm: &photocraft_doc::SmartObject) -> Option> { + let preview = sm.vector_preview.as_ref()?; + let (owner, source) = match &sm.source { + photocraft_doc::SmartSource::Embedded { bytes, .. } => (bytes, bytes.as_slice()), + photocraft_doc::SmartSource::Linked { path } => self + .globals + .iter() + .find_map(|(_, key, data)| crate::linked::embedded_payload_in_blocks([(key, data.as_slice())], path).map(|(_, bytes)| (data, bytes)))?, + }; + self.vector_previews.get(preview, owner, source) + } + /// The document's global blocks with the linked-layer files and filter caches brought up to /// date: unreferenced files dropped (when every reference is known), new files appended, /// filter caches kept or regenerated per smart object. Unchanged blocks stay byte-identical. diff --git a/crates/io/src/psd_export/stage.rs b/crates/io/src/psd_export/stage.rs new file mode 100644 index 0000000000..869208e319 --- /dev/null +++ b/crates/io/src/psd_export/stage.rs @@ -0,0 +1,223 @@ +//! Encoded channels parked on the scratch disk while a large PSD/PSB save is assembled (#5333). +//! +//! A save encodes the whole file model before it writes a byte (#8115: it can still be +//! cancelled, and the scratch reads behind it are checked, before the file is replaced). In +//! memory that model is a second, compressed copy of every layer. With +//! [`PsdExportOptions::stage_on_scratch_from`](super::PsdExportOptions::stage_on_scratch_from) +//! set and spilling on, each channel is appended to a [`StageFile`] in the scratch directory as +//! soon as it is encoded, and the model keeps only a [`Stored`] range: the writer then copies it +//! through in short reads (`photocraft_psd`'s `put_encoded`). A channel that cannot be staged +//! (the disk is full, say) stays in memory: staging only ever saves memory. + +use std::sync::Arc; + +use photocraft_psd::source::{ReadAt, Source, Stored}; +use photocraft_psd::{ChannelData, ImageData, PsdError}; +use photocraft_raster::stage_file::StageFile; + +/// Encoded data shorter than this stays in memory: a range costs more than it saves. +const MIN_STAGED_BYTES: usize = 64 << 10; +/// Raw pixel bytes (layers and composite, as sampled) from which a save made by +/// [`crate::export_with`] stages its channels: about a 24 MP RGBA/16 document with five layers. +pub(crate) const STAGE_DOCUMENTS_FROM: u64 = 1 << 30; + +#[cfg(test)] +thread_local! { + /// Where this thread's exports stage, instead of the process-wide scratch directory. + pub(crate) static DIR_FOR_TESTS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +struct Backing(StageFile); + +impl ReadAt for Backing { + fn len(&self) -> u64 { + self.0.len() + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + self.0.read_exact_at(buf, offset).map_err(|e| PsdError::Read(format!("staged export data, {} bytes at offset {offset}: {e}", buf.len()))) + } +} + +/// The stage of one export: a file on the scratch disk shared by every channel. +pub(crate) struct Stage { + file: Arc, + source: Source, +} + +impl Stage { + /// A stage for a document of `raw_bytes` pixel bytes, when `from` asks for one at that size + /// and a stage file can be made in the scratch directory. + pub(crate) fn for_document(raw_bytes: u64, from: Option) -> Option> { + if raw_bytes < from? { + return None; + } + #[cfg(test)] + if let Some(dir) = DIR_FOR_TESTS.with(|d| d.borrow().clone()) { + return StageFile::create(&dir).ok().map(Self::new); + } + StageFile::in_scratch().map(Self::new) + } + + fn new(file: StageFile) -> Arc { + let file = Arc::new(Backing(file)); + let source = Source::from_arc(file.clone()); + Arc::new(Self { file, source }) + } + + /// Moves `data` to the stage; `None` (the data kept by the caller) when it is short or the + /// write fails. + fn park(&self, data: &[u8]) -> Option { + if data.len() < MIN_STAGED_BYTES { + return None; + } + let offset = self.file.0.append(data).ok()?; + Some(Stored { source: self.source.clone(), offset, len: data.len() as u64 }) + } + + pub(crate) fn channel(&self, mut c: ChannelData) -> ChannelData { + if c.stored.is_none() + && let Some(stored) = self.park(&c.data) + { + c.data = Vec::new(); + c.stored = Some(stored); + } + c + } + + pub(crate) fn image(&self, mut image: ImageData) -> ImageData { + if image.stored.is_none() + && let Some(stored) = self.park(&image.data) + { + image.data = Vec::new(); + image.stored = Some(stored); + } + image + } + + /// Appends one part of a channel written band by band; `None` when the write fails. + fn append(&self, data: &[u8]) -> Option { + self.file.0.append(data).ok() + } + + /// Bytes on the scratch disk. + #[cfg(test)] + pub(crate) fn len(&self) -> u64 { + self.file.0.len() + } +} + +/// One channel of a layer encoded band by band (#5333): its head (an RLE row-count table, filled +/// in when the last band is encoded) and the bands' encoded parts in order. With a stage every +/// part goes to the scratch disk as it is made, so a layer of any size is saved holding about one +/// band; without one the parts are joined in memory, as a channel encoded whole would be. +pub(crate) struct Parts { + stage: Option>, + head_len: usize, + /// Without a stage: the head's room and the parts so far. + joined: Vec, + /// With a stage: the parts, staged or (when a write failed) kept. + parts: Vec, + len: u64, +} + +enum Part { + Kept(Vec), + Staged { offset: u64, len: u64 }, +} + +impl Part { + fn len(&self) -> u64 { + match self { + Part::Kept(v) => v.len() as u64, + Part::Staged { len, .. } => *len, + } + } +} + +impl Parts { + pub(crate) fn new(stage: Option>, head_len: usize) -> Self { + let joined = if stage.is_none() { vec![0; head_len] } else { Vec::new() }; + Parts { stage, head_len, joined, parts: Vec::new(), len: head_len as u64 } + } + + /// Adds the next encoded part. + pub(crate) fn push(&mut self, data: Vec) { + if data.is_empty() { + return; + } + self.len += data.len() as u64; + match &self.stage { + None => self.joined.extend_from_slice(&data), + Some(stage) => self.parts.push(match stage.append(&data) { + Some(offset) => Part::Staged { offset, len: data.len() as u64 }, + None => Part::Kept(data), + }), + } + } + + /// The channel: `head` (of the length given to [`Parts::new`]) and then the parts. + pub(crate) fn finish(mut self, id: i16, compression: photocraft_psd::Compression, head: &[u8]) -> photocraft_psd::Result { + if head.len() != self.head_len { + return Err(PsdError::Invalid("banded channel head has the wrong length".into())); + } + let Some(stage) = self.stage else { + self.joined[..head.len()].copy_from_slice(head); + self.joined.shrink_to_fit(); + return Ok(ChannelData::new(id, Some(compression), self.joined)); + }; + let mut parts = Vec::with_capacity(self.parts.len() + 1); + if !head.is_empty() { + parts.push(Part::Kept(head.to_vec())); + } + parts.append(&mut self.parts); + let mut starts = Vec::with_capacity(parts.len()); + let mut at = 0; + for p in &parts { + starts.push(at); + at += p.len(); + } + let joined = Joined { file: stage.file.clone(), parts, starts, len: self.len }; + let stored = Stored { source: Source::from_arc(Arc::new(joined)), offset: 0, len: self.len }; + Ok(ChannelData { id, compression: Some(compression), data: Vec::new(), stored: Some(stored) }) + } +} + +/// A banded channel's bytes, in memory and in its staged parts. A read is split at the parts, so +/// the writer's consecutive pass reads each staged append from its start to its end, and the stage +/// file checks it. +struct Joined { + file: Arc, + parts: Vec, + starts: Vec, + len: u64, +} + +impl ReadAt for Joined { + fn len(&self) -> u64 { + self.len + } + + fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + if offset.checked_add(buf.len() as u64).is_none_or(|end| end > self.len) { + return Err(PsdError::Read(format!("staged export data, {} bytes at offset {offset} past its {} bytes", buf.len(), self.len))); + } + let mut pos = offset; + let mut buf = buf; + let mut i = self.starts.partition_point(|&s| s <= pos).saturating_sub(1); + while !buf.is_empty() { + let (part, start) = (&self.parts[i], self.starts[i]); + let within = pos - start; + let n = usize::try_from((part.len() - within).min(buf.len() as u64)).unwrap_or(buf.len()); + let (now, rest) = std::mem::take(&mut buf).split_at_mut(n); + match part { + Part::Kept(v) => now.copy_from_slice(&v[within as usize..within as usize + n]), + Part::Staged { offset, .. } => self.file.read_at(offset + within, now)?, + } + buf = rest; + pos += n as u64; + i += 1; + } + Ok(()) + } +} diff --git a/crates/io/src/psd_export/tests.rs b/crates/io/src/psd_export/tests.rs index af7c8a2241..fdf0288c9d 100644 --- a/crates/io/src/psd_export/tests.rs +++ b/crates/io/src/psd_export/tests.rs @@ -196,6 +196,21 @@ fn layer_block_follows_a_kept_merged_transparency_marker() { assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); } +/// #9563: a `Txt2` kept from the opened file is dropped once no type layer is left (Photoshop +/// drops it too); with a type layer the block is still written. +#[test] +fn a_kept_txt2_goes_with_the_last_type_layer() { + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(b"<< /0 << /1 (stale) >> >>".to_vec()))]; + let file = document_to_psd(&doc); + assert!(!file.global_blocks.iter().any(|b| b.key == *b"Txt2"), "no type layers: no Txt2"); + + doc.layers.push(Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer::default()))); + let file = document_to_psd(&doc); + assert!(file.global_blocks.iter().any(|b| b.key == *b"Txt2"), "a type layer keeps its Txt2"); +} + /// A merged-transparency marker kept from the opened file describes that file's composite. /// Saved with an opaque composite, it would make the first alpha channel read back as the /// merged transparency, and the channel would be gone: the save drops it. @@ -568,3 +583,262 @@ fn alpha_names_count_the_merged_transparency() { assert_eq!(back.channels[0].name, "Alpha 1"); assert!((back.channels[0].opacity - 0.5).abs() < 1e-6); } + +/// A linked smart object whose path names a FIFO is not read (#8601): opening a FIFO for +/// reading blocks until a writer appears, and a device such as `/dev/zero` never ends, so the +/// export would hang or run out of memory. The export finishes and writes the layer as pixels, +/// saying why. The export runs on its own thread so that a regression fails here instead of +/// hanging the test run. +#[cfg(unix)] +#[test] +fn a_linked_fifo_is_not_read() { + let dir = std::env::temp_dir().join(format!("photocraft-linked-fifo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = dir.join("link.png"); + let _ = std::fs::remove_file(&fifo); + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success(), "mkfifo failed"); + let mut doc = document(8, 6); + let smart = photocraft_doc::SmartObject { + contents_id: SmartContentsId::fresh(), + source: photocraft_doc::SmartSource::Linked { path: fifo.to_string_lossy().into_owned() }, + transform: photocraft_geom::Affine::IDENTITY, + smart_filters: vec![], + cache: Some(Surface::new(doc.pixel_format())), + psd_raw: None, + filters_enabled: true, + filter_mask: None, + warp: None, + stack_mode: None, + perspective: None, + vector_preview: None, + }; + doc.layers.push(Layer::new("Linked", LayerContent::Smart(smart))); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(document_to_psd_with(&doc, &PsdExportOptions::default()).1)); + let warnings = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the export blocked reading the FIFO"); + let _ = std::fs::remove_dir_all(&dir); + assert!(warnings.iter().any(|w| w.contains("smart object written as pixels") && w.contains("not a regular file")), "{warnings:?}"); +} + +/// #5333: with staging on, every large encoded channel and the merged image are parked on the +/// scratch disk as they are encoded, and the file written from that model is the same, byte for +/// byte, as one assembled in memory: as PSD, as PSB, and as a PSD promoted to PSB on the way out. +#[test] +fn staged_channels_write_the_same_file_as_in_memory_ones() { + let dir = std::env::temp_dir().join(format!("pc-psd-stage-{}", std::process::id())); + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = Some(dir.clone())); + let doc = sized_document(true, SampleType::U8); + let ctl = Interrupt::NONE; + for (force_psb, limit) in [(false, PSD_MAX_FILE_SIZE), (true, PSD_MAX_FILE_SIZE), (false, SIZED_LIMIT)] { + let plain = PsdExportOptions { force_psb, ..Default::default() }; + let staged = PsdExportOptions { stage_on_scratch_from: Some(0), ..plain.clone() }; + let (want, _) = document_to_psd_sized(&doc, &plain, 0, &ctl, limit); + let (file, _) = document_to_psd_sized(&doc, &staged, 0, &ctl, limit); + let layer = &file.layers()[0]; + // The opaque alpha packs to a few KB and stays in memory; the noise colour channels go. + assert!(layer.channels.iter().filter(|c| c.id >= 0).all(|c| c.stored.is_some() && c.data.is_empty()), "every noise channel is staged"); + assert!(layer.channels.iter().filter(|c| c.id == -1).all(|c| c.stored.is_none()), "the short alpha stays in memory"); + assert!(file.image_data.stored.is_some() && file.image_data.data.is_empty(), "the merged image is staged"); + assert_eq!(file.write_as_psb, want.write_as_psb); + let bytes = want.to_bytes().unwrap(); + assert!(file.to_bytes().unwrap() == bytes, "psb {force_psb}, limit {limit}: staged bytes differ"); + let mut w = std::io::Cursor::new(Vec::new()); + file.write_to(&mut w).unwrap(); + assert!(w.into_inner() == bytes, "psb {force_psb}, limit {limit}: streamed staged bytes differ"); + } + // Under the threshold, or with no stage asked for, nothing is staged. + let (file, _) = document_to_psd_sized(&doc, &PsdExportOptions { stage_on_scratch_from: Some(u64::MAX), ..Default::default() }, 0, &ctl, PSD_MAX_FILE_SIZE); + assert!(file.layers()[0].channels.iter().all(|c| c.stored.is_none()) && file.image_data.stored.is_none()); + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None); + #[cfg(unix)] + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the stage file has no name on disk"); + let _ = std::fs::remove_dir(&dir); +} + +/// Short channels stay in memory, and a stage that cannot be written keeps the data in memory. +#[test] +fn staging_keeps_short_or_unwritable_data_in_memory() { + let dir = std::env::temp_dir().join(format!("pc-psd-stage-short-{}", std::process::id())); + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = Some(dir.clone())); + let stage = stage::Stage::for_document(1, Some(0)).unwrap(); + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None); + let short = stage.channel(ChannelData::new(0, Some(Compression::Raw), vec![1; 100])); + assert!(short.stored.is_none() && short.data.len() == 100); + let long = stage.channel(ChannelData::new(0, Some(Compression::Raw), vec![2; 1 << 17])); + assert!(long.stored.is_some() && long.data.is_empty()); + assert_eq!(long.encoded().unwrap().as_ref(), &vec![2u8; 1 << 17][..]); + assert_eq!(stage.len(), 1 << 17); + assert!(stage::Stage::for_document(10, Some(11)).is_none(), "under the threshold"); + assert!(stage::Stage::for_document(10, None).is_none(), "not asked for"); + let _ = std::fs::remove_dir(&dir); +} + +/// The first layer's channels, decoded. +fn decoded_channels(file: &PsdFile) -> Vec> { + let layer = &file.layers()[0]; + let (w, h) = (layer.rect.width() as usize, layer.rect.height() as usize); + layer.channels.iter().map(|c| c.decode(w, h, file.header.depth, file.header.version).unwrap()).collect() +} + +/// A noisy, translucent layer inset from the canvas: `w`×`h` px in `mode`/`depth`. +fn banding_document(mode: ColorMode, depth: SampleType, w: u32, h: u32) -> Document { + let mut doc = Document::new("banded", photocraft_geom::Size::new(w, h), mode, depth); + let mut layer = Layer::raster("Layer 1", doc.pixel_format()); + let rect = photocraft_geom::Rect::new(5, 3, w as i32 - 7, h as i32 - 2); + let surface = layer.surface_mut().unwrap(); + let ch = surface.channels(); + let mut state = 0x2545_f491_u32; + let n = rect.width() as usize * rect.height() as usize * ch; + let data: Vec = (0..n) + .map(|i| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + // Runs as well as noise, so RLE packs both kinds of row. + let v = if (i / (ch * 40)).is_multiple_of(3) { 0.5 } else { (state >> 20) as f32 / 4095.0 }; + if i % ch == ch - 1 { v.max(0.25) } else { v } + }) + .collect(); + surface.write_region(rect, &data); + doc.layers.push(layer); + doc +} + +/// Exports `doc` with layers past `banded` bytes saved in bands of `rows` rows. +fn export_banded(doc: &Document, options: &PsdExportOptions, banded: Option) -> (PsdFile, Vec) { + let row_bytes = doc.size.width as usize * doc.pixel_format().bytes_per_pixel(); + banded::BANDS_FOR_TESTS.set(banded.map(|rows| (0, rows * row_bytes + 3))); + let out = document_to_psd_sized(doc, options, 0, &Interrupt::NONE, PSD_MAX_FILE_SIZE); + banded::BANDS_FOR_TESTS.set(None); + out +} + +/// #5333: a layer saved band by band (here every layer, in bands of a few rows) writes the bytes +/// of one gathered whole (ZIP with prediction, 32-bit, the same pixels); RGB, CMYK and Lab; PSD, PSB and a PSD +/// promoted to PSB; in memory and staged on the scratch disk (then also streamed). +#[test] +fn banded_layers_write_the_bytes_of_whole_ones() { + let dir = std::env::temp_dir().join(format!("pc-psd-banded-{}", std::process::id())); + let cases = [ + (ColorMode::Rgb, SampleType::U8), + (ColorMode::Rgb, SampleType::U16), + (ColorMode::Rgb, SampleType::F32), + (ColorMode::Cmyk, SampleType::U8), + (ColorMode::Lab, SampleType::U16), + ]; + for (mode, depth) in cases { + let doc = banding_document(mode, depth, 203, 157); + for force_psb in [false, true] { + for staged in [false, true] { + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone())); + let options = PsdExportOptions { force_psb, stage_on_scratch_from: staged.then_some(0), ..Default::default() }; + let (want, want_warnings) = export_banded(&doc, &options, None); + for rows in [1, 7, 1000] { + let what = format!("{mode:?} {depth:?}, psb {force_psb}, staged {staged}, {rows} rows a band"); + let (file, warnings) = export_banded(&doc, &options, Some(rows)); + assert_eq!(warnings, want_warnings, "{what}"); + let layer = &file.layers()[0]; + assert_eq!(layer.rect, want.layers()[0].rect, "{what}"); + if staged { + assert!(layer.channels.iter().all(|c| c.stored.is_some() && c.data.is_empty()), "{what}: banded channels are staged"); + } + if depth == SampleType::F32 { + // One zlib stream fed band by band packs differently from one fed whole + // (the encoder sees a different amount of input ahead), so the ZIP + // channels are compared decoded, here and as the streamed file reads back. + let mut w = std::io::Cursor::new(Vec::new()); + file.write_to(&mut w).unwrap(); + let back = PsdFile::from_bytes(&w.into_inner()).unwrap(); + assert_eq!(decoded_channels(&back), decoded_channels(&want), "{what}: banded ZIP channels differ"); + assert!(back.image_data.encoded().unwrap() == want.image_data.encoded().unwrap(), "{what}"); + continue; + } + let bytes = want.to_bytes().unwrap(); + assert!(file.to_bytes().unwrap() == bytes, "{what}: banded bytes differ"); + let mut w = std::io::Cursor::new(Vec::new()); + file.write_to(&mut w).unwrap(); + assert!(w.into_inner() == bytes, "{what}: streamed banded bytes differ"); + if !force_psb { + let (mut promoted, mut whole) = (file.clone(), want.clone()); + promoted.promote_to_psb().unwrap(); + whole.promote_to_psb().unwrap(); + let mut w = std::io::Cursor::new(Vec::new()); + promoted.write_to(&mut w).unwrap(); + assert!(w.into_inner() == whole.to_bytes().unwrap(), "{what}: promoted banded bytes differ"); + } + } + } + } + } + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #5333: a layer past the gather size is saved with its pixels (it used to be saved empty, with a +/// warning), and they come back on import. +#[test] +fn banded_layers_round_trip() { + let dir = std::env::temp_dir().join(format!("pc-psd-banded-rt-{}", std::process::id())); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = banding_document(ColorMode::Rgb, depth, 181, 133); + for staged in [false, true] { + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone())); + let options = PsdExportOptions { stage_on_scratch_from: staged.then_some(0), ..Default::default() }; + let (file, warnings) = export_banded(&doc, &options, Some(5)); + assert!(warnings.is_empty(), "{warnings:?}"); + let mut w = std::io::Cursor::new(Vec::new()); + file.write_to(&mut w).unwrap(); + let back = PsdFile::from_bytes(&w.into_inner()).unwrap(); + let (read, _) = crate::psd_import::psd_to_document(&back); + let full = photocraft_geom::Rect::new(0, 0, doc.size.width as i32, doc.size.height as i32); + let want = doc.layers[0].surface().unwrap().read_region(full); + let got = read.layers[0].surface().unwrap().read_region(full); + assert!(got == want, "{depth:?}, staged {staged}: pixels differ after the round trip"); + } + } + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #5333: a save cancelled while a layer is saved in bands stops at the next band (it neither +/// finishes the layer nor encodes it again raw) and reports the cancel, so it never reaches its +/// commit point and the target is never written: RLE and ZIP, PSD and PSB, in memory and staged. +#[test] +fn a_cancelled_save_stops_between_the_bands_of_a_layer() { + let dir = std::env::temp_dir().join(format!("pc-psd-banded-cancel-{}", std::process::id())); + let this = std::thread::current().id(); + // Cancelled once two bands are done (they are counted on this thread). + let after_two = || std::thread::current().id() == this && banded::BANDS_DONE.get() >= 2; + let ctl = Interrupt::cancel_only(&after_two); + for depth in [SampleType::U8, SampleType::F32] { + let doc = banding_document(ColorMode::Rgb, depth, 203, 157); + let row_bytes = doc.size.width as usize * doc.pixel_format().bytes_per_pixel(); + let bands = |ctl: &Interrupt, run: &dyn Fn(&Interrupt)| { + banded::BANDS_DONE.set(0); + banded::BANDS_FOR_TESTS.set(Some((0, row_bytes + 3))); + run(ctl); + banded::BANDS_FOR_TESTS.set(None); + banded::BANDS_DONE.get() + }; + for force_psb in [false, true] { + for staged in [false, true] { + let what = format!("{depth:?}, psb {force_psb}, staged {staged}"); + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone())); + let options = PsdExportOptions { force_psb, stage_on_scratch_from: staged.then_some(0), ..Default::default() }; + let export = |ctl: &Interrupt| drop(document_to_psd_sized(&doc, &options, 0, ctl, PSD_MAX_FILE_SIZE)); + let rows = doc.layers[0].surface().unwrap().content_bounds().height() as usize; + assert_eq!(bands(&Interrupt::NONE, &export), rows, "{what}: one band a row"); + assert_eq!(bands(&ctl, &export), 2, "{what}: stops at the band after the cancel"); + } + } + stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None); + // The save sees the cancel, so it returns before its commit point and writes nothing. + let name = format!("banded-cancel-{depth:?}.psd"); + let save = |ctl: &Interrupt| { + let r = crate::encode_with(&doc, &name, &crate::ExportOptions::default(), ctl); + assert!(matches!(r, Err(crate::IoError::Cancelled)), "{depth:?}: {:?}", r.err()); + }; + assert_eq!(bands(&ctl, &save), 2, "{depth:?}: the save stops at the band after the cancel"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs new file mode 100644 index 0000000000..d661166018 --- /dev/null +++ b/crates/io/src/psd_gate.rs @@ -0,0 +1,265 @@ +//! Process-wide serialization gate for PSD/PSB pixel decoding. +//! +//! Prevents concurrent PSD imports from spending the same headroom at the same time, +//! using cancellation-aware waiting outside the UI thread. Reentrant/nested decodes on +//! the same thread are admitted without deadlocking and share the enclosing attempt's +//! budget. Approval prompts never hold this lock. +//! +//! Nested callers check cancellation and take the reentrant guard before calling +//! `consume_attempt_allowance`, so a cancelled or refused nested import never spends its +//! parent's remaining allowance; a failed charge deducts nothing. + +use std::cell::Cell; +use std::marker::PhantomData; +use std::sync::{Condvar, Mutex, OnceLock}; +use std::thread::ThreadId; +#[cfg(any(test, not(target_arch = "wasm32")))] +use std::time::Duration; + +use crate::IoError; + +struct GateState { + active: bool, + owner: Option, +} + +static GATE: OnceLock<(Mutex, Condvar)> = OnceLock::new(); + +/// Returns the process-wide decode gate, creating its mutex and condition variable on first use. +fn get_gate() -> &'static (Mutex, Condvar) { + GATE.get_or_init(|| (Mutex::new(GateState { active: false, owner: None }), Condvar::new())) +} + +thread_local! { + static NESTED_DEPTH: Cell = const { Cell::new(0) }; + static ATTEMPT_ALLOWANCE: Cell> = const { Cell::new(None) }; +} + +/// RAII guard releasing decode admission when dropped. +/// Non-Send and non-Sync to prevent cross-thread leakage or premature release. +pub(crate) struct AdmissionGuard { + _marker: PhantomData<*const ()>, +} + +impl Drop for AdmissionGuard { + fn drop(&mut self) { + let depth = NESTED_DEPTH.with(|d| { + let val = d.get().saturating_sub(1); + d.set(val); + val + }); + if depth == 0 { + ATTEMPT_ALLOWANCE.with(|a| a.set(None)); + let (mutex, cvar) = get_gate(); + let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + state.active = false; + state.owner = None; + cvar.notify_one(); + } + } +} + +/// Returns whether the current thread currently holds decode admission. +pub(crate) fn is_holding_admission() -> bool { + NESTED_DEPTH.with(|d| d.get() > 0) +} + +/// Sets the remaining memory allowance for the active import attempt on the current thread. +pub(crate) fn set_active_attempt_budget(allowance: u64) { + ATTEMPT_ALLOWANCE.with(|a| a.set(Some(allowance))); +} + +/// Consumes `bytes` from the enclosing attempt's remaining allowance, or returns +/// [`IoError::PsdImportBudget`] if the required amount exceeds what remains. +/// +/// `u64::MAX` is a format estimate's saturated overflow sentinel, not a requirement: it is +/// refused even when the remaining allowance is also `u64::MAX` (the native unknown-memory +/// attempt cap), and a refusal never spends the parent's allowance. +pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> { + ATTEMPT_ALLOWANCE.with(|a| match a.get() { + Some(remaining) => { + if bytes == u64::MAX || bytes > remaining { + return Err(IoError::PsdImportBudget { required: bytes, limit: remaining }); + } + a.set(Some(remaining - bytes)); + Ok(()) + } + None => { + // Unknown allowance during reentrant gate must fail closed. + Err(IoError::PsdImportBudget { required: bytes, limit: 0 }) + } + }) +} + +/// The enclosing attempt's remaining allowance, for tests that assert a nested import was +/// charged instead of resetting the parent attempt's budget. +#[cfg(test)] +pub(crate) fn active_attempt_remaining() -> Option { + ATTEMPT_ALLOWANCE.with(|a| a.get()) +} + +/// Acquires process-wide admission to decode PSD pixels, or awaits completion of an active decode. +/// +/// If the current thread already holds admission (a nested/reentrant decode), it is admitted +/// immediately without reacquiring the mutex or waiting on the condvar. Otherwise, it polls +/// `ctl.check()` every 50 ms while waiting so cancellation halts the wait immediately. +pub(crate) fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result { + let current_thread = std::thread::current().id(); + let (mutex, _cvar) = get_gate(); + let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + + if state.active && state.owner == Some(current_thread) { + ctl.check().map_err(|_| IoError::Cancelled)?; + NESTED_DEPTH.with(|d| d.set(d.get().saturating_add(1))); + return Ok(AdmissionGuard { _marker: PhantomData }); + } + + #[cfg(target_arch = "wasm32")] + if state.active { + // Stateless wasm is single-threaded: contention cannot be resolved by waiting on Condvar. + return Err(IoError::Unsupported("concurrent PSD decoding is not supported on wasm".into())); + } + + #[cfg(not(target_arch = "wasm32"))] + while state.active { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (next_state, _) = _cvar.wait_timeout(state, Duration::from_millis(50)).unwrap_or_else(std::sync::PoisonError::into_inner); + state = next_state; + } + + ctl.check().map_err(|_| IoError::Cancelled)?; + state.active = true; + state.owner = Some(current_thread); + NESTED_DEPTH.with(|d| d.set(1)); + + Ok(AdmissionGuard { _marker: PhantomData }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn reentrant_admission_does_not_deadlock() { + let ctl = photocraft_raster::Interrupt::NONE; + let guard1 = acquire_decode_admission(&ctl).unwrap(); + assert!(is_holding_admission()); + + // Nested admission on the same thread + let guard2 = acquire_decode_admission(&ctl).unwrap(); + assert!(is_holding_admission()); + + drop(guard2); + assert!(is_holding_admission()); + drop(guard1); + assert!(!is_holding_admission()); + + // Can acquire again after release + let _guard3 = acquire_decode_admission(&ctl).unwrap(); + assert!(is_holding_admission()); + } + + #[test] + fn unknown_allowance_during_reentrant_gate_fails_closed() { + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = acquire_decode_admission(&ctl).unwrap(); + // Allowance is None (unknown) + let res = consume_attempt_allowance(100); + assert!(matches!(res, Err(IoError::PsdImportBudget { limit: 0, .. }))); + } + + #[test] + fn nested_allowance_consumption_deducts_and_caps() { + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = acquire_decode_admission(&ctl).unwrap(); + set_active_attempt_budget(5000); + + assert!(consume_attempt_allowance(2000).is_ok()); + assert_eq!(ATTEMPT_ALLOWANCE.with(|a| a.get()), Some(3000)); + + assert!(consume_attempt_allowance(3001).is_err()); + assert!(consume_attempt_allowance(1000).is_ok()); + assert_eq!(ATTEMPT_ALLOWANCE.with(|a| a.get()), Some(2000)); + } + + /// A saturated estimate is not a charge, even when the parent's allowance is the same + /// saturated value: it is refused and spends nothing. + #[test] + fn a_saturated_nested_charge_is_refused_and_spends_nothing() { + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = acquire_decode_admission(&ctl).unwrap(); + set_active_attempt_budget(u64::MAX); + + let err = consume_attempt_allowance(u64::MAX).expect_err("the overflow sentinel is never a charge"); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == u64::MAX && limit == u64::MAX)); + assert_eq!(active_attempt_remaining(), Some(u64::MAX), "a refused sentinel charge leaves the parent's allowance untouched"); + + // One byte below the sentinel is an ordinary, enormous charge. + assert!(consume_attempt_allowance(u64::MAX - 1).is_ok()); + assert_eq!(active_attempt_remaining(), Some(1)); + } + + #[test] + fn gate_serializes_concurrent_threads() { + let ctl = photocraft_raster::Interrupt::NONE; + let guard = acquire_decode_admission(&ctl).unwrap(); + + let thread_started = Arc::new(AtomicBool::new(false)); + let thread_acquired = Arc::new(AtomicBool::new(false)); + + let t_started = Arc::clone(&thread_started); + let t_acquired = Arc::clone(&thread_acquired); + let handle = std::thread::spawn(move || { + let ctl = photocraft_raster::Interrupt::NONE; + t_started.store(true, Ordering::SeqCst); + let g = acquire_decode_admission(&ctl).unwrap(); + t_acquired.store(true, Ordering::SeqCst); + drop(g); + }); + + while !thread_started.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + // Give background thread time to reach condvar wait + std::thread::sleep(Duration::from_millis(60)); + assert!(!thread_acquired.load(Ordering::SeqCst), "second thread must block while guard held"); + + drop(guard); + handle.join().unwrap(); + assert!(thread_acquired.load(Ordering::SeqCst), "second thread must acquire after release"); + } + + #[test] + fn blocked_wait_aborts_on_cancellation() { + let ctl = photocraft_raster::Interrupt::NONE; + let guard = acquire_decode_admission(&ctl).unwrap(); + + let cancelled = Arc::new(AtomicBool::new(false)); + let c_flag = Arc::clone(&cancelled); + + let flag_for_thread = Arc::clone(&cancelled); + let handle = std::thread::spawn(move || { + let cancelled = || flag_for_thread.load(Ordering::SeqCst); + let progress = |_: f32| {}; + let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress); + acquire_decode_admission(&interrupt).map(drop) + }); + + // Let thread block on condvar + std::thread::sleep(Duration::from_millis(60)); + // Cancel waiting thread + c_flag.store(true, Ordering::SeqCst); + + let res = handle.join().unwrap(); + assert!(matches!(res, Err(IoError::Cancelled)), "waiting thread must abort with Cancelled"); + + // First thread releases guard + drop(guard); + + // Subsequent acquire succeeds cleanly + let next_guard = acquire_decode_admission(&ctl); + assert!(next_guard.is_ok(), "gate must remain functional after cancelled wait"); + } +} diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 633d7f19c2..12aabf6e8d 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -16,7 +16,7 @@ use photocraft_raster::Surface; use crate::adjust_map::{self, ADJUSTMENT_KEYS}; use crate::blocks; -use crate::pixels::{interleave, max_sample, sample_for_depth, zero_sample}; +use crate::pixels::{max_sample, sample_for_depth, try_interleave, zero_sample}; /// Resources that are mapped to document fields or regenerated on export. pub(crate) const MAPPED_RESOURCES: [u16; 19] = [ @@ -56,8 +56,8 @@ pub(crate) struct Ctx<'a> { pub warnings: Vec, /// Decoding encountered damaged PSD data; interactive opens ask whether to continue. pub damaged: bool, - /// Failure to read source channel data, which must fail the open. - pub read_error: Option, + /// Source reads and pixel allocation failures must fail the open. + pub fatal_error: Option, /// Document resolution (type sizes are converted to points with it). pub dpi: f32, /// The parsed `Txt2` block (type settings EngineData lacks, e.g. optical kerning). @@ -75,6 +75,8 @@ pub(crate) struct Ctx<'a> { pub smart_contents: std::collections::HashMap, /// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file). pub plld_sizes: std::collections::HashMap>, + /// Shared vector previews are hashed/parsed once, under a per-file work budget. + pub vector_previews: crate::vector_preview::ImportCache, /// Cancellation and progress for background opens (checked per layer record). pub ctl: photocraft_raster::Interrupt<'a>, /// Layer records decoded so far, out of `total` (progress). @@ -94,6 +96,12 @@ const BAND_BYTES: usize = 32 << 20; fn band_rows(top: i32, h: usize, row_bytes: usize) -> Vec<(usize, usize)> { let tile = TILE_SIZE as usize; let rows = (BAND_BYTES / row_bytes.max(1)).max(tile) / tile * tile; + row_bands(top, h, rows) +} + +// `rows` is a positive multiple of TILE_SIZE. Mask decoding uses one tile row at a +// time so a narrow but tall mask never needs a whole-plane conversion buffer. +fn row_bands(top: i32, h: usize, rows: usize) -> Vec<(usize, usize)> { // The first band ends at the first tile boundary at least `rows` rows in. let into_tile = usize::try_from(top.rem_euclid(TILE_SIZE)).unwrap_or(0); let mut out = Vec::new(); @@ -188,6 +196,12 @@ struct Placement { } impl Ctx<'_> { + fn allocation_failed(&mut self, error: impl std::fmt::Display) { + let message = format!("PSD pixel allocation failed: {error}"); + self.warn(message.clone()); + self.fatal_error.get_or_insert(crate::IoError::OutOfMemory(message)); + } + fn warn(&mut self, s: impl Into) { self.warnings.push(s.into()); } @@ -203,7 +217,11 @@ impl Ctx<'_> { /// (`true`); damaged data is warned about, its layer left empty (`false`). fn channel_failed(&mut self, name: &str, id: i16, e: &photocraft_psd::PsdError) -> bool { if let photocraft_psd::PsdError::Read(msg) = e { - self.read_error.get_or_insert_with(|| format!("layer \"{name}\": {msg}")); + self.fatal_error.get_or_insert_with(|| crate::IoError::Read(format!("layer \"{name}\": {msg}"))); + return true; + } + if let photocraft_psd::PsdError::OutOfMemory(_) = e { + self.allocation_failed(format!("layer \"{name}\" channel {id}: {e}")); return true; } self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty")); @@ -240,27 +258,12 @@ impl Ctx<'_> { Placement { id, transform, perspective, warp } } - /// Decodes a PSD layer channel, marking the document damaged if its data is unreadable. - fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option> { - rec.channel(id)?; - if self.read_error.is_some() { - return None; - } - match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { - Ok(p) => Some(p), - Err(e) => { - self.channel_failed(name, id, &e); - None - } - } - } - /// Builds a raster surface from the layer channels, leaving the surface empty if /// any present channel fails to decode rather than inventing replacement pixels. fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { let r = rec.rect; // Once the file couldn't be read the open fails: nothing more is decoded. - if r.is_empty() || r.size().is_err() || self.read_error.is_some() { + if r.is_empty() || r.size().is_err() || self.fatal_error.is_some() { return Surface::new(self.fmt); } let (w, h) = r.size().unwrap_or((0, 0)); @@ -311,11 +314,26 @@ impl Ctx<'_> { None => refs.push(None), } } - let mut bytes = interleave(&refs, &fill, w * (b1 - b0), s, &invert); + let mut bytes = match try_interleave(&refs, &fill, w * (b1 - b0), s, &invert) { + Ok(bytes) => bytes, + Err(error) => { + self.allocation_failed(format!("layer \"{name}\": {error}")); + return Surface::new(self.fmt); + } + }; if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 { crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true); } - surf.write_interleaved(Rect::new(r.left, r.top + b0 as i32, r.right, r.top + b1 as i32), &bytes); + if let Err(error) = surf.try_write_interleaved(Rect::new(r.left, r.top + b0 as i32, r.right, r.top + b1 as i32), &bytes) { + self.allocation_failed(format!("layer \"{name}\": {error}")); + return Surface::new(self.fmt); + } + // The interleaved band goes back to the scratch pool for the next band (and the next + // import): it is a fresh page-faulted mmap otherwise. The decoded planes don't: the + // decoder allocates its own, so pooled ones would only sit there. + drop(refs); + photocraft_raster::scratch::give(bytes); + drop(planes); } surf.prune(); // A white background, a solid fill's cache: one tile per value, shared across layers. @@ -336,14 +354,42 @@ impl Ctx<'_> { }; let mut surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]); if !rect.is_empty() + && self.fatal_error.is_none() && let Ok((w, h)) = rect.size() - && let Some(plane) = self.channel_plane(rec, id, name) { - let s = self.mask_fmt.sample; - let bytes = interleave(&[Some(&plane)], &[zero_sample(s)], w * h, s, &[false]); - surface.write_interleaved(Rect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes); - surface.prune(); - surface.share_uniform_tiles(&mut self.uniform); + let decoder = rec.channel(id)?.row_decoder(w, h, self.file.header.depth, self.file.header.version); + match decoder { + Err(error) => { + self.channel_failed(name, id, &error); + } + Ok(decoder) => { + for (b0, b1) in row_bands(rect.top, h, TILE_SIZE as usize) { + let mut bytes = match decoder.rows(b0, b1) { + Ok(bytes) => bytes, + Err(error) => { + self.channel_failed(name, id, &error); + // Discard bands already written: damaged mask data must not + // leave a plausible but incomplete mask on the layer. + surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]); + break; + } + }; + // A mask is one channel: convert its big-endian samples in place. + // Preserve float bits, including NaN payloads, without a float round trip. + #[cfg(target_endian = "little")] + for sample in bytes.chunks_exact_mut(self.mask_fmt.sample.bytes()) { + sample.reverse(); + } + let band = Rect::new(rect.left, rect.top + b0 as i32, rect.right, rect.top + b1 as i32); + if let Err(error) = surface.try_write_interleaved(band, &bytes) { + self.allocation_failed(format!("layer \"{name}\" mask: {error}")); + return None; + } + } + surface.prune(); + surface.share_uniform_tiles(&mut self.uniform); + } + } } let params = m.parameters; Some(LayerMask { @@ -498,7 +544,7 @@ impl Ctx<'_> { 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(); + let (text, transform) = blocks::parse_tysh(&block.data)?; photocraft_text::psd::transform_is_safe(&transform).then(|| TextLayer { text, transform, ..Default::default() }) }) }) @@ -547,7 +593,8 @@ impl Ctx<'_> { // 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)) { + if self.fatal_error.is_none() && (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)); } @@ -645,9 +692,17 @@ impl Ctx<'_> { } _ => stored, }; + // The global blocks' data lives in `self.global` (an owned open moves it out of the file). + let vector_preview = rec.block(&crate::vector_preview::PSD_KEY).and_then(|block| { + let global = &self.global; + self.vector_previews.decode(&id, &block.data, || { + crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source) + }) + }); LayerContent::Smart(SmartObject { contents_id, source: SmartSource::Linked { path: id }, + vector_preview, transform, smart_filters: stack.filters, cache: Some(cache), @@ -806,8 +861,8 @@ 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() } -fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { - interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) +fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> photocraft_psd::Result> { + crate::pixels::try_interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } pub(crate) fn unicode_names(data: &[u8]) -> Vec { @@ -844,7 +899,7 @@ fn flat_composite( match file.composite_rgba8() { Ok(img) => return Ok(img), // Over the output budget: the fallback below would make the same allocation. - Err(e @ PsdError::LimitExceeded(_)) => return Err(e), + Err(e @ (PsdError::LimitExceeded(_) | PsdError::OutOfMemory(_))) => return Err(e), Err(_) => {} } let all = match merged { @@ -861,7 +916,7 @@ fn flat_composite( .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?; let mut data = Vec::new(); - data.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?; + data.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for RGBA output"))?; data.resize(len, 255u8); for c in 0..3.min(usize::from(h.channels)) { let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?; @@ -875,22 +930,24 @@ fn flat_composite( /// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size /// float copy of the image). -fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) { +fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) -> Result<(), String> { let fmt = s.format(); let row = (canvas.width() as usize).saturating_mul(4); - let mut vals = Vec::new(); + let mut vals: Vec = Vec::new(); let mut y = canvas.y0; while y < canvas.y1 { let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1)); let start = ((y - canvas.y0) as usize).saturating_mul(row); let Some(rows) = data.get(start..start.saturating_add((band.height() as usize).saturating_mul(row))) else { break }; vals.clear(); + vals.try_reserve(rows.len().saturating_mul(fmt.channels()) / 4).map_err(|e| e.to_string())?; for p in rows.as_chunks::<4>().0 { vals.extend(photocraft_raster::from_rgba(&fmt, p.map(|x| f32::from(x) / 255.0))); } - s.write_region(band, &vals); + s.try_write_region(band, &vals).map_err(|e| e.to_string())?; y = band.y1; } + Ok(()) } pub(crate) fn parse_guides(data: &[u8], doc: &mut Document) { @@ -971,7 +1028,7 @@ pub(crate) fn psd_to_document_owned( mut file: PsdFile, ctl: &photocraft_raster::Interrupt, decoded: Option>>, -) -> Option<(Document, Vec, bool, Option)> { +) -> Option<(Document, Vec, bool, Option)> { let global = file .global_blocks .iter_mut() @@ -981,7 +1038,7 @@ pub(crate) fn psd_to_document_owned( (b.signature, b.key, Arc::new(data)) }) .collect(); - psd_to_document_global(&file, ctl, decoded, global) + psd_to_document_global(&file, ctl, decoded, global, true) } /// Whether `file` stores spot planes the way earlier PhotoCraft versions (releases 0.1.0 to @@ -1058,12 +1115,23 @@ pub(crate) fn merged_needed(file: &PsdFile) -> bool { } /// A damaged merged preview need not prevent opening independently stored layers (#6152). -/// Other modes are imported flattened, so their merged image cannot be discarded. +/// Other modes are imported flattened, so their merged image cannot be discarded. This is also +/// the decision the memory budget makes about layer pixels, so it follows [`import_format`] +/// (an unknown mode imports as RGB) rather than listing modes a second time. pub(crate) fn can_recover_layers(file: &PsdFile) -> bool { - matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty() + import_format(file).1 && !file.layers().is_empty() } -fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Document, layered: bool, multichannel: bool, warnings: &mut Vec) -> bool { +/// Reads the document-level metadata; the second value is the allocation failure that stopped a +/// pattern from being read, which fails the open. +fn read_initial_metadata( + file: &PsdFile, + global: &[PsdGlobalBlock], + doc: &mut Document, + layered: bool, + multichannel: bool, + warnings: &mut Vec, +) -> (bool, Option) { let header = &file.header; for resource in &file.resources { match resource.id { @@ -1121,7 +1189,12 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do block.key_str() )); } - doc.patterns = crate::pattern_map::from_global_blocks(doc); + let mut alloc_error = None; + match crate::pattern_map::try_from_global_blocks(doc) { + Ok(patterns) => doc.patterns = patterns, + Err(error) => alloc_error = Some(format!("patterns: {error}")), + } + warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns)); doc.notes = crate::annotations_map::notes_from_blocks(doc); if crate::annotations_map::anno_unreadable(doc) { warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into()); @@ -1130,7 +1203,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do doc.measurement.scale = scale; } let _ = layered; - damaged + (damaged, alloc_error) } /// [`psd_to_document_with`] with the merged image already decoded (by @@ -1152,7 +1225,31 @@ pub(crate) fn psd_to_document_decoded_integrity( decoded: Option>>, ) -> Option<(Document, Vec, bool)> { let global = file.global_blocks.iter().map(|b| (b.signature, b.key, Arc::new(b.data.clone()))).collect(); - psd_to_document_global(file, ctl, decoded, global).map(|(doc, warnings, damaged, _)| (doc, warnings, damaged)) + psd_to_document_global(file, ctl, decoded, global, false).map(|(doc, warnings, damaged, _)| (doc, warnings, damaged)) +} + +/// Admission for a legacy conversion. +/// +/// A nested conversion spends the enclosing attempt's remaining allowance and never resets it. +/// A standalone conversion refuses an ineligible file before waiting for the decode gate and +/// rechecks the fresh measurement once admitted, so a refused file never waits behind an active +/// decode and a memory drop during the wait is seen before any pixel is decoded. +fn legacy_admission(est: &crate::psd_budget::PsdMemoryEstimate, ctl: &photocraft_raster::Interrupt) -> Result { + if crate::psd_gate::is_holding_admission() { + // Cancellation is checked before the charge and admission is reentrant here, so a + // cancelled or refused nested conversion never spends its parent's allowance. + ctl.check().map_err(|_| crate::IoError::Cancelled)?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + crate::psd_gate::consume_attempt_allowance(est.total_bytes)?; + return Ok(guard); + } + let snapshot = || crate::memory::MemorySnapshot::current(); + // Legacy standalone is unconfirmed: it cannot silently auto allow an estimate above S. + crate::memory::evaluate_psd_admission(est.total_bytes, est.tile_bytes, 0, false, None, snapshot())?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + let outcome = crate::memory::evaluate_psd_admission(est.total_bytes, est.tile_bytes, 0, false, None, snapshot())?; + crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); + Ok(guard) } fn psd_to_document_global( @@ -1160,7 +1257,31 @@ fn psd_to_document_global( ctl: &photocraft_raster::Interrupt, decoded: Option>>, global: Vec, -) -> Option<(Document, Vec, bool, Option)> { + preflighted: bool, +) -> Option<(Document, Vec, bool, Option)> { + // Legacy conversion APIs return warnings rather than Result. Reject before metadata or + // pixels are decoded, and retain the fatal error for the ordinary import path. An import + // that checked the file against its own limit first (`preflighted`) does not estimate twice. + let _guard = if !preflighted || !crate::psd_gate::is_holding_admission() { + let est = match crate::psd_budget::estimate(file) { + Ok(e) => e, + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + }; + match legacy_admission(&est, ctl) { + Ok(guard) => Some(guard), + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + } + } else { + None + }; let (mode, layered, multichannel, depth) = import_format(file); let h = &file.header; let mut warnings = Vec::new(); @@ -1168,7 +1289,7 @@ fn psd_to_document_global( warnings.push(format!("unknown color mode {}; importing as RGB", h.color_mode.as_u16())); } let mut doc = Document::new("Untitled", Size::new(h.width, h.height), mode, depth); - let damaged = read_initial_metadata(file, &global, &mut doc, layered, multichannel, &mut warnings); + let (damaged, pattern_error) = read_initial_metadata(file, &global, &mut doc, layered, multichannel, &mut warnings); let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); @@ -1181,7 +1302,7 @@ fn psd_to_document_global( cmyk: fmt.mode == ColorMode::Cmyk, warnings, damaged, - read_error: None, + fatal_error: None, dpi: doc.resolution_dpi, txt2: global.iter().find(|(_, k, _)| k == b"Txt2").and_then(|(_, _, d)| photocraft_text::psd::parse_txt2(d)), filter_effects: Vec::new(), @@ -1190,11 +1311,15 @@ fn psd_to_document_global( unfiltered_bytes: 0, smart_contents: Default::default(), plld_sizes: Default::default(), + vector_previews: Default::default(), ctl: *ctl, done: 0, uniform: Default::default(), total: file.layers().len(), }; + if let Some(error) = pattern_error { + cx.allocation_failed(error); + } for (_, key, data) in cx.global.clone().iter().filter(|(_, key, _)| matches!(key, b"FEid" | b"FXid")) { // Smart-filter caches can be large: a cancelled open stops between blocks. if ctl.cancelled() { @@ -1219,7 +1344,7 @@ fn psd_to_document_global( finish_import(doc, cx, ctl) } -fn finish_import(mut doc: Document, mut cx: Ctx<'_>, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec, bool, Option)> { +fn finish_import(mut doc: Document, mut cx: Ctx<'_>, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec, bool, Option)> { let raw_comps = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == crate::comps_map::LAYER_COMPS).map(|(_, _, data)| data.clone()); let (comps, applied, state, warnings) = crate::comps_map::decode_comps(raw_comps.as_deref().map(Vec::as_slice), &doc); (doc.layer_comps, doc.last_applied_comp, doc.last_document_state) = (comps, applied, state); @@ -1230,7 +1355,7 @@ fn finish_import(mut doc: Document, mut cx: Ctx<'_>, ctl: &photocraft_raster::In return None; } ctl.progress(1.0); - Some((doc, cx.warnings, cx.damaged, cx.read_error)) + Some((doc, cx.warnings, cx.damaged, cx.fatal_error)) } fn decode_merged( @@ -1246,6 +1371,9 @@ fn decode_merged( } ctl.progress(0.1); if let Err(error) = &merged { + if matches!(error, photocraft_psd::PsdError::OutOfMemory(_)) { + cx.allocation_failed(format!("merged image: {error}")); + } cx.warn_unreadable_data(format!("merged image could not be decoded: {error}")); if can_recover_layers(file) { cx.warn_unreadable_data("extra alpha/spot channels in the merged image could not be recovered"); @@ -1261,6 +1389,9 @@ fn decode_merged( } fn import_decoded_content(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_>) { + if cx.fatal_error.is_some() { + return; + } if import.multichannel { import_multichannel(import.file, import.merged, doc, cx); } else if import.layered { @@ -1268,13 +1399,19 @@ fn import_decoded_content(import: DecodedImport<'_>, doc: &mut Document, cx: &mu } else { import_flattened(import.file, import.merged, import.sizes, import.canvas, import.fmt, doc, cx); } - import_extra_channels(import, doc, cx); + // A layer allocation that failed above makes the whole import fail: more full-size + // planes here would only risk the abort the failure was reported to avoid. + if cx.fatal_error.is_none() { + import_extra_channels(import, doc, cx); + } } fn import_multichannel(file: &PsdFile, merged: &Option>>, doc: &mut Document, cx: &mut Ctx<'_>) { if let Some(Ok(all)) = merged { let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); - crate::multichannel_map::import(file, all, doc, &names, &mut cx.warnings); + if let Err(error) = crate::multichannel_map::import(file, all, doc, &names, &mut cx.warnings) { + cx.fatal_error.get_or_insert(error); + } } if !file.layers().is_empty() { cx.warn(format!("Multichannel documents have no layers: {} layer records were not imported", file.layers().len())); @@ -1305,11 +1442,18 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_ fill.push(max_sample(depth)); let mut inv = vec![cx.cmyk; cc]; inv.push(false); - let mut bytes = interleave(&planes, &fill, n, depth, &inv); + let mut bytes = match crate::pixels::try_interleave(&planes, &fill, n, depth, &inv) { + Ok(bytes) => bytes, + Err(error) => return cx.allocation_failed(format!("merged image: {error}")), + }; if fmt.mode == ColorMode::Lab && depth == SampleType::U16 { crate::pixels::lab16_chroma(&mut bytes, cc + 1, true); } - let mut surface = Surface::from_interleaved(fmt, canvas, &bytes); + let mut surface = Surface::new(fmt); + if let Err(error) = surface.try_write_interleaved(canvas, &bytes) { + return cx.allocation_failed(format!("merged image: {error}")); + } + photocraft_raster::scratch::give(bytes); if alpha_idx.is_some() { let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); let mut vals = Vec::new(); @@ -1323,7 +1467,9 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_ px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) }; } } - surface.write_region(band, &vals); + if let Err(error) = surface.try_write_region(band, &vals) { + return cx.allocation_failed(format!("merged image: {error}")); + } y = band.y1; } } @@ -1384,10 +1530,21 @@ fn import_flattened( } } let mut surface = Surface::new(fmt); - write_rgba8(&mut surface, canvas, &img.data); + let written = write_rgba8(&mut surface, canvas, &img.data); drop(img); - surface.prune(); - doc.layers.push(flat_background(surface)); + match written { + Ok(()) => { + surface.prune(); + doc.layers.push(flat_background(surface)); + } + Err(error) => cx.allocation_failed(format!("flattened image: {error}")), + } + } + Err(error @ photocraft_psd::PsdError::OutOfMemory(_)) => cx.allocation_failed(format!("flattened image: {error}")), + // A flattened file has nothing but its merged image: a failed source read fails the open + // rather than opening it without the image. + Err(photocraft_psd::PsdError::Read(message)) => { + cx.fatal_error.get_or_insert(crate::IoError::Read(message)); } Err(_) if matches!(merged, Some(Err(_))) => {} Err(error) => cx.warn_unreadable_data(format!("the image could not be read: {error}")), @@ -1425,12 +1582,18 @@ fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported")); break; }; - let mut raw = be_to_ne_plane(p, depth, n); + let mut raw = match be_to_ne_plane(p, depth, n) { + Ok(raw) => raw, + Err(error) => return cx.allocation_failed(format!("extra channel {idx}: {error}")), + }; if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) { crate::channel_map::photoshop_spot_plane(&mut raw, depth); } let mut s = Surface::new(cx.mask_fmt); - s.write_interleaved(canvas, &raw); + if let Err(error) = s.try_write_interleaved(canvas, &raw) { + return cx.allocation_failed(format!("extra channel {idx}: {error}")); + } + photocraft_raster::scratch::give(raw); s.prune(); let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, s)); diff --git a/crates/io/src/psd_import/tests.rs b/crates/io/src/psd_import/tests.rs index 3bb19e3ef8..a2b42161e4 100644 --- a/crates/io/src/psd_import/tests.rs +++ b/crates/io/src/psd_import/tests.rs @@ -388,3 +388,242 @@ fn layer_bands_end_on_tile_rows_and_cover_the_layer() { // A small layer is one band. assert_eq!(band_rows(0, 2000, 8000), vec![(0, 2000)]); } + +// Tall, narrow masks cross tile bands while keeping these cases only a few KiB. +fn banded_mask_file(depth: u16, compression: Compression, version: Version, real: bool) -> (PsdFile, Vec, photocraft_psd::Rect) { + use photocraft_psd::{ChannelData, LayerMask as PsdMask, MaskData, MaskParameters, RealMask}; + let mut file = testgen::layered(version, PsdMode::Rgb, depth, compression); + file.layer_info.as_mut().unwrap().layers.truncate(1); + let rect = photocraft_psd::Rect::from_xywh(-3, -17, 5, 600); + let mut plane = Vec::new(); + for i in 0..3000 { + match depth { + 8 => plane.push((i % 251) as u8), + 16 => plane.extend(((i * 73) as u16).to_be_bytes()), + 32 => plane.extend(((i % 251) as f32 / 125.0 - 0.5).to_be_bytes()), + _ => panic!("unexpected test case"), + } + } + let mut mask = PsdMask::new(rect, 0, 3 | 16); + mask.flags |= PsdMask::FLAG_PARAMETERS; + mask.parameters = Some(MaskParameters { flags: 3, user_density: Some(123), user_feather: Some(2.5), ..Default::default() }); + let record = &mut file.layers_mut()[0]; + record.channels.retain(|c| c.id != CHANNEL_USER_MASK && c.id != CHANNEL_REAL_USER_MASK); + let id = if real { + // Different synthetic bounds/defaults ensure -3's own metadata is selected. + mask.rect = photocraft_psd::Rect::from_xywh(1, 1, 1, 1); + mask.real = Some(RealMask { flags: 3, background: 255, rect }); + record.channels.push(ChannelData::encode(CHANNEL_USER_MASK, compression, &vec![0; usize::from(depth / 8)], 1, 1, depth, version).unwrap()); + CHANNEL_REAL_USER_MASK + } else { + CHANNEL_USER_MASK + }; + record.mask = MaskData::Mask(mask); + record.channels.push(ChannelData::encode(id, compression, &plane, 5, 600, depth, version).unwrap()); + (file, plane, rect) +} + +fn assert_banded_mask(file: &PsdFile, plane: &[u8], rect: photocraft_psd::Rect, real: bool) { + let (doc, warnings, damaged) = psd_to_document_decoded_integrity(file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(!damaged, "{warnings:?}"); + let mask = doc.layers[0].mask.as_ref().unwrap(); + let sample = sample_for_depth(file.header.depth); + let expected = crate::pixels::interleave(&[Some(plane)], &[zero_sample(sample)], 3000, sample, &[false]); + assert_eq!(mask.surface.to_interleaved(Rect::new(rect.left, rect.top, rect.right, rect.bottom)), expected); + assert_eq!(mask.surface.pixel(-4, -18), vec![if real { 1.0 } else { 0.0 }]); + assert!(!mask.enabled); + assert!(!mask.linked); + assert_eq!(mask.density, 123.0 / 255.0); + assert_eq!(mask.feather, 2.5); +} + +#[test] +fn mask_bands_preserve_samples_bounds_defaults_and_parameters() { + for depth in [8, 16, 32] { + for compression in Compression::ALL { + for version in [Version::Psd, Version::Psb] { + for real in [false, true] { + let (file, plane, rect) = banded_mask_file(depth, compression, version, real); + assert_banded_mask(&file, &plane, rect, real); + let bytes = file.to_bytes().unwrap(); + let eager = PsdFile::from_bytes(&bytes).unwrap(); + let lazy = PsdFile::from_source(photocraft_psd::Source::new(bytes)).unwrap(); + assert_banded_mask(&eager, &plane, rect, real); + assert_banded_mask(&lazy, &plane, rect, real); + } + } + } + } +} + +#[test] +fn raw_and_rle_mask_sources_are_read_in_bands() { + use photocraft_psd::{ReadAt, Source, Stored}; + use std::sync::{Arc, Mutex}; + struct Reads { + bytes: Vec, + sizes: Arc>>, + } + impl ReadAt for Reads { + fn len(&self) -> u64 { + self.bytes.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + self.sizes.lock().unwrap().push(buf.len()); + self.bytes.read_at(offset, buf) + } + } + for depth in [8, 16, 32] { + for compression in [Compression::Raw, Compression::Rle] { + for real in [false, true] { + let (mut file, plane, rect) = banded_mask_file(depth, compression, Version::Psb, real); + let id = if real { CHANNEL_REAL_USER_MASK } else { CHANNEL_USER_MASK }; + let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == id).unwrap(); + let bytes = std::mem::take(&mut channel.data); + let len = bytes.len(); + let sizes = Arc::new(Mutex::new(Vec::new())); + channel.stored = Some(Stored { source: Source::new(Reads { bytes, sizes: sizes.clone() }), offset: 0, len: len as u64 }); + assert_banded_mask(&file, &plane, rect, real); + let reads = sizes.lock().unwrap(); + match compression { + Compression::Raw => { + assert_eq!(*reads, [17, 256, 256, 71].map(|rows| 5 * rows * usize::from(depth / 8)).to_vec()) + } + Compression::Rle => { + assert_eq!(reads.len(), 5, "one index read and four payload bands: {reads:?}"); + assert_eq!(reads[0], 600 * 4, "PSB row index"); + } + _ => panic!("unexpected test case"), + } + assert!(reads.iter().all(|&n| n < len), "read whole {len}-byte mask: {reads:?}"); + } + } + } +} + +#[test] +fn a_damaged_later_mask_band_discards_earlier_bands() { + let (mut file, _, rect) = banded_mask_file(8, Compression::Rle, Version::Psd, false); + let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == CHANNEL_USER_MASK).unwrap(); + channel.data = [0, 2].repeat(600); + for y in 0..600 { + // Each valid row repeats five bytes; row 400 promises two literal bytes but has one. + channel.data.extend(if y == 400 { [1, 9] } else { [0xFC, 7] }); + } + assert!(channel.row_decoder(5, 600, 8, Version::Psd).unwrap().rows(0, 17).is_ok()); + let (doc, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap(); + assert!(damaged); + assert!(warnings.iter().any(|w| w.contains("channel -2 could not be decoded"))); + let mask = doc.layers[0].mask.as_ref().unwrap(); + assert_eq!(mask.surface.tile_count(), 0); + assert_eq!(mask.surface.pixel(rect.left, rect.top), vec![0.0]); +} + +/// An allocation or read that fails while the layers are built fails the open, and the merged +/// image's extra channels are not converted on the way out: that is another full plane per +/// channel, asked for after the import was already reported as failing. +#[test] +fn a_fatal_layer_error_skips_the_extra_channel_conversion() { + use photocraft_psd::ReadAt; + struct Failing { + bytes: Vec, + data: Vec<(u64, u64)>, + armed: std::sync::Arc, + } + impl ReadAt for Failing { + fn len(&self) -> u64 { + self.bytes.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> { + let end = offset + buf.len() as u64; + if self.armed.load(std::sync::atomic::Ordering::SeqCst) && self.data.iter().any(|&(a, b)| offset < b && end > a) { + return Err(PsdError::Read("the drive went away".into())); + } + self.bytes.as_slice().read_at(offset, buf) + } + } + let bytes = crate::psd_export::document_to_psd(&channels_document(SampleType::U8)).to_bytes().unwrap(); + let whole = PsdFile::from_bytes(&bytes).unwrap(); + assert!(merged_needed(&whole), "the extra channels come from the merged image"); + let converted = psd_to_document_global(&whole, &photocraft_raster::Interrupt::NONE, None, Vec::new(), false).unwrap(); + assert!(converted.3.is_none() && converted.0.channels.len() == 3, "the control file converts its channels"); + + let lazy = PsdFile::from_source(photocraft_psd::Source::new(bytes.clone())).unwrap(); + let data: Vec<(u64, u64)> = + lazy.layers().iter().flat_map(|l| &l.channels).filter_map(|c| c.stored.as_ref()).filter(|s| s.len > 0).map(|s| (s.offset, s.offset + s.len)).collect(); + assert!(!data.is_empty()); + // Reads fail only once the file is parsed: the layers decode lazily from the source. + let armed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let file = PsdFile::from_source(photocraft_psd::Source::new(Failing { bytes, data, armed: armed.clone() })).unwrap(); + armed.store(true, std::sync::atomic::Ordering::SeqCst); + let (doc, _, _, fatal) = psd_to_document_global(&file, &photocraft_raster::Interrupt::NONE, None, Vec::new(), false).unwrap(); + assert!(matches!(fatal, Some(crate::IoError::Read(_))), "{fatal:?}"); + assert!(doc.channels.is_empty(), "no extra channel was converted after the failure"); +} + +/// Only a failed allocation fails the open; a size limit or damaged compressed data still +/// leaves the layer empty with a damage warning, and the rest of the file opens. A failed +/// allocation names the layer and its channel. +#[test] +fn only_allocation_failures_are_fatal_to_a_layer_channel() { + let file = testgen::small(Version::Psd, Compression::Raw); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let mut cx = Ctx { + file: &file, + global: Vec::new(), + fmt, + mask_fmt: PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false), + cc: 3, + cmyk: false, + warnings: Vec::new(), + damaged: false, + fatal_error: None, + dpi: 72.0, + txt2: None, + filter_effects: Vec::new(), + filter_masks: Default::default(), + unfiltered: Default::default(), + unfiltered_bytes: 0, + smart_contents: Default::default(), + plld_sizes: Default::default(), + vector_previews: Default::default(), + ctl: photocraft_raster::Interrupt::NONE, + done: 0, + uniform: Default::default(), + total: 0, + }; + assert!(!cx.channel_failed("Layer 1", 0, &PsdError::LimitExceeded("decoded channel data exceeds MAX_DECODED_BYTES"))); + assert!(cx.fatal_error.is_none() && cx.damaged); + assert!(cx.channel_failed("Layer 1", 2, &PsdError::OutOfMemory("not enough memory for the decoded channel data"))); + match cx.fatal_error { + Some(crate::IoError::OutOfMemory(message)) => assert!(message.contains("layer \"Layer 1\" channel 2"), "{message}"), + other => panic!("{other:?}"), + } +} + +/// A standalone legacy conversion that is over budget is refused *before* it waits for the +/// decode gate, so an ineligible file never blocks behind an active decode. The legacy API keeps +/// its warning (the ordinary import path still receives the fatal error). +#[test] +fn legacy_conversion_refuses_before_waiting_for_the_decode_gate() { + let mut file = testgen::small(Version::Psd, Compression::Zip); + // 30,000² tiles are several GiB: far past the hard budget of the 4 GiB machine below. + file.layers_mut()[0].rect = photocraft_psd::Rect::from_xywh(0, 0, 30_000, 30_000); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(4 << 30), swap_free: None, commit_headroom: None }; + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).expect("active decode"); + + let (tx, rx) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let (doc, warnings) = crate::memory::with_mock_memory(snapshot, || crate::psd_to_document(&file)); + tx.send((doc.layers.len(), warnings)).ok(); + }); + + // The refusal arrives while this thread still holds the gate: the legacy call never waited. + let (layers, warnings) = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("a refused legacy conversion must not wait for the decode gate"); + assert_eq!(layers, 0, "a refused conversion returns an empty document"); + assert!(warnings.iter().any(|w| w.contains("pixel memory")), "{warnings:?}"); + + drop(_guard); + handle.join().expect("worker thread"); +} diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index beaccc5bda..f2165dbb87 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -73,7 +73,7 @@ fn thumbnail_bytes(w: u32, h: u32, jfif: &[u8]) -> Result, IoError> { /// The profile of the compositor's RGB output for `doc`: an RGB document's own (embedded when it /// parses, else sRGB), a gray document's gray profile seen as RGB, and sRGB for CMYK and Lab /// (which the compositor renders in sRGB). -fn composite_profile(doc: &Document) -> Profile { +pub(crate) fn composite_profile(doc: &Document) -> Profile { let space = match doc.mode { ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => ColorSpace::Gray, ColorMode::Cmyk | ColorMode::Lab => return Builtin::Srgb.profile().clone(), diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 72adbe1291..cacce2b7cb 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -173,6 +173,7 @@ pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &Filt let n = w.checked_mul(h).ok_or("the smart filter mask is too large")?; let bytes = crate::pixels::interleave(&[Some(&plane)], &[crate::pixels::zero_sample(sample)], n, sample, &[false]); surface.write_interleaved(GeomRect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes); + photocraft_raster::scratch::give(bytes); surface.prune(); if surface.content_bounds().is_empty() { return Ok(None); @@ -215,6 +216,7 @@ pub fn unfiltered_from_item(item: &FilterEffectsItem, fmt: PixelFormat) -> Optio } let mut surface = Surface::new(fmt); surface.write_interleaved(GeomRect::new(item.rect.left, item.rect.top, item.rect.right, item.rect.bottom), &bytes); + photocraft_raster::scratch::give(bytes); surface.prune(); Some(surface) } diff --git a/crates/io/src/smart_map/halftone_tests.rs b/crates/io/src/smart_map/halftone_tests.rs index 931483c55b..d4f67dbfec 100644 --- a/crates/io/src/smart_map/halftone_tests.rs +++ b/crates/io/src/smart_map/halftone_tests.rs @@ -154,6 +154,19 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() { } } +/// Keeps a Gallery stack opaque when its own descriptor holds a setting beside the effect list, +/// which a save would otherwise drop. +#[test] +fn halftone_pattern_unknown_outer_setting_stays_verbatim() { + let valid = native_effect("ScrD", 2, 5); + let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7)); + let item = native_item(gallery); + let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap(); + let filter = filter_from_item(&parsed); + assert_eq!(filter.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&filter).unwrap().to_bytes(), item.to_bytes()); +} + /// Rejects unsupported export values instead of silently changing their meaning. #[test] fn halftone_pattern_invalid_export_settings_are_reported() { diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index 55bd79ec06..f8f3f50e3f 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -710,7 +710,11 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option { let mut params = Map::new(); if g.key == "halftonePattern" { // Do not turn an unfamiliar shape or malformed setting into the engine's defaults: - // keeping the whole item opaque preserves it through a subsequent save. + // keeping the whole item opaque preserves it through a subsequent save. That covers + // the stack's own descriptor too: a key beside `GEfs` would not be written back. + if f.get("GEfs").is_some() && !(f.class_id.is(GALLERY_CLASS) && only_keys(f, &["GEfs"])) { + return None; + } if !e.class_id.is(GALLERY_CLASS) || !only_keys(e, &["GEfk", "HlSz", "Cntr", "ScrT", "GELv"]) || !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft")) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 27b536d996..0728035530 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -10,7 +10,7 @@ //! stops and direction in the layer's own gradient fill, with a warning that the exact geometry //! is not kept. -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use photocraft_color::blend::BlendMode; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; @@ -333,6 +333,7 @@ fn options(fontdb: Arc, depth_left: usize) -> usvg::Opti } /// A parsed drawing. +#[derive(Debug)] struct Parsed { tree: usvg::Tree, /// The drawing has text but no font at all was available to shape it. `usvg` then drops the @@ -368,18 +369,414 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Resul Ok(Parsed { tree, fontless_text }) } +const MAX_BOUNDED_SVG_BYTES: u64 = 16 << 20; +const MAX_EMBEDDED_SVG_DEPTH: usize = 32; + +struct ImageBudget { + pixels: u64, + xml_bytes: u64, + svg_images: u64, + /// The deepest chain of embedded SVG images (an SVG image inside an SVG image is 2). resvg + /// draws each one through a scratch pixmap as large as the one it is drawn into and frees it + /// afterwards, so this many such surfaces exist at once, however many siblings there are. + svg_chain: u64, + draw_nodes: u64, + repeatable_raster: bool, + repeatable_svg: bool, + fontless_text: bool, + /// An embedded image that can't be read fails the drawing instead of being left out. A + /// stored display preview must draw completely, or it isn't used. + strict: bool, + error: Option, +} + +/// Leave out an embedded image that can't be read, or with a strict budget record `error`. +fn unreadable_image(budget: &Mutex, error: &str) -> Option { + if budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).strict { + image_error(budget, error); + } + None +} + +/// Record the first budget or decoding failure seen while usvg resolves images. Resolver +/// callbacks cannot return errors, so the parse checks this slot afterwards and fails with it. +fn image_error(budget: &Mutex, error: impl Into) { + let mut budget = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if budget.error.is_none() { + budget.error = Some(error.into()); + } +} + +/// Deduct one image's `width × height` decoded pixels from the shared budget before it is +/// decoded. A zero-sized, overflowing or over-budget image records an error and returns `false`. +fn charge_image(budget: &Mutex, width: usize, height: usize) -> bool { + let pixels = u64::try_from(width).ok().zip(u64::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)); + let mut budget = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match pixels.filter(|pixels| *pixels != 0).and_then(|pixels| budget.pixels.checked_sub(pixels)) { + Some(remaining) => { + budget.pixels = remaining; + true + } + None => { + budget.error.get_or_insert_with(|| "embedded SVG raster images exceed the decoded pixel budget".into()); + false + } + } +} + +/// The size declared by a GIF's first image descriptor, skipping extension blocks with checked +/// offsets; `None` when the stream is truncated or malformed. +/// +/// A GIF's first frame can exceed its logical screen. resvg allocates the frame's RGBA buffer, +/// so inspect its descriptor too, without reading any LZW pixels. +fn gif_frame_size(bytes: &[u8]) -> Option<(usize, usize)> { + let packed = *bytes.get(10)?; + let table = if packed & 0x80 != 0 { 3usize.checked_mul(1usize.checked_shl(u32::from((packed & 7) + 1))?)? } else { 0 }; + let mut at = 13usize.checked_add(table)?; + loop { + match *bytes.get(at)? { + 0x2c => { + let word = |offset: usize| -> Option { + let pair: [u8; 2] = bytes.get(at.checked_add(offset)?..at.checked_add(offset)?.checked_add(2)?)?.try_into().ok()?; + Some(usize::from(u16::from_le_bytes(pair))) + }; + return Some((word(5)?, word(7)?)); + } + 0x21 => { + at = at.checked_add(2)?; + loop { + let length = usize::from(*bytes.get(at)?); + at = at.checked_add(1)?; + if length == 0 { + break; + } + bytes.get(at..at.checked_add(length)?)?; + at = at.checked_add(length)?; + } + } + _ => return None, + } + } +} + +/// Widen `dimensions` to the largest frame declared by the WebP RIFF `chunks` (VP8/VP8L +/// bitstreams and one level of ANMF animation frames); `None` when a chunk is malformed. +/// +/// VP8X declares a container canvas, while a malformed lossy VP8 chunk may declare a larger +/// frame which the downstream decoder allocates before comparing it with that canvas. +fn webp_frame_sizes(chunks: &[u8], dimensions: &mut (usize, usize), nested: bool) -> Option<()> { + let mut at = 0usize; + while at < chunks.len() { + let header = chunks.get(at..at.checked_add(8)?)?; + let length = usize::try_from(u32::from_le_bytes(header.get(4..8)?.try_into().ok()?)).ok()?; + let from = at.checked_add(8)?; + let end = from.checked_add(length)?; + let payload = chunks.get(from..end)?; + match header.get(..4)? { + b"VP8 " | b"VP8L" => { + let mut probe = b"RIFF\0\0\0\0WEBP".to_vec(); + probe.extend_from_slice(header); + probe.extend_from_slice(payload.get(..payload.len().min(10))?); + let size = imagesize::blob_size(&probe).ok()?; + dimensions.0 = dimensions.0.max(size.width); + dimensions.1 = dimensions.1.max(size.height); + } + b"ANMF" if !nested => { + let word = |at: usize| -> Option { + let bytes: [u8; 3] = payload.get(at..at.checked_add(3)?)?.try_into().ok()?; + usize::try_from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], 0]).checked_add(1)?).ok() + }; + dimensions.0 = dimensions.0.max(word(6)?); + dimensions.1 = dimensions.1.max(word(9)?); + webp_frame_sizes(payload.get(16..)?, dimensions, true)?; + } + b"ANMF" => return None, + _ => {} + } + at = end.checked_add(length & 1)?; + if at > chunks.len() { + return None; + } + } + Some(()) +} + +/// usvg's data-URL image resolver with budgets: nested SVG images are parsed with the same +/// budget up to [`MAX_EMBEDDED_SVG_DEPTH`] and within the `xml_left` levels of XML nesting the +/// drawing left them, and PNG/JPEG/GIF/WebP images are charged their largest declared frame +/// before decoding. An overrun records an error and resolves to no image so the enclosing parse +/// fails. An image that isn't one of those formats, is invalid, or has a MIME type usvg doesn't +/// know is left out and the rest still draws, as usvg's own resolver does (with a strict budget +/// it is an error too). +fn bounded_data_image( + mime: &str, + data: Arc>, + options: &usvg::Options, + budget: &Arc>, + depth: usize, + xml_left: usize, +) -> Option { + if budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.is_some() { + return None; + } + let format = photocraft_codecs::detect(&data); + if mime == "image/svg+xml" || (mime == "text/plain" && format.is_none()) { + if depth >= MAX_EMBEDDED_SVG_DEPTH { + image_error(budget, "embedded SVG images exceed the nesting limit"); + return None; + } + // A budget overrun inside the image is already recorded; any other failure (not an + // SVG, nested deeper than the drawing left room for) only leaves the image out. + let Ok(tree) = parse_image_bounded(&data, budget.clone(), depth + 1, xml_left) else { + return unreadable_image(budget, "can't parse an embedded SVG image"); + }; + let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + counter.svg_images = counter.svg_images.saturating_add(1); + counter.svg_chain = counter.svg_chain.max(u64::try_from(depth + 1).unwrap_or(u64::MAX)); + Some(usvg::ImageKind::SVG(tree)) + } else { + let supported = matches!( + format, + Some(photocraft_codecs::Format::Png | photocraft_codecs::Format::Jpeg | photocraft_codecs::Format::Gif | photocraft_codecs::Format::WebP) + ); + let size = supported.then(|| imagesize::blob_size(&data).ok()).flatten(); + let Some(size) = size else { return unreadable_image(budget, "embedded SVG image has unsupported or invalid raster data") }; + let mut dimensions = (size.width, size.height); + if format == Some(photocraft_codecs::Format::Gif) { + let Some(frame) = gif_frame_size(&data) else { return unreadable_image(budget, "can't read embedded GIF frame dimensions") }; + dimensions = (dimensions.0.max(frame.0), dimensions.1.max(frame.1)); + } + if format == Some(photocraft_codecs::Format::WebP) && data.get(12..).and_then(|chunks| webp_frame_sizes(chunks, &mut dimensions, false)).is_none() { + return unreadable_image(budget, "can't read embedded WebP frame dimensions"); + } + // usvg's resolver only wraps the bytes (resvg decodes them when drawing): an image under + // a MIME type it doesn't know, or with no pixels, is left out uncharged, as usvg would. + let Some(image) = (usvg::ImageHrefResolver::default_data_resolver())(mime, data, options) else { + return unreadable_image(budget, "embedded SVG raster image has an unsupported MIME type"); + }; + if dimensions.0 == 0 || dimensions.1 == 0 { + return unreadable_image(budget, "embedded SVG image has no pixels"); + } + if !charge_image(budget, dimensions.0, dimensions.1) { + return None; + } + Some(image) + } +} + +/// [`parse_image_bounded_with`] using the shared system font database. +fn parse_image_bounded(bytes: &[u8], budget: Arc>, depth: usize, xml_left: usize) -> Result { + parse_image_bounded_with(bytes, budget, depth, xml_left, fonts) +} + +/// Parse one (possibly gzip-compressed) SVG at nesting `depth` against `budget`: the byte, +/// inflated-size and XML-depth limits apply first, embedded images go through +/// [`bounded_data_image`], and the tree's drawing-node count and repeatable image resources are +/// added to the budget for the caller's work estimate. Fonts load only when text is present. +/// usvg parses an embedded SVG on top of the drawing's own recursion, so the drawing and the +/// images inside it share `xml_left` levels of XML nesting ([`MAX_XML_DEPTH`] for the drawing). +/// Byte and decode overruns are also recorded in `budget`, so they fail the whole drawing; a +/// nesting or syntax error only fails this SVG. +fn parse_image_bounded_with( + bytes: &[u8], + budget: Arc>, + depth: usize, + xml_left: usize, + fonts: fn() -> Arc, +) -> Result { + let overrun = |error: IoError| { + image_error(&budget, error.to_string()); + error + }; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_BOUNDED_SVG_BYTES { + return Err(overrun(IoError::Svg("embedded SVG source exceeds the 16 MiB byte limit".into()))); + } + let xml = inflate_with_limit(bytes, MAX_BOUNDED_SVG_BYTES).map_err(overrun)?; + // Charged before anything can leave the image out, so skipped images still use up the budget. + let xml_len = u64::try_from(xml.len()).unwrap_or(u64::MAX); + let left = { + let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let left = counter.xml_bytes.checked_sub(xml_len); + if let Some(left) = left { + counter.xml_bytes = left; + } + left + }; + if left.is_none() { + return Err(overrun(IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))); + } + let nesting = nesting_depth(&xml, xml_left); + if nesting > xml_left { + return Err(IoError::Svg(format!("elements are nested more than {xml_left} levels deep"))); + } + let has_text = xml.windows(4).any(|w| w == b"text"); + let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) }; + if has_text && fontdb.faces().next().is_none() { + budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).fontless_text = true; + } + // The resolver below replaces `options`' own, so its embedded-SVG depth argument is unused. + let mut opt = options(fontdb, 0); + let shared = budget.clone(); + let image_xml_left = xml_left.saturating_sub(nesting); + opt.image_href_resolver.resolve_data = Box::new(move |mime, data, options| bounded_data_image(mime, data, options, &shared, depth, image_xml_left)); + // The bounded inflate above is the only decompression step. from_data would detect a + // nested gzip payload and inflate it again without the source-byte limit. + let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?; + let tree = usvg::Tree::from_str(text, &opt); + if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() { + return Err(IoError::Svg(error)); + } + let tree = tree.map_err(|e| IoError::Svg(e.to_string()))?; + // Include nested SVG trees too. Paint servers can reuse one parsed raster in many + // drawing nodes, so the work estimate must not assume every source is decoded once. + let nodes = u64::try_from(count_nodes_with_limit(tree.root(), true, usize::MAX, false)).unwrap_or(u64::MAX); + let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + counter.draw_nodes = counter.draw_nodes.saturating_add(nodes); + let (raster, svg) = repeatable_image_resources(&tree); + counter.repeatable_raster |= raster; + counter.repeatable_svg |= svg; + Ok(tree) +} + +/// Used paint/effect resources can decode an image once per use. Ordinary paths, gradients, +/// clipping paths and blur filters without image references do not multiply unrelated photos. +fn repeatable_image_resources(tree: &usvg::Tree) -> (bool, bool) { + /// Mark `kinds` (raster, SVG) for images reachable from `group`, visiting each group once + /// and turning conservative (both kinds) past the depth or step bound. + fn visit(group: &usvg::Group, depth: usize, seen: &mut std::collections::HashSet, steps: &mut usize, kinds: &mut (bool, bool)) { + if depth > MAX_RECURSION || *steps >= 1_000_000 { + *kinds = (true, true); // Conservative when resource inspection itself reaches its bound. + return; + } + if !seen.insert((group as *const usvg::Group).addr()) { + return; + } + for node in group.children() { + *steps = steps.saturating_add(1); + match node { + usvg::Node::Image(image) => match image.kind() { + usvg::ImageKind::SVG(_) => kinds.1 = true, + _ => kinds.0 = true, + }, + usvg::Node::Group(child) => visit(child, depth + 1, seen, steps, kinds), + _ => {} + } + node.subroots(|root| visit(root, depth + 1, seen, steps, kinds)); + if (kinds.0 && kinds.1) || *steps >= 1_000_000 { + if *steps >= 1_000_000 { + *kinds = (true, true); + } + return; + } + } + } + let (mut seen, mut steps, mut kinds) = (std::collections::HashSet::new(), 0, (false, false)); + for root in tree.patterns().iter().map(|p| p.root()).chain(tree.masks().iter().map(|m| m.root())).chain(tree.clip_paths().iter().map(|c| c.root())) { + visit(root, 0, &mut seen, &mut steps, &mut kinds); + } + for filter in tree.filters() { + for primitive in filter.primitives() { + if let usvg::filter::Kind::Image(image) = primitive.kind() { + visit(image.root(), 0, &mut seen, &mut steps, &mut kinds); + } + } + } + kinds +} + +struct ParsedWork { + parsed: Parsed, + svg_images: u64, + svg_chain: u64, + decoded: u64, + draw_nodes: u64, + repeatable_raster: bool, + repeatable_svg: bool, +} + +/// Parse untrusted SVG with at most `max_pixels` (capped at 24 MP) of decoded image pixels. The +/// scratch surfaces its embedded SVG images need at the drawing's own size count against the +/// same limit. +fn parse_bounded(bytes: &[u8], max_pixels: u64) -> Result { + let (parsed, chain) = parse_bounded_inner(bytes, max_pixels)?; + let area = canvas(&parsed.tree, &mut Vec::new()).size.area(); + check_scratch_surfaces(area, chain, max_pixels)?; + Ok(parsed) +} + +/// [`parse_bounded`] without the scratch-surface check, also returning the deepest chain of +/// embedded SVG images (see [`ImageBudget::svg_chain`]) so a render can check it at its own size. +fn parse_bounded_inner(bytes: &[u8], max_pixels: u64) -> Result<(Parsed, u64), IoError> { + parse_bounded_inner_with(bytes, max_pixels, fonts) +} + +/// [`parse_bounded_inner`] with an explicit font source, so tests can pin the available fonts. +fn parse_bounded_inner_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc) -> Result<(Parsed, u64), IoError> { + parse_bounded_work_with(bytes, max_pixels, false, fonts).map(|work| (work.parsed, work.svg_chain)) +} + +/// resvg draws an embedded SVG image through a scratch pixmap as large as the `area` it is drawn +/// into, one per level of a `chain` of nested images, all alive at once; past `max_pixels` (at +/// most 24 MP) of them is an error. Siblings reuse the space one after another, so a drawing +/// with any number of side-by-side SVG images needs one, like the drawing itself. +fn check_scratch_surfaces(area: u64, chain: u64, max_pixels: u64) -> Result<(), IoError> { + if chain != 0 && area.checked_mul(chain).is_none_or(|pixels| pixels > max_pixels.min(MAX_RASTER_PIXELS)) { + return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into())); + } + Ok(()) +} + +/// The decoder's charged pixels, including raster images inside nested SVG images. +fn parse_bounded_work_with(bytes: &[u8], max_pixels: u64, strict: bool, fonts: fn() -> Arc) -> Result { + let budget = Arc::new(Mutex::new(ImageBudget { + pixels: max_pixels.min(MAX_RASTER_PIXELS), + xml_bytes: MAX_BOUNDED_SVG_BYTES, + svg_images: 0, + svg_chain: 0, + draw_nodes: 0, + repeatable_raster: false, + repeatable_svg: false, + fontless_text: false, + strict, + error: None, + })); + let tree = parse_image_bounded_with(bytes, budget.clone(), 0, MAX_XML_DEPTH, fonts)?; + let counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let decoded = max_pixels.min(MAX_RASTER_PIXELS).saturating_sub(counter.pixels); + Ok(ParsedWork { + parsed: Parsed { tree, fontless_text: counter.fontless_text }, + svg_images: counter.svg_images, + svg_chain: counter.svg_chain, + decoded, + draw_nodes: counter.draw_nodes, + repeatable_raster: counter.repeatable_raster, + repeatable_svg: counter.repeatable_svg, + }) +} + /// The plain XML of `bytes`: itself, or the inflated content of a gzip `.svgz`, refused past /// [`MAX_INFLATED_BYTES`] (a decompression bomb) or when the stream is corrupt. fn inflate(bytes: &[u8]) -> Result, IoError> { - use std::io::Read; if !bytes.starts_with(&[0x1f, 0x8b]) { return Ok(std::borrow::Cow::Borrowed(bytes)); } + inflate_with_limit(bytes, MAX_INFLATED_BYTES) +} + +/// [`inflate`] with an explicit `limit`, which also applies to uncompressed input. +fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result, IoError> { + use std::io::Read; + if !bytes.starts_with(&[0x1f, 0x8b]) { + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit { + return Err(IoError::Svg(format!("the SVG exceeds the {limit} byte limit"))); + } + return Ok(std::borrow::Cow::Borrowed(bytes)); + } let mut out = Vec::new(); - let mut reader = flate2::bufread::MultiGzDecoder::new(bytes).take(MAX_INFLATED_BYTES.saturating_add(1)); + let mut reader = flate2::bufread::MultiGzDecoder::new(bytes).take(limit.saturating_add(1)); reader.read_to_end(&mut out).map_err(|e| IoError::Svg(format!("the compressed SVG couldn't be inflated: {e}")))?; - if u64::try_from(out.len()).unwrap_or(u64::MAX) > MAX_INFLATED_BYTES { - return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", MAX_INFLATED_BYTES >> 20))); + if u64::try_from(out.len()).unwrap_or(u64::MAX) > limit { + return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", limit >> 20))); } Ok(std::borrow::Cow::Owned(out)) } @@ -466,28 +863,181 @@ pub fn import_svg(name: &str, bytes: &[u8]) -> Result { /// Opens an SVG while rasterising any subtrees deeper than `max_group_depth` into single pixel /// layers. The document's hard nesting limit remains [`MAX_GROUP_DEPTH`]. pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: usize) -> Result { - import_parsed(name, parse(bytes)?, max_group_depth) + import_svg_inner(name, bytes, max_group_depth, None, false) } -fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result { - import_parsed_with_budget(name, parsed, max_group_depth, MAX_PIXEL_BYTES) +/// Opens an SVG with a conservative budget for eagerly rendered layer caches. A canvas above +/// the budget (or the rasterisation limit) is rejected before any layer cache is allocated. +/// When the canvas fits but its elements' combined caches would exceed the budget, the whole +/// drawing becomes one pixel layer, with an explicit editability warning. +pub fn import_svg_bounded(name: &str, bytes: &[u8], max_cached_pixels: u64) -> Result { + import_svg_inner(name, bytes, MAX_GROUP_DEPTH, Some(max_cached_pixels), false) } +/// Import a drawing with one aggregate pixel-work allowance and return its conservative charge. +/// This includes decoded raster pixels (including repeated paints), eager layer-cache surfaces +/// and embedded-SVG intermediate surfaces. Image decoding and each canvas/cache component keep +/// their independent 24 MP caps; the aggregate allowance can pay for several components. +/// Raster-bearing patterns, masks and image-filter resources conservatively reserve repaint +/// work across drawing nodes; ordinary vector paths, artboard clips and blur alone do not. +/// [`import_svg_bounded`] retains its existing independent image/cache limits. An embedded image +/// it can't read is left out, as on placement. +pub fn import_svg_bounded_with_work(name: &str, bytes: &[u8], max_work: u64) -> Result<(ImportResult, u64), IoError> { + import_svg_bounded_with_work_mode(name, bytes, max_work, false) +} + +/// [`import_svg_bounded_with_work`] for a stored display preview: an embedded image it can't +/// read fails it, so only a preview that draws completely stands in for its native source. +pub fn import_svg_preview_with_work(name: &str, bytes: &[u8], max_work: u64) -> Result<(ImportResult, u64), IoError> { + import_svg_bounded_with_work_mode(name, bytes, max_work, true) +} + +fn import_svg_bounded_with_work_mode(name: &str, bytes: &[u8], max_work: u64, strict: bool) -> Result<(ImportResult, u64), IoError> { + if max_work == 0 { + return Err(IoError::Svg("SVG preview validation work budget is exhausted".into())); + } + let ParsedWork { parsed, svg_images, decoded, draw_nodes, repeatable_raster, repeatable_svg, .. } = + parse_bounded_work_with(bytes, max_work.min(MAX_RASTER_PIXELS), strict, fonts)?; + let area = canvas(&parsed.tree, &mut Vec::new()).size.area(); + // This preview check charges every embedded SVG image a canvas-sized surface of work. + if svg_images != 0 && area.checked_mul(svg_images.saturating_add(1)).is_none_or(|pixels| pixels > max_work.min(MAX_RASTER_PIXELS)) { + return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into())); + } + let uses = draw_nodes.max(1); + let intermediate_surfaces = svg_images.saturating_mul(if repeatable_svg { uses } else { 1 }).saturating_add(1); + // Only raster-bearing repeatable resources can repaint an already-parsed image. Plain + // vector paths alongside a photo do not decode that photo again for each path. + let decoded_work = decoded + .checked_mul(if repeatable_raster { uses } else { 1 }) + .filter(|pixels| *pixels <= max_work) + .ok_or_else(|| IoError::Svg("SVG image decoding exceeds the validation work budget".into()))?; + let cache_limit = (max_work.saturating_sub(decoded_work) / intermediate_surfaces).min(MAX_RASTER_PIXELS); + if area > cache_limit { + return Err(IoError::Svg("SVG preview decoding and rendering exceed the validation work budget".into())); + } + let nodes = count_nodes(parsed.tree.root(), true); + let layers = area.checked_mul(u64::try_from(nodes.max(1)).unwrap_or(u64::MAX)); + // import_parsed uses this same bound to select one whole-drawing fallback before it + // renders any layer caches. A tiny canvas cannot hide a huge decoded source image. + let rendered = if nodes > MAX_NODES || layers.is_none_or(|pixels| pixels > cache_limit) { area } else { layers.unwrap_or(area) }; + let work = rendered + .checked_mul(intermediate_surfaces) + .and_then(|pixels| decoded_work.checked_add(pixels)) + .filter(|pixels| *pixels <= max_work) + .ok_or_else(|| IoError::Svg("SVG preview validation work budget exceeded".into()))?; + import_parsed(name, parsed, MAX_GROUP_DEPTH, Some(cache_limit), false).map(|imported| (imported, work)) +} + +/// The same cache bound without a lossy aggregate-budget fallback. Smart-object operations +/// use this variant so editing contents or changing Stack Mode fails before excessive caches +/// are rendered, rather than silently changing the source's layer structure. +pub fn import_svg_bounded_layers(name: &str, bytes: &[u8], max_cached_pixels: u64) -> Result { + import_svg_inner(name, bytes, MAX_GROUP_DEPTH, Some(max_cached_pixels), true) +} + +/// Opens untrusted SVG (smart-object contents) as [`import_svg`] does, as editable layers under +/// its retained-pixel budget, but with the bounded parse: embedded images may decode to at most +/// `max_decoded_pixels` (capped at 24 MP) in all, nested SVG images get the same budget, and +/// compressed data inflates to at most 16 MiB. +pub fn import_svg_bounded_images(name: &str, bytes: &[u8], max_decoded_pixels: u64) -> Result { + import_parsed(name, parse_bounded(bytes, max_decoded_pixels)?, MAX_GROUP_DEPTH, None, false) +} + +/// The pixel bounds [`import_svg_bounded`] gives the drawing, found without rendering any layer +/// cache. It applies the same bounded parse (decoded-image and nested-SVG budgets) and the same +/// canvas limit, so a canvas of exactly `max_cached_pixels` (at most 24 MP) fits and one more +/// pixel does not. A smart object's warp frame needs only these bounds, so an SVG whose editable +/// layer caches exceed the budget (rejected by [`import_svg_bounded_layers`]) still has one. +pub fn bounded_canvas_bounds(bytes: &[u8], max_cached_pixels: u64) -> Result { + let parsed = parse_bounded(bytes, max_cached_pixels)?; + let cv = canvas(&parsed.tree, &mut Vec::new()); + let limit = max_cached_pixels.min(MAX_RASTER_PIXELS); + if cv.size.area() > limit { + return Err(IoError::Svg(format!("{} × {} px exceeds the SVG layer cache budget of {limit} pixels", cv.size.width, cv.size.height))); + } + Ok(Rect::from_size(cv.size)) +} + +/// Parse (bounded when `max_cached_pixels` is set) and convert to layers; see [`import_parsed_with_limits`]. +fn import_svg_inner( + name: &str, + bytes: &[u8], + max_group_depth: usize, + max_cached_pixels: Option, + reject_cache_overflow: bool, +) -> Result { + let parsed = match max_cached_pixels { + Some(limit) => parse_bounded(bytes, limit)?, + None => parse(bytes)?, + }; + import_parsed(name, parsed, max_group_depth, max_cached_pixels, reject_cache_overflow) +} + +/// [`import_parsed_with_limits`] with the default retained-pixel byte budget. +fn import_parsed( + name: &str, + parsed: Parsed, + max_group_depth: usize, + max_cached_pixels: Option, + reject_cache_overflow: bool, +) -> Result { + import_parsed_with_limits(name, parsed, max_group_depth, max_cached_pixels, reject_cache_overflow, MAX_PIXEL_BYTES) +} + +#[cfg(test)] fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, pixel_budget: u64) -> Result { + import_parsed_with_limits(name, parsed, max_group_depth, None, false, pixel_budget) +} + +/// Convert a parsed tree into a document. With `max_cached_pixels`, a canvas over the budget is +/// an error and caches over it either fail (`reject_cache_overflow`) or collapse the drawing into +/// one pixel layer with a warning; more than [`MAX_NODES`] elements always collapse. +/// `pixel_budget` bounds the bytes of retained layer pixels. +fn import_parsed_with_limits( + name: &str, + parsed: Parsed, + max_group_depth: usize, + max_cached_pixels: Option, + reject_cache_overflow: bool, + pixel_budget: u64, +) -> Result { let Parsed { tree, fontless_text } = parsed; let mut warnings = Vec::new(); if fontless_text { warnings.push("SVG: the text needs a font and none is installed, so it was dropped".to_string()); } let cv = canvas(&tree, &mut warnings); + let nodes = count_nodes(tree.root(), max_cached_pixels.is_some()); + let over_cache_budget = if let Some(budget) = max_cached_pixels { + let area = cv.size.area(); + if area > budget.min(MAX_RASTER_PIXELS) { + return Err(IoError::Svg(format!( + "{} × {} px exceeds the SVG layer cache budget of {} pixels", + cv.size.width, + cv.size.height, + budget.min(MAX_RASTER_PIXELS) + ))); + } + let count = u64::try_from(nodes.min(MAX_NODES)).unwrap_or(u64::MAX); + area.checked_mul(count).is_none_or(|pixels| pixels > budget) + } else { + false + }; + if over_cache_budget && reject_cache_overflow { + return Err(IoError::Svg("the source's editable layer caches exceed the SVG cache budget".into())); + } let mut doc = Document::new(name, cv.size, ColorMode::Rgb, SampleType::U8); let fmt = doc.pixel_format(); let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale); let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH)); let mut conv = Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4], pixel_budget, retained_pixel_bytes: 0 }; - if count_nodes(tree.root()) > MAX_NODES { - conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); + if nodes > MAX_NODES || over_cache_budget { + if over_cache_budget { + conv.warnings.push("SVG: combined layer caches exceed the clipboard cache budget; opened as one pixel layer instead of editable shapes".into()); + } else { + conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes")); + } let (w, h) = (cv.size.width, cv.size.height); if let Some(mut pixmap) = pixmap(w, h) { resvg::render(&tree, global, &mut pixmap.as_mut()); @@ -506,8 +1056,29 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize, /// the origin. Smart objects use it to stay sharp at any placement. pub fn rasterize(bytes: &[u8], scale: f32) -> Result { let tree = parse(bytes)?.tree; - let cv = canvas(&tree, &mut Vec::new()); - let pixmap = render(&tree, cv.scale * scale.clamp(1.0 / 64.0, 64.0))?; + rasterize_tree(&tree, scale) +} + +/// Render untrusted embedded SVG with intrinsic raster-image and aggregate decode budgets. +pub fn rasterize_bounded(bytes: &[u8], scale: f32, max_decoded_pixels: u64) -> Result { + let (parsed, chain) = parse_bounded_inner(bytes, max_decoded_pixels)?; + let tree = parsed.tree; + if chain != 0 { + let cv = canvas(&tree, &mut Vec::new()); + let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0); + let width = ((tree.size().width() * k).ceil() as u32).max(1); + let height = ((tree.size().height() * k).ceil() as u32).max(1); + check_scratch_surfaces(u64::from(width) * u64::from(height), chain, max_decoded_pixels) + .map_err(|_| IoError::Svg("embedded SVG intermediate images exceed the rendered pixel budget".into()))?; + } + rasterize_tree(&tree, scale) +} + +/// Render the whole tree at `scale` (clamped to 1/64–64) times its canvas size into a straight-alpha +/// buffer at the origin; a result over the 24 MP rasterisation limit is an error. +fn rasterize_tree(tree: &usvg::Tree, scale: f32) -> Result { + let cv = canvas(tree, &mut Vec::new()); + let pixmap = render(tree, cv.scale * scale.clamp(1.0 / 64.0, 64.0))?; let rect = Rect::new(0, 0, pixmap.width() as i32, pixmap.height() as i32); let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect(); Ok(photocraft_compose::Buffer { rect, px }) @@ -578,16 +1149,26 @@ fn surface(pixmap: &tiny_skia::Pixmap, rect: Rect, fmt: PixelFormat) -> Surface } /// Drawable elements in the tree (iterative: the nesting can be anything). -fn count_nodes(root: &usvg::Group) -> usize { - let mut n = 0; +fn count_nodes(root: &usvg::Group, include_text: bool) -> usize { + count_nodes_with_limit(root, include_text, MAX_NODES, true) +} + +/// Count structural cache nodes or drawing nodes, stopping only at the caller's limit. +fn count_nodes_with_limit(root: &usvg::Group, include_text: bool, limit: usize, count_groups: bool) -> usize { + let mut n: usize = 0; let mut stack: Vec<&usvg::Group> = vec![root]; while let Some(g) = stack.pop() { for node in g.children() { - n += 1; + if count_groups || !matches!(node, usvg::Node::Group(_)) { + n = n.saturating_add(1); + } if let usvg::Node::Group(child) = node { stack.push(child); } - if n > MAX_NODES { + if include_text && let usvg::Node::Text(text) = node { + stack.push(text.flattened()); + } + if n > limit { return n; } } diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs index 426962fa9c..fd7f2713a7 100644 --- a/crates/io/src/svg/tests.rs +++ b/crates/io/src/svg/tests.rs @@ -1,5 +1,161 @@ use super::*; +fn embedded_image(mime: &str, bytes: &[u8]) -> String { + let data = bytes.iter().map(|byte| format!("%{byte:02X}")).collect::(); + format!("") +} + +fn image_svg(images: &str) -> String { + format!("{images}") +} + +fn png_dimensions(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes +} + +/// Source-image decoding and canvas rendering are separate work components. +#[test] +fn preview_work_counts_large_compressed_rasters_on_tiny_canvases() { + let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let svg = image_svg(&embedded_image("image/png", &png)); + let (imported, work) = import_svg_bounded_with_work("work", svg.as_bytes(), 8192).unwrap(); + assert_eq!(imported.document.size, Size::new(1, 1)); + assert!(work >= 4097, "a one-pixel canvas must pay for its 4096-pixel decoded raster: {work}"); + assert!(import_svg_bounded_with_work("work", svg.as_bytes(), 4096).is_err(), "decoder work cannot leave the canvas unpaid"); + let (same, exact_work) = import_svg_bounded_with_work("work", svg.as_bytes(), work).unwrap(); + assert_eq!(exact_work, work); + assert_eq!(same.document.size, imported.document.size); +} + +/// A full-size input and output may each use the existing 24 MP component allowance. +#[test] +fn preview_work_can_pay_for_both_a_24mp_image_and_a_24mp_canvas() { + let image = photocraft_codecs::Image::from_u8(6000, 4000, photocraft_codecs::ChannelLayout::Rgb, vec![255; 6000 * 4000 * 3]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::(); + let svg = + format!(r#""#); + assert!(import_svg_bounded_with_work("work", svg.as_bytes(), 48_000_000 - 1).is_err()); + let (imported, work) = import_svg_bounded_with_work("work", svg.as_bytes(), 48_000_000).unwrap(); + assert_eq!(work, 48_000_000); + assert_eq!(imported.document.size, Size::new(6000, 4000)); + assert!(!imported.document.layers.is_empty(), "the legal component limits must still retain the display"); +} + +#[test] +fn preview_work_plain_paths_and_artboard_clip_do_not_repeat_photo_decode() { + let image = photocraft_codecs::Image::from_u8(6000, 4000, photocraft_codecs::ChannelLayout::Rgb, vec![255; 6000 * 4000 * 3]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::(); + let svg = format!( + r#""# + ); + let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap(); + assert!(!parsed.repeatable_raster && !parsed.repeatable_svg); + let (imported, work) = import_svg_bounded_with_work("photo and paths", svg.as_bytes(), 48_000_000).unwrap(); + assert!(work <= 48_000_000, "ordinary photo composition stays within two full-size surfaces: {work}"); + assert_eq!(imported.document.size, Size::new(6000, 4000)); +} + +#[test] +fn preview_work_repeatable_raster_patterns_pay_for_repainting() { + let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::(); + let svg = format!( + r#""# + ); + let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap(); + assert!(parsed.repeatable_raster); + assert!( + import_svg_bounded_with_work("pattern", svg.as_bytes(), 8192).is_err(), + "repeated pattern painting cannot be charged as one decode plus a tiny canvas" + ); +} + +#[test] +fn preview_work_use_images_charge_each_resolved_occurrence() { + let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::(); + let svg = format!( + r##""## + ); + let parsed = parse_bounded_work_with(svg.as_bytes(), MAX_RASTER_PIXELS, true, fonts).unwrap(); + assert!(parsed.decoded >= 3 * 4096, "each resolved use must reserve its own raster decode: {}", parsed.decoded); + assert!(!parsed.repeatable_raster); + assert!(import_svg_bounded_with_work("uses", svg.as_bytes(), 3 * 4096).is_err()); + let blurred = svg + .replace("", "") + .replace(""); + assert_eq!(rasterize_bounded(external.as_bytes(), 1.0, 16).unwrap().px, vec![[0.0, 0.0, 1.0, 1.0]]); +} + #[test] fn sniffing_accepts_svg_and_rejects_other_xml() { assert!(is_svg(b"")); @@ -42,14 +198,20 @@ fn text_without_any_font_is_reported_not_silently_dropped() { let svg = b"II"; let parsed = parse_with(svg, no_fonts).unwrap(); assert!(parsed.fontless_text); - let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap(); + assert!(r.document.layers.is_empty()); + assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); + // Clipboard/import paths use the bounded parser and retain the same diagnostic. + let (parsed, _) = parse_bounded_inner_with(svg, MAX_RASTER_PIXELS, no_fonts).unwrap(); + assert!(parsed.fontless_text); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, Some(MAX_RASTER_PIXELS), false).unwrap(); assert!(r.document.layers.is_empty()); assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); // A drawing without text never loads or misses fonts. let parsed = parse_with(b"", no_fonts).unwrap(); assert!(!parsed.fontless_text); - let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap(); assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } @@ -84,7 +246,7 @@ fn transformed_text_lands_where_resvg_draws_it() { 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 r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).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); @@ -178,7 +340,7 @@ fn text_in_missing_fonts_falls_back_to_an_installed_one() { ); let parsed = parse_with(svg.as_bytes(), inter_only).unwrap(); assert!(!parsed.fontless_text); - let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap(); assert_eq!(r.document.layers.first().map(|l| l.name.as_str()), Some("II"), "{family}: {:?}", r.warnings); } } @@ -201,8 +363,8 @@ fn text_in_a_font_on_disk_is_set_from_memory() { let parsed = parse_with(svg, inter_on_disk).unwrap(); let faces: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect(); assert_eq!(faces, [true], "the face text was set in is held in memory"); - let on_disk = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); - let in_memory = import_parsed("t", parse_with(svg, inter_only).unwrap(), MAX_GROUP_DEPTH).unwrap(); + let on_disk = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap(); + let in_memory = import_parsed("t", parse_with(svg, inter_only).unwrap(), MAX_GROUP_DEPTH, None, false).unwrap(); assert_eq!(on_disk.document.layers.first().map(|l| l.name.as_str()), Some("IHI")); let outlines = |r: &ImportResult| { let groups = r.document.layers.iter().filter_map(|l| if let LayerContent::Group(g) = &l.content { Some(g) } else { None }); @@ -238,7 +400,7 @@ fn a_font_file_gone_bad_since_indexing_is_not_cached() { let parsed = parse_with(svg, indexed_then_overwritten).unwrap(); let sources: Vec<_> = parsed.tree.fontdb().faces().map(|f| matches!(f.source, usvg::fontdb::Source::Binary(_))).collect(); assert_eq!(sources, [true], "the whole file is read into memory now"); - let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap().document.layers; + let layers = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap().document.layers; assert_eq!(layers.first().map(|l| l.name.as_str()), Some("IHI")); assert!( layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| matches!(&c.content, LayerContent::Shape(s) if !s.path.subpaths.is_empty())))), @@ -436,6 +598,88 @@ fn rgba8_renders_at_its_own_size_within_the_pixel_limit() { assert!(rasterize_rgba8(b""#; + let at_limit = import_svg_bounded("bounded", bytes, 128).unwrap(); + assert_eq!(at_limit.document.layers.len(), 2); + assert!(at_limit.document.layers.iter().all(|l| matches!(l.content, LayerContent::Shape(_)))); + let over = import_svg_bounded("bounded", bytes, 127).unwrap(); + assert!(import_svg_bounded_layers("bounded", bytes, 127).unwrap_err().to_string().contains("cache budget")); + assert_eq!(over.document.layers.len(), 1); + assert!(matches!(over.document.layers[0].content, LayerContent::Raster(_))); + assert!(over.warnings.iter().any(|w| w.contains("cache budget") && w.contains("editable shapes"))); + let surface = over.document.layers[0].surface().unwrap(); + assert_eq!(surface.read_region(Rect::new(1, 1, 2, 2)), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(surface.read_region(Rect::new(6, 1, 7, 2)), vec![0.0, 0.0, 1.0, 1.0]); + // Ordinary file imports retain their existing editable-layer behavior. + assert_eq!(import_svg("unbounded", bytes).unwrap().document.layers.len(), 2); +} + +#[test] +fn bounded_import_rejects_canvas_before_rendering_and_respects_raster_limit() { + let bytes = br#""#; + assert!(import_svg_bounded("bounded", bytes, 0).is_err()); + assert!(import_svg_bounded("bounded", bytes, 63).is_err()); + assert!(import_svg_bounded("bounded", bytes, 64).is_ok()); + let oversized = br#""#; + assert!(import_svg_bounded("bounded", oversized, u64::MAX).unwrap_err().to_string().contains("cache budget")); +} + +/// The smart-object budget is 24,000,000 pixels: a total exactly equal to it fits, one more +/// node of caches does not. Bounds-only callers keep a frame past the layer-cache budget, and +/// the canvas itself is limited at the same exact boundary. +#[test] +fn bounded_canvas_bounds_match_the_import_at_the_exact_24_mp_boundary() { + const BUDGET: u64 = 24_000_000; + let rects = |n: usize| { + format!("{}", "".repeat(n)) + }; + // 2000 × 2000 × 6 = 24,000,000: exactly at the budget, still editable layers. + let at_budget = rects(6); + let strict = import_svg_bounded_layers("at", at_budget.as_bytes(), BUDGET).unwrap(); + assert_eq!(strict.document.layers.len(), 6); + assert_eq!(bounded_canvas_bounds(at_budget.as_bytes(), BUDGET).unwrap(), strict.document.bounds()); + // A seventh node is 28,000,000 pixels of caches: strict layers refuse it, bounds do not. + let over = rects(7); + assert!(import_svg_bounded_layers("over", over.as_bytes(), BUDGET).unwrap_err().to_string().contains("cache budget")); + assert_eq!(bounded_canvas_bounds(over.as_bytes(), BUDGET).unwrap(), Rect::new(0, 0, 2000, 2000)); + // The canvas: 4000 × 6000 is exactly 24 MP and fits; one more row does not. + let canvas = |h: u32| format!(""); + assert_eq!(bounded_canvas_bounds(canvas(6000).as_bytes(), BUDGET).unwrap(), Rect::new(0, 0, 4000, 6000)); + assert!(bounded_canvas_bounds(canvas(6001).as_bytes(), BUDGET).unwrap_err().to_string().contains("cache budget")); + // A larger request never lifts the 24 MP rasterisation limit, and parse budgets still apply. + assert!(bounded_canvas_bounds(canvas(6001).as_bytes(), u64::MAX).is_err()); + assert!(bounded_canvas_bounds(b"{}", + embedded_image( + "image/png", + &[b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".as_slice(), &32768u32.to_be_bytes(), &32768u32.to_be_bytes(), &[8, 6, 0, 0, 0]].concat() + ) + ); + assert!(bounded_canvas_bounds(huge_image.as_bytes(), BUDGET).is_err(), "decoded images keep their budget"); +} +/// A one-font database reproduces Linux hosts without Times New Roman. A missing authored +/// family must still produce text geometry; it must not silently disappear on save-back. +#[test] +fn missing_svg_font_uses_an_available_generic_instead_of_dropping_text() { + fn inter() -> 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) + } + let svg=br#"LOGO"#; + let parsed = parse_with(svg, inter).unwrap(); + assert!(!parsed.fontless_text); + assert!(!parsed.tree.root().children().is_empty(), "missing-family fallback must preserve text geometry"); + let mut db = (*inter()).clone(); + let before = db.query(&usvg::fontdb::Query { families: &[usvg::fontdb::Family::Serif], ..Default::default() }); + resolve_generic_families(&mut db, |_| None); + assert_eq!(db.query(&usvg::fontdb::Query { families: &[usvg::fontdb::Family::Serif], ..Default::default() }), before); +} + #[test] fn resolve_data_rejects_oversized_and_malformed_embedded_png_and_webp() { let opt = options(Arc::new(usvg::fontdb::Database::new()), MAX_XML_DEPTH); @@ -497,3 +741,169 @@ fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() { assert!(resolve("image/svg+xml", Arc::new(twice.clone()), &opt).is_none()); assert!(resolve("text/plain", Arc::new(twice), &opt).is_none()); } + +/// The outer gzip is size-limited by PhotoCraft. Its result must be XML, never another +/// compressed stream which usvg could inflate independently without that byte limit. +#[test] +fn bounded_svg_rejects_nested_gzip_at_top_level_and_inside_data_urls() { + use std::io::Write; + let gz = |bytes: &[u8]| { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + }; + let svg = b""; + let once = gz(svg); + let twice = gz(&once); + assert!(parse_bounded(&once, 64).is_ok(), "ordinary SVGZ remains accepted"); + assert!(parse_bounded(&twice, 64).unwrap_err().to_string().contains("UTF-8")); + assert!(rasterize_bounded(&twice, 1.0, 64).is_err()); + assert!(bounded_canvas_bounds(&twice, 64).is_err()); + for mime in ["image/svg+xml", "text/plain"] { + let single = image_svg(&embedded_image(mime, &once)); + assert!(parse_bounded(single.as_bytes(), 64).is_ok()); + // Inflated once, the doubly compressed image is not SVG text: it is left out, never + // inflated a second time. + let nested = image_svg(&embedded_image(mime, &twice)); + assert!(!parse_bounded(nested.as_bytes(), 64).unwrap().tree.root().has_children()); + } + assert!(parse_bounded(b"\xff\xfe", 64).is_err(), "invalid XML encoding is refused"); +} + +/// An embedded image the bounded resolver can't charge (an unknown MIME type, a format resvg +/// doesn't draw, a PNG under another MIME type, a corrupt PNG) is left out as usvg's own +/// resolver leaves it out, and the rest of the drawing still renders. +#[test] +fn unreadable_embedded_images_are_left_out_and_the_drawing_still_renders() { + let image = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + for (mime, bytes) in [ + ("image/x-foo", png.clone()), + ("application/octet-stream", png.clone()), + ("application/octet-stream", png_dimensions(6000, 5000)), + ("image/png", png_dimensions(0, 4)), + ("image/bmp", b"BM\0\0\0\0".to_vec()), + ("image/png", png[..20].to_vec()), + ] { + let svg = format!( + "{}", + embedded_image(mime, &bytes) + ); + let buf = rasterize_bounded(svg.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap_or_else(|e| panic!("{mime}: {e}")); + let at = |x: usize, y: usize| buf.px[y * 4 + x]; + assert!(at(3, 1)[0] > 0.99 && at(3, 1)[3] > 0.99, "{mime}: the rect still draws"); + assert!(import_svg_bounded("clipboard", svg.as_bytes(), MAX_RASTER_PIXELS).is_ok(), "{mime}"); + } +} + +/// resvg reuses one scratch surface per level of nested SVG images, so side-by-side SVG +/// images (icons in a layout) cost one drawing-sized surface, not one each, and a drawing +/// rendered at up to the 24 MP output limit with one level of them still renders. +#[test] +fn side_by_side_svg_images_need_one_scratch_surface() { + let icon = ""; + let images: String = (0..12) + .map(|i| { + let data = icon.bytes().map(|byte| format!("%{byte:02X}")).collect::(); + format!("", i * 150) + }) + .collect(); + let layout = format!("{images}"); + assert!(parse_bounded(layout.as_bytes(), 24_000_000).is_ok(), "12 icons on a 2 MP canvas"); + let buf = rasterize_bounded(layout.as_bytes(), 2.0, 24_000_000).unwrap(); + assert_eq!((buf.rect.width(), buf.rect.height()), (3840, 2160)); + assert!(buf.px[100 * 3840 + 100][2] > 0.99, "the first icon draws"); + let one = format!("{}", &images[..images.find("/>").unwrap() + 2]); + let big = rasterize_bounded(one.as_bytes(), 10.0, 24_000_000).unwrap(); + assert_eq!((big.rect.width(), big.rect.height()), (4000, 3001), "a 12 MP render with one SVG image"); +} + +/// usvg parses an embedded SVG on top of the drawing's own recursion: 511 + 511 levels, each fine +/// alone, overflowed a 1 MB stack. The bounded path shares [`MAX_XML_DEPTH`] between them too. +#[test] +fn bounded_embedded_svg_gets_only_the_nesting_depth_the_drawing_left() { + let nested = |depth: usize| { + format!( + "{}{}", + "".repeat(depth), + "".repeat(depth) + ) + }; + let embed = move |outer: usize, inner: usize| { + let data = nested(inner).bytes().map(|byte| format!("%{byte:02X}")).collect::(); + format!( + "{}{}", + "".repeat(outer), + "".repeat(outer) + ) + }; + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let deep = embed(MAX_XML_DEPTH - 2, MAX_XML_DEPTH - 2); + let buf = rasterize_bounded(deep.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap(); + assert!(buf.px[4 * 8 + 4][3] < 0.01, "the too-deep image is left out"); + let fine = embed(200, 200); + let buf = rasterize_bounded(fine.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap(); + assert!(buf.px[4 * 8 + 4][3] > 0.99, "within the shared budget the image draws"); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Each level of embedded SVG image adds the parser's and renderer's frames on top of the one it +/// sits in. Every chain the bounded path takes (base64 grows each level, so the XML byte budget +/// ends it before [`MAX_EMBEDDED_SVG_DEPTH`]) parses and renders on a 1 MB thread stack (Windows' +/// main-thread size); deeper ones are refused. +#[test] +fn the_deepest_chain_of_embedded_svg_images_fits_a_small_stack() { + fn base64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..4 { + out.push(if i <= chunk.len() { T[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' }); + } + } + out + } + let chain = |levels: usize| { + let mut svg = "".to_string(); + for _ in 0..levels { + svg = format!( + "", + base64(svg.as_bytes()) + ); + } + svg + }; + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let mut accepted = 0; + for levels in 1..=MAX_EMBEDDED_SVG_DEPTH + 1 { + match rasterize_bounded(chain(levels).as_bytes(), 1.0, MAX_RASTER_PIXELS) { + Ok(buf) => { + assert!(buf.px[4 * 8 + 4][3] > 0.99, "{levels} levels: the innermost rect draws through every level"); + accepted = levels; + } + Err(_) => assert!(accepted > 0, "a one-level chain renders"), + } + } + assert!((20..=MAX_EMBEDDED_SVG_DEPTH).contains(&accepted), "deepest accepted chain: {accepted}"); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Only a stored display preview is strict about an embedded image it can't read; an edited SVG +/// saved back from an external editor leaves it out, as placement does. +#[test] +fn only_stored_previews_refuse_unreadable_embedded_images() { + let svg = image_svg(&embedded_image("image/bmp", b"BM\0\0\0\0")); + assert!(import_svg_bounded_with_work("edit", svg.as_bytes(), MAX_RASTER_PIXELS).is_ok()); + assert!(import_svg_preview_with_work("preview", svg.as_bytes(), MAX_RASTER_PIXELS).is_err()); +} diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index ba35e53fed..619e9e53c9 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -21,7 +21,7 @@ use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; -use crate::psd_import::psd_to_document; +use crate::psd_import::psd_to_document_owned; use crate::{ExportOptions, ExportResult, ImportResult, IoError}; /// `true` when saving `doc` as TIFF writes layer data: anything beyond a single Background @@ -48,19 +48,64 @@ fn set_resource(resources: &mut Vec, id: u16, data: Option Result { - let mut warnings = Vec::new(); - let (isd, w) = match ImageSourceData::from_bytes(layers) { +/// Everything a layered TIFF page's Photoshop tags say, read before any pixel is decoded. +/// +/// It is built from a [`codecs::TiffBands`] page's dimensions, layout, sample type, +/// extra-sample count, metadata and decoder scratch bound, never from its pixels, so the whole +/// import is admitted through the adaptive budget before the composite is decoded. +pub(crate) struct LayeredPlan { + /// The PSD model the layer tag rebuilds, or `None` when the tag could not be read and the + /// page opens flattened. + model: Option>, + /// Why the page opens flattened, when the layer tag did not parse. + flatten_warning: Option, + /// Warnings the layer tag and the image resources produced. + warnings: Vec, + /// Bytes this import needs beside what it already holds, for admission; see + /// [`layered_import_bytes`]. + pub(crate) required: u64, + /// The part of `required` that is tile pixels, spillable to the scratch disk + /// ([`crate::psd_budget::PsdMemoryEstimate::tile_bytes`]); 0 for a flattened fallback, whose + /// admission charge is decode and conversion memory. + pub(crate) tiles: u64, +} + +struct LayeredModel { + /// The PSD model with an empty merged image: the TIFF's pixels replace it after decoding. + file: photocraft_psd::PsdFile, +} + +impl LayeredPlan { + /// Whether the page's layer tag parsed into a PSD model, as opposed to opening flattened. + /// + /// A flattened fallback still reads the page's image resources for channel names and spot + /// flags, so the decoder's copies stay available; a layered page's tags are already in the + /// plan and the decoder's copies can be dropped. + pub(crate) fn is_layered(&self) -> bool { + self.model.is_some() + } +} + +/// Reads a layered TIFF page's layer tag and image resources into a [`LayeredPlan`] without +/// decoding any pixels, and checks the group nesting depth and the PSD memory estimate. +pub(crate) fn preflight(bands: &codecs::TiffBands<'_>) -> Result { + let (w, h) = bands.dimensions(); + let (layout, sample) = (bands.layout(), bands.sample_type()); + let extras = bands.extra_samples(); + let scratch = bands.decode_scratch_bytes(); + let layers = bands.meta.photoshop_layers.as_deref().unwrap_or_default(); + let (isd, isd_warnings) = match ImageSourceData::from_bytes(layers) { Ok(v) => v, Err(e) => { - let mut r = image_to_document(name, img)?; - r.warnings.push(format!("the PSD layer data could not be read ({e}); opened flattened")); - return Ok(r); + // Malformed layer data opens flat, but the composite it opens still has to be + // bounded: its whole-image decode and the conversion are admitted too. + let warning = format!("the PSD layer data could not be read ({e}); opened flattened"); + let required = crate::flat::flattened_import_bytes(w, h, layout, sample, extras, scratch); + return Ok(LayeredPlan { model: None, flatten_warning: Some(warning), warnings: Vec::new(), required, tiles: 0 }); } }; - warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); - let (mut resources, w) = match img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()).map(resources_from_bytes) { + let mut warnings: Vec = isd_warnings.into_iter().map(|w| format!("layer data: {w}")).collect(); + let (mut resources, resource_warnings) = match bands.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()).map(resources_from_bytes) { Some(Ok(v)) => v, Some(Err(e)) => { warnings.push(format!("the PSD image resources could not be read: {e}")); @@ -68,58 +113,40 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result (Vec::new(), Vec::new()), }; - warnings.extend(w.into_iter().map(|w| format!("image resources: {w}"))); - // The profile, resolution, XMP and EXIF are the TIFF's own tags; they win over any copy - // in the resources. - set_resource(&mut resources, ids::ICC_PROFILE, img.icc.clone()); - if let Some((x, y)) = img.meta.dpi.filter(|d| d.0 > 0.0) { + warnings.extend(resource_warnings.into_iter().map(|w| format!("image resources: {w}"))); + // The profile, resolution, XMP and EXIF are the TIFF's own tags; they win over any copy in + // the resources. They are set here, from the page's metadata, because the model is complete + // before the composite exists. + set_resource(&mut resources, ids::ICC_PROFILE, bands.icc.clone()); + if let Some((x, y)) = bands.meta.dpi.filter(|d| d.0 > 0.0) { // Both axes, so the PSD import notes a vertical resolution the document can't keep. let ri = ResolutionInfo { v_res_fixed: ResolutionInfo::from_dpi(f64::from(y)).v_res_fixed, ..ResolutionInfo::from_dpi(f64::from(x)) }; set_resource(&mut resources, ids::RESOLUTION_INFO, Some(ri.to_bytes())); } - if let Some(xmp) = &img.meta.xmp { + if let Some(xmp) = &bands.meta.xmp { set_resource(&mut resources, ids::XMP, Some(xmp.as_bytes().to_vec())); } - if let Some(exif) = &img.meta.exif { + if let Some(exif) = &bands.meta.exif { set_resource(&mut resources, ids::EXIF, Some(exif.clone())); } // The TIFF's pixels become the PSD's merged image: planar, big-endian, CMYK inverted. - let img = if img.sample_type() == CSample::F16 { - warnings.push("16-bit float samples are stored as 32-bit float".to_string()); - std::borrow::Cow::Owned(img.convert(img.layout(), CSample::F32)) - } else { - std::borrow::Cow::Borrowed(img) + let mode = match layout { + ChannelLayout::Gray | ChannelLayout::GrayA => PsdMode::Grayscale, + ChannelLayout::Rgb | ChannelLayout::Rgba => PsdMode::Rgb, + ChannelLayout::Cmyk | ChannelLayout::CmykA => PsdMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => PsdMode::Lab, }; - let (mode, color_channels) = match img.layout() { - ChannelLayout::Gray | ChannelLayout::GrayA => (PsdMode::Grayscale, 1), - ChannelLayout::Rgb | ChannelLayout::Rgba => (PsdMode::Rgb, 3), - ChannelLayout::Cmyk | ChannelLayout::CmykA => (PsdMode::Cmyk, 4), - ChannelLayout::Lab | ChannelLayout::LabA => (PsdMode::Lab, 3), - }; - let depth = match img.sample_type() { + let depth = match sample { CSample::U8 => 8, CSample::U16 => 16, CSample::F16 | CSample::F32 => 32, }; - let (w, h) = img.dimensions(); - let mut data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; - let bps = img.sample_type().bytes(); - let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps); - let mut added = 0usize; - for e in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES) { - match native_plane_to_be(&e.data, bps) { - Ok(be) if be.len() == plane_len => { - data.extend(be); - added += 1; - } - _ => warnings.push("an extra TIFF sample did not match the image and was dropped".into()), - } - } - let channels = u16::try_from(img.layout().channels().saturating_add(added)).map_err(|_| IoError::Unsupported("too many channels".into()))?; + // The extra samples that fit the image count towards the channels the estimate sees; the + // codec already capped them at [`codecs::MAX_EXTRA_SAMPLES`]. + let channels = u16::try_from(layout.channels().saturating_add(extras)).map_err(|_| IoError::Unsupported("too many channels".into()))?; let header = Header::new(isd.version, w, h, channels, depth, mode); - let image_data = ImageData::new(Compression::Raw, data); - let mut file = isd.into_psd(header, resources, image_data); + let mut file = isd.into_psd(header, resources, ImageData::new(Compression::Raw, Vec::new())); // Empty `Patt` and the default filter mask are written so Photoshop's block list matches // a file it saved. They carry nothing the document stores, so they are not kept: a // round trip would otherwise grow `metadata.psd_global_blocks` on every open. @@ -130,11 +157,102 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result u64 { + let pixels = u64::from(w).saturating_mul(u64::from(h)); + let ch = channels as u64; + let e = extras as u64; + let in_bps = sample.bytes() as u64; + let out_bps = if sample == CSample::F16 { CSample::F32.bytes() as u64 } else { in_bps }; + let decoded = pixels.saturating_mul(ch.saturating_add(e.saturating_mul(2))).saturating_mul(in_bps); + // A normalized 32-bit buffer and the 32-bit result exist beside the 16-bit source. + let float_convert = if sample == CSample::F16 { pixels.saturating_mul(ch.saturating_add(e)).saturating_mul(8) } else { 0 }; + let planar = pixels.saturating_mul(ch.saturating_add(e)).saturating_mul(out_bps); + let plane = if e > 0 { pixels.saturating_mul(out_bps) } else { 0 }; + est.saturating_add(decoded).saturating_add(float_convert).saturating_add(planar).saturating_add(plane).saturating_add(scratch).saturating_add(tags as u64) +} + +/// Opens a layered TIFF page from its decoded `img`, using the model and estimate [`preflight`] +/// read from the same page's tags. +/// +/// The caller has admitted `plan.required` through the adaptive budget and holds decode +/// admission before any pixel is decoded, so nothing here resolves a second budget or charges +/// the enclosing attempt again: a top-level TIFF is charged once, and a nested independent one +/// spends only what its parent still has. `ctl` stops the PSD conversion between layers. +pub(crate) fn import_layered(name: &str, img: &Image, plan: LayeredPlan, ctl: &photocraft_raster::Interrupt) -> Result { + let LayeredPlan { model, flatten_warning, mut warnings, required: _, tiles: _ } = plan; + let Some(model) = model else { + let mut r = image_to_document(name, img)?; + if let Some(warning) = flatten_warning { + r.warnings.push(warning); + } + return Ok(r); + }; + let LayeredModel { mut file } = *model; + let mode = match img.layout() { + ChannelLayout::Gray | ChannelLayout::GrayA => PsdMode::Grayscale, + ChannelLayout::Rgb | ChannelLayout::Rgba => PsdMode::Rgb, + ChannelLayout::Cmyk | ChannelLayout::CmykA => PsdMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => PsdMode::Lab, + }; + let color_channels = img.layout().color_channels(); + // A half-float image is stored as 32-bit float; it is converted here, after admission, as + // the conversion allocates several times the decoded image (charged by the estimate). + let bps = if img.sample_type() == CSample::F16 { CSample::F32.bytes() } else { img.sample_type().bytes() }; + let (w, h) = img.dimensions(); + let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps); + let img = if img.sample_type() == CSample::F16 { + warnings.push("16-bit float samples are stored as 32-bit float".to_string()); + std::borrow::Cow::Owned(img.convert(img.layout(), CSample::F32)) + } else { + std::borrow::Cow::Borrowed(img) + }; + let mut data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; + let mut added = 0usize; + for e in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES) { + match native_plane_to_be(&e.data, bps) { + Ok(be) if be.len() == plane_len => { + data.try_reserve(be.len()).map_err(|_| IoError::OutOfMemory("not enough memory for the TIFF extra samples".into()))?; + data.extend(be); + added += 1; + } + Err(error @ IoError::OutOfMemory(_)) => return Err(error), + _ => warnings.push("an extra TIFF sample did not match the image and was dropped".into()), + } + } + file.header.channels = u16::try_from(img.layout().channels().saturating_add(added)).map_err(|_| IoError::Unsupported("too many channels".into()))?; + file.image_data = ImageData::new(Compression::Raw, data); // Spot planes are read in Photoshop's polarity (0 = solid ink), as from a PSD. The importer's // rule for PSDs from earlier PhotoCraft versions never applies: those versions stored no // extra samples in a TIFF, and a TIFF with them carries 1053. - let (mut doc, w) = psd_to_document(&file); - warnings.extend(w); + let (mut doc, psd_warnings, _, fatal_error) = psd_to_document_owned(file, ctl, None).ok_or(IoError::Cancelled)?; + if let Some(error) = fatal_error { + return Err(error); + } + warnings.extend(psd_warnings); doc.name = name.to_string(); if doc.icc_profile.is_none() { doc.icc_profile = img.icc.clone().map(Arc::new); @@ -237,15 +355,19 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // on the encoded file size is not used here: PSB-style data is written only past 30000 px, // when asked for, or when a length really overflows 32 bits. let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some(); + // Save Transparency (see `ExportOptions::tiff_transparency`): off, the composite is matted. + let save_transparency = opts.tiff_transparency.unwrap_or(!user_channels); let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { force_psb, size_limit_psb: false, - merged_matte: user_channels, + merged_matte: !save_transparency, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), read_linked_files: opts.read_linked_files, + // The layered TIFF reads the merged image's bytes directly: they stay in memory. + stage_on_scratch_from: None, }; // TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap // or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter @@ -263,7 +385,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = match tiff_image_source(doc, &file).to_bytes(order) { + let mut has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + // The layers move out of the model rather than being copied, and are freed once written + // (#6607); the model keeps its header, resources and composite for the image below. + let (layers, w) = match tiff_image_source(doc, &mut file, save_transparency && has_alpha).into_bytes(order) { Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); @@ -271,7 +396,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr warnings.extend(w); warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); file = psb; - tiff_image_source(doc, &file).to_bytes(order)? + has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + tiff_image_source(doc, &mut file, save_transparency && has_alpha).into_bytes(order)? } r => r?, }; @@ -285,26 +411,28 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); - let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); let channels = cc + usize::from(has_alpha); let extra_n = usize::from(file.header.channels).saturating_sub(channels); let kept = extra_n.min(codecs::MAX_EXTRA_SAMPLES); if extra_n > kept { warnings.push(format!("{extra_n} extra channels; TIFF keeps the first {kept}")); } - // With spot or alpha channels, the IFD image is colour + those channels. The merged - // transparency plane stays out of the IFD (see above); a file with no extra channels keeps - // it, so a plain transparent TIFF still opens with alpha. - let write_alpha = has_alpha && kept == 0; + // The IFD image is colour, then the transparency with Save Transparency on, then the spot and + // alpha channels. With it on, the samples are the PSD merged image's channels in order, so + // the PSD model's channel resources (which list the transparency first) are kept as they are. + let write_alpha = has_alpha && save_transparency; // One identifier per extra sample actually stored. The PSD model's 1053 lists the merged - // transparency too, which the IFD leaves out next to extra samples, so it is not copied. - resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); - if kept > 0 { - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); + // transparency too, which the IFD leaves out without it, so it is not copied then. + if !write_alpha { + resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + if kept > 0 { + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); + } } // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. - // The IFD below does not, so the id moves back by one. + // An IFD without it does not, so the id moves back by one. if has_alpha + && !write_alpha && kept > 0 && let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) && r.data.len() >= 2 @@ -322,8 +450,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr } // Past MAX_EXTRA_SAMPLES the TIFF stores only the first `kept` channels: the name and // DisplayInfo resources describe just those, and a Quick Mask among the rest is dropped. + // With the transparency written, those lists hold it ahead of the channels, and the + // channels sit one sample later in the IFD. if extra_n > kept { - keep_first_channels(&mut resources, kept, cc); + keep_first_channels(&mut resources, kept + usize::from(write_alpha), cc); } let resources = resources_to_bytes(&resources)?; let ifd_channels = cc + usize::from(write_alpha); @@ -332,6 +462,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let cmyk = fmt.mode == ColorMode::Cmyk; let data = interleave(&file.image_data.data, w, h, ifd_channels, cc, sample, cmyk)?; let extras = native_extra_planes(&file.image_data.data, w, h, channels, kept, sample)?; + // Likewise the planar composite, now interleaved. + drop(file); ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); let layout = layout_for(fmt.mode, write_alpha); @@ -354,26 +486,35 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr } /// The layer data (`ImageSourceData`) of a layered TIFF saved from `file`, adjusted the way -/// Photoshop writes it. -fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSourceData { +/// Photoshop writes it. The layers, global mask and global blocks move out of `file` (the +/// export's own model), which keeps its header, resources and merged image. `ifd_alpha`: the +/// composite in the IFD keeps its transparency as the first extra sample (Save Transparency). +fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile, ifd_alpha: bool) -> ImageSourceData { // Photoshop pairs alpha-name resources with samples past the colour channels and ignores - // the ExtraSamples kind. The merged transparency must not occupy that first slot when a - // spot is also stored. Layer transparency stays in each layer's channel −1. - let mut isd = ImageSourceData::from_psd(file); + // the ExtraSamples kind. Layer transparency stays in each layer's channel −1. let spot_extras = file.header.channels as usize > { let cc = doc.pixel_format().mode.color_channels(); let alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); cc + usize::from(alpha) }; - // Positive layer count: the merged image's first extra sample is not transparency - // (Photoshop's own spot TIFF does this; a negative count would claim the spot plane is alpha). - if spot_extras && let Some(info) = &mut isd.layer_info { - info.merged_alpha = false; - } - // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the - // first spot read back as transparency. The PSD export already drops it over an opaque - // composite; a transparent one keeps it there, but the TIFF leaves that sample out here. - if spot_extras { + let mut isd = ImageSourceData::take_from_psd(file); + if ifd_alpha && spot_extras { + // Save Transparency next to spot or alpha channels, as Photoshop (27.11) writes it: a + // positive layer count and an `MTrn` block first in the layer data mark the first extra + // sample as the merged transparency. + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + } else if !ifd_alpha { + // Positive layer count: the merged image's first extra sample, if any, is not + // transparency (Photoshop's own spot TIFF does this; a negative count would claim the + // spot plane is alpha). + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make + // the first spot read back as transparency. The PSD export already drops it over an + // opaque composite; a transparent one keeps it there, but the IFD leaves that sample out. isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); } if let Some(info) = &mut isd.layer_info { @@ -399,6 +540,10 @@ fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSou if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") { isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec())); } + // Photoshop writes the marker first, ahead of `Patt`. + if ifd_alpha && spot_extras && !isd.global_blocks.iter().any(|b| b.is_merged_transparency_marker()) { + isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"MTrn", Vec::new())); + } isd } @@ -553,12 +698,16 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec Result, IoError> { if bps == 1 { - return Ok(src.to_vec()); + let mut out = Vec::new(); + out.try_reserve_exact(src.len()).map_err(|_| IoError::OutOfMemory("not enough memory for a TIFF extra sample".into()))?; + out.extend_from_slice(src); + return Ok(out); } if !matches!(bps, 2 | 4) || !src.len().is_multiple_of(bps) { return Err(IoError::Unsupported("an extra channel plane is incomplete".into())); } - let mut out = Vec::with_capacity(src.len()); + let mut out = Vec::new(); + out.try_reserve_exact(src.len()).map_err(|_| IoError::OutOfMemory("not enough memory for a TIFF extra sample".into()))?; for c in src.chunks_exact(bps) { match (bps, to_be, c) { (2, true, [a, b]) => { @@ -668,7 +817,11 @@ fn matte_and_drop_alpha(img: &mut Image) -> bool { let white_f: f32 = if cmyk { 0.0 } else { 1.0 }; // Lab's white is L* 100 with a* and b* 0, which PhotoCraft holds mid-range (`(0 + 128) / 255`). let lab = img.layout().is_lab(); - let mut out = Vec::with_capacity(img.data().len() / stride * keep); + // Out of memory keeps the transparency (the caller warns) rather than aborting the app. + let mut out = Vec::new(); + if out.try_reserve_exact(img.data().len() / stride * keep).is_err() { + return false; + } for px in img.data().chunks_exact(stride) { let Some(color) = px.get(..keep) else { return false }; let Some(alpha) = px.get(keep..keep.saturating_add(bps)) else { return false }; @@ -777,12 +930,27 @@ fn ne_f32(b: &[u8]) -> Option { Some(f32::from_ne_bytes(arr)) } +/// A flat TIFF's composite under Save Transparency (`ExportOptions::tiff_transparency`), then +/// its alpha and spot channels ([`stamp_flat_channels`]). Off, the image is matted over white +/// and keeps no transparency; on, it keeps it next to the channels; unset, it keeps it only +/// when there are no channels. +pub(crate) fn flat_transparency_and_channels(doc: &Document, img: &mut Image, save_transparency: Option) -> Vec { + let mut warnings = Vec::new(); + if save_transparency == Some(false) && img.layout().has_alpha() && !matte_and_drop_alpha(img) { + warnings.push("the image transparency could not be composited over white and was kept".into()); + } + warnings.extend(stamp_flat_channels(doc, img, save_transparency == Some(true))); + warnings +} + /// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the /// IFD, plus Photoshop resources for the name, ink colour and solidity. At most /// [`codecs::MAX_EXTRA_SAMPLES`] are kept. A TIFF with no extra channels is left unchanged. /// When any are written, the composite's own transparency is left out of the IFD: readers pair /// the channel resources with every sample past the colour channels, in order. -pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec { +/// `keep_transparency` (Save Transparency on) keeps the composite's transparency as the first +/// extra sample, ahead of the channels; off, the image is matted over white first. +pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transparency: bool) -> Vec { let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some())); if total == 0 { return Vec::new(); @@ -825,7 +993,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec Vec img.meta.photoshop_resources = Some(bytes), Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), } @@ -845,7 +1019,13 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec, names: &[&str], channels: &[&AlphaChannel], quick_id: Option) -> Result, IoError> { +fn channel_resources( + existing: Option<&[u8]>, + names: &[&str], + channels: &[&AlphaChannel], + quick_id: Option, + transparency: bool, +) -> Result, IoError> { let mut resources = match existing.filter(|b| !b.is_empty()) { Some(b) => resources_from_bytes(b)?.0, None => Vec::new(), @@ -853,10 +1033,11 @@ fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&Alpha resources.retain(|r| { !matches!(r.id, 1006 | 1045 | crate::channel_map::DISPLAY_INFO | crate::channel_map::QUICK_MASK_INFO | crate::channel_map::ALPHA_IDENTIFIERS) }); - resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); - resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); - resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, names.len()))); + let listed: Vec<&str> = transparency.then_some("Transparency").into_iter().chain(names.iter().copied()).collect(); + resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&listed))); + resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&listed))); + resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, transparency))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(transparency, names.len()))); if let Some(id) = quick_id { let mut data = id.to_be_bytes().to_vec(); data.push(0); @@ -967,7 +1148,7 @@ mod tests { use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent}; use photocraft_raster::Surface; - use crate::{ExportOptions, export}; + use crate::{ExportOptions, ImportOptions, IoError, export}; /// Hidden solid fills on a 2048² canvas: counted as canvas-sized planes (16 MiB each at 8 /// bits), 140 of them pass 2 GB, while their real pixels (the cached rendering, pruned to @@ -986,6 +1167,30 @@ mod tests { doc } + /// A small two-layer RGB document exported as a TIFF with Photoshop layer data: the real + /// file the admission tests below import. No fixture is decoded at a size near a threshold. + fn layered_tiff(w: u32, h: u32) -> Vec { + let mut doc = Document::new("charge.tif", photocraft_geom::Size::new(w, h), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + let bytes = (w as usize * h as usize * fmt.bytes_per_pixel()).max(1); + for name in ["bottom", "top"] { + let mut s = Surface::new(fmt); + s.write_interleaved(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &vec![96u8; bytes]); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + export(&doc, "charge.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).expect("export layered TIFF").bytes + } + + /// The bytes of the layered TIFF's decoded composite: the size of both the planar copy the + /// importer builds and the interleaved image it keeps beside it. + fn composite_bytes(bytes: &[u8]) -> u64 { + let img = photocraft_codecs::decode_with(bytes, &photocraft_codecs::DecodeOptions { keep_orientation: true, ..Default::default() }) + .expect("decode the composite"); + let (w, h) = img.dimensions(); + let sample = u64::try_from(img.sample_type().bytes()).unwrap_or(1); + u64::from(w).saturating_mul(u64::from(h)).saturating_mul(sample).saturating_mul(img.layout().channels() as u64) + } + #[test] fn layered_tiff_ignores_the_uncompressed_size_estimate() { let doc = many_cheap_fills(); @@ -1010,4 +1215,337 @@ mod tests { let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&r.bytes); assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB)); } + + /// The adaptive budget, not only the caller's pixel limit, decides a layered TIFF: a known + /// zero reading refuses the import before the planar composite is allocated, while a normal + /// machine opens the same file. Before this route the fixed 2 GiB default admitted it. + #[test] + fn layered_tiff_import_honours_a_known_zero_adaptive_budget() { + let bytes = layered_tiff(8, 6); + let zero = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(zero, || match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdImportBudget { limit: 0, .. }) => {} + other => panic!("expected an import-budget refusal, got {other:?}"), + }); + + let plenty = crate::memory::MemorySnapshot { available_ram: Some(16 << 30), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(plenty, || { + let opened = crate::import_with_options("plenty.tif", &bytes, &ImportOptions::default()).expect("a normal machine opens it"); + assert_eq!(opened.document.layers.len(), 2); + }); + } + + /// Approval and its estimate bound reach the TIFF route: the same options that a PSD takes + /// decide the layered TIFF's composite, layer and retained-image charge. The fixture is tiny; + /// the threshold charge is a thread-local test seam, so nothing near a gigabyte is allocated. + #[test] + fn layered_tiff_import_uses_the_adaptive_budget_approval_options() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let bytes = layered_tiff(8, 6); + // 12 GiB: S = 6 GiB, H = 9 GiB. + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snapshot, || { + let plain = crate::import_with_options("l.tif", &bytes, &ImportOptions::default()).expect("automatic").document.layers.len(); + crate::psd_budget::with_test_estimate_charge(7 * gb, || { + let required = match crate::import_with_options("l.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + assert_eq!((soft_limit, hard_limit), (6 * gb, 9 * gb)); + assert_eq!(available_ram, Some(12 * gb)); + required + } + other => panic!("expected an approval request, got {other:?}"), + }; + assert!((7 * gb..=9 * gb).contains(&required), "the charge lands in the approval band: {required}"); + + // Approval opens the same file. + let approved = ImportOptions { psd_high_memory_approved: true, ..Default::default() }; + assert_eq!(crate::import_with_options("l.tif", &bytes, &approved).expect("approved").document.layers.len(), plain); + // A matching one-shot estimate bound is enough; a smaller one needs fresh consent. + let bounded = ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required), ..Default::default() }; + assert!(crate::import_with_options("l.tif", &bytes, &bounded).is_ok()); + let stale = ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required - 1), ..Default::default() }; + assert!(matches!(crate::import_with_options("l.tif", &bytes, &stale), Err(IoError::PsdApprovalRequired { .. }))); + // Approval never bypasses a lower caller limit. + let lowered = ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() }; + assert!(matches!( + crate::import_with_options("l.tif", &bytes, &lowered), + Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1 + )); + }); + }); + } + + /// A real nested layered TIFF spends the enclosing attempt's remaining allowance: the charge + /// covers the planar composite and the decoded image kept beside it, and a refused nested + /// import neither waits for a fresh gate nor resets the parent's budget. + #[test] + fn real_nested_layered_tiff_import_is_charged_without_resetting_the_parent() { + let bytes = layered_tiff(8, 6); + let composite = composite_bytes(&bytes); + assert!(composite > 0); + let ctl = photocraft_raster::Interrupt::NONE; + + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).expect("parent admission"); + let start = 1u64 << 40; + crate::psd_gate::set_active_attempt_budget(start); + crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl).expect("nested import"); + let remaining = crate::psd_gate::active_attempt_remaining().expect("the parent attempt keeps an allowance"); + let charged = start - remaining; + assert!( + charged >= composite.saturating_mul(2), + "both the planar composite ({composite}) and the decoded image kept beside it are charged; charged {charged}" + ); + assert!(crate::psd_gate::is_holding_admission(), "the nested import must not release the parent's admission"); + + // One byte short: the real import is refused, the reason is the charge, and the parent's + // remaining allowance is untouched. + crate::psd_gate::set_active_attempt_budget(charged - 1); + match crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (charged, charged - 1)), + other => panic!("expected an import-budget refusal, got {other:?}"), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(charged - 1), "a failed nested import leaves the allowance as it was"); + + // Exactly enough succeeds and leaves nothing, so the charge is not reset per attempt. + crate::psd_gate::set_active_attempt_budget(charged); + crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl).expect("exact allowance"); + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(0)); + } + + /// A cancelled job stops waiting for the PSD decode gate: the interrupt reaches the layered + /// TIFF's gate wait, so it does not block behind another decode until that one finishes. + #[test] + fn cancelled_layered_tiff_import_stops_waiting_for_the_gate() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::{Duration, Instant}; + + let bytes = layered_tiff(8, 6); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(32 << 30), swap_free: None, commit_headroom: None }; + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE).expect("active decode"); + + let cancel = Arc::new(AtomicBool::new(false)); + let prechecked = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + + let handle = { + let cancel = Arc::clone(&cancel); + let prechecked = Arc::clone(&prechecked); + std::thread::spawn(move || { + let provider = std::rc::Rc::new(move || { + prechecked.store(true, Ordering::SeqCst); + snapshot + }); + let cancelled = move || cancel.load(Ordering::SeqCst); + let progress = |_: f32| {}; + let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress); + let result = crate::memory::with_mock_memory_provider(provider, || { + crate::import_with_options_and_interrupt("cancel.tif", &bytes, &ImportOptions::default(), &ctl) + }); + tx.send(result.map(|r| r.document.layers.len())).ok(); + }) + }; + + // The provider's first call is the precheck, which happens before the gate wait. + let deadline = Instant::now() + Duration::from_secs(10); + while !prechecked.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the import never reached its admission precheck"); + std::thread::yield_now(); + } + cancel.store(true, Ordering::SeqCst); + + // This thread still holds the gate, so the only way to answer is to abort the wait. + let result = rx.recv_timeout(Duration::from_secs(10)).expect("a cancelled import must stop waiting for the gate"); + match result { + Err(IoError::Cancelled) => {} + other => panic!("expected the cancelled import to fail, got {other:?}"), + } + + drop(_guard); + handle.join().expect("worker thread"); + } + + /// The estimate covers what the layered import holds besides the PSD layer model: the + /// decoded composite, the planar merged copy, the decoder's extra-sample planes and scratch, + /// the tags it copies, and a half-float conversion's extra buffers. + #[test] + fn layered_estimate_counts_decode_conversion_extras_and_scratch() { + use photocraft_codecs::SampleType as S; + // 100 × 50 px, RGB, 8-bit, no extras: the decoded image plus the planar copy. + let pixels = 100u64 * 50; + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::U8, 0, 0, 0), 2 * pixels * 3); + // Two extra samples add their interleaved buffer, their split planes and one big-endian + // plane copied into the merged image. + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::U8, 2, 0, 0), pixels * 13); + // A half-float page is converted: the 16-bit image and planes stay alive beside the + // normalized 32-bit buffer and the 32-bit result. + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::F16, 0, 0, 0), pixels * (3 * 2 + 3 * 8 + 3 * 4)); + let base = super::layered_import_bytes(0, 100, 50, 3, S::U8, 0, 0, 0); + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::U8, 0, 1234, 99), base + 1234 + 99); + assert_eq!(super::layered_import_bytes(u64::MAX, 100, 50, 3, S::U8, 0, 0, 0), u64::MAX, "an estimate never wraps into an admitted one"); + } + + /// A TIFF whose layer tag cannot be read still opens flattened, and its whole-image decode + /// and conversion are admitted like a layered page: a refused file never reaches the decode, + /// and the flatten warning is unchanged. + #[test] + fn malformed_layer_tag_bounds_the_composite_before_it_is_decoded() { + use std::cell::Cell; + use std::rc::Rc; + + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![40; 4 * 3 * 3]).unwrap(); + img.meta.photoshop_layers = Some(b"not Photoshop image source data".to_vec()); + let bytes = photocraft_codecs::encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + + let decoded = Rc::new(Cell::new(0u32)); + let hook = |decoded: &Rc>| -> Rc { + let decoded = Rc::clone(decoded); + Rc::new(move || decoded.set(decoded.get() + 1)) + }; + + let zero = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(zero, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + match crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + other => panic!("expected an import-budget refusal, got {other:?}"), + } + assert_eq!(decoded.get(), 0, "a refused file must not decode its composite"); + }); + }); + + let plenty = crate::memory::MemorySnapshot { available_ram: Some(16 << 30), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(plenty, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + let opened = crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()).expect("a normal machine opens the composite"); + assert_eq!(opened.document.layers.len(), 1); + assert!(opened.warnings.iter().any(|w| w.contains("opened flattened")), "{:?}", opened.warnings); + assert_eq!(decoded.get(), 1, "the admitted import decodes the composite exactly once"); + }); + }); + } + + /// Admission happens before a layered TIFF's composite is decoded: a known-zero budget and + /// an approval-needed estimate both refuse the file without reaching the decode, and an + /// approved import does reach it. The fixture is the real tiny layered TIFF; the estimate + /// threshold is the scoped test seam, so nothing near a gigabyte is allocated. + #[test] + fn layered_tiff_is_refused_before_its_composite_is_decoded() { + use std::cell::Cell; + use std::rc::Rc; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let bytes = layered_tiff(8, 6); + let decoded = Rc::new(Cell::new(0u32)); + let hook = |decoded: &Rc>| -> Rc { + let decoded = Rc::clone(decoded); + Rc::new(move || decoded.set(decoded.get() + 1)) + }; + + let zero = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(zero, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + other => panic!("expected an import-budget refusal, got {other:?}"), + } + assert_eq!(decoded.get(), 0, "a refused file must not decode its composite"); + }); + }); + + // 12 GiB: S = 6 GiB, H = 9 GiB. The seam puts the estimate in the approval band. + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snapshot, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + crate::psd_budget::with_test_estimate_charge(7 * gb, || { + match crate::import_with_options("approval.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, hard_limit, .. }) => { + assert_eq!((soft_limit, hard_limit), (6 * gb, 9 * gb)); + assert!(required > 6 * gb, "the charge reaches the approval band: {required}"); + } + other => panic!("expected an approval request, got {other:?}"), + } + assert_eq!(decoded.get(), 0, "approval is needed before the composite is decoded, not after it"); + + let approved = ImportOptions { psd_high_memory_approved: true, ..Default::default() }; + let opened = crate::import_with_options("approval.tif", &bytes, &approved).expect("approved"); + assert_eq!(opened.document.layers.len(), 2); + assert_eq!(decoded.get(), 1, "the approved import decodes the composite exactly once"); + }); + }); + }); + } + + /// Available memory can drop while a layered TIFF waits for the decode gate: the post-gate + /// recheck must refuse it before the composite is decoded. A per-attempt provider shared with + /// this test only changes the measurement; nothing near a gigabyte is allocated. + #[test] + fn layered_tiff_memory_drop_while_waiting_for_the_gate_refuses_before_decoding() { + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let bytes = layered_tiff(8, 6); + + // Hold the gate so the import stops between its pre-gate check and the post-gate one. + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE).expect("active decode"); + let available = Arc::new(AtomicU64::new(32 * gb)); + let prechecked = Arc::new(AtomicBool::new(false)); + let decoded = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + + let handle = { + let available = Arc::clone(&available); + let prechecked = Arc::clone(&prechecked); + let decoded = Arc::clone(&decoded); + std::thread::spawn(move || { + let provider = Rc::new(move || { + // Read before signalling, so the pre-gate check that the main thread waits + // for has already measured the larger figure. + let ram = available.load(Ordering::SeqCst); + prechecked.store(true, Ordering::SeqCst); + crate::memory::MemorySnapshot { available_ram: Some(ram), swap_free: None, commit_headroom: None } + }); + let hook: Rc = Rc::new(move || decoded.store(true, Ordering::SeqCst)); + let result = crate::memory::with_mock_memory_provider(provider, || { + crate::flat::with_before_composite_decode(hook, || crate::import_with_options("drop.tif", &bytes, &ImportOptions::default())) + }); + tx.send(result.map(|r| r.document.layers.len())).ok(); + }) + }; + + // The provider's first call is the pre-gate admission check. + let deadline = Instant::now() + Duration::from_secs(10); + while !prechecked.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the import never reached its admission precheck"); + std::thread::yield_now(); + } + // The precheck admitted it; now the machine has nothing left. Release the gate afterwards. + available.store(0, Ordering::SeqCst); + drop(_guard); + + let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding"); + match result { + Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"), + } + assert!(!decoded.load(Ordering::SeqCst), "the composite must not be decoded after the post-gate refusal"); + handle.join().expect("worker thread"); + } } diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 621edf47b8..d7667d408b 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -407,11 +407,25 @@ pub fn live_from_vogk(data: &[u8]) -> Option { Some(LiveShape::Rect { rect: rect_of(o)?, radii: [g("topLeft"), g("topRight"), g("bottomRight"), g("bottomLeft")] }) } 5 => Some(LiveShape::Ellipse { rect: rect_of(o)? }), - 4 => Some(LiveShape::Line { - from: point_of(o, "keyOriginLineStart")?, - to: point_of(o, "keyOriginLineEnd")?, - weight: num(o.get("keyOriginLineWeight")).unwrap_or(1.0), - }), + 4 => { + let from = point_of(o, "keyOriginLineStart")?; + let to = point_of(o, "keyOriginLineEnd")?; + let weight = num(o.get("keyOriginLineWeight")).unwrap_or(1.0); + let start = bool_of(o, "keyOriginLineArrowSt"); + let end = bool_of(o, "keyOriginLineArrowEnd"); + let width = num(o.get("keyOriginLineArrWdth")).unwrap_or(0.0); + let length = num(o.get("keyOriginLineArrLngth")).unwrap_or(0.0); + let concavity = num(o.get("keyOriginLineArrConc")).unwrap_or(0.0); + if start || end || width > 0.0 || length > 0.0 { + // Missing or invalid PSD arrow sizes must not prevent later edits. + let width = if width.is_finite() && width >= 1.0 { width } else { 52.0 }; + let length = if length.is_finite() && length >= 1.0 { length } else { 50.0 }; + let concavity = if concavity.is_finite() { concavity.clamp(0.0, 100.0) } else { 0.0 }; + Some(LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity }) + } else { + Some(LiveShape::Line { from, to, weight }) + } + } _ => None, } } @@ -463,6 +477,20 @@ pub fn vogk_bytes(s: &LiveShape, dpi: f32) -> Option> { .with("keyOriginLineArrLngth", Value::Double(0.0)) .with("keyOriginLineArrConc", Value::Integer(0)) } + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + let pnt = |p: [f64; 2]| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(p[0])).with("Vrtc", Value::Double(p[1]))); + let r = [from[0].min(to[0]), from[1].min(to[1]), (from[0] - to[0]).abs(), (from[1] - to[1]).abs()]; + base(4) + .with("keyOriginShapeBBox", bbox(r)) + .with("keyOriginLineEnd", pnt(*to)) + .with("keyOriginLineStart", pnt(*from)) + .with("keyOriginLineWeight", Value::Double(*weight)) + .with("keyOriginLineArrowSt", Value::Boolean(*start)) + .with("keyOriginLineArrowEnd", Value::Boolean(*end)) + .with("keyOriginLineArrWdth", Value::Double(*width)) + .with("keyOriginLineArrLngth", Value::Double(*length)) + .with("keyOriginLineArrConc", Value::Integer(concavity.round() as i32)) + } LiveShape::Polygon { .. } => return None, }; let o = o.with("keyOriginIndex", Value::Integer(0)); @@ -709,6 +737,9 @@ mod tests { LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [1.0, 2.0, 3.0, 4.0] }, LiveShape::Ellipse { rect: [5.0, 6.0, 7.0, 8.0] }, LiveShape::Line { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0 }, + LiveShape::ArrowLine { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 50.0 }, + // Both arrowheads off still needs to retain the adjustable parameters. + LiveShape::ArrowLine { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0, start: false, end: false, width: 31.0, length: 31.0, concavity: 0.0 }, ] { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); diff --git a/crates/io/src/vector_preview.rs b/crates/io/src/vector_preview.rs new file mode 100644 index 0000000000..646d8c73f1 --- /dev/null +++ b/crates/io/src/vector_preview.rs @@ -0,0 +1,567 @@ +//! PhotoCraft's private PSD display-preview block. The linked-layer payload is never replaced. +//! +//! `pcVP` carries a versioned header, BLAKE3 of the native AI/PDF/EPS source, the SVG byte +//! length and a bounded display-only SVG. Other readers may ignore it and use PSD cached pixels. + +use std::sync::Arc; + +use photocraft_doc::VectorPreview; +use photocraft_geom::Size; + +pub const PSD_KEY: [u8; 4] = *b"pcVP"; +const HEADER: &[u8; 8] = b"PCVP\0\0\0\x01"; +const MAX_BYTES: usize = 16 * 1024 * 1024; +const MAX_PIXELS: u64 = 24_000_000; +const PREFIX: usize = 44; + +/// Recognize native-vector payloads by content; names never authorize a preview. +pub fn is_native_source(source: &[u8]) -> bool { + source.starts_with(b"%PDF-") || source.starts_with(b"%!PS-Adobe-") || source.starts_with(&[0xc5, 0xd0, 0xd3, 0xc6]) +} + +#[cfg(test)] +thread_local! { static HASHED_SOURCE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +/// Hash only a supported, bounded source. PSD import caches this result per source UUID. +pub(crate) fn source_hash(source: &[u8]) -> Option<[u8; 32]> { + if source.is_empty() || source.len() > MAX_BYTES || !is_native_source(source) { + return None; + } + #[cfg(test)] + HASHED_SOURCE_BYTES.with(|count| count.set(count.get().saturating_add(source.len()))); + Some(*blake3::hash(source).as_bytes()) +} + +/// Verify the source binding and render limits before retaining a preview from a file. +pub fn validate(preview: &VectorPreview, source: &[u8]) -> Option { + document(preview, source).map(|document| document.size) +} + +/// Recover only the unitless source-pixel frame of a normalized preview, without drawing it. +/// The caller must compute `expected_source_hash` from the actual immutable native source; +/// the preview's claimed digest alone never establishes that binding. Rendering callers must +/// still use [`validate`] before drawing. Unsupported metadata leaves the PSD frame available. +pub fn frame_metadata(preview: &VectorPreview, expected_source_hash: [u8; 32]) -> Option<(f64, f64)> { + if preview.source_hash != expected_source_hash || preview.svg.is_empty() || preview.svg.len() > MAX_BYTES || !crate::svg::is_svg(&preview.svg) { + return None; + } + let text = std::str::from_utf8(&preview.svg).ok()?; + let parsed = roxmltree::Document::parse(text).ok()?; + let root = parsed.root_element(); + if root.tag_name().name() != "svg" { + return None; + } + let dimension = |key| root.attribute(key)?.parse::().ok(); + let (width, height) = dimension("width").zip(dimension("height"))?; + (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 && width <= 16384.0 && height <= 16384.0 && width * height <= MAX_PIXELS as f64) + .then_some((width, height)) +} + +/// Exact source-pixel frame of a normalized preview. Native PSD dimensions can be fractional; +/// the integer display canvas alone must not replace their placement coordinate system. +pub fn frame(preview: &VectorPreview, source: &[u8]) -> Option<(f64, f64)> { + frame_for_svg(&preview.svg, validate(preview, source)?) +} + +/// The source-pixel frame written by a preview's root `width`/`height`, accepted only when it +/// rounds to the validated display canvas `size`. Without unitless dimensions the display canvas +/// itself is the frame; non-finite, oversized or mismatched dimensions give `None`. +pub(crate) fn frame_for_svg(svg: &[u8], size: Size) -> Option<(f64, f64)> { + let text = std::str::from_utf8(svg).ok()?; + let parsed = roxmltree::Document::parse(text).ok()?; + let root = parsed.root_element(); + let dimension = |key| root.attribute(key).and_then(|value| value.parse::().ok()); + let Some((width, height)) = dimension("width").zip(dimension("height")) else { + // A general SVG may spell lengths in physical units. Its validated display canvas is + // the fallback; our normalized native previews always write unitless source pixels. + return Some((f64::from(size.width), f64::from(size.height))); + }; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || width > 16384.0 || height > 16384.0 || width * height > MAX_PIXELS as f64 { + return None; + } + // Match svg::canvas's f32 conversion, minimum size and rounding exactly. A mismatched + // viewport cannot supply trusted source geometry even when the SVG itself can render. + let rounded = |value: f64| ((value as f32).max(1.0).round() as u32).clamp(1, 16384); + (rounded(width) == size.width && rounded(height) == size.height).then_some((width, height)) +} + +/// Bounded display document used for PSD smart-filter source caches, never as embedded contents. +pub(crate) fn document(preview: &VectorPreview, source: &[u8]) -> Option { + if preview.source_hash != source_hash(source)? { + return None; + } + display_document(&preview.svg) +} + +/// Bounded preview parsing after the caller has checked the original-source binding. +fn display_document(svg: &[u8]) -> Option { + display_document_with_budget(svg, 4 * MAX_PIXELS) +} + +#[cfg(test)] +thread_local! { static RENDERED_PREVIEWS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +/// Keep callers that need only a display document separate from aggregate-work accounting. +fn display_document_with_budget(svg: &[u8], max_pixels: u64) -> Option { + display_document_with_work(svg, max_pixels).map(|(document, _)| document) +} + +/// Validate a display-only SVG and report the decode/render allowance it consumed. +fn display_document_with_work(svg: &[u8], max_pixels: u64) -> Option<(photocraft_doc::Document, u64)> { + if max_pixels == 0 || svg.is_empty() || svg.len() > MAX_BYTES || !crate::svg::is_svg(svg) { + return None; + } + #[cfg(test)] + RENDERED_PREVIEWS.with(|count| count.set(count.get().saturating_add(1))); + crate::svg::import_svg_preview_with_work("Preview.svg", svg, max_pixels).ok().map(|(imported, work)| (imported.document, work)) +} + +/// Serialize only a validated preview of the same native source; the blob is four-byte padded. +pub fn encode(preview: &VectorPreview, source: &[u8]) -> Option> { + validate(preview, source)?; + block_bytes(preview) +} + +/// The `pcVP` blob of a preview whose source binding and SVG the caller has validated. +fn block_bytes(preview: &VectorPreview) -> Option> { + let mut out = Vec::with_capacity(PREFIX + preview.svg.len() + 3); + out.extend_from_slice(HEADER); + out.extend_from_slice(&preview.source_hash); + out.extend_from_slice(&u32::try_from(preview.svg.len()).ok()?.to_be_bytes()); + out.extend_from_slice(&preview.svg); + out.resize(out.len().next_multiple_of(4), 0); + Some(out) +} + +/// Reject unknown versions, stale hashes, malformed SVG, oversize payloads and trailing data. +pub fn decode(block: &[u8], source: &[u8]) -> Option { + decode_with_hash(block, source_hash(source)?) +} + +/// PSD imports reuse the hash of their immutable, bounded embedded source. This function +/// never hashes the large source again and still verifies every block's source binding. +pub(crate) fn decode_with_hash(block: &[u8], expected_hash: [u8; 32]) -> Option { + decode_with_hash_budget(block, expected_hash, 4 * MAX_PIXELS).map(|(preview, _)| preview) +} + +/// Check the bounded private block before rendering, retaining its actual validation charge. +fn decode_with_hash_budget(block: &[u8], expected_hash: [u8; 32], max_pixels: u64) -> Option<(VectorPreview, u64)> { + if block.len() > PREFIX + MAX_BYTES + 3 || block.get(..8)? != HEADER { + return None; + } + let source_hash: [u8; 32] = block.get(8..40)?.try_into().ok()?; + let length = usize::try_from(u32::from_be_bytes(block.get(40..44)?.try_into().ok()?)).ok()?; + if length == 0 || length > MAX_BYTES { + return None; + } + let end = PREFIX.checked_add(length)?; + if block.len() != end.next_multiple_of(4) || block.get(end..)?.iter().any(|byte| *byte != 0) { + return None; + } + // Reject mismatched native contents before allocating or parsing any SVG data. + if source_hash != expected_hash { + return None; + } + let svg = block.get(PREFIX..end)?; + let (_, work) = display_document_with_work(svg, max_pixels)?; + Some((VectorPreview { source_hash, svg: Arc::new(svg.to_vec()) }, work)) +} + +/// One PSD import's source/preview identities and aggregate validation budget. Cached instances +/// share the preview allocation and do not spend the work budget again. +pub(crate) struct ImportCache { + source_hashes: std::collections::HashMap>, + previews: std::collections::HashMap<([u8; 32], [u8; 32]), Option>, + remaining_pixels: u64, +} + +impl Default for ImportCache { + /// One file import's cache with the standard 96 MP aggregate preview-work allowance. + fn default() -> Self { + Self::new(96_000_000) + } +} + +impl ImportCache { + /// Start one file operation with an independent aggregate preview-work allowance. + fn new(max_pixels: u64) -> Self { + Self { source_hashes: Default::default(), previews: Default::default(), remaining_pixels: max_pixels } + } + + /// The source callback borrows the first matching immutable linked-layer payload. No disk + /// paths are followed, and an exhausted import does not hash additional unknown sources. + pub(crate) fn decode<'a>(&mut self, id: &str, block: &[u8], source: impl FnOnce() -> Option<&'a [u8]>) -> Option { + if block.len() > PREFIX + MAX_BYTES + 3 || block.get(..8)? != HEADER { + return None; + } + if self.remaining_pixels == 0 && !self.source_hashes.contains_key(id) { + return None; + } + let hash = (*self.source_hashes.entry(id.to_string()).or_insert_with(|| source_hash(source()?)))?; + let key = (hash, *blake3::hash(block).as_bytes()); + if let Some(cached) = self.previews.get(&key) { + return cached.clone(); + } + let limit = self.remaining_pixels; + let decoded = decode_with_hash_budget(block, hash, limit); + let preview = match decoded { + Some((preview, work)) => { + self.remaining_pixels = self.remaining_pixels.saturating_sub(work); + Some(preview) + } + None => { + // A failed parse can already have decoded images. Reserve its full attempted + // allowance, even when no display document was returned. + self.remaining_pixels = self.remaining_pixels.saturating_sub(limit); + None + } + }; + self.previews.insert(key, preview.clone()); + preview + } +} + +/// A native preview validated once per PSD export: its bounded display document (the smart-filter +/// source cache), the exact source-pixel frame (`None` when the SVG's own size does not supply +/// one) and the encoded `pcVP` block. +pub(crate) struct Exported { + pub(crate) document: photocraft_doc::Document, + pub(crate) frame: Option<(f64, f64)>, + pub(crate) block: Vec, +} + +type SourceIdentity = (usize, usize, usize); +type SourceEntry = (Arc>, Option<[u8; 32]>); +type PreviewIdentity = ([u8; 32], [u8; 32]); + +/// One PSD export's source/preview identities and aggregate validation budget, mirroring +/// [`ImportCache`]. Instances sharing one immutable source and preview allocation hash and render +/// once; distinct previews share the import's 96 MP budget. +pub(crate) struct ExportCache { + /// Hash of the actual source bytes by (owning allocation, offset, length). Each entry keeps + /// its owner alive, so an address can never be reused for other bytes during the export. + sources: std::collections::HashMap, + /// Content hash of each preview SVG allocation, kept alive in the same way. + svgs: std::collections::HashMap>, [u8; 32])>, + previews: std::collections::HashMap>>, + remaining_pixels: u64, +} + +impl Default for ExportCache { + /// One PSD export's cache with the standard 96 MP aggregate preview-work allowance. + fn default() -> Self { + Self::new(96_000_000) + } +} + +impl ExportCache { + /// Start one file operation with an independent aggregate preview-work allowance. + fn new(max_pixels: u64) -> Self { + Self { sources: Default::default(), svgs: Default::default(), previews: Default::default(), remaining_pixels: max_pixels } + } + + /// The validated preview of `source`, a byte range of the immutable `owner`. The preview's + /// claimed hash is compared with the hash of those actual bytes on every call; only that + /// hash and the render are reused. + pub(crate) fn get(&mut self, preview: &VectorPreview, owner: &Arc>, source: &[u8]) -> Option> { + let base = owner.as_ptr().addr(); + let offset = source.as_ptr().addr().checked_sub(base)?; + // Only a range of the pinned owner may be identified by its address. + if offset.checked_add(source.len())? > owner.len() || preview.svg.is_empty() || preview.svg.len() > MAX_BYTES { + return None; + } + let key = (base, offset, source.len()); + let svg_key = Arc::as_ptr(&preview.svg).addr(); + // An exhausted export does not hash additional unknown sources or previews. + if self.remaining_pixels == 0 && !(self.sources.contains_key(&key) && self.svgs.contains_key(&svg_key)) { + return None; + } + let hash = self.sources.entry(key).or_insert_with(|| (Arc::clone(owner), source_hash(source))).1?; + if preview.source_hash != hash { + return None; + } + let svg_hash = self.svgs.entry(svg_key).or_insert_with(|| (Arc::clone(&preview.svg), *blake3::hash(&preview.svg).as_bytes())).1; + if let Some(cached) = self.previews.get(&(hash, svg_hash)) { + return cached.clone(); + } + let limit = self.remaining_pixels; + let exported = match display_document_with_work(&preview.svg, limit) { + Some((document, work)) => { + self.remaining_pixels = self.remaining_pixels.saturating_sub(work); + let frame = frame_for_svg(&preview.svg, document.size); + block_bytes(preview).map(|block| Arc::new(Exported { document, frame, block })) + } + None => { + // Failed validation may already have spent its whole decode allowance. + self.remaining_pixels = self.remaining_pixels.saturating_sub(limit); + None + } + }; + self.previews.insert((hash, svg_hash), exported.clone()); + exported + } +} + +#[cfg(test)] +mod tests { + + /// Frame recovery parses bounded geometry only; display validation remains a separate gate. + #[test] + fn metadata_frame_is_source_bound_and_does_not_decode_old_preview_images() { + let source = b"%PDF-1.7\noriginal native vector"; + let hash = *blake3::hash(source).as_bytes(); + let mut preview = photocraft_doc::VectorPreview { + source_hash: hash, + svg: std::sync::Arc::new( + br#""# + .to_vec(), + ), + }; + assert_eq!(super::frame_metadata(&preview, hash), Some((8.3, 6.2))); + assert!(super::validate(&preview, source).is_none(), "malformed image data is still rejected by actual display validation"); + assert!(super::frame_metadata(&preview, [0; 32]).is_none()); + for (width, height) in [("8pt", "6"), ("NaN", "6"), ("0", "6"), ("16384", "16384")] { + preview.svg = std::sync::Arc::new(format!(r#""#).into_bytes()); + assert!(super::frame_metadata(&preview, hash).is_none(), "unsupported frame {width} by {height}"); + } + } + use super::*; + + const SOURCE: &[u8] = b"%PDF-1.7\nprivate native source"; + const SVG: &[u8] = br#""#; + + fn preview() -> VectorPreview { + VectorPreview { source_hash: *blake3::hash(SOURCE).as_bytes(), svg: Arc::new(SVG.to_vec()) } + } + + #[test] + fn block_roundtrip_binds_preview_to_exact_native_contents() { + let preview = preview(); + let block = encode(&preview, SOURCE).unwrap(); + assert_eq!(decode(&block, SOURCE), Some(preview)); + assert!(decode(&block, b"%PDF-1.7\nchanged source").is_none()); + assert!(decode(&block, SVG).is_none()); + } + + #[test] + fn malformed_preview_blocks_are_ignored_without_losing_the_native_source() { + let good = encode(&preview(), SOURCE).unwrap(); + for truncated in [0, 7, 39, 43, good.len() - 1] { + assert!(decode(&good[..truncated], SOURCE).is_none()); + } + let mut bad = good.clone(); + bad[7] = 2; + assert!(decode(&bad, SOURCE).is_none()); + bad = good.clone(); + bad[40..44].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(decode(&bad, SOURCE).is_none()); + bad = good; + bad.extend_from_slice(&[0; 4]); + assert!(decode(&bad, SOURCE).is_none()); + let mut preview = preview(); + preview.svg = Arc::new(b"not SVG".to_vec()); + assert!(encode(&preview, SOURCE).is_none()); + preview.svg = Arc::new(br#""#.to_vec()); + assert!(encode(&preview, SOURCE).is_none()); + } + + #[test] + fn many_psd_instances_hash_the_native_payload_once_and_share_the_validated_preview() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; + use photocraft_geom::Affine; + use photocraft_raster::Surface; + + let mut source = vec![b' '; MAX_BYTES]; + source[..SOURCE.len()].copy_from_slice(SOURCE); + let preview = VectorPreview { source_hash: *blake3::hash(&source).as_bytes(), svg: Arc::new(SVG.to_vec()) }; + let mut doc = Document::new("shared preview", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let mut smart = SmartObject::new( + SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: Arc::new(source.clone()) }, + Affine::IDENTITY, + Some(Surface::new(doc.pixel_format())), + ); + smart.vector_preview = Some(preview.clone()); + doc.layers.push(Layer::new("native", LayerContent::Smart(smart))); + let out = crate::export(&doc, "psd", &Default::default()).unwrap(); + let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let record = file.layers()[0].clone(); + *file.layers_mut() = vec![record; 100]; + let before = HASHED_SOURCE_BYTES.with(std::cell::Cell::get); + let (back, _) = crate::psd_to_document(&file); + let hashed = HASHED_SOURCE_BYTES.with(std::cell::Cell::get) - before; + assert_eq!(hashed, source.len(), "a shared 16 MiB payload must not be hashed per layer"); + let previews: Vec<_> = back + .layers + .iter() + .map(|layer| { + let LayerContent::Smart(smart) = &layer.content else { panic!("smart object lost") }; + smart.vector_preview.as_ref().expect("preview lost") + }) + .collect(); + assert_eq!(previews.len(), 100); + assert!(previews.iter().all(|actual| **actual == preview)); + assert!(previews.iter().all(|actual| Arc::ptr_eq(&actual.svg, &previews[0].svg)), "shared layers retain one SVG allocation"); + } + + #[test] + fn psd_export_hashes_and_renders_a_shared_native_preview_once_for_all_instances() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; + use photocraft_geom::Affine; + use photocraft_raster::Surface; + + let counters = || { + ( + HASHED_SOURCE_BYTES.with(std::cell::Cell::get), + RENDERED_PREVIEWS.with(std::cell::Cell::get), + crate::psd_export::EMBED_HASHED_SOURCE_BYTES.with(std::cell::Cell::get), + ) + }; + let mut source = vec![b' '; 256 * 1024]; + source[..SOURCE.len()].copy_from_slice(SOURCE); + let preview = VectorPreview { source_hash: *blake3::hash(&source).as_bytes(), svg: Arc::new(SVG.to_vec()) }; + let mut doc = Document::new("shared preview", Size::new(8, 6), ColorMode::Rgb, SampleType::U8); + let mut smart = SmartObject::new( + SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: Arc::new(source.clone()) }, + Affine::IDENTITY, + Some(Surface::new(doc.pixel_format())), + ); + smart.vector_preview = Some(preview.clone()); + for _ in 0..100 { + doc.layers.push(Layer::new("native", LayerContent::Smart(smart.clone()))); + } + let check = |bytes: &[u8]| { + let back = crate::import("shared.psd", bytes).unwrap().document; + assert_eq!(back.layers.len(), 100); + for layer in &back.layers { + let LayerContent::Smart(sm) = &layer.content else { panic!("smart object lost") }; + assert_eq!(sm.vector_preview.as_ref(), Some(&preview), "valid preview lost"); + let SmartSource::Linked { path } = &sm.source else { panic!("expected the PSD embedded-file table") }; + assert_eq!(crate::linked::embedded_payload(&back.metadata, path).unwrap().1, source.as_slice(), "original native bytes changed"); + } + back + }; + + // Embedded instances share one source allocation. + let before = counters(); + let out = crate::export(&doc, "psd", &Default::default()).unwrap(); + let after = counters(); + assert_eq!(after.0 - before.0, source.len(), "the shared native source must not be hashed per layer or per use"); + assert_eq!(after.1 - before.1, 1, "the shared preview must not be rendered per layer or per use"); + assert_eq!(after.2 - before.2, source.len(), "the conversion cache must not re-hash the shared Embedded owner per layer"); + let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + assert!(file.layers().iter().all(|layer| layer.block(&PSD_KEY).is_some())); + let back = check(&out.bytes); + + // Reopened instances link to one preserved embedded file of the document. + let before = counters(); + let again = crate::export(&back, "psd", &Default::default()).unwrap(); + let after = counters(); + assert_eq!(after.0 - before.0, source.len(), "the preserved linked payload must not be hashed per layer or per use"); + assert_eq!(after.1 - before.1, 1, "the reopened shared preview must not be rendered per layer or per use"); + assert_eq!(after.2 - before.2, 0, "preserved linked-table sources need no Embedded conversion hash"); + check(&again.bytes); + } + + #[test] + fn export_cache_checks_actual_source_bytes_and_bounds_distinct_previews() { + let owner = Arc::new(SOURCE.to_vec()); + let red = preview(); + let mut blue = red.clone(); + blue.svg = Arc::new(String::from_utf8(SVG.to_vec()).unwrap().replace("red", "blue").into_bytes()); + // A claimed hash of other bytes is never trusted, even after the real source was cached. + let mut stale = red.clone(); + stale.source_hash = *blake3::hash(b"%PDF-1.7\nother").as_bytes(); + let changed = Arc::new(b"%PDF-1.7\nprivate native sourcf".to_vec()); + let mut cache = ExportCache::new(95); + assert!(cache.get(&stale, &owner, owner.as_slice()).is_none()); + let first = cache.get(&red, &owner, owner.as_slice()).unwrap(); + assert_eq!(first.block, encode(&red, SOURCE).unwrap()); + assert_eq!(first.frame, Some((8.0, 6.0))); + assert!(cache.get(&red, &changed, changed.as_slice()).is_none(), "equal-length changed bytes must not reuse the cached hash"); + assert!(cache.get(&stale, &owner, owner.as_slice()).is_none()); + // A range outside the owner cannot be keyed by its address. + assert!(cache.get(&red, &owner, SOURCE).is_none()); + let remaining = cache.remaining_pixels; + assert_eq!(remaining, 47); + for _ in 0..100 { + assert!(Arc::ptr_eq(&cache.get(&red, &owner, owner.as_slice()).unwrap(), &first)); + } + assert_eq!(cache.remaining_pixels, remaining, "cached instances are free"); + // Two 8×6 previews take 96 pixels; a 95-pixel export may validate only one. + assert!(cache.get(&blue, &owner, owner.as_slice()).is_none()); + assert_eq!(cache.remaining_pixels, 0); + let other = Arc::new(SOURCE.to_vec()); + assert!(cache.get(&red, &other, other.as_slice()).is_none(), "an exhausted export hashes no further sources"); + assert!(cache.get(&red, &owner, owner.as_slice()).is_some(), "a valid cache hit spends no work"); + } + + /// Distinct tiny previews are cheap; a tiny canvas cannot disguise a large raster decode. + #[test] + fn preview_caches_charge_decode_work_and_retain_many_normal_tiny_previews() { + let owner = Arc::new(SOURCE.to_vec()); + let mut imports = ImportCache::new(10_000); + let mut exports = ExportCache::new(10_000); + let mut tiny = Vec::new(); + for n in 0..12 { + let mut drawing = preview(); + drawing.svg = Arc::new(String::from_utf8(SVG.to_vec()).unwrap().replace("red", &format!("#{n:06x}")).into_bytes()); + let block = block_bytes(&drawing).unwrap(); + assert!(imports.decode("native", &block, || Some(SOURCE)).is_some()); + assert!(exports.get(&drawing, &owner, owner.as_slice()).is_some()); + tiny.push((drawing, block)); + } + assert_eq!(imports.remaining_pixels, 10_000 - 12 * 48); + assert_eq!(exports.remaining_pixels, imports.remaining_pixels); + let image = photocraft_codecs::Image::from_u8(64, 64, photocraft_codecs::ChannelLayout::Rgb, vec![255; 64 * 64 * 3]).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let data = png.iter().map(|byte| format!("%{byte:02X}")).collect::(); + let mut drawing = preview(); + drawing.svg = Arc::new( + format!(r#""#) + .into_bytes(), + ); + let block = block_bytes(&drawing).unwrap(); + let before = imports.remaining_pixels; + assert!(imports.decode("native", &block, || None).is_some()); + assert!(exports.get(&drawing, &owner, owner.as_slice()).is_some()); + assert!(before - imports.remaining_pixels >= 4097); + assert_eq!(exports.remaining_pixels, imports.remaining_pixels); + // An invalid new preview may already spend work; its attempted allowance is charged. + let mut invalid = preview(); + invalid.svg = Arc::new(br#""#.to_vec()); + assert!(imports.decode("native", &block_bytes(&invalid).unwrap(), || None).is_none()); + assert!(exports.get(&invalid, &owner, owner.as_slice()).is_none()); + assert_eq!(imports.remaining_pixels, 0); + assert_eq!(exports.remaining_pixels, 0); + assert!(imports.decode("native", &tiny[0].1, || None).is_some(), "valid import cache hits remain available"); + assert!(exports.get(&tiny[0].0, &owner, owner.as_slice()).is_some(), "valid export cache hits remain available"); + } + + #[test] + fn distinct_import_previews_share_a_work_budget_and_cached_instances_are_free() { + let red = preview(); + let mut blue = red.clone(); + blue.svg = Arc::new(String::from_utf8(SVG.to_vec()).unwrap().replace("red", "blue").into_bytes()); + let red_block = encode(&red, SOURCE).unwrap(); + let blue_block = encode(&blue, SOURCE).unwrap(); + // Two 8×6 previews would take 96 pixels; a 95-pixel import may validate only one. + // This small budget exercises the production 96 MP policy without allocating 96 MP. + let mut cache = ImportCache::new(95); + assert!(cache.decode("shared", &red_block, || Some(SOURCE)).is_some()); + assert_eq!(cache.remaining_pixels, 47); + for _ in 0..100 { + assert!(cache.decode("shared", &red_block, || panic!("cached source was read again")).is_some()); + } + assert_eq!(cache.remaining_pixels, 47); + assert!(cache.decode("shared", &blue_block, || panic!("cached source was read again")).is_none()); + assert_eq!(cache.remaining_pixels, 0); + assert!(cache.decode("new source", &blue_block, || panic!("exhausted import read another source")).is_none()); + assert!(cache.decode("shared", &red_block, || panic!("valid cache hit spent work")).is_some()); + let mut cache = ImportCache::new(96); + assert!(cache.decode("shared", &red_block, || Some(SOURCE)).is_some()); + assert!(cache.decode("shared", &blue_block, || None).is_some()); + assert_eq!(cache.remaining_pixels, 0); + } +} diff --git a/crates/io/tests/it/banded.rs b/crates/io/tests/it/banded.rs index eb8e97d6b5..c526b79fdb 100644 --- a/crates/io/tests/it/banded.rs +++ b/crates/io/tests/it/banded.rs @@ -82,7 +82,7 @@ fn psd_merged_image_matches_composite_and_drops_near_opaque_alpha() { let file = document_to_psd(&d); assert_eq!(file.merged_has_alpha(), alpha < 0.99, "alpha {alpha}"); let merged = merged_composite(&file).unwrap(); - let want = photocraft_compose::flatten(&d).px; + let want = photocraft_compose::flatten(&d).into_px(); let m = merged.iter().zip(&want).flat_map(|(a, b)| (0..4).map(move |i| (a[i] - b[i]).abs())).fold(0.0f32, f32::max); // Un-matting divides the 8-bit rounding error by alpha. assert!(m <= 1.0 / (255.0 * alpha) + 1e-4, "alpha {alpha}: max diff {m}"); diff --git a/crates/io/tests/it/blend_modes_corpus.rs b/crates/io/tests/it/blend_modes_corpus.rs index 296e2e2a50..72980bed5b 100644 --- a/crates/io/tests/it/blend_modes_corpus.rs +++ b/crates/io/tests/it/blend_modes_corpus.rs @@ -18,7 +18,7 @@ fn cell_error(mode: BlendMode) -> (f32, f32) { let doc = import("rgb-blend-modes.psd", &bytes).expect("opens").document; let file = PsdFile::from_bytes(&bytes).expect("parses"); let merged = merged_composite(&file).expect("merged image"); - let ours = photocraft_compose::flatten(&doc).px; + let ours = photocraft_compose::flatten(&doc).into_px(); let width = doc.size.width as i32; let duck = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == "rubber_duck_PNG1" && l.blend == mode).expect("the mode's duck"); let surface = duck.surface().expect("duck pixels"); diff --git a/crates/io/tests/it/cmyk_composite_export.rs b/crates/io/tests/it/cmyk_composite_export.rs index 0fea33695d..77f35a62d2 100644 --- a/crates/io/tests/it/cmyk_composite_export.rs +++ b/crates/io/tests/it/cmyk_composite_export.rs @@ -313,7 +313,8 @@ fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() { let doc = document(SampleType::U16, 0.5); let rgb = photocraft_compose::flatten(&doc); let expected = - photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::>()).unwrap(); + photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.into_px().into_iter().flatten().collect::>()) + .unwrap(); let result = export(&doc, "png", &ExportOptions::default()).unwrap(); diff --git a/crates/io/tests/it/color.rs b/crates/io/tests/it/color.rs index 06b5134e17..5da5df1c7e 100644 --- a/crates/io/tests/it/color.rs +++ b/crates/io/tests/it/color.rs @@ -116,11 +116,11 @@ fn cmyk_fills_survive_psd_roundtrip_at_every_depth() { let mut doc = Document::new("CMYK fill", photocraft_geom::Size::new(9, 6), ColorMode::Cmyk, depth); doc.icc_profile = profile.clone(); doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill))); - let expected = photocraft_compose::flatten(&doc).px; + let expected = photocraft_compose::flatten(&doc).into_px(); assert!(max_diff(&expected, &photocraft_compose::render_tiled(&doc, doc.bounds(), 3).px) <= 1e-6); let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; let back = import("x.psd", &bytes).unwrap().document; - let actual = photocraft_compose::flatten(&back).px; + let actual = photocraft_compose::flatten(&back).into_px(); let diff = max_diff(&expected, &actual); assert!(diff <= 1e-6, "{depth:?} {style:?}: round-trip error {diff}"); assert_eq!(back.icc_profile, doc.icc_profile); @@ -144,7 +144,7 @@ fn uncached_cmyk_gradient_corpus_roundtrip() { let bytes = std::fs::read(path).expect("run cargo xtask corpus --all"); let doc = import("x.psd", &bytes).unwrap().document; assert!(doc.layers.iter().any(|l| matches!(l.content, photocraft_doc::LayerContent::Fill(_)) && l.fill_cache.is_none())); - let expected = photocraft_compose::flatten(&doc).px; + let expected = photocraft_compose::flatten(&doc).into_px(); let saved = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; let back = import("x.psd", &saved).unwrap().document; let diff = max_diff(&expected, &photocraft_compose::flatten(&back).px); @@ -180,6 +180,53 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() { } } +#[test] +fn cmyk_merged_composite_reads_through_the_embedded_profile() { + // The merged image of a CMYK file tagged with a non-default profile is turned into RGB through + // that profile, as the layers are, or every comparison against it is off (#3778). + use photocraft_cms::{Intent, Transform}; + use photocraft_psd::{Compression, ImageResource, Version, testgen}; + let profile = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }); + let reference = |p: &Profile, ink: &[f32; 4]| { + let t = Transform::new(p, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap(); + let mut o = [0.0f32; 4]; + t.eval(ink, &mut o); + [o[0].clamp(0.0, 1.0), o[1].clamp(0.0, 1.0), o[2].clamp(0.0, 1.0)] + }; + for depth in [8u16, 16] { + let mut f = testgen::merged_only(Version::Psd, photocraft_psd::ColorMode::Cmyk, depth, Compression::Raw, 4, 1); + let inks: [[f32; 4]; 4] = [[0.1, 0.6, 0.7, 0.05], [0.8, 0.2, 0.1, 0.0], [0.3, 0.3, 0.3, 0.5], [0.0, 0.0, 0.0, 0.0]]; + let max = if depth == 8 { 255.0 } else { 65535.0 }; + let mut data = Vec::new(); + for c in 0..4 { + for ink in &inks { + // PSD stores CMYK inverted: full scale is no ink. + let v = ((1.0 - ink[c]) * max).round(); + if depth == 8 { data.push(v as u8) } else { data.extend((v as u16).to_be_bytes()) } + } + } + f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data, stored: None }; + f.resources.push(ImageResource::new(1039, profile.to_bytes().to_vec())); + let merged = merged_composite(&f).unwrap(); + let mut seen_far = false; + for (px, ink) in merged.iter().zip(&inks) { + let ink = ink.map(|v| ((v * max).round()) / max); + let want = reference(&profile, &ink); + let coated = reference(Builtin::CoatedCmyk.profile(), &ink); + seen_far |= (0..3).any(|k| (want[k] - coated[k]).abs() > 4.0 / 255.0); + for k in 0..3 { + assert!((px[k] - want[k]).abs() <= 2.0 / 255.0, "{depth}-bit {ink:?}: {px:?} vs {want:?} (built-in {coated:?})"); + } + } + assert!(seen_far, "the fixture profile differs from the built-in one"); + } +} + /// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`. fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec { let mut v = vec![0, 1, 0, 0]; @@ -258,7 +305,7 @@ fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() { _ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])), } // The layer now renders: the composite differs from the background alone. - let with_map = photocraft_compose::flatten(&back).px; + let with_map = photocraft_compose::flatten(&back).into_px(); let mut hidden = back.clone(); hidden.layers[1].visible = false; assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}"); diff --git a/crates/io/tests/it/composite.rs b/crates/io/tests/it/composite.rs index cef80e6a08..74eb0c95f1 100644 --- a/crates/io/tests/it/composite.rs +++ b/crates/io/tests/it/composite.rs @@ -15,7 +15,7 @@ fn check_export_oracle(d: &photocraft_doc::Document, tol: f32) { let f = PsdFile::from_bytes(&r.bytes).unwrap(); let merged = merged_composite(&f).unwrap(); let d2 = import("x.psd", &r.bytes).unwrap().document; - let mut ours = photocraft_compose::flatten(&d2).px; + let mut ours = photocraft_compose::flatten(&d2).into_px(); if d.mode == ColorMode::Cmyk { // The merged CMYK image is the RGB composite separated through the CMYK profile, which // gamut-maps colours that blend modes push outside CMYK: project ours the same way. @@ -107,7 +107,7 @@ fn builder_oracle(depth: u16, mode: ColorMode) { let f = PsdFile::from_bytes(&bytes).unwrap(); let merged = merged_composite(&f).unwrap(); let d2 = import("b.psd", &bytes).unwrap().document; - let ours = photocraft_compose::flatten(&d2).px; + let ours = photocraft_compose::flatten(&d2).into_px(); let m = max_diff(&ours, &merged); // Density/feather of masks are not written by the builder; the generated // doc uses them only on a disabled mask, so results must match. @@ -157,7 +157,7 @@ fn hidden_and_clipped_affect_composite() { for l in &mut d2.layers { l.visible = true; } - let other = photocraft_compose::flatten(&d2).px; + let other = photocraft_compose::flatten(&d2).into_px(); assert!(max_diff(&other, &merged) > 0.0, "unhiding a layer must change the composite"); } diff --git a/crates/io/tests/it/corpus.rs b/crates/io/tests/it/corpus.rs index 182d60095a..3c8ca3288a 100644 --- a/crates/io/tests/it/corpus.rs +++ b/crates/io/tests/it/corpus.rs @@ -316,9 +316,9 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option) { return (Outcome::Error, None); } }; - let ours = photocraft_compose::flatten(doc).px; + let ours = photocraft_compose::flatten(doc).into_px(); // Our own export must not change how the document renders. - let again = photocraft_compose::flatten(&reimported).px; + let again = photocraft_compose::flatten(&reimported).into_px(); let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY }; let layers = doc.layer_count(); // Oracle choice: the merged image (Photoshop's composite; for files without layers it is the diff --git a/crates/io/tests/it/effects_multi.rs b/crates/io/tests/it/effects_multi.rs index 637e926085..7cabb04b4c 100644 --- a/crates/io/tests/it/effects_multi.rs +++ b/crates/io/tests/it/effects_multi.rs @@ -17,7 +17,7 @@ fn roundtrip(doc: &Document) -> Document { } fn max_diff(a: &Document, b: &Document) -> f32 { - let (x, y) = (photocraft_compose::flatten(a).px, photocraft_compose::flatten(b).px); + let (x, y) = (photocraft_compose::flatten(a).into_px(), photocraft_compose::flatten(b).into_px()); assert_eq!(x.len(), y.len()); x.iter().zip(&y).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0, f32::max) } diff --git a/crates/io/tests/it/export_progress.rs b/crates/io/tests/it/export_progress.rs index 8f372db4b2..d7c3b48856 100644 --- a/crates/io/tests/it/export_progress.rs +++ b/crates/io/tests/it/export_progress.rs @@ -91,3 +91,24 @@ fn a_cancelled_export_stops_with_cancelled() { assert!(matches!(r, Err(IoError::Cancelled)), "{name} midway: {:?}", r.map(|r| r.bytes.len())); } } + +/// #6607: a PSD/PSB save stops at the file model, which is serialized into the file as it is +/// written; streamed into a file it holds exactly the exported bytes. Other formats come back as +/// their bytes. +#[test] +fn encoded_saves_write_exactly_the_exported_bytes() { + for depth in [SampleType::U8, SampleType::U16] { + let doc = layered(depth); + for name in ["a.psd", "a.psb", "a.png"] { + let opts = ExportOptions::default(); + let want = photocraft_io::export(&doc, name, &opts).unwrap().bytes; + let r = photocraft_io::encode_with(&doc, name, &opts, &Interrupt::NONE).unwrap(); + assert_eq!(matches!(r.encoded, photocraft_io::Encoded::Psd(_)), !name.ends_with(".png"), "{name}"); + assert_eq!(r.encoded.encoded_len().unwrap(), want.len() as u64, "{name} {depth:?}: the encoded length"); + let mut file = std::io::Cursor::new(Vec::new()); + r.encoded.write_to(&mut file).unwrap(); + assert!(file.into_inner() == want, "{name} {depth:?}: the streamed file differs from the export"); + assert!(r.encoded.into_bytes().unwrap() == want, "{name} {depth:?}"); + } + } +} diff --git a/crates/io/tests/it/flat.rs b/crates/io/tests/it/flat.rs index f6e888eb06..c40c76a12b 100644 --- a/crates/io/tests/it/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -350,7 +350,7 @@ fn layered_to_png_warns_flatten() { assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings); let back = import("out.png", &r.bytes).unwrap().document; // Composite equals our flatten. - let flat = photocraft_compose::flatten(&d).px; + let flat = photocraft_compose::flatten(&d).into_px(); let s = back.layers[0].surface().unwrap(); let px: Vec<[f32; 4]> = s.read_region(back.bounds()).as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]]).collect(); assert!(max_diff(&px, &flat) <= 1.0 / 255.0 + 1e-4); diff --git a/crates/io/tests/it/lab16.rs b/crates/io/tests/it/lab16.rs index 0aee251861..ff0707c11b 100644 --- a/crates/io/tests/it/lab16.rs +++ b/crates/io/tests/it/lab16.rs @@ -60,8 +60,8 @@ fn lab_round_trips_at_every_depth() { let p = back.layers[0].surface().unwrap().pixel(2, 0); let tol = if depth == SampleType::U8 { 0.5 / 255.0 + 1e-6 } else { 2e-5 }; assert!((p[1] - A).abs() <= tol && (p[2] - B).abs() <= tol, "{depth:?}: {p:?}"); - let a = photocraft_compose::flatten(&doc).px; - let b = photocraft_compose::flatten(&back).px; + let a = photocraft_compose::flatten(&doc).into_px(); + let b = photocraft_compose::flatten(&back).into_px(); for (x, y) in a.iter().zip(&b) { for c in 0..4 { assert!((x[c] - y[c]).abs() <= 1.0 / 255.0, "{depth:?}: {x:?} vs {y:?}"); diff --git a/crates/io/tests/it/large_files.rs b/crates/io/tests/it/large_files.rs index b0a2669480..be4d5eeab7 100644 --- a/crates/io/tests/it/large_files.rs +++ b/crates/io/tests/it/large_files.rs @@ -42,7 +42,7 @@ fn check_grid(g: &Grid, doc: &Document, what: &str) { ]; 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 px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).into_px(); let got = px.first().copied().unwrap_or_default(); let want = g.expected(x, y); for c in 0..3 { diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 40154335d0..d9e597297e 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -38,8 +38,10 @@ mod layer_labels; mod layer_xmp; mod modes; mod multichannel; +mod native_vector_preview; mod orientation; mod pattern_fill_psd; +mod pcraft_full_composite; mod pcraft_manifest_name; mod pdn_corpus; mod png_text_metadata; @@ -71,12 +73,15 @@ mod resolution_axes; mod roundtrip; mod smart_contents_identity; mod smart_corpus; +mod streaming_exr; mod svg; mod text_corpus; mod text_styles; +mod tiff_banded_import; mod tiff_corpus; mod tiff_layers; mod tiff_pages; +mod tiff_save_options; mod tiff_type_geometry; mod vector; mod xcf; diff --git a/crates/io/tests/it/multichannel.rs b/crates/io/tests/it/multichannel.rs index fd8f8448c3..f4f7154d41 100644 --- a/crates/io/tests/it/multichannel.rs +++ b/crates/io/tests/it/multichannel.rs @@ -56,7 +56,7 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { as_ink.channels[1].surface = s; as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0)); // 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); + let (shown, inked) = (photocraft_compose::flatten(&d).into_px(), photocraft_compose::flatten(&as_ink).into_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)"); @@ -115,7 +115,7 @@ fn alpha_type_channels_are_stored_as_is_not_as_ink() { 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; + let px = photocraft_compose::flatten(&d).into_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. @@ -223,3 +223,31 @@ fn corpus_multichannel_round_trip_keeps_channels_and_resources() { let keys = |f: &PsdFile| f.global_blocks.iter().map(|b| b.key).collect::>(); assert_eq!(keys(&back), keys(&f)); } + +#[test] +fn tile_row_conversion_preserves_spot_and_alpha_planes() { + for depth in [8, 16, 32] { + let mut f = testgen::merged_only(Version::Psb, ColorMode::Multichannel, depth, Compression::Rle, 5, 600); + let mut info = 1u32.to_be_bytes().to_vec(); + display_record(&mut info, [0, 0, 0], 0, 2); + display_record(&mut info, [65535, 0, 0], 50, 1); + f.resources.push(ImageResource::new(1077, info)); + let raw = f.decode_merged().unwrap(); + let (doc, _) = psd_to_document(&f); + let sample = usize::from(depth / 8); + let plane = 5 * 600 * sample; + for (k, channel) in doc.channels.iter().enumerate() { + let values = channel.surface.read_region(doc.bounds()); + for (i, value) in values.iter().enumerate() { + let bytes = &raw[k * plane + i * sample..k * plane + (i + 1) * sample]; + let original = match depth { + 8 => f32::from(bytes[0]) / 255.0, + 16 => f32::from(u16::from_be_bytes(bytes.try_into().unwrap())) / 65535.0, + _ => f32::from_be_bytes(bytes.try_into().unwrap()), + }; + let expected = if channel.spot.is_some() { 1.0 - original } else { original }; + assert!((value - expected).abs() < 0.00001, "depth {depth}, channel {k}, pixel {i}"); + } + } + } +} diff --git a/crates/io/tests/it/native_vector_preview.rs b/crates/io/tests/it/native_vector_preview.rs new file mode 100644 index 0000000000..d1a8964eb9 --- /dev/null +++ b/crates/io/tests/it/native_vector_preview.rs @@ -0,0 +1,158 @@ +//! Native embedded files stay native while PhotoCraft retains a source-bound display preview. + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource, VectorPreview}; +use photocraft_geom::{Affine, Size}; +use photocraft_io::{ExportOptions, export, import, linked, smart_map, vector_preview}; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +const PDF: &[u8] = b"%PDF-1.7\nopaque native source bytes\n%%EOF\n"; +const EPS: &[u8] = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n1 0 0 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n"; +const SVG: &[u8] = br#""#; + +fn fixture(name: &str, source: &[u8]) -> Document { + let mut doc = Document::new("native", Size::new(20, 20), ColorMode::Rgb, SampleType::U8); + let mut cache = Surface::new(doc.pixel_format()); + cache.fill_rect(photocraft_geom::Rect::new(2, 3, 18, 15), &[1.0, 0.0, 0.0, 1.0]); + let mut sm = SmartObject::new( + SmartSource::Embedded { file_name: name.into(), bytes: Arc::new(source.to_vec()) }, + Affine { m: [2.0, 0.0, 0.0, 2.0, 2.0, 3.0] }, + Some(cache), + ); + sm.vector_preview = Some(VectorPreview { source_hash: *blake3::hash(source).as_bytes(), svg: Arc::new(SVG.to_vec()) }); + doc.layers.push(Layer::new("native", LayerContent::Smart(sm))); + doc +} + +fn smart(doc: &Document) -> &SmartObject { + let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("native vector was rasterized") }; + sm +} + +#[test] +fn ai_pdf_eps_psd_and_psb_roundtrips_keep_source_bytes_preview_and_pixel_frame() { + for (name, bytes) in [("Artwork.ai", PDF), ("Artwork.pdf", PDF), ("Artwork.eps", EPS)] { + for extension in ["psd", "psb"] { + let original = fixture(name, bytes); + let output = export(&original, extension, &ExportOptions::default()).unwrap(); + assert!(!output.warnings.iter().any(|w| w.contains("smart object written as pixels")), "{:?}", output.warnings); + let file = PsdFile::from_bytes(&output.bytes).unwrap(); + let placed = smart_map::parse_sold(&file.layers()[0].block(b"SoLd").unwrap().data).unwrap(); + assert_eq!(smart_map::stored_size(&placed.descriptor), Some((8.0, 6.0))); + assert!(file.layers()[0].block(&vector_preview::PSD_KEY).is_some()); + let back = import(extension, &output.bytes).unwrap().document; + let sm = smart(&back); + let SmartSource::Linked { path } = &sm.source else { panic!("expected PSD embedded-table source") }; + let (actual_name, actual_bytes) = linked::embedded_payload(&back.metadata, path).unwrap(); + assert_eq!(actual_name, name); + assert_eq!(actual_bytes, bytes); + assert_eq!(sm.vector_preview, smart(&original).vector_preview); + assert_eq!(sm.transform, smart(&original).transform); + let again = export(&back, extension, &Default::default()).unwrap(); + let again = import(extension, &again.bytes).unwrap().document; + assert_eq!(smart(&again).vector_preview, sm.vector_preview); + assert_eq!(smart(&again).transform, sm.transform); + } + } +} + +#[test] +fn stale_or_corrupt_psd_preview_is_discarded_and_native_source_survives() { + let output = export(&fixture("Artwork.ai", PDF), "psd", &Default::default()).unwrap(); + for hash_offset in [8, 44] { + let mut file = PsdFile::from_bytes(&output.bytes).unwrap(); + let block = file.layers_mut()[0].blocks.iter_mut().find(|b| b.key == vector_preview::PSD_KEY).unwrap(); + block.data[hash_offset] ^= 0xff; + let back = import("test.psd", &file.to_bytes().unwrap()).unwrap().document; + let sm = smart(&back); + assert!(sm.vector_preview.is_none()); + let SmartSource::Linked { path } = &sm.source else { panic!("source disappeared") }; + assert_eq!(linked::embedded_payload(&back.metadata, path).unwrap().1, PDF); + } +} + +#[test] +fn replacing_native_source_cannot_export_a_stale_preview() { + let mut doc = fixture("Artwork.ai", PDF); + let LayerContent::Smart(sm) = &mut doc.layers[0].content else { unreachable!() }; + sm.source = SmartSource::Embedded { file_name: "Artwork.ai".into(), bytes: Arc::new(b"%PDF-1.7\nother".to_vec()) }; + let output = export(&doc, "psd", &Default::default()).unwrap(); + let file = PsdFile::from_bytes(&output.bytes).unwrap(); + assert!(file.layers()[0].block(&vector_preview::PSD_KEY).is_none()); + assert!(smart(&import("test.psd", &output.bytes).unwrap().document).vector_preview.is_none()); +} + +#[test] +fn native_preview_supplies_unfiltered_pixels_for_psd_smart_filter_masks() { + let mut doc = fixture("Artwork.ai", PDF); + let LayerContent::Smart(sm) = &mut doc.layers[0].content else { unreachable!() }; + sm.smart_filters.push(photocraft_doc::SmartFilter { + command: "filter.blur.gaussianBlur".into(), + params: serde_json::json!({"radius": 1.0}), + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + // All-white masks intentionally reopen as None. Use an actual masked region to prove + // native-preview source pixels keep a nontrivial mask through PSD serialization. + mask.surface.fill_rect(photocraft_geom::Rect::new(4, 4, 8, 8), &[0.0]); + sm.filter_mask = Some(mask); + let out = export(&doc, "psd", &Default::default()).unwrap(); + assert!(!out.warnings.iter().any(|warning| warning.contains("smart filter mask was not written")), "{:?}", out.warnings); + let file = PsdFile::from_bytes(&out.bytes).unwrap(); + assert!(file.global_blocks.iter().any(|block| &block.key == b"FEid")); + let back = import("test.psd", &out.bytes).unwrap().document; + let mask = smart(&back).filter_mask.as_ref().expect("nontrivial filter mask was lost"); + assert_eq!(mask.value(5, 5), 0.0); + assert_eq!(mask.value(0, 0), 1.0); + assert!(smart(&back).vector_preview.is_some()); + assert_eq!(smart(&back).smart_filters.len(), 1); +} + +#[test] +fn one_hundred_shared_native_instances_keep_source_bytes_and_preview_through_psd_resaves() { + let mut doc = fixture("Artwork.ai", PDF); + let layer = doc.layers[0].clone(); + for _ in 1..100 { + doc.layers.push(Layer::new("native", layer.content.clone())); + } + let check = |bytes: &[u8]| { + let back = import("shared.psd", bytes).unwrap().document; + assert_eq!(back.layers.len(), 100); + let file = PsdFile::from_bytes(bytes).unwrap(); + assert!(file.layers().iter().all(|layer| layer.block(&vector_preview::PSD_KEY).is_some())); + for layer in &back.layers { + let LayerContent::Smart(sm) = &layer.content else { panic!("native vector was rasterized") }; + assert_eq!(sm.vector_preview, smart(&doc).vector_preview); + assert_eq!(sm.transform, smart(&doc).transform); + let SmartSource::Linked { path } = &sm.source else { panic!("expected PSD embedded-table source") }; + assert_eq!(linked::embedded_payload(&back.metadata, path).unwrap(), ("Artwork.ai".to_string(), PDF)); + } + back + }; + let back = check(&export(&doc, "psd", &Default::default()).unwrap().bytes); + check(&export(&back, "psd", &Default::default()).unwrap().bytes); +} + +#[test] +fn native_preview_preserves_fractional_source_pixel_frame_in_psd() { + let mut doc = fixture("Artwork.ai", PDF); + let LayerContent::Smart(sm) = &mut doc.layers[0].content else { unreachable!() }; + let svg = br#""#; + sm.vector_preview.as_mut().unwrap().svg = Arc::new(svg.to_vec()); + assert_eq!(vector_preview::frame(sm.vector_preview.as_ref().unwrap(), PDF), Some((8.3, 6.2))); + let out = export(&doc, "psd", &Default::default()).unwrap(); + let file = PsdFile::from_bytes(&out.bytes).unwrap(); + let placed = smart_map::parse_sold(&file.layers()[0].block(b"SoLd").unwrap().data).unwrap(); + assert_eq!(smart_map::stored_size(&placed.descriptor), Some((8.3, 6.2))); + let back = import("test.psd", &out.bytes).unwrap().document; + assert_eq!(smart(&back).transform, smart(&doc).transform); + let again = export(&back, "psd", &Default::default()).unwrap(); + let file = PsdFile::from_bytes(&again.bytes).unwrap(); + let placed = smart_map::parse_sold(&file.layers()[0].block(b"SoLd").unwrap().data).unwrap(); + assert_eq!(smart_map::stored_size(&placed.descriptor), Some((8.3, 6.2))); +} diff --git a/crates/io/tests/it/pcraft_full_composite.rs b/crates/io/tests/it/pcraft_full_composite.rs new file mode 100644 index 0000000000..65acccdcd5 --- /dev/null +++ b/crates/io/tests/it/pcraft_full_composite.rs @@ -0,0 +1,140 @@ +//! With [`ExportOptions::pcraft_full_composite`] on, a `.pcraft` save writes the flattened +//! document at its canvas size as `composite/full.png`, next to the 256 px thumbnail and the +//! 1024 px preview, so other apps can read the whole image without decoding tiles. Off (the +//! default), the save writes the bundle it wrote before the option existed. + +use photocraft_color::{BlendMode, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_format::zip::ZipReader; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export}; +use photocraft_raster::Surface; + +/// A background, a soft Multiply layer at 60 % and a layer that leaves part of the canvas clear. +fn doc(w: u32, h: u32, depth: SampleType) -> Document { + let mut d = Document::new("full", Size::new(w, h), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let layer = |name: &str, seed: usize, r: Rect| { + let n = r.width() as usize * r.height() as usize * fmt.bytes_per_pixel(); + let px: Vec = (0..n).map(|i| ((i * 31 + seed * 17 + i / 7) % 251) as u8).collect(); + Layer::new(name, LayerContent::Raster(Surface::from_interleaved(fmt, r, &px))) + }; + d.layers.push(layer("Back", 1, Rect::new(0, 0, w as i32, h as i32 / 2))); + let mut soft = layer("Soft", 2, Rect::new(w as i32 / 4, h as i32 / 4, w as i32, h as i32)); + soft.opacity = 0.6; + soft.blend = BlendMode::Multiply; + d.layers.push(soft); + d +} + +fn on() -> ExportOptions { + ExportOptions { pcraft_full_composite: true, ..Default::default() } +} + +/// The bundle's member named by the manifest's `full_composite`, decoded: size and RGBA8 pixels. +fn full_composite(bytes: &[u8]) -> ((u32, u32), Vec) { + let png = photocraft_format::read_full_composite(bytes).expect("manifest").expect("the manifest names the full-size composite"); + assert_eq!(png, ZipReader::new(bytes).expect("zip").read_by_name("composite/full.png", usize::MAX).expect("entry")); + let image = photocraft_codecs::decode(&png).expect("png"); + (image.dimensions(), image.to_rgba8()) +} + +fn flattened(doc: &Document) -> Vec { + photocraft_compose::render(doc, doc.bounds()).to_rgba8().pixels +} + +#[test] +fn a_default_save_writes_the_bundle_without_the_full_composite() { + for depth in SampleType::ALL { + let d = doc(1500, 700, depth); + let bytes = export(&d, "a.pcraft", &ExportOptions::default()).expect("export").bytes; + assert_eq!(photocraft_format::read_full_composite(&bytes).expect("manifest"), None, "{depth:?}"); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none(), "{depth:?}"); + // The bytes a save wrote before the option: the bundle with its two previews alone. + let mut named = d.clone(); + named.name = "a.pcraft".into(); + let previews = photocraft_format::SaveOptions { + thumbnail: Some(photocraft_compose::thumbnail(&named, 256)), + composite: Some(photocraft_compose::thumbnail(&named, 1024)), + ..Default::default() + }; + assert!(bytes == photocraft_format::save_to_bytes(&named, &previews).expect("save"), "{depth:?}: the default save changed"); + } +} + +#[test] +fn a_pcraft_save_writes_the_flattened_document_at_its_canvas_size() { + for depth in SampleType::ALL { + let d = doc(300, 200, depth); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + let (size, pixels) = full_composite(&bytes); + assert_eq!(size, (300, 200), "{depth:?}"); + assert!(pixels == flattened(&d), "{depth:?}: the full-size composite differs from the flattened document"); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0), "{depth:?}: transparent pixels stay transparent"); + let mut saved = d.clone(); + saved.name = "a.pcraft".into(); + assert_eq!(photocraft_format::load_from_bytes(&bytes).expect("load"), saved, "{depth:?}"); + } +} + +#[test] +fn a_document_larger_than_the_preview_keeps_its_full_size() { + let d = doc(1500, 700, SampleType::U8); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + let (size, pixels) = full_composite(&bytes); + assert_eq!(size, (1500, 700)); + assert!(pixels == flattened(&d), "the full-size composite differs from the flattened document"); + let preview = ZipReader::new(&bytes).expect("zip").read_by_name("composite/preview.png", usize::MAX).expect("preview"); + assert_eq!(photocraft_codecs::decode(&preview).expect("png").dimensions(), (1024, 478)); +} + +#[test] +fn the_full_composite_carries_the_profile_of_its_pixels() { + use photocraft_cms::{Builtin, ColorSpace, Profile}; + let full_icc = |d: &Document| { + let bytes = export(d, "a.pcraft", &on()).expect("export").bytes; + let png = ZipReader::new(&bytes).expect("zip").read_by_name("composite/full.png", usize::MAX).expect("entry"); + photocraft_codecs::decode(&png).expect("png").icc.expect("an embedded profile") + }; + let srgb = Builtin::Srgb.profile().to_bytes().to_vec(); + let p3 = Builtin::DisplayP3.profile().to_bytes().to_vec(); + let mut d = doc(40, 30, SampleType::U8); + d.icc_profile = Some(std::sync::Arc::new(p3.clone())); + assert_eq!(full_icc(&d), p3, "the composite is in the document's RGB space"); + d.icc_profile = None; + assert_eq!(full_icc(&d), srgb); + // A CMYK document composites to sRGB: its CMYK profile does not describe the PNG. + let mut cmyk = Document::new("cmyk", Size::new(40, 30), ColorMode::Cmyk, SampleType::U8); + cmyk.icc_profile = Some(std::sync::Arc::new(Builtin::CoatedCmyk.profile().to_bytes().to_vec())); + assert_eq!(full_icc(&cmyk), srgb); + // A gray document's composite repeats its gray samples in R, G and B: the PNG carries its + // gray curve as an RGB profile. + let gray = Builtin::SGray.profile().clone(); + let mut g = Document::new("gray", Size::new(40, 30), ColorMode::Grayscale, SampleType::U8); + g.icc_profile = Some(std::sync::Arc::new(gray.to_bytes().to_vec())); + let tagged = Profile::parse(&full_icc(&g)).expect("profile"); + assert_eq!(tagged.color_space, ColorSpace::Rgb); + assert_eq!(tagged.to_bytes(), gray.gray_as_rgb().expect("gray as RGB").to_bytes()); +} + +#[test] +fn a_canvas_past_the_render_limit_is_saved_without_it() { + // 40000 x 30000 px is over the compositor's 2^30-pixel limit; the layers stay small. + let mut d = doc(64, 64, SampleType::U8); + d.size = Size::new(40_000, 30_000); + assert!(photocraft_io::pcraft_full_composite(&d).is_none()); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + assert_eq!(photocraft_format::read_full_composite(&bytes).expect("manifest"), None); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none()); +} + +#[test] +fn a_sparse_canvas_past_the_composite_memory_budget_is_saved_without_it() { + // The layer data stays tiny; an 8193 x 8192 RGBA8 composite exceeds 256 MiB. + let mut d = doc(64, 64, SampleType::U8); + d.size = Size::new(8193, 8192); + assert!(photocraft_io::pcraft_full_composite(&d).is_none()); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + assert_eq!(photocraft_format::read_full_composite(&bytes).expect("manifest"), None); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none()); +} diff --git a/crates/io/tests/it/psd32_export.rs b/crates/io/tests/it/psd32_export.rs index f9a297a7f6..428fb7903e 100644 --- a/crates/io/tests/it/psd32_export.rs +++ b/crates/io/tests/it/psd32_export.rs @@ -118,7 +118,7 @@ fn layered_32bit_export_reimports_render_equal() { for mode in [ColorMode::Rgb, ColorMode::Grayscale] { let doc = common::gen_doc(mode, SampleType::F32, Features::ALL); let back = import("t.psd", &export_psd(&doc)).unwrap().document; - let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px); + let (a, b) = (photocraft_compose::flatten(&doc).into_px(), photocraft_compose::flatten(&back).into_px()); assert_eq!(a.len(), b.len()); let d = common::max_diff(&a, &b); assert!(d <= 1e-4, "{mode:?}: render differs by {d}"); @@ -154,7 +154,7 @@ fn photoshop_32bit_corpus_round_trip() { assert_32bit_layout(&name, &out); let back = import(&name, &out).unwrap().document; assert_eq!(doc.layer_count(), back.layer_count(), "{name}: layer count"); - let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px); + let (a, b) = (photocraft_compose::flatten(&doc).into_px(), photocraft_compose::flatten(&back).into_px()); assert_eq!(a.len(), b.len(), "{name}"); let d = common::max_diff(&a, &b); assert!(d <= 1e-4, "{name}: render differs by {d}"); diff --git a/crates/io/tests/it/psd_damaged_merged.rs b/crates/io/tests/it/psd_damaged_merged.rs index 8e20687bb5..51121d59b7 100644 --- a/crates/io/tests/it/psd_damaged_merged.rs +++ b/crates/io/tests/it/psd_damaged_merged.rs @@ -32,7 +32,7 @@ fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() { for compression in Compression::ALL { let mut file = layered_in_mode(mode, depth, extension); let intact = import(extension, &file.to_bytes().unwrap()).unwrap(); - let expected = photocraft_compose::flatten(&intact.document).px; + let expected = photocraft_compose::flatten(&intact.document).into_px(); let pixels = file.decode_merged().unwrap(); file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap(); file.image_data.data.truncate(file.image_data.data.len() / 2); diff --git a/crates/io/tests/it/psd_import_file.rs b/crates/io/tests/it/psd_import_file.rs index f8e9243cc1..cd1b08761a 100644 --- a/crates/io/tests/it/psd_import_file.rs +++ b/crates/io/tests/it/psd_import_file.rs @@ -44,11 +44,13 @@ fn open(name: &str, path: &std::path::Path) -> Result panic!("{}: bytes {:?}, file {:?}", case.name, a.map(|r| r.warnings), b.map(|r| r.warnings)), } } - assert!(checked > 50, "only {checked} cases opened"); + // Four parts each opening at least 13 cases retain (and slightly strengthen) the former + // aggregate floor of more than 50 successful opens. + assert!(checked >= 13, "part {part}: only {checked} cases opened"); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_0() { + import_file_opens_what_importing_the_bytes_opens_part(0); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_1() { + import_file_opens_what_importing_the_bytes_opens_part(1); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_2() { + import_file_opens_what_importing_the_bytes_opens_part(2); +} + +#[test] +fn import_file_opens_what_importing_the_bytes_opens_part_3() { + import_file_opens_what_importing_the_bytes_opens_part(3); } #[test] diff --git a/crates/io/tests/it/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs index 8b85c4f3ba..bffdb96643 100644 --- a/crates/io/tests/it/psd_kerning.rs +++ b/crates/io/tests/it/psd_kerning.rs @@ -120,6 +120,60 @@ fn fresh_metrics_text_writes_no_txt2_block() { assert!(saves_txt2(Kerning::Optical), "Optical lives only in Txt2"); } +/// #10308: stylistic sets (and other Txt2-only OpenType flags) on a PhotoCraft-native document +/// write a `Txt2` block and round-trip through save and reopen. +#[test] +fn fresh_stylistic_sets_round_trip_through_psd() { + use photocraft_doc::text::FontFeature; + let base = CharStyle { font_family: "Inter".into(), size_pt: 24.0, ..Default::default() }; + let mut t = TextLayer { + text: "Hi".into(), + runs: vec![TextRun { + len: 2, + style: CharStyle { features: vec![FontFeature { tag: "ss01".into(), value: 1 }, FontFeature { tag: "ss05".into(), value: 1 }], ..base }, + }], + ..Default::default() + }; + t.sync_summary(); + let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut r = t.clone(); + photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(r))); + let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let txt2 = + file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)).expect("Txt2 written for stylistic sets"); + let style = txt2 + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(|a| a.first()) + .and_then(|o| o.path(&["0", "6", "0"])) + .and_then(E::as_array) + .and_then(|a| a.first()) + .and_then(|r| r.path(&["0", "0", "6"])) + .expect("style run"); + // Bits 0 and 4 → sets 1 and 5. + assert_eq!(style.get("85").and_then(E::as_i64), Some((1 << 0) | (1 << 4))); + assert_eq!(style.path(&["53", "99"]).and_then(E::as_str), Some("SimplePaint"), "paint stream tag"); + assert_eq!(style.path(&["53", "0", "0"]).and_then(E::as_i64), Some(1), "fill colour type RGB"); + assert!(style.path(&["53", "0", "1"]).and_then(E::as_array).is_some_and(|v| v.len() == 4), "fill colour values"); + assert_eq!(style.get("0").and_then(E::as_i64), Some(0), "font index 0"); + assert_eq!( + txt2.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|a| a.first()).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str), + Some("Inter-Regular"), + "fresh Txt2 seeds a font entry" + ); + assert!(txt2.path(&["0", "8", "0"]).and_then(E::as_array).is_some_and(|f| !f.is_empty()), "fresh Txt2 names a frame"); + let back = photocraft_io::import("k.psd", &bytes).unwrap().document; + let LayerContent::Text(b) = &back.layers[0].content else { panic!("not text") }; + let runs = b.char_runs(); + assert_eq!(runs.iter().map(|r| r.len).sum::(), b.text.len(), "the runs cover the text"); + for r in &runs { + let on = |tag: &str| r.style.features.iter().any(|f| f.tag == tag && f.value == 1); + assert!(on("ss01") && on("ss05"), "both sets on every character: {:?}", r.style.features); + } +} + /// Two imported layers can name the same Txt2 object; export must update the second /// preserved TySh pointer so each layer restores its own automatic kerning mode. #[test] @@ -199,14 +253,22 @@ type Reopened = (String, Option, Vec); /// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one /// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's /// index and font features (but `calt`, on by default and written on as Photoshop does), and -/// "Hi"'s style run in the saved `Txt2`. +/// "Hi"'s style run in the saved `Txt2`. The kept layers are styled as their objects are (font +/// and size too), so their objects are saved unchanged, which this checks. fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec, E) { let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); for (text, index) in kept { let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]); + // `photoshop_txt2` writes MyriadPro-Regular at 12 pt (the document is at 72 dpi). + t.runs[0].style.size_pt = 12.0; + t.runs[0].style.postscript_name = Some("MyriadPro-Regular".into()); + t.sync_summary(); photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format()); - let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None); - t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap())); + let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap(); + // As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415: + // a layer whose style disagrees with its object is saved as a style edit). + photocraft_text::psd::apply_txt2(&mut t, &raw, &photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap()); + t.psd_raw = Some(std::sync::Arc::new(raw)); doc.layers.push(Layer::new(*text, LayerContent::Text(t))); } doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))]; @@ -228,6 +290,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect()) }) .collect(); + let source = photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap(); + let object = |txt2: &E, i: i32| txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(usize::try_from(i).ok()?)).cloned(); + for (text, index) in kept { + assert_eq!(object(&txt2, *index), object(&source, *index), "{text}: the kept layer's object is saved unchanged"); + } let hi = usize::try_from(layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap()).unwrap(); let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone(); (layers, run) diff --git a/crates/io/tests/it/psd_type_bounds.rs b/crates/io/tests/it/psd_type_bounds.rs index b87d4f4a3a..43ce38c674 100644 --- a/crates/io/tests/it/psd_type_bounds.rs +++ b/crates/io/tests/it/psd_type_bounds.rs @@ -283,7 +283,7 @@ fn imported_type_layers_keep_their_full_bounds() { let o = iou(&ours.surface, cache); assert!(o > 0.97, "{}: re-render overlap {o}", c.name); // And the document composite is the file's merged composite. - let px = photocraft_compose::flatten(&doc).px; + let px = photocraft_compose::flatten(&doc).into_px(); let worst = common::max_diff(&px, &merged); assert!(worst <= 2.0 / 255.0, "{}: composite differs from the merged image by {worst}", c.name); } diff --git a/crates/io/tests/it/roundtrip.rs b/crates/io/tests/it/roundtrip.rs index 1031b5504a..155429eba2 100644 --- a/crates/io/tests/it/roundtrip.rs +++ b/crates/io/tests/it/roundtrip.rs @@ -116,6 +116,7 @@ fn text_shape_smart_raw_blocks_survive() { "Smart", LayerContent::Smart(SmartObject { contents_id: SmartContentsId::fresh(), + vector_preview: None, source: SmartSource::Linked { path: String::new() }, transform: photocraft_geom::Affine::IDENTITY, smart_filters: vec![], diff --git a/crates/io/tests/it/streaming_exr.rs b/crates/io/tests/it/streaming_exr.rs new file mode 100644 index 0000000000..3c395bec3f --- /dev/null +++ b/crates/io/tests/it/streaming_exr.rs @@ -0,0 +1,92 @@ +use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; +use photocraft_doc::LayerContent; +use photocraft_raster::Interrupt; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { + let values: Vec = (0..31 * 35 * layout.channels()).map(|i| [-0.25, 4.0, 0.0, 0.0001, 0.5][i % 5]).collect(); + let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); + photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() +} + +fn open(path: &std::path::Path, ctl: &Interrupt) -> Result { + photocraft_io::import_file("HDR", path, &photocraft_io::ImportOptions::default(), ctl) +} + +// One test keeps these imports from competing for the global working allowance. +#[test] +fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication() { + let path = std::env::temp_dir().join(format!("photocraft-exr-stream-{}.exr", std::process::id())); + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::F16, SampleType::F32] { + for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let bytes = fixture(layout, sample, compression); + std::fs::write(&path, &bytes).unwrap(); + let reference = photocraft_io::import("HDR", &bytes).unwrap(); + let actual = open(&path, &Interrupt::NONE).unwrap(); + { + let a = &actual.document; + let r = &reference.document; + assert_eq!(actual.warnings, reference.warnings); + assert_eq!(a.name, r.name); + assert_eq!(a.size, r.size); + assert_eq!(a.mode, r.mode); + assert_eq!(a.depth, photocraft_color::SampleType::F32); + assert_eq!(a.icc_profile, r.icc_profile); + assert_eq!(a.layers[0].locks, r.layers[0].locks); + let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&a.layers[0].content, &r.layers[0].content) else { + panic!("raster background required") + }; + assert_eq!( + a.to_interleaved(reference.document.bounds()), + r.to_interleaved(reference.document.bounds()), + "{layout:?}/{sample:?}/{compression:?}" + ); + } + } + } + } + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + std::fs::write(&path, &bytes).unwrap(); + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::Relaxed); + let progress = |p| { + if p > 0.2 { + cancelled.store(true, Ordering::Relaxed); + } + }; + assert!(matches!(open(&path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); + std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); + assert!(open(&path, &Interrupt::NONE).is_err()); + // A syntactically complete huge header/table must be refused before reading + // its pixel blocks when scratch is unavailable. No giant fixture is needed. + assert!(!photocraft_raster::spill::enabled()); + let mut header_end = 8; + loop { + if bytes[header_end] == 0 { + header_end += 1; + break; + } + for _ in 0..2 { + while bytes[header_end] != 0 { + header_end += 1; + } + header_end += 1; + } + let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; + header_end += 4 + length; + } + let mut huge = bytes[..header_end].to_vec(); + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); + huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); + let offset = (header_end + 2048 * 8) as u64; + for _ in 0..2048 { + huge.extend_from_slice(&offset.to_le_bytes()); + } + huge.extend_from_slice(&[0; 8]); + std::fs::write(&path, &huge).unwrap(); + assert!(matches!(open(&path, &Interrupt::NONE), Err(photocraft_io::IoError::OutOfMemory(ref message)) if message.contains("decoded image tiles"))); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/io/tests/it/text_corpus.rs b/crates/io/tests/it/text_corpus.rs index 32e914d051..b0deed7746 100644 --- a/crates/io/tests/it/text_corpus.rs +++ b/crates/io/tests/it/text_corpus.rs @@ -315,3 +315,65 @@ fn created_text_layer_roundtrips_through_psd() { assert_eq!(bt.transform, t.transform); assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10)); } + +/// Review of #8888: the older Photoshop's text objects (`/DocumentObjects /TextObjects`) in the +/// real psd-tools `text.psd`. Saving the type layer unedited leaves them (and the font list they +/// index) exactly as the file has them; editing only the size changes the size of the legacy style +/// run and nothing else in it, and the object's text, paragraph runs and view are untouched. +#[cfg(feature = "corpus")] +#[test] +fn corpus_text_psd_legacy_objects_are_edited_in_place() { + use photocraft_text::engine_data::Value as E; + + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/text.psd"); + assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display()); + let bytes = std::fs::read(&path).unwrap(); + let doc = photocraft_io::import("text.psd", &bytes).unwrap().document; + let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()).expect("text.psd has a Txt2 block"); + let mut all = Vec::new(); + walk(&doc.layers, &mut all); + let layer = all.iter().find_map(|l| if let LayerContent::Text(t) = &l.content { Some(t.clone()) } else { None }).expect("a type layer"); + + let before = photocraft_text::psd::parse_txt2(&txt2).expect("the file's Txt2 parses"); + let legacy = |block: &E| block.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).cloned().expect("the legacy object"); + let features = |entry: &E| { + entry + .path(&["Model", "StyleRun", "RunArray"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|r| r.path(&["RunData", "StyleSheet", "Features"])) + .cloned() + }; + let items = |value: &E| if let E::Dict(items) = value { items.clone() } else { Vec::new() }; + + // Unedited: the legacy object and the font list are as the file has them. + let unedited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &layer)], Some(&txt2), doc.resolution_dpi)).unwrap(); + assert!(legacy(&unedited) == legacy(&before), "an unedited layer leaves the legacy text object alone"); + assert!(unedited.path(&["DocumentResources", "FontSet"]) == before.path(&["DocumentResources", "FontSet"]), "and the font list it indexes"); + + // Edited: only the size. + let mut bigger = layer.clone(); + for run in &mut bigger.runs { + run.style.size_pt += 7.0; + } + let edited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &bigger)], Some(&txt2), doc.resolution_dpi)).unwrap(); + let (was, now) = (features(&legacy(&before)).expect("features"), features(&legacy(&edited)).expect("features")); + let size = |f: &E| f.get("FontSize").and_then(E::as_f64).expect("FontSize"); + assert!((size(&now) - size(&was) - 7.0).abs() < 1e-6, "the legacy size follows the edit: {} -> {}", size(&was), size(&now)); + let rest = |f: &E| items(f).into_iter().filter(|(k, _)| k != "FontSize").collect::>(); + let changed: Vec = + rest(&was).iter().zip(rest(&now).iter()).filter(|(a, b)| a != b).map(|(a, b)| format!("{}: {:?} -> {:?}", a.0, a.1, b.1)).collect(); + assert!(rest(&was) == rest(&now), "every other style key of the run is as the file has it; changed: {changed:?}"); + let others = |entry: &E, key: &str| entry.path(&["Model", key]).cloned(); + for key in ["Text", "ParagraphRun", "KernRun"] { + assert!(others(&legacy(&edited), key) == others(&legacy(&before), key), "{key} is untouched"); + } + assert!(legacy(&edited).get("View") == legacy(&before).get("View"), "the view is untouched"); + let lengths = |entry: &E| { + entry + .path(&["Model", "StyleRun", "RunArray"]) + .and_then(E::as_array) + .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64)).collect::>()) + }; + assert!(lengths(&legacy(&edited)) == lengths(&legacy(&before)), "the runs still cover the text"); +} diff --git a/crates/io/tests/it/tiff_banded_import.rs b/crates/io/tests/it/tiff_banded_import.rs new file mode 100644 index 0000000000..a7bc468205 --- /dev/null +++ b/crates/io/tests/it/tiff_banded_import.rs @@ -0,0 +1,220 @@ +//! TIFFs open a group of rows at a time straight into the layer's tiles (#8169): the document +//! is the one decoding the whole image gives, for every layout and depth, and an image over the +//! old 2^30 px limit opens. + +use photocraft_codecs::{self as codecs, ChannelLayout, EncodeOptions, Format, Image, SampleType, TiffCompression}; +use photocraft_doc::LayerContent; + +/// A smooth pattern with some detail, in `layout` and `sample`. +fn pattern(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) -> Image { + let ch = layout.channels(); + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| (0..ch).map(move |c| ((x * 7 + y * 13 + c as u32 * 29) % 256) as f32 / 255.0))).collect(); + Image::from_normalized(w, h, layout, sample, &px).unwrap() +} + +fn tiff(img: &Image, compression: TiffCompression) -> Vec { + codecs::encode(img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap() +} + +/// The layer's pixels as the codec's interleaved bytes. +fn layer_bytes(r: &photocraft_io::ImportResult) -> Vec { + let d = &r.document; + let [layer] = &d.layers[..] else { panic!("{} layers", d.layers.len()) }; + let LayerContent::Raster(s) = &layer.content else { panic!("not a raster layer") }; + s.to_interleaved(d.bounds()) +} + +#[test] +fn every_layout_opens_as_the_whole_image_decode_does() { + let cases = [ + (ChannelLayout::Rgb, SampleType::U8), + (ChannelLayout::Rgba, SampleType::U16), + (ChannelLayout::Gray, SampleType::U8), + (ChannelLayout::GrayA, SampleType::F32), + (ChannelLayout::Cmyk, SampleType::U8), + (ChannelLayout::CmykA, SampleType::U16), + (ChannelLayout::Lab, SampleType::U16), + (ChannelLayout::Rgb, SampleType::F16), + ]; + for (layout, sample) in cases { + for compression in [TiffCompression::None, TiffCompression::Lzw] { + let what = format!("{layout:?} {sample:?} {compression:?}"); + let img = pattern(301, 257, layout, sample); + let bytes = tiff(&img, compression); + let r = photocraft_io::import("t.tif", &bytes).unwrap_or_else(|e| panic!("{what}: {e}")); + let decoded = codecs::decode(&bytes).unwrap(); + let target = match layout { + ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA, + ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA, + _ => ChannelLayout::Rgba, + }; + let stored = decoded.sample_type(); + let depth = if stored == SampleType::F16 { SampleType::F32 } else { stored }; + assert!(layer_bytes(&r) == decoded.convert(target, depth).data(), "{what}: pixels"); + let d = &r.document; + assert_eq!((d.size.width, d.size.height), (301, 257), "{what}"); + assert_eq!(d.layers[0].name, if layout.has_alpha() { "Layer 0" } else { "Background" }, "{what}"); + assert_eq!(d.icc_profile.as_deref().map(Vec::as_slice), decoded.icc.as_deref(), "{what}"); + assert_eq!(r.warnings.iter().any(|w| w.contains("16-bit float")), stored == SampleType::F16, "{what}: {:?}", r.warnings); + } + } +} + +/// A file stored as one strip is one group, the whole image, converted to the layer in rounds of +/// rows: the document is the one the whole-image decode gives (#8536 review). +#[test] +fn a_single_strip_tiff_opens_as_the_whole_image_decode_does() { + use flate2::{Compression, write::ZlibEncoder}; + use std::io::Write; + let (w, h) = (1531, 2049); + let img = pattern(w, h, ChannelLayout::Rgb, SampleType::U8); + let mut z = ZlibEncoder::new(Vec::new(), Compression::fast()); + z.write_all(img.data()).unwrap(); + let strip = z.finish().unwrap(); + let mut f = b"II*\0\0\0\0\0".to_vec(); + f.extend_from_slice(&strip); + let bytes = rgb_tiff_directory(f, w, h, h, &[8], &[strip.len() as u32]); + let r = photocraft_io::import("one-strip.tif", &bytes).unwrap(); + let decoded = codecs::decode(&bytes).unwrap(); + assert_eq!(decoded.data(), img.data()); + assert!(layer_bytes(&r) == decoded.convert(ChannelLayout::Rgba, SampleType::U8).data(), "pixels"); +} + +/// Peak resident memory of this process so far (Linux), in bytes. +fn peak_rss() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: u64 = status.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) +} + +/// An RGB8 TIFF of `w`×`h` with Deflate strips of 64 rows, written a strip at a time: a few +/// horizontal bands of colour with a ramp, which compresses well. +fn big_rgb_tiff(w: u32, h: u32) -> Vec { + use flate2::{Compression, write::ZlibEncoder}; + use std::io::Write; + let rows = 64u32; + let mut f = b"II*\0\0\0\0\0".to_vec(); + let mut offsets = Vec::new(); + let mut counts = Vec::new(); + let mut row = vec![0u8; w as usize * 3]; + for y0 in (0..h).step_by(rows as usize) { + let mut z = ZlibEncoder::new(Vec::new(), Compression::fast()); + for y in y0..(y0 + rows).min(h) { + let band = (y / 997) as u8; + for (x, px) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + px.copy_from_slice(&[band.wrapping_mul(40), (x / 1024) as u8, 200]); + } + z.write_all(&row).unwrap(); + } + let strip = z.finish().unwrap(); + offsets.push(f.len() as u32); + counts.push(strip.len() as u32); + f.extend_from_slice(&strip); + } + rgb_tiff_directory(f, w, h, rows, &offsets, &counts) +} + +/// An all-black RGB8 TIFF of `w`×`h` whose Deflate strips of 64 rows all point at one +/// compressed strip, padded with zeros just past what the decompression-bomb guard asks for: a +/// valid file of tens of MB declaring hundreds of GB of pixels (#8536 review). +#[cfg(target_os = "linux")] +fn black_rgb_tiff(w: u32, h: u32) -> Vec { + use flate2::{Compression, write::ZlibEncoder}; + use std::io::Write; + let rows = 64u32; + let mut z = ZlibEncoder::new(Vec::new(), Compression::fast()); + z.write_all(&vec![0u8; w as usize * 3 * rows as usize]).unwrap(); + let strip = z.finish().unwrap(); + let mut f = b"II*\0\0\0\0\0".to_vec(); + let at = f.len() as u32; + f.extend_from_slice(&strip); + let stored = u64::from(w) * u64::from(h) * 3; + f.resize((stored / 4096 + (1 << 20)) as usize, 0); + let n = h.div_ceil(rows) as usize; + rgb_tiff_directory(f, w, h, rows, &vec![at; n], &vec![strip.len() as u32; n]) +} + +/// `f` (a little-endian header and the strip data) with the strip tables and the directory of +/// an RGB8 Deflate image appended. +fn rgb_tiff_directory(mut f: Vec, w: u32, h: u32, rows: u32, offsets: &[u32], counts: &[u32]) -> Vec { + let arrays_at = f.len() as u32; + for v in offsets.iter().chain(counts) { + f.extend_from_slice(&v.to_le_bytes()); + } + let bits_at = f.len() as u32; + f.extend_from_slice(&[8, 0, 8, 0, 8, 0]); + let ifd = f.len() as u32; + f[4..8].copy_from_slice(&ifd.to_le_bytes()); + let n = offsets.len() as u32; + let entries: [(u16, u16, u32, u32); 10] = [ + (256, 4, 1, w), + (257, 4, 1, h), + (258, 3, 3, bits_at), + (259, 3, 1, 8), + (262, 3, 1, 2), + // A single strip's offset and count fit in the entry itself. + (273, 4, n, if let [o] = offsets { *o } else { arrays_at }), + (277, 3, 1, 3), + (278, 4, 1, rows), + (279, 4, n, if let [c] = counts { *c } else { arrays_at + 4 * n }), + (284, 3, 1, 1), + ]; + f.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, ty, count, v) in entries { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&ty.to_le_bytes()); + f.extend_from_slice(&count.to_le_bytes()); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&0u32.to_le_bytes()); + f +} + +/// Opt-in, release: `cargo test --release -p photocraft-io --test it -- tiff_banded_import:: --ignored +/// --nocapture`. Opens a 35,000 × 35,000 px TIFF (1.2 GP, the size from #8169's second report; +/// it failed with "1225000000 pixels exceed max 1073741824"). `BANNER=1` opens the 117,504 × +/// 22,464 px banner from #8456 instead (2.64 GP; about 11 GB of RAM), `SIZE=x` any size. +#[test] +#[ignore = "needs about 5 GB of RAM (11 GB with BANNER=1); run in release"] +fn opens_a_tiff_over_a_gigapixel() { + let size = std::env::var("SIZE").ok().and_then(|s| s.split_once('x').and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?)))); + let (w, h): (u32, u32) = size.unwrap_or(if std::env::var_os("BANNER").is_some() { (117_504, 22_464) } else { (35_000, 35_000) }); + let t = std::time::Instant::now(); + let bytes = big_rgb_tiff(w, h); + eprintln!("built a {w}x{h} TIFF of {} MB in {:.1?}", bytes.len() / 1_000_000, t.elapsed()); + let before = peak_rss(); + let t = std::time::Instant::now(); + let r = photocraft_io::import("big.tif", &bytes).unwrap(); + let took = t.elapsed(); + let d = &r.document; + assert_eq!((d.size.width, d.size.height), (w, h)); + let LayerContent::Raster(s) = &d.layers[0].content else { panic!() }; + for (x, y) in [(0, 0), (w - 1, h - 1), (w / 2, h / 2), (5000.min(w - 1), 21_000.min(h - 1))] { + let band = (y / 997) as u8; + let want = [band.wrapping_mul(40), (x / 1024) as u8, 200, 255]; + assert_eq!(s.to_interleaved(photocraft_geom::Rect::new(x as i32, y as i32, x as i32 + 1, y as i32 + 1)), want, "pixel {x},{y}"); + } + let layer_gb = u64::from(w) * u64::from(h) * 4; + eprintln!( + "opened in {took:.1?}; peak RSS {:.2} GB before, {:.2} GB after; the RGBA layer is {:.2} GB", + before.unwrap_or(0) as f64 / 1e9, + peak_rss().unwrap_or(0) as f64 / 1e9, + layer_gb as f64 / 1e9 + ); +} + +/// A file that declares more pixels than the machine can hold is refused before the layer is +/// allocated (#8536 review). Linux overcommits, so allocating the tiles succeeded and writing them +/// had the process killed (with every open document) instead of returning an error. +#[test] +#[cfg(target_os = "linux")] +fn a_tiff_larger_than_free_memory_is_refused() { + let (w, h) = (300_000, 300_000); + let bytes = black_rgb_tiff(w, h); + let layer = u64::from(w) * u64::from(h) * 4; + let free = photocraft_io::memory::available().unwrap(); + assert!(free < layer, "this machine has {free} bytes free, enough for the {layer} byte layer"); + let e = photocraft_io::import("huge.tif", &bytes).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains("not enough memory to open this 300000 × 300000 px image"), "{e}"); +} diff --git a/crates/io/tests/it/tiff_corpus.rs b/crates/io/tests/it/tiff_corpus.rs index 697eab6d32..1b3c36a05d 100644 --- a/crates/io/tests/it/tiff_corpus.rs +++ b/crates/io/tests/it/tiff_corpus.rs @@ -124,8 +124,8 @@ fn corpus_round_trips_through_layered_tiff() { } d }; - let x = photocraft_compose::flatten(&expected).px; - let y = photocraft_compose::flatten(&back).px; + let x = photocraft_compose::flatten(&expected).into_px(); + let y = photocraft_compose::flatten(&back).into_px(); let diff = max_diff(&x, &y); if diff > 1.0 / 255.0 + 1e-5 { return Err(format!("render differs by {diff}")); diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index 70ec2fef2e..3c7815a37a 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -87,8 +87,8 @@ fn tiff_and_psd_open_as_the_same_document() { ) .unwrap() .document; - let a = photocraft_compose::flatten(&d).px; - let b = photocraft_compose::flatten(&flat).px; + let a = photocraft_compose::flatten(&d).into_px(); + let b = photocraft_compose::flatten(&flat).into_px(); assert!(max_diff(&a, &b) <= 2.0 / 255.0, "{mode:?} {depth:?}: composite differs by {}", max_diff(&a, &b)); } } @@ -357,8 +357,7 @@ fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { } } -#[test] -fn damaged_layer_data_opens_flattened() { +fn damaged_layer_data_opens_flattened_part(part: u64) { let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); let r = tiff(&d, true); let (_, layers) = tags(&r.bytes); @@ -376,9 +375,15 @@ fn damaged_layer_data_opens_flattened() { // them, a different quarter each day. let sweep = photocraft_testkit::Sweep::new(4); let mut case = 0; + let mut selected = 0; let mut runs = || { case += 1; - sweep.includes(case) + if !sweep.includes(case) { + return false; + } + let belongs = selected % 4 == part; + selected += 1; + belongs }; for cut in (0..r.bytes.len()).step_by(11).filter(|_| runs()) { let _ = import("x.tif", &r.bytes[..cut]); @@ -1139,3 +1144,23 @@ fn channels_past_sixteen_leave_no_resources_behind() { assert_eq!(names, want.iter().map(String::as_str).collect::>()); assert!(back.quick_mask.is_none()); } + +#[test] +fn damaged_layer_data_opens_flattened_part_0() { + damaged_layer_data_opens_flattened_part(0); +} + +#[test] +fn damaged_layer_data_opens_flattened_part_1() { + damaged_layer_data_opens_flattened_part(1); +} + +#[test] +fn damaged_layer_data_opens_flattened_part_2() { + damaged_layer_data_opens_flattened_part(2); +} + +#[test] +fn damaged_layer_data_opens_flattened_part_3() { + damaged_layer_data_opens_flattened_part(3); +} diff --git a/crates/io/tests/it/tiff_save_options.rs b/crates/io/tests/it/tiff_save_options.rs new file mode 100644 index 0000000000..dc1b564156 --- /dev/null +++ b/crates/io/tests/it/tiff_save_options.rs @@ -0,0 +1,365 @@ +//! TIFF Options (#8827): Save Transparency on and off, flat and layered, with and without spot +//! channels, and Save Image Pyramid. Unset Save Transparency is covered in `tiff_layers`. + +use crate::common::*; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; +use photocraft_psd::tiff::ImageSourceData; +use photocraft_raster::Surface; + +/// A 4×4 document whose one layer is opaque red in its middle and transparent around it, with a +/// "White" spot channel inked in its left half when `spot`. +fn art(depth: SampleType, spot: bool) -> Document { + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + if spot { + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + let mut white = AlphaChannel::new("White", plate); + white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0)); + doc.channels.push(white); + } + doc +} + +fn save(doc: &Document, layers: bool, transparency: Option) -> ExportResult { + export(doc, "t.tif", &ExportOptions { tiff_layers: layers, tiff_transparency: transparency, ..Default::default() }).expect("export") +} + +fn extra_samples_tag(bytes: &[u8]) -> Vec { + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap(); + dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default() +} + +/// A TIFF's channel resources: the names in 1006, and the byte lengths of DisplayInfo (1077) +/// and the alpha identifiers (1053). +fn channel_resources(bytes: &[u8]) -> (Vec, usize, usize) { + let tag = photocraft_codecs::tiff_photoshop_tags(bytes).0.expect("resources"); + let (resources, _) = photocraft_psd::tiff::resources_from_bytes(tag).expect("parse resources"); + let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default(); + let pascal = data(1006); + let (mut at, mut names) = (0usize, Vec::new()); + while let Some(&len) = pascal.get(at) { + names.push(String::from_utf8_lossy(&pascal[at + 1..at + 1 + usize::from(len)]).into_owned()); + at += 1 + usize::from(len); + } + (names, data(1077).len(), data(1053).len()) +} + +/// The first sample of pixel (0, 0) and its alpha, on the 0–1 scale. +fn corner(img: &photocraft_codecs::Image) -> (f32, Option) { + let f = img.convert(img.layout(), photocraft_codecs::SampleType::F32); + let s = f.to_f32_samples().unwrap(); + (s[0], img.layout().has_alpha().then(|| s[img.layout().channels() - 1])) +} + +fn layer_alpha(doc: &Document, x: i32, y: i32) -> f32 { + let LayerContent::Raster(s) = &doc.layers[0].content else { panic!("raster") }; + // Alpha is the last channel (index 4 in CMYK). + s.pixel(x, y).last().copied().unwrap_or(1.0) +} + +#[test] +fn off_mattes_the_composite_on_white_and_layers_keep_theirs() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for spot in [false, true] { + for layers in [false, true] { + let case = format!("{depth:?} spot {spot} layers {layers}"); + let doc = art(depth, spot); + let r = save(&doc, layers, Some(false)); + assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{case}: the user chose it: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{case}"); + assert_eq!(img.meta.extra_samples.len(), usize::from(spot), "{case}"); + assert_eq!(extra_samples_tag(&r.bytes), if spot { vec![0] } else { vec![] }, "{case}"); + assert!((corner(&img).0 - 1.0).abs() < 1e-4, "{case}: transparent corner is white"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), if spot { vec!["White"] } else { vec![] }, "{case}"); + if layers { + assert_eq!(layer_alpha(&back, 0, 0), 0.0, "{case}: the layer keeps its transparency"); + let (isd, _) = ImageSourceData::from_bytes(photocraft_codecs::tiff_photoshop_tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.layer_info.unwrap().merged_alpha, "{case}: no merged transparency is claimed"); + assert!(!isd.global_blocks.iter().any(|b| b.is_merged_transparency_marker()), "{case}"); + } + } + } + } +} + +#[test] +fn on_keeps_transparency_ahead_of_spot_channels() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for layers in [false, true] { + let case = format!("{depth:?} layers {layers}"); + let doc = art(depth, true); + let r = save(&doc, layers, Some(true)); + assert!(!r.warnings.iter().any(|w| w.contains("composited over white")), "{case}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha(), "{case}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{case}"); + // Transparency (unassociated alpha) first, then the spot. + assert_eq!(extra_samples_tag(&r.bytes), vec![2, 0], "{case}"); + assert_eq!(corner(&img).1, Some(0.0), "{case}"); + let back = import("t.tif", &r.bytes).unwrap().document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"], "{case}: the transparency is not opened as a channel"); + assert!(back.channels[0].spot.is_some(), "{case}"); + assert_eq!(back.channels[0].surface.pixel(0, 0)[0], 1.0, "{case}: inked"); + assert_eq!(back.channels[0].surface.pixel(3, 0)[0], 0.0, "{case}: bare"); + assert_eq!(layer_alpha(&back, 0, 0), 0.0, "{case}"); + assert_eq!(layer_alpha(&back, 1, 1), 1.0, "{case}"); + // Photoshop pairs the channel resources with every sample past the colour channels, + // so both paths list the transparency first: a name, a DisplayInfo record and an id. + let (names, display, ids) = channel_resources(&r.bytes); + assert_eq!(names, ["Transparency", "White"], "{case}"); + assert_eq!(display, 4 + 2 * 13, "{case}: DisplayInfo records"); + assert_eq!(ids, 2 * 4, "{case}: identifiers"); + if layers { + // As Photoshop writes it: a positive layer count and `MTrn` first. + let (isd, _) = ImageSourceData::from_bytes(photocraft_codecs::tiff_photoshop_tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.layer_info.as_ref().unwrap().merged_alpha, "{case}"); + assert_eq!(&isd.global_blocks[0].key, b"MTrn", "{case}"); + // The marker is kept for the next save, as from Photoshop's own file. + let mut want = doc.clone(); + want.metadata.psd_global_blocks = vec![(*b"8BIM", *b"MTrn", std::sync::Arc::new(Vec::new()))]; + assert_docs_eq(&want, &back); + } + } + } +} + +/// Channel names outside ASCII survive a save with Save Transparency on, flat and layered: the +/// Unicode names (1045) carry them, after the transparency's entry. +#[test] +fn on_keeps_non_ascii_channel_names() { + let mut doc = art(SampleType::U8, true); + doc.channels[0].name = "Blå".into(); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + doc.channels.push(AlphaChannel::new("Máscara 2", Surface::new(gray))); + for layers in [false, true] { + let r = save(&doc, layers, Some(true)); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Blå", "Máscara 2"], "layers {layers}"); + assert!(back.channels[0].spot.is_some(), "layers {layers}: the spot keeps its ink"); + } +} + +#[test] +fn on_keeps_a_quick_mask_in_place() { + let mut doc = art(SampleType::U8, true); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut q = Surface::new(gray); + q.fill_rect(Rect::new(2, 2, 4, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", q)); + for layers in [false, true] { + let r = save(&doc, layers, Some(true)); + assert_eq!(extra_samples_tag(&r.bytes), vec![2, 0, 0], "layers {layers}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["White"], "layers {layers}"); + let quick = back.quick_mask.as_ref().unwrap_or_else(|| panic!("layers {layers}: quick mask")); + assert_eq!(quick.surface.pixel(3, 3)[0], 1.0, "layers {layers}"); + assert_eq!(quick.surface.pixel(0, 0)[0], 0.0, "layers {layers}"); + } +} + +/// Past 16 channels a layered TIFF keeps the first 16. With Save Transparency on, the name and +/// DisplayInfo lists hold the transparency ahead of them, so trimming keeps 17 entries and +/// every channel still opens under its own name (they used to shift by one). +#[test] +fn on_past_sixteen_channels_keeps_each_channels_name() { + let mut doc = art(SampleType::U8, false); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + for i in 1..=18 { + doc.channels.push(AlphaChannel::new(format!("Alpha {i}"), Surface::new(gray))); + } + let r = save(&doc, true, Some(true)); + assert!(r.warnings.iter().any(|w| w.contains("18 extra channels") && w.contains("first 16")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha()); + assert_eq!(img.meta.extra_samples.len(), 16); + let back = import("t.tif", &r.bytes).unwrap().document; + let want: Vec = (1..=16).map(|i| format!("Alpha {i}")).collect(); + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), want.iter().map(String::as_str).collect::>()); +} + +#[test] +fn on_without_transparency_writes_no_alpha() { + // An opaque document has no transparency to save. + let mut doc = art(SampleType::U8, false); + doc.layers[0].locks.transparency = true; + let fmt = doc.pixel_format(); + if let LayerContent::Raster(s) = &mut doc.layers[0].content { + s.fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); + } + for layers in [false, true] { + let r = save(&doc, layers, Some(true)); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha() || corner(&img).1 == Some(1.0), "layers {layers}"); + } +} + +#[test] +fn pyramid_levels_follow_the_full_image_and_open_the_same_document() { + // Big enough to need a level: 100 px halves to 50. + let mut doc = Document::new("t", Size::new(100, 100), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(10, 10, 60, 30), &photocraft_raster::from_rgba(&fmt, [1.0, 0.5, 0.0, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 50, 100), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + doc.channels.push(AlphaChannel::new("Alpha 1", plate)); + for layers in [false, true] { + let opts = |pyramid| ExportOptions { + tiff_layers: layers, + encode: photocraft_codecs::EncodeOptions { tiff_pyramid: pyramid, ..Default::default() }, + ..Default::default() + }; + let with = export(&doc, "t.tif", &opts(true)).unwrap(); + let without = export(&doc, "t.tif", &opts(false)).unwrap(); + let info = photocraft_codecs::tiff_info(&with.bytes).unwrap(); + let kinds: Vec<_> = info.pages.iter().map(|p| (p.kind, p.width)).collect(); + assert_eq!(kinds, [(photocraft_codecs::TiffPageKind::Page, 100), (photocraft_codecs::TiffPageKind::ReducedResolution, 50)], "layers {layers}"); + // The level holds the image at half size: level (15, 10) covers full-size (30..32, 20..22), + // inside the orange fill; (40, 40) is outside it, composited over white (the alpha + // channel keeps the transparency out of the file). + let level = photocraft_codecs::decode_tiff_page(&with.bytes, 1, &photocraft_codecs::DecodeOptions::default()).unwrap(); + assert_eq!(level.dimensions(), (50, 50), "layers {layers}"); + let level = level.convert(level.layout(), photocraft_codecs::SampleType::F32); + let px = level.to_f32_samples().unwrap(); + for ((x, y), want) in [((15, 10), [1.0f32, 0.5, 0.0]), ((40, 40), [1.0, 1.0, 1.0])] { + let i = (y * 50 + x) * level.layout().channels(); + for (c, want) in want.iter().enumerate() { + assert!((px[i + c] - want).abs() < 0.01, "layers {layers} ({x}, {y}) channel {c}: {}", px[i + c]); + } + } + let a = import("t.tif", &with.bytes).unwrap(); + let b = import("t.tif", &without.bytes).unwrap(); + assert!(!a.warnings.iter().any(|w| w.contains("page")), "layers {layers}: {:?}", a.warnings); + assert_docs_eq(&b.document, &a.document); + } +} + +/// `PHOTOCRAFT_TIFF_INTEROP=` writes every TIFF Options combination there, to check with +/// other readers (libtiff `tiffinfo`, ImageMagick, vips): +/// +/// ```sh +/// PHOTOCRAFT_TIFF_INTEROP=/tmp/tiff cargo test -p photocraft-io --test it -- tiff_save_options::write_interop_samples --ignored +/// ``` +#[test] +#[ignore = "writes sample files for other readers; set PHOTOCRAFT_TIFF_INTEROP"] +fn write_interop_samples() { + let Some(dir) = std::env::var_os("PHOTOCRAFT_TIFF_INTEROP") else { return }; + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + for (mode, mname) in [(ColorMode::Rgb, "rgb"), (ColorMode::Cmyk, "cmyk"), (ColorMode::Grayscale, "gray")] { + for (depth, dname) in [(SampleType::U8, "8"), (SampleType::U16, "16"), (SampleType::F32, "32")] { + if mode == ColorMode::Cmyk && depth == SampleType::F32 { + continue; + } + let mut doc = Document::new("t", Size::new(300, 200), mode, depth); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(40, 30, 260, 170), &photocraft_raster::from_rgba(&fmt, [0.9, 0.2, 0.1, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + for spot in [false, true] { + if spot { + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 150, 200), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + let mut white = AlphaChannel::new("White", plate); + white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0)); + doc.channels = vec![white]; + } + for layers in [false, true] { + for (t, tname) in [(None, "unset"), (Some(true), "on"), (Some(false), "off")] { + for (c, cname) in [ + (photocraft_codecs::TiffCompression::None, "none"), + (photocraft_codecs::TiffCompression::Lzw, "lzw"), + (photocraft_codecs::TiffCompression::Deflate, "zip"), + ] { + for pyramid in [false, true] { + let opts = ExportOptions { + tiff_layers: layers, + tiff_transparency: t, + encode: photocraft_codecs::EncodeOptions { tiff_compression: c, tiff_pyramid: pyramid, ..Default::default() }, + ..Default::default() + }; + let r = export(&doc, "t.tif", &opts).unwrap(); + let name = format!( + "{mname}{dname}-{}-{}-t{tname}-{cname}{}.tif", + if spot { "spot" } else { "plain" }, + if layers { "layered" } else { "flat" }, + if pyramid { "-pyramid" } else { "" } + ); + std::fs::write(dir.join(name), r.bytes).unwrap(); + } + } + } + } + } + } + } +} + +/// Save Transparency in every colour mode a TIFF holds: off mattes on the mode's white (paper +/// for CMYK, L* 100 for Lab), on keeps the transparency; flat and, where the mode keeps layers, +/// layered. +#[test] +fn every_colour_mode_saves_or_mattes_its_transparency() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::new("t", Size::new(4, 4), mode, depth); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [0.2, 0.4, 0.6, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + for layers in [false, true] { + for t in [Some(true), Some(false)] { + let case = format!("{mode:?} {depth:?} layers {layers} {t:?}"); + let r = save(&doc, layers, t); + let img = photocraft_codecs::decode(&r.bytes).unwrap_or_else(|e| panic!("{case}: {e}")); + assert_eq!(img.layout().has_alpha(), t == Some(true), "{case}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.mode, mode, "{case}"); + if t == Some(false) { + // The merged image (what other readers show) is matted on the mode's own + // white, flat or layered: CMYK paper is no ink, Lab white L* 100 with a* + // and b* at their midpoint. + let white: &[f32] = match mode { + ColorMode::Cmyk => &[0.0, 0.0, 0.0, 0.0], + ColorMode::Lab => &[1.0, 128.0 / 255.0, 128.0 / 255.0], + ColorMode::Grayscale => &[1.0], + _ => &[1.0, 1.0, 1.0], + }; + let f = img.convert(img.layout(), photocraft_codecs::SampleType::F32); + let merged = f.to_f32_samples().unwrap(); + assert_eq!(img.layout().channels(), white.len(), "{case}"); + assert!(merged.iter().zip(white).all(|(a, b)| (a - b).abs() < 1e-2), "{case}: merged {:?} vs {white:?}", &merged[..white.len()]); + // Only the transparent corner is matted: the opaque centre at (1, 1) + // (sample 5 of the 4-px-wide image) keeps its colour. + let centre = &merged[5 * white.len()..6 * white.len()]; + assert!(centre.iter().zip(white).any(|(a, b)| (a - b).abs() >= 1e-2), "{case}: centre {centre:?} is matte white"); + // The corner was transparent: now the mode's white, read back opaque. + let LayerContent::Raster(s) = &back.layers[0].content else { panic!("raster") }; + let want = photocraft_raster::from_rgba(&back.pixel_format(), [1.0, 1.0, 1.0, 1.0]); + if !layers { + let px = s.pixel(0, 0); + assert!(px.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-2), "{case}: {px:?} vs {want:?}"); + } + } else { + assert_eq!(layer_alpha(&back, 0, 0), 0.0, "{case}"); + } + assert_eq!(layer_alpha(&back, 1, 1), 1.0, "{case}"); + } + } + } + } +} diff --git a/crates/io/tests/large_png_export.rs b/crates/io/tests/large_png_export.rs new file mode 100644 index 0000000000..797efab049 --- /dev/null +++ b/crates/io/tests/large_png_export.rs @@ -0,0 +1,68 @@ +//! PNG export of a multi-gigapixel print (#8456): a 136 ft × 26 ft banner at 72 ppi is +//! 117504 × 22464 px (2.64 GP, 7.9 GB of 8-bit RGB), past the 2^30 px a whole-image export could +//! hold. It is written a band of rows at a time, its zlib stream split over IDAT chunks. + +use std::io::Read; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export}; +use photocraft_raster::Surface; + +/// The IHDR fields and the IDAT payloads of `png`. +fn chunks(png: &[u8]) -> ((u32, u32, u8, u8), Vec<&[u8]>) { + let (mut at, mut ihdr, mut idat) = (8, None, Vec::new()); + while at + 12 <= png.len() { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + let data = &png[at + 8..at + 8 + len]; + match &png[at + 4..at + 8] { + b"IHDR" => ihdr = Some((u32::from_be_bytes(data[0..4].try_into().unwrap()), u32::from_be_bytes(data[4..8].try_into().unwrap()), data[8], data[9])), + b"IDAT" => idat.push(data), + _ => {} + } + at += 12 + len; + } + (ihdr.unwrap(), idat) +} + +/// Run with `cargo test --release -p photocraft-io --test large_png_export -- --ignored`; with +/// `COMPOSITE=1` a translucent layer on top makes the export composite the banner. +/// Measured 2026-10-22 (20 threads): 12.3 s and 0.49 GB peak RSS, composited 29 s and 1.2 GB; the +/// whole-image export took 12.9 s and 8.0 GB, composited 43 s and 10.3 GB. +#[test] +#[ignore = "encodes 7.9 GB of pixels; run in release"] +fn a_banner_over_two_gigapixels_exports_as_png() { + const W: u32 = 117_504; + const H: u32 = 22_464; + let mut doc = Document::new("Tent banner", Size::new(W, H), ColorMode::Rgb, SampleType::U8); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(doc.bounds(), &[1.0, 1.0, 1.0, 1.0]); + s.fill_rect(Rect::new(0, 15_000, W as i32, H as i32), &[0.1, 0.45, 0.8, 1.0]); + s.fill_rect(Rect::new(40_000, 2_000, 77_504, 14_000), &[0.55, 0.05, 0.08, 1.0]); + doc.layers.push(Layer::new("Background", LayerContent::Raster(s))); + if std::env::var_os("COMPOSITE").is_some() { + let mut t = Surface::new(doc.pixel_format()); + t.fill_rect(Rect::new(10_000, 5_000, 100_000, 20_000), &[0.9, 0.7, 0.1, 0.6]); + doc.layers.push(Layer::new("Text", LayerContent::Raster(t))); + } + + let started = std::time::Instant::now(); + let r = export(&doc, "banner.png", &ExportOptions::default()).unwrap(); + eprintln!("exported {} MB in {:.1} s; warnings {:?}", r.bytes.len() >> 20, started.elapsed().as_secs_f64(), r.warnings); + + let ((w, h, depth, color), idat) = chunks(&r.bytes); + assert_eq!((w, h, depth, color), (W, H, 8, 2), "8-bit RGB"); + // The zlib stream inflates (Adler-32 checked) to a filter byte and W RGB pixels per row. + let joined = idat.iter().flat_map(|c| c.iter().copied()).collect::>(); + let mut z = flate2::read::ZlibDecoder::new(joined.as_slice()); + let (mut buf, mut total) = (vec![0u8; 1 << 20], 0u64); + loop { + let n = z.read(&mut buf).unwrap(); + if n == 0 { + break; + } + total += n as u64; + } + assert_eq!(total, u64::from(H) * (1 + u64::from(W) * 3)); +} diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs index 7fc6532862..180a82dd85 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -477,6 +477,8 @@ fn read_response(stream: &mut Stream) -> Result> { let mut chunked = false; // Where the chunk walk resumes, so each read costs its own bytes, not the whole body again. let mut chunk_at = 0usize; + // Where the header-end search resumes (three bytes back, for a marker split across reads). + let mut head_at = 0usize; let started = std::time::Instant::now(); loop { if started.elapsed() > REPLY_DEADLINE { @@ -491,14 +493,17 @@ fn read_response(stream: &mut Stream) -> Result> { return Err(Error::Protocol("the reply is larger than 16 MiB".into())); } if body_at.is_none() - && let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") + && let Some(end) = raw.get(head_at..).and_then(|r| r.windows(4).position(|w| w == b"\r\n\r\n")).map(|p| head_at + p) { body_at = Some(end + 4); let (_, headers, _) = http_response(&raw)?; expected = headers.iter().find(|(k, _)| k == "content-length").and_then(|(_, v)| v.parse().ok()); chunked = headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")); } - let Some(at) = body_at else { continue }; + let Some(at) = body_at else { + head_at = raw.len().saturating_sub(3); + continue; + }; let body = raw.get(at..).unwrap_or_default(); let complete = match expected { Some(len) if !chunked => body.len() >= len, diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index 14ddf43846..c428915a49 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -651,3 +651,23 @@ 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()); } + +/// A reply arriving a byte at a time, its blank line split across reads, is still found: the +/// header search resumes where it stopped instead of rescanning everything read so far. +#[test] +fn a_header_trickled_a_byte_at_a_time_is_found() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + s.set_nodelay(true).unwrap(); + for b in b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokEXTRA" { + s.write_all(&[*b]).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }); + let mut stream = Stream::Tcp(TcpStream::connect(addr).unwrap()); + let raw = read_response(&mut stream).unwrap(); + assert!(raw.ends_with(b"\r\n\r\nok"), "{:?}", String::from_utf8_lossy(&raw)); + server.join().unwrap(); +} diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 822af3804c..9ec59adad4 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -6,7 +6,8 @@ //! same approach Photoshop's History panel exposes to users (one state per step). //! //! Memory is bounded by `max_states` plus an approximate byte budget. The current document's -//! tiles and tiles only history holds count against it, including selection masks. +//! tiles and tiles only history holds count against it, including selection masks, and so do the +//! embedded contents of smart objects (shared `Arc` bytes, counted once however many states hold them). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -40,6 +41,13 @@ pub struct HistoryState { pub id: u64, } +/// The context in which a selected action row must remain available after it is folded away. +#[derive(Clone, Debug)] +pub enum ActionBrushSource { + Current(HistoryState), + Cleared(HistoryState), +} + #[derive(Clone, Debug)] pub struct History { /// Past states; the last one is the state *before* the current document. @@ -48,8 +56,8 @@ pub struct History { /// Lazily initialized when a byte budget is used; unlimited histories pay no counting cost. tiles: Option, pub max_states: usize, - /// Pixel memory budget in bytes for the current document plus the tiles only history holds - /// (0 = unlimited). [`History::trim`] drops the oldest states beyond it. + /// Pixel memory budget in bytes for the current document plus the tiles (and embedded smart + /// object contents) only history holds (0 = unlimited). [`History::trim`] drops the oldest states beyond it. pub max_bytes: usize, /// Label of the step that produced the current document. current_label: String, @@ -57,6 +65,10 @@ pub struct History { current_layers: LayerTarget, /// [`HistoryState::visibility`] of the current state. current_visibility: Vec, + /// Optional compound-operation accumulator; snapshots and rollback carry it with the history. + visibility_capture: Option>>, + /// Changes only when every undo/redo state is irreversibly discarded. + purge_generation: u64, /// History Options › Allow Non-Linear History: a step recorded while later states exist /// (after stepping back) keeps them and goes to the end of the list, instead of discarding /// them; see [`History::record`] and [`History::remove`]. @@ -74,11 +86,15 @@ pub struct History { next_id: u64, /// The History Brush's source state, set in the History panel (`None`: the default). brush_source: Option, + /// A snapshot-backed alias of a folded row. Clear History moves this with its row source; + /// a snapshot selected explicitly by the user remains separate in the document state. + brush_source_snapshot: Option, } /// What [`History::rollback`] needs to take the most recent [`History::record`] back. #[derive(Clone, Debug)] struct LastRecord { + visibility_capture_before: Option>>, /// The document it replaced (the state a rollback makes current again). before: Arc, /// How many former redo states it moved onto the undo side (always 0 in linear history). @@ -98,6 +114,7 @@ struct Cleared { undo: VecDeque, redo: Vec, brush_source: Option, + brush_source_snapshot: Option, } /// The undo label of the step [`History::clear_states`] adds (Edit › Undo Clear History). @@ -110,6 +127,7 @@ impl Default for History { } impl History { + /// Initialize history with the supplied document as its starting state and no active action visibility capture. pub fn new(max_states: usize) -> Self { Self { undo: VecDeque::new(), @@ -120,12 +138,15 @@ impl History { current_label: "Open".into(), current_layers: LayerTarget::default(), current_visibility: Vec::new(), + visibility_capture: None, + purge_generation: 0, non_linear: false, last_record: None, cleared: None, current_id: 0, next_id: 1, brush_source: None, + brush_source_snapshot: None, } } @@ -178,7 +199,7 @@ impl History { } evicted.push((at, state)); } - self.last_record = Some(LastRecord { before, moved, evicted }); + self.last_record = Some(LastRecord { before, moved, evicted, visibility_capture_before: self.visibility_capture.clone() }); } pub fn can_undo(&self) -> bool { @@ -220,6 +241,72 @@ impl History { /// again when a later change coalesces into the step). pub fn add_current_visibility(&mut self, ids: &[LayerId]) { merge_ids(&mut self.current_visibility, ids); + if let Some(capture) = &mut self.visibility_capture { + merge_ids(Arc::make_mut(capture), ids); + } + } + + /// Retain only changes that recorded edits make undoable, across trimming and local purges. + pub fn begin_visibility_capture(&mut self, prefix: &[LayerId]) { + self.visibility_capture = Some(Arc::new(prefix.to_vec())); + } + + /// Return the visibility prefix accumulated for the current grouped action, if capture is active. + pub fn captured_visibility(&self) -> Option<&[LayerId]> { + self.visibility_capture.as_deref().map(Vec::as_slice) + } + + /// Release an action's accumulator when a purge leaves no grouped step to publish. + pub fn end_visibility_capture(&mut self) { + self.visibility_capture = None; + } + + /// An irreversible history purge establishes a new starting point for later edits. + pub fn was_purged_since(&self, earlier: &Self) -> bool { + self.purge_generation != earlier.purge_generation + } + + /// Keep source selections when temporary action rows are consolidated. Return a selected + /// intermediate state so the caller can retain its pixels as a normal history snapshot. + /// A source already deleted before consolidation remains unresolved. + pub fn preserve_action_brush_source(&mut self, later: &Self) -> Option { + let current = self.current_id; + let prior_current = later.current_id; + let collapsed = |id| { + if id == prior_current || self.held_states().any(|state| state.id == id) { None } else { later.held_states().find(|state| state.id == id).cloned() } + }; + let source = later + .brush_source + .and_then(collapsed) + .map(ActionBrushSource::Current) + .or_else(|| later.cleared.as_ref().and_then(|cleared| cleared.brush_source).and_then(collapsed).map(ActionBrushSource::Cleared)); + let map = |source: Option| source.map(|id| if id == prior_current { current } else { id }); + self.brush_source = map(later.brush_source); + self.brush_source_snapshot = later.brush_source_snapshot; + if let (Some(dst), Some(src)) = (&mut self.cleared, &later.cleared) { + dst.brush_source = map(src.brush_source); + dst.brush_source_snapshot = src.brush_source_snapshot; + } + source + } + + /// The snapshot retaining a folded row's pixels in the currently active source context. + pub fn brush_source_snapshot(&self) -> Option { + self.brush_source_snapshot + } + + /// Retain a folded row through a native snapshot, without adding a visible Undo state. + pub fn set_action_brush_snapshot(&mut self, snapshot: u64) { + self.brush_source = None; + self.brush_source_snapshot = Some(snapshot); + } + + /// Keep a folded source inactive until Undo Clear History restores its source context. + pub fn set_cleared_brush_snapshot(&mut self, snapshot: u64) { + if let Some(cleared) = &mut self.cleared { + cleared.brush_source = None; + cleared.brush_source_snapshot = Some(snapshot); + } } /// Undo: `current` becomes redoable; returns the document to make current and the layers it @@ -227,10 +314,11 @@ impl History { 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; + let Cleared { undo, redo, brush_source, brush_source_snapshot } = *cleared; self.undo = undo; self.redo = redo; self.brush_source = brush_source; + self.brush_source_snapshot = brush_source_snapshot; self.tiles = None; return Some((current, self.current_layers.clone())); } @@ -243,6 +331,9 @@ 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()); let visibility = std::mem::replace(&mut self.current_visibility, prev.visibility); + if let Some(capture) = &mut self.visibility_capture { + merge_ids(Arc::make_mut(capture), &visibility); + } let id = std::mem::replace(&mut self.current_id, prev.id); self.redo.push(HistoryState { label, doc: current, layers, visibility, id }); Some((prev.doc, prev.layers)) @@ -259,6 +350,9 @@ 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()); let visibility = std::mem::replace(&mut self.current_visibility, next.visibility); + if let Some(capture) = &mut self.visibility_capture { + merge_ids(Arc::make_mut(capture), &self.current_visibility); + } let id = std::mem::replace(&mut self.current_id, next.id); self.undo.push_back(HistoryState { label, doc: current, layers, visibility, id }); Some((next.doc, next.layers)) @@ -286,6 +380,11 @@ impl History { } } + /// Visible History panel row ids in order, including the current and dimmed redo rows. + pub fn row_ids(&self) -> impl Iterator + '_ { + self.undo.iter().map(|state| state.id).chain(std::iter::once(self.current_id)).chain(self.redo.iter().rev().map(|state| state.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(); @@ -315,6 +414,7 @@ impl History { 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); + self.brush_source_snapshot = None; true } @@ -338,7 +438,17 @@ impl History { self.current_label = label.into(); } - /// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the + /// The user's Edit › Purge › Undo also starts a new action-history boundary. Internal + /// compound commands use [`Self::purge_last`] to fold their records without this boundary. + pub fn purge_undo(&mut self) -> bool { + let purged = self.purge_last(); + if purged { + self.purge_generation = self.purge_generation.wrapping_add(1); + } + purged + } + + /// Forget the most recent undo state and every redo state: the /// last step can no longer be undone and its pixels are released. pub fn purge_last(&mut self) -> bool { self.clear_redo(); @@ -428,7 +538,8 @@ impl History { if self.undo.len().saturating_add(held.evicted.len()) <= held.moved { return None; } - let LastRecord { moved, evicted, .. } = self.last_record.take()?; + let LastRecord { moved, evicted, visibility_capture_before, .. } = self.last_record.take()?; + self.visibility_capture = visibility_capture_before; // Put each back where it was taken off, the last one taken off first. for (at, state) in evicted.into_iter().rev() { if let Some(tiles) = &mut self.tiles { @@ -458,6 +569,12 @@ impl History { self.last_record.as_ref().map(|held| held.before.clone()) } + /// Keep identifiers consumed by a completed compound operation reserved when its history + /// is consolidated from an earlier snapshot. Removed rows must not name a later state. + pub fn reserve_ids_from(&mut self, later: &Self) { + self.next_id = self.next_id.max(later.next_id); + } + /// Forget the redo states, e.g. after undoing half of a compound step that failed. pub fn clear_redo(&mut self) { if !self.redo.is_empty() { @@ -475,6 +592,7 @@ impl History { /// 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.purge_generation = self.purge_generation.wrapping_add(1); self.last_record = None; self.current_visibility.clear(); self.undo.clear(); @@ -487,6 +605,7 @@ impl History { /// 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; + self.brush_source_snapshot = None; } /// History panel › Clear History: take every state but the current one off the list without @@ -498,13 +617,27 @@ impl History { if self.undo.is_empty() && self.redo.is_empty() { return false; } + self.clear_states_including_empty(); + true + } + + /// Preserve a Clear already accepted during an action when its command-local rows fold + /// into this captured history, which may have no rows. Ordinary empty Clear still refuses. + pub fn preserve_action_clear(&mut self, later: &Self) { + if later.is_cleared() && !self.is_cleared() { + self.clear_states_including_empty(); + } + } + + /// Apply an accepted Clear to an empty captured history as well as populated histories, preserving its boundary. + fn clear_states_including_empty(&mut self) { 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 })); + let brush_source_snapshot = self.brush_source_snapshot.take(); + self.cleared = Some(Box::new(Cleared { undo, redo, brush_source, brush_source_snapshot })); self.last_record = None; self.tiles = None; - true } /// Whether the last change to the list was Clear History (so undo restores the states). @@ -534,8 +667,8 @@ impl History { 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`] - /// bounds by [`History::max_bytes`]). + /// Pixel bytes of `current` plus the tiles only history holds, embedded smart object contents + /// included (what [`History::trim`] bounds by [`History::max_bytes`]). pub fn pixel_bytes(&self, current: &Document) -> usize { if let Some(tiles) = &self.tiles { return usize::try_from(tiles.bytes_with(current)).unwrap_or(usize::MAX); @@ -571,24 +704,25 @@ impl History { } /// 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. 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. + /// tiles only history holds (newest states first), both with embedded smart object contents. + /// The oldest undo states that don't fit are 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, &[]) } - /// [`History::trim`] counting the tiles only `pinned` documents hold (History panel - /// 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. + /// [`History::trim`] counting the tiles (and embedded smart object contents) only `pinned` + /// documents hold (History panel 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 { // 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), + // oldest of them go first, as the list's would. The source set before it stays pinned. + let (undo, redo, brush_source) = match &mut self.cleared { + Some(c) => (&mut c.undo, &c.redo, c.brush_source), + None => (&mut self.undo, &self.redo, self.brush_source), }; if self.max_bytes == 0 || undo.len() <= 1 { return 0; @@ -622,7 +756,7 @@ impl History { } // 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)); + let source = 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)); } @@ -632,7 +766,7 @@ impl History { continue; } total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); - if total > self.max_bytes && keep >= 1 { + if total > self.max_bytes && (keep >= 1 || source == Some(undo.len() - 1)) { break; } keep += 1; @@ -666,14 +800,13 @@ fn merge_ids(into: &mut Vec, from: &[LayerId]) { } } -/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`. +/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) and the +/// embedded contents of its smart objects not already in `seen`. fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { let mut n = 0usize; - tiles::surfaces(doc, |surface| { - for (_, tile) in surface.tiles() { - if seen.insert(Arc::as_ptr(tile) as usize) { - n = n.saturating_add(tile.bytes().len()); - } + tiles::blocks(doc, |ptr, len| { + if seen.insert(ptr) { + n = n.saturating_add(len); } }); n diff --git a/crates/ops/src/tests.rs b/crates/ops/src/tests.rs index b71b2b9ec4..74c8b014a4 100644 --- a/crates/ops/src/tests.rs +++ b/crates/ops/src/tests.rs @@ -14,6 +14,31 @@ fn edit(h: &mut History, cur: &mut Arc, label: &str, f: impl FnOnce(&m h.record(label, before, LayerTarget::default()); } +/// A failed local transaction drops its visibility flags; a successful local purge keeps them. +#[test] +fn visibility_capture_follows_rollback_snapshot_and_purge() { + let mut doc = base(); + doc.insert_above(None, Layer::raster("Second", doc.pixel_format())); + let ids = [doc.layers[0].id, doc.layers[1].id]; + let mut cur = Arc::new(doc); + let mut history = History::default(); + history.begin_visibility_capture(&[]); + edit(&mut history, &mut cur, "Prefix", |doc| doc.layer_mut(ids[0]).unwrap().visible = false); + history.add_current_visibility(&ids[..1]); + let prefix = cur.clone(); + let checkpoint = history.clone(); + edit(&mut history, &mut cur, "Failed part", |doc| doc.layer_mut(ids[1]).unwrap().visible = false); + history.add_current_visibility(&ids[1..]); + cur = history.rollback().unwrap().0; + assert_eq!(cur.as_ref(), prefix.as_ref()); + assert_eq!(history.captured_visibility(), Some(ids[..1].as_ref())); + assert_eq!(checkpoint.captured_visibility(), Some(ids[..1].as_ref()), "a cloned snapshot is isolated"); + edit(&mut history, &mut cur, "Successful part", |doc| doc.layer_mut(ids[1]).unwrap().visible = false); + history.add_current_visibility(&ids[1..]); + assert!(history.purge_last()); + assert_eq!(history.captured_visibility(), Some(ids.as_ref()), "purging a local record keeps its actual change"); +} + #[test] fn undo_redo_cycle() { let mut h = History::default(); @@ -197,6 +222,53 @@ fn clear_states_resets_the_brush_source_until_undo() { assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); } +/// The byte budget keeps the source set before Clear History among the cleared states, so +/// Undo Clear History brings it back as the History Brush's source. +#[test] +fn byte_budget_keeps_the_brush_source_of_the_cleared_states() { + 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)); + let source = h.state(1).unwrap(); + assert!(h.clear_states()); + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 3); + assert_counter(&h, &cur); + assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared"); + cur = h.undo(cur).unwrap().0; + assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step"); + assert_eq!(h.brush_source(), Some(0)); + assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state"); +} + +/// When the History Brush's source is the newest past state, it is the state the byte budget +/// always keeps, and no older state is kept beside it. +#[test] +fn byte_budget_counts_a_newest_brush_source_as_the_kept_state() { + 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(4)); + let source = h.state(4).unwrap(); + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 4); + assert_counter(&h, &cur); + assert_eq!(h.past_len(), 1); + assert_eq!(h.brush_source(), Some(0)); + assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source)); +} + #[test] fn new_edit_clears_redo() { let mut h = History::default(); @@ -655,6 +727,110 @@ fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() { assert_counter(&h, &cur); } +const TILE: usize = 256 * 256 * 4; +const SOURCE: usize = 1_000_000; + +fn embedded(len: usize) -> photocraft_doc::SmartSource { + photocraft_doc::SmartSource::Embedded { file_name: "source.pcraft".into(), bytes: Arc::new(vec![7; len]) } +} + +/// `base()` with one smart object holding `source`; returns the document and the layer's id. +fn smart_document(source: photocraft_doc::SmartSource) -> (Document, photocraft_doc::LayerId) { + use photocraft_doc::{Affine, LayerContent, SmartObject}; + let mut doc = base(); + let layer = Layer::new("Smart", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, None))); + let id = layer.id; + doc.insert_above(None, layer); + (doc, id) +} + +fn replace_source(doc: &mut Document, id: photocraft_doc::LayerId, source: photocraft_doc::SmartSource) { + if let Some(photocraft_doc::LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.source = source; + } +} + +#[test] +fn embedded_smart_object_contents_count_toward_history_bytes() { + let (with, _) = smart_document(embedded(SOURCE)); + let without = base(); + let mut h = History::default(); + assert_eq!(h.pixel_bytes(&with), h.pixel_bytes(&without) + SOURCE, "uncounted history walks the document"); + assert_eq!(h.pixel_bytes(&without), TILE); + // With a budget the counter keeps the figure incrementally. + let mut cur = Arc::new(with.clone()); + h.max_bytes = usize::MAX; + edit(&mut h, &mut cur, "Rename", |d| d.name = "renamed".into()); + edit(&mut h, &mut cur, "Rename again", |d| d.name = "renamed again".into()); + h.trim(&cur); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE); + assert_counter(&h, &cur); + // A linked source holds no bytes of its own. + let (linked, _) = smart_document(photocraft_doc::SmartSource::Linked { path: "a.png".into() }); + assert_eq!(History::default().pixel_bytes(&linked), TILE); +} + +#[test] +fn states_sharing_one_embedded_source_count_it_once() { + let (doc, _) = smart_document(embedded(SOURCE)); + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + for i in 0..10 { + edit(&mut h, &mut cur, &format!("Rename {i}"), |d| d.name = format!("renamed {i}")); + } + h.trim(&cur); + assert_eq!(h.past_len(), 10); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE); + assert_eq!(h.unique_bytes(&cur), 0); + assert_counter(&h, &cur); + // A duplicated layer shares the same bytes too. + edit(&mut h, &mut cur, "Duplicate", |d| { + if let Some(copy) = d.layers.last().cloned() { + d.insert_above(None, copy); + } + }); + h.trim(&cur); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE); + assert_counter(&h, &cur); +} + +#[test] +fn a_replaced_embedded_source_counts_until_the_state_holding_it_is_trimmed() { + const REPLACED: usize = 400_000; + let (doc, id) = smart_document(embedded(SOURCE)); + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + edit(&mut h, &mut cur, "Replace Contents", |d| replace_source(d, id, embedded(REPLACED))); + assert_eq!(h.pixel_bytes(&cur), TILE + SOURCE + REPLACED, "the state before still holds the old contents"); + assert_eq!(h.unique_bytes(&cur), SOURCE); + edit(&mut h, &mut cur, "Rename", |d| d.name = "renamed".into()); + h.trim(&cur); + assert_counter(&h, &cur); + h.max_bytes = TILE + REPLACED; + assert_eq!(h.trim(&cur), 1, "dropping the oldest state releases the old contents"); + assert_eq!(h.pixel_bytes(&cur), TILE + REPLACED); + assert_counter(&h, &cur); +} + +#[test] +fn embedded_contents_push_old_states_out_of_the_budget() { + let (doc, id) = smart_document(embedded(SOURCE)); + let mut cur = Arc::new(doc); + let mut h = History::new(50); + for i in 0..5 { + // Each step swaps in contents of its own; only the tile is shared between states. + edit(&mut h, &mut cur, &format!("Replace {i}"), |d| replace_source(d, id, embedded(SOURCE))); + } + assert_eq!(h.trim(&cur), 0, "unlimited by default"); + assert_eq!(h.pixel_bytes(&cur), TILE + 6 * SOURCE); + // The current contents and the two newest states fit. + h.max_bytes = TILE + 3 * SOURCE; + assert_eq!(h.trim(&cur), 3); + assert_eq!(h.past_len(), 2); + assert_eq!(h.pixel_bytes(&cur), TILE + 3 * SOURCE); + assert_counter(&h, &cur); +} + #[test] fn selection_tiles_count_current_and_history_without_double_counting() { use photocraft_doc::{PixelFormat, Surface}; @@ -982,6 +1158,7 @@ fn transferred_tiles_cannot_make_the_budget_check_underestimate_current() { } #[test] +/// Clear History changes the state list without changing pixels and remains undoable where the operation permits it. fn clear_states_keeps_the_document_and_undo_brings_the_list_back() { let mut h = History::new(10); let mut cur = Arc::new(base()); @@ -1005,6 +1182,34 @@ fn clear_states_keeps_the_document_and_undo_brings_the_list_back() { assert!(!h.is_cleared()); } +#[test] +/// Finalizing a grouped action must not discard an accepted Clear merely because its captured history was empty. +fn an_action_preserves_an_accepted_clear_over_an_empty_capture() { + let mut captured = History::new(50); + assert!(!captured.clear_states(), "ordinary empty Clear still refuses"); + captured.preserve_action_clear(&History::new(50)); + assert!(!captured.is_cleared(), "no boundary without an accepted Clear"); + let mut later = captured.clone(); + let original = Arc::new(base()); + let mut current = original.clone(); + edit(&mut later, &mut current, "Black source", |doc| doc.name = "black".into()); + assert!(later.set_brush_source(1)); + current = later.undo(current).unwrap().0; + assert!(Arc::ptr_eq(¤t, &original)); + assert!(later.clear_states(), "the live redo row makes Clear legitimate"); + captured.preserve_action_clear(&later); + assert!(captured.is_cleared() && captured.can_undo()); + assert_eq!(captured.undo_label(), Some(CLEAR_HISTORY_LABEL)); + let Some(ActionBrushSource::Cleared(source)) = captured.preserve_action_brush_source(&later) else { panic!("the exact cleared source must survive") }; + assert_eq!(source.doc.name, "black"); + captured.set_cleared_brush_snapshot(7); + let restored = captured.undo(current.clone()).unwrap().0; + assert!(Arc::ptr_eq(&restored, ¤t), "Undo Clear restores the list, not pixels"); + assert!(!captured.is_cleared()); + assert_eq!(captured.brush_source_snapshot(), Some(7)); + assert_eq!(captured.past_len(), 0); +} + #[test] fn cleared_states_still_count_in_the_memory_figures_and_the_budget() { let mut h = History::new(50); diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index ec191f301a..64da14929e 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -1,7 +1,7 @@ //! Tile references owned by history snapshots. The snapshots keep these addresses alive; //! COW edits cannot change a retained tile in place. Count occurrences (including aliases //! within one document), so removing a state releases a tile only after its last reference. -use photocraft_doc::{Document, LayerContent, Surface}; +use photocraft_doc::{Document, LayerContent, SmartSource, Surface}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -11,65 +11,48 @@ pub(crate) struct Tiles { bytes: u128, } -pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { +pub(crate) fn surfaces(doc: &Document, visit: impl FnMut(&Surface)) { + // Undo retains the whole clip, not only the displayed frame. Video aliases are visited + // too: Tiles tracks every owner and counts their shared allocations only once. + doc.for_each_surface(visit); +} + +/// Every shared allocation `doc` holds that history can retain, as `visit(address, length)`: +/// the tiles of [`surfaces`] and the embedded contents of smart objects (an `Arc` that undo +/// states and duplicated layers share, replaced by Replace Contents). +pub(crate) fn blocks(doc: &Document, mut visit: impl FnMut(usize, usize)) { + surfaces(doc, |surface| { + for (_, tile) in surface.tiles() { + visit(Arc::as_ptr(tile) as usize, tile.len()); + } + }); for (_, _, layer) in doc.walk() { - if let Some(surface) = layer.surface() { - visit(surface); - } - if let Some(mask) = &layer.mask { - visit(&mask.surface); - } - if let Some(cache) = &layer.fill_cache { - visit(&cache.surface); - } if let LayerContent::Smart(smart) = &layer.content - && let Some(mask) = &smart.filter_mask + && let SmartSource::Embedded { bytes, .. } = &smart.source { - visit(&mask.surface); + visit(Arc::as_ptr(bytes) as usize, bytes.len()); } - if let Some(video) = &layer.video { - // Undo retains the whole clip, not only the displayed frame. Visit aliases too: - // Tiles tracks every owner and counts their shared allocations only once. - for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { - visit(frame); - } - } - } - for channel in doc.channels.iter().chain(&doc.quick_mask) { - visit(&channel.surface); - } - // A pattern fill or layer style copies its pattern into the document. - for pattern in &doc.patterns { - visit(&pattern.surface); - } - if let Some(selection) = &doc.selection { - visit(selection); } } impl Tiles { pub(crate) fn add(&mut self, doc: &Document) { - surfaces(doc, |surface| { - for (_, tile) in surface.tiles() { - let count = self.refs.entry(Arc::as_ptr(tile) as usize).or_insert_with(|| { - self.bytes += tile.bytes().len() as u128; - (0, tile.bytes().len()) - }); - count.0 += 1; - } + blocks(doc, |ptr, len| { + let count = self.refs.entry(ptr).or_insert_with(|| { + self.bytes += len as u128; + (0, len) + }); + count.0 += 1; }); } pub(crate) fn remove(&mut self, doc: &Document) { - surfaces(doc, |surface| { - for (_, tile) in surface.tiles() { - let key = Arc::as_ptr(tile) as usize; - if let Some(count) = self.refs.get_mut(&key) { - count.0 -= 1; - if count.0 == 0 { - self.bytes -= count.1 as u128; - self.refs.remove(&key); - } + blocks(doc, |ptr, _| { + if let Some(count) = self.refs.get_mut(&ptr) { + count.0 -= 1; + if count.0 == 0 { + self.bytes -= count.1 as u128; + self.refs.remove(&ptr); } } }); @@ -85,12 +68,9 @@ impl Tiles { pub(crate) fn bytes_with(&self, current: &Document) -> u128 { let mut total = self.bytes; let mut seen = HashSet::new(); - surfaces(current, |surface| { - for (_, tile) in surface.tiles() { - let key = Arc::as_ptr(tile) as usize; - if !self.refs.contains_key(&key) && seen.insert(key) { - total += tile.bytes().len() as u128; - } + blocks(current, |ptr, len| { + if !self.refs.contains_key(&ptr) && seen.insert(ptr) { + total += len as u128; } }); total diff --git a/crates/paint/Cargo.toml b/crates/paint/Cargo.toml index b72880c40c..f07ffeefaf 100644 --- a/crates/paint/Cargo.toml +++ b/crates/paint/Cargo.toml @@ -12,6 +12,7 @@ photocraft-geom = { workspace = true } photocraft-color = { workspace = true } photocraft-raster = { workspace = true } serde = { workspace = true } +rustc-hash = { workspace = true } # Large tips rasterize, accumulate and composite in parallel (the same pixels as serially). [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/paint/examples/big_dab_bench.rs b/crates/paint/examples/big_dab_bench.rs index a31265224c..b8231b9003 100644 --- a/crates/paint/examples/big_dab_bench.rs +++ b/crates/paint/examples/big_dab_bench.rs @@ -1,5 +1,5 @@ //! Where a large brush's time goes: one dab of a 2000 px tip, by stage. -//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness]` +//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness] [angle]` use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer}; @@ -10,26 +10,41 @@ fn main() { let args: Vec = std::env::args().collect(); let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(2000.0); let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); - let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; + let angle: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let brush = BrushSettings { size, hardness, angle, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; let fmt = PixelFormat::RGBA8; let mut pre = Surface::new(fmt); - pre.fill_rect(Rect::new(0, 0, 4928, 3280), &[0.5, 0.5, 0.5, 1.0]); + pre.fill_rect(Rect::new(0, 0, 6000, 4000), &[0.5, 0.5, 0.5, 1.0]); let mut target = pre.clone(); // Stage 1: the tip's coverage alone. let ctx = BrushContext::new(&brush); - let dab = Dab::round(photocraft_geom::Point::new(2464.0, 1640.0), size / 2.0, 1.0); + let mut dab = Dab::round(photocraft_geom::Point::new(3000.0, 2000.0), size / 2.0, 1.0); + dab.angle = angle; let rect = ctx.dab_rect(&dab, false); let mut cov = Vec::new(); - let t = Instant::now(); + // Warm the reusable coverage buffer and take the median of seven rasterizations. ctx.rasterize(&dab, false, rect, &mut cov); - let raster = t.elapsed(); - println!("tip {size} px, hardness {hardness}: dab rect {}x{} = {:.1} MP", rect.width(), rect.height(), rect.width() as f64 * rect.height() as f64 / 1e6); - println!(" rasterize (coverage): {:>7.1} ms", raster.as_secs_f64() * 1e3); + let mut samples = Vec::new(); + for _ in 0..7 { + let t = Instant::now(); + ctx.rasterize(std::hint::black_box(&dab), false, rect, &mut cov); + std::hint::black_box(&cov); + samples.push(t.elapsed()); + } + samples.sort(); + let raster = samples.get(3).copied().unwrap_or_default(); + println!( + "tip {size} px, hardness {hardness}, angle {angle} (24 MP document): dab rect {}x{} = {:.1} MP", + rect.width(), + rect.height(), + rect.width() as f64 * rect.height() as f64 / 1e6 + ); + println!(" rasterize (coverage): {:>7.3} ms", raster.as_secs_f64() * 1e3); // Stage 2+3: push through the renderer (rasterize + accumulate), then composite. let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); let t = Instant::now(); // Two points a pixel apart: the generator places the first dab at once (a lone point waits). - r.push(&[StrokePoint::new(2464.0, 1640.0, 1.0), StrokePoint::new(2465.0, 1640.0, 1.0)]); + r.push(&[StrokePoint::new(3000.0, 2000.0, 1.0), StrokePoint::new(3001.0, 2000.0, 1.0)]); let push = t.elapsed(); println!(" push (rasterize+accumulate): {:>5.1} ms (accumulate ≈ {:.1} ms)", push.as_secs_f64() * 1e3, push.saturating_sub(raster).as_secs_f64() * 1e3); let t = Instant::now(); @@ -38,7 +53,7 @@ fn main() { println!(" composite (dirty tiles): {:>7.1} ms over {}x{}", comp.as_secs_f64() * 1e3, dmg.width(), dmg.height()); // A second dab half a spacing away, as a stroke does: the same again. let t = Instant::now(); - r.push(&[StrokePoint::new(2464.0 + f64::from(size) * 0.25, 1640.0, 1.0), StrokePoint::new(2466.0 + f64::from(size) * 0.25, 1640.0, 1.0)]); + r.push(&[StrokePoint::new(3000.0 + f64::from(size) * 0.25, 2000.0, 1.0), StrokePoint::new(3002.0 + f64::from(size) * 0.25, 2000.0, 1.0)]); let dmg = r.composite(&pre, &mut target, None, false, false); println!(" next dab push+composite: {:>7.1} ms over {}x{}", t.elapsed().as_secs_f64() * 1e3, dmg.width(), dmg.height()); } diff --git a/crates/paint/examples/coverage_bench.rs b/crates/paint/examples/coverage_bench.rs new file mode 100644 index 0000000000..469738c4c8 --- /dev/null +++ b/crates/paint/examples/coverage_bench.rs @@ -0,0 +1,53 @@ +//! Isolate dab accumulation over a 6016×6016 (36 MP) plane, without rasterization/compositing. +//! Run in release; reports median of five runs after warmup. Compare the same harness on both +//! revisions. Resident tiles model a long stroke; moving dabs also exercise new tile allocation. +use std::hint::black_box; +use std::time::Instant; + +use photocraft_geom::Rect; +use photocraft_paint::render::CoverageMap; + +fn run(size: i32, moving: bool) -> (f64, f32) { + let mut map = CoverageMap::new(3); + // A long stroke has already visited 2048 tiles, including negative coordinates. + for y in -16..16 { + for x in -32..32 { + map.accumulate(Rect::new(x * 64, y * 64, x * 64 + 1, y * 64 + 1), &[0.1], 0.8, None, None); + } + } + let vals: Vec = (0..size * size) + .map(|i| { + let x = (i % size) as f32 + 0.5 - size as f32 / 2.0; + let y = (i / size) as f32 + 0.5 - size as f32 / 2.0; + (1.0 - (x * x + y * y).sqrt() / (size as f32 / 2.0)).max(0.0) * 0.1 + }) + .collect(); + let dabs = 4000; + let start = Instant::now(); + for i in 0..dabs { + let (x, y) = if moving { ((i * 17) % (6016 - size), (i * 7) % (6016 - size)) } else { (i % 31 - 15, i % 29 - 14) }; + map.accumulate(black_box(Rect::new(x, y, x + size, y + size)), black_box(&vals), 0.8, None, None); + } + let micros = start.elapsed().as_secs_f64() * 1e6 / f64::from(dabs); + let checksum = black_box(map.get(0, 0) + map.get(128, 128)); + (micros, checksum) +} + +fn main() { + for size in [20, 50, 128, 256] { + for moving in [false, true] { + black_box(run(size, moving)); + let mut times = Vec::new(); + let mut checksum = 0.0; + for _ in 0..5 { + let (micros, value) = run(size, moving); + times.push(micros); + checksum += value; + } + times.sort_by(f64::total_cmp); + if let Some(median) = times.get(2) { + println!("size={size} moving={moving} us_per_dab={median:.3} checksum={checksum:.7}"); + } + } + } +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index fc61e41152..e43da363c5 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -576,6 +576,8 @@ pub struct BrushSettings { pub background: [f32; 4], /// Erase instead of paint. pub erase: bool, + /// Paint beneath existing pixels; opaque pixels and transparency-locked layers are unchanged. + pub behind: bool, /// Brush blend mode (Normal, Multiply, Dissolve…). pub mode: BlendMode, // --- Brush Tip Shape --- @@ -634,6 +636,7 @@ impl Default for BrushSettings { color_cmyk: None, background: [1.0, 1.0, 1.0, 1.0], erase: false, + behind: false, mode: BlendMode::Normal, tip: TipShape::Round, angle: 0.0, diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index f384d527bb..8b024de5a6 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -246,6 +246,43 @@ fn pencil_1px_line_is_the_same_whichever_way_and_however_smoothed() { assert_eq!(sampled, pixels((1.5, 1.5), (20.5, 9.5), 0.0)); } +#[test] +fn pencil_1px_freehand_line_never_breaks_where_it_turns() { + // #8040: each segment of a freehand stroke samples its own line, so where the stroke turned + // the last pixel of one segment and the first of the next could sit two pixels apart. + let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() }; + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut rnd = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for _ in 0..300 { + let mut pts = vec![StrokePoint::new(20.0 + rnd() * 8.0, 20.0 + rnd() * 8.0, 1.0)]; + for _ in 0..6 { + let l = *pts.last().unwrap(); + let (dx, dy) = ((rnd() - 0.5) * 6.0, (rnd() - 0.5) * 6.0); + pts.push(StrokePoint::new((l.x + dx).clamp(1.0, 46.0), (l.y + dy).clamp(1.0, 46.0), 1.0)); + } + let s = paint(&b, &pts, 48, 48); + let painted: Vec<(i32, i32)> = (0..48).flat_map(|y| (0..48).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect(); + // Every painted pixel is reachable from the first through 8-connected neighbours. + let mut reached = vec![painted[0]]; + let mut i = 0; + while i < reached.len() { + let (x, y) = reached[i]; + for &q in &painted { + if (q.0 - x).abs() <= 1 && (q.1 - y).abs() <= 1 && !reached.contains(&q) { + reached.push(q); + } + } + i += 1; + } + let path: Vec<(f64, f64)> = pts.iter().map(|p| (p.x, p.y)).collect(); + assert_eq!(reached.len(), painted.len(), "the line breaks along {path:?}"); + } +} // ---------- spacing ---------- #[test] @@ -1204,6 +1241,41 @@ fn blend_mode_and_dissolve() { assert!(vals.iter().all(|&v| v == 0.0 || v == 1.0) && vals.contains(&0.0) && vals.contains(&1.0)); } +#[test] +fn behind_preserves_native_channels_and_respects_alpha_selection_and_locks() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat::new(mode, sample, true); + let b = BrushSettings { size: 12.0, behind: true, opacity: 0.5, color: [0.1, 0.3, 0.8, 1.0], ..brush() }; + let mut s = Surface::new(fmt); + let native = photocraft_raster::from_rgba(&fmt, [0.8, 0.2, 0.1, 0.5]); + s.fill_rect(Rect::new(0, 0, 12, 12), &native); + let before = s.pixel(6, 6); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false)); + selection.fill_rect(Rect::new(0, 0, 8, 12), &[0.5]); + render_stroke(&mut s, &b, &[StrokePoint::new(6.0, 6.0, 1.0)], Some(&selection), false, 1.0); + let source = photocraft_raster::from_rgba(&fmt, b.color); + let a = fmt.channels() - 1; + let under = 0.25 * (1.0 - before[a]); + let alpha = before[a] + under; + let got = s.pixel(6, 6); + assert!((got[a] - alpha).abs() < 0.005, "{fmt:?}: {got:?}"); + for c in 0..a { + let expected = (before[c] * before[a] + source[c] * under) / alpha; + assert!((got[c] - expected).abs() < 0.005, "{fmt:?} channel {c}: {got:?}, expected {expected}"); + } + assert_eq!(s.pixel(9, 6), before, "outside selection"); + let locked = s.clone(); + render_stroke(&mut s, &b, &[StrokePoint::new(6.0, 6.0, 1.0)], None, true, 1.0); + assert_eq!(s.read_region(Rect::new(0, 0, 12, 12)), locked.read_region(Rect::new(0, 0, 12, 12)), "locked transparency"); + s.fill_rect(Rect::new(0, 0, 12, 12), &photocraft_raster::from_rgba(&fmt, [0.8, 0.2, 0.1, 1.0])); + let opaque = s.pixel(6, 6); + render_stroke(&mut s, &b, &[StrokePoint::new(6.0, 6.0, 1.0)], None, false, 1.0); + assert_eq!(s.pixel(6, 6), opaque, "opaque pixels"); + } + } +} + #[test] fn presets_render_and_round_trip() { let presets = crate::presets::builtin(); @@ -1279,6 +1351,7 @@ fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() brushes.push(b); brushes.push(BrushSettings { wet_edges: true, ..base.clone() }); brushes.push(BrushSettings { mode: BlendMode::Dissolve, ..base.clone() }); + brushes.push(BrushSettings { behind: true, ..base.clone() }); brushes.push(BrushSettings { erase: true, ..base.clone() }); let mut b = base.clone(); b.texture.enabled = true; diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 5c8c88b640..02d54c18ff 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -21,6 +21,8 @@ pub const SPEED_SPACING_MS: f64 = 8.0; /// Longest pixel-grid segment (in pixels) before falling back to distance spacing. const MAX_GRID_STEPS: f64 = 1e7; +/// Widest gap (in pixels) [`PathWalker::bridge`] fills between two pixel-grid segments. +const MAX_BRIDGE: f64 = 4.0; /// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer). const MIN_SPEED_STEP: f64 = 0.5; @@ -242,6 +244,8 @@ pub struct PathWalker { /// Pixel-grid stepping for small aliased tips: the dab-centre offset inside a pixel (see /// [`PathWalker::pixel_grid`]). grid: Option, + /// The grid cell of the last pixel-grid step, so the next segment joins it (#8040). + cell: Option<(f64, f64)>, } impl PathWalker { @@ -258,6 +262,7 @@ impl PathWalker { speed: None, speed_acc: 0.0, grid: None, + cell: None, } } @@ -296,11 +301,32 @@ impl PathWalker { let mut q = lerp_pt(a, p, t.clamp(0.0, 1.0)); let cn = grid_cell(an + dn * t, offset); (q.x, q.y) = if steep { (cn, c) } else { (c, cn) }; + self.bridge(&q, out); self.emit(q, out); } true } + /// Fills the pixels between the last grid step and `q` when they don't touch. Each segment + /// samples its own line, so where a freehand stroke turns, the last pixel of one segment and + /// the first of the next can sit two pixels apart, and the 1 px Pencil line breaks (#8040). + fn bridge(&mut self, q: &StrokePoint, out: &mut impl FnMut(StepInput)) { + let Some((mut x, mut y)) = self.cell else { return }; + // Only a turn's small gap: a step cut off by the walker's budget is not filled in. + if (q.x - x).abs().max((q.y - y).abs()) > MAX_BRIDGE { + return; + } + while (q.x - x).abs() > 1.5 || (q.y - y).abs() > 1.5 { + if (q.x - x).abs() > 0.5 { + x += (q.x - x).signum(); + } + if (q.y - y).abs() > 0.5 { + y += (q.y - y).signum(); + } + self.emit(StrokePoint { x, y, ..*q }, out); + } + } + /// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement /// spreads them out. Segments without timestamps are spaced by distance. pub fn speed_spacing(mut self, interval_ms: f64) -> Self { @@ -309,6 +335,9 @@ impl PathWalker { } fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) { + if let Some(g) = self.grid { + self.cell = Some((grid_cell(point.x, g), grid_cell(point.y, g))); + } let s = StepInput { point, direction: self.dir, initial_direction: self.initial_dir.unwrap_or(self.dir), step: self.step }; self.step += 1; out(s); diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 74d74be893..214a28177d 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -166,6 +166,45 @@ pub fn tip_shape(radius: f32, hardness: f32) -> (f32, f32) { ((r0 * radius).min(radius - edge).max(0.0), (s * radius).max(edge).max(1e-3)) } +/// Dab-invariant round-tip geometry, shared by all pixels and subpixel samples. +#[derive(Clone, Copy)] +pub(crate) struct TipProfile { + core: f32, + shoulder: f32, + reach: f32, +} + +impl TipProfile { + #[inline] + pub(crate) fn new(radius: f32, hardness: f32) -> Self { + let (core, shoulder) = tip_shape(radius, hardness); + Self { core, shoulder, reach: core + 2.6 * shoulder } + } + + #[inline] + pub(crate) fn reach(self) -> f32 { + self.reach + } + + #[inline] + pub(crate) fn falloff(self, d: f32) -> f32 { + if d <= self.core { + return 1.0; + } + // Keep division: multiplying by a cached reciprocal changes coverage bits. + let x = (d - self.core) / self.shoulder; + soft_exp(x * x) + } + + #[inline] + pub(crate) fn coverage(self, d: f32) -> f32 { + if d.is_nan() || d >= self.reach + 0.5 { + return 0.0; + } + self.falloff(d) + } +} + /// `exp(-u)` as `(1 − u/64)⁶⁴` by six squarings: within 1/255 of `exp` where it matters, exact /// arithmetic (the same on every platform) and exactly 0 from `u = 64`. #[inline] @@ -180,12 +219,7 @@ fn soft_exp(u: f32) -> f32 { /// Opacity of a round tip at distance `d` from its centre, edge included (see [`tip_shape`]). #[inline] pub fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { - let (r0, s) = tip_shape(radius, hardness); - if d <= r0 { - return 1.0; - } - let x = (d - r0) / s; - soft_exp(x * x) + TipProfile::new(radius, hardness).falloff(d) } /// How far from its centre a round tip still paints (where [`tip_falloff`] drops below ~0.1 %). @@ -207,11 +241,7 @@ pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 { /// Coverage of a round dab at distance `d` from its centre. #[inline] pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { - // NaN distances paint nothing. - if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 { - return 0.0; - } - tip_falloff(d, radius, hardness) + TipProfile::new(radius, hardness).coverage(d) } /// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface). diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index 32b4c265ea..a9a7485fa5 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -39,6 +39,18 @@ impl Default for MixerSettings { } } +impl MixerSettings { + /// Effective per-dab wetness, including Transfer's Wetness control. + pub fn effective_wet(&self, dab: &crate::Dab) -> f32 { + self.wet.clamp(0.0, 1.0) * dab.wet.clamp(0.0, 1.0) + } + + /// Whether this dab can update the pickup well from the canvas. + pub fn samples_canvas(&self, dab: &crate::Dab) -> bool { + self.effective_wet(dab) > 0.0 + } +} + /// Paint carried between strokes (session tool state). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,6 +61,9 @@ pub struct MixerState { pub amount: f32, /// Colour picked up from the canvas (straight RGBA), None = clean. pub pickup: Option<[f32; 4]>, + /// The foreground colour in effect when the reservoir was loaded from the canvas + /// (`load_sampled`), None when it holds the foreground itself. + pub sampled_over: Option<[f32; 4]>, /// A bounded 64 x 64 spatial load in tip-local coordinates. Empty cells carry no paint. pub spatial: Option>, /// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush @@ -62,6 +77,15 @@ impl MixerState { self.spatial = None; self.reservoir = Some(color); self.amount = 1.0; + self.sampled_over = None; + } + + /// Load the reservoir with a colour sampled from the canvas (⌥-click) while `foreground` is + /// the foreground colour; `holds_sample` stays true until the foreground changes. + pub fn load_sampled(&mut self, color: [f32; 4], foreground: [f32; 4]) { + self.load(color); + self.sampled_over = Some(foreground); + self.color = Some(foreground); } /// Clean Brush: empty the reservoir and the pickup well. pub fn clean(&mut self) { @@ -69,6 +93,12 @@ impl MixerState { self.reservoir = None; self.amount = 0.0; self.pickup = None; + self.sampled_over = None; + } + + /// Whether the reservoir holds a canvas sample taken while `foreground` was the foreground. + pub fn holds_sample(&self, foreground: [f32; 4]) -> bool { + self.reservoir.is_some() && self.sampled_over == Some(foreground) } /// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the /// reservoir with it. @@ -83,14 +113,16 @@ impl MixerState { pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) { // Automatic Load replenishes a sampled tip as well as a solid-color load. // Move its bounded buffer through Clean instead of allocating a copy. + let sampled = self.holds_sample(color).then_some(color); let spatial = if load { self.spatial.take() } else { None }; - let paint = if spatial.is_some() { self.reservoir.unwrap_or(color) } else { color }; + let paint = if spatial.is_some() || sampled.is_some() { self.reservoir.unwrap_or(color) } else { color }; if clean { self.clean(); } if load { self.load(paint); self.spatial = spatial; + self.sampled_over = sampled; } self.color = Some(color); } @@ -158,7 +190,7 @@ pub fn apply_mixer_dab( state: &mut MixerState, lock_transparency: bool, ) { - let sample = sample.map(|surface| { + let sample = sample.filter(|_| m.samples_canvas(&fp.dab)).map(|surface| { let source = crate::retouch::Region::read(surface, work.rect); if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha { source @@ -198,37 +230,38 @@ pub fn apply_mixer_dab_region( let res = state.reservoir.map(native); let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 }; let mut pickup: Option> = state.pickup.map(native); - let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); - let mut acc = vec![0.0f32; n]; + let (mix, flow, load) = (m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); let mut paint = vec![0.0f32; n]; // Transfer › Wetness / Mix Jitter scale the settings per dab. - let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); + let (wet, mix) = (m.effective_wet(&fp.dab), mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. - acc.iter_mut().for_each(|v| *v = 0.0); - let (mut wsum, mut asum) = (0.0f32, 0.0f32); let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let w = fp.at(x, y); - if w <= 0.0 { - continue; + if wet > 0.0 { + let mut acc = vec![0.0f32; n]; + let (mut wsum, mut asum) = (0.0f32, 0.0f32); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let w = fp.at(x, y); + if w <= 0.0 { + continue; + } + let px = sample.unwrap_or(work).px(x, y); + let al = a.map_or(1.0, |ai| px[ai]); + for c in 0..nc { + acc[c] += px[c] * w * al; + } + wsum += w; + asum += w * al; } - let px = sample.unwrap_or(work).px(x, y); - let al = a.map_or(1.0, |ai| px[ai]); - for c in 0..nc { - acc[c] += px[c] * w * al; - } - wsum += w; - asum += w * al; } - } - if asum > 1e-6 && wet > 0.0 { - let ca = asum / wsum.max(1e-6); - let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); - pickup = Some(match pickup.take() { - None => canvas, - Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), - }); + if asum > 1e-6 { + let ca = asum / wsum.max(1e-6); + let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); + pickup = Some(match pickup.take() { + None => canvas, + Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), + }); + } } let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 }; let wp = if pickup.is_some() { mix * wet } else { 0.0 }; diff --git a/crates/paint/src/mixer/reference_tests.rs b/crates/paint/src/mixer/reference_tests.rs index 73242fa2f9..0f6f0a2a82 100644 --- a/crates/paint/src/mixer/reference_tests.rs +++ b/crates/paint/src/mixer/reference_tests.rs @@ -152,3 +152,56 @@ fn contiguous_mixer_matches_frozen_kernel_across_models_depths_and_spatial_loads } } } + +/// Dry dabs must preserve both the painted pixels and the carried pickup across transitions. +#[test] +fn dry_and_wet_transitions_match_frozen_kernel() { + use crate::retouch::Region; + use crate::{BrushContext, BrushSettings, Dab}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_geom::Point; + + let rect = Rect::new(0, 0, 48, 48); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, depth, alpha); + let mut surface = Surface::new(fmt); + surface.fill_rect(rect, &from_rgba(&fmt, [0.25, 0.5, 0.75, 0.6])); + let mut source = Surface::new(PixelFormat::RGBA32F); + source.fill_rect(rect, &[0.7, 0.1, 0.3, 0.8]); + let ctx = BrushContext::new(&BrushSettings { size: 16.0, ..Default::default() }); + for spatial in [false, true] { + for loaded in [false, true] { + for sample in [None, Some(&source)] { + for lock in [false, true] { + let mut actual = Region::read(&surface, rect); + let mut expected = actual.clone(); + let mut state = MixerState::default(); + if loaded { + state.load([0.8, 0.2, 0.1, 1.0]); + } + if spatial { + state.spatial = Some(vec![[0.2, 0.8, 0.4, 0.7]; 4096]); + } + let mut expected_state = state.clone(); + for (i, (base_wet, dab_wet, mix)) in + [(0.7, 1.0, 0.0), (0.0, 1.0, 0.7), (0.7, 0.0, 0.7), (0.7, 1.0, 0.7)].into_iter().enumerate() + { + let mut dab = Dab::round(Point::new(20.0 + i as f64, 20.0), 8.0, 1.0); + dab.wet = dab_wet; + let fp = ctx.footprint(&dab, i); + let settings = MixerSettings { wet: base_wet, mix, ..Default::default() }; + reference(&mut expected, &fp, fmt, sample, &settings, &mut expected_state, lock); + apply_mixer_dab(&mut actual, &fp, fmt, sample, &settings, &mut state, lock); + assert_eq!(actual, expected, "{mode:?} {depth:?} alpha={alpha} spatial={spatial} loaded={loaded} lock={lock} dab={i}"); + assert_eq!(state, expected_state, "mixer state after dab {i}"); + } + } + } + } + } + } + } + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 24f7259a76..83243bdafa 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -7,9 +7,11 @@ //! buffer is sparse and compositing always starts from the pre-stroke pixels, strokes can be fed in //! chunks and re-composited incrementally for interactive painting (see [`StrokeRenderer`]). -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; +use rustc_hash::FxHashMap; + use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -19,7 +21,7 @@ use crate::dynamics::{DabGenerator, grid_cell}; use crate::retouch::{Footprint, alpha_index, over_native}; use crate::rng::hash2; use crate::tile::{Mips, PatternImage}; -use crate::{Dab, StrokePoint, dab_coverage, tip_falloff, tip_reach}; +use crate::{Dab, StrokePoint, TipProfile}; /// Salt of the 8-bit dither's per-pixel noise. const DITHER_SALT: u64 = 0x0000_6469_7468_6572; @@ -311,7 +313,8 @@ impl BrushContext { let r = d.radius; let ro = d.roundness.max(0.5 / r).min(1.0); let rm = (r * ro).max(0.5); - let reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) }; + let profile = TipProfile::new(rm, hardness); + let reach = if aliased || mips.is_some() { rm } else { profile.reach().max(rm) }; let reach2 = (reach + 0.5) * (reach + 0.5); // Sampled-tip mapping. let (level, inv_scale, tw, th) = match mips { @@ -342,7 +345,9 @@ impl BrushContext { if !(mips.is_some() || small || (aliased && b.square) || noise || tex_tip || wet) && depth.is_none() && proj.is_none() - && d.angle == 0.0 + // Rotation preserves a circle's distance; ellipses still need the transform. + && (d.angle == 0.0 || ro == 1.0) + && d.angle.is_finite() && d.radius.is_finite() && d.roundness.is_finite() && rm.is_finite() @@ -352,7 +357,7 @@ impl BrushContext { && d.alpha.is_finite() && w > 0 { - rasterize_round(d, rect, out, hardness, aliased); + rasterize_round(d, rect, out, profile, aliased); return; } for yy in 0..h { @@ -382,7 +387,7 @@ impl BrushContext { let sd = ((su * ro).powi(2) + sv * sv).sqrt(); // Each sample takes the tip's own edge, so the covered area // changes smoothly with the dab's position. - sum += tip_falloff(sd, rm, hardness); + sum += profile.falloff(sd); } } (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) @@ -393,7 +398,7 @@ impl BrushContext { continue; } let dd = d2.sqrt(); - let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { dab_coverage(dd, rm, hardness) }; + let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; (val, dd / rm) } Some(m) => { @@ -475,10 +480,28 @@ struct CovTile { col: Vec, } +impl CovTile { + fn new() -> Self { + Self { cov: vec![0.0; (COV_TILE * COV_TILE) as usize], depth: Vec::new(), col: Vec::new() } + } +} + +/// Preserve both signed coordinates without collisions. FxHash mixes the packed word so +/// horizontal and vertical runs both distribute across the table's buckets. +#[inline] +fn tile_key((tx, ty): (i32, i32)) -> u64 { + (u64::from(tx as u32) << 32) | u64::from(ty as u32) +} + +#[inline] +fn tile_coords(key: u64) -> (i32, i32) { + ((key >> 32) as i32, key as i32) +} + /// A sparse tiled coverage (+ optional colour) buffer. #[derive(Clone, Debug, Default)] pub struct CoverageMap { - tiles: HashMap<(i32, i32), CovTile>, + tiles: FxHashMap, nc: usize, bounds: Rect, dirty: HashSet<(i32, i32)>, @@ -494,14 +517,14 @@ impl CoverageMap { #[inline] pub fn get(&self, x: i32, y: i32) -> f32 { let (tx, ty) = (x.div_euclid(COV_TILE), y.div_euclid(COV_TILE)); - self.tiles.get(&(tx, ty)).map_or(0.0, |t| t.cov[((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize]) + self.tiles.get(&tile_key((tx, ty))).map_or(0.0, |t| t.cov[((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize]) } /// Wet Edges depth at a pixel (see [`CovTile::depth`]); `None` when the stroke has none. #[inline] pub fn depth(&self, x: i32, y: i32) -> Option { let (tx, ty) = (x.div_euclid(COV_TILE), y.div_euclid(COV_TILE)); - self.tiles.get(&(tx, ty)).and_then(|t| t.depth.get(((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize).copied()) + self.tiles.get(&tile_key((tx, ty))).and_then(|t| t.depth.get(((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize).copied()) } /// Accumulate dab values over `rect`: flow builds up towards the dab's opacity ceiling @@ -517,61 +540,56 @@ impl CoverageMap { let (t0x, t0y) = (rect.x0.div_euclid(COV_TILE), rect.y0.div_euclid(COV_TILE)); let (t1x, t1y) = ((rect.x1 - 1).div_euclid(COV_TILE), (rect.y1 - 1).div_euclid(COV_TILE)); let want_col = color.is_some() && nc > 0; - // The tiles this dab touches (a tile with no value in it is skipped), each independent of - // the others: a large dab's tiles accumulate in parallel, to the same values. - let mut keys: Vec<((i32, i32), Rect)> = Vec::new(); - for ty in t0y..=t1y { - for tx in t0x..=t1x { + let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc }; + // Iterate directly for small dabs: no work-list allocations or removing existing tiles. + let keys = (t0y..=t1y).flat_map(|ty| { + (t0x..=t1x).map(move |tx| { let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&rect); - if tr.is_empty() { - continue; - } - keys.push(((tx, ty), tr)); - } - } - let touched = map_items(&keys, keys.len() * (COV_TILE * COV_TILE) as usize, |(_, tr)| { - (tr.y0..tr.y1).any(|y| { - let row = (y - rect.y0) as usize * w; - vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0) + ((tx, ty), tr) }) }); - // The touched tiles leave the map while they accumulate and go back after: the dab's cost - // follows the tiles under it (one to four for a small tip), not the size of the map, which a - // long stroke grows to thousands of tiles. - let n = (COV_TILE * COV_TILE) as usize; - let mut tiles: Vec<(Rect, (i32, i32), CovTile)> = keys - .into_iter() - .zip(touched) - .filter(|(_, t)| *t) - .map(|((key, tr), _)| { - let mut tile = self.tiles.remove(&key).unwrap_or_else(|| CovTile { cov: vec![0.0; n], depth: Vec::new(), col: Vec::new() }); - if want_col && tile.col.is_empty() { - tile.col = vec![0.0; n * nc]; - } - if depth.is_some() && tile.depth.is_empty() { - tile.depth = vec![0.0; n]; - } - self.dirty.insert(key); - (tr, key, tile) - }) - .collect(); - let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc }; - // The serial path stays a plain loop over an inlined step (see `rasterize_round`). #[cfg(not(target_arch = "wasm32"))] - if parallel(tiles.len() * n) { - use rayon::prelude::*; - tiles.par_iter_mut().for_each(|(tr, key, tile)| dab.tile(*tr, *key, tile)); - } else { - for (tr, key, tile) in &mut tiles { - dab.tile(*tr, *key, tile); + { + let n = (COV_TILE * COV_TILE) as usize; + let count = (i64::from(t1x) - i64::from(t0x) + 1) as usize; + let count = count.saturating_mul((i64::from(t1y) - i64::from(t0y) + 1) as usize); + if parallel(count.saturating_mul(n)) { + let keys: Vec<_> = keys.collect(); + let touched = map_items(&keys, count.saturating_mul(n), |(_, tr)| dab.touches(*tr)); + let keys: Vec<_> = keys.into_iter().zip(touched).filter_map(|(key, touched)| touched.then_some(key)).collect(); + if parallel(keys.len().saturating_mul(n)) { + use rayon::prelude::*; + // Only genuinely parallel work takes ownership of tiles out of the map. + let mut tiles: Vec<_> = keys + .into_iter() + .map(|(key, tr)| { + self.dirty.insert(key); + let tile = self.tiles.remove(&tile_key(key)).unwrap_or_else(CovTile::new); + (key, tr, tile) + }) + .collect(); + tiles.par_iter_mut().for_each(|(key, tr, tile)| { + dab.prepare(tile); + dab.tile(*tr, *key, tile); + }); + for (key, _, tile) in tiles { + self.tiles.insert(tile_key(key), tile); + } + } else { + self.accumulate_serial(keys.into_iter(), dab); + } + return; } } - #[cfg(target_arch = "wasm32")] - for (tr, key, tile) in &mut tiles { - dab.tile(*tr, *key, tile); - } - for (_, key, tile) in tiles { - self.tiles.insert(key, tile); + self.accumulate_serial(keys.filter(|(_, tr)| dab.touches(*tr)), dab); + } + + fn accumulate_serial(&mut self, keys: impl Iterator, dab: DabValues<'_>) { + for (key, tr) in keys { + let tile = self.tiles.entry(tile_key(key)).or_insert_with(CovTile::new); + dab.prepare(tile); + dab.tile(tr, key, tile); + self.dirty.insert(key); } } @@ -584,7 +602,7 @@ impl CoverageMap { /// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the /// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own. fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self { - let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone())); + let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(&tile_key(*k))).or_else(|| self.tiles.get(&tile_key(*k))).map(|t| (tile_key(*k), t.clone())); Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() } } @@ -595,7 +613,7 @@ impl CoverageMap { for (&key, source) in &other.tiles { let Some(target) = self.tiles.get_mut(&key) else { self.tiles.insert(key, source.clone()); - self.dirty.insert(key); + self.dirty.insert(tile_coords(key)); continue; }; if target.depth.is_empty() { @@ -615,7 +633,7 @@ impl CoverageMap { } } } - self.dirty.insert(key); + self.dirty.insert(tile_coords(key)); } } } @@ -654,6 +672,23 @@ struct DabValues<'a> { } impl DabValues<'_> { + fn touches(self, tr: Rect) -> bool { + (tr.y0..tr.y1).any(|y| { + let row = (y - self.rect.y0) as usize * self.w; + self.vals[row + (tr.x0 - self.rect.x0) as usize..row + (tr.x1 - self.rect.x0) as usize].iter().any(|&v| v > 0.0) + }) + } + + fn prepare(self, tile: &mut CovTile) { + let n = (COV_TILE * COV_TILE) as usize; + if self.color.is_some() && tile.col.is_empty() { + tile.col = vec![0.0; n * self.nc]; + } + if self.depth.is_some() && tile.depth.is_empty() { + tile.depth = vec![0.0; n]; + } + } + /// Adds the dab over `tr` (within tile `(tx, ty)`) to `tile`. #[inline(always)] fn tile(self, tr: Rect, (tx, ty): (i32, i32), tile: &mut CovTile) { @@ -756,9 +791,9 @@ impl StrokeRenderer { /// Every coverage tile (stroke and Dual Brush), into `keys`. fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) { - keys.extend(self.cov.tiles.keys()); + keys.extend(self.cov.tiles.keys().copied().map(tile_coords)); if let Some(d) = &self.dual { - keys.extend(d.tiles.keys()); + keys.extend(d.tiles.keys().copied().map(tile_coords)); } } @@ -835,7 +870,7 @@ impl StrokeRenderer { target.write_region(old, ®ion); dmg = dmg.union(&old); } - if merged.cov.tiles.contains_key(&(tx, ty)) { + if merged.cov.tiles.contains_key(&tile_key((tx, ty))) { dmg = dmg.union(&tile.intersect(&after)); } } @@ -974,7 +1009,7 @@ impl StrokeRenderer { } } let subset = |m: &CoverageMap| CoverageMap { - tiles: keys.iter().filter_map(|k| m.tiles.get(k).map(|t| (*k, t.clone()))).collect(), + tiles: keys.iter().filter_map(|k| m.tiles.get(&tile_key(*k)).map(|t| (tile_key(*k), t.clone()))).collect(), nc: m.nc, bounds: m.bounds, dirty: HashSet::new(), @@ -996,7 +1031,7 @@ impl StrokeRenderer { }; t.raster_pending(); // Tiles whose coverage the tail doesn't change still composite the same: redraw them all. - t.cov.dirty.extend(t.cov.tiles.keys().copied()); + t.cov.dirty.extend(t.cov.tiles.keys().copied().map(tile_coords)); Some(t) } @@ -1007,7 +1042,7 @@ impl StrokeRenderer { } for ty in r.y0.div_euclid(COV_TILE)..=(r.y1 - 1).div_euclid(COV_TILE) { for tx in r.x0.div_euclid(COV_TILE)..=(r.x1 - 1).div_euclid(COV_TILE) { - if self.cov.tiles.contains_key(&(tx, ty)) { + if self.cov.tiles.contains_key(&tile_key((tx, ty))) { self.cov.dirty.insert((tx, ty)); } } @@ -1054,9 +1089,9 @@ impl StrokeRenderer { let (t1x, t1y) = ((r.x1 - 1).div_euclid(COV_TILE), (r.y1 - 1).div_euclid(COV_TILE)); let span = (i64::from(t1x) - i64::from(t0x) + 1).saturating_mul(i64::from(t1y) - i64::from(t0y) + 1); if span > self.cov.tiles.len() as i64 { - return self.cov.tiles.keys().any(|&(tx, ty)| (t0x..=t1x).contains(&tx) && (t0y..=t1y).contains(&ty)); + return self.cov.tiles.keys().copied().map(tile_coords).any(|(tx, ty)| (t0x..=t1x).contains(&tx) && (t0y..=t1y).contains(&ty)); } - (t0y..=t1y).any(|ty| (t0x..=t1x).any(|tx| self.cov.tiles.contains_key(&(tx, ty)))) + (t0y..=t1y).any(|ty| (t0x..=t1x).any(|tx| self.cov.tiles.contains_key(&tile_key((tx, ty))))) } /// Dense final coverage over the stroke bounds. @@ -1064,7 +1099,8 @@ impl StrokeRenderer { let b = self.bounds(); let w = b.width() as usize; let mut out = vec![0.0f32; w * b.height() as usize]; - for (&(tx, ty), t) in &self.cov.tiles { + for (&key, t) in &self.cov.tiles { + let (tx, ty) = tile_coords(key); let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&b); for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { @@ -1087,7 +1123,7 @@ impl StrokeRenderer { pub fn composite(&mut self, pre: &Surface, target: &mut Surface, selection: Option<&Surface>, lock_transparency: bool, all: bool) -> Rect { let keys: Vec<(i32, i32)> = if all { self.cov.dirty.clear(); - let mut k: Vec<_> = self.cov.tiles.keys().copied().collect(); + let mut k: Vec<_> = self.cov.tiles.keys().copied().map(tile_coords).collect(); k.sort_unstable(); k } else { @@ -1133,7 +1169,7 @@ impl StrokeRenderer { let mut items: Vec<((i32, i32), Rect, Vec)> = batch.iter().map(|&key| (key, Rect::EMPTY, pool.pop().unwrap_or_default())).collect(); let composite_tile = |(key, done, region): &mut ((i32, i32), Rect, Vec)| { let (tx, ty) = *key; - let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return }; + let Some(tile) = self.cov.tiles.get(&tile_key((tx, ty))) else { return }; let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); if tr.is_empty() { return; @@ -1188,7 +1224,21 @@ impl StrokeRenderer { src[ch] = p[ch] / c; } } - if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) { + if b.behind { + let Some(a) = a_idx else { continue }; + let da = px[a]; + if lock_transparency || da >= 1.0 { + continue; + } + let underneath = src[a] * k * (1.0 - da); + let alpha = da + underneath; + if alpha > 0.0 { + for channel in 0..a { + px[channel] = (px[channel] * da + src[channel] * underneath) / alpha; + } + px[a] = alpha; + } + } else if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) { over_native(&fmt, px, &src[..n], k, lock_transparency); } else { let d = to_rgba(&fmt, px); @@ -1244,9 +1294,10 @@ pub fn render_stroke( r.composite(&pre, target, selection, lock_transparency, true) } -// The zero-angle transform only changes signs; squaring gives the same radial distance. +// With zero angle, the transform only changes signs. For circles, any rotation preserves +// radial distance. Both cases can omit the transform (projection is excluded by the caller). #[inline(never)] -fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: bool) { +fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], profile: TipProfile, aliased: bool) { let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) }; let ro = d.roundness.max(0.5 / d.radius).min(1.0); let rm = (d.radius * ro).max(0.5); @@ -1254,8 +1305,8 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: if w == 0 { return; } - let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) }; - let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), hardness, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 }; + let reach = if aliased { rm } else { profile.reach().max(rm) }; + let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), profile, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 }; // Rows are independent: a large tip's rows run in parallel, to the same values. The serial // path stays a plain loop over an inlined row: the same code as before the threads (a closure // passed to a helper cost a small tip half its rasterization time). @@ -1278,7 +1329,7 @@ struct RoundTip { ro: f32, rm: f32, reach2: f32, - hardness: f32, + profile: TipProfile, alpha: f32, aliased: bool, x0: i32, @@ -1300,7 +1351,7 @@ impl RoundTip { for (xx, dst) in row.iter_mut().enumerate() { let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx; let d2 = (dx * self.ro).powi(2) + dy * dy; - let val = if d2 >= self.reach2 { 0.0 } else { tip_falloff(d2.sqrt(), self.rm, self.hardness) }; + let val = if d2 >= self.reach2 { 0.0 } else { self.profile.falloff(d2.sqrt()) }; *dst = if val <= 0.0 { 0.0 } else { val * self.alpha }; } } @@ -1309,3 +1360,6 @@ impl RoundTip { #[cfg(test)] mod reference; + +#[cfg(test)] +mod coverage_tests; diff --git a/crates/paint/src/render/coverage_tests.rs b/crates/paint/src/render/coverage_tests.rs new file mode 100644 index 0000000000..0b992308ad --- /dev/null +++ b/crates/paint/src/render/coverage_tests.rs @@ -0,0 +1,190 @@ +use super::*; + +use std::collections::{HashMap, HashSet}; + +#[test] +fn packed_tile_coordinates_round_trip_without_collisions() { + let values = [i32::MIN, i32::MIN + 1, -1_000_003, -1, 0, 1, 1_000_003, i32::MAX - 1, i32::MAX]; + let coords: Vec<_> = values.iter().flat_map(|&x| values.iter().map(move |&y| (x, y))).collect(); + let keys: HashSet<_> = coords.iter().copied().map(tile_key).collect(); + assert_eq!(keys.len(), coords.len()); + for coord in coords { + assert_eq!(tile_coords(tile_key(coord)), coord); + } +} + +struct DabInput { + rect: Rect, + vals: Vec, + ceil: f32, + color: Option>, + depth: Option>, +} + +fn serial(map: &mut CoverageMap, dab: &DabInput) { + if dab.rect.is_empty() { + return; + } + map.bounds = map.bounds.union(&dab.rect); + let (x0, y0) = (dab.rect.x0.div_euclid(COV_TILE), dab.rect.y0.div_euclid(COV_TILE)); + let (x1, y1) = ((dab.rect.x1 - 1).div_euclid(COV_TILE), (dab.rect.y1 - 1).div_euclid(COV_TILE)); + let w = dab.rect.width() as usize; + let values = DabValues { vals: &dab.vals, rect: dab.rect, w, ceil: dab.ceil, color: dab.color.as_deref(), depth: dab.depth.as_deref(), nc: map.nc }; + let keys = (y0..=y1).flat_map(|ty| { + (x0..=x1).map(move |tx| { + let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&dab.rect); + ((tx, ty), tr) + }) + }); + map.accumulate_serial(keys.filter(|(_, tr)| values.touches(*tr)), values); +} + +fn pixel_index(rect: Rect, x: i32, y: i32) -> usize { + (y - rect.y0) as usize * rect.width() as usize + (x - rect.x0) as usize +} + +fn make_dab(rect: Rect, seed: usize, color: bool, depth: bool) -> DabInput { + let w = rect.width() as usize; + let h = rect.height() as usize; + let n = w * h; + let vals = (0..n).map(|i| if (i + seed).is_multiple_of(11) { 0.0 } else { (((i * 7 + seed * 3) % 13) as f32 + 1.0) / 17.0 }).collect(); + let colors = color.then(|| vec![seed as f32 / 10.0, 0.75]); + let depths = depth.then(|| (0..n).map(|i| ((i * 3 + seed * 2) % 17) as f32 / 16.0).collect()); + DabInput { rect, vals, ceil: if seed.is_multiple_of(2) { 0.72 } else { 0.93 }, color: colors, depth: depths } +} + +#[derive(Default)] +struct OraclePixel { + cov: f32, + color: [f32; 2], + has_color: bool, + depth: Option, +} + +fn apply_oracle(oracle: &mut HashMap<(i32, i32), OraclePixel>, dab: &DabInput) { + for y in dab.rect.y0..dab.rect.y1 { + for x in dab.rect.x0..dab.rect.x1 { + let i = pixel_index(dab.rect, x, y); + let v = dab.vals[i]; + if v <= 0.0 { + continue; + } + let p = oracle.entry((x, y)).or_default(); + if let Some(depth) = dab.depth.as_ref().and_then(|d| d.get(i)) { + p.depth = Some(p.depth.map_or(*depth, |old| old.max(*depth))); + } + let old = p.cov; + let new = if dab.ceil > old { old + v * (dab.ceil - old) } else { old }; + if new > old { + if let Some(color) = &dab.color { + let k = (new - old) / (1.0 - old); + for (channel, out) in p.color.iter_mut().enumerate() { + *out = color[channel] * k + *out * (1.0 - k); + } + p.has_color = true; + } + p.cov = new; + } + } + } +} + +#[test] +fn overlapping_accumulation_matches_dense_pixel_oracle_and_serial_path() { + let mut zero_only = make_dab(Rect::new(384, 0, 386, 2), 6, true, true); + zero_only.vals.fill(0.0); + let dabs = [ + make_dab(Rect::new(-67, -3, 68, 67), 1, false, false), // crosses negative and positive tile edges + make_dab(Rect::new(-70, -130, 260, 195), 2, true, true), // lazily enables colour and depth across more tiles + make_dab(Rect::new(62, 60, 70, 68), 3, true, true), + make_dab(Rect::new(-70, 64, -63, 70), 4, false, true), // depth continues after coverage ceiling + make_dab(Rect::new(192, 0, 193, 1), 5, true, true), // revisit an existing tile + zero_only, // all-zero tile must be skipped + ]; + let mut actual = CoverageMap::new(2); + let mut expected_serial = CoverageMap::new(2); + let mut oracle = HashMap::new(); + let mut bounds = Rect::default(); + for dab in &dabs { + actual.accumulate(dab.rect, &dab.vals, dab.ceil, dab.color.as_deref(), dab.depth.as_deref()); + serial(&mut expected_serial, dab); + apply_oracle(&mut oracle, dab); + bounds = bounds.union(&dab.rect); + } + + assert_eq!(actual.bounds(), bounds); + assert_eq!(actual.bounds(), expected_serial.bounds()); + let expected_dirty: HashSet<_> = expected_serial.dirty.iter().copied().collect(); + assert_eq!(actual.take_dirty().into_iter().collect::>(), expected_dirty); + assert_eq!(expected_serial.take_dirty().into_iter().collect::>(), expected_dirty); + + let tile_coords_actual: HashSet<_> = actual.tiles.keys().copied().map(tile_coords).collect(); + assert_eq!(tile_coords_actual, expected_dirty); + assert!(!tile_coords_actual.contains(&(6, 0))); // the zero-only tile was never allocated + assert!(tile_coords_actual.len() >= 16); + + for y in -130..195 { + for x in -70..260 { + let i = pixel_index_for_tile(x, y); + let tx = x.div_euclid(COV_TILE); + let ty = y.div_euclid(COV_TILE); + let a = actual.tiles.get(&tile_key((tx, ty))); + let b = expected_serial.tiles.get(&tile_key((tx, ty))); + if let (Some(a), Some(b)) = (a, b) { + assert_eq!(a.cov[i].to_bits(), b.cov[i].to_bits(), "coverage at ({x}, {y})"); + assert_eq!(a.depth.get(i).map(|v| v.to_bits()), b.depth.get(i).map(|v| v.to_bits()), "depth at ({x}, {y})"); + assert_eq!( + a.col.get(i * 2..i * 2 + 2).map(|s| [s[0].to_bits(), s[1].to_bits()]), + b.col.get(i * 2..i * 2 + 2).map(|s| [s[0].to_bits(), s[1].to_bits()]), + "color at ({x}, {y})" + ); + } else { + assert!(a.is_none() && b.is_none()); + } + let p = oracle.get(&(x, y)); + let expected_cov = p.map_or(0.0, |p| p.cov); + assert_eq!(actual.get(x, y).to_bits(), expected_cov.to_bits(), "dense oracle coverage at ({x}, {y})"); + let expected_depth = p.and_then(|p| p.depth).unwrap_or(0.0); + assert_eq!(actual.depth(x, y).unwrap_or(0.0).to_bits(), expected_depth.to_bits(), "dense oracle depth at ({x}, {y})"); + if let Some(p) = p.filter(|p| p.has_color) { + let color = &actual.tiles[&tile_key((tx, ty))].col; + assert_eq!(color[i * 2].to_bits(), p.color[0].to_bits(), "dense oracle color at ({x}, {y})"); + assert_eq!(color[i * 2 + 1].to_bits(), p.color[1].to_bits(), "dense oracle color at ({x}, {y})"); + } + } + } + + // Depth still records a later dab even where coverage already reached its ceiling. + let (x, y) = (-67, 64); + let mut repeated = CoverageMap::new(0); + let values = [1.0]; + repeated.accumulate(Rect::new(x, y, x + 1, y + 1), &values, 0.5, None, None); + repeated.accumulate(Rect::new(x, y, x + 1, y + 1), &values, 0.5, None, Some(&[0.2])); + repeated.accumulate(Rect::new(x, y, x + 1, y + 1), &values, 0.5, None, Some(&[0.8])); + assert_eq!(repeated.get(x, y), 0.5); + assert_eq!(repeated.depth(x, y), Some(0.8)); +} + +fn pixel_index_for_tile(x: i32, y: i32) -> usize { + (y.rem_euclid(COV_TILE) * COV_TILE + x.rem_euclid(COV_TILE)) as usize +} + +#[test] +fn sparse_large_footprint_only_allocates_and_dirties_nonzero_tiles() { + let rect = Rect::new(-128, -128, 256, 256); + let mut vals = vec![0.0; rect.width() as usize * rect.height() as usize]; + vals[pixel_index(rect, -1, 64)] = 0.5; + let mut map = CoverageMap::new(0); + map.accumulate(rect, &vals, 0.8, Some(&[1.0]), None); + assert_eq!(map.tiles.len(), 1); + assert_eq!(map.take_dirty(), vec![(-1, 1)]); + assert_eq!(map.get(-1, 64), 0.4); + assert!(map.tiles.values().all(|tile| tile.col.is_empty() && tile.depth.is_empty())); + vals.fill(0.0); + map.accumulate(rect, &vals, 0.8, None, None); + assert!(map.take_dirty().is_empty()); + assert_eq!(map.tiles.len(), 1); + let bounds = map.bounds(); + map.accumulate(Rect::new(0, 0, 0, 0), &[], 0.8, None, None); + assert_eq!(map.bounds(), bounds); +} diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs index 10172e6fea..e10fdd099f 100644 --- a/crates/paint/src/render/reference.rs +++ b/crates/paint/src/render/reference.rs @@ -1,6 +1,7 @@ //! Frozen scalar rasterizer used only as a pixel oracle for the computed-tip fast path. use super::*; use crate::tile::GrayTile; +use crate::{soft_exp, tip_reach, tip_shape}; use photocraft_geom::Point; impl BrushContext { @@ -161,6 +162,64 @@ fn computed_tips_match_scalar_bits() { } } +#[test] +fn circular_tips_ignore_finite_rotation() { + for aliased in [false, true] { + for hardness in [0.0, 0.5, 1.0] { + let ctx = BrushContext::new(&BrushSettings { aliased, hardness, ..Default::default() }); + for radius in [3.0, 15.25, 150.0] { + for roundness in [1.0, 1.5] { + let mut d = Dab::round(Point::new(-31.25, 63.75), radius, 0.37); + d.roundness = roundness; + let rect = ctx.dab_rect(&d, false); + let expected: Vec<_> = [false, true] + .into_iter() + .map(|dual| { + let mut coverage = Vec::new(); + ctx.rasterize_reference(&d, dual, rect, &mut coverage, None); + coverage + }) + .collect(); + for angle in [-2.3, 0.7, std::f32::consts::FRAC_PI_2, std::f32::consts::PI] { + for (flip_x, flip_y) in [(false, false), (true, false), (false, true), (true, true)] { + d.angle = angle; + d.flip_x = flip_x; + d.flip_y = flip_y; + for (dual, expected) in [false, true].into_iter().zip(&expected) { + let mut actual = vec![0.75; 19]; + ctx.rasterize(&d, dual, rect, &mut actual); + assert_eq!(actual.len(), expected.len()); + let mismatch = actual.iter().zip(expected).enumerate().find(|(_, (a, b))| a.to_bits() != b.to_bits()); + assert!( + mismatch.is_none(), + "circle changed with rotation: {mismatch:?}; {d:?}, aliased={aliased}, hardness={hardness}, dual={dual}" + ); + } + } + } + } + } + } + } +} + +#[test] +fn rotated_ellipses_and_projected_circles_match_scalar_bits() { + for aliased in [false, true] { + let ctx = BrushContext::new(&BrushSettings { aliased, hardness: 0.5, ..Default::default() }); + for (roundness, proj_scale) in [(0.4, 1.0), (1.0, 0.6)] { + for angle in [0.0, 0.7, -2.3] { + let mut d = Dab::round(Point::new(-31.25, 63.75), 15.25, 0.37); + d.roundness = roundness; + d.angle = angle; + d.proj_scale = proj_scale; + d.proj_angle = 0.3; + compare(&ctx, &d, false, ctx.dab_rect(&d, false), false); + } + } + } +} + #[test] fn large_corner_dabs_keep_off_canvas_coverage() { for (hardness, aliased) in [(0.0, false), (1.0, false), (1.0, true)] { @@ -243,3 +302,58 @@ fn computed_tips_match_at_pixel_and_float_boundaries() { } } } + +#[test] +fn cached_profiles_match_scalar_for_small_and_transformed_dabs() { + for hardness in [-0.5, 0.0, 0.035, 0.37, 0.95, 1.0, 1.5, f32::NAN, f32::INFINITY] { + let ctx = BrushContext::new(&BrushSettings { hardness, ..Default::default() }); + for radius in [0.5, 1.25, 2.99, 3.0, 7.5, 100.0] { + for dual in [false, true] { + for with_depth in [false, true] { + for angle in [0.0, 0.7] { + let mut d = Dab::round(Point::new(4.25, -3.75), radius, 0.73); + d.angle = angle; + d.roundness = 0.6; + compare(&ctx, &d, dual, ctx.dab_rect(&d, dual), with_depth); + } + } + } + } + } +} + +// Keep the pre-cache scalar coverage equations independent of TipProfile. +fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { + let (r0, s) = tip_shape(radius, hardness); + if d <= r0 { + return 1.0; + } + let x = (d - r0) / s; + soft_exp(x * x) +} + +fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { + // NaN distances paint nothing. + if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 { + return 0.0; + } + tip_falloff(d, radius, hardness) +} + +#[test] +fn cached_profile_preserves_public_coverage_at_boundaries() { + for radius in [-1.0, 0.0, 0.5, 3.0, 100.0, f32::NAN, f32::INFINITY] { + for hardness in [-1.0, 0.0, 0.035, 0.37, 1.0, 2.0, f32::NAN, f32::INFINITY] { + let profile = TipProfile::new(radius, hardness); + let (core, shoulder) = tip_shape(radius, hardness); + for d in [-1.0, -0.0, core, core + shoulder, profile.reach(), profile.reach() + 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] { + let expected = dab_coverage(d, radius, hardness); + assert_eq!(profile.coverage(d).to_bits(), expected.to_bits()); + assert_eq!(crate::dab_coverage(d, radius, hardness).to_bits(), expected.to_bits()); + let expected = tip_falloff(d, radius, hardness); + let actual = crate::tip_falloff(d, radius, hardness); + assert!(actual.to_bits() == expected.to_bits() || (actual.is_nan() && expected.is_nan())); + } + } + } +} diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index ba3f29f89d..9e4ddc2266 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -140,9 +140,10 @@ impl Plugin { } r }; - // A few bands at a time (one per worker), so peak memory stays near one band per thread. + // A few bands at a time: one per worker, and no more instances than the run's memory + // budget holds at their cap, so peak memory stays bounded however many cores run (#8603). #[cfg(not(target_arch = "wasm32"))] - let group = rayon::current_num_threads().max(1); + let group = self.limits().parallel_bands(rayon::current_num_threads()); #[cfg(target_arch = "wasm32")] let group = 1; for chunk in bands.chunks(group) { @@ -155,7 +156,7 @@ impl Plugin { let results: Vec> = chunk.iter().map(run).collect(); for r in results { for (c, t) in r?.tiles() { - out.tile_mut(*c).bytes_mut().copy_from_slice(t.bytes()); + out.tile_mut(*c).bytes_mut().copy_from_slice(&t.bytes()); } } } @@ -210,7 +211,8 @@ impl Plugin { v = v.clamp(0.0, 1.0); } if let Some(d) = out.get_mut(di + ch) { - *d = o + (v - o) * k; + // As a built-in edit mixes through the selection (#9483). + *d = o * (1.0 - k) + v * k; } } } @@ -225,7 +227,7 @@ fn encode_band(surface: &Surface, band: Rect, data: &[f32]) -> Surface { let mut s = Surface::with_default(surface.format(), &surface.default_pixel()); for c in band.tiles() { if let Some(t) = surface.tile(c) { - s.tile_mut(c).bytes_mut().copy_from_slice(t.bytes()); + s.tile_mut(c).bytes_mut().copy_from_slice(&t.bytes()); } else { s.tile_mut(c); } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 9f66b545ba..af3b3b8655 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -23,6 +23,10 @@ pub struct Limits { pub max_module_bytes: usize, /// Cap on a plug-in's linear memory. pub max_memory_bytes: usize, + /// Cap on the linear memory of all of one run's instances together: bands run in parallel + /// only as far as their [`Limits::max_memory_bytes`] fit in it, so a run's peak memory does + /// not grow with the number of cores (#8603). + pub max_total_memory_bytes: usize, /// Target size of one band of `f32` pixels handed to the plug-in. pub band_bytes: usize, /// Instructions (fuel) any call may use, plus [`Limits::fuel_per_sample`] per sample for @@ -37,10 +41,12 @@ pub struct Limits { } impl Default for Limits { + /// The limits plug-ins run under unless a caller narrows them; see `docs/plugins.md`. fn default() -> Self { Self { max_module_bytes: 32 << 20, max_memory_bytes: 512 << 20, + max_total_memory_bytes: 2 << 30, band_bytes: 4 << 20, fuel_base: 50_000_000, fuel_per_sample: 4_000, @@ -50,6 +56,16 @@ impl Default for Limits { } } +impl Limits { + /// How many bands of one run may hold an instance at once on `threads` workers: no more + /// than [`Limits::max_memory_bytes`] fits into [`Limits::max_total_memory_bytes`], and always + /// at least one, so a tight budget runs bands one at a time instead of refusing the run. + pub fn parallel_bands(&self, threads: usize) -> usize { + let fit = self.max_total_memory_bytes.checked_div(self.max_memory_bytes).unwrap_or(threads); + threads.min(fit).max(1) + } +} + /// A loaded, validated plug-in. Cheap to share (`Arc`) and safe to run from several threads: /// every run gets its own store and instance. pub struct Plugin { @@ -151,6 +167,26 @@ fn check_code_section(bytes: &[u8]) -> Result<()> { Ok(()) } +/// What a file that is not WebAssembly actually is, judged from its magic number, so the install +/// error can name the format the person picked instead of only the one we wanted (public format +/// facts: the PE/COFF, ELF, Mach-O and zip signatures). `None` for anything else. Reads with +/// `starts_with`/`get`, so a file shorter than the signature is fine. +fn format_hint(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"MZ") { + return Some("a Windows native executable or library (a .dll, or a Photoshop plug-in such as .8bf/.8bt)"); + } + if bytes.get(..4) == Some(&[0x7f, b'E', b'L', b'F']) { + return Some("a Linux native library (ELF)"); + } + if matches!(bytes.get(..4), Some(&[0xce, 0xfa, 0xed, 0xfe] | &[0xcf, 0xfa, 0xed, 0xfe] | &[0xca, 0xfe, 0xba, 0xbe] | &[0xbe, 0xba, 0xfe, 0xca])) { + return Some("a macOS native binary (Mach-O, thin or fat)"); + } + if matches!(bytes.get(..4), Some(&[0x50, 0x4b, 0x03, 0x04] | &[0x50, 0x4b, 0x05, 0x06])) { + return Some("a zip archive"); + } + None +} + impl Plugin { /// Validates and loads a module: checks the ABI version and reads the manifest. pub fn load(bytes: &[u8], limits: Limits) -> Result { @@ -158,7 +194,11 @@ impl Plugin { return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes))); } if !bytes.starts_with(b"\0asm") { - return Err(Error::Module("not a WebAssembly binary (missing \\0asm header)".into())); + let msg = "not a WebAssembly binary (missing \\0asm header)"; + return Err(Error::Module(match format_hint(bytes) { + Some(hint) => format!("{msg}; this file looks like {hint} - PhotoCraft plug-ins are WebAssembly modules (.wasm)"), + None => msg.to_string(), + })); } check_code_section(bytes)?; let mut config = Config::default(); diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 17e352982e..113d401dcf 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -219,6 +219,64 @@ fn malformed_and_oversized_modules_are_refused() { assert!(matches!(e, Error::Abi(_)), "{e}"); } +/// The message `Plugin::load` gives a file that is not WebAssembly and not recognised (#10362). +const NOT_WASM: &str = "not a valid plug-in module: not a WebAssembly binary (missing \\0asm header)"; + +/// Loads `bytes`, which must be refused as a module, and returns the message. +fn refusal(bytes: &[u8]) -> String { + let Err(e) = Plugin::load(bytes, Limits::default()) else { panic!("{bytes:?} was accepted") }; + assert!(matches!(e, Error::Module(_)), "{e}"); + e.to_string() +} + +/// The message names the format (`format`), says plug-ins are WebAssembly, and stays on one line +/// (`Plugin::load` has only bytes, so the hint cannot carry a path). +fn assert_names(bytes: &[u8], format: &str) { + let m = refusal(bytes); + assert!(m.starts_with(NOT_WASM), "{m}"); + assert!(m.contains(format), "{m}"); + assert!(m.contains("PhotoCraft plug-ins are WebAssembly modules"), "{m}"); + assert!(!m.contains('\n'), "{m}"); +} + +#[test] +fn a_pe_coff_file_is_named_as_a_windows_binary() { + // #10362: an old Photoshop plug-in (.8bt) is a native Windows module. + assert_names(b"MZ\x90\x00\x03\0", "Windows"); + assert!(refusal(b"MZ").contains("8bf")); +} + +#[test] +fn an_elf_file_is_named_as_a_linux_library() { + assert_names(&[0x7f, b'E', b'L', b'F', 2, 1, 1], "Linux"); +} + +#[test] +fn a_mach_o_file_is_named_as_a_macos_binary() { + for head in [[0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xbe, 0xba, 0xfe, 0xca]] { + assert_names(&[&head[..], &[7, 1][..]].concat(), "macOS"); + } +} + +#[test] +fn a_zip_file_is_named_as_a_zip_archive() { + assert_names(b"PK\x03\x04\x14\0", "zip"); + assert_names(b"PK\x05\x06\0\0", "zip"); +} + +#[test] +fn an_empty_or_unrecognised_file_keeps_the_plain_message() { + // Nothing recognised (including a signature cut short): today's message, exactly. + for bytes in [&b""[..], &b"x"[..], &b"junkjunk"[..], &[0x7f, b'E'][..], &b"PK"[..]] { + assert_eq!(refusal(bytes), NOT_WASM, "{bytes:?}"); + } +} + +#[test] +fn a_valid_wasm_module_still_loads_unchanged() { + assert!(Plugin::load(INVERT, Limits::default()).is_ok()); +} + #[test] fn a_code_section_claiming_no_functions_but_many_bytes_is_refused() { // Header, then a code section (id 10) declaring 2000 bytes whose function count is 0, cut off @@ -337,6 +395,39 @@ fn folder_loading_skips_bad_files() { std::fs::remove_dir_all(&dir).ok(); } +/// #8603: a run whose memory budget holds a single instance still covers every band on a +/// many-worker pool, one band at a time, with the same pixels as a run of parallel bands. +#[test] +fn one_band_at_a_time_matches_parallel_bands() { + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let s = surface(PixelFormat::RGBA8, 600, 300); + let canvas = Rect::new(0, 0, 600, 300); + let run = |limits: Limits| { + let p = Plugin::load(INVERT, limits).unwrap(); + pool.install(|| p.apply(&s, canvas, None, &json!({}))).unwrap() + }; + let parallel = run(Limits { band_bytes: 1, ..Limits::default() }); + let serial = run(Limits { band_bytes: 1, max_total_memory_bytes: 512 << 20, ..Limits::default() }); + assert_eq!(serial, parallel); + assert_ne!(serial, s, "the filter ran"); +} + +/// #8603: one run on 16 workers must not hold 16 instances at the 512 MiB cap (8 GiB). With the +/// default limits it holds at most four (2 GiB); a smaller per-instance cap lets every worker +/// run, and a budget under one cap still runs one band at a time rather than none. +#[test] +fn parallel_bands_fit_the_total_memory_budget() { + let d = Limits::default(); + assert_eq!(d.parallel_bands(16), 4); + assert!(d.parallel_bands(64) * d.max_memory_bytes <= d.max_total_memory_bytes); + assert_eq!(d.parallel_bands(2), 2, "fewer workers than the budget allows"); + let small = Limits { max_memory_bytes: 64 << 20, ..Limits::default() }; + assert_eq!(small.parallel_bands(16), 16, "small instances keep every worker busy"); + let tight = Limits { max_total_memory_bytes: 1 << 20, ..Limits::default() }; + assert_eq!(tight.parallel_bands(16), 1, "at least one band runs"); + assert_eq!(d.parallel_bands(0), 1); +} + #[test] fn a_start_function_that_spins_is_cut_off_at_one_slice() { // `fuel_base` is what a call may use; the start function cannot be interrupted to check the diff --git a/crates/psd/Cargo.toml b/crates/psd/Cargo.toml index 12662f9bd3..7ecce9ee67 100644 --- a/crates/psd/Cargo.toml +++ b/crates/psd/Cargo.toml @@ -20,6 +20,7 @@ testgen = [] [dependencies] thiserror = { workspace = true } flate2 = { workspace = true } +encoding_rs = { workspace = true } serde = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs index 04e268da94..bd341c7b00 100644 --- a/crates/psd/examples/atn_dump.rs +++ b/crates/psd/examples/atn_dump.rs @@ -56,9 +56,10 @@ fn main() { match atn::parse(&bytes) { Ok(f) => { println!( - "== {path}: set {:?}, {} actions{}", + "== {path}: set {:?}, {} actions{}{}", f.name.to_string_lossy(), f.actions.len(), + f.encoding.as_deref().map(|e| format!(", names in {e}")).unwrap_or_default(), if f.warnings.is_empty() { String::new() } else { format!(" ({})", f.warnings.join("; ")) } ); for a in &f.actions { @@ -66,8 +67,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.label(), keys(d)), - None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.label()), + Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), f.item_label(item), keys(d)), + None => println!(" {:<22} {:<28}{flags}", item.event.id(), f.item_label(item)), } } } diff --git a/crates/psd/fuzz/fuzz_targets/atn.rs b/crates/psd/fuzz/fuzz_targets/atn.rs index 3bb1777c8a..e02e25b073 100644 --- a/crates/psd/fuzz/fuzz_targets/atn.rs +++ b/crates/psd/fuzz/fuzz_targets/atn.rs @@ -1,14 +1,12 @@ //! Fuzz the `.atn` actions parser: any input must return `Ok` or `Err`, never panic, and -//! anything that parses must survive a write and parse back equal. +//! anything that parses must survive writing, reparsing and stable canonical writing. #![no_main] use libfuzzer_sys::fuzz_target; -use photocraft_psd::atn; + +#[path = "../../tests/it/common/atn_roundtrip.rs"] +mod atn_roundtrip; fuzz_target!(|data: &[u8]| { - if let Ok(f) = atn::parse(data) { - let bytes = atn::write(&f).expect("a parsed set is writable"); - let again = atn::parse(&bytes).expect("a written set parses"); - assert_eq!(again.actions, f.actions); - } + atn_roundtrip::check(data); }); diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 6bb900d435..384070f286 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -6,7 +6,7 @@ //! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. //! //! ```text -//! file := version u32 (16), set name UnicodeString, expanded u8, count u32, count × action +//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action //! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, //! name UnicodeString, expanded u8, count u32, count × item //! item := expanded u8, enabled u8, with dialog u8, dialog options u8, @@ -20,17 +20,28 @@ //! 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. +//! legacy descriptor layout: bare four-byte identifiers and no top-level class or name. +//! +//! Which legacy encoding is not recorded: Windows Photoshop uses the system code page (GBK in +//! Chinese, Shift_JIS in Japanese…), Mac Photoshop the Mac one. [`parse`] works it out once per +//! file into [`AtnFile::encoding`], first from steps that also store their name as Unicode (the +//! descriptor's class name), else by guessing from the bytes; [`parse_with_encoding`] takes it +//! from the caller. [`AtnFile::item_label`] reads names with it. The bytes themselves stay as +//! they are. //! //! [`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 -//! `parse(write(f)) == f`, and `write(parse(b)) == b` for files Photoshop wrote. +//! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so +//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files +//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout. +use crate::codepage; use crate::descriptor::{Descriptor, UnicodeString}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt, decode_legacy_name}; -/// The only version this module reads and writes (Photoshop 6 and every later release). +mod legacy; + +/// The version written by this module (Photoshop 6 and every later release). pub const VERSION: u32 = 16; /// Most actions read from one set (Photoshop's own sets hold a few dozen). pub const MAX_ACTIONS: usize = 10_000; @@ -50,6 +61,37 @@ pub struct AtnFile { /// Problems that did not stop the parse (trailing bytes…). #[cfg_attr(feature = "serde", serde(default))] pub warnings: Vec, + /// The encoding of the 8-bit names that are not ASCII or UTF-8 ("GBK", "Shift_JIS", + /// "macintosh"…): the one given to [`parse_with_encoding`], else the one that turns such + /// names into the Unicode class names stored with them, else a guess from the names shown + /// from their bytes alone. `None` when there are no such names, or nothing tells and no + /// name depends on it. [`write`] ignores it: names are written as stored. + #[cfg_attr(feature = "serde", serde(default))] + pub encoding: Option, +} + +impl AtnFile { + /// [`AtnItem::label`], with 8-bit names read in this file's [`encoding`](Self::encoding). + /// A name that is valid UTF-8 is read in that encoding only if it reads entirely as common + /// text there (GBK 去色 is also valid UTF-8, "ȥɫ"). + pub fn item_label(&self, item: &AtnItem) -> String { + let Some(enc) = self.encoding.as_deref().and_then(|label| codepage::resolve_label(label).ok()) else { return item.label() }; + let read = |bytes: &[u8]| match std::str::from_utf8(bytes) { + Ok(s) if s.is_ascii() || !codepage::reads_as_common(bytes, enc) => s.to_owned(), + _ => codepage::decode(bytes, enc), + }; + if let Some(class) = class_name(item) { + return class; + } + let dict = read(&item.dictionary); + if !dict.trim().is_empty() { + return dict; + } + match &item.event { + AtnEvent::Name(n) => read(n), + AtnEvent::Code(_) => item.event.id(), + } + } } /// One action of a set. @@ -116,8 +158,7 @@ impl AtnItem { /// 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() { + if let Some(class) = class_name(self) { return class; } let dict = self.dictionary(); @@ -125,6 +166,12 @@ impl AtnItem { } } +/// The descriptor's class name, when Photoshop stored one: the step's name as Unicode. +fn class_name(item: &AtnItem) -> Option { + let class = item.descriptor.as_ref().map(|d| d.name.to_string_lossy())?; + (!class.trim().is_empty()).then_some(class) +} + /// 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))] @@ -169,7 +216,8 @@ fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { Ok(()) } -fn read_item(r: &mut Reader<'_>) -> Result { +/// Read one step with the descriptor dialect selected by the file version. +fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { let expanded = r.u8()? != 0; let enabled = r.u8()? != 0; let with_dialog = r.u8()? != 0; @@ -182,13 +230,14 @@ fn read_item(r: &mut Reader<'_>) -> Result { let dictionary = read_ascii(r)?; let descriptor = match r.i32()? { 0 => None, - -1 => Some(Descriptor::read(r)?), + -1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }), n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), }; Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) } -fn read_action(r: &mut Reader<'_>) -> Result { +/// Read an action and its steps using the file's descriptor dialect. +fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { let function_key = r.u16()?; let shift = r.u8()? != 0; let command = r.u8()? != 0; @@ -203,17 +252,35 @@ fn read_action(r: &mut Reader<'_>) -> Result { } let mut items = Vec::with_capacity((n as usize).min(1024)); for _ in 0..n { - items.push(read_item(r)?); + items.push(read_item(r, legacy)?); } Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) } -/// Parses an `.atn` file. +/// Parses a version 12 or 16 `.atn` file, working out the encoding of its 8-bit names (see the +/// module docs). pub fn parse(data: &[u8]) -> Result { + parse_with_encoding(data, None) +} + +/// [`parse`], reading 8-bit names in `encoding` (a label such as `shift_jis`, `gbk`, `big5`, +/// `euc-kr`, `windows-1252`, `macintosh`) instead of working it out. An unknown label, or one +/// for an encoding that cannot hold an 8-bit name (UTF-16, `replacement`), is an error. +pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result { + let forced = encoding.map(codepage::resolve_label).transpose()?; + let mut f = parse_layout(data)?; + let mut warnings = Vec::new(); + let enc = forced.or_else(|| name_encoding(&f, &mut warnings)); + f.warnings.extend(warnings); + f.encoding = enc.map(|e| e.name().to_owned()); + Ok(f) +} + +fn parse_layout(data: &[u8]) -> Result { let mut r = Reader::new(data); let version = r.u32()?; - if version != VERSION { - return Err(PsdError::Unsupported(format!("actions file version {version} (only version 16, Photoshop 6 and later, is supported)"))); + if !matches!(version, 12 | VERSION) { + return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)"))); } let name = UnicodeString::read(&mut r)?; let expanded = r.u8()? != 0; @@ -225,13 +292,63 @@ pub fn parse(data: &[u8]) -> Result { } let mut actions = Vec::with_capacity((n as usize).min(256)); for _ in 0..n { - actions.push(read_action(&mut r)?); + actions.push(read_action(&mut r, version == 12)?); } let mut warnings = Vec::new(); if !r.is_empty() { warnings.push(format!("{} trailing bytes ignored", r.remaining())); } - Ok(AtnFile { name, expanded, actions, warnings }) + Ok(AtnFile { name, expanded, actions, warnings, encoding: None }) +} + +/// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown +/// from them needs one; adds a warning when the guess is uncertain. +fn name_encoding(f: &AtnFile, warnings: &mut Vec) -> Option<&'static encoding_rs::Encoding> { + let items = || f.actions.iter().flat_map(|a| &a.items); + let legacy = |b: &[u8]| !b.is_ascii(); + // Steps that store their name as Unicode too: the encoding that turns one into the other. + let pairs: Vec<(&[u8], String)> = items() + .filter(|i| legacy(&i.dictionary)) + .filter_map(|i| class_name(i).filter(|c| std::str::from_utf8(&i.dictionary) != Ok(c.as_str())).map(|c| (i.dictionary.as_slice(), c))) + .take(codepage::KNOWN_PAIRS) + .collect(); + let pairs: Vec<(&[u8], &str)> = pairs.iter().map(|(b, c)| (*b, c.as_str())).collect(); + if let Some(enc) = codepage::match_known(&pairs) { + return Some(enc); + } + // Otherwise guess from the names shown from bytes alone. + let invalid = |b: &[u8]| std::str::from_utf8(b).is_err(); + let mut orphans: Vec<&[u8]> = Vec::new(); + for i in items() { + if class_name(i).is_none() && invalid(&i.dictionary) { + orphans.push(&i.dictionary); + } + if let AtnEvent::Name(n) = &i.event + && invalid(n) + { + orphans.push(n); + } + } + if orphans.is_empty() { + return None; + } + // The set, action and class names are UTF-16: their script points at the writer's locale. + let mut hint = f.name.to_string_lossy(); + for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) { + if hint.len() > codepage::GUESS_SAMPLE { + break; + } + hint.push('\n'); + hint.push_str(&name); + } + let guess = codepage::guess(&orphans, &hint); + if guess.uncertain { + warnings.push(format!( + "step names aren't UTF-8 and their encoding couldn't be determined; read as {}; load with an explicit encoding to choose another", + guess.encoding.name() + )); + } + Some(guess.encoding) } fn write_item(out: &mut Vec, item: &AtnItem) -> Result<()> { @@ -336,6 +453,7 @@ mod tests { AtnAction { name: UnicodeString::new_nul("Empty"), ..Default::default() }, ], warnings: Vec::new(), + ..Default::default() } } @@ -390,6 +508,7 @@ mod tests { ..Default::default() }], warnings: Vec::new(), + ..Default::default() }; let mut expect = Vec::new(); expect.extend_from_slice(&[0, 0, 0, 16]); // version @@ -430,7 +549,7 @@ mod tests { #[test] fn rejects_other_versions_and_bad_structure() { let mut bytes = write(&set()).unwrap(); - bytes[3] = 12; + bytes[3] = 7; assert!(matches!(parse(&bytes), Err(PsdError::Unsupported(_)))); // A count far beyond the data must fail before allocating. let mut huge = vec![0, 0, 0, 16, 0, 0, 0, 0, 0]; @@ -447,6 +566,281 @@ mod tests { assert!(matches!(parse(&bad_flag), Err(PsdError::Invalid(_)))); } + const GBK_SELECTIVE_COLOR: &[u8] = b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab"; // 可选颜色 (from #6743) + const GBK_DESATURATE: &[u8] = b"\xc8\xa5\xc9\xab"; // 去色: valid UTF-8 by accident ("ȥɫ") + const LABELS: [Option<&str>; 5] = [None, Some("shift_jis"), Some("gbk"), Some("utf-8"), Some("macintosh")]; + + /// A step stored with no descriptor, so nothing but its 8-bit names tells what it is called. + fn bare(event: AtnEvent, dictionary: &[u8]) -> AtnItem { + AtnItem { event, dictionary: dictionary.to_vec(), ..Default::default() } + } + + /// A step whose descriptor also stores its panel name as Unicode, as Photoshop does for most. + fn known(dictionary: &[u8], class: &str) -> AtnItem { + let descriptor = Descriptor { name: UnicodeString::new_nul(class), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }; + AtnItem { event: AtnEvent::name("set"), dictionary: dictionary.to_vec(), descriptor: Some(descriptor), ..Default::default() } + } + + fn one_action(set: &str, action: &str, items: Vec) -> Vec { + let f = AtnFile { + name: UnicodeString::new_nul(set), + actions: vec![AtnAction { name: UnicodeString::new_nul(action), items, ..Default::default() }], + ..Default::default() + }; + write(&f).unwrap() + } + + /// The issue's set: Chinese Photoshop's Selective Color step, named in GBK. + fn gbk_set() -> Vec { + one_action("相馆", "调白", vec![bare(AtnEvent::name("syntheticFill"), GBK_SELECTIVE_COLOR)]) + } + + fn labels(f: &AtnFile) -> Vec { + f.actions.iter().flat_map(|a| a.items.iter().map(|i| f.item_label(i))).collect() + } + + #[test] + fn gbk_step_names_read_as_gbk() { + let f = parse(&gbk_set()).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("GBK")); + assert_eq!(labels(&f), ["可选颜色"]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + // The item itself, without the file's code page, reads as before. + assert_ne!(f.actions[0].items[0].label(), "可选颜色"); + } + + #[test] + fn legacy_names_are_kept_byte_for_byte() { + let bytes = gbk_set(); + let f = parse(&bytes).unwrap(); + assert_eq!(f.actions[0].items[0].dictionary, GBK_SELECTIVE_COLOR); + assert_eq!(write(&f).unwrap(), bytes); + assert_eq!(parse(&write(&f).unwrap()).unwrap(), f); + } + + #[test] + fn text_event_names_decode_too() { + let f = parse(&one_action("相馆", "调白", vec![bare(AtnEvent::Name(GBK_SELECTIVE_COLOR.to_vec()), b"")])).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("GBK")); + assert_eq!(labels(&f), ["可选颜色"]); + } + + #[test] + fn known_names_decide_the_code_page_of_the_rest() { + // French Photoshop on a Mac: "Définir" is stored as Mac Roman and as Unicode; the bare + // step's "Répéter" is only Mac Roman, which also reads as windows-1252 ("RŽpŽter"). + let items = vec![known(b"D\x8efinir", "Définir"), bare(AtnEvent::name("x"), b"R\x8ep\x8eter")]; + let f = parse(&one_action("Set 1", "Action 1", items)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("macintosh")); + assert_eq!(labels(&f), ["Définir", "Répéter"]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + } + + #[test] + fn class_names_hint_the_script() { + // GBK 保存 reads as common text in GBK, Big5 and EUC-KR alike; only the Chinese class + // name of another step (whose panel name is ASCII, so it settles nothing itself) says GBK. + let items = vec![known(b"Adjust", "调整"), bare(AtnEvent::name("x"), b"\xb1\xa3\xb4\xe6")]; + let f = parse(&one_action("Set 1", "Action 1", items)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("GBK")); + assert_eq!(labels(&f), ["调整", "保存"]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + } + + #[test] + fn latin1_symbol_names_read_as_windows_1252() { + // Windows Photoshop "Größe »" and "50 °" with no Unicode copy: Mac Roman reads these + // bytes as letters and symbols ("Grˆfle ª", "50 ∞"), but they are windows-1252, as before + // #6743. + let f = parse(&one_action("Größe ändern", "Aktion", vec![bare(AtnEvent::name("x"), b"Gr\xf6\xdfe \xbb")])).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("windows-1252")); + assert_eq!(labels(&f), ["Größe »"]); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + let f = parse(&one_action("Größe ändern", "Aktion", vec![bare(AtnEvent::name("x"), b"50 \xb0")])).unwrap(); + assert_eq!(labels(&f), ["50 °"]); + // Known limitation: "Logo ®" alone reads as Mac Roman "Logo Æ", as Mac "Æble" needs that + // byte (codepage's symbols_on_mac_roman_letters_stay_ambiguous). + let f = parse(&one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"Logo \xae")])).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("macintosh")); + assert_eq!(labels(&f), ["Logo Æ"]); + // Mac Roman bytes that windows-1252 reads as controls or rare letters stay Mac Roman. + let f = + parse(&one_action("Définir", "Action 1", vec![bare(AtnEvent::name("x"), b"Gr\x9a\xa7e"), bare(AtnEvent::name("y"), b"Arri\x8fre-plan")])).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("macintosh")); + assert_eq!(labels(&f), ["Größe", "Arrière-plan"]); + } + + #[test] + fn mac_roman_sharp_s_and_slashed_o_stay_mac_roman() { + // Mac Roman ß ø æ Æ are the bytes of windows-1252 § ¿ ¾ ®: German and Danish Mac names + // with no Unicode copy still read as Mac Roman, each alone and as a set. + let words: [&[u8]; 5] = [b"Wei\xa7", b"Gau\xa7scher Weichzeichner", b"St\xbfrrelse", b"L\xberredst\xbfrrelse", b"\xaeble"]; + let text = ["Weiß", "Gaußscher Weichzeichner", "Størrelse", "Lærredstørrelse", "Æble"]; + for (bytes, text) in words.iter().zip(text) { + let f = parse(&one_action("Größe ändern", "Aktion 1", vec![bare(AtnEvent::name("x"), bytes)])).unwrap(); + assert_eq!((f.encoding.as_deref(), labels(&f)), (Some("macintosh"), vec![text.to_string()])); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + } + let items = words.iter().map(|w| bare(AtnEvent::name("x"), w)).collect(); + let f = parse(&one_action("Größe ändern", "Aktion 1", items)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("macintosh")); + assert_eq!(labels(&f), text); + } + + #[test] + fn mac_roman_bullets_stay_mac_roman() { + // Mac Roman • (0xA5) is windows-1252 ¥, which must not count for windows-1252. + for (set, bytes, text) in [ + ("Größe ändern", &b"Ebene \xa5 Kopie"[..], "Ebene • Kopie"), + ("Définir", b"Niveaux \xa5 Courbes", "Niveaux • Courbes"), + ("Tamaño de imagen", b"Tama\x96o \xa5 50%", "Tamaño • 50%"), + ] { + let f = parse(&one_action(set, "Aktion 1", vec![bare(AtnEvent::name("x"), bytes)])).unwrap(); + assert_eq!((f.encoding.as_deref(), labels(&f)), (Some("macintosh"), vec![text.to_string()])); + } + } + + #[test] + fn russian_sets_with_latin_compounds_stay_windows_1251() { + // Ordinary windows-1251 names plus one glued to a Latin word ("CMYKканал"): the whole + // set reads as Cyrillic, with no warning. + let words: [&[u8]; 5] = [ + b"\xcd\xee\xe2\xfb\xe9 \xf1\xeb\xee\xe9", + b"\xd0\xe0\xe7\xec\xe5\xf0 \xe8\xe7\xee\xe1\xf0\xe0\xe6\xe5\xed\xe8\xff", + b"\xca\xee\xef\xe8\xff \xf1\xeb\xee\xff 2", + b"\xca\xe0\xed\xe0\xeb RGB", + b"CMYK\xea\xe0\xed\xe0\xeb", + ]; + let text = ["Новый слой", "Размер изображения", "Копия слоя 2", "Канал RGB", "CMYKканал"]; + let items = words.iter().map(|w| bare(AtnEvent::name("x"), w)).collect(); + let f = parse(&one_action("Набор 1", "Операция 1", items)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("windows-1251")); + assert_eq!(labels(&f), text); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + } + + #[test] + fn known_names_alone_never_warn() { + // Nothing decodes to the Unicode name, but nothing is shown from the bytes either. + let f = parse(&one_action("Set 1", "Action 1", vec![known(b"\x80\xff\x80", "Something")])).unwrap(); + assert_eq!(f.encoding, None); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + assert_eq!(labels(&f), ["Something"]); + } + + #[test] + fn uncertain_guess_warns() { + let f = parse(&one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"\x80\xff\x80")])).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("windows-1251")); + assert_eq!(f.warnings.len(), 1, "{:?}", f.warnings); + assert!(f.warnings[0].contains("windows-1251"), "{:?}", f.warnings); + assert_eq!(labels(&f), ["ЂяЂ"]); + } + + #[test] + fn unresolved_codepage_tie_keeps_a_top_candidate_and_the_original_bytes() { + // GBK 保存 ties with other CJK pages. No Unicode name settles it, but windows-1252 + // scored lower, so its "±£´æ" must not replace the tied candidates' readable text. + let bytes = one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"\xb1\xa3\xb4\xe6")]); + let f = parse(&bytes).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("GBK")); + assert_eq!(labels(&f), ["保存"]); + assert_eq!(f.warnings.len(), 1, "{:?}", f.warnings); + assert!(f.warnings[0].contains("read as GBK"), "{:?}", f.warnings); + assert_eq!(write(&f).unwrap(), bytes); + // Callers can still resolve an ambiguous guess explicitly. + let forced = parse_with_encoding(&bytes, Some("windows-1252")).unwrap(); + assert_eq!(labels(&forced), ["±£´æ"]); + assert!(forced.warnings.is_empty(), "{:?}", forced.warnings); + assert_eq!(write(&forced).unwrap(), bytes); + } + + #[test] + fn utf8_files_have_no_encoding() { + let f = parse(&write(&set()).unwrap()).unwrap(); + assert_eq!(f.encoding, None); + let items: Vec<&AtnItem> = f.actions.iter().flat_map(|a| &a.items).collect(); + assert_eq!(labels(&f), items.iter().map(|i| i.label()).collect::>()); + let f = parse(&one_action("Set", "A", vec![bare(AtnEvent::name("x"), "高斯模糊 ✓".as_bytes())])).unwrap(); + assert_eq!((f.encoding.clone(), labels(&f)), (None, vec!["高斯模糊 ✓".to_string()])); + } + + #[test] + fn utf8_lookalike_names_decode_with_the_file() { + let items = vec![ + bare(AtnEvent::name("x"), GBK_SELECTIVE_COLOR), + bare(AtnEvent::name("y"), GBK_DESATURATE), + bare(AtnEvent::name("z"), "Gaussian Blur ✓".as_bytes()), + ]; + let f = parse(&one_action("相馆", "调白", items)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("GBK")); + assert_eq!(labels(&f), ["可选颜色", "去色", "Gaussian Blur ✓"]); + } + + #[test] + fn utf8_names_stay_in_single_byte_files() { + // windows-1252 "Größe" beside a UTF-8 "Weiß"; windows-1251 "Уровни" beside a UTF-8 "Привет". + let latin = vec![bare(AtnEvent::name("x"), b"Gr\xf6\xdfe"), bare(AtnEvent::name("y"), "Weiß".as_bytes())]; + let f = parse(&one_action("Größe ändern", "Aktion", latin)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("windows-1252")); + assert_eq!(labels(&f), ["Größe", "Weiß"]); + let cyrillic = vec![bare(AtnEvent::name("x"), b"\xd3\xf0\xee\xe2\xed\xe8"), bare(AtnEvent::name("y"), "Привет".as_bytes())]; + let f = parse(&one_action("Набор", "Операция", cyrillic)).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("windows-1251")); + assert_eq!(labels(&f), ["Уровни", "Привет"]); + // A forced single-byte encoding leaves UTF-8 names alone too. + let f = parse_with_encoding(&one_action("S", "A", vec![bare(AtnEvent::name("y"), "Weiß".as_bytes())]), Some("windows-1252")).unwrap(); + assert_eq!(labels(&f), ["Weiß"]); + } + + #[test] + fn forced_encoding_wins() { + let f = parse_with_encoding(&gbk_set(), Some("big5")).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("Big5")); + assert_ne!(labels(&f), ["可选颜色"]); + // A wrong override still loads, and saves the bytes it read. + let f = parse_with_encoding(&gbk_set(), Some("shift_jis")).unwrap(); + assert_eq!(f.encoding.as_deref(), Some("Shift_JIS")); + assert_eq!(write(&f).unwrap(), gbk_set()); + // Forcing applies to all-UTF-8 files too. + assert_eq!(parse_with_encoding(&write(&set()).unwrap(), Some("gbk")).unwrap().encoding.as_deref(), Some("GBK")); + } + + #[test] + fn bad_labels_are_errors() { + for bad in ["nope", "replacement", "utf-16be", ""] { + assert!(matches!(parse_with_encoding(&gbk_set(), Some(bad)), Err(PsdError::Unsupported(_))), "{bad}"); + } + } + + #[test] + fn legacy_files_never_panic() { + let items = vec![known(b"D\x8efinir", "Définir"), bare(AtnEvent::Name(GBK_SELECTIVE_COLOR.to_vec()), GBK_DESATURATE)]; + let seed = one_action("相馆", "调白", items); + let check = |bytes: &[u8]| { + for label in LABELS { + if let Ok(f) = parse_with_encoding(bytes, label) { + let _ = labels(&f); + } + } + }; + for cut in 0..seed.len() { + check(&seed[..cut]); + } + let mut x: u32 = 0x85eb_ca6b; + for i in 0..500 { + let mut bytes = seed.clone(); + for _ in 0..(i % 5 + 1) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + let at = (x as usize) % bytes.len(); + bytes[at] = (x >> 8) as u8; + } + check(&bytes); + } + } + #[test] fn garbage_never_panics() { let seed = write(&set()).unwrap(); diff --git a/crates/psd/src/atn/legacy.rs b/crates/psd/src/atn/legacy.rs new file mode 100644 index 0000000000..c0f5053cbd --- /dev/null +++ b/crates/psd/src/atn/legacy.rs @@ -0,0 +1,111 @@ +//! Version 12 descriptors use bare codes and omit Unicode class names. + +use crate::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value, vec_for_count}; +use crate::error::{PsdError, Result}; +use crate::io::Reader; + +/// Read the unnamed, classless descriptor at the root of a legacy action step. +pub(super) fn read(r: &mut Reader<'_>) -> Result { + descriptor(r, Id::Code(*b"null"), 0) +} + +fn class(r: &mut Reader<'_>) -> Result { + Ok(Class { name: UnicodeString::default(), class_id: Id::Code(r.array()?) }) +} + +/// Read items after their enclosing class, with the same depth bound as modern descriptors. +fn descriptor(r: &mut Reader<'_>, class_id: Id, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(PsdError::LimitExceeded("descriptor nesting too deep")); + } + let n = r.u32()?; + // A bare key, type and Boolean need nine bytes, without a modern ID length. + r.check_count(u64::from(n), 9)?; + let mut items = vec_for_count(n); + for _ in 0..n { + items.push((Id::Code(r.array()?), value(r, depth + 1)?)); + } + Ok(Descriptor { name: UnicodeString::default(), class_id, items }) +} + +/// Decode a legacy value without trying the modern dialect on malformed input. +fn value(r: &mut Reader<'_>, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(PsdError::LimitExceeded("descriptor nesting too deep")); + } + Ok(match &r.array::<4>()? { + b"obj " => { + let n = r.u32()?; + r.check_count(u64::from(n), 8)?; + let mut items = vec_for_count(n); + for _ in 0..n { + items.push(reference(r)?); + } + Value::Reference(items) + } + ty @ (b"Objc" | b"GlbO") => { + let class_id = Id::Code(r.array()?); + let d = descriptor(r, class_id, depth + 1)?; + if ty == b"Objc" { Value::Descriptor(d) } else { Value::GlobalObject(d) } + } + b"VlLs" => { + let n = r.u32()?; + r.check_count(u64::from(n), 4)?; + let mut items = vec_for_count(n); + for _ in 0..n { + items.push(value(r, depth + 1)?); + } + Value::List(items) + } + b"doub" => Value::Double(r.f64()?), + b"UntF" => Value::UnitFloat { unit: r.array()?, value: r.f64()? }, + b"UnFl" => { + let unit = r.array()?; + let n = r.u32()?; + r.check_count(u64::from(n), 8)?; + let mut values = vec_for_count(n); + for _ in 0..n { + values.push(r.f64()?); + } + Value::UnitFloats { unit, values } + } + b"TEXT" => Value::Text(UnicodeString::read(r)?), + b"enum" => Value::Enumerated { type_id: Id::Code(r.array()?), value: Id::Code(r.array()?) }, + b"long" => Value::Integer(r.i32()?), + b"comp" => Value::LargeInteger(r.i64()?), + b"bool" => Value::Boolean(r.u8()? != 0), + b"type" => Value::Class(class(r)?), + b"GlbC" => Value::GlobalClass(class(r)?), + ty @ (b"alis" | b"Pth " | b"tdta") => { + let n = r.u32()?; + let bytes = r.bytes_u64(u64::from(n))?.to_vec(); + match ty { + b"alis" => Value::Alias(bytes), + b"Pth " => Value::Path(bytes), + _ => Value::RawData(bytes), + } + } + b"ObAr" => { + let class_id = Id::Code(r.array()?); + let prefix = r.u32()?; + Value::ObjectArray(ObjectArray { prefix, body: descriptor(r, class_id, depth + 1)? }) + } + other => return Err(PsdError::Unsupported(format!("legacy descriptor OSType {:?}", String::from_utf8_lossy(other)))), + }) +} + +/// Every legacy reference form carries a bare class, including index and identifier forms. +fn reference(r: &mut Reader<'_>) -> Result { + let ty = r.array::<4>()?; + let class = class(r)?; + Ok(match &ty { + b"prop" => ReferenceItem::Property { class, key: Id::Code(r.array()?) }, + b"Clss" => ReferenceItem::Class(class), + b"Enmr" => ReferenceItem::Enumerated { class, type_id: Id::Code(r.array()?), value: Id::Code(r.array()?) }, + b"rele" => ReferenceItem::Offset { class, offset: r.i32()? }, + b"Idnt" => ReferenceItem::Identifier { class, id: r.u32()? }, + b"indx" => ReferenceItem::Index { class, index: r.u32()? }, + b"name" => ReferenceItem::Name { class, name: UnicodeString::read(r)? }, + other => return Err(PsdError::Unsupported(format!("legacy reference item type {:?}", String::from_utf8_lossy(other)))), + }) +} diff --git a/crates/psd/src/codepage.rs b/crates/psd/src/codepage.rs new file mode 100644 index 0000000000..af472ea574 --- /dev/null +++ b/crates/psd/src/codepage.rs @@ -0,0 +1,874 @@ +//! Guessing the legacy code page of 8-bit names. +//! +//! Some formats store names as bytes in the writer's system ANSI code page: an `.atn` file's +//! event and dictionary names are CP936 (GBK) from Chinese Photoshop, CP932 (Shift_JIS) from +//! Japanese, Mac Roman from French Photoshop on a Mac, and so on. Nothing in the file says +//! which. When the file also stores some of those names as Unicode, [`match_known`] finds the +//! code page that turns the bytes into them. Otherwise [`guess`] infers one code page per file +//! from the names that are not valid UTF-8: +//! +//! 1. Candidates: Shift_JIS, GBK, Big5, EUC-KR, windows-1252, windows-1251, macintosh (Mac +//! Roman). A candidate that cannot decode every name without errors is dropped. +//! 2. Each survivor is scored by the share of non-ASCII bytes that fall in its common-text +//! ranges (GB2312 hanzi, JIS level-1 kanji and kana, Big5 level 1, KS X 1001 Hangul, +//! letters, each page's punctuation, the degree sign, and ¡ opening an exclamation: "¡Hola!"). The +//! highest share wins. Two single-byte readings don't count: the letters windows-1252 alone +//! has at 0x80–0x9F (Š š Ž ž Ÿ ƒ ˆ), where Mac Roman keeps its accented lowercase (Mac +//! "Opacité" is 1252 "OpacitŽ"), unless one starts a word ("Škoda", "šuma") in a file whose +//! own Unicode text doesn't write the Mac Roman letter on that byte instead (a French "état", +//! a German "über", "öffnen"); +//! and a windows-1251 Cyrillic letter glued into an ASCII word (Mac "Höhe" is 1251 "Hшhe"), +//! unless the name's letters are mostly Cyrillic ("CMYKканал", "Копiя") or the letter is in a +//! Cyrillic word of its own ("Layer Слой", "Слой1"). +//! 3. A tie is broken by the script of a hint (text the file stores as UTF-16, so it decodes +//! fine): kana → Shift_JIS; Hangul → EUC-KR; Cyrillic → windows-1251; Han → GBK, then +//! Big5, then Shift_JIS; Latin letters → windows-1252, then Mac Roman. +//! 4. Still tied: windows-1252 if it is among them, otherwise the first highest-scoring candidate, +//! flagged [`Guess::uncertain`]. +//! 5. Mac Roman must also beat windows-1252 when the Latin-1 symbols that are no letter in +//! Mac Roman (© ° « ± …) count as common text (1252 "Größe »" is Mac Roman "Grˆfle ª"). +//! Otherwise the names are windows-1252, with step 3's hint and step 4's warning. +//! +//! Only the first [`GUESS_SAMPLE`] bytes of names are examined. + +use encoding_rs::{ + BIG5, DecoderResult, EUC_KR, Encoding, GBK, ISO_2022_JP, MACINTOSH, REPLACEMENT, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE, WINDOWS_1251, WINDOWS_1252, + X_USER_DEFINED, +}; + +use crate::error::{PsdError, Result}; + +/// At most this many bytes of legacy names are examined by [`guess`]. +pub(crate) const GUESS_SAMPLE: usize = 64 * 1024; +/// At most this many `(bytes, text)` pairs are examined by [`match_known`]. +pub(crate) const KNOWN_PAIRS: usize = 256; + +/// The code page [`guess`] settled on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Guess { + /// The encoding to decode every legacy name with. + pub(crate) encoding: &'static Encoding, + /// Nothing settled the highest-scoring candidates' tie; `encoding` is a fallback among them. + pub(crate) uncertain: bool, +} + +/// Candidates in tie order (encoding_rs exposes encodings as statics, hence a function). +fn candidates() -> [&'static Encoding; 7] { + [SHIFT_JIS, GBK, BIG5, EUC_KR, WINDOWS_1252, WINDOWS_1251, MACINTOSH] +} + +/// The candidate that decodes the most of `pairs` (a name's bytes and the same name as Unicode) +/// exactly; ties go to the earlier candidate. `None` when it decodes none of them. Only the first +/// [`KNOWN_PAIRS`] pairs are examined. +pub(crate) fn match_known(pairs: &[(&[u8], &str)]) -> Option<&'static Encoding> { + let pairs = pairs.get(..KNOWN_PAIRS).unwrap_or(pairs); + let mut best = None; + let mut best_count = 0usize; + for enc in candidates() { + let count = pairs.iter().filter(|(bytes, text)| enc.decode_without_bom_handling_and_without_replacement(bytes).is_some_and(|d| d == *text)).count(); + if count > best_count { + best = Some(enc); + best_count = count; + } + } + best +} + +/// Guesses the one code page that `legacy` (names that are not valid UTF-8, in file order) were +/// written in. `hint` is text from the same file whose script points at the writer's locale. +pub(crate) fn guess(legacy: &[&[u8]], hint: &str) -> Guess { + let sample = sample(legacy); + if sample.is_empty() { + return Guess { encoding: UTF_8, uncertain: false }; + } + // The accented letters the file's own Unicode text uses: what its writer's language writes. + let hint_letters: std::collections::BTreeSet = hint.chars().filter(|c| !c.is_ascii() && c.is_alphabetic()).flat_map(char::to_lowercase).collect(); + let mut best: Vec<&'static Encoding> = Vec::new(); + let mut best_score = (0u64, 1u64); + for enc in candidates() { + if !sample.iter().all(|(name, truncated)| decodes_cleanly(enc, name, *truncated)) { + continue; + } + let score = sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| { + let (nc, nh) = score(enc, name, &hint_letters); + (c.saturating_add(nc), h.saturating_add(nh)) + }); + let score = if score.1 == 0 { (0, 1) } else { score }; + // Compare common/high shares without division: a/b vs c/d ⇔ a·d vs c·b. + let lhs = u128::from(score.0) * u128::from(best_score.1); + let rhs = u128::from(best_score.0) * u128::from(score.1); + if best.is_empty() || lhs > rhs { + best = vec![enc]; + best_score = score; + } else if lhs == rhs { + best.push(enc); + } + } + let picked = match best.as_slice() { + [only] => Guess { encoding: only, uncertain: false }, + tied => match hinted(hint).iter().find(|enc| tied.contains(enc)) { + Some(enc) => Guess { encoding: enc, uncertain: false }, + // A fallback must not discard the scores and choose a lower-scoring page. + None => { + let encoding = if tied.contains(&WINDOWS_1252) { WINDOWS_1252 } else { tied.first().copied().unwrap_or(WINDOWS_1252) }; + Guess { encoding, uncertain: true } + } + }, + }; + if picked.encoding != MACINTOSH { + return picked; + } + // windows-1252 maps every byte, so it is always a survivor here. + let latin = |enc| { + sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| { + let (nc, nh) = single_byte_score(enc, name, is_latin_common); + (c.saturating_add(nc), h.saturating_add(nh)) + }) + }; + let (windows, mac) = (latin(WINDOWS_1252), latin(MACINTOSH)); + let lhs = u128::from(windows.0) * u128::from(mac.1.max(1)); + let rhs = u128::from(mac.0) * u128::from(windows.1.max(1)); + if lhs < rhs { + picked + } else if lhs > rhs || hinted(hint).contains(&WINDOWS_1252) { + Guess { encoding: WINDOWS_1252, uncertain: false } + } else { + Guess { encoding: WINDOWS_1252, uncertain: true } + } +} + +/// Decodes `bytes` with `enc`; invalid sequences become U+FFFD. +pub(crate) fn decode(bytes: &[u8], enc: &'static Encoding) -> String { + enc.decode_without_bom_handling(bytes).0.into_owned() +} + +/// Whether `name` decodes cleanly in the double-byte page `enc` and every non-ASCII byte of it is +/// common text there. A name in such a page can be valid UTF-8 by accident (GBK 去色 is +/// `C8 A5 C9 AB`, "ȥɫ"); real UTF-8 rarely passes, as its continuation bytes fall outside the +/// common ranges. Single-byte pages are never asked: their letters cover UTF-8's lead and +/// continuation bytes ("Weiß" is "Weiß" in windows-1252), and a legacy name there is valid UTF-8 +/// by accident only for unlikely pairs such as "é". +pub(crate) fn reads_as_common(name: &[u8], enc: &'static Encoding) -> bool { + if ![SHIFT_JIS, GBK, BIG5, EUC_KR].contains(&enc) { + return false; + } + let (common, high) = score(enc, name, &std::collections::BTreeSet::new()); + high > 0 && common == high && decodes_cleanly(enc, name, false) +} + +/// Resolves an encoding label (`shift_jis`, `gbk`, `big5`, `euc-kr`, `windows-1252`, `utf-8`…; +/// case and surrounding whitespace are ignored). Labels for encodings that cannot hold an 8-bit +/// name are rejected, and so is ISO-2022-JP: its names are 7-bit escape sequences, which would +/// pass through as ASCII. +pub(crate) fn resolve_label(label: &str) -> Result<&'static Encoding> { + match Encoding::for_label(label.as_bytes()) { + Some(enc) if ![REPLACEMENT, UTF_16LE, UTF_16BE, X_USER_DEFINED, ISO_2022_JP].contains(&enc) => Ok(enc), + _ => Err(PsdError::Unsupported(format!("text encoding `{label}`: use a label such as shift_jis, gbk, big5, euc-kr, windows-1252 or utf-8"))), + } +} + +/// The names [`guess`] examines, each with whether it was cut short: names in order while they +/// fit in [`GUESS_SAMPLE`] bytes, and at least the first (truncated if it alone is longer). +fn sample<'a>(legacy: &[&'a [u8]]) -> Vec<(&'a [u8], bool)> { + let mut out = Vec::new(); + let mut total = 0usize; + for name in legacy { + let next = total.saturating_add(name.len()); + if next > GUESS_SAMPLE { + if out.is_empty() { + out.push((name.get(..GUESS_SAMPLE).unwrap_or(name), true)); + } + break; + } + out.push((*name, false)); + total = next; + } + out +} + +/// Whether `enc` decodes `bytes` without a single error. A `truncated` name may end inside a +/// character, which is not held against it. +fn decodes_cleanly(enc: &'static Encoding, bytes: &[u8], truncated: bool) -> bool { + let mut decoder = enc.new_decoder_without_bom_handling(); + // Every candidate decodes one byte to at most three UTF-8 bytes, so this is linear in + // `bytes` (a sampled name, or one name the file already holds). + let mut out = String::with_capacity(bytes.len().saturating_mul(3).saturating_add(16)); + let mut rest = bytes; + loop { + let (result, read) = decoder.decode_to_string_without_replacement(rest, &mut out, !truncated); + match result { + DecoderResult::InputEmpty => return true, + DecoderResult::Malformed(..) => return false, + DecoderResult::OutputFull => { + rest = rest.get(read..).unwrap_or_default(); + out.clear(); + } + } + } +} + +/// `(common, high)`: how many of a name's non-ASCII bytes `enc` reads as common text, and how +/// many non-ASCII bytes it has. `hint_letters` are the accented letters the file's own Unicode +/// text writes (lowercase), which settle a word-initial letter of windows-1252's 0x80–0x9F. +fn score(enc: &'static Encoding, name: &[u8], hint_letters: &std::collections::BTreeSet) -> (u64, u64) { + if enc == WINDOWS_1252 || enc == WINDOWS_1251 || enc == MACINTOSH { + // Letters, typographic punctuation, the degree sign ("Rotation 90°") and ¡ opening an + // exclamation ("¡Hola!") are common, as the double-byte pages' punctuation rows are, with two readings + // held back: the letters windows-1252 alone has at 0x80–0x9F, where Mac Roman keeps its + // accented lowercase (Mac "Opacité" is 1252 "OpacitŽ"), unless one starts a word ("Škoda", + // "ŠKODA", "šuma") and the file's own text doesn't write the Mac Roman letter instead (a + // French "état", a German "öffnen"); and a windows-1251 Cyrillic letter glued into an ASCII + // word (Mac "Höhe" is 1251 "Hшhe"), unless the name's letters are mostly Cyrillic + // ("CMYKканал", Ukrainian "Копiя") or the letter is in a Cyrillic word of its own ("Layer + // Слой"); a lone one right after a digit (Mac "90°" alone is 1251 "90Ў") never counts. + let decoded = Name::new(&decode(name, enc)); + let chars = &decoded.chars; + let cyrillic_name = chars.iter().filter(|c| is_cyrillic(**c)).count() >= chars.iter().filter(|c| c.is_ascii_alphabetic()).count(); + let (mut common, mut high) = (0u64, 0u64); + for (i, &ch) in chars.iter().enumerate() { + if ch.is_ascii() { + continue; + } + high += 1; + let (after_ascii, letters_after) = position(&decoded, i); + let counts = if enc == WINDOWS_1252 && RARE_1252_LETTERS.contains(&ch) { + // Opening a word, capital or not ("Škoda", "šuma"), before an ASCII letter of + // either case: Mac Roman's lowercase ä before a capital ("äKODA") is no word + // either. + let word_initial = !after_ascii && letters_after >= 1; + // The same byte as Mac Roman reads it: a letter the file's text writes, where it + // never writes this one, says the writer meant that ("état", not "Žtat"). + let written = |c: char| c.to_lowercase().all(|l| hint_letters.contains(&l)); + let mac = name.get(i..=i).map(|b| decode(b, MACINTOSH)).and_then(|s| s.chars().next()); + word_initial && !(mac.is_some_and(|m| m.is_alphabetic() && written(m)) && !written(ch)) + } else if enc == WINDOWS_1251 && is_cyrillic(ch) { + (cyrillic_name && !lone_after_digit(&decoded, i)) || cyrillic_word(&decoded, i) + } else { + ch.is_alphabetic() || TYPOGRAPHIC.contains(&ch) || ch == '°' || (ch == '¡' && opens_exclamation(&decoded, i)) + }; + common += u64::from(counts); + } + return (common, high); + } + let (mut common, mut high) = (0u64, 0u64); + let mut i = 0usize; + while let Some(&lead) = name.get(i) { + if lead < 0x80 { + i += 1; + continue; + } + // Shift_JIS reads 0x80 and 0xA0–0xDF (half-width katakana, rare in real text) as one + // byte; elsewhere a lone non-ASCII byte is an error the decoder already rejected. + let single = if enc == SHIFT_JIS { lead == 0x80 || (0xA0..=0xDF).contains(&lead) || lead >= 0xFD } else { lead == 0x80 || lead == 0xFF }; + if single { + high += 1; + i += 1; + continue; + } + // A lead byte with nothing after it is where the sample was cut (the decoder rejects one + // at the real end of a name), so it is not scored. + let Some(&trail) = name.get(i + 1) else { break }; + // GB18030 four-byte sequences (second byte a digit) are never common text. + let len = if enc == GBK && trail.is_ascii_digit() { 4 } else { 2 }; + let len = len.min(name.len().saturating_sub(i)); + high += len as u64; + if len == 2 && is_common(enc, lead, trail) { + common += 2; + } + i += len; + } + (common, high) +} + +/// [`score`] for a single-byte page, where one byte is one character and a non-ASCII byte is a +/// non-ASCII character: `common` decides which of them count. +/// +/// windows-1251 has Cyrillic letters (І і Ј д) where windows-1252 has ² ³ £ ä, so a windows-1252 +/// name like "Fläche m²" or "Preis £5" would score higher as windows-1251 ("Flдche mІ", "Preis +/// Ј5"); [`score`] counts a Cyrillic letter only in a name whose letters are mostly Cyrillic, +/// which keeps ordinary Russian names such as "CMYKканал" or Ukrainian "Копiя" (Latin i). +fn single_byte_score(enc: &'static Encoding, name: &[u8], common: impl Fn(&Name, usize) -> bool) -> (u64, u64) { + let name = Name::new(&decode(name, enc)); + (0..name.chars.len()) + .filter(|i| name.chars.get(*i).is_some_and(|c| !c.is_ascii())) + .fold((0, 0), |(c, h), i| (c.saturating_add(u64::from(common(&name, i))), h.saturating_add(1))) +} + +/// A decoded name with what the position checks of [`score`] need, each found in one pass, so a +/// long name (a hostile file's 64 KiB of one letter) costs linear time rather than a rescan of +/// the rest of the name from every character. +struct Name { + chars: Vec, + /// `letters_from[k]`: how many letters run from `k` on (0 one past the end). + letters_from: Vec, + /// Where the last "!" is. + last_bang: Option, + /// For each Cyrillic letter, the run of Cyrillic letters around it (`start..end`); `i..i + 1` + /// for any other character. + cyrillic_runs: Vec<(usize, usize)>, +} + +impl Name { + fn new(text: &str) -> Self { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut letters_from = vec![0usize; n + 1]; + let mut run = 0usize; + for (slot, c) in letters_from.iter_mut().zip(&chars).rev() { + run = if c.is_alphabetic() { run.saturating_add(1) } else { 0 }; + *slot = run; + } + let last_bang = chars.iter().rposition(|c| *c == '!'); + let mut cyrillic_runs: Vec<(usize, usize)> = (0..n).map(|i| (i, i + 1)).collect(); + let mut start = 0; + while start < n { + if !chars.get(start).is_some_and(|c| is_cyrillic(*c)) { + start += 1; + continue; + } + let mut end = start + 1; + while chars.get(end).is_some_and(|c| is_cyrillic(*c)) { + end += 1; + } + for slot in cyrillic_runs.get_mut(start..end).into_iter().flatten() { + *slot = (start, end); + } + start = end; + } + Self { chars, letters_from, last_bang, cyrillic_runs } + } +} + +/// Whether the character at `i` of `chars` is common in Western names, for weighing windows-1252 +/// against Mac Roman: letters (but not those windows-1252 has only at 0x80–0x9F, where Mac Roman +/// keeps its accented lowercase: Mac "Größe" is 1252 "Grš§e"), typographic punctuation, +/// [`LATIN_SYMBOLS`], and ¡ opening an exclamation ("¡Hola!"), as in [`score`]. +fn is_latin_common(name: &Name, i: usize) -> bool { + let Some(&ch) = name.chars.get(i) else { return false }; + (ch.is_alphabetic() && !RARE_1252_LETTERS.contains(&ch) && ch != 'Œ' && ch != 'œ') + || TYPOGRAPHIC.contains(&ch) + || LATIN_SYMBOLS.contains(&ch) + || (ch == '¡' && opens_exclamation(name, i)) +} + +/// Where the character at `i` of `name` sits: right after an ASCII letter or digit (inside a word +/// or a number), and how many letters (accented ones too: "¡Ánimo!") follow it directly. +fn position(name: &Name, i: usize) -> (bool, usize) { + let after_ascii = i.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_alphanumeric()); + let letters_after = name.letters_from.get(i.saturating_add(1)).copied().unwrap_or(0); + (after_ascii, letters_after) +} + +/// Whether ¡ at `i` opens a Spanish exclamation ("¡Hola!", "Capa ¡Atención!", "¡Ánimo!"): no letter +/// or digit right before it, two letters or more after it, and the closing "!" later in the name. +/// Mac Roman's degree sign on the same byte follows a number ("Rotation 90°") or stands before a +/// unit ("20 °C", "20 °Celsius"), with no "!" to close. +fn opens_exclamation(name: &Name, i: usize) -> bool { + let (after_ascii, letters_after) = position(name, i); + !after_ascii && letters_after >= 2 && name.last_bang.is_some_and(|bang| bang > i) +} + +/// The letters windows-1252 has at 0x80–0x9F and Mac Roman doesn't: there Mac Roman keeps its +/// accented lowercase (ä é ö ü ...), so these never decide against it. Œ and œ (0x8C, 0x9C: Mac +/// å, ú) are real French letters and keep counting in [`score`]; [`is_latin_common`] leaves them +/// out as well. +const RARE_1252_LETTERS: [char; 7] = ['Š', 'š', 'Ž', 'ž', 'Ÿ', 'ƒ', 'ˆ']; + +/// A Cyrillic letter (windows-1251's 0xC0–0xFF and the letters among 0x80–0xBF). +fn is_cyrillic(ch: char) -> bool { + ('\u{0400}'..='\u{04FF}').contains(&ch) && ch.is_alphabetic() +} + +/// Whether the Cyrillic letter at `i` is in a Cyrillic word of its own: a run of two or more, +/// not glued to an ASCII letter, and not right after a digit ("Слой" beside "Layer", "Слой1"; +/// not "OpacitЋ", nor "90ЎЋ", which is Mac Roman "90°é": a digit ends a number, not a word, so +/// what follows it without a space is its unit or suffix). +fn cyrillic_word(name: &Name, i: usize) -> bool { + let (start, end) = cyrillic_run(name, i); + let glued_before = start.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_alphanumeric()); + let glued_after = name.chars.get(end).is_some_and(|c| c.is_ascii_alphabetic()); + end.saturating_sub(start) >= 2 && !glued_before && !glued_after +} + +/// The run of Cyrillic letters around the Cyrillic letter at `i` (`start..end`). +fn cyrillic_run(name: &Name, i: usize) -> (usize, usize) { + name.cyrillic_runs.get(i).copied().unwrap_or((i, i.saturating_add(1))) +} + +/// Whether the Cyrillic letter at `i` stands alone right after a digit: a unit or a symbol misread +/// (Mac Roman "90°" is windows-1251 "90Ў"), whatever the rest of the name; a run after a digit is +/// a Russian unit ("5шт") and still counts. +fn lone_after_digit(name: &Name, i: usize) -> bool { + let (start, end) = cyrillic_run(name, i); + end.saturating_sub(start) == 1 && start.checked_sub(1).and_then(|k| name.chars.get(k)).is_some_and(|c| c.is_ascii_digit()) +} + +/// Latin-1 symbols people type in names (windows-1252 0xA2–0xB7) whose byte is not a letter in +/// Mac Roman. Left out because Mac Roman has a letter there: § ® ¹ » ¼ ½ ¾ ¿ (ß Æ π ª º Ω æ ø, +/// as in Mac "Weiß", "Størrelse", "Æble"), € and ™ (0x80, 0x99: Ä, ô), and ¥ (0xA5, Mac Roman's +/// bullet in [`TYPOGRAPHIC`]: Mac "Ebene • Kopie"); and ¡ (0xA1), where Mac Roman has its degree +/// sign, the commoner of the two in names (Mac "Rotation 90°", "20 °C"), which counts only where +/// it opens an exclamation closed by "!" ("¡Hola!"; [`opens_exclamation`], in [`score`] and +/// [`is_latin_common`]). So a windows-1252 name whose only non-ASCII character is one of the +/// others ("Logo ®", "¿Tamaño?", "Preis ¥100") still reads as Mac Roman ("Logo Æ"). +const LATIN_SYMBOLS: [char; 10] = ['¢', '£', '©', '«', '°', '±', '²', '³', '¶', '·']; + +/// Quotes, dashes, ellipsis and bullet: windows-1252 and windows-1251 share them at 0x82–0x97; +/// Mac Roman has them too. +const TYPOGRAPHIC: [char; 10] = ['‘', '’', '‚', '“', '”', '„', '–', '—', '…', '•']; + +/// Whether the two-byte character `lead trail` is in `enc`'s common-text ranges. +fn is_common(enc: &'static Encoding, lead: u8, trail: u8) -> bool { + let both = u16::from_be_bytes([lead, trail]); + if enc == GBK { + // GB2312 hanzi, and the punctuation rows 0xA1 and 0xA3. + (0xA1..=0xFE).contains(&trail) && ((0xB0..=0xF7).contains(&lead) || lead == 0xA1 || lead == 0xA3) + } else if enc == BIG5 { + // Level-1 hanzi and punctuation. + (0xA4..=0xC6).contains(&lead) || lead == 0xA1 + } else if enc == SHIFT_JIS { + // Kana, level-1 kanji and punctuation. + (0x829F..=0x8396).contains(&both) || (0x889F..=0x9872).contains(&both) || (0x8140..=0x81FC).contains(&both) + } else if enc == EUC_KR { + // KS X 1001 Hangul and punctuation. + trail >= 0xA1 && ((0xB0..=0xC8).contains(&lead) || lead == 0xA1) + } else { + false + } +} + +/// The candidates the script of `hint` points at, strongest first. +fn hinted(hint: &str) -> Vec<&'static Encoding> { + let has = |lo: char, hi: char| hint.chars().any(|c| (lo..=hi).contains(&c) && c.is_alphabetic()); + if has('\u{3040}', '\u{30FF}') { + vec![SHIFT_JIS] + } else if has('\u{AC00}', '\u{D7A3}') { + vec![EUC_KR] + } else if has('\u{0400}', '\u{04FF}') { + vec![WINDOWS_1251] + } else if has('\u{4E00}', '\u{9FFF}') { + vec![GBK, BIG5, SHIFT_JIS] + } else if has('\u{00C0}', '\u{024F}') { + vec![WINDOWS_1252, MACINTOSH] + } else { + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GBK_ISSUE: &[&[u8]] = &[b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab"]; // 可选颜色 (from #6743) + const SJIS_COMMENT: &[&[u8]] = &[ + b"\x91\x49\x91\xf0\x20\x3a", // 選択 : + b"\x90\xdd\x92\xe8\x20\x3a", // 設定 : + b"\x83\x52\x83\x73\x81\x5b", // コピー + b"\x95\xdb\x91\xb6", // 保存 + ]; + + fn encoded(enc: &'static Encoding, words: &[&str]) -> Vec> { + words.iter().map(|w| enc.encode(w).0.into_owned()).collect() + } + + fn guess_words(enc: &'static Encoding, words: &[&str], hint: &str) -> Guess { + let bytes = encoded(enc, words); + let refs: Vec<&[u8]> = bytes.iter().map(Vec::as_slice).collect(); + guess(&refs, hint) + } + + #[test] + fn issue_gbk_bytes_read_as_gbk() { + let g = guess(GBK_ISSUE, "调白"); + assert_eq!(g.encoding, GBK); + assert!(!g.uncertain); + assert_eq!(decode(GBK_ISSUE[0], g.encoding), "可选颜色"); + } + + #[test] + fn kanji_only_hint_still_reads_shift_jis() { + let g = guess(SJIS_COMMENT, "保存"); + assert_eq!(g.encoding, SHIFT_JIS); + assert_eq!(decode(SJIS_COMMENT[0], g.encoding), "選択 :"); + } + + #[test] + fn big5_reads_as_big5() { + assert_eq!(guess_words(BIG5, &["色彩範圍", "移動", "選取", "儲存"], "").encoding, BIG5); + } + + #[test] + fn hangul_hint_reads_euc_kr() { + let g = guess_words(EUC_KR, &["선택", "이동", "저장", "색상 범위"], "동작 1"); + assert_eq!(g.encoding, EUC_KR); + assert!(!g.uncertain); + } + + #[test] + fn cyrillic_hint_reads_1251() { + let g = guess_words(WINDOWS_1251, &["Выделить", "Переместить", "Сохранить"], "Набор 1"); + assert_eq!(g.encoding, WINDOWS_1251); + assert!(!g.uncertain); + } + + #[test] + fn latin_hint_reads_1252() { + let g = guess_words(WINDOWS_1252, &["Größe"], "Größe ändern"); + assert_eq!(g.encoding, WINDOWS_1252); + assert!(!g.uncertain); + } + + #[test] + fn tie_without_hint_is_1252_and_uncertain() { + let g = guess_words(WINDOWS_1252, &["Größe"], "Set 1"); + assert_eq!(g.encoding, WINDOWS_1252); + assert!(g.uncertain); + } + + #[test] + fn han_tie_prefers_gbk() { + // GBK 保存: GBK, Big5 and EUC-KR all read both characters as common text. + let names: &[&[u8]] = &[b"\xb1\xa3\xb4\xe6"]; + let g = guess(names, "动作"); + assert_eq!(g.encoding, GBK); + assert!(!g.uncertain); + assert_eq!(decode(names[0], g.encoding), "保存"); + assert_eq!(guess(names, "Set 1"), Guess { encoding: GBK, uncertain: true }); + } + + #[test] + fn typographic_punctuation_reads_as_1252() { + // ’ (0x92) then a letter is also a level-1 kanji pair in Shift_JIS. + let names: &[&[u8]] = &[b"Taille de l\x92image"]; + let g = guess(names, "Actions par défaut"); + assert_eq!(g.encoding, WINDOWS_1252); + assert!(!g.uncertain); + assert_eq!(decode(names[0], g.encoding), "Taille de l’image"); + assert_eq!(guess(names, "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true }); + } + + #[test] + fn utf8_lookalikes_read_as_common_text() { + // GBK 去色 and EUC-KR 칠 are also valid UTF-8 ("ȥɫ", "ĥ"). + assert!(reads_as_common(b"\xc8\xa5\xc9\xab", GBK)); + assert!(reads_as_common(b"\xc4\xa5", EUC_KR)); + // Real UTF-8 text does not: its continuation bytes fall outside the common ranges. + assert!(!reads_as_common("高斯模糊".as_bytes(), GBK)); + assert!(!reads_as_common("Gaussian Blur ✓".as_bytes(), GBK)); + assert!(!reads_as_common("Größe".as_bytes(), WINDOWS_1252)); + assert!(!reads_as_common(b"Blur", GBK), "ASCII has nothing to re-read"); + // Nor does UTF-8 in a single-byte page, though "Weiß" is à and Ÿ there and "Привет" is + // all Р, С and letters: accidental UTF-8 is a double-byte page's problem. + assert!(!reads_as_common("Weiß".as_bytes(), WINDOWS_1252)); + assert!(!reads_as_common("Привет".as_bytes(), WINDOWS_1251)); + assert!(!reads_as_common("Weiß".as_bytes(), MACINTOSH)); + } + + #[test] + fn latin1_symbols_read_as_1252_not_mac_roman() { + // Mac Roman has letters where windows-1252 has some symbols: 1252 "Größe »" is Mac Roman + // "Grˆfle ª", "50 °" is "50 ∞". Latin-1 (what a code-page-blind reader shows) wins. + for words in [&["Größe »"][..], &["50 °"], &["Contraste « auto »", "© Größe", "± 5"]] { + let g = guess_words(WINDOWS_1252, words, "Größe ändern"); + assert_eq!(g, Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + } + } + + #[test] + fn mac_roman_accents_on_1252_letter_bytes_stay_mac_roman() { + // #10774: Mac Roman é ä ö ü Ä (0x8E 0x8A 0x9A 0x9F 0x80) are windows-1252 Ž Š š Ÿ € and + // windows-1251 Ћ Љ ш џ Ђ. The rare 1252 letters don't count, and a Cyrillic letter in a + // Latin word doesn't either, so Mac Roman wins with a Latin hint and without one. + let cases: [(&[&str], &str); 6] = [ + (&["Opacité 50%"], "Définir"), + (&["Opacité 50%"], "Set 1"), + (&["Sélection", "Créer un calque", "Luminosité/Contraste"], "Définir"), + (&["Höhe", "Grün", "Bär"], "Größe ändern"), + (&["Höhe"], "Set 1"), + (&["Ändern"], "Set 1"), + ]; + for (words, hint) in cases { + let g = guess_words(MACINTOSH, words, hint); + assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); + let bytes = encoded(MACINTOSH, words); + for (b, w) in bytes.iter().zip(words) { + assert_eq!(decode(b, g.encoding), *w); + } + } + } + + #[test] + fn mac_roman_degree_sign_is_not_cyrillic() { + // #10774: Mac Roman ° (0xA1) is windows-1251 Ў and windows-1252 ¡; the degree sign is + // common text, a lone Cyrillic letter among Latin ones is not. + // "Layer 90°é" (review): ° and é are windows-1251 Ў and Ћ, two Cyrillic letters in a row, + // but glued to the number, so no word of their own. + let cases: [(&[&str], &str); 6] = [ + (&["Rotation 90°"], "Définir"), + (&["Rotation 90°"], "Set 1"), + (&["Rotation 90°"], "Größe ändern"), + (&["Rotation 90°", "Arrière-plan"], "Définir"), + (&["Layer 90°é"], "Set 1"), + (&["Layer 90°é"], "Définir"), + ]; + for (words, hint) in cases { + let g = guess_words(MACINTOSH, words, hint); + assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); + } + } + + #[test] + fn the_same_names_in_windows_1252_stay_windows_1252() { + for words in [&["Opacité 50%"][..], &["Höhe", "Grün", "Bär"], &["Rotation 90°"], &["Ändern"], &["Fläche m²"], &["Preis £5"]] { + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Größe ändern"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + } + } + + #[test] + fn cyrillic_names_in_latin_hinted_files_and_cyrillic_words_beside_latin_ones_stay_1251() { + // A Cyrillic step name in a set with a French name reads as it did; a Cyrillic word beside + // a Latin one counts as Cyrillic, and the hint settles the tie with windows-1252's letters. + assert_eq!(guess_words(WINDOWS_1251, &["Ёлка"], "Définir"), Guess { encoding: WINDOWS_1251, uncertain: false }); + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой1"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }, "a digit after the word"); + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой", "Ёлка"], "Set 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + // Alone and without a hint, "Layer Слой" is as ambiguous as before (every letter is one in + // windows-1252 too): the flagged fallback. + assert_eq!(guess_words(WINDOWS_1251, &["Layer Слой"], "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true }); + } + + #[test] + fn a_rare_letter_starting_a_word_keeps_windows_1252() { + // windows-1252 Š Ž Ÿ š ž (0x8A 0x8E 0x9F 0x9A 0x9E) are Mac Roman ä é ü ö û: one starting + // a word ("Škoda", all caps "ŠKODA", lowercase "šuma") counts, an accent inside a word (Mac + // "Opacité" as "OpacitŽ") does not. + for words in [&["Škoda"][..], &["ŠKODA"], &["Žaba"], &["Šibenik", "Žaba"], &["ŽUPA", "Set"], &["šuma"], &["žaba"]] { + assert_eq!(guess_words(WINDOWS_1252, words, "Nastavení"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + } + // In a file whose own text writes ä and never Š (a German Photoshop), the byte reads as + // the Mac Roman ä it would be there: the file's alphabet decides what the bytes can't. + assert_eq!(guess_words(WINDOWS_1252, &["Škoda"], "Größe ändern").encoding, MACINTOSH); + assert_eq!(guess_words(WINDOWS_1252, &["šuma"], "Größe ändern").encoding, MACINTOSH, "the file writes ö and never š"); + } + + #[test] + fn an_inverted_exclamation_opening_an_exclamation_reads_as_windows_1252() { + // windows-1252 ¡ (0xA1) is Mac Roman °: ¡ opening an exclamation, two letters or more + // after it (accented ones too) and the closing "!" later ("¡Hola!", "¡Ánimo!"), counts for + // windows-1252 (review); after a number ("90°"), before a unit ("20 °C", "20 °Celsius") or + // without a "!" the byte stays the degree sign. + for words in [&["¡Hola!"][..], &["Capa ¡Atención!"], &["¡Ánimo!"]] { + assert_eq!(guess_words(WINDOWS_1252, words, "Configuración"), Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}"); + assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}"); + } + // (With a Latin hint: without any, a lone 0xA1 before an ASCII letter is also a Big5 + // punctuation pair, a tie the fallback flags.) + for words in [&["Rotation 90°"][..], &["Temperatur 20 °C"], &["20 °Celsius"]] { + for hint in ["Configuración", "Größe ändern"] { + assert_eq!(guess_words(MACINTOSH, words, hint), Guess { encoding: MACINTOSH, uncertain: false }, "{words:?} with hint {hint:?}"); + } + } + assert_eq!(guess_words(MACINTOSH, &["Rotation 90°"], "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }); + } + + #[test] + fn a_lone_cyrillic_letter_after_a_digit_is_no_cyrillic_name() { + // Mac Roman "90°" alone has no ASCII letter, so its one windows-1251 letter (Ў) would make + // the name "mostly Cyrillic" (review): a lone Cyrillic letter right after a digit never + // counts. A one-letter Cyrillic name and a Russian unit after a digit still do. + for hint in ["Set 1", "Définir"] { + assert_eq!(guess_words(MACINTOSH, &["90°"], hint), Guess { encoding: MACINTOSH, uncertain: false }, "hint {hint:?}"); + } + assert_eq!(guess_words(WINDOWS_1251, &["Я"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + assert_eq!(guess_words(WINDOWS_1251, &["5шт"], "Набор 1"), Guess { encoding: WINDOWS_1251, uncertain: false }); + } + + #[test] + fn word_initial_mac_roman_accents_read_as_mac_roman_when_the_file_writes_them() { + // Mac é (0x8E) and ü (0x9F) are windows-1252 Ž and Ÿ, capitals that could start a word: + // in a file whose own text has é or ü and no Ž or Ÿ, they are the Mac Roman letters. + assert_eq!(guess_words(MACINTOSH, &["état"], "Définir"), Guess { encoding: MACINTOSH, uncertain: false }); + assert_eq!(guess_words(MACINTOSH, &["über Ebene", "Höhe"], "Größe ändern"), Guess { encoding: MACINTOSH, uncertain: false }); + // Without such text the capital reading stands, as before: the bytes alone can't tell. + assert_eq!(guess_words(MACINTOSH, &["éclair"], "Set 1").encoding, WINDOWS_1252); + } + + #[test] + fn cyrillic_names_with_latin_words_still_read_as_1251() { + // Mostly Cyrillic letters count; a Latin brand name or a Latin i in Ukrainian doesn't + // turn the set into something else. + let g = guess_words(WINDOWS_1251, &["CMYKканал", "Копiя", "Слой 1"], "Набор 1"); + assert_eq!(g, Guess { encoding: WINDOWS_1251, uncertain: false }); + let bytes = encoded(WINDOWS_1251, &["CMYKканал"]); + assert_eq!(decode(&bytes[0], g.encoding), "CMYKканал"); + } + + #[test] + fn symbols_on_mac_roman_letters_stay_ambiguous() { + // Known limitation: 1252 "Logo ®" is Mac Roman "Logo Æ", and ® is not counted for + // windows-1252 because Mac "Æble" and "Weiß" use those bytes, so the name reads as Mac + // Roman whatever the hint, as it did before the symbols counted at all. + let logo: &[&[u8]] = &[b"Logo \xae"]; + assert_eq!(guess(logo, "Größe ändern"), Guess { encoding: MACINTOSH, uncertain: false }); + assert_eq!(guess(logo, "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }); + } + + #[test] + fn mac_roman_letters_still_read_as_mac_roman() { + // Mac Roman "Größe" is 1252 "Grš§e", "Ändern" "€ndern", "Contrôle" "Contr™le": Mac Roman + // still wins once § counts as common text in windows-1252. + // Mac Roman ß ø æ Æ are 1252 § ¿ ¾ ®, which windows-1252 must not count as common. + let words = [ + "Größe", + "Ändern", + "Contrôle", + "Weiß", + "Schwarzweiß", + "Gaußscher Weichzeichner", + "Schließen", + "Størrelse", + "Lærredstørrelse", + "Fjern støy", + "Æble", + ]; + for word in words { + let g = guess_words(MACINTOSH, &[word], "Définir l’arrière-plan"); + assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{word}"); + } + } + + #[test] + fn latin_hint_reads_mac_roman_when_1252_does_not_fit() { + // Mac Roman "Arrière-plan": è is 0x8F, a control character in windows-1252. + let names: &[&[u8]] = &[b"Arri\x8fre-plan"]; + let g = guess(names, "Définir l’arrière-plan"); + assert_eq!(g.encoding, MACINTOSH); + assert!(!g.uncertain); + assert_eq!(decode(names[0], g.encoding), "Arrière-plan"); + } + + #[test] + fn known_names_pick_the_code_page() { + // Every step of the corpus/atn sets that has a non-ASCII panel name also stores it as + // Unicode (the descriptor's class name); here, Mac Roman "Définir". + let pairs: &[(&[u8], &str)] = &[(b"D\x8efinir", "Définir")]; + assert_eq!(match_known(pairs), Some(MACINTOSH)); + // GBK 保存 is a three-way tie for the statistics; the known name settles it. + assert_eq!(match_known(&[(b"\xb1\xa3\xb4\xe6", "保存")]), Some(GBK)); + } + + #[test] + fn most_matching_names_win() { + let pairs: &[(&[u8], &str)] = &[(b"\xb1\xa3\xb4\xe6", "保存"), (b"D\x8efinir", "Définir"), (b"Arri\x8fre-plan", "Arrière-plan")]; + assert_eq!(match_known(pairs), Some(MACINTOSH)); + } + + #[test] + fn known_name_ties_take_candidate_order() { + // Curly quotes are 0x93/0x94 in both windows-1252 and windows-1251. + assert_eq!(match_known(&[(b"\x93x\x94", "“x”")]), Some(WINDOWS_1252)); + } + + #[test] + fn known_names_that_match_nothing_decide_nothing() { + assert_eq!(match_known(&[(b"D\x8efinir", "Set")]), None); + assert_eq!(match_known(&[]), None); + // Past the cap, pairs are not examined. + let mut pairs = vec![(&b"D\x8efinir"[..], "x"); KNOWN_PAIRS]; + pairs.push((b"\xb1\xa3\xb4\xe6", "保存")); + assert_eq!(match_known(&pairs), None); + } + + #[test] + fn bytes_no_cjk_page_accepts_still_decode() { + // 0xFF is invalid in every double-byte candidate; windows-1251 (Ђ я Ђ) and Mac Roman + // (Ä ˇ Ä) read all three bytes as letters, windows-1252 only one (€ ÿ €). With nothing to + // break the tie, the first highest-scoring candidate still decodes them. + let names: &[&[u8]] = &[b"\x80\xff\x80"]; + let g = guess(names, ""); + assert_eq!(g, Guess { encoding: WINDOWS_1251, uncertain: true }); + assert_eq!(decode(names[0], g.encoding), "ЂяЂ"); + assert_eq!(guess(names, "Набор").encoding, WINDOWS_1251); + } + + #[test] + fn uncertain_fallback_stays_among_the_highest_scoring_candidates() { + // Mac Roman "éäé" (0x8E 0x8A 0x8E) is windows-1251 "ЋЉЋ", three Cyrillic letters in a + // row, a word of their own: both pages score every non-ASCII byte as a letter (an odd + // count of bytes, so no double-byte page reads it). Windows-1252 reads "ŽŠŽ", no + // word-initial capital among them, so it must not win their unresolved tie. (Mac Roman + // "Ändern" no longer ties: its lone Cyrillic letter glued into a Latin word, 1251 + // "Ђndern", doesn't count, #10774.) + let names: &[&[u8]] = &[b"\x8e\x8a\x8e"]; + for hint in ["Set 1", "保存"] { + let g = guess(names, hint); + assert!([MACINTOSH, WINDOWS_1251].contains(&g.encoding), "{hint}: {g:?}"); + assert!(g.uncertain, "{hint}: {g:?}"); + assert_eq!(score(g.encoding, names[0], &Default::default()), (3, 3)); + } + assert_eq!(guess(&[b"\x80ndern"], "Set 1"), Guess { encoding: MACINTOSH, uncertain: false }, "Mac Roman \"Ändern\" is certain now"); + } + + #[test] + fn empty_or_utf8_input_is_utf8() { + let g = guess(&[], ""); + assert_eq!(g.encoding, UTF_8); + assert!(!g.uncertain); + } + + #[test] + fn mixed_code_pages_pick_one_and_never_fail() { + let names: &[&[u8]] = &[GBK_ISSUE[0], SJIS_COMMENT[0]]; + let g = guess(names, ""); + for name in names { + assert!(!decode(name, g.encoding).is_empty()); + } + } + + #[test] + fn huge_input_is_sampled() { + let names = vec![GBK_ISSUE[0]; 10_000]; + assert_eq!(guess(&names, "").encoding, GBK); + } + + #[test] + fn one_name_over_the_sample_cap_is_truncated_not_rejected() { + // "a" then 可 repeated past the cap, so the cut lands inside a two-byte character. + let mut name = b"a".to_vec(); + name.extend(b"\xbf\xc9".repeat(GUESS_SAMPLE / 2 + 1)); + assert_eq!(guess(&[&name], "可").encoding, GBK); + } + + #[test] + fn labels_resolve_and_reject() { + for ok in ["shift_jis", "gbk", "big5", "euc-kr", "windows-1252", "utf-8"] { + assert!(resolve_label(ok).is_ok(), "{ok}"); + } + for bad in ["nope", "replacement", "utf-16le", "x-user-defined", "", "iso-2022-kr", "iso-2022-jp", "csISO2022JP"] { + let Err(e) = resolve_label(bad) else { panic!("{bad} should be rejected") }; + assert!(e.to_string().contains("shift_jis"), "{e}"); + } + } + + #[test] + fn labels_match_loosely() { + assert_eq!(resolve_label("Shift_JIS").ok(), Some(SHIFT_JIS)); + assert_eq!(resolve_label(" gbk ").ok(), Some(GBK)); + assert_eq!(resolve_label("gb2312").ok(), Some(GBK)); + } + #[test] + fn a_name_of_one_long_letter_run_is_scored_in_linear_time() { + // 64 KiB of one letter (é in windows-1252, й in windows-1251, Mac Roman's È), of + // alternating Cyrillic letters, and of "¡ab" with no "!": every character used to rescan + // the rest of the name, which took minutes. + for unit in [&[0xe9u8][..], &[0xe9, 0xea][..], &[0xa1, b'a', b'b'][..]] { + let name = unit.repeat(GUESS_SAMPLE / unit.len()); + let started = std::time::Instant::now(); + let _ = guess(&[&name], ""); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?} took {:?}", unit, started.elapsed()); + } + } +} diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index eb5d05d609..73c71bab1d 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -210,7 +210,7 @@ pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout return Err(PsdError::UnexpectedEof { offset: data.len(), needed: total - data.len() }); } let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| PsdError::LimitExceeded("not enough memory for the decoded channel data"))?; + out.try_reserve_exact(total).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?; out.extend_from_slice(&data[..total]); Ok(out) } @@ -228,23 +228,18 @@ pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout /// Bytes a PSD RLE stream (two-byte row counts) of `rows` rows grows by when written as PSB /// (four-byte row counts); an error when `data` is too short to hold the count table. pub(crate) fn rle_widening(data: &[u8], rows: usize) -> Result { - let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))?; - if data.len() < table { - return Err(PsdError::UnexpectedEof { offset: data.len(), needed: table - data.len() }); - } - Ok(table as u64) + rle_widening_len(data.len() as u64, rows) } -/// Writes a PSD RLE stream (two-byte row counts) as the PSB one (four-byte row counts): the packed -/// rows are the same in both versions, only the count table widens. -pub(crate) fn put_widened_rle(out: &mut Vec, data: &[u8], rows: usize) -> Result<()> { - let table_len = usize::try_from(rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?; - let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?; - for count in table.as_chunks::<2>().0 { - out.extend_from_slice(&[0, 0, count[0], count[1]]); +/// [`rle_widening`] from the stream's length alone, so data still in its source (or staged on +/// the scratch disk) is not read to measure it. +pub(crate) fn rle_widening_len(len: u64, rows: usize) -> Result { + let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))? as u64; + if len < table { + let offset = usize::try_from(len).unwrap_or(usize::MAX); + return Err(PsdError::UnexpectedEof { offset, needed: usize::try_from(table - len).unwrap_or(usize::MAX) }); } - out.extend_from_slice(packed); - Ok(()) + Ok(table) } /// Encodes planar data (excluding the 2-byte compression field). @@ -311,7 +306,7 @@ thread_local! { // Keep only the requested pixels, but consume the trailer to verify Adler-32. // A bounded surplus tolerates padding without unbounded decompression work. fn inflate_checked(data: &[u8], expected: u64, keep: bool) -> Result> { - const OOM: PsdError = PsdError::LimitExceeded("not enough memory for the decoded channel data"); + const OOM: PsdError = PsdError::OutOfMemory("not enough memory for the decoded channel data"); #[cfg(test)] INFLATES.with(|n| n.set(n.get() + 1)); let mut decoder = flate2::Decompress::new(true); @@ -364,7 +359,7 @@ fn decode_rle(data: &[u8], layout: &PlaneLayout, total: usize) -> Result } // Up to MAX_DECODED_BYTES: report a machine without that much memory instead of aborting. let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| PsdError::LimitExceeded("not enough memory for the decoded channel data"))?; + out.try_reserve_exact(total).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?; for c in counts { let row = r.bytes(c)?; packbits::decode_into(row, rb, &mut out)?; @@ -379,62 +374,69 @@ fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { if rows == 0 { return Ok(Vec::new()); } - // Rows are independent, so they pack in parallel chunks and concatenate in order. + // Rows are independent, so they pack in parallel chunks of rows. Two passes: the first only + // measures each packed row, so the output is allocated once at its exact size and the second + // packs every row straight into its place. Collecting packed chunks and then copying them into + // the output held the encoded image twice, with growth slack, at the save's peak (#6607). let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) }; let chunk_rows = rows.div_ceil(threads).max(1); - struct Chunk { - counts: Vec, - packed: Vec, - } - let pack_chunk = |first: usize, count: usize| -> Result { - let mut chunk = Chunk { counts: vec![0u8; count * cs], packed: Vec::new() }; - for row in first..first + count { - let start = chunk.packed.len(); - packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut chunk.packed); - let n = chunk.packed.len() - start; - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - chunk.counts[(row - first) * 2..(row - first) * 2 + 2].copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - chunk.counts[(row - first) * 4..(row - first) * 4 + 4].copy_from_slice(&n.to_be_bytes()); - } + let row = |r: usize| &decoded[r * rb..(r + 1) * rb]; + let mut lens = vec![0usize; rows]; + for_each_chunk(threads, lens.chunks_mut(chunk_rows).enumerate().collect(), |(c, lens): (usize, &mut [usize])| { + let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); + for (i, len) in lens.iter_mut().enumerate() { + scratch.clear(); + packbits::encode(row(c * chunk_rows + i), &mut scratch); + *len = scratch.len(); + } + })?; + let mut out = vec![0u8; rows * cs + lens.iter().sum::()]; + let (counts, mut packed) = out.split_at_mut(rows * cs); + for (count, &n) in counts.chunks_exact_mut(cs).zip(&lens) { + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + count.copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + count.copy_from_slice(&n.to_be_bytes()); } - Ok(chunk) - }; - let chunk_list: Vec = if threads == 1 { - vec![pack_chunk(0, rows)?] - } else { - std::thread::scope(|scope| -> Result> { - let handles: Vec<_> = (0..threads) - .map(|t| { - let first = t * chunk_rows; - let count = chunk_rows.min(rows.saturating_sub(first)); - scope.spawn(move || pack_chunk(first, count)) - }) - .collect(); - // Join every worker first, then surface the first error like the sequential - // path would instead of aborting the scope with a thread panic. - let mut all = Vec::with_capacity(threads); - for h in handles { - all.push(h.join().map_err(|_| PsdError::LimitExceeded("an RLE worker panicked"))??); - } - Ok(all) - })? - }; - let packed_total: usize = chunk_list.iter().map(|c| c.packed.len()).sum(); - let mut out = vec![0u8; rows * cs + packed_total]; - let mut counts_at = 0usize; - let mut packed_at = rows * cs; - for chunk in &chunk_list { - out[counts_at..counts_at + chunk.counts.len()].copy_from_slice(&chunk.counts); - counts_at += chunk.counts.len(); - out[packed_at..packed_at + chunk.packed.len()].copy_from_slice(&chunk.packed); - packed_at += chunk.packed.len(); } + let mut parts = Vec::with_capacity(threads); + for (c, lens) in lens.chunks(chunk_rows).enumerate() { + let (part, rest) = packed.split_at_mut(lens.iter().sum()); + parts.push((c, lens, part)); + packed = rest; + } + for_each_chunk(threads, parts, |(c, lens, mut part): (usize, &[usize], &mut [u8])| { + let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); + for (i, &n) in lens.iter().enumerate() { + scratch.clear(); + packbits::encode(row(c * chunk_rows + i), &mut scratch); + let (dst, rest) = std::mem::take(&mut part).split_at_mut(n); + dst.copy_from_slice(&scratch); + part = rest; + } + })?; Ok(out) } +/// Runs `f` on every item, each on its own thread unless `threads` is 1 (wasm32). +fn for_each_chunk(threads: usize, items: Vec, f: impl Fn(T) + Sync) -> Result<()> { + if threads == 1 { + items.into_iter().for_each(f); + return Ok(()); + } + std::thread::scope(|scope| { + let handles: Vec<_> = items.into_iter().map(|item| scope.spawn(|| f(item))).collect(); + // Join every worker instead of aborting the scope with a thread panic. + let mut ok = true; + for h in handles { + ok &= h.join().is_ok(); + } + if ok { Ok(()) } else { Err(PsdError::LimitExceeded("an RLE worker panicked")) } + }) +} + /// zlib-compresses `data` at the default level. pub fn zip_compress(data: &[u8]) -> Vec { let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); @@ -450,7 +452,7 @@ pub fn zip_compress(data: &[u8]) -> Vec { /// bytes, so a stream declaring a huge size reports an error on a machine /// without that much memory instead of aborting. pub fn zip_decompress(data: &[u8], expected: usize) -> Result> { - const OOM: PsdError = PsdError::LimitExceeded("not enough memory for the decoded channel data"); + const OOM: PsdError = PsdError::OutOfMemory("not enough memory for the decoded channel data"); const CHUNK: usize = 1 << 20; let mut dec = flate2::read::ZlibDecoder::new(data).take(u64::try_from(expected).unwrap_or(u64::MAX)); let mut out = Vec::new(); @@ -612,9 +614,22 @@ mod parallel_rle_equivalence { let parallel = encode_rle(&data, &layout).expect("parallel encode"); let sequential = encode_rle_sequential(&data, &layout).expect("sequential encode"); assert_eq!(parallel, sequential, "rows {rows} rb {rb}"); + // #6607: measured first, then packed in place: one allocation, exactly the file's size. + assert_eq!(parallel.capacity(), parallel.len(), "rows {rows} rb {rb}: the output grew or was over-allocated"); } } + /// A row too long for a PSD's 16-bit RLE count is the same error from the two-pass encoder. + #[test] + fn rle_rows_past_sixteen_bits_are_refused_in_psd() { + let layout_for = |rows: usize, rb: usize, version: Version| PlaneLayout { planes: 1, width: rb, height: rows, depth: 8, version }; + let rb = 70_000; + let layout = layout_for(3, rb, Version::Psd); + let noise: Vec = (0..3 * rb).map(|i| (i * 31 % 251) as u8).collect(); + assert_eq!(encode_rle(&noise, &layout), Err(PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))); + assert_eq!(encode_rle(&noise, &layout_for(3, rb, Version::Psb)), encode_rle_sequential(&noise, &layout_for(3, rb, Version::Psb))); + } + /// The pre-parallel encoder, kept as the oracle. fn encode_rle_sequential(decoded: &[u8], layout: &PlaneLayout) -> Result> { let rows = layout.rows()?; diff --git a/crates/psd/src/error.rs b/crates/psd/src/error.rs index a1fc1a5c98..2e4f87d849 100644 --- a/crates/psd/src/error.rs +++ b/crates/psd/src/error.rs @@ -31,6 +31,10 @@ pub enum PsdError { /// A dimension, count or allocation exceeded the configured safety limits. #[error("limit exceeded: {0}")] LimitExceeded(&'static str), + /// A buffer could not be allocated: the system has no memory for it. Unlike + /// [`PsdError::LimitExceeded`] the data may be valid, so the caller can retry smaller. + #[error("out of memory: {0}")] + OutOfMemory(&'static str), /// Structurally invalid data. #[error("invalid data: {0}")] Invalid(String), @@ -49,6 +53,23 @@ impl PsdError { pub(crate) fn invalid(msg: impl Into) -> Self { PsdError::Invalid(msg.into()) } + + /// The error a streaming write ([`crate::PsdFile::write_to`]) failed with: the serializer's + /// own error when it was one, otherwise the I/O error's text. + pub(crate) fn from_io(e: std::io::Error) -> Self { + let text = e.to_string(); + match e.into_inner().map(|inner| inner.downcast::()) { + Some(Ok(e)) => *e, + _ => PsdError::Invalid(format!("write failed: {text}")), + } + } +} + +/// A serializer error inside a streaming write ([`crate::PsdFile::write_to`]), as `InvalidData`. +impl From for std::io::Error { + fn from(e: PsdError) -> Self { + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + } } /// Convenience alias. diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 828e7bc032..910d19563a 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -1,13 +1,15 @@ //! The top-level [`PsdFile`] model, parser and writer. -use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, put_widened_rle, rle_widening, validate_planes}; +use std::io::{Seek, Write}; + +use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening_len, validate_planes}; use crate::error::{PsdError, Result}; use crate::header::{ColorMode, Header, Version}; use crate::image_data::ImageData; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, Sink, WriteExt}; use crate::layer::{LayerInfo, LayerRecord}; use crate::resources::{self, ImageResource, ResourceData, ids}; -use crate::tagged::{TaggedBlock, read_blocks_with, uses_long_length, write_blocks}; +use crate::tagged::{TaggedBlock, read_blocks_with, uses_long_length, write_blocks_to}; /// Where the layer info was stored in the file. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -132,6 +134,17 @@ impl PsdFile { data: &[u8], want: impl FnOnce(&PsdFile) -> bool, recover: impl FnOnce(&PsdFile) -> bool, + ) -> Result<(Self, Option>>)> { + Self::from_bytes_and_merged_preflight(data, |file| Ok(want(file)), recover) + } + + /// Parses with a fallible pixel preflight after layer records are available and before + /// the merged image is read, validated or decoded. A preflight error is always fatal; + /// `recover` only handles damage encountered after an accepted preflight. + pub fn from_bytes_and_merged_preflight( + data: &[u8], + want: impl FnOnce(&PsdFile) -> Result, + recover: impl FnOnce(&PsdFile) -> bool, ) -> Result<(Self, Option>>)> { let mut r = Reader::new(data); let header = Header::read(&mut r)?; @@ -202,7 +215,7 @@ impl PsdFile { image_data, write_as_psb: false, }; - let want = want(&file); + let want = want(&file)?; let merged = file.read_merged_section(&mut r, want, recover)?; Ok((file, merged)) } @@ -227,13 +240,13 @@ impl PsdFile { let merged = match merged { Ok(merged) => merged, Err(error) => { - if matches!(error, PsdError::LimitExceeded(_)) || !recover(file) { + if matches!(error, PsdError::LimitExceeded(_) | PsdError::OutOfMemory(_)) || !recover(file) { return Err(error); } let compression = Compression::from_u16(encoded.u16()?); let source = encoded.peek_rest(); let mut data = Vec::new(); - data.try_reserve_exact(source.len()).map_err(|_| PsdError::LimitExceeded("not enough memory to retain the damaged merged image"))?; + data.try_reserve_exact(source.len()).map_err(|_| PsdError::OutOfMemory("not enough memory to retain the damaged merged image"))?; data.extend_from_slice(source); file.image_data = ImageData::new(compression, data); Some(Err(error)) @@ -259,37 +272,66 @@ impl PsdFile { r.u16()?; let data = r.peek_rest(); let decoded = decode_planes(compression, data, &layout); + // An allocation failure is the open's, before the encoded image is copied under the same + // memory pressure. + if matches!(decoded, Err(PsdError::OutOfMemory(_))) { + return decoded.map(Ok); + } if decoded.is_err() { validate_planes(compression, data, &layout)?; } - self.image_data = ImageData::new(compression, data.to_vec()); + let mut copy = Vec::new(); + copy.try_reserve_exact(data.len()).map_err(|_| PsdError::OutOfMemory("not enough memory to retain the merged image"))?; + copy.extend_from_slice(data); + self.image_data = ImageData::new(compression, copy); r.skip(data.len())?; Ok(decoded) } /// Serializes the file. pub fn to_bytes(&self) -> Result> { + // Reserve the whole file up front, at its exact length: appending to an empty Vec + // reallocates and copies repeatedly, and a reserve that is a few bytes short makes the + // last append double the buffer, copying the whole file once more (#6607). + let len = usize::try_from(self.encoded_len()?).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; + self.write_to(&mut std::io::Cursor::new(&mut out)).map_err(PsdError::from_io)?; + Ok(out) + } + + /// Serializes the file into `w` as it goes, starting at its current position: the bytes of + /// [`Self::to_bytes`] without ever holding them all (#6607). Length fields are back-patched + /// by seeking. A serializer error (a section too large for PSD, too many layers) comes back + /// as `InvalidData` carrying the [`PsdError`]; `w` then holds a partial file. + pub fn write_to(&self, w: &mut W) -> std::io::Result<()> { + let mut out = Sink::new(w)?; + self.write_staging(&mut out)?; + out.finish() + } + + /// [`Self::write_to`] writing out every `stage` staged bytes (tests: tiny stages make every + /// length field a back-patch through the stream). + #[cfg(test)] + pub(crate) fn write_to_staging(&self, w: &mut W, stage: usize) -> std::io::Result<()> { + let mut out = Sink::staging(w, stage)?; + self.write_staging(&mut out)?; + out.finish() + } + + fn write_staging(&self, out: &mut Sink<'_, W>) -> std::io::Result<()> { let (v, widen) = self.write_form(); let psb = v.is_psb(); - // Reserve the pixel data up front: appending it to an empty Vec reallocates and copies - // repeatedly. Everything else (headers, resources, records) is small by comparison. - let widened = if widen { self.rle_widening()? } else { 0 }; - let hint = self - .layer_info - .as_ref() - .map_or(0, LayerInfo::channel_data_len) - .saturating_add(usize::try_from(self.image_data.encoded_len()).unwrap_or(usize::MAX)) - .saturating_add(usize::try_from(widened).unwrap_or(usize::MAX)); - let mut out = Vec::new(); - out.try_reserve(hint).map_err(|_| PsdError::LimitExceeded("not enough memory to serialize the file"))?; - if widen { - Header { version: v, ..self.header.clone() }.write(&mut out); - } else { - self.header.write(&mut out); - } - out.put_len(self.color_mode_data.len() as u64, false)?; - out.put(&self.color_mode_data); - resources::write_section(&mut out, &self.resources)?; + out.small(|o| { + if widen { + Header { version: v, ..self.header.clone() }.write(o); + } else { + self.header.write(o); + } + o.put_len(self.color_mode_data.len() as u64, false) + })?; + out.put(&self.color_mode_data)?; + out.small(|o| resources::write_section(o, &self.resources))?; let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); // An entirely empty section is written as a zero length. (A section @@ -297,51 +339,53 @@ impl PsdFile { // the model and is normalized to this form.) let section_empty = self.layer_info.is_none() && self.global_layer_mask.is_none() && self.global_blocks.is_empty() && self.layer_mask_trailing.is_empty(); - let at = out.begin_len(psb); + let at = out.begin_len(psb)?; if !section_empty { - let li = out.begin_len(psb); + let li = out.begin_len(psb)?; if let (Some(info), false) = (&self.layer_info, in_block) { - info.write_body_as(&mut out, v, widen)?; + info.write_body_to(out, v, widen)?; } out.end_len(li, psb)?; let need_mask_field = self.global_layer_mask.is_some() || !self.global_blocks.is_empty() || (in_block && self.layer_info.is_some()); if need_mask_field { let gm = self.global_layer_mask.as_ref().map_or(&[][..], |g| &g.data[..]); - out.put_len(gm.len() as u64, false)?; - out.put(gm); + out.small(|o| o.put_len(gm.len() as u64, false))?; + out.put(gm)?; } match (&self.layer_info, &self.layer_info_placement) { (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) => { let (before, after) = self.global_blocks.split_at((*index).min(self.global_blocks.len())); - write_blocks(&mut out, before, v, 4)?; - // Write into the final buffer: a temporary LayerInfo payload and a clone - // of all neighboring opaque blocks otherwise double the save's memory. - out.put(signature); - out.put(key); + write_blocks_to(out, before, v, 4)?; + // Written in place: a temporary LayerInfo payload and a clone of all + // neighboring opaque blocks otherwise double the save's memory. + out.put(signature)?; + out.put(key)?; let long = uses_long_length(v, key); - let at = out.begin_len(long); - let start = out.len(); - info.write_body_as(&mut out, v, widen)?; - let len = out.len() - start; + let at = out.begin_len(long)?; + let start = out.pos(); + info.write_body_to(out, v, widen)?; + let len = out.pos() - start; out.end_len(at, long)?; match padding { - Some(bytes) => out.put(bytes), - None => out.extend(std::iter::repeat_n(0u8, (4 - len % 4) % 4)), + Some(bytes) => out.put(bytes)?, + None => out.zeros(((4 - len % 4) % 4) as usize)?, } - write_blocks(&mut out, after, v, 4)?; + write_blocks_to(out, after, v, 4)?; } - _ => write_blocks(&mut out, &self.global_blocks, v, 4)?, + _ => write_blocks_to(out, &self.global_blocks, v, 4)?, } - out.put(&self.layer_mask_trailing); + out.put(&self.layer_mask_trailing)?; } out.end_len(at, psb)?; - out.put_u16(self.image_data.compression.as_u16()); + out.small(|o| { + o.put_u16(self.image_data.compression.as_u16()); + Ok(()) + })?; match self.merged_widened_rows(widen)? { - 0 => out.put(&self.image_data.encoded()?), - rows => put_widened_rle(&mut out, &self.image_data.encoded()?, rows)?, + 0 => out.put_encoded(&self.image_data.data, self.image_data.stored.as_ref()), + rows => out.put_widened_encoded(&self.image_data.data, self.image_data.stored.as_ref(), rows), } - Ok(out) } /// The version the file is written as, and whether its RLE count tables widen on the way @@ -370,13 +414,13 @@ impl PsdFile { for c in &layer.channels { match layer.widened_rows(c, true)? { 0 => {} - rows => n = n.saturating_add(rle_widening(&c.encoded()?, rows)?), + rows => n = n.saturating_add(rle_widening_len(c.encoded_len(), rows)?), } } } match self.merged_widened_rows(true)? { 0 => Ok(n), - rows => Ok(n.saturating_add(rle_widening(&self.image_data.encoded()?, rows)?)), + rows => Ok(n.saturating_add(rle_widening_len(self.image_data.encoded_len(), rows)?)), } } @@ -419,7 +463,7 @@ impl PsdFile { } let merged = match self.merged_widened_rows(widen)? { 0 => 0, - rows => rle_widening(&self.image_data.encoded()?, rows)?, + rows => rle_widening_len(self.image_data.encoded_len(), rows)?, }; Ok(n.saturating_add(2).saturating_add(self.image_data.encoded_len()).saturating_add(merged)) } @@ -590,6 +634,200 @@ impl PsdFile { mod write_tests { use super::*; use crate::{Compression, testgen}; + use std::io::{Seek, Write}; + + /// #6607: the output is allocated once, at its exact length. A reserve a few bytes short of + /// the file made the last append double the buffer, copying the whole file once more. + #[test] + fn serializing_allocates_the_output_once() { + for case in testgen::all_cases() { + let bytes = case.file.to_bytes().unwrap(); + assert_eq!(bytes.capacity(), bytes.len(), "{}: the buffer grew past its first allocation", case.name); + } + } + + /// A stream that records the length of every write it gets. + struct Recorder { + inner: std::io::Cursor>, + writes: Vec, + } + + impl Write for Recorder { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.writes.push(b.len()); + self.inner.write(b) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl Seek for Recorder { + fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result { + self.inner.seek(to) + } + } + + /// The bytes `file` streams with `stage`-byte staging, after a 3-byte prefix that must stay. + fn streamed(file: &PsdFile, stage: usize) -> Vec { + let mut w = std::io::Cursor::new(b"pre".to_vec()); + w.set_position(3); + file.write_to_staging(&mut w, stage).unwrap(); + let end = w.position(); + let mut bytes = w.into_inner(); + assert_eq!(&bytes[..3], b"pre"); + assert_eq!(end, bytes.len() as u64, "the stream must end at the file's end"); + bytes.drain(..3); + bytes + } + + /// #6607: streaming into a file writes exactly the bytes of `to_bytes`, wherever the stream + /// starts and however little is staged, so every length field is back-patched through the + /// stream (both placements of the layer info, PSD and PSB, every mode and depth). + #[test] + fn streaming_writes_the_same_bytes_with_lengths_patched_by_seeking() { + for case in testgen::all_cases() { + let want = case.file.to_bytes().unwrap(); + for stage in [0, 1, 7, 4096] { + assert!(streamed(&case.file, stage) == want, "{} stage {stage}: streamed bytes differ", case.name); + } + let mut w = std::io::Cursor::new(Vec::new()); + case.file.write_to(&mut w).unwrap(); + assert!(w.into_inner() == want, "{}: write_to differs from to_bytes", case.name); + } + } + + /// A PSD model written as PSB (its RLE count tables widened on the way out) and a model read + /// lazily from a source (its pixel data not in memory) stream the bytes `to_bytes` writes. + #[test] + fn promoted_and_source_backed_files_stream_the_same_bytes() { + for case in testgen::all_cases() { + let mut promoted = case.file.clone(); + if promoted.header.version == Version::Psd { + promoted.promote_to_psb().unwrap(); + let want = promoted.to_bytes().unwrap(); + for stage in [0, 1, 7, 4096] { + assert!(streamed(&promoted, stage) == want, "{} promoted, stage {stage}", case.name); + } + } + let bytes = case.file.to_bytes().unwrap(); + let lazy = PsdFile::from_source(crate::Source::new(bytes.clone())).unwrap(); + for stage in [0, 1, 4096] { + assert!(streamed(&lazy, stage) == bytes, "{} source-backed, stage {stage}", case.name); + } + } + } + + /// Channel data and a merged image still in their source (an imported file's, or what an + /// exporter spilled to scratch) are copied through in short reads, never read whole. + #[test] + fn source_backed_data_is_copied_in_short_reads() { + struct Reads(Vec, std::sync::Mutex); + impl crate::source::ReadAt for Reads { + fn len(&self) -> u64 { + self.0.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { + let mut most = self.1.lock().unwrap(); + *most = (*most).max(buf.len()); + self.0.as_slice().read_at(offset, buf) + } + } + let mut file = testgen::small(Version::Psd, Compression::Raw); + file.image_data = ImageData::new(Compression::Raw, vec![5; 300]); + let info = file.layer_info.as_mut().unwrap(); + info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), vec![6; 301]); + let bytes = file.to_bytes().unwrap(); + let source = std::sync::Arc::new(Reads(bytes.clone(), std::sync::Mutex::new(0))); + let lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap(); + *source.1.lock().unwrap() = 0; + let mut w = std::io::Cursor::new(Vec::new()); + lazy.write_to(&mut w).unwrap(); + assert!(w.into_inner() == bytes); + assert!(*source.1.lock().unwrap() <= 7, "read {} bytes at once", source.1.lock().unwrap()); + } + + /// #5333: RLE data still in its source widens to PSB in short reads too, the count table and + /// then the packed rows, one consecutive pass, never the whole merged image or channel at once. + #[test] + fn source_backed_rle_widens_in_short_reads() { + struct Reads(Vec, std::sync::Mutex<(usize, Vec<(u64, usize)>)>); + impl crate::source::ReadAt for Reads { + fn len(&self) -> u64 { + self.0.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> { + let mut seen = self.1.lock().unwrap(); + seen.0 = seen.0.max(buf.len()); + seen.1.push((offset, buf.len())); + self.0.as_slice().read_at(offset, buf) + } + } + let file = testgen::small(Version::Psd, Compression::Rle); + assert_eq!(file.image_data.compression, Compression::Rle); + let bytes = file.to_bytes().unwrap(); + let mut promoted = file.clone(); + promoted.promote_to_psb().unwrap(); + let want = promoted.to_bytes().unwrap(); + let source = std::sync::Arc::new(Reads(bytes, std::sync::Mutex::new((0, Vec::new())))); + let mut lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap(); + assert!(lazy.image_data.stored.is_some(), "the merged image stays in the source"); + lazy.promote_to_psb().unwrap(); + *source.1.lock().unwrap() = (0, Vec::new()); + let mut w = std::io::Cursor::new(Vec::new()); + lazy.write_to(&mut w).unwrap(); + assert!(w.into_inner() == want); + let seen = source.1.lock().unwrap(); + assert!(seen.0 <= 7, "read {} bytes at once", seen.0); + let merged = lazy.image_data.stored.as_ref().unwrap(); + let reads: Vec<_> = seen.1.iter().filter(|(at, _)| (merged.offset..merged.offset + merged.len).contains(at)).collect(); + let mut next = merged.offset; + for &&(at, n) in &reads { + assert_eq!(at, next, "the merged image is read in one consecutive pass"); + next += n as u64; + } + assert_eq!(next, merged.offset + merged.len); + } + + /// #6607: pixel data goes to the stream as it is, never through a staging copy, so a save + /// holds the model and a short buffer, not a second copy of the file. + #[test] + fn streaming_passes_channel_and_merged_data_straight_through() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let big = 3 * crate::io::STAGE_FOR_TESTS; + file.image_data = ImageData::new(Compression::Raw, vec![5; big]); + let info = file.layer_info.as_mut().unwrap(); + info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), vec![6; big + 1]); + let mut w = Recorder { inner: std::io::Cursor::new(Vec::new()), writes: Vec::new() }; + file.write_to(&mut w).unwrap(); + assert!(w.writes.contains(&big), "merged image data was staged: {:?}", w.writes); + assert!(w.writes.contains(&(big + 1)), "channel data was staged: {:?}", w.writes); + assert!(w.writes.iter().filter(|&&n| n != big && n != big + 1).all(|&n| n < crate::io::STAGE_FOR_TESTS * 2), "{:?}", w.writes); + let bytes = w.inner.into_inner(); + assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), file); + assert!(bytes == file.to_bytes().unwrap()); + } + + /// A serializer error in the middle of a stream comes back as the same `PsdError` `to_bytes` + /// reports, inside an `InvalidData` I/O error. + #[test] + fn streaming_reports_serializer_errors() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let info = file.layer_info.as_mut().unwrap(); + let layer = info.layers[0].clone(); + info.layers = vec![layer; 32768]; + let want = file.to_bytes().unwrap_err(); + let e = file.write_to(&mut std::io::Cursor::new(Vec::new())).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(PsdError::from_io(e), want); + // A damaged count table of a promoted file fails the stream as it fails `to_bytes`. + let mut file = testgen::small(Version::Psd, Compression::Rle); + file.promote_to_psb().unwrap(); + file.image_data.data.truncate(3); + let want = file.to_bytes().unwrap_err(); + let e = file.write_to(&mut std::io::Cursor::new(Vec::new())).unwrap_err(); + assert_eq!(PsdError::from_io(e), want); + } /// `encoded_len` is exactly what `to_bytes` writes, for every generated file and both layer /// info placements, with and without padding (#5285). @@ -848,4 +1086,22 @@ mod write_tests { assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); } } + #[test] + fn preflight_refusal_precedes_merged_validation_and_cannot_be_recovered() { + let file = testgen::small(Version::Psd, Compression::Zip); + let mut bytes = file.to_bytes().unwrap(); + bytes.truncate(bytes.len() - file.image_data.data.len() - 2); + let recover_called = std::cell::Cell::new(false); + let preflight = |_: &PsdFile| Err(PsdError::LimitExceeded("test import budget")); + let recover = |_: &PsdFile| { + recover_called.set(true); + true + }; + assert!(matches!(PsdFile::from_bytes_and_merged_preflight(&bytes, preflight, recover), Err(PsdError::LimitExceeded("test import budget")))); + assert!(matches!( + PsdFile::from_source_and_merged_preflight(crate::Source::new(bytes), preflight, recover), + Err(PsdError::LimitExceeded("test import budget")) + )); + assert!(!recover_called.get()); + } } diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs index a7c7e3d8ee..8537b74649 100644 --- a/crates/psd/src/io.rs +++ b/crates/psd/src/io.rs @@ -1,5 +1,7 @@ //! Bounds-checked big-endian reader and writer helpers. +use std::io::{Seek, SeekFrom, Write}; + use crate::error::{PsdError, Result}; /// A name as UTF-16 code units, capped at `max` **without ever splitting a surrogate pair**. @@ -209,6 +211,182 @@ impl WriteExt for Vec { } } +/// Staged bytes [`Sink`] collects before writing them out in one call. +const STAGE: usize = 64 << 10; +/// Bytes of stored channel data read per piece by [`Sink::put_encoded`]. +const COPY_CHUNK: u64 = if cfg!(test) { 7 } else { 8 << 20 }; +#[cfg(test)] +pub(crate) const STAGE_FOR_TESTS: usize = STAGE; + +/// Writes the file to a seekable stream as it is serialized (#6607): the small parts (headers, +/// records, lengths) are staged in a short buffer, while pixel data and other long slices go +/// straight to the stream, so the whole file never sits in memory. A length placeholder +/// ([`Sink::begin_len`]) that was already written out is back-patched by seeking. The bytes are +/// exactly the ones the `Vec` writers ([`WriteExt`]) produce. +pub(crate) struct Sink<'w, W: Write + Seek + ?Sized> { + w: &'w mut W, + /// Bytes not yet written; they start at stream position `base`. + buf: Vec, + base: u64, + /// Staged bytes that trigger a write ([`STAGE`]; tests use less). + stage: usize, +} + +impl<'w, W: Write + Seek + ?Sized> Sink<'w, W> { + /// A sink writing at `w`'s current position. + pub(crate) fn new(w: &'w mut W) -> std::io::Result { + Self::staging(w, STAGE) + } + + /// [`Self::new`] writing out once `stage` bytes are staged. + pub(crate) fn staging(w: &'w mut W, stage: usize) -> std::io::Result { + let base = w.stream_position()?; + Ok(Sink { w, buf: Vec::new(), base, stage }) + } + + /// The stream position of the next byte. + pub(crate) fn pos(&self) -> u64 { + self.base + self.buf.len() as u64 + } + + /// Appends a small part built by `f` with the `Vec` writers. Lengths `f` back-patches stay + /// inside what it appends. + pub(crate) fn small(&mut self, f: impl FnOnce(&mut Vec) -> Result<()>) -> std::io::Result<()> { + f(&mut self.buf)?; + if self.buf.len() >= self.stage { self.flush() } else { Ok(()) } + } + + /// Appends `b`, straight to the stream when it is long. + pub(crate) fn put(&mut self, b: &[u8]) -> std::io::Result<()> { + if self.buf.len() + b.len() < self.stage { + self.buf.extend_from_slice(b); + return Ok(()); + } + self.flush()?; + self.w.write_all(b)?; + self.base += b.len() as u64; + Ok(()) + } + + /// Appends RLE channel data whose 2-byte row-count table widens to 4 bytes (a PSD model + /// written as PSB): the table is widened in staged pieces, the packed rows go straight + /// through: each two-byte count becomes `00 00` and the count, the packed rows are unchanged. + pub(crate) fn put_widened_rle(&mut self, data: &[u8], rows: usize) -> std::io::Result<()> { + let table_len = usize::try_from(crate::compression::rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?; + let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?; + for part in table.chunks(STAGE / 2) { + self.widen_counts(part)?; + } + self.put(packed) + } + + /// [`Self::put_widened_rle`] for data held in memory (`data`) or, when `stored`, still in its + /// source: the count table and then the packed rows are read in [`COPY_CHUNK`]-sized parts + /// (#5333), so a staged merged image or channel is never read back whole to widen it. + pub(crate) fn put_widened_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>, rows: usize) -> std::io::Result<()> { + let Some(s) = stored else { return self.put_widened_rle(data, rows) }; + let table_len = crate::compression::rle_widening_len(s.len, rows)?; + // Whole counts per part; the parts follow each other, so a staged range is read in one pass. + let table_part = (COPY_CHUNK / 2).max(1) * 2; + let mut at = 0u64; + while at < s.len { + let n = if at < table_len { (table_len - at).min(table_part) } else { (s.len - at).min(COPY_CHUNK) }; + let part = s.read_part(at, n)?; + if at < table_len { + self.widen_counts(&part)? + } else { + self.put(&part)? + } + at += n; + } + Ok(()) + } + + /// Two-byte RLE row counts as four-byte ones (`00 00` and the count), staged. + fn widen_counts(&mut self, counts: &[u8]) -> std::io::Result<()> { + self.small(|o| { + for count in counts.as_chunks::<2>().0 { + o.extend_from_slice(&[0, 0, count[0], count[1]]); + } + Ok(()) + }) + } + + /// Appends encoded bytes held in memory (`data`) or, when `stored`, still in their source: + /// those are copied through in [`COPY_CHUNK`] pieces, so a channel or merged image kept on + /// disk (an imported file's, or one an exporter spilled to scratch) is never read whole. + pub(crate) fn put_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>) -> std::io::Result<()> { + let Some(s) = stored else { return self.put(data) }; + let mut at = 0u64; + while at < s.len { + let n = (s.len - at).min(COPY_CHUNK); + let part = s.read_part(at, n)?; + self.put(&part)?; + at += n; + } + Ok(()) + } + + /// `n` zero bytes (padding, at most a few). + pub(crate) fn zeros(&mut self, n: usize) -> std::io::Result<()> { + self.small(|b| { + b.extend(std::iter::repeat_n(0u8, n)); + Ok(()) + }) + } + + /// Reserves a length placeholder (4 or 8 bytes), returning its stream position. + pub(crate) fn begin_len(&mut self, long: bool) -> std::io::Result { + let at = self.pos(); + self.zeros(if long { 8 } else { 4 })?; + Ok(at) + } + + /// Back-patches the placeholder at `at` with the number of bytes written since. + pub(crate) fn end_len(&mut self, at: u64, long: bool) -> std::io::Result<()> { + let hdr = if long { 8 } else { 4 }; + let n = self.pos() - at - hdr; + let bytes = if long { + n.to_be_bytes().to_vec() + } else { + u32::try_from(n).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?.to_be_bytes().to_vec() + }; + if at >= self.base { + let i = (at - self.base) as usize; + self.buf[i..i + bytes.len()].copy_from_slice(&bytes); + return Ok(()); + } + self.flush()?; + self.w.seek(SeekFrom::Start(at))?; + self.w.write_all(&bytes)?; + self.w.seek(SeekFrom::Start(self.base))?; + Ok(()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.w.write_all(&self.buf)?; + self.base += self.buf.len() as u64; + self.buf.clear(); + Ok(()) + } + + /// Writes out what is still staged. Dropping a sink without it loses those bytes. + pub(crate) fn finish(mut self) -> std::io::Result<()> { + self.flush()?; + self.w.flush() + } +} + +/// Runs a streaming writer on the end of `out`, the `Vec` writers' way of sharing it. +pub(crate) fn append_with(out: &mut Vec, f: impl FnOnce(&mut Sink<'_, std::io::Cursor<&mut Vec>>) -> std::io::Result<()>) -> Result<()> { + let at = out.len() as u64; + let mut c = std::io::Cursor::new(out); + c.set_position(at); + let mut s = Sink::new(&mut c).map_err(PsdError::from_io)?; + f(&mut s).map_err(PsdError::from_io)?; + s.finish().map_err(PsdError::from_io) +} + /// Reads a Pascal string (length byte + bytes) whose total size is padded to /// a multiple of `align`. Returns the raw name bytes. pub(crate) fn read_pascal(r: &mut Reader<'_>, align: usize) -> Result> { diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index cbc4ccb424..90f6550863 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -3,10 +3,10 @@ use std::borrow::Cow; use crate::blend::BlendMode; -use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, put_widened_rle, rle_widening}; +use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, rle_widening_len}; use crate::error::{PsdError, Result}; use crate::header::{MAX_DIMENSION, Version}; -use crate::io::{Reader, WriteExt, decode_legacy_name, read_pascal, write_pascal}; +use crate::io::{Reader, Sink, WriteExt, append_with, decode_legacy_name, read_pascal, write_pascal}; use crate::source::Stored; use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, uses_long_length, write_blocks}; @@ -692,7 +692,7 @@ impl LayerRecord { for c in &self.channels { out.put_i16(c.id); let rows = self.widened_rows(c, widen)?; - let grown = if rows == 0 { 0 } else { rle_widening(&c.encoded()?, rows)? }; + let grown = if rows == 0 { 0 } else { rle_widening_len(c.encoded_len(), rows)? }; out.put_len(c.stored_len().saturating_add(grown), version.is_psb())?; } out.put(b"8BIM"); @@ -783,7 +783,7 @@ impl LayerInfo { for l in &self.layers { for c in l.channels.iter().filter(|c| c.compression.is_some()) { let rows = l.widened_rows(c, widen)?; - let grown = if rows == 0 { 0 } else { rle_widening(&c.encoded()?, rows)? }; + let grown = if rows == 0 { 0 } else { rle_widening_len(c.encoded_len(), rows)? }; n = n.saturating_add(2).saturating_add(c.encoded_len()).saturating_add(grown); } } @@ -813,6 +813,8 @@ impl LayerInfo { /// Bytes of channel data and their compression fields: the part of the body worth reserving up /// front (the records are small next to the pixels). Saturates instead of overflowing. + /// (Saves reserve their exact length now, #6607; the tests still check it as a lower bound.) + #[cfg(test)] pub(crate) fn channel_data_len(&self) -> usize { self.layers .iter() @@ -823,24 +825,42 @@ impl LayerInfo { /// Serializes the body without any trailing padding (the padding field is ignored). pub(crate) fn write_unpadded(&self, out: &mut Vec, version: Version) -> Result<()> { - self.write_unpadded_as(out, version, false) + append_with(out, |s| self.write_unpadded_to(s, version, false)) } - /// [`Self::write_unpadded`], widening the RLE count tables of a PSD model written as PSB - /// (`widen`) on the way out. - fn write_unpadded_as(&self, out: &mut Vec, version: Version, widen: bool) -> Result<()> { + /// Serializes the body (without a length field). + #[cfg(test)] + pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { + append_with(out, |s| self.write_body_to(s, version, false)) + } + + /// [`Self::write_unpadded`] to a streaming [`Sink`], widening the RLE count tables of a PSD + /// model written as PSB (`widen`) on the way out: the records are staged, the channel data + /// goes straight through (#6607). + pub(crate) fn write_unpadded_to( + &self, + out: &mut Sink<'_, W>, + version: Version, + widen: bool, + ) -> std::io::Result<()> { let n = i16::try_from(self.layers.len()).map_err(|_| PsdError::LimitExceeded("more than 32767 layers"))?; - out.put_i16(if self.merged_alpha { -n } else { n }); + out.small(|o| { + o.put_i16(if self.merged_alpha { -n } else { n }); + Ok(()) + })?; for l in &self.layers { - l.write(out, version, widen)?; + out.small(|o| l.write(o, version, widen))?; } for l in &self.layers { for c in &l.channels { if let Some(comp) = c.compression { - out.put_u16(comp.as_u16()); + out.small(|o| { + o.put_u16(comp.as_u16()); + Ok(()) + })?; match l.widened_rows(c, widen)? { - 0 => out.put(&c.encoded()?), - rows => put_widened_rle(out, &c.encoded()?, rows)?, + 0 => out.put_encoded(&c.data, c.stored.as_ref())?, + rows => out.put_widened_encoded(&c.data, c.stored.as_ref(), rows)?, } } } @@ -848,25 +868,20 @@ impl LayerInfo { Ok(()) } - /// Serializes the body (without a length field). - #[cfg(test)] - pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { - self.write_body_as(out, version, false) - } - - /// [`Self::write_body`], widening RLE count tables when `widen` (see [`Self::write_unpadded_as`]). - pub(crate) fn write_body_as(&self, out: &mut Vec, version: Version, widen: bool) -> Result<()> { - let start = out.len(); - self.write_unpadded_as(out, version, widen)?; + /// The body (without a length field) to a streaming [`Sink`], widening RLE count tables when + /// `widen` (see [`Self::write_unpadded_to`]). + pub(crate) fn write_body_to( + &self, + out: &mut Sink<'_, W>, + version: Version, + widen: bool, + ) -> std::io::Result<()> { + let start = out.pos(); + self.write_unpadded_to(out, version, widen)?; match &self.padding { Some(p) => out.put(p), - None => { - if (out.len() - start) % 2 == 1 { - out.put_u8(0); - } - } + None => out.zeros(((out.pos() - start) % 2) as usize), } - Ok(()) } } diff --git a/crates/psd/src/lazy.rs b/crates/psd/src/lazy.rs index d81d102d11..f78fa90068 100644 --- a/crates/psd/src/lazy.rs +++ b/crates/psd/src/lazy.rs @@ -131,6 +131,17 @@ impl PsdFile { source: Source, want: impl FnOnce(&PsdFile) -> bool, recover: impl FnOnce(&PsdFile) -> bool, + ) -> Result<(Self, Option>>)> { + Self::from_source_and_merged_preflight(source, |file| Ok(want(file)), recover) + } + + /// Parses with a fallible pixel preflight after layer records are available and before + /// the merged image is read, validated or decoded. A preflight error is always fatal; + /// `recover` only handles damage encountered after an accepted preflight. + pub fn from_source_and_merged_preflight( + source: Source, + want: impl FnOnce(&PsdFile) -> Result, + recover: impl FnOnce(&PsdFile) -> bool, ) -> Result<(Self, Option>>)> { let mut c = Cursor::new(&source); let header = Header::read(&mut Reader::new(&c.bytes(26)?))?; @@ -217,7 +228,7 @@ impl PsdFile { image_data: ImageData::new(Compression::Raw, Vec::new()), write_as_psb: false, }; - if !want(&file) { + if !want(&file)? { let compression = Compression::from_u16(c.u16()?); file.image_data = ImageData { compression, data: Vec::new(), stored: Some(c.stored_rest()) }; return Ok((file, None)); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index f5ffbed8b7..3050994216 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -48,6 +48,7 @@ pub mod asl; pub mod atn; pub mod blend; pub mod builder; +mod codepage; pub mod compression; pub mod descriptor; pub mod error; diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index af4afea0a2..fb604f0c02 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -192,7 +192,28 @@ fn read_vma_body<'a>(r: &mut Rd<'a>) -> Result<&'a [u8]> { r.take(len) } +/// Pixel storage needed by a pattern, inspected without decoding channel data. +#[derive(Debug, Clone)] +pub struct PatternMemory { + /// Pattern width in pixels. + pub width: u32, + /// Pattern height in pixels. + pub height: u32, + /// Bits per sample, including packed bitmap samples. + pub depth: u16, + /// PSD colour mode number. + pub mode: u32, + /// All written planes, including any ignored extra arrays. + pub planes: usize, + /// Sum of full padded channel plane sizes. + pub decoded_bytes: u64, +} + fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { + read_pattern_with(r, budget, true, &mut |_| {}) +} + +fn read_pattern_with(r: &mut Rd, budget: &mut PatternBudget, decode: bool, visit: &mut dyn FnMut(PatternMemory)) -> Result { let PatternHeader { mode, name, id, palette } = read_header(r)?; let body = read_vma_body(r)?; let mut v = Rd { b: body, p: 0 }; @@ -289,8 +310,18 @@ fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { if channels.len() < nc { return Err(PsdError::invalid("pattern has fewer channels than its colour mode")); } + visit(PatternMemory { + width: w, + height: h, + depth: first_depth.unwrap_or(8), + mode, + planes: channels.len(), + decoded_bytes: channels + .iter() + .try_fold(0u64, |sum, c| sum.checked_add(c.destination.total_bytes()?).ok_or(PsdError::LimitExceeded("pattern memory size overflow")))?, + }); let mut planes = Vec::new(); - for channel in channels { + for channel in channels.into_iter().filter(|_| decode) { let plane = decode_planes(channel.compression, channel.data, &channel.source)?; planes.push(place_channel_plane(plane, &channel.source, channel.x, channel.y, &channel.destination)?); } @@ -377,7 +408,7 @@ fn place_channel_plane(mut plane: Vec, source: &PlaneLayout, x: usize, y: us fn zeroed_pattern_plane(len: usize) -> Result> { let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for the padded pattern channel"))?; + out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for the padded pattern channel"))?; out.resize(len, 0); Ok(out) } @@ -472,6 +503,28 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu Ok(out) } +/// Inspect a pattern block's dimensions and decoded sizes without allocating pixel planes. +/// Uses the pixel parser’s structural validation and shared decode limits. Returns +/// earlier records even if a later record is malformed, since those can decode first. +pub fn pattern_block_memory(data: &[u8], budget: &mut PatternBudget) -> Vec { + let mut r = Rd { b: data, p: 0 }; + let mut out = Vec::new(); + while r.p + 4 <= data.len() { + let Ok(len) = r.u32() else { break }; + let len = len as usize; + if len == 0 { + break; + } + let Ok(body) = r.take(len) else { break }; + // Earlier patterns may already have decoded when a later record is malformed. + if read_pattern_with(&mut Rd { b: body, p: 0 }, budget, false, &mut |m| out.push(m)).is_err() { + break; + } + r.p = (r.p + (4 - len % 4) % 4).min(data.len()); + } + out +} + /// Whether `Patt` / `Pat2` / `Pat3` block data keeps a pattern's transparency in the user mask /// array, as PhotoCraft wrote it before #8054. Photoshop refuses such a file, so a save must /// rewrite the block rather than keep it. Unreadable data counts as `false`. diff --git a/crates/psd/src/patterns/tests.rs b/crates/psd/src/patterns/tests.rs index c66c8189d4..e66d583738 100644 --- a/crates/psd/src/patterns/tests.rs +++ b/crates/psd/src/patterns/tests.rs @@ -267,7 +267,7 @@ fn large_channel_padding_within_the_existing_budget_is_allowed() { #[test] fn padded_plane_allocation_failure_is_an_error() { // Capacity overflow is deterministic and never asks the OS for huge memory. - assert!(matches!(zeroed_pattern_plane(usize::MAX), Err(PsdError::LimitExceeded("not enough memory for the padded pattern channel")))); + assert!(matches!(zeroed_pattern_plane(usize::MAX), Err(PsdError::OutOfMemory("not enough memory for the padded pattern channel")))); } #[test] diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 47828c82d5..04f7c28ae8 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -34,7 +34,7 @@ pub fn f32_to_bytes(samples: &[f32]) -> Vec { /// Allocates `len` bytes, reporting a machine without that much memory instead of aborting. fn try_buffer(len: usize) -> Result> { let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for the converted plane"))?; + out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for the converted plane"))?; Ok(out) } @@ -128,7 +128,7 @@ fn interleave(mode: ColorMode, color: &[Cow<'_, [u8]>], alpha: Option<&[u8]>, pa .filter(|&len| len as u64 <= crate::compression::MAX_DECODED_BYTES) .ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?; let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?; + out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory for RGBA output"))?; out.resize(len, 255); if mode == ColorMode::Rgb { let [r, g, b] = color else { return Err(PsdError::invalid("RGB requires three channels")) }; @@ -439,7 +439,7 @@ mod tests { fn plane_conversion_reports_absurd_sizes() { assert!(matches!(unpack_bits(&[0], usize::MAX / 2, 4), Err(PsdError::LimitExceeded(_)))); assert!(matches!(plane_to_u8(&[0], 1, usize::MAX / 2, 4), Err(PsdError::LimitExceeded(_)))); - assert!(matches!(unpack_bits(&[0], 1 << 40, 1 << 20), Err(PsdError::LimitExceeded(_)))); + assert!(matches!(unpack_bits(&[0], 1 << 40, 1 << 20), Err(PsdError::LimitExceeded(_) | PsdError::OutOfMemory(_)))); } /// A bitmap composite whose RGBA output is over the decode budget is refused up front. diff --git a/crates/psd/src/rows.rs b/crates/psd/src/rows.rs index 6413c801df..f5f95f74d4 100644 --- a/crates/psd/src/rows.rs +++ b/crates/psd/src/rows.rs @@ -98,7 +98,7 @@ impl ChannelData { } let table = bytes.get(0, table_len)?; let mut offsets = Vec::new(); - offsets.try_reserve_exact(height + 1).map_err(|_| PsdError::LimitExceeded("not enough memory for the RLE row table"))?; + offsets.try_reserve_exact(height + 1).map_err(|_| PsdError::OutOfMemory("not enough memory for the RLE row table"))?; let mut at = table_len; offsets.push(at); for c in table.chunks_exact(size as usize) { @@ -150,7 +150,7 @@ impl RowDecoder<'_> { return Err(PsdError::LimitExceeded("decoded channel data exceeds MAX_DECODED_BYTES")); } let mut out = Vec::new(); - out.try_reserve_exact(n).map_err(|_| PsdError::LimitExceeded("not enough memory for the decoded channel data"))?; + out.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?; match &self.kind { Kind::Empty => {} Kind::Raw(bytes) => out.extend_from_slice(&bytes.get((r0 * self.row_bytes) as u64, n as u64)?), diff --git a/crates/psd/src/source.rs b/crates/psd/src/source.rs index 2e7c8039dd..e04e861a1f 100644 --- a/crates/psd/src/source.rs +++ b/crates/psd/src/source.rs @@ -173,7 +173,7 @@ impl Source { return Err(out_of_range(offset, n, self.0.len())); } let mut out = Vec::new(); - out.try_reserve_exact(n).map_err(|_| PsdError::LimitExceeded("not enough memory to read the data"))?; + out.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory to read the data"))?; out.resize(n, 0); self.0.read_at(offset, &mut out)?; Ok(out) diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 6fce19bbe9..e54f4daf0b 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -9,7 +9,7 @@ use crate::blend::BlendMode; use crate::descriptor::UnicodeString; use crate::error::{PsdError, Result}; use crate::header::Version; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, Sink, WriteExt}; /// Keys whose length field is 8 bytes in PSB files: the thirteen the Adobe /// specification lists, plus keys Photoshop also writes with 8-byte lengths @@ -508,6 +508,29 @@ pub(crate) fn write_blocks(out: &mut Vec, blocks: &[TaggedBlock], version: V Ok(()) } +/// [`write_blocks`] to a streaming [`Sink`]: each block's data goes straight through, so a large +/// opaque block (an embedded smart object) is never copied whole. +pub(crate) fn write_blocks_to( + out: &mut Sink<'_, W>, + blocks: &[TaggedBlock], + version: Version, + alignment: usize, +) -> std::io::Result<()> { + for b in blocks { + out.small(|o| { + o.put(&b.signature); + o.put(&b.key); + o.put_len(b.data.len() as u64, uses_long_length(version, &b.key)) + })?; + out.put(&b.data)?; + match &b.padding { + Some(p) => out.put(p)?, + None => out.zeros(TaggedBlock::default_padding(b.data.len(), alignment))?, + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index b669fcc637..9fadf9a00c 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -152,16 +152,54 @@ impl ImageSourceData { /// Serializes the data in `order`. Returns the bytes and warnings about blocks that were /// dropped because they could not be converted to a little-endian layout. pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec, Vec)> { + Self::finish(self.body()?, self.version, order) + } + + /// [`Self::to_bytes`] that frees the layers once their block is written, before the bytes + /// are converted to `order` (a copy of them all): a layered TIFF save holds them one time + /// fewer (#6607). + pub fn into_bytes(self, order: ByteOrder) -> Result<(Vec, Vec)> { + let (body, version) = (self.body()?, self.version); + drop(self); + Self::finish(body, version, order) + } + + /// The signature, then the blocks in big-endian order. + fn body(&self) -> Result> { let version = self.version; + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + let len_field = |key: &[u8; 4]| if uses_long_length(version, key) { 8u64 } else { 4 }; + // Reserved at its exact length, as the buffer holds all the layers' pixels: one that is a + // few bytes short doubles at its last append, copying them once more (#6607). + let mut len = signature.len() as u64; + let info_pad = match &self.layer_info { + Some(info) => { + let pad = info.pad_len(version, 4)?; + len = len.saturating_add(8 + len_field(&self.layer_info_key)).saturating_add(info.unpadded_len(version)?).saturating_add(pad as u64); + pad + } + None => 0, + }; + if let Some(m) = &self.global_layer_mask { + let pad = TaggedBlock::default_padding(m.data.len(), 4); + len = len.saturating_add(8 + len_field(b"LMsk")).saturating_add(m.data.len() as u64).saturating_add(pad as u64); + } + len = self.global_blocks.iter().fold(len, |n, b| n.saturating_add(b.encoded_len_aligned(version, 4) as u64)); + len = len.saturating_add(self.trailing.len() as u64); + let no_memory = || PsdError::OutOfMemory("not enough memory for the layer data"); let mut body = Vec::new(); + body.try_reserve_exact(usize::try_from(len).map_err(|_| no_memory())?).map_err(|_| no_memory())?; + body.put(signature); if let Some(info) = &self.layer_info { - // Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`. - let pad = info.pad_len(version, 4)?; - let mut data = Vec::new(); - data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; - info.write_unpadded(&mut data, version)?; - data.resize(data.len() + pad, 0); - padded_block(self.layer_info_key, data).write(&mut body, version, 4)?; + // Written straight into the body, padded to 4 inside its length (as a padded copy + // would be): a separate buffer for it would hold all the pixels a second time. + let long = uses_long_length(version, &self.layer_info_key); + body.put(b"8BIM"); + body.put(&self.layer_info_key); + let at = body.begin_len(long); + info.write_unpadded(&mut body, version)?; + body.resize(body.len() + info_pad, 0); + body.end_len(at, long)?; } // Blocks without explicit padding are padded to 4 as they are written (alignment 4), so // the global mask and blocks are written from the model, without a copy of their data. @@ -173,12 +211,18 @@ impl ImageSourceData { b.write(&mut body, version, 4)?; } body.put(&self.trailing); - let (body, warnings) = - if order.is_little() { transcode::transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little)? } else { (body, Vec::new()) }; - let mut out = Vec::with_capacity(36 + body.len()); - out.put(if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }); - out.put(&body); - Ok((out, warnings)) + Ok(body) + } + + /// The `body` (signature included) converted to `order`. + fn finish(body: Vec, version: Version, order: ByteOrder) -> Result<(Vec, Vec)> { + if !order.is_little() { + return Ok((body, Vec::new())); + } + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + let blocks = body.get(signature.len()..).ok_or_else(|| PsdError::invalid("image source data without its signature"))?; + // Converted behind the signature, into one buffer sized for both. + transcode::transcode_blocks_after(signature, blocks, version, ByteOrder::Big, ByteOrder::Little) } /// The layers, global mask and global blocks of a PSD as image source data (byte order Big). @@ -198,6 +242,25 @@ impl ImageSourceData { } } + /// [`Self::from_psd`] that moves the layers, global mask and global blocks out of `file` + /// instead of copying them: a layered TIFF save would otherwise hold every layer's pixels + /// twice (#6607). `file` keeps its header, resources and merged image. + pub fn take_from_psd(file: &mut PsdFile) -> Self { + let key = match &file.layer_info_placement { + LayerInfoPlacement::GlobalBlock { key, .. } => *key, + LayerInfoPlacement::Section => layer_info_key(file.header.depth), + }; + ImageSourceData { + version: file.header.version, + byte_order: ByteOrder::Big, + layer_info: file.layer_info.take(), + layer_info_key: key, + global_layer_mask: file.global_layer_mask.take(), + global_blocks: std::mem::take(&mut file.global_blocks), + trailing: std::mem::take(&mut file.layer_mask_trailing), + } + } + /// Assembles a PSD model from this data plus the parts a TIFF stores natively: the header /// (dimensions, depth, colour mode, channel count), the image resources (tag 34377) and the /// merged image (the TIFF's pixels). @@ -223,6 +286,7 @@ impl ImageSourceData { } /// A block padded to a multiple of 4, as Photoshop writes them in TIFFs. +#[cfg(test)] fn padded_block(key: [u8; 4], data: Vec) -> TaggedBlock { let pad = (4 - data.len() % 4) % 4; TaggedBlock { signature: *b"8BIM", key, data, padding: Some(vec![0; pad]) } diff --git a/crates/psd/src/tiff/tests.rs b/crates/psd/src/tiff/tests.rs index 3836822bf4..533e534430 100644 --- a/crates/psd/src/tiff/tests.rs +++ b/crates/psd/src/tiff/tests.rs @@ -8,6 +8,32 @@ use crate::testgen; /// Keys the generator plants that the transcoder (rightly) does not know. const UNKNOWN: [&[u8; 4]; 3] = [b"zOdd", b"zNop", b"Txt2"]; +/// #6607: moving the layers out of a PSD writes the bytes copying them does, and leaves the +/// rest of the file (its merged image) as it was. The big-endian body is reserved at its exact +/// length, so the buffer is never grown (a copy of every layer's pixels). +#[test] +fn taking_the_layers_writes_the_same_bytes_as_copying_them() { + for case in testgen::all_cases() { + for order in [ByteOrder::Big, ByteOrder::Little] { + let copied = ImageSourceData::from_psd(&case.file).to_bytes(order); + let mut file = case.file.clone(); + let taken = ImageSourceData::take_from_psd(&mut file).into_bytes(order); + match (copied, taken) { + (Ok(a), Ok(b)) => { + assert_eq!(a, b, "{} {order:?}", case.name); + if !order.is_little() { + assert_eq!(b.0.capacity(), b.0.len(), "{}: the body grew", case.name); + } + } + (a, b) => assert_eq!(a.is_err(), b.is_err(), "{} {order:?}", case.name), + } + assert!(file.layer_info.is_none() && file.global_blocks.is_empty() && file.global_layer_mask.is_none(), "{}", case.name); + assert_eq!(file.image_data, case.file.image_data, "{}", case.name); + assert_eq!(file.header, case.file.header, "{}", case.name); + } + } +} + /// `d` without the blocks a little-endian round trip drops, and without the layer-info /// padding (which follows the body length, so it changes when blocks are dropped). fn known(d: &ImageSourceData) -> ImageSourceData { @@ -973,6 +999,11 @@ fn global_blocks_written_without_copies_keep_their_bytes() { assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat()); let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat()); + // Converted behind the signature in the buffer it starts with: never grown (growing + // would at least double it), so the body is not moved to make room. + let (after, _) = transcode_blocks_after(signature, &body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); + assert_eq!(after, [signature, &little[..]].concat()); + assert_eq!(after.capacity(), signature.len() + body.len()); } } diff --git a/crates/psd/src/tiff/transcode.rs b/crates/psd/src/tiff/transcode.rs index 31d12b702f..15c33d4a9d 100644 --- a/crates/psd/src/tiff/transcode.rs +++ b/crates/psd/src/tiff/transcode.rs @@ -7,6 +7,16 @@ pub fn transcode_blocks(data: &[u8], version: Version, from: ByteOrder, to: Byte Ok(t.finish()) } +/// [`transcode_blocks`] written after `prefix`, in one buffer sized for both. +pub fn transcode_blocks_after(prefix: &[u8], data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { + let mut out = Vec::new(); + out.try_reserve_exact(prefix.len().saturating_add(data.len())).map_err(|_| PsdError::OutOfMemory("not enough memory for the layer data"))?; + out.extend_from_slice(prefix); + let mut t = Tx::appending(out, data, version, from, to); + t.blocks()?; + Ok(t.finish()) +} + /// Converts a layer-info body (the data of a `Layr` / `Lr16` / `Lr32` block) between orders. pub fn transcode_layer_info(data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { let mut t = Tx::new(data, version, from, to); @@ -61,8 +71,13 @@ macro_rules! copy_int { impl<'a> Tx<'a> { fn new(data: &'a [u8], version: Version, from: ByteOrder, to: ByteOrder) -> Self { + Self::appending(Vec::with_capacity(data.len()), data, version, from, to) + } + + /// A walker that appends to `out`. + fn appending(out: Vec, data: &'a [u8], version: Version, from: ByteOrder, to: ByteOrder) -> Self { let r = if from.is_little() { Reader::new_little(data) } else { Reader::new(data) }; - Tx { r, out: Vec::with_capacity(data.len()), from, to, version, warnings: Vec::new(), depth: 0 } + Tx { r, out, from, to, version, warnings: Vec::new(), depth: 0 } } fn finish(self) -> (Vec, Vec) { diff --git a/crates/psd/tests/fixtures/atn/README.md b/crates/psd/tests/fixtures/atn/README.md new file mode 100644 index 0000000000..e0912ea462 --- /dev/null +++ b/crates/psd/tests/fixtures/atn/README.md @@ -0,0 +1,31 @@ +# Synthetic ATN parser fixtures + +These seven tiny files are original synthetic test data. The four version-16 files were independently +encoded field by field from the version-16 layout documented in `src/atn.rs`, without calling +PhotoCraft's writer; the three version-12 files (`v12-*.atn`) encode the version-12 layout documented +there (#10743). They are licensed MIT OR Apache-2.0 under the repository's licenses. They contain no +user artwork, Photoshop program code or proprietary assets. They are not Photoshop-authored +interoperability oracles. + +| File | Bytes | Purpose | SHA-256 | +|---|---:|---|---| +| `empty.atn` | 13 | Empty set and explicit big-endian version | `6fd936f91296a13b0e7456a38b139a100fd17407d1c582d4502ce76413d01539` | +| `metadata.atn` | 214 | UTF-16 surrogate pairs/unpaired units, action order, function key, modifiers, panel flags, disabled step, unknown string event, dialog options | `cec2ff5a25e003aa29e790ea6b110f06ba848f0afd7901f3f8ad4740f782ede2` | +| `descriptors.atn` | 859 | Every descriptor value variant and every reference form currently represented by the parser | `6c76502822c1f36c8060a71adb227c4540663518785bbab1b70951dac0ef58ad` | +| `float-bits.atn` | 428 | NaN payload, negative zero, infinities, and floats inside lists, objects and arrays | `0aa6278a569c777f249b305e889896132c2a87e44a24cb1a55d1d3fc0928fd0c` | +| `v12-stop.atn` | 113 | Version 12: Stop with Unicode text and a Boolean | `293462a7d5cf0043fdc5135630524699f545679088c54bad83f4a86e15160271` | +| `v12-values.atn` | 562 | Version 12: named plug-in event, every legacy value and reference form, bare `warp`/`list`/`time` codes, an object array with three objects and two columns, unpaired UTF-16, opaque path/alias/data bytes, and a descriptorless step | `23d3f377996435988367365ee5c2a15462c0ed42ce506dc4a15039387ac6a853` | +| `v12-commands.atn` | 404 | Version 12: Gaussian Blur, Levels, Select All and Make Layer | `451958b686da9aab45e65ae902c86333b7149d72021c21904e3aae8e39eb77cb` | + +`tests/it/atn_contract.rs` checks the independently encoded version-16 bytes and expected public +structures; `tests/it/atn_v12.rs` (and `photocraft-engine`'s `tests/it/atn_v12.rs`) check the +version-12 files. The version-12 files follow the version 12 layout in `src/atn.rs`, assembled from +the measured grammar in #10603. Their `Idnt` reference follows the inferred index-like layout; no +real version 12 identifier reference was available. Saving them produces version 16, so keep them +out of the version-16 byte-identical corpus oracle. + +The files also seed the `atn` fuzz target: `cargo fuzz run atn crates/psd/tests/fixtures/atn` +(from `crates/psd`: `cargo fuzz run atn tests/fixtures/atn`). + +A fixture change should explain the changed wire fields and update this table; do not regenerate +expectations merely to make a failing parser pass. diff --git a/crates/psd/tests/fixtures/atn/descriptors.atn b/crates/psd/tests/fixtures/atn/descriptors.atn new file mode 100644 index 0000000000..26b1bdd361 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/descriptors.atn differ diff --git a/crates/psd/tests/fixtures/atn/empty.atn b/crates/psd/tests/fixtures/atn/empty.atn new file mode 100644 index 0000000000..dbcd73d196 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/empty.atn differ diff --git a/crates/psd/tests/fixtures/atn/float-bits.atn b/crates/psd/tests/fixtures/atn/float-bits.atn new file mode 100644 index 0000000000..0f272adea8 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/float-bits.atn differ diff --git a/crates/psd/tests/fixtures/atn/metadata.atn b/crates/psd/tests/fixtures/atn/metadata.atn new file mode 100644 index 0000000000..29c5597e00 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/metadata.atn differ diff --git a/crates/psd/tests/fixtures/atn/v12-commands.atn b/crates/psd/tests/fixtures/atn/v12-commands.atn new file mode 100644 index 0000000000..6b02e7ae71 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/v12-commands.atn differ diff --git a/crates/psd/tests/fixtures/atn/v12-stop.atn b/crates/psd/tests/fixtures/atn/v12-stop.atn new file mode 100644 index 0000000000..826bdabe55 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/v12-stop.atn differ diff --git a/crates/psd/tests/fixtures/atn/v12-values.atn b/crates/psd/tests/fixtures/atn/v12-values.atn new file mode 100644 index 0000000000..0fc46dde8e Binary files /dev/null and b/crates/psd/tests/fixtures/atn/v12-values.atn differ diff --git a/crates/psd/tests/it/atn_contract.rs b/crates/psd/tests/it/atn_contract.rs new file mode 100644 index 0000000000..f6c38bf81c --- /dev/null +++ b/crates/psd/tests/it/atn_contract.rs @@ -0,0 +1,243 @@ +//! Public-API contract for the `.atn` parser, against independently encoded wire fixtures. +use photocraft_psd::PsdError; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value}; + +#[path = "common/atn_roundtrip.rs"] +mod atn_roundtrip; + +const FIXTURES: &[(&str, &[u8])] = &[ + ("empty", include_bytes!("../fixtures/atn/empty.atn")), + ("metadata", include_bytes!("../fixtures/atn/metadata.atn")), + ("descriptors", include_bytes!("../fixtures/atn/descriptors.atn")), + ("float bits", include_bytes!("../fixtures/atn/float-bits.atn")), +]; + +fn file_with(desc: Descriptor) -> AtnFile { + AtnFile { + actions: vec![AtnAction { + items: vec![AtnItem { event: AtnEvent::Code(*b"test"), descriptor: Some(desc), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + } +} + +fn class(id: &str) -> Class { + Class { name: UnicodeString::default(), class_id: Id::new(id) } +} + +#[test] +fn independent_wire_fixtures_parse_and_write_byte_exactly() { + for (name, bytes) in FIXTURES { + let parsed = atn::parse(bytes).unwrap(); + assert!(parsed.warnings.is_empty(), "{name}"); + assert_eq!(atn::write(&parsed).unwrap(), *bytes, "{name}"); + atn_roundtrip::check(bytes); + } + assert_eq!(atn::write(&AtnFile::default()).unwrap(), FIXTURES[0].1); + assert_eq!(&FIXTURES[0].1[..4], &[0, 0, 0, 16], "the wire version is big-endian on every host"); +} + +#[test] +fn unicode_metadata_unknown_events_and_dialog_flags_are_preserved() { + let file = atn::parse(FIXTURES[1].1).unwrap(); + assert_eq!(file.name.0, vec![0x0053, 0x0065, 0x0074, 0x0020, 0xd83d, 0xde80, 0xd800, 0]); + assert!(file.expanded); + assert_eq!(file.actions.len(), 2); + let action = &file.actions[0]; + assert_eq!(action.name, UnicodeString::new_nul("Action Ω")); + assert_eq!((action.function_key, action.shift, action.command, action.color, action.expanded), (12, true, true, 7, true)); + let blur = &action.items[0]; + assert_eq!((blur.expanded, blur.enabled, blur.with_dialog, blur.dialog_options), (true, true, true, 2)); + assert_eq!(blur.event, AtnEvent::Code(*b"GsnB")); + assert_eq!(blur.descriptor, Some(Descriptor::new("null").with("Rds ", Value::UnitFloat { unit: *b"#Pxl", value: 2.5 }))); + let future = &action.items[1]; + assert_eq!(future.event, AtnEvent::Name("syntheticFutureEvent".into())); + assert_eq!((future.expanded, future.enabled, future.with_dialog, future.dialog_options), (false, false, true, 255)); + assert_eq!(future.dictionary, b"Future event"); + assert!(future.descriptor.is_none()); + assert_eq!(file.actions[1].name, UnicodeString::new_nul("Empty")); + assert!(file.actions[1].items.is_empty()); +} + +#[test] +fn every_descriptor_variant_and_reference_form_is_retained() { + let expected = Descriptor::new("syntheticRoot") + .with( + "reference", + Value::Reference(vec![ + ReferenceItem::Property { class: class("Lyr "), key: Id::new("Nm ") }, + ReferenceItem::Class(class("Dcmn")), + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ReferenceItem::Offset { class: class("Lyr "), offset: -3 }, + ReferenceItem::Identifier { class: class("Lyr "), id: 0x1234_5678 }, + ReferenceItem::Index { class: class("Lyr "), index: 4 }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Layer Ω") }, + ]), + ) + .with("nested", Value::Descriptor(Descriptor::new("child").with("flag", Value::Boolean(true)))) + .with("global", Value::GlobalObject(Descriptor::new("global"))) + .with("list", Value::List(vec![Value::Boolean(false), Value::Double(-3.5), Value::Text(UnicodeString::new("Ω"))])) + .with("double", Value::Double(1.25)) + .with("unit", Value::UnitFloat { unit: *b"#Prc", value: 37.5 }) + .with("units", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.25, -0.0, -2.5] }) + .with("text", Value::Text(UnicodeString(vec![0x0054, 0x0065, 0x0078, 0x0074, 0x0020, 0xd83d, 0xde80, 0xdfff, 0]))) + .with("enum", Value::Enumerated { type_id: Id::new("Ordn"), value: Id::new("syntheticValue") }) + .with("integer", Value::Integer(i32::MIN)) + .with("large", Value::LargeInteger(0x0123_4567_89ab_cdef)) + .with("boolean", Value::Boolean(true)) + .with("class", Value::Class(class("Lyr "))) + .with("globalClass", Value::GlobalClass(class("Dcmn"))) + .with("alias", Value::Alias(vec![0, 255, 1, 128])) + .with("path", Value::Path(b"C:\\synthetic\\image.psd\0".to_vec())) + .with("raw", Value::RawData(vec![0, 127, 128, 255])) + .with( + "objectArray", + Value::ObjectArray(ObjectArray { + prefix: 16, + body: Descriptor::new("rationalPoint").with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }), + }), + ); + let file = atn::parse(FIXTURES[2].1).unwrap(); + assert_eq!(file.actions[0].items[0].descriptor, Some(expected)); +} + +#[test] +fn nan_payloads_signed_zero_and_infinities_survive_without_false_fuzz_failures() { + let file = atn::parse(FIXTURES[3].1).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + for (key, bits) in [ + ("nan", 0x7ff8_0000_0000_1234), + ("negativeZero", 0x8000_0000_0000_0000), + ("positiveInfinity", 0x7ff0_0000_0000_0000), + ("negativeInfinity", 0xfff0_0000_0000_0000), + ] { + let Some(Value::Double(value)) = descriptor.get(key) else { panic!("missing {key}") }; + assert_eq!(value.to_bits(), bits, "{key}"); + } + // Ordinary AST PartialEq fails for NaN, despite this valid byte-exact round trip. + assert_ne!(file.actions.clone(), file.actions); + atn_roundtrip::check(FIXTURES[3].1); +} + +#[test] +fn every_truncated_fixture_is_refused() { + for (name, bytes) in FIXTURES { + for end in 0..bytes.len() { + assert!(atn::parse(&bytes[..end]).is_err(), "{name}: cut at {end}/{}", bytes.len()); + } + } +} + +#[test] +fn trailing_bytes_only_add_a_diagnostic() { + for (name, bytes) in FIXTURES { + let mut input = bytes.to_vec(); + input.extend_from_slice(b"extra"); + let mut parsed = atn::parse(&input).unwrap(); + assert_eq!(parsed.warnings, ["5 trailing bytes ignored"], "{name}"); + parsed.warnings.clear(); + assert_eq!(atn::write(&parsed).unwrap(), *bytes, "{name}"); + atn_roundtrip::check(&input); + } +} + +#[test] +fn unsupported_versions_and_invalid_item_tags_are_refused() { + for version in [0_u32, 7, 11, 15, 17, u32::MAX] { + let mut input = FIXTURES[0].1.to_vec(); + input[..4].copy_from_slice(&version.to_be_bytes()); + assert!(matches!(atn::parse(&input), Err(PsdError::Unsupported(_)))); + } + // Version 12 (Photoshop 5) is read too (#10743): the smallest set parses to an empty set. + let mut v12 = FIXTURES[0].1.to_vec(); + v12[..4].copy_from_slice(&12_u32.to_be_bytes()); + assert!(atn::parse(&v12).unwrap().actions.is_empty()); + let file = file_with(Descriptor::new("null")); + let valid = atn::write(&file).unwrap(); + // The empty set/action names put the first step at offset 28. + for kind in [*b"bad!", *b"LONG", [0; 4]] { + let mut input = valid.clone(); + input[32..36].copy_from_slice(&kind); + assert!(matches!(atn::parse(&input), Err(PsdError::Invalid(_)))); + } + for flag in [-2_i32, 1, i32::MAX] { + let mut input = valid.clone(); + input[44..48].copy_from_slice(&flag.to_be_bytes()); + assert!(matches!(atn::parse(&input), Err(PsdError::Invalid(_)))); + } +} + +#[test] +fn declared_action_and_step_limits_are_enforced() { + let mut actions = FIXTURES[0].1.to_vec(); + let count = (atn::MAX_ACTIONS + 1) as u32; + actions[9..13].copy_from_slice(&count.to_be_bytes()); + actions.resize(13 + count as usize * 15, 0); + assert!(matches!(atn::parse(&actions), Err(PsdError::LimitExceeded("too many actions in one set")))); + + let mut steps = atn::write(&AtnFile { actions: vec![AtnAction::default()], ..Default::default() }).unwrap(); + let count = (atn::MAX_ITEMS + 1) as u32; + steps[24..28].copy_from_slice(&count.to_be_bytes()); + steps.resize(28 + count as usize * 20, 0); + assert!(matches!(atn::parse(&steps), Err(PsdError::LimitExceeded("too many steps in one action")))); + + let mut short = FIXTURES[0].1.to_vec(); + short[9..13].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(matches!(atn::parse(&short), Err(PsdError::UnexpectedEof { .. }))); +} + +#[test] +fn nested_descriptors_obey_the_parser_depth_limit() { + let mut desc = Descriptor::new("leaf").with("flag", Value::Boolean(true)); + for _ in 0..2 { + desc = Descriptor::new("node").with("next", Value::Descriptor(desc)); + } + let shallow = atn::write(&file_with(desc.clone())).unwrap(); + atn_roundtrip::check(&shallow); + assert!(atn::parse(&shallow).is_ok()); + for _ in 0..MAX_DEPTH + 2 { + desc = Descriptor::new("node").with("next", Value::Descriptor(desc)); + } + let deep = atn::write(&file_with(desc)).unwrap(); + assert!(matches!(atn::parse(&deep), Err(PsdError::LimitExceeded(_)))); +} + +#[test] +fn generated_metadata_and_float_patterns_round_trip() { + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + for index in 0..256_u16 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let mut file = file_with(Descriptor::new("generated").with("value", Value::Double(f64::from_bits(seed)))); + file.name = UnicodeString(vec![index, 0xd800 | index, 0, 0xffff]); + file.expanded = index & 1 != 0; + let action = &mut file.actions[0]; + action.function_key = index; + action.shift = index & 2 != 0; + action.command = index & 4 != 0; + action.color = index; + action.expanded = index & 8 != 0; + action.name = UnicodeString(vec![0xdc00 | index, index]); + let item = &mut action.items[0]; + item.expanded = index & 16 != 0; + item.enabled = index & 32 != 0; + item.with_dialog = index & 64 != 0; + item.dialog_options = index as u8; + atn_roundtrip::check(&atn::write(&file).unwrap()); + } +} + +#[test] +fn actual_fuzz_invariant_handles_deterministic_seed_mutations() { + for (_, bytes) in FIXTURES { + for index in 0..128 { + let mut mutated = bytes.to_vec(); + let at = index * 47 % mutated.len(); + mutated[at] ^= (index as u8).wrapping_mul(13).wrapping_add(1); + atn_roundtrip::check(&mutated); + } + } +} diff --git a/crates/psd/tests/it/atn_v12.rs b/crates/psd/tests/it/atn_v12.rs new file mode 100644 index 0000000000..28f3921d1f --- /dev/null +++ b/crates/psd/tests/it/atn_v12.rs @@ -0,0 +1,145 @@ +use photocraft_psd::PsdError; +use photocraft_psd::atn; +use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem as R, UnicodeString, Value as V}; + +const STOP: &[u8] = include_bytes!("../fixtures/atn/v12-stop.atn"); +const VALUES: &[u8] = include_bytes!("../fixtures/atn/v12-values.atn"); +const COMMANDS: &[u8] = include_bytes!("../fixtures/atn/v12-commands.atn"); + +fn code(id: &[u8; 4]) -> Id { + Id::Code(*id) +} + +fn class(id: &[u8; 4]) -> Class { + Class { name: UnicodeString::default(), class_id: code(id) } +} + +fn descriptor(id: &[u8; 4], items: Vec<(Id, V)>) -> Descriptor { + Descriptor { name: UnicodeString::default(), class_id: code(id), items } +} + +/// The fixture bytes are independent of the version 16 writer and legacy reader. +#[test] +fn stop_and_named_event_preserve_headers_and_canonicalize_to_v16() { + let parsed = atn::parse(STOP).unwrap(); + assert_eq!(parsed.name, UnicodeString::new_nul("Legacy")); + let action = &parsed.actions[0]; + assert_eq!((action.function_key, action.shift, action.command, action.color), (3, true, false, 2)); + assert_eq!(action.name, UnicodeString::new_nul("Run")); + let step = &action.items[0]; + assert_eq!((step.expanded, step.enabled, step.with_dialog, step.dialog_options), (true, true, false, 2)); + assert_eq!(step.event, atn::AtnEvent::Code(*b"Stop")); + assert_eq!( + step.descriptor, + Some(descriptor(b"null", vec![(code(b"Msge"), V::Text(UnicodeString::new_nul("Review ✓"))), (code(b"Cntn"), V::Boolean(true)),])) + ); + let plugin = atn::parse(VALUES).unwrap(); + assert_eq!(plugin.actions[0].items[0].event, atn::AtnEvent::name("LegacyPlugin")); + assert!(plugin.actions[0].items[1].descriptor.is_none()); + for bytes in [STOP, VALUES, COMMANDS] { + let original = atn::parse(bytes).unwrap(); + let modern = atn::write(&original).unwrap(); + assert_eq!(&modern[..4], &16_u32.to_be_bytes()); + assert_eq!(atn::parse(&modern).unwrap(), original); + } +} + +#[test] +fn all_legacy_values_and_reference_classes_are_retained() { + let parsed = atn::parse(VALUES).unwrap(); + let actual = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + let floats = |values| V::UnitFloats { unit: *b"#Pxl", values }; + let expected = descriptor( + b"null", + vec![ + (code(b"warp"), V::Boolean(true)), + (code(b"list"), V::GlobalClass(class(b"list"))), + (code(b"time"), V::Enumerated { type_id: code(b"time"), value: code(b"warp") }), + (code(b"long"), V::Integer(-17)), + (code(b"comp"), V::LargeInteger(i64::MIN + 23)), + (code(b"doub"), V::Double(-0.5)), + (code(b"unit"), V::UnitFloat { unit: *b"#Pxl", value: 2.5 }), + (code(b"TEXT"), V::Text(UnicodeString(vec![0xd800, 0]))), + (code(b"Objc"), V::Descriptor(descriptor(b"warp", vec![(code(b"flag"), V::Boolean(false))]))), + (code(b"GlbO"), V::GlobalObject(descriptor(b"list", vec![]))), + (code(b"type"), V::Class(class(b"time"))), + (code(b"VlLs"), V::List(vec![V::Boolean(false), V::Descriptor(descriptor(b"time", vec![]))])), + ( + code(b"ObAr"), + V::ObjectArray(ObjectArray { + prefix: 3, + body: descriptor(b"time", vec![(code(b"Hrzn"), floats(vec![1.0, 2.0, 3.0])), (code(b"Vrtc"), floats(vec![4.0, 5.0, 6.0]))]), + }), + ), + ( + code(b"refs"), + V::Reference(vec![ + R::Property { class: class(b"Lyr "), key: code(b"Nm ") }, + R::Class(class(b"Dcmn")), + R::Enumerated { class: class(b"Lyr "), type_id: code(b"Ordn"), value: code(b"Trgt") }, + R::Offset { class: class(b"Lyr "), offset: -2 }, + R::Index { class: class(b"Lyr "), index: 3 }, + R::Identifier { class: class(b"Lyr "), id: 9 }, + R::Name { class: class(b"Lyr "), name: UnicodeString::new_nul("Layer ✓") }, + ]), + ), + (code(b"Pth "), V::Path(vec![0, 1, 255, 3])), + (code(b"alis"), V::Alias(b"alias".to_vec())), + (code(b"tdta"), V::RawData(vec![])), + ], + ); + assert_eq!(actual, &expected); +} + +fn with_descriptor(body: &[u8]) -> Vec { + let start = STOP.windows(4).position(|w| w == [255; 4]).unwrap() + 4; + let mut bytes = STOP[..start].to_vec(); + bytes.extend_from_slice(body); + bytes +} + +/// Reject every truncated fixture while preserving trailing data as a warning. +#[test] +fn truncations_fail_and_trailing_bytes_only_warn() { + for bytes in [STOP, VALUES, COMMANDS] { + for cut in 0..bytes.len() { + assert!(atn::parse(&bytes[..cut]).is_err(), "length {}, cut {cut}", bytes.len()); + } + let mut padded = bytes.to_vec(); + padded.extend_from_slice(b"junk"); + let mut parsed = atn::parse(&padded).unwrap(); + assert_eq!(parsed.warnings, ["4 trailing bytes ignored"]); + parsed.warnings.clear(); + assert_eq!(parsed, atn::parse(bytes).unwrap()); + } +} + +/// Reject excessive counts/depth and unsupported forms without dialect fallback. +#[test] +fn legacy_counts_depth_and_unknown_forms_fail_without_fallback() { + assert!(atn::parse(&with_descriptor(&u32::MAX.to_be_bytes())).is_err()); + for prefix in [b"VlLs".as_slice(), b"obj ", b"UnFl#Pxl", b"TEXT", b"Pth ", b"ObArtime\0\0\0\x03"] { + let mut body = b"\0\0\0\x01test".to_vec(); + body.extend_from_slice(prefix); + body.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(atn::parse(&with_descriptor(&body)).is_err()); + } + // The smallest descriptor item is a bare key/type and one Boolean byte. + assert!(atn::parse(&with_descriptor(b"\0\0\0\x01testbool\x01")).is_ok()); + for ty in [b"VlLs\0\0\0\x01".as_slice(), b"Objcnull\0\0\0\x01test"] { + let mut body = b"\0\0\0\x01test".to_vec(); + for _ in 0..=MAX_DEPTH { + body.extend_from_slice(ty); + } + body.extend_from_slice(b"bool\x01"); + assert!(matches!(atn::parse(&with_descriptor(&body)), Err(PsdError::LimitExceeded(_)))); + } + for body in [b"\0\0\0\x01testwhat\0".as_slice(), b"\0\0\0\x01testobj \0\0\0\x01whatLyr "] { + assert!(matches!(atn::parse(&with_descriptor(body)), Err(PsdError::Unsupported(_)))); + } + for version in [0_u32, 7, 11, 13, 17, 0x0005_1600] { + let mut bytes = STOP.to_vec(); + bytes[..4].copy_from_slice(&version.to_be_bytes()); + assert!(matches!(atn::parse(&bytes), Err(PsdError::Unsupported(message)) if message.contains(&version.to_string()))); + } +} diff --git a/crates/psd/tests/it/common/atn_roundtrip.rs b/crates/psd/tests/it/common/atn_roundtrip.rs new file mode 100644 index 0000000000..ded43b55e2 --- /dev/null +++ b/crates/psd/tests/it/common/atn_roundtrip.rs @@ -0,0 +1,71 @@ +//! Shared round-trip invariant for the `.atn` parser contract suite. +use photocraft_psd::atn::{self, AtnFile}; +use photocraft_psd::descriptor::{Descriptor, Value}; + +fn scalar(value: &mut f64, bits: &mut Vec) { + bits.push(value.to_bits()); + // AST equality must remain meaningful when a valid descriptor contains NaN. + if value.is_nan() { + *value = 0.0; + } +} + +fn descriptor(desc: &mut Descriptor, bits: &mut Vec) { + for (_, item) in &mut desc.items { + value(item, bits); + } +} + +fn value(item: &mut Value, bits: &mut Vec) { + match item { + Value::Double(v) | Value::UnitFloat { value: v, .. } => scalar(v, bits), + Value::UnitFloats { values, .. } => { + for v in values { + scalar(v, bits); + } + } + Value::Descriptor(d) | Value::GlobalObject(d) => descriptor(d, bits), + Value::ObjectArray(array) => descriptor(&mut array.body, bits), + Value::List(items) => { + for item in items { + value(item, bits); + } + } + Value::Reference(_) + | Value::Text(_) + | Value::Enumerated { .. } + | Value::Integer(_) + | Value::LargeInteger(_) + | Value::Boolean(_) + | Value::Class(_) + | Value::GlobalClass(_) + | Value::Alias(_) + | Value::Path(_) + | Value::RawData(_) => {} + } +} + +fn floats(file: &mut AtnFile) -> Vec { + let mut bits = Vec::new(); + for action in &mut file.actions { + for item in &mut action.items { + if let Some(d) = &mut item.descriptor { + descriptor(d, &mut bits); + } + } + } + bits +} + +pub fn check(data: &[u8]) { + if let Ok(mut original) = atn::parse(data) { + let encoded = atn::write(&original).expect("a parsed set is writable"); + let mut again = atn::parse(&encoded).expect("a written set parses"); + assert_eq!(atn::write(&again).expect("a reparsed set is writable"), encoded); + assert_eq!(floats(&mut original), floats(&mut again), "every floating-point bit pattern survives"); + // Trailing-input diagnostics are not serialized into an action set. + original.warnings.clear(); + again.warnings.clear(); + assert_eq!(again, original, "set metadata, action metadata and every descriptor value survive"); + } +} diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs index ee6879aa66..db445f849c 100644 --- a/crates/psd/tests/it/main.rs +++ b/crates/psd/tests/it/main.rs @@ -1,5 +1,7 @@ //! The crate's integration tests, linked into one test binary. +mod atn_contract; +mod atn_v12; mod builder; mod corpus; mod corpus_atn; diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 41b188a94f..058e275b60 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -11,10 +11,19 @@ workspace = true photocraft-geom = { workspace = true } photocraft-color = { workspace = true } serde = { workspace = true } +crc32fast = "1" +lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode"] } -# Native only: converting a region's tiles is independent per tile, so it runs on all cores. +# Native only: converting a region's tiles is independent per tile, so it runs on all cores; +# sysinfo reads the physical RAM size for the memory limit and the scratch volume's free space. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } +sysinfo = { version = "=0.40.1", default-features = false, features = ["system", "disk"] } + +# Available physical memory and commit headroom (memory/system.rs) through winsafe's safe +# `GlobalMemoryStatusEx` wrapper. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/examples/scratch_bench.rs b/crates/raster/examples/scratch_bench.rs new file mode 100644 index 0000000000..c7d04a56c4 --- /dev/null +++ b/crates/raster/examples/scratch_bench.rs @@ -0,0 +1,52 @@ +//! 24 MP release benchmark: identical pixel access with RAM-only and disk-backed tiles. +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_raster::{Surface, spill}; +use std::time::Instant; + +fn run(dir: Option<&std::path::Path>) -> Result<(), String> { + spill::configure(dir, 16 << 20)?; + let start = Instant::now(); + let area = Rect::from_xywh(0, 0, 6000, 4000); + let mut surface = Surface::new(PixelFormat::RGBA8); + let mut random = 0x1234abcd_u32; + for tc in area.tiles() { + let mut bytes = surface.tile_mut(tc).bytes_mut(); + for byte in bytes.iter_mut() { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + *byte = random as u8; + } + } + spill::settle(); + let build_ms = start.elapsed().as_secs_f64() * 1000.0; + let resident = spill::stats().resident_bytes; + let disk = spill::stats().scratch_bytes; + let read = Instant::now(); + let mut checksum = 0u64; + // Rows exercise the same locks and conversions the CPU renderer uses. + let mut row = vec![[0; 4]; 6000]; + for y in 0..4000 { + surface.read_rgba8_into(Rect::from_xywh(0, y, 6000, 1), &mut row); + for pixel in &row { + checksum = checksum.wrapping_add(u32::from_le_bytes(*pixel) as u64); + } + } + spill::check_integrity()?; + println!( + "mode={} build_ms={build_ms:.2} read_ms={:.2} resident_bytes={resident} scratch_bytes={disk} checksum={checksum} tile_side={TILE_SIZE}", + if dir.is_some() { "scratch" } else { "ram" }, + read.elapsed().as_secs_f64() * 1000.0 + ); + drop(surface); + spill::configure(None, 0)?; + Ok(()) +} + +fn main() -> Result<(), String> { + run(None)?; + let dir = std::env::temp_dir().join(format!("photocraft-24mp-{}", std::process::id())); + run(Some(&dir))?; + std::fs::remove_dir(&dir).map_err(|e| e.to_string()) +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index af3c6d48e2..77121d0e7d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -9,31 +9,47 @@ mod interrupt; pub use interrupt::{Cancelled, Interrupt}; +pub mod scratch; use std::collections::BTreeMap; use std::fmt; -use std::sync::Arc; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, RwLockReadGuard, RwLockWriteGuard}; 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. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum AllocationError { + ScratchRead(String), SizeOverflow, - NotEnoughMemory { bytes: usize }, + NotEnoughMemory { + bytes: usize, + }, + /// The pixels given don't fill the region they are written to. + LengthMismatch { + expected: usize, + got: usize, + }, } impl fmt::Display for AllocationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::ScratchRead(error) => write!(f, "scratch tile could not be read: {error}"), Self::SizeOverflow => f.write_str("requested surface allocation is too large"), Self::NotEnoughMemory { bytes } => write!(f, "not enough memory to allocate {} bytes", bytes), + Self::LengthMismatch { expected, got } => write!(f, "{got} bytes of pixels given for a region that takes {expected}"), } } } impl std::error::Error for AllocationError {} +pub mod memory; +mod scratch_file; +pub mod spill; +pub mod stage_file; /// One shared tile per pixel value (and tile size), for [`Surface::share_uniform_tiles`]. #[derive(Debug, Default)] @@ -54,38 +70,39 @@ impl TileInterner { } /// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// The bytes may live on the scratch disk ([`spill`]); [`Tile::bytes`] and [`Tile::bytes_mut`] +/// bring them back and keep them in memory while the returned guard lives. Take one guard per +/// tile, not per pixel, in hot loops. pub struct Tile { - data: Box<[u8]>, + cell: Arc, } impl Tile { fn try_filled(format: &PixelFormat, pixel: &[u8]) -> Result { let n = (TILE_SIZE * TILE_SIZE) as usize; let bytes = n.checked_mul(format.bytes_per_pixel()).ok_or(AllocationError::SizeOverflow)?; - let mut data = Vec::new(); - data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - data.resize(bytes, 0); + let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?; if pixel.iter().any(|&b| b != 0) { for px in data.chunks_exact_mut(pixel.len()) { px.copy_from_slice(pixel); } } - Ok(Tile { data: data.into_boxed_slice() }) + Ok(Tile::from_vec(data)) } /// Clone this tile's pixel buffer with a fallible reservation. pub fn try_clone(&self) -> Result { - let bytes = self.data.len(); - let mut data = Vec::new(); - data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - data.extend_from_slice(&self.data); - Ok(Tile { data: data.into_boxed_slice() }) + let source = self.try_bytes().map_err(AllocationError::ScratchRead)?; + let bytes = source.len(); + let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?; + data.copy_from_slice(&source); + Ok(Tile::from_vec(data)) } fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { let row_len = TILE_SIZE as usize * format.bytes_per_pixel(); - let mut data = vec![0u8; row_len * TILE_SIZE as usize]; + let mut data = scratch::take_exact(row_len * TILE_SIZE as usize, 0u8); if pixel.iter().any(|&b| b != 0) { // One prebuilt row copied per tile row: 256 bulk copies instead of one // copy_from_slice per pixel. @@ -97,15 +114,38 @@ impl Tile { out.copy_from_slice(&row); } } - Tile { data: data.into_boxed_slice() } + Tile::from_vec(data) } - pub fn bytes(&self) -> &[u8] { - &self.data + fn from_vec(data: Vec) -> Self { + Tile { cell: spill::Cell::new(data) } } - pub fn bytes_mut(&mut self) -> &mut [u8] { - &mut self.data + /// The tile's bytes (read back from the scratch disk if needed). + pub fn bytes(&self) -> TileBytes<'_> { + let guard = self.cell.read(); + let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; + TileBytes(guard, placeholder) + } + pub fn bytes_mut(&mut self) -> TileBytesMut<'_> { + let guard = self.cell.write(); + let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; + TileBytesMut(guard, placeholder) + } + /// `Err` while this tile's scratch copy is unreadable (its bytes read as a placeholder). + /// Free when no tile in the process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + self.cell.read_error().map_or(Ok(()), Err) + } + /// Fallible pixel access for callers that can propagate a scratch read failure. + pub fn try_bytes(&self) -> Result, String> { + let bytes = self.bytes(); + match self.cell.read_error() { + Some(error) => Err(error), + None => Ok(bytes), + } } - /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels that differ from the encoded /// pixel `dp` (one pixel of the tile's format), or `None` when every pixel equals it (or `dp` /// is empty). @@ -121,7 +161,7 @@ impl Tile { // 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() { + for (y, row) in self.bytes().chunks_exact(ts * bpp).enumerate() { let Some(first) = first_difference(row, &blank) else { continue }; let Some(last) = last_difference(row, &blank) else { continue }; x0 = x0.min(first / bpp); @@ -131,6 +171,67 @@ impl Tile { } (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) } + /// Byte length, without touching the pixels (they may be on disk). + pub fn len(&self) -> usize { + self.cell.len() + } + pub fn is_empty(&self) -> bool { + self.cell.len() == 0 + } +} + +impl Clone for Tile { + fn clone(&self) -> Self { + match self.try_bytes() { + Ok(bytes) => { + let mut data = scratch::take_exact(bytes.len(), 0u8); + data.copy_from_slice(&bytes); + Tile::from_vec(data) + } + // Preserve the original backing; the transaction will reject this poisoned tile. + Err(_) => Tile { cell: self.cell.clone() }, + } + } +} + +impl PartialEq for Tile { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.cell, &other.cell) || (self.len() == other.len() && *self.bytes() == *other.bytes()) + } +} + +impl Eq for Tile {} + +impl std::fmt::Debug for Tile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tile").field("len", &self.len()).finish_non_exhaustive() + } +} + +/// Read guard over a tile's bytes; derefs to `[u8]`. +pub struct TileBytes<'a>(RwLockReadGuard<'a, Option>>, Vec); + +impl Deref for TileBytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0.as_deref().unwrap_or(&self.1) + } +} + +/// Write guard over a tile's bytes; derefs to `[u8]`. +pub struct TileBytesMut<'a>(RwLockWriteGuard<'a, Option>>, Vec); + +impl Deref for TileBytesMut<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0.as_deref().unwrap_or(&self.1) + } +} + +impl DerefMut for TileBytesMut<'_> { + fn deref_mut(&mut self) -> &mut [u8] { + self.0.as_deref_mut().unwrap_or(&mut self.1) + } } /// Bytes compared at once while looking for a difference in a tile row. @@ -195,12 +296,15 @@ impl UniformTiles { if let Some(k) = self.of.get(&key) { return *k; } - let mut px = t.data.chunks_exact(bpp); - let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.data.get(..bpp) == Some(p0)) { - Some(k) => k, - None => { - self.canonical.push(t.clone()); - self.canonical.len() - 1 + let bytes = t.try_bytes().ok()?; + let mut px = bytes.chunks_exact(bpp); + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| { + match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } } }); self.of.insert(key, k); @@ -220,12 +324,13 @@ fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: u // Convert contiguous channel samples a row at a time. Dispatching on depth here removes the // per-pixel channel loop and lets the compiler vectorise conversion. let span = tr.width() as usize * n; + let mut target = t.bytes_mut(); for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; let src = &data[o..o + span]; let bytes = sample.bytes(); - let dst = &mut t.data[base * bytes..(base + span) * bytes]; + let dst = &mut target[base * bytes..(base + span) * bytes]; match sample { SampleType::U8 => { for (d, v) in dst.iter_mut().zip(src) { @@ -263,17 +368,49 @@ fn convert_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, data: &[f32], w: } } -fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { +/// Copies the rows of `r` (interleaved `bytes`, `w` px wide, `bpp` bytes per pixel) into one +/// tile, first copying it fallibly when an undo snapshot still shares it. +fn copy_tile(tc: TileCoord, t: &mut Arc, r: Rect, bytes: &[u8], w: usize, bpp: usize) -> Result<(), AllocationError> { + if Arc::get_mut(t).is_none() { + *t = Arc::new(t.try_clone()?); + } + let t = Arc::get_mut(t).ok_or(AllocationError::SizeOverflow)?; + let mut target = t.bytes_mut(); + let tr = tc.rect().intersect(&r); + let span = tr.width() as usize * bpp; + for y in tr.y0..tr.y1 { + let src = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; + let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; + let (Some(d), Some(s)) = (target.get_mut(dst..dst + span), bytes.get(src..src + span)) else { return Err(AllocationError::SizeOverflow) }; + d.copy_from_slice(s); + } + Ok(()) +} + +/// The tiles of an interleaved write, on all cores: each writes only its own bytes. +#[cfg(not(target_arch = "wasm32"))] +fn copy_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, bytes: &[u8], w: usize, bpp: usize) -> Result<(), AllocationError> { + use rayon::prelude::*; + tiles.par_iter_mut().try_for_each(|(tc, t)| copy_tile(*tc, t, r, bytes, w, bpp)) +} + +/// Serial counterpart, for the web build. +#[cfg(target_arch = "wasm32")] +fn copy_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, bytes: &[u8], w: usize, bpp: usize) -> Result<(), AllocationError> { + tiles.iter_mut().try_for_each(|(tc, t)| copy_tile(*tc, t, r, bytes, w, bpp)) +} + +fn decode_tile_row_rgba(fmt: &PixelFormat, t: &[u8], base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { let n = fmt.channels(); match (fmt.mode, fmt.sample, fmt.alpha) { (ColorMode::Rgb, SampleType::U8, true) => { - let src = &t.data[base..base + dst.len() * 4]; + let src = &t[base..base + dst.len() * 4]; for (d, s) in dst.iter_mut().zip(src.as_chunks::<4>().0) { *d = [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, s[3] as f32 / 255.0]; } } (ColorMode::Grayscale, SampleType::U8, true) => { - let src = &t.data[base..base + dst.len() * 2]; + let src = &t[base..base + dst.len() * 2]; for (d, s) in dst.iter_mut().zip(src.as_chunks::<2>().0) { let g = s[0] as f32 / 255.0; *d = [g, g, g, s[1] as f32 / 255.0]; @@ -282,7 +419,7 @@ fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f3 (ColorMode::Cmyk, SampleType::U8, _) => { // Identical ink bytes share the exact ICC result; alpha is independent. let mut previous: Option<([u8; 4], [f32; 3])> = None; - let src = &t.data[base..base + dst.len() * n]; + let src = &t[base..base + dst.len() * n]; for (d, s) in dst.iter_mut().zip(src.chunks_exact(n)) { let ink = [s[0], s[1], s[2], s[3]]; let rgb = match previous { @@ -301,7 +438,7 @@ fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f3 // 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, row); + read_samples(t, fmt.sample, base, 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 { @@ -319,13 +456,31 @@ impl Surface { pub fn try_read_region(&self, r: Rect) -> Result, AllocationError> { let len = (r.width() as usize).checked_mul(r.height() as usize).and_then(|n| n.checked_mul(self.channels())).ok_or(AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(AllocationError::SizeOverflow)?; - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - out.resize(len, 0.0); + let mut out = scratch::try_take_exact(len, 0.0f32).ok_or(AllocationError::NotEnoughMemory { bytes })?; + let generation = spill::read_error_generation(); self.read_region_into(r, &mut out); + spill::check_since(generation).map_err(AllocationError::ScratchRead)?; + self.region_health(r).map_err(AllocationError::ScratchRead)?; Ok(out) } + /// `Err` if any stored tile of this surface is unreadable. Free when no tile in the + /// process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + self.tiles.values().try_for_each(|t| t.scratch_health()) + } + + /// [`Surface::scratch_health`] for the tiles overlapping `r` only. + pub fn region_health(&self, r: Rect) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + r.tiles().filter_map(|c| self.tiles.get(&c)).try_for_each(|t| t.scratch_health()) + } + /// Reads a region as encoded interleaved bytes with fallible allocation. pub fn try_to_interleaved(&self, r: Rect) -> Result, AllocationError> { let bpp = self.format.bytes_per_pixel(); @@ -342,7 +497,7 @@ impl Surface { match self.tiles.get(&tc) { Some(t) => { let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + out[dst..dst + span].copy_from_slice(&t.try_bytes().map_err(AllocationError::ScratchRead)?[src..src + span]); } None => { for px in out[dst..dst + span].chunks_exact_mut(bpp) { @@ -406,6 +561,9 @@ impl Surface { /// Fallible counterpart to [`Surface::tile_mut`]. Pixel storage is reserved before changing /// the surface, and a shared tile is copied only after its replacement has been allocated. pub fn try_tile_mut(&mut self, c: TileCoord) -> Result<&mut Tile, AllocationError> { + if let Some(tile) = self.tiles.get(&c) { + drop(tile.try_bytes().map_err(AllocationError::ScratchRead)?); + } if !self.tiles.contains_key(&c) { let tile = Arc::new(Tile::try_filled(&self.format, &self.default_pixel)?); self.tiles.insert(c, tile); @@ -439,18 +597,41 @@ impl Surface { for tc in r.tiles() { let tr = tc.rect().intersect(&r); let t = self.try_tile_mut(tc)?; + let mut target = t.bytes_mut(); let span = tr.width() as usize * n; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * r.width() as usize + (tr.x0 - r.x0) as usize) * n; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; for (i, v) in data[src..src + span].iter().enumerate() { - write_sample(&mut t.data, sample, dst + i, *v); + write_sample(&mut target, sample, dst + i, *v); } } } Ok(()) } + /// Tile rects that differ between `self` and `other`: tiles present in only one, or holding + /// a different (copied-on-write) tile. Compares pointers, never pixels, so it is cheap and + /// never reads spilled tiles back. `None` when the format or default pixel differ (every + /// pixel may read differently). + pub fn changed_rect(&self, other: &Surface) -> Option { + if self.format != other.format || self.default_pixel != other.default_pixel { + return None; + } + let mut r = Rect::EMPTY; + for (c, t) in &self.tiles { + if other.tiles.get(c).is_none_or(|o| !Arc::ptr_eq(t, o)) { + r = r.union(&c.rect()); + } + } + for c in other.tiles.keys() { + if !self.tiles.contains_key(c) { + r = r.union(&c.rect()); + } + } + Some(r) + } + /// Coarse bounds: union of allocated tile rects. pub fn tile_bounds(&self) -> Rect { self.tiles.keys().fold(Rect::EMPTY, |r, c| r.union(&c.rect())) @@ -491,14 +672,17 @@ impl Surface { let bpp = self.format.bytes_per_pixel(); let mut saved = 0; for t in self.tiles.values_mut() { - let mut px = t.data.chunks_exact(bpp); + let Ok(bytes) = t.try_bytes() else { continue }; + let mut px = bytes.chunks_exact(bpp); let Some(p0) = px.next() else { continue }; if !px.all(|p| p == p0) { continue; } - let shared = interner.tiles.entry(t.data.len()).or_default().entry(p0.to_vec()).or_insert_with(|| t.clone()); + let p0 = p0.to_vec(); + drop(bytes); + let shared = interner.tiles.entry(t.len()).or_default().entry(p0).or_insert_with(|| t.clone()); if !Arc::ptr_eq(shared, t) { - saved += t.data.len(); + saved += t.len(); *t = shared.clone(); } } @@ -509,7 +693,7 @@ impl Surface { pub fn prune(&mut self) { let dp = self.default_pixel.as_ref(); let bpp = dp.len(); - self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != dp)); + self.tiles.retain(|_, t| t.bytes().chunks_exact(bpp).any(|px| px != dp)); } #[inline] @@ -532,8 +716,9 @@ impl Surface { let n = self.channels(); match self.tiles.get(&c) { Some(t) => { + let data = t.bytes(); for (i, o) in out.iter_mut().enumerate().take(n) { - *o = read_sample(&t.data, self.format.sample, base + i); + *o = read_sample(&data, self.format.sample, base + i); } } None => { @@ -548,9 +733,9 @@ impl Surface { let (c, base) = self.locate(x, y); let n = self.channels(); let sample = self.format.sample; - let t = self.tile_mut(c); + let mut t = self.tile_mut(c).bytes_mut(); for (i, v) in px.iter().enumerate().take(n) { - write_sample(&mut t.data, sample, base + i, *v); + write_sample(&mut t, sample, base + i, *v); } } @@ -559,7 +744,7 @@ impl Surface { pub fn sample_channel(&self, x: i32, y: i32, c: usize) -> f32 { let (tc, base) = self.locate(x, y); match self.tiles.get(&tc) { - Some(t) => read_sample(&t.data, self.format.sample, base + c), + Some(t) => read_sample(&t.bytes(), self.format.sample, base + c), None => read_sample(&self.default_pixel, self.format.sample, c), } } @@ -589,9 +774,10 @@ impl Surface { match self.tiles.get(&TileCoord { tx: tx as i32, ty }) { None => out[i..end].fill(dp), Some(t) => { + let data = t.bytes(); for (k, o) in out[i..end].iter_mut().enumerate() { let lx = (x + k as i64 * step - tx * i64::from(TILE_SIZE)) as usize; - *o = read_sample(&t.data, self.format.sample, (ly * TILE_SIZE as usize + lx) * n + c); + *o = read_sample(&data, self.format.sample, (ly * TILE_SIZE as usize + lx) * n + c); } } } @@ -619,10 +805,11 @@ impl Surface { let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); - if tile.is_none() && zero { + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + if guard.is_none() && zero { continue; } + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; @@ -630,7 +817,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; - read_samples(&t.data, self.format.sample, base, dst); + read_samples(t, self.format.sample, base, dst); } None if n == 1 => dst.fill(dp[0]), None => { @@ -658,7 +845,8 @@ impl Surface { let mut row = Vec::new(); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = ((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize; let dst = &mut out[o..o + tr.width() as usize]; @@ -673,7 +861,7 @@ impl Surface { } /// Reads `r` as straight 8-bit RGBA into `out` (`w*h` entries). RGBA8 surfaces copy tile bytes - /// directly; other formats convert through [`Surface::read_rgba_into`] one tile row at a time. + /// directly; other formats convert while holding the same tile read guard. pub fn read_rgba8_into(&self, r: Rect, out: &mut [[u8; 4]]) { let w = r.width() as usize; debug_assert_eq!(out.len(), w * r.height() as usize); @@ -684,10 +872,11 @@ impl Surface { let mut heap = Vec::new(); let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; - let mut row = Vec::new(); + let mut px = [0.0f32; 8]; for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = ((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize; let dst = &mut out[o..o + tr.width() as usize]; @@ -696,14 +885,19 @@ impl Surface { Some(t) if rgba8 => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * 4; let len = dst.len(); - for (d, s) in dst.iter_mut().zip(t.data[base..base + len * 4].as_chunks::<4>().0) { + for (d, s) in dst.iter_mut().zip(t[base..base + len * 4].as_chunks::<4>().0) { *d = [s[0], s[1], s[2], s[3]]; } } - Some(_) => { - row.resize(dst.len(), [0.0f32; 4]); - self.read_rgba_into(Rect::new(tr.x0, y, tr.x1, y + 1), &mut row); - for (d, p) in dst.iter_mut().zip(&row) { + Some(t) => { + // A second read lock could deadlock behind an eviction writer. + let n = fmt.channels(); + 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() { + for (c, v) in px.iter_mut().enumerate().take(n) { + *v = read_sample(t, fmt.sample, base + i * n + c); + } + let p = to_rgba(&fmt, &px[..n]); *d = [q(p[0]), q(p[1]), q(p[2]), q(p[3])]; } } @@ -722,7 +916,7 @@ impl Surface { pub fn read_region(&self, r: Rect) -> Vec { let n = self.channels(); let w = r.width() as usize; - let mut out = vec![0.0f32; w * r.height() as usize * n]; + let mut out = scratch::take(w * r.height() as usize * n, 0.0f32); self.read_samples_into(r, &mut out); out } @@ -836,12 +1030,12 @@ impl Surface { self.tiles.insert(tc, t.clone()); continue; } - let t = self.tile_mut(tc); + let mut t = self.tile_mut(tc).bytes_mut(); for y in tr.y0..tr.y1 { let ly = (y - tc.ty * TILE_SIZE) as usize; let lx0 = (tr.x0 - tc.tx * TILE_SIZE) as usize; let lx1 = (tr.x1 - tc.tx * TILE_SIZE) as usize; - let row = &mut t.data[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; + let row = &mut t[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; for p in row.chunks_exact_mut(bpp) { p.copy_from_slice(&enc); } @@ -863,30 +1057,49 @@ impl Surface { assert_eq!(bytes.len(), w * r.height() as usize * bpp, "interleaved length mismatch"); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let t = self.tile_mut(tc); + let mut t = self.tile_mut(tc).bytes_mut(); let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - t.data[dst..dst + span].copy_from_slice(&bytes[src..src + span]); + t[dst..dst + span].copy_from_slice(&bytes[src..src + span]); } } } + /// Fallible counterpart to [`Surface::write_interleaved`], writing the tiles on all cores + /// (native targets): a length that doesn't match `r` is [`AllocationError::LengthMismatch`], + /// not a panic, and a tile that can't be allocated stops the write with + /// [`AllocationError::NotEnoughMemory`] (the tiles written so far keep their new pixels). + /// + /// This enters Rayon, as [`Surface::write_region_par`] does. + pub fn try_write_interleaved(&mut self, r: Rect, bytes: &[u8]) -> Result<(), AllocationError> { + let bpp = self.format.bytes_per_pixel(); + let w = r.width() as usize; + let expected = w.checked_mul(r.height() as usize).and_then(|v| v.checked_mul(bpp)).ok_or(AllocationError::SizeOverflow)?; + if bytes.len() != expected { + return Err(AllocationError::LengthMismatch { expected, got: bytes.len() }); + } + let mut tiles = self.try_take_tiles(r)?; + let written = copy_tiles(&mut tiles, r, bytes, w, bpp); + self.put_tiles(tiles); + written + } + /// Read `r` as interleaved encoded bytes in the surface's format. pub fn to_interleaved(&self, r: Rect) -> Vec { let bpp = self.format.bytes_per_pixel(); let w = r.width() as usize; - let mut out = vec![0u8; w * r.height() as usize * bpp]; + let mut out = scratch::take(w * r.height() as usize * bpp, 0u8); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let dst = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; - match self.tiles.get(&tc) { + match self.tiles.get(&tc).map(|t| t.bytes()) { Some(t) => { let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + out[dst..dst + span].copy_from_slice(&t[src..src + span]); } None => { for p in out[dst..dst + span].chunks_exact_mut(bpp) { @@ -953,7 +1166,8 @@ 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. - return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared)); + let keep = shared.bytes().get(..bpp) != Some(&*self.default_pixel); + return keep.then_some((dc, shared)); } // The identities of the source tiles under a covered window (the offset is the same // for every target of this call). @@ -964,7 +1178,7 @@ impl Surface { return hit.clone().map(|t| (dc, t)); } - let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() }; + let mut data = scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8); let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); let row_len = TILE_SIZE as usize * bpp; // Per stored row: copy the source span that falls inside the content in as few @@ -972,7 +1186,7 @@ impl Surface { // row — no whole-tile pre-fill, which rewrote nearly every byte twice. for local_y in 0..i64::from(TILE_SIZE) { let y = ty0 + local_y; - let Some(row) = tile.data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; + let Some(row) = data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); // The row's target columns whose SOURCE lies inside the content are copied // (the copy loop crosses source tiles as needed); the rest stays default — @@ -998,7 +1212,7 @@ impl Surface { let mut x = cx0.max(tx0); let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); // Rows run over the same one or two source tiles: cache the last lookup. - let mut cached: Option<(i64, i64, &std::sync::Arc)> = None; + let mut cached: Option<(i64, i64, TileBytes<'_>)> = None; while x < x_end { // The source document position this target column reads, and the source // tile it lives in (the shift crosses source tiles as x runs). @@ -1007,16 +1221,9 @@ impl Surface { // A missing source tile inside the content holds the default pixel, not // zeros: fill the span from the default row instead of skipping it // (echelon's white-mask-with-sparse-tiles case). - let st = match cached.take() { - Some((ctx, cty, t)) if ctx == tx && cty == ty => { - cached = Some((ctx, cty, t)); - t - } - _ => match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { - Some(t) => { - cached = Some((tx, ty, t)); - t - } + if !matches!(&cached, Some((ctx, cty, _)) if *ctx == tx && *cty == ty) { + match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { + Some(t) => cached = Some((tx, ty, t.bytes())), None => { let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); if !fill_row.is_empty() { @@ -1029,13 +1236,14 @@ impl Surface { x = span_end; continue; } - }, - }; + } + } + let Some((_, _, st)) = &cached else { break }; let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); let span = (span_end - x) as usize * bpp; let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; let dst = (x - tx0) as usize * bpp; - if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.data.get(src..src + span)) { + if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.get(src..src + span)) { d.copy_from_slice(sr); } x = span_end; @@ -1043,7 +1251,12 @@ 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. - let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile)); + let finished = if data.chunks(default_row.len()).any(|row| row != default_row) { + Some(Arc::new(Tile::from_vec(data))) + } else { + scratch::give(data); + None + }; // Workers that missed the memo at the same time each built a copy: all of them take // the one stored first, so the targets share it however many threads raced. let finished = match memo_key { @@ -1088,13 +1301,15 @@ impl Surface { continue; } let tile = self.tile_mut(c); + let mut bytes = tile.bytes_mut(); for i in 0..px_count { for k in 0..color { let at = i * n + k; - let v = read_sample(&tile.data, format.sample, at); - write_sample(&mut tile.data, format.sample, at, f(v)); + let v = read_sample(&bytes, format.sample, at); + write_sample(&mut bytes, format.sample, at, f(v)); } } + drop(bytes); if let Some(mapped) = self.tiles.get(&c) { done.insert(key, mapped.clone()); } @@ -1113,17 +1328,19 @@ impl Surface { out.tiles.insert(*c, converted.clone()); continue; } - let dst = out.tile_mut(*c); + let source = t.bytes(); + let mut dst = out.tile_mut(*c).bytes_mut(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { for (k, v) in src.iter_mut().enumerate() { - *v = read_sample(&t.data, self.format.sample, i * from_n + k); + *v = read_sample(&source, self.format.sample, i * from_n + k); } let px = convert_pixel(&self.format, &to, &src); for (k, v) in px.iter().enumerate() { - write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + write_sample(&mut dst, to.sample, i * to.channels() + k, *v); } } + drop(dst); if let Some(converted) = out.tiles.get(c) { done.insert(Arc::as_ptr(t), converted.clone()); } @@ -1145,16 +1362,19 @@ impl Surface { continue; } let dst = out.try_tile_mut(*c)?; + let source_bytes = t.try_bytes().map_err(AllocationError::ScratchRead)?; + let mut dest_bytes = dst.bytes_mut(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { for (k, v) in src.iter_mut().enumerate() { - *v = read_sample(&t.data, self.format.sample, i * from_n + k); + *v = read_sample(&source_bytes, self.format.sample, i * from_n + k); } let px = convert_pixel(&self.format, &to, &src); for (k, v) in px.iter().enumerate() { - write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + write_sample(&mut dest_bytes, to.sample, i * to.channels() + k, *v); } } + drop(dest_bytes); if let Some(converted) = out.tiles.get(c) { done.insert(Arc::as_ptr(t), converted.clone()); } diff --git a/crates/raster/src/memory.rs b/crates/raster/src/memory.rs new file mode 100644 index 0000000000..b996bd3152 --- /dev/null +++ b/crates/raster/src/memory.rs @@ -0,0 +1,240 @@ +//! Process-wide memory admission and adaptive tile residency. Native measurements are safe +//! `sysinfo` calls; web clients retain their explicit MB limit without filesystem assumptions. + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; + +pub mod system; + +const MIB: u64 = 1 << 20; + +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub total_bytes: u64, + pub available_bytes: u64, + pub process_resident_bytes: u64, + /// Address-space/virtual usage; this is not a portable measure of committed memory. + pub process_virtual_bytes: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct Policy { + /// None keeps the pre-streaming absolute MB setting. + pub percent: Option, + pub absolute_bytes: u64, +} + +impl Default for Policy { + fn default() -> Self { + Self { percent: None, absolute_bytes: 8 << 30 } + } +} + +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Budgets { + pub process_ceiling_bytes: u64, + pub effective_process_bytes: u64, + pub tile_bytes: u64, + pub transient_bytes: u64, +} + +/// Other applications and untiled buffers count against the same ceiling. Keep both system +/// headroom and a working reserve; virtual address-space size is deliberately not treated as RSS. +pub fn budgets(policy: Policy, sample: Snapshot, resident_tiles: u64) -> Budgets { + let ceiling = match (policy.percent, sample.total_bytes) { + (Some(p), total) if total > 0 => total / 100 * u64::from(p.clamp(1, 95)), + _ => policy.absolute_bytes, + } + .max(MIB); + let headroom = (sample.total_bytes / 20).max(256 * MIB); + let effective = if sample.total_bytes == 0 { + ceiling + } else { + ceiling.min(sample.process_resident_bytes.saturating_add(sample.available_bytes).saturating_sub(headroom)) + } + .max(MIB); + let transient = (effective / 8).clamp(MIB, 2 << 30); + let other = sample.process_resident_bytes.saturating_sub(resident_tiles); + let tiles = effective.saturating_sub(other).saturating_sub(transient).max(MIB); + Budgets { process_ceiling_bytes: ceiling, effective_process_bytes: effective, tile_bytes: tiles, transient_bytes: transient } +} + +static POLICY: Mutex = Mutex::new(Policy { percent: None, absolute_bytes: 8 << 30 }); +static CURRENT: Mutex<(Snapshot, Budgets)> = Mutex::new(( + Snapshot { total_bytes: 0, available_bytes: 0, process_resident_bytes: 0, process_virtual_bytes: 0 }, + Budgets { process_ceiling_bytes: 8 << 30, effective_process_bytes: 8 << 30, tile_bytes: 7 << 30, transient_bytes: 1 << 30 }, +)); +static RESERVED: AtomicU64 = AtomicU64::new(0); +static CONFIGURATION: AtomicU64 = AtomicU64::new(0); + +pub fn configuration_generation() -> u64 { + CONFIGURATION.load(Ordering::Relaxed) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn measure() -> Snapshot { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + static SYSTEM: OnceLock>> = OnceLock::new(); + let mut guard = SYSTEM.get_or_init(|| Mutex::new(System::new().ok())).lock().unwrap_or_else(PoisonError::into_inner); + // No provider: an unknown total keeps the configured ceiling (see [`budgets`]). + let Some(s) = guard.as_mut() else { return Snapshot::default() }; + s.refresh_memory(); + let pid = Pid::from_u32(std::process::id()); + s.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), true, ProcessRefreshKind::nothing().with_memory()); + // The shared detector bounds host memory by the process's cgroup and, on macOS, counts only + // reclaimable pages. A constraint it could not read keeps the host figure here: the tile + // budget only decides what to spill, and a fail-closed zero would stall every operation. + let available = match system::current() { + system::SystemMemory { available_ram: Some(ram), constraint_unreadable: false, .. } => ram, + _ => s.available_memory(), + }; + Snapshot { + total_bytes: s.total_memory(), + available_bytes: available, + process_resident_bytes: s.process(pid).map_or(0, |p| p.memory()), + process_virtual_bytes: s.process(pid).map_or(0, |p| p.virtual_memory()), + } +} + +#[cfg(target_arch = "wasm32")] +pub fn measure() -> Snapshot { + Snapshot::default() +} + +pub fn refresh() -> Budgets { + let policy = *POLICY.lock().unwrap_or_else(PoisonError::into_inner); + let sample = measure(); + let b = budgets(policy, sample, crate::spill::stats().resident_bytes as u64); + *CURRENT.lock().unwrap_or_else(PoisonError::into_inner) = (sample, b); + // The test override remains fixed even when process pressure changes. + let tiles = std::env::var("PHOTOCRAFT_TILE_BUDGET_MB").ok().and_then(|s| s.parse::().ok()).map_or(b.tile_bytes, |mb| mb.saturating_mul(MIB)); + crate::spill::set_budget(usize::try_from(tiles).unwrap_or(usize::MAX)); + b +} + +pub fn configure(policy: Policy) -> Budgets { + *POLICY.lock().unwrap_or_else(PoisonError::into_inner) = policy; + CONFIGURATION.fetch_add(1, Ordering::Relaxed); + refresh() +} + +pub fn stats() -> (Snapshot, Budgets, u64) { + let (s, b) = *CURRENT.lock().unwrap_or_else(PoisonError::into_inner); + (s, b, RESERVED.load(Ordering::Relaxed)) +} + +/// A bounded temporary working set. Callers retain the guard until the allocation is released. +pub struct Reservation(u64); + +pub fn try_reserve(bytes: u64) -> Result { + let (_, b, _) = stats(); + RESERVED + .try_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= b.transient_bytes)) + .map_err(|_| "not enough working memory; reduce the operation or wait for other jobs".to_string())?; + Ok(Reservation(bytes)) +} + +/// Admit a legacy operation that needs a whole buffer, including concurrent working sets and +/// the current process RSS. Streaming workers keep the smaller [`try_reserve`] allowance. +/// This deliberately refuses work before an allocation rather than relying on OS paging. +pub fn try_reserve_operation(bytes: u64) -> Result { + let b = refresh(); + let (sample, _, _) = stats(); + // Resident tiles are evictable when spilling is on, so only the untiled part of RSS is + // charged. The transient allowance is always admissible: it is what the budget reserves + // for working sets, so normal-sized operations are never refused by a high RSS sample. + let evictable = if crate::spill::enabled() { crate::spill::stats().resident_bytes as u64 } else { 0 }; + let untiled = sample.process_resident_bytes.saturating_sub(evictable); + let available = if sample.total_bytes == 0 { b.transient_bytes } else { b.effective_process_bytes.saturating_sub(untiled).max(b.transient_bytes) }; + let refused = || "not enough working memory for this operation; reduce its region or use a streaming format".to_string(); + if bytes > available { + return Err(refused()); + } + // It fits on its own: wait (bounded) for concurrent operations to return their working + // sets instead of failing an operation that only overlapped another one. + #[cfg(not(target_arch = "wasm32"))] + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if RESERVED.try_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= available)).is_ok() { + return Ok(Reservation(bytes)); + } + #[cfg(target_arch = "wasm32")] + return Err(refused()); + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::Instant::now(); + if now >= deadline { + return Err(refused()); + } + let guard = RELEASED.0.lock().unwrap_or_else(PoisonError::into_inner); + // Re-checked after the wake-up; a missed notification costs at most this slice. + let slice = deadline.saturating_duration_since(now).min(std::time::Duration::from_millis(50)); + drop(RELEASED.1.wait_timeout(guard, slice).unwrap_or_else(PoisonError::into_inner)); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +static RELEASED: (Mutex<()>, std::sync::Condvar) = (Mutex::new(()), std::sync::Condvar::new()); + +impl Drop for Reservation { + fn drop(&mut self) { + RESERVED.fetch_sub(self.0, Ordering::AcqRel); + #[cfg(not(target_arch = "wasm32"))] + { + let _guard = RELEASED.0.lock().unwrap_or_else(PoisonError::into_inner); + RELEASED.1.notify_all(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ceiling_includes_non_tile_memory_and_other_apps() { + let s = Snapshot { total_bytes: 64 << 30, available_bytes: 10 << 30, process_resident_bytes: 20 << 30, process_virtual_bytes: 100 << 30 }; + let b = budgets(Policy { percent: Some(90), absolute_bytes: 8 << 30 }, s, 15 << 30); + assert!(b.process_ceiling_bytes > 57 << 30); + assert!(b.effective_process_bytes < 30 << 30); + assert!(b.tile_bytes + (5 << 30) + b.transient_bytes <= b.effective_process_bytes); + } + #[test] + fn old_absolute_setting_and_missing_measurements_work() { + let b = budgets(Policy { percent: None, absolute_bytes: 512 * MIB }, Snapshot::default(), 0); + assert_eq!(b.process_ceiling_bytes, 512 * MIB); + assert_eq!(b.tile_bytes + b.transient_bytes, b.effective_process_bytes); + } + #[test] + fn hostile_percent_and_pressure_do_not_overflow() { + let b = budgets(Policy { percent: Some(255), absolute_bytes: u64::MAX }, Snapshot { total_bytes: u64::MAX, ..Snapshot::default() }, u64::MAX); + assert!(b.process_ceiling_bytes < u64::MAX); + assert_eq!(b.tile_bytes, MIB); + } + #[test] + fn an_operation_that_fits_alone_waits_for_a_concurrent_one() { + let available = refresh().transient_bytes; + let first = try_reserve_operation(available / 2 + 1).unwrap(); + let releaser = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + drop(first); + }); + let second = try_reserve_operation(available / 2 + 1); + releaser.join().unwrap(); + assert!(second.is_ok(), "overlapping work is serialized, not refused"); + assert!(try_reserve_operation(u64::MAX).is_err(), "work that can never fit is refused at once"); + } + #[test] + fn reservations_are_bounded_and_returned() { + let before = stats().2; + let r = try_reserve(1024).unwrap(); + assert_eq!(stats().2, before + 1024); + assert!(try_reserve(u64::MAX).is_err()); + drop(r); + assert_eq!(stats().2, before); + } +} diff --git a/crates/raster/src/memory/system.rs b/crates/raster/src/memory/system.rs new file mode 100644 index 0000000000..ac2e4b772b --- /dev/null +++ b/crates/raster/src/memory/system.rs @@ -0,0 +1,1175 @@ +//! Platform measurements of the memory this process can use, shared by the tile residency +//! budget ([`super::measure`]) and the import admission budget (`photocraft_io::memory`), so +//! there is one detector of available RAM. +//! +//! - Linux: `/proc/meminfo` `MemAvailable`, capped by the process's memory cgroup (v2 +//! `memory.max`/`memory.high` and v1 `memory.limit_in_bytes`/`memsw`, with every visible +//! ancestor); a cgroup that exists but cannot be read fails closed. +//! - Windows: `GlobalMemoryStatusEx` available physical memory and commit headroom. +//! - macOS: the reclaimable pool from the VM counters (free, file-backed, purgeable). +//! - Elsewhere (including the web): unknown. +//! +//! Swap is reported separately and never counted as available RAM. + +/// Memory this process can use now, distinguishing physical RAM from swap and commit headroom. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct SystemMemory { + /// Available physical RAM in bytes, bounded by applicable container or process limits. Free + /// swap is excluded. `Some(0)` is zero capacity, not unknown memory. + pub available_ram: Option, + /// Available swap headroom in bytes, if known. + pub swap_free: Option, + /// System or process commit headroom in bytes, if measurable. + pub commit_headroom: Option, + /// A memory constraint exists but could not be read, so `available_ram` is the fail-closed + /// `Some(0)` rather than a measurement. Admission refuses on it; the tile residency budget + /// falls back to the host figure instead, because a zero budget would stall every operation. + pub constraint_unreadable: bool, +} + +impl SystemMemory { + /// Unknown memory measurement. + pub const UNKNOWN: Self = Self { available_ram: None, swap_free: None, commit_headroom: None, constraint_unreadable: false }; +} + +/// Measures [`SystemMemory`] from the platform provider. +pub fn current() -> SystemMemory { + #[cfg(target_os = "linux")] + { + linux::snapshot() + } + #[cfg(windows)] + { + windows_mem::snapshot() + } + #[cfg(target_os = "macos")] + { + macos_mem::snapshot() + } + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] + { + fallback_mem::snapshot() + } +} + +/// Bytes this process can still use without running out, counting free swap, or `None` where +/// that isn't known. On Linux: [`current`]'s available RAM plus free swap (both capped by the +/// memory cgroup). Elsewhere `None`: Windows refuses an allocation past its commit limit, which +/// the importers already report as an error, and macOS pages to disk as it needs. +pub fn available_with_swap() -> Option { + #[cfg(target_os = "linux")] + { + linux::available() + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(target_os = "linux")] +pub(super) mod linux { + use super::SystemMemory; + use std::path::{Path, PathBuf}; + + /// cgroup v1 reports `PAGE_COUNTER_MAX * page_size` for "no limit", which is always close to + /// `i64::MAX`. Any limit at or above 4 EiB is treated as unlimited. + const V1_UNLIMITED: u64 = 1 << 62; + + /// Bound on ancestor walks; cgroup hierarchies are far shallower than this. + const MAX_CGROUP_DEPTH: usize = 128; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum Limit { + Missing, + Unlimited, + Value(u64), + Invalid, + } + + /// What the process's memory cgroup says, independent of host `/proc/meminfo`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(super) enum CgroupLimits { + /// No memory controller in this process's hierarchy: host memory applies. + NotConstrained, + /// Headroom under the memory controller; `None` means that dimension is unlimited. + Limited { ram: Option, swap: Option }, + /// A memory controller exists but its mount, path or limit could not be read. Callers + /// must fail closed instead of falling back to unrestricted host memory. + Unreadable, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct CgroupDir { + leaf: PathBuf, + /// The visible root of the walk (the mount point). + root: PathBuf, + /// Whether `root` is the hierarchy's own root rather than a subtree exposed by a mount. + /// Only the hierarchy root is exempt from exposing its controller interface files. + hierarchy_root: bool, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Membership<'a> { + /// Unified (v2) hierarchy path (`0::`). + v2: Option<&'a str>, + /// v1 hierarchy path for the `memory` controller. + v1_memory: Option<&'a str>, + } + + /// Result of reading `/proc/self/cgroup`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum MembershipParse<'a> { + Parsed(Membership<'a>), + /// A non-empty line could not be understood, so the absence of a memory controller cannot + /// be established. + Malformed, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct Mount { + root: String, + mount_point: String, + fstype: String, + options: String, + super_options: String, + } + + /// Measures available RAM and free swap from `/proc/meminfo`, capped by the process's memory + /// cgroup. Unreadable cgroup constraints fail closed as zero available memory rather than + /// falling back to unrestricted host memory. + pub(super) fn snapshot() -> SystemMemory { + snapshot_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) + } + + /// Provider body with explicit roots, so tests exercise the real filesystem parsing without + /// global mocks. + pub(super) fn snapshot_at(proc_root: &Path, sys_root: &Path) -> SystemMemory { + let info = std::fs::read_to_string(proc_root.join("meminfo")).ok(); + let host_ram = info.as_deref().and_then(|i| meminfo(i, "MemAvailable:")); + let host_swap = info.as_deref().and_then(|i| meminfo(i, "SwapFree:")); + + let (cgroup_ram, cgroup_swap) = match cgroup_limits(proc_root, sys_root) { + CgroupLimits::NotConstrained => (None, None), + CgroupLimits::Limited { ram, swap } => (ram, swap), + CgroupLimits::Unreadable => return SystemMemory { available_ram: Some(0), swap_free: Some(0), commit_headroom: None, constraint_unreadable: true }, + }; + + let available_ram = match (host_ram, cgroup_ram) { + (Some(host), Some(cgroup)) => Some(host.min(cgroup)), + (Some(host), None) => Some(host), + (None, Some(cgroup)) => Some(cgroup), + (None, None) => None, + }; + let swap_free = match (host_swap, cgroup_swap) { + (Some(host), Some(cgroup)) => Some(host.min(cgroup)), + (Some(host), None) => Some(host), + (None, Some(cgroup)) => Some(cgroup), + (None, None) => None, + }; + SystemMemory { available_ram, swap_free, commit_headroom: None, constraint_unreadable: false } + } + + pub(super) fn available() -> Option { + available_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) + } + + /// Legacy RAM-plus-swap helper with explicit roots. Its meaning is deliberately unchanged: + /// the adaptive budget excludes swap, but flat and layered TIFF conversion still count it + /// (now bounded by the cgroup figures derived above, including v1 `memsw`). + pub(super) fn available_at(proc_root: &Path, sys_root: &Path) -> Option { + let snap = snapshot_at(proc_root, sys_root); + snap.available_ram.map(|ram| ram.saturating_add(snap.swap_free.unwrap_or(0))) + } + + /// A `/proc/meminfo` field in bytes. + pub(super) fn meminfo(info: &str, field: &str) -> Option { + let kb: u64 = info.lines().find_map(|l| l.strip_prefix(field))?.trim().trim_end_matches("kB").trim().parse().ok()?; + kb.checked_mul(1024) + } + + /// Resolves the memory cgroup from `/proc/self/cgroup` plus `/proc/self/mountinfo`. + /// + /// v1 is preferred when the `memory` controller appears there; otherwise the unified v2 + /// hierarchy is used. A memory controller that cannot be resolved or read, or membership that + /// cannot be parsed, is [`CgroupLimits::Unreadable`], never host memory. Ancestors above a + /// cgroup namespace's root are not visible to an unprivileged reader (the kernel still + /// enforces them), so only visible ancestors can be accounted for. The widest visible view of + /// the hierarchy is used, not merely the deepest matching mount point. + pub(super) fn cgroup_limits(proc_root: &Path, sys_root: &Path) -> CgroupLimits { + let Ok(groups) = std::fs::read_to_string(proc_root.join("self/cgroup")) else { + // Without membership we cannot prove that no memory cgroup applies. + return CgroupLimits::Unreadable; + }; + let membership = match parse_membership(&groups) { + MembershipParse::Parsed(membership) => membership, + MembershipParse::Malformed => return CgroupLimits::Unreadable, + }; + let mountinfo = std::fs::read_to_string(proc_root.join("self/mountinfo")).ok(); + + if let Some(path) = membership.v1_memory { + let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup", Some("memory")) else { + return CgroupLimits::Unreadable; + }; + return v1_limits(&dir); + } + if let Some(path) = membership.v2 { + let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup2", None) else { + return CgroupLimits::Unreadable; + }; + return v2_limits(&dir); + } + CgroupLimits::NotConstrained + } + + /// Parses `/proc/self/cgroup`. + /// + /// Kernel format is `::`; the + /// unified hierarchy uses `0::`. A line that does not have that shape makes the whole + /// file untrustworthy: returning "no memory controller" from a partial parse would silently + /// fall back to unrestricted host memory, so an empty or malformed file is reported as + /// malformed instead. + fn parse_membership(text: &str) -> MembershipParse<'_> { + let mut membership = Membership { v2: None, v1_memory: None }; + let mut saw_line = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + saw_line = true; + let mut fields = line.splitn(3, ':'); + let (Some(hierarchy), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { + return MembershipParse::Malformed; + }; + let Ok(hierarchy) = hierarchy.parse::() else { return MembershipParse::Malformed }; + if !path.starts_with('/') { + return MembershipParse::Malformed; + } + if hierarchy == 0 { + if !controllers.is_empty() { + return MembershipParse::Malformed; + } + membership.v2.get_or_insert(path); + } else if controllers.is_empty() { + return MembershipParse::Malformed; + } else if controllers.split(',').any(|c| c.trim() == "memory") { + membership.v1_memory.get_or_insert(path); + } + } + if !saw_line { + return MembershipParse::Malformed; + } + MembershipParse::Parsed(membership) + } + + /// Parses `/proc/self/mountinfo` into the mount fields used to locate a cgroup hierarchy. + /// + /// Malformed lines are skipped; callers fail closed when no matching mount is found. + fn parse_mountinfo(text: &str) -> Vec { + let mut mounts = Vec::new(); + for line in text.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + let Some(separator) = fields.iter().position(|f| *f == "-") else { continue }; + let (Some(root), Some(point), Some(fstype), Some(super_options)) = + (fields.get(3).copied(), fields.get(4).copied(), fields.get(separator + 1).copied(), fields.get(separator + 3).copied()) + else { + continue; + }; + let options = fields.get(5).copied().unwrap_or(""); + mounts.push(Mount { + root: unescape(root), + mount_point: unescape(point), + fstype: fstype.to_string(), + options: options.to_string(), + super_options: super_options.to_string(), + }); + } + mounts + } + + /// Finds the mount for `fstype`/`controller` and joins the process's cgroup path to it. + /// + /// Reads `/proc/self/mountinfo` (dedicated controller mounts, bind mounts and non-root mount + /// roots). If that file cannot be read, the conventional `/sys/fs/cgroup[/]` + /// layout is the only guess available. When mountinfo *is* readable, a missing matching mount + /// fails closed instead of guessing another mount's hierarchy: a wrong directory must never + /// become unrestricted host memory. + /// + /// A bind mount of a subtree has a deeper `root` and hides the cgroup ancestors above that + /// subtree, so the *widest* matching view is preferred: the mount whose cgroup root is + /// highest in the hierarchy (fewest path components), not the longest mount point. A deep + /// bind mount must not be allowed to hide a limit on an ancestor that is visible through the + /// hierarchy's real mount. + fn resolve_dir(sys_root: &Path, mountinfo: Option<&str>, membership: &str, fstype: &str, controller: Option<&str>) -> Option { + let membership = unescape(membership.trim()); + let Some(info) = mountinfo else { + let root = match controller { + Some(c) => sys_root.join(c), + None => sys_root.to_path_buf(), + }; + let leaf = join_membership(&root, &membership)?; + // The conventional layout mounts each hierarchy at its own root. + return leaf.is_dir().then_some(CgroupDir { leaf, root, hierarchy_root: true }); + }; + + let mut best: Option<(usize, usize, CgroupDir)> = None; + for mount in parse_mountinfo(info) { + if mount.fstype != fstype { + continue; + } + if controller.is_some_and(|c| !has_option(&mount.options, c) && !has_option(&mount.super_options, c)) { + continue; + } + let Some(relative) = strip_mount_root(&mount.root, &membership) else { continue }; + let root = normalize_root(&mount.mount_point); + let Some(leaf) = join_membership(&root, relative) else { continue }; + if !leaf.is_dir() { + continue; + } + let key = (cgroup_path_depth(&mount.root), mount.mount_point.len()); + if best.as_ref().is_none_or(|(depth, point, _)| key < (*depth, *point)) { + let hierarchy_root = mount.root.trim_end_matches('/').is_empty(); + best = Some((key.0, key.1, CgroupDir { leaf, root, hierarchy_root })); + } + } + best.map(|(_, _, dir)| dir) + } + + /// Trims trailing slashes so `Path` equality holds while walking ancestors, keeping `/`. + fn normalize_root(point: &str) -> PathBuf { + let trimmed = point.trim_end_matches('/'); + PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed }) + } + + /// Joins a cgroup path below a visible hierarchy root, normalizing `.` components and + /// rejecting parent traversal so a malformed path cannot escape the mount. + fn join_membership(root: &Path, path: &str) -> Option { + let mut leaf = root.to_path_buf(); + for component in Path::new(path.trim_start_matches('/')).components() { + match component { + std::path::Component::Normal(part) => leaf.push(part), + std::path::Component::CurDir => {} + std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return None, + } + } + Some(leaf) + } + + /// Number of path components in a mount's cgroup root; `/` or an empty root is zero. + fn cgroup_path_depth(root: &str) -> usize { + Path::new(root).components().filter(|c| matches!(c, std::path::Component::Normal(_))).count() + } + + /// Whether a v2 mount's `cgroup.controllers` exposes the memory controller. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum V2Controller { + /// `cgroup.controllers` lists `memory`. + Available, + /// `cgroup.controllers` is readable and omits `memory`, so no cgroup memory limit exists. + Absent, + /// `cgroup.controllers` could not be read, so absence cannot be established. + Unknown, + } + + /// Whether the cgroup v2 hierarchy at `root` exposes the memory controller. An unreadable + /// `cgroup.controllers` is `Unknown`, so its absence cannot be proven. + fn v2_controller(root: &Path) -> V2Controller { + match std::fs::read_to_string(root.join("cgroup.controllers")) { + Ok(text) => { + if text.split_whitespace().any(|controller| controller == "memory") { + V2Controller::Available + } else { + V2Controller::Absent + } + } + Err(_) => V2Controller::Unknown, + } + } + + /// Whether a cgroup's `cgroup.subtree_control` enables memory for its children. + /// + /// Interface files are created by the parent, so this decides whether a child *must* expose + /// `memory.max`: a readable control file without memory means the child legitimately has no + /// limit, while an unreadable one means that cannot be established. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Delegation { + Enabled, + Disabled, + Unknown, + } + + /// Whether `parent` enables the memory controller for its children in + /// `cgroup.subtree_control`. An unreadable file is `Unknown`, not "disabled". + fn v2_delegation(parent: &Path) -> Delegation { + match std::fs::read_to_string(parent.join("cgroup.subtree_control")) { + Ok(text) => { + if text.split_whitespace().any(|controller| controller.trim_start_matches('+') == "memory") { + Delegation::Enabled + } else { + Delegation::Disabled + } + } + Err(_) => Delegation::Unknown, + } + } + + /// cgroup v2: `memory.max`/`memory.current`, reclaiming `inactive_file`, plus + /// `memory.swap.max`/`memory.swap.current`. Every visible ancestor limit applies. + /// + /// A missing `memory.max` is not always an error: the hierarchy root is exempt from resource + /// control, and a child has the file only when its parent delegates the controller. A missing + /// file where the parent *does* delegate memory, or where delegation cannot be read, means the + /// visible view is incomplete and is reported as unreadable instead of unrestricted. + fn v2_limits(dir: &CgroupDir) -> CgroupLimits { + if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { + return CgroupLimits::Unreadable; + } + let controller = v2_controller(&dir.root); + + let mut nodes = Vec::new(); + let mut node = Some(dir.leaf.as_path()); + while let Some(path) = node { + nodes.push(path); + if path == dir.root.as_path() { + break; + } + if nodes.len() > MAX_CGROUP_DEPTH { + return CgroupLimits::Unreadable; + } + node = path.parent(); + } + if nodes.last().is_none_or(|path| *path != dir.root.as_path()) { + return CgroupLimits::Unreadable; + } + nodes.reverse(); + + let mut ram: Option = None; + let mut swap: Option = None; + let mut enabled = false; + for (index, path) in nodes.iter().enumerate() { + let expected = if index == 0 { + if dir.hierarchy_root { + // The hierarchy root is exempt from resource control and may have no + // `memory.max` at all. + None + } else if controller == V2Controller::Absent { + Some(Delegation::Disabled) + } else { + // A mounted subtree root must expose the controller when it is enabled for + // that cgroup; without the parent's control file its absence is unprovable. + Some(Delegation::Unknown) + } + } else { + Some(v2_delegation(nodes[index - 1])) + }; + match read_limit(&path.join("memory.max")) { + Limit::Value(max) => { + enabled = true; + let Some(current) = read_u64(&path.join("memory.current")) else { return CgroupLimits::Unreadable }; + let inactive = read_stat(&path.join("memory.stat"), "inactive_file"); + let room = max.saturating_sub(current.saturating_sub(inactive)); + ram = Some(ram.map_or(room, |r| r.min(room))); + } + Limit::Unlimited => enabled = true, + Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Missing => match expected { + None | Some(Delegation::Disabled) => {} + Some(Delegation::Enabled | Delegation::Unknown) => return CgroupLimits::Unreadable, + }, + } + match read_limit(&path.join("memory.swap.max")) { + Limit::Value(max) => { + enabled = true; + let Some(current) = read_u64(&path.join("memory.swap.current")) else { return CgroupLimits::Unreadable }; + let room = max.saturating_sub(current); + swap = Some(swap.map_or(room, |r| r.min(room))); + } + Limit::Unlimited => enabled = true, + Limit::Invalid => return CgroupLimits::Unreadable, + // Kernels before swap accounting expose `memory.max` without `memory.swap.*`; + // absence there means no swap limit at this cgroup, not an unreadable constraint. + Limit::Missing => {} + } + } + + if enabled { + return CgroupLimits::Limited { ram, swap }; + } + match controller { + // The controller is genuinely absent, or available but not enabled anywhere on the + // visible path (the root may normally have no interface files): no cgroup limit. + V2Controller::Absent | V2Controller::Available => CgroupLimits::NotConstrained, + // No readable limit file and no metadata proving the controller absent. + V2Controller::Unknown => CgroupLimits::Unreadable, + } + } + + /// cgroup v1: `memory.limit_in_bytes`/`memory.usage_in_bytes` (inactive cache reclaimable) + /// and the separate `memory.memsw` memory-plus-swap bound. + /// + /// A memsw limit also caps RAM because memory usage counts toward it, but its remaining room + /// is not swap that can be added on top of a different ancestor's RAM headroom. Each + /// ancestor's swap allowance is `memsw limit - memory limit - swap already used`, and those + /// allowances are combined separately. Subtracting one global combined headroom from one + /// global RAM headroom would invent swap room from different ancestors. + fn v1_limits(dir: &CgroupDir) -> CgroupLimits { + if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { + return CgroupLimits::Unreadable; + } + let mut ram: Option = None; + let mut swap: Option = None; + let mut enabled = false; + let mut node = Some(dir.leaf.as_path()); + let mut depth = 0; + while let Some(path) = node { + if depth >= MAX_CGROUP_DEPTH { + return CgroupLimits::Unreadable; + } + + // `None` means unlimited at this ancestor; the raw usage feeds the swap bound. + let mut memory_usage = None; + let memory_limit = match read_limit(&path.join("memory.limit_in_bytes")) { + Limit::Missing | Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Unlimited => { + enabled = true; + None + } + Limit::Value(bytes) if bytes >= V1_UNLIMITED => { + enabled = true; + None + } + Limit::Value(bytes) => { + enabled = true; + let Some(usage) = read_u64(&path.join("memory.usage_in_bytes")) else { return CgroupLimits::Unreadable }; + memory_usage = Some(usage); + let inactive = std::fs::read_to_string(path.join("memory.stat")) + .ok() + .and_then(|s| stat(&s, "total_inactive_file").or_else(|| stat(&s, "inactive_file"))) + .unwrap_or(0); + let room = bytes.saturating_sub(usage.saturating_sub(inactive)); + ram = Some(ram.map_or(room, |r| r.min(room))); + Some(bytes) + } + }; + + match read_limit(&path.join("memory.memsw.limit_in_bytes")) { + Limit::Missing | Limit::Unlimited => {} + Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Value(bytes) if bytes >= V1_UNLIMITED => {} + Limit::Value(bytes) => { + let Some(memsw_usage) = read_u64(&path.join("memory.memsw.usage_in_bytes")) else { + return CgroupLimits::Unreadable; + }; + // Memory plus swap is bounded by memsw, so its remaining room also bounds RAM. + let combined_room = bytes.saturating_sub(memsw_usage); + ram = Some(ram.map_or(combined_room, |r| r.min(combined_room))); + if let Some(memory_limit) = memory_limit { + // Swap can only use the part of memsw above the memory limit after the + // swap already in use; filling RAM to its own limit does not consume it. + let used_swap = memsw_usage.saturating_sub(memory_usage.unwrap_or(0)); + let room = bytes.saturating_sub(memory_limit).saturating_sub(used_swap); + swap = Some(swap.map_or(room, |s| s.min(room))); + } else { + // With no RAM limit the remaining memsw room can all be consumed by RAM, + // so no swap room is guaranteed on top of the RAM allowance. + swap = Some(0); + } + } + } + + if path == dir.root.as_path() { + break; + } + node = path.parent(); + depth += 1; + } + if !enabled { + // The v1 hierarchy claims a memory controller but never exposed a limit file. + return CgroupLimits::Unreadable; + } + CgroupLimits::Limited { ram, swap } + } + + /// Reads a cgroup limit file: `max` is unlimited, a number is a byte limit, and a missing file + /// is distinguished from one that cannot be read or parsed. + fn read_limit(path: &Path) -> Limit { + match std::fs::read_to_string(path) { + Ok(text) => match text.trim() { + "max" => Limit::Unlimited, + value => match value.parse::() { + Ok(bytes) => Limit::Value(bytes), + Err(_) => Limit::Invalid, + }, + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limit::Missing, + Err(_) => Limit::Invalid, + } + } + + /// Reads a whitespace-terminated unsigned value from a cgroup interface file. + fn read_u64(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() + } + + /// Reads `field` from a cgroup `*.stat` file, treating a missing file or field as zero. + fn read_stat(path: &Path, field: &str) -> u64 { + std::fs::read_to_string(path).ok().and_then(|s| stat(&s, field)).unwrap_or(0) + } + + /// Finds `field` in `key value` stat lines and parses its value. + fn stat(s: &str, field: &str) -> Option { + s.lines().find_map(|l| { + let mut parts = l.split_whitespace(); + let key = parts.next()?; + if key == field { parts.next()?.parse().ok() } else { None } + }) + } + + /// Whether a comma-separated mount option list contains `controller`. + fn has_option(options: &str, controller: &str) -> bool { + options.split(',').any(|option| option.trim() == controller) + } + + /// Strips a mount's root prefix from a cgroup path, returning the path relative to the mount. + fn strip_mount_root<'a>(root: &str, path: &'a str) -> Option<&'a str> { + let root = root.trim_end_matches('/'); + if root.is_empty() { + return Some(path); + } + let rest = path.strip_prefix(root)?; + if rest.is_empty() { + return Some(""); + } + rest.strip_prefix('/') + } + + /// Decodes mountinfo/mount-path octal escapes (`\040` space, `\011` tab, `\134` backslash). + fn unescape(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + if byte == b'\\' + && let Some(digits) = bytes.get(index + 1..index + 4) + { + let code = digits.iter().try_fold(0u32, |acc, digit| { + let value = digit.checked_sub(b'0')?; + (value < 8).then_some(acc * 8 + u32::from(value)) + }); + if let Some(byte) = code.and_then(|code| u8::try_from(code).ok()) { + out.push(byte); + index += 4; + continue; + } + } + out.push(byte); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() + } + + /// Exposed for the Linux provider tests; the policy itself never calls this. + #[cfg(test)] + pub(super) fn membership_for_test(text: &str) -> Option<(Option, Option)> { + match parse_membership(text) { + MembershipParse::Parsed(membership) => Some((membership.v2.map(str::to_string), membership.v1_memory.map(str::to_string))), + MembershipParse::Malformed => None, + } + } +} + +#[cfg(windows)] +mod windows_mem { + use super::SystemMemory; + + /// Measures available physical and commit memory through `GlobalMemoryStatusEx`. A failed + /// query reports unknown memory rather than inventing a measurement. + pub(super) fn snapshot() -> SystemMemory { + match winsafe::GlobalMemoryStatusEx() { + Ok(msx) => SystemMemory { + available_ram: Some(msx.ullAvailPhys), + swap_free: None, + commit_headroom: Some(msx.ullAvailPageFile), + constraint_unreadable: false, + }, + Err(_) => SystemMemory::UNKNOWN, + } + } +} + +/// Conservative reclaimable physical RAM from macOS VM counters, or `None` when the query failed. +/// +/// `total` is installed physical memory (`sysctl` `HW_MEMSIZE`). The other three are the same +/// `host_statistics64` `vm_statistics64` fields that `sysinfo` derives: +/// +/// - `free` = `free_memory` = `free_count - speculative_count` pages; +/// - `available` = `available_memory` = `active + inactive + free_count` pages; +/// - `used` = `used_memory` = `internal_page_count - purgeable_count + wire_count + +/// compressor_page_count` pages. +/// +/// XNU documents `AVAILABLE_NON_COMPRESSED_MEMORY = active + inactive + free + speculative` as +/// "everything that is not either wired, compressed, or stolen" +/// (). +/// That includes anonymous working sets, which can only be released by compressing or swapping +/// them, so on its own it is too optimistic for a budget that excludes swap. +/// +/// `internal_page_count` counts anonymous pages and `external_page_count` counts file-backed +/// pages (), +/// so `total - used` is the pool that can be handed out without evicting a working set: +/// `free + file-backed + purgeable`. That is close to the fully-reclaimable pool jetsam monitors +/// (`file-backed + free + purgeable`, same document). The smaller of the two derivations is +/// taken so a counter disagreement stays conservative, and the result is never below `free`: +/// pages that are already free are allocatable by definition, so a derived value below them +/// means the counters disagree and cannot be used to claim less. +/// +/// Only a demonstrably failed query is unknown. A live host always has a non-zero installed size +/// and non-zero used memory (wired kernel pages alone), so either reading zero proves the +/// provider query failed. A genuine "no reclaimable pages" reading stays a known zero rather +/// than becoming the 2 GiB unknown-memory allowance. +#[cfg(any(target_os = "macos", test))] +fn macos_available_ram(total: u64, free: u64, available: u64, used: u64) -> Option { + if total == 0 || used == 0 { + return None; + } + let free = free.min(total); + let reclaimable = total.saturating_sub(used).min(available).max(free); + Some(reclaimable.min(total)) +} + +#[cfg(target_os = "macos")] +mod macos_mem { + use super::SystemMemory; + use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + + /// Available physical memory through `sysinfo`'s safe API. + /// + /// On macOS that is `host_statistics64`'s `vm_statistics64` (see + /// [`super::macos_available_ram`]). The estimate counts free pages, file-backed pages and + /// purgeable pages, not anonymous working sets, wired memory, compressed memory or swap. A + /// failed query reports unknown; a successful zero reading stays zero. + pub(super) fn snapshot() -> SystemMemory { + let system = match System::new_with_specifics(RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram())) { + Ok(system) => system, + Err(_) => return SystemMemory::UNKNOWN, + }; + match super::macos_available_ram(system.total_memory(), system.free_memory(), system.available_memory(), system.used_memory()) { + Some(ram) => SystemMemory { available_ram: Some(ram), swap_free: None, commit_headroom: None, constraint_unreadable: false }, + None => SystemMemory::UNKNOWN, + } + } +} + +#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] +mod fallback_mem { + use super::SystemMemory; + + /// Fallback provider for platforms without a validated memory-availability provider. + /// + /// Available memory is reported as unknown (`None`), which applies the conservative native + /// fallback budget (2 GiB, approval above it) without inventing a measurement. + pub(super) fn snapshot() -> SystemMemory { + SystemMemory::UNKNOWN + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_available_ram_counts_reclaimable_pages_not_free_only() { + let gb = 1u64 << 30; + // A failed provider query: no installed size, or no used memory on a live host. + assert_eq!(macos_available_ram(0, 0, 0, 0), None, "an empty query is not a measurement"); + assert_eq!(macos_available_ram(0, 1 << 20, 4 * gb, 4 * gb), None, "no installed size: the sysctl query failed"); + assert_eq!(macos_available_ram(16 * gb, 1 << 20, 8 * gb, 0), None, "no used memory on a live host: the VM query failed"); + + // A genuine zero-capacity reading stays zero, not unknown. + assert_eq!(macos_available_ram(16 * gb, 0, 0, 16 * gb), Some(0), "everything is wired or compressed"); + + // Low free memory beside a large file-backed cache: free alone would report ~0 and the + // hard budget would collapse, while 12 GiB is reclaimable without touching a working set. + assert_eq!(macos_available_ram(16 * gb, 100 << 20, 12 * gb, 4 * gb), Some(12 * gb)); + + // A loaded machine: most memory is anonymous working set, which is not reclaimable, so + // `available` (which counts it) does not raise the estimate above total - used. + assert_eq!(macos_available_ram(16 * gb, 200 << 20, 8 * gb, 14 * gb), Some(2 * gb)); + + // Counters that disagree: free pages are allocatable by definition, so never claim less. + assert_eq!(macos_available_ram(16 * gb, 3 * gb, gb, 15 * gb), Some(3 * gb)); + + // Never above installed memory. + assert_eq!(macos_available_ram(gb, 2 * gb, 4 * gb, 1), Some(gb)); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_provider_excludes_swap_and_commit_headroom() { + let snap = current(); + assert_eq!(snap.swap_free, None, "the adaptive budget must not count swap"); + assert_eq!(snap.commit_headroom, None); + // The numeric floor and zero-versus-unknown semantics are covered deterministically by + // `macos_available_ram_counts_reclaimable_pages_not_free_only`; asserting a live `> 0` + // here would be vacuous because a genuine zero is a valid reading. + } + + #[cfg(target_os = "linux")] + use std::path::{Path, PathBuf}; + + #[cfg(target_os = "linux")] + fn fixture(name: &str) -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("photocraft-cgroup-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("proc/self")).unwrap(); + std::fs::create_dir_all(root.join("sys/fs/cgroup")).unwrap(); + (root.join("proc"), root.join("sys/fs/cgroup")) + } + + #[cfg(target_os = "linux")] + fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, text).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_memory_is_read_from_meminfo() { + let info = "MemTotal: 24314352 kB\nMemAvailable: 12626632 kB\nSwapFree: 1024 kB\n"; + assert_eq!(linux::meminfo(info, "MemAvailable:"), Some(12_626_632 * 1024)); + assert_eq!(linux::meminfo(info, "SwapFree:"), Some(1024 * 1024)); + assert_eq!(linux::meminfo(info, "Missing:"), None); + assert_eq!(linux::meminfo("MemAvailable: lots kB", "MemAvailable:"), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_cgroup_membership_distinguishes_v1_and_v2() { + assert_eq!(linux::membership_for_test("0::/user.slice/session.scope\n"), Some((Some("/user.slice/session.scope".into()), None))); + assert_eq!(linux::membership_for_test("4:memory:/docker/abc\n"), Some((None, Some("/docker/abc".into())))); + assert_eq!(linux::membership_for_test("4:cpu,cpuacct:/user.slice\n5:memory:/a/b\n"), Some((None, Some("/a/b".into())))); + assert_eq!(linux::membership_for_test("12:pids:/\n"), Some((None, None)), "no memory controller is demonstrably absent"); + assert_eq!(linux::membership_for_test("garbage\n"), None); + assert_eq!(linux::membership_for_test(""), None, "an empty file cannot prove absence"); + assert_eq!(linux::membership_for_test("0:memory:/app\n"), None, "the unified hierarchy cannot name controllers"); + assert_eq!(linux::membership_for_test("4::/app\n"), None, "a v1 line must name a controller"); + assert_eq!(linux::membership_for_test("4:memory:relative\n"), None, "cgroup paths are absolute"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_mountinfo_octal_escapes_are_decoded() { + let (proc, sys) = fixture("escaped"); + write(&proc.join("self/cgroup"), "0::/app\n"); + let mount = sys.join("cg root"); + let escaped = mount.display().to_string().replace(' ', "\\040"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", escaped)); + write(&mount.join("app/memory.max"), "1073741824\n"); + write(&mount.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v2_cgroup_applies_mount_path_and_ancestor_limits() { + let (proc, sys) = fixture("v2"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw,nsdelegate\n", sys.display())); + let leaf = sys.join("app.scope"); + write(&sys.join("memory.max"), "8589934592\n"); + write(&sys.join("memory.current"), "2147483648\n"); + write(&sys.join("memory.stat"), "inactive_file 1073741824\n"); + write(&leaf.join("memory.max"), "max\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the parent's limit applies"); + + write(&leaf.join("memory.max"), "4294967296\n"); + write(&leaf.join("memory.current"), "1073741824\n"); + write(&leaf.join("memory.stat"), "inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: None }, "the tighter leaf limit wins"); + + write(&leaf.join("memory.swap.max"), "1073741824\n"); + write(&leaf.join("memory.swap.current"), "268435456\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: Some(768 << 20) }); + + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v2_distinguishes_disabled_controller_from_missing_expected_limit() { + // The hierarchy root may legitimately have no `memory.max`; a child has one exactly when + // its parent delegates the controller. + let (proc, sys) = fixture("v2-metadata"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu +memory\n"); + write(&sys.join("app/memory.max"), "1073741824\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }, "a missing root file is normal"); + + write(&sys.join("cgroup.controllers"), "cpu io\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu\n"); + std::fs::remove_file(sys.join("app/memory.max")).unwrap(); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::NotConstrained, + "readable controllers without memory prove the controller is absent" + ); + + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+memory\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "memory is delegated to the child, so the missing child file means the view is incomplete" + ); + + std::fs::remove_file(sys.join("cgroup.controllers")).unwrap(); + std::fs::remove_file(sys.join("cgroup.subtree_control")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "without metadata the absence of a constraint cannot be established"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // Available but not delegated anywhere on the path: no cgroup limit applies. + let (proc, sys) = fixture("v2-not-enabled"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu\n"); + write(&sys.join("app/cgroup.controllers"), "\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_prefers_the_widest_hierarchy_view_over_a_deep_bind_mount() { + let (proc, sys) = fixture("bind-widest"); + write(&proc.join("self/cgroup"), "0::/user.slice/app.scope\n"); + let narrow = sys.join("bind"); + write( + &proc.join("self/mountinfo"), + &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", sys.display(), narrow.display()), + ); + write(&sys.join("user.slice/memory.max"), "2147483648\n"); + write(&sys.join("user.slice/memory.current"), "0\n"); + write(&sys.join("user.slice/cgroup.subtree_control"), "+memory\n"); + write(&sys.join("user.slice/app.scope/memory.max"), "max\n"); + write(&narrow.join("memory.max"), "max\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, + "the hierarchy's own mount exposes user.slice's limit; the deep bind mount would hide it" + ); + + write(&proc.join("self/mountinfo"), &format!("31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", narrow.display())); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: None, swap: None }, + "the bind mount alone cannot see the ancestor limit, which is why the widest view is preferred" + ); + + std::fs::remove_file(narrow.join("memory.max")).unwrap(); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "a mounted subtree root cannot silently lose the controller file it should expose" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_cgroup_honours_controller_mounts_and_ancestors() { + let (proc, sys) = fixture("v1"); + write(&proc.join("self/cgroup"), "4:memory:/docker/abc\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,nosuid,nodev,noexec,relatime shared:12 - cgroup cgroup rw,memory\n", mount.display())); + let leaf = mount.join("docker/abc"); + write(&mount.join("docker/memory.limit_in_bytes"), "9223372036854771712\n"); + write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); + write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); + write(&mount.join("memory.stat"), "total_inactive_file 1073741824\n"); + write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the v1 sentinel is unlimited"); + + write(&leaf.join("memory.limit_in_bytes"), "3221225472\n"); + write(&leaf.join("memory.usage_in_bytes"), "1073741824\n"); + write(&leaf.join("memory.stat"), "total_inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, "the tighter leaf limit wins"); + + write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "swap room is memsw limit - memory limit - swap used at the same ancestor, not combined headroom minus RAM headroom" + ); + + write(&mount.join("memory.memsw.usage_in_bytes"), "3221225472\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, + "a gigabyte already swapped uses the ancestor's whole swap allowance" + ); + + write(&mount.join("memory.memsw.limit_in_bytes"), "10737418240\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "only the memsw allowance above the memory limit counts as added swap room" + ); + + write(&mount.join("memory.limit_in_bytes"), "9223372036854771712\n"); + write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); + write(&mount.join("memory.memsw.limit_in_bytes"), "2147483648\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, "memsw alone still caps RAM"); + + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_swap_bounds_combine_per_ancestor() { + let (proc, sys) = fixture("v1-swap-ancestors"); + write(&proc.join("self/cgroup"), "4:memory:/a/b\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw - cgroup cgroup rw,memory\n", mount.display())); + write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); + write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); + write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); + write(&mount.join("a/memory.limit_in_bytes"), "10737418240\n"); + write(&mount.join("a/memory.usage_in_bytes"), "1073741824\n"); + write(&mount.join("a/memory.memsw.limit_in_bytes"), "21474836480\n"); + write(&mount.join("a/memory.memsw.usage_in_bytes"), "2147483648\n"); + write(&mount.join("a/b/memory.limit_in_bytes"), "3221225472\n"); + write(&mount.join("a/b/memory.usage_in_bytes"), "1073741824\n"); + + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "root swap allowance is 9 - 8 - 0 = 1 GiB; /a contributes 9 GiB and the leaf adds no swap limit" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_mount_root_prefix_is_stripped() { + let (proc, sys) = fixture("v1-root"); + write(&proc.join("self/cgroup"), "4:memory:/docker/abc/child\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 /docker/abc {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); + write(&mount.join("memory.limit_in_bytes"), "1073741824\n"); + write(&mount.join("memory.usage_in_bytes"), "0\n"); + write(&mount.join("memory.stat"), "total_inactive_file 0\n"); + write(&mount.join("child/memory.limit_in_bytes"), "1073741824\n"); + write(&mount.join("child/memory.usage_in_bytes"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_cgroup_falls_back_to_conventional_layouts() { + let (proc, sys) = fixture("fallback-v2"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&sys.join("app.scope/memory.max"), "1073741824\n"); + write(&sys.join("app.scope/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("fallback-v1"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + write(&sys.join("memory/memory.limit_in_bytes"), "1073741824\n"); + write(&sys.join("memory/memory.usage_in_bytes"), "0\n"); + write(&sys.join("memory/memory.stat"), "total_inactive_file 0\n"); + write(&sys.join("memory/app/memory.limit_in_bytes"), "1073741824\n"); + write(&sys.join("memory/app/memory.usage_in_bytes"), "0\n"); + write(&sys.join("memory/app/memory.stat"), "total_inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_unreadable_constraints_fail_closed() { + let (proc, sys) = fixture("unreadable-missing-dir"); + write(&proc.join("self/cgroup"), "0::/missing\n"); + write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + let snap = linux::snapshot_at(&proc, &sys); + assert_eq!(snap.available_ram, Some(0), "host memory must not be assumed"); + assert_eq!(snap.swap_free, Some(0)); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("unreadable-v1-limit"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); + std::fs::create_dir_all(mount.join("app")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("unreadable-v2-limit"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&sys.join("app/memory.max"), "lots\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("no-memory-controller"); + write(&proc.join("self/cgroup"), "4:cpu,cpuacct:/user.slice\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("malformed-membership"); + write(&proc.join("self/cgroup"), "0:memory:/app\n"); + write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "a malformed membership line cannot prove the controller absent"); + assert_eq!(linux::snapshot_at(&proc, &sys).available_ram, Some(0), "host memory must not be assumed"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("empty-membership"); + write(&proc.join("self/cgroup"), ""); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "an empty membership file cannot prove absence"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("mountinfo-without-cgroup"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "30 20 8:1 / / rw - ext4 /dev/root rw\n"); + write(&sys.join("app/memory.max"), "1073741824\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "a readable mountinfo without a cgroup mount must not be guessed away by the conventional path" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_snapshot_combines_host_meminfo_and_cgroup_room() { + let (proc, sys) = fixture("snapshot"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&proc.join("meminfo"), "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("app.scope/memory.max"), "4294967296\n"); + write(&sys.join("app.scope/memory.current"), "0\n"); + write(&sys.join("app.scope/memory.swap.max"), "536870912\n"); + write(&sys.join("app.scope/memory.swap.current"), "0\n"); + + let snap = linux::snapshot_at(&proc, &sys); + assert_eq!(snap.available_ram, Some(4 << 30), "the cgroup limit is smaller than host MemAvailable"); + assert_eq!(snap.swap_free, Some(512 << 20)); + assert_eq!(snap.commit_headroom, None); + assert_eq!(linux::available_at(&proc, &sys), Some((4 << 30) + (512 << 20)), "legacy helper keeps RAM + swap"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } +} diff --git a/crates/raster/src/scratch.rs b/crates/raster/src/scratch.rs new file mode 100644 index 0000000000..cb0f898306 --- /dev/null +++ b/crates/raster/src/scratch.rs @@ -0,0 +1,384 @@ +//! Process-wide, capped pool of large scratch buffers. +//! +//! With glibc's mmap threshold pinned (`MALLOC_TRIM_THRESHOLD_`, which is how PhotoCraft returns +//! memory to the OS), every allocation over 128 KiB is a fresh `mmap` that is page-faulted in and +//! `munmap`ed on free. Tiles, compositor bands and filter scratch are all that size, so a single +//! viewport render took 350× the page faults. This pool keeps freed buffers (at least +//! [`MIN_POOLED_BYTES`] of capacity) on per-type free lists and hands them back out, so the +//! steady state allocates nothing. +//! +//! Retention is capped by [`set_budget`] (default [`DEFAULT_BUDGET`]); [`release`] drops +//! everything, which is what returns the memory to the OS. The engine releases when a document +//! closes; off wasm a background thread also releases once the pool has gone unused for +//! [`IDLE_RELEASE_SECS`], so every host (app, CLI, tests) gives memory back the same way. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +/// Buffers with less capacity than this are cheap from the heap and are never retained. +pub const MIN_POOLED_BYTES: usize = 64 * 1024; +/// Default cap on the bytes the pool retains: about the working set of a 36 MP filter (tiles, +/// halo images and result bands); a browser's wasm heap never shrinks, so there it is small. +pub const DEFAULT_BUDGET: usize = if cfg!(target_arch = "wasm32") { 256 << 20 } else { 2 << 30 }; + +/// Pooled bytes a miss never evicts. A filter's output tiles are all misses (the old tiles stay +/// in the undo history); evicting every byte they need also dropped the scratch planes the next +/// tile reuses. On a 36 MP Gaussian Blur with the trim threshold set, 256 MiB keeps the timings +/// at glibc's defaults (128 MiB: r 40 ~17% slower) for ~10% more peak memory (0: ~20% slower). +pub const DEFAULT_MISS_FLOOR: usize = if cfg!(target_arch = "wasm32") { 64 << 20 } else { 256 << 20 }; + +static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_BUDGET); +static MISS_FLOOR: AtomicUsize = AtomicUsize::new(DEFAULT_MISS_FLOOR); +static RETAINED: AtomicUsize = AtomicUsize::new(0); +static USES: AtomicU64 = AtomicU64::new(0); +static IDLE_RELEASE: AtomicBool = AtomicBool::new(true); + +/// Seconds without a [`take`] or [`give`] after which the idle thread releases the pool. +pub const IDLE_RELEASE_SECS: u64 = 2; + +/// Starts the idle-release thread once (off wasm, where there are threads). If the thread +/// can't be spawned the pool still works; it is then released only by [`release`]. +fn start_idle_release() { + #[cfg(not(target_arch = "wasm32"))] + { + static STARTED: std::sync::Once = std::sync::Once::new(); + STARTED.call_once(|| { + let _ = std::thread::Builder::new().name("scratch-idle".into()).spawn(|| { + let mut seen = uses(); + loop { + std::thread::sleep(std::time::Duration::from_secs(IDLE_RELEASE_SECS)); + let now = uses(); + if now == seen { + // Decided under the lock, so `set_idle_release(false)` can't return + // while a release it should have stopped is about to start. + let old = { + let mut pool = lock(); + // A `give` or `take` since `now` was read means the pool is in use. + (uses() == now && retained_bytes() > 0 && IDLE_RELEASE.load(Ordering::Relaxed)).then(|| empty(&mut pool)) + }; + drop(old); + } + seen = now; + } + }); + }); + } +} + +/// One free list: buffers keyed by capacity (in elements), each with the sequence number it +/// was returned at (oldest first within a capacity). +#[doc(hidden)] +pub type FreeList = BTreeMap)>>; + +/// All the free lists behind one lock, so the oldest buffer of any type can be evicted. +/// `order` maps every pooled buffer's sequence number to its type and capacity, so the oldest +/// is found in O(log n); best-fit lookups are a range query on the capacity key. +#[doc(hidden)] +pub struct Pool { + seq: u64, + order: BTreeMap, + u8s: FreeList, + u16s: FreeList, + f32s: FreeList, + px4: FreeList<[f32; 4]>, +} + +static POOL: Mutex = + Mutex::new(Pool { seq: 0, order: BTreeMap::new(), u8s: BTreeMap::new(), u16s: BTreeMap::new(), f32s: BTreeMap::new(), px4: BTreeMap::new() }); + +/// Which free list a buffer lives on. +#[doc(hidden)] +#[derive(Clone, Copy)] +pub enum Tag { + U8, + U16, + F32, + Px4, +} + +/// A buffer evicted from the pool, dropped by the caller once the lock is released. +#[allow(dead_code)] +enum Evicted { + U8(Vec), + U16(Vec), + F32(Vec), + Px4(Vec<[f32; 4]>), +} + +/// Element types that have a free list. Implemented for the buffers the hot paths use. +pub trait Pooled: Copy + Send + 'static { + #[doc(hidden)] + const TAG: Tag; + #[doc(hidden)] + fn list(pool: &mut Pool) -> &mut FreeList; +} + +macro_rules! pooled { + ($($t:ty => $field:ident, $tag:ident),*) => {$( + impl Pooled for $t { + const TAG: Tag = Tag::$tag; + fn list(pool: &mut Pool) -> &mut FreeList { + &mut pool.$field + } + } + )*}; +} +pooled!(u8 => u8s, U8, u16 => u16s, U16, f32 => f32s, F32, [f32; 4] => px4, Px4); + +fn bytes_of(v: &Vec) -> usize { + v.capacity().saturating_mul(std::mem::size_of::()) +} + +fn lock() -> std::sync::MutexGuard<'static, Pool> { + POOL.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Remove the oldest buffer of capacity `cap` from `list`. +fn drop_oldest(list: &mut FreeList, cap: usize) -> Option> { + let queue = list.get_mut(&cap)?; + let v = queue.pop_front()?.1; + if queue.is_empty() { + list.remove(&cap); + } + Some(v) +} + +impl Pool { + /// Remove the buffer returned longest ago, of any type. `None` when the pool is empty. + fn evict_oldest(&mut self) -> Option { + let (_, (tag, cap)) = self.order.pop_first()?; + let evicted = match tag { + Tag::U8 => drop_oldest(&mut self.u8s, cap).map(Evicted::U8), + Tag::U16 => drop_oldest(&mut self.u16s, cap).map(Evicted::U16), + Tag::F32 => drop_oldest(&mut self.f32s, cap).map(Evicted::F32), + Tag::Px4 => drop_oldest(&mut self.px4, cap).map(Evicted::Px4), + }; + if let Some(e) = &evicted { + let freed = match e { + Evicted::U8(v) => bytes_of(v), + Evicted::U16(v) => bytes_of(v), + Evicted::F32(v) => bytes_of(v), + Evicted::Px4(v) => bytes_of(v), + }; + RETAINED.fetch_sub(freed, Ordering::Relaxed); + } + evicted + } +} + +/// A `Vec` of `len` copies of `fill`, reusing a pooled buffer when one fits: the one whose +/// capacity equals `len`, else the smallest with capacity in `len..=2*len`. +pub fn take(len: usize, fill: T) -> Vec { + take_with(len, fill, 2) +} + +/// An empty `Vec` with room for at least `cap` elements, reusing a pooled buffer when one fits. +pub fn take_cap(cap: usize) -> Vec { + USES.fetch_add(1, Ordering::Relaxed); + match pop::(cap, 2) { + Some(mut v) => { + v.clear(); + v + } + None => Vec::with_capacity(cap), + } +} + +/// Like [`take`] but only reuses a buffer whose capacity is exactly `len`, so +/// `into_boxed_slice` never reallocates. +pub fn take_exact(len: usize, fill: T) -> Vec { + take_with(len, fill, 1) +} + +/// Pop the best-fitting free buffer: capacity `len`, else the smallest up to `len * slack`. +/// +/// On a miss the caller allocates `len` fresh, so the oldest free buffers of at least that many +/// bytes are dropped first, down to [`set_miss_floor`]: memory the pool can't reuse for this request +/// goes back instead of piling up beside the new buffer, so pool plus live buffers exceed what +/// the program would hold without the pool by at most the floor (#10479). +fn pop(len: usize, slack: usize) -> Option> { + let need = len.saturating_mul(std::mem::size_of::()); + if need < MIN_POOLED_BYTES { + return None; + } + let mut evicted = Vec::new(); + let hit = { + let mut pool = lock(); + let max = len.saturating_mul(slack); + let list = T::list(&mut pool); + let cap = list.range(len..=max).next().map(|(&cap, _)| cap); + // The newest buffer of that capacity is the one most likely still in cache. + let found = cap.and_then(|cap| { + let queue = list.get_mut(&cap)?; + let entry = queue.pop_back(); + if queue.is_empty() { + list.remove(&cap); + } + entry + }); + match found { + Some((seq, v)) => { + pool.order.remove(&seq); + RETAINED.fetch_sub(bytes_of(&v), Ordering::Relaxed); + Some(v) + } + None => { + let target = RETAINED.load(Ordering::Relaxed).saturating_sub(need).max(MISS_FLOOR.load(Ordering::Relaxed)); + while RETAINED.load(Ordering::Relaxed) > target { + match pool.evict_oldest() { + Some(e) => evicted.push(e), + None => break, + } + } + None + } + } + }; + // Freed outside the lock. + drop(evicted); + hit +} + +fn take_with(len: usize, fill: T, slack: usize) -> Vec { + USES.fetch_add(1, Ordering::Relaxed); + if len == 0 { + return Vec::new(); + } + match pop::(len, slack) { + Some(mut v) => { + v.clear(); + v.resize(len, fill); + v + } + None => vec![fill; len], + } +} + +/// Fallible [`take_exact`]: on a pool miss the heap allocation is `try_reserve_exact`, so +/// `None` means the allocation failed rather than aborting. +pub fn try_take_exact(len: usize, fill: T) -> Option> { + USES.fetch_add(1, Ordering::Relaxed); + let mut v = match pop::(len, 1) { + Some(v) => v, + None => { + let mut v = Vec::new(); + if v.try_reserve_exact(len).is_err() { + // The pool may hold up to the miss floor of buffers this request couldn't use: + // give them back, then try once more before failing the caller. + release(); + v.try_reserve_exact(len).ok()?; + } + v + } + }; + v.clear(); + v.resize(len, fill); + Some(v) +} + +/// Return a buffer to the pool. Small buffers, and buffers bigger than the whole budget, are +/// dropped; otherwise the buffers returned longest ago (of any type) make room for it. +pub fn give(v: Vec) { + USES.fetch_add(1, Ordering::Relaxed); + let bytes = bytes_of(&v); + if bytes < MIN_POOLED_BYTES { + return; + } + let mut evicted = Vec::new(); + { + let mut pool = lock(); + // Read under the lock, which `set_budget` also takes, so no insert uses a stale budget. + let budget = BUDGET.load(Ordering::Relaxed); + if bytes > budget { + return; + } + start_idle_release(); + while RETAINED.load(Ordering::Relaxed).saturating_add(bytes) > budget { + match pool.evict_oldest() { + Some(e) => evicted.push(e), + None => break, + } + } + RETAINED.fetch_add(bytes, Ordering::Relaxed); + pool.seq += 1; + let seq = pool.seq; + let cap = v.capacity(); + pool.order.insert(seq, (T::TAG, cap)); + T::list(&mut pool).entry(cap).or_default().push_back((seq, v)); + } + // Freed outside the lock. + drop(evicted); +} + +/// Set the cap on retained bytes (existing entries beyond it go on the next [`give`]). +pub fn set_budget(bytes: usize) { + let _pool = lock(); + BUDGET.store(bytes, Ordering::Relaxed); +} + +/// Set the pooled bytes a miss never evicts (default [`DEFAULT_MISS_FLOOR`]). +pub fn set_miss_floor(bytes: usize) { + MISS_FLOOR.store(bytes, Ordering::Relaxed); +} + +/// Let the idle thread release the pool (the default) or not. Tests that check +/// [`retained_bytes`] turn it off, so a slow runner can't have the pool emptied under them. +pub fn set_idle_release(on: bool) { + // Under the lock: once this returns, the idle thread can't be midway into a release. + let _pool = lock(); + IDLE_RELEASE.store(on, Ordering::Relaxed); +} + +/// Bytes currently held by the pool. +pub fn retained_bytes() -> usize { + RETAINED.load(Ordering::Relaxed) +} + +/// Number of [`take`]/[`give`] calls so far; a changing value means the pool is in use. +pub fn uses() -> u64 { + USES.load(Ordering::Relaxed) +} + +/// Drop every pooled buffer, handing the memory back to the OS. +pub fn release() { + let old = empty(&mut lock()); + // Freed outside the lock. + drop(old); +} + +/// Everything `pool` holds, swapped out of it (to be freed once the lock is dropped). +fn empty(pool: &mut Pool) -> Pool { + RETAINED.store(0, Ordering::Relaxed); + let seq = pool.seq; + std::mem::replace(pool, Pool { seq, order: BTreeMap::new(), u8s: BTreeMap::new(), u16s: BTreeMap::new(), f32s: BTreeMap::new(), px4: BTreeMap::new() }) +} + +/// A pooled buffer that derefs to a slice and returns itself to the pool on drop. +pub struct Buf(Vec); + +impl Buf { + /// `len` copies of `fill` from the pool. + pub fn new(len: usize, fill: T) -> Self { + Buf(take(len, fill)) + } +} + +impl std::ops::Deref for Buf { + type Target = [T]; + fn deref(&self) -> &[T] { + &self.0 + } +} + +impl std::ops::DerefMut for Buf { + fn deref_mut(&mut self) -> &mut [T] { + &mut self.0 + } +} + +impl Drop for Buf { + fn drop(&mut self) { + give(std::mem::take(&mut self.0)); + } +} diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs new file mode 100644 index 0000000000..19a9a1056b --- /dev/null +++ b/crates/raster/src/scratch_file.rs @@ -0,0 +1,506 @@ +//! The scratch file: where cold tiles go when pixel memory is over budget. +//! +//! - One file per configured directory, never visible after the process ends. Unix creates it +//! with private permissions and unlinks it right after opening, so the space is returned when +//! the last handle closes, even on a crash or `exit` without destructors. Windows opens it +//! delete-on-close. Files of dead processes (an older build, or a crash between creating and +//! unlinking) are reaped when the directory is next configured. +//! - Space is handed out in 4 KiB-aligned extents from a free list (best fit, coalescing), else +//! appended at the end. +//! - All I/O is positioned: no shared cursor, no lock held during I/O. The allocator lock only +//! guards the free list. +//! - Unix reads and writes with `pread`/`pwrite` on the one handle (no reopen by path, which +//! is impossible once unlinked). Windows reads through one handle per thread (the file opened +//! again by path): it serialises I/O on one synchronous handle, so parallel readers sharing it +//! would run one at a time. +//! +//! Ported from Fotox's `fx-tiles` scratch file (same author), minus its `unsafe` `ReOpenFile`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +const ALIGN: u64 = 4096; + +/// A region of the scratch file holding one compressed tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Extent { + pub offset: u64, + /// Exact length of the data. + pub len: u32, + /// Allocated length (multiple of 4 KiB, ≥ `len`). + pub alloc: u64, + /// CRC-32 of the stored bytes, checked on every read. + pub crc32: u32, +} + +/// Free-list allocator. Pure bookkeeping, no I/O. +#[derive(Debug, Default)] +pub(crate) struct Allocator { + by_offset: BTreeMap, + by_size: BTreeSet<(u64, u64)>, + end: u64, +} + +impl Allocator { + /// Bytes currently allocated (end of file minus free space). + pub fn used(&self) -> u64 { + self.end - self.by_offset.values().sum::() + } + + pub fn alloc(&mut self, len: u32) -> Extent { + let size = u64::from(len).div_ceil(ALIGN).max(1) * ALIGN; + if let Some(&(free_size, offset)) = self.by_size.range((size, 0)..).next() { + self.remove_free(offset, free_size); + if free_size > size { + self.insert_free(offset + size, free_size - size); + } + return Extent { offset, len, alloc: size, crc32: 0 }; + } + let offset = self.end; + self.end += size; + Extent { offset, len, alloc: size, crc32: 0 } + } + + pub fn free(&mut self, extent: Extent) { + let mut offset = extent.offset; + let mut size = extent.alloc; + if let Some((&prev_off, &prev_size)) = self.by_offset.range(..offset).next_back() + && prev_off + prev_size == offset + { + self.remove_free(prev_off, prev_size); + offset = prev_off; + size += prev_size; + } + if let Some(&next_size) = self.by_offset.get(&(offset + size)) { + self.remove_free(offset + size, next_size); + size += next_size; + } + if offset + size == self.end { + // Free space at the end of the file just shrinks the used range. + self.end = offset; + } else { + self.insert_free(offset, size); + } + } + + fn insert_free(&mut self, offset: u64, size: u64) { + self.by_offset.insert(offset, size); + self.by_size.insert((size, offset)); + } + + fn remove_free(&mut self, offset: u64, size: u64) { + self.by_offset.remove(&offset); + self.by_size.remove(&(size, offset)); + } +} + +pub(crate) struct ScratchFile { + main: File, + #[cfg(windows)] + readers: OnceLock>, + path: PathBuf, + /// The directory entry still exists (Unix unlink failed): remove it on drop. + #[cfg(not(windows))] + linked: bool, + allocator: Mutex, +} + +impl std::fmt::Debug for ScratchFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScratchFile").field("path", &self.path).finish() + } +} + +impl ScratchFile { + pub fn create(dir: &Path) -> std::io::Result { + let (main, path, linked) = open_private(dir)?; + #[cfg(windows)] + let _ = linked; + Ok(Self { + main, + #[cfg(windows)] + readers: OnceLock::new(), + path, + #[cfg(not(windows))] + linked, + allocator: Mutex::new(Allocator::default()), + }) + } + + /// Another handle to the open file (the Unix one has no name), for fault-injection tests. + pub(crate) fn duplicate_handle(&self) -> std::io::Result { + self.main.try_clone() + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Reserve space and write `data` there. + pub fn write(&self, data: &[u8]) -> std::io::Result { + let len = u32::try_from(data.len()).map_err(|_| std::io::Error::other("block larger than 4 GiB"))?; + let mut extent = self.lock().alloc(len); + if let Err(e) = write_all_at(&self.main, data, extent.offset) { + self.free(extent); + return Err(e); + } + extent.crc32 = crc32fast::hash(data); + Ok(extent) + } + + /// Read an extent into `buf` (resized to it) and check its CRC. + pub fn read_into(&self, extent: Extent, buf: &mut Vec) -> std::io::Result<()> { + buf.resize(extent.len as usize, 0); + read_exact_at(self.reader(), buf, extent.offset)?; + if crc32fast::hash(buf) != extent.crc32 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("scratch CRC mismatch at offset {}", extent.offset))); + } + Ok(()) + } + + pub fn free(&self, extent: Extent) { + let mut allocator = self.lock(); + allocator.free(extent); + if allocator.end == 0 { + // Release the physical disk space after the last document/history tile closes. + // Keep the allocator locked so a new writer cannot reserve an extent before + // this truncation; failures retain excess space, never live tile bytes. + let _ = self.main.set_len(0); + } + } + + pub fn used(&self) -> u64 { + self.lock().used() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Allocator> { + self.allocator.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Positioned reads on one handle run in parallel on Unix. + #[cfg(not(windows))] + fn reader(&self) -> &File { + &self.main + } + + /// A handle for reading on the calling thread. + #[cfg(windows)] + fn reader(&self) -> &File { + let extra = self.readers.get_or_init(|| { + // The same file opened again by path: std shares read, write and delete, so this + // works while `main` (opened delete-on-close) is alive. A failure leaves fewer + // handles, never an error. + let count = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 16) - 1; + (0..count).map_while(|_| std::fs::OpenOptions::new().read(true).open(&self.path).ok()).collect() + }); + match thread_slot() % (extra.len() + 1) { + 0 => &self.main, + i => extra.get(i - 1).unwrap_or(&self.main), + } + } +} + +/// Creates a file only this process can reach in `dir`, gone once its last handle closes: Unix +/// opens it private (0600) and unlinks it right away, Windows opens it delete-on-close. Files +/// of dead processes are reaped. Returns the file, its name, and whether the name is still +/// there (a filesystem refused the unlink: remove it when done). +pub(crate) fn open_private(dir: &Path) -> std::io::Result<(File, PathBuf, bool)> { + std::fs::create_dir_all(dir)?; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + // A reused process id must not collide with an abandoned file from an older launch. + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128); + let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000; + options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE); + } + let file = options.open(&path)?; + #[cfg(unix)] + reap_abandoned(dir, &file); + // Unix: the open handle keeps the inode; nothing is left behind at any exit. A + // filesystem that refuses the unlink keeps the name until drop or the next reap. + #[cfg(not(windows))] + let linked = std::fs::remove_file(&path).is_err(); + #[cfg(windows)] + let linked = false; + Ok((file, path, linked)) +} + +/// Accept only the names we generate, never other temporary files or directories. +#[cfg(any(unix, test))] +fn scratch_pid(name: &str) -> Option { + let (pid, nonce) = name.strip_prefix("photocraft-")?.strip_suffix(".scratch")?.split_once('-')?; + if pid.is_empty() || nonce.is_empty() || !pid.bytes().chain(nonce.bytes()).all(|b| b.is_ascii_digit()) { + return None; + } + let pid = pid.parse::().ok()?; + (pid > 0 && nonce.parse::().is_ok()).then_some(pid) +} + +#[cfg(unix)] +fn reap_abandoned(dir: &Path, own: &File) { + use std::os::unix::fs::MetadataExt; + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + let Ok(metadata) = own.metadata() else { return }; + let Ok(mut system) = System::new() else { return }; + system.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::nothing()); + // Fail closed when process enumeration isn't available. Live files are never removed. + if system.process(Pid::from_u32(std::process::id())).is_none() { + return; + } + reap_with(dir, metadata.uid(), |pid| system.process(Pid::from_u32(pid)).is_some()); +} + +#[cfg(unix)] +fn reap_with(dir: &Path, uid: u32, live: impl Fn(u32) -> bool) { + use std::os::unix::fs::MetadataExt; + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(scratch_pid) else { continue }; + if pid == std::process::id() || live(pid) { + continue; + } + let path = entry.path(); + let Ok(meta) = std::fs::symlink_metadata(&path) else { continue }; + // Shared /tmp: do not remove other users' files or follow symlinks. + if meta.file_type().is_file() && meta.uid() == uid { + let _ = std::fs::remove_file(path); + } + } +} + +impl Drop for ScratchFile { + fn drop(&mut self) { + // Windows deletes on close and Unix unlinked at creation; only a refused unlink remains. + #[cfg(not(windows))] + if self.linked { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// A stable small number per thread, handed out in turn, so the worker threads of a pool land +/// on different handles. +#[cfg(windows)] +fn thread_slot() -> usize { + static NEXT: AtomicUsize = AtomicUsize::new(0); + thread_local! { + static SLOT: usize = NEXT.fetch_add(1, Ordering::Relaxed); + } + SLOT.with(|slot| *slot) +} + +#[cfg(unix)] +pub(crate) fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> { + std::os::unix::fs::FileExt::write_all_at(file, data, offset) +} + +#[cfg(unix)] +pub(crate) fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(file, buf, offset) +} + +#[cfg(windows)] +pub(crate) fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !data.is_empty() { + let n = file.seek_write(data, offset)?; + if n == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + data = data.get(n..).unwrap_or_default(); + offset += n as u64; + } + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !buf.is_empty() { + let n = file.seek_read(buf, offset)?; + if n == 0 { + return Err(std::io::ErrorKind::UnexpectedEof.into()); + } + buf = buf.get_mut(n..).unwrap_or_default(); + offset += n as u64; + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleanup_names_are_strict() { + assert_eq!(scratch_pid("photocraft-123-987.scratch"), Some(123)); + for name in [ + "photocraft-0-1.scratch", + "photocraft-1-.scratch", + "photocraft-1-../notes.scratch", + "photocraft-1-2.scratch.bak", + "other-1-2.scratch", + "photocraft-4294967296-2.scratch", + ] { + assert_eq!(scratch_pid(name), None, "{name}"); + } + } + + #[cfg(any(unix, windows))] + #[test] + fn abrupt_exit_does_not_leave_permanent_scratch_files() { + const CHILD_DIR: &str = "PHOTOCRAFT_SCRATCH_EXIT_TEST_DIR"; + if let Some(dir) = std::env::var_os(CHILD_DIR) { + let file = ScratchFile::create(Path::new(&dir)).unwrap(); + file.write(b"unreleased pixels").unwrap(); + // No Rust destructors run: this exercises the lifetime of the static manager. + std::process::exit(0); + } + let dir = std::env::temp_dir().join(format!("pc-abrupt-exit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "scratch_file::tests::abrupt_exit_does_not_leave_permanent_scratch_files", "--nocapture"]) + .env(CHILD_DIR, &dir) + .status() + .unwrap(); + assert!(result.success()); + // Windows: delete-on-close. Unix: unlinked right after opening. Neither needs Drop. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no scratch file outlives an exit without destructors"); + let next = ScratchFile::create(&dir).unwrap(); + // Unix unlinks the open file; Windows keeps the entry until the delete-on-close handle closes. + let live_entries = if cfg!(unix) { 0 } else { 1 }; + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), live_entries, "a live scratch file is unlinked on Unix only"); + next.write(b"still usable").unwrap(); + drop(next); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "closing the last scratch file leaves the directory empty"); + std::fs::remove_dir(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn unix_files_are_private_and_reaping_preserves_live_and_unrelated_files() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let dir = std::env::temp_dir().join(format!("pc-reap-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let own = ScratchFile::create(&dir).unwrap(); + let meta = own.main.metadata().unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + let stale = dir.join("photocraft-123-1.scratch"); + let live = dir.join("photocraft-124-1.scratch"); + let unrelated = dir.join("notes.scratch"); + let link = dir.join("photocraft-125-1.scratch"); + for p in [&stale, &live, &unrelated] { + std::fs::write(p, b"test").unwrap(); + } + std::os::unix::fs::symlink(&unrelated, &link).unwrap(); + reap_with(&dir, meta.uid().wrapping_add(1), |_| false); + assert!(stale.exists(), "another owner's files remain"); + reap_with(&dir, meta.uid(), |pid| pid == 124); + assert!(!stale.exists()); + assert!(live.exists() && unrelated.exists() && link.exists()); + assert!(!own.path.exists() && !own.linked, "the open scratch file is already unlinked"); + drop(own); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn parallel_positioned_reads_share_one_unlinked_file() { + let dir = std::env::temp_dir().join(format!("pc-scratch-parallel-{}", std::process::id())); + let scratch = ScratchFile::create(&dir).unwrap(); + let extents: Vec<_> = (0..32u8).map(|i| scratch.write(&vec![i; 5000 + usize::from(i)]).unwrap()).collect(); + std::thread::scope(|s| { + for t in 0..8 { + let (scratch, extents) = (&scratch, &extents); + s.spawn(move || { + let mut buf = Vec::new(); + for (i, e) in extents.iter().enumerate().skip(t) { + scratch.read_into(*e, &mut buf).unwrap(); + assert_eq!(buf, vec![i as u8; 5000 + i]); + } + }); + } + }); + drop(scratch); + std::fs::remove_dir(dir).unwrap(); + } + + #[test] + fn alloc_reuses_and_coalesces() { + let mut a = Allocator::default(); + let x = a.alloc(100); + let y = a.alloc(5000); + let z = a.alloc(4096); + assert_eq!((x.offset, y.offset, z.offset), (0, 4096, 12288)); + assert_eq!(a.used(), 16384); + a.free(x); + a.free(y); // coalesces with x: one free extent of 12 KiB at 0 + let w = a.alloc(12000); + assert_eq!(w.offset, 0, "coalesced hole is reused"); + a.free(z); // tail extent: the file end shrinks + assert_eq!(a.used(), 12288); + a.free(w); + assert_eq!(a.used(), 0); + assert_eq!(a.end, 0, "everything freed collapses the file"); + } + + #[test] + fn best_fit_splits_larger_holes() { + let mut a = Allocator::default(); + let big = a.alloc(40000); + let _tail = a.alloc(1); + a.free(big); + assert_eq!(a.alloc(10).offset, 0); + assert_eq!(a.alloc(10).offset, 4096, "remainder of the split hole is used next"); + } + + #[test] + fn file_roundtrip_and_crc() { + let dir = std::env::temp_dir().join("photocraft-scratch-test"); + let scratch = ScratchFile::create(&dir).unwrap(); + let a = scratch.write(b"hello tiles").unwrap(); + let b = scratch.write(&[7u8; 9000]).unwrap(); + let mut buf = Vec::new(); + scratch.read_into(a, &mut buf).unwrap(); + assert_eq!(buf, b"hello tiles"); + scratch.read_into(b, &mut buf).unwrap(); + assert_eq!(buf, vec![7u8; 9000]); + let bad = Extent { crc32: a.crc32 ^ 1, ..a }; + assert!(scratch.read_into(bad, &mut buf).is_err(), "a CRC mismatch is an error, not garbage"); + scratch.free(a); + scratch.free(b); + assert_eq!(scratch.main.metadata().unwrap().len(), 0, "closing the last tile releases physical disk space"); + let path = scratch.path().to_path_buf(); + drop(scratch); + assert!(!path.exists(), "scratch file is removed on drop"); + } +} diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs new file mode 100644 index 0000000000..ac37c85686 --- /dev/null +++ b/crates/raster/src/spill.rs @@ -0,0 +1,828 @@ +//! Scratch disk: tile pixels beyond a memory budget move to a file and come back on access. +//! +//! Every [`Tile`](crate::Tile) keeps its bytes in a [`Cell`]: resident (`Some`) or on disk +//! (`None`, with its [`Extent`] in the scratch file). Readers take the cell's read lock, so a +//! tile in use is never evicted; a spilled tile is read back (LZ4 + CRC-checked) by whoever +//! touches it first. +//! +//! - **Accounting.** [`stats`] counts the bytes of every live tile that is resident, whether or +//! not a scratch disk is configured. +//! - **Eviction** is the clock ("second chance") algorithm over a registry of weak cell +//! pointers: a tile touched since the hand last passed keeps its place once. A background +//! thread evicts down to 90 % of the budget; a thread that pushes memory past 125 % helps. +//! - **Clean tiles are free to drop.** A tile written once and not changed since keeps its +//! extent, so evicting it again only frees memory; changing it frees the extent. +//! - **Off by default.** Until [`configure`] sets a directory and a budget nothing is evicted +//! (tests, the web build). +//! +//! Failures never panic: a scratch write or file creation that fails, or a volume running low +//! on free space, keeps the tile in memory and pauses spilling (reported in +//! [`SpillStats::last_error`]); it is retried with exponential backoff and resumes on its own. +//! [`epoch`] tells owners of memory caps (history) to re-apply them meanwhile. A failed read retains its disk extent and +//! poisons the tile: edits and saves check the health of the document they publish, plus +//! [`check_since`] for failures that came and went during the operation. The +//! infallible display API uses a temporary placeholder, never replacement pixel storage. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; +use std::time::{Duration, Instant}; + +use crate::scratch_file::{Extent, ScratchFile}; + +/// One tile's bytes and where they live. +pub(crate) struct Cell { + data: RwLock>>, + /// Byte length of the tile (fixed for its lifetime). + len: usize, + error: Mutex>, + /// A copy on disk that matches `data` (kept while the tile is unchanged). + slot: Mutex, Extent)>>, + /// Touched since the clock hand last passed. + referenced: AtomicBool, +} + +impl Cell { + pub(crate) fn new(bytes: Vec) -> Arc { + let len = bytes.len(); + let cell = Arc::new(Cell { data: RwLock::new(Some(bytes)), len, error: Mutex::new(None), slot: Mutex::new(None), referenced: AtomicBool::new(true) }); + M.live.fetch_add(1, Ordering::Relaxed); + M.resident.fetch_add(len, Ordering::Relaxed); + M.register(&cell); + maybe_evict(); + cell + } + + pub(crate) fn len(&self) -> usize { + self.len + } + + pub(crate) fn read_error(&self) -> Option { + self.error.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + fn set_error(&self, error: Option) { + let mut old = self.error.lock().unwrap_or_else(PoisonError::into_inner); + match (old.is_some(), error.is_some()) { + (false, true) => { + M.failed.fetch_add(1, Ordering::Release); + M.read_errors.fetch_add(1, Ordering::Release); + } + (true, false) => { + // Invalidate computations that started while pixels were unreadable too. + // Publish this before reporting the tile healthy to commit guards. + M.read_errors.fetch_add(1, Ordering::Release); + M.failed.fetch_sub(1, Ordering::Release); + } + _ => {} + } + *old = error; + } + + #[inline] + fn touch(&self) { + if !self.referenced.load(Ordering::Relaxed) { + self.referenced.store(true, Ordering::Relaxed); + } + } + + /// Read access; reads the tile back from the scratch disk if it was evicted. + pub(crate) fn read(&self) -> RwLockReadGuard<'_, Option>> { + let mut loaded_pixels = false; + loop { + self.touch(); + let g = self.data.read().unwrap_or_else(PoisonError::into_inner); + if g.is_some() { + if loaded_pixels { + // Pin the tile before eviction: even a budget smaller than one tile + // must not repeatedly evict the pixels this reader just loaded. + maybe_evict(); + } + return g; + } + drop(g); + let mut w = self.data.write().unwrap_or_else(PoisonError::into_inner); + let loaded = self.load(&mut w).is_ok(); + drop(w); + if !loaded { + return self.data.read().unwrap_or_else(PoisonError::into_inner); + } + loaded_pixels = true; + } + } + + /// Write access: resident, and any disk copy is released (it no longer matches). + pub(crate) fn write(&self) -> RwLockWriteGuard<'_, Option>> { + self.touch(); + let mut w = self.data.write().unwrap_or_else(PoisonError::into_inner); + let loaded = w.is_none(); + if self.load(&mut w).is_err() { + // Keep the only original copy. The write guard's temporary placeholder is + // discarded, and the transaction's integrity check refuses to commit it. + return w; + } + if let Some((file, extent)) = self.slot.lock().unwrap_or_else(PoisonError::into_inner).take() { + file.free(extent); + } + if loaded { + // Evicting here could only pick other tiles (this one is locked), so it is safe, and + // a long run of writes to spilled tiles must not outgrow the budget. + maybe_evict(); + } + w + } + + /// Fill `w` from the scratch disk if it is not resident (`w` is this cell's write guard). + fn load(&self, w: &mut Option>) -> Result<(), String> { + if w.is_some() { + return Ok(()); + } + let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner).clone(); + let bytes = match slot { + Some((file, extent)) => { + let mut packed = Vec::new(); + match file.read_into(extent, &mut packed).map_err(|e| e.to_string()).and_then(|()| unpack(&packed, self.len)) { + Ok(b) => b, + Err(e) => { + let error = format!("scratch read failed ({e}); editing and saving are blocked until the tile is readable"); + self.set_error(Some(error.clone())); + M.error(error.clone()); + return Err(error); + } + } + } + None => { + let error = "a spilled tile has no scratch copy; editing and saving are blocked".to_string(); + self.set_error(Some(error.clone())); + M.error(error.clone()); + return Err(error); + } + }; + *w = Some(bytes); + self.set_error(None); + M.resident.fetch_add(self.len, Ordering::Relaxed); + M.reloads.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + /// Move this tile to disk if nobody is using it. Returns the bytes freed. + fn evict(&self, file: &Arc) -> Result { + let Ok(mut w) = self.data.try_write() else { return Ok(0) }; + let Some(bytes) = w.as_ref() else { return Ok(0) }; + let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner); + if slot.is_none() { + let packed = lz4_flex::block::compress(bytes); + let extent = file.write(&packed).map_err(|e| format!("scratch write to {} failed: {e}", file.path().display()))?; + M.written.fetch_add(packed.len() as u64, Ordering::Relaxed); + *slot = Some((file.clone(), extent)); + } + drop(slot); + *w = None; + M.resident.fetch_sub(self.len, Ordering::Relaxed); + M.evictions.fetch_add(1, Ordering::Relaxed); + Ok(self.len) + } +} + +impl Drop for Cell { + fn drop(&mut self) { + if self.error.get_mut().unwrap_or_else(PoisonError::into_inner).is_some() { + M.read_errors.fetch_add(1, Ordering::Release); + M.failed.fetch_sub(1, Ordering::Release); + } + if let Some(bytes) = self.data.get_mut().unwrap_or_else(PoisonError::into_inner).take() { + M.resident.fetch_sub(self.len, Ordering::Relaxed); + // The next tile of this size reuses the buffer, as main's pool expects. + crate::scratch::give(bytes); + } + if let Some((file, extent)) = self.slot.get_mut().unwrap_or_else(PoisonError::into_inner).take() { + file.free(extent); + } + M.live.fetch_sub(1, Ordering::Relaxed); + } +} + +fn unpack(packed: &[u8], len: usize) -> Result, String> { + let mut bytes = crate::scratch::take_exact(len, 0u8); + match lz4_flex::block::decompress_into(packed, &mut bytes) { + Ok(n) if n == len => Ok(bytes), + Ok(n) => { + crate::scratch::give(bytes); + Err(format!("tile decoded to {n} bytes, expected {len}")) + } + Err(e) => { + crate::scratch::give(bytes); + Err(e.to_string()) + } + } +} + +/// Process-wide state. Plain statics: everything here is const-constructible. +struct Manager { + failed: AtomicUsize, + read_errors: AtomicU64, + resident: AtomicUsize, + live: AtomicUsize, + /// Pixel bytes to keep resident; 0 = spilling off or paused. + budget: AtomicUsize, + /// Spilling is paused after a write, create or free-space failure ([`Config::paused`]). + paused: AtomicBool, + /// Changes whenever spilling turns on or off, so owners of memory caps re-apply them. + epoch: AtomicU64, + /// Scratch bytes allowed before the volume would run low or the size cap is reached. + space_limit: AtomicU64, + /// Re-read the volume's free space once the scratch file grows past this. + next_space_check: AtomicU64, + /// A sweep wrote successfully since the last resume: reset the retry backoff. + recovering: AtomicBool, + registry: Mutex, + config: Mutex, + evictions: AtomicU64, + reloads: AtomicU64, + written: AtomicU64, +} + +struct Registry { + cells: Vec>, + hand: usize, +} + +struct Config { + dir: Option, + /// Opened at configuration time; startup also reaps abandoned Unix files. + file: Option>, + last_error: Option, + /// The budget [`configure`] / [`set_budget`] asked for; restored when a pause ends. + budget: usize, + paused: Option, + /// Delay before the next retry; doubles per failed retry up to [`RETRY_MAX`]. + backoff: Duration, + /// Explicit scratch size limit ([`set_size_cap`]); the free-space reserve always applies. + size_cap: Option, +} + +struct Pause { + message: String, + retry_at: Instant, +} + +const RETRY_FIRST: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(60); +/// Re-check free space after this much scratch growth. +const SPACE_CHECK_EVERY: u64 = 64 << 20; +/// Leave at least max(1 GiB, 5 % of the volume) free for the system and other programs. +const RESERVE_MIN: u64 = 1 << 30; + +static M: Manager = Manager { + failed: AtomicUsize::new(0), + read_errors: AtomicU64::new(0), + resident: AtomicUsize::new(0), + live: AtomicUsize::new(0), + budget: AtomicUsize::new(0), + paused: AtomicBool::new(false), + epoch: AtomicU64::new(0), + space_limit: AtomicU64::new(u64::MAX), + next_space_check: AtomicU64::new(0), + recovering: AtomicBool::new(false), + registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }), + config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None }), + evictions: AtomicU64::new(0), + reloads: AtomicU64::new(0), + written: AtomicU64::new(0), +}; + +impl Manager { + fn register(&self, cell: &Arc) { + let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); + r.cells.push(Arc::downgrade(cell)); + // Drop the pointers of freed tiles once they outnumber the live ones. + if r.cells.len() > self.live.load(Ordering::Relaxed).saturating_mul(2).saturating_add(4096) { + r.cells.retain(|w| w.strong_count() > 0); + r.hand = 0; + } + } + + fn config(&self) -> std::sync::MutexGuard<'_, Config> { + self.config.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn error(&self, msg: String) { + log_error(&msg); + self.config().last_error = Some(msg); + } + + /// Set the active budget, bumping [`epoch`] when spilling turns on or off. + fn set_active(&self, budget: usize) { + let old = self.budget.swap(budget, Ordering::AcqRel); + if (old > 0) != (budget > 0) { + self.epoch.fetch_add(1, Ordering::AcqRel); + } + } + + /// Pause spilling after a failure: every tile stays in memory (owners re-apply their + /// memory caps through [`epoch`]) and a retry is scheduled with exponential backoff. + fn pause(&self, c: &mut Config, reason: &str) { + let wait = c.backoff; + c.backoff = c.backoff.saturating_mul(2).min(RETRY_MAX); + let message = format!("{reason}; spilling is paused (tiles stay in memory), retrying in {} s", wait.as_secs()); + log_error(&message); + c.last_error = Some(message.clone()); + c.paused = Some(Pause { message, retry_at: Instant::now().checked_add(wait).unwrap_or_else(Instant::now) }); + self.recovering.store(false, Ordering::Relaxed); + self.paused.store(true, Ordering::Release); + self.set_active(0); + } + + /// End a pause: the directory is usable and has room again. + fn resume(&self, c: &mut Config) { + if let Some(p) = c.paused.take() + && c.last_error.as_deref() == Some(p.message.as_str()) + { + c.last_error = None; + } + self.paused.store(false, Ordering::Release); + self.recovering.store(true, Ordering::Relaxed); + self.set_active(c.budget); + } + + /// Open the scratch file and check its volume's room; pause (and say why) if either fails. + /// The caller holds the config lock. Returns whether spilling can go ahead. + fn prepare(&self, c: &mut Config) -> bool { + let Some(dir) = c.dir.clone() else { return false }; + if c.file.is_none() { + match std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir)) { + Ok(f) => c.file = Some(Arc::new(f)), + Err(e) => { + self.pause(c, &format!("could not create a scratch file in {}: {e}", dir.display())); + return false; + } + } + } + let used = c.file.as_ref().map_or(0, |f| f.used()); + refresh_space(c, &dir, used); + if used >= self.space_limit.load(Ordering::Relaxed) { + let msg = no_room(&dir, c.size_cap); + self.pause(c, &msg); + return false; + } + true + } + + /// Retry a paused scratch disk once its backoff has elapsed. Cheap when not paused. + fn retry(&self) { + if !self.paused.load(Ordering::Acquire) { + return; + } + let mut c = match self.config.try_lock() { + Ok(c) => c, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + // Someone else is configuring or retrying. + Err(std::sync::TryLockError::WouldBlock) => return, + }; + let due = c.paused.as_ref().is_some_and(|p| Instant::now() >= p.retry_at); + if !due { + return; + } + if c.dir.is_none() || c.budget == 0 { + c.paused = None; + self.paused.store(false, Ordering::Release); + return; + } + if self.prepare(&mut c) { + self.resume(&mut c); + } + } + + /// The scratch file, if spilling is on and the volume still has room. + fn file(&self) -> Option> { + let mut c = self.config(); + if self.budget.load(Ordering::Relaxed) == 0 { + return None; + } + if c.file.is_none() && !self.prepare(&mut c) { + return None; + } + c.file.clone() + } + + /// Whether the scratch file may grow further; re-reads free space every + /// [`SPACE_CHECK_EVERY`] bytes and pauses spilling when the volume runs low. + fn has_room(&self, file: &ScratchFile) -> bool { + let used = file.used(); + if used < self.next_space_check.load(Ordering::Relaxed) && used < self.space_limit.load(Ordering::Relaxed) { + return true; + } + let mut c = self.config(); + let Some(dir) = c.dir.clone() else { return false }; + refresh_space(&c, &dir, used); + if used < self.space_limit.load(Ordering::Relaxed) { + return true; + } + if c.paused.is_none() { + let msg = no_room(&dir, c.size_cap); + self.pause(&mut c, &msg); + } + false + } + + /// Evict until resident memory is at most `target` or `max_scan` cells were looked at. + /// Returns the bytes freed. + fn sweep(&self, target: usize, max_scan: usize) -> usize { + let Some(file) = self.file() else { return 0 }; + let mut freed = 0; + let mut scanned = 0; + while self.resident.load(Ordering::Relaxed) > target && scanned < max_scan { + // Pick victims under the registry lock (cheap), evict them after releasing it. + let mut victims = Vec::with_capacity(64); + { + let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); + let mut i = 0; + while victims.len() < 64 && i < 4096 && !r.cells.is_empty() { + if r.hand >= r.cells.len() { + r.hand = 0; + } + let h = r.hand; + i += 1; + match r.cells.get(h).and_then(Weak::upgrade) { + None => { + r.cells.swap_remove(h); + continue; + } + Some(cell) => { + r.hand += 1; + if cell.referenced.swap(false, Ordering::Relaxed) { + continue; + } + if cell.data.try_read().is_ok_and(|d| d.is_some()) { + victims.push(cell); + } + } + } + } + scanned += i; + if i == 0 { + break; + } + } + for cell in victims { + if !self.has_room(&file) { + return freed; + } + match cell.evict(&file) { + Ok(n) => freed += n, + Err(e) => { + // Disk full or gone: keep tiles in memory until a retry succeeds. + let mut c = self.config(); + if c.paused.is_none() { + self.pause(&mut c, &e); + } + return freed; + } + } + } + } + if freed > 0 && self.recovering.swap(false, Ordering::Relaxed) { + self.config().backoff = RETRY_FIRST; + } + freed + } +} + +/// Free space on the volume holding `dir`, minus the reserve, sets how far the file may grow. +fn refresh_space(c: &Config, dir: &Path, used: u64) { + let room = free_space(dir).map_or(u64::MAX, |(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20)))); + let limit = c.size_cap.map_or(room, |cap| cap.min(room)); + M.space_limit.store(limit, Ordering::Relaxed); + M.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed); +} + +fn no_room(dir: &Path, cap: Option) -> String { + match cap { + Some(cap) => format!("scratch disk {} reached its size limit ({} MB) or is low on free space", dir.display(), cap >> 20), + None => format!("scratch disk {} is low on free space", dir.display()), + } +} + +/// (available, total) bytes of the volume holding `dir`: the mount point that is its longest +/// prefix. `None` if unknown, which disables only the free-space check, not spilling. +#[cfg(not(target_arch = "wasm32"))] +fn free_space(dir: &Path) -> Option<(u64, u64)> { + let dir = std::fs::canonicalize(dir).ok()?; + let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()).ok()?; + disks.iter().filter(|d| dir.starts_with(d.mount_point())).max_by_key(|d| d.mount_point().as_os_str().len()).map(|d| (d.available_space(), d.total_space())) +} + +#[cfg(target_arch = "wasm32")] +fn free_space(_dir: &Path) -> Option<(u64, u64)> { + None +} + +/// Called after resident memory grew. +fn maybe_evict() { + let budget = M.budget.load(Ordering::Relaxed); + if budget == 0 { + M.retry(); + return; + } + let resident = M.resident.load(Ordering::Relaxed); + if resident <= budget { + return; + } + let worker = MAINTENANCE.load(Ordering::Acquire) > 0; + // Far over budget (or no worker thread): this thread helps. + if !worker || resident > budget.saturating_add(budget / 4) { + M.sweep(low_water(budget), 1 << 16); + } +} + +fn low_water(budget: usize) -> usize { + budget / 10 * 9 +} + +// The engine owns and joins the worker. Standalone raster consumers keep synchronous +// eviction; this crate never starts a background thread or retains its owner. +static MAINTENANCE: AtomicUsize = AtomicUsize::new(0); + +/// Register an externally owned residency worker. Keep this guard until that worker exits. +pub struct Maintenance; + +pub fn background_maintenance() -> Maintenance { + MAINTENANCE.fetch_add(1, Ordering::AcqRel); + Maintenance +} + +impl Drop for Maintenance { + fn drop(&mut self) { + MAINTENANCE.fetch_sub(1, Ordering::AcqRel); + } +} + +/// One bounded clock pass, plus a due retry of a paused scratch disk. The caller owns +/// scheduling and shutdown. +pub fn maintain_budget() -> usize { + M.retry(); + let budget = M.budget.load(Ordering::Relaxed); + if budget == 0 || M.resident.load(Ordering::Relaxed) <= budget { + return 0; + } + M.sweep(low_water(budget), 4096) +} + +/// Change the memory budget of a configured scratch disk (the memory limit moved). A paused +/// disk keeps its pause and resumes with this budget. +pub fn set_budget(budget_bytes: usize) { + let mut c = M.config(); + if c.dir.is_none() || c.budget == 0 { + return; + } + c.budget = budget_bytes.max(1); + if c.paused.is_none() { + M.set_active(c.budget); + } + drop(c); + maybe_evict(); +} + +/// Limit how large the scratch file may grow (`None`: only the free-space reserve applies). +/// Reaching the limit pauses spilling like a full disk; changing it retries a paused disk now. +pub fn set_size_cap(cap: Option) { + let mut c = M.config(); + if c.size_cap == cap { + return; + } + c.size_cap = cap; + M.next_space_check.store(0, Ordering::Relaxed); + if let Some(p) = &mut c.paused { + p.retry_at = Instant::now(); + c.backoff = RETRY_FIRST; + } + drop(c); + M.retry(); +} + +/// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk +/// stay readable either way. Changing the directory starts a new scratch file there; the old one +/// is deleted once its last tile is freed or changed. A directory that can't be used is kept and +/// retried with backoff (spilling stays paused meanwhile); the error is returned and reported. +pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> { + let mut c = M.config(); + let dir = dir.filter(|_| budget_bytes > 0).map(Path::to_path_buf); + if dir != c.dir { + c.file = None; + } + c.dir = dir.clone(); + c.budget = if dir.is_some() { budget_bytes } else { 0 }; + c.backoff = RETRY_FIRST; + c.paused = None; + c.last_error = None; + M.paused.store(false, Ordering::Release); + M.next_space_check.store(0, Ordering::Relaxed); + if dir.is_some() && !M.prepare(&mut c) { + return Err(c.last_error.clone().unwrap_or_default()); + } + M.set_active(c.budget); + drop(c); + maybe_evict(); + Ok(()) +} + +/// Changes whenever spilling turns on or off, including a pause after a disk error and the +/// resume after it. Owners of memory caps that depend on [`enabled`] re-apply them on a change. +pub fn epoch() -> u64 { + M.epoch.load(Ordering::Acquire) +} + +/// Whether spilling is paused after a scratch disk failure (it retries on its own). +pub fn paused() -> bool { + M.paused.load(Ordering::Acquire) +} + +/// Retry a paused scratch disk on the next maintenance pass instead of after its backoff. +#[doc(hidden)] +pub fn retry_now_for_tests() { + if let Some(p) = &mut M.config().paused { + p.retry_at = Instant::now(); + } +} + +/// Where the tile memory stands. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpillStats { + /// Pixel bytes of live tiles in memory. + pub resident_bytes: usize, + pub live_tiles: usize, + /// 0 = spilling off. + pub budget_bytes: usize, + pub dir: Option, + /// Bytes allocated in the current scratch file. + pub scratch_bytes: u64, + pub evictions: u64, + pub reloads: u64, + /// Compressed bytes written to scratch so far. + pub written_bytes: u64, + pub last_error: Option, + /// Spilling is paused after a scratch disk failure and will be retried. + pub paused: bool, + /// Scratch file size limit ([`set_size_cap`]); `None` = only the free-space reserve. + pub size_cap_bytes: Option, +} + +pub fn stats() -> SpillStats { + let c = M.config(); + SpillStats { + resident_bytes: M.resident.load(Ordering::Relaxed), + live_tiles: M.live.load(Ordering::Relaxed), + budget_bytes: M.budget.load(Ordering::Relaxed), + dir: c.dir.clone(), + scratch_bytes: c.file.as_ref().map_or(0, |f| f.used()), + evictions: M.evictions.load(Ordering::Relaxed), + reloads: M.reloads.load(Ordering::Relaxed), + written_bytes: M.written.load(Ordering::Relaxed), + last_error: c.last_error.clone(), + paused: c.paused.is_some(), + size_cap_bytes: c.size_cap, + } +} + +/// Last storage error, without scanning the scratch allocator for UI status updates. +pub fn last_error() -> Option { + M.config().last_error.clone() +} + +/// Uncompressed tile bytes the scratch disk can still take before it reaches its size limit +/// ([`set_size_cap`]) or the volume's free-space reserve, so an import can admit tile pixels +/// beyond physical RAM. `None` while spilling is off or paused, and when neither the free space +/// nor a size limit is known (nothing to promise). Tiles are written compressed; this counts +/// them uncompressed, less 1/64 for the allocator's block rounding, so it never over-promises. +pub fn scratch_capacity() -> Option { + if !enabled() || paused() { + return None; + } + let (dir, used, cap) = { + let c = M.config(); + (c.dir.clone()?, c.file.as_ref().map_or(0, |f| f.used()), c.size_cap) + }; + let room = free_space(&dir).map(|(available, total)| available.saturating_sub(RESERVE_MIN.max(total / 20))); + let room = match (room, cap) { + (Some(room), Some(cap)) => room.min(cap.saturating_sub(used)), + (Some(room), None) => room, + (None, Some(cap)) => cap.saturating_sub(used), + (None, None) => return None, + }; + Some(room - room / 64) +} + +/// Whether spilling is on (tiles beyond the budget go to disk). +pub fn enabled() -> bool { + M.budget.load(Ordering::Relaxed) > 0 +} + +/// Whether any live tile in the process is currently unreadable (cheap; the fast path of +/// every per-document health check). +pub fn any_failed() -> bool { + M.failed.load(Ordering::Acquire) > 0 +} + +/// Process-wide status for the UI: some live tile, in any document, is unreadable. Commit +/// guards check only the pixels they publish ([`Tile::scratch_health`](crate::Tile::scratch_health) +/// and the document's health) so one document's failure does not block the others. +pub fn check_integrity() -> Result<(), String> { + if any_failed() { + Err(M.config().last_error.clone().unwrap_or_else(|| "scratch pixels are unreadable; editing and saving are blocked".into())) + } else { + Ok(()) + } +} + +/// Capture before an edit/import/save or derived cache computation. A tile that fails or +/// recovers while the operation runs might have fed it placeholder pixels and then been +/// dropped or repaired; its result must still never be committed as real pixels. Both failure +/// and recovery change this generation. Tiles that were already unreadable when the operation +/// began do not change it: callers also check the health of the pixels they read. +pub fn read_error_generation() -> u64 { + M.read_errors.load(Ordering::Acquire) +} + +/// Refuse a result if any tile failed or recovered since `generation` was captured. A retry +/// (capturing a new generation) succeeds once the transition is over. +pub fn check_since(generation: u64) -> Result<(), String> { + if generation == read_error_generation() { + Ok(()) + } else { + Err(last_error().unwrap_or_else(|| "scratch read failed during this operation; the result was discarded".into())) + } +} + +/// A second handle to the current scratch file, for tests that damage or restore its bytes. +/// The Unix file is unlinked, so it cannot be reopened by path. All scratch I/O is positioned, +/// so the shared file offset of a duplicated handle does not affect it. +#[doc(hidden)] +pub fn scratch_handle_for_tests() -> Option { + M.config().file.as_ref().and_then(|f| f.duplicate_handle().ok()) +} + +/// Evict down to the budget now, on this thread (tests, and before a measurement). +pub fn settle() { + let budget = M.budget.load(Ordering::Relaxed); + // Stops when nothing more can be evicted (every remaining tile is in use). + while budget > 0 && M.resident.load(Ordering::Relaxed) > budget { + if M.sweep(budget, usize::MAX) == 0 { + break; + } + } +} + +fn log_error(msg: &str) { + #[cfg(not(target_arch = "wasm32"))] + eprintln!("photocraft: {msg}"); + #[cfg(target_arch = "wasm32")] + let _ = msg; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_read_retains_backing_and_blocks_commit_until_repaired() { + let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id())); + let file = Arc::new(ScratchFile::create(&dir).unwrap()); + let mut tile = crate::Tile { cell: Cell::new(vec![73; 1024]) }; + let generation = read_error_generation(); + assert_eq!(tile.cell.evict(&file).unwrap(), 1024); + let good = tile.cell.slot.lock().unwrap().as_ref().unwrap().1; + tile.cell.slot.lock().unwrap().as_mut().unwrap().1.crc32 ^= 1; + + assert!(tile.try_bytes().is_err()); + let failed_generation = read_error_generation(); + assert!(tile.cell.data.read().unwrap().is_none(), "placeholder must never become stored pixels"); + assert!(check_integrity().is_err()); + assert!(tile.scratch_health().is_err(), "the tile reports its own failure"); + assert!(crate::Tile::from_vec(vec![1; 16]).scratch_health().is_ok(), "other tiles stay healthy"); + assert!(tile.try_bytes().is_err()); + assert!(check_since(failed_generation).is_ok(), "re-reading a tile that already failed is not a new transition"); + tile.bytes_mut()[0] = 9; + assert!(tile.cell.data.read().unwrap().is_none()); + assert!(tile.cell.slot.lock().unwrap().is_some(), "a failed mutation must preserve the original extent"); + let snapshot = tile.clone(); + assert!(Arc::ptr_eq(&tile.cell, &snapshot.cell)); + configure(None, 0).unwrap(); + assert!(check_integrity().is_err(), "changing preferences must not clear unreadable pixels"); + + tile.cell.slot.lock().unwrap().as_mut().unwrap().1 = good; + assert_eq!(&*tile.try_bytes().unwrap(), &[73; 1024]); + assert!(check_integrity().is_ok()); + assert!(tile.scratch_health().is_ok()); + assert!(check_since(generation).is_err(), "repairing or dropping a failed tile must not validate an earlier result"); + assert!(check_since(failed_generation).is_err(), "recovery must reject a computation begun during the failure"); + assert_eq!(tile.cell.evict(&file).unwrap(), 1024); + tile.cell.slot.lock().unwrap().as_mut().unwrap().1.crc32 ^= 1; + assert!(tile.try_bytes().is_err()); + let dropped_generation = read_error_generation(); + drop((snapshot, tile, file)); + assert!(check_integrity().is_ok()); + assert!(check_since(dropped_generation).is_err(), "dropping the failed tile must also reject its placeholder result"); + let _ = std::fs::remove_dir(&dir); + } +} diff --git a/crates/raster/src/stage_file.rs b/crates/raster/src/stage_file.rs new file mode 100644 index 0000000000..aff858c279 --- /dev/null +++ b/crates/raster/src/stage_file.rs @@ -0,0 +1,178 @@ +//! Encoded data an exporter parks on the scratch disk instead of in memory (#5333). +//! +//! A large PSD/PSB save encodes every channel before it writes a byte (so it can still be +//! cancelled, and its scratch reads are checked, before the file is replaced). Kept in memory, +//! those channels are a second copy of the document. A [`StageFile`] holds them on the scratch +//! disk instead: an append-only file made like the tile scratch file (private, unlinked on Unix, +//! delete-on-close on Windows), so nothing is left behind at any exit, and the space returns as +//! soon as the save drops it. +//! +//! Like the tile scratch file, every append carries a CRC-32: a read pass that covers an append +//! from its start to its end (whole, or in consecutive parts, as a save copies it) fails on its +//! last part if the disk handed back different bytes, so the save errors before it replaces +//! anything instead of writing them into the document. + +use std::collections::BTreeMap; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, PoisonError}; + +use crate::scratch_file::{open_private, read_exact_at, write_all_at}; + +pub struct StageFile { + file: File, + path: PathBuf, + /// The directory entry still exists (the unlink was refused): remove it on drop. + linked: bool, + /// Bytes reserved so far; appends take their offset from it. + end: Mutex, + /// Each append by its offset, with its checksum and the read pass under way. + extents: Mutex>, +} + +struct Extent { + len: u64, + crc32: u32, + /// Where the current read pass continues (`None` once it skipped or jumped: not checked). + next: Option, + hasher: crc32fast::Hasher, +} + +impl std::fmt::Debug for StageFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StageFile").field("path", &self.path).field("len", &self.len()).finish() + } +} + +impl StageFile { + /// A new, empty stage file in `dir`. + pub fn create(dir: &Path) -> std::io::Result { + let (file, path, linked) = open_private(dir)?; + Ok(Self { file, path, linked, end: Mutex::new(0), extents: Mutex::default() }) + } + + /// A stage file in the configured scratch directory, when spilling is on (`None` otherwise, + /// or when the file cannot be made: the caller then keeps its data in memory). + pub fn in_scratch() -> Option { + if !crate::spill::enabled() { + return None; + } + Self::create(&crate::spill::stats().dir?).ok() + } + + /// Appends `data` and returns its offset. Parallel appends each get their own range. + pub fn append(&self, data: &[u8]) -> std::io::Result { + let offset = { + let mut end = self.end.lock().unwrap_or_else(PoisonError::into_inner); + let offset = *end; + *end = offset.checked_add(data.len() as u64).ok_or_else(|| std::io::Error::other("stage file too large"))?; + offset + }; + write_all_at(&self.file, data, offset)?; + let extent = Extent { len: data.len() as u64, crc32: crc32fast::hash(data), next: Some(offset), hasher: crc32fast::Hasher::new() }; + self.extents.lock().unwrap_or_else(PoisonError::into_inner).insert(offset, extent); + Ok(offset) + } + + /// Fills `buf` from `offset`, which must lie within what was appended. A read that ends a + /// pass over one append from its start fails with `InvalidData` if the append's bytes changed. + pub fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> std::io::Result<()> { + let Some(end) = offset.checked_add(buf.len() as u64).filter(|&end| end <= self.len()) else { + return Err(std::io::ErrorKind::UnexpectedEof.into()); + }; + read_exact_at(&self.file, buf, offset)?; + let mut extents = self.extents.lock().unwrap_or_else(PoisonError::into_inner); + let Some((&start, extent)) = extents.range_mut(..=offset).next_back() else { return Ok(()) }; + let extent_end = start + extent.len; + if end > extent_end { + return Ok(()); // spans appends (no save reads this way): not checked + } + if offset == start { + extent.hasher = crc32fast::Hasher::new(); + extent.next = Some(start); + } + if extent.next != Some(offset) { + extent.next = None; + return Ok(()); + } + extent.hasher.update(buf); + extent.next = Some(end); + if end == extent_end { + let crc32 = std::mem::take(&mut extent.hasher).finalize(); + extent.next = None; + if crc32 != extent.crc32 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "staged save data changed on the scratch disk (checksum mismatch)")); + } + } + Ok(()) + } + + /// Bytes appended (reserved) so far. + pub fn len(&self) -> u64 { + *self.end.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl Drop for StageFile { + fn drop(&mut self) { + if self.linked { + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_read_back_and_leave_nothing_behind() { + let dir = std::env::temp_dir().join(format!("pc-stage-{}", std::process::id())); + let stage = StageFile::create(&dir).unwrap(); + #[cfg(unix)] + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the stage file is unlinked while open"); + let parts: Vec> = (0..8u8).map(|i| vec![i; 1000 + usize::from(i)]).collect(); + let offsets: Vec = std::thread::scope(|s| { + let handles: Vec<_> = parts.iter().map(|p| s.spawn(|| stage.append(p).unwrap())).collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert_eq!(stage.len(), parts.iter().map(|p| p.len() as u64).sum::()); + for (p, &at) in parts.iter().zip(&offsets) { + let mut buf = vec![0; p.len()]; + stage.read_exact_at(&mut buf, at).unwrap(); + assert_eq!(&buf, p); + } + let mut past = [0u8; 2]; + assert!(stage.read_exact_at(&mut past, stage.len() - 1).is_err(), "reads stop at what was appended"); + drop(stage); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir(&dir); + } + + /// Bytes that change on the disk fail the pass that copies them, whole or in parts. + #[test] + fn a_changed_append_fails_its_read_pass() { + let dir = std::env::temp_dir().join(format!("pc-stage-crc-{}", std::process::id())); + let stage = StageFile::create(&dir).unwrap(); + let first = stage.append(&[1; 100]).unwrap(); + let second = stage.append(&[2; 100]).unwrap(); + let pass = |at: u64, part: usize| -> std::io::Result<()> { + let mut buf = vec![0; part]; + (0..100 / part as u64).try_for_each(|i| stage.read_exact_at(&mut buf, at + i * part as u64)) + }; + pass(first, 10).unwrap(); + pass(second, 100).unwrap(); + write_all_at(&stage.file, &[9], second + 57).unwrap(); + pass(first, 25).unwrap(); + assert_eq!(pass(second, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "in parts"); + assert_eq!(pass(second, 100).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "whole"); + let mut part = [0; 10]; + stage.read_exact_at(&mut part, second + 50).unwrap(); // a read that starts mid-append is not a pass + drop(stage); + let _ = std::fs::remove_dir(&dir); + } +} diff --git a/crates/raster/src/tests.rs b/crates/raster/src/tests.rs index 47e78008fd..472849c977 100644 --- a/crates/raster/src/tests.rs +++ b/crates/raster/src/tests.rs @@ -41,7 +41,7 @@ fn previous_read_region(surface: &Surface, r: Rect) -> Vec { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; for i in 0..n { - out[o + i] = read_sample(&t.data, surface.format.sample, base + i); + out[o + i] = read_sample(&t.bytes(), surface.format.sample, base + i); } } None => out[o..o + n].copy_from_slice(&dp), @@ -137,7 +137,7 @@ fn write_region_reference(surface: &mut Surface, r: Rect, data: &[f32]) { let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; for i in 0..n { - write_sample(&mut t.data, sample, base + i, data[o + i]); + write_sample(&mut t.bytes_mut(), sample, base + i, data[o + i]); } } } @@ -338,6 +338,30 @@ fn fallible_region_write_rejects_bad_input_without_mutating_surface() { assert_eq!(surface, before); } +#[test] +fn fallible_interleaved_write_matches_the_infallible_one_and_rejects_bad_lengths() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + // Across tile edges, at a negative origin. + let r = Rect::new(-3, 250, 300, 517); + let bytes: Vec = (0..r.width() as usize * r.height() as usize * fmt.bytes_per_pixel()).map(|i| (i * 31 % 251) as u8).collect(); + let mut a = Surface::new(fmt); + a.write_interleaved(r, &bytes); + let mut b = Surface::new(fmt); + b.try_write_interleaved(r, &bytes).unwrap(); + assert_eq!(a, b); + let before = b.clone(); + let got = bytes.len() - 1; + assert_eq!(b.try_write_interleaved(r, &bytes[1..]), Err(AllocationError::LengthMismatch { expected: bytes.len(), got })); + assert_eq!(b, before); + // Tiles an undo snapshot shares are copied, not written through. + let again: Vec = bytes.iter().map(|v| v ^ 0x5a).collect(); + b.try_write_interleaved(r, &again).unwrap(); + a.write_interleaved(r, &again); + assert_eq!(a, b); + assert_eq!(before.to_interleaved(r), bytes); + } +} + #[test] fn fallible_tile_edits_move_weakly_cached_pixels_and_copy_shared_pixels() { for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { @@ -547,13 +571,13 @@ fn tile_content_bounds_match_a_per_pixel_scan() { for &s in &spots { data[s - s % bpp + byte] ^= 0x5a; } - let t = Tile { data: data.into_boxed_slice() }; + let t = Tile::from_vec(data); 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() }; + let t = Tile::from_vec(data); assert_eq!(t.content_bounds(&dp), Some((9, 3, 10, 4)), "bpp {bpp}"); } } @@ -810,3 +834,22 @@ fn content_bounds_with_tile_skipping() { s.fill_rect(Rect::new(-700, 4000, -699, 4001), &[0.5]); assert_eq!(s.content_bounds(), Rect::new(-700, 0, 2000, 4001)); } + +#[test] +fn fallible_interleaved_writes_match_encoded_pixels_and_reject_bad_lengths() { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat::new(ColorMode::Rgb, sample, true); + let rect = Rect::new(-2, -1, 3, 2); + let bytes: Vec = (0..rect.width() as usize * rect.height() as usize * format.bytes_per_pixel()).map(|i| (i % 251) as u8).collect(); + let mut surface = Surface::new(format); + assert!(surface.try_write_interleaved(rect, &bytes[..bytes.len() - 1]).is_err()); + assert!(surface.tiles.is_empty()); + surface.try_write_interleaved(rect, &bytes).unwrap(); + assert_eq!(surface.to_interleaved(rect), bytes); + let mut shared = surface.clone(); + let replacement = vec![0; bytes.len()]; + shared.try_write_interleaved(rect, &replacement).unwrap(); + assert_eq!(surface.to_interleaved(rect), bytes); + assert_eq!(shared.to_interleaved(rect), replacement); + } +} diff --git a/crates/raster/tests/scratch.rs b/crates/raster/tests/scratch.rs new file mode 100644 index 0000000000..a0f532f174 --- /dev/null +++ b/crates/raster/tests/scratch.rs @@ -0,0 +1,361 @@ +//! The scratch pool is process-wide, so these tests live in their own binary (nothing else +//! allocates tiles here) and take a lock to run one at a time. + +use photocraft_raster::scratch::*; +use std::sync::{Mutex, PoisonError}; + +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Lets a miss evict every pooled byte for the test's lifetime, so sizes can be counted. +struct NoFloor; +impl NoFloor { + /// Sets the miss floor to zero for this guard's lifetime. + fn new() -> Self { + set_miss_floor(0); + NoFloor + } +} +impl Drop for NoFloor { + fn drop(&mut self) { + set_miss_floor(DEFAULT_MISS_FLOOR); + } +} +/// Locks the process-wide pool tests against concurrent access. +fn lock_serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Runs a test alone with idle release off, so the idle thread can't empty the pool between a +/// `give` and the checks after it; idle release comes back on when dropped. +struct Serial { + _lock: std::sync::MutexGuard<'static, ()>, +} +impl Drop for Serial { + fn drop(&mut self) { + set_idle_release(true); + } +} +/// Serializes a test and disables idle release until the guard is dropped. +fn serial() -> Serial { + let _lock = lock_serial(); + set_idle_release(false); + Serial { _lock } +} + +#[test] +/// Verifies pooled buffers are reused by pointer, retain capacity, and are refilled to the requested length. +fn reuse_returns_same_capacity_and_refills() { + let _g = serial(); + release(); + let mut v = take(100_000, 0.0f32); + v.iter_mut().for_each(|x| *x = 7.0); + let ptr = v.as_ptr(); + give(v); + assert_eq!(retained_bytes(), 400_000); + let w = take(100_000, 1.5f32); + assert_eq!(w.as_ptr(), ptr); + assert_eq!(w.len(), 100_000); + assert!(w.iter().all(|&x| x == 1.5)); + assert_eq!(retained_bytes(), 0); + // A smaller request within 2x reuses it too, and is sized exactly. + give(w); + let s = take(60_000, 0.0f32); + assert_eq!(s.as_ptr(), ptr); + assert_eq!(s.len(), 60_000); + release(); +} + +#[test] +/// Verifies oversized or non-exact buffers are discarded instead of reused. +fn too_big_a_buffer_is_not_reused() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + give(vec![0u8; 1_000_000]); + assert_eq!(take_exact(1_000_000, 0u8).len(), 1_000_000); + assert_eq!(retained_bytes(), 0); + give(vec![0u8; 1_000_000]); + // More than 2x (or not exact): allocated fresh, and the miss drops the unusable buffer. + assert_eq!(take(100_000, 0u8).capacity(), 100_000); + assert_eq!(retained_bytes(), 0); + give(vec![0u8; 1_000_000]); + assert_eq!(take_exact(999_999, 0u8).capacity(), 999_999); + assert_eq!(retained_bytes(), 0); + release(); +} + +#[test] +/// Verifies exact try-takes reuse a matching buffer and succeed on a miss. +fn try_take_exact_reuses_and_allocates() { + let _g = serial(); + release(); + let buf = vec![0u8; 200_000]; + let ptr = buf.as_ptr(); + give(buf); + let v = try_take_exact(200_000, 9u8); + assert!(v.as_ref().is_some_and(|v| v.len() == 200_000 && v.iter().all(|&b| b == 9))); + assert_eq!(v.as_ref().map(|v| v.as_ptr()), Some(ptr)); + assert_eq!(retained_bytes(), 0); + assert!(try_take_exact(300_000, 0u8).is_some()); + release(); +} + +#[test] +/// Verifies buffers below the pooling threshold are not retained. +fn small_buffers_are_not_retained() { + let _g = serial(); + release(); + give(vec![0u8; MIN_POOLED_BYTES - 1]); + give(vec![0.0f32; 100]); + assert_eq!(retained_bytes(), 0); + assert_eq!(take(10, 3u8), vec![3u8; 10]); +} + +#[test] +/// Verifies zero-length take and give operations remain empty. +fn zero_length() { + let _g = serial(); + assert!(take(0, 0u8).is_empty()); + give(Vec::::new()); + assert!(take_exact(0, [0.0f32; 4]).is_empty()); +} + +#[test] +/// Verifies the byte budget evicts oldest buffers and rejects buffers larger than the budget. +fn budget_evicts_oldest() { + let _g = serial(); + release(); + set_budget(250_000); + let (a, b) = (vec![0u8; 100_000], vec![0u8; 100_000]); + let pb = b.as_ptr(); + give(a); + give(b); + give(vec![0u8; 100_000]); + assert_eq!(retained_bytes(), 200_000); + // The oldest was evicted; the survivors include b. + let first = take_exact(100_000, 0u8); + let second = take_exact(100_000, 0u8); + assert!(first.as_ptr() == pb || second.as_ptr() == pb); + // Larger than the whole budget: dropped. + give(vec![0u8; 300_000]); + assert_eq!(retained_bytes(), 0); + set_budget(DEFAULT_BUDGET); + release(); +} + +#[test] +/// Verifies release empties pooled buffers of several element types. +fn release_empties() { + let _g = serial(); + give(vec![0u8; 200_000]); + give(vec![0.0f32; 200_000]); + give(vec![[0.0f32; 4]; 100_000]); + assert!(retained_bytes() > 0); + release(); + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies the usage counter advances for both take and give. +fn uses_counts_calls() { + let _g = serial(); + let before = uses(); + give(take(10, 0u8)); + assert_eq!(uses(), before + 2); +} + +#[test] +/// Verifies dropping a `Buf` returns its storage to the pool. +fn buf_returns_on_drop() { + let _g = serial(); + release(); + { + let mut b = Buf::new(150_000, 0.0f32); + b[3] = 1.0; + assert_eq!(b.len(), 150_000); + } + assert_eq!(retained_bytes(), 600_000); + release(); +} + +#[test] +/// Verifies concurrent take and give operations preserve initialized values and finish cleanly. +fn concurrent_smoke() { + let _g = serial(); + release(); + let handles: Vec<_> = (0..8u8) + .map(|t| { + std::thread::spawn(move || { + for i in 0..200 { + let v = take(70_000 + i % 5, t); + assert!(v.iter().all(|&x| x == t)); + give(v); + } + }) + }) + .collect(); + for h in handles { + assert!(h.join().is_ok()); + } + release(); + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies budget eviction uses insertion age across different element types. +fn eviction_is_oldest_first_across_types() { + let _g = serial(); + release(); + set_budget(500_000); + give(vec![0u8; 300_000]); + give(vec![0.0f32; 50_000]); + assert_eq!(retained_bytes(), 500_000); + // Needs 200 000 more: the older u8 buffer goes, the f32 one stays. + give(vec![[0.0f32; 4]; 12_500]); + assert_eq!(retained_bytes(), 400_000); + assert_eq!(take_exact(50_000, 1.0f32).len(), 50_000); + assert_eq!(retained_bytes(), 200_000); + set_budget(DEFAULT_BUDGET); + release(); +} + +#[test] +/// Verifies reuse chooses the smallest eligible capacity and exact takes require an exact match. +fn best_fit_picks_the_smallest_fitting_capacity() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + for n in [180_000usize, 120_000, 150_000, 300_000] { + give(vec![0u8; n]); + } + // Within 2x of 100 000: 120 000 is the tightest fit, then 150 000, then 180 000. + assert_eq!(take(100_000, 0u8).capacity(), 120_000); + assert_eq!(take(100_000, 0u8).capacity(), 150_000); + assert_eq!(take(100_000, 0u8).capacity(), 180_000); + // 300 000 is more than 2x: allocated fresh, and the miss drops the pooled one. + assert_eq!(take(100_000, 0u8).capacity(), 100_000); + assert_eq!(retained_bytes(), 0); + // An exact capacity beats a smaller-slack scan. + give(vec![0u8; 100_000]); + assert_eq!(take_exact(100_000, 0u8).capacity(), 100_000); + assert_eq!(take_exact(250_000, 0u8).capacity(), 250_000); + release(); +} + +#[test] +/// Verifies repeated budget overflow evicts oldest entries across mixed types and capacities. +fn eviction_walks_oldest_first_through_mixed_types_and_capacities() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + set_budget(700_000); + // Oldest to newest, 100 000 bytes each, interleaved types and two u8 capacities. + give(vec![0u8; 100_000]); + let f = vec![0.0f32; 25_000]; + give(f); + give(vec![0u8; 100_000]); + give(vec![0u16; 50_000]); + let newest = vec![0u8; 150_000]; + let np = newest.as_ptr(); + give(newest); + assert_eq!(retained_bytes(), 550_000); + // 200 000 more overflows by 50 000: the oldest (u8) goes. + give(vec![[0.0f32; 4]; 12_500]); + assert_eq!(retained_bytes(), 650_000); + // 100 000 more: the next oldest (the f32 buffer) goes. + give(vec![0u8; 100_000]); + assert_eq!(retained_bytes(), 650_000); + // 200 000 more: the u8 then the u16 buffer go. + give(vec![0u8; 200_000]); + assert_eq!(retained_bytes(), 650_000); + // The newest u8 buffer of the old batch is still pooled; the f32 one was evicted. + let again = take_exact(150_000, 0u8); + assert_eq!(again.as_ptr(), np); + assert_eq!(retained_bytes(), 500_000); + // The evicted f32 buffer is a miss: the oldest buffers, at least its 100 000 bytes, leave + // (the 200 000-byte [f32; 4] one). + assert_eq!(take_exact(25_000, 0.0f32).len(), 25_000); + assert_eq!(retained_bytes(), 300_000); + set_budget(DEFAULT_BUDGET); + release(); +} + +#[test] +/// Verifies the idle-release worker clears an unused pooled buffer. +fn idle_pool_is_released_by_the_background_thread() { + // Idle release stays on here: it is what this test checks. + let _g = lock_serial(); + release(); + give(vec![0u8; MIN_POOLED_BYTES]); + assert_eq!(retained_bytes(), MIN_POOLED_BYTES); + // Released after one full idle interval without use (at most two from the give). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3 * IDLE_RELEASE_SECS + 1); + while retained_bytes() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies a miss discards pooled bytes in proportion to its allocation and a hit preserves the pool. +fn a_miss_drops_as_many_pooled_bytes_as_it_allocates() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + // Three 64 KiB f32 buffers a byte request can't use. + for _ in 0..3 { + give(vec![0.0f32; MIN_POOLED_BYTES / 4]); + } + assert_eq!(retained_bytes(), 3 * MIN_POOLED_BYTES); + // A 128 KiB miss drops the two oldest, so pool plus live buffers stay at 192 KiB. + let v = take(2 * MIN_POOLED_BYTES, 0u8); + assert_eq!(retained_bytes(), MIN_POOLED_BYTES); + give(v); + // A hit drops nothing. + let w = take(MIN_POOLED_BYTES / 4, 0.0f32); + assert_eq!(retained_bytes(), 2 * MIN_POOLED_BYTES); + drop(w); + release(); +} + +#[test] +/// Verifies miss eviction stops when the configured retained-byte floor is reached. +fn a_miss_keeps_the_floor() { + let _g = serial(); + release(); + set_miss_floor(2 * MIN_POOLED_BYTES); + for _ in 0..3 { + give(vec![0.0f32; MIN_POOLED_BYTES / 4]); + } + // A 192 KiB miss would drop all three; only one goes, down to the floor. + assert_eq!(take(3 * MIN_POOLED_BYTES, 0u8).len(), 3 * MIN_POOLED_BYTES); + assert_eq!(retained_bytes(), 2 * MIN_POOLED_BYTES); + set_miss_floor(DEFAULT_MISS_FLOOR); + release(); +} + +#[test] +/// Verifies a failed oversized reservation leaves the pool empty. +fn a_failed_reservation_releases_the_pool() { + let _g = serial(); + release(); + // Unmatched buffers under the miss floor stay pooled on a miss. + give(vec![0u8; 4 * MIN_POOLED_BYTES]); + assert!(retained_bytes() > 0); + // No allocator can reserve this: the call fails, but not before the pool was emptied. (The + // retry after the release isn't observable here: it needs an allocator that fails once.) + assert!(try_take_exact(isize::MAX as usize, 0u8).is_none()); + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies a successful miss preserves unmatched buffers within the miss floor. +fn a_successful_miss_keeps_unmatched_pooled_buffers() { + let _g = serial(); + release(); + give(vec![0u8; 4 * MIN_POOLED_BYTES]); + let v = try_take_exact(2 * MIN_POOLED_BYTES, 0u8); + assert!(v.as_ref().is_some_and(|v| v.len() == 2 * MIN_POOLED_BYTES && v.iter().all(|&b| b == 0))); + assert_eq!(retained_bytes(), 4 * MIN_POOLED_BYTES, "within the miss floor, nothing is dropped"); + release(); +} diff --git a/crates/raster/tests/scratch_capacity.rs b/crates/raster/tests/scratch_capacity.rs new file mode 100644 index 0000000000..33698e7d15 --- /dev/null +++ b/crates/raster/tests/scratch_capacity.rs @@ -0,0 +1,40 @@ +//! [`spill::scratch_capacity`]: the room imports may count on for tile pixels beyond RAM. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_raster::spill; + +#[test] +fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() { + let root = std::env::temp_dir().join(format!("photocraft-scratch-capacity-{}", std::process::id())); + let dir = root.join("scratch"); + + // Spilling off: nothing to promise. + spill::configure(None, 0).unwrap(); + assert_eq!(spill::scratch_capacity(), None); + + spill::configure(Some(&dir), 1 << 20).unwrap(); + assert!(spill::enabled() && !spill::paused()); + // The free-space reserve applies; an unknown volume without a size limit promises nothing. + if let Some(room) = spill::scratch_capacity() { + assert!(room < u64::MAX / 2, "the reserve and margin are applied: {room}"); + } + + // The Scratch Size Limit bounds it, less the 1/64 margin, whatever the volume has free. + let cap = 64u64 << 20; + spill::set_size_cap(Some(cap)); + let room = spill::scratch_capacity().expect("a size limit is known room"); + assert!(room <= cap - cap / 64, "{room}"); + + // Paused (an unusable directory): no credit while tiles cannot leave memory. + let blocked = root.join("not-a-dir"); + std::fs::write(&blocked, b"x").unwrap(); + assert!(spill::configure(Some(&blocked), 1 << 20).is_err()); + assert!(spill::paused()); + assert_eq!(spill::scratch_capacity(), None); + + spill::set_size_cap(None); + spill::configure(None, 0).unwrap(); + assert_eq!(spill::scratch_capacity(), None); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/raster/tests/scratch_recovery.rs b/crates/raster/tests/scratch_recovery.rs new file mode 100644 index 0000000000..fc4e91bfc7 --- /dev/null +++ b/crates/raster/tests/scratch_recovery.rs @@ -0,0 +1,117 @@ +//! Scratch disk failures pause spilling instead of turning it off for the session: tiles stay +//! in memory, the pause is retried with backoff and spilling resumes once the disk has room. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +fn check(s: &Surface, seed: u32, n: i32) { + for i in 0..n { + let t = s.tile(TileCoord { tx: i, ty: 0 }).unwrap(); + assert!(*t.bytes() == *noise(seed + i as u32, TILE_BYTES), "tile {i} of seed {seed} changed"); + } +} + +#[test] +fn full_or_missing_scratch_disk_pauses_and_recovers() { + let root = std::env::temp_dir().join(format!("photocraft-scratch-recovery-{}", std::process::id())); + let dir = root.join("scratch"); + let budget = 8 * TILE_BYTES; + spill::configure(Some(&dir), budget).unwrap(); + assert!(spill::enabled() && !spill::paused()); + + // 1. "Disk full": the scratch file reaches its size limit. Spilling pauses, every tile stays + // in memory and readable, and owners of memory caps see the change. + let cap = 16 * TILE_BYTES as u64; + spill::set_size_cap(Some(cap)); + let epoch = spill::epoch(); + let a = surface(1, 64); + spill::settle(); + let st = spill::stats(); + assert!(st.paused && !spill::enabled(), "{st:?}"); + assert_ne!(spill::epoch(), epoch, "pausing must tell owners to re-apply their caps"); + assert!(st.resident_bytes > budget, "tiles must stay in memory while paused: {st:?}"); + assert!(st.scratch_bytes < cap + 2 * TILE_BYTES as u64, "the file grew past its limit: {st:?}"); + assert!(st.last_error.as_deref().is_some_and(|e| e.contains("paused") && e.contains("retrying in 1 s")), "{st:?}"); + check(&a, 1, 64); + + // 2. A retry while the disk is still full pauses again, with a longer backoff; a budget + // change (the memory limit moved) does not end the pause. + spill::retry_now_for_tests(); + spill::maintain_budget(); + let st = spill::stats(); + assert!(st.paused && !spill::enabled(), "{st:?}"); + assert!(st.last_error.as_deref().is_some_and(|e| e.contains("retrying in 2 s")), "{st:?}"); + spill::set_budget(budget); + assert!(!spill::enabled(), "set_budget must not bypass the pause"); + + // 3. Room again: the next retry resumes spilling with the latest budget and clears the error. + let epoch = spill::epoch(); + spill::set_size_cap(None); + spill::maintain_budget(); + let st = spill::stats(); + assert!(!st.paused && spill::enabled() && st.budget_bytes == budget && st.last_error.is_none(), "{st:?}"); + assert_ne!(spill::epoch(), epoch); + spill::settle(); + assert!(spill::stats().resident_bytes <= budget, "{:?}", spill::stats()); + check(&a, 1, 64); + drop(a); + + // 4. "Disk gone": the directory can't be created (a file is in the way). Configuring reports + // it and pauses; the directory is kept and retried, and spilling resumes once it works. + let blocked = root.join("blocked"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(spill::configure(Some(&blocked), budget).is_err()); + let st = spill::stats(); + assert!(st.paused && !spill::enabled() && st.dir.as_deref() == Some(blocked.as_path()), "{st:?}"); + let b = surface(500, 32); + spill::settle(); + check(&b, 500, 32); + std::fs::remove_file(&blocked).unwrap(); + spill::retry_now_for_tests(); + spill::maintain_budget(); + assert!(spill::enabled() && !spill::paused(), "{:?}", spill::stats()); + spill::settle(); + assert!(spill::stats().resident_bytes <= budget); + check(&b, 500, 32); + + // 5. Cleanup: once spilling is off and the tiles are freed, no scratch file is left behind. + drop(b); + spill::configure(None, 0).unwrap(); + for d in [&dir, &blocked] { + let left: Vec<_> = std::fs::read_dir(d).map(|r| r.flatten().map(|e| e.path()).collect()).unwrap_or_default(); + assert!(left.is_empty(), "scratch files left in {}: {left:?}", d.display()); + } + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/raster/tests/scratch_translate.rs b/crates/raster/tests/scratch_translate.rs new file mode 100644 index 0000000000..f3e9741da7 --- /dev/null +++ b/crates/raster/tests/scratch_translate.rs @@ -0,0 +1,44 @@ +//! Upstream's shared fills and pixel translations must also work with paged tile bytes. + +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +#[test] +fn translations_and_shared_fills_preserve_spilled_pixels_at_every_depth() { + let dir = std::env::temp_dir().join(format!("photocraft-translate-spill-{}", std::process::id())); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat::RGBA8.with_sample(depth); + let tile_bytes = (TILE_SIZE * TILE_SIZE) as usize * format.bytes_per_pixel(); + spill::configure(Some(&dir), tile_bytes * 2).unwrap(); + let bounds = Rect::new(-17, -21, 281, 271); + let pixels: Vec = (0..bounds.size().area() as usize).flat_map(|i| [(i % 127) as f32 / 126.0, (i % 59) as f32 / 58.0, 0.75, 1.0]).collect(); + let mut original = Surface::new(format); + original.write_region(bounds, &pixels); + let expected = original.to_interleaved(bounds); + spill::settle(); + assert!(spill::stats().resident_bytes <= tile_bytes * 2); + let reloads = spill::stats().reloads; + for (dx, dy) in [(19, -31), (256, -512)] { + let mut moved = original.translated(dx, dy, original.content_bounds()); + assert_eq!(moved.to_interleaved(bounds.translate(dx, dy)), expected, "{depth:?} ({dx}, {dy})"); + moved.fill_rect(Rect::from_xywh(bounds.x0 + dx, bounds.y0 + dy, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + spill::settle(); + assert_eq!(original.to_interleaved(bounds), expected, "translation edits changed the source"); + } + assert!(spill::stats().reloads > reloads, "translation must read paged pixels"); + let mut fill = Surface::new(format); + fill.fill_rect(Rect::new(0, 0, 1024, 1024), &[0.5, 0.25, 0.75, 1.0]); + assert!(std::sync::Arc::ptr_eq(fill.tile(TileCoord::new(0, 0)).unwrap(), fill.tile(TileCoord::new(3, 3)).unwrap())); + let snapshot = fill.clone(); + let color = snapshot.pixel(10, 10); + spill::settle(); + fill.fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(snapshot.pixel(1, 1), color); + assert_eq!(fill.pixel(10, 10), color); + assert_eq!(fill.pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + spill::check_integrity().unwrap(); + } + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/raster/tests/spill.rs b/crates/raster/tests/spill.rs new file mode 100644 index 0000000000..078c1c4bf6 --- /dev/null +++ b/crates/raster/tests/spill.rs @@ -0,0 +1,149 @@ +//! Scratch disk: tiles over the budget go to disk and come back unchanged. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per tile and per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +/// A surface of `n × 1` tiles of noise; tile `i` holds `noise(seed + i)`. +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +fn check(s: &Surface, seed: u32, n: i32) { + for i in 0..n { + let t = s.tile(TileCoord { tx: i, ty: 0 }).unwrap(); + assert!(*t.bytes() == *noise(seed + i as u32, TILE_BYTES), "tile {i} of seed {seed} changed"); + } +} + +#[test] +fn tiles_spill_and_come_back() { + let dir = std::env::temp_dir().join(format!("photocraft-spill-test-{}", std::process::id())); + let budget = 8 * TILE_BYTES; + spill::configure(Some(&dir), budget).unwrap(); + assert!(spill::enabled()); + + // 1. Over budget: the oldest tiles go to disk, resident memory drops to the budget. + let a = surface(1, 32); + let b = surface(1000, 32); + spill::settle(); + let st = spill::stats(); + assert!(st.resident_bytes <= budget, "resident {} > budget {budget}", st.resident_bytes); + assert!(st.evictions >= 48, "only {} evictions", st.evictions); + assert!(st.scratch_bytes > 0 && st.last_error.is_none(), "{st:?}"); + + // 2. Every pixel reads back exactly (CRC-checked, LZ4 round trip), through every reader. + check(&a, 1, 32); + check(&b, 1000, 32); + assert!(spill::stats().reloads > 0); + let mut row = vec![[0u8; 4]; TILE_SIZE as usize]; + a.read_rgba8_into(Rect::from_xywh(5 * TILE_SIZE, 7, TILE_SIZE as u32, 1), &mut row); + let want = noise(6, TILE_BYTES); + let off = 7 * TILE_SIZE as usize * 4; + assert!(row.iter().flatten().eq(want[off..off + TILE_SIZE as usize * 4].iter())); + + // Upstream's fallible readers and clones must reload evicted storage too. + spill::settle(); + let rect = Rect::from_xywh(0, 0, TILE_SIZE as u32, TILE_SIZE as u32); + assert_eq!(a.try_to_interleaved(rect).unwrap(), noise(1, TILE_BYTES)); + assert_eq!(a.try_read_region(rect).unwrap(), a.read_region(rect)); + let copied = a.tile(TileCoord::new(0, 0)).unwrap().try_clone().unwrap(); + assert_eq!(&*copied.try_bytes().unwrap(), &noise(1, TILE_BYTES)); + drop(copied); + + // 3. Copy-on-write still works on spilled tiles: the clone changes, the original doesn't. + spill::settle(); + let mut c = a.clone(); + c.fill_rect(Rect::from_xywh(0, 0, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(c.pixel(3, 3), vec![1.0, 0.0, 0.0, 1.0]); + check(&a, 1, 32); + spill::settle(); + // The edit survives its own eviction (the stale disk copy was released, a new one written). + assert_eq!(c.pixel(3, 3), vec![1.0, 0.0, 0.0, 1.0]); + check(&a, 1, 32); + + // 4. Parallel readers of spilled tiles all see the right bytes. + spill::settle(); + std::thread::scope(|scope| { + for k in 0..8 { + let (a, b) = (&a, &b); + scope.spawn(move || { + for round in 0..4 { + let (s, seed) = if (k + round) % 2 == 0 { (a, 1) } else { (b, 1000) }; + check(s, seed, 32); + } + }); + } + }); + + // 5. Dropping everything frees memory and scratch space. + drop((a, b, c)); + let st = spill::stats(); + assert_eq!(st.scratch_bytes, 0, "scratch extents leaked: {st:?}"); + + // 6. Off: nothing more is evicted, and the budget no longer applies. + spill::configure(None, 0).unwrap(); + assert!(!spill::enabled()); + let before = spill::stats().evictions; + let d = surface(5000, 32); + assert_eq!(spill::stats().evictions, before); + check(&d, 5000, 32); + drop(d); + + // 7. Encoded bytes and COW edits survive eviction at every supported depth. + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat { mode: ColorMode::Rgb, sample, alpha: true }; + let len = (TILE_SIZE * TILE_SIZE) as usize * fmt.bytes_per_pixel(); + spill::configure(Some(&dir), len * 2).unwrap(); + let mut s = Surface::new(fmt); + for i in 0..12 { + s.tile_mut(TileCoord::new(i, 0)).bytes_mut().copy_from_slice(&noise(8000 + i as u32, len)); + } + let original = s.clone(); + spill::settle(); + assert!(spill::stats().resident_bytes <= len * 2); + s.fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + spill::settle(); + assert_eq!(s.pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + for i in 0..12 { + assert_eq!(&*original.tile(TileCoord::new(i, 0)).unwrap().bytes(), &noise(8000 + i as u32, len)); + } + let mut converted = [[0; 4]; 4]; + s.read_rgba8_into(Rect::from_xywh(0, 0, 4, 1), &mut converted); + assert_eq!(converted, [[255, 0, 0, 255]; 4]); + } + // A tiny budget must still allow a pinned reader to finish instead of evicting itself. + spill::configure(Some(&dir), 1).unwrap(); + let tiny = surface(9000, 2); + spill::settle(); + check(&tiny, 9000, 2); + drop(tiny); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/raw/README.md b/crates/raw/README.md index 0acf28165a..23a10038b3 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -27,7 +27,7 @@ Implemented only from public specifications, papers and observation of files: * Publicly documented maker-note / private tags (ExifTool's tag tables): Canon ModelID (0x0010), 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 + (0x7010), SR2Private Offset / Length / Key (0x7200 / 0x7201 / 0x7221), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings (0x2020) preview tags, the Fujifilm RAF header and metadata records (RawImageCropTopLeft / RawImageCroppedSize, FujiLayout, XTransLayout, WB_GRGBLevels, RawExposureBias) and FujiIFD (RawImageFullWidth / Height, BitsPerSample, StripOffsets / ByteCounts, BlackLevel, @@ -41,6 +41,8 @@ Implemented only from public specifications, papers and observation of files: * Sony cRAW (ARW 2): H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (Electronic Imaging 2016) and the RawDigger / diglloyd write-ups of the 11 + 7-bit scheme; the exact bit layout and tone-curve scale were established by observation of sample files. + Encrypted SR2SubIFD metadata with key bytes `11 22 33 44` was measured independently against + ExifTool's metadata dumps; see [Sony metadata evidence](../../docs/sony-arw-metadata.md). * Panasonic RW2 RawFormat 5, uncompressed Olympus ORF and uncompressed Fujifilm RAF: established by observation of sample files (bit packing, page layout, sample justification; for RAF the three sample storages and the reversed order of the X-Trans / Bayer layout records, each @@ -73,7 +75,7 @@ camera colour tables were copied. | CR2 sRAW / mRAW (subsampled YCbCr) | Decoded to camera RGB and developed like LinearRaw: Canon's subsampled lossless JPEG (Y 2×2 or 2×1), BT.601 inverse, bilinear chroma, the per-shot sRAW gains from ColorData (version 15: EOS 6D Mark II, 77D, 200D, 800D; other bodies warn and rely on the fitted camera look). See `src/sraw.rs` | | NEF / NRW, ARW, PEF and other TIFF/EP raws | Uncompressed and lossless-JPEG (incl. Sony lossless ARW) CFA data | | Nikon compressed NEF (compression 34713): lossless and lossy type 1 / 2, 12 and 14 bit | Decoded: fixed Huffman tables + maker-note `0x0096` seeds and curve (see `src/nefc.rs`). BlackLevel `0x003d` is read in 14-bit units. "Lossy after split" files are unsupported (preview fallback) | -| Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits, the curve's 512 black level when no BlackLevel tag is written (the first-generation bodies, e.g. the ILCE-7) | +| Sony compressed ARW ("cRAW", SonyRawFileType 2) | Decoded: 11-bit min/max + 7-bit delta blocks, SonyToneCurve to 14 bits; applied WB and black from plain tags or encrypted SR2SubIFD with the measured common key. If metadata is unreadable, black falls back to 512 and WB to grey-world. Measured ILCE-7 / ILCE-6000 image area; camera colour calibration remains incomplete | | Panasonic / Leica RW2, RawFormat 5 (12- and 14-bit packed) | Decoded, with PanasonicRaw black / white / WB / sensor borders | | Panasonic / Leica RW2, RawFormat 3 and 4 (vendor-compressed, 12-bit) | Decoded; the black level is read 15 counts above the recorded tag; PanasonicRaw white / WB / sensor borders | | Olympus ORF, uncompressed 16-bit (E-1, E-400…) | Decoded, with ImageProcessing black / WB / ValidBits / crop | diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index d7ae5e84d7..321ecf1223 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -149,6 +149,9 @@ const PROFILES: &[Profile] = &[Profile { /// 4352 x 2868 sensor data, giving the camera JPEG's 4288 x 2848 frame /// (10 pixels of margin on the right and bottom; the right border reads the /// camera's flat 255 from column 4310 on). +/// * **ILCE-6000** (3 files): the same 12 x 12 origin and 6000 x 4000 image; +/// confirmed by the file's EXIF size and SR2SubIFD's area [12, 12, 6012, 4012], +/// and checked against the matching camera JPEGs. No colour profile is inferred. struct ImageArea { make: &'static str, model: &'static str, @@ -163,6 +166,7 @@ const IMAGE_AREAS: &[ImageArea] = &[ ImageArea { make: "NIKON CORPORATION", model: "NIKON D5100", size: (4992, 3280), crop: (8, 8, 4928, 3264) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) }, ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) }, + ImageArea { make: "SONY", model: "ILCE-6000", size: (6048, 4024), crop: (12, 12, 6000, 4000) }, ]; /// Applies the measured profile of the camera that made `s`, when there is one, @@ -307,6 +311,19 @@ mod tests { apply(&mut s, 14); assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000)); + let mut s = sensor("SONY", "ILCE-6000", 6048, 4024); + apply(&mut s, 14); + assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000)); + assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); + // A file's explicit crop wins, including a different in-camera aspect ratio. + s.crop = Rect::new(12, 324, 6000, 3376); + apply(&mut s, 14); + assert_eq!(s.crop, Rect::new(12, 324, 6000, 3376)); + + let mut s = sensor("SONY", "ILCE-6000", 3024, 2012); + apply(&mut s, 14); + assert_eq!(s.crop, Rect::new(0, 0, 3024, 2012)); + // Another size or model of the same maker is left alone. let mut s = nef("NIKON D800E", 2496, 1646); apply(&mut s, 14); diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 8dc2ff064f..8ec7611923 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -34,9 +34,19 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, // than the caller's per-buffer limit. let budget = (w as u64).saturating_mul(h as u64).saturating_mul(samples as u64).saturating_mul(16).min(limits.max_alloc); let budget = usize::try_from(budget).unwrap_or(usize::MAX); + // jxl-oxide inflates Brotli-compressed boxes (`brob`: Exif, XMP) into storage the tracker + // doesn't see, while it reads. A raw tile has no use for metadata, so refuse it unread. + if has_compressed_box(src) { + return Err(RawError::unsupported("JPEG XL raw tile with compressed metadata boxes")); + } guarded(|| { let image = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(budget)).read(src).map_err(|e| RawError::malformed(format!("JPEG XL raw data: {e}")))?; + // Rendering samples spline geometry into an untracked vector with no bound on the sample + // count, whatever their colour; sensor data never draws splines. + if (0..image.num_loaded_frames()).filter_map(|i| image.frame(i)).any(|f| f.header().flags.splines()) { + return Err(RawError::unsupported("JPEG XL raw tile with splines")); + } let (jw, jh) = (image.width() as usize, image.height() as usize); if (jw, jh) != (w, h) { return Err(RawError::malformed(format!("JPEG XL raw tile is {jw}x{jh}, expected {w}x{h}"))); @@ -76,7 +86,65 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, }) } +/// The ISO/IEC 18181-2 container's signature box. +#[cfg(not(target_arch = "wasm32"))] +const CONTAINER_SIGNATURE: &[u8] = &[0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A]; + +/// Whether `src` is a JPEG XL container holding a Brotli-compressed (`brob`) box. A malformed box +/// size ends the walk: the decoder refuses that file itself. +#[cfg(not(target_arch = "wasm32"))] +fn has_compressed_box(src: &[u8]) -> bool { + if !src.starts_with(CONTAINER_SIGNATURE) { + return false; + } + let mut at = 0usize; + while let Some(head) = src.get(at..at.saturating_add(8)) { + if &head[4..] == b"brob" { + return true; + } + let size = match u32::from_be_bytes([head[0], head[1], head[2], head[3]]) { + 0 => return false, // the last box, running to the end + 1 => match src.get(at + 8..at.saturating_add(16)).and_then(|b| <[u8; 8]>::try_from(b).ok()) { + Some(b) => u64::from_be_bytes(b), + None => return false, + }, + n => u64::from(n), + }; + match usize::try_from(size).ok().filter(|&s| s >= 8).and_then(|s| at.checked_add(s)) { + Some(next) => at = next, + None => return false, + } + } + false +} + #[cfg(not(target_arch = "wasm32"))] fn guarded(f: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(RawError::malformed("the JPEG XL decoder failed on this file"))) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::{CONTAINER_SIGNATURE, decode, has_compressed_box}; + use crate::Limits; + + fn container(boxes: &[(&[u8; 4], &[u8])]) -> Vec { + let mut out = CONTAINER_SIGNATURE.to_vec(); + for (kind, body) in boxes { + out.extend_from_slice(&(body.len() as u32 + 8).to_be_bytes()); + out.extend_from_slice(*kind); + out.extend_from_slice(body); + } + out + } + + #[test] + fn a_compressed_metadata_box_is_refused_before_decoding() { + let brob = container(&[(b"ftyp", b"jxl \0\0\0\0jxl "), (b"brob", b"Exif\x8b\x02\x80"), (b"jxlc", &[0xFF, 0x0A])]); + assert!(has_compressed_box(&brob)); + assert!(decode(&brob, 1, 1, 1, 16, None, &Limits::default()).is_err()); + assert!(!has_compressed_box(&container(&[(b"ftyp", b"jxl \0\0\0\0jxl "), (b"jxlc", &[0xFF, 0x0A])]))); + // A bare codestream is not a container. + assert!(!has_compressed_box(&[0xFF, 0x0A, 0, 0, 0, 0, b'b', b'r', b'o', b'b'])); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 2eac7d7b09..46bd3e4f60 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -64,6 +64,7 @@ mod rw2; mod saturation; mod sensor; mod sony; +mod sony_metadata; mod sraw; mod tiff; mod tiffep; diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 9b333fd4cf..9722a41158 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -249,6 +249,11 @@ pub(crate) struct Opcodes { /// Those past the cap are named as not applied. const MAX_POLYNOMIALS: usize = 64; +/// The most GainMap opcodes kept from one list, for the same reason: development applies every +/// map to every pixel it covers, so the map count multiplies the whole image's work. Real files +/// carry one per CFA plane (four). +const MAX_GAIN_MAPS: usize = 64; + /// Parses an opcode list. pub(crate) fn parse(list: &[u8]) -> Opcodes { let mut out = Opcodes::default(); @@ -259,7 +264,7 @@ pub(crate) fn parse(list: &[u8]) -> Opcodes { let start = at.saturating_add(16); let Some(params) = list.get(start..start.saturating_add(size as usize)) else { break }; let applied = match id { - 9 => gain_map(params).map(|m| out.gain_maps.push(m)).is_some(), + 9 if out.gain_maps.len() < MAX_GAIN_MAPS => gain_map(params).map(|m| out.gain_maps.push(m)).is_some(), 8 if out.polynomials.len() < MAX_POLYNOMIALS => map_polynomial(params).map(|p| out.polynomials.push(p)).is_some(), _ => false, }; @@ -323,6 +328,15 @@ mod tests { assert!((m.at(&m.row(-3.0, 0), 9.0) - 2.0).abs() < 1e-6); } + #[test] + fn gain_maps_past_the_cap_are_named_not_applied() { + let map = gain_map_params([0, 0, 10, 10], 1, &[[1.0, 2.0], [3.0, 4.0]]); + let b = list(&vec![(9, map); MAX_GAIN_MAPS + 1]); + let Opcodes { gain_maps, skipped, .. } = parse(&b); + assert_eq!(gain_maps.len(), MAX_GAIN_MAPS); + assert_eq!(skipped, vec!["GainMap".to_string()]); + } + #[test] fn hostile_lists_do_not_panic() { let b = list(&[(9, gain_map_params([0, 0, 10, 10], 2, &[[1.0, 2.0], [3.0, 4.0]]))]); diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 677361aae5..5c83066c21 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -279,8 +279,24 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou retained_segment_bytes(&segments, samples, limits)?; let le = t.le; let tiled = t.tag_uint(ifd, tag::TILE_WIDTH).is_some(); - let decoded: Vec>> = par::map(segments.len(), |i| { - let s = &segments[i]; + // Segments naming the same bytes at the same geometry decode to the same samples, so each + // distinct one is decoded once and shared. A file can point every tile at one small range; + // decoding it per tile multiplied header work (a lossless JPEG rebuilds its Huffman tables on + // every decode) and retained buffers by the tile count. + let mut first_of = std::collections::HashMap::new(); + let mut unique: Vec = Vec::new(); + let slot: Vec = segments + .iter() + .enumerate() + .map(|(i, s)| { + *first_of.entry((s.offset, s.len, s.w, s.h, s.h.min(height - s.y))).or_insert_with(|| { + unique.push(i); + unique.len() - 1 + }) + }) + .collect(); + let decoded: Vec>> = par::map(unique.len(), |u| { + let s = &segments[unique[u]]; let rows = s.h.min(height - s.y); let src = t.bytes(s.offset, s.len).ok_or_else(|| RawError::malformed("raw data lies outside the file"))?; // Every branch below sizes at least one buffer from the segment's own geometry, which @@ -322,9 +338,9 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou // One segment holding the whole image row for row (a single strip): its buffer is the plane, no // second full-size buffer and no copy. The scatter below then has no segment left to place. let whole = matches!(segments.as_slice(), [s] if s.x == 0 && s.y == 0 && s.w == width && s.h >= height); - let mut decoded = decoded.into_iter(); + let mut decoded: Vec> = decoded.into_iter().collect::>()?; let taken = if whole { - let mut d = decoded.next().ok_or_else(|| RawError::malformed("raw segment missing"))??; + let mut d = decoded.pop().ok_or_else(|| RawError::malformed("raw segment missing"))?; if d.len() < width * height * samples { return Err(RawError::malformed("raw segment is smaller than the image")); } @@ -334,8 +350,9 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou None }; let mut data = taken.unwrap_or_else(|| vec![0u16; width * height * samples]); - for (s, d) in segments.iter().zip(decoded) { - let d = d?; + // The whole-image segment above is the plane itself: nothing is left to scatter. + for (s, &k) in segments.iter().zip(&slot).filter(|_| !whole) { + let d = &decoded[k]; let rows = s.h.min(height - s.y); let cols = s.w.min(width - s.x); for r in 0..rows { @@ -467,6 +484,11 @@ fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, t 3 => ColorSpace::RGB, n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), }; + // zune-jpeg re-sorts and re-checks every pending Extended XMP fragment after each marker it + // parses, which is quadratic in a tile of many small fragments; a raw tile has no metadata. + if has_extended_xmp(src) { + return Err(RawError::unsupported("lossy JPEG tile with Extended XMP metadata")); + } let options = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(s.w).set_max_height(s.h); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), options); dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; @@ -492,6 +514,36 @@ fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, t Ok(v) } +/// Whether a JPEG stream has an APP1 Extended XMP segment before its first scan. Walks the +/// markers as a header parse does: stray bytes skip to the next `FF`. +fn has_extended_xmp(b: &[u8]) -> bool { + const EXTENDED_XMP: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + let mut i = 2; // past SOI + while let Some(&byte) = b.get(i) { + if byte != 0xFF { + match b[i..].iter().position(|&v| v == 0xFF) { + Some(n) => i += n, + None => return false, + } + continue; + } + match b.get(i + 1) { + None | Some(0xD9 | 0xDA) => return false, + Some(0xFF) => i += 1, + Some(0x01 | 0xD0..=0xD8) => i += 2, + Some(&code) => { + let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { return false }; + // The identifier must lie inside the segment's declared payload (length counts its own two bytes). + if code == 0xE1 && b.get(i + 4..(i + 2 + len).min(b.len())).is_some_and(|p| p.starts_with(EXTENDED_XMP)) { + return true; + } + i += 2 + len; + } + } + } + false +} + /// Reverses TIFF predictor 2 (horizontal differencing) on interleaved samples: each sample /// gains its channel's predecessor in the row, wrapping at the sample's container (8 or 16 bits). fn undelta(mut out: Vec, plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec { @@ -592,6 +644,25 @@ fn unpack_row_msb(row: &[u8], out: &mut [u16], bits: u32) { mod tests { use super::*; + #[test] + fn extended_xmp_is_found_before_the_scan_only() { + let app1 = |payload: &[u8]| { + let mut seg = vec![0xFF, 0xE1]; + seg.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + seg.extend_from_slice(payload); + seg + }; + let xmp = app1(b"http://ns.adobe.com/xmp/extension/\0GUID"); + let plain = app1(b"http://ns.adobe.com/xap/1.0/\0"); + let jpeg = |segs: &[&[u8]]| [&[0xFF, 0xD8][..], &segs.concat(), &[0xFF, 0xDA, 0, 2, 0xFF, 0xD9]].concat(); + assert!(has_extended_xmp(&jpeg(&[&plain, &xmp]))); + assert!(!has_extended_xmp(&jpeg(&[&plain]))); + // After the scan it is entropy data, not a marker the header parse reads. + assert!(!has_extended_xmp(&[jpeg(&[]), xmp.clone()].concat())); + // A truncated length ends the walk. + assert!(!has_extended_xmp(&[0xFF, 0xD8, 0xFF, 0xE1, 0x00])); + } + /// The grouped 10/12/14-bit readers equal the plain bit reader on every row width. #[test] fn grouped_unpack_matches_the_bit_reader() { diff --git a/crates/raw/src/sony_metadata.rs b/crates/raw/src/sony_metadata.rs new file mode 100644 index 0000000000..7584d398a8 --- /dev/null +++ b/crates/raw/src/sony_metadata.rs @@ -0,0 +1,262 @@ +//! Sony's encrypted SR2SubIFD: black levels and the white balance actually applied by the camera. +//! +//! Directory/tag meanings are from ExifTool's public Sony tag documentation. The keystream was +//! measured independently by XOR-ing file bytes with ExifTool 13.59's black-box `-v5` metadata +//! dumps of three ILCE-6000 ARWs (56958-byte blocks, key bytes 11 22 33 44). All 14239 whole +//! words in each block obey k[n] = k[n-63] XOR k[n-127]; the first 127 words agree across the +//! files. This is metadata cipher data, not camera colour calibration or image data. LightCraft's +//! documented investigation suggested the experiment; no sibling or third-party decoder code +//! is used. Other keys remain unreadable rather than being guessed. + +use crate::Limits; +use crate::tiff::{Ifd, Tiff}; + +const PRIVATE_DATA: u16 = 50740; +const OFFSET: u16 = 0x7200; +const LENGTH: u16 = 0x7201; +const KEY: u16 = 0x7221; +const MAX_METADATA: usize = 1 << 20; + +// Measured from metadata only. The key's first 127 words, in byte-stream order (big endian). +const INITIAL: [u32; 127] = [ + 0x54c2c4d6, 0x49c478bc, 0x34ae2f8e, 0x25d80504, 0xc0d9d6b0, 0xd838fb71, 0xe8eff27d, 0xfbc1fcea, 0x506c499a, 0x47f20f37, 0x710777cf, 0x7867e7ba, 0x42d67caa, + 0x7f2bd11a, 0x67a216ca, 0x0e986d40, 0x4ae8d4c0, 0xe36778b4, 0x5a958415, 0xdbfe2be8, 0x20faa1aa, 0x7132a6b8, 0xf4de4b7f, 0x55991aa1, 0xa849d5aa, 0x49577832, + 0xb92f3daa, 0x399cc526, 0x22cdd000, 0xe1977a29, 0x37c5db55, 0xb0177e1e, 0x2a1016aa, 0xa300086e, 0x3bab9bfe, 0x262eece0, 0x23771aa9, 0x0a5dc91c, 0x31b902ae, + 0x58e64bf8, 0x259c300e, 0xa57705c8, 0x284a6541, 0xfb229c60, 0x1bacaa9e, 0xbcab3350, 0x67cd9fbe, 0x8f135e60, 0xf8c26a40, 0x6770da61, 0x3e1febfd, 0xd0c70803, + 0x8dbb037b, 0x6f6fa4c5, 0x6749d10d, 0x7f51598d, 0xd5e5a4ec, 0x207dfa91, 0x6558ebc2, 0xbe594639, 0x617a9e5d, 0x3c497950, 0x0844eb3f, 0x04207ed2, 0xd27ceac4, + 0x70d20f05, 0xb47003f6, 0xe9e4e3ae, 0xcc19d265, 0x326dd956, 0xf0d3a327, 0xb71275f0, 0x7994e285, 0x0aff594d, 0x128e8345, 0x7bda597a, 0xd634c380, 0xe24a006f, + 0x8974818b, 0x3320b22a, 0xbe808417, 0xa2d5648a, 0x6fe80b39, 0x23ebad41, 0xa2d11e5d, 0x027d9397, 0x9a722ac8, 0x432c7dac, 0x7146692a, 0x82a3dc77, 0xd66887c5, + 0x831f43b7, 0x4e5dddde, 0x03793f81, 0x306ab437, 0x00ccf86c, 0xfc6ed3d2, 0x076b8fdb, 0x9808cfca, 0x0f4eef6e, 0xc8cc3830, 0x104ac16a, 0xa189eff4, 0x3e085c08, + 0xd28baf88, 0x5c853ac4, 0xe60480f8, 0xc51acd98, 0x691e5ee1, 0x333feeb9, 0x1e35bc33, 0xec4a4642, 0xee57c5a5, 0xbeeb51f7, 0xe0c4f32c, 0xa5422f6a, 0x1d266d12, + 0x3752fd3b, 0xfbc53c7d, 0x2421a4a3, 0xcdc6a2de, 0x26e6b330, 0x6c073d46, 0x058e2f27, 0x43833f30, 0x46d1382e, 0x5f0804ec, +]; + +fn decipher(data: &mut [u8]) { + let mut history = INITIAL; + for (n, chunk) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let slot = n % 127; + let word = if n < 127 { + INITIAL.get(n).copied().unwrap_or(0) + } else { + history.get(slot).copied().unwrap_or(0) ^ history.get((n - 63) % 127).copied().unwrap_or(0) + }; + if let Some(previous) = history.get_mut(slot) { + *previous = word; + } + for (byte, key) in chunk.iter_mut().zip(word.to_be_bytes()) { + *byte ^= key; + } + } + // Sony leaves an incomplete final word in the clear. +} + +#[derive(Default)] +pub(crate) struct Levels { + pub black: Option<[f64; 4]>, + pub wb: Option<[f64; 4]>, +} + +/// Reads only four SHORT/SSHORT levels from a complete directory. Overflow value offsets are +/// absolute file offsets, but must point inside this decrypted block, never into other file data. +fn four(t: &Tiff, ifd: &Ifd, start: usize, tag: u16) -> Option<[f64; 4]> { + let mut e = *ifd.get(tag)?; + if !matches!(e.typ, 3 | 8) || e.count != 4 { + return None; + } + e.at = e.at.checked_sub(start)?; + t.raw(&e)?; + let v = [t.float(&e, 0)?, t.float(&e, 1)?, t.float(&e, 2)?, t.float(&e, 3)?]; + v.iter().all(|x| (0.0..=16383.0).contains(x)).then_some(v) +} + +fn parse(plain: &[u8], start: usize, le: bool) -> Option { + let t = Tiff { data: plain, le, first_ifd: 0 }; + let count = usize::from(t.u16_at(0)?); + let end = count.checked_mul(12)?.checked_add(6)?; + plain.get(..end)?; + let ifd = t.ifd_at(0, 0)?; + Some(Levels { black: four(&t, &ifd, start, 0x7310), wb: four(&t, &ifd, start, 0x7313) }) +} + +pub(crate) fn read(t: &Tiff, limits: &Limits) -> Option { + let ifd0 = t.ifd_at(t.first_ifd, 0)?; + let pointer = ifd0.get(PRIVATE_DATA)?; + // BYTE[4]/UNDEFINED[4] containing a file-order offset (not a DNG private payload). + if !matches!(pointer.typ, 1 | 7) || pointer.count != 4 { + return None; + } + let private = t.ifd_at(t.u32_at(pointer.at)? as usize, 0)?; + if t.raw(private.get(KEY)?)? != [0x11, 0x22, 0x33, 0x44] { + return None; + } + let start = t.tag_uint(&private, OFFSET)? as usize; + let len = t.tag_uint(&private, LENGTH)? as usize; + if !(6..=MAX_METADATA).contains(&len) || len as u64 > limits.max_alloc { + return None; + } + let mut plain = t.bytes(start, len)?.to_vec(); + decipher(&mut plain); + parse(&plain, start, t.le) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testgen::{TiffBuilder, Val}; + use crate::{DevelopOptions, RawFormat, WhiteBalance, decode, develop_sensor}; + + /// Procedural CFA TIFF with plain and encrypted levels that deliberately disagree. + /// No camera file, scene pixels, metadata dump or private fixture is included. + fn fixture(be: bool, plain: bool, black: [u16; 4], wb: [u16; 4]) -> Vec { + let u16_bytes = |v: u16| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let u32_bytes = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut block = Vec::new(); + block.extend_from_slice(&u16_bytes(2)); + for (tag, typ, offset) in [(0x7310, 3, 2048), (0x7313, 8, 2056)] { + block.extend_from_slice(&u16_bytes(tag)); + block.extend_from_slice(&u16_bytes(typ)); + block.extend_from_slice(&u32_bytes(4)); + block.extend_from_slice(&u32_bytes(offset)); // replaced with absolute offsets below + } + block.extend_from_slice(&[0; 4]); + block.resize(2048, 0); // crosses multiple 127-word recurrence periods + for v in black.into_iter().chain(wb) { + block.extend_from_slice(&u16_bytes(v)); + } + block.extend_from_slice(&[0xAA, 0xBB]); // tail stored in the clear + let mut b = TiffBuilder { big_endian: be, ..Default::default() }; + let len = block.len(); + let data = b.blob(block); + let pixels = b.blob( + (0..32 * 16) + .flat_map(|i| { + u16_bytes(match (i % 32 % 2, i / 32 % 2) { + (0, 0) => 1512, + (1, 1) => 2512, + _ => 3512, + }) + }) + .collect(), + ); + let mut entries = vec![ + (256, Val::Long(vec![32])), + (257, Val::Long(vec![16])), + (258, Val::Short(vec![16])), + (259, Val::Short(vec![1])), + (262, Val::Short(vec![32803])), + (273, Val::Blobs(vec![pixels])), + (277, Val::Short(vec![1])), + (278, Val::Long(vec![16])), + (279, Val::Long(vec![32 * 16 * 2])), + (33421, Val::Short(vec![2, 2])), + (33422, Val::Byte(vec![0, 1, 1, 2])), + (50717, Val::Short(vec![16383])), + ]; + if plain { + entries.extend([(0x7310, Val::Short(vec![600; 4])), (0x7313, Val::Short(vec![2048, 1024, 1024, 1536]))]); + } + let raw = b.ifd(entries); + let private = b.ifd(vec![(OFFSET, Val::Blobs(vec![data])), (LENGTH, Val::Long(vec![len as u32])), (KEY, Val::Byte(vec![0x11, 0x22, 0x33, 0x44]))]); + let root = b.ifd(vec![ + (271, Val::Ascii("SONY".into())), + (272, Val::Ascii("ILCE-Synthetic".into())), + (330, Val::Ifds(vec![raw])), + (PRIVATE_DATA, Val::Ifds(vec![private])), + ]); + b.chain = vec![root]; + let mut bytes = b.build(); + let t = Tiff::new(&bytes).unwrap(); + let root = t.ifd_at(t.first_ifd, 0).unwrap(); + let pointer = *root.get(PRIVATE_DATA).unwrap(); + let private = t.ifd_at(t.u32_at(pointer.at).unwrap() as usize, 0).unwrap(); + let start = t.tag_uint(&private, OFFSET).unwrap() as usize; + // TIFF BYTE[4] offset rather than the generator's LONG[1]. + bytes[pointer.at - 6..pointer.at - 4].copy_from_slice(&u16_bytes(1)); + bytes[pointer.at - 4..pointer.at].copy_from_slice(&u32_bytes(4)); + for (entry, offset) in [(10, 2048), (22, 2056)] { + bytes[start + entry..start + entry + 4].copy_from_slice(&u32_bytes((start + offset) as u32)); + } + decipher(&mut bytes[start..start + len]); + bytes + } + + #[test] + fn encrypted_applied_white_balance_and_black_reach_the_developer() { + for be in [false, true] { + let bytes = fixture(be, false, [512, 513, 514, 515], [3072, 1024, 1024, 2048]); + let s = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.format, RawFormat::Arw); + assert_eq!(s.camera_wb, Some([3.0, 1.0, 2.0])); + assert_eq!(s.black.values, vec![512.0, 513.0, 514.0, 515.0]); + let dev = develop_sensor(&s, &DevelopOptions::default()).unwrap(); + assert_eq!(dev.info.wb_multipliers, [3.0, 1.0, 2.0]); + assert!(!dev.warnings.iter().any(|w| w.contains("grey world") || w.contains("assumed 0"))); + let explicit = develop_sensor(&s, &DevelopOptions { white_balance: WhiteBalance::Multipliers([3.0, 1.0, 2.0]), ..Default::default() }).unwrap(); + assert_eq!(dev.rgb, explicit.rgb); + } + } + + #[test] + fn plain_levels_win_and_invalid_encrypted_levels_are_ignored() { + let s = decode(&fixture(false, true, [800; 4], [3072, 1024, 1024, 2048]), &Limits::default()).unwrap(); + assert_eq!(s.camera_wb, Some([2.0, 1.0, 1.5])); + assert_eq!(s.black.values, vec![600.0; 4]); + let mut broken_plain = fixture(false, true, [800; 4], [3072, 1024, 1024, 2048]); + let t = Tiff::new(&broken_plain).unwrap(); + let raw = t.all_ifds().into_iter().find(|i| i.has(0x7313)).unwrap(); + let at = raw.get(0x7313).unwrap().at; + broken_plain[at..at + 8].fill(0); + let s = decode(&broken_plain, &Limits::default()).unwrap(); + assert_eq!(s.camera_wb, Some([3.0, 1.0, 2.0])); + assert_eq!(s.black.values, vec![600.0; 4]); + for wb in [[0, 0, 0, 0], [65535, 1024, 1024, 2048], [30000, 1024, 1024, 2048], [3072, 0, 0, 2048]] { + let s = decode(&fixture(false, false, [65535; 4], wb), &Limits::default()).unwrap(); + assert_eq!(s.camera_wb, None); + assert_eq!(s.black.values, vec![0.0]); + } + } + + #[test] + fn hostile_private_metadata_is_bounded_and_optional() { + let bytes = fixture(false, false, [512; 4], [3072, 1024, 1024, 2048]); + for n in 0..bytes.len() { + if let Some(t) = Tiff::new(&bytes[..n]) { + let _ = read(&t, &Limits::default()); + } + } + let t = Tiff::new(&bytes).unwrap(); + assert!(read(&t, &Limits { max_alloc: 2000, ..Default::default() }).is_none()); + let root = t.ifd_at(t.first_ifd, 0).unwrap(); + let private = t.ifd_at(t.u32_at(root.get(PRIVATE_DATA).unwrap().at).unwrap() as usize, 0).unwrap(); + for (at, value) in [(private.get(KEY).unwrap().at, 0), (private.get(LENGTH).unwrap().at, u32::MAX), (private.get(OFFSET).unwrap().at, u32::MAX)] { + let mut corrupt = bytes.clone(); + corrupt[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert!(read(&Tiff::new(&corrupt).unwrap(), &Limits::default()).is_none()); + let s = decode(&corrupt, &Limits::default()).unwrap(); + assert_eq!(s.camera_wb, None); + } + let start = t.tag_uint(&private, OFFSET).unwrap() as usize; + let len = t.tag_uint(&private, LENGTH).unwrap() as usize; + let mut corrupt = bytes.clone(); + decipher(&mut corrupt[start..start + len]); + // Values before the encrypted block cannot be interpreted as metadata. + corrupt[start + 22..start + 26].copy_from_slice(&8u32.to_le_bytes()); + decipher(&mut corrupt[start..start + len]); + assert!(read(&Tiff::new(&corrupt).unwrap(), &Limits::default()).unwrap().wb.is_none()); + } + + #[test] + fn measured_keystream_recurrence_and_unencrypted_tail() { + let mut zero = vec![0; 14239 * 4 + 2]; + decipher(&mut zero); + assert_eq!(&zero[..4], &[0x54, 0xc2, 0xc4, 0xd6]); + // Words recovered independently from all three oracle dumps, including ring wrap + // and the final complete word of a real metadata block. + assert_eq!(&zero[127 * 4..128 * 4], &[0x86, 0xbe, 0x2e, 0x12]); + assert_eq!(&zero[255 * 4..256 * 4], &[0xc9, 0x1a, 0x54, 0xc4]); + assert_eq!(&zero[14238 * 4..14239 * 4], &[0xb9, 0xd3, 0x6e, 0xab]); + assert_eq!(&zero[14239 * 4..], &[0, 0]); + decipher(&mut zero); + assert!(zero.iter().all(|&b| b == 0)); + } +} diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 003eb3c18c..fe0ea80a94 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -166,25 +166,34 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result n.tag_floats(m, NIKON_BLACK_LEVEL).into_iter().map(|v| if plane.bits == 12 { v / 4.0 } else { v }).collect(), - None => t.tag_floats(&raw, SONY_BLACK_LEVEL), + None => { + if rggb_by_position(&cfa, &sony_black).is_some() { + sony_black + } else { + sony_levels.as_ref().and_then(|s| s.black).map(|v| v.to_vec()).unwrap_or_default() + } + } }; let black = if let Some(b) = bl.first() { BlackLevels::uniform(*b as f32) } else if let Some(v) = rggb_by_position(&cfa, &vendor_black) { BlackLevels { rows: 2, cols: 2, values: v.to_vec(), delta_h: Vec::new(), delta_v: Vec::new() } } else if craw { - // cRAW files of the first generation (an ILCE-7 writes none) carry no - // BlackLevel tag; the tone curve maps the black code to 512, so their - // decoded data has a 512 black level (see `sony.rs`). Other tag-less - // bodies sit higher (a DSC-RX0's darkest samples are near 800), so 512 - // is a floor, still far closer than 0. + // When neither plain nor encrypted metadata is readable, the common cRAW + // black code maps to 512. Some bodies sit higher, so this remains a fallback. BlackLevels::uniform(512.0) } else if nikon.is_some() { // Nikon bodies that write no BlackLevel tag subtract the black level in camera: a D3200 @@ -204,10 +213,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result Some([*r, 1.0, *b]), _ => None, }, - None => match t.tag_floats(&raw, SONY_WB_RGGB_LEVELS).as_slice() { - [r, g1, g2, b] if *g1 + *g2 > 0.0 => Some([2.0 * r / (g1 + g2), 1.0, 2.0 * b / (g1 + g2)]), - _ => None, - }, + None => sony_plain_wb.or_else(|| sony_levels.as_ref().and_then(|s| s.wb).and_then(|v| sony_wb(&v))), } .filter(|m| m.iter().all(|v| (0.25..8.0).contains(v))); // DNG-style DefaultCropOrigin / DefaultCropSize (some vendors write them in the raw IFD). @@ -258,6 +264,16 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result Option<[f64; 3]> { + let [r, g1, g2, b] = <[f64; 4]>::try_from(levels).ok()?; + if [r, g1, g2, b].iter().any(|v| !v.is_finite() || *v <= 0.0) { + return None; + } + let g = (g1 + g2) / 2.0; + let m = [r / g, 1.0, b / g]; + m.iter().all(|v| (0.25..8.0).contains(v)).then_some(m) +} + #[cfg(test)] mod padding_tests { use super::padded_width; diff --git a/crates/raw/src/tone.rs b/crates/raw/src/tone.rs index 00df46fd37..e398a6da95 100644 --- a/crates/raw/src/tone.rs +++ b/crates/raw/src/tone.rs @@ -138,32 +138,81 @@ const PRO_TO_P3: [[f32; 3]; 3] = [[1.632638, -0.379816, -0.252821], [-0.153846, /// A change of log₂ luminance computed on a reduced copy, carried to full size along the image's /// own edges (guided upsampling): a local linear model `change = a · log₂ Y + b` per reduced pixel. struct Upsampled { - sw: usize, - sh: usize, - a: Vec, - b: Vec, + grid: Grid, + /// `[a, b]` per reduced pixel (one cache line serves both lookups). + ab: Vec<[f32; 2]>, /// log₂ luminance floor (the reduced copy's 0.1th percentile). floor: f32, } impl Upsampled { - /// The change at full-size pixel (`x`, `y`) of a `w × h` image, of linear luminance `lum`. - fn change(&self, w: usize, h: usize, x: usize, y: usize, lum: f32) -> f32 { - let l = if lum > 0.0 { lum.log2().max(self.floor) } else { self.floor }; - bilinear(&self.a, self.sw, self.sh, w, h, x, y) * l + bilinear(&self.b, self.sw, self.sh, w, h, x, y) + fn new(sw: usize, sh: usize, w: usize, h: usize, a: &[f32], b: &[f32], floor: f32) -> Self { + Upsampled { grid: Grid::new(sw, sh, w, h), ab: a.iter().zip(b).map(|(a, b)| [*a, *b]).collect(), floor } + } + + /// The change at full-size pixel (`x`, `y`) whose linear luminance has the log₂ `log` (`None`: not above 0). + fn change(&self, x: usize, y: usize, log: Option) -> f32 { + let l = log.map_or(self.floor, |l| l.max(self.floor)); + let [a, b] = self.grid.at2(&self.ab, x, y); + a * l + b } } -/// `v` (`sw × sh`) at full-size pixel (`x`, `y`) of a `w × h` image, bilinearly. -fn bilinear(v: &[f32], sw: usize, sh: usize, w: usize, h: usize, x: usize, y: usize) -> f32 { - let sx = ((x as f32 + 0.5) * sw as f32 / w as f32 - 0.5).clamp(0.0, (sw - 1) as f32); - let sy = ((y as f32 + 0.5) * sh as f32 / h as f32 - 0.5).clamp(0.0, (sh - 1) as f32); - let (x0, y0) = (sx as usize, sy as usize); - let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); - let (fx, fy) = (sx - x0 as f32, sy - y0 as f32); - let top = v[y0 * sw + x0] * (1.0 - fx) + v[y0 * sw + x1] * fx; - let bottom = v[y1 * sw + x0] * (1.0 - fx) + v[y1 * sw + x1] * fx; - top * (1.0 - fy) + bottom * fy +/// Where one full-size coordinate reads a reduced axis: the two neighbours and the weight of the +/// second (and one minus it). +#[derive(Clone, Copy)] +struct Tap { + i0: u32, + i1: u32, + f: f32, + g: f32, +} + +/// The bilinear lookup of a reduced `sw × sh` plane at the pixels of a `w × h` image, with the +/// taps of every column and row worked out once instead of for every pixel. +struct Grid { + sw: usize, + cols: Vec, + rows: Vec, +} + +impl Grid { + fn new(sw: usize, sh: usize, w: usize, h: usize) -> Self { + let axis = |s: usize, n: usize| -> Vec { + (0..n) + .map(|i| { + let p = ((i as f32 + 0.5) * s as f32 / n as f32 - 0.5).clamp(0.0, (s - 1) as f32); + let i0 = p as usize; + let f = p - i0 as f32; + Tap { i0: i0 as u32, i1: (i0 + 1).min(s - 1) as u32, f, g: 1.0 - f } + }) + .collect() + }; + Grid { sw, cols: axis(sw, w), rows: axis(sh, h) } + } + + /// `v` (the reduced plane) at full-size pixel (`x`, `y`), bilinearly. + #[inline] + fn at(&self, v: &[f32], x: usize, y: usize) -> f32 { + let (cx, cy) = (self.cols[x], self.rows[y]); + let (r0, r1) = (cy.i0 as usize * self.sw, cy.i1 as usize * self.sw); + let top = v[r0 + cx.i0 as usize] * cx.g + v[r0 + cx.i1 as usize] * cx.f; + let bottom = v[r1 + cx.i0 as usize] * cx.g + v[r1 + cx.i1 as usize] * cx.f; + top * cy.g + bottom * cy.f + } + + /// [`at`](Self::at) for two planes stored together. + #[inline] + fn at2(&self, v: &[[f32; 2]], x: usize, y: usize) -> [f32; 2] { + let (cx, cy) = (self.cols[x], self.rows[y]); + let (r0, r1) = (cy.i0 as usize * self.sw, cy.i1 as usize * self.sw); + let (p00, p01, p10, p11) = (v[r0 + cx.i0 as usize], v[r0 + cx.i1 as usize], v[r1 + cx.i0 as usize], v[r1 + cx.i1 as usize]); + [0, 1].map(|k| { + let top = p00[k] * cx.g + p01[k] * cx.f; + let bottom = p10[k] * cx.g + p11[k] * cx.f; + top * cy.g + bottom * cy.f + }) + } } /// Box means of `f` over `k × k` cells, `k` the smallest factor that brings the long side to @@ -193,11 +242,55 @@ fn reduce(w: usize, h: usize, side: usize, f: impl Fn(usize) -> (sw, sh, rows.concat()) } +/// [`reduce`] of one plane at two sizes in a single pass over the pixels (each pixel's value +/// worked out once): the means of `f` for `sides[0]` and for `sides[1]`, as `reduce` gives them. +fn reduce_pair(w: usize, h: usize, sides: [usize; 2], f: impl Fn(usize) -> f32 + Sync) -> [(usize, usize, Vec); 2] { + let k = sides.map(|side| w.max(h).div_ceil(side).max(1)); + let sizes = k.map(|k| (w.div_ceil(k), h.div_ceil(k))); + // Bands of whole cell rows of both sizes. + let step = { + let (mut a, mut b) = (k[0], k[1]); + while b != 0 { + (a, b) = (b, a % b); + } + k[0] / a * k[1] + }; + let bands = par::map(h.div_ceil(step), |band| { + let (y0, y1) = (band * step, ((band + 1) * step).min(h)); + let mut row = vec![0.0f32; w]; + let mut sums = [vec![0.0f64; sizes[0].0], vec![0.0f64; sizes[1].0]]; + let mut out = [Vec::new(), Vec::new()]; + for y in y0..y1 { + for (x, v) in row.iter_mut().enumerate() { + *v = f(y * w + x).max(0.0); + } + for g in 0..2 { + for (cell, vs) in sums[g].iter_mut().zip(row.chunks(k[g])) { + let mut s = *cell; + for v in vs { + s += f64::from(*v); + } + *cell = s; + } + // The cell row ends here. + if (y + 1) % k[g] == 0 || y + 1 == h { + let rows = y + 1 - y / k[g] * k[g]; + for (sx, s) in sums[g].iter_mut().enumerate() { + let n = ((((sx + 1) * k[g]).min(w) - sx * k[g]) * rows) as f64; + out[g].push((*s / n) as f32); + *s = 0.0; + } + } + } + } + out + }); + [0, 1].map(|g| (sizes[g].0, sizes[g].1, bands.iter().flat_map(|b| b[g].iter().copied()).collect())) +} + /// The luminance change of one image (Shadows, Highlights, Clarity on the work copy; Texture on /// a half-size copy) and the black point. pub(crate) struct ToneMap { - w: usize, - h: usize, work: Option, texture: Option, /// Linear black point to subtract (0: none). @@ -212,11 +305,20 @@ impl ToneMap { if w == 0 || h == 0 || (tone.gain_neutral() && black_point <= 0.0) { return None; } - let (sw, sh, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]); - let y: Vec = y.into_iter().map(|[v]| v).collect(); - let p01 = quantile(&y, 0.001).max(2f32.powi(-20)); + // The work copy serves the black point and Shadows, Highlights and Clarity, Texture has + // its own; when both are needed they are reduced in one pass over the pixels. + let plane = |(sw, sh, y): (usize, usize, Vec<[f32; 1]>)| (sw, sh, y.into_iter().map(|[v]| v).collect::>()); + let (work_copy, texture_copy) = match (!tone.work_neutral() || black_point > 0.0, tone.texture != 0.0) { + (true, true) => { + let [work, texture] = reduce_pair(w, h, [WORK_SIDE, TEXTURE_SIDE], &lum); + (Some(work), Some(texture)) + } + (true, false) => (Some(plane(reduce(w, h, WORK_SIDE, |i| [lum(i)]))), None), + _ => (None, Some(plane(reduce(w, h, TEXTURE_SIDE, |i| [lum(i)])))), + }; + let p01 = work_copy.as_ref().map_or(0.0, |(_, _, y)| quantile(y, 0.001).max(2f32.powi(-20))); let black = if black_point > 0.0 { black_point * p01 } else { 0.0 }; - let work = (!tone.work_neutral()).then(|| { + let work = work_copy.filter(|_| !tone.work_neutral()).map(|(sw, sh, y)| { let l: Vec = y.iter().map(|v| v.max(p01).log2()).collect(); let n = l.len() as f64; let geo = (l.iter().map(|v| f64::from(*v)).sum::() / n) as f32; @@ -237,10 +339,10 @@ impl ToneMap { // change follow strong edges only: inside texture it stays smooth, so fine detail keeps // its contrast as in Camera Raw (with ε 0.01 shadow detail lost a fifth of it; with ε 4 the change bled ~16 px over 3-stop edges). let (a, b) = linear_fit(&l, &change, sw, sh, 2, UPSAMPLE_EPS); - Upsampled { sw, sh, a, b, floor: p01.log2() } + Upsampled::new(sw, sh, w, h, &a, &b, p01.log2()) }); - let texture = (tone.texture != 0.0).then(|| texture(w, h, &lum, tone.texture)); - Some(ToneMap { w, h, work, texture, black }) + let texture = texture_copy.map(|copy| texture(w, h, copy, tone.texture)); + Some(ToneMap { work, texture, black }) } /// The gain at full-size pixel (`x`, `y`) of linear luminance `lum`. @@ -248,7 +350,8 @@ impl ToneMap { if self.work.is_none() && self.texture.is_none() { return 1.0; } - let c = [&self.work, &self.texture].iter().filter_map(|u| u.as_ref()).map(|u| u.change(self.w, self.h, x, y, lum)).sum::(); + let log = (lum > 0.0).then(|| lum.log2()); + let c = [&self.work, &self.texture].iter().filter_map(|u| u.as_ref()).map(|u| u.change(x, y, log)).sum::(); c.clamp(-6.0, 6.0).exp2() } @@ -300,11 +403,9 @@ fn clarity(change: &mut [f32], l: &[f32], sw: usize, sh: usize, geo: f32, scale: } } -/// Texture `s` on a half-size copy: each Gaussian band of log₂ luminance through its measured +/// Texture `s` on its reduced copy `(sw, sh, y)` of the luminance: each Gaussian band of log₂ luminance through its measured /// response (zero for flat areas), carried to full size. -fn texture(w: usize, h: usize, lum: &(impl Fn(usize) -> f32 + Sync), s: f32) -> Upsampled { - let (sw, sh, y) = reduce(w, h, TEXTURE_SIDE, |i| [lum(i)]); - let y: Vec = y.into_iter().map(|[v]| v).collect(); +fn texture(w: usize, h: usize, (sw, sh, y): (usize, usize, Vec), s: f32) -> Upsampled { let p01 = quantile(&y, 0.001).max(2f32.powi(-20)); let l: Vec = y.iter().map(|v| v.max(p01).log2()).collect(); let scale = sw.max(sh) as f32 / TEXTURE_SIDE as f32; @@ -320,7 +421,7 @@ fn texture(w: usize, h: usize, lum: &(impl Fn(usize) -> f32 + Sync), s: f32) -> prev = base; } let (a, b) = linear_fit(&l, &change, sw, sh, 2, TEXTURE_UPSAMPLE_EPS); - Upsampled { sw, sh, a, b, floor: p01.log2() } + Upsampled::new(sw, sh, w, h, &a, &b, p01.log2()) } /// Texture's band response on the knots [`tables::TEXTURE_KNOTS`], read linearly, flat beyond. @@ -332,7 +433,13 @@ fn knots(c: &[f32; 11], d: f32) -> f32 { c[i - 1] + (c[i] - c[i - 1]) * f } +/// Columns (or output samples) one pass of [`gauss`] and [`box_mean`] accumulates at a time. +const BLOCK: usize = 64; + /// Gaussian blur of `v` (`w × h`), σ `sigma`, radius ⌈3σ⌉, mirrored edges. +/// +/// Every output sample is the taps' sum in tap order, as a plain loop over the kernel would +/// give; the passes run along the rows so that neighbouring samples are summed side by side. fn gauss(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec { if sigma <= 0.05 { return v.to_vec(); @@ -341,23 +448,49 @@ fn gauss(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec { let mut k: Vec = (-r..=r).map(|x| (-(x * x) as f32 / (2.0 * sigma * sigma)).exp()).collect(); let sum: f32 = k.iter().sum(); k.iter_mut().for_each(|v| *v /= sum); - let rows = par::map(h, |y| { - let src = &v[y * w..(y + 1) * w]; - (0..w as isize).map(|x| k.iter().enumerate().map(|(j, kv)| kv * src[mirror(x + j as isize - r, w)]).sum::()).collect::>() - }); - let tmp = rows.concat(); - let cols = par::map(w, |x| { - (0..h as isize).map(|y| k.iter().enumerate().map(|(j, kv)| kv * tmp[mirror(y + j as isize - r, h) * w + x]).sum::()).collect::>() - }); + let r = r as usize; + let mut tmp = vec![0.0f32; w * h]; + par::chunks_mut(&mut tmp, w, |y, out| blur_row(&v[y * w..(y + 1) * w], out, &k, r)); let mut out = vec![0.0f32; w * h]; - for (x, col) in cols.iter().enumerate() { - for (y, v) in col.iter().enumerate() { - out[y * w + x] = *v; + par::chunks_mut(&mut out, w, |y, row| { + // The rows the taps of output row `y` read. + let src: Vec<&[f32]> = (0..k.len()).map(|j| mirror(y as isize + j as isize - r as isize, h)).map(|m| &tmp[m * w..(m + 1) * w]).collect(); + for (x0, block) in row.chunks_mut(BLOCK).enumerate().map(|(i, b)| (i * BLOCK, b)) { + let mut acc = [-0.0f32; BLOCK]; + let acc = &mut acc[..block.len()]; + for (kv, s) in k.iter().zip(&src) { + for (a, s) in acc.iter_mut().zip(&s[x0..]) { + *a += kv * s; + } + } + block.copy_from_slice(acc); } - } + }); out } +/// One row of [`gauss`]: `out[x]` is the sum over the taps `j` of `k[j] · src[mirror(x + j − r)]`. +fn blur_row(src: &[f32], out: &mut [f32], k: &[f32], r: usize) { + let w = src.len(); + let edge = |x: usize| k.iter().enumerate().map(|(j, kv)| kv * src[mirror(x as isize + j as isize - r as isize, w)]).sum::(); + if w <= 2 * r { + out.iter_mut().enumerate().for_each(|(x, o)| *o = edge(x)); + return; + } + (0..r).chain(w - r..w).for_each(|x| out[x] = edge(x)); + // Inside, the taps read `src[x - r + j]` without mirroring. + for (off, block) in out[r..w - r].chunks_mut(BLOCK).enumerate().map(|(i, b)| (i * BLOCK, b)) { + let mut acc = [-0.0f32; BLOCK]; + let acc = &mut acc[..block.len()]; + for (j, kv) in k.iter().enumerate() { + for (a, s) in acc.iter_mut().zip(&src[off + j..]) { + *a += kv * s; + } + } + block.copy_from_slice(acc); + } +} + /// Minimum over a `(2r + 1)²` window, edges clamped. fn min_filter(v: &[f32], w: usize, h: usize, r: usize) -> Vec { let rows = par::map(h, |y| { @@ -378,10 +511,7 @@ fn min_filter(v: &[f32], w: usize, h: usize, r: usize) -> Vec { /// Camera Raw's Dehaze on linear ProPhoto RGB (see the module docs). pub(crate) struct Haze { - w: usize, - h: usize, - sw: usize, - sh: usize, + grid: Grid, /// Transmission per work-copy pixel (Dehaze > 0); empty for the veil of Dehaze < 0. t: Vec, /// Airlight, linear ProPhoto. @@ -413,7 +543,7 @@ impl Haze { } let air = air.map(|v| v.max(1e-6)); if amount < 0.0 { - return Some(Haze { w, h, sw, sh, t: Vec::new(), air, veil: HAZE_DOWN * -amount }); + return Some(Haze { grid: Grid::new(sw, sh, w, h), t: Vec::new(), air, veil: HAZE_DOWN * -amount }); } let air_p3 = p3(&air).map(|v| v.max(1e-6)); let scale = sw.max(sh) as f32 / WORK_SIDE as f32; @@ -426,15 +556,16 @@ impl Haze { let smooth = guided(&guide, &dark, sw, sh, ((HAZE_SMOOTH * scale).round() as usize).max(1), HAZE_EPS); let wgt = HAZE_UP * amount; let t = smooth.iter().map(|d| (1.0 - wgt * d).max(HAZE_T0)).collect(); - Some(Haze { w, h, sw, sh, t, air, veil: 0.0 }) + Some(Haze { grid: Grid::new(sw, sh, w, h), t, air, veil: 0.0 }) } /// Dehaze at full-size pixel (`x`, `y`) of linear ProPhoto `v`. + #[inline(always)] pub(crate) fn apply(&self, x: usize, y: usize, v: [f32; 3]) -> [f32; 3] { if self.t.is_empty() { return [0, 1, 2].map(|c| v[c] * (1.0 - self.veil) + self.air[c] * self.veil); } - let t = bilinear(&self.t, self.sw, self.sh, self.w, self.h, x, y).max(HAZE_T0); + let t = self.grid.at(&self.t, x, y).max(HAZE_T0); [0, 1, 2].map(|c| ((v[c] - self.air[c]) / t + self.air[c]).max(0.0)) } } @@ -489,33 +620,46 @@ fn mirror(i: isize, n: usize) -> usize { fn box_mean(v: &[f32], w: usize, h: usize, r: usize) -> Vec { let k = (2 * r + 1) as f64; let ri = r as isize; - let rows = par::map(h, |y| { + let mut tmp = vec![0.0f32; w * h]; + par::chunks_mut(&mut tmp, w, |y, out| { let src = &v[y * w..(y + 1) * w]; let mut acc: f64 = (-ri..=ri).map(|i| f64::from(src[mirror(i, w)])).sum(); - let mut out = vec![0.0f32; w]; for (x, o) in out.iter_mut().enumerate() { *o = (acc / k) as f32; let x = x as isize; acc += f64::from(src[mirror(x + ri + 1, w)]) - f64::from(src[mirror(x - ri, w)]); } - out }); - let tmp = rows.concat(); - let cols = par::map(w, |x| { - let at = |y: isize| f64::from(tmp[mirror(y, h) * w + x]); - let mut acc: f64 = (-ri..=ri).map(at).sum(); - let mut out = vec![0.0f32; h]; - for (y, o) in out.iter_mut().enumerate() { - *o = (acc / k) as f32; + // Down the columns: every column's running sum advances by one row at a time, the columns + // of a block side by side. Blocks in parallel, each into its own buffer. + let blocks = par::map(w.div_ceil(BLOCK), |bi| { + let (x0, x1) = (bi * BLOCK, ((bi + 1) * BLOCK).min(w)); + let n = x1 - x0; + let row = |y: isize| &tmp[mirror(y, h) * w + x0..mirror(y, h) * w + x1]; + let mut acc = [-0.0f64; BLOCK]; + let acc = &mut acc[..n]; + for i in -ri..=ri { + for (a, s) in acc.iter_mut().zip(row(i)) { + *a += f64::from(*s); + } + } + let mut out = vec![0.0f32; h * n]; + for (y, o) in out.chunks_exact_mut(n).enumerate() { let y = y as isize; - acc += at(y + ri + 1) - at(y - ri); + let (add, sub) = (row(y + ri + 1), row(y - ri)); + for (((o, a), p), q) in o.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) { + *o = (*a / k) as f32; + *a += f64::from(*p) - f64::from(*q); + } } out }); let mut out = vec![0.0f32; w * h]; - for (x, col) in cols.iter().enumerate() { - for (y, v) in col.iter().enumerate() { - out[y * w + x] = *v; + for (bi, block) in blocks.iter().enumerate() { + let x0 = bi * BLOCK; + let n = block.len() / h.max(1); + for (y, src) in block.chunks_exact(n.max(1)).enumerate() { + out[y * w + x0..y * w + x0 + n].copy_from_slice(src); } } out @@ -544,7 +688,8 @@ fn guided(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec f32 { + let sx = ((x as f32 + 0.5) * sw as f32 / w as f32 - 0.5).clamp(0.0, (sw - 1) as f32); + let sy = ((y as f32 + 0.5) * sh as f32 / h as f32 - 0.5).clamp(0.0, (sh - 1) as f32); + let (x0, y0) = (sx as usize, sy as usize); + let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); + let (fx, fy) = (sx - x0 as f32, sy - y0 as f32); + let top = v[y0 * sw + x0] * (1.0 - fx) + v[y0 * sw + x1] * fx; + let bottom = v[y1 * sw + x0] * (1.0 - fx) + v[y1 * sw + x1] * fx; + top * (1.0 - fy) + bottom * fy + } + + fn gauss_ref(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec { + if sigma <= 0.05 { + return v.to_vec(); + } + let r = (3.0 * sigma).ceil() as isize; + let mut k: Vec = (-r..=r).map(|x| (-(x * x) as f32 / (2.0 * sigma * sigma)).exp()).collect(); + let sum: f32 = k.iter().sum(); + k.iter_mut().for_each(|v| *v /= sum); + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + (0..w as isize).map(|x| k.iter().enumerate().map(|(j, kv)| kv * src[mirror(x + j as isize - r, w)]).sum::()).collect::>() + }); + let tmp = rows.concat(); + let cols = par::map(w, |x| { + (0..h as isize).map(|y| k.iter().enumerate().map(|(j, kv)| kv * tmp[mirror(y + j as isize - r, h) * w + x]).sum::()).collect::>() + }); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out + } + + fn box_mean_ref(v: &[f32], w: usize, h: usize, r: usize) -> Vec { + let k = (2 * r + 1) as f64; + let ri = r as isize; + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + let mut acc: f64 = (-ri..=ri).map(|i| f64::from(src[mirror(i, w)])).sum(); + let mut out = vec![0.0f32; w]; + for (x, o) in out.iter_mut().enumerate() { + *o = (acc / k) as f32; + let x = x as isize; + acc += f64::from(src[mirror(x + ri + 1, w)]) - f64::from(src[mirror(x - ri, w)]); + } + out + }); + let tmp = rows.concat(); + let cols = par::map(w, |x| { + let at = |y: isize| f64::from(tmp[mirror(y, h) * w + x]); + let mut acc: f64 = (-ri..=ri).map(at).sum(); + let mut out = vec![0.0f32; h]; + for (y, o) in out.iter_mut().enumerate() { + *o = (acc / k) as f32; + let y = y as isize; + acc += at(y + ri + 1) - at(y - ri); + } + out + }); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out + } + + /// Values around log₂ luminance, from a fixed LCG. + fn noise(n: usize, seed: u64) -> Vec { + let mut s = seed; + (0..n) + .map(|_| { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((s >> 40) as f32 / (1u64 << 24) as f32) * 14.0 - 12.0 + }) + .collect() + } + + fn same(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.to_bits() == b.to_bits()) + } + + #[test] + fn gauss_gives_the_bits_of_the_tap_loop() { + // Sizes below, around and above the kernel (edges only, a few interior samples, mostly interior). + for (w, h) in [(1, 1), (5, 3), (13, 40), (64, 7), (65, 70), (131, 97), (300, 41)] { + let v = noise(w * h, (w * 31 + h) as u64); + for sigma in [0.04, 0.42, 1.0, 2.1, 4.2, 8.4, 13.44] { + assert!(same(&gauss(&v, w, h, sigma), &gauss_ref(&v, w, h, sigma)), "{w}x{h} sigma {sigma}"); + } + } + } + + #[test] + fn box_mean_gives_the_bits_of_the_column_loop() { + for (w, h) in [(1, 1), (5, 3), (13, 40), (64, 7), (65, 70), (131, 97), (300, 41)] { + let v = noise(w * h, (w * 17 + h) as u64); + for r in [1, 2, 8, 32, 128] { + assert!(same(&box_mean(&v, w, h, r), &box_mean_ref(&v, w, h, r)), "{w}x{h} r {r}"); + } + } + } + + #[test] + fn the_tap_tables_give_the_bits_of_the_per_pixel_lookup() { + for ((sw, sh), (w, h)) in [((1, 1), (7, 5)), ((3, 2), (3, 2)), ((12, 9), (97, 61)), ((308, 205), (1232, 820)), ((1232, 616), (8256, 4128))] { + let a = noise(sw * sh, 5); + let b = noise(sw * sh, 6); + let grid = Grid::new(sw, sh, w, h); + let ab: Vec<[f32; 2]> = a.iter().zip(&b).map(|(a, b)| [*a, *b]).collect(); + for y in (0..h).step_by(if h > 1000 { 7 } else { 1 }) { + for x in (0..w).step_by(if w > 1000 { 5 } else { 1 }) { + let (ra, rb) = (bilinear_ref(&a, sw, sh, w, h, x, y), bilinear_ref(&b, sw, sh, w, h, x, y)); + assert_eq!(grid.at(&a, x, y).to_bits(), ra.to_bits(), "{sw}x{sh} to {w}x{h} at {x},{y}"); + let [pa, pb] = grid.at2(&ab, x, y); + assert!(pa.to_bits() == ra.to_bits() && pb.to_bits() == rb.to_bits(), "{sw}x{sh} to {w}x{h} at {x},{y}"); + } + } + } + } + + #[test] + fn reduce_pair_gives_the_bits_of_two_reductions() { + for (w, h) in [(1, 1), (7, 5), (100, 61), (1233, 77), (2500, 1700), (333, 2999)] { + let v = noise(w * h, (w + 3 * h) as u64); + for sides in [[1232, 2464], [100, 50], [7, 8], [1232, 1232], [3, 600]] { + let pair = reduce_pair(w, h, sides, |i| v[i]); + for (g, side) in sides.iter().enumerate() { + let (sw, sh, one) = reduce(w, h, *side, |i| [v[i]]); + let one: Vec = one.into_iter().map(|[v]| v).collect(); + assert!((sw, sh) == (pair[g].0, pair[g].1) && same(&one, &pair[g].2), "{w}x{h} side {side}"); + } + } + } + } } diff --git a/crates/raw/tests/it/hostile.rs b/crates/raw/tests/it/hostile.rs index 6fdd0e7434..a60025be69 100644 --- a/crates/raw/tests/it/hostile.rs +++ b/crates/raw/tests/it/hostile.rs @@ -232,6 +232,37 @@ fn patch_long(b: &mut [u8], tag: u16, to: u32) -> bool { patched == 1 } +/// Tiles that all point at the same bytes decode once and fill every tile (the range is shared, +/// not decoded per tile), and give the image a lossless JPEG of that one tile repeated. +#[test] +fn tiles_sharing_one_range_fill_every_tile() { + let mut d = DngSpec::cfa(16, 16, mosaic(&scene(16, 16), 16, [0, 1, 1, 2], 64, 4000)); + d.bits = 12; + d.white = 4000; + d.black = vec![64]; + d.storage = DngStorage::Lj92Tiles { width: 8, height: 8 }; + let mut b = d.build(); + // TileOffsets (324) and TileByteCounts (325): LONG x 4, stored out of line. + for tag in [324u16, 325] { + let e = (8..b.len() - 12) + .find(|&i| b[i..i + 2] == tag.to_le_bytes() && b[i + 2..i + 4] == 4u16.to_le_bytes() && b[i + 4..i + 8] == 4u32.to_le_bytes()) + .expect("tile tag"); + let at = u32::from_le_bytes(b[e + 8..e + 12].try_into().unwrap()) as usize; + let first: [u8; 4] = b[at..at + 4].try_into().unwrap(); + for k in 1..4 { + b[at + 4 * k..at + 4 * k + 4].copy_from_slice(&first); + } + } + let shared = decode(&b, &Limits::default()).expect("a repeated tile range decodes"); + let plane = &shared.data; + // Every 8x8 tile holds the first tile's samples. + for (ty, tx) in [(0, 8), (8, 0), (8, 8)] { + for y in 0..8 { + assert_eq!(plane[(ty + y) * 16 + tx..(ty + y) * 16 + tx + 8], plane[y * 16..y * 16 + 8]); + } + } +} + /// The lossless-JPEG intermediate is sized from the segment, so the plane check alone /// does not bound it: it has to be charged against `max_alloc` too (#2925). #[test] diff --git a/crates/raw/tests/it/raw_corpus.rs b/crates/raw/tests/it/raw_corpus.rs index afd168a38a..7d2ca341d2 100644 --- a/crates/raw/tests/it/raw_corpus.rs +++ b/crates/raw/tests/it/raw_corpus.rs @@ -68,9 +68,8 @@ const WANTS: &[Want] = &[ model: "DSC-RX0", size: (4832, 3224), cfa: [0, 1, 1, 2], - // No readable BlackLevel tag: the cRAW fallback of 512 (#1902). The file's darkest - // samples sit near 800, so this body's real level is still to be read. - black: &[512.0], + // SR2SubIFD black levels, verified with the metadata oracle (not the 512 fallback). + black: &[800.0; 4], white: [16301.0; 3], }, // The G9's high-resolution mode: a 10480 × 7794 sensor cropped to 10368 × 7776. @@ -109,6 +108,9 @@ fn decode_matches_the_measured_oracles() { let cfa = s.cfa.as_ref().unwrap_or_else(|| panic!("{}: no CFA", w.file)); assert_eq!(cfa.colors, w.cfa, "{}", w.file); assert_eq!(s.black.values, w.black, "{}", w.file); + if w.file == "DSC00009.ARW" { + assert_eq!(s.camera_wb, Some([2056.0 / 1024.0, 1.0, 1908.0 / 1024.0])); + } for ch in 0..3 { assert!((s.white[ch] - w.white[ch]).abs() < 0.5, "{} white[{ch}] = {}", w.file, s.white[ch]); } @@ -130,11 +132,11 @@ fn developing_produces_the_crop_in_16bit_rgb() { hi = (*v).max(hi); } // A real exposure covers a sane share of the range and never leaves it. The PowerShot - // CR2 and the RX0 ARW are the documented exceptions: neither carries an as-shot white + // CR2 is the documented exception: it carries no as-shot white // balance we read, so the grey-world estimate brightens these scenes (the PowerShot's // own DNG, with its file WB, develops blacks to ~0 — reading the maker-note WB of - // Canon and Sony is the follow-up). - let estimated_wb = matches!(w.file, "IMG_4059.CR2" | "DSC00009.ARW"); + // Canon is the follow-up). Sony's SR2SubIFD now supplies the applied gains and black. + let estimated_wb = w.file == "IMG_4059.CR2"; let black_cap: u16 = if estimated_wb { 16384 } else { 4096 }; assert!(lo < black_cap, "{}: developed blacks at {lo}", w.file); assert!(hi > u16::MAX / 4, "{}: developed highlights at {hi}", w.file); diff --git a/crates/tablet/Cargo.toml b/crates/tablet/Cargo.toml index c4d1c8d6bf..e14e4a5ae0 100644 --- a/crates/tablet/Cargo.toml +++ b/crates/tablet/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button on Windows" -# The one isolated `unsafe` helper crate (craftrules never-crash, AGENTS.md): the AppKit event +# The one isolated `unsafe` helper crate (see AGENTS.md, Never crash): the AppKit event # monitor on macOS and the WH_GETMESSAGE pointer hook on Windows need platform interop. Only # `src/macos.rs` and `src/windows.rs` allow `unsafe`; everything else is denied, and every other # crate keeps the workspace's `unsafe_code = "forbid"`. diff --git a/crates/tablet/src/lib.rs b/crates/tablet/src/lib.rs index 78c17613ed..41b48f9966 100644 --- a/crates/tablet/src/lib.rs +++ b/crates/tablet/src/lib.rs @@ -9,7 +9,7 @@ //! messages on the UI thread's message queue to read `PEN_FLAG_BARREL` (`GetPointerPenInfo`), //! which winit 0.30 drops by mapping pen inputs to touch events. //! AppKit's monitor API and Windows' thread hook need OS interop, so `macos.rs` and `windows.rs` -//! allow `unsafe` (craftrules never-crash: an isolated, tested helper crate with `SAFETY:` +//! allow `unsafe` (an isolated, tested helper crate with `SAFETY:` //! comments on every block). //! - **Linux X11** ([`x11::spawn`]): a second, pure-Rust X connection (x11rb) selects XInput2 //! raw events on the root window and maps the tablet device's valuators ("Abs Pressure", diff --git a/crates/testkit/src/gpu.rs b/crates/testkit/src/gpu.rs index 700908ecfb..3bb5df1ed8 100644 --- a/crates/testkit/src/gpu.rs +++ b/crates/testkit/src/gpu.rs @@ -11,6 +11,13 @@ //! - **libvulkan** with the NVIDIA and lavapipe drivers both installed: `wgpu::Instance::new` on //! several threads segfaults. //! +//! Separately, the **Intel Vulkan driver** on Windows (`igvk64.dll`) crashes a process that so +//! much as creates a wgpu instance with Vulkan in it (#4), even on one thread. The app leaves +//! Vulkan out on Intel; tests use wgpu's defaults, so on such a machine run them with +//! `WGPU_BACKEND=dx12`. Tests that build their own instance read it through +//! `InstanceDescriptor::new_without_display_handle_from_env`; `wgpu::Instance::default()` +//! ignores it. +//! //! So every test that builds a GPU harness (or its own wgpu device) holds [`gpu_lock`] for its //! whole run, harness included. Under nextest each test is its own process and the lock costs //! nothing; `.config/nextest.toml` caps how many of those run at once instead. diff --git a/crates/testkit/src/perf.rs b/crates/testkit/src/perf.rs index 6bdf68ab0a..38e6a14cb9 100644 --- a/crates/testkit/src/perf.rs +++ b/crates/testkit/src/perf.rs @@ -27,6 +27,19 @@ pub fn process_peak_rss_bytes() -> Option { parse_vm_hwm(&status) } +/// Minor page faults this process has taken so far (`/proc/self/stat` field 10), `None` off +/// Linux. A scenario that maps fresh memory on every run shows up here (#10479). +pub fn minor_faults() -> Option { + let stat = std::fs::read_to_string("/proc/self/stat").ok()?; + parse_minflt(&stat) +} + +fn parse_minflt(stat: &str) -> Option { + // `comm` (field 2) may contain spaces; the fields after its closing `)` start at field 3. + let rest = stat.get(stat.rfind(')')?.checked_add(1)?..)?; + rest.split_whitespace().nth(7)?.parse().ok() +} + fn parse_vm_hwm(status: &str) -> Option { let line = status.lines().find(|l| l.starts_with("VmHWM:"))?; let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; @@ -149,6 +162,14 @@ pub fn write_report(path: &str, report: &Value) -> Result<(), String> { mod tests { use super::*; + #[test] + fn minflt_is_field_ten_even_with_spaces_in_comm() { + assert_eq!(parse_minflt("42 (cat) R 1 2 3 0 -1 4194304 122 0 0 0"), Some(122)); + assert_eq!(parse_minflt("42 (a b) c) S 1 2 3 0 -1 0 7 0"), Some(7)); + assert_eq!(parse_minflt("42 (cat) R 1"), None); + assert_eq!(parse_minflt("garbage"), None); + } + #[test] fn summary_nearest_rank() { let v: Vec = (1..=20).map(f64::from).collect(); diff --git a/crates/text/build.rs b/crates/text/build.rs index 5377370d7d..3cc6867ace 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,5 +1,5 @@ //! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its -//! `docs/integration.md`; rules in `craftrules/standards/fonts.md`). +//! `docs/integration.md`). //! //! With `CRAFT_FONTS_DIR=`, every font in its `fonts/manifest.txt` is //! embedded and exposed as `photocraft_text::CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty and the diff --git a/crates/text/src/area.rs b/crates/text/src/area.rs index 407065355d..7ed8525064 100644 --- a/crates/text/src/area.rs +++ b/crates/text/src/area.rs @@ -249,13 +249,23 @@ impl AreaShape { let mid = y0 + (y1 - y0) * 0.5; let mut whole: Vec<(f32, f32)> = Vec::new(); let mut ever: Vec<(f32, f32)> = Vec::new(); + // A run of Combine / Join components is one union: gather its intervals and merge them + // once when the run ends. Folding each component into the growing result rewrote the + // whole list per component, quadratic for a shape of many disjoint components. + let (mut more_whole, mut more_ever) = (Vec::new(), Vec::new()); for c in &self.comps { let inside = self.component_intervals(c, mid); let edges = self.swept(c, y0, y1); let w = boolean(&inside, &edges, |a, b| a && !b); let e = boolean(&inside, &edges, |a, b| a || b); + if matches!(c.op, PathOp::Combine | PathOp::Join) { + more_whole.extend(w); + more_ever.extend(e); + continue; + } + (whole, ever) = (union_all(whole, &mut more_whole), union_all(ever, &mut more_ever)); (whole, ever) = match c.op { - PathOp::Combine | PathOp::Join => (boolean(&whole, &w, |a, b| a || b), boolean(&ever, &e, |a, b| a || b)), + PathOp::Combine | PathOp::Join => (whole, ever), PathOp::Subtract => (boolean(&whole, &e, |a, b| a && !b), boolean(&ever, &w, |a, b| a && !b)), PathOp::Intersect => (boolean(&whole, &w, |a, b| a && b), boolean(&ever, &e, |a, b| a && b)), PathOp::Exclude => { @@ -265,7 +275,7 @@ impl AreaShape { } }; } - whole.into_iter().filter(|(l, r)| r - l >= min_width).collect() + union_all(whole, &mut more_whole).into_iter().filter(|(l, r)| r - l >= min_width).collect() } /// The first baseline at or below `from` whose band `[b - ascent, b + descent]` has spans @@ -308,6 +318,24 @@ pub fn path_bounds(path: &Path) -> Option<(f64, f64, f64, f64)> { path.control_bounds() } +/// The union of the sorted, disjoint intervals `base` and the intervals in `more` (any order, +/// drained), as [`boolean`] with `a || b` gives it: touching intervals join, empty ones vanish. +fn union_all(base: Vec<(f32, f32)>, more: &mut Vec<(f32, f32)>) -> Vec<(f32, f32)> { + if more.is_empty() { + return base; + } + let mut all: Vec<(f32, f32)> = base.into_iter().chain(more.drain(..)).filter(|(l, r)| l < r).collect(); + all.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f32, f32)> = Vec::with_capacity(all.len()); + for (l, r) in all { + match out.last_mut() { + Some(last) if l <= last.1 => last.1 = last.1.max(r), + _ => out.push((l, r)), + } + } + out +} + /// Boolean combination of two sorted, disjoint interval lists, in one linear merge. fn boolean(a: &[(f32, f32)], b: &[(f32, f32)], f: impl Fn(bool, bool) -> bool) -> Vec<(f32, f32)> { let ends = |v: &[(f32, f32)]| -> Vec { v.iter().flat_map(|&(l, r)| [l, r]).collect() }; @@ -370,6 +398,27 @@ mod tests { (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 } + #[test] + fn many_disjoint_components_union_like_the_pairwise_fold() { + // Within MAX_AREA_COMPONENTS: narrow columns, every other one overlapping its neighbour. + let cols: Vec = (0..2000) + .map(|i| { + let x = f64::from(i) * 3.0 + if i % 2 == 1 { -2.5 } else { 0.0 }; + Subpath::polygon(&[(x, 0.0), (x + 1.0, 0.0), (x + 1.0, 100.0), (x, 100.0)]).with_op(PathOp::Combine) + }) + .collect(); + let a = AreaShape::new(&Path::new(cols), false).unwrap(); + let got = a.spans(10.0, 20.0, 0.0); + // Each overlapping pair is one span: [3k, 3k + 1.5] for k = 0, 2, 4, ... + assert_eq!(got.len(), 1000); + for (k, &(l, r)) in got.iter().enumerate() { + let x = k as f32 * 6.0; + assert!((l - x).abs() < 1e-3 && (r - x - 1.5).abs() < 1e-3, "span {k}: {l}..{r}"); + } + // An empty interval adds nothing, a touching one joins. + assert_eq!(union_all(vec![(0.0, 1.0)], &mut vec![(5.0, 5.0), (1.0, 2.0)]), [(0.0, 2.0)]); + } + #[test] fn rectangle_band_is_its_width() { let a = AreaShape::new(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)]), false).unwrap(); diff --git a/crates/text/src/closed_path_tests.rs b/crates/text/src/closed_path_tests.rs new file mode 100644 index 0000000000..64d37d3d9c --- /dev/null +++ b/crates/text/src/closed_path_tests.rs @@ -0,0 +1,149 @@ +//! Closed-path regression fixtures use the same four cubic knots as the shape ellipse. +use super::*; +use photocraft_doc::Knot; +use photocraft_geom::Point; + +fn ellipse() -> Subpath { + let k = 0.552_284_749_830_793_4; + let point = |x, y| Point::new(x, y); + Subpath { + closed: true, + knots: vec![ + Knot::smooth(point(100.0, 0.0), point(100.0 - 100.0 * k, 0.0), point(100.0 + 100.0 * k, 0.0)), + Knot::smooth(point(200.0, 60.0), point(200.0, 60.0 - 60.0 * k), point(200.0, 60.0 + 60.0 * k)), + Knot::smooth(point(100.0, 120.0), point(100.0 + 100.0 * k, 120.0), point(100.0 - 100.0 * k, 120.0)), + Knot::smooth(point(0.0, 60.0), point(0.0, 60.0 + 60.0 * k), point(0.0, 60.0 - 60.0 * k)), + ], + op: Default::default(), + } +} + +fn centre(m: [f64; 6], x: f64) -> (f64, f64) { + (m[0] * x + m[4], m[1] * x + m[5]) +} + +#[test] +fn closed_rectangle_left_edge_continues_past_the_top_left_seam() { + let p = Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + let start = arc_length_at(&p, false, 0.0, 50.0).unwrap(); + assert!((start - 350.0).abs() < 1e-6); + let f = PathFrame::new(&p, start as f32, false).unwrap(); + let m = f.frame(75.0).expect("glyph after the seam remains on the closed path"); + let q = centre(m, 75.0); + assert!((q.0 - 25.0).abs() < 1e-6 && q.1.abs() < 1e-6, "{q:?}"); + assert_eq!(&m[..4], &[1.0, 0.0, -0.0, 1.0], "text now travels right on the top side"); + assert_eq!(f.apply(75.0, 0.0), q, "caret and glyph use the same wrapped parameter"); +} + +#[test] +fn closed_ellipse_upper_left_and_apex_cross_the_seam_in_both_directions() { + for reverse in [false, true] { + let mut p = ellipse(); + if reverse { + p.knots.reverse(); + for k in &mut p.knots { + std::mem::swap(&mut k.in_ctrl, &mut k.out_ctrl); + } + } + for flip in [false, true] { + let whole = PathFrame::new(&p, 0.0, flip).unwrap(); + let length = whole.length(); + for remaining in [0.001, 0.2, 30.0] { + let start = (length - remaining) as f32; + let f = PathFrame::new(&p, start, flip).unwrap(); + let x = remaining + 15.0; + let m = f.frame(x).expect("ellipse glyph after the seam must remain visible"); + let (point, tangent) = whole.at((f64::from(start) + x).rem_euclid(length)); + let actual = centre(m, x); + assert!((actual.0 - point.0).hypot(actual.1 - point.1) < 1e-6); + assert!((m[0] - tangent.0).abs() < 1e-6 && (m[1] - tangent.1).abs() < 1e-6); + assert!((m[0] * m[3] - m[1] * m[2] - 1.0).abs() < 1e-6, "no reflection or scale jump"); + let before = f.apply(remaining - 0.001, 0.0); + let after = f.apply(remaining + 0.001, 0.0); + assert!((before.0 - after.0).hypot(before.1 - after.1) < 0.01, "seam is continuous"); + } + } + } +} + +#[test] +fn closed_path_shows_one_lap_without_repeating_glyph_centres() { + let p = ellipse(); + let length = PathFrame::new(&p, 0.0, false).unwrap().length(); + let f = PathFrame::new(&p, (length - 5.0) as f32, false).unwrap(); + let mut points = Vec::new(); + for i in 0..100 { + let x = length * i as f64 / 100.0; + let m = f.frame(x).expect("one complete lap is available from the text origin"); + points.push(centre(m, x)); + } + for (i, a) in points.iter().enumerate() { + assert!(points.iter().skip(i + 1).all(|b| (a.0 - b.0).hypot(a.1 - b.1) > 0.01)); + } + assert!(f.frame(length).is_none(), "the first repeated location is overflow, not another lap"); + assert!(f.frame(length + 20.0).is_none()); +} + +#[test] +fn open_path_clipping_offsets_and_end_tangents_stay_unchanged() { + let p = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0)]); + for flip in [false, true] { + let f = PathFrame::new(&p, 25.0, flip).unwrap(); + assert!(f.frame(-26.0).is_none()); + assert!(f.frame(-25.0).is_some()); + assert!(f.frame(75.0).is_some()); + assert!(f.frame(76.0).is_none()); + let q = f.apply(100.0, 0.0); + assert!((q.0 - if flip { -25.0 } else { 125.0 }).abs() < 1e-9 && q.1.abs() < 1e-9); + } +} + +#[test] +fn closed_path_alignment_uses_one_lap_from_the_text_left_edge() { + let p = ellipse(); + let length = PathFrame::new(&p, 0.0, false).unwrap().length(); + for left in [-length / 2.0, -length, 0.0] { + let shape = TextShape::Path { path: p.clone(), start: (length - 5.0) as f32, flip: false }; + let warp = Warp::on_path_with_text_start(&shape, left).unwrap(); + assert!(matches!(warp.place_glyph(left + 0.5), Placement::Frame(_))); + assert!(matches!(warp.place_glyph(left + length - 0.5), Placement::Frame(_))); + assert_eq!(warp.place_glyph(left - 0.5), Placement::Hidden); + assert_eq!(warp.place_glyph(left + length + 0.5), Placement::Hidden); + // The origin and tangent at each text coordinate are independent of clipping alignment. + let reference = Warp::on_path(&shape).unwrap(); + assert_eq!(warp.apply(left, 0.0), reference.apply(left, 0.0)); + } +} + +#[test] +fn closed_path_start_offsets_wrap_and_invalid_coordinates_are_hidden() { + let p = Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + for start in [-10.0, 0.0, 400.0, 790.0] { + let f = PathFrame::new(&p, start, false).unwrap(); + let reference = PathFrame::new(&p, start.rem_euclid(400.0), false).unwrap(); + assert_eq!(f.frame(20.0), reference.frame(20.0)); + assert!(f.frame(f64::NAN).is_none()); + assert!(f.frame(f64::INFINITY).is_none()); + } +} + +#[test] +fn caret_projection_roundtrips_closed_seams_and_open_offsets() { + let p = ellipse(); + let length = PathFrame::new(&p, 0.0, false).unwrap().length(); + for flip in [false, true] { + for left in [0.0, -length / 2.0, -length] { + let shape = TextShape::Path { path: p.clone(), start: (length - 20.0) as f32, flip }; + let w = Warp::on_path_with_text_start(&shape, left).unwrap(); + for x in [left + 0.5, left + 50.0, left + length / 2.0, left + length - 0.5] { + let point = w.apply(x, 0.0); + let actual = w.path_text_x(point.0, point.1).unwrap(); + assert!((actual - x).abs() < 1e-6, "caret must not jump to start after seam: {actual} vs {x}"); + } + } + let shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0)]), start: 25.0, flip }; + let w = Warp::on_path(&shape).unwrap(); + let x = w.path_text_x(40.0, 0.0).unwrap(); + assert!((x - if flip { 35.0 } else { 15.0 }).abs() < 1e-9); + } +} diff --git a/crates/text/src/engine_data.rs b/crates/text/src/engine_data.rs index 19f3af1886..4acd08ac50 100644 --- a/crates/text/src/engine_data.rs +++ b/crates/text/src/engine_data.rs @@ -11,6 +11,10 @@ pub enum Value { Dict(Vec<(String, Value)>), Array(Vec), String(String), + /// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte). + /// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop + /// rejects a `Txt2` whose raw strings come back as UTF-16 (#9091). + RawString(String), Int(i64), Real(f64), Bool(bool), @@ -67,7 +71,7 @@ impl Value { } pub fn as_str(&self) -> Option<&str> { match self { - Value::String(s) | Value::Name(s) => Some(s), + Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s), _ => None, } } @@ -173,7 +177,7 @@ impl Parser<'_> { } } } - Ok(Value::String(decode_string(&raw))) + Ok(decode_string(&raw)) } Some(b'/') => Ok(Value::Name(self.name())), Some(_) => { @@ -205,12 +209,12 @@ impl Parser<'_> { } } -fn decode_string(raw: &[u8]) -> String { +fn decode_string(raw: &[u8]) -> Value { if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { let u: Vec = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - String::from_utf16_lossy(&u) + Value::String(String::from_utf16_lossy(&u)) } else { - raw.iter().map(|&c| c as char).collect() + Value::RawString(raw.iter().map(|&c| c as char).collect()) } } @@ -293,7 +297,18 @@ fn write_value(v: &Value, depth: usize, out: &mut Vec) { } out.extend_from_slice(b" ]"); } - Value::String(s) => { + Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => { + out.push(b'('); + for c in s.chars() { + let c = u32::from(c) as u8; + if matches!(c, b'(' | b')' | b'\\') { + out.push(b'\\'); + } + out.push(c); + } + out.push(b')'); + } + Value::String(s) | Value::RawString(s) => { out.push(b'('); let mut raw = vec![0xFE, 0xFF]; for u in s.encode_utf16() { @@ -382,6 +397,17 @@ mod tests { assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1)); } + #[test] + fn raw_strings_are_written_back_as_the_same_bytes() { + let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap(); + assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into()))); + assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)")); + assert_eq!(v.get("3"), Some(&Value::String("h".into()))); + let bytes = write(&v); + assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap(), v); + } + #[test] fn write_parse_roundtrip() { let v = parse(SAMPLE).unwrap(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index d31b888fad..7c1ec8dc94 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -10,8 +10,8 @@ //! into text space: a line becomes a column running top to bottom, and successive columns advance //! right to left. Within a column, CJK characters stay upright (centred on the column, placed with //! the font's vertical metrics: `VORG`, `vhea`/`vmtx`, else the ideographic em box) and use the -//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark; other -//! text (Latin, digits) runs rotated 90° clockwise, Photoshop's default. Lines, clusters, carets +//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark. Roman +//! letters stand upright one per em unless the run sets `rotate_roman`. Lines, clusters, carets //! and hit tests stay in line space; [`TextLayout::to_text`] / [`TextLayout::to_line`] convert. use std::borrow::Cow; @@ -177,6 +177,17 @@ impl TextLayout { }) } + /// Whether boxed text doesn't all fit its shape: a box's layout drops the lines that + /// overflow (`layout` breaks out of the line loop), so any character the laid-out lines + /// don't cover — the last line's range ends short of the whole text — points at dropped + /// content. Text that fits completely covers its end: paragraph breaks are inside their + /// lines, and a trailing empty paragraph that fits gains its own (empty) line. Empty text + /// has nothing to drop. Callers gate this on the shape being a box; point text has no box + /// to overflow. + pub fn overflows(&self, text: &str) -> bool { + !text.is_empty() && self.lines.last().is_none_or(|ln| ln.range.end < text.len()) + } + /// Byte offset of the caret nearest to a text-space point. pub fn hit_test(&self, x: f32, y: f32) -> usize { let (x, y) = self.to_line(x, y); @@ -313,7 +324,7 @@ impl Layouter { if flow.stop { break; } - let ps = paras[para_starts.iter().rposition(|&s| s <= prange.start).unwrap_or(0)].style.clone(); + let ps = paras[run_index_at(¶_starts, prange.start)].style.clone(); self.layout_paragraph(fonts, &mut out, &mut flow, Para::new(&env, fonts, pi, prange, ps)); } out.styles = styles; diff --git a/crates/text/src/layout/lines.rs b/crates/text/src/layout/lines.rs index b8943ef609..74ee690780 100644 --- a/crates/text/src/layout/lines.rs +++ b/crates/text/src/layout/lines.rs @@ -303,8 +303,10 @@ impl Para<'_> { let mut pos = self.indent_start + if line_index == 0 { self.first_indent } else { 0.0 }; let mut trailing_tabs = 0.0f32; let mut trailing_growth = 0.0f32; + // Whether every slot after `j` is blank, from one reverse scan rather than one per slot. + let last_inked = slots.iter().rposition(|n| !n.blank); for (j, s) in slots.iter().enumerate() { - let rest_blank = slots[j + 1..].iter().all(|n| n.blank); + let rest_blank = last_inked.is_none_or(|k| k <= j); if s.blank && rest_blank { trailing_growth += s.growth; } diff --git a/crates/text/src/layout/shaping.rs b/crates/text/src/layout/shaping.rs index 283c7e2ef3..c7d701c5d7 100644 --- a/crates/text/src/layout/shaping.rs +++ b/crates/text/src/layout/shaping.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use std::cell::Cell; use std::ops::Range; -use parley::{Alignment, AlignmentOptions, FontFeatures, Layout, LayoutContext, StyleProperty}; +use parley::{Alignment, AlignmentOptions, FontFeatures, Layout, LayoutContext, PositionedLayoutItem, StyleProperty}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextAlign, TextDirection}; use super::area::{LineMetrics, MAX_AREA_PASSES, area_metrics, break_area}; @@ -161,7 +161,10 @@ impl VerticalPiece<'_> { fn push_all(&self, b: &mut parley::RangedBuilder<'_, RunBrush>, piece: &str) { let mut seg: Option<(usize, VClass)> = None; for (i, ch) in piece.char_indices() { - let cls = vertical_class(ch); + let cls = match vertical_class(ch) { + VClass::Rotate if !self.style.rotate_roman && upright_roman(ch) => VClass::Upright, + cls => cls, + }; match seg { Some((_, c)) if c == cls => {} Some((from, c)) => { @@ -193,6 +196,7 @@ pub(super) struct Para<'a> { /// Runs intersecting the paragraph: `si0..si_end`. si0: usize, si_end: usize, + roman: Vec<(Range, f32, f32, usize)>, fallback: Vec, pub(super) indent_start: f32, indent_end: f32, @@ -242,6 +246,7 @@ impl<'a> Para<'a> { tabs, si0, si_end, + roman: Vec::new(), fallback, indent_start, indent_end, @@ -253,9 +258,33 @@ impl<'a> Para<'a> { area_budget: Cell::new(MAX_AREA_PASSES), }; para.break_scale = para.horizontal_break_scale(); + para.roman = para.roman_ranges(); para } + fn roman_ranges(&self) -> Vec<(Range, f32, f32, usize)> { + let env = self.env; + let mut roman = Vec::new(); + if env.vertical { + for (ri, st) in env.styles.iter().enumerate().take(self.si_end).skip(self.si0) { + let rs = env.run_starts[ri]; + let (a, z) = (rs.max(self.start), (rs + env.runs[ri].len).min(self.content_end)); + if a >= z || st.rotate_roman { + continue; + } + let base = a - self.start + self.prefix.len(); + let piece = self.ptext.get(base..base + (z - a)).unwrap_or(""); + let px = st.size_pt * env.k; + for (i, ch) in piece.char_indices() { + if upright_roman(ch) && !ch.is_whitespace() { + roman.push(((base + i)..(base + i + ch.len_utf8()), st.tracking / 1000.0 * px, px, ri)); + } + } + } + } + roman + } + pub(super) fn first_style(&self) -> &'a CharStyle { &self.env.styles[self.si0] } @@ -290,7 +319,7 @@ impl<'a> Para<'a> { } /// Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px). - fn shape(&self, lcx: &mut LayoutContext, fcx: &mut parley::FontContext, tab_spacing: &[f32]) -> Layout { + fn shape(&self, lcx: &mut LayoutContext, fcx: &mut parley::FontContext, tab_spacing: &[f32], roman_spacing: &[f32]) -> Layout { let env = self.env; let mut b = lcx.ranged_builder(fcx, &self.ptext, 1.0, false); // Paragraph-start style as the default (covers the direction mark and empty @@ -318,6 +347,12 @@ impl<'a> Para<'a> { for (&at, &spacing) in self.tabs.iter().zip(tab_spacing) { b.push(StyleProperty::LetterSpacing(spacing), at..at + 1); } + for ((range, _, _, ri), &spacing) in self.roman.iter().zip(roman_spacing) { + b.push(StyleProperty::LetterSpacing(spacing), range.clone()); + let mut feats = feature_list(&env.styles[*ri], env.small_caps[*ri]); + feats.extend(["kern", "liga", "clig", "dlig"].map(|f| format!("\"{f}\" 0"))); + b.push(StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), range.clone()); + } b.build(&self.ptext) } @@ -383,11 +418,18 @@ impl<'a> Para<'a> { /// that layout gives until they agree; if they don't settle, keep the safe layout. pub(super) fn break_paragraph(&self, lcx: &mut LayoutContext, fcx: &mut parley::FontContext, flow: &Flow) -> (Layout, Vec, bool) { let wide = if self.env.wraps { self.tab_px } else { 0.0 }; - let mut layout = self.shape(lcx, fcx, &vec![wide; self.tabs.len()]); + let mut roman_spacing: Vec = self.roman.iter().map(|r| r.1).collect(); + let mut layout = self.shape(lcx, fcx, &vec![wide; self.tabs.len()], &roman_spacing); + if !self.roman.is_empty() { + let _ = self.break_lines(&mut layout, flow); + let advances = cluster_advances(&layout, &self.roman); + roman_spacing = self.roman.iter().zip(&advances).map(|((_, track, em, _), a)| a.map_or(*track, |a| em + track - (a - track))).collect(); + layout = self.shape(lcx, fcx, &vec![wide; self.tabs.len()], &roman_spacing); + } let (mut slots, mut full) = self.break_lines(&mut layout, flow); if self.env.wraps && !self.tabs.is_empty() - && let Some((settled, settled_slots, settled_full)) = self.settle_tabs(lcx, fcx, &layout, flow) + && let Some((settled, settled_slots, settled_full)) = self.settle_tabs(lcx, fcx, &layout, flow, &roman_spacing) { layout = settled; slots = settled_slots; @@ -403,12 +445,13 @@ impl<'a> Para<'a> { fcx: &mut parley::FontContext, layout: &Layout, flow: &Flow, + roman_spacing: &[f32], ) -> Option<(Layout, Vec, bool)> { let first = self.tab_widths(layout); let mut want: Vec = first.iter().map(|t| t.width).collect(); for _ in 0..4 { let spacing: Vec = first.iter().zip(&want).map(|(t, w)| w / t.scale - (t.advance - self.tab_px)).collect(); - let mut next = self.shape(lcx, fcx, &spacing); + let mut next = self.shape(lcx, fcx, &spacing, roman_spacing); let (next_slots, next_full) = self.break_lines(&mut next, flow); let got: Vec = self.tab_widths(&next).iter().map(|t| t.width).collect(); if got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 0.01) { @@ -436,3 +479,28 @@ impl<'a> Para<'a> { AlignmentOptions { align_when_overflowing: !self.env.wraps } } } + +/// Whether vertical type sets `c` upright when its style doesn't rotate roman text (#9470): the +/// rotating class except half-width katakana and Hangul, which keep their rotated setting. +fn upright_roman(c: char) -> bool { + vertical_class(c) == VClass::Rotate && !(0xFF61..=0xFFDC).contains(&(c as u32)) +} + +/// The shaped advance (px, letter spacing included) of the cluster starting each of `ranges` +/// (sorted byte ranges of the shaped text), `None` where no cluster starts there. +fn cluster_advances(layout: &Layout, ranges: &[(Range, f32, f32, usize)]) -> Vec> { + let mut out = vec![None; ranges.len()]; + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + for c in gr.run().visual_clusters() { + if let Ok(i) = ranges.binary_search_by_key(&c.text_range().start, |r| r.0.start) { + out[i] = Some(c.advance()); + } + } + } + } + out +} diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 7c51759482..16d63c68b8 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -218,6 +218,25 @@ mod nonfinite_tests { assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, 24.0); } + /// #9470: `BaselineDirection` sets vertical type's roman text upright (1, what Photoshop's + /// Vertical Type tool writes in the run) or rotated (2); a run without one takes the style + /// sheet's (Photoshop's default sheet says 2). It is written back. + #[test] + fn baseline_direction_round_trips() { + let sheet = parse(b"<< /BaselineDirection 2 >>").unwrap(); + let upright = parse(b"<< /BaselineDirection 1 >>").unwrap(); + assert!(!char_style(&[Some(&sheet)], &upright, &[], 1.0).rotate_roman, "the run's own value wins"); + let plain = parse(b"<< /FontSize 24.0 >>").unwrap(); + assert!(char_style(&[Some(&sheet)], &plain, &[], 1.0).rotate_roman, "else the sheet's"); + assert!(!char_style(&[], &plain, &[], 1.0).rotate_roman, "none at all: upright"); + for rotate in [false, true] { + let s = CharStyle { rotate_roman: rotate, ..Default::default() }; + let data = style_sheet_data(&s, 0, 1.0); + assert_eq!(data.get("BaselineDirection").and_then(E::as_i64), Some(if rotate { 2 } else { 1 })); + assert_eq!(char_style(&[Some(&sheet)], &data, &[], 1.0).rotate_roman, rotate); + } + } + /// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back. #[test] fn font_baseline_round_trips() { diff --git a/crates/text/src/psd/build.rs b/crates/text/src/psd/build.rs index e4097c7de8..755411cd38 100644 --- a/crates/text/src/psd/build.rs +++ b/crates/text/src/psd/build.rs @@ -54,6 +54,7 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E { FontBaseline::Subscript => 2, }), ), + ("BaselineDirection".into(), E::Int(if s.rotate_roman { 2 } else { 1 })), ("Underline".into(), E::Bool(s.underline)), ("Strikethrough".into(), E::Bool(s.strikethrough)), ("Ligatures".into(), E::Bool(s.ligatures)), @@ -98,24 +99,45 @@ pub(crate) fn font_entry(name: &str) -> E { } /// Keep imported font metadata while retaining the requested font indices. New fonts use -/// the normal defaults; same-name duplicates retain their own positional records. +/// the normal defaults; duplicate records are consumed once before reusing the first match. pub(crate) fn font_set(names: &[String], original: Option<&E>) -> E { let entries = original.and_then(E::as_array).unwrap_or_default(); - // Resolve fallback names once, retaining the first match just as the original scan did. - // Missing names and reordered resource dictionaries must not rescan all entries per font. - let mut first_by_name = std::collections::HashMap::new(); - for entry in entries { + // Keep a queue per name so differently ordered ResourceDict/DocumentResources do not + // collapse distinct duplicate metadata. Each index leaves its queue at most once. + let mut by_name = std::collections::HashMap::new(); + for (i, entry) in entries.iter().enumerate() { if let Some(name) = entry.get("Name").and_then(E::as_str) { - first_by_name.entry(name).or_insert(entry); + let (_, available) = by_name.entry(name).or_insert_with(|| (i, std::collections::VecDeque::new())); + available.push_back(i); } } + let mut used = vec![false; entries.len()]; E::Array( names .iter() .enumerate() .map(|(i, name)| { let matches = |entry: &&E| entry.get("Name").and_then(E::as_str) == Some(name.as_str()); - entries.get(i).filter(matches).or_else(|| first_by_name.get(name.as_str()).copied()).cloned().unwrap_or_else(|| font_entry(name)) + let positional = entries.get(i).filter(matches).filter(|_| used.get(i) == Some(&false)).map(|_| i); + let selected = positional.or_else(|| { + let (_, available) = by_name.get_mut(name.as_str())?; + while let Some(index) = available.pop_front() { + if used.get(index) == Some(&false) { + return Some(index); + } + } + None + }); + if let Some(index) = selected + && let Some(used) = used.get_mut(index) + { + *used = true; + } + selected + .or_else(|| by_name.get(name.as_str()).map(|(first, _)| *first)) + .and_then(|index| entries.get(index)) + .cloned() + .unwrap_or_else(|| font_entry(name)) }) .collect(), ) @@ -155,19 +177,9 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> { out } -/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, -/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set -/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which -/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking -/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode -/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit -/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is -/// larger than a template may be (it is copied once per run). -fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Option> { - if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { - return None; - } - let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; +/// The engine text of `layer` (`\r` breaks and the trailing `\r`) as `(character run, UTF-16 +/// length)` spans: one per run that has text. +fn char_spans(layer: &TextLayer) -> Vec<(usize, i64)> { let runs = layer.char_runs(); let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); let mut spans: Vec<(usize, i64)> = Vec::new(); @@ -183,12 +195,30 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> _ => spans.push((ri, n)), } } + spans +} + +/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs, +/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set +/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which +/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking +/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode +/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit +/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is +/// larger than a template may be (it is copied once per run). +fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32, seed_empty: bool) -> Option> { + if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let spans = char_spans(layer); let default = CharStyle::default(); let mut out = Vec::with_capacity(spans.len()); let mut looked_up = std::collections::HashMap::new(); for (ri, n) in spans { let s = runs.get(ri).map_or(&default, |r| &r.style); - let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name)); + let font = *looked_up.entry(postscript_for(s)).or_insert_with_key(|name| txt2_font(fonts, name, seed_empty)); let mut run = base.clone(); let style = run.get_mut("0")?.get_mut("0")?.get_mut("6")?; if let Some(font) = font { @@ -220,14 +250,307 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec, dpi: f32) -> Some(out) } +/// Whether the style runs of a `Txt2` text model (`/6 /0`) agree with `layer`'s character runs +/// on every key [`txt2_style_runs`] writes, the auto-kern mode aside ([`with_modes`] refits that +/// on its own): font (by name, through `fonts`, the block's font list), size, faux bold and +/// italic, leading, scales, tracking, fill colour and the OpenType keys. A key the file's run +/// lacks, or holds as another type, is no evidence of an edit and is skipped, so an unedited +/// save still gets the file's block back byte for byte (#2374); a key that disagrees means the +/// layer's style was edited since the file was written (#8415). +fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bool { + let Some(file_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return true }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + style_runs_match(layer, file_runs, "1", &["0", "0", "6"], |s, style| txt2_style_agrees(s, style, fonts, k)) +} + +/// [`txt2_style_matches`] for the older Photoshop's object at `/DocumentObjects /TextObjects` +/// (`entry`): its `Model /StyleRun /RunArray` runs hold the style under `RunData /StyleSheet +/// /Features`, named keys, with the run length under `Length`. `fonts` is the block's +/// `DocumentResources /FontSet /Resources`. +fn td_style_matches(layer: &TextLayer, entry: &E, fonts: &[E], dpi: f32) -> bool { + let Some(file_runs) = entry.path(&["Model", "StyleRun", "RunArray"]).and_then(E::as_array) else { return true }; + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + style_runs_match(layer, file_runs, "Length", &["RunData", "StyleSheet", "Features"], |s, style| td_style_agrees(s, style, fonts, k)) +} + +/// Whether `file_runs` (a run array whose runs hold their length under `len_key` and their style +/// at `style_path`) agree, run for run over the layer's engine text, with the layer's character +/// runs by `agrees`. A run without a style dictionary says nothing. +fn style_runs_match(layer: &TextLayer, file_runs: &[E], len_key: &str, style_path: &[&str], agrees: impl Fn(&CharStyle, &E) -> bool) -> bool { + let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get(len_key)).and_then(E::as_i64).unwrap_or(0); + let runs = layer.char_runs(); + let default = CharStyle::default(); + let ed_text = format!("{}\r", layer.text.replace('\n', "\r")); + let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len)); + let (mut fi, mut left) = (0usize, run_len(0)); + // Each (layer run, file run) pair is compared once, however long it is. + let mut compared = None; + for (b, ch) in ed_text.char_indices() { + while b >= run_end && ri + 1 < runs.len() { + ri += 1; + run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len)); + } + while left <= 0 && fi + 1 < file_runs.len() { + fi += 1; + left = run_len(fi); + } + if compared != Some((ri, fi)) { + let s = runs.get(ri).map_or(&default, |r| &r.style); + if let Some(style) = file_runs.get(fi).and_then(|r| r.path(style_path)) + && !agrees(s, style) + { + return false; + } + compared = Some((ri, fi)); + } + left = left.saturating_sub(ch.len_utf16() as i64); + } + true +} + +/// Whether one `Txt2` style dictionary agrees with `s` (see [`txt2_style_matches`]); `k` is the +/// document's points per PostScript point, as in [`txt2_style_runs`]. +fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want))); + let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want); + let font = style.get("0").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)).and_then(|f| f.path(&["0", "0", "0"])); + if font.and_then(E::as_str).is_some_and(|name| name != postscript_for(s)) { + return false; + } + let auto_leading = s.leading_pt.is_none(); + if !(number("1", s.size_pt * k) && flag("2", s.faux_bold) && flag("3", s.faux_italic) && flag("4", auto_leading)) { + return false; + } + // An automatic leading's value is Photoshop's own; only a set one is ours to compare. + if style.get("4").and_then(E::as_bool) == Some(false) && !auto_leading && !number("5", s.leading_pt.unwrap_or(0.0) * k) { + return false; + } + if !(number("6", s.horizontal_scale) && number("7", s.vertical_scale) && number("8", s.tracking.round())) { + return false; + } + if let Some(paint) = style.get("53").and_then(|p| p.get("0")) { + let (kind, values) = fill_color(&s.color); + if paint.get("0").and_then(E::as_i64).is_some_and(|v| v != kind) { + return false; + } + if let Some(have) = paint.get("1").and_then(E::as_array) { + let same = + have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b))); + if !same { + return false; + } + } + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + if !TXT2_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) { + return false; + } + // A mask beyond the 20 sets is ignored on import (`add_txt2_features`), so here too. + // Missing or invalid key 85 counts as 0 (the reader's default), not "any mask agrees". + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).unwrap_or(0) == sets +} + +/// EngineData's `FontCaps` code of a style: 0 normal, 1 small caps, 2 all caps. +fn caps_code(s: &CharStyle) -> i64 { + match s.caps { + Caps::Normal => 0, + Caps::SmallCaps => 1, + Caps::AllCaps => 2, + } +} + +/// EngineData's `FontBaseline` code of a style: 0 normal, 1 superscript, 2 subscript. +fn baseline_code(s: &CharStyle) -> i64 { + match s.baseline { + FontBaseline::Normal => 0, + FontBaseline::Superscript => 1, + FontBaseline::Subscript => 2, + } +} + +/// The OpenType flags of a legacy style (`Features`), by key, and the feature each turns on: the +/// named keys of [`TXT2_FEATURES`]. +const TD_FEATURES: [(&str, &str); 7] = [ + ("ContextualLigatures", "calt"), + ("Fractions", "frac"), + ("Ordinals", "ordn"), + ("Swash", "swsh"), + ("Titling", "titl"), + ("StylisticAlternates", "salt"), + ("Ornaments", "ornm"), +]; + +/// The legacy key of the Stylistic Sets bit mask, as [`TXT2_STYLISTIC_SETS`] is for `Txt2`'s own. +const TD_STYLISTIC_SETS: &str = "StylisticSets"; + +/// The PostScript name of font `index` in a legacy font list (`DocumentResources /FontSet +/// /Resources`: `<< /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (name) /Type n >> >> >>`). +fn td_font_name(fonts: &[E], index: i64) -> Option<&str> { + fonts.get(usize::try_from(index).ok()?)?.path(&["Resource", "Identifier", "Name"])?.as_str() +} + +/// Whether one legacy `Features` dictionary agrees with `s`: [`txt2_style_agrees`] for the named +/// keys. A key the file's run lacks, or holds as another type, is no evidence of an edit. +fn td_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool { + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want))); + let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want); + if style.get("Font").and_then(E::as_i64).and_then(|i| td_font_name(fonts, i)).is_some_and(|name| name != postscript_for(s)) { + return false; + } + let auto_leading = s.leading_pt.is_none(); + if !(number("FontSize", s.size_pt * k) && flag("FauxBold", s.faux_bold) && flag("FauxItalic", s.faux_italic) && flag("AutoLeading", auto_leading)) { + return false; + } + if style.get("AutoLeading").and_then(E::as_bool) == Some(false) && !auto_leading && !number("Leading", s.leading_pt.unwrap_or(0.0) * k) { + return false; + } + if !(number("HorizontalScale", s.horizontal_scale) && number("VerticalScale", s.vertical_scale) && number("Tracking", s.tracking.round())) { + return false; + } + // Per run, like the rest: a baseline shift, the case and baseline position, the underline and + // strikethrough (a non-zero position is on, whichever position the file chose), the ligatures + // and the auto-kern mode. + let whole = |key: &str, want: i64| style.get(key).and_then(E::as_i64).is_none_or(|v| v == want); + let on = |key: &str, want: bool| style.get(key).and_then(E::as_i64).is_none_or(|v| (v != 0) == want); + if !(number("BaselineShift", s.baseline_shift_pt * k) + && whole("FontCaps", caps_code(s)) + && whole("FontBaseline", baseline_code(s)) + && on("UnderlinePosition", s.underline) + && on("StrikethroughPosition", s.strikethrough) + && flag("Ligatures", s.ligatures) + && flag("DiscretionaryLigatures", s.discretionary_ligatures) + && whole("AutoKern", txt2_mode(s))) + { + return false; + } + if let Some(paint) = style.get("FillColor").and_then(|p| p.get("Color")) { + let (kind, values) = fill_color(&s.color); + if paint.get("Type").and_then(E::as_i64).is_some_and(|v| v != kind) { + return false; + } + if let Some(have) = paint.get("Values").and_then(E::as_array) { + let same = + have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b))); + if !same { + return false; + } + } + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + if !TD_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) { + return false; + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + style.get(TD_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets) +} + +/// The index of the PostScript font `name` in a legacy font list, appending it (shaped like the +/// list's first entry, with its font type) when missing. `None` for an empty list. +fn td_font(fonts: &mut Vec, name: &str) -> Option { + if let Some(i) = fonts.iter().position(|f| f.path(&["Resource", "Identifier", "Name"]).and_then(E::as_str) == Some(name)) { + return i64::try_from(i).ok(); + } + let mut entry = fonts.first()?.clone(); + entry.get_mut("Resource")?.get_mut("Identifier")?.set("Name", E::String(name.to_string())); + fonts.push(entry); + i64::try_from(fonts.len() - 1).ok() +} + +/// `entry` (an object at `/DocumentObjects /TextObjects`) with its `Model /StyleRun` runs remade +/// from the layer's character runs: the entry's first style run with its `Features` set from each +/// run's own, every other key Photoshop wrote kept, as [`txt2_style_runs`] does for the numbered +/// objects. Photoshop re-renders a type layer from this copy when the file has one, so a +/// style-only edit left here showed the old style on reopen. `None` when the first run has no +/// `Features` dictionary, or is larger than a template may be: the entry is then left as the file +/// has it. +fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec, dpi: f32) -> Option { + let base = entry.path(&["Model", "StyleRun", "RunArray"])?.as_array()?.first()?; + if !matches!(base.path(&["RunData", "StyleSheet", "Features"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) { + return None; + } + let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 }; + let runs = layer.char_runs(); + let default = CharStyle::default(); + let mut out = Vec::new(); + for (ri, n) in char_spans(layer) { + let s = runs.get(ri).map_or(&default, |r| &r.style); + let mut run = base.clone(); + let features = run.get_mut("RunData")?.get_mut("StyleSheet")?.get_mut("Features")?; + if let Some(font) = td_font(fonts, &postscript_for(s)) { + features.set("Font", E::Int(font)); + } + features.set("FontSize", real(s.size_pt * k)); + features.set("FauxBold", E::Bool(s.faux_bold)); + features.set("FauxItalic", E::Bool(s.faux_italic)); + features.set("AutoLeading", E::Bool(s.leading_pt.is_none())); + // An automatic leading's value is Photoshop's own (`.01` in the files seen): kept. + if let Some(leading) = s.leading_pt { + features.set("Leading", real(leading * k)); + } + features.set("HorizontalScale", real(s.horizontal_scale)); + features.set("VerticalScale", real(s.vertical_scale)); + features.set("Tracking", E::Int(s.tracking.round() as i64)); + features.set("AutoKern", E::Int(txt2_mode(s))); + features.set("BaselineShift", real(s.baseline_shift_pt * k)); + features.set("FontCaps", E::Int(caps_code(s))); + features.set("FontBaseline", E::Int(baseline_code(s))); + // On keeps the position the file chose (a non-zero value), else the first one. + for (key, on) in [("UnderlinePosition", s.underline), ("StrikethroughPosition", s.strikethrough)] { + let kept = features.get(key).and_then(E::as_i64).filter(|v| *v != 0).unwrap_or(1); + features.set(key, E::Int(if on { kept } else { 0 })); + } + features.set("Ligatures", E::Bool(s.ligatures)); + features.set("DiscretionaryLigatures", E::Bool(s.discretionary_ligatures)); + if let Some(paint @ E::Dict(_)) = features.get_mut("FillColor").and_then(|p| p.get_mut("Color")) { + let (kind, values) = fill_color(&s.color); + paint.set("Type", E::Int(kind)); + paint.set("Values", E::Array(values)); + } + let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0); + for (key, tag) in TD_FEATURES { + features.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt"))); + } + let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n); + features.set(TD_STYLISTIC_SETS, E::Int(sets)); + run.set("Length", E::Int(n)); + out.push(run); + } + let mut entry = entry.clone(); + entry.get_mut("Model")?.get_mut("StyleRun")?.set("RunArray", E::Array(out)); + Some(entry) +} + +/// A `Txt2` font-list entry (`<< /0 << /99 /CoolTypeFont /0 << /0 name /2 type >> >> >>`). +fn txt2_font_entry(name: &str, kind: i64) -> E { + E::Dict(vec![( + "0".into(), + E::Dict(vec![ + ("99".into(), E::Name("CoolTypeFont".into())), + ("0".into(), E::Dict(vec![("0".into(), E::String(name.to_string())), ("2".into(), E::Int(kind))])), + ]), + )]) +} + /// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont /// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry) -/// when missing. `None` for an empty list: there is no entry to shape a new one on. -fn txt2_font(fonts: &mut Vec, name: &str) -> Option { +/// when missing. An empty list gets a seeded CoolTypeFont entry only when `seed_empty` is set — +/// a PhotoCraft-native block (#10308); a file's own block keeps its `/0` as it was (#2374), so +/// `None` there, as before. +fn txt2_font(fonts: &mut Vec, name: &str, seed_empty: bool) -> Option { let named = |f: &E| f.path(&["0", "0", "0"]).and_then(E::as_str) == Some(name); if let Some(i) = fonts.iter().position(named) { return i64::try_from(i).ok(); } + if fonts.is_empty() { + if !seed_empty { + return None; + } + fonts.push(txt2_font_entry(name, 0)); + return Some(0); + } let mut entry = fonts.first()?.clone(); let font = entry.get_mut("0")?.get_mut("0")?; let kind = font.get("2").cloned(); @@ -244,13 +567,69 @@ fn txt2_font(fonts: &mut Vec, name: &str) -> Option { /// `i32::MAX`, and sizing the array by it would make every save allocate gigabytes. pub const MAX_TEXT_INDEX: i32 = 0xFFFF; -/// Whether the document `Txt2` block is needed for `objects`: only a non-Metrics auto-kern mode +/// Whether `style` carries an OpenType setting that lives only in `Txt2` (EngineData has no field +/// for stylistic sets or the [`TXT2_FEATURES`] flags). Defaults that match a plain style do not +/// count: Contextual Alternates on (or absent), and the other flags off. +fn txt2_only_opentype(style: &CharStyle) -> bool { + let on = |tag: &str| style.features.iter().any(|f| f.tag == tag && f.value > 0); + let off = |tag: &str| style.features.iter().any(|f| f.tag == tag && f.value == 0); + if (1..=20).any(|n| on(&format!("ss{n:02}"))) { + return true; + } + for (_, tag) in TXT2_FEATURES { + if tag == "calt" { + if off("calt") { + return true; + } + } else if on(tag) { + return true; + } + } + false +} + +/// Whether the document `Txt2` block is needed for `objects`: a non-Metrics auto-kern mode /// (`Optical`) is exclusive to `Txt2` — EngineData's `AutoKerning` cannot distinguish Metrics from -/// Optical — so a fresh document of ordinary Metrics text has nothing to put in the block. -/// Writing Photoshop's empty `<< /0 << >> >>` stub anyway makes some readers (Photopea) fail -/// (#3514); the export omits the block for such a document. +/// Optical — and so are Stylistic Sets and the other [`TXT2_FEATURES`] flags (#10308). A fresh +/// document of ordinary Metrics text with no such OpenType settings has nothing to put in the +/// block. Writing Photoshop's empty `<< /0 << >> >>` stub anyway makes some readers (Photopea) +/// fail (#3514); the export omits the block for such a document. pub fn needs_txt2(objects: &[(i32, &TextLayer)]) -> bool { - objects.iter().any(|(_, l)| l.char_runs().iter().any(|r| r.style.kerning == Kerning::Optical)) + objects.iter().any(|(_, l)| l.char_runs().iter().any(|r| r.style.kerning == Kerning::Optical || txt2_only_opentype(&r.style))) +} + +/// A `/53` SimplePaint dictionary (`<< /99 /SimplePaint /0 << >> >>`) for a fresh style run. +fn simple_paint_dict() -> E { + E::Dict(vec![("99".into(), E::Name("SimplePaint".into())), ("0".into(), E::dict())]) +} + +/// A minimal `Txt2` style run Photoshop will read: the shape [`txt2_style_runs`] fills from a +/// layer when the file has no template object to copy (a PhotoCraft-native document). Includes an +/// empty `/53` SimplePaint dict so the fill colour can be written. +fn minimal_txt2_style_run() -> E { + let style = E::Dict(vec![("0".into(), E::Int(0)), ("11".into(), E::Int(1)), ("53".into(), simple_paint_dict())]); + let data = E::Dict(vec![("0".into(), E::String(String::new())), ("6".into(), style)]); + E::Dict(vec![("0".into(), E::Dict(vec![("0".into(), data)])), ("1".into(), E::Int(1))]) +} + +/// A point-text object with a view naming frame `frame` and empty style runs, for a layer the +/// file's block has no object for and no template to copy (#10308, #7698). Style runs are filled +/// by [`txt2_style_runs`] next. +fn fresh_point_text_object(frame: usize, text: &str) -> E { + let frame = i64::try_from(frame).unwrap_or(0); + let view = E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(frame))])]))]); + // One paragraph run per `\r`-terminated paragraph (lengths in UTF-16 code units), as + // `edited_object` refits them. + let paragraphs = text + .split_inclusive('\r') + .map(|p| E::Dict(vec![("0".into(), E::dict()), ("1".into(), E::Int(i64::try_from(p.encode_utf16().count()).unwrap_or(i64::MAX)))])) + .collect(); + let model = E::Dict(vec![ + ("0".into(), E::String(text.to_string())), + ("5".into(), E::Dict(vec![("0".into(), E::Array(paragraphs))])), + ("6".into(), E::Dict(vec![("0".into(), E::Array(vec![]))])), + ]); + E::Dict(vec![("0".into(), model), ("1".into(), view)]) } /// The document's `Txt2` block for `objects`: `(TextIndex, layer)` pairs, one text object each @@ -290,8 +669,17 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // in place, never grown: a layer the block has no entry for has no stale copy to fix. let mut td: Option> = prev.as_ref().and_then(|p| p.path(&["DocumentObjects", "TextObjects"])).and_then(E::as_array).map(<[E]>::to_vec); let mut td_changed = false; + // The legacy block's font list (`DocumentResources /FontSet /Resources`), which its style runs index. + let mut td_fonts: Vec = + prev.as_ref().and_then(|p| p.path(&["DocumentResources", "FontSet", "Resources"])).and_then(E::as_array).map(<[E]>::to_vec).unwrap_or_default(); + let td_fonts_before = td_fonts.len(); let mut fonts: Vec = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default(); let fonts_before = fonts.len(); + // A PhotoCraft-native block: the document had no `Txt2` of its own (#10308). Only such a + // block gets the fresh point-text objects, minimal style runs, seeded font list and frame + // list below; a file's own block (Photoshop's, any era) is written as before (#2374, #7698). + let native = prev.is_none(); + let seed_empty = native; // Looked up once per save, not per layer: a crafted block with many objects must not cost // a pass over all of them for every type layer. let all_stubs = prev_objects.iter().all(is_stub); @@ -332,12 +720,27 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi *slot = fixed; td_changed = true; } + // And its character style, when it no longer says what the layer says: a style-only + // edit (font, size, faux bold, …) left the old style here, and Photoshop reads it back. + if let Some(entry) = entries.get_mut(at) + && !td_style_matches(layer, entry, &td_fonts, dpi) + && let Some(restyled) = td_restyled(entry, layer, &mut td_fonts, dpi) + { + *entry = restyled; + td_changed = true; + } } // A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so // it is replaced like a missing one (below) and re-saving such a file repairs it, whether // or not the layer's text changed. let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs); - let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str())); + // Kept only while its style runs still say what the layer says (#8415): Photoshop reads + // the character style from them, so a style-only edit (font, faux bold, size, …) must + // restyle the object the way an edited one is, or the file's old style shows on reopen. + let kept = slot.filter(|o| { + let model = o.get("0"); + model.and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()) && model.is_some_and(|m| txt2_style_matches(layer, m, &fonts, dpi)) + }); // An edited object (#6968): the file's object at this slot with its text changed. It keeps // the file's structure — paragraph runs, the view naming its frame, the rest — with every // run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in @@ -350,11 +753,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // can't be refitted keeps the frame that object was drawn in. let fresh = (kept.is_none() && edited.is_none()) .then(|| { - let template = template?; + // Without a template, only a PhotoCraft-native block gets a fresh object; a file's + // block keeps the stub it always got. + if template.is_none() && !native { + return None; + } let own = slot.and_then(|o| object_frame(o, true)).filter(|f| frame_count.is_some_and(|n| *f < n)); let frame = match own { Some(f) => f, - None if frame_count.is_some() && taken.contains(&at) => { + None if (frame_count.is_some() || template.is_none()) && taken.contains(&at) => { while taken.contains(&next_free) { next_free += 1; } @@ -362,7 +769,12 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi } None => at, }; - let object = new_object(template, frame, &text)?; + // Prefer a file object as template (#7698); otherwise a point-text object with a + // view naming its own frame so a PhotoCraft-native Txt2 still opens (#10308). + let object = match template { + Some(template) => new_object(template, frame, &text)?, + None => fresh_point_text_object(frame, &text), + }; taken.insert(frame); frames_needed = frames_needed.max(frame.saturating_add(1)); Some(object) @@ -374,11 +786,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict); // A new or edited object takes the layer's own style: Photoshop reads the character style // from these runs, so the template's (or the old text's) would show, and come back on - // reopen. They are built on one of the object's own runs, every key Photoshop wrote kept. - let styled = (!unchanged && !is_stub(&object)) - .then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned()) - .flatten() - .and_then(|base| txt2_style_runs(layer, &base, &mut fonts, dpi)); + // reopen. They are built on one of the object's own runs, every key Photoshop wrote kept; + // without a template run, a minimal base is filled the same way (#10308). + let styled = (!unchanged) + .then(|| { + let file_base = (!is_stub(&object)).then(|| model.path(&["6", "0"]).and_then(E::as_array).and_then(<[E]>::first).cloned()).flatten(); + let base = file_base.or_else(|| native.then(minimal_txt2_style_run))?; + txt2_style_runs(layer, &base, &mut fonts, dpi, seed_empty) + }) + .flatten(); let file_runs = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes)); let runs = styled.or(file_runs).unwrap_or_else(|| { modes @@ -408,8 +824,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi } } // The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and - // `/1` whatever it holds beside the text objects. - let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); + // `/1` whatever it holds beside the text objects. A PhotoCraft-native block gets a frame list + // (`/0 /8 /0`) so each object's view can name a frame (#10308, #7698); a file's block is never + // given a `/0` or a frame list it didn't have (#2374). + let mut outer = prev.clone().unwrap_or_else(|| { + E::Dict(vec![ + ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), + ("0".into(), E::Dict(vec![("8".into(), E::Dict(vec![("0".into(), E::Array(vec![]))]))])), + ]) + }); pad_frames(&mut outer, slots.len().max(frames_needed)); // A path layer's frame holds its path; a layer taken off its path loses its path frame. An // unchanged path leaves the file's frame as it was, and so does a frame the file already had @@ -428,10 +851,18 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi } } } + // Write the font list whenever it grew. Create `/0/1` only in a PhotoCraft-native block; + // otherwise leave the file's `/0` alone (#2374). if fonts.len() != fonts_before - && let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1")) + && let Some(doc @ E::Dict(_)) = outer.get_mut("0") { - list.set("0", E::Array(fonts)); + match doc.get_mut("1") { + Some(list @ E::Dict(_)) => list.set("0", E::Array(fonts)), + _ if seed_empty => { + doc.set("1", E::Dict(vec![("0".into(), E::Array(fonts))])); + } + _ => {} + } } match outer.get_mut("1") { Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)), @@ -444,6 +875,11 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi && let Some(objects @ E::Dict(_)) = outer.get_mut("DocumentObjects") { objects.set("TextObjects", E::Array(entries)); + if td_fonts.len() != td_fonts_before + && let Some(set @ E::Dict(_)) = outer.get_mut("DocumentResources").and_then(|r| r.get_mut("FontSet")) + { + set.set("Resources", E::Array(td_fonts)); + } } // Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers). if let (Some(bytes), Some(p)) = (previous, &prev) diff --git a/crates/text/src/psd/edit.rs b/crates/text/src/psd/edit.rs index 1b1b90e0ff..ba08ab1e79 100644 --- a/crates/text/src/psd/edit.rs +++ b/crates/text/src/psd/edit.rs @@ -36,7 +36,7 @@ fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool { fits_budget(x, nodes, bytes) }), E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)), - E::String(s) | E::Name(s) => { + E::String(s) | E::RawString(s) | E::Name(s) => { let Some(rem) = bytes.checked_sub(s.len()) else { return false }; *bytes = rem; true @@ -150,7 +150,7 @@ pub(super) fn td_edited_object(object: &E, text: &str) -> Option { let E::Dict(entries) = model else { return None }; // The text must be there to replace: refitted runs over a text the model doesn't hold would // be a half-edited object. - if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_))) { + if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_) | E::RawString(_))) { return None; } let total = utf16_len(text) as usize; diff --git a/crates/text/src/psd/import.rs b/crates/text/src/psd/import.rs index 374c7408e5..a3852f4ede 100644 --- a/crates/text/src/psd/import.rs +++ b/crates/text/src/psd/import.rs @@ -32,19 +32,32 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { }) else { return; }; - let Some(model) = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.get("0")) else { - return; + // The numbered object, else the older Photoshop's at `/DocumentObjects /TextObjects` (named + // keys: run lengths under `Length`, the style under `RunData /StyleSheet /Features`, the + // auto-kern mode under `AutoKern`). + let numbered = txt2 + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(|a| a.get(index)) + .and_then(|o| o.get("0")) + .and_then(|model| Some((model.get("0").and_then(E::as_str)?, model.path(&["6", "0"]).and_then(E::as_array)?))); + let legacy = || { + let model = txt2.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|a| a.get(index))?.get("Model")?; + Some((model.get("Text").and_then(E::as_str)?, model.path(&["StyleRun", "RunArray"]).and_then(E::as_array)?)) }; - let Some(text) = model.get("0").and_then(E::as_str) else { - return; + let (text, style_runs, is_numbered) = match numbered { + Some((text, runs)) => (text, runs, true), + None => { + let Some((text, runs)) = legacy() else { return }; + (text, runs, false) + } }; if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text { return; } - let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { - return; - }; - let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6"]))).collect(); + let (len_key, style_path, kern_key): (&str, &[&str], &str) = + if is_numbered { ("1", &["0", "0", "6"], "11") } else { ("Length", &["RunData", "StyleSheet", "Features"], "AutoKern") }; + let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get(len_key).and_then(E::as_f64).unwrap_or(0.0), r.path(style_path))).collect(); let lens = utf16_to_byte_lengths(text, &styles.iter().map(|m| m.0).collect::>()); // Txt2 style per byte segment, then split the model runs at segment boundaries. let mut segs: Vec<(usize, Option<&E>)> = lens.into_iter().zip(styles.iter().map(|m| m.1)).collect(); @@ -65,13 +78,13 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let txt2_style = segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end); // Txt2 has the exact mode (EngineData's reading also marks neighbours of manual // kerns manual, like Photoshop's legacy reader). - match txt2_style.and_then(|st| st.get("11")).and_then(E::as_i64) { + match txt2_style.and_then(|st| st.get(kern_key)).and_then(E::as_i64) { Some(0) => style.kerning = Kerning::Off, Some(1) => style.kerning = Kerning::Metrics, Some(2) => style.kerning = Kerning::Optical, _ => {} } - if let Some(st) = txt2_style { + if let Some(st) = txt2_style.filter(|_| is_numbered) { add_txt2_features(&mut style, st); } let len = piece_end - at; @@ -490,15 +503,18 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).take(max_sruns).collect()) .unwrap_or_default(); // The paragraph run each character run starts in (UTF-16 offsets). + // Each paragraph run's end, once: a scan from the first paragraph for every character run was + // quadratic in the file's run counts. A negative or NaN length ends nothing (it never did). + let para_ends: Vec = plens16 + .iter() + .scan(0.0, |end, n| { + *end += n.max(0.0); + Some(*end) + }) + .collect(); let para_of = |at: f64| -> Option { - let mut end = 0.0; - for (i, n) in plens16.iter().enumerate() { - end += n; - if at < end { - return Some(i); - } - } - plens16.len().checked_sub(1) + let i = para_ends.partition_point(|&end| end <= at); + if i < para_ends.len() { Some(i) } else { plens16.len().checked_sub(1) } }; let mut at16 = 0.0; let bases: Vec<[Option<&E>; 3]> = ssheets @@ -590,6 +606,9 @@ pub(crate) fn char_style(base: &[Option<&E>], d: &E, fonts: &[String], k: f32) - Some(2) => FontBaseline::Subscript, _ => FontBaseline::Normal, }; + // Vertical type's roman text: 2 rotated along the column, 1 upright (#9470). A run without + // its own value takes the style sheet's, which Photoshop writes as 2. + s.rotate_roman = g("BaselineDirection").and_then(E::as_i64) == Some(2); if let Some(c) = g("FillColor") { // Positions matter (alpha, then the channels): a non-finite entry voids the colour. let v: Vec = c.get("Values").and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default(); diff --git a/crates/text/src/psd_path.rs b/crates/text/src/psd_path.rs index 3d61fdf851..109e92db95 100644 --- a/crates/text/src/psd_path.rs +++ b/crates/text/src/psd_path.rs @@ -154,7 +154,7 @@ fn starting_at(path: &Subpath, s: f64) -> Subpath { return path.clone(); } let s = if path.closed { s.rem_euclid(total) } else { s }; - if s <= SAME || (!path.closed && s >= total - SAME) || (path.closed && s >= total - SAME) { + if s <= SAME || s >= total - SAME { return path.clone(); } // The segment and parameter at arc length `s` (linear within a measured piece). diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index dacd120dcd..063b5f4512 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -8,7 +8,7 @@ use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::DrawSettings; use skrifa::{GlyphId, MetadataProvider}; -use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; +use crate::layout::{DecorationRect, GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; use crate::warp::{Placement, Warp}; @@ -135,45 +135,64 @@ fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) -> true } +/// The glyphs and decorations one colour pass draws, as indices into the layout's lists. +#[derive(Default)] +pub(crate) struct Pass { + pub(crate) glyphs: Vec, + pub(crate) decorations: Vec, +} + /// Distinct visible colours (`alpha > 0.0`) used by placed glyphs or decorations, each paired with -/// whether any placed glyph of that colour uses faux/synthetic bold. -fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) -> Vec<(Color, bool)> { - let mut colors: Vec<(Color, bool)> = Vec::new(); - let mut record = |c: Color, bold: bool| { +/// whether any placed glyph of that colour uses faux/synthetic bold and the items it draws. Built +/// in one pass with a hash of the colour: a linear search per glyph, and then a walk of every +/// glyph per colour, made text with many coloured runs quadratic. +pub(crate) fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) -> Vec<(Color, bool, Pass)> { + type Key = (std::mem::Discriminant, [u32; 4], u32); + fn slot<'a>(colors: &'a mut Vec<(Color, bool, Pass)>, index: &mut std::collections::HashMap, c: Color, bold: bool) -> Option<&'a mut Pass> { if !(c.alpha.is_finite() && c.alpha > 0.0) { - return; + return None; } - match colors.iter_mut().find(|(existing, _)| *existing == c) { - Some((_, b)) => *b |= bold, - None => colors.push((c, bold)), - } - }; - for g in &layout.glyphs { + // `+ 0.0` folds -0.0 into 0.0, which compare equal. + let key = (std::mem::discriminant(&c.mode), c.c.map(|v| (v + 0.0).to_bits()), c.alpha.to_bits()); + let i = *index.entry(key).or_insert_with(|| { + colors.push((c, false, Pass::default())); + colors.len() - 1 + }); + colors[i].1 |= bold; + Some(&mut colors[i].2) + } + let mut colors: Vec<(Color, bool, Pass)> = Vec::new(); + let mut index = std::collections::HashMap::new(); + for (i, g) in layout.glyphs.iter().enumerate() { let Some(st) = layout.styles.get(g.style as usize) else { continue }; if !(st.color.alpha.is_finite() && st.color.alpha > 0.0) { continue; } - if glyph_is_placed(layout, g, warp) { - record(st.color, is_bold(layout, g)); + if glyph_is_placed(layout, g, warp) + && let Some(pass) = slot(&mut colors, &mut index, st.color, is_bold(layout, g)) + { + pass.glyphs.push(i); } } - for d in &layout.decorations { + for (i, d) in layout.decorations.iter().enumerate() { let Some(st) = layout.styles.get(d.style as usize) else { continue }; - record(st.color, false); + if let Some(pass) = slot(&mut colors, &mut index, st.color, false) { + pass.decorations.push(i); + } } colors } -/// Draws every glyph and decoration of `layout` whose style colour is `color` (or all of them -/// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending +/// Draws the glyphs and decorations of `layout` in `only` (or all of them when it is `None`) into `sink`, through `transform` (text space → sink space), bending /// the outlines with `warp` first (in text space). Faux bold glyphs are drawn from their outline /// thickened by [`FAUX_BOLD_RADIUS`] ([`crate::embolden`]). -fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) { - for g in &layout.glyphs { +fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only: Option<&Pass>, warp: Option<&Warp>) { + let glyphs: Box> = match only { + Some(p) => Box::new(p.glyphs.iter().filter_map(|&i| layout.glyphs.get(i))), + None => Box::new(layout.glyphs.iter()), + }; + for g in glyphs { let st = &layout.styles[g.style as usize]; - if only_color.is_some_and(|c| c != &st.color) { - continue; - } let face = &layout.faces[g.face as usize]; let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { continue; @@ -217,11 +236,11 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c None => draw_glyph(&outline, settings, bold.as_ref(), &mut Pen::new(&mut *sink, transform.mul(&glyph))), } } - for d in &layout.decorations { - let st = &layout.styles[d.style as usize]; - if only_color.is_some_and(|c| c != &st.color) { - continue; - } + let decorations: Box> = match only { + Some(p) => Box::new(p.decorations.iter().filter_map(|&i| layout.decorations.get(i))), + None => Box::new(layout.decorations.iter()), + }; + for d in decorations { match warp { Some(w) => { let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; @@ -281,7 +300,8 @@ fn glyph_center(layout: &TextLayout, g: &PlacedGlyph, font: &skrifa::FontRef, si /// The deformation of a type layer: the path it runs along, or its Warp Text settings (None /// when neither applies). pub fn layout_deform(layout: &TextLayout, layer: &photocraft_doc::TextLayer) -> Option { - Warp::on_path(&layer.shape).or_else(|| layout_warp(layout, layer.warp.as_ref())) + let text_start = layout.bounds().map_or(0.0, |bounds| f64::from(bounds[0])); + Warp::on_path_with_text_start(&layer.shape, text_start).or_else(|| layout_warp(layout, layer.warp.as_ref())) } /// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat). @@ -372,7 +392,7 @@ pub(crate) fn rasterize_passes( } let n = format.mode.color_channels(); let stride = n + 1; - let any_bold = colors.iter().any(|&(_, b)| b); + let any_bold = colors.iter().any(|(_, b, _)| *b); if !fits_raster_budget(w, h, stride, format.sample, any_bold, MAX_RASTER_BYTES) { return nothing(format); } @@ -383,13 +403,13 @@ pub(crate) fn rasterize_passes( let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m)); let whole = Rect::new(0, 0, w as i32, h as i32); let mut pass_pixels = 0u64; - for &(c, _) in &colors { + for (c, _, pass) in &colors { // Rasterise each colour only over its own glyphs (faux bold's thickened outlines included): // a full-size pass per colour made text with many coloured runs cost colours x area // (#8602). Outside its ink a colour's coverage is zero, so the result is the same. let sub = if per_colour { let mut b = Bounds::default(); - draw(layout, &xf, &mut b, Some(&c), warp); + draw(layout, &xf, &mut b, Some(pass), warp); b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY).intersect(&whole) } else { whole @@ -410,11 +430,11 @@ pub(crate) fn rasterize_passes( let Some(mut cov) = Coverage::try_new(sw, sh) else { return nothing(format); }; - draw(layout, &xf, &mut cov, Some(&c), warp); + draw(layout, &xf, &mut cov, Some(pass), warp); let Some(cov) = cov.finish() else { return nothing(format); }; - let comps = color_in(&format, &c); + let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias); } diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 69897329b7..138a1991b0 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -286,6 +286,11 @@ pub fn request_for_languages<'a>(languages: impl IntoIterator) { } } +/// The served fallback family for the script `language` (a BCP 47 tag) is written in, if any. +pub fn fallback_for_language(language: &str) -> Option { + fallback_for(&state().fallbacks, language_script(language)?) +} + /// Families the host can serve, installed or not (for font menus). pub fn families() -> Vec { state().families.clone() diff --git a/crates/text/src/spell.rs b/crates/text/src/spell.rs index bc2afa63c8..31ddf7bf03 100644 --- a/crates/text/src/spell.rs +++ b/crates/text/src/spell.rs @@ -216,6 +216,13 @@ struct Rows { cur: Vec, } +/// Damerau–Levenshtein (optimal string alignment) distance between `a` and `b` in chars, or None +/// when above `max`. Exposed for "did you mean" hints elsewhere (engine command ids, #3674). +pub fn edit_distance(a: &str, b: &str, max: usize) -> Option { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + distance(&a, &b, max, &mut Rows::default()) +} + /// Damerau–Levenshtein (optimal string alignment) distance, or None when above `max`. fn distance(a: &[char], b: &[char], max: usize, rows: &mut Rows) -> Option { let (n, m) = (a.len(), b.len()); @@ -327,5 +334,7 @@ mod tests { assert_eq!(distance(&c("teh"), &c("the"), 2, &mut r), Some(1)); assert_eq!(distance(&c("teh"), &c("toe"), 2, &mut r), Some(2)); assert_eq!(distance(&c("teh"), &c("banana"), 2, &mut r), None); + assert_eq!(edit_distance("edit.undoo", "edit.undo", 2), Some(1)); + assert_eq!(edit_distance("teh", "banana", 2), None); } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 9dd58a55d4..f2792a3850 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1062,12 +1062,12 @@ fn build_txt2_round_trips_kerning_modes() { assert_eq!(modes, vec![(1, Kerning::Off), (1, Kerning::Metrics)]); } -/// #3514: the document `Txt2` is only needed for a non-Metrics (`Optical`) auto-kern mode. A fresh -/// document of ordinary Metrics (or EngineData-carried Off) text must not write Photoshop's empty -/// stub, which some readers (Photopea) reject. +/// #3514 / #10308: the document `Txt2` is needed for Optical kerning and for OpenType settings +/// EngineData can't carry (stylistic sets, swashes, …). A fresh document of ordinary Metrics text +/// must not write Photoshop's empty stub, which some readers (Photopea) reject. #[test] -fn needs_txt2_only_for_optical() { - use photocraft_doc::text::Kerning; +fn needs_txt2_for_optical_and_txt2_only_opentype() { + use photocraft_doc::text::{FontFeature, Kerning}; let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; let metrics = runs_of("AV", &[(2, style(Kerning::Metrics))]); assert!(!crate::psd::needs_txt2(&[(0, &metrics)])); @@ -1076,6 +1076,57 @@ fn needs_txt2_only_for_optical() { let optical = runs_of("AV", &[(1, style(Kerning::Optical)), (1, CharStyle::default())]); assert!(crate::psd::needs_txt2(&[(0, &optical)])); assert!(!crate::psd::needs_txt2(&[])); + let with_ss = |tag: &str, value: u16| { + let mut s = style(Kerning::Metrics); + s.features = vec![FontFeature { tag: tag.into(), value }]; + runs_of("AV", &[(2, s)]) + }; + assert!(crate::psd::needs_txt2(&[(0, &with_ss("ss01", 1))]), "stylistic set 1 lives only in Txt2"); + assert!(crate::psd::needs_txt2(&[(0, &with_ss("ss05", 1))]), "stylistic set 5 lives only in Txt2"); + assert!(!crate::psd::needs_txt2(&[(0, &with_ss("ss01", 0))]), "an off set is the default"); + assert!(crate::psd::needs_txt2(&[(0, &with_ss("swsh", 1))]), "swash lives only in Txt2"); + assert!(crate::psd::needs_txt2(&[(0, &with_ss("calt", 0))]), "calt off is Txt2-only"); + assert!(!crate::psd::needs_txt2(&[(0, &with_ss("calt", 1))]), "calt on is the default"); +} + +/// #10308: the fresh objects, minimal style runs, seeded font list and frame list are only for a +/// PhotoCraft-native block. A file's own block — here the older Photoshop's, with an empty +/// numbered `/0` or none at all — gets no `/0` key, frame list or font list it didn't have, and +/// a layer without a template object gets the stub it always got. +#[test] +fn photoshop_blocks_get_no_native_txt2_scaffolding() { + use crate::engine_data::Value as E; + use photocraft_doc::text::FontFeature; + let style = CharStyle { font_family: "Inter".into(), features: vec![FontFeature { tag: "ss01".into(), value: 1 }], ..Default::default() }; + let t = styled("Hi", style); + let with_doc = photoshop_era_txt2(&["Line 1"]); + let without_doc = crate::engine_data::write_bare(&[ + ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), + ("DocumentObjects".into(), photoshop_era_objects(&["Line 1"])), + ]); + for (label, prev, doc) in [("empty /0", with_doc, Some(E::dict())), ("no /0", without_doc, None)] { + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + assert_eq!(out.get("0").cloned(), doc, "{label}: the file's /0 is left as it was"); + let object = out.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.first()).expect("the numbered object"); + assert!(object.get("1").is_none(), "{label}: no fresh view naming a frame the file has no list for"); + assert!(object.path(&["0", "5"]).is_none(), "{label}: no fresh paragraph runs"); + } +} + +/// #10308: a fresh native object has one paragraph run per paragraph, each its UTF-16 length. +#[test] +fn fresh_native_txt2_object_has_a_paragraph_run_per_paragraph() { + use crate::engine_data::Value as E; + use photocraft_doc::text::FontFeature; + let style = CharStyle { features: vec![FontFeature { tag: "ss02".into(), value: 1 }], ..Default::default() }; + let t = styled("Hi\nBy\u{e9}e", style); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], None)).unwrap(); + let object = out.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.first()).expect("the object"); + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("Hi\rBy\u{e9}e\r")); + let lens: Vec = + object.path(&["0", "5", "0"]).and_then(E::as_array).expect("paragraph runs").iter().filter_map(|r| r.get("1").and_then(E::as_i64)).collect(); + assert_eq!(lens, vec![3, 5]); + assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).map(<[E]>::len), Some(1), "a native block names one frame"); } /// A kept `Txt2` object — same text, so its extras are still true — keeps everything the file @@ -1436,6 +1487,56 @@ fn edited_text_keeps_the_files_txt2_object() { } } +/// #8415: a style-only edit (`type.setStyle` with no text change) reaches the file's `Txt2`. +/// Photoshop reads the character style from the object's style runs, and an object whose text +/// still matched kept the file's runs, so Photoshop reopened the layer in its old font with faux +/// bold still on. A layer whose style agrees with the runs still gets the block back byte for +/// byte (#2374); one whose font or faux bold changed is restyled like an edited object, and the +/// glyph pen positions laid out in the old style are dropped. +#[test] +fn style_only_edit_restyles_the_kept_txt2_object() { + use crate::engine_data::Value as E; + // Strings in the block are UTF-16 with a BOM; every character here is below U+0100. + let utf16 = |s: &str| format!("(\u{fe}\u{ff}{})", s.chars().map(|c| format!("\0{c}")).collect::()); + let font = |name: &str| format!("<< /0 << /99 /CoolTypeFont /0 << /0 {} /2 1 >> >> >> ", utf16(name)); + // One object as Photoshop writes it: paragraph runs, style runs, a view and pen positions. + let prev = format!( + "\n\n/98 << /0 14 >> /0 << /1 << /0 [ {}{}] >> >> /1 << /1 [ << /0 << /0 {} /5 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> >> >> /1 3 >> ] >> /6 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 true /3 false /11 1 >> >> >> /1 3 >> ] >> >> /1 << /0 [ << /0 0 >> ] >> /21 << /1 [ 1.25 2.5 ] >> >> ] >>", + font("Arial-Black"), + font("ArialMT"), + utf16("AV\r"), + ); + let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); + let style = CharStyle { postscript_name: Some("Arial-Black".into()), size_pt: 12.0, faux_bold: true, ..Default::default() }; + let t = styled("AV", style.clone()); + assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev, "an unedited style keeps the file's block"); + + let font_name = |out: &E, run: &E| { + let i = run.path(&["0", "0", "6", "0"]).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("a font index"); + out.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|f| f.get(i)).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str).map(str::to_owned) + }; + for (what, edited, want_font) in [ + ("faux bold off", CharStyle { faux_bold: false, ..style.clone() }, "Arial-Black"), + ( + "font and faux bold", + CharStyle { postscript_name: Some("AppleSDGothicNeo-Heavy".into()), faux_bold: false, ..style.clone() }, + "AppleSDGothicNeo-Heavy", + ), + ] { + let t = styled("AV", edited); + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); + let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; + assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("AV\r"), "{what}: the text is unchanged"); + let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap(); + assert!(!runs.is_empty(), "{what}: the object has style runs"); + for run in runs { + assert_eq!(run.path(&["0", "0", "6", "2"]).and_then(E::as_bool), Some(false), "{what}: faux bold is off in Txt2"); + assert_eq!(font_name(&out, run).as_deref(), Some(want_font), "{what}: Txt2 names the layer's font"); + } + assert!(object.get("21").is_none(), "{what}: pen positions of the old style are dropped"); + } +} + /// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an /// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`, /// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out. @@ -1682,6 +1783,26 @@ fn psd_round_trips_antialias_opentype_and_warp() { } } +/// EngineData only maps Photoshop's `OldStyle` bool to `onum`. Other Numerals tags (`lnum` / +/// `pnum` / `tnum`) stay in `.pcraft` until a Photoshop-authored PSD oracle exists — we do not +/// invent EngineData keys or copy figure-style tables from GPL editors (AGENTS.md clean-room). +#[test] +fn psd_engine_data_keeps_only_oldstyle_among_numeral_tags() { + let style = CharStyle { + font_family: "Inter".into(), + size_pt: 20.0, + features: ["lnum", "onum", "pnum", "tnum"].iter().map(|t| FontFeature { tag: (*t).into(), value: 1 }).collect(), + ..Default::default() + }; + let t = styled("0123456789", style); + let bytes = crate::psd::build_tysh(&t, 72.0, None); + let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).expect("tysh"); + let runs = back.char_runs(); + let mut got: Vec<&str> = runs[0].style.features.iter().map(|f| f.tag.as_str()).collect(); + got.sort_unstable(); + assert_eq!(got, ["onum"]); +} + /// A legacy-signature `TySh` (#1469): zero PointBase, `bounds` (the layout box) and `boundingBox` /// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a /// `transform` that cancels the remote origin. @@ -2759,6 +2880,25 @@ fn font_metadata_large_nameless_lists_rebuild_after_edit() { } } +#[test] +fn font_metadata_reordered_duplicates_keep_distinct_dictionary_records() { + use crate::engine_data::Value as E; + let record = |name: &str, marker| E::Dict(vec![("Name".into(), E::String(name.into())), ("PrivateFontFlag".into(), E::Int(marker))]); + let mut layer = point("Original", 24.0); + let mut template = crate::psd::build_engine_data(&layer, None, 72.0); + let resource = vec![record("A", 1), record("A", 2), record("B", 3)]; + let document = vec![record("B", 30), record("A", 10), record("A", 20)]; + template.get_mut("ResourceDict").unwrap().set("FontSet", E::Array(resource.clone())); + template.get_mut("DocumentResources").unwrap().set("FontSet", E::Array(document.clone())); + layer.text = "Edited".into(); + let rebuilt = crate::psd::build_engine_data(&layer, Some(template), 72.0); + assert_eq!(&rebuilt.path(&["ResourceDict", "FontSet"]).unwrap().as_array().unwrap()[..3], resource.as_slice()); + let fonts = rebuilt.path(&["DocumentResources", "FontSet"]).unwrap().as_array().unwrap(); + assert_eq!(&fonts[..3], &[document[1].clone(), document[2].clone(), document[0].clone()], "reordered duplicates must keep distinct metadata"); + let repeated = crate::psd::font_set(&["A", "A", "A", "A"].map(str::to_string), Some(&E::Array(resource.clone()))); + assert_eq!(repeated, E::Array(vec![resource[0].clone(), resource[1].clone(), resource[0].clone(), resource[0].clone()])); +} + /// Same-runner release probe: compare the original and repaired source, not a wall-clock CI limit. #[test] #[ignore = "release performance measurement for large imported FontSets"] @@ -2824,6 +2964,39 @@ fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() { assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines); } +/// [`TextLayout::overflows`] reads dropped lines off the layout: text that fits covers its end +/// through trailing empty paragraphs; a taller text's lines stop short of the text's end; an +/// empty uncovered tail of pure trailing breaks that fit is not an overflow. +#[test] +fn overflows_says_whether_a_box_holds_its_text() { + let mut e = TextEngine::new(); + let tall_box = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 900.0 }; + // Everything fits, trailing breaks included: the trailing empty paragraphs cover the + // text's end. + for text in ["a", "a\n", "a\n\n", "", "Lorem ipsum dolor sit amet"] { + let l = e.layout(&TextLayer { shape: tall_box.clone(), ..point(text, 24.0) }, 72.0); + assert!(!l.overflows(text), "{text:?}: {} lines", l.lines.len()); + } + // The box holds one line of 24 pt type — additional lines are dropped, and the last line + // stops short of the text. + let short_box = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 34.0 }; + let text = "ab\ncd\nef"; + let l = e.layout(&TextLayer { shape: short_box.clone(), ..point(text, 24.0) }, 72.0); + assert!(l.overflows(text), "{:?}", l.lines.last().map(|ln| ln.range.clone())); + // A trailing break whose empty paragraph doesn't fit is an overflow as well. + let l = e.layout(&TextLayer { shape: short_box.clone(), ..point("ab\n\n", 24.0) }, 72.0); + assert!(l.overflows("ab\n\n"), "{:?}", l.lines.last().map(|ln| ln.range.clone())); + // A box holding one full line plus exactly one more empty paragraph is not. + let exact = point("ab\n", 24.0); + let l = e.layout(&TextLayer { shape: tall_box.clone(), ..exact }, 72.0); + assert!(!l.overflows("ab\n"), "{:?}", l.lines.last().map(|ln| ln.range.clone())); + // Empty text never overflows: even a box too short for its paragraph (no lines laid out) + // has nothing to lose, so no overflow mark. + let l = e.layout(&TextLayer { shape: short_box.clone(), ..point("", 24.0) }, 72.0); + assert!(!l.overflows(""), "{} lines", l.lines.len()); + let _ = &mut e; +} + /// Closed polygon in text space. fn area_poly(pts: &[(f64, f64)]) -> photocraft_doc::vector::Path { photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polygon(pts)]) @@ -3158,3 +3331,307 @@ fn per_colour_passes_match_full_size_passes() { } } } + +/// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the +/// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs +/// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`. +fn styled_era_txt2(text: &str, font: &str, size: f64, auto_kern: i64) -> Vec { + use crate::engine_data::Value as E; + let font_entry = |name: &str| { + E::Dict(vec![( + "Resource".into(), + E::Dict(vec![ + ("StreamTag".into(), E::Name("CoolTypeFont".into())), + ("Identifier".into(), E::Dict(vec![("Name".into(), E::String(name.into())), ("Type".into(), E::Int(1))])), + ]), + )]) + }; + let features = E::Dict(vec![ + ("Font".into(), E::Int(0)), + ("FontSize".into(), E::Real(size)), + ("FauxBold".into(), E::Bool(false)), + ("FauxItalic".into(), E::Bool(false)), + ("AutoLeading".into(), E::Bool(true)), + ("Leading".into(), E::Real(0.01)), + ("HorizontalScale".into(), E::Real(1.0)), + ("VerticalScale".into(), E::Real(1.0)), + ("Tracking".into(), E::Int(0)), + ("AutoKern".into(), E::Int(auto_kern)), + ("BaselineShift".into(), E::Real(0.0)), + ("FontCaps".into(), E::Int(0)), + ("FontBaseline".into(), E::Int(0)), + ("UnderlinePosition".into(), E::Int(0)), + ("StrikethroughPosition".into(), E::Int(0)), + ("Ligatures".into(), E::Bool(true)), + ("DiscretionaryLigatures".into(), E::Bool(false)), + ("ContextualLigatures".into(), E::Bool(true)), + ("StylisticSets".into(), E::Int(0)), + ( + "FillColor".into(), + E::Dict(vec![ + ("StreamTag".into(), E::Name("SimplePaint".into())), + ( + "Color".into(), + E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![E::Real(1.0), E::Real(0.0), E::Real(0.0), E::Real(0.0)]))]), + ), + ]), + ), + ]); + let total = format!("{text}\r").encode_utf16().count() as i64; + let run = |len: i64, data: E| E::Dict(vec![("RunData".into(), data), ("Length".into(), E::Int(len))]); + let placeholder = || E::Dict(vec![("0".into(), E::String(String::new()))]); + let style_data = E::Dict(vec![( + "StyleSheet".into(), + E::Dict(vec![("Name".into(), E::String(String::new())), ("Parent".into(), E::Int(0)), ("Features".into(), features)]), + )]); + let entry = E::Dict(vec![ + ( + "Model".into(), + E::Dict(vec![ + ("Text".into(), E::String(format!("{text}\r"))), + ("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), + ("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, style_data)]))])), + ("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), + ]), + ), + ("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])), + ]); + crate::engine_data::write_bare(&[ + ("DocumentResources".into(), E::Dict(vec![("FontSet".into(), E::Dict(vec![("Resources".into(), E::Array(vec![font_entry(font)]))]))])), + ("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![entry]))])), + ]) +} + +/// The length and `Features` of each style run of the first legacy object. +fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data::Value)> { + use crate::engine_data::Value as E; + block + .path(&["DocumentObjects", "TextObjects"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|e| e.path(&["Model", "StyleRun", "RunArray"])) + .and_then(E::as_array) + .map(|rs| { + rs.iter() + .map(|r| (r.get("Length").and_then(E::as_i64).unwrap_or(-1), r.path(&["RunData", "StyleSheet", "Features"]).cloned().unwrap_or(E::Int(0)))) + .collect() + }) + .unwrap_or_default() +} + +/// Review of #8888 (CodeRabbit, psd.rs): a style-only edit (no text change) restyles the older +/// Photoshop's copy too, which Photoshop re-renders from. Its font list gains the new font, the +/// run keeps every key Photoshop wrote, and the lengths still cover the text. +#[test] +fn a_style_only_edit_restyles_the_photoshop_era_objects() { + use crate::engine_data::Value as E; + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); + let style = CharStyle { + postscript_name: Some("Courier".into()), + size_pt: 20.0, + faux_bold: true, + color: Color::rgb(0.0, 0.5, 1.0), + tracking: 25.0, + ..Default::default() + }; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev))).unwrap(); + let runs = era_runs(&out); + assert_eq!(runs.len(), 1); + let (length, f) = &runs[0]; + assert_eq!(*length, 7, "the run covers the text and its trailing return"); + assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0)); + assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true)); + assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25)); + assert_eq!(f.get("Leading").and_then(E::as_f64), Some(0.01), "an automatic leading keeps Photoshop's own value"); + assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1)); + assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4)); + assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept"); + // The font went into the block's list and the run names it. + let fonts = out.path(&["DocumentResources", "FontSet", "Resources"]).and_then(E::as_array).expect("the font list"); + let index = f.get("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("the font index"); + let name = |i: usize| fonts.get(i).and_then(|r| r.path(&["Resource", "Identifier", "Name"])).and_then(E::as_str); + assert_eq!(name(index), Some("Courier")); + assert_eq!(name(0), Some("ArialMT"), "the file's font stays first"); + // The text did not change. + let text = + out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str); + assert_eq!(text, Some("Line 1\r")); +} + +/// A layer whose style already agrees with the file's run leaves the legacy objects as they are. +#[test] +fn an_unedited_style_leaves_the_photoshop_era_objects_alone() { + let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); + let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev)); + assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap())); +} + +/// Both object lists, as CodeRabbit asked: the numbered object and the older copy are restyled by +/// the same style-only edit. +#[test] +fn a_style_only_edit_restyles_both_object_lists() { + use crate::engine_data::Value as E; + let mut prev = photoshop_txt2(&[(4, 1)]); + prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0, 1)); + let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() }; + let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap(); + let legacy = era_runs(&out); + let first = legacy.first().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64), f.get("FauxItalic").and_then(E::as_bool))); + assert_eq!(first, Some((4, Some(31.0), Some(true)))); + let numbered = out + .path(&["1", "1"]) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|o| o.path(&["0", "6", "0"])) + .and_then(E::as_array) + .and_then(<[E]>::first) + .and_then(|r| r.path(&["0", "0", "6"])); + assert_eq!(numbered.and_then(|s| s.get("1")).and_then(E::as_f64), Some(31.0), "the numbered object is restyled too"); +} + +/// Two character runs give two style runs in the legacy copy, with the lengths of the runs. +#[test] +fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() { + use crate::engine_data::Value as E; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() }; + let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() }; + let t = runs_of("Hello", &[(2, small), (3, big)]); + let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); + let seen: Vec<(i64, Option)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect(); + assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return"); +} + +/// Review of #8888: every key the legacy restyle writes is written per run, so a multi-run edit +/// does not copy the first run's baseline shift, case, underline or strikethrough to the others. +#[test] +fn each_photoshop_era_style_run_gets_its_own_values() { + use crate::engine_data::Value as E; + use photocraft_doc::text::{Caps, FontBaseline}; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let decorated = CharStyle { baseline_shift_pt: 3.0, caps: Caps::SmallCaps, baseline: FontBaseline::Superscript, underline: true, ..base.clone() }; + let struck = CharStyle { strikethrough: true, caps: Caps::AllCaps, ligatures: false, discretionary_ligatures: true, ..base.clone() }; + let t = runs_of("Hello", &[(2, decorated), (2, base.clone()), (1, struck)]); + let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); + let key = |i: usize, k: &str| runs.get(i).and_then(|(_, f)| f.get(k)).cloned(); + assert_eq!(runs.iter().map(|r| r.0).collect::>(), vec![2, 2, 2]); + assert_eq!(key(0, "BaselineShift").and_then(|v| v.as_f64()), Some(3.0)); + assert_eq!(key(1, "BaselineShift").and_then(|v| v.as_f64()), Some(0.0), "the second run is not the first"); + assert_eq!([0, 1, 2].map(|i| key(i, "FontCaps").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(2)]); + assert_eq!([0, 1, 2].map(|i| key(i, "FontBaseline").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(0)]); + assert_eq!([0, 1, 2].map(|i| key(i, "UnderlinePosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(true), Some(false), Some(false)]); + assert_eq!([0, 1, 2].map(|i| key(i, "StrikethroughPosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(false), Some(false), Some(true)]); + assert_eq!([0, 1, 2].map(|i| key(i, "Ligatures").and_then(|v| v.as_bool())), [Some(true), Some(true), Some(false)]); + assert_eq!([0, 1, 2].map(|i| key(i, "DiscretionaryLigatures").and_then(|v| v.as_bool())), [Some(false), Some(false), Some(true)]); + let _ = E::Int(0); +} + +/// Each of those keys alone is enough to restyle the legacy copy (an edit of only that key), and +/// a layer that agrees on all of them leaves the block's bytes alone. +#[test] +fn every_legacy_style_key_is_compared() { + use photocraft_doc::text::{Caps, FontBaseline, Kerning}; + let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); + let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; + let unedited = crate::psd::build_txt2(&[(0, &styled("Hello", base.clone()))], Some(&prev)); + assert!( + era_runs(&crate::psd::parse_txt2(&unedited).unwrap()) == era_runs(&crate::psd::parse_txt2(&prev).unwrap()), + "an agreeing layer leaves the legacy copy as it is" + ); + let edits = [ + CharStyle { baseline_shift_pt: 2.0, ..base.clone() }, + CharStyle { caps: Caps::AllCaps, ..base.clone() }, + CharStyle { baseline: FontBaseline::Subscript, ..base.clone() }, + CharStyle { underline: true, ..base.clone() }, + CharStyle { strikethrough: true, ..base.clone() }, + CharStyle { ligatures: false, ..base.clone() }, + CharStyle { discretionary_ligatures: true, ..base.clone() }, + CharStyle { kerning: Kerning::Optical, ..base.clone() }, + ]; + for (i, edit) in edits.into_iter().enumerate() { + let out = crate::psd::build_txt2(&[(0, &styled("Hello", edit))], Some(&prev)); + assert!(era_runs(&crate::psd::parse_txt2(&out).unwrap()) != era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "edit {i} restyles the legacy copy"); + } +} + +/// Review of #8888: the legacy `AutoKern` mode is read on import, as `/11` is for the numbered +/// objects, so the layer a legacy file opens as agrees with its legacy copy. +#[test] +fn legacy_auto_kern_is_read_on_import() { + use photocraft_doc::text::Kerning; + let t = styled("AVA", CharStyle::default()); + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); + for (mode, want) in [(0, Kerning::Off), (1, Kerning::Metrics), (2, Kerning::Optical)] { + let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVA", "ArialMT", 13.0, mode)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert!( + back.char_runs().iter().all(|r| r.style.kerning == want), + "AutoKern {mode}: {:?}", + back.char_runs().iter().map(|r| r.style.kerning).collect::>() + ); + } + // Other text, or another object, changes nothing. + let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVX", "ArialMT", 13.0, 2)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics)); +} + +/// Review of the batch (FredApps, with #9104): a style-only save rewrites `Txt2`, and the raw +/// (non-BOM) strings Photoshop wrote in it, `/2 (0100)` and `/3 (01110)` under `/98`, must come +/// back as the same bytes: re-encoded as `FE FF` UTF-16 they make Photoshop refuse the file. +#[test] +fn a_style_only_save_keeps_the_raw_strings_of_the_file() { + let mut prev = photoshop_txt2(&[(4, 1)]); + let version = b"/98 << /0 14 >>"; + let at = prev.windows(version.len()).position(|w| w == version).expect("the version key"); + prev.splice(at..at + version.len(), b"/98 << /0 14 /2 (0100) /3 (01110) >>".iter().copied()); + // A size the file's runs do not have: the block is rewritten. + let style = CharStyle { size_pt: 31.0, faux_bold: false, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev)); + assert_ne!(out, prev, "the style edit rewrites the block"); + let has = |needle: &[u8]| out.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(0100)") && has(b"(01110)"), "the raw strings come back as the same bytes: {:?}", String::from_utf8_lossy(&out)); + assert!(!has(b"(\xfe\xff\x000\x001\x000\x000)"), "and not as UTF-16"); +} + +/// The same for the older Photoshop's objects: restyling the legacy copy leaves a raw string +/// elsewhere in the block as the file wrote it. +#[test] +fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() { + use crate::engine_data::Value as E; + let era = crate::psd::parse_txt2(&styled_era_txt2("Line 1", "ArialMT", 13.0, 1)).unwrap(); + let E::Dict(mut items) = era else { panic!("a dictionary") }; + items.push(("98".into(), E::Dict(vec![("0".into(), E::Int(14)), ("2".into(), E::RawString("0100".into()))]))); + let prev = crate::engine_data::write_bare(&items); + let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 24.0, ..Default::default() }; + let out = crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev)); + assert!(out.windows(6).any(|w| w == b"(0100)"), "{:?}", String::from_utf8_lossy(&out)); + let runs = era_runs(&crate::psd::parse_txt2(&out).unwrap()); + assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled"); +} + +/// Colour passes are grouped in one walk of the glyphs: with a colour per letter, every placed +/// glyph lands in exactly one pass and each pass holds only its own glyphs. +#[test] +fn colour_passes_hold_each_glyph_once() { + let mut e = TextEngine::new(); + let text = "abcdeghjkm".repeat(200); + let runs = text + .char_indices() + .map(|(i, ch)| TextRun { len: ch.len_utf8(), style: CharStyle { size_pt: 12.0, color: Color::rgb(i as f32 / 2000.0, 0.5, 0.5), ..Default::default() } }) + .collect(); + let t = TextLayer { text, runs, ..Default::default() }; + let l = e.layout(&t, 72.0); + let passes = crate::render::visible_color_passes(&l, None); + assert!(passes.len() > 1000, "{} passes", passes.len()); + let mut seen: Vec = passes.iter().flat_map(|(_, _, p)| p.glyphs.iter().copied()).collect(); + seen.sort_unstable(); + assert_eq!(seen, (0..l.glyphs.len()).collect::>()); + for (c, _, p) in &passes { + assert!(p.glyphs.iter().all(|&i| l.styles[l.glyphs[i].style as usize].color == *c)); + } +} diff --git a/crates/text/src/vertical_tests.rs b/crates/text/src/vertical_tests.rs index 0da13b3497..1f36298b34 100644 --- a/crates/text/src/vertical_tests.rs +++ b/crates/text/src/vertical_tests.rs @@ -19,6 +19,15 @@ fn vertical(text: &str, family: &str, size_pt: f32) -> TextLayer { } } +/// [`vertical`] with its roman text rotated along the column (PSD `BaselineDirection` 2). +fn rotated(text: &str, family: &str, size_pt: f32) -> TextLayer { + let mut t = vertical(text, family, size_pt); + for r in &mut t.runs { + r.style.rotate_roman = true; + } + t +} + /// An engine with an installed Japanese font registered, and that font's family. fn cjk_engine() -> Option<(TextEngine, String)> { #[cfg(not(target_arch = "wasm32"))] @@ -53,7 +62,7 @@ fn classes_follow_vertical_orientation() { #[test] fn latin_is_rotated_and_runs_down_columns_right_to_left() { let mut e = TextEngine::new(); - let l = e.layout(&vertical("abc\nde", "Inter", 20.0), 72.0); + let l = e.layout(&rotated("abc\nde", "Inter", 20.0), 72.0); assert!(l.vertical); assert_eq!(l.lines.len(), 2); assert!(l.glyphs.iter().all(|g| g.orient == GlyphOrient::Rotated)); @@ -67,16 +76,90 @@ fn latin_is_rotated_and_runs_down_columns_right_to_left() { // Rotated text is centred on the column through the anchor (x = 0): its baseline sits left. assert!(a.x < 0.0 && a.x > -10.0, "{a:?}"); // Bounds: one column for "abc" is taller than wide. - let one = e.layout(&vertical("abcdef", "Inter", 20.0), 72.0); + let one = e.layout(&rotated("abcdef", "Inter", 20.0), 72.0); let [x0, y0, x1, y1] = one.bounds().unwrap(); assert!((y1 - y0) > 2.0 * (x1 - x0), "{:?}", one.bounds()); assert!((x0 + 10.0).abs() < 1e-3 && (x1 - 10.0).abs() < 1e-3 && y0.abs() < 1e-3); } +/// #9470: Photoshop's Vertical Type tool sets roman text upright, one character per em down the +/// column (`BaselineDirection` 1): a word is a stack of letters, not the word turned on its side. +#[test] +fn latin_stands_upright_one_per_em_by_default() { + let mut e = TextEngine::new(); + let l = e.layout(&vertical("VERTICAL", "Inter", 36.0), 72.0); + assert!(l.vertical); + assert!(l.glyphs.iter().all(|g| g.orient == GlyphOrient::Upright), "{:?}", l.glyphs); + // One em (36 px) per character down a single column. + let steps: Vec = l.glyphs.windows(2).map(|w| w[1].y - w[0].y).collect(); + assert!(steps.iter().all(|d| (d - 36.0).abs() < 0.5), "{steps:?}"); + assert!(l.glyphs.windows(2).all(|w| (w[1].x - w[0].x).abs() < 6.0), "one column: {:?}", l.glyphs); + // Rendered: a narrow stack about 8 em tall, much taller than the rotated word. + let at = Affine::translate(200.0, 40.0); + let (_, up) = e.render(&TextLayer { transform: at, ..vertical("VERTICAL", "Inter", 36.0) }, 72.0, PixelFormat::RGBA8); + let (_, turned) = e.render(&TextLayer { transform: at, ..rotated("VERTICAL", "Inter", 36.0) }, 72.0, PixelFormat::RGBA8); + assert!(up.rect.width() < 40 && up.rect.height() > 7 * 36, "{:?}", up.rect); + assert!(up.rect.height() > turned.rect.height() + 50, "{:?} vs {:?}", up.rect, turned.rect); +} + +/// An engine with an installed font that joins "fi" into one ligature glyph, and its family. +fn ligature_engine() -> Option<(TextEngine, String)> { + #[cfg(not(target_arch = "wasm32"))] + for path in [ + "/System/Library/Fonts/Supplemental/Hoefler Text.ttc", + "/System/Library/Fonts/Supplemental/Baskerville.ttc", + "/System/Library/Fonts/Supplemental/Didot.ttc", + "/System/Library/Fonts/Times.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", + "C:\\Windows\\Fonts\\calibri.ttf", + "C:\\Windows\\Fonts\\cambria.ttc", + ] { + let Ok(bytes) = std::fs::read(path) else { continue }; + let mut e = TextEngine::new(); + for fam in e.fonts.register_font_data(bytes) { + let flat = TextLayer { orientation: Orientation::Horizontal, ..vertical("fi", &fam, 30.0) }; + if e.layout(&flat, 72.0).glyphs.len() == 1 { + return Some((e, fam)); + } + } + } + eprintln!("no installed font with an fi ligature found: skipping"); + None +} + +/// CodeRabbit on #9478: upright roman letters never join into a ligature; "fi" and "ffl" are +/// one glyph per letter, one em apart. +#[test] +fn upright_roman_letters_stay_apart_without_ligatures() { + let Some((mut e, fam)) = ligature_engine() else { return }; + for text in ["fi", "ffl", "office"] { + let l = e.layout(&vertical(text, &fam, 30.0), 72.0); + assert_eq!(l.glyphs.len(), text.chars().count(), "{fam} {text}: one glyph per letter: {:?}", l.glyphs); + let steps: Vec = l.glyphs.windows(2).map(|w| w[1].y - w[0].y).collect(); + assert!(steps.iter().all(|d| (d - 30.0).abs() < 0.5), "{fam} {text}: one em apart: {steps:?}"); + } +} + +/// CodeRabbit on #9478: half-width katakana and Hangul keep their rotated setting; only roman +/// text stands upright by default (#9470 leaves CJK as it was). +#[test] +fn half_width_cjk_stays_rotated_beside_upright_roman() { + let mut e = TextEngine::new(); + for text in ["アイウ", "ᄀᄁᆪ"] { + let l = e.layout(&vertical(text, "Inter", 24.0), 72.0); + assert!(!l.glyphs.is_empty()); + assert!(l.glyphs.iter().all(|g| g.orient == GlyphOrient::Rotated), "{text}: {:?}", l.glyphs); + } + let l = e.layout(&vertical("A", "Inter", 24.0), 72.0); + assert!(l.glyphs.iter().all(|g| g.orient == GlyphOrient::Upright), "roman stays upright"); +} + #[test] fn rotated_render_ink_is_taller_than_wide() { let mut e = TextEngine::new(); - let horizontal = TextLayer { orientation: Orientation::Horizontal, ..vertical("Vertical", "Inter", 30.0) }; + let horizontal = TextLayer { orientation: Orientation::Horizontal, ..rotated("Vertical", "Inter", 30.0) }; let (_, h) = e.render(&TextLayer { transform: Affine::translate(100.0, 50.0), ..horizontal.clone() }, 72.0, PixelFormat::RGBA8); let (_, v) = e.render(&TextLayer { transform: Affine::translate(100.0, 50.0), orientation: Orientation::Vertical, ..horizontal }, 72.0, PixelFormat::RGBA8); assert!(h.rect.width() > h.rect.height()); @@ -109,7 +192,7 @@ fn caret_hit_test_and_arrow_geometry_follow_columns() { #[test] fn box_text_wraps_columns_within_the_box_height() { let mut e = TextEngine::new(); - let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 20.0, width: 200.0, height: 60.0 }, ..vertical("ab cd ef gh ij kl mn op", "Inter", 20.0) }; + let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 20.0, width: 200.0, height: 60.0 }, ..rotated("ab cd ef gh ij kl mn op", "Inter", 20.0) }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 3, "{}", l.lines.len()); let [x0, y0, x1, y1] = l.bounds().unwrap(); @@ -163,8 +246,11 @@ fn cjk_is_upright_centred_with_vertical_alternates() { } else { assert_eq!(dash.orient, GlyphOrient::Rotated); } - // Latin inside vertical CJK runs rotated. - assert_eq!(g[9].orient, GlyphOrient::Rotated); + // Latin inside vertical CJK stands upright (Photoshop's Vertical Type tool), or rotated when + // its style says so. + assert_eq!(g[9].orient, GlyphOrient::Upright); + let r = e.layout(&rotated(text, &fam, 40.0), 72.0); + assert_eq!(r.glyphs[9].orient, GlyphOrient::Rotated); // Punctuation squeeze: 、「 takes 1.5 em down the column, ト、 keeps full cells. let s = e.layout(&vertical("ト、「東", &fam, 40.0), 72.0); let cx: Vec = s.clusters.iter().map(|c| c.x).collect(); diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index 439fc453bf..c2078966c1 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -85,6 +85,45 @@ impl Warp { PathFrame::new(path, *start, *flip).map(|f| Warp(Kind::Path(f))) } + /// Path text with its logical left edge after paragraph alignment. A closed path has + /// one lap of capacity from this edge, including centred and right-aligned text whose + /// coordinates start below zero. Open paths keep their existing endpoint clipping. + pub fn on_path_with_text_start(shape: &TextShape, text_start: f64) -> Option { + if !text_start.is_finite() { + return None; + } + let mut warp = Self::on_path(shape)?; + if let Kind::Path(frame) = &mut warp.0 { + frame.text_start = text_start; + } + Some(warp) + } + + /// Text-space x nearest a point on a type path, using the same seam and one-lap + /// interval as glyph placement. Used when clicking to place the caret after the seam. + pub fn path_text_x(&self, x: f64, y: f64) -> Option { + let Kind::Path(frame) = &self.0 else { return None }; + if !x.is_finite() || !y.is_finite() { + return None; + } + let offset = frame.project(x, y) - frame.start; + Some(if frame.closed { (offset - frame.text_start).rem_euclid(frame.length()) + frame.text_start } else { offset }) + } + + /// Caret hit testing in the occupied text interval. In a closed path's unused gap, + /// choose the nearer endpoint along the path instead of always choosing the last caret. + pub fn path_text_x_near_text(&self, x: f64, y: f64, text_end: f64) -> Option { + let offset = self.path_text_x(x, y)?; + let Kind::Path(frame) = &self.0 else { return None }; + if frame.closed && text_end.is_finite() && offset > text_end { + let previous = offset - frame.length(); + if frame.text_start - previous < offset - text_end { + return Some(previous); + } + } + Some(offset) + } + /// Longest segment (text-space px) worth sending through [`Warp::apply`] unsplit. pub fn max_segment(&self) -> f64 { match &self.0 { @@ -128,6 +167,8 @@ struct PathFrame { /// Arc length at each point. cum: Vec, start: f64, + closed: bool, + text_start: f64, } impl PathFrame { @@ -163,7 +204,14 @@ impl PathFrame { } cum.push(acc); } - (pts.len() >= 2 && acc.is_finite() && acc > 1e-6 && start.is_finite()).then(|| PathFrame { pts, cum, start: f64::from(start) }) + (pts.len() >= 2 && acc.is_finite() && acc > 1e-6 && start.is_finite()).then(|| PathFrame { + pts, + cum, + // A click at the seam may project to either 0 or length (then round as f32). + start: if path.closed { f64::from(start).rem_euclid(acc) } else { f64::from(start) }, + closed: path.closed, + text_start: 0.0, + }) } fn length(&self) -> f64 { @@ -186,8 +234,9 @@ impl PathFrame { best.1 } - /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. + /// Point and unit tangent: closed paths wrap; open paths extend their end tangents. fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { + let s = if self.closed { s.rem_euclid(self.length()) } else { s }; let last = self.pts.len() - 1; let i = self.cum.partition_point(|&c| c < s).clamp(1, last); let (a, b) = (self.pts[i - 1], self.pts[i]); @@ -205,7 +254,15 @@ impl PathFrame { /// The rigid map for a glyph centred at text-space `cx`; `None` outside the path. fn frame(&self, cx: f64) -> Option<[f64; 6]> { let s = self.start + cx; - if !(0.0..=self.length()).contains(&s) { + if !cx.is_finite() + || if self.closed { + // Stop after one lap of text, not at the arbitrary first knot. A half-open + // interval excludes the first duplicate location at the next revolution. + !(self.text_start..self.text_start + self.length()).contains(&cx) + } else { + !(0.0..=self.length()).contains(&s) + } + { return None; } let (p, t) = self.at(s); @@ -298,3 +355,7 @@ mod tests { assert!(s.apply(100.0, -40.0).1 < -40.0); } } + +#[cfg(test)] +#[path = "closed_path_tests.rs"] +mod closed_path_tests; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c95da851e0..9fccf05de1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -50,12 +50,14 @@ rayon = { workspace = true } # Safe native UI-language APIs (Windows/macOS); no subprocess or web dependency. sys-locale = "=0.3.2" -# Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`): the safe binding the -# desktop app already uses, pinned to the same version. +# Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`) and the user's decimal +# symbol (`i18n::decimal_separator`, a registry read): the safe binding the desktop app already +# uses, pinned to the same version. [target.'cfg(target_os = "windows")'.dependencies] -winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi"] } +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi", "advapi"] } [dev-dependencies] +photocraft-io = { workspace = true, features = ["testgen"] } # egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with # `Context::default()` (no install_fonts) can lay out text. egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index df65585d7e..a90d4903ad 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/examples/bevel_bench.rs b/crates/ui-egui/examples/bevel_bench.rs index 15c76e6d35..4d022b77f7 100644 --- a/crates/ui-egui/examples/bevel_bench.rs +++ b/crates/ui-egui/examples/bevel_bench.rs @@ -101,7 +101,7 @@ struct Gpu { } fn gpu() -> Option { - let instance = wgpu::Instance::default(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) .ok()?; diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index d51fcf610c..af4036e384 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -136,7 +136,7 @@ struct Gpu { } fn gpu() -> Option { - let instance = wgpu::Instance::default(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) .ok()?; diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 180cdfa8fc..35e6174199 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -199,7 +199,7 @@ fn main() { .walk() .iter() .flat_map(|(_, _, l)| l.surface().into_iter().chain(l.mask.as_ref().map(|m| &m.surface))) - .map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::()) + .map(|s| s.tiles().map(|(_, t)| t.len() as u64).sum::()) .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); let vram = g.video_memory(); diff --git a/crates/ui-egui/examples/interactive_bench.rs b/crates/ui-egui/examples/interactive_bench.rs index 2ffe3c1ce1..0b4cc5c952 100644 --- a/crates/ui-egui/examples/interactive_bench.rs +++ b/crates/ui-egui/examples/interactive_bench.rs @@ -44,7 +44,7 @@ struct Gpu { } fn gpu() -> Option { - let instance = wgpu::Instance::default(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })) .ok()?; @@ -83,9 +83,9 @@ struct Bench { /// state later rows need). only: Option, rows: Vec<(String, f64, f64, f64, f64)>, - /// Per row (same order as `rows`): the timed samples and the peak RSS while they ran, for - /// `--json` (`cargo xtask perf`). - extra: Vec<(Vec, Option)>, + /// Per row (same order as `rows`): the timed samples, the peak RSS while they ran and the + /// minor page faults per warm run (all of it, untimed setup included), for `--json` (`cargo xtask perf`). + extra: Vec<(Vec, Option, Option)>, rss: photocraft_testkit::perf::RssSampler, } @@ -127,11 +127,17 @@ impl Bench { } self.rss.reset(); let cold = f(self); + let faults0 = photocraft_testkit::perf::minor_faults(); let mut v: Vec = (0..self.reps).map(|_| f(self)).collect(); - self.extra.push((v.clone(), self.rss.peak())); + // Minor page faults per warm run: memory mapped fresh from the kernel each time (#10479). + // Counted over the whole of `f`, so it includes untimed setup and cleanup (an undo, an + // extra refresh), not only the work the milliseconds measure. + let faults = faults0.zip(photocraft_testkit::perf::minor_faults()).map(|(a, b)| b.saturating_sub(a) / self.reps as u64); + self.extra.push((v.clone(), self.rss.peak(), faults)); v.sort_by(f64::total_cmp); let med = v[v.len() / 2]; - println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold); + let fl = faults.map_or(String::new(), |f| format!(", {f} faults/run incl. setup")); + println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1}{fl})", v[0], v[v.len() - 1], cold); self.rows.push((name.to_string(), med, v[0], v[v.len() - 1], cold)); } } @@ -473,11 +479,12 @@ fn main() { .rows .iter() .zip(&b.extra) - .map(|((n, med, min, max, cold), (samples, rss))| { + .map(|((n, med, min, max, cold), (samples, rss, faults))| { let mut r = json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold}); // For `cargo xtask perf`: raw samples (it computes p50 / p95) and peak RSS. r["samples_ms"] = json!(samples); r["peak_rss_bytes"] = json!(rss); + r["minor_faults_per_run"] = json!(faults); r }) .collect(); diff --git a/crates/ui-egui/examples/multi_channel_preview_bench.rs b/crates/ui-egui/examples/multi_channel_preview_bench.rs new file mode 100644 index 0000000000..02f0e29712 --- /dev/null +++ b/crates/ui-egui/examples/multi_channel_preview_bench.rs @@ -0,0 +1,97 @@ +//! 24 MP Liquify preview updates: all channels, Red, and Red+Blue. +//! RAYON_NUM_THREADS=8 cargo run --release -p photocraft-ui-egui --example multi_channel_preview_bench +use photocraft_engine::Session; +use photocraft_geom::Rect; +use photocraft_ui_egui::canvas::{ToolEvent, tool_event}; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::{Value, json}; +use std::time::Instant; + +fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Result<(), Box> { + let (request, _) = ControlRequest::new("ui.menu.invoke", params); + match handle(app, ctx, &request) { + Outcome::Done(value) if value.get("ok") != Some(&Value::Bool(false)) => Ok(()), + Outcome::Done(value) => Err(format!("control failed: {value}").into()), + _ => Err("expected an inline workspace response".into()), + } +} + +fn main() -> Result<(), Box> { + let mut fixture = Session::new(); + fixture.execute("file.new", json!({"width":6000,"height":4000,"depth":16,"background":"transparent"}))?; + fixture.execute("layer.new.layer", json!({}))?; + fixture.edit("Bands", |doc, id| { + let surface = doc + .layer_mut(id.ok_or(photocraft_engine::EngineError::NoDocument)?) + .and_then(|l| l.surface_mut()) + .ok_or(photocraft_engine::EngineError::NoDocument)?; + for x in (0..6000).step_by(64) { + let value = 0.15 + (x % 640) as f32 / 1000.0; + surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &[value, 0.4, 1.0 - value, 0.8]); + } + Ok(()) + })?; + let st = fixture.active().ok_or(photocraft_engine::EngineError::NoDocument)?; + let layer = st.active_layer.ok_or(photocraft_engine::EngineError::NoDocument)?; + let doc = st.doc.clone(); + let ctx = egui::Context::default(); + let mut apps = Vec::new(); + let modes = ["all", "red", "red_blue"]; + let mut opening = Vec::new(); + for mode in modes { + let mut session = Session::new(); + session.add_document((*doc).clone(), None); + session.select_layer(layer)?; + if mode != "all" { + session.execute("channel.target", json!({"colors":if mode=="red" {vec![0]} else {vec![0,2]}}))?; + } + let mut app = PhotocraftApp::new(session, Services::default()); + let start = Instant::now(); + invoke(&mut app, &ctx, json!({"id":"filter.liquify"}))?; + opening.push(start.elapsed().as_secs_f64() * 1000.0); + invoke(&mut app, &ctx, json!({"id":"filter.liquify","params":{"ui":{"size":120,"density":50}}}))?; + tool_event(&mut app, ToolEvent::Down { x: 2000.0, y: 1500.0, pressure: 1.0 }, egui::Modifiers::NONE); + apps.push(app); + } + let mut samples = vec![Vec::new(); 3]; + for run in 0..7 { + for offset in 0..3 { + let which = (run + offset) % 3; + let app = apps.get_mut(which).ok_or("missing benchmark app")?; + let x = 2050.0 + run as f64 * 10.0; + let start = Instant::now(); + tool_event(app, ToolEvent::Move { x, y: 1510.0, pressure: 0.8 }, egui::Modifiers::NONE); + let ms = start.elapsed().as_secs_f64() * 1000.0; + if run > 0 + && let Some(row) = samples.get_mut(which) + { + row.push(ms); + } + } + } + for app in &mut apps { + let (request, _) = ControlRequest::new("ui.inspect", Value::Null); + let Outcome::Done(state) = handle(app, &ctx, &request) else { return Err("expected inline state".into()) }; + if state.pointer("/result/distort/liquify/maxDisplacement").and_then(Value::as_f64).is_none_or(|value| value <= 0.0) { + return Err("the benchmark did not deform the Liquify field".into()); + } + } + let rows: Vec<_> = modes + .iter() + .zip(samples) + .zip(opening) + .map(|((mode, samples), open_ms)| { + let mut sorted = samples.clone(); + sorted.sort_by(f64::total_cmp); + json!({"target":mode,"open_ms":open_ms,"samples_ms":samples,"p50_ms":sorted.get(3),"max_ms":sorted.last()}) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty( + &json!({"size":[6000,4000],"depth":16,"operation":"Liquify 120px live move; native proxy preview","warmups":1,"repeats":6,"rows":rows}) + )? + ); + Ok(()) +} diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 4ea1c1e87b..efa1300c73 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -12,6 +12,11 @@ //! its numbers are only comparable with other `--quick` runs. Every row records p50 / p95 / max //! over its samples, the peak resident memory measured in-process while it ran, and the GPU //! bytes the canvas holds for the document (texture + compositor) when there is a GPU. +//! +//! A row whose samples time a command and then the canvas refresh after it also records each +//! part on its own (`cmd_samples_ms`, `refresh_samples_ms`; `samples_ms` stays their sum) and +//! what the refreshes did (`refresh_kinds`: `Refresh::kind` counts, also "rect" / "full" without +//! an adapter; `fallbacks`: `CpuReason::kind` of any refresh the wgpu compositor didn't draw). use std::time::Instant; @@ -21,7 +26,7 @@ use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_engine::Session; use photocraft_geom::Rect; use photocraft_raster::Surface; -use photocraft_testkit::perf::{RssSampler, report, row, write_report}; +use photocraft_testkit::perf::{RssSampler, Summary, report, row, write_report}; use photocraft_ui_egui::gpu_canvas::GpuCanvas; use serde_json::{Value, json}; @@ -102,6 +107,49 @@ struct Bench { errors: Vec<(String, String)>, /// The row being timed, so a panic inside it is reported against that row. current: Option, + /// Pixels the last `refresh` redrew (`Refresh.px`; the rect's area on the CPU). + last_px: std::cell::Cell, + /// Refreshed pixels per sample, for rows that report them (#9329); `record` prints the p50. + px: std::cell::RefCell>, + /// What the last `refresh` did: `Refresh::kind` and the `CpuReason::kind` of its fallback. + last_kind: std::cell::Cell<(&'static str, Option<&'static str>)>, + /// The current sample's command / refresh split, set by `after_cmd` or `drag_frame`. + split: std::cell::Cell>, + /// The (document id, revision) the canvas shows, as the app's canvas cache keeps it; `None` + /// when unknown (nothing refreshed yet, or a drag frame showed a document not in the session). + shown: std::cell::Cell>, + /// Revisions before the last edit that the last `refresh` also had to show (0 in the app's + /// one-edit-per-frame case; 1 when the canvas didn't show this document at all): setup work + /// that no untimed refresh caught up on. + last_missed: std::cell::Cell, +} + +/// One sample's command time and the canvas refresh after it. +#[derive(Clone, Copy, Debug)] +struct Split { + cmd_ms: f64, + refresh_ms: f64, + kind: &'static str, + fallback: Option<&'static str>, + /// See `Bench::last_missed`. + missed: u64, +} + +/// The p50 of `v` as the row's `p50_ms` takes it. +fn median(v: &[f64]) -> f64 { + Summary::of(v).map_or(f64::NAN, |s| s.p50) +} + +/// A GPU refresh's kind and fallback; `refresh_with` reports nothing to draw as `"rect"` (like +/// the CPU-compositor rect upload), which the bench calls `"none"` as its CPU path does. +fn gpu_kind(r: &photocraft_ui_egui::gpu_canvas::Refresh) -> (&'static str, Option<&'static str>) { + let fallback = r.fallback.as_ref().map(|f| f.kind()); + (if r.kind == "rect" && r.px == 0 && fallback.is_none() { "none" } else { r.kind }, fallback) +} + +/// The layer a command's result names (`{"layer": id}`), else the active layer it left. +fn added(s: &Session, result: &Value) -> Res { + result.get("layer").and_then(Value::as_u64).map_or_else(|| active_layer(s), Ok) } impl Bench { @@ -109,9 +157,8 @@ impl Bench { self.only.as_ref().is_none_or(|o| name.contains(o.as_str())) } - /// The canvas refresh after an edit (`canvas::ensure_gpu`): the damage rect grown by the - /// effect reach when the session reports one, else everything; waits for the GPU. - /// Re-composite only `r` of the active document (what the canvas hadn't shown yet). + /// Re-composite only `r` of the active document: the caller knows the canvas already shows + /// the rest of the current revision (a stroke's live preview, say). fn refresh_rect(&self, s: &Session, r: photocraft_geom::Rect) -> Res { let st = s.active().ok_or("no document")?; let doc = &st.doc; @@ -128,21 +175,53 @@ impl Bench { } } } + self.shown.set(Some((doc.id.0, st.revision))); Ok(ms(t)) } + /// The canvas refresh after an edit (`canvas::ensure_gpu`), with the damage the app's + /// `damage_since` gives it: nothing when the canvas shows this revision, the last edit's rect + /// grown by the effect reach when it showed the one before (and the session reports a rect), + /// else everything (`full`, or several edits since the last refresh); waits for the GPU. fn refresh(&self, s: &Session, full: bool) -> Res { let st = s.active().ok_or("no document")?; let doc = &st.doc; - let damage = if full { None } else { st.last_damage.map(|r| r.inflate(reach(&doc.layers)).intersect(&doc.bounds())) }; + // The revision of this document the canvas showed; `None` when it showed nothing or + // another document (a new one, a drag frame's, the previous group's). + let showed = self.shown.replace(Some((doc.id.0, st.revision))).filter(|&(id, _)| id == doc.id.0).map(|(_, rev)| rev); + let seen = showed.filter(|_| !full); + // Redrawing everything only because the canvas didn't show this document also draws + // its setup: count that as missed unless the row asked for a full refresh. + self.last_missed.set(match showed { + _ if full => 0, + Some(rev) => st.revision.saturating_sub(rev).saturating_sub(1), + None => 1, + }); + let damage = match seen { + Some(rev) if rev == st.revision => Some(Rect::EMPTY), + Some(rev) if rev.checked_add(1) == Some(st.revision) => st.last_damage.map(|r| r.inflate(reach(&doc.layers)).intersect(&doc.bounds())), + _ => None, + }; let t = Instant::now(); match &self.gpu { Some((g, rs)) => { - g.refresh(doc.id.0, doc, damage, None); + let r = g.refresh(doc.id.0, doc, damage, None); + self.last_px.set(r.px); + self.last_kind.set(gpu_kind(&r)); let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None }); } None => { let r = damage.unwrap_or(doc.bounds()).intersect(&doc.bounds()); + self.last_px.set(if r.is_empty() { 0 } else { r.size().area() }); + // The kinds `GpuCanvas::refresh` gives the CPU compositor's rect and full draws. + let kind = if r.is_empty() { + "none" + } else if damage.is_some() { + "rect" + } else { + "full" + }; + self.last_kind.set((kind, None)); if !r.is_empty() { std::hint::black_box(photocraft_compose::render(doc, r)); } @@ -151,6 +230,17 @@ impl Bench { Ok(ms(t)) } + /// The time of a command `t` started timing plus the canvas refresh after it (`full`: the + /// whole document), with the split kept for this sample's `cmd_samples_ms` / + /// `refresh_samples_ms`. + fn after_cmd(&self, s: &Session, t: Instant, full: bool) -> Res { + let cmd_ms = ms(t); + let refresh_ms = self.refresh(s, full)?; + let (kind, fallback) = self.last_kind.get(); + self.split.set(Some(Split { cmd_ms, refresh_ms, kind, fallback, missed: self.last_missed.get() })); + Ok(cmd_ms + refresh_ms) + } + /// Frame `i` of a Move tool drag of `layer` (#128): the canvas shows the document with the /// layer at the pointer (`layer_multi_cmds::moved`) and recomposites where it was in the /// previous frame and where it is now (`move_ui::damage`). @@ -164,16 +254,35 @@ impl Bench { let t = Instant::now(); let shown = photocraft_engine::layer_multi_cmds::moved(&doc, &[id], d, d / 2).map_err(|e| e.to_string())?; let damage = was.translate(prev, prev / 2).union(&was.translate(d, d / 2)).inflate(1).intersect(&doc.bounds()); - match &self.gpu { + let cmd_ms = ms(t); + let t = Instant::now(); + // The canvas now shows a document the session doesn't hold: the next refresh redraws all. + self.shown.set(None); + let kind = match &self.gpu { Some((g, rs)) => { - g.refresh(doc.id.0, &shown, Some(damage), None); + let r = g.refresh(doc.id.0, &shown, Some(damage), None); let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None }); + gpu_kind(&r) } None => { std::hint::black_box(photocraft_compose::render(&shown, damage)); + (if damage.is_empty() { "none" } else { "rect" }, None) } + }; + let refresh_ms = ms(t); + self.split.set(Some(Split { cmd_ms, refresh_ms, kind: kind.0, fallback: kind.1, missed: 0 })); + Ok(cmd_ms + refresh_ms) + } + + /// Frees the canvas's textures and compositor pages of documents not in `live`, as the app + /// does when a document closes (`GpuCanvas::retain`). + fn retain(&self, live: &[u64]) { + if let Some((g, _)) = &self.gpu { + g.retain(live); + } + if self.shown.get().is_some_and(|(id, _)| !live.contains(&id)) { + self.shown.set(None); } - Ok(ms(t)) } fn gpu_bytes(&self, s: &Session) -> Option { @@ -190,32 +299,68 @@ impl Bench { } self.current = Some(name.to_string()); self.sampler.reset(); + self.px.borrow_mut().clear(); let mut samples = Vec::new(); + let mut splits = Vec::new(); let mut run = || -> Res<()> { if warm { f(self, s, 0)?; + // `refresh px` describes only the timed runs. + self.px.borrow_mut().clear(); } for i in 0..reps.max(1) { + self.split.set(None); samples.push(f(self, s, i + 1)?); + splits.push(self.split.take()); } Ok(()) }; let result = run(); - self.record(name, s, &samples, result); + let splits: Option> = splits.into_iter().collect(); + self.record(name, s, &samples, splits.as_deref().unwrap_or_default(), result); } - fn record(&mut self, name: &str, s: &Session, samples: &[f64], result: Res<()>) { + /// Reports a row. `splits` (one per sample, or none) adds the command / refresh split. + fn record(&mut self, name: &str, s: &Session, samples: &[f64], splits: &[Split], result: Res<()>) { match result { Ok(()) => { - let r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s)); + let mut r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s)); + let mut split = String::new(); + if !splits.is_empty() && splits.len() == samples.len() { + let cmd: Vec = splits.iter().map(|x| x.cmd_ms).collect(); + let refresh: Vec = splits.iter().map(|x| x.refresh_ms).collect(); + let mut kinds = std::collections::BTreeMap::<&str, usize>::new(); + let mut fallbacks = std::collections::BTreeSet::<&str>::new(); + for x in splits { + *kinds.entry(x.kind).or_default() += 1; + fallbacks.extend(x.fallback); + } + split = format!(" cmd {:.2} + refresh {:.2} ms {kinds:?}", median(&cmd), median(&refresh)); + // A timed refresh that also drew earlier, untimed edits overstates this row. + let missed = splits.iter().filter(|x| x.missed > 0).count(); + if missed > 0 { + eprintln!("{name}: {missed} of {} timed refreshes also redrew setup edits no untimed refresh showed", splits.len()); + } + if let Some(o) = r.as_object_mut() { + o.insert("cmd_samples_ms".into(), json!(cmd)); + o.insert("refresh_samples_ms".into(), json!(refresh)); + o.insert("refresh_kinds".into(), json!(kinds)); + o.insert("fallbacks".into(), json!(fallbacks)); + } + } println!( - "{name:<52} p50 {:>9.2} ms p95 {:>9.2} ms max {:>9.2} ms (n {}, peak RSS {} MB)", + "{name:<52} p50 {:>9.2} ms p95 {:>9.2} ms max {:>9.2} ms (n {}, peak RSS {} MB){split}", r["p50_ms"].as_f64().unwrap_or(f64::NAN), r["p95_ms"].as_f64().unwrap_or(f64::NAN), r["max_ms"].as_f64().unwrap_or(f64::NAN), samples.len(), r["peak_rss_bytes"].as_u64().unwrap_or(0) >> 20, ); + let mut px = std::mem::take(&mut *self.px.borrow_mut()); + if !px.is_empty() { + px.sort_unstable(); + println!("{name:<52} refresh px {}", px[px.len() / 2]); + } self.rows.push(r); } Err(e) => { @@ -368,6 +513,12 @@ fn main() { rows: Vec::new(), errors: Vec::new(), current: None, + last_px: std::cell::Cell::new(0), + px: std::cell::RefCell::new(Vec::new()), + last_kind: std::cell::Cell::new(("none", None)), + split: std::cell::Cell::new(None), + shown: std::cell::Cell::new(None), + last_missed: std::cell::Cell::new(0), }; println!("perf_scenarios: {} mode, {} reps, {}", if quick { "quick" } else { "full" }, reps, adapter.as_deref().unwrap_or("CPU canvas")); let t_all = Instant::now(); @@ -375,7 +526,7 @@ fn main() { // A panic in one group (a crash the scenario found) is reported against the row that was // running, and the other groups still run. type Group = fn(&mut Bench, &Sizes); - let groups: [(&str, Group); 7] = [ + let groups: [(&str, Group); 8] = [ ("layered", layered_scenarios), ("many layers", many_layer_scenarios), ("full-canvas layers", full_canvas_layer_scenarios), @@ -383,6 +534,7 @@ fn main() { ("long operations", job_scenarios), ("algorithms", algorithm_scenarios), ("huge document", huge_document_scenarios), + ("redraw area", redraw_area_scenarios), ]; let use_gpu = b.gpu.is_some(); if let Some(wanted) = arg(&args, "--group") @@ -448,28 +600,28 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { let d = if i % 2 == 0 { 10 } else { -10 }; let t = Instant::now(); exec(s, "layer.translate", json!({"layer": target, "dx": d, "dy": d / 2}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time("move tool drag frame, styled layer", &mut s, reps, true, |b, s, i| b.drag_frame(s, target, i)); b.time("set layer opacity + refresh", &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time("set layer fill + refresh", &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": target, "fill": if i % 2 == 0 { 0.5 } else { 1.0 }}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time("set blend mode + refresh", &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": target, "blend": if i % 2 == 0 { "Multiply" } else { "Normal" }}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time("toggle visibility + refresh", &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": target, "visible": i % 2 == 0}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); let _ = exec(&mut s, "layer.setProps", json!({"layer": target, "visible": true})); b.time("undo move + refresh", &mut s, reps, true, |b, s, _| { @@ -477,7 +629,7 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { b.refresh(s, false)?; let t = Instant::now(); exec(s, "edit.undo", json!({}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time("redo opacity change + refresh", &mut s, reps, true, |b, s, i| { exec(s, "layer.setProps", json!({"layer": target, "opacity": 0.5 + 0.02 * i as f64}))?; @@ -485,7 +637,7 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { b.refresh(s, false)?; let t = Instant::now(); exec(s, "edit.redo", json!({}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); // Advanced Blending on one layer sends the whole document to the CPU compositor (#6689): @@ -498,7 +650,7 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { b.time("set opacity on a layer with Advanced Blending + refresh", &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); let _ = exec(&mut s, "layer.layerStyle.blendingOptions", json!({"layer": other, "knockout": "none"})); let _ = b.refresh(&s, true); @@ -511,7 +663,7 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { b.time("toggle adjustment layer visibility + refresh", &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": adj, "visible": i % 2 == 1}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); let _ = exec(&mut s, "layer.setProps", json!({"layer": adj, "visible": true})); let _ = exec(&mut s, "layer.select", json!({"layer": target})); @@ -541,48 +693,75 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { Err(e) => b.errors.push(("full-canvas styled layer".into(), e)), } - // A 12 MP layer, duplicated and pasted: the first time (a fresh layer, cold caches) and - // again (the same source). + // A 12 MP layer, duplicated and pasted: the first time (a fresh layer, so the copy's caches + // are cold) and again (the same source). The fresh layer is on screen before the timer + // starts, as in the app (its setup is several edits, so that untimed refresh redraws + // everything). + // Each "first" sample deletes the layers it added (untimed), so every sample lands on the + // same stack instead of one two 12 MP layers taller each time. let (lw, lh) = sz.big_layer; - let fresh_layer = |s: &mut Session| -> Res { + let (lx, ly) = ((w - lw) / 2, (h - lh) / 2); + let fresh_layer = |b: &Bench, s: &mut Session| -> Res { exec(s, "layer.new.layer", json!({"name": "12 MP"}))?; - exec(s, "select.rect", json!({"x": (w - lw) / 2, "y": (h - lh) / 2, "width": lw, "height": lh}))?; + exec(s, "select.rect", json!({"x": lx, "y": ly, "width": lw, "height": lh}))?; exec(s, "edit.fill", json!({"color": "#3070a0"}))?; exec(s, "select.deselect", json!({}))?; + b.refresh(s, false)?; active_layer(s) }; - let reps_big = reps.min(5); - let mut src = 0; - b.time("duplicate 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| { - src = fresh_layer(s)?; - b.refresh(s, false)?; - let t = Instant::now(); - exec(s, "layer.duplicate", json!({"layer": src}))?; - Ok(ms(t) + b.refresh(s, false)?) - }); - b.time("duplicate 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| { - let t = Instant::now(); - exec(s, "layer.duplicate", json!({"layer": src}))?; - let v = ms(t) + b.refresh(s, false)?; - exec(s, "edit.undo", json!({}))?; - b.refresh(s, false)?; - Ok(v) - }); - b.time("paste 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| { - let id = fresh_layer(s)?; + let delete_layers = |b: &Bench, s: &mut Session, ids: &[u64]| -> Res<()> { + for &id in ids { + exec(s, "layer.delete", json!({"layer": id}))?; + } + b.refresh(s, false).map(|_| ()) + }; + let copy_layer = |b: &Bench, s: &mut Session, id: u64| -> Res<()> { exec(s, "layer.select", json!({"layer": id}))?; exec(s, "select.all", json!({}))?; exec(s, "edit.copy", json!({}))?; exec(s, "select.deselect", json!({}))?; - b.refresh(s, false)?; + b.refresh(s, false).map(|_| ()) + }; + let reps_big = reps.min(5); + b.time("duplicate 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| { + let src = fresh_layer(b, s)?; let t = Instant::now(); - exec(s, "edit.paste", json!({}))?; - Ok(ms(t) + b.refresh(s, false)?) + let copy = exec(s, "layer.duplicate", json!({"layer": src}))?; + let v = b.after_cmd(s, t, false)?; + delete_layers(b, s, &[added(s, ©)?, src])?; + Ok(v) }); - b.time("paste 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| { + b.time("paste 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| { + let src = fresh_layer(b, s)?; + copy_layer(b, s, src)?; + let t = Instant::now(); + let pasted = exec(s, "edit.paste", json!({}))?; + let v = b.after_cmd(s, t, false)?; + delete_layers(b, s, &[added(s, &pasted)?, src])?; + Ok(v) + }); + + // The source the repeat rows duplicate and paste (copied once), only when they run. + let repeat_rows = ["duplicate 12 MP layer (repeat) + refresh", "paste 12 MP layer (repeat) + refresh"]; + if !repeat_rows.iter().any(|r| b.wanted(r)) { + return; + } + let src = match fresh_layer(b, &mut s).and_then(|id| copy_layer(b, &mut s, id).map(|()| id)) { + Ok(id) => id, + Err(e) => return b.errors.push(("12 MP source layer".into(), e)), + }; + b.time("duplicate 12 MP layer (repeat) + refresh", &mut s, reps_big, true, |b, s, _| { + let t = Instant::now(); + exec(s, "layer.duplicate", json!({"layer": src}))?; + let v = b.after_cmd(s, t, false)?; + exec(s, "edit.undo", json!({}))?; + b.refresh(s, false)?; + Ok(v) + }); + b.time("paste 12 MP layer (repeat) + refresh", &mut s, reps_big, true, |b, s, _| { let t = Instant::now(); exec(s, "edit.paste", json!({}))?; - let v = ms(t) + b.refresh(s, false)?; + let v = b.after_cmd(s, t, false)?; exec(s, "edit.undo", json!({}))?; b.refresh(s, false)?; Ok(v) @@ -639,12 +818,12 @@ fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { b.time(opacity, &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": mid, "opacity": if i % 2 == 0 { 0.5 } else { 1.0 }}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time(hide, &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": mid, "visible": i % 2 == 1}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); // #6533: an adjustment among them, added after the rows above so their document is unchanged. // It lands on top (above the active `full n-1`), #6533's "hide the top adjustment layer" case. @@ -666,7 +845,7 @@ fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { b.refresh(s, false)?; let t = Instant::now(); exec(s, "layer.setProps", json!({"layer": adj, "visible": false}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); } Err(e) => b.errors.push((adjust.to_string(), e)), @@ -736,13 +915,13 @@ fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) { b.time(&name, &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.translate", json!({"layer": mid, "dx": if i % 2 == 0 { 1 } else { -1 }, "dy": 0}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); // Layer › New › Layer: as fast at any layer count. Undone (untimed) so the count stays put. b.time(new_layer, &mut s, reps, true, |b, s, _| { let t = Instant::now(); exec(s, "layer.new.layer", json!({}))?; - let took = ms(t) + b.refresh(s, false)?; + let took = b.after_cmd(s, t, false)?; exec(s, "edit.undo", json!({}))?; b.refresh(s, false)?; Ok(took) @@ -787,12 +966,12 @@ fn depth_scenarios(b: &mut Bench, sz: &Sizes) { let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400)); let t = Instant::now(); exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 60, "hardness": 0.8, "color": "#c03020"}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); b.time(&mv, &mut s, reps, true, |b, s, i| { let t = Instant::now(); exec(s, "layer.translate", json!({"layer": layer, "dx": if i % 2 == 0 { 10 } else { -10 }, "dy": 0}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); } } @@ -810,12 +989,18 @@ fn job_scenarios(b: &mut Bench, sz: &Sizes) { Ok(psd) => { let mut holder = Session::new(); b.time(open, &mut holder, reps, true, |b, s, _| { + // Close the previous sample's document before the timer, on the GPU too (each + // import has a new id, so its pages would otherwise stay resident): its + // teardown isn't decode. + drop(std::mem::replace(s, Session::new())); + b.retain(&[]); let t = Instant::now(); let d = photocraft_io::import("layered.psd", &psd).map_err(|e| e.to_string())?.document; - *s = Session::new(); s.open_document(d, Some("layered.psd".into())); - Ok(ms(t) + b.refresh(s, true)?) + b.after_cmd(s, t, true) }); + drop(holder); + b.retain(&[]); } Err(e) => b.errors.push((open.into(), e)), } @@ -835,12 +1020,14 @@ fn job_scenarios(b: &mut Bench, sz: &Sizes) { exec(s, "select.deselect", json!({}))?; Ok(v) }); + // The canvas shows this document (and the fill's undone edits) before the timer. + let _ = b.refresh(&s, true); b.time(heal, &mut s, reps, false, |b, s, i| { let (x, y) = (w / 4 + i as u32 * 60, h / 4); let pts: Vec = (0..10).map(|k| json!([x + k * 12, y + k * 4])).collect(); let t = Instant::now(); exec(s, "paint.spotHealing", json!({"points": pts, "size": 60}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); } Err(e) => b.errors.push((caf.into(), e)), @@ -927,7 +1114,7 @@ fn background_job_scenarios(b: &mut Bench, sz: &Sizes) { // The UI thread's work: starting the job, then each frame's poll (the last one // applies the 500 presets). let t = Instant::now(); - let job = start_job(s, "brush.presets.importAbr", json!({"data": data, "group": "Bench"}))?; + let job = start_job(s, "brush.presets.importAbr", json!({"data": data, "group": "Bench", "replace": true}))?; let mut worst = ms(t); let t0 = Instant::now(); loop { @@ -952,6 +1139,70 @@ fn background_job_scenarios(b: &mut Bench, sz: &Sizes) { if b.wanted(frame) { frame_scenario(b, frame, w, h); } + let full = "app frame: full CPU refresh of a 24 MP document"; + if b.wanted(full) { + full_refresh_frame_scenario(b, full, w, h); + } +} + +/// #9328: the whole app (kittest harness, CPU canvas) with a 24 MP document; each sample toggles +/// a layer's visibility and steps one frame, and every frame recomposites all 24 MP on the CPU. +/// Always 10 samples, whatever `--reps` says. +/// +/// The stack is the photo plus three full-canvas copies in blend modes, and the top copy toggles. +/// Toggling the photo's only layer (the first version) measured 3.4 ms: with it hidden no layer +/// is visible, so the reduced composite's one-colour shortcut (`compose::uniform`) draws the whole +/// canvas as one pixel. Here the photo stays visible in every frame, its noisy tiles are never one +/// colour, and the toggled layer's damage is the whole canvas. Each frame checks that the canvas +/// refresh it recorded was a CPU one covering the whole document (within the reduction's rounding). +fn full_refresh_frame_scenario(b: &mut Bench, name: &str, w: u32, h: u32) { + let mut s = match open_photo(w, h) { + Ok(s) => s, + Err(e) => return b.errors.push((name.into(), e)), + }; + let built = (|| -> Res { + let photo = active_layer(&s)?; + for (blend, opacity) in [("Multiply", 0.7), ("Screen", 0.5), ("Overlay", 0.6)] { + exec(&mut s, "layer.duplicate", json!({"layer": photo}))?; + let copy = active_layer(&s)?; + if copy == photo { + return Err("layer.duplicate did not select the copy".into()); + } + exec(&mut s, "layer.setProps", json!({"layer": copy, "blend": blend, "opacity": opacity}))?; + } + active_layer(&s) + })(); + let top = match built { + Ok(l) => l, + Err(e) => return b.errors.push((name.into(), e)), + }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(4).build_eframe(move |cc| { + photocraft_ui_egui::PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + photocraft_ui_egui::PhotocraftApp::new(s, photocraft_ui_egui::Services::default()) + }); + harness.run_steps(6); + b.current = Some(name.to_string()); + b.sampler.reset(); + let mut samples = Vec::new(); + let pixels = u64::from(w) * u64::from(h); + let run = (|| -> Res<()> { + for i in 0..10 { + harness.state_mut().run("layer.setProps", json!({"layer": top, "visible": i % 2 == 1}))?; + harness.state_mut().perf.last_refresh_px = 0; + let t = Instant::now(); + harness.step(); + samples.push(ms(t)); + // A damage refresh counts its reduced texels times the factor squared, which can fall + // a few rows and columns short of the document when the factor doesn't divide it. + let perf = &harness.state().perf; + if !matches!(perf.last_refresh, "full" | "rect") || perf.last_refresh_px.saturating_mul(100) < pixels.saturating_mul(95) { + return Err(format!("frame {i} refreshed {} px ({}), not the whole {pixels} px on the CPU", perf.last_refresh_px, perf.last_refresh)); + } + } + Ok(()) + })(); + let st = std::mem::take(&mut harness.state_mut().session); + b.record(name, &st, &samples, &[], run); } /// The whole app (kittest harness, CPU canvas) with a 24 MP document: frame times while a @@ -994,7 +1245,7 @@ fn frame_scenario(b: &mut Bench, name: &str, w: u32, h: u32) { })(); // The rows' document handle: the harness's session. let st = std::mem::take(&mut harness.state_mut().session); - b.record(name, &st, &samples, run); + b.record(name, &st, &samples, &[], run); } /// #211 rows: selection algorithms, Content-Aware Scale and Select Subject on 24 MP, and a brush @@ -1058,7 +1309,7 @@ fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) { let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400)); let t = Instant::now(); exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 40, "hardness": 0.8, "color": "#c03020"}))?; - Ok(ms(t) + b.refresh(s, false)?) + b.after_cmd(s, t, false) }); } // P32: releasing a long stroke. The canvas draws it live (untimed here); the release offers @@ -1197,5 +1448,101 @@ fn liquify_preview_scenario(b: &mut Bench) { if let Some(median) = low_sorted.get(low_sorted.len() / 2) { println!(" existing 1600 px proxy update p50 {median:.2} ms (lower-resolution output)"); } - b.record(name, &Session::new(), &samples, Ok(())); + b.record(name, &Session::new(), &samples, &[], Ok(())); +} + +/// The share of the canvas `r` covers. +fn coverage(doc: &photocraft_doc::Document, r: Rect) -> f64 { + r.intersect(&doc.bounds()).size().area() as f64 / doc.bounds().size().area().max(1) as f64 +} + +/// The content bounds of `layer`'s pixels. +fn layer_bounds(s: &Session, layer: u64) -> Res { + let doc = &s.active().ok_or("no document")?.doc; + doc.layer(photocraft_doc::LayerId(layer)).and_then(|l| l.surface()).map(|p| p.content_bounds()).ok_or_else(|| "layer has no pixels".to_string()) +} + +/// A document with one painted layer covering the whole canvas; returns it and the layer. +fn full_layer_doc(w: u32, h: u32, depth: u32) -> Res<(Session, u64)> { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"width": w, "height": h, "depth": depth, "background": "white"}))?; + exec(&mut s, "layer.new.layer", json!({"name": "full"}))?; + exec(&mut s, "edit.fill", json!({"color": "#6080a0"}))?; + let layer = active_layer(&s)?; + let doc = s.active().ok_or("no document")?.doc.clone(); + let cover = coverage(&doc, layer_bounds(&s, layer)?); + if cover < 0.95 { + return Err(format!("path check: the painted layer covers {:.1} % of the canvas, not >= 95 %", cover * 100.0)); + } + Ok((s, layer)) +} + +/// #9329 PR A: the redraw area of edits on a full-canvas layer, where redrawing the whole +/// document is the expensive cliff. Each row checks it hits that path and prints `refresh px`. +fn redraw_area_scenarios(b: &mut Bench, sz: &Sizes) { + let undo = "undo a brush dab on a full-canvas 16-bit 15000x10000 layer + refresh"; + if b.wanted(undo) { + b.current = Some(undo.to_string()); + let (w, h) = sz.huge16; + match full_layer_doc(w, h, 16) { + Err(e) => b.errors.push((undo.to_string(), e)), + Ok((mut s, _)) => { + let _ = b.refresh(&s, true); + // Two 1.2 GB layers and, before the fix, a 150 MP refresh per sample. + let reps = b.reps.min(5); + b.time(undo, &mut s, reps, true, |b, s, i| { + let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400)); + exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 60, "hardness": 0.8, "color": "#c03020"}))?; + b.refresh(s, false)?; + let before = s.active().ok_or("no document")?.doc.clone(); + let t = Instant::now(); + exec(s, "edit.undo", json!({}))?; + let took = b.after_cmd(s, t, false)?; + b.px.borrow_mut().push(b.last_px.get()); + let after = s.active().ok_or("no document")?.doc.clone(); + if photocraft_engine::layer_multi_cmds::step_damage(&before, &after).is_some_and(|r| r.is_empty()) { + return Err("path check: the undo changed no pixels".into()); + } + Ok(took) + }); + } + } + } + + let fill = "fill a 2 % selection on a full-canvas 24 MP layer + refresh"; + if b.wanted(fill) { + b.current = Some(fill.to_string()); + let (w, h) = sz.big_photo; + let built = full_layer_doc(w, h, 8).and_then(|(mut s, layer)| { + exec(&mut s, "select.rect", json!({"x": w / 3, "y": h / 3, "width": w / 8, "height": h / 8}))?; + let doc = s.active().ok_or("no document")?.doc.clone(); + let sel = doc.selection.as_ref().map(|m| m.content_bounds()).ok_or("no selection")?; + let share = coverage(&doc, sel); + if share > 0.02 { + return Err(format!("path check: the selection covers {:.2} % of the canvas, not <= 2 %", share * 100.0)); + } + Ok((s, layer)) + }); + match built { + Err(e) => b.errors.push((fill.to_string(), e)), + Ok((mut s, _)) => { + let _ = b.refresh(&s, true); + let reps = b.reps; + b.time(fill, &mut s, reps, true, |b, s, i| { + // The colour alternates, so every sample changes pixels. + let color = if i % 2 == 0 { "#20c040" } else { "#c02040" }; + let before = s.active().ok_or("no document")?.doc.clone(); + let t = Instant::now(); + exec(s, "edit.fill", json!({"color": color}))?; + let took = b.after_cmd(s, t, false)?; + b.px.borrow_mut().push(b.last_px.get()); + let after = s.active().ok_or("no document")?.doc.clone(); + if photocraft_engine::layer_multi_cmds::step_damage(&before, &after).is_some_and(|r| r.is_empty()) { + return Err("path check: the fill changed no pixels".into()); + } + Ok(took) + }); + } + } + } } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 7fa07a10bc..8429b1c466 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -22,6 +22,8 @@ //! `--right-click-at X,Y` opens a screen-space context menu after the script, including panel //! and document-tab menus that are outside the document-coordinate control pointer. //! `--click-at X,Y` opens a screen-space menu (for example the top Select menu) after the script. +//! `--hover-at X,Y` leaves the pointer there without a click, for what a tool shows under it (the +//! brush outline, the Clone Source overlay). use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; use photocraft_ui_egui::{PhotocraftApp, Services}; @@ -170,6 +172,10 @@ fn main() { harness.event(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE }); harness.run_steps(4); } + if let Some((x, y)) = arg(&args, "--hover-at").and_then(|s| s.split_once(',').and_then(|(x, y)| Some((x.parse::().ok()?, y.parse::().ok()?)))) { + harness.event(egui::Event::PointerMoved(egui::pos2(x, y))); + harness.run_steps(4); + } let t_settle = std::time::Instant::now(); while t_settle.elapsed() < std::time::Duration::from_millis(settle_ms) { harness.step(); diff --git a/crates/ui-egui/src/alt_click_tests.rs b/crates/ui-egui/src/alt_click_tests.rs index 2bbc1a673e..0c3ceda8e8 100644 --- a/crates/ui-egui/src/alt_click_tests.rs +++ b/crates/ui-egui/src/alt_click_tests.rs @@ -326,3 +326,19 @@ fn alt_click_with_the_zoom_tool_zooms_out_held_or_latched() { assert_eq!(h.state().ui.views[0].zoom, 1.0, "sticky={sticky} scrubby={scrubby}"); } } + +#[test] +fn alt_click_with_the_mixer_brush_loads_the_reservoir_not_the_foreground() { + let mut h = harness(two_colours()); + h.state_mut().ui.tool = Tool::MixerBrush; + h.state_mut().run("tools.setColors", json!({"foreground": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let rev = h.state().session.active().unwrap().revision; + assert!(h.state().session.tools.mixer.reservoir.is_none()); + click(&mut h, 50.0, 50.0, Modifiers::ALT); + assert_eq!(h.state().session.tools.mixer.reservoir, Some([1.0, 0.0, 0.0, 1.0]), "⌥-click loaded red into the reservoir"); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0], "the foreground colour is unchanged"); + assert_eq!(h.state().session.active().unwrap().revision, rev, "nothing was painted"); + click(&mut h, 150.0, 50.0, Modifiers::ALT); + assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 1.0, 0.0, 1.0]), "⌥-click loaded green"); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0]); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e5f987c71d..8ab43724d7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -4,9 +4,9 @@ use super::{ brush_picker, camera_raw_ui, canvas, dds_options_ui, delete_layer_prompt, dialogs, discard_ui, distort_ui, dock, field_wheel, file_open, gpu_canvas, gpu_status, gradient_ui, histogram_panel, i18n, jobs_ui, jpeg_options_ui, json, kys_import, last_brush, layers_panel_ui, mask_thumbs_ui, mask_trash_prompt, monitor_status, native_menu, notices, open_prompt, paint_mouse, palette, panel_docking, panels, prefs_ui, preset_files_ui, preset_panels, rasterize_prompt, - screen_picker, shape_stroke_ui, shortcuts, single_line_drag, state, stroke_trace, stylus, targa_options_ui, text_field_menu, theme, tiff_options_ui, - timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, type_transform, updates_ui, vector_ui, view_cmds, wheel_nav, - wide_angle_ui, widgets, work_area, workspace_ui, + screen_picker, shape_stroke_ui, shortcuts, single_line_drag, smart_svg_ui, state, stroke_trace, stylus, targa_options_ui, text_field_menu, theme, + tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, type_transform, updates_ui, vector_ui, + view_cmds, wheel_nav, wide_angle_ui, widgets, work_area, workspace_ui, }; mod clipboard; @@ -43,10 +43,12 @@ impl PhotocraftApp { display_memo: HashMap::new(), navigator_gpu: HashMap::new(), navigator_plain: std::collections::HashSet::new(), + streams: HashMap::new(), info_samplers: None, monitors: Default::default(), checker: None, drag: None, + cancelled_vector_drag: false, move_blocked: false, tool_override: None, live_stroke: None, @@ -98,6 +100,7 @@ impl PhotocraftApp { tab_strip: None, drop_places: Default::default(), view_docs: Vec::new(), + path_document: None, fps: 0.0, last_frame_time: 0.0, thumbs: HashMap::new(), @@ -122,6 +125,7 @@ impl PhotocraftApp { synthetic_reinject: false, synthetic_snapshot: None, automation_input: false, + run_depth: 0, paused_playbacks: Default::default(), channel_thumbs: None, channel_views: HashMap::new(), @@ -129,6 +133,7 @@ impl PhotocraftApp { type_clicks: None, type_transform_preview: None, type_font_preview: None, + color_picker_preview: None, guide_drag: None, layer_box: None, crop: Default::default(), @@ -139,9 +144,11 @@ impl PhotocraftApp { clone_preview: None, info_sample: None, os_clip_sig: None, + os_clip_svg: None, clip_unmirrored: true, clip_external: false, clip_read_for_paste: false, + smart_svg: Default::default(), transform_preview: None, move_mods: Default::default(), nudge_bundle: Default::default(), @@ -150,6 +157,7 @@ impl PhotocraftApp { solid_fill_preview: None, pattern_fill_preview: None, shape_fill_preview: None, + widget_colors: Vec::new(), distort: Default::default(), gradient: Default::default(), camera_raw: None, @@ -157,6 +165,7 @@ impl PhotocraftApp { raw_redevelop: Vec::new(), wide_angle: None, dialog_edits: Default::default(), + color_picker_live: Vec::new(), pending_after_prompt: None, tone_hist: None, doc_hist: None, @@ -174,17 +183,22 @@ impl PhotocraftApp { perf: Default::default(), stroke_trace: stroke_trace::StrokeTrace::from_env(), render_path: Default::default(), + slow_frames: Default::default(), prefs_rt: Default::default(), updates_rt: Default::default(), discard: None, file_dialog: None, tiff_options: None, + tiff_remembered: Default::default(), save_dirs: HashMap::new(), disk_stamps: HashMap::new(), targa_options: None, jpeg_options: None, dds_options: None, held_opens: Default::default(), + held_memory_prompts: Default::default(), + prompt_order: Default::default(), + held_preset_import: None, allow_close: false, stylus: Default::default(), control_click: Default::default(), @@ -256,14 +270,20 @@ impl eframe::App for PhotocraftApp { if self.custom_titlebar { titlebar::release_after_os_resize(ctx, raw_input); } - if self.stroke_trace.enabled() { - // The OS's moves and the control channel's (injected just above). - let moves = raw_input.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); - self.stroke_trace.begin_frame(frame_start, moves); - } + // The OS's moves and the control channel's (injected just above). + let moves = raw_input.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); + self.perf.stage.start(frame_start, moves); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + photocraft_compose::discard_unreadable_pixel_caches(); + if let Err(error) = photocraft_raster::spill::check_integrity() { + self.ui.status = error; + self.ui.status_error = true; + } else if let Some(error) = photocraft_raster::spill::last_error() { + self.ui.status = error; + self.ui.status_error = true; + } self.ui_frame(ui, frame); self.finish_type_reset_pass(ui.ctx()); } diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 5a3cad5012..54d4de90b9 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -8,7 +8,76 @@ fn clip_signature(w: u32, h: u32, px: &[u8]) -> u64 { sig } +/// Match the bitmap signature by sampling the bounded SVG mirror, without another full pixel buffer. +fn svg_clip_signature(clip: &photocraft_engine::edit_cmds::Clip) -> u64 { + let b = clip.bounds; + let (w, h) = (b.width(), b.height()); + let mut sig = (u64::from(w) << 32) | u64::from(h); + if w == 0 || h == 0 { + return sig; + } + for i in (0..b.size().area().saturating_mul(4)).step_by(997) { + let x = b.x0.saturating_add((i / 4 % u64::from(w)) as i32); + let y = b.y0.saturating_add((i / 4 / u64::from(w)) as i32); + let mut pixel = [[0; 4]]; + clip.surface.read_rgba8_into(photocraft_geom::Rect::from_xywh(x, y, 1, 1), &mut pixel); + let value = pixel[0].get((i % 4) as usize).copied().unwrap_or_default(); + sig = sig.rotate_left(5) ^ u64::from(value); + } + sig +} + impl PhotocraftApp { + /// Capture SVG and its raster mirror together; failures leave the previous copy intact. + pub(super) fn import_os_svg_clipboard(&mut self) -> Result { + let Some(get) = self.services.clipboard_get_svg.as_mut() else { return Ok(false) }; + let source = match get() { + Ok(source) => source, + Err(_) if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() => return Ok(false), + Err(error) => { + self.ui.status = error.clone(); + self.ui.status_error = true; + return Err(error); + } + }; + let Some(source) = source else { + self.os_clip_svg = None; + // Bitmap equality cannot prove that an earlier external vector source is current. + if self.clip_external && self.session.clipboard.as_ref().is_some_and(|clip| clip.svg.is_some()) { + self.session.clipboard = None; + self.os_clip_sig = None; + self.clip_external = false; + self.clip_unmirrored = true; + } + return Ok(false); + }; + // Already imported, or a newer internal copy: either stays until the external bytes + // change. A raster mirror an ordinary paste took of these bytes is not their vectors. + let holds_vectors_or_newer = self.session.clipboard.as_ref().is_some_and(|clip| clip.svg.is_some() || !self.clip_external); + if self.os_clip_svg.as_deref().is_some_and(|old| old == &source) && holds_vectors_or_newer { + return Ok(true); + } + let clip = photocraft_engine::edit_cmds::Clip::from_svg(&source).map_err(|error| { + let error = error.to_string(); + self.ui.status = error.clone(); + self.ui.status_error = true; + error + })?; + self.os_clip_sig = Some(svg_clip_signature(&clip)); + self.os_clip_svg = clip.svg.clone(); + self.session.clipboard = Some(clip); + self.clip_external = true; + self.clip_unmirrored = false; + Ok(true) + } + + /// The SVG bytes on the OS clipboard right now, read on an explicit paste without importing + /// them; `None` when there is no SVG service or the read fails (nothing is known then). + fn os_svg_now(&mut self) -> Option>>> { + let get = self.services.clipboard_get_svg.as_mut()?; + get().ok().map(|source| source.map(std::sync::Arc::new)) + } + /// Mirror the session clipboard onto the OS clipboard (RGBA8). pub(super) fn export_os_clipboard(&mut self) { self.clip_unmirrored = true; @@ -24,6 +93,7 @@ impl PhotocraftApp { clip.surface.read_rgba8_into(b, &mut px); let bytes: Vec = px.into_iter().flatten().collect(); if set(b.width(), b.height(), &bytes).is_ok() { + self.os_clip_svg = None; self.os_clip_sig = Some(clip_signature(b.width(), b.height(), &bytes)); self.clip_unmirrored = false; } @@ -59,7 +129,19 @@ impl PhotocraftApp { }; let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { - if self.os_clip_sig.take().is_some() && !self.clip_unmirrored { + if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() { + self.os_clip_sig = None; + // Keep remembering an SVG only while it is still there (an SVG-only clipboard), + // so a later copy of the same bytes in another app is imported again. + if let Some(now) = self.os_svg_now() + && now != self.os_clip_svg + { + self.os_clip_svg = None; + } + return Ok(false); + } + self.os_clip_svg = None; + if (self.os_clip_sig.take().is_some() || self.clip_external) && !self.clip_unmirrored { self.session.clipboard = None; self.clip_external = false; self.clip_unmirrored = true; @@ -73,7 +155,11 @@ impl PhotocraftApp { let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes); surface.prune(); - self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { surface, bounds: r, layers: None, resolution: None, source: None }); + self.session.clipboard = + Some(photocraft_engine::edit_cmds::Clip { components: None, surface, bounds: r, layers: None, resolution: None, source: None, svg: None }); + // The SVG that came with this image (a vector app puts both): a later internal copy is + // newer than it, so Paste As must not bring it back while it is unchanged. + self.os_clip_svg = self.os_svg_now().flatten(); self.os_clip_sig = Some(sig); self.clip_external = true; self.clip_unmirrored = false; diff --git a/crates/ui-egui/src/app/clipboard_tests.rs b/crates/ui-egui/src/app/clipboard_tests.rs index 65318b174f..956776a50e 100644 --- a/crates/ui-egui/src/app/clipboard_tests.rs +++ b/crates/ui-egui/src/app/clipboard_tests.rs @@ -58,6 +58,128 @@ fn os_clipboard_honours_export_clipboard_preference() { type OsClip = Arc)>>>; +const CLIP_SVG: &str = r##""##; + +#[test] +fn svg_clipboard_paste_as_keeps_source_and_creates_the_requested_content() { + for mode in ["pixels", "path", "shape"] { + let services = Services { clipboard_get_svg: Some(Box::new(|| Ok(Some(CLIP_SVG.as_bytes().to_vec())))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 24, "height": 20})).unwrap(); + let (layers, paths) = { + let doc = &app.session.active().unwrap().doc; + (doc.layers.len(), doc.paths.len()) + }; + app.run("edit.paste", json!({"as": mode})).unwrap(); + let state = app.session.active().unwrap(); + if mode == "path" { + assert_eq!(state.doc.paths.len(), paths + 1); + assert_eq!(state.doc.layers.len(), layers); + } else { + let content = &state.doc.layer(state.active_layer.unwrap()).unwrap().content; + assert!(match mode { + "pixels" => matches!(content, photocraft_doc::LayerContent::Raster(_)), + _ => matches!(content, photocraft_doc::LayerContent::Shape(_)), + }); + } + let source = app.session.clipboard.as_ref().unwrap().svg.clone().unwrap(); + assert_eq!(source.as_slice(), CLIP_SVG.as_bytes()); + app.run("edit.undo", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.layers.len(), doc.paths.len()), (layers, paths)); + app.run("edit.redo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&source, app.session.clipboard.as_ref().unwrap().svg.as_ref().unwrap())); + } +} + +#[test] +fn svg_clipboard_errors_preserve_the_copy_and_empty_reads_invalidate_external_source() { + let response = Arc::new(Mutex::new(Ok(Some(CLIP_SVG.as_bytes().to_vec())))); + let read = response.clone(); + let services = Services { clipboard_get_svg: Some(Box::new(move || read.lock().unwrap().clone())), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 24, "height": 20})).unwrap(); + app.run("edit.paste", json!({"as": "path"})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let source = app.session.clipboard.as_ref().unwrap().svg.clone().unwrap(); + for failure in [Err("clipboard is busy".into()), Ok(Some(b""##; + for params in [json!({}), json!({"as":"pixels"})] { + let image = OsClip::default(); + let read = image.clone(); + let services = Services { + clipboard_get_svg: Some(Box::new(|| Ok(Some(SOURCE.as_bytes().to_vec())))), + clipboard_get_image: Some(Box::new(move || Ok(read.lock().unwrap().clone()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width":64,"height":64})).unwrap(); + app.import_os_svg_clipboard().unwrap(); + let clip = app.session.clipboard.as_ref().unwrap(); + let b = clip.bounds; + let mut pixels = vec![[0; 4]; b.size().area() as usize]; + clip.surface.read_rgba8_into(b, &mut pixels); + let bytes: Vec<_> = pixels.into_iter().flatten().collect(); + let signature = bytes.iter().step_by(997).fold((u64::from(b.width()) << 32) | u64::from(b.height()), |sig, byte| sig.rotate_left(5) ^ u64::from(*byte)); + assert_eq!(app.os_clip_sig, Some(signature), "match the image signature across all four RGBA components"); + *image.lock().unwrap() = Some((b.width(), b.height(), bytes)); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = false); + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("edit.fill", json!({"color":"#0000ff"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + app.run("edit.paste", params).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + assert!(!app.clip_external, "the unchanged older SVG must not replace the newer internal copy"); + } +} + +#[test] +fn action_vector_paste_reads_the_current_svg_source() { + let services = Services { clipboard_get_svg: Some(Box::new(|| Ok(Some(CLIP_SVG.as_bytes().to_vec())))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 24, "height": 20})).unwrap(); + app.session.actions.list.push(photocraft_engine::actions_cmds::Action::new("SVG", vec![("edit.paste".into(), json!({"as": "shape"}))])); + let result = app.run("actions.play", json!({"action": "SVG"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_))); +} + #[test] fn failed_clipboard_reads_preserve_editable_contents_until_retry() { use std::sync::atomic::{AtomicBool, Ordering}; @@ -458,3 +580,89 @@ fn targeting_a_mask_swaps_to_its_own_colour_pair() { app.run("layer.layerMask.delete", json!({})).unwrap(); assert_eq!(pair(&app), (GREEN, BLUE), "deleting the targeted mask goes back to the pixels"); } + +type SvgClip = Arc>, String>>>; + +/// An app whose OS clipboard holds the SVG `svg` and the bitmap `image`, exporting nothing +/// (so an internal copy stays newer than what the OS clipboard holds). +fn app_with_svg_and_image(svg: &SvgClip, image: &OsClip) -> PhotocraftApp { + let (s, i) = (svg.clone(), image.clone()); + let services = Services { + clipboard_get_svg: Some(Box::new(move || s.lock().unwrap().clone())), + clipboard_get_image: Some(Box::new(move || Ok(i.lock().unwrap().clone()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = false); + app +} + +fn copy_blue(app: &mut PhotocraftApp) { + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("edit.fill", json!({"color":"#0000ff"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); +} + +/// An ordinary paste takes the bitmap a vector app put next to its SVG; a later internal copy +/// is newer than that SVG, so Paste As Pixels keeps it while the SVG is unchanged +/// (CodeRabbit on #10776). +#[test] +fn an_internal_copy_after_an_ordinary_paste_survives_an_unchanged_svg() { + let svg: SvgClip = Arc::new(Mutex::new(Ok(Some(CLIP_SVG.as_bytes().to_vec())))); + let image: OsClip = Arc::new(Mutex::new(Some((2, 1, vec![200; 8])))); + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + assert!(app.clip_external); + copy_blue(&mut app); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + assert!(!app.clip_external); + // Right after the ordinary paste (no internal copy), Paste As still reaches the vectors. + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + app.run("edit.paste", json!({"as":"path"})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().svg.is_some()); +} + +/// Once the OS clipboard no longer holds the SVG, the same SVG copied again in another app is +/// new; while an SVG-only clipboard still holds it, the newer internal copy stays +/// (CodeRabbit on #10776). +#[test] +fn a_remembered_svg_is_forgotten_once_the_clipboard_no_longer_holds_it() { + let svg: SvgClip = Arc::new(Mutex::new(Ok(Some(CLIP_SVG.as_bytes().to_vec())))); + let image: OsClip = Arc::new(Mutex::new(None)); + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + copy_blue(&mut app); + // SVG only, unchanged: the internal copy stays. + app.run("edit.paste", json!({})).unwrap(); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + // Text copied elsewhere, then the same SVG again: it is imported. + *svg.lock().unwrap() = Ok(None); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + *svg.lock().unwrap() = Ok(Some(CLIP_SVG.as_bytes().to_vec())); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().svg.is_some(), "the SVG copied again must be imported"); +} + +/// Paste As Pixels falls back to the bitmap when the SVG can't be read, and only fails when +/// neither can be (CodeRabbit on #10776). +#[test] +fn pixel_paste_reads_the_bitmap_when_the_svg_read_fails() { + let svg: SvgClip = Arc::new(Mutex::new(Err("clipboard is busy".into()))); + let image: OsClip = Arc::new(Mutex::new(Some((2, 1, vec![255; 8])))); + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Raster(_))); + assert!(!app.ui.status_error, "{}", app.ui.status); + let mut app = app_with_svg_and_image(&svg, &Arc::new(Mutex::new(None))); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + assert!(app.run("edit.paste", json!({"as":"pixels"})).is_err()); +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 9e7e986d69..9a5621c5be 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,6 +1,6 @@ use super::{ - DocId, PhotocraftApp, Tool, Value, actions, canvas, delete_layer_prompt, dock, gpu_canvas, jobs_ui, json, mask_trash_prompt, notices, panel_docking, state, - transform_tool, type_transform, vector_ui, + DocId, PhotocraftApp, Tool, Value, actions, canvas, delete_layer_prompt, dock, gpu_canvas, jobs_ui, json, mask_trash_prompt, notices, panel_docking, + smart_svg_ui, state, transform_tool, type_transform, vector_ui, }; impl PhotocraftApp { @@ -35,6 +35,13 @@ impl PhotocraftApp { } pub(crate) fn run_command(&mut self, id: &str, params: Value) -> Result { + self.run_depth += 1; + let r = self.run_command_at_depth(id, params); + self.run_depth -= 1; + r + } + + fn run_command_at_depth(&mut self, id: &str, params: Value) -> Result { if !vector_ui::cancel_stale(self) { return Err(self.ui.status.clone()); } @@ -66,6 +73,9 @@ impl PhotocraftApp { if let Some(result) = panel_docking::command(self, id, ¶ms) { return result; } + if let Some(result) = smart_svg_ui::intercept(self, id, ¶ms) { + return result; + } if let Some(r) = transform_tool::intercept(self, id) { return r; } @@ -75,10 +85,42 @@ impl PhotocraftApp { let t0 = gpu_canvas::now_ms(); // The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU). if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") { - if !clip_read { + let paste_as = if id == "edit.paste" { + match params.get("as") { + None => None, + Some(value) => match value.as_str() { + Some(mode @ ("pixels" | "path" | "shape")) => Some(mode), + _ => return Err("`as` must be pixels, path or shape".into()), + }, + } + } else { + None + }; + if let Some(mode) = paste_as { + // Pixels can come from the bitmap when the SVG can't be read; vectors can't. + let (imported, svg_error) = match self.import_os_svg_clipboard() { + Ok(imported) => (imported, None), + Err(error) if mode == "pixels" => (false, Some(error)), + Err(error) => return Err(error), + }; + if mode == "pixels" + && !imported + && !clip_read + && let Err(error) = self.import_os_clipboard() + { + return Err(svg_error.unwrap_or(error)); + } + if let Some(error) = svg_error { + if self.session.clipboard.is_none() { + return Err(error); + } + self.ui.status.clear(); + self.ui.status_error = false; + } + } else if !clip_read { self.import_os_clipboard()?; } - if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { + if paste_as.is_none() && self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { if self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) { // The clipboard may well hold an image that can't be reached: say why. notices::clipboard_unreadable(self); @@ -121,6 +163,9 @@ impl PhotocraftApp { self.sync_command_side_effects(id, &r, creates_active_mask, creates_active_vector_mask, creates_adjustment_or_fill, path_mask); self.perf.last_command = id.to_string(); self.perf.command_ms = gpu_canvas::now_ms() - t0; + if self.run_depth == 1 { + self.perf.stage.command(id, self.perf.command_ms); + } match &r { Ok(_) => { if self.ui.status_error { @@ -208,6 +253,8 @@ impl PhotocraftApp { | "select.loadSelection" | "select.loadLuminosity" | "path.toSelection" + | "path.addToSelection" + | "path.subtractFromSelection" ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { @@ -226,6 +273,7 @@ impl PhotocraftApp { // Adding a mask selects its thumbnail: the next brush or footer Delete targets it. self.ui.mask_target = true; self.ui.vector_mask_target = false; + self.target_mask_over_colour_rows(); } if result.is_ok() && creates_active_vector_mask { // Likewise a new vector mask (#4728): Properties shows its Density and Feather, and a @@ -243,6 +291,22 @@ impl PhotocraftApp { } pub fn sync_views(&mut self) { + // The Paths panel selection belongs to the active document. Ordinary edits replace the + // snapshot and advance its revision; a replaced snapshot without a newer revision is a + // reopened document, even with the same ID. + let document = self.session.active().map(|st| (st.doc.id, st.revision, std::sync::Arc::downgrade(&st.doc))); + let same_document = match (&self.path_document, &document) { + (Some((old_id, old_revision, old_source)), Some((id, revision, source))) => old_id == id && (revision > old_revision || old_source.ptr_eq(source)), + (None, None) => true, + _ => false, + }; + if !same_document { + self.ui.selected_path = None; + } + self.path_document = document; + vector_ui::cancel_stale_pen(self); + crate::direct_select::cancel_stale(self); + canvas::cancel_stale_vector_drag(self); type_transform::cancel_stale(self); crate::cam_transform::cancel_stale(self); crate::lasso_ui::cancel_stale(self); @@ -276,12 +340,14 @@ impl PhotocraftApp { let new_docs: Vec = ids.iter().enumerate().filter(|(_, id)| !self.view_docs.contains(id)).map(|(i, _)| i).collect(); for &idx in &new_docs { if !self.ui.windows.iter().any(|w| w.document == idx) { - crate::view_cmds::float_window(self, idx, idx); + crate::view_cmds::float_window(self, idx, idx, false); } } } self.canvases.retain(|(doc, _), _| ids.contains(doc)); self.navigator_textures.retain(|doc, _| ids.contains(doc)); + // Closing a document cancels its page work; its GPU pages go with `retain_gpu_documents`. + self.streams.retain(|doc, _| ids.contains(doc)); self.view_docs = ids; self.prune_thumbs(); self.sync_mask_targets(); diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 425742d519..1032873c56 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::{ControlRequest, PhotocraftApp, Receiver}; +use super::{ControlRequest, PhotocraftApp, Receiver, gpu_canvas}; impl PhotocraftApp { /// Attach a control channel (requests arrive from a transport thread: TCP, stdin, tests). @@ -7,6 +7,19 @@ impl PhotocraftApp { self } + /// Close this frame's stage record (`frame_stages`): keep it if it was slow, and hand it to + /// the stroke trace. `ui_ms` is the UI build's time. + pub(crate) fn finish_frame(&mut self, ui_ms: f64) { + let mut stages = std::mem::take(&mut self.perf.stage); + stages.ui_ms = ui_ms; + let stages = self.slow_frames.finish(stages, gpu_canvas::now_ms(), self.perf.gpu, self.perf.gpu_ms); + let info = &self.perf.gpu_info; + self.stroke_trace.frame(&stages, self.live_stroke.is_some(), || { + let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string(); + if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) } + }); + } + pub(crate) fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 97b25ddb40..ae230542ce 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -58,6 +58,19 @@ impl PhotocraftApp { /// [`Self::open_bytes`] for a file read from `path` (File › Save writes back there; it goes /// to Open Recent), when known. pub(crate) fn open_bytes_from(&mut self, name: &str, bytes: &[u8], path: Option<&str>) -> Result, String> { + self.open_bytes_placed(name, bytes, path, None, None) + } + + /// [`Self::open_bytes_from`] for a file dropped on a tab slot (`slot`) or opened as one of a + /// set (`order`): both survive the inline decode, a memory prompt and the final placement. + pub(crate) fn open_bytes_placed( + &mut self, + name: &str, + bytes: &[u8], + path: Option<&str>, + slot: Option, + order: Option, + ) -> Result, String> { if let Some(r) = preset_files_ui::open(self, name, bytes) { return r.map(|()| Vec::new()); } @@ -65,15 +78,28 @@ impl PhotocraftApp { // Decoded on a worker: a tab with progress appears now, the document when it's ready // (warnings are shown then). if self.background_jobs { - jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?; + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes), slot, order)?; return Ok(Vec::new()); } let import = self.services.import.as_ref().ok_or("no importer configured")?; let opts = photocraft_engine::jobs::import_options(self.session.prefs()); - let (mut doc, warnings, damaged) = if photocraft_io::is_psd(bytes) { + // A PSD and a magic-detected TIFF both go through the typed IO path: only it carries + // `PsdApprovalRequired` (and, for a PSD, the structured damaged flag), so a layered + // TIFF's high-memory decision can raise its prompt instead of becoming a terminal + // string. Every other format keeps the shell's import service. + let typed_open = photocraft_io::is_psd(bytes) || photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff); + let (mut doc, warnings, damaged) = if typed_open { // Read PSD damage as structured information in the same decode, not by - // retrying an error or matching the text of a compatibility warning. - let (r, damaged) = photocraft_io::import_for_open(name, bytes, &opts).map_err(|e| e.to_string())?; + // retrying an error or matching the text of a compatibility warning. A TIFF never + // sets it: its layered import has no damaged-content confirmation. + let (r, damaged) = match photocraft_io::import_for_open(name, bytes, &opts) { + Ok(result) => result, + Err(photocraft_io::IoError::PsdApprovalRequired { .. }) => { + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes), slot, order)?; + return Ok(Vec::new()); + } + Err(error) => return Err(error.to_string()), + }; (r.document, r.warnings, damaged) } else { let (doc, warnings) = import(name, bytes, &opts)?; @@ -99,7 +125,8 @@ impl PhotocraftApp { path: path.map(str::to_string), warnings: warnings.clone(), bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())), - slot: None, + slot, + order, question, policy_applied: false, }, @@ -112,13 +139,18 @@ impl PhotocraftApp { /// Returns the import warnings (also shown to the user). Brush and gradient /// files go to the preset libraries, as for interactive opens. pub fn open_automation_bytes(&mut self, name: &str, bytes: &[u8]) -> Result, String> { + self.open_automation_bytes_with_approval(name, bytes, false) + } + + /// [`Self::open_automation_bytes`] with the caller's high-memory approval for the imported bytes. + pub fn open_automation_bytes_with_approval(&mut self, name: &str, bytes: &[u8], approved: bool) -> Result, String> { let events_enabled = self.session.prefs().script_events.enabled; if events_enabled { self.session.edit_prefs(|prefs| prefs.script_events.enabled = false); } let result = match preset_files_ui::open(self, name, bytes) { Some(r) => r.map(|()| Vec::new()), - None => self.import_automation_document(name, bytes), + None => self.import_automation_document(name, bytes, approved), }; if events_enabled { self.session.edit_prefs(|prefs| prefs.script_events.enabled = true); @@ -128,9 +160,10 @@ impl PhotocraftApp { /// The document half of [`open_automation_bytes`](Self::open_automation_bytes): no script /// 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> { + fn import_automation_document(&mut self, name: &str, bytes: &[u8], approved: bool) -> Result, String> { let import = self.services.import.as_ref().ok_or("no importer configured")?; - let opts = photocraft_engine::jobs::import_options(self.session.prefs()); + let mut opts = photocraft_engine::jobs::import_options(self.session.prefs()); + opts.psd_high_memory_approved = approved; 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. @@ -163,6 +196,9 @@ impl PhotocraftApp { /// (a flat file): confirming the document's own file is recorded as a `file.save` step, /// 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.smart_svg.modal_open() { + return Err("Answer Edit SVG Contents before starting a save".into()); + } if self.save_options_open() { return Err(SAVE_OPTIONS_OPEN.into()); } @@ -212,9 +248,10 @@ impl PhotocraftApp { { return r; } + let from = app.session.journal.len(); let r = app.save_to(path.clone(), choices)?; if plain_save && own.as_deref() == Some(path.as_str()) { - app.journal_as_plain_save(&path); + app.journal_as_plain_save(&path, from); } Ok(r) }) @@ -223,6 +260,9 @@ impl PhotocraftApp { /// [`Self::save_as_with`] once the path is known. pub(crate) fn save_to(&mut self, path: String, choices: FormatChoices) -> Result { + if self.smart_svg.modal_open() { + return Err("Answer Edit SVG Contents before starting a save".into()); + } // 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 @@ -242,13 +282,14 @@ impl PhotocraftApp { 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 + // A TIFF asks for TIFF Options (compression, pyramid, transparency, layers); the save // continues from the prompt, which records the step once answered. - if tiff_options_ui::wants_prompt(self, &path) { - tiff_options_ui::park(self, path.clone())?; + if choices.tiff.is_none() && tiff_options_ui::wants_prompt(self, &path) { + tiff_options_ui::park(self, path.clone(), false, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - self.save_as_step(path, None, choices) + let tiff_layers = choices.tiff.and_then(|t| t.layers); + self.save_as_step(path, tiff_layers, choices) } /// File › Save back to the document's own file, recorded as a `file.save` step with the @@ -270,8 +311,8 @@ impl PhotocraftApp { /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF, /// 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}); + let mut step = self.save_as_record(&path); + let r = self.write_save(path, tiff_layers, choices)?; if let Some(layers) = tiff_layers { step["tiffLayers"] = layers.into(); } @@ -280,15 +321,72 @@ impl PhotocraftApp { Ok(r) } + /// The `file.saveAs` step that records a Save As of the active document to `path`, taken + /// before the save renames the document. With the document's own name kept (the name Save + /// As suggests), only the folder and the extension are recorded, so the action saves each + /// document it is played on under that document's name, as in Photoshop (#5305); a name + /// typed in is recorded as it is. + pub(crate) fn save_as_record(&self, path: &str) -> Value { + let file = std::path::Path::new(path); + let kept = self.own_file_stem().is_some_and(|own| file.file_stem().is_some_and(|s| s.to_string_lossy() == own)); + match (kept, file.extension()) { + (true, Some(ext)) => { + let folder = file.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + serde_json::json!({"folder": folder, "extension": ext.to_string_lossy()}) + } + _ => serde_json::json!({"path": path}), + } + } + + /// `step` (a save answered in a format prompt, recorded with its `path`) as + /// [`Self::save_as_record`] records it when it is a Save As of the active document. + pub(crate) fn record_answered_save_as(&self, record: &str, step: &mut Value) { + if record != "file.saveAs" { + return; + } + let Some(path) = step.get("path").and_then(Value::as_str).map(str::to_owned) else { return }; + if let (Some(s), Value::Object(r)) = (step.as_object_mut(), self.save_as_record(&path)) { + s.remove("path"); + s.extend(r); + } + } + + /// The name Save As suggests for the active document, without its extension: its file's, + /// or its own for a document never saved ("Untitled" for one with no name). + fn own_file_stem(&self) -> Option { + let st = self.session.active()?; + let source = st.path.as_deref().unwrap_or(&st.doc.name); + let stem = std::path::Path::new(source).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty()); + Some(stem.unwrap_or_else(|| "Untitled".into())) + } + + /// The file a recorded `file.saveAs` step writes: its `path`, or for a step recorded with + /// the document's own name, that folder and extension with the active document's name. + fn recorded_save_path(&self, params: &Value) -> Result { + if let Some(path) = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()) { + return Ok(path.to_string()); + } + let (Some(folder), Some(ext)) = + (params.get("folder").and_then(Value::as_str), params.get("extension").and_then(Value::as_str).filter(|e| !e.is_empty())) + else { + return Err("Save As needs a `path`, or a `folder` and an `extension`".into()); + }; + let stem = self.own_file_stem().ok_or("no document")?; + let name = format!("{stem}.{ext}"); + Ok(if folder.is_empty() { name } else { std::path::Path::new(folder).join(name).to_string_lossy().into_owned() }) + } + /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]), - /// 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) { + /// keeping its Targa, DDS, JPEG or TIFF answer so playback writes the file the same way. A save + /// parked behind Targa, DDS, JPEG or TIFF Options hasn't been journaled yet: it is recorded as + /// File › Save once answered. Only a step journaled at index `from` or later (by this save) + /// is re-labeled. + pub(crate) fn journal_as_plain_save(&mut self, path: &str, from: usize) { 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)), + self.tiff_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"; @@ -296,14 +394,34 @@ impl PhotocraftApp { } // 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) + // A Save As that kept the document's own name was recorded as its folder (#5305); the + // document now has that file, so it resolves back to `path`. + let journal = &self.session.journal; + let recorded = journal.last().filter(|last| last.0 == "file.saveAs" && journal.len() > from).and_then(|last| self.recorded_save_path(&last.1).ok()); + if recorded.is_some_and(|r| std::path::Path::new(&r) == std::path::Path::new(path)) + && let Some(last) = self.session.journal.last_mut() && let Some(step) = last.1.as_object() - && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat")) + && step.keys().all(|k| { + matches!( + k.as_str(), + "path" + | "folder" + | "extension" + | "tgaBits" + | "ddsCompression" + | "ddsMipmaps" + | "quality" + | "jpegFormat" + | "tiffCompression" + | "tiffTransparency" + | "tiffPyramid" + ) + }) { let mut step = step.clone(); - step.remove("path"); + for k in ["path", "folder", "extension"] { + step.remove(k); + } *last = ("file.save".into(), Value::Object(step)); } } @@ -336,27 +454,34 @@ impl PhotocraftApp { // 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_step(params, id)?; if id == "file.saveACopy" { + let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save a Copy needs a `path`")?; // 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(); return crate::view_cmds::write_copy(self, &doc, path, choices); } - self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), choices) + let path = self.recorded_save_path(params)?; + self.save_as_step(path, params.get("tiffLayers").and_then(Value::as_bool), choices) } /// What a save writes by default under the preferences: File Handling › Image Previews /// decides whether a PSD/PSB gets its thumbnail, and Maximize PSD and PSB File Compatibility /// whether it gets its real merged image. Ask When Saving has no dialog here yet, so it saves - /// both, as Photoshop's checkboxes start on. A JPEG or WebP is written the way the active + /// both, as Photoshop's checkboxes start on. Save Full-Size Composite in .pcraft Files decides + /// whether a `.pcraft` save also writes `composite/full.png`. A JPEG or WebP is written the way the active /// document's file was (its JPEG quality, a WebP lossy at its quality or lossless), as /// Photoshop's Save and Save As options start there. pub(crate) fn save_settings(&self) -> ExportSettings { let file_handling = &self.session.prefs().file_handling; let previews = file_handling.image_previews != photocraft_engine::prefs::Ask::Never; let maximize = file_handling.maximize_psd_compatibility != photocraft_engine::prefs::Ask::Never; - let mut settings = ExportSettings { image_previews: previews, psd_maximize_compatibility: maximize, ..ExportSettings::default() }; + let mut settings = ExportSettings { + image_previews: previews, + psd_maximize_compatibility: maximize, + pcraft_full_composite: file_handling.pcraft_full_composite, + ..ExportSettings::default() + }; if let Some(st) = self.session.active() { let e = &st.doc.file_encoding; settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); @@ -474,6 +599,11 @@ impl PhotocraftApp { } fn saved_inner(&mut self, doc: DocId, revision: u64, path: &str, warnings: &[String], copy: bool, kind: SaveKind) { + // Per-output Image Assets failures from the after-save hook, reported with this save (#11117). + let mut asset_errors: Vec = Vec::new(); + // The successful outputs and their folder, kept for the partial-failure status line. + let mut asset_ok = 0usize; + let mut asset_dir = String::new(); // Automation: only a layered save becomes the document's file; a flat one is a // copy, as in the headless server (#2579). let (hooks, arm) = match kind { @@ -499,15 +629,25 @@ impl PhotocraftApp { app.remember_stamp(doc, path); } // "Save Document" script events and File › Generate › Image Assets. - if hooks - && let Some(i) = app.session.active_index() - && let Some(r) = photocraft_engine::automate_cmds::document_saved(&mut app.session, i) - { - let template = "Saved {path}; image assets: {n} in {dir}"; - app.ui.status = crate::i18n::fmt( - tl!(template), - &[("path", path), ("n", &r["files"].as_array().map_or(0, Vec::len).to_string()), ("dir", r["dir"].as_str().unwrap_or(""))], - ); + if hooks && let Some(r) = photocraft_engine::automate_cmds::document_saved(&mut app.session, doc) { + let files = r["files"].as_array().map_or(0, Vec::len); + asset_ok = files; + asset_dir = r["dir"].as_str().unwrap_or("").to_string(); + let n = files.to_string(); + let dir = r["dir"].as_str().unwrap_or(""); + asset_errors = r["errors"] + .as_array() + .into_iter() + .flatten() + .map(|e| format!("{}: {}", e["asset"].as_str().unwrap_or("an asset"), e["error"].as_str().unwrap_or("the export failed"))) + .collect(); + let template = if asset_errors.is_empty() { + "Saved {path}; image assets: {n} in {dir}" + } else { + "Saved {path}; image assets: {n} in {dir} ({e} failed)" + }; + let e = asset_errors.len().to_string(); + app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path), ("n", &n), ("dir", dir), ("e", &e)]); } Ok(()) }); @@ -523,6 +663,21 @@ impl PhotocraftApp { self.ui.status_error = false; let what = crate::i18n::fmt(tl!("Saved {name}"), &[("name", file_open::display_name(path).as_str())]); notices::io_warnings(self, &what, warnings); + // #11117: a successful save whose Image Assets partially failed must say which outputs + // did not land, and of which document: the status keeps the full saved path (same-named + // documents only differ by their folder) and what did go out, the notice lists every failure. + if !asset_errors.is_empty() { + let e = asset_errors.len(); + let full = crate::i18n::fmt(tl!("Saved {name}"), &[("name", path)]); + let title = crate::i18n::fmt( + &crate::i18n::trn(crate::i18n::current(), e as u64, "{what}: {n} Image Assets output failed", "{what}: {n} Image Assets outputs failed"), + &[("what", &full), ("n", &e.to_string())], + ); + let written = crate::i18n::fmt(tl!("{ok} written to {dir}"), &[("ok", &asset_ok.to_string()), ("dir", &asset_dir)]); + self.ui.status = format!("{title} ({written}): {}", asset_errors[0]); + self.ui.status_error = true; + notices::post(self, title, asset_errors, true, None); + } self.sync_views(); } @@ -546,15 +701,21 @@ impl PhotocraftApp { 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: + /// Writes a save that waited on a format prompt (Targa, DDS, JPEG or TIFF 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)); + let (record, mut step) = step; + let written = self.with_document(doc, |app| { + // Before the save renames the document: a Save As that keeps its own name records + // only the folder (#5305). + app.record_answered_save_as(record, &mut step); + app.write_document(path, settings, copy) + }); if written.is_ok() { - self.session.journal.push((step.0.into(), step.1)); + self.session.journal.push((record.into(), step)); } match written { Err(e) => { @@ -679,3 +840,70 @@ impl PhotocraftApp { f } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #11117: when Image Assets partially failed after a successful user save, the failures + /// were dropped with the hook result and the status only counted the assets that worked. + #[test] + fn a_user_save_reports_image_assets_outputs_that_failed() { + let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let root = std::env::temp_dir().join(format!("photocraft-11117-{}-{unique}", std::process::id())); + let assets = root.join("probe-assets"); + std::fs::create_dir_all(&assets).unwrap(); + // `blocked` is a regular file, so the `blocked/fail.png` asset cannot create its folder; + // `good.png` stays writable. No permissions or artificial results are involved. + std::fs::write(assets.join("blocked"), "not a folder").unwrap(); + let path = root.join("probe.psd").to_string_lossy().into_owned(); + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // A save script closes this earlier tab, shifting the saved document's index. + app.session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let doc = app.session.active().unwrap().doc.id; + for name in ["good.png", "blocked/fail.png"] { + let id = app.session.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + // A layer with no pixels is "the layer is empty" before its path is even looked at, + // so both layers carry a painted pixel and only the blocked folder can fail. + app.session.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + app.session.execute("layer.renameLayer", json!({"layer": id, "name": name})).unwrap(); + } + app.session.prefs.edit(|p| p.plug_ins.generator_enabled = true); + app.session.file_menu.image_assets.push(doc); + app.session + .execute("file.scripts.scriptEventsManager", json!({"enabled": true, "add": {"event": "saveDocument", "steps": [["file.close", {"document": 0}]]}})) + .unwrap(); + + let revision = app.session.active().unwrap().revision; + app.saved(doc, revision, &path, &[], false); + + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().unwrap().doc.id, doc); + + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str()), "the document save itself succeeded"); + assert!(assets.join("good.png").is_file(), "the good asset was exported"); + let notice = app.ui.notices.iter().find(|notice| notice.error).expect("the failed asset is reported as an error notice"); + assert_eq!(notice.title, format!("Saved {path}: 1 Image Assets output failed"), "the failure names the saved document by its full path"); + assert_eq!(notice.lines.len(), 1, "{:?}", notice.lines); + assert!(notice.lines[0].starts_with("blocked/fail.png: asset path"), "{:?}", notice.lines[0]); + assert!(app.ui.status_error, "the status line carries the failure"); + assert!(app.ui.status.starts_with(¬ice.title), "{}", app.ui.status); + assert!( + app.ui.status.contains("1 written to") && app.ui.status.contains("blocked/fail.png"), + "the status keeps the success count, folder and first failure: {}", + app.ui.status + ); + // A clean generation afterwards reports the success line with no error notice (#11079 control). + app.ui.notices.clear(); + app.session.execute("file.scripts.scriptEventsManager", json!({"enabled": false})).unwrap(); + std::fs::remove_file(assets.join("blocked")).unwrap(); + let revision = app.session.active().unwrap().revision; + app.saved(doc, revision, &path, &[], false); + assert!(app.ui.notices.iter().all(|notice| !notice.error), "no notice when every asset went out"); + assert!(!app.ui.status_error); + assert!(app.ui.status.contains("image assets: 2 in"), "{}", app.ui.status); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index d19eb31f7a..07ea17b7e8 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,9 +1,9 @@ use super::{ PhotocraftApp, analysis_ui, brush_panel, brush_picker, camera_raw_ui, canvas, dds_options_ui, dialogs, discard_ui, distort_ui, field_wheel, file_open, gpu_canvas, gpu_status, gradient_ui, i18n, jobs_ui, jpeg_options_ui, kys_import, last_brush, layers_panel_ui, monitor_status, native_menu, notices, - paint_mouse, palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, targa_options_ui, text_field_menu, - theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, view_cmds, - wide_angle_ui, work_area, workspace_ui, + paint_mouse, palette, panels, prefs_ui, preset_panels, rasterize_prompt, screen_picker, shape_stroke_ui, shortcuts, smart_svg_ui, targa_options_ui, + text_field_menu, theme, tiff_options_ui, timeline_ui, titlebar, tool_cursor, tool_memory, transform_tool, type_panels_ui, type_tool, updates_ui, vector_ui, + view_cmds, wide_angle_ui, work_area, workspace_ui, }; impl PhotocraftApp { @@ -54,15 +54,14 @@ impl PhotocraftApp { self.drain_captures(ctx); self.drain_control(ctx); native_menu::run(self, ctx); + view_cmds::leave_full_screen_without_document(self, ctx); // Switching tools commits, also between the Type and Type Mask tools. 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); - } + vector_ui::finish_pen_if_left(self); transform_tool::end_if_left(self); shape_stroke_ui::sync_editor(self, ctx); prefs_ui::tick(self, ctx); @@ -75,6 +74,10 @@ impl PhotocraftApp { // maximize it into the work area once (#315). work_area::fit_window(ctx); discard_ui::guard_window_close(self, ctx); + // A zoom field being typed in: plays its alert, and notes a click on the pasteboard. + crate::zoom_field::tick(self, ctx); + // A colour swatch's Color Picker OK hands its colour back to the swatch widget. + crate::color_picker_ui::widget_tick(self, ctx); // Background jobs: apply finished ones, keep frames coming, Esc cancels (before the // shortcuts see Esc). if !screen_picker::tick(self, ctx) { @@ -167,13 +170,67 @@ impl PhotocraftApp { self.automation_input = false; // No editor was drawn this pass: end its IME context rather than keep a stale owner. type_tool::sync_ime(self, &ctx); + // The frame's stage record was started with its input: close it here too. + self.finish_frame(0.0); return; } let t0 = gpu_canvas::now_ms(); - self.perf.frame_refresh_ms = 0.0; + self.perf.stage.ui_start(t0, frame.info().cpu_usage); // A software adapter or CPU mode chosen at startup is every document's reason. crate::render_path::frame(self); - self.stroke_trace.cpu_prev(frame.info().cpu_usage); + // Every frame that started its stage record closes it, whichever way `ui_body` returns. + let drawn = self.ui_body(ui); + let ui_ms = gpu_canvas::now_ms() - t0; + // A GPU measurement lands a frame or two after the refresh it belongs to. Refreshes + // carry them out, but the last refresh before the app settles has none following it, so + // drain the timer here too or its number is never reported. Drain it either way: a + // measurement left sitting would surface later against an unrelated refresh. Report it + // only while the canvas is still on the GPU, since a CPU refresh clears `gpu_ms` + // precisely so a GPU number is never shown as that refresh's. + if let Some(gpu) = &self.gpu + && let Some(t) = gpu.take_gpu_timing() + && self.perf.last_refresh.starts_with("gpu") + { + self.perf.record_gpu(t); + } + self.perf.frame(ui_ms); + self.finish_frame(ui_ms); + if !drawn { + return; + } + // This pass handled its input: the in-flight synthetic batch is delivered once and the + // next step may be injected on the next frame. + self.finish_synthetic_pass(); + // Synthetic input is injected one press/release step per frame: keep frames coming until + // the queue is empty, then release control replies waiting on it. + // Only a pass that reached the end of the UI and delivered the in-flight batch counts as + // settled input; an early-returning pass keeps the batch (and the waiting replies). + if !self.synthetic.is_empty() || self.synthetic_batch.is_some() { + ctx.request_repaint(); + } else if !self.input_waiters.is_empty() { + for w in self.input_waiters.drain(..) { + let _ = w.send(serde_json::json!({"ok": true, "result": null})); + } + } + // A menu or dialog can start a filter after `jobs_ui::tick` ran in this frame. Keep + // polling it even when the platform would otherwise put the event loop to sleep. + 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)); + // The Type tool's IME descriptor is the last writer of this pass: every panel and the + // canvas have finished mutating the model, and an absent/focus-moved editor ends here. + type_tool::sync_ime(self, &ctx); + // Issue any due `ui.screenshot` now that this pass's state is settled: a queued request + // that has waited its passes and animation delay captures exactly this frame. + self.issue_captures(&ctx); + // Last, after everything drawn this frame: a lost window device can't draw it (#3438). + gpu_status::check_window(self, &ctx); + } + + /// The frame's UI, between `ui_start` and `finish_frame`. False when the screen picker's + /// wait modal took the frame (its input, IME and window check already handled). + fn ui_body(&mut self, ui: &mut egui::Ui) -> bool { + let ctx = ui.ctx().clone(); // 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). @@ -204,7 +261,7 @@ impl PhotocraftApp { self.automation_input = false; type_tool::sync_ime(self, &ctx); gpu_status::check_window(self, &ctx); - return; + return false; } if screen_picker::busy(&ctx) { ui.disable(); @@ -260,6 +317,8 @@ impl PhotocraftApp { targa_options_ui::show(self, &ctx); jpeg_options_ui::show(self, &ctx); dds_options_ui::show(self, &ctx); + smart_svg_ui::poll(self, &ctx); + smart_svg_ui::show(self, &ctx); distort_ui::show(self, &ctx); camera_raw_ui::show(self, &ctx); wide_angle_ui::show(self, &ctx); @@ -299,54 +358,6 @@ impl PhotocraftApp { egui::Modal::new(egui::Id::unique("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {}); ctx.set_cursor_icon(egui::CursorIcon::Wait); } - let ui_ms = gpu_canvas::now_ms() - t0; - // A GPU measurement lands a frame or two after the refresh it belongs to. Refreshes - // carry them out, but the last refresh before the app settles has none following it, so - // drain the timer here too or its number is never reported. Drain it either way: a - // measurement left sitting would surface later against an unrelated refresh. Report it - // only while the canvas is still on the GPU, since a CPU refresh clears `gpu_ms` - // precisely so a GPU number is never shown as that refresh's. - if let Some(gpu) = &self.gpu - && let Some(t) = gpu.take_gpu_timing() - && self.perf.last_refresh.starts_with("gpu") - { - self.perf.record_gpu(t); - } - self.perf.frame(ui_ms); - if self.stroke_trace.enabled() { - self.stroke_trace.canvas(self.perf.frame_refresh_ms); - let info = &self.perf.gpu_info; - self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || { - let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string(); - if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) } - }); - } - // This pass handled its input: the in-flight synthetic batch is delivered once and the - // next step may be injected on the next frame. - self.finish_synthetic_pass(); - // Synthetic input is injected one press/release step per frame: keep frames coming until - // the queue is empty, then release control replies waiting on it. - // Only a pass that reached the end of the UI and delivered the in-flight batch counts as - // settled input; an early-returning pass keeps the batch (and the waiting replies). - if !self.synthetic.is_empty() || self.synthetic_batch.is_some() { - ctx.request_repaint(); - } else if !self.input_waiters.is_empty() { - for w in self.input_waiters.drain(..) { - let _ = w.send(serde_json::json!({"ok": true, "result": null})); - } - } - // A menu or dialog can start a filter after `jobs_ui::tick` ran in this frame. Keep - // polling it even when the platform would otherwise put the event loop to sleep. - 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)); - // The Type tool's IME descriptor is the last writer of this pass: every panel and the - // canvas have finished mutating the model, and an absent/focus-moved editor ends here. - type_tool::sync_ime(self, &ctx); - // Issue any due `ui.screenshot` now that this pass's state is settled: a queued request - // that has waited its passes and animation delay captures exactly this frame. - self.issue_captures(&ctx); - // Last, after everything drawn this frame: a lost window device can't draw it (#3438). - gpu_status::check_window(self, &ctx); + true } } diff --git a/crates/ui-egui/src/app/targets.rs b/crates/ui-egui/src/app/targets.rs index fbdc91c8a7..1d40cdd570 100644 --- a/crates/ui-egui/src/app/targets.rs +++ b/crates/ui-egui/src/app/targets.rs @@ -39,7 +39,10 @@ impl PhotocraftApp { pub fn filter_mask_targeted(&self) -> bool { let Some(st) = self.session.active() else { return false }; let Some(id) = st.active_layer.filter(|id| self.ui.filter_mask_target == Some(id.0)) else { return false }; - let composite = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite && st.doc.quick_mask.is_none(); + let composite = (st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite + || (matches!(st.channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Color(_)) + && photocraft_engine::mask_view_cmds::current(st).is_some_and(|v| v.filter))) + && st.doc.quick_mask.is_none(); composite && st.doc.layer(id).is_some_and(|l| matches!(&l.content, photocraft_doc::LayerContent::Smart(sm) if !sm.smart_filters.is_empty())) } @@ -48,9 +51,17 @@ impl PhotocraftApp { /// is the picture. An alpha channel or Quick Mask wins, as the engine routes those itself. pub fn layer_mask_targeted(&self) -> bool { let Some(st) = self.session.active() else { return false }; + if photocraft_engine::mask_view_cmds::current(st).is_some_and(|v| v.filter) { + return false; + } let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask; let composite = (st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite || layer_mask) && st.doc.quick_mask.is_none(); let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); + if matches!(st.channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Color(_)) + && photocraft_engine::mask_view_cmds::current(st).is_some() + { + return true; + } if !composite || !has_mask { return false; } @@ -98,6 +109,22 @@ impl PhotocraftApp { self.sync_mask_targets(); } + /// Targeting a pixel layer's mask (its thumbnail, or a mask just added to it) takes the + /// Channels panel off colour rows left selected and onto the mask, as in Photoshop, so tools, + /// menu commands and the engine all edit the mask. A view change only: no command, no step. + pub(crate) fn target_mask_over_colour_rows(&mut self) { + use photocraft_engine::channel_cmds::ChannelTarget; + let Some(st) = self.session.active_mut() else { return }; + let raster_with_mask = + st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some() && matches!(l.content, photocraft_doc::LayerContent::Raster(_))); + if matches!(st.channel_view.target, ChannelTarget::Color(_)) && photocraft_engine::mask_view_cmds::current(st).is_none() && raster_with_mask { + let v = &mut st.channel_view; + v.target = ChannelTarget::LayerMask; + v.selected_colors.clear(); + v.color_hidden.clear(); + } + } + /// Viewing a layer mask (#196) targets it; a vector-mask target needs a vector mask on the /// active layer (a shape layer's path is its content, not a mask). Targeting a mask or the /// pixels brings back that target's foreground/background pair, as in Photoshop (#2166). diff --git a/crates/ui-egui/src/browse_field_tests.rs b/crates/ui-egui/src/browse_field_tests.rs index 09debf418b..c42b296a95 100644 --- a/crates/ui-egui/src/browse_field_tests.rs +++ b/crates/ui-egui/src/browse_field_tests.rs @@ -81,3 +81,21 @@ fn image_processor_folders_are_chosen_with_browse() { assert_eq!(field(&h, "output"), Some(json!("/tmp/web"))); assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Folder { .. }, FileDialogRequest::Folder { .. }])); } + +#[test] +fn data_sets_as_files_folder_is_chosen_with_browse() { + let (mut h, asked) = harness(vec![Some(FileDialogAnswer::FolderTo("/tmp/dataset-exports".into()))]); + h.state_mut().run("file.new", json!({"width": 64, "height": 64})).unwrap(); + h.state_mut().run("image.variables.dataSets", json!({"dataSets": [{"name": "Set 1", "values": []}]})).unwrap(); + h.state_mut().sync_views(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.export.dataSetsAsFiles", json!({})).unwrap(); + h.run_steps(4); + assert!(field(&h, "dir").is_some(), "the dialog has a Dir field"); + assert_eq!(field(&h, "naming"), Some(json!("{name}"))); + assert_eq!(field(&h, "format"), Some(json!("png"))); + h.get_by_label("Browse…").click(); + h.run_steps(4); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Folder { .. }])); + assert_eq!(field(&h, "dir"), Some(json!("/tmp/dataset-exports"))); +} diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs new file mode 100644 index 0000000000..3523267b10 --- /dev/null +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -0,0 +1,384 @@ +//! The canvas Shift+right-click menu of the painting tools, as in the reference application: Edit +//! Brush… (the Brush Preset picker a plain right-click opens) and the tool's own Mode list from +//! its options bar (blend modes, the Eraser's Brush / Pencil / Block, the Dodge and Burn Range, +//! the Sponge's mode). Every row sets exactly what the tool's options-bar control sets, through +//! the same command (`tools.setBrush` for a brush-based tool's mode) or tool option. + +use egui::{Context, Id}; +use photocraft_doc::BlendMode; +use serde_json::{Value, json}; + +use crate::{PhotocraftApp, state::Tool, theme::Tokens}; + +/// One row of the menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Item { + /// Opens the Brush Preset picker at the click point. + EditBrush, + Separator, + /// The tool's blend mode. + Blend(BlendMode), + /// Painting-only: paint behind the layer's pixels (Brush and Pencil). + Behind, + /// Painting-only: paint transparency (Brush, Pencil, Paint Bucket). + Clear, + /// The Eraser's Mode: `brush`, `pencil` or `block`. + Eraser(&'static str), + /// The Dodge and Burn Range: `shadows`, `midtones` or `highlights`. + Range(&'static str), + /// The Sponge's mode: `desaturate` or `saturate`. + Sponge(&'static str), +} + +impl Item { + /// The English label (translated when drawn); `None` for a separator. + pub(crate) fn label(self) -> Option<&'static str> { + Some(match self { + Item::EditBrush => "Edit Brush…", + Item::Separator => return None, + Item::Blend(m) => m.label(), + Item::Behind => "Behind", + Item::Clear => "Clear", + Item::Eraser(m) => match m { + "pencil" => "Pencil", + "block" => "Block", + _ => "Brush", + }, + Item::Range(r) => match r { + "shadows" => "Shadows", + "highlights" => "Highlights", + _ => "Midtones", + }, + Item::Sponge(m) => match m { + "saturate" => "Saturate", + _ => "Desaturate", + }, + }) + } +} + +/// The Brush's and Pencil's Mode list in the options bar's order, Behind and Clear after Dissolve: +/// `(mode, behind)`, `(None, false)` being Clear. +pub(crate) fn choices() -> Vec<((Option, bool), &'static str)> { + let mut choices: Vec<_> = BlendMode::LAYER_MODES.iter().map(|m| ((Some(*m), false), m.label())).collect(); + let at = choices.iter().position(|o| o.0 == (Some(BlendMode::Dissolve), false)).map_or(choices.len(), |i| i + 1); + choices.insert(at, ((None, true), "Behind")); + choices.insert(at + 1, ((None, false), "Clear")); + choices +} + +/// The blend modes in their menu groups (a separator before Darken, Lighten, Overlay, Difference +/// and Hue), with Behind and/or Clear after Dissolve. +fn blend_items(behind: bool, clear: bool) -> Vec { + let mut out = Vec::new(); + for m in BlendMode::LAYER_MODES { + if matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) { + out.push(Item::Separator); + } + out.push(Item::Blend(m)); + if m == BlendMode::Dissolve { + if behind { + out.push(Item::Behind); + } + if clear { + out.push(Item::Clear); + } + } + } + out +} + +/// `tool`'s menu, top to bottom. Empty for a tool without one (its Shift+right-click keeps its +/// other route). Only what the tool's options bar offers is listed: the reference application's +/// Behind for the stamps, History Brush, Gradient and Paint Bucket, the Blur, Sharpen and Smudge +/// modes, the Spot Healing modes and the Healing Brush's source and Replace have no setting here. +pub(crate) fn items(tool: Tool) -> Vec { + let with_brush = |rest: Vec| std::iter::once(Item::EditBrush).chain(std::iter::once(Item::Separator)).chain(rest).collect::>(); + match tool { + Tool::Brush | Tool::Pencil => with_brush(blend_items(true, true)), + Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush => with_brush(blend_items(false, false)), + Tool::MixerBrush | Tool::ColorReplacement | Tool::BackgroundEraser | Tool::Blur | Tool::Sharpen | Tool::Smudge => vec![Item::EditBrush], + Tool::Eraser => ["brush", "pencil", "block"].into_iter().map(Item::Eraser).collect(), + Tool::Dodge | Tool::Burn => with_brush(["shadows", "midtones", "highlights"].into_iter().map(Item::Range).collect()), + Tool::Sponge => with_brush(["desaturate", "saturate"].into_iter().map(Item::Sponge).collect()), + Tool::Healing => crate::blend_keys::HEALING_MODES.into_iter().map(Item::Blend).collect(), + Tool::Gradient => blend_items(false, false), + Tool::PaintBucket => blend_items(false, true), + _ => Vec::new(), + } +} + +/// The rows' labels, separators left out (what `ui.inspect` reports as `choices`). +pub(crate) fn labels(tool: Tool) -> Vec<&'static str> { + items(tool).into_iter().filter_map(Item::label).collect() +} + +/// Is a row available? Behind and Clear need transparency: greyed on the Background layer. +pub(crate) fn enabled(app: &PhotocraftApp, item: Item) -> bool { + match item { + Item::Separator => false, + Item::Behind | Item::Clear => !app + .session + .active() + .is_some_and(|st| st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| crate::doc_props_ui::is_background(&st.doc, l))), + _ => true, + } +} + +/// The checked row: `tool`'s current mode, as its options bar shows it. +pub(crate) fn current(app: &PhotocraftApp, tool: Tool) -> Option { + let o = &app.ui.tool_options; + let brush = &app.session.tools.brush; + let painting = |clear: bool| { + if clear { + Item::Clear + } else if brush.behind { + Item::Behind + } else { + Item::Blend(brush.mode) + } + }; + let item = match tool { + Tool::Brush => painting(o.brush_clear), + Tool::Pencil => painting(o.pencil_clear), + Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing => Item::Blend(crate::blend_keys::brush_tool_mode(tool, brush.mode)), + Tool::Gradient => Item::Blend(o.gradient_blend_mode), + Tool::PaintBucket if o.bucket_clear => Item::Clear, + Tool::PaintBucket => Item::Blend(o.bucket_blend_mode), + _ => return items(tool).into_iter().find(|i| ui_value(app, *i).is_some_and(|v| v)), + }; + Some(item) +} + +/// Whether a tool-option row (Eraser, Range, Sponge) is the current value; `None` for other rows. +fn ui_value(app: &PhotocraftApp, item: Item) -> Option { + let o = &app.ui.tool_options; + Some(match item { + Item::Eraser(m) => o.eraser_mode == m, + Item::Range(r) => o.tone_range == r, + Item::Sponge(m) => o.sponge_mode == m, + _ => return None, + }) +} + +/// Does the active tool have a menu for this gesture (Shift, without Alt, Ctrl or Command)? +pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool { + mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd && !items(tool).is_empty() +} + +/// The modifiers that decide a right-button gesture: the ones held when the button went down. +/// egui reports `secondary_clicked` on release, so letting go of Shift a moment before the button +/// must not turn Shift+right-click into a plain right-click (an erase dab, with the Erase +/// preference). The control path decides on the press too. +pub(crate) fn gesture_mods(ctx: &Context, mods: egui::Modifiers) -> egui::Modifiers { + let key = menu_id().with("press-mods"); + let (pressed, down, released) = ctx.input(|i| { + (i.pointer.button_pressed(egui::PointerButton::Secondary), i.pointer.secondary_down(), i.pointer.button_released(egui::PointerButton::Secondary)) + }); + if pressed { + ctx.data_mut(|d| d.insert_temp(key, mods)); + return mods; + } + if !down && !released { + ctx.data_mut(|d| d.remove::(key)); + return mods; + } + let held = ctx.data(|d| d.get_temp::(key)); + if released { + ctx.data_mut(|d| d.remove::(key)); + } + held.unwrap_or(mods) +} + +pub(crate) fn menu_id() -> Id { + Id::unique("canvas-brush-mode-menu") +} + +/// Opens the active tool's menu at screen point `pos`. +pub(crate) fn open(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) { + if !pos.iter().all(|v| v.is_finite()) || items(app.ui.tool).is_empty() { + return; + } + // The checked row reads the tool's own brush. + crate::paint_mouse::sync_tool_brush(app); + app.ui.brush_picker = None; + app.ui.layer_menu = None; + app.ui.canvas_tool_menu = None; + app.ui.brush_mode_menu = Some(pos); + app.ui.brush_mode_menu_tool = Some(app.ui.tool); + crate::canvas_popup::mark_open(ctx, menu_id()); +} + +fn close(app: &mut PhotocraftApp) { + app.ui.brush_mode_menu = None; + app.ui.brush_mode_menu_tool = None; +} + +/// Is the open menu still the active tool's? A tool change closes it. +fn current_tool(app: &PhotocraftApp) -> bool { + app.ui.brush_mode_menu_tool == Some(app.ui.tool) +} + +/// A press outside dismisses the popup, and consumes that entire canvas gesture through Up. +pub(crate) fn blocks_canvas(app: &PhotocraftApp, ctx: &Context) -> bool { + let key = menu_id().with("dismiss-pointer"); + if ctx.data(|d| d.get_temp::(key)).unwrap_or(false) { + if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(key)); + } + return true; + } + if app.ui.brush_mode_menu.is_some() { + if let Some(rect) = ctx.memory(|m| m.area_rect(menu_id())) + && crate::canvas_popup::outside_pressed(ctx, menu_id(), rect) + { + ctx.data_mut(|d| d.insert_temp(key, true)); + } + return true; + } + false +} + +/// Document-coordinate control pointers dismiss; menu rows are chosen with screen-space ui.click +/// or `ui.context.choose`. +pub(crate) fn blocks_control(app: &mut PhotocraftApp, ctx: &Context, ev: crate::canvas::ToolEvent) -> bool { + use crate::canvas::ToolEvent; + let key = menu_id().with("control-dismiss"); + let blocked = ctx.data(|d| d.get_temp::(key)).unwrap_or(false); + if blocked { + if matches!(ev, ToolEvent::Up { .. }) { + ctx.data_mut(|d| d.remove::(key)); + } + return true; + } + if app.ui.brush_mode_menu.is_some() { + if !current_tool(app) { + close(app); + return false; + } + if matches!(ev, ToolEvent::Down { .. }) { + close(app); + ctx.data_mut(|d| d.insert_temp(key, true)); + } + return true; + } + false +} + +/// The engine command a row runs, if any: a brush-based tool's mode goes through +/// `tools.setBrush`, as its options-bar dropdown does. The Brush and Pencil also set or clear +/// Behind (Clear keeps the underlying mode, as the options bar does). +pub(crate) fn engine_call(app: &PhotocraftApp, tool: Tool, item: Item) -> Option<(&'static str, Value)> { + let brush = match (tool, item) { + (Tool::Brush | Tool::Pencil, Item::Blend(m)) => json!({"mode": m, "behind": false}), + (Tool::Brush | Tool::Pencil, Item::Behind) => json!({"mode": BlendMode::Normal, "behind": true}), + (Tool::Brush | Tool::Pencil, Item::Clear) => json!({"mode": app.session.tools.brush.mode, "behind": false}), + (Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing, Item::Blend(m)) => json!({"mode": m}), + _ => return None, + }; + Some(("tools.setBrush", json!({ "brush": brush }))) +} + +/// The row of the open menu whose English label is `label`, if it can be chosen now. +pub(crate) fn find(app: &PhotocraftApp, label: &str) -> Result { + if app.ui.brush_mode_menu.is_none() || !current_tool(app) { + return Err("no Shift+right-click menu is open".into()); + } + let item = items(app.ui.tool).into_iter().find(|i| i.label() == Some(label)).ok_or_else(|| format!("the menu has no row `{label}`"))?; + if !enabled(app, item) { + return Err(format!("`{label}` is unavailable")); + } + Ok(item) +} + +/// Chooses `item` for the active tool and closes the menu. +pub(crate) fn choose(app: &mut PhotocraftApp, ctx: &Context, item: Item) { + let tool = app.ui.tool; + let pos = app.ui.brush_mode_menu; + close(app); + if !enabled(app, item) { + return; + } + crate::paint_mouse::sync_tool_brush(app); + if let Some((id, params)) = engine_call(app, tool, item) { + match app.run(id, params) { + Ok(_) => match tool { + Tool::Brush => app.ui.tool_options.brush_clear = item == Item::Clear, + Tool::Pencil => app.ui.tool_options.pencil_clear = item == Item::Clear, + _ => {} + }, + Err(e) => app.ui.status = e, + } + return; + } + match item { + Item::EditBrush => { + if let Some(at) = pos { + crate::brush_picker::open(&mut app.ui, ctx, at); + } + } + Item::Eraser(m) => app.ui.tool_options.eraser_mode = m.into(), + Item::Range(r) => app.ui.tool_options.tone_range = r.into(), + Item::Sponge(m) => app.ui.tool_options.sponge_mode = m.into(), + Item::Blend(m) if tool == Tool::Gradient => crate::blend_keys::set_gradient_mode(app, m), + Item::Blend(m) if tool == Tool::PaintBucket => { + app.ui.tool_options.bucket_clear = false; + app.ui.tool_options.bucket_blend_mode = m; + } + Item::Clear if tool == Tool::PaintBucket => app.ui.tool_options.bucket_clear = true, + _ => {} + } +} + +pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) { + let Some([x, y]) = app.ui.brush_mode_menu else { return }; + if !current_tool(app) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let same_tool = current_tool(app); + close(app); + // Escape also consumes the release of a right-button gesture still in flight. + if same_tool && ctx.input(|i| i.pointer.secondary_down()) { + ctx.data_mut(|d| d.insert_temp(menu_id().with("dismiss-pointer"), true)); + } + return; + } + crate::paint_mouse::sync_tool_brush(app); + let tool = app.ui.tool; + let rows: Vec<(Item, bool)> = items(tool).into_iter().map(|i| (i, enabled(app, i))).collect(); + let current = current(app, tool); + let screen = crate::work_area::visible_rect(ctx); + let id = menu_id(); + let margin = ctx.global_style().spacing.menu_margin.sum() + egui::Vec2::splat(2.0); + let content: f32 = rows.iter().map(|(i, _)| if *i == Item::Separator { 7.0 } else { 22.0 }).sum(); + let height = content.min((screen.height() - margin.y).max(0.0)); + let width = (screen.width() - margin.x).clamp(0.0, 190.0); + let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(width + margin.x, height + margin.y), |r| r.size()); + let pos = egui::pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top())); + let mut picked = None; + let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).constrain_to(screen).show(ctx, |ui| { + let t = Tokens::get(ctx); + egui::Frame::menu(ui.style()).fill(t.card).stroke(egui::Stroke::new(1.0, t.card_border)).show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 0.0; + crate::widgets::menu_scroll(ui, |ui| { + for (item, on) in rows { + let Some(label) = item.label() else { + ui.separator(); + continue; + }; + let selected = Some(item) == current; + let color = if selected && t.bevel { t.accent_text } else { t.text }; + let text = if on { egui::RichText::new(tl!(label)).color(color) } else { egui::RichText::new(tl!(label)) }; + let row = egui::Button::selectable(selected, text).min_size(egui::vec2(width, 22.0)); + if ui.add_enabled(on, row).clicked() { + picked = Some(item); + } + } + }); + }); + }); + if let Some(item) = picked { + choose(app, ctx, item); + } else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) { + close(app); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3896b84cf6..5c20d57f83 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,18 +53,19 @@ pub mod tool_feedback; mod tool_input; mod transform_controls; mod view_xform; +pub(crate) mod viewport_pages; pub mod wheel_nav; +pub mod zoom_field; pub mod zoom_hold; pub mod zoom_levels; pub mod zoom_window_fit; #[cfg(test)] pub(crate) use cursors::eyedropper_ring_colors; -pub(crate) use cursors::{alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor}; +pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor}; use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair}; #[cfg(test)] pub(crate) use display::GPU_OUTPUT; -#[cfg(test)] use display::texture_key; pub(crate) use display::{cache_key, canvas_display, display_doc, display_image}; use display::{checker, hdr_preview, sync_display_lut}; @@ -86,26 +87,31 @@ use navigator::navigator_waits_for_drag; pub use navigator::{NAVIGATOR_SIDE, navigator_gpu_view, navigator_texture, navigator_waits_for_gpu}; #[cfg(test)] use paint::draw_tool_state; -pub(crate) use paint::{clip_line, marching_ants_lines, marching_ants_mapped}; +pub(crate) use paint::{clip_line, marching_ants_line_shapes, marching_ants_mapped}; use paint::{ - draw_beyond_canvas, draw_drag_preview, marching_ants_segments, paint_cpu_detail, paint_cpu_document, paint_gpu_view, paint_mapped_image_at, pattern_xf, + draw_beyond_canvas, draw_drag_preview, marching_ants_segment_shapes, paint_cpu_detail, paint_cpu_document, paint_gpu_view, paint_mapped_image_at, + pattern_xf, }; use selection::selection_shown_offset; pub use selection::{SelCursor, selection_cursor}; pub(crate) use selection::{floating_outline, selection_drag_delta}; #[cfg(test)] use start_screen::start_screen_drop_hint; +pub(crate) use strokes::multiple_color_target; pub use strokes::paint_target; pub(crate) use strokes::{ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, spread_ms, strokes_live}; use strokes::{HOLD_REPEAT_MS, draw_clone_preview, feed_live_stroke, pointer_button_modifiers, pointer_moves, stroke_wants_time, tap_at_peak_pressure}; #[cfg(test)] pub(crate) use strokes::{SPOT_HEALING_TRAIL, live_retouch_points}; pub(crate) use tabs::arranged_cells; +pub(crate) use tabs::tab_title_at; #[cfg(test)] use tabs::{TEAR_OFF, pro_tab_title, tab_context_items, tab_drop_index, tab_move_target, tabs, torn_off}; pub use tabs::{TabStrip, document_area, extra_windows}; use tabs::{dots, paint_dots}; #[cfg(test)] +use tabs::{main_canvas_document, move_target_after_filter}; +#[cfg(test)] pub(crate) use textures::detail_plan; pub(crate) use textures::{DetailPlan, damage_since, effect_reach, ensure_texture_in_view, retain_gpu_documents, shown_as_document}; pub use textures::{MAX_TEXTURE, ensure_texture}; @@ -115,10 +121,7 @@ use textures::{ensure_gpu, redraw_focus, visible_doc_rect}; pub use tool_input::{ToolEvent, selection_drag_kind, tool_event}; pub(crate) use tool_input::{command_guide_at, command_moves_layer, event_tool}; pub(crate) use transform_controls::{LAYER_EDGES, layer_edges_rect}; -use transform_controls::{ - begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_several, - transform_controls_turn, -}; +use transform_controls::{begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_turn}; #[cfg(test)] use transform_controls::{transform_controls_hit, transform_controls_layers}; pub use view_xform::{ViewXform, doc_point_at, fill_view, fit_view}; @@ -230,7 +233,11 @@ struct SelectionPaintInput<'a> { idx: usize, } -fn paint_selection_outline(app: &mut PhotocraftApp, input: SelectionPaintInput<'_>) { +/// The marching ants of the selection, traced from this frame's snapshot like the image under +/// them, returned to be painted over the grid and guides (#10671): a guide on the same device +/// pixel as an edge used to hide it, at some zoom levels and not others. +fn paint_selection_outline(app: &mut PhotocraftApp, input: SelectionPaintInput<'_>) -> Vec { + let mut ants = Vec::new(); // Select and Mask outlines its refined selection (on its preview proxy, reduced by `k`) in // the Marching Ants view and hides the ants in the others. let refined = crate::select_mask_view::ants(app, input.idx); @@ -314,15 +321,23 @@ fn paint_selection_outline(app: &mut PhotocraftApp, input: SelectionPaintInput<' shown.center = [input.xf.center[0] - dx as f32, input.xf.center[1] - dy as f32]; } // Under a Free Transform box they are where the box puts them (#5297). - if !(carried && crate::transform_tool::draw_selection(app, input.painter, &input.xf, input.doc.id, segs, time)) { - marching_ants_segments(input.painter, &shown, segs, time); - } + let boxed = carried.then(|| crate::transform_tool::selection_ants(app, input.painter, &input.xf, input.doc.id, segs, time)).flatten(); + ants = boxed.unwrap_or_else(|| marching_ants_segment_shapes(input.painter, &shown, segs, time)); input.ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } + ants } -fn paint_canvas(app: &mut PhotocraftApp, ui: &egui::Ui, view: &View, xf: ViewXform, input: &CanvasPaintInput<'_>) -> (bool, egui::layers::ShapeIdx, Rect) { +/// Paints the document, and returns whether the pixels past the canvas show, the Layer Edges slot, +/// the canvas edge on screen, and the selection's ants still to be painted ([`paint_selection_outline`]). +fn paint_canvas( + app: &mut PhotocraftApp, + ui: &egui::Ui, + view: &View, + xf: ViewXform, + input: &CanvasPaintInput<'_>, +) -> (bool, egui::layers::ShapeIdx, Rect, Vec) { let ctx = input.ctx; let rect = input.rect; let painter = input.painter; @@ -403,6 +418,29 @@ fn paint_canvas(app: &mut PhotocraftApp, ui: &egui::Ui, view: &View, xf: ViewXfo offset: [0.0, 0.0], }; paint_gpu_view(painter, rect, params, &pattern); + } else if gpu_ok && viewport_pages::show(app, ctx, idx, &xf, view.zoom) { + // Too large for the document's texture set: the pages in view, rendered in the background. + on_gpu = true; + app.perf.gpu = true; + let key = viewport_pages::stream_key(doc.id.0); + let params = crate::gpu_canvas::ViewParams { + doc: key, + doc_size: [doc.size.width, doc.size.height], + zoom: point_zoom, + center: xf.center, + rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 }, + shadow: { + let t = crate::theme::Tokens::get(ctx); + !t.bevel && !t.pro && drop_shadow + }, + pixel_grid, + view_key: egui::Id::unique(("pc-canvas-pages", ctx.viewport_id(), doc.id.0)).value(), + display: sync_display_lut(app, doc, key, output), + output: output.unwrap_or(0), + hdr: hdr_preview(app, doc), + offset: [0.0, 0.0], + }; + paint_gpu_view(painter, rect, params, &pattern); } else if gpu_ok && ensure_gpu(app, ctx, idx, redraw_focus(&target_xf, &xf)) { on_gpu = true; app.perf.gpu = true; @@ -544,9 +582,9 @@ fn paint_canvas(app: &mut PhotocraftApp, ui: &egui::Ui, view: &View, xf: ViewXfo // in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer. let layer_edges = painter.add(egui::Shape::Noop); - paint_selection_outline(app, SelectionPaintInput { ctx, painter, ui, view, xf, rect, doc, idx }); + let ants = paint_selection_outline(app, SelectionPaintInput { ctx, painter, ui, view, xf, rect, doc, idx }); - (beyond, layer_edges, edge_rect) + (beyond, layer_edges, edge_rect, ants) } struct CanvasNavigationInput<'a> { @@ -980,6 +1018,7 @@ fn handle_canvas_tool_stroke( && app.drag.is_none() && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) + && !crate::brush_mode_menu::blocks_canvas(app, ctx) && let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)) { let d = xf.to_doc(p); @@ -1243,7 +1282,7 @@ struct CanvasOverlayInput<'a> { zoom_out: &'a dyn Fn(bool) -> bool, } -fn paint_primary_overlays(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut View, input: &CanvasOverlayInput<'_>) { +fn paint_primary_overlays(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut View, input: &CanvasOverlayInput<'_>, ants: Vec) { let ctx = input.ctx; let painter = input.painter; let xf = input.xf; @@ -1264,6 +1303,8 @@ fn paint_primary_overlays(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut if app.ui.view.shows(app.ui.view.show.canvas_guides) || crate::guide_layout_ui::preview(app, doc).is_some() { crate::rulers::draw_guides(app, painter, xf, doc); } + // The selection's ants go over the grid and guides, as in Photoshop (#10671). + painter.extend(ants); // The pending crop frame shows on its own document only, not on the other tiles (#1918). let foreign_crop = if app.session.active_index() == Some(idx) { None } else { app.ui.crop_rect.take() }; draw_drag_preview(app, painter, xf); @@ -1360,10 +1401,8 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C // corner badge, as Photoshop's corner radius cursor. ui.ctx().set_cursor_icon(egui::CursorIcon::Default); crate::icons::cursor_badge(ctx, "scan", p); - } else if let Some((r, p, c)) = response - .hover_pos() - .filter(|_| !transform_controls_several(app)) - .and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some((r, p, c)) = + response.hover_pos().and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -1445,7 +1484,7 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: & t if app.alt_sampling || (app.drag.is_none() && !app.services.right_button_down.as_ref().is_some_and(|f| f()) - && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => + && alt_picks(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => { // While it samples (button held), the comparison ring replaces the cursor (#213). if let Some(icon) = eyedropper_ring(app, painter, xf, p, response.is_pointer_button_down_on()) { @@ -1550,6 +1589,7 @@ fn draw_default_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: & if precise_other_cursor(app) { egui::CursorIcon::Crosshair } else { pen_cursor(ui.ctx(), tool, p) } } // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool. + Tool::Move if crate::move_mods::copying(app, alt) => egui::CursorIcon::Copy, Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => { @@ -1742,6 +1782,21 @@ fn sync_view_to_document(app: &PhotocraftApp, ctx: &egui::Context, view: &mut Vi aspect } +fn update_polygon_camera(app: &mut PhotocraftApp, ctx: &egui::Context, (idx, primary): (usize, bool), rect: Rect, view: &mut View, ppp: f32, aspect: f32) { + let previous_center = view.center; + if primary { + polygon_cancel_stale(app); + polygon_edge_scroll(app, ctx, idx, rect, view, ppp); + } + // Bounds belong to an actual edge-scroll update, not an idle/finished polygon. + if view.center != previous_center && !app.session.prefs().tools.overscroll { + crate::scrollbars::clamp_view_with_aspect(view, rect.size(), ppp, aspect); + } + if view.center != previous_center { + ctx.request_repaint(); + } +} + pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); // The camera as the app holds it before this frame's input. The canvas works on its own copy @@ -1799,6 +1854,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let doc = app.session.documents()[idx].doc.clone(); let size = [doc.size.width, doc.size.height]; let aspect = sync_view_to_document(app, &ctx, &mut view, &doc, rect, ppp); + update_polygon_camera(app, &ctx, (idx, primary), rect, &mut view, ppp, aspect); + let flip = app.ui.view.flip_horizontal; // Document rendering follows the target; only shader uniforms and overlay geometry ease. let target_xf = ViewXform { rect, zoom: view.zoom / ppp, center: view.center, flip, rotation: view.rotation, aspect }; @@ -1878,6 +1935,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::crop_ui::cancel_stale(app); crate::crop_ui::ensure_frame(app); let mut drawing = crate::crop_ui::active(app); + if let Some(pen) = app.ui.pen.as_mut().filter(|pen| pen.dragging && !pen.curvature) { + pen.reposition = reposition; + drawing = true; + } if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) { d.reposition = reposition; drawing = true; @@ -1992,7 +2053,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } else { xf }; - let (beyond, layer_edges, edge_rect) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf, &paint_input)); + let (beyond, layer_edges, edge_rect, ants) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf, &paint_input)); // A Hand drag (the tool, Space or the middle button) keeps every overlay on screen, as in // Photoshop: the Pen path being drawn, paths, guides, grid, transform box (#6592). Only the // tool cursors below give way to the fist. @@ -2013,7 +2074,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: panning, zoom_out: &zoom_out, }; - paint_primary_overlays(app, ui, &mut view, &input); + paint_primary_overlays(app, ui, &mut view, &input, ants); + } else { + painter.extend(ants); } let tail = CanvasTailInput { ctx: &ctx, @@ -2081,6 +2144,9 @@ mod transform_controls_tests; #[cfg(test)] mod layer_edges_tests; +#[cfg(test)] +mod guide_ants_tests; + #[cfg(test)] mod rotation_preview_isolation_tests { use super::*; @@ -2609,3 +2675,223 @@ mod lasso_preview_tests { assert_eq!((dashes, solid), (0, 1), "Magnetic Lasso: the traced border is solid"); } } + +#[cfg(test)] +mod multi_channel_ui_tests { + use super::*; + fn app() -> PhotocraftApp { + 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.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app + } + #[test] + fn pixel_tools_capture_the_whole_channel_set_and_gradients_stay_on_pixels() { + let mut app = app(); + assert_eq!(paint_target(&app), json!({"colors":[0,2]})); + app.ui.tool = Tool::Gradient; + app.ui.tool_options.gradient_classic = false; + assert!(!crate::gradient_ui::live_mode(&app)); + } + #[test] + fn viewing_a_layer_mask_overrides_colour_targets_for_live_brush_strokes() { + for depth in [8, 16, 32] { + for view in ["gray", "overlay"] { + for colors in [vec![0], vec![0, 2]] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":64,"height":64,"depth":depth})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"colors":colors})).unwrap(); + app.run("view.layerMask", json!({"mode":view})).unwrap(); + assert_eq!(paint_target(&app), json!("mask"), "{depth}-bit {view} {colors:?}"); + assert!(app.layer_mask_targeted()); + app.run("tools.setColors", json!({"foreground":[0.0,0.0,0.0,1.0]})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":12,"hardness":1.0,"smoothing":{"amount":0.0}}})).unwrap(); + app.ui.tool = Tool::Brush; + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let original = st.doc.layer(layer).unwrap().surface().unwrap().clone(); + let steps = st.history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 32.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 32.0, y: 32.0, pressure: 1.0 }, egui::Modifiers::NONE); + let shown = display_doc(&mut app, 0).0; + let shown_layer = shown.layer(layer).unwrap(); + assert_eq!(shown_layer.surface().unwrap(), &original); + assert!(shown_layer.mask.as_ref().unwrap().surface.sample_channel(28, 32, 0) < 0.1); + tool_event(&mut app, ToolEvent::Up { x: 32.0, y: 32.0 }, egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + let committed = st.doc.layer(layer).unwrap(); + assert_eq!(committed.surface().unwrap(), &original); + assert_eq!(committed.mask.as_ref().unwrap().surface, shown_layer.mask.as_ref().unwrap().surface); + assert_eq!(st.history.past_len(), steps + 1); + app.run("channel.target", json!({"colors":colors})).unwrap(); + assert_eq!(paint_target(&app), json!({"colors":colors})); + assert!(!app.layer_mask_targeted()); + } + } + } + } + /// The colour rows stay selected when another layer becomes active. An adjustment layer has no + /// pixels, so the Brush paints its mask, as it does without colour rows selected. + #[test] + fn colour_rows_left_selected_still_paint_an_adjustment_layers_mask() { + for colors in [vec![0], vec![0, 2]] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":64,"height":64})).unwrap(); + app.run("channel.target", json!({"colors":colors})).unwrap(); + app.run("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + app.sync_views(); + assert_eq!(paint_target(&app), json!("mask"), "{colors:?}"); + app.run("tools.setColors", json!({"foreground":[0.0,0.0,0.0,1.0]})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":12,"hardness":1.0,"smoothing":{"amount":0.0}}})).unwrap(); + app.ui.tool = Tool::Brush; + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let steps = st.history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 32.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 32.0, y: 32.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 32.0, y: 32.0 }, egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "{colors:?}: one stroke"); + assert!(st.doc.layer(layer).unwrap().mask.as_ref().unwrap().surface.sample_channel(28, 32, 0) < 0.1, "{colors:?}: the mask was painted"); + } + } + + /// Targeting a layer mask with colour rows selected (its thumbnail, or adding the mask) moves + /// the Channels panel onto the mask, as in Photoshop: tools, dialogs and menu commands then + /// all edit the mask. Several rows still stop the Move tool on a layer that needs + /// rasterizing, which paints only after the prompt. + #[test] + fn targeting_a_mask_over_colour_rows_edits_the_mask_everywhere() { + let ctx = egui::Context::default(); + let set = |app: &mut PhotocraftApp, p: serde_json::Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + assert!(matches!(crate::control::handle(app, &ctx, &req), crate::control::Outcome::Done(v) if v["ok"] != false)); + }; + for colors in [vec![0], vec![0, 2]] { + for add in [false, true] { + let mut app = self::app(); + if !add { + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + set(&mut app, json!({"maskTarget": false})); + } + app.run("channel.target", json!({"colors":colors})).unwrap(); + app.sync_views(); + assert!(paint_target(&app).get("colors").is_some(), "{colors:?}: the rows are the target first"); + let journal = app.session.journal.len(); + if add { + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + } else { + set(&mut app, json!({"maskTarget": true})); + } + app.sync_views(); + assert_eq!(app.session.journal.len(), journal + usize::from(add), "{colors:?} add={add}: no extra recorded step"); + assert_eq!(paint_target(&app), json!("mask"), "{colors:?} add={add}"); + assert!(app.layer_mask_targeted(), "{colors:?} add={add}"); + assert_eq!(app.with_mask_target("filter.blur.gaussianBlur", json!({}))["target"], "mask"); + assert!(crate::distort_ui::colour_set_target(&app).is_null()); + assert!(!multiple_color_target(&app)); + // Clicking a colour row again takes the rows back. + app.run("channel.target", json!({"colors":colors})).unwrap(); + app.sync_views(); + assert!(paint_target(&app).get("colors").is_some(), "{colors:?} add={add}: the rows again"); + } + } + // A mask added to another layer, or a fill layer's mask, leaves the rows targeted. + let mut app = self::app(); + let active = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let other = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": active.0})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.run("layer.layerMask.revealAll", json!({"layer": other.0})).unwrap(); + app.sync_views(); + assert_eq!(paint_target(&app), json!({"colors":[0,2]}), "a mask on another layer"); + app.run("layer.newFillLayer.solidColor", json!({})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + set(&mut app, json!({"maskTarget": true})); + app.sync_views(); + assert!(matches!(app.session.active().unwrap().channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Color(_)), "a fill layer"); + let mut app = self::app(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.sync_views(); + assert_eq!(paint_target(&app), json!("pixels")); + assert!(crate::rasterize_prompt::needed(&app).is_some()); + assert!(multiple_color_target(&app)); + let refused = crate::liquify_ui::open(&mut app, &ctx).unwrap_err(); + assert!(refused.contains("Rasterize"), "{refused}"); + } + + #[test] + fn subset_does_not_block_guides_or_vector_path_transforms() { + let mut app = app(); + app.ui.tool = Tool::Move; + app.run("view.newGuide", json!({"orientation":"vertical","position":12})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 12.0, y: 12.0, pressure: 1.0 }, egui::Modifiers::default()); + assert!(app.guide_drag.is_some()); + app.guide_drag = None; + app.run("path.set", json!({"name":"work","path":{"subpaths":[{"closed":true,"knots":[[10,10],[30,10],[30,20],[10,20]]}]}})).unwrap(); + app.ui.tool = Tool::PathSelection; + assert!(crate::menus::is_enabled(&app, "edit.freeTransform")); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + assert!(app.ui.transform.as_ref().unwrap().path.is_some()); + } +} + +pub(crate) use drag::cancel_stale_vector_drag; + +#[cfg(test)] +#[path = "pen_gesture_owner_tests.rs"] +mod pen_gesture_owner_tests; +#[cfg(test)] +#[path = "pen_path_visual_tests.rs"] +mod pen_path_visual_tests; + +/// Between Polygonal Lasso clicks, reveal more of the image near or beyond the viewport edge. +/// Only the view moves: fixed vertices stay in document coordinates, including while button-up. +fn polygon_edge_scroll(app: &PhotocraftApp, ctx: &egui::Context, idx: usize, rect: Rect, view: &mut View, ppp: f32) { + if app.ui.polygon.is_empty() + || app.ui.tool != Tool::PolygonLasso + || app.session.active_index() != Some(idx) + || !app.ui.dialogs.is_empty() + || app.camera_raw.is_some() + || app.discard.is_some() + || app.drag.is_some() + || !crate::menus::modal_allows(app, "select.lasso") + || app.ui.transform.is_some() + || crate::distort_ui::modal_workspace(app).is_some() + || ctx.text_edit_focused() + || egui::Popup::is_any_open(ctx) + || crate::hold_keys::for_frame(app, ctx, false).is_some() + { + return; + } + let Some((pointer, dt)) = ctx.input(|i| (i.focused && !i.pointer.middle_down()).then(|| i.pointer.hover_pos().map(|p| (p, i.stable_dt))).flatten()) else { + return; + }; + let zoom = view.zoom / ppp; + if !pointer.x.is_finite() || !pointer.y.is_finite() || !dt.is_finite() || dt <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { + return; + } + // Moving over a neighbouring document is not a request to pan either tiled view. + if arranged_cells(app, app.last_canvas_rect).is_some_and(|cells| cells.iter().any(|(other, cell)| *other != idx && cell.contains(pointer))) { + return; + } + let edge = 32.0_f32.min(rect.width() * 0.25).min(rect.height() * 0.25); + if edge <= 0.0 { + return; + } + let speed = |p: f32, lo: f32, hi: f32| { + let direction = ((edge - (hi - p)) / edge).clamp(0.0, 1.0) - ((edge - (p - lo)) / edge).clamp(0.0, 1.0); + if direction == 0.0 { 0.0 } else { direction.signum() * (80.0 + 520.0 * direction.abs()) } + }; + let velocity = vec2(speed(pointer.x, rect.left(), rect.right()), speed(pointer.y, rect.top(), rect.bottom())); + let velocity = velocity * (600.0 / velocity.length().max(600.0)); + let xf = ViewXform { rect, zoom, center: view.center, flip: app.ui.view.flip_horizontal, rotation: view.rotation, aspect: app.ui.view.display_aspect() }; + let delta = xf.unmap_vec(velocity) * dt.min(0.05) / zoom; + view.center[0] += delta.x; + view.center[1] += delta.y; +} diff --git a/crates/ui-egui/src/canvas/ants.rs b/crates/ui-egui/src/canvas/ants.rs index 2af2e7bd08..b74b53b1e1 100644 --- a/crates/ui-egui/src/canvas/ants.rs +++ b/crates/ui-egui/src/canvas/ants.rs @@ -148,14 +148,20 @@ pub(crate) fn shapes(lines: impl Iterator, clip: Rect, styl (mesh, slanted) } +/// The ants along `lines` (screen points) at frame time `time`, as `painter` would draw them but +/// not yet drawn: the canvas traces them early and paints them over the guides (#10671). +pub(crate) fn shapes_for(painter: &egui::Painter, lines: impl Iterator, time: f64, snap_slanted: bool) -> Vec { + let style = Style::new(painter.pixels_per_point(), time); + let (mesh, mut slanted) = shapes(lines, painter.clip_rect(), style, snap_slanted); + if !mesh.is_empty() { + slanted.push(Shape::mesh(mesh)); + } + slanted +} + /// Draw the ants along `lines` (screen points) at frame time `time`; see [`shapes`]. pub(crate) fn draw(painter: &egui::Painter, lines: impl Iterator, time: f64, snap_slanted: bool) { - let style = Style::new(painter.pixels_per_point(), time); - let (mesh, slanted) = shapes(lines, painter.clip_rect(), style, snap_slanted); - painter.extend(slanted); - if !mesh.is_empty() { - painter.add(Shape::mesh(mesh)); - } + painter.extend(shapes_for(painter, lines, time, snap_slanted)); } /// The segments of the polyline `points`, back to the first when `closed`. diff --git a/crates/ui-egui/src/canvas/background_redraw/tests.rs b/crates/ui-egui/src/canvas/background_redraw/tests.rs index 55afe9ac6b..ce47c0c003 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -249,8 +249,9 @@ fn an_edit_damaging_most_of_a_large_document_is_drawn_in_the_background_too() { let Some(mut g) = gpu_app() else { return }; let doc = cpu_only_document(g.harness.state_mut(), 2600, 1700); settle(&mut g); - // Filling part of the Blend If layer can change the composite anywhere: the whole canvas. - g.harness.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 300, "height": 300})).expect("select"); + // Filling most of the Blend If layer touches tiles all over it: the whole canvas (a fill of a + // small part damages only its own tiles, #9329). + g.harness.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 2500, "height": 1500})).expect("select"); g.harness.step(); g.harness.state_mut().run("edit.fill", json!({"color": "#22ee22"})).expect("fill"); assert_eq!(g.harness.state().session.active().expect("document").last_damage, Some(Rect::new(0, 0, 2600, 1700))); @@ -611,6 +612,150 @@ fn a_canvas_over_the_texture_budget_records_its_reason() { assert_eq!(g.harness.state().render_path.history().filter(|c| c.doc == doc).count(), 1, "once, not every frame"); } +/// Frames until the active document's viewport pages are all rendered and uploaded. +fn settle_pages(g: &mut Gpu) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + g.harness.step(); + let app = g.harness.state(); + let id = app.session.active().expect("document").doc.id; + if app.streams.get(&id).is_some_and(|s| !s.busy() && s.stats.wanted > 0 && s.stats.ready == s.stats.wanted) { + return; + } + assert!(std::time::Instant::now() < deadline, "the viewport pages didn't finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// The screen pixel at the window's centre. +fn centre_pixel(g: &mut Gpu) -> [u8; 4] { + let img = g.harness.render().expect("render"); + let p = img.get_pixel(img.width() / 2, img.height() / 2); + p.0 +} + +/// #5332: a document whose texture set doesn't fit the canvas budget stays on the GPU canvas, +/// drawn from the pages in view instead of the CPU canvas, with the same pixels; an edit +/// re-renders only the pages, and closing it frees them. +#[test] +fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new"); + g.harness.run_steps(3); + assert!(on_gpu_canvas(&g)); + let whole = centre_pixel(&mut g); + let gpu = g.harness.state().gpu.clone().expect("gpu canvas"); + let doc = g.harness.state().session.active().expect("document").doc.clone(); + let key = crate::canvas::viewport_pages::stream_key(doc.id.0); + // Room for pages (half the budget), not for the document's texture set. + let (set, _) = gpu.canvas_fit(&doc).expect("fit"); + gpu.set_canvas_budget(set - 1); + settle_pages(&mut g); + assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g)); + assert!(gpu.texture_info(doc.id.0).is_none(), "no texture set"); + let (pages, bytes) = gpu.pages(key); + let stats = g.harness.state().streams[&doc.id].stats.clone(); + assert_eq!(pages.len(), stats.resident); + assert!(stats.wanted > 0 && stats.last_error.is_none(), "{stats:?}"); + assert!(bytes <= (set - 1) / 2, "pages stay within half the budget: {bytes}"); + let shown = centre_pixel(&mut g); + assert!((0..3).all(|i| shown[i].abs_diff(whole[i]) <= 2), "pages {shown:?}, texture set {whole:?}"); + // An edit re-renders the pages it reaches. + let uploads = stats.uploads; + g.harness.state_mut().run("edit.fill", json!({"color": "#cc8844"})).expect("fill"); + settle_pages(&mut g); + let stats = g.harness.state().streams[&doc.id].stats.clone(); + assert!(stats.dirtied > 0 && stats.uploads > uploads, "{stats:?}"); + let filled = centre_pixel(&mut g); + assert!(filled[0] > 0xb0 && filled[2] < 0x60, "the fill is shown: {filled:?}"); + // A small edit re-renders only the part of each page it reaches, into the page held. + let (held, partial) = (gpu.pages(key).0.len(), stats.partial); + // (A frame between the steps, as in use: the edit's damage is known one revision at a time.) + g.harness.state_mut().run("select.rect", json!({"x": 1400, "y": 900, "width": 200, "height": 200})).expect("select"); + settle_pages(&mut g); + g.harness.state_mut().run("edit.fill", json!({"color": "#22aa22"})).expect("small fill"); + settle_pages(&mut g); + let stats = g.harness.state().streams[&doc.id].stats.clone(); + assert!(stats.partial > partial, "{stats:?}"); + assert_eq!(gpu.pages(key).0.len(), held, "no new page textures"); + // Just its square is shown: 200 px at about 20% zoom, some 40 screen pixels a side. + let img = g.harness.render().expect("render"); + let green = img.pixels().filter(|p| p.0[1] > 0x90 && p.0[0] < 0x50 && p.0[2] < 0x50).count(); + assert!((1000..2500).contains(&green), "the small fill is shown: {green} pixels"); + // A budget too small for one page draws it on the CPU canvas, as before. + gpu.set_canvas_budget(1); + g.harness.run_steps(3); + assert_eq!(render_path_of(&g), (Some("canvasTooLarge"), Some("CPU Rendering"))); + // Its pages and their work are freed meanwhile (#5332 review). + assert!(gpu.pages(key).0.is_empty(), "pages freed"); + assert!(!g.harness.state().streams.contains_key(&doc.id), "stream dropped"); + gpu.set_canvas_budget(set - 1); + settle_pages(&mut g); + // Closing the document cancels its work and frees its pages. + g.harness.state_mut().run("file.close", json!({"save": false})).expect("close"); + g.harness.run_steps(3); + assert!(g.harness.state().streams.is_empty()); + assert!(gpu.pages(key).0.is_empty()); +} + +/// #5332 review: the open documents share one texture budget rather than each having all of +/// it. A document alone on screen keeps the whole budget; one in a background tab gives its +/// textures up when the shown one needs the room; and documents shown side by side split it, +/// a document over its share drawing from viewport pages. +#[test] +fn open_documents_share_one_texture_budget_with_the_shown_ones_first() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + let gpu = g.harness.state().gpu.clone().expect("gpu canvas"); + let new = |g: &mut Gpu| { + g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new"); + g.harness.run_steps(3); + assert!(on_gpu_canvas(g), "{:?}", render_path_of(g)); + g.harness.state().session.active().expect("document").doc.clone() + }; + let a = new(&mut g); + let (set, _) = gpu.canvas_fit(&a).expect("fit"); + // Room for one texture set and a half: a document alone on screen has all of it. + let budget = set + set / 2; + gpu.set_canvas_budget(budget); + assert_eq!(gpu.canvas_fit(&a), Some((set, budget)), "alone, the whole budget"); + assert!(gpu.texture_info(a.id.0).is_some()); + // A second tab: the shown document's texture set takes the room of the background one's. + let b = new(&mut g); + assert_eq!(gpu.canvas_fit(&b), Some((set, budget))); + assert!(gpu.texture_info(b.id.0).is_some()); + assert!(gpu.texture_info(a.id.0).is_none(), "the background document's textures are freed"); + // Back to the first tab: it gets its texture set back and the other gives its up. + g.harness.state_mut().session.set_active(0); + g.harness.run_steps(3); + assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g)); + assert!(gpu.texture_info(a.id.0).is_some()); + assert!(gpu.texture_info(b.id.0).is_none()); + // Side by side, they split the budget: neither set fits its half, so both draw from pages. + g.harness.state_mut().ui.view.arrange = "twoUpVertical".into(); + g.harness.run_steps(3); + assert_eq!(gpu.canvas_fit(&a), Some((set, budget / 2))); + assert_eq!(gpu.canvas_fit(&b), Some((set, budget - budget / 2))); + settle_pages(&mut g); + assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g)); + let streams = &g.harness.state().streams; + assert!(streams.contains_key(&a.id) && streams.contains_key(&b.id), "both on pages"); + assert!(gpu.texture_info(a.id.0).is_none() && gpu.texture_info(b.id.0).is_none()); + // Switching between them re-splits nothing: no page set is dropped. + let held = gpu.pages(crate::canvas::viewport_pages::stream_key(a.id.0)).0; + g.harness.state_mut().session.set_active(1); + g.harness.run_steps(3); + assert_eq!(gpu.canvas_fit(&a), Some((set, budget / 2))); + assert_eq!(gpu.pages(crate::canvas::viewport_pages::stream_key(a.id.0)).0, held, "no churn on a switch"); + // Back to tabs: the shown document has the whole budget, and its texture set, again. + g.harness.state_mut().ui.view.arrange = "tabs".into(); + g.harness.run_steps(3); + assert_eq!(gpu.canvas_fit(&b), Some((set, budget))); + assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g)); + assert!(gpu.texture_info(b.id.0).is_some()); +} + #[test] fn a_flipped_view_records_why_it_draws_on_the_cpu() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index d2a49241d5..5cfa676b66 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -313,7 +313,7 @@ fn choose_crop(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> use crate::crop_overlay::{CropOverlay, OverlayShow}; use crate::crop_presets::{Choice, apply_choice}; match command { - CROP_CANCEL => crate::crop_ui::cancel(app), + CROP_CANCEL => crate::crop_ui::dismiss(app), CROP_COMMIT => crate::canvas::commit_crop(app), CROP_NEW => apply_choice(app, Choice::New), CROP_DELETE => apply_choice(app, Choice::Delete), @@ -1019,6 +1019,33 @@ fn spec_dialog(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, /// current options) and Simulate Pressure (#2978). const STROKE_PATH_SPEC: &str = r##"{"tool":"brush|pencil|eraser|backgroundEraser|cloneStamp|patternStamp|healingBrush|spotHealingBrush|historyBrush|blur|sharpen|smudge|dodge|burn|sponge|colorReplacement|mixerBrush"="brush","simulatePressure":bool=false}"##; +/// Where the Stroke Path dialog's Simulate Pressure choice is remembered: the UI-state preferences' +/// dialogs map, under the command id, as `edit.fill` and `view.newGuide` use their own (#10527). +const STROKE_PATH_REMEMBERED: &str = "path.stroke"; + +/// The choice the next Stroke Path dialog opens with: what the last OK applied, else off. Saved data +/// is never trusted — anything but an object holding a real bool (a hand-edited preferences file, +/// an agent that wrote something else) falls back to Photoshop's default, off. +fn remembered_simulate_pressure(app: &PhotocraftApp) -> bool { + app.session + .prefs() + .dialogs + .get(STROKE_PATH_REMEMBERED) + .and_then(Value::as_object) + .and_then(|saved| saved.get("simulatePressure")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +/// OK ran the stroke: remember the Simulate Pressure it applied so the dialog opens with it again +/// next time (#10527). Cancel never reaches here, so cancelling changes nothing, and a stroke run +/// without the dialog (the Paths panel's Stroke button) writes nothing either. A stroke whose params +/// said nothing about pressure applied the engine's default, off. +pub(crate) fn remember_simulate_pressure(app: &mut PhotocraftApp, params: &Value) { + let on = params.get("simulatePressure").and_then(Value::as_bool).unwrap_or(false); + app.session.prefs.edit(|p| p.dialogs.insert(STROKE_PATH_REMEMBERED.into(), json!({ "simulatePressure": on }))); +} + /// The Stroke Path dialog for the path `name` (Paths panel menu, Alt-click on its Stroke button). pub(crate) fn stroke_path_dialog(app: &mut PhotocraftApp, name: &str) { pen_dialog(app, "path.stroke", "Stroke Path…", name); @@ -1055,6 +1082,12 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) { fields.insert(key.into(), json!(value)); } } + if command == "path.stroke" { + // The spec-driven dialog reappears with fresh fields, so the checkbox needs its own seed: + // a schema dialog carrying a `__spec` is outside `filter_dialog::remember`. Only Simulate + // Pressure is remembered; the tool stays on the spec's default (#10527). + fields.insert("simulatePressure".into(), json!(remembered_simulate_pressure(app))); + } spec_dialog(app, command, label, spec, fields); } @@ -1066,8 +1099,8 @@ pub(crate) fn make_selection(app: &mut PhotocraftApp) -> Result<(), String> { // else the Paths panel path it adds to (a selected saved path, or the Work Path), which it // selects so it is the active path. let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || crate::vector_ui::targeted_vector_mask(app).is_some()); - if pending { - crate::vector_ui::pen_commit(app, false); + if pending && !crate::vector_ui::pen_commit(app, false) { + return Err(app.ui.status.clone()); } let name = if pending_layer { Some("layer".to_string()) } else { crate::vector_ui::active_path_name(app) } .ok_or_else(|| "No path is available for this action".to_string())?; @@ -1086,8 +1119,8 @@ fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, com } let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let pending_layer = pending && (app.ui.tool_options.vector_mode == "shape" || app.ui.vector_mask_target); - if pending { - crate::vector_ui::pen_commit(app, false); + if pending && !crate::vector_ui::pen_commit(app, false) { + return Err(app.ui.status.clone()); } let name = if pending { Some(if pending_layer { "layer" } else { "work" }.to_string()) } else { menu.path_name.clone() } .ok_or_else(|| "No path is available for this action".to_string())?; @@ -1155,7 +1188,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { continue; }; let text = if crop_label_is_ui(&command) { tl!(&label).to_string() } else { label }; - let item = egui::Button::selectable(mode_checked(app, &menu, &command), text).min_size(egui::vec2(200.0, 20.0)); + // The overlay rows carry their own glyph (#10889); the rest stay text-only. + let mut atoms = egui::Atoms::default(); + if let Some(kind) = command.strip_prefix(CROP_OVERLAY).and_then(crate::crop_overlay::CropOverlay::from_id) { + atoms.push_right(crate::icons::image(kind.icon(), 14.0, t.icon)); + } + atoms.push_right(text); + let item = egui::Button::selectable(mode_checked(app, &menu, &command), atoms).min_size(egui::vec2(200.0, 20.0)); if ui.add_enabled(entry_enabled(app, &menu, &command), item).clicked() { selected = Some(command); } diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 6f8d0a3aa8..e5c46b42ae 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -905,6 +905,151 @@ fn pen_menu_saved_path_clipping_and_numeric_transform() { assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.transform")); } +// #10527: the Stroke Path dialog's Simulate Pressure checkbox keeps the choice the last OK applied. + +/// A document with the Pen tool picked and a Work Path. Every route to the Stroke Path dialog (the +/// Pen canvas menu, the Paths panel context menu, Alt-click on its Stroke button) ends in the same +/// `pen_dialog`, so driving the menu route covers the other two. +fn stroke_app() -> PhotocraftApp { + let mut app = app(); + app.ui.tool = Tool::Pen; + work_path(&mut app); + app.ui.selected_path = Some("work".into()); + app +} + +/// Open the Stroke Path dialog through the Pen canvas menu and return its id. +fn open_stroke(app: &mut PhotocraftApp) -> u64 { + choose_open(app, "path.stroke"); + let d = app.ui.dialogs.last().expect("Stroke Path… opens a dialog"); + assert_eq!(d.fields.get("__command").and_then(Value::as_str), Some("path.stroke")); + assert_eq!(d.fields.get("name").and_then(Value::as_str), Some("work"), "the Work Path is stroked"); + d.id +} + +/// The Simulate Pressure the freshly opened dialog carries, or `None` when it carries none at all. +/// The spec body draws a missing field as the schema default (off), so `None` and `Some(false)` look +/// the same on screen; asserting a seeded `Some(bool)` pins the defensive read, which must never +/// trust a saved value into a non-bool. +fn pressure_on_open(app: &mut PhotocraftApp) -> Option { + let id = open_stroke(app); + let on = app.ui.dialog_mut(id).unwrap().fields.get("simulatePressure").and_then(Value::as_bool); + app.ui.close_dialog(id); + on +} + +/// Tick or untick Simulate Pressure in the open dialog. +fn set_pressure(app: &mut PhotocraftApp, id: u64, on: bool) { + app.ui.dialog_mut(id).unwrap().fields.insert("simulatePressure".into(), json!(on)); +} + +#[test] +fn stroke_path_reopens_with_the_simulate_pressure_that_was_applied() { + let mut app = stroke_app(); + // The first one opens off: no remembered choice, and Photoshop's default is off. + assert_eq!(pressure_on_open(&mut app), Some(false), "the first Stroke Path opens with a seeded, off checkbox"); + // Tick Simulate Pressure and press OK: the stroke runs with it. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.stroke"), "OK stroked the path"); + // Re-opening shows the choice that was applied (#10527). + assert_eq!(pressure_on_open(&mut app), Some(true), "the applied choice opens next time"); + // Unticking it and pressing OK applies off, which is what opens next time. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, false); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(pressure_on_open(&mut app), Some(false), "an applied off wins over the remembered on"); +} + +#[test] +fn stroke_path_cancel_leaves_the_remembered_simulate_pressure_alone() { + let mut app = stroke_app(); + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(pressure_on_open(&mut app), Some(true), "the applied choice opens again"); + // Untick it and press Cancel: nothing was applied, so the next opening is unchanged. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, false); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.ui.dialogs.is_empty(), "Cancel closes the dialog"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "path.stroke").count(), 1, "Cancel ran no stroke"); + assert_eq!(pressure_on_open(&mut app), Some(true), "Cancel leaves the remembered choice alone (#10527)"); + // Cancel can also be the first thing done: still nothing to remember. + let mut fresh = stroke_app(); + let id = open_stroke(&mut fresh); + set_pressure(&mut fresh, id, true); + crate::dialogs::cancel(&mut fresh, id).unwrap(); + assert_eq!(pressure_on_open(&mut fresh), Some(false), "a cancelled on never becomes the remembered choice"); +} + +#[test] +fn stroke_path_falls_back_to_off_for_a_corrupt_saved_simulate_pressure() { + let mut app = stroke_app(); + // Saved data is never trusted: anything but a real bool in the remembered choice reopens the + // checkbox off rather than showing or applying it. + let corrupt = + [json!("not an object"), json!(null), json!({}), json!({"simulatePressure": "yes"}), json!({"simulatePressure": 1}), json!({"simulatePressure": null})]; + for bad in corrupt { + app.session.prefs.edit(|p| p.dialogs.insert("path.stroke".into(), bad.clone())); + assert_eq!(pressure_on_open(&mut app), Some(false), "{bad} must reopen the checkbox off"); + } + // No remembered key at all: off as well, and one applied OK writes a clean value back. + app.session.prefs.edit(|p| p.dialogs.remove("path.stroke")); + assert_eq!(pressure_on_open(&mut app), Some(false), "no remembered choice opens off"); + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.prefs().dialogs.get("path.stroke"), Some(&json!({"simulatePressure": true})), "only the checkbox is stored"); + assert_eq!(pressure_on_open(&mut app), Some(true), "and it comes back after a clean write"); +} + +#[test] +fn stroke_path_failed_confirmation_does_not_remember_simulate_pressure() { + let mut app = stroke_app(); + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(pressure_on_open(&mut app), Some(true), "the applied choice is remembered"); + // Untick it, point the dialog at a path that does not exist and press OK: the stroke fails, so + // nothing was applied and nothing may be remembered. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, false); + app.ui.dialog_mut(id).unwrap().fields.insert("name".into(), json!("no such path")); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "stroking a missing path fails"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "path.stroke").count(), 1, "the failed OK ran no stroke"); + while let Some(d) = app.ui.dialogs.last().map(|d| d.id) { + app.ui.close_dialog(d); + } + assert_eq!(app.session.prefs().dialogs.get("path.stroke"), Some(&json!({"simulatePressure": true})), "a failed OK leaves the memory alone"); + assert_eq!(pressure_on_open(&mut app), Some(true), "the next opening still shows the last applied choice"); +} + +#[test] +fn stroke_path_does_not_remember_its_tool_choice() { + // Only Simulate Pressure sticks (#10527). Nobody asked for the tool to be remembered, so the + // dialog keeps opening on the spec's default and the engine keeps defaulting to the brush. + let mut app = stroke_app(); + let id = open_stroke(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("tool".into(), json!("pencil")); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.stroke")); + let id = open_stroke(&mut app); + let (pressure, tool) = { + let f = &app.ui.dialog_mut(id).unwrap().fields; + (f.get("simulatePressure").cloned(), f.get("tool").cloned()) + }; + app.ui.close_dialog(id); + assert_eq!(pressure, Some(json!(true)), "the checkbox is remembered"); + assert_eq!(tool, None, "the tool is not: the dialog opens on the spec default"); + // The Paths panel's plain Stroke button strokes without a dialog, so it writes nothing: the + // remembered choice is still the one the dialog applied. + app.run("path.stroke", json!({"name": "work", "tool": "brush"})).unwrap(); + assert_eq!(app.session.prefs().dialogs.get("path.stroke"), Some(&json!({"simulatePressure": true})), "a dialog-less stroke changes no memory"); +} + #[test] fn pen_shape_menu_enables_shape_ops_and_style_copy_only_when_applicable() { let mut app = app(); @@ -993,6 +1138,38 @@ fn right_click_on_a_pending_crop_opens_the_crop_menu_and_a_ratio_refits_the_fram assert!(h.state().ui.canvas_tool_menu.is_none()); } +/// The menu's Crop row, clicked with the mouse, crops; the Crop tool then shows no frame until +/// the next press, so the applied crop doesn't look still pending. +#[test] +fn the_crop_row_crops_and_no_frame_shows_until_the_next_press() { + let mut app = crop_app(); + app.ui.tool_options.crop_shield.classic_mode = false; + let mut h = harness(app); + // Tall enough for the whole menu, down to its last row. + h.set_size(vec2(1000.0, 1400.0)); + draw_crop(h.state_mut()); + h.run_steps(2); + let r = h.state().ui.crop_rect.unwrap(); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + h.get_by_label("Crop").click(); + h.run_steps(3); + let size = h.state().session.active().unwrap().doc.size; + assert_eq!((f64::from(size.width), f64::from(size.height)), ((r[2] - r[0]).round(), (r[3] - r[1]).round()), "cropped to the frame"); + assert!(h.state().ui.canvas_tool_menu.is_none()); + assert_eq!(h.state().ui.tool, Tool::Crop); + assert_eq!(h.state().ui.crop_rect, None, "no frame around the cropped image"); + // A click on the canvas brings back a frame around the whole cropped image. + let center = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(center)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, f64::from(size.width), f64::from(size.height)]), "the click brings a frame back"); +} + #[test] fn untouched_crop_frame_has_no_menu_and_a_pending_one_wins_over_the_pasteboard() { let mut h = harness(crop_app()); @@ -1217,4 +1394,8 @@ fn crop_menu_rows_act_like_the_options_bar() { reopen(&mut app); choose(&mut app, &ctx, CROP_CANCEL); assert!(!crate::crop_ui::pending(&app)); + // ...and, as Esc does, hides the frame without cropping (#10370). + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert_eq!(app.session.active().unwrap().doc.size, size); } diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index 06819c5ea1..f87e74ebae 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -159,7 +159,7 @@ pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorI pub(super) fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { if matches!(ev, ToolEvent::Down { .. }) { let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); - app.alt_sampling = !physical_right && alt_samples(app.active_tool(), mods); + app.alt_sampling = !physical_right && !app.secondary_erase && alt_samples(app.active_tool(), mods); } if !app.alt_sampling { return false; @@ -173,9 +173,12 @@ pub(super) fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui: } pub(super) fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) { - if let Some([r, g, b]) = eyedropper_color(app, x, y) { + if let Some(mut rgb) = eyedropper_color(app, x, y) { + if super::paint_target(app) != json!("pixels") { + rgb = [photocraft_color::convert::rgb_to_gray(rgb); 3]; + } let key = if mods.alt { "background" } else { "foreground" }; - let _ = app.run("tools.setColors", json!({ key: [r, g, b, 1.0] })); + let _ = app.run("tools.setColors", json!({ key: [rgb[0],rgb[1],rgb[2],1.0] })); } } @@ -207,3 +210,10 @@ pub(crate) fn eyedropper_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Optio pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> { sample_color(app, x, y, "all") } + +pub(crate) fn alt_loads_mixer(tool: Tool, mods: egui::Modifiers) -> bool { + mods.alt && !mods.ctrl && tool == Tool::MixerBrush +} +pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { + alt_samples(tool, mods) || alt_loads_mixer(tool, mods) +} diff --git a/crates/ui-egui/src/canvas/display.rs b/crates/ui-egui/src/canvas/display.rs index 3b111232bd..d1d8e219e1 100644 --- a/crates/ui-egui/src/canvas/display.rs +++ b/crates/ui-egui/src/canvas/display.rs @@ -110,6 +110,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::type_panels_ui::color_picker::display_doc(app, idx) { return shown; } + if let Some(shown) = crate::color_picker_preview::display_doc(app, idx) { + return shown; + } if let Some(shown) = crate::solid_fill_ui::display_doc(app, idx) { return shown; } diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 6083756416..ac3da2eacb 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -31,6 +31,9 @@ pub struct Drag { /// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`. pub sel_axis: Option<[f64; 2]>, pub lasso: Option, + /// Freeform Pen and Path Selection gestures belong to their source document and target. + pub(super) vector_owner: Option, + pub(super) pen_owner: Option, } impl Drag { @@ -49,6 +52,19 @@ impl Drag { sel_move: None, sel_axis: None, lasso: None, + vector_owner: None, + pen_owner: None, + } + } + + /// Vector drags capture their owner at the press, the Freeform Pen also its destination, before + /// later ⌥-clicks or a temporary Direct Selection can finish them. + pub(crate) fn capture_vector_owners(&mut self, app: &PhotocraftApp) { + if matches!(self.tool, Tool::FreeformPen | Tool::PathSelection) { + self.vector_owner = VectorGestureOwner::capture(app); + } + if self.tool == Tool::FreeformPen { + self.pen_owner = crate::vector_ui::capture_pen_owner(app); } } @@ -326,3 +342,58 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor }, ); } + +/// The document, revision and target a vector drag started on. +#[derive(Clone, Debug)] +pub(super) struct VectorGestureOwner { + doc: photocraft_doc::DocId, + document: std::sync::Weak, + revision: u64, + layer: Option, + selected_path: Option, + vector_mask: bool, + mode: String, + tool: Tool, +} + +/// Cancel a vector drag before it can preview or finish on another document or target; the rest of +/// its pointer events go nowhere. True when one was cancelled. +pub(crate) fn cancel_stale_vector_drag(app: &mut PhotocraftApp) -> bool { + let stale = app.drag.as_ref().and_then(|d| d.vector_owner.as_ref()).is_some_and(|owner| !owner.valid(app)); + if stale { + app.drag = None; + app.cancelled_vector_drag = true; + } + stale +} + +impl VectorGestureOwner { + fn capture(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(Self { + doc: st.doc.id, + document: std::sync::Arc::downgrade(&st.doc), + revision: st.revision, + layer: st.active_layer, + selected_path: app.ui.selected_path.clone(), + vector_mask: app.ui.vector_mask_target, + mode: app.ui.tool_options.vector_mode.clone(), + tool: app.active_tool(), + }) + } + + pub(super) fn valid(&self, app: &PhotocraftApp) -> bool { + Self::capture(app).is_some_and(|now| { + self.doc == now.doc + && self.document.ptr_eq(&now.document) + && self.revision == now.revision + && self.layer == now.layer + && self.selected_path == now.selected_path + && self.vector_mask == now.vector_mask + && self.mode == now.mode + && self.tool == now.tool + }) + } +} + +use photocraft_doc::Document; diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index a7d3f8dc94..3a6ee54d97 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -84,11 +84,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Ok(computed) if finished == request => publish_filter_preview(app, idx, finished, computed.result, computed.ms), // Mid-drag, a frame for the value the pointer has just left is what makes the // canvas follow the drag. Three things have to hold for it: it belongs to this - // preview, it has a frame to show (an empty one would blank a good preview), and - // it must not displace a frame that is already exact for the dialog, which is - // what a slider dragged out and back would otherwise make the canvas do. + // preview (at any proxy factor: it is drawn at its own, and a radius drag can move + // the factor, #10474), it has a frame to show (an empty one would blank a good + // preview), and it must not displace a frame that is already exact for the dialog, + // which is what a slider dragged out and back would otherwise make the canvas do. Ok(computed) - if finished.same_target(&request) && computed.result.is_some() && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) => + if finished.same_target_at_any_scale(&request) + && computed.result.is_some() + && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) => { publish_filter_preview(app, idx, finished, computed.result, computed.ms); } @@ -135,8 +138,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e ctx.request_repaint_after(std::time::Duration::from_millis(16)); } let preview = app.filter_preview.as_ref()?; - // Keep the last preview during a drag, but never one from another source or dialog. - if !preview.key.same_target(&request) { + // Keep the last preview during a drag, but never one from another source or dialog. It is + // drawn at its own factor, so a drag that changes the factor keeps it too (#10474). + if !preview.key.same_target_at_any_scale(&request) { return None; } let result = preview.result.as_ref()?; diff --git a/crates/ui-egui/src/canvas/gesture.rs b/crates/ui-egui/src/canvas/gesture.rs index 251b6c9205..ae89b9063f 100644 --- a/crates/ui-egui/src/canvas/gesture.rs +++ b/crates/ui-egui/src/canvas/gesture.rs @@ -13,6 +13,9 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { app.live_stroke = None; return; } + if d.vector_owner.as_ref().is_some_and(|owner| !owner.valid(app)) { + return; + } let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]); end[0] = sx; @@ -78,7 +81,11 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } match d.tool { Tool::ObjectSelection => crate::retouch_ui::finish_object_selection(app, d.start, [end[0], end[1]], d.modifiers), - Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())), + Tool::FreeformPen => { + if let Some(owner) = d.pen_owner { + crate::vector_ui::freeform_finish_owned(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight()), owner); + } + } // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. t if crate::vector_ui::is_shape_tool(t) && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) @@ -172,6 +179,7 @@ pub(crate) fn constrained_polygon_point(from: [f64; 2], point: [f64; 2]) -> [f64 /// first click fixes the selection mode; a new-selection polygon hides the old outline while it is /// drawn, and replaces it in one history step when it closes. pub(super) fn polygon_click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) { + polygon_cancel_stale(app); let close = app .ui .polygon @@ -202,6 +210,7 @@ pub fn polygon_retract(app: &mut PhotocraftApp) -> bool { } if app.ui.polygon.is_empty() { app.ui.polygon_mode.clear(); + app.ui.polygon_doc = None; } true } @@ -209,13 +218,13 @@ pub fn polygon_retract(app: &mut PhotocraftApp) -> bool { /// The polygon in progress belongs to the document it was started on (`UiState::polygon_doc`): /// once another document becomes active it is dropped, so its outline doesn't show on (or get /// committed to) the other document, and the selection it would have replaced is shown again. -/// Switching tools is handled in `PhotocraftApp::sync_views`. Like the Lasso's +/// Runs before canvas input and shortcuts as well as in `PhotocraftApp::sync_views`. Like the Lasso's /// ([`crate::lasso_ui::cancel_stale`]) and the Magnetic Lasso's ([`crate::magnetic_lasso_ui::frame`]). pub fn polygon_cancel_stale(app: &mut PhotocraftApp) { let active = app.session.active().map(|st| st.doc.id); if app.ui.polygon.is_empty() { app.ui.polygon_doc = None; - } else if app.ui.polygon_doc.is_some_and(|d| Some(d) != active) { + } else if app.ui.tool != Tool::PolygonLasso || app.ui.polygon_doc.is_some_and(|d| Some(d) != active) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); app.ui.polygon_doc = None; @@ -230,8 +239,10 @@ pub fn polygon_replaces_selection(app: &PhotocraftApp) -> bool { /// Close the polygonal lasso and make the selection in the mode it started in. pub fn commit_polygon(app: &mut PhotocraftApp) { + polygon_cancel_stale(app); let pts = std::mem::take(&mut app.ui.polygon); let mode = std::mem::take(&mut app.ui.polygon_mode); + app.ui.polygon_doc = None; if pts.len() >= 3 { let mode = if mode.is_empty() { selection_mode(app, egui::Modifiers::NONE).to_owned() } else { mode }; let o = &app.ui.tool_options; @@ -282,5 +293,6 @@ pub fn commit_crop(app: &mut PhotocraftApp) { crate::crop_ui::restore(app, kept); } else if let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; + crate::crop_ui::hide_frame(app); } } diff --git a/crates/ui-egui/src/canvas/gpu_canvas.rs b/crates/ui-egui/src/canvas/gpu_canvas.rs index 48aeb07e0f..ef9435f346 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas.rs @@ -34,12 +34,15 @@ use eframe::wgpu; use crate::render_path::CpuReason; +pub use pages::{MAX_PAGE_SIDE, PageUpload}; pub use photocraft_gpu::{DeviceHealth, Fault}; mod budget; mod callback; mod doc_textures; +mod pages; mod perf; +mod residency; mod resources; mod setup; mod shaders; @@ -93,7 +96,7 @@ const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; pub const F16_BUDGET_PX: u64 = 100_000_000; const VIEW_UNIFORM_SIZE: u64 = 128; const VIEW_FLOATS: usize = 32; -const TILE_UNIFORM_SIZE: u64 = 16; +const TILE_UNIFORM_SIZE: u64 = 32; /// Whether this view can use the GPU canvas. Flip and non-square pixel aspect correction are /// still CPU blits (the shader's filtering assumes square pixels); rotation is a shader uniform. @@ -347,24 +350,43 @@ impl GpuCanvas { r.callback_resources.get::()?.compositor_budget } - /// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]). + /// Set the GPU bytes the open documents' display textures may take together (see + /// [`canvas_budget`] and [`GpuCanvas::show_documents`]). pub fn set_canvas_budget(&self, bytes: u64) { self.canvas_budget.store(bytes, Ordering::Relaxed); } - /// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is - /// created for it and it is drawn on the CPU canvas. + /// Whether `doc`'s display textures fit its share of [`canvas_budget`]; when they don't, + /// nothing is created for it and it is drawn from viewport pages or on the CPU canvas. pub fn fits_canvas(&self, doc: &photocraft_doc::Document) -> bool { self.canvas_fit(doc).is_some_and(|(bytes, budget)| bytes <= budget) } - /// The bytes `doc`'s display textures would take and [`canvas_budget`] (`None` without a GPU - /// canvas): the numbers of a [`CpuReason::CanvasTooLarge`]. - pub fn canvas_fit(&self, doc: &photocraft_doc::Document) -> Option<(u64, u64)> { + /// The bytes `doc`'s whole-document texture set would take. + pub fn canvas_bytes(&self, doc: &photocraft_doc::Document) -> u64 { let size = [doc.size.width, doc.size.height]; - let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size))); + canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size))) + } + + /// The bytes `doc`'s display textures would take and its share of [`canvas_budget`] (`None` + /// without a GPU canvas): the numbers of a [`CpuReason::CanvasTooLarge`]. Alone on screen a + /// document has the whole budget; see [`GpuCanvas::show_documents`]. + pub fn canvas_fit(&self, doc: &photocraft_doc::Document) -> Option<(u64, u64)> { + let bytes = self.canvas_bytes(doc); let r = self.rs.renderer.read(); - r.callback_resources.get::().map(|res| (bytes, res.canvas_budget.load(Ordering::Relaxed))) + r.callback_resources.get::().map(|res| (bytes, res.residency.share(res.canvas_budget.load(Ordering::Relaxed), doc.id.0, bytes))) + } + + /// The documents the app's windows show now, with [`GpuCanvas::canvas_bytes`] of each: they + /// split [`canvas_budget`] (see [`residency`]), and the textures of documents not among them + /// are freed first when a shown one needs the room. Cheap when nothing changed. + pub fn show_documents(&self, shown: &[(u64, u64)]) { + if self.rs.renderer.read().callback_resources.get::().is_none_or(|res| res.residency.shows(shown)) { + return; + } + if let Some(res) = self.rs.renderer.write().callback_resources.get_mut::() { + res.residency.publish(shown); + } } /// Act as if the driver couldn't build the compositor's pipelines (the DX12 FXC X3507 case, @@ -511,7 +533,7 @@ impl GpuCanvas { let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return }; if res.docs.get(&doc).is_none_or(|d| d.size != size || d.format != format) { - let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { + let Some(tex) = DocTextures::new_for(device, res, doc, size, self.tile, format) else { res.docs.remove(&doc); return; }; @@ -644,7 +666,7 @@ impl GpuCanvas { } } if fresh { - let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { + let Some(tex) = DocTextures::new_for(device, res, key, size, self.tile, format) else { res.docs.remove(&key); res.compositor = Some(comp); return Err(photocraft_gpu::Unsupported(OVER_CANVAS_BUDGET.into())); @@ -859,7 +881,7 @@ impl GpuCanvas { let mut renderer = self.rs.renderer.write(); let res = renderer.callback_resources.get_mut::()?; if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { - let Some(tex) = DocTextures::new(&self.rs.device, res, size, self.tile, format) else { + let Some(tex) = DocTextures::new_for(&self.rs.device, res, key, size, self.tile, format) else { res.docs.remove(&key); return None; }; @@ -884,6 +906,7 @@ impl GpuCanvas { // LUTs and identity-transform signatures can outlive document textures. Prune both, // or a preserved-ID reopen can skip rebuilding a missing LUT. res.luts.retain(|k, _| live.contains(&k.0)); + res.residency.retain(|d| live.contains(&d)); res.display_lut_signatures.retain(|k, _| live.contains(&k.0)); } diff --git a/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs b/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs index fb09f08e14..e1c2389bfb 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs @@ -35,6 +35,33 @@ pub(super) fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], u buffer } +/// A tile's uniform: its document rect (x, y, w, h) and the exclusive document bounds it draws +/// (x0, y0, x1, y1). A whole-document tile draws all its quad covers (no bound, so rounding at a +/// shared edge cannot open a seam); a viewport page only its core, so the one-texel apron around +/// it filters its edge without being drawn twice. +pub(super) fn tile_uniform([x, y, w, h]: [u32; 4], core: Option<[i32; 4]>) -> [f32; 8] { + let [cx0, cy0, cx1, cy1] = core.map_or([-1e30, -1e30, 1e30, 1e30], |c| c.map(|v| v as f32)); + [x as f32, y as f32, w as f32, h as f32, cx0, cy0, cx1, cy1] +} + +/// Run `f` with its validation and out-of-memory errors caught, rather than reaching the +/// device's uncaptured-error handler (which takes the GPU canvas down for the session): what `f` +/// returned, and the first error. Native wgpu reports these synchronously; where they arrive +/// later (WebGPU) none is seen. +pub(super) fn catch_gpu_errors(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option) { + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let out = f(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut error = None; + for pop in [memory.pop(), validation.pop()] { + if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) { + error.get_or_insert(e); + } + } + (out, error) +} + impl DocTextures { /// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit /// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB @@ -45,17 +72,7 @@ impl DocTextures { if bytes > res.canvas_budget.load(Ordering::Relaxed) { return None; } - let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); - let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); - let set = Self::create(device, res, size, tile, format); - // Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen. - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - let mut error = None; - for pop in [memory.pop(), validation.pop()] { - if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) { - error.get_or_insert(e); - } - } + let (set, error) = catch_gpu_errors(device, || Self::create(device, res, size, tile, format)); if let Some(e) = error { log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20); res.canvas_budget.fetch_min(bytes.saturating_sub(1), Ordering::Relaxed); @@ -64,6 +81,16 @@ impl DocTextures { Some(set) } + /// [`DocTextures::new`] for key `key`, first freeing the textures of documents no window + /// shows when the open documents' together wouldn't fit the canvas budget. + pub(super) fn new_for(device: &wgpu::Device, res: &mut Resources, key: u64, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option { + let bytes = canvas_texture_bytes(size, tile, texel_bytes(format)); + if bytes <= res.canvas_budget.load(Ordering::Relaxed) { + res.make_room(key, bytes); + } + Self::new(device, res, size, tile, format) + } + fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self { let mut tiles = Vec::new(); for ty in (0..size[1]).step_by(tile as usize) { @@ -87,7 +114,7 @@ impl DocTextures { .map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() })) .collect(); let full = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), wgpu::BufferUsages::UNIFORM); + let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&tile_uniform([tx, ty, w, h], None)), wgpu::BufferUsages::UNIFORM); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_canvas_tile"), layout: &res.tile_bgl, @@ -96,7 +123,7 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, ], }); - tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); + tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform, page: None }); } } Self { size, format, tiles } diff --git a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs index feb0df375e..8de45748b6 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs @@ -66,6 +66,37 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { } // On the same device (see the top of the file). canvas_textures_the_gpu_refuses_leave_it_working(&gpu); + refused_viewport_page_work_is_caught(&gpu); +} + +/// #5332 review: viewport page uploads run their texture, buffer and bind group creation and +/// their write inside the same error scopes as a texture set, so an error the GPU raises there +/// (out of memory, in use) is returned instead of disabling the GPU for the session. A texture +/// wider than the device allows is refused the same way, synchronously, on any adapter. +fn refused_viewport_page_work_is_caught(gpu: &GpuCanvas) { + let device = &gpu.rs.device; + let max = device.limits().max_texture_dimension_2d; + let (texture, error) = super::doc_textures::catch_gpu_errors(device, || { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("too_wide_page"), + size: wgpu::Extent3d { width: max + 1, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + }); + drop(texture); + assert!(error.is_some(), "the refusal is caught"); + assert!(gpu.health.is_ok(), "{:?}", gpu.fault()); + // A page the GPU accepts is stored as before. + let buf = Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]); + let core = Rect::new(1, 1, 63, 63); + assert!(gpu.upload_page(PageUpload { key: 9, page: 1, size: [64, 64], depth: SampleType::U8, factor: 1, core, buf: &buf })); + assert_eq!(gpu.pages(9).0, vec![1]); + assert!(gpu.health.is_ok(), "{:?}", gpu.fault()); } /// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error diff --git a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs new file mode 100644 index 0000000000..bc62505d7e --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs @@ -0,0 +1,199 @@ +//! Viewport pages of a document too large for its whole-document texture set (see +//! `canvas::viewport_pages`): independent single-level textures at their own reduction, drawn +//! by the same tile pipeline. They live under their own key, never next to a texture set. + +use super::doc_textures::{catch_gpu_errors, init_buffer, tile_uniform}; +use super::resources::{DocTextures, Resources, Tile, f32_bytes}; +use super::texels::{texel_bytes, texels}; +use super::{GpuCanvas, Ordering, wgpu}; +use photocraft_geom::Rect as DRect; + +/// The largest page texture side [`GpuCanvas::upload_page`] accepts (a 512 px page and its apron). +pub const MAX_PAGE_SIDE: u32 = 1024; + +/// One page to store: `buf` (document pixels reduced by `factor`, its rect in reduced pixels) +/// drawn over document area `core` of the `size` document `key`. +pub struct PageUpload<'a> { + pub key: u64, + pub page: u64, + pub size: [u32; 2], + pub depth: photocraft_doc::SampleType, + pub factor: u32, + pub core: DRect, + pub buf: &'a photocraft_compose::Buffer, +} + +impl DocTextures { + /// GPU bytes held by this set's textures (pages have one level, texture sets their mips). + pub(super) fn page_bytes(&self) -> u64 { + let bpp = texel_bytes(self.format); + self.tiles.iter().filter(|t| t.page.is_some()).map(|t| u64::from(t.texture.width()) * u64::from(t.texture.height()) * bpp).sum() + } +} + +impl GpuCanvas { + /// The GPU bytes one page of `w`×`h` texels takes for a `depth` document of `size`. + pub fn page_texture_bytes(&self, depth: photocraft_doc::SampleType, size: [u32; 2], w: u32, h: u32) -> u64 { + u64::from(w) * u64::from(h) * texel_bytes(self.format_for(depth, size)) + } + + /// Store (or replace) page `page` of `key`. False, with nothing changed, when the device is + /// not healthy, the page is malformed, or the pages of `key` would exceed the canvas budget + /// (the caller evicts its cold pages first; this is the backstop). + /// Writes `buf` (texels at the page's reduction, a part of it) into the page `page` of `key` + /// already resident: an edit's re-render, without a new texture. False when that page isn't + /// held or `buf` falls outside it. + pub fn update_page(&self, key: u64, page: u64, buf: &photocraft_compose::Buffer) -> bool { + let (w, h) = (buf.rect.width(), buf.rect.height()); + if !self.health.is_ok() || w == 0 || h == 0 || buf.px.len() as u64 != u64::from(w) * u64::from(h) { + return false; + } + let renderer = self.rs.renderer.read(); + let Some(res) = renderer.callback_resources.get::() else { return false }; + let Some(tile) = res.docs.get(&key).and_then(|d| d.tiles.iter().find(|t| t.page.is_some_and(|(id, _)| id == page))) else { + return false; + }; + let Some((_, factor)) = tile.page.filter(|(_, f)| *f > 0) else { return false }; + // The texture's first texel, at the page's reduction. + let (ox, oy) = (i64::from(tile.rect[0] / factor), i64::from(tile.rect[1] / factor)); + let (x, y) = (i64::from(buf.rect.x0) - ox, i64::from(buf.rect.y0) - oy); + let texture = &tile.texture; + if x < 0 || y < 0 || x + i64::from(w) > i64::from(texture.width()) || y + i64::from(h) > i64::from(texture.height()) { + return false; + } + let format = texture.format(); + self.rs.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x: x as u32, y: y as u32, z: 0 }, aspect: wgpu::TextureAspect::All }, + &texels(format, &buf.px), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + true + } + + pub fn upload_page(&self, p: PageUpload<'_>) -> bool { + let PageUpload { key, page, size, depth, factor, core, buf } = p; + let (w, h) = (buf.rect.width(), buf.rect.height()); + if !self.health.is_ok() + || factor == 0 + || w == 0 + || h == 0 + || w > MAX_PAGE_SIDE + || h > MAX_PAGE_SIDE + || buf.rect.x0 < 0 + || buf.rect.y0 < 0 + || buf.px.len() as u64 != u64::from(w) * u64::from(h) + || core.is_empty() + { + return false; + } + let (Some(x), Some(y), Some(dw), Some(dh)) = + ((buf.rect.x0 as u32).checked_mul(factor), (buf.rect.y0 as u32).checked_mul(factor), w.checked_mul(factor), h.checked_mul(factor)) + else { + return false; + }; + let format = self.format_for(depth, size); + let bytes = u64::from(w) * u64::from(h) * texel_bytes(format); + let (device, queue) = (&self.rs.device, &self.rs.queue); + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return false }; + let budget = res.canvas_budget.load(Ordering::Relaxed); + if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format || d.tiles.iter().any(|t| t.page.is_none())) { + res.docs.insert(key, DocTextures { size, format, tiles: Vec::new() }); + } + let Some(d) = res.docs.get_mut(&key) else { return false }; + let replaced = d.tiles.iter().position(|t| t.page.is_some_and(|(id, _)| id == page)); + let held = + d.page_bytes() - replaced.map_or(0, |i| u64::from(d.tiles[i].texture.width()) * u64::from(d.tiles[i].texture.height()) * texel_bytes(format)); + if held.saturating_add(bytes) > budget { + return false; + } + // Room among the other documents: those no window shows give theirs up first. + res.make_room(key, held + bytes); + let rect = [x, y, dw, dh]; + // A page the GPU refuses (out of memory) fails alone: it falls back to the CPU canvas + // instead of the uncaptured error disabling the GPU canvas for the session. + let ((texture, view, uniform, bind_group), error) = catch_gpu_errors(device, || { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_viewport_page"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let uniform = init_buffer( + device, + "pc_viewport_page", + &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), + wgpu::BufferUsages::UNIFORM, + ); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_viewport_page"), + layout: &res.tile_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, + ], + }); + queue.write_texture( + texture.as_image_copy(), + &texels(format, &buf.px), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + (texture, view, uniform, bind_group) + }); + if let Some(e) = error { + log::warn!("a {w}×{h} viewport page couldn't be stored on the GPU: {e}"); + return false; + } + let Some(d) = res.docs.get_mut(&key) else { return false }; + if let Some(i) = replaced { + d.tiles.remove(i); + } + let tile = Tile { rect, texture, levels: vec![view], bind_group, _uniform: uniform, page: Some((page, factor)) }; + // Coarser pages first: where pages of two zooms overlap, the finer one is drawn on top. + let at = d.tiles.iter().position(|t| t.page.is_some_and(|(_, f)| f < factor)).unwrap_or(d.tiles.len()); + d.tiles.insert(at, tile); + true + } + + /// Drop the pages of `key` not in `live` (all of them, and the key, when `live` is empty). + pub fn retain_pages(&self, key: u64, live: &[u64]) { + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + if live.is_empty() { + res.docs.remove(&key); + } else if let Some(d) = res.docs.get_mut(&key) { + d.tiles.retain(|t| t.page.is_some_and(|(id, _)| live.contains(&id))); + } + } + + /// Free document `key`'s texture set and what the compositor holds for it, once it is drawn + /// from pages instead: memory the pages are budgeted against. + pub fn release_texture_set(&self, key: u64) { + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + res.docs.remove(&key); + if let Some(c) = res.compositor.as_mut() { + c.forget_doc(photocraft_doc::DocId(key)); + } + } + + /// The pages of `key` the GPU holds (page ids), and the bytes they take. + pub fn pages(&self, key: u64) -> (Vec, u64) { + let r = self.rs.renderer.read(); + let Some(d) = r.callback_resources.get::().and_then(|res| res.docs.get(&key)) else { return (Vec::new(), 0) }; + (d.tiles.iter().filter_map(|t| t.page.map(|(id, _)| id)).collect(), d.page_bytes()) + } + + /// The display texture budget all documents share ([`super::canvas_budget`]); a document's + /// pages are held to its share ([`GpuCanvas::canvas_fit`]). + pub fn canvas_budget(&self) -> u64 { + self.canvas_budget.load(Ordering::Relaxed) + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/perf.rs b/crates/ui-egui/src/canvas/gpu_canvas/perf.rs index b1fedc6a62..58b12774b2 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/perf.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/perf.rs @@ -68,9 +68,9 @@ pub struct Perf { pub spans: std::collections::BTreeMap<&'static str, f64>, #[serde(skip)] max_decay: f64, - /// Composite and upload time of this frame's canvas refreshes, in ms (the stroke trace). + /// This frame's stages, gathered as it runs (see `frame_stages`). #[serde(skip)] - pub(crate) frame_refresh_ms: f64, + pub(crate) stage: crate::frame_stages::FrameStages, } /// What the app renders with and why (Help › System Info, Preferences › Performance, the `perf` @@ -214,7 +214,7 @@ pub fn backend_name(b: wgpu::Backend) -> &'static str { impl Perf { pub fn record(&mut self, kind: &'static str, px: u64, composite_ms: f64, upload_ms: f64) { - self.frame_refresh_ms += composite_ms + upload_ms; + self.stage.refresh(kind, px, composite_ms, upload_ms); self.last_refresh = kind; self.last_refresh_px = px; self.composite_ms = composite_ms; diff --git a/crates/ui-egui/src/canvas/gpu_canvas/residency.rs b/crates/ui-egui/src/canvas/gpu_canvas/residency.rs new file mode 100644 index 0000000000..df681a000b --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/residency.rs @@ -0,0 +1,209 @@ +//! One display-texture budget ([`super::canvas_budget`]) for every open document, rather than +//! the whole budget for each (N documents could hold N times it, #5332 review). +//! +//! The app publishes the documents its windows show and what each one's whole-document texture +//! set needs ([`super::GpuCanvas::show_documents`]). Those split the budget max-min fairly: a +//! document that fits its share keeps its texture set, a larger one is drawn from viewport pages +//! within it, and a document alone on screen still has the whole budget. The split changes only +//! when the shown documents (or their sizes) do, never with focus, so switching between windows +//! moves nothing. Documents no window shows have no share: they keep their textures while there +//! is room (switching back to a tab is free) and are the first freed, least recently shown +//! first, when a shown document needs the room. + +use std::collections::HashMap; +use std::sync::atomic::Ordering; + +use super::resources::{DocTextures, Resources}; +use super::texels::texel_bytes; + +/// Keys tagged above this bit are previews (filter preview, adjustment proxies, gradients, +/// thumbnails): never evicted for another document. +const TAG_BITS: u64 = !((1u64 << 58) - 1); +/// The tag of a document's viewport pages (`viewport_pages::stream_key`). +const PAGES_BIT: u64 = 1u64 << 57; + +impl DocTextures { + /// GPU bytes of every texture (all mip levels) in this set or page set. + pub(super) fn bytes(&self) -> u64 { + let bpp = texel_bytes(self.format); + let level = |w: u32, h: u32, l: u32| u64::from((w >> l).max(1)) * u64::from((h >> l).max(1)) * bpp; + self.tiles.iter().map(|t| (0..t.texture.mip_level_count()).map(|l| level(t.texture.width(), t.texture.height(), l)).sum::()).sum() + } +} + +impl Resources { + /// Free the textures of documents no window shows ([`Residency::victims`]) so that `key` + /// can hold `bytes` within the canvas budget. + pub(super) fn make_room(&mut self, key: u64, bytes: u64) { + let budget = self.canvas_budget.load(Ordering::Relaxed); + let held: Vec<(u64, u64)> = self.docs.iter().map(|(k, d)| (*k, d.bytes())).collect(); + for k in self.residency.victims(&held, key, bytes, budget) { + log::debug!("freeing the display textures of background key {k:#x} for {key:#x}"); + self.docs.remove(&k); + } + } +} + +/// Document `doc`'s allocation when documents needing `needs` bytes split `budget` max-min +/// fairly (`doc` indexes `needs`): those needing less than an equal share take what they need, +/// the rest split what is left; the leftover, if any, is `doc`'s too. +pub(super) fn fair_share(budget: u64, needs: &[u64], doc: usize) -> u64 { + let mut order: Vec = (0..needs.len()).collect(); + order.sort_by_key(|&i| needs[i]); + let mut left = budget; + let mut others = 0u64; + for (n, &i) in order.iter().enumerate() { + let take = needs[i].min(left / (order.len() - n) as u64); + left -= take; + if i != doc { + others += take; + } + } + budget - others +} + +/// The documents on screen and when each was last shown. +#[derive(Default)] +pub(super) struct Residency { + /// Shown documents: (document id, bytes of its whole texture set). + shown: Vec<(u64, u64)>, + /// When each document was last among the shown (a counter of changes). + seen: HashMap, + tick: u64, +} + +impl Residency { + /// Record the shown documents. False when nothing changed. + pub(super) fn publish(&mut self, shown: &[(u64, u64)]) -> bool { + if self.shown == shown { + return false; + } + self.tick += 1; + for (id, _) in shown { + self.seen.insert(*id, self.tick); + } + self.shown = shown.to_vec(); + true + } + + /// The share of `budget` document `id` (its texture set `need` bytes) may hold: the whole + /// budget when nothing else is shown. + pub(super) fn share(&self, budget: u64, id: u64, need: u64) -> u64 { + let mut needs: Vec = self.shown.iter().filter(|(d, _)| *d != id).map(|(_, n)| *n).collect(); + needs.push(need); + fair_share(budget, &needs, needs.len() - 1) + } + + /// Which held keys (`held`: key and bytes) to free, in order, so that `key` can hold + /// `bytes` within `budget`: the texture sets and pages of documents no window shows, least + /// recently shown first. Never a shown document's, nor `key`'s own document's, nor a preview + /// or a key of a document never shown (an adjustment proxy). It may not free enough: what + /// a shown document takes is bounded by its share instead. + pub(super) fn victims(&self, held: &[(u64, u64)], key: u64, bytes: u64, budget: u64) -> Vec { + let owner = |k: u64| (k & TAG_BITS == 0).then_some(k & !PAGES_BIT); + let mut total = held.iter().filter(|(k, _)| *k != key).map(|(_, b)| *b).fold(bytes, u64::saturating_add); + if total <= budget { + return Vec::new(); + } + let mine = owner(key); + let mut cold: Vec<(u64, u64, u64)> = held + .iter() + .filter_map(|&(k, b)| { + let d = owner(k)?; + let at = *self.seen.get(&d)?; + (Some(d) != mine && !self.shown.iter().any(|(s, _)| *s == d)).then_some((at, k, b)) + }) + .collect(); + cold.sort_unstable(); + let mut out = Vec::new(); + for (_, k, b) in cold { + if total <= budget { + break; + } + total -= b; + out.push(k); + } + out + } + + /// Whether `shown` is what was published last. + pub(super) fn shows(&self, shown: &[(u64, u64)]) -> bool { + self.shown == shown + } + + /// Forget closed documents. + pub(super) fn retain(&mut self, live: impl Fn(u64) -> bool) { + self.seen.retain(|d, _| live(*d)); + self.shown.retain(|(d, _)| live(*d)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GB: u64 = 1 << 30; + + #[test] + fn one_document_has_the_whole_budget() { + let r = Residency::default(); + assert_eq!(r.share(4 * GB, 1, 9 * GB), 4 * GB); + let mut r = Residency::default(); + r.publish(&[(1, 9 * GB)]); + assert_eq!(r.share(4 * GB, 1, 9 * GB), 4 * GB, "shown alone"); + assert_eq!(r.share(4 * GB, 1, GB), 4 * GB); + } + + #[test] + fn shown_documents_split_the_budget_max_min_fairly() { + let mut r = Residency::default(); + // A small document takes what it needs; the large one the rest. + r.publish(&[(1, GB), (2, 9 * GB)]); + assert_eq!(r.share(4 * GB, 1, GB), GB); + assert_eq!(r.share(4 * GB, 2, 9 * GB), 3 * GB); + // What the others leave is the asking document's too. + r.publish(&[(1, GB), (2, 2 * GB)]); + assert_eq!(r.share(4 * GB, 2, 2 * GB), 3 * GB, "the leftover is its own too"); + // Two large ones split it evenly, whichever has focus: no churn switching windows. + r.publish(&[(1, 3 * GB), (2, 3 * GB)]); + assert_eq!(r.share(4 * GB, 1, 3 * GB), 2 * GB); + assert_eq!(r.share(4 * GB, 2, 3 * GB), 2 * GB); + // Three, one of them small. + r.publish(&[(1, 3 * GB), (2, 3 * GB), (3, GB / 2)]); + assert_eq!(r.share(4 * GB, 1, 3 * GB), 7 * GB / 4); + assert_eq!(r.share(4 * GB, 3, GB / 2), GB / 2, "needs only half a GB"); + // Everything fits: each share covers its need, together within the budget. + r.publish(&[(1, GB), (2, GB)]); + assert!(r.share(4 * GB, 1, GB) >= GB && r.share(4 * GB, 2, GB) >= GB); + // A document no window shows (a refresh of a background tab) is priced as one more. + assert_eq!(r.share(4 * GB, 7, 4 * GB), 2 * GB); + } + + #[test] + fn republishing_the_same_documents_changes_nothing() { + let mut r = Residency::default(); + assert!(r.publish(&[(1, GB)])); + assert!(!r.publish(&[(1, GB)])); + assert!(r.publish(&[(1, 2 * GB)]), "the document was resized"); + } + + #[test] + fn background_documents_are_freed_least_recently_shown_first() { + let mut r = Residency::default(); + // Tabs 1, 2, 3 shown in turn, then 4. + for d in 1..=4 { + r.publish(&[(d, GB)]); + } + let pages = |d: u64| d ^ PAGES_BIT; + let held = [(1, GB), (pages(2), GB / 2), (2, GB / 2), (3, GB), (1 << 61 | 3, GB), (99, GB)]; + // Room enough: nothing goes. + assert!(r.victims(&held, 4, GB, 16 * GB).is_empty()); + // 4 needs 2 GB within 6 GB (5 held): tab 1 goes first, then tab 2's pages and set. + assert_eq!(r.victims(&held, 4, 2 * GB, 6 * GB), vec![1]); + assert_eq!(r.victims(&held, 4, 3 * GB, 6 * GB), vec![1, 2, pages(2)]); + // Never a shown document's, a preview's or an unknown key's, even when that isn't enough. + assert_eq!(r.victims(&held, 4, 10 * GB, 6 * GB), vec![1, 2, pages(2), 3]); + // Nor the document's own: its pages make room by evicting the other tabs. + assert_eq!(r.victims(&held, pages(3), GB, 4 * GB), vec![1, 2, pages(2)]); + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/resources.rs b/crates/ui-egui/src/canvas/gpu_canvas/resources.rs index 721377e928..d9990f0920 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/resources.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/resources.rs @@ -36,8 +36,10 @@ pub(super) struct Resources { pub(super) layer_fit: Option, pub(super) compositor_cache_budget: Option, pub(super) compositor_focus: Option, - /// GPU bytes one document's display texture set may take (see [`canvas_budget`]). + /// GPU bytes the documents' display textures may take together (see [`canvas_budget`]). pub(super) canvas_budget: std::sync::Arc, + /// How that budget is split between the open documents (see [`super::residency`]). + pub(super) residency: super::residency::Residency, pub(super) encode_bgl: wgpu::BindGroupLayout, encode_pipeline: wgpu::RenderPipeline, pub(super) lut_bgl: wgpu::BindGroupLayout, @@ -166,6 +168,9 @@ pub(super) struct Tile { pub(super) levels: Vec, pub(super) bind_group: wgpu::BindGroup, pub(super) _uniform: wgpu::Buffer, + /// A viewport page ([`GpuCanvas::upload_page`]): its id and reduction factor (document + /// pixels per texel); `None` for a tile of a whole-document texture set. + pub(super) page: Option<(u64, u32)>, } fn uniform_entry(binding: u32, size: u64, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { @@ -366,6 +371,7 @@ impl Resources { compositor_cache_budget: None, compositor_focus: None, canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))), + residency: Default::default(), encode_bgl, encode_pipeline, } diff --git a/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs b/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs index c29376a936..27d9249443 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs @@ -9,7 +9,7 @@ struct View { g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma h: vec4, // cos, sin, center_doc_x, center_doc_y }; -struct Tile { r: vec4 }; // x, y, w, h in doc px +struct Tile { r: vec4, core: vec4 }; // x, y, w, h in doc px; drawn x0, y0, x1, y1 @group(0) @binding(0) var view: View; @group(0) @binding(1) var samp: sampler; @@ -163,6 +163,8 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { let p = in.pos.xy; let scale = view.a.z; let d = px_to_doc(p) - view.d.xy; // document pixel coordinates (within this copy) + // A viewport page's apron is only sampled; its neighbour draws those pixels. + if (any(d < tile.core.xy) || any(d >= tile.core.zw)) { discard; } let size = tile.r.zw; let t = d - tile.r.xy; // texel coordinates within this tile let mode = view.c.x; diff --git a/crates/ui-egui/src/canvas/guide_ants_tests.rs b/crates/ui-egui/src/canvas/guide_ants_tests.rs new file mode 100644 index 0000000000..c51049c10d --- /dev/null +++ b/crates/ui-egui/src/canvas/guide_ants_tests.rs @@ -0,0 +1,69 @@ +//! #10671: the selection's marching ants are painted over the guides, so a guide on a selection +//! edge doesn't hide it (it did at some zoom levels, where both land on the same device pixel). + +use super::*; +use serde_json::json; + +const GUIDE: Color32 = Color32::from_rgb(0x4a, 0xff, 0xff); + +/// A document with guides on all four edges of a rectangular selection, as in the report. +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + for (orientation, position) in [("vertical", 100), ("vertical", 300), ("horizontal", 100), ("horizontal", 260)] { + app.run("view.newGuide", json!({"orientation": orientation, "position": position})).unwrap(); + } + app.run("select.rect", json!({"x": 100, "y": 100, "width": 200, "height": 160})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::RectMarquee; + app.ui.extras.rulers = false; + app.ui.extras.guides = true; + app +} + +/// The painted shapes' order in one frame: the indices of the guide lines and of the ants' meshes. +fn painted(app: &mut PhotocraftApp, ppp: f32, zoom: f32, primary: bool) -> (Vec, Vec) { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + ctx.set_pixels_per_point(ppp); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(700.0, 500.0)); + let view = View { zoom, center: [300.0, 200.0], doc_size: [600, 400], fit_pending: false, ..Default::default() }; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + canvas_view(app, ui, 0, rect, view.clone(), primary); + }); + output.textures_delta.clear(); + let (mut guides, mut ants) = (Vec::new(), Vec::new()); + for (i, s) in output.shapes.iter().enumerate() { + match &s.shape { + egui::Shape::LineSegment { stroke, .. } if stroke.color == GUIDE => guides.push(i), + egui::Shape::Mesh(m) if m.vertices.iter().any(|v| v.color == Color32::BLACK) && m.vertices.iter().any(|v| v.color == Color32::WHITE) => { + ants.push(i) + } + _ => {} + } + } + (guides, ants) +} + +#[test] +fn the_ants_are_painted_over_the_guides_at_every_zoom() { + let mut app = app(); + for ppp in [1.0, 1.25, 2.0] { + for zoom in [0.18, 0.25, 0.3333, 0.5, 0.6667, 1.0, 1.5, 2.0, 3.0] { + let (guides, ants) = painted(&mut app, ppp, zoom, true); + assert_eq!(guides.len(), 4, "@{ppp}x, {zoom}: the four guides are drawn"); + assert!(!ants.is_empty(), "@{ppp}x, {zoom}: the selection has its ants"); + let (last_guide, first_ants) = (guides.iter().max().unwrap(), ants.iter().min().unwrap()); + assert!(first_ants > last_guide, "@{ppp}x, {zoom}: ants at {ants:?} are under the guides at {guides:?}"); + } + } +} + +/// A secondary view (another tile of the document) draws no guides, but still its ants. +#[test] +fn a_secondary_view_still_draws_the_ants() { + let mut app = app(); + let (guides, ants) = painted(&mut app, 1.0, 0.5, false); + assert!(guides.is_empty()); + assert!(!ants.is_empty()); +} diff --git a/crates/ui-egui/src/canvas/move_ui.rs b/crates/ui-egui/src/canvas/move_ui.rs index 29920474f7..cef09dee1b 100644 --- a/crates/ui-egui/src/canvas/move_ui.rs +++ b/crates/ui-egui/src/canvas/move_ui.rs @@ -51,6 +51,80 @@ pub(crate) fn float_selected(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f } } +/// Most pixel memory the layers of a live Move drag may hold. Every frame of the drag shifts +/// copies of them by a pixel offset that is rarely a whole tile, so a multi-gigabyte layer would +/// copy gigabytes per pointer event on the UI thread and could run the machine out of memory +/// (#6114, #6163). Past it the drag shows the outline and arrow, as with Preferences › Interface › +/// Show bounding box when dragging layer, and the release moves the layers once. +const LIVE_LIMIT: u64 = 512 << 20; + +/// Pixel memory a shift of `s` by a pixel offset that isn't a whole tile writes, as +/// `Surface::translated` builds it: each target tile reads a 2 × 2 block of source tiles (one for +/// every block holding a stored tile, so isolated tiles each straddle four), and a block inside the +/// content whose four tiles are all stored is built once per distinct set of tiles. The inside of +/// a solid fill (one shared tile, `fill_rect`, PSD import) therefore costs one tile however large +/// it is; only its edge is written. Counts tile identities, never pixels: cheap at drag start. +fn shift_bytes(s: &photocraft_raster::Surface) -> u64 { + let b = photocraft_compose::bounds::content_bounds(s); + if b.is_empty() { + return 0; + } + let stored: std::collections::HashMap<(i32, i32), usize> = + s.tiles().filter(|(c, _)| !c.rect().intersect(&b).is_empty()).map(|(c, t)| ((c.tx, c.ty), Arc::as_ptr(t) as usize)).collect(); + let blocks: std::collections::HashSet<(i32, i32)> = + stored.keys().flat_map(|&(x, y)| [(x.saturating_sub(1), y.saturating_sub(1)), (x, y.saturating_sub(1)), (x.saturating_sub(1), y), (x, y)]).collect(); + let mut built = std::collections::HashSet::new(); + let mut written = 0u64; + for (x, y) in blocks { + let (x1, y1) = (x.saturating_add(1), y.saturating_add(1)); + let corners = [(x, y), (x1, y), (x, y1), (x1, y1)].map(|c| stored.get(&c).copied()); + let inside = b.contains_rect(&photocraft_geom::TileCoord::new(x, y).rect().union(&photocraft_geom::TileCoord::new(x1, y1).rect())); + let shared = match corners { + [Some(p0), Some(p1), Some(p2), Some(p3)] if inside => !built.insert([p0, p1, p2, p3]), + _ => false, + }; + if !shared { + written += 1; + } + } + // Blocks are not trimmed to the moved content; the targets `translated` builds are, so they + // never exceed the tiles the content can reach either. + let t = photocraft_geom::TILE_SIZE as u64; + let reach = (u64::from(b.width()).div_ceil(t) + 1).saturating_mul(u64::from(b.height()).div_ceil(t) + 1); + written.min(reach).saturating_mul(t * t).saturating_mul(s.format().bytes_per_pixel() as u64) +} + +/// Whether the pixel memory a live drag frame writes for the move targets `ids` of `doc` exceeds +/// `limit`, counting what `moved_target` shifts: a target whose unlinked mask is dragged alone +/// (`mask_target`) copies that mask only and its descendants stay put; any other target shifts its +/// content and linked mask (and a smart object's filter mask), and every layer inside it likewise (a group's contents move whole). +fn exceeds(doc: &Document, ids: &[LayerId], limit: u64, mask_target: bool) -> bool { + let mut total = 0u64; + for id in ids { + let Some(root) = doc.layer(*id) else { continue }; + let moving = if mask_target && photocraft_engine::layer_multi_cmds::moves_mask_alone(root) { + total = total.saturating_add(root.mask.as_ref().map_or(0, |m| shift_bytes(&m.surface))); + Vec::new() + } else { + with_descendants(doc, &[*id]) + }; + for l in moving.iter().filter_map(|d| doc.layer(*d)) { + let content = l.surface().map_or(0, shift_bytes); + let mask = l.mask.as_ref().filter(|m| m.linked).map_or(0, |m| shift_bytes(&m.surface)); + // `shift_smart` shifts a smart object's filter mask too, linked or not. + let filter_mask = match &l.content { + photocraft_doc::LayerContent::Smart(sm) => sm.filter_mask.as_ref().map_or(0, |m| shift_bytes(&m.surface)), + _ => 0, + }; + total = total.saturating_add(content).saturating_add(mask).saturating_add(filter_mask); + } + if total > limit { + return true; + } + } + false +} + /// Preview keys of move drags: `BASE + n`, one per offset shown (see `canvas::display_doc`). const BASE: u64 = 1 << 39; @@ -71,6 +145,9 @@ pub(crate) struct MovePreview { shown: Option>, /// Shows a floating selection (`select.float`): offsets are the piece's, from where it was cut. floating: bool, + /// The moving layers hold more pixels than [`LIVE_LIMIT`]: they aren't copied for every frame + /// of the drag, which shows its outline and arrow alone (#6114, #6163). + too_big: bool, } impl MovePreview { @@ -139,10 +216,14 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option<(Arc, u offsets: Vec::new(), shown: None, floating: true, + too_big: false, }); } if offset == (0, 0) && app.move_preview.as_ref().is_none_or(|p| p.offsets.is_empty()) { diff --git a/crates/ui-egui/src/canvas/move_ui/tests.rs b/crates/ui-egui/src/canvas/move_ui/tests.rs index 80bea71dc2..66ddeb6346 100644 --- a/crates/ui-egui/src/canvas/move_ui/tests.rs +++ b/crates/ui-egui/src/canvas/move_ui/tests.rs @@ -161,6 +161,232 @@ fn live(outline: bool) -> bool { shown } +/// A layer past the pixel-memory limit isn't copied for each frame of a drag (#6114, #6163). +#[test] +fn layers_over_the_limit_are_not_followed_live() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + 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(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + let one_tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * 4; + assert!(super::exceeds(&st.doc, &[id], one_tile - 1, false), "a tile of pixels is over a limit below its size"); + assert!(super::exceeds(&st.doc, &[id], one_tile * 4 - 1, false), "a shift can write four tiles for each stored one"); + assert!(!super::exceeds(&st.doc, &[id], u64::MAX, false), "nothing is over an unlimited drag"); + assert!(!super::exceeds(&st.doc, &[], 0, false), "no layers hold no pixels"); + assert!(!super::exceeds(&st.doc, &[id], super::LIVE_LIMIT, false), "an ordinary layer follows the pointer"); +} + +/// A drag whose layers are past the limit shows no live copy (the outline and arrow stay), and +/// the release still moves the layer once (#6114, #6163). +#[test] +fn a_drag_over_the_limit_shows_no_live_copy_and_still_commits() { + 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.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(); + let id = app.session.active().unwrap().active_layer.unwrap(); + // Enough stored tiles, all sharing one allocation, to be past the limit before the drag starts. + let step = photocraft_geom::TILE_SIZE; + app.session + .edit("paint", |doc, a| { + let surface = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + let tile_bytes = u64::from(step.unsigned_abs()).pow(2) * surface.format().bytes_per_pixel() as u64; + let count = (super::LIVE_LIMIT / (tile_bytes * 2) + 2) as i32; // a row writes about (n + 1) x 2 tiles + let tile = surface.solid_tile(&[1.0, 0.0, 0.0, 1.0]); + surface.put_tiles((0..count).map(|x| (photocraft_geom::TileCoord::new(x, 0), tile.clone()))); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let m = egui::Modifiers::NONE; + let to = 16.0 + f64::from(step); // a whole tile, so the release shares the tiles too + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: to, y: 16.0, pressure: 1.0 }, m); + assert!(super::display_doc(&mut app, 0).is_none(), "a layer over the limit isn't copied for the frame"); + assert!(!super::showing(&app)); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: to, y: 16.0 }, m); + let d = &app.session.active().unwrap().doc; + assert!(d.layer(id).and_then(|l| l.surface()).is_some_and(|s| s.pixel(to as i32, 16)[3] > 0.5), "the release moved the layer"); +} + +/// What a live drag frame writes is the tiles the content can reach, not four per stored tile for +/// every layer: a dense photo stays live, isolated tiles (each straddles four) don't (#6114). +#[test] +fn the_estimate_counts_reachable_target_tiles_and_what_moved_target_copies() { + 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.session.execute("layer.new.layer", json!({})).unwrap(); + let step = photocraft_geom::TILE_SIZE; + let tile_bytes = u64::from(step.unsigned_abs()).pow(2) * 4; + let tiles_over_limit = (super::LIVE_LIMIT / tile_bytes + 1) as i32; + // A dense block of the same number of stored tiles as the sparse one below. + let side = (f64::from(tiles_over_limit / 4)).sqrt() as i32; + app.session + .edit("paint", |doc, a| { + let surface = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + let tile = surface.solid_tile(&[1.0, 0.0, 0.0, 1.0]); + surface.put_tiles((0..side * side).map(|i| (photocraft_geom::TileCoord::new(i % side, i / side), tile.clone()))); + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + assert!(!super::exceeds(&st.doc, &[id], super::LIVE_LIMIT, false), "a dense layer writes about as many tiles as it stores"); + // The same count of isolated tiles: each straddles four target tiles. + let sparse = tiles_over_limit / 4 + 1; + app.session + .edit("paint", |doc, a| { + let surface = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + let tile = surface.solid_tile(&[1.0, 0.0, 0.0, 1.0]); + surface.put_tiles((0..sparse).map(|i| (photocraft_geom::TileCoord::new(i * 10, i * 10 + 1000), tile.clone()))); + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap(); + assert!(super::exceeds(&st.doc, &[id], super::LIVE_LIMIT, false), "isolated tiles each write up to four"); + // Only the mask is copied when an unlinked mask is dragged alone: the huge content isn't charged. + app.session.execute("layer.layerMask.revealAll", json!({})).unwrap(); + app.session.execute("layer.layerMask.linked", json!({"linked": false})).unwrap(); + let st = app.session.active().unwrap(); + assert!(super::exceeds(&st.doc, &[id], 1, false), "dragging the layer copies its content"); + assert!(!super::exceeds(&st.doc, &[id], super::LIVE_LIMIT / 4, true), "dragging the unlinked mask alone copies the mask only"); +} + +/// A group moves its contents whole; a group's unlinked mask dragged alone leaves them where they are. +#[test] +fn the_estimate_follows_a_group_into_its_contents() { + 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.session.execute("layer.new.layer", json!({})).unwrap(); + let inner = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("paint", |doc, a| { + let surface = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + let tile = surface.solid_tile(&[1.0, 0.0, 0.0, 1.0]); + surface.put_tiles((0..600).map(|i| (photocraft_geom::TileCoord::new(i * 10, i * 10), tile.clone()))); + Ok(()) + }) + .unwrap(); + let group = app.session.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + app.session.execute("layer.moveTo", json!({"layer": inner.0, "target": group, "position": "into"})).unwrap(); + let group = photocraft_doc::LayerId(group); + let st = app.session.active().unwrap(); + assert!(super::exceeds(&st.doc, &[group], super::LIVE_LIMIT, false), "the group's contents are shifted with it"); + app.session.execute("layer.layerMask.revealAll", json!({"layer": group.0})).unwrap(); + app.session.execute("layer.layerMask.linked", json!({"layer": group.0, "linked": false})).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.doc.layer(group).is_some_and(|l| l.mask.as_ref().is_some_and(|m| !m.linked)), "the group has an unlinked mask"); + assert!(!super::exceeds(&st.doc, &[group], super::LIVE_LIMIT, true), "an unlinked group mask dragged alone leaves the contents"); +} + +/// A `width` × `height` `depth`-bit document whose new layer covers the canvas, one fill per tile +/// with its own colour (a photo: no two tiles share pixels), and the Move tool on. +fn photo(width: u32, height: u32, depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": width, "height": height, "depth": depth})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + let canvas = Rect::new(0, 0, width as i32, height as i32); + let surface = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + for (i, c) in canvas.tiles().enumerate() { + let v = (i % 251) as f32 / 251.0; + surface.fill_rect(c.rect().intersect(&canvas), &[v, 1.0 - v, 0.5, 1.0]); + } + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app +} + +/// Mid-drag by (37, 11): is the layer shown at the pointer? +fn follows_a_drag(app: &mut PhotocraftApp) -> bool { + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(app, ToolEvent::Move { x: 137.0, y: 111.0, pressure: 1.0 }, m); + let shown = super::display_doc(app, 0).is_some(); + crate::canvas::tool_event(app, ToolEvent::Up { x: 137.0, y: 111.0 }, m); + shown +} + +/// Ordinary photos keep the live drag: a 36 MP 8-bit layer (580 stored tiles) and a 24 MP 16-bit +/// one write their tiles plus one edge row and column, not four tiles for each stored one. +#[test] +fn ordinary_photos_follow_the_pointer() { + for (w, h, depth) in [(7360, 4912, 8), (6000, 4000, 16)] { + let mut app = photo(w, h, depth); + assert!(follows_a_drag(&mut app), "{w} × {h} {depth}-bit"); + } +} + +/// A solid fill stores one tile shared by every covered position, and a shift shares it too (only +/// the edge is written), so a large filled layer drags live as it does without the limit. +#[test] +fn a_large_solid_layer_follows_the_pointer() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 12000, "height": 12000, "depth": 16})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.all", json!({})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + assert!(follows_a_drag(&mut app), "a filled 12000 × 12000 16-bit layer"); +} + +/// Content off the tile grid: the 2 × 2 blocks round its stored tiles reach a column and a row +/// past what a shift writes, which is at most the tiles the content can reach. +#[test] +fn the_estimate_is_capped_by_what_the_content_reaches() { + let format = photocraft_color::PixelFormat::RGBA8; + let mut s = photocraft_raster::Surface::new(format); + s.fill_rect(Rect::new(128, 128, 384, 384), &[1.0, 0.0, 0.0, 1.0]); + let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * format.bytes_per_pixel() as u64; + assert_eq!(s.tile_count(), 4, "a tile-sized square straddles four tiles"); + assert_eq!(super::shift_bytes(&s), 4 * tile, "it reaches (1 + 1) × (1 + 1) target tiles, not the nine blocks round its tiles"); +} + +/// `shift_smart` shifts a smart object's filter mask with it: the estimate counts it. +#[test] +fn the_estimate_counts_a_smart_filter_mask() { + 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.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(); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + assert!(!super::exceeds(&app.session.active().unwrap().doc, &[id], super::LIVE_LIMIT, false), "a small smart object"); + app.session + .edit("paint", |doc, a| { + let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(a.unwrap()).unwrap().content else { panic!("not a smart object") }; + let mut mask = photocraft_doc::LayerMask::hide_all(); + // Isolated tiles, each written as four by a shift: past the limit at one byte a pixel. + let tile = mask.surface.solid_tile(&[1.0]); + let count = (super::LIVE_LIMIT / (1 << 16) / 4 + 1) as i32; + mask.surface.put_tiles((0..count).map(|i| (photocraft_geom::TileCoord::new(i * 10, 0), tile.clone()))); + sm.filter_mask = Some(mask); + Ok(()) + }) + .unwrap(); + assert!(super::exceeds(&app.session.active().unwrap().doc, &[id], super::LIVE_LIMIT, false), "the filter mask moves with it"); +} + #[test] fn bounding_box_preference_turns_the_live_drag_off() { assert!(live(false), "by default the layer follows the pointer"); diff --git a/crates/ui-egui/src/canvas/paint.rs b/crates/ui-egui/src/canvas/paint.rs index 415bb071a7..e65cad697c 100644 --- a/crates/ui-egui/src/canvas/paint.rs +++ b/crates/ui-egui/src/canvas/paint.rs @@ -140,7 +140,13 @@ fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, origin: [f32; 2], /// Draw boundary segments as marching ants: white base, black dashes phased along x + y. pub(super) fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate::outline::Segment], time: f64) { - marching_ants_mapped(painter, segs, time, |p| xf.to_screen(p[0] as f32, p[1] as f32)); + painter.extend(marching_ants_segment_shapes(painter, xf, segs, time)); +} + +/// [`marching_ants_segments`] as shapes, not yet drawn (`ants::shapes_for`). +pub(super) fn marching_ants_segment_shapes(painter: &egui::Painter, xf: &ViewXform, segs: &[crate::outline::Segment], time: f64) -> Vec { + let lines = segs.iter().map(|(a, b)| (xf.to_screen(a[0] as f32, a[1] as f32), xf.to_screen(b[0] as f32, b[1] as f32))); + marching_ants_line_shapes(painter, lines, time) } /// [`marching_ants_segments`] with each outline point placed on screen by `to_screen` (a @@ -177,6 +183,11 @@ pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator< crate::ants::draw(painter, lines, time, true); } +/// [`marching_ants_lines`] as shapes, not yet drawn (`ants::shapes_for`). +pub(crate) fn marching_ants_line_shapes(painter: &egui::Painter, lines: impl Iterator, time: f64) -> Vec { + crate::ants::shapes_for(painter, lines, time, true) +} + /// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright /// frame, the composition `guides` (unit-square polylines, `crop_overlay::guides`), corner and /// edge handles. @@ -463,10 +474,15 @@ pub(super) fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: let shield = crate::crop_shield::current(app, painter.ctx()); if deg == 0.0 && xf.rotation == 0.0 { let r = Rect::from_two_pos(xf.to_screen(c[0] as f32, c[1] as f32), xf.to_screen(c[2] as f32, c[3] as f32)); - crop_overlay(painter, r, shield, &crate::crop_overlay::current_guides(app, r.width(), r.height())); + crop_overlay( + painter, + r, + shield, + &crate::crop_overlay::current_guides_for_frame(app, [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], painter.clip_rect()), + ); } else { let q = crate::crop_ui::corners(c, deg).map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); - crop_overlay_turned(painter, q, shield, &crate::crop_overlay::current_guides(app, q[0].distance(q[1]), q[0].distance(q[3]))); + crop_overlay_turned(painter, q, shield, &crate::crop_overlay::current_guides_for_frame(app, q, painter.clip_rect())); } // The frame's angle beside the pointer while it turns (#1792). if let (Some(a), Some(h)) = (crate::crop_ui::turning(app), hover) { diff --git a/crates/ui-egui/src/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index dc64d34312..c0db27eb87 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -114,6 +114,10 @@ pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { /// brush with Alt held (#297), erases (Erase preference) or opens the Brush Preset picker. Arms /// `secondary_erase` or `brush_resize_armed` for this frame's `Down`. pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons { + if crate::brush_mode_menu::blocks_canvas(app, &response.ctx) { + app.secondary_erase = false; + return Buttons::default(); + } let physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); let (mods, mut right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); right_down |= physical_right; @@ -137,13 +141,41 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) app.brush_resize_armed = resize_start || (physical_right && resizing); // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); + // Decided by the modifiers held when the button went down, not by those of the release frame that reports the click. + let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods))); + if mode_menu && !resizing && app.drag.is_none() { + app.secondary_erase = false; + if response.secondary_clicked() + && let Some(p) = response.interact_pointer_pos() + { + crate::brush_mode_menu::open(app, &response.ctx, [p.x, p.y]); + } + return Buttons { + started: response.drag_started_by(PointerButton::Primary), + dragged: response.dragged_by(PointerButton::Primary), + stopped: response.drag_stopped_by(PointerButton::Primary), + clicked: response.clicked(), + }; + } let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu; + // Shift+Alt+right-press opens Photoshop's HUD Color Picker (#6098) instead of the picker. + // Tools whose right-click is the canvas tool menu (Quick Selection, #7268) keep that menu. + let sticky = crate::workspace_ui::sticky_mods(app, mods); + let hud_gesture = right_click_picker(tool) && !layer_menu && !erase && sticky.shift && sticky.alt; + let right_press = response.ctx.input(|i| i.pointer.button_pressed(PointerButton::Secondary)) && response.contains_pointer(); + if hud_gesture + && right_press + && let Some(p) = response.ctx.input(|i| i.pointer.hover_pos()) + { + crate::color_hud::open(app, [p.x, p.y], true); + } let right_stroke = erase && app.drag.is_some(); let right_start = erase && right_started; let right_click = response.secondary_clicked(); if right_click && !erase && !layer_menu + && !crate::color_hud::is_open(&app.ui) && right_click_picker(tool) && let Some(p) = response.interact_pointer_pos() { @@ -166,10 +198,33 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) /// the Brush Preset picker at screen point `at`, and nothing paints. pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: bool, mods: egui::Modifiers, at: [f32; 2]) -> bool { let tool = app.ui.tool; + // Shift+Alt+right is Photoshop's HUD Color Picker (#6098): the down opens it, the up closes + // it. As on the canvas, the right-erase preference wins over the HUD, and a tool whose + // right-click is the canvas tool menu keeps that menu (handled by the caller). + let hud_gesture = right_click_picker(tool) && mods.shift && mods.alt && !right_erases(app, tool); + // A control-channel request carries one modifier set, so the Up need not repeat the Down's: + // the caller may release Shift and Alt first. Any Up therefore closes a non-held HUD that is + // still open; `release` leaves a real-mouse (held) one to its button release. + let hud_up = !down && app.ui.color_hud.is_some_and(|h| !h.hold); + if hud_gesture || hud_up { + if down { + crate::color_hud::open(app, at, false); + } else { + crate::color_hud::release(&mut app.ui); + } + return false; + } if crate::brush_resize::applies(tool) && (crate::brush_resize::is_right_gesture(mods) || app.brush_resize.is_some_and(|r| r.secondary)) { app.brush_resize_armed = down && app.drag.is_none(); return true; } + if crate::brush_mode_menu::is_gesture(tool, mods) && app.drag.is_none() && app.brush_resize.is_none() { + app.secondary_erase = false; + if down { + crate::brush_mode_menu::open(app, ctx, at); + } + return false; + } if right_erases(app, tool) { app.secondary_erase = down; return true; diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index 4b54a1ce98..38df3c79a8 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -65,6 +65,182 @@ fn strokes(h: &Harness<'static, PhotocraftApp>) -> Vec { h.state().session.journal.iter().filter(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).collect() } +fn mode_right_click(h: &mut Harness<'static, PhotocraftApp>, mods: Modifiers) { + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(mods)); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: mods }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: mods }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn shift_right_mode_picker_selects_journals_and_clear_erases() { + use egui_kittest::kittest::Queryable; + let mut h = harness(Some("erase")); + let history = h.state().session.active().unwrap().history.past_len(); + let journal = h.state().session.journal.len(); + mode_right_click(&mut h, Modifiers::SHIFT); + assert!(h.state().ui.brush_mode_menu.is_some()); + assert!(h.state().ui.brush_picker.is_none() && h.state().drag.is_none()); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + h.get_by_label("Multiply").click(); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Multiply); + assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply", "behind": false}}))); + let (a, b) = drag(&mut h, PointerButton::Primary); + let mid = a + (b - a) * 0.5; + assert!(alpha_at(&h, mid) > 0.9); + mode_right_click(&mut h, Modifiers::SHIFT); + h.get_by_label("Clear").click(); + h.run_steps(2); + assert!(h.state().ui.tool_options.brush_clear); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Multiply, "Clear keeps the underlying mode, as the options bar does"); + drag(&mut h, PointerButton::Primary); + assert_eq!(alpha_at(&h, mid), 0.0); + assert!(strokes(&h).last().unwrap()["erase"].as_bool().unwrap()); +} + +#[test] +fn mode_picker_outside_dismissal_does_not_paint_or_erase() { + for button in [PointerButton::Primary, PointerButton::Secondary] { + let mut h = harness(Some("erase")); + drag(&mut h, PointerButton::Primary); + let journal = h.state().session.journal.len(); + let history = h.state().session.active().unwrap().history.past_len(); + mode_right_click(&mut h, Modifiers::SHIFT); + // A full outside drag must only dismiss, including its release click. + drag(&mut h, button); + assert!(h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.journal.len(), journal, "dismissal must not paint/erase with {button:?}"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + } +} + +#[test] +fn mode_control_can_pick_a_row_with_ui_click() { + use crate::control::{ControlRequest, Outcome, handle}; + use egui_kittest::kittest::Queryable; + let mut h = harness(Some("erase")); + let ctx = h.ctx.clone(); + let before = h.state().session.journal.len(); + let (req, _) = ControlRequest::new( + "ui.pointer", + json!({"button": "right", "shift": true, + "events": [{"kind": "down", "x": 200, "y": 150}, {"kind": "up", "x": 200, "y": 150}]}), + ); + assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::Done(_))); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_some()); + assert_eq!(h.state().session.journal.len(), before); + let at = h.get_by_label("Darken").rect().center(); + let (req, _) = ControlRequest::new("ui.click", json!({"x": at.x, "y": at.y})); + assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::AfterInput)); + // The app injects these queued control events into its next frame's input. + let events = std::mem::take(&mut h.state_mut().synthetic); + for event in events { + h.event(event); + } + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Darken); + assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Darken", "behind": false}}))); +} + +#[test] +fn shift_right_mode_picker_cancels_and_other_gestures_keep_their_routes() { + let mut h = harness(None); + let journal = h.state().session.journal.len(); + mode_right_click(&mut h, Modifiers::SHIFT); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + mode_right_click(&mut h, Modifiers::SHIFT); + let outside = h.state().last_canvas_rect.min + vec2(2.0, 2.0); + h.event(egui::Event::PointerMoved(outside)); + press(&mut h, outside, PointerButton::Secondary, true); + press(&mut h, outside, PointerButton::Secondary, false); + assert!(h.state().ui.brush_mode_menu.is_none()); + mode_right_click(&mut h, Modifiers::SHIFT); + h.state_mut().ui.tool = Tool::Pencil; + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + // Spot Healing has no Shift+right-click menu here: its right-click keeps the picker. + h.state_mut().ui.tool = Tool::SpotHealing; + h.run_steps(1); + mode_right_click(&mut h, Modifiers::SHIFT); + assert!(h.state().ui.brush_mode_menu.is_none() && h.state().ui.brush_picker.is_some()); + h.state_mut().ui.brush_picker = None; + h.state_mut().ui.tool = Tool::Brush; + mode_right_click(&mut h, Modifiers::NONE); + assert!(h.state().ui.brush_picker.is_some() && h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.journal.len(), journal, "opening/cancelling never changes brush or pixels"); +} + +#[test] +fn escape_dismissal_consumes_a_held_secondary_release() { + for release_shift in [false, true] { + let mut h = harness(Some("erase")); + let journal = h.state().session.journal.len(); + let history = h.state().session.active().unwrap().history.past_len(); + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT)); + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + // A quick second press can arrive in the frame that opens the menu for the first + // click's release, leaving the secondary button held while the picker is visible. + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::SHIFT }); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + assert!(h.state().ui.brush_mode_menu.is_some()); + assert!(h.ctx.input(|i| i.pointer.secondary_down())); + h.key_press(egui::Key::Escape); + h.run_steps(1); + assert!(h.state().ui.brush_mode_menu.is_none()); + let mods = if release_shift { Modifiers::NONE } else { Modifiers::SHIFT }; + h.event(egui::Event::ModifiersChanged(mods)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: mods }); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none(), "Escape must consume the pending release"); + assert!(h.state().ui.brush_picker.is_none() && h.state().drag.is_none() && !h.state().secondary_erase); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + mode_right_click(&mut h, Modifiers::SHIFT); + assert!(h.state().ui.brush_mode_menu.is_some(), "the next gesture must still work"); + } +} + +#[test] +fn shift_released_before_the_right_button_still_opens_the_mode_picker() { + // egui reports `secondary_clicked` on release. With Erase on, a click that lost Shift a + // frame early must open the menu, not erase a dab. + let mut h = harness(Some("erase")); + let history = h.state().session.active().unwrap().history.past_len(); + let journal = h.state().session.journal.len(); + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT)); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + // Shift goes up in a frame of its own while the right button is still down. + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_some(), "the press decided the gesture"); + assert!(h.state().drag.is_none() && !h.state().secondary_erase); + assert_eq!(h.state().session.journal.len(), journal, "no erase dab"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); +} + #[test] fn left_drag_paints() { let mut h = harness(None); @@ -313,6 +489,44 @@ fn right_stroke_erases_with_pen_pressure_through_the_control_channel_path() { assert!(app.ui.brush_picker.is_some() && !app.secondary_erase); } +#[test] +fn shift_alt_right_through_the_control_channel_respects_the_erase_preference() { + // As on the canvas, with the right-erase preference the erase stroke wins over the HUD + // for Brush/Eraser; without it, Shift+Alt+right opens the HUD (#6098). + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80})).unwrap(); + app.ui.tool = Tool::Brush; + let shift_alt = egui::Modifiers { shift: true, alt: true, ..Default::default() }; + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + assert!(pointer_secondary(&mut app, &egui::Context::default(), true, shift_alt, [10.0, 10.0]), "the events reach the tool"); + assert!(app.ui.color_hud.is_none(), "no HUD with the erase preference"); + assert!(app.secondary_erase, "the down arms the erasing stroke"); + pointer_secondary(&mut app, &egui::Context::default(), false, shift_alt, [10.0, 10.0]); + // With the default preference the same gesture opens the HUD instead. + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "brushPicker"})).unwrap(); + assert!(!pointer_secondary(&mut app, &egui::Context::default(), true, shift_alt, [10.0, 10.0])); + assert!(app.ui.color_hud.is_some_and(|h| !h.hold), "the control-channel HUD opened"); +} + +#[test] +fn a_control_channel_up_closes_the_hud_without_repeating_its_modifiers() { + // Each control-channel request carries its own modifiers, so an Up sent after the keys + // are released arrives without Shift+Alt: it must still close the HUD it opened, or the + // HUD stays up and keeps picking from the hover (#6098 review). + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80})).unwrap(); + app.ui.tool = Tool::Brush; + let shift_alt = egui::Modifiers { shift: true, alt: true, ..Default::default() }; + assert!(!pointer_secondary(&mut app, &egui::Context::default(), true, shift_alt, [10.0, 10.0])); + assert!(app.ui.color_hud.is_some_and(|h| !h.hold), "the control-channel HUD opened"); + assert!(!pointer_secondary(&mut app, &egui::Context::default(), false, egui::Modifiers::NONE, [10.0, 10.0])); + assert!(app.ui.color_hud.is_none(), "the Up closes it whatever the modifiers say"); + // A real-mouse HUD belongs to the canvas gesture and waits for its own button release. + crate::color_hud::open(&mut app, [10.0, 10.0], true); + pointer_secondary(&mut app, &egui::Context::default(), false, egui::Modifiers::NONE, [10.0, 10.0]); + assert!(app.ui.color_hud.is_some_and(|h| h.hold), "a held HUD is left to the canvas"); +} + #[test] fn the_brush_is_a_per_tool_option_that_presets_keep() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -479,3 +693,162 @@ fn one_frame_feeds_every_pointer_move_not_just_the_latest() { press(&mut h, end, PointerButton::Primary, false); assert_committed_points(&h, &queued); } + +/// Shift+right-click with `tool` and the labels of the menu it opened (empty: none opened). +fn open_tool_menu(h: &mut Harness<'static, PhotocraftApp>, tool: Tool) -> Vec<&'static str> { + h.state_mut().ui.tool = tool; + h.run_steps(1); + mode_right_click(h, Modifiers::SHIFT); + if h.state().ui.brush_mode_menu.is_none() { Vec::new() } else { crate::brush_mode_menu::labels(tool) } +} + +fn choose_row(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_label(label).click(); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none(), "choosing {label} closes the menu"); +} + +#[test] +fn shift_right_menu_lists_each_tools_own_rows_in_order() { + use crate::brush_mode_menu::{Item, items}; + use egui_kittest::kittest::Queryable; + let blend: Vec<&'static str> = photocraft_doc::BlendMode::LAYER_MODES.iter().map(|m| m.label()).collect(); + let with = |extra: &[&'static str]| { + let mut v = blend.clone(); + for (i, l) in extra.iter().enumerate() { + v.insert(2 + i, l); + } + v + }; + let edit = |rest: Vec<&'static str>| std::iter::once("Edit Brush…").chain(rest).collect::>(); + for (tool, want) in [ + (Tool::Brush, edit(with(&["Behind", "Clear"]))), + (Tool::Pencil, edit(with(&["Behind", "Clear"]))), + (Tool::CloneStamp, edit(with(&[]))), + (Tool::Eraser, vec!["Brush", "Pencil", "Block"]), + (Tool::Dodge, edit(vec!["Shadows", "Midtones", "Highlights"])), + (Tool::Sponge, edit(vec!["Desaturate", "Saturate"])), + (Tool::PaintBucket, with(&["Clear"])), + (Tool::MixerBrush, vec!["Edit Brush…"]), + (Tool::Healing, vec!["Normal", "Multiply", "Screen", "Darken", "Lighten", "Color", "Luminosity"]), + (Tool::SpotHealing, vec![]), + (Tool::MagicEraser, vec![]), + ] { + let mut h = harness(None); + let journal = h.state().session.journal.len(); + assert_eq!(open_tool_menu(&mut h, tool), want, "{tool:?}"); + assert_eq!(h.state().session.journal.len(), journal, "{tool:?}: opening changes nothing"); + if want.is_empty() { + continue; + } + assert!(h.state().ui.brush_picker.is_none(), "{tool:?}"); + // A separator after Edit Brush…, and the blend modes in their groups. + let rows = items(tool); + if rows.first() == Some(&Item::EditBrush) && rows.len() > 1 { + assert_eq!(rows.get(1), Some(&Item::Separator), "{tool:?}"); + } + if want.contains(&"Darken") && want.contains(&"Dissolve") { + let at = rows.iter().position(|i| *i == Item::Blend(photocraft_doc::BlendMode::Darken)).unwrap(); + assert_eq!(rows[at - 1], Item::Separator, "{tool:?}"); + } + // The rows the menu shows first are drawn, top to bottom, in that order. + let tops: Vec = want.iter().take(4).map(|l| h.get_by_label(l).rect().top()).collect(); + assert!(tops.windows(2).all(|w| w[0] < w[1]), "{tool:?}: {tops:?}"); + } +} + +#[test] +fn shift_right_menu_rows_set_what_the_options_bar_sets() { + use photocraft_doc::BlendMode; + let mut h = harness(None); + // Pencil: Clear is the Pencil's own flag, Behind its brush's, both through tools.setBrush. + open_tool_menu(&mut h, Tool::Pencil); + choose_row(&mut h, "Clear"); + assert!(h.state().ui.tool_options.pencil_clear && !h.state().ui.tool_options.brush_clear); + open_tool_menu(&mut h, Tool::Pencil); + choose_row(&mut h, "Behind"); + assert!(!h.state().ui.tool_options.pencil_clear && h.state().session.tools.brush.behind); + assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Normal", "behind": true}}))); + // Clone Stamp: its own brush's mode, as the options bar's Mode dropdown. + open_tool_menu(&mut h, Tool::CloneStamp); + choose_row(&mut h, "Multiply"); + assert_eq!(h.state().session.tools.brush.mode, BlendMode::Multiply); + assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply"}}))); + assert_eq!(crate::blend_keys::tool_mode(h.state_mut()), Some(BlendMode::Multiply)); + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(1); + assert_eq!(h.state().session.tools.brush.mode, BlendMode::Normal, "the Brush keeps its own mode"); + // Healing Brush: the short list. + open_tool_menu(&mut h, Tool::Healing); + choose_row(&mut h, "Luminosity"); + assert_eq!(h.state().session.tools.brush.mode, BlendMode::Luminosity); + // Tool options: Eraser Mode, Dodge Range, Sponge Mode, Paint Bucket and Gradient modes. + open_tool_menu(&mut h, Tool::Eraser); + choose_row(&mut h, "Block"); + assert_eq!(h.state().ui.tool_options.eraser_mode, "block"); + open_tool_menu(&mut h, Tool::Dodge); + choose_row(&mut h, "Highlights"); + assert_eq!(h.state().ui.tool_options.tone_range, "highlights"); + open_tool_menu(&mut h, Tool::Sponge); + choose_row(&mut h, "Saturate"); + assert_eq!(h.state().ui.tool_options.sponge_mode, "saturate"); + open_tool_menu(&mut h, Tool::PaintBucket); + choose_row(&mut h, "Clear"); + assert!(h.state().ui.tool_options.bucket_clear); + open_tool_menu(&mut h, Tool::PaintBucket); + choose_row(&mut h, "Screen"); + assert!(!h.state().ui.tool_options.bucket_clear); + assert_eq!(h.state().ui.tool_options.bucket_blend_mode, BlendMode::Screen); + open_tool_menu(&mut h, Tool::Gradient); + choose_row(&mut h, "Overlay"); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Overlay); + // The checked row is the current one. + open_tool_menu(&mut h, Tool::Eraser); + assert_eq!(crate::brush_mode_menu::current(h.state(), Tool::Eraser), Some(crate::brush_mode_menu::Item::Eraser("block"))); +} + +#[test] +fn edit_brush_opens_the_brush_preset_picker_at_the_click() { + for tool in [Tool::Brush, Tool::CloneStamp, Tool::MixerBrush, Tool::Dodge] { + let mut h = harness(None); + open_tool_menu(&mut h, tool); + let at = h.state().ui.brush_mode_menu.unwrap(); + let journal = h.state().session.journal.len(); + choose_row(&mut h, "Edit Brush…"); + assert_eq!(h.state().ui.brush_picker, Some(at), "{tool:?}"); + assert_eq!(h.state().session.journal.len(), journal, "{tool:?}"); + h.run_steps(2); + assert!(h.state().ui.brush_picker.is_some(), "{tool:?}: the picker stays open"); + } +} + +#[test] +fn behind_and_clear_are_greyed_on_the_background_layer() { + use crate::brush_mode_menu::{Item, enabled, find}; + use egui_kittest::kittest::Queryable; + let mut h = harness(None); + let (bg, top) = { + let doc = &h.state().session.active().unwrap().doc; + (doc.layers[0].id, doc.layers[1].id) + }; + h.state_mut().session.active_mut().unwrap().active_layer = Some(bg); + for tool in [Tool::Brush, Tool::Pencil, Tool::PaintBucket] { + open_tool_menu(&mut h, tool); + assert!(!enabled(h.state(), Item::Clear), "{tool:?}"); + assert!(find(h.state(), "Clear").is_err(), "{tool:?}"); + assert!(enabled(h.state(), Item::Blend(photocraft_doc::BlendMode::Multiply))); + let journal = h.state().session.journal.len(); + h.get_by_label("Clear").click(); + h.run_steps(2); + let o = &h.state().ui.tool_options; + assert!(!o.brush_clear && !o.pencil_clear && !o.bucket_clear, "{tool:?}"); + assert_eq!(h.state().session.journal.len(), journal, "{tool:?}: a greyed row does nothing"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + } + assert!(!enabled(h.state(), Item::Behind)); + // On a normal layer they are available again. + h.state_mut().session.active_mut().unwrap().active_layer = Some(top); + assert!(enabled(h.state(), Item::Behind) && enabled(h.state(), Item::Clear)); +} diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index 923e81140a..ce1ae599b5 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -97,7 +97,7 @@ pub(crate) fn unit_menu_of(ui: &mut egui::Ui, current: Unit, units: &[Unit]) -> } /// A guide being dragged: from a ruler (new) or an existing one (index). -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct GuideDrag { pub vertical: bool, pub index: Option, @@ -108,13 +108,22 @@ pub struct GuideDrag { /// 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, + pub flipped: bool, } impl GuideDrag { + pub fn shows_vertical(&self) -> bool { + self.vertical != self.flipped + } + pub fn track(&mut self, at: [f64; 2], alt: bool) { + self.flipped = alt; + self.pos = if self.shows_vertical() { at[0] } else { at[1] }; + } + /// 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, over_ruler: false } + Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false, flipped: false } } } @@ -235,7 +244,7 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, if let Some(d) = drag { // A guide being moved shows its own colour; a new one dragged from a ruler the preference's. let list = if d.vertical { &doc.guides.vertical } else { &doc.guides.horizontal }; - line(d.vertical, d.pos, d.index.and_then(|i| list.get(i)).map_or(guide, |p| own(d.vertical, *p))); + line(d.shows_vertical(), d.pos, d.index.and_then(|i| list.get(i)).map_or(guide, |p| own(d.vertical, *p))); } } @@ -245,14 +254,14 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, 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 axis = usize::from(!d.shows_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 extent = if d.shows_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()))) + Some((if d.shows_vertical() { "X:" } else { "Y:" }, format!("{value} {}", ur.rulers.suffix()))) } /// Draw [`guide_drag_readout`] beside the pointer while a guide is dragged. @@ -294,20 +303,29 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { // 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.flipped && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 { crate::guide_select::select(app, d.vertical, i); return; } - let extent = if d.vertical { size.width } else { size.height } as f64; + let shows = d.shows_vertical(); + let extent = if shows { size.width } else { size.height } as f64; let orientation = if d.vertical { "vertical" } else { "horizontal" }; 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})), + (None, true) => app.run("view.newGuide", json!({"orientation": if shows {"vertical"}else{"horizontal"}, "position": pos})), (Some(i), true) => app - .run("view.moveGuide", json!({"orientation": orientation, "index": i, "position": pos})) - .inspect(|_| crate::guide_select::select(app, d.vertical, i)), + .run("view.moveGuide", json!({"orientation": orientation,"toOrientation":if shows {"vertical"}else{"horizontal"}, "index": i, "position": pos})) + .inspect(|_| { + let index = if d.flipped { + app.session.active().map_or(0, |st| if shows { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }).saturating_sub(1) + } else { + i + }; + crate::guide_select::select(app, shows, index); + }), (Some(i), false) => app.run("view.deleteGuide", json!({"orientation": orientation, "index": i})), (None, false) => Ok(serde_json::Value::Null), }; @@ -519,7 +537,7 @@ 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, over_ruler: false }); + app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false, flipped: false }); } if resp.drag_stopped() && let Some(mut d) = app.guide_drag.take() diff --git a/crates/ui-egui/src/canvas/rulers/tests.rs b/crates/ui-egui/src/canvas/rulers/tests.rs index 33684c9b7f..9b08137eed 100644 --- a/crates/ui-egui/src/canvas/rulers/tests.rs +++ b/crates/ui-egui/src/canvas/rulers/tests.rs @@ -202,13 +202,13 @@ fn info_panel_shows_guide_drag_position_outside_canvas() { 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, over_ruler: false }); + app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0, over_ruler: false, flipped: 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, over_ruler: false }); + h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0, over_ruler: false, flipped: false }); h.run_steps(2); assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); } @@ -220,15 +220,18 @@ 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 }; + let v = GuideDrag { vertical: true, index: Some(0), pos: 103.24, press: 50.0, over_ruler: false, flipped: 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 }; + let h = GuideDrag { vertical: false, index: None, pos: 40.0, press: 40.0, over_ruler: false, flipped: 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:")); + assert_eq!( + guide_drag_readout(&app, GuideDrag { vertical: true, index: None, pos: 103.0, press: 0.0, over_ruler: false, flipped: false }).map(|r| r.0), + Some("X:") + ); app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "pixels"})).unwrap(); app.guide_drag = Some(v); @@ -456,14 +459,14 @@ fn guide_drags_create_move_and_delete() { 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, over_ruler: false }); - finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0, over_ruler: false }); // off canvas: ignored + finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0, over_ruler: false, flipped: false }); + finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0, over_ruler: false, flipped: 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, over_ruler: false }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0, over_ruler: false, flipped: 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, over_ruler: false }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0, over_ruler: false, flipped: 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); @@ -699,20 +702,20 @@ fn a_guide_let_go_on_a_ruler_is_removed_not_moved() { 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 }, + GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 10.0, over_ruler: true, flipped: false }, // 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 }, + GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 50.0, over_ruler: true, flipped: false }, ] { 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 }); + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 10.0, over_ruler: false, flipped: 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 }); + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 80.0, over_ruler: false, flipped: 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"); } @@ -866,3 +869,101 @@ fn shift_dragged_guides_land_on_ruler_ticks() { assert_eq!(snap_to_tick(&app, &xf, true, 37.0), 33.0); assert_eq!(snap_to_tick(&app, &xf, false, 37.0), 40.0); } + +/// #5034: ⌥ turns a guide dragged out of a ruler, as in Photoshop: out of the left ruler it +/// comes out horizontal, at the pointer's y. +#[test] +fn alt_turns_a_guide_dragged_out_of_a_ruler() { + let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), app); + // ⌥ held for the whole drag (`event_modifiers` would let go of it after each event). + let alt = egui::Modifiers::ALT; + h.event(egui::Event::ModifiersChanged(alt)); + let (from, to) = (pos2(RULER / 2.0, 100.0), xf.to_screen(37.0, 51.0)); + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(1); + assert!(h.state().guide_drag.is_some_and(|d| !d.shows_vertical()), "drawn horizontal while ⌥ is held"); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }); + h.run_steps(2); + let g = &h.state().session.active().unwrap().doc.guides; + assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![], vec![51.0])); +} + +/// #5034: with the Move tool, ⌥ while dragging a guide turns it about the pointer, in one +/// undo step; letting go of ⌥ before the release turns it back. An ⌥-click turns it in place. +#[test] +fn alt_turns_a_guide_moved_with_the_move_tool() { + use crate::canvas::{ToolEvent, tool_event}; + let new_app = || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Move; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap(); + app + }; + let guides = |app: &PhotocraftApp| { + let g = &app.session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) + }; + let (none, alt) = (egui::Modifiers::NONE, egui::Modifiers::ALT); + + let mut app = new_app(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 44.0, pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 63.0, y: 17.0, pressure: 1.0 }, alt); + assert!(app.guide_drag.is_some_and(|d| !d.shows_vertical() && d.pos == 17.0)); + tool_event(&mut app, ToolEvent::Up { x: 63.0, y: 17.0 }, alt); + assert_eq!(guides(&app), (vec![], vec![17.0])); + assert!(app.session.undo()); + assert_eq!(guides(&app), (vec![50.0], vec![]), "one undo step"); + + // ⌥ let go before the release: still vertical, moved along x. + let mut app = new_app(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 44.0, pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 63.0, y: 17.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Up { x: 63.0, y: 17.0 }, none); + assert_eq!(guides(&app), (vec![63.0], vec![])); + + // An ⌥-click turns the guide where it was clicked. + let mut app = new_app(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 44.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 44.0 }, alt); + assert_eq!(guides(&app), (vec![], vec![44.0])); +} + +/// #5034: a guide dropped on either ruler is deleted, whichever way it runs; a horizontal +/// guide over the left ruler is still level with the image, so this needs the pointer. +#[test] +fn a_guide_dropped_on_either_ruler_is_deleted() { + let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + for (pointer, over) in [(pos2(RULER / 2.0, 150.0), true), (pos2(200.0, RULER / 2.0), true), (pos2(200.0, 150.0), false)] { + 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; + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 30})).unwrap(); + app.guide_drag = Some(GuideDrag { vertical: false, index: Some(0), pos: 64.0, ..Default::default() }); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), app); + h.event(egui::Event::PointerMoved(pointer)); + h.run_steps(1); + let mut d = h.state().guide_drag.unwrap(); + // The canvas release owns pointer classification in the current routing. + d.over_ruler = dropped_on_a_ruler(h.state(), full, pointer); + assert_eq!(d.over_ruler, over, "{pointer:?}"); + let app = h.state_mut(); + app.guide_drag = None; + finish_drag(app, d); + let left = app.session.active().unwrap().doc.guides.horizontal.clone(); + assert_eq!(left, if over { vec![] } else { vec![64.0] }, "{pointer:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.guides.horizontal, vec![30.0], "one Undo restores the guide"); + } +} diff --git a/crates/ui-egui/src/canvas/scrollbars.rs b/crates/ui-egui/src/canvas/scrollbars.rs index 375edc13b6..305e9c86d3 100644 --- a/crates/ui-egui/src/canvas/scrollbars.rs +++ b/crates/ui-egui/src/canvas/scrollbars.rs @@ -71,9 +71,13 @@ impl Span { } } -/// Clamp one axis of the view centre with Overscroll off, as Photoshop 25.4 does: an image larger -/// than the view can't leave its edges, and one smaller than the view stays inside it, where -/// zooming put it (zooming out around the pointer doesn't re-centre it; Fit on Screen does). +/// Clamp one axis of the view centre with Overscroll off: an image larger than the view can't +/// leave its edges, and one smaller than the view is centred in it, so zooming out around the +/// pointer keeps it in the middle (#10488, #4799). +/// +/// #1718 measured Photoshop 25.4 leaving a smaller image where zooming put it. Two reports of +/// Photoshop itself say it stays centred with Overscroll off, which is what turning Overscroll off +/// is asked for, so PhotoCraft centres it; Overscroll on keeps the free view. pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 { if !(zoom.is_finite() && zoom > 0.0 && len.is_finite() && len > 0.0 && size.is_finite() && size > 0.0) { return center; @@ -82,9 +86,11 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 { return len / 2.0; } let half = size / zoom / 2.0; - // Larger: the view stays within the image. Smaller: the image stays within the view. - let (a, b) = (half, len - half); - center.clamp(a.min(b), a.max(b)) + // Smaller than the view (or exactly its size): centred. Larger: the view stays within it. + if 2.0 * half >= len { + return len / 2.0; + } + center.clamp(half, len - half) } /// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a @@ -112,7 +118,7 @@ pub fn clamp_view_with_aspect(view: &mut View, size: egui::Vec2, ppp: f32, aspec if view.center[0].is_finite() { view.center[0] } else { w as f32 / 2.0 }, if view.center[1].is_finite() { view.center[1] } else { h as f32 / 2.0 }, ]; - let xf = crate::canvas::ViewXform { rect: egui::Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect }; + let xf = crate::canvas::ViewXform { rect: Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect }; let image = xf.image_bounds(view.doc_size); if !image.is_finite() { return false; @@ -309,12 +315,13 @@ mod tests { assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 1.0), 250.0); assert_eq!(clamp_axis(4000.0, 1000.0, 500.0, 1.0), 750.0); assert_eq!(clamp_axis(600.0, 1000.0, 500.0, 1.0), 600.0); - // Smaller than the view (2500 doc px of view for 1000 of image): it stays where it is - // while it is inside the view, and is pushed back in at the edges. - assert_eq!(clamp_axis(-90.0, 1000.0, 500.0, 0.2), -90.0); - 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"); + // Smaller than the view (2500 doc px of view for 1000 of image): centred, wherever zooming + // around the pointer left it (#10488). + for center in [-90.0, -400.0, 300.0, 1400.0] { + assert_eq!(clamp_axis(center, 1000.0, 500.0, 0.2), 500.0, "{center}"); + } assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred"); + assert_eq!(clamp_axis(10.0, 1000.0, 1000.0, 1.0), 500.0); let mut v = View { zoom: 1.0, center: [-500.0, 20.0], @@ -332,6 +339,20 @@ mod tests { assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop"); } + #[test] + fn overscroll_off_centres_a_fitting_rotated_image() { + // 600 px at 45 degrees is about 849 points across, so it fits a 900-point view both ways. + // Started off centre, the clamp centres its screen-space bounds in the view (#10488). + let mut v = View { zoom: 1.0, center: [100.0, 450.0], doc_size: [600, 600], rotation: 45.0, ..Default::default() }; + let size = vec2(900.0, 900.0); + assert!(clamp_view_with_aspect(&mut v, size, 1.0, 1.0)); + let rect = Rect::from_min_size(Pos2::ZERO, size); + let xf = crate::canvas::ViewXform { rect, zoom: 1.0, center: v.center, flip: false, rotation: v.rotation, aspect: 1.0 }; + let c = xf.image_bounds(v.doc_size).center(); + assert!((c - xf.rect.center()).length() < 1e-2, "centred: {c:?}"); + assert!(!clamp_view_with_aspect(&mut v, size, 1.0, 1.0), "stable: no repaint loop"); + } + #[test] fn hostile_numbers_are_harmless() { for z in [0.0, -1.0, f32::NAN, f32::INFINITY] { @@ -516,16 +537,13 @@ mod tests { } else { assert!((x - w / 2.0).abs() < 1e-3, "clamped to the left edge: {x}"); } - // Zoomed out below fit (Photoshop): the image is not re-centred, it stays where it - // is while it fits in the view, and is pushed back in at the view's edge. + // Zoomed out below fit: the image is centred in the view on both axes, wherever + // the zoom left it (#10488). set_view(&mut h, 0.1, [-50.0, 9000.0]); if !overscroll { - let r = h.state().last_canvas_rect; - let (hw, hh) = (r.width() / 0.1 / 2.0, r.height() / 0.1 / 2.0); - let c = view(&h).center; - assert_eq!(c[0], -50.0, "inside the view: left alone"); - assert!((c[1] - hh).abs() < 1e-2, "pushed back in: the image's top on the view's: {c:?}"); - assert!(c[0] - hw <= 0.0 && c[0] + hw >= 2000.0 && c[1] - hh <= 0.0 && c[1] + hh >= 1500.0, "{c:?}"); + assert_eq!(view(&h).center, [1000.0, 750.0], "centred"); + } else { + assert_eq!(view(&h).center, [-50.0, 9000.0], "overscroll on: the view is free"); } } } diff --git a/crates/ui-egui/src/canvas/select_mask_view.rs b/crates/ui-egui/src/canvas/select_mask_view.rs index e60da1054d..b4962d512d 100644 --- a/crates/ui-egui/src/canvas/select_mask_view.rs +++ b/crates/ui-egui/src/canvas/select_mask_view.rs @@ -57,7 +57,7 @@ impl Mode { Mode::ALL.into_iter().find(|m| m.id() == id) } - fn label(self) -> &'static str { + pub(crate) fn label(self) -> &'static str { match self { Mode::OnionSkin => tl!("Onion Skin"), Mode::MarchingAnts => tl!("Marching Ants"), @@ -70,7 +70,7 @@ impl Mode { } /// The single-letter key that picks this view. - fn key(self) -> egui::Key { + pub(crate) fn key(self) -> egui::Key { match self { Mode::OnionSkin => egui::Key::O, Mode::MarchingAnts => egui::Key::M, @@ -83,7 +83,7 @@ impl Mode { } /// The view's opacity (percent) until it is changed; `None` for views without one. - fn default_opacity(self) -> Option { + pub(crate) fn default_opacity(self) -> Option { match self { Mode::OnionSkin | Mode::Overlay => Some(50.0), Mode::OnBlack | Mode::OnWhite => Some(100.0), @@ -91,7 +91,7 @@ impl Mode { } } - fn next(self) -> Mode { + pub(crate) fn next(self) -> Mode { let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0); Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin) } @@ -438,6 +438,10 @@ struct View { opacity: f32, show_edge: bool, show_original: bool, + /// Overlay's colour (Photoshop's default rubylith red) and whether it marks the selected + /// areas rather than the masked ones. + color: Color, + selected: bool, } impl View { @@ -449,6 +453,16 @@ impl View { opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32, show_edge: b("showEdge"), show_original: b("showOriginal"), + color: v + .and_then(|v| v.get("color")) + .and_then(Value::as_array) + .filter(|c| c.len() == 3) + .and_then(|c| { + let ch = |i: usize| c.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32); + Some(Color::rgb(ch(0)?, ch(1)?, ch(2)?)) + }) + .unwrap_or(Color::rgb(1.0, 0.0, 0.0)), + selected: v.and_then(|v| v.get("indicates")).and_then(Value::as_str) == Some("selected"), } } } @@ -461,16 +475,31 @@ fn length(params: &Value, key: &str, k: u32) -> f64 { /// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the /// requested view. `None` when the refinement fails or is cancelled. pub fn preview_document(doc: &Document, active: Option, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option { + let k = k.max(1); + preview_proxy(crate::proxy::proxy_document(doc, k), active, params, k, cancel) +} + +/// [`preview_document`] on `proxy`, the document already reduced by `k` (the Select and Mask +/// workspace builds it itself, its selection already carrying the workspace's tool edits). +pub fn preview_proxy( + mut proxy: Document, + active: Option, + params: &Value, + k: u32, + cancel: Option<&photocraft_engine::jobs::JobCtx>, +) -> Option { let view = View::of(params); let k = k.max(1); - let mut proxy = crate::proxy::proxy_document(doc, k); let active = active.filter(|id| proxy.layer(*id).is_some()); // What Select and Mask starts from: the selection, else the active layer's mask. let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone())); let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX)); let coverage = if view.show_edge { let band = Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize); - band.with_brush(scaled_strokes(params, k).as_ref().and_then(|s| photocraft_algo::matting::EdgeBrush::new(s, canvas)).as_ref(), canvas) + // With nothing selected OK refines nothing (`matting::refine_area`), so the brush shows nothing either. + let selects = original.as_ref().is_some_and(|s| !s.content_bounds().intersect(&canvas).is_empty()); + let brush = if selects { scaled_strokes(params, k).and_then(|s| photocraft_algo::matting::EdgeBrush::new(&s, canvas)) } else { None }; + band.with_brush(brush.as_ref(), canvas) } else if view.show_original { Coverage::read(original.as_ref(), canvas) } else { @@ -559,7 +588,10 @@ fn show(doc: &mut Document, active: Option, view: &View, coverage: &Cov doc.selection = Some(coverage.surface(|v| v)); match view.mode { Mode::MarchingAnts => {} - Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))), + Mode::Overlay => { + let marked = if view.selected { coverage.surface(|v| v) } else { coverage.surface(|v| 1.0 - v) }; + doc.layers.push(veil("Overlay", view.color, o, Some(marked))); + } Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))), Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))), Mode::BlackWhite => { @@ -705,6 +737,24 @@ mod tests { assert!(pixel(&edge, 35, 10)[0] > 0.99 && pixel(&edge, 55, 10)[0] < 0.01, "Show Edge includes the painted area"); } + /// Overlay takes Photoshop's colour swatch and Indicates: the masked (default) or the selected + /// areas carry the tint. + #[test] + fn overlay_colour_and_what_it_indicates() { + let doc = half_selected(); + let active = doc.layers.first().map(|l| l.id); + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02); + let view = |indicates: &str| json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": [0.0, 0.0, 1.0], "indicates": indicates}}); + let masked = preview_document(&doc, active, &view("masked"), 1, None).expect("a preview"); + assert!(near(&pixel(&masked, 35, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&masked, 5, 10), &[1.0, 1.0, 1.0, 1.0])); + let selected = preview_document(&doc, active, &view("selected"), 1, None).expect("a preview"); + assert!(near(&pixel(&selected, 5, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&selected, 35, 10), &[1.0, 1.0, 1.0, 1.0])); + // A hostile colour falls back to red. + let bad = json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": ["x", 2, null]}}); + let red = preview_document(&doc, active, &bad, 1, None).expect("a preview"); + assert!(near(&pixel(&red, 35, 10), &[1.0, 0.0, 0.0, 1.0])); + } + /// Show Edge shows the band around the selection's edge; Show Original the unrefined selection. #[test] fn show_edge_and_show_original() { diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 05365dab54..8b3c6525a1 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -216,15 +216,17 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { let crop_tol = crate::crop_ui::tolerance(app); let gesture = if let Some(t) = &app.ui.transform { let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) { - crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])), + crate::transform_tool::Hit::Corner(index) => { + t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, crate::transform_tool::moving_layers(t))) + } _ => None, }; if t.mode == crate::state::TransformMode::Distort { corner } else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) { - corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)]))) + corner.or_else(|| Some((Gesture::Point, crate::transform_tool::moving_layers(t)))) } else if in_quad(&t.quad, p) { - Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)])) + Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, crate::transform_tool::moving_layers(t))) } else { None } @@ -379,10 +381,13 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: let deg = crate::crop_ui::angle(app); let ratio = crate::crop_ui::resize_ratio(app, rect, mods); let q = view.map_or(p, |view| crate::crop_mode::to_press(app, Some(view), p)); - // The frame for the pointer at `q`, as `crop_ui` resizes it. - let frame_at = |q: [f64; 2]| match view { - Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f), - None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)), + // The frame for the pointer at `q`, as `crop_ui` resizes it (a ⇧ corner along its diagonal). + let frame_at = |q: [f64; 2]| { + let q = crate::crop_ui::resize_point(app, rect, hx, hy, start, q, mods); + match view { + Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f), + None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)), + } }; let frame = frame_at(q)?; let at = |r: [f64; 4]| { @@ -415,17 +420,37 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: let original = at(rect); let travel = crate::crop_ui::turn([handle[0] - original[0], handle[1] - original[1]], [0.0, 0.0], -deg); let mut local = [(travel[0] + d[0]) / scale, (travel[1] + d[1]) / scale]; - if deg == 0.0 - && hx != 0 + if hx != 0 && hy != 0 && let Some(k) = ratio { - // A corner with a ratio has one degree of freedom. Put the other axis on the same - // ratio, rather than letting the raw pointer's unused axis override the snapped edge. - if lines.iter().any(|line| line.vertical) { - local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow; + if deg == 0.0 { + // A corner with a ratio has one degree of freedom. Put the other axis on the same + // ratio, rather than letting the raw pointer's unused axis override the snapped edge. + if lines.iter().any(|line| line.vertical) { + local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow; + } else { + local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow; + } } else { - local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow; + // A turned corner with a ratio slides along its turned diagonal, which crosses the + // guides at an angle: slide it onto the nearest line it reached. Moved sideways onto + // the line instead, the ratio (a ⇧ corner's diagonal) put it back off the line. + let along = [f64::from(hx) * k, f64::from(hy)]; + let v = crate::crop_ui::turn(along, [0.0, 0.0], deg); + // A diagonal nearly parallel to the line meets it far along: snap only to a crossing + // inside the tolerance box `snap_point` read the handle in (|s·v| ≤ tol on each axis). + let reach = tol / v[0].abs().max(v[1].abs()); + let slide = lines + .iter() + .filter_map(|line| { + let (gap, step) = if line.vertical { (line.pos - handle[0], v[0]) } else { (line.pos - handle[1], v[1]) }; + (step.abs() > 1e-9).then(|| gap / step) + }) + .filter(|s| s.abs() <= reach) + .min_by(|a, b| a.abs().total_cmp(&b.abs())); + let Some(s) = slide else { return Some((p, Vec::new())) }; + local = [(travel[0] + s * along[0]) / scale, (travel[1] + s * along[1]) / scale]; } } let d = crate::crop_ui::turn(local, [0.0, 0.0], deg); diff --git a/crates/ui-egui/src/canvas/strokes.rs b/crates/ui-egui/src/canvas/strokes.rs index 11a288eb6a..c7c6d4b643 100644 --- a/crates/ui-egui/src/canvas/strokes.rs +++ b/crates/ui-egui/src/canvas/strokes.rs @@ -6,14 +6,73 @@ use super::{Color32, DRect, PhotocraftApp, Pos2, Stroke, Tool, json, pos2}; pub(crate) struct ClonePreviewCache { pub(super) key: String, - texture: egui::TextureHandle, + /// The brush a clipped overlay took its coverage from: a hardness or tip change redraws it. + pub(super) brush: Option, + /// The document area the texture covers. + pub(super) rect: DRect, + pub(super) texture: egui::TextureHandle, } -/// A transient source image beneath the brush outline; never part of the document render. +/// The most document pixels one dab's coverage is rendered over for the overlay: a 1000 px brush, +/// the largest the overlay draws, at hardness 0 reaches about 1.73 × its radius (`tip_reach`). +const CLONE_PREVIEW_MAX_PIXELS: u64 = 1800 * 1800; + +/// The largest side `retouch_cmds::clone_preview_periodic` samples, for the overlay's area and for +/// the source area it reads (`Mapping::source_rect`: the overlay's, mapped, with a 2 px apron). +const CLONE_PREVIEW_MAX_SIDE: u32 = 1024; + +/// The coverage of one dab of `brush` at `at`, as the brush engine paints it with opacity and flow +/// at full: its tip, angle, roundness and hardness (a soft tip reaches past its radius). The area +/// it covers and its `width × height` coverage values in 0..1. A very large soft tip, or a source +/// the Clone Source panel scales down (which reads a larger area than it covers), is cropped +/// around `at` to the sampling budget, keeping the middle of the dab. +fn clone_dab_coverage( + brush: &photocraft_paint::BrushSettings, + at: [f64; 2], + map: &photocraft_engine::presets::clone_source::Mapping, +) -> Option<(DRect, Vec)> { + let brush = photocraft_paint::BrushSettings { opacity: 1.0, flow: 1.0, erase: false, ..brush.clone() }; + let stroke = photocraft_paint::Stroke { brush, points: vec![photocraft_paint::StrokePoint::new(at[0], at[1], 1.0)] }; + let (rect, cov) = photocraft_paint::retouch::stroke_coverage_within(&stroke, CLONE_PREVIEW_MAX_PIXELS)?; + let fits = |r: DRect| r.width() <= CLONE_PREVIEW_MAX_SIDE && r.height() <= CLONE_PREVIEW_MAX_SIDE; + let (cx, cy) = (at[0].floor() as i32, at[1].floor() as i32); + let around = |half: i32| rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half))); + // The source area scales with the overlay's: shrink by what overflows, a few times for the + // apron's rounding. + let mut half = i32::try_from((CLONE_PREVIEW_MAX_SIDE - 4) / 2).unwrap_or(510); + let mut window = around(half); + for _ in 0..8 { + let src = map.source_rect(window); + if fits(window) && fits(src) { + break; + } + let over = f64::from(src.width().max(src.height())) / f64::from(CLONE_PREVIEW_MAX_SIDE - 4); + half = ((f64::from(half) / over.max(1.0)).floor() as i32).saturating_sub(2).max(1); + window = around(half); + } + if window.is_empty() || !fits(window) || !fits(map.source_rect(window)) { + return None; + } + if window == rect { + return Some((rect, cov)); + } + let stride = rect.width() as usize; + let mut cropped = Vec::with_capacity(window.width() as usize * window.height() as usize); + for y in window.y0..window.y1 { + let row = (y - rect.y0) as usize * stride; + let from = row + (window.x0 - rect.x0) as usize; + cropped.extend_from_slice(cov.get(from..from + window.width() as usize)?); + } + Some((window, cropped)) +} + +/// A transient source image beneath the brush outline; never part of the document render. The +/// Clone Source panel drives the Clone Stamp and the Healing Brush alike, so both show it. pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, at: [f64; 2], output: Option) { use photocraft_engine::presets::clone_source::{Mapping, overlay_blend, transform_matrix}; let overlay = app.session.presets.clone.overlay.clone(); - if app.ui.tool != Tool::CloneStamp || !overlay.show || (overlay.auto_hide && app.drag.is_some()) { + let tool = app.ui.tool; + if !matches!(tool, Tool::CloneStamp | Tool::Healing) || !overlay.show || (overlay.auto_hide && app.drag.is_some()) { return; } let slot = app.session.presets.clone.active().clone(); @@ -22,30 +81,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte if !radius.is_finite() || radius <= 0.0 || radius > 500.0 || !at.iter().all(|v| v.is_finite() && v.abs() < 1_000_000.0) { return; } + // View › Pattern Preview repeats the source for the Clone Stamp only (`paint.cloneStamp`'s + // `wrap`); the Healing Brush samples it as it is. + let wrap = tool == Tool::CloneStamp && app.ui.view.pattern_preview; let stroke_start = app.drag.as_ref().map(|d| d.start); let anchor = if app.ui.tool_options.clone_aligned { slot.anchor.or(stroke_start) } else { stroke_start }.unwrap_or(at); let map = Mapping { source: (source[0], source[1]), anchor: (anchor[0], anchor[1]), m: transform_matrix(slot.scale, slot.rotation, slot.flip_h, slot.flip_v) }; - let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32); let Some(st) = app.session.active() else { return }; let (display, display_key) = canvas_display(app, &st.doc, output); - // A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its - // outline instead of a circle, as Photoshop does with Clipped on. - let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None }; - let tip_key = tip_rings.as_ref().map(|r| { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h)); - (h.finish(), radius.to_bits(), at.map(f64::to_bits)) - }); let key = format!( - "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}", - st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview + "{:?}:{}:{:?}:{:?}:{map:?}:{}:{}:{}:{}:{wrap}:{}:{}", + st.doc.id, + st.revision, + st.active_layer, + at.map(f64::to_bits), + app.ui.tool_options.clone_sample, + display_key, + overlay.invert, + overlay.blend, + overlay.clipped, + radius.to_bits() ); - if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { - let Ok(mut buf) = - photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview) - else { + let brush = overlay.clipped.then_some(&app.session.tools.brush); + if app.clone_preview.as_ref().is_none_or(|c| c.key != key || c.brush.as_ref() != brush) { + // Clipped (Photoshop's default): the dab the tool paints here, so a soft brush previews + // soft and a shaped tip keeps its outline. Unclipped: the brush's whole square. + let (rect, coverage) = match brush { + Some(b) => { + let Some((rect, coverage)) = clone_dab_coverage(b, at, &map) else { return }; + (rect, Some(coverage)) + } + None => ( + DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32), + None, + ), + }; + let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, wrap) else { return; }; if overlay.invert { @@ -59,55 +131,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte // composite, whatever Sample reads), so Difference turns black where they line up. if overlay.blend != "normal" { let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] }; - let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else { + let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", wrap) else { return; }; for (px, d) in buf.px.iter_mut().zip(&under.px) { *px = overlay_blend(&overlay.blend, *px, *d); } } - if let Some(rings) = &tip_rings { - let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]); - for (px, inside) in buf.px.iter_mut().zip(mask) { - if !inside { - *px = [0.0; 4]; - } + if let Some(coverage) = &coverage { + for (px, c) in buf.px.iter_mut().zip(coverage) { + px[3] *= c.clamp(0.0, 1.0); } } let image = display_image(display.as_deref(), &buf); + let brush = brush.cloned(); match app.clone_preview.as_mut() { Some(cache) => { cache.texture.set(image, canvas_texture_options(1)); cache.key = key; + cache.brush = brush; + cache.rect = rect; + } + None => { + let texture = painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)); + app.clone_preview = Some(ClonePreviewCache { key, brush, rect, texture }); } - None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)) }), } } let Some(cache) = &app.clone_preview else { return }; + let rect = cache.rect; let tint = Color32::from_white_alpha((overlay.opacity.clamp(0.0, 100.0) * 2.55).round() as u8); - // A textured triangle fan provides a circular clip, including mirrored canvas views. + // A textured quad over the covered area: its texels carry the clip, in mirrored and rotated + // canvas views too. let mut mesh = egui::Mesh::with_texture(cache.texture.id()); - let vertex = |x: f64, y: f64| egui::epaint::Vertex { - pos: xf.to_screen(x as f32, y as f32), - uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32), - color: tint, - }; - if !overlay.clipped || tip_rings.is_some() { - for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] { - mesh.vertices.push(vertex(f64::from(x), f64::from(y))); - } - mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); - painter.add(egui::Shape::mesh(mesh)); - return; - } - mesh.vertices.push(vertex(at[0], at[1])); - for i in 0..=64 { - let angle = f64::from(i) * std::f64::consts::TAU / 64.0; - mesh.vertices.push(vertex(at[0] + radius * angle.cos(), at[1] + radius * angle.sin())); - } - for i in 1..=64 { - mesh.indices.extend_from_slice(&[0, i, i + 1]); + for (x, y, u, v) in [(rect.x0, rect.y0, 0.0, 0.0), (rect.x1, rect.y0, 1.0, 0.0), (rect.x1, rect.y1, 1.0, 1.0), (rect.x0, rect.y1, 0.0, 1.0)] { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: tint }); } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); painter.add(egui::Shape::mesh(mesh)); } @@ -115,8 +175,8 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte /// Healing Brush and History Brush composite their source. pub(super) enum EngineStroke { Brush(Box), - Retouch(photocraft_engine::retouch_cmds::LiveRetouch), - Dab(photocraft_engine::retouch_cmds::LiveDab), + Retouch(Box), + Dab(Box), Mixer(Box), } @@ -304,32 +364,32 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::color_replacement_params(app); p["points"] = json!(d.points); - EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?)) } else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { // The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed. p["points"] = json!(d.points); p["target"] = paint_target(app); - EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?)) } else if d.tool == Tool::PatternStamp { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points); p["points"] = json!(d.points); p["target"] = paint_target(app); if app.ui.tool_options.pattern_stamp_impressionist { - EngineStroke::Dab(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?)) } else { - EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?) + EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?)) } } else if d.tool == Tool::BackgroundEraser { // The params `eraser_ui::finish_stroke` commits (layer pixels only). let p = crate::eraser_ui::background_eraser_params(app, &d.points); - EngineStroke::Dab(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?) + EngineStroke::Dab(Box::new(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?)) } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? }; p["points"] = json!(d.points); p["target"] = paint_target(app); - EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) + EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)) } else { let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?)) @@ -406,7 +466,7 @@ pub(super) fn feed_live_stroke(app: &mut PhotocraftApp) { } } } - app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); + app.perf.stage.stroke_ms += crate::gpu_canvas::now_ms() - started; } /// The points the live retouching stroke has rendered. @@ -433,12 +493,12 @@ pub(super) fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) { match stroke { EngineStroke::Retouch(mut live) if !dab => { if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_retouch(live); + let _ = app.session.offer_live_retouch(*live); } } EngineStroke::Dab(mut live) if dab => { if rest.is_empty() || live.push(&rest).is_ok() { - let _ = app.session.offer_live_dab(live); + let _ = app.session.offer_live_dab(*live); } } _ => {} @@ -603,7 +663,7 @@ pub(super) fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } let started = crate::gpu_canvas::now_ms(); let result = app.run(stroke_command(app, d.tool), p); - app.stroke_trace.stroke(crate::gpu_canvas::now_ms() - started); + app.perf.stage.stroke_ms += crate::gpu_canvas::now_ms() - started; app.session.clear_live_stroke(); let Ok(r) = result else { return }; let after = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| (st.revision, st.doc.clone())); @@ -641,6 +701,18 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { ChannelTarget::Alpha(i) if i < st.doc.channels.len() => return json!({ "channel": i }), ChannelTarget::Composite if st.doc.quick_mask.is_some() => return json!("quickMask"), ChannelTarget::LayerMask => return json!("mask"), + ChannelTarget::Color(_) => { + if let Some(view) = photocraft_engine::mask_view_cmds::current(st) { + return json!(if view.filter { "filterMask" } else { "mask" }); + } + // A colour set edits a raster layer's own pixels. The rows stay selected across layer + // switches, so an adjustment layer's mask and a layer that needs rasterizing first + // keep the layer's own target below. + let raster = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Raster(_))); + if raster && let Some(colors) = photocraft_engine::color_channels::selected(&app.session) { + return colors.params(); + } + } _ => {} } // A smart object's targeted (or shown) filter mask takes the paint (FILE-215-3). @@ -653,3 +725,12 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { let mask = app.ui.mask_target || viewing || crate::adjustment_layer_active(st); json!(if mask && has_mask { "mask" } else { "pixels" }) } + +/// Colour rows can remain selected behind a viewed mask; restrictions follow what is edited. +/// Several rows stop Move and transforms on any layer, which the engine refuses there too. +pub(crate) fn multiple_color_target(app: &PhotocraftApp) -> bool { + let Some(st) = app.session.active() else { return false }; + matches!(st.channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Color(_)) + && photocraft_engine::mask_view_cmds::current(st).is_none() + && photocraft_engine::color_channels::selected(&app.session).is_some_and(|colors| colors.len() > 1) +} diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 82a1e2089f..8d06d2a499 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -5,31 +5,56 @@ use super::{Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, TextureOptions, c /// Tabs + canvas for the active document, or the start screen; then drops layers dragged onto /// another document (`layer_transfer`). pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if crate::jobs_ui::batch_preview(app, ui) { + return; + } crate::distort_ui::drop_foreign(app); documents(app, ui); crate::layer_transfer::finish(app, ui.ctx()); // Once a frame, not per document view: the right-click or options-bar Brush Preset picker. crate::paint_mouse::show_picker(app, ui.ctx()); + // And the Shift+Alt+right-click HUD Color Picker (#6098), while its gesture is held. + crate::color_hud::show(app, ui.ctx()); + crate::brush_mode_menu::show(app, ui.ctx()); } -/// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the -/// active document fills the area. pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option> { - let idx = app.session.active_index()?; - let n = app.session.documents().len(); + let documents = docked_document_indices(app); + let n = documents.len(); + if n == 0 { + return None; + } let cells = crate::view_cmds::cells(&app.ui.view.arrange, rect, n)?; - // Each document keeps its tile: a click into another one activates it where it is (#6017; it - // used to move to the first tile). With more documents than tiles, the shown run stays put - // while the active document is in it, and otherwise slides only as far as it must. - let start = app.arrange_start.min(n.saturating_sub(cells.len())); - let start = if idx < start { idx } else { start.max((idx + 1).saturating_sub(cells.len())) }; - Some((start..n).zip(cells).collect()) + // Keep stable tiles, expressing the remembered document index in the filtered docked list. + let start = documents.partition_point(|&d| d < app.arrange_start).min(n.saturating_sub(cells.len())); + let idx = app.session.active_index().and_then(|d| documents.iter().position(|&p| p == d)); + let start = idx.map_or(start, |idx| if idx < start { idx } else { start.max((idx + 1).saturating_sub(cells.len())) }); + Some(documents[start..].iter().copied().zip(cells).collect()) +} + +/// Tell the GPU canvas which documents are on screen (the arranged tiles or the main canvas's, +/// plus those of open document windows): they split the display texture budget, and the +/// textures of the others are freed first. Only a change of that set (or a document's size) +/// re-splits it, so switching between shown documents or windows moves no texture. +pub(crate) fn publish_shown(app: &PhotocraftApp) { + let Some(gpu) = &app.gpu else { return }; + let docs = app.session.documents(); + let mut shown: Vec = match arranged_cells(app, app.last_canvas_rect) { + Some(cells) => cells.into_iter().map(|(d, _)| d).collect(), + None => main_canvas_document(app).into_iter().collect(), + }; + shown.extend(app.ui.windows.iter().filter(|w| w.open).map(|w| w.document)); + let mut shown: Vec<(u64, u64)> = shown.into_iter().filter_map(|d| docs.get(d)).map(|st| (st.doc.id.0, gpu.canvas_bytes(&st.doc))).collect(); + shown.sort_unstable(); + shown.dedup(); + gpu.show_documents(&shown); } fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.drop_canvas_rect = None; app.tab_strip = None; retain_gpu_documents(app); + publish_shown(app); crate::transform_tool::track_steps(app, ui.ctx()); crate::cas_transform::tick(app, ui.ctx()); crate::symmetry_ui::track(app, ui.ctx()); @@ -47,6 +72,12 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { open_on_double_click(app, ui, area); return; } + if n > 0 && !opening && docked_document_indices(app).is_empty() { + // A torn-off document belongs to its OS window. Leaving its canvas in the main + // viewport would make the same page appear twice, which is what “New Window” does. + paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots); + return; + } if !app.ui.view.hides_tabs() || opening { // Flat Spectrum tabs sit directly against the canvas. The parent's default vertical // item spacing would otherwise leave a visible gap below the tab strip (#2188). @@ -57,12 +88,19 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.tab_strip = Some(tabs(app, ui)); ui.spacing_mut().item_spacing.y = spacing; drop_slot_line(app, ui); + // A tab click switches documents before any canvas is drawn this frame. + publish_shown(app); } if let Some(job) = app.jobs.focus.or_else(|| (n == 0).then(|| app.jobs.opens.last().map(|o| o.job)).flatten()) { crate::jobs_ui::open_card(app, ui, job); return; } - let Some(idx) = app.session.active_index() else { return }; + let Some(active_doc) = app.session.active_index() else { return }; + let Some(idx) = main_canvas_document(app) else { + paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots); + return; + }; + let main_focused = ui.ctx().input(|i| i.viewport().focused.unwrap_or(true)); let rect = ui.available_rect_before_wrap(); app.last_canvas_rect = rect; app.drop_canvas_rect = Some(rect); @@ -74,20 +112,23 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (d, cell) in shown { let cell = cell.shrink(1.0); let view = app.ui.views[d].clone(); - let v = canvas_view(app, ui, d, cell, view, d == idx); - if d != idx { + let v = canvas_view(app, ui, d, cell, view, d == active_doc); + if d != active_doc { app.ui.views[d] = v; - if ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p))) { + if main_focused && ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p))) { app.session.set_active(d); } } - let stroke = if d == idx { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, Color32::from_black_alpha(160)) }; + let stroke = if d == active_doc { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, Color32::from_black_alpha(160)) }; ui.painter().rect_stroke(cell, 0, stroke, egui::StrokeKind::Inside); } return; } + if idx != active_doc && main_focused && ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| rect.contains(p))) { + app.session.set_active(idx); + } let view = app.ui.views[idx].clone(); - canvas_view(app, ui, idx, rect, view, true); + canvas_view(app, ui, idx, rect, view, app.session.active_index() == Some(idx)); } /// The tab strip; returns where its document tabs are. @@ -97,7 +138,9 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { return pro_tabs(app, ui); } let active = app.session.active_index(); - let tab_count = app.session.documents().len(); + let documents = docked_document_indices(app); + let tab_count = documents.len(); + let document_count = app.session.documents().len(); let mut doc_tabs = Vec::with_capacity(tab_count); let mut activate = None; let mut close = None; @@ -117,10 +160,9 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { // after the documents. let opening = crate::jobs_ui::open_tabs(app); // Every tab's natural width, whether or not it stays on the strip. - let natural: Vec = app - .session - .documents() + let natural: Vec = documents .iter() + .filter_map(|&doc| app.session.documents().get(doc)) .map(|st| { let name = studio_tab_name(app, st); ui.painter().layout_no_wrap(name, font.clone(), t.text).size().x @@ -137,7 +179,7 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { .jobs .focus .and_then(|job| opening.iter().position(|(open, _, _)| *open == job)) - .map_or_else(|| app.session.active_index().unwrap_or(0), |p| tab_count + p); + .map_or_else(|| app.session.active_index().and_then(|doc| documents.iter().position(|&d| d == doc)).unwrap_or(0), |p| tab_count + p); let frame = egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| { let (row, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), tab_h), Sense::hover()); let f = fit_doc_tabs(&natural, selected, row.width()); @@ -150,8 +192,9 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { x = r.right() + STUDIO_TAB_GAP; } for &(i, r) in placed.iter().filter(|(i, _)| *i < tab_count) { - let Some(st) = app.session.documents().get(i) else { continue }; - let sel = Some(i) == active && !focused_open; + let Some(&doc) = documents.get(i) else { continue }; + let Some(st) = app.session.documents().get(doc) else { continue }; + let sel = Some(doc) == active && !focused_open; let name = studio_tab_name(app, st); let meta = format!("{}/{}", mode_label(&st.doc), st.doc.depth.bits()); let natural_w = natural.get(i).copied().unwrap_or(0.0); @@ -160,13 +203,13 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let name_g = crate::tab_strip::elided(ui, &name, font.clone(), t.text, name_max); // The title was cut: the tooltip has the rest of it. let cut = name_g.size().x + 0.5 < natural_w - STUDIO_TAB_PAD + STUDIO_TAB_GAP - meta_g.size().x; - let resp = ui.interact(r, ui.scope_id().with(("dtab", i)), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with(("dtab", doc)), Sense::click_and_drag()); let saving = crate::jobs_ui::save_progress(app, st.doc.id); resp.widget_info(|| { let label = saving.as_ref().map_or_else(|| st.doc.name.clone(), |s| format!("{} - {s}", st.doc.name)); egui::WidgetInfo::selected(egui::Role::Button, true, sel, label) }); - doc_tabs.push((i, r)); + doc_tabs.push((doc, r)); if sel { ui.painter().rect_filled(r, t.radius_sm, t.card); ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside); @@ -174,7 +217,7 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.5)); } if dragging.is_some_and(|p| r.contains(p)) { - drag_over = Some(i); + drag_over = Some(doc); ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } let color = if sel { t.text } else { t.text_dim }; @@ -182,25 +225,25 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 10.0, ny), name_g.clone(), color); ui.painter().galley(egui::pos2(r.left() + 16.0 + name_g.size().x, r.center().y - meta_g.size().y / 2.0), meta_g, t.text_faint); let xr = Rect::from_center_size(egui::pos2(r.right() - 12.0, r.center().y), egui::vec2(16.0, 16.0)); - let xresp = ui.interact(xr, ui.scope_id().with(("dtabx", i)), Sense::click()); + let xresp = ui.interact(xr, ui.scope_id().with(("dtabx", doc)), Sense::click()); if xresp.hovered() { ui.painter().rect_filled(xr, 4.0, t.hover); } crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_faint }); let resp = if cut { resp.on_hover_text(name) } else { resp }; if tab_close_clicked(&resp, &xresp) { - close = Some(i); + close = Some(doc); } else if resp.clicked() { - activate = Some(i); + activate = Some(doc); } match tab_drag(ui, &resp, i, r, row, &placed, &t) { - Some(TabDrop::Move(to)) => tab_move = Some((i, to)), - Some(TabDrop::Float(at)) => tear_off = Some((i, at)), + Some(TabDrop::Move(to)) => tab_move = Some((doc, move_target_after_filter(&documents, doc, to, document_count))), + Some(TabDrop::Float(at)) => tear_off = Some((doc, at)), None => {} } let has_path = st.path.is_some(); resp.context_menu(|ui| { - tab_action = tab_context_menu(ui, i, tab_count, has_path); + tab_action = tab_context_menu(ui, doc, document_count, has_path); }); } // Files opening in the background: a tab with a progress underline; × cancels. @@ -236,15 +279,18 @@ pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { // Pinned to the strip's right end, as Photoshop's is, not after the last tab (#8290). let left = (row.right() - crate::tab_strip::CHEVRON_W).max(row.left()); let r = Rect::from_min_size(egui::pos2(left, row.top()), egui::vec2(crate::tab_strip::CHEVRON_W, row.height())); - let labels: Vec<&str> = - app.session.documents().iter().map(|st| st.doc.name.as_str()).chain(opening.iter().map(|(_, name, _)| name.as_str())).collect(); + let labels: Vec<&str> = documents + .iter() + .filter_map(|&doc| app.session.documents().get(doc).map(|st| st.doc.name.as_str())) + .chain(opening.iter().map(|(_, name, _)| name.as_str())) + .collect(); let mut picked = None; // Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks // the active one (#4487). let every: Vec = (0..labels.len()).collect(); crate::tab_strip::overflow_button(ui, ui.scope_id().with("dtab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); match picked { - Some(i) if i < tab_count => activate = Some(i), + Some(i) if i < tab_count => activate = documents.get(i).copied(), Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), None => {} } @@ -436,9 +482,13 @@ fn fit_doc_tabs(natural: &[f32], selected: usize, avail: f32) -> crate::tab_stri /// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask /// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted. pub(super) fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { + tab_title_at(app, i, app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0)) +} + +/// [`pro_tab_title`] at `zoom` percent (a workspace with its own view, such as Select and Mask). +pub(crate) fn tab_title_at(app: &PhotocraftApp, i: usize, zoom: f32) -> String { use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.documents().get(i) else { return String::new() }; - let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0); // Quick Mask mode targets the mask, not the layer (see `paint_target`): painting and Free // Transform change the red overlay, so the tab says so, as Photoshop's does. let quick_mask = st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite; @@ -468,12 +518,15 @@ fn studio_tab_name(app: &PhotocraftApp, st: &photocraft_engine::DocState) -> Str /// strip; the active document's tab always stays on the strip (#1276, #4487). fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let t = crate::theme::Tokens::get(ui.ctx()); - let active = app.session.active_index().filter(|_| app.jobs.focus.is_none()); + let documents = docked_document_indices(app); + let active_doc = app.session.active_index().filter(|_| app.jobs.focus.is_none()); + let active = active_doc.and_then(|doc| documents.iter().position(|&d| d == doc)); let (mut activate, mut close) = (None, None); let mut tab_action = None; let mut tab_move = None; let mut tear_off = None; - let tab_count = app.session.documents().len(); + let tab_count = documents.len(); + let document_count = app.session.documents().len(); let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx()); let mut drag_over = None; let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; @@ -485,15 +538,11 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { // Files opening in the background (#210) are tabs too ("name (Opening… 45%)" with a progress // underline); they share the fit's index space, after the documents. let opening = crate::jobs_ui::open_tabs(app); - let mut titles: Vec = (0..tab_count).map(|i| pro_tab_title(app, i)).collect(); + let mut titles: Vec = documents.iter().map(|&i| pro_tab_title(app, i)).collect(); titles.extend(opening.iter().map(|(_, name, frac)| format!("{name} ({} {:.0}%)", tl!("Opening…"), frac * 100.0))); // Every tab's natural width, whether or not it stays on the strip. let natural: Vec = titles.iter().map(|title| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + DOC_TAB_PAD).collect(); - let selected = app - .jobs - .focus - .and_then(|job| opening.iter().position(|(open, _, _)| *open == job)) - .map_or_else(|| app.session.active_index().unwrap_or(0), |p| tab_count + p); + let selected = app.jobs.focus.and_then(|job| opening.iter().position(|(open, _, _)| *open == job)).map_or_else(|| active.unwrap_or(0), |p| tab_count + p); let f = fit_doc_tabs(&natural, selected, strip.width()); let mut x = strip.left(); let mut placed: Vec<(usize, Rect)> = Vec::with_capacity(f.shown.len()); @@ -505,9 +554,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let mut doc_tabs = Vec::with_capacity(tab_count); let (mut focus_open, mut cancel_open) = (None, None); for &(i, r) in placed.iter().filter(|(i, _)| *i < tab_count) { + let Some(&doc) = documents.get(i) else { continue }; let Some(title) = titles.get(i) else { continue }; let g = crate::tab_strip::elided(ui, title, font.clone(), t.text, (r.width() - DOC_TAB_PAD).max(1.0)); - let resp = ui.interact(r, ui.scope_id().with(("ptab", i)), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with(("ptab", doc)), Sense::click_and_drag()); let sel = Some(i) == active; if sel { ui.painter().rect_filled(r, 0.0, t.chrome); @@ -515,12 +565,12 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.35)); } if dragging.is_some_and(|p| r.contains(p)) { - drag_over = Some(i); + drag_over = Some(doc); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator)); let (xr, at) = pro_tab_layout(r, g.size().y, mac); - let xresp = ui.interact(xr, ui.scope_id().with(("ptabx", i)), Sense::click()); + let xresp = ui.interact(xr, ui.scope_id().with(("ptabx", doc)), Sense::click()); // Painted: name the tab and its × for accessibility (and so tests and agents can find them). resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, g.text())); xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Close"))); @@ -529,20 +579,20 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let cut = natural.get(i).copied().unwrap_or(0.0) > r.width() + 0.5; let resp = if cut { resp.on_hover_text(title.as_str()) } else { resp }; if tab_close_clicked(&resp, &xresp) { - close = Some(i); + close = Some(doc); } else if resp.clicked() { - activate = Some(i); + activate = Some(doc); } match tab_drag(ui, &resp, i, r, strip, &placed, &t) { - Some(TabDrop::Move(to)) => tab_move = Some((i, to)), - Some(TabDrop::Float(at)) => tear_off = Some((i, at)), + Some(TabDrop::Move(to)) => tab_move = Some((doc, move_target_after_filter(&documents, doc, to, document_count))), + Some(TabDrop::Float(at)) => tear_off = Some((doc, at)), None => {} } - let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some()); + let has_path = app.session.documents().get(doc).is_some_and(|d| d.path.is_some()); resp.context_menu(|ui| { - tab_action = tab_context_menu(ui, i, tab_count, has_path); + tab_action = tab_context_menu(ui, doc, document_count, has_path); }); - doc_tabs.push((i, r)); + doc_tabs.push((doc, r)); } for &(i, r) in placed.iter().filter(|(i, _)| *i >= tab_count) { let Some((job, _, frac)) = opening.get(i - tab_count) else { continue }; @@ -579,7 +629,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let every: Vec = (0..labels.len()).collect(); crate::tab_strip::overflow_button(ui, ui.scope_id().with("ptab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked); match picked { - Some(i) if i < tab_count => activate = Some(i), + Some(i) if i < tab_count => activate = documents.get(i).copied(), Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open), None => {} } @@ -704,25 +754,31 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(st) = app.session.documents().get(w.document) else { continue }; let title = format!("{} — window {}", st.doc.name, w.id); let vid = egui::ViewportId::from_hash_of(("docwin", w.id)); - let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]); + let mut builder = crate::os_window::builder().with_title(title).with_inner_size([800.0, 600.0]); if let Some([x, y]) = w.position { builder = builder.with_position([x, y]); } let mut view = w.view.clone(); let mut close = false; + let mut focused = false; ctx.show_viewport_immediate(vid, builder, |ui, class| { if ui.ctx().input(|i| i.viewport().close_requested()) { close = true; } + focused = ui.ctx().input(|i| i.viewport().focused == Some(true)); + if focused && !w.focused && app.session.active_index() != Some(w.document) { + app.session.set_active(w.document); + } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(crate::theme::Tokens::get(ui.ctx()).canvas)).show(ui, |ui| { let rect = ui.available_rect_before_wrap(); - view = canvas_view(app, ui, w.document, rect, view.clone(), false); + view = canvas_view(app, ui, w.document, rect, view.clone(), focused); // Preferences › Zoom Resizes Windows: a zoom wraps the window around the image. crate::zoom_window_fit::follow(app, ui.ctx(), class, vid, &w, &mut view, rect); }); }); if let Some(win) = app.ui.windows.iter_mut().find(|x| x.id == w.id) { win.view = view; + win.focused = focused; if close { win.open = false; } @@ -730,3 +786,28 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } app.ui.windows.retain(|w| w.open); } + +/// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the +/// active document fills the area. +pub(super) fn docked_document_indices(app: &PhotocraftApp) -> Vec { + (0..app.session.documents().len()).filter(|&doc| !crate::view_cmds::document_is_floating(app, doc)).collect() +} + +pub(super) fn main_canvas_document(app: &PhotocraftApp) -> Option { + let active = app.session.active_index()?; + if !crate::view_cmds::document_is_floating(app, active) { + return Some(active); + } + docked_document_indices(app).into_iter().next() +} + +/// Convert a final tab index in the visible (docked-only) strip to the document list index, +/// keeping hidden floating documents in their existing relative order. +pub(super) fn move_target_after_filter(visible: &[usize], from: usize, to: usize, document_count: usize) -> usize { + let remaining: Vec = visible.iter().copied().filter(|&doc| doc != from).collect(); + match remaining.get(to) { + Some(&doc) if doc > from => doc - 1, + Some(&doc) => doc, + None => document_count.saturating_sub(1), + } +} diff --git a/crates/ui-egui/src/canvas/tabs_tests.rs b/crates/ui-egui/src/canvas/tabs_tests.rs index 677949600b..6285b7e651 100644 --- a/crates/ui-egui/src/canvas/tabs_tests.rs +++ b/crates/ui-egui/src/canvas/tabs_tests.rs @@ -150,6 +150,17 @@ fn ctrl_tab_cycles_through_the_document_tabs() { assert!(!h.state().ui.panels.dock, "plain Tab hides the panels"); } +#[test] +fn ctrl_tab_records_a_relative_document_switch() { + let mut h = harness(2, ThemeKind::Pro); + h.state_mut().run("actions.record", json!({"name": "Switch"})).unwrap(); + h.key_press_modifiers(ctrl(), egui::Key::Tab); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!(steps, &[("document.activate".into(), json!({"offset": -1}))]); +} + /// One document: Ctrl+Tab stays on it quietly; a focused widget or text field doesn't keep the key. #[test] fn ctrl_tab_with_one_document_or_a_focused_field() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index f213a67d9d..73ef696976 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -175,15 +175,15 @@ fn inline_filter_preview_is_composited_and_recorded_once() { app.background_jobs = false; app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); - app.perf.frame_refresh_ms = 0.0; + app.perf.stage = Default::default(); let (_, _, size) = ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); assert_eq!(size, [320, 240]); assert_eq!(app.perf.last_refresh, "filter-preview"); assert_eq!(app.perf.last_refresh_px, 320 * 240); - assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms, "one record per preview"); + assert_eq!(app.perf.stage.composite_ms + app.perf.stage.upload_ms, app.perf.composite_ms + app.perf.upload_ms, "one record per preview"); // Unchanged request: nothing is recomputed or recorded again. ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("the same preview"); - assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms); + assert_eq!(app.perf.stage.composite_ms + app.perf.stage.upload_ms, app.perf.composite_ms + app.perf.upload_ms); } /// #6326: a filter's live preview is never coarser than the screen. On a document big enough @@ -371,6 +371,31 @@ fn drive_until(app: &mut PhotocraftApp, ctx: &egui::Context, what: &str, done: i } } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_held_preview_frame_stays_on_the_canvas_when_the_proxy_factor_changes() { + // #10474: Minimum and Maximum take their proxy factor from the radius, so a radius drag across + // a whole pixel changes `FilterPreviewKey::k`. The frame for the old factor stays on the + // canvas, drawn at its own factor, until the one for the new factor lands. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 2400, "height": 2200})).unwrap(); + app.background_jobs = true; + app.ui.views.get_mut(0).expect("the new document's view").zoom = 0.25; + crate::filter_dialog::open(&mut app, "filter.other.maximum").unwrap(); + app.ui.dialogs[0].fields.insert("radius".into(), json!(6.0)); + let ctx = egui::Context::default(); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.as_ref().is_some_and(|p| p.result.is_some())); + assert_eq!(app.filter_preview.as_ref().map(|p| p.key.k), Some(2), "6 px on a reduced document previews on the proxy"); + + // 1.5 px previews at full resolution: a new request at a new factor, computed on the worker. + app.ui.dialogs[0].fields.insert("radius".into(), json!(1.5)); + let (k, _, size) = ensure_filter_preview(&mut app, 0, &ctx).expect("the held frame stays on the canvas"); + assert_eq!((k, size), (2, [1200, 1100]), "drawn at its own factor"); + drive_until(&mut app, &ctx, "the full-resolution frame", |a| a.filter_preview.as_ref().is_some_and(|p| p.key.k == 1 && p.result.is_some())); + let (k, _, size) = ensure_filter_preview(&mut app, 0, &ctx).expect("the new frame"); + assert_eq!((k, size), (1, [2400, 2200])); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { @@ -1175,70 +1200,151 @@ fn dragging_a_tab_off_the_strip_floats_it_in_a_window() { } } +/// An app with a 64 × 64 white document, a clone source and the Clone Stamp at `size` px. +fn clone_overlay_app(size: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.session.tools.brush.size = size; + app +} + +/// One frame of the overlay at (32, 32): its shapes and texture uploads. +fn clone_overlay_frame(ctx: &egui::Context, app: &mut PhotocraftApp, xf: &ViewXform) -> egui::FullOutput { + ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); + draw_clone_preview(app, &painter, xf, [32.0, 32.0], None); + }) +} + +/// The overlay texture a frame uploaded and the alpha at a document pixel, read from the +/// document area the overlay covers (transparent outside it). +fn clone_overlay_alpha(app: &PhotocraftApp, output: &mut egui::FullOutput) -> impl Fn(i32, i32) -> u8 + use<> { + let cache = app.clone_preview.as_ref().expect("an overlay was drawn"); + let image = output + .textures_delta + .set + .get(&cache.texture.id()) + .and_then(|deltas| deltas.last()) + .map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), + }) + .expect("the overlay uploads a texture"); + output.textures_delta.clear(); + let rect = cache.rect; + assert_eq!(image.size, [rect.width() as usize, rect.height() as usize], "the texture covers the overlay's area"); + move |x, y| { + if x < rect.x0 || y < rect.y0 || x >= rect.x1 || y >= rect.y1 { + return 0; + } + image.pixels[(y - rect.y0) as usize * image.width() + (x - rect.x0) as usize].a() + } +} + +fn clone_overlay_view(side: f32, zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(side, side)), zoom, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 } +} + #[test] fn clone_overlay_is_circular_and_respects_visibility() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); let revision = app.session.active().unwrap().revision; - let mut output = ctx.run_ui(Default::default(), |ui| { - let ctx = ui.ctx(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); - output.textures_delta.clear(); - let mesh = output - .shapes - .iter() - .find_map(|s| match &s.shape { - egui::Shape::Mesh(m) => Some(m), - _ => None, - }) - .unwrap(); - assert_eq!(mesh.indices.len(), 64 * 3); - let center = xf.to_screen(32.0, 32.0); - assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01)); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "the overlay is drawn"); + let alpha = clone_overlay_alpha(&app, &mut output); + assert_eq!(alpha(32, 32), 255, "the centre shows the source"); + assert_eq!(alpha(32 + 8, 32), 255, "inside the hard 10 px radius"); + assert_eq!(alpha(32 + 8, 32 + 8), 0, "the brush square's corner, outside the circle, is clipped"); + assert_eq!(alpha(32 + 12, 32), 0, "past the hard tip's rim"); assert_eq!(app.session.active().unwrap().revision, revision); assert!(app.session.presets.clone.active().anchor.is_none()); let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone(); app.ui.view.pattern_preview = true; - let mut periodic_output = ctx.run_ui(Default::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); + let mut periodic_output = clone_overlay_frame(&ctx, &mut app, &xf); assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels"); assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit"); periodic_output.textures_delta.clear(); assert_eq!(app.session.active().unwrap().revision, revision); app.session.presets.clone.overlay.show = false; - let mut output = ctx.run_ui(Default::default(), |ui| { - let ctx = ui.ctx(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); output.textures_delta.clear(); assert!(output.shapes.iter().all(|s| !matches!(s.shape, egui::Shape::Mesh(_)))); } +/// A soft brush previews soft: the overlay fades towards the rim with the brush's hardness, as +/// the stroke it previews does, and reaches past the radius as a soft tip paints. A brush +/// change redraws an overlay that hasn't moved. +#[test] +fn clone_overlay_follows_the_brush_hardness() { + let ctx = egui::Context::default(); + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let hard = clone_overlay_alpha(&app, &mut output); + app.session.tools.brush.hardness = 0.0; + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let soft = clone_overlay_alpha(&app, &mut output); + assert!(hard(32, 32) == 255 && soft(32, 32) > 240, "both show the source at the centre: {} {}", hard(32, 32), soft(32, 32)); + assert_eq!(hard(32 + 8, 32), 255); + assert!(soft(32 + 8, 32) < 200, "a soft tip fades before its rim: {}", soft(32 + 8, 32)); + assert!(soft(32 + 4, 32) > soft(32 + 8, 32), "and fades outwards"); + assert!(hard(32 + 12, 32) == 0 && soft(32 + 13, 32) > 0, "a soft tip reaches past the radius, a hard one doesn't"); + // The largest brush the overlay draws still shows at its softest. + let mut big = clone_overlay_app(1000.0); + big.session.tools.brush.hardness = 0.0; + let mut output = clone_overlay_frame(&ctx, &mut big, &xf); + assert_eq!(clone_overlay_alpha(&big, &mut output)(32, 32), 255, "a 1000 px soft brush previews too"); +} + +/// A scaled Clone Source reads a larger source area than the overlay covers: a large brush's +/// overlay shrinks around the pointer until that area fits the sampling budget, rather than +/// vanishing. +#[test] +fn clone_overlay_of_a_scaled_source_stays_within_the_sampling_budget() { + let ctx = egui::Context::default(); + let xf = clone_overlay_view(100.0, 1.0); + for (size, scale) in [(600.0, 200), (1000.0, 300), (1000.0, 50)] { + let mut app = clone_overlay_app(size); + app.run("cloneSource.set", json!({"width": scale, "height": scale})).unwrap(); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "{size} px at {scale} %: the overlay shows"); + assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{size} px at {scale} %: the source under the pointer"); + } +} + +/// The Healing Brush shares the Clone Stamp's source and overlay (Photoshop's Clone Source +/// panel drives both); other tools show none. +#[test] +fn clone_overlay_shows_for_the_healing_brush_too() { + let ctx = egui::Context::default(); + let xf = clone_overlay_view(100.0, 1.0); + for (tool, shown) in [(Tool::Healing, true), (Tool::CloneStamp, true), (Tool::Brush, false), (Tool::SpotHealing, false)] { + let mut app = clone_overlay_app(20.0); + app.ui.tool = tool; + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + assert_eq!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), shown, "{tool:?}"); + if shown { + assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{tool:?}: the source shows under the brush"); + } + output.textures_delta.clear(); + } +} + #[test] fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 4.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(200.0, 4.0); for invert in [false, true] { app.session.presets.clone.overlay.invert = invert; let mut output = ctx.run_ui(Default::default(), |ui| { draw_clone_preview(&mut app, ui.painter(), &xf, [32.0, 32.0], None); }); - let upload = output.textures_delta.set.values().flatten().find(|d| d.image.size() == [20, 20]).expect("clone overlay upload"); + let id = app.clone_preview.as_ref().unwrap().texture.id(); + let upload = output.textures_delta.set.get(&id).and_then(|d| d.last()).expect("clone overlay upload"); let options = upload.options; output.textures_delta.clear(); assert_eq!(options.magnification, egui::TextureFilter::Nearest, "invert={invert}"); @@ -1249,27 +1355,20 @@ fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() { #[test] fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; - // The centre texel of the overlay texture uploaded for `mode`. + let mut app = clone_overlay_app(20.0); + let xf = clone_overlay_view(100.0, 1.0); + // The overlay texel at the brush centre for `mode`. let mut centre = |mode: &str| { app.run("cloneSource.overlay", json!({"blend": mode})).unwrap(); - let mut output = ctx.run_ui(Default::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); - // The brush's 20 × 20 px square (the font atlas may upload too). - let image = output.textures_delta.set.values().flatten().find_map(|d| match &d.image { - egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), - _ => None, + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let cache = app.clone_preview.as_ref().unwrap(); + let image = output.textures_delta.set.get(&cache.texture.id()).and_then(|d| d.last()).map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), }); output.textures_delta.clear(); let image = image.expect("the overlay uploads a texture"); - image.pixels[image.width() * (image.height() / 2) + image.width() / 2] + let rect = cache.rect; + image.pixels[(32 - rect.y0) as usize * image.width() + (32 - rect.x0) as usize] }; // A white source over the white image: Normal shows white, Difference black. assert_eq!(centre("normal"), Color32::WHITE); @@ -1281,32 +1380,14 @@ fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() { #[test] fn clone_overlay_is_clipped_to_a_shaped_tip() { let ctx = egui::Context::default(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); - app.run("cloneSource.set", json!({"source": [16,16]})).unwrap(); - app.ui.tool = Tool::CloneStamp; - app.session.tools.brush.size = 20.0; + let mut app = clone_overlay_app(20.0); app.session.tools.brush.roundness = 0.25; - let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; - let mut output = ctx.run_ui(Default::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); - draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); - }); - let image = output - .textures_delta - .set - .values() - .flatten() - .find_map(|d| match &d.image { - egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()), - _ => None, - }) - .expect("the overlay uploads a texture"); - output.textures_delta.clear(); - let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a(); - assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source"); - assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped"); - assert_eq!(alpha(1, 10), 255, "the tip's long axis stays"); + let xf = clone_overlay_view(100.0, 1.0); + let mut output = clone_overlay_frame(&ctx, &mut app, &xf); + let alpha = clone_overlay_alpha(&app, &mut output); + assert_eq!(alpha(32, 32), 255, "the tip's centre shows the source"); + assert_eq!(alpha(32, 24), 0, "inside the circle but outside the flat tip: clipped"); + assert!(alpha(25, 32) > 240, "the tip's long axis stays: {}", alpha(25, 32)); } #[test] @@ -1679,12 +1760,20 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); crate::channel_view::ensure(&mut app, &ctx, 0); let mut delta = ctx.tex_manager().write().take_delta(); - let egui::ImageData::Color(image) = &delta.set.get(&tex).unwrap()[0].image; + let upload = &delta.set.get(&tex).unwrap()[0]; + let egui::ImageData::Color(image) = &upload.image; let st = &app.session.documents()[0]; + // Undo redraws only the tiles the stroke changed (#9329): a partial upload of them. + let f = factor as i32; + let pos = upload.pos.expect("undo uploads only the changed tiles, not the whole mask"); + let r = DRect::new(pos[0] as i32 * f, pos[1] as i32 * f, (pos[0] + image.size[0]) as i32 * f, (pos[1] + image.size[1]) as i32 * f) + .intersect(&st.doc.bounds()); + // It covers the stroke's span (x 10..70 along y 30, size 12), so it + // proves coverage and not only values. + assert!(r.contains_rect(&DRect::new(10, 26, 70, 34)), "undo covers the stroke: {r:?}"); assert_eq!( image.pixels, - crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), factor, crate::channel_view::Look::default(), st.active_layer) - .unwrap(), + crate::channel_view::render(&st.doc, &st.channel_view, r, factor, crate::channel_view::Look::default(), st.active_layer).unwrap(), "undo restores the shown mask" ); delta.clear(); @@ -2890,3 +2979,55 @@ fn damage_grows_by_nested_effect_reach() { group.visible = false; assert_eq!(effect_reach(&[group]), 0); } + +#[test] +fn tab_reorder_keeps_floating_documents_in_place() { + let visible = [0, 2]; + assert_eq!(move_target_after_filter(&visible, 0, 1, 3), 2); + assert_eq!(move_target_after_filter(&visible, 2, 0, 3), 0); +} + +#[test] +fn arrange_layout_only_includes_docked_documents() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for name in ["first", "floating", "last"] { + app.run("file.new", json!({"width": 80, "height": 60, "name": name})).unwrap(); + } + app.ui.view.arrange = "threeUpStacked".into(); + crate::menus::invoke(&mut app, &egui::Context::default(), "window.arrange.floatInWindow", json!({"document": 1})).unwrap(); + let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let shown: Vec<_> = arranged_cells(&app, rect).unwrap().into_iter().map(|(doc, _)| doc).collect(); + assert_eq!(shown, vec![0, 2]); + + crate::menus::invoke(&mut app, &egui::Context::default(), "window.arrange.floatInWindow", json!({"document": 0})).unwrap(); + crate::menus::invoke(&mut app, &egui::Context::default(), "window.arrange.floatInWindow", json!({"document": 2})).unwrap(); + assert!(arranged_cells(&app, rect).is_none(), "the main canvas should be blank when every document is floating"); +} + +#[test] +fn torn_off_document_is_not_drawn_again_in_the_main_window() { + for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 60, "name": "floating"})).unwrap(); + app.run("file.new", json!({"width": 80, "height": 60, "name": "docked"})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "window.arrange.floatInWindow", json!({"document": 0})).unwrap(); + assert!(app.ui.windows[0].floating); + assert_eq!(app.session.active_index(), Some(0), "{kind:?}: the floated page remains active"); + assert_eq!(main_canvas_document(&app), Some(1), "{kind:?}: the main canvas picks a docked page instead"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(680.0, 480.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| document_area(app, ui)); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.state_mut().1 = true; + h.run_steps(3); + let tabs = h.state().0.tab_strip.as_ref().map(|s| s.tabs.iter().map(|(doc, _)| *doc).collect::>()); + assert_eq!(tabs, Some(vec![1]), "{kind:?}: only the docked document stays on the main tab strip"); + } +} diff --git a/crates/ui-egui/src/canvas/textures.rs b/crates/ui-egui/src/canvas/textures.rs index 99f9e77096..455f2edb56 100644 --- a/crates/ui-egui/src/canvas/textures.rs +++ b/crates/ui-egui/src/canvas/textures.rs @@ -389,6 +389,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no { return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); } + // Between colours under a text or stop Color Picker (and the document): the target layers' bounds. + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::color_picker_preview::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1) + { + return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); + } // Between hovered fonts (and the document): the text layer's old/new ink bounds. if seen.0 == now.0 && let Some(st) = app.session.documents().get(idx) @@ -466,10 +473,12 @@ pub(super) fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz // The texture is shared by every display: only what it stores counts, not the monitor. let display_key = texture_key(display.as_deref()); let seen = app.canvases.get(&(id, GPU_OUTPUT)).map(|c| (c.revision, c.preview_key)); + let damage_start = crate::gpu_canvas::now_ms(); let mut damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, raw_key), display_key, last_damage)); if let Some(region) = damage { damage = Some(crate::blend_preview::clip_damage(app, id, revision, raw_key, region, blend_visible)); } + app.perf.stage.damage_ms += crate::gpu_canvas::now_ms() - damage_start; // To an adjustment dialog's preview: only what the view shows now (the rest as it moves there). if damage.is_some() && let Some(r) = seen.filter(|s| s.0 == revision).and_then(|s| crate::adjust_preview::switch_region(app, id, revision, s.1 ^ display_key, raw_key)) @@ -551,7 +560,9 @@ pub(super) fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz return false; } let partial = present && damage.is_some(); + let budget_start = crate::gpu_canvas::now_ms(); gpu_budget(app, &gpu, idx, partial, visible); + app.perf.stage.damage_ms += crate::gpu_canvas::now_ms() - budget_start; let defer = crate::background_redraw::enabled(app); let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer); app.perf.gpu_layer_fit = gpu.layer_fit(); @@ -621,7 +632,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: /// Memory Usage and GPU Memory (Preferences › Performance), in bytes. fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { let performance = &app.session.prefs().performance; - (u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) + (u64::from(performance.effective_memory_usage_mb()).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) } /// The document pixels a view shows, with a margin for filtering. Uses all four canvas corners @@ -666,11 +677,23 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { if let Some(gpu) = &app.gpu { // Keep each document's texture plus its preview textures (filter preview, adjustment proxy); // retaining only document ids freed the previews every frame (blank canvas while previewing). - let mut live: Vec = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect(); + let mut live: Vec = app + .session + .documents() + .iter() + .flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62), super::viewport_pages::stream_key(st.doc.id.0)]) + .collect(); live.extend(crate::adjust_preview::gpu_keys(app)); gpu.retain(&live); } crate::background_redraw::retain(app); + // Page work for closed documents (or of a dropped GPU canvas) is cancelled. + if app.gpu.is_none() { + app.streams.clear(); + } else { + let ids: Vec<_> = app.session.documents().iter().map(|st| st.doc.id).collect(); + app.streams.retain(|id, _| ids.contains(id)); + } // Upload markers must not outlive the resources they describe: native reopen can reuse // both the document ID and revision before the next frame while a preview dialog stays open. let documents = app.session.documents(); diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index d193bf6e00..671b507723 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -1,9 +1,10 @@ +use super::cancel_stale_vector_drag; use super::cursors::{alt_eyedropper, sample_eyedropper}; use super::drag::Drag; use super::gesture::{finish_gesture, polygon_click, selection_mode}; use super::selection::finish_selection_drag; use super::strokes::{HOLD_REPEAT_MS, begin_live_stroke, feed_live_stroke, paint_target, snap_aliased_tip, stroke_params, strokes_live}; -use super::{PhotocraftApp, Tool, json}; +use super::{PhotocraftApp, Tool, json, multiple_color_target}; /// Abstract tool event, produced by the mouse or by automation (`ui.pointer`). #[derive(Clone, Copy, Debug, PartialEq)] @@ -191,7 +192,9 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui:: if let ToolEvent::Down { x, y, .. } = raw && let Some((vertical, i)) = command_guide_at(app, app.active_tool(), [x, y], mods) { - app.guide_drag = Some(crate::rulers::GuideDrag::existing(vertical, i, [x, y])); + let mut guide = crate::rulers::GuideDrag::existing(vertical, i, [x, y]); + guide.track([x, y], mods.alt); + app.guide_drag = Some(guide); crate::snap_ui::begin_guide(app, [x, y]); return None; } @@ -214,7 +217,13 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: crate::guide_select::flip(app, vertical, i, [x, y]); return true; } - app.guide_drag = Some(crate::rulers::GuideDrag::existing(vertical, i, [x, y])); + let mut guide = crate::rulers::GuideDrag::existing(vertical, i, [x, y]); + guide.track([x, y], mods.alt); + app.guide_drag = Some(guide); + return true; + } + if multiple_color_target(app) { + app.ui.status = "Move and transforms on several colour channels are not implemented yet".into(); return true; } // Auto-Select (or ⌘-click while it is off) picks the layer under the pointer first @@ -273,14 +282,14 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: } ToolEvent::Move { x, y, .. } => { if let Some(d) = app.guide_drag.as_mut().filter(|d| d.index.is_some()) { - d.pos = if d.vertical { x } else { y }; + d.track([x, y], mods.alt); return true; } } ToolEvent::Up { x, y } => { if let Some(mut d) = app.guide_drag.filter(|d| d.index.is_some()) { app.guide_drag = None; - d.pos = if d.vertical { x } else { y }; + d.track([x, y], mods.alt); crate::rulers::finish_drag(app, d); return true; } @@ -290,19 +299,42 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: false } +fn consumes_cancelled_vector_event(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { + cancel_stale_vector_drag(app); + if app.cancelled_vector_drag { + match ev { + ToolEvent::Down { .. } => app.cancelled_vector_drag = false, + ToolEvent::Move { .. } => return true, + ToolEvent::Up { .. } => { + app.cancelled_vector_drag = false; + return true; + } + } + } + false +} + +fn handle_eyedropper_event(app: &mut PhotocraftApp, tool: Tool, ev: ToolEvent, mods: egui::Modifiers) -> bool { + if tool != Tool::Eyedropper { + return false; + } + match ev { + ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => sample_eyedropper(app, x, y, mods), + ToolEvent::Up { .. } => {} + } + true +} + /// Tool state machine. Shared by mouse input and automation. pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + if consumes_cancelled_vector_event(app, ev) { + return; + } let Some((armed, mods)) = preflight_tool_event(app, ev, mods) else { return }; let Some((raw, ev, mods)) = filter_event_before_tool(app, ev, mods, armed) else { return }; let tool = event_tool(app, mods); - if tool == Tool::Eyedropper { - match ev { - ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => { - sample_eyedropper(app, x, y, mods); - return; - } - ToolEvent::Up { .. } => return, - } + if handle_eyedropper_event(app, tool, ev, mods) { + return; } // A locked layer (the Background without a selection, say): no drag, and Photoshop's message // once the pointer moves (a click says nothing). ⌘ with a selection tool here (#896); the Move @@ -391,6 +423,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { let _ = app.run("layer.select", json!({"layer": id.0})); } + // The component pressed is selected, so the drag moves it (and the others selected). + crate::path_components::press(app, [x, y], mods.shift); } Tool::Pen => { if !(mods.alt && crate::vector_ui::pen_grab_handle(app, x, y)) { @@ -473,7 +507,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // ⌥ flips Dodge/Burn and Blur/Sharpen for this stroke. let tool = crate::retouch_ui::alt_flipped(tool, mods.alt); - app.drag = Some(Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) }); + let mut drag = Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) }; + drag.capture_vector_owners(app); + app.drag = Some(drag); app.trail = None; app.stylus.begin_stroke(); if from.is_some() { diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index fccc2c9751..7e00fd8cb3 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -40,12 +40,6 @@ pub(super) fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_ st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() } -/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but -/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. -pub(super) fn transform_controls_several(app: &PhotocraftApp) -> bool { - transform_controls_layers(app).len() > 1 -} - /// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected /// layers, joined into one. pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { @@ -110,11 +104,9 @@ pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { transform_controls_cursor(r, p).is_some() } -/// Enter the existing Free Transform session when a Move-tool transform control is pressed. +/// Enter the existing Free Transform session when a Move-tool transform control is pressed: on +/// every selected layer, as the frame shows them (#6501). pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { - if transform_controls_several(app) { - return false; - } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; @@ -128,8 +120,8 @@ pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::C } } -/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles -/// for one layer, and the outline of every selected shape layer, as in Photoshop. +/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles, +/// and the outline of every selected shape layer, as in Photoshop. pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; @@ -140,9 +132,6 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P } } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); - if transform_controls_several(app) { - return; - } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); diff --git a/crates/ui-egui/src/canvas/transform_controls_tests.rs b/crates/ui-egui/src/canvas/transform_controls_tests.rs index 957ae3589d..19fd52f080 100644 --- a/crates/ui-egui/src/canvas/transform_controls_tests.rs +++ b/crates/ui-egui/src/canvas/transform_controls_tests.rs @@ -115,7 +115,7 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; - assert!(transform_controls_several(&app), "two layers framed"); + assert_eq!(transform_controls_layers(&app).len(), 2, "two layers framed"); app.session.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap(); assert!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some()); let xf = @@ -126,34 +126,36 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() { } #[test] -fn several_selected_layers_share_one_frame_without_handles() { - // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform - // works on one layer, so the shared frame doesn't start it. +fn several_selected_layers_share_one_frame_that_transforms_them_all() { + // As in Photoshop: Show Transform Controls frames every selected layer, and its handles + // start one Free Transform of all of them (#6501). let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); let a = app.session.active().unwrap().active_layer.unwrap(); app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); app.sync_views(); app.ui.tool = Tool::Move; app.ui.tool_options.move_show_transform = true; let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; let one = transform_controls_rect(&app, &xf).unwrap(); - assert!(!transform_controls_several(&app)); app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); - assert!(transform_controls_several(&app)); let both = transform_controls_rect(&app, &xf).unwrap(); assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); - let ctx = egui::Context::default(); - assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); - assert!(app.ui.transform.is_none()); // The Background never joins the frame. let bg = app.session.active().unwrap().doc.layers[0].id; app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); assert_eq!(transform_controls_layers(&app).len(), 2); + assert_eq!(transform_controls_rect(&app, &xf), Some(both)); + let ctx = egui::Context::default(); + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + let t = app.ui.transform.as_ref().expect("the shared frame's handle starts Free Transform"); + assert_eq!(t.rect, [20.0, 30.0, 150.0, 150.0]); + assert_eq!(t.layers, [a.0, b.0]); } #[test] diff --git a/crates/ui-egui/src/canvas/viewport_pages.rs b/crates/ui-egui/src/canvas/viewport_pages.rs new file mode 100644 index 0000000000..07933ede30 --- /dev/null +++ b/crates/ui-egui/src/canvas/viewport_pages.rs @@ -0,0 +1,1048 @@ +//! Viewport pages for a document whose display textures don't fit the GPU canvas budget +//! ([`super::gpu_canvas::GpuCanvas::fits_canvas`], from the detected RAM and VRAM, so also with +//! GPU Memory on Auto). Instead of the whole-document texture set, the view's pages are rendered +//! on two background workers at the view's reduction and uploaded as their own GPU textures, +//! under [`stream_key`]: the work and the GPU memory follow what is on screen, not the document. +//! +//! Documents that fit keep the texture set and everything around it (the layer budget, deferred +//! refresh, background redraw). Without a GPU canvas the CPU canvas draws every document. +//! +//! Pages are derived data: an edit marks the pages its damage reaches dirty (the rest stay) and, +//! for a small one, re-renders just the texels it reaches into the page held; a +//! dirty page stays on screen until its replacement is uploaded, and work for pages that are no +//! longer wanted (a pan, a zoom, an edit) is cancelled. + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; + +use photocraft_doc::Document; +use photocraft_engine::display_color::CanvasDisplay; +use photocraft_geom::Rect as DRect; + +use super::textures::texture_buffer; +use super::{ViewXform, canvas_display, damage_since, display_doc, effect_reach, texture_key, visible_doc_rect}; +use crate::PhotocraftApp; + +/// Document pixels per page side at the page's reduction. +pub(crate) const PAGE_SIDE: u32 = 512; +/// The most pages a view asks for: coarser pages are used beyond it. +const MAX_PAGES: usize = 256; +/// Pages rendering at once, over all views and documents. +const MAX_JOBS: usize = 2; +/// Pages uploaded per frame, and the texel bytes they may take: staging stays bounded however +/// many pages finish at once. +const UPLOADS_PER_FRAME: usize = 2; +const UPLOAD_BYTES_PER_FRAME: u64 = 32 << 20; +/// The coarsest reduction (document pixels per texel). +const MAX_FACTOR: u32 = 1 << 16; + +static ACTIVE: AtomicUsize = AtomicUsize::new(0); + +/// The GPU canvas key of `doc`'s pages, apart from its texture set and the preview keys (bits 58 +/// to 63 tag those: the filter preview, adjustment proxies, gradients and thumbnails). +pub(crate) fn stream_key(doc: u64) -> u64 { + doc ^ (1u64 << 57) +} + +/// A page: `factor` document pixels per texel, column `x` and row `y` of `PAGE_SIDE` texels. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct PageId { + pub factor: u32, + pub x: u32, + pub y: u32, +} + +impl PageId { + /// The GPU canvas page id. + pub fn id(self) -> u64 { + (u64::from(self.factor.trailing_zeros()) << 56) | (u64::from(self.y) << 28) | u64::from(self.x) + } + + /// The document pixels this page draws. + pub fn core(self, size: [u32; 2]) -> DRect { + let side = u64::from(PAGE_SIDE) * u64::from(self.factor); + let (x, y) = (u64::from(self.x) * side, u64::from(self.y) * side); + if self.factor == 0 || x >= u64::from(size[0]) || y >= u64::from(size[1]) { + return DRect::EMPTY; + } + let end = |o: u64, n: u32| (o + side).min(u64::from(n)) as i32; + DRect::new(x as i32, y as i32, end(x, size[0]), end(y, size[1])) + } + + /// The document pixels it is rendered from: the core and one texel around it, so filtering + /// at its edge matches its neighbour's. + pub fn source(self, size: [u32; 2]) -> DRect { + let core = self.core(size); + if core.is_empty() { + return core; + } + core.inflate(self.factor as i32).intersect(&DRect::new(0, 0, size[0] as i32, size[1] as i32)) + } +} + +/// The page reduction for `zoom` (screen pixels per document pixel): the largest power of two at +/// which a texel still covers at most one screen pixel. +pub(crate) fn factor(zoom: f32) -> u32 { + if !zoom.is_finite() || zoom <= 0.0 { + return 1; + } + let mut k = 1u32; + while k < MAX_FACTOR && zoom * (k * 2) as f32 <= 1.0 { + k *= 2; + } + k +} + +/// The part of `page` a change to `damage` reaches, widened to whole texels: rendered from just +/// that area, its texels are the ones the whole page has (reductions are globally aligned). +fn partial_region(page: PageId, size: [u32; 2], damage: DRect) -> DRect { + let k = page.factor.max(1) as i32; + let source = page.source(size); + let d = damage.intersect(&source); + if d.is_empty() { + return DRect::EMPTY; + } + let up = |v: i32| v.div_euclid(k).saturating_add(i32::from(v.rem_euclid(k) != 0)).saturating_mul(k); + DRect::new(d.x0.div_euclid(k) * k, d.y0.div_euclid(k) * k, up(d.x1), up(d.y1)).intersect(&source) +} + +/// The pages covering `visible` of a `size` document at `zoom`, centre first: at most +/// `max_pages` (coarser when the view needs more). +pub(crate) fn pages(size: [u32; 2], visible: DRect, zoom: f32, max_pages: usize) -> Vec { + let visible = visible.intersect(&DRect::new(0, 0, size[0].min(i32::MAX as u32) as i32, size[1].min(i32::MAX as u32) as i32)); + if visible.is_empty() || max_pages == 0 { + return Vec::new(); + } + let mut k = factor(zoom); + loop { + let side = u64::from(PAGE_SIDE) * u64::from(k); + let span = |a: i32, b: i32| (a as u64 / side, (b as u64).div_ceil(side)); + let ((x0, x1), (y0, y1)) = (span(visible.x0, visible.x1), span(visible.y0, visible.y1)); + if (x1 - x0) * (y1 - y0) <= max_pages.min(MAX_PAGES) as u64 { + let mut out: Vec = (y0..y1).flat_map(|y| (x0..x1).map(move |x| PageId { factor: k, x: x as u32, y: y as u32 })).collect(); + let (cx, cy) = ((x0 + x1) as i64, (y0 + y1) as i64); + out.sort_by_key(|p| (i64::from(p.x) * 2 + 1 - cx).abs() + (i64::from(p.y) * 2 + 1 - cy).abs()); + return out; + } + if k >= MAX_FACTOR { + return Vec::new(); + } + k *= 2; + } +} + +/// What a stream's pages show: the document revision, its preview and display keys, its size. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Identity { + pub revision: u64, + /// The raw preview key ([`display_doc`]); `display` is folded in for [`damage_since`]. + pub preview: u64, + pub display: u64, + pub size: [u32; 2], +} + +/// A page on the GPU. +#[derive(Debug)] +struct Page { + /// Rendered before the latest edit that reached it: shown until its replacement arrives. + dirty: bool, + /// The exact reduction (not the quick preview). + exact: bool, + /// While dirty, the only document area that changed since it was uploaded (aligned to its + /// texels): just that part is rendered again and written into the page. `None`: all of it. + partial: Option, + /// Bumped whenever the page's content is invalidated: a result from older work is dropped. + epoch: u64, + touched: u64, + bytes: u64, +} + +struct Job { + page: PageId, + epoch: u64, + cancel: Arc, +} + +/// A finished page render. +pub(crate) struct Done { + pub page: PageId, + pub epoch: u64, + pub exact: bool, + /// Rendered for [`Page::partial`]: written into the page held, not a page of its own. + pub region: Option, + pub result: Result, +} + +/// The counters `inspect` reports. +#[derive(Clone, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Stats { + pub wanted: usize, + pub ready: usize, + pub resident: usize, + pub resident_bytes: u64, + pub limit: u64, + pub jobs: usize, + pub rendered: u64, + pub uploads: u64, + pub cancelled: u64, + pub dirtied: u64, + /// Pages updated in part, by re-rendering only what an edit reached. + pub partial: u64, + pub evicted: u64, + pub last_error: Option, +} + +/// One document's viewport pages ([`PhotocraftApp::streams`]). +pub(crate) struct Stream { + pages: HashMap, + /// Epochs of pages not (or no longer) on the GPU, so their results can still be checked. + epochs: HashMap, + jobs: Vec, + ready: Vec, + failed: HashSet<(PageId, u64)>, + tx: mpsc::Sender, + rx: mpsc::Receiver, + pub seen: Option, + clock: u64, + /// The page ids last given to the GPU canvas, to tell it only of changes. + synced: Vec, + health: (u64, u64), + /// Each window showing the document (New Window for Document): the clock of its last frame + /// and the pages it wanted, so one window's frame doesn't cancel, drop or evict the pages + /// another window still shows. + views: HashMap)>, + pub stats: Stats, +} + +impl Default for Stream { + fn default() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + pages: HashMap::new(), + epochs: HashMap::new(), + jobs: Vec::new(), + ready: Vec::new(), + failed: HashSet::new(), + tx, + rx, + seen: None, + clock: 0, + synced: Vec::new(), + health: (0, 0), + views: HashMap::new(), + stats: Stats::default(), + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + for j in &self.jobs { + j.cancel.store(true, Ordering::Relaxed); + } + } +} + +impl Stream { + fn epoch(&self, page: PageId) -> u64 { + self.pages.get(&page).map_or_else(|| self.epochs.get(&page).copied().unwrap_or(0), |p| p.epoch) + } + + fn bump(&mut self, page: PageId) { + let e = self.epoch(page) + 1; + match self.pages.get_mut(&page) { + Some(p) => p.epoch = e, + None => { + self.epochs.insert(page, e); + } + } + } + + /// The document changed to `now`: the pages whose source `damage` reaches (every page + /// without it) are dirty, and work on them is cancelled. Nothing else is scanned: the cost + /// is the pages held, not the document's layers or tiles. + pub fn invalidate(&mut self, now: Identity, damage: Option) { + let size_changed = self.seen.is_some_and(|s| s.size != now.size); + self.seen = Some(now); + if size_changed { + self.reset(); + return; + } + let hit = |p: PageId| damage.is_none_or(|d| !p.source(now.size).intersect(&d).is_empty()); + let pages: Vec = self.pages.keys().copied().filter(|p| hit(*p)).collect(); + for p in &pages { + if let Some(page) = self.pages.get_mut(p) { + // An exact page that is current, or only partly stale, takes the new damage too. + let partial = damage.filter(|_| page.exact && (!page.dirty || page.partial.is_some())).and_then(|d| { + let r = page.partial.unwrap_or(DRect::EMPTY).union(&partial_region(*p, now.size, d)); + let source = p.source(now.size); + // Most of the page: rendering it whole is no slower. + (r.width() as u64 * r.height() as u64 * 2 <= source.width() as u64 * source.height() as u64).then_some(r) + }); + page.dirty = true; + page.exact &= partial.is_some(); + page.partial = partial; + } + self.bump(*p); + } + self.stats.dirtied += pages.len() as u64; + let jobs: Vec = self.jobs.iter().map(|j| j.page).filter(|p| hit(*p)).collect(); + for p in jobs { + if !self.pages.contains_key(&p) { + self.bump(p); + } + } + self.cancel_stale(); + self.ready.retain(|d| !hit(d.page)); + self.failed.retain(|(p, _)| !hit(*p)); + } + + /// Forget every page (the GPU canvas lost them, or the document changed size). + pub fn reset(&mut self) { + let pages: Vec = self.pages.keys().chain(self.jobs.iter().map(|j| &j.page)).copied().collect(); + for p in pages { + let e = self.epoch(p) + 1; + self.epochs.insert(p, e); + } + self.pages.clear(); + self.ready.clear(); + self.failed.clear(); + self.synced.clear(); + self.cancel_stale(); + } + + /// Cancel jobs whose page moved to a newer epoch. + fn cancel_stale(&mut self) { + for j in &self.jobs { + if j.epoch != self.epoch(j.page) && !j.cancel.swap(true, Ordering::Relaxed) { + self.stats.cancelled += 1; + } + } + } + + /// Window `view` is about to show `wanted` (call before [`Self::update`] in its frame). + pub fn view(&mut self, view: u64, wanted: &[PageId]) { + self.views.insert(view, (self.clock + 1, wanted.to_vec())); + } + + /// Whether a window other than the one updating shows `page`: one whose frame was at most + /// [`VIEW_TTL`] updates ago (a closed window's pages are released after that). + fn shown_elsewhere(&self, page: &PageId) -> bool { + self.views.values().any(|(at, pages)| *at != self.clock && *at + VIEW_TTL >= self.clock && pages.contains(page)) + } + + /// Take finished work, cancel work for pages not `wanted`, and start work for wanted pages + /// that are missing, dirty or still a quick preview (`faithful`: the document has a quick + /// preview at reductions), as `spawn(page, epoch, exact, cancel, region)` allows: `region`, the + /// part of a held page to render again ([`Page::partial`]), or the whole page. + pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc, Option) -> bool) { + self.clock += 1; + let clock = self.clock; + self.views.retain(|_, (at, _)| *at + VIEW_TTL >= clock); + // What any window shows: work and results for another window's pages are kept. + let want: HashSet = + wanted.iter().copied().chain(self.views.values().filter(|(at, _)| *at != clock).flat_map(|(_, pages)| pages.iter().copied())).collect(); + while let Ok(d) = self.rx.try_recv() { + let Some(i) = self.jobs.iter().position(|j| j.page == d.page && j.epoch == d.epoch) else { continue }; + // Cancelled work ends in an interruption, not a failure: the page is asked for again. + let cancelled = self.jobs.swap_remove(i).cancel.load(Ordering::Relaxed); + if cancelled || d.epoch != self.epoch(d.page) || !want.contains(&d.page) { + continue; + } + // A part of a page no longer held (evicted meanwhile): the whole page is asked for. + if d.region.is_some() && !self.pages.get(&d.page).is_some_and(|q| q.dirty && q.partial == d.region) { + continue; + } + match &d.result { + Ok(_) => { + self.stats.rendered += 1; + self.ready.retain(|r| r.page != d.page); + self.ready.push(d); + } + Err(e) => { + log::warn!("viewport page {:?}: {e}", d.page); + self.stats.last_error = Some(e.clone()); + self.failed.insert((d.page, d.epoch)); + } + } + } + for j in &self.jobs { + if !want.contains(&j.page) && !j.cancel.swap(true, Ordering::Relaxed) { + self.stats.cancelled += 1; + } + } + self.ready.retain(|d| want.contains(&d.page)); + for p in wanted { + if let Some(page) = self.pages.get_mut(p) { + page.touched = self.clock; + } + } + let current = |s: &Self, p: &PageId| s.pages.get(p).is_some_and(|q| !q.dirty) || s.ready.iter().any(|d| d.page == *p); + let all_shown = wanted.iter().all(|p| current(self, p)); + for p in wanted { + let live = self.jobs.iter().filter(|j| !j.cancel.load(Ordering::Relaxed)).count(); + if live >= MAX_JOBS { + break; + } + let epoch = self.epoch(*p); + if self.jobs.iter().any(|j| j.page == *p && j.epoch == epoch) || self.failed.contains(&(*p, epoch)) { + continue; + } + // First every page in view (a quick preview at a reduction), then the exact ones. + let exact = p.factor == 1 || !faithful || all_shown; + let have = self.ready.iter().find(|d| d.page == *p).map(|d| d.exact).or_else(|| self.pages.get(p).filter(|q| !q.dirty).map(|q| q.exact)); + if have.is_some_and(|e| e || !exact) { + continue; + } + let region = self.pages.get(p).filter(|q| q.dirty).and_then(|q| q.partial); + let cancel = Arc::new(AtomicBool::new(false)); + if !spawn(*p, epoch, exact || region.is_some(), cancel.clone(), region) { + break; + } + self.jobs.push(Job { page: *p, epoch, cancel }); + } + self.stats.wanted = wanted.len(); + self.stats.ready = wanted.iter().filter(|p| self.pages.contains_key(p)).count(); + self.stats.jobs = self.jobs.len(); + } + + /// Retry failed pages once the scratch storage or memory configuration changed (`health`). + pub fn retry_after(&mut self, health: (u64, u64)) { + if health != self.health { + self.health = health; + self.failed.clear(); + } + } + + /// Whether a wanted page can't be shown: its render or upload failed and nothing current of + /// it is held, so the view would have a hole, or keep showing the page from before an edit + /// (the CPU canvas draws instead). + pub fn stuck(&self, wanted: &[PageId]) -> bool { + wanted.iter().any(|p| self.pages.get(p).is_none_or(|q| q.dirty) && self.failed.contains(&(*p, self.epoch(*p)))) + } + + /// Whether work is outstanding (the view repaints until it is done). + pub fn busy(&self) -> bool { + !self.jobs.is_empty() || !self.ready.is_empty() + } + + /// The finished pages to upload this frame: at most [`UPLOADS_PER_FRAME`], and + /// [`UPLOAD_BYTES_PER_FRAME`] of texels (`texel_bytes` each), the first always. + pub fn take_uploads(&mut self, wanted: &[PageId], texel_bytes: u64) -> Vec { + let order = |d: &Done| wanted.iter().position(|p| *p == d.page).unwrap_or(usize::MAX); + self.ready.sort_by_key(order); + let mut out = Vec::new(); + let mut bytes = 0u64; + while out.len() < UPLOADS_PER_FRAME && !self.ready.is_empty() { + let b = self.ready[0].result.as_ref().map_or(0, |b| b.px.len() as u64 * texel_bytes); + if !out.is_empty() && bytes + b > UPLOAD_BYTES_PER_FRAME { + break; + } + bytes += b; + out.push(self.ready.remove(0)); + } + out + } + + /// Make room for `bytes` more under `limit` by evicting the least recently wanted pages not + /// in `wanted` (another window's last: they are only shown there): whether any was evicted, + /// or `None` when the wanted pages leave no room. + pub fn make_room(&mut self, page: PageId, bytes: u64, limit: u64, wanted: &[PageId]) -> Option { + let held = |s: &Self| s.pages.iter().filter(|(p, _)| **p != page).map(|(_, q)| q.bytes).sum::(); + let mut evicted = false; + while held(self) + bytes > limit { + let cold = self + .pages + .iter() + .filter(|(p, _)| **p != page && !wanted.contains(p)) + .min_by_key(|(p, q)| (self.shown_elsewhere(p), q.touched)) + .map(|(p, _)| *p)?; + self.drop_page(cold); + self.stats.evicted += 1; + evicted = true; + } + Some(evicted) + } + + /// The GPU page ids of the pages held. + pub fn ids(&self) -> Vec { + self.pages.keys().map(|p| p.id()).collect() + } + + fn drop_page(&mut self, p: PageId) { + if let Some(q) = self.pages.remove(&p) { + self.epochs.insert(p, q.epoch); + } + } + + /// `done` is on the GPU (`ok`), or couldn't be stored. + pub fn uploaded(&mut self, done: &Done, bytes: u64, ok: bool) { + if !ok { + self.stats.last_error = Some("the GPU canvas couldn't store a viewport page".into()); + self.failed.insert((done.page, done.epoch)); + return; + } + self.stats.uploads += 1; + self.epochs.remove(&done.page); + if done.region.is_some() + && let Some(page) = self.pages.get_mut(&done.page) + { + (page.dirty, page.partial, page.epoch, page.touched) = (false, None, done.epoch, self.clock); + self.stats.partial += 1; + return; + } + self.pages.insert(done.page, Page { dirty: false, exact: done.exact, partial: None, epoch: done.epoch, touched: self.clock, bytes }); + } + + /// The part of `page` written by `done` couldn't be stored: drop the page, so it is + /// rendered whole. + pub fn partial_failed(&mut self, page: PageId) { + self.drop_page(page); + } + + /// Drop pages no longer worth keeping: once every wanted page is shown and current, the + /// dirty ones and the ones finer than the view (they would draw over it). Coarser clean + /// pages stay (under the view's own, for a quick pan or zoom out) until evicted. The GPU + /// page ids to keep when they changed since the last call. + pub fn prune(&mut self, wanted: &[PageId]) -> Option> { + let all_current = wanted.iter().all(|p| self.pages.get(p).is_some_and(|q| !q.dirty)); + if all_current && let Some(f) = wanted.first().map(|p| p.factor) { + let drop: Vec = + self.pages.iter().filter(|(p, q)| !wanted.contains(p) && !self.shown_elsewhere(p) && (q.dirty || p.factor < f)).map(|(p, _)| *p).collect(); + for p in drop { + self.drop_page(p); + } + } + // Epochs only guard results still on their way. + let jobs: HashSet = self.jobs.iter().map(|j| j.page).collect(); + self.epochs.retain(|p, _| jobs.contains(p)); + let mut ids = self.ids(); + ids.sort_unstable(); + self.stats.resident = ids.len(); + self.stats.resident_bytes = self.pages.values().map(|q| q.bytes).sum(); + (ids != self.synced).then(|| { + self.synced = ids.clone(); + ids + }) + } + + /// The pages this stream believes are on the GPU. + pub fn resident(&self) -> usize { + self.pages.len() + } +} + +/// How many stream updates a window's pages stay protected after its last frame. +const VIEW_TTL: u64 = 64; + +/// Render `page` of `doc` on a worker, if one of the [`MAX_JOBS`] is free. +fn spawn( + ctx: &egui::Context, + tx: mpsc::Sender, + doc: Arc, + display: Option>, + (page, epoch, exact, region): (PageId, u64, bool, Option), + cancel: Arc, +) -> bool { + if ACTIVE.fetch_add(1, Ordering::AcqRel) >= MAX_JOBS { + ACTIVE.fetch_sub(1, Ordering::AcqRel); + return false; + } + struct Slot(egui::Context); + impl Drop for Slot { + fn drop(&mut self) { + ACTIVE.fetch_sub(1, Ordering::AcqRel); + // A view waiting for a free worker (another document's) can start now. + self.0.request_repaint(); + } + } + let slot = Slot(ctx.clone()); + let work = move || { + let _slot = slot; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&doc, page, exact, region, display.as_deref(), &cancel))) + .unwrap_or_else(|_| Err("viewport page render panicked".into())); + let _ = tx.send(Done { page, epoch, exact, region, result }); + }; + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::Builder::new().name("pc-viewport-page".into()).spawn(work).is_ok() + } + #[cfg(target_arch = "wasm32")] + { + work(); + true + } +} + +/// One page as the GPU canvas stores it (encoded for the display like the texture set). +fn render( + doc: &Document, + page: PageId, + exact: bool, + region: Option, + display: Option<&CanvasDisplay>, + cancel: &AtomicBool, +) -> Result { + let size = [doc.size.width, doc.size.height]; + let source = region.unwrap_or_else(|| page.source(size)); + let rows = u64::from(source.height().min(128)); + let _working = photocraft_raster::memory::try_reserve(u64::from(source.width()) * rows * 16 + (16 << 20))?; + let before = photocraft_raster::spill::read_error_generation(); + let check = || cancel.load(Ordering::Relaxed); + let i = photocraft_raster::Interrupt::cancel_only(&check); + let buf = if exact { + photocraft_compose::viewport::render_page(doc, source, page.factor, i)? + } else { + photocraft_compose::viewport::preview_page(doc, source, page.factor, i)? + }; + i.check().map_err(|e| e.to_string())?; + photocraft_raster::spill::check_since(before)?; + Ok(texture_buffer(display, &buf).into_owned()) +} + +/// Draw document `idx` from viewport pages when its texture set doesn't fit the GPU canvas +/// budget: true when the view should paint [`stream_key`] with the GPU canvas. +pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: &ViewXform, zoom: f32) -> bool { + let Some(gpu) = app.gpu.clone() else { return false }; + if app.ui.view.pattern_preview || gpu.fault().is_some() { + return false; + } + let Some((revision, last_damage, id, size)) = app + .session + .documents() + .get(idx) + .map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id, st.doc.size)) + else { + return false; + }; + let size = [size.width, size.height]; + let (doc, raw_key) = display_doc(app, idx); + // A size-changing preview draws as main does; a document that fits keeps its texture set. + if [doc.size.width, doc.size.height] != size || gpu.fits_canvas(&doc) { + if let Some(s) = app.streams.remove(&id) { + drop(s); + gpu.retain_pages(stream_key(id.0), &[]); + } + return false; + } + let key = stream_key(id.0); + // Half the document's share of the canvas budget: the rest is headroom for what the + // compositor needs. + let limit = gpu.canvas_fit(&doc).map_or(0, |(_, share)| share) / 2; + let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1); + let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX); + // No room for a single page: the CPU canvas draws the document, as main does for every + // document over the budget, and the pages and work held for it are freed. + if max_pages == 0 { + if let Some(s) = app.streams.remove(&id) { + drop(s); + gpu.retain_pages(key, &[]); + } + return false; + } + let (display, _) = canvas_display(app, &doc, None); + let display_key = texture_key(display.as_deref()); + let now = Identity { revision, preview: raw_key, display: display_key, size }; + let seen = app.streams.get(&id).and_then(|s| s.seen); + if seen != Some(now) { + let damage = seen + .filter(|s| s.size == size && s.display == display_key) + .and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage)); + app.streams.entry(id).or_default().invalidate(now, damage); + } + let wanted = pages(size, visible_doc_rect(xf), zoom, max_pages); + if gpu.texture_info(id.0).is_some() { + // Switching from the texture set: free it, the pages are budgeted against that memory. + gpu.release_texture_set(id.0); + } + let stream = app.streams.entry(id).or_default(); + stream.retry_after((photocraft_raster::spill::read_error_generation(), photocraft_raster::memory::configuration_generation())); + // A page that failed: the CPU canvas draws the document until it is retried. + if stream.stuck(&wanted) { + return false; + } + let faithful = photocraft_compose::proxy::proxy_faithful(&doc); + let texel_bytes = gpu.page_texture_bytes(doc.depth, size, 1, 1); + // The GPU canvas dropped the pages (released, or pruned with a closed document): start over. + if stream.resident() > 0 && gpu.pages(key).0.len() != stream.resident() { + stream.reset(); + } + // Drawn from pages: no texture set, so no layer budget or background redraw applies. + crate::background_redraw::cancel(app, id); + if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) { + app.perf.gpu_layer_fit = None; + } + app.navigator_gpu.remove(&id); + crate::render_path::note(app, id.0, crate::render_path::Path::Gpu { full: true }); + let stream = app.streams.entry(id).or_default(); + stream.view(ctx.viewport_id().0.value(), &wanted); + let tx = stream.tx.clone(); + stream.update(&wanted, faithful, |page, epoch, exact, cancel, region| { + spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact, region), cancel) + }); + for d in stream.take_uploads(&wanted, texel_bytes) { + let Ok(buf) = &d.result else { continue }; + if d.region.is_some() { + if gpu.update_page(key, d.page.id(), buf) { + stream.uploaded(&d, 0, true); + } else { + stream.partial_failed(d.page); + gpu.retain_pages(key, &stream.ids()); + } + continue; + } + let bytes = buf.px.len() as u64 * texel_bytes; + let ok = match stream.make_room(d.page, bytes, limit, &wanted) { + None => false, + Some(evicted) => { + if evicted { + gpu.retain_pages(key, &stream.ids()); + } + gpu.upload_page(crate::gpu_canvas::PageUpload { + key, + page: d.page.id(), + size, + depth: doc.depth, + factor: d.page.factor, + core: d.page.core(size), + buf, + }) + } + }; + stream.uploaded(&d, bytes, ok); + } + if let Some(ids) = stream.prune(&wanted) { + gpu.retain_pages(key, &ids); + } + stream.stats.limit = limit; + if stream.busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIZE: [u32; 2] = [20_000, 12_000]; + + fn id(revision: u64) -> Identity { + Identity { revision, preview: 1, display: 2, size: SIZE } + } + + fn page(factor: u32, x: u32, y: u32) -> PageId { + PageId { factor, x, y } + } + + fn done(p: PageId, epoch: u64, exact: bool, side: i32) -> Done { + let rect = DRect::new(0, 0, side, side); + Done { page: p, epoch, exact, region: None, result: Ok(photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; (side * side) as usize] }) } + } + + /// A stream holding `held` clean, exact pages. + fn holding(held: &[PageId]) -> Stream { + let mut s = Stream::default(); + s.invalidate(id(1), None); + for p in held { + s.uploaded(&done(*p, s.epoch(*p), true, 4), 100, true); + } + s + } + + /// Spawns every job it is offered, recording it with its cancel flag. + fn recorder(log: &mut Vec<(PageId, u64, bool, Arc)>) -> impl FnMut(PageId, u64, bool, Arc, Option) -> bool + '_ { + |p, e, x, c, _| { + log.push((p, e, x, c)); + true + } + } + + #[test] + fn factor_keeps_a_texel_at_most_one_screen_pixel() { + assert_eq!(factor(4.0), 1); + assert_eq!(factor(1.0), 1); + assert_eq!(factor(0.5), 2); + assert_eq!(factor(0.3), 2); + assert_eq!(factor(0.25), 4); + assert_eq!(factor(0.0), 1); + assert_eq!(factor(f32::NAN), 1); + assert_eq!(factor(1e-9), MAX_FACTOR); + } + + #[test] + fn pages_cover_the_view_centre_first_and_coarsen_past_the_limit() { + let visible = DRect::new(1000, 1000, 2500, 2000); + let got = pages(SIZE, visible, 1.0, 64); + // Columns 1..=4, rows 1..=3 of 512 px pages. + assert_eq!(got.len(), 4 * 3); + assert!(got.iter().all(|p| p.factor == 1)); + let union = got.iter().fold(DRect::EMPTY, |u, p| u.union(&p.core(SIZE))); + assert_eq!(union.intersect(&visible), visible, "every visible pixel is on a page"); + let centre = got[0].core(SIZE); + assert!(centre.inflate(512).contains(1750, 1500), "centre first: {:?}", got[0]); + // The whole document at 1:1 is 40×24 pages: over the limit it uses a reduction. + let whole = DRect::new(0, 0, SIZE[0] as i32, SIZE[1] as i32); + let coarse = pages(SIZE, whole, 1.0, 64); + assert!(!coarse.is_empty() && coarse.len() <= 64); + assert!(coarse.iter().all(|p| p.factor == 4), "{:?}", coarse[0]); + assert!(pages(SIZE, whole, 1.0, 0).is_empty()); + assert!(pages(SIZE, DRect::new(-500, -500, -1, -1), 1.0, 64).is_empty(), "off the document"); + } + + #[test] + fn a_page_core_tiles_the_document_and_its_source_adds_one_texel() { + let p = page(2, 1, 0); + assert_eq!(p.core(SIZE), DRect::new(1024, 0, 2048, 1024)); + assert_eq!(p.source(SIZE), DRect::new(1022, 0, 2050, 1026)); + // The last column is clipped to the document. + let last = page(1, SIZE[0] / 512, 0); + assert_eq!(last.core(SIZE).x1, SIZE[0] as i32); + assert!(page(1, 1000, 0).core(SIZE).is_empty()); + // Ids differ across factors and positions. + assert_ne!(page(1, 1, 0).id(), page(2, 1, 0).id()); + assert_ne!(page(1, 1, 0).id(), page(1, 0, 1).id()); + } + + #[test] + fn an_edit_dirties_only_the_pages_it_reaches() { + let (a, b, c) = (page(1, 0, 0), page(1, 1, 0), page(1, 4, 4)); + let mut s = holding(&[a, b, c]); + // Damage on page a's right edge also reaches b's one-texel apron. + s.invalidate(id(2), Some(DRect::new(500, 10, 512, 20))); + assert!(s.pages[&a].dirty && s.pages[&b].dirty); + assert!(!s.pages[&c].dirty && s.pages[&c].exact, "far page untouched"); + assert_eq!(s.stats.dirtied, 2); + // A dirty page stays on screen until replaced; no damage known dirties every page. + s.invalidate(id(3), None); + assert_eq!(s.pages.len(), 3); + assert!(s.pages.values().all(|p| p.dirty)); + // A new size starts over. + s.invalidate(Identity { size: [100, 100], ..id(4) }, None); + assert_eq!(s.resident(), 0); + } + + #[test] + fn pan_zoom_and_edits_cancel_obsolete_work() { + let mut s = holding(&[]); + let mut log = Vec::new(); + let view = [page(1, 0, 0), page(1, 1, 0), page(1, 2, 0)]; + s.update(&view, true, recorder(&mut log)); + assert_eq!(log.len(), MAX_JOBS, "bounded workers"); + assert!(log.iter().all(|j| j.2), "1:1 pages are rendered exactly"); + // Pan: the first page is no longer wanted. + s.update(&view[1..], true, recorder(&mut Vec::new())); + assert!(log[0].3.load(Ordering::Relaxed), "work for a page out of view is cancelled"); + assert!(!log[1].3.load(Ordering::Relaxed)); + // Edit on the second page: its result would be stale. + s.invalidate(id(2), Some(DRect::new(600, 0, 601, 1))); + assert!(log[1].3.load(Ordering::Relaxed), "work on an edited page is cancelled"); + // Its old result is dropped when it arrives, and so is a cancelled job's interruption. + s.tx.send(Done { page: log[0].0, epoch: log[0].1, exact: true, region: None, result: Err("interrupted".into()) }).unwrap(); + s.tx.send(done(log[1].0, log[1].1, true, 4)).unwrap(); + s.update(&view[1..], true, |_, _, _, _, _| false); + assert!(s.ready.is_empty(), "stale result dropped"); + assert!(!s.stuck(&view) && s.stats.last_error.is_none(), "a cancellation is not a failure"); + // Zoom: every page changes factor. + let mut log2 = Vec::new(); + s.update(&[page(1, 1, 0)], true, recorder(&mut log2)); + let jobs = s.jobs.len(); + s.update(&[page(2, 0, 0)], true, |_, _, _, _, _| true); + assert!(log2.iter().all(|j| j.3.load(Ordering::Relaxed)), "zoom cancels the old factor's work"); + assert!(s.stats.cancelled >= 3 && jobs > 0); + } + + #[test] + fn reductions_show_a_quick_preview_before_the_exact_page() { + let mut s = holding(&[]); + let view = [page(2, 0, 0)]; + let mut log = Vec::new(); + s.update(&view, true, recorder(&mut log)); + assert_eq!(log.len(), 1); + assert!(!log[0].2, "a faithful document previews first"); + s.tx.send(done(view[0], log[0].1, false, 4)).unwrap(); + let mut log2 = Vec::new(); + s.update(&view, true, recorder(&mut log2)); + assert!(log2.len() == 1 && log2[0].2, "then the exact page, once every page is shown"); + let mut log3 = Vec::new(); + holding(&[]).update(&view, false, recorder(&mut log3)); + assert!(log3[0].2, "an unfaithful preview is skipped"); + } + + #[test] + fn a_small_edit_re_renders_only_the_texels_it_reaches() { + let (a, b) = (page(4, 1, 1), page(4, 3, 3)); + let mut s = holding(&[a, b]); + // Page a: core 2048..4096 at 4 px per texel, source one texel more. + s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111))); + assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2112, 2112)), "whole texels"); + assert!(s.pages[&a].dirty && s.pages[&a].exact); + // More damage on a dirty page adds to its part. + s.invalidate(id(3), Some(DRect::new(2200, 2100, 2204, 2104))); + assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2204, 2112))); + let mut regions = Vec::new(); + s.update(&[a], true, |p, e, x, _, r| { + regions.push((p, e, x, r)); + true + }); + assert_eq!(regions, vec![(a, s.epoch(a), true, s.pages[&a].partial)]); + // The part is written into the page held: same page, same bytes, current again. + let mut d = done(a, regions[0].1, true, 4); + d.region = regions[0].3; + s.tx.send(d).unwrap(); + s.update(&[a], true, |_, _, _, _, _| panic!("nothing left to render")); + let up = s.take_uploads(&[a], 16); + assert_eq!(up.len(), 1); + s.uploaded(&up[0], 0, true); + assert!(!s.pages[&a].dirty && s.pages[&a].partial.is_none() && s.pages[&a].bytes == 100); + assert_eq!(s.stats.partial, 1); + // Most of a page, or damage of unknown extent, renders it whole. + s.invalidate(id(4), Some(DRect::new(2048, 2048, 4000, 4000))); + assert!(s.pages[&a].dirty && s.pages[&a].partial.is_none() && !s.pages[&a].exact); + s.invalidate(id(5), None); + assert!(s.pages[&b].partial.is_none()); + // A part of a page evicted meanwhile is dropped: the whole page is asked for. + let mut s = holding(&[a]); + s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111))); + let region = s.pages[&a].partial; + let epoch = s.epoch(a); + s.partial_failed(a); + let mut d = done(a, epoch, true, 4); + d.region = region; + s.tx.send(d).unwrap(); + let mut log = Vec::new(); + s.update(&[a], true, recorder(&mut log)); + assert!(s.ready.is_empty()); + assert_eq!(log.len(), 1, "the whole page"); + } + + #[test] + fn uploads_per_frame_are_bounded() { + let mut s = holding(&[]); + let view: Vec = (0..4).map(|x| page(1, x, 0)).collect(); + for p in &view { + s.ready.push(done(*p, 0, true, 514)); + } + // 514² texels of 16 bytes: 4.2 MB each. + let first = s.take_uploads(&view, 16); + assert_eq!(first.len(), UPLOADS_PER_FRAME); + assert_eq!(first[0].page, view[0], "in view order"); + // A page over the byte cap still goes alone. + let big = s.take_uploads(&view, 1 << 10); + assert_eq!(big.len(), 1); + assert_eq!(s.ready.len(), 1); + } + + #[test] + fn the_budget_evicts_cold_pages_but_never_wanted_ones() { + let held: Vec = (0..4).map(|x| page(1, x, 0)).collect(); + let mut s = holding(&held); + s.clock = 10; + s.pages.get_mut(&held[2]).unwrap().touched = 9; + let wanted = [held[0], held[1], page(1, 9, 9)]; + // 400 held, limit 450: room for 100 more means evicting one cold page, the oldest. + assert_eq!(s.make_room(wanted[2], 100, 450, &wanted), Some(true)); + assert!(!s.pages.contains_key(&held[3]) && s.pages.contains_key(&held[2])); + assert_eq!(s.make_room(wanted[2], 100, 399, &wanted), Some(true)); + assert_eq!(s.resident(), 2); + assert_eq!(s.make_room(wanted[2], 100, 250, &wanted), None, "wanted pages are never evicted"); + assert_eq!(s.stats.evicted, 2); + } + + #[test] + fn a_failed_page_falls_back_until_it_is_retried() { + let mut s = holding(&[]); + let view = [page(1, 0, 0)]; + let mut log = Vec::new(); + s.update(&view, true, recorder(&mut log)); + s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, region: None, result: Err("out of memory".into()) }).unwrap(); + s.update(&view, true, |_, _, _, _, _| panic!("no retry while failed")); + assert!(s.stuck(&view)); + assert_eq!(s.stats.last_error.as_deref(), Some("out of memory")); + s.retry_after((7, 7)); + assert!(!s.stuck(&view), "a new memory configuration retries"); + s.invalidate(id(2), None); + assert!(!s.stuck(&view)); + } + + /// An edited page whose new render fails isn't left showing its old content (#5332 review). + #[test] + fn a_dirty_page_whose_render_failed_falls_back() { + let view = [page(1, 0, 0)]; + let mut s = holding(&view); + s.invalidate(id(2), None); + assert!(s.pages[&view[0]].dirty && !s.stuck(&view), "the old page shows while it renders"); + let mut log = Vec::new(); + s.update(&view, true, recorder(&mut log)); + s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, region: None, result: Err("out of memory".into()) }).unwrap(); + s.update(&view, true, |_, _, _, _, _| panic!("no retry while failed")); + assert!(s.stuck(&view), "a stale page is not drawn in place of the failed one"); + s.retry_after((7, 7)); + assert!(!s.stuck(&view)); + } + + /// #5332 review: two windows showing one document (New Window for Document) share its + /// stream. One window's frame doesn't cancel the other's work, drop its results, evict its + /// pages or prune them as finer than its own view; a window gone for a while releases them. + #[test] + fn windows_on_one_document_keep_each_others_pages() { + let (a, b) = ([page(1, 0, 0)], [page(4, 0, 0)]); + let mut s = holding(&[]); + let (mut log_a, mut log_b) = (Vec::new(), Vec::new()); + s.view(1, &a); + s.update(&a, true, recorder(&mut log_a)); + s.view(2, &b); + s.update(&b, false, recorder(&mut log_b)); + assert!(!log_a[0].3.load(Ordering::Relaxed), "window 2's frame keeps window 1's work"); + // Window 1's page finishes during window 2's frame: it is kept for upload. + s.tx.send(done(a[0], log_a[0].1, true, 4)).unwrap(); + s.view(2, &b); + s.update(&b, false, |_, _, _, _, _| true); + assert!(s.ready.iter().any(|d| d.page == a[0]), "window 1's result is kept"); + for d in s.take_uploads(&b, 16) { + s.uploaded(&d, 100, true); + } + s.tx.send(done(b[0], log_b[0].1, true, 4)).unwrap(); + s.view(2, &b); + s.update(&b, false, |_, _, _, _, _| true); + for d in s.take_uploads(&b, 16) { + s.uploaded(&d, 100, true); + } + assert_eq!(s.resident(), 2); + // Window 2's view is current: window 1's finer page isn't pruned, nor evicted first. + s.prune(&b); + assert!(s.pages.contains_key(&a[0]), "not pruned while window 1 shows it"); + s.pages.insert(page(4, 1, 0), Page { dirty: false, exact: true, partial: None, epoch: 0, touched: s.clock, bytes: 100 }); + assert_eq!(s.make_room(page(4, 2, 0), 100, 300, &b), Some(true)); + assert!(s.pages.contains_key(&a[0]) && !s.pages.contains_key(&page(4, 1, 0)), "a page no window shows goes first"); + // Window 1 closed: once its pages are past the grace period, window 2 prunes them. + for _ in 0..=VIEW_TTL { + s.view(2, &b); + s.update(&b, false, |_, _, _, _, _| true); + } + s.prune(&b); + assert!(!s.pages.contains_key(&a[0]), "released after the closed window's grace period"); + // Without a recorded window (one view), nothing changes: other pages are cancelled. + let mut s = holding(&[]); + let mut log = Vec::new(); + s.update(&a, true, recorder(&mut log)); + s.update(&b, true, |_, _, _, _, _| true); + assert!(log[0].3.load(Ordering::Relaxed)); + } + + #[test] + fn prune_drops_finer_and_dirty_pages_once_the_view_is_current() { + let (fine, coarse, view) = (page(1, 0, 0), page(4, 0, 0), page(2, 0, 0)); + let mut s = holding(&[fine, coarse]); + assert!(s.prune(&[view]).is_some()); + assert_eq!(s.resident(), 2, "kept until the view's own page is shown"); + s.uploaded(&done(view, s.epoch(view), true, 4), 100, true); + let ids = s.prune(&[view]).expect("changed"); + assert_eq!(ids.len(), 2); + assert!(!s.pages.contains_key(&fine) && s.pages.contains_key(&coarse)); + assert!(s.prune(&[view]).is_none(), "unchanged: nothing to tell the GPU"); + assert!(s.epochs.is_empty(), "epochs only for work in flight"); + } +} diff --git a/crates/ui-egui/src/canvas/wheel_nav.rs b/crates/ui-egui/src/canvas/wheel_nav.rs index 0d190a0633..3ada75339b 100644 --- a/crates/ui-egui/src/canvas/wheel_nav.rs +++ b/crates/ui-egui/src/canvas/wheel_nav.rs @@ -3,6 +3,12 @@ //! - The wheel scrolls up and down; ⇧ or ⌘/Ctrl + wheel scrolls sideways. //! - ⌥/Alt + wheel zooms around the pointer, 10% per wheel notch (eased or instant, see below). //! ⇧/Shift + ⌥/Alt + wheel snaps to the next zoom ladder level instead. +//! On Windows and Linux Ctrl + ⌥/Alt + wheel (no ⇧) is the same zoom gesture (`alt_gesture`, +//! #10488): the windowing layer reports a held Ctrl as both `ctrl` and `command`, and Ctrl + Alt + +//! wheel was doing nothing before. Ctrl + Alt + ⇧ + wheel is deliberately left exactly as it was +//! before #10488 (a plain scroll with Zoom with Scroll Wheel off, nothing with it on; pinned by a +//! test in `wheel_nav/tests.rs`). Which Photoshop behaviour it should have is open on #10488: +//! there is no measured Photoshop evidence for the combination. //! - Preferences › General › Zoom with Scroll Wheel swaps the two: the wheel zooms and //! ⌥/Alt + wheel scrolls; ⇧/Shift + wheel snaps to the next zoom ladder level. //! - A trackpad pinch zooms around the pointer, unless Preferences › Enhanced Controls › @@ -339,6 +345,27 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, animated_zo read_with(ctx, zoom_with_wheel, pinch_zooms, animated_zoom, owner, hovered, true, true) } +/// Is this wheel event part of the ⌥/Alt zoom gesture (#10488)? +/// +/// winit reports a held Ctrl on Windows (and Linux) as BOTH `ctrl` and `command`, so a plain +/// `!command` test excluded Ctrl + Alt + wheel there and left it doing nothing. `command` is only +/// forgiven together with `ctrl`, without `mac_cmd` and without ⇧: Ctrl + Alt + ⇧ + wheel keeps +/// the behaviour it had before #10488 (it is not an Alt gesture). On macOS egui-winit sets +/// `command` and `mac_cmd` from the Command key and `ctrl` from Control, so ⌘ + ⌥ and ⌘ + ⌃ + ⌥ +/// both stay out of the zoom gesture and are left to egui's own scroll handling; on Windows and +/// Linux `mac_cmd` is never set. +/// +/// AltGr: on Windows, winit (`get_agnostic_mods`) removes ctrl and alt from the modifier state +/// while AltGr is down on a layout that has one, and on X11/Wayland AltGr is `ISO_Level3_Shift` +/// (Mod5), which is neither; so AltGr does not arrive as Ctrl + Alt. It is not excluded here +/// though: egui-winit also turns the physical AltRight key into a `Key::AltRight` event, and +/// `wheel_events` sets `alt` from it (the #8049 fallback), so AltGr + wheel can reach this +/// function with `alt` set and `command` clear and zoom like ⌥. Not verified on a physical AltGr +/// keyboard; behaviour deliberately unchanged. +fn alt_gesture(m: &Modifiers) -> bool { + m.alt && (!m.command || (m.ctrl && !m.mac_cmd && !m.shift)) +} + /// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's /// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch /// in the frame the wheel turns and stops there (#5721), as scroll areas already do. @@ -362,7 +389,7 @@ pub fn read_with( // --- Modifier tracking (always runs for all callers) --- let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| { let wheel = wheel_events(i); - let latest_alt = wheel.last().map(|(_, _, m, _)| m.alt && !m.command && !m.ctrl); + let latest_alt = wheel.last().map(|(_, _, m, _)| alt_gesture(m)); 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()) }); @@ -389,14 +416,19 @@ pub fn read_with( // --- Input classification (hovered viewport only) --- // The wheel events that belong to the gesture in progress: Shift+Alt (snap zoom), Alt-only // (continuous zoom), Shift-only (snap zoom with zoom_with_wheel), or plain (scroll/zoom). + // The Alt branches take [`alt_gesture`], so Ctrl + Alt (Windows' ctrl + command mirror) counts + // as Alt here too. Ctrl + Alt + ⇧ does not (`alt_gesture` refuses it, `latest_shift` keeps its + // strict test): it is neither alt nor shift here, as before #10488, so the notch is a plain + // scroll with Zoom with Scroll Wheel off and nothing with it on. Pinned in `wheel_nav/tests.rs`; + // which Photoshop gesture it should be is open on #10488. // `instant`: the part of `raw` egui leaves unsmoothed (a trackpad; see `wheel_events`). let (raw, instant) = wheel .iter() .filter(|(_, _, m, _)| { if alt && shift { - m.alt && m.shift && !m.command && !m.ctrl + alt_gesture(m) && m.shift } else if alt { - m.alt && !m.shift && !m.command && !m.ctrl + alt_gesture(m) && !m.shift } else if shift { m.shift && !m.alt && !m.command && !m.ctrl } else { diff --git a/crates/ui-egui/src/canvas/wheel_nav/tests.rs b/crates/ui-egui/src/canvas/wheel_nav/tests.rs index d2211a7daa..423cc4b488 100644 --- a/crates/ui-egui/src/canvas/wheel_nav/tests.rs +++ b/crates/ui-egui/src/canvas/wheel_nav/tests.rs @@ -616,6 +616,50 @@ fn each_modifier_maps_like_photoshop() { assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "{pan:?} {zoom}"); let (pan, zoom, _) = gesture(vec![vec![wheel(notch, CTRL)]], true, false); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "⌘/Ctrl + wheel still scrolls sideways: {pan:?} {zoom}"); + + // #10488, as Windows sends it: winit reports a held Ctrl as BOTH `ctrl` and `command`, so a + // gesture condition that only asks for `!command` never fires there. Ctrl + ⌥ + wheel is a dead + // gesture today (nothing at all with Zoom with Scroll Wheel on, a plain vertical scroll with it + // off), so teaching it to zoom like ⌥ + wheel replaces no working behaviour. + let ctrl_alt = Modifiers { alt: true, ctrl: true, command: true, ..Modifiers::NONE }; + let (pan, zoom, _) = one(ctrl_alt); + assert!(pan == Vec2::ZERO && (zoom - NOTCH).abs() < 1e-4, "Ctrl + ⌥ + wheel zooms 10%, at once: {pan:?} {zoom}"); + // With the preference on the gesture swaps: ⌥ + wheel scrolls, so Ctrl + ⌥ + wheel scrolls too. + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, ctrl_alt)]], true, false); + assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "with the preference on, Ctrl + ⌥ + wheel scrolls like ⌥: {pan:?} {zoom}"); + // Animated Zoom on (the default): the notch is eased over its frames, ×1.1 in total. + let (pan, zoom, _) = gesture_with(vec![vec![wheel(notch, ctrl_alt)]], false, false, true); + assert!(pan == Vec2::ZERO && (zoom - NOTCH).abs() < 1e-4, "eased, one Ctrl + ⌥ notch is still ×1.1: {pan:?} {zoom}"); + // A Windows touchpad pinch while ⌥ is held keeps folding into one pinch event: the fold looks at + // `ctrl` whatever else is held, and *replaces* the wheel event, so nothing is counted twice. + let (pan, zoom, _) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), ctrl_alt)]], false, true); + let speed = egui::InputOptions::default().scroll_zoom_speed; + assert!(pan == Vec2::ZERO && (zoom - (speed * 40.0 * 0.25).exp()).abs() < 1e-4, "a fractional Ctrl + ⌥ step zooms once: {pan:?} {zoom}"); + // Ctrl + ⌥ + ⇧ + wheel keeps the behaviour it had on main before #10488, in all four + // combinations of Zoom with Scroll Wheel and Animated Zoom (measured on origin/main with a + // probe test): with the preference off the notch is a plain vertical scroll (pan [0 40], no + // zoom, no snap); with it on the notch does nothing at all. `alt_gesture` refuses the + // ctrl + command mirror together with ⇧, so the classification sees neither alt nor shift, + // exactly as `!command && !ctrl` did. Which Photoshop gesture the combination should be is + // open on #10488 (no measured evidence), so it is pinned, not chosen. + let ctrl_alt_shift = Modifiers { alt: true, ctrl: true, command: true, shift: true, ..Modifiers::NONE }; + for animated in [false, true] { + let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(notch, ctrl_alt_shift)]], false, false, animated); + assert!( + near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0 && snaps == 0, + "Ctrl + ⌥ + ⇧ + wheel scrolls as before with the preference off (animated {animated}): {pan:?} {zoom} snaps {snaps}" + ); + let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(notch, ctrl_alt_shift)]], true, false, animated); + assert!(pan == Vec2::ZERO && zoom == 1.0 && snaps == 0, "and does nothing with the preference on (animated {animated}): {pan:?} {zoom} snaps {snaps}"); + } + // Without the Windows ctrl mirror the same wheel does snap, so only the ctrl half kills it. + let (pan, zoom, snaps) = one(Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + assert!(pan == Vec2::ZERO && zoom == 1.0 && snaps == 1, "⌥ + ⇧ + wheel still snaps: {pan:?} {zoom} snaps {snaps}"); + // macOS ⌘ + ⌥ (command set, ctrl not) is deliberately NOT the zoom gesture: it is left to egui's + // own scroll handling. Ctrl + ⌥ + ⇧ is not the zoom gesture either (the rows above, unchanged + // from before #10488); which behaviour is right for it is the open question on #10488. + let (pan, zoom, _) = one(Modifiers { alt: true, ..CMD }); + assert!(zoom == 1.0 && pan != Vec2::ZERO, "⌘ + ⌥ + wheel keeps scrolling: {pan:?} {zoom}"); } #[test] @@ -1013,3 +1057,51 @@ fn enable_gestures_off_keeps_the_trackpad_pinch() { let zoom = touch_zoom(frames, false); assert!((zoom - 1.25).abs() < 1e-4, "fingers resting: {zoom}"); } + +#[test] +fn alt_gesture_is_per_platform_modifier_shape() { + let alt = Modifiers::ALT; + // Windows / Linux: a held Ctrl arrives as ctrl + command, `mac_cmd` never set. + assert!(alt_gesture(&Modifiers { ctrl: true, command: true, ..alt }), "Ctrl + Alt zooms"); + assert!(alt_gesture(&alt), "Alt zooms"); + // macOS: ⌘ is command + mac_cmd, ⌃ is ctrl alone. + assert!(!alt_gesture(&Modifiers { mac_cmd: true, command: true, ..alt }), "⌘ + ⌥ keeps its meaning"); + assert!(!alt_gesture(&Modifiers { mac_cmd: true, command: true, ctrl: true, ..alt }), "⌘ + ⌃ + ⌥ is not the zoom gesture"); + assert!(alt_gesture(&Modifiers { ctrl: true, ..alt }), "macOS ⌃ + ⌥ (command unset) still counts as Alt"); + // AltGr: winit reports neither alt nor ctrl for it (Windows `get_agnostic_mods` filters both + // out on layouts with an AltGr key; X11/Wayland deliver ISO_Level3_Shift), so it is NONE here. + assert!(!alt_gesture(&Modifiers::NONE), "AltGr alone is no modifier egui sees"); + assert!(!alt_gesture(&Modifiers::CTRL), "Ctrl alone is not Alt"); +} + +#[test] +fn mac_cmd_alt_wheel_does_not_zoom() { + let notch = Vec2::new(0.0, 1.0); + let cmd_alt = Modifiers { mac_cmd: true, command: true, ctrl: true, alt: true, ..Modifiers::NONE }; + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, cmd_alt)]], false, false); + assert!((zoom - 1.0).abs() < 1e-6, "⌘ + ⌃ + ⌥ + wheel must not zoom: {zoom}"); + assert!(pan != Vec2::ZERO, "⌘ + ⌃ + ⌥ + wheel keeps scrolling: {pan:?}"); + let (_, zoom, _) = gesture(vec![vec![wheel(notch, Modifiers { ctrl: true, command: true, alt: true, ..Modifiers::NONE })]], false, false); + assert!(zoom > 1.0, "Ctrl + Alt + wheel zooms on Windows/Linux: {zoom}"); +} + +#[test] +fn alt_key_press_before_the_wheel_counts_as_alt_modifier() { + // `wheel_events` adds ⌥ from an Alt key event when the wheel's own modifiers miss it (#8049): + // the same frame with the key press and a modifier-less wheel must equal the wheel with `alt`. + let notch = Vec2::new(0.0, 1.0); + let key = |pressed| Event::Key { key: Key::AltRight, physical_key: Some(Key::AltRight), pressed, repeat: false, modifiers: Modifiers::NONE }; + for animated in [false, true] { + let via_key = gesture_with(vec![vec![key(true), wheel(notch, Modifiers::NONE)]], false, false, animated); + let via_mods = gesture_with(vec![vec![wheel(notch, Modifiers::ALT)]], false, false, animated); + assert_eq!(via_key, via_mods, "AltRight key press + wheel == wheel with alt (animated {animated})"); + assert!(via_key.1 > 1.0, "and it zooms: {via_key:?}"); + } +} + +#[test] +fn ctrl_alt_shift_mirror_is_not_an_alt_gesture() { + let m = Modifiers { alt: true, ctrl: true, command: true, shift: true, ..Modifiers::NONE }; + assert!(!alt_gesture(&m), "Ctrl + Alt + Shift keeps its pre-#10488 behaviour"); + assert!(alt_gesture(&Modifiers { alt: true, shift: true, ..Modifiers::NONE }), "Alt + Shift is still the snap gesture"); +} diff --git a/crates/ui-egui/src/canvas/zoom_field.rs b/crates/ui-egui/src/canvas/zoom_field.rs new file mode 100644 index 0000000000..4f256e44b0 --- /dev/null +++ b/crates/ui-egui/src/canvas/zoom_field.rs @@ -0,0 +1,631 @@ +//! Photoshop's rules for a zoom field (status bar, Navigator) while a number is typed into it. +//! +//! A typed zoom is held until Enter (or a click on something other than the pasteboard). Until +//! then the image keeps its zoom, and these keys never apply what was typed: +//! +//! * Esc drops it: the field shows the current zoom again. +//! * Tab and ⇧Tab keep the focus in the field, write the typed number as the field shows a zoom +//! (`045` and `45,0` become `45`, `1/2` becomes `50`; the `%` is drawn beside the field), select +//! it so typing replaces it, and play the system alert (Windows: the Asterisk sound). +//! * Alt (tapped) does only that alert: it neither puts the keyboard on the menu bar nor leaves +//! the field. +//! * A click, with either button, on the pasteboard (the canvas around the image), anywhere on the +//! title bar (menu titles, the empty drag area, the workspace switcher, search, the theme +//! button, Minimise / Maximise / Close), or on an open menu or dropdown (Help's "Search menus" +//! field included), keeps the typed number unapplied, rewritten and selected as Tab does it. +//! * Whatever the key or click, a typed number is never applied while the window is not focused. +//! * Only digits, `/` and the system's decimal separator ([`crate::i18n::decimal_separator`]: `,` on +//! a German Windows, `.` on an English one, in ratios too: `100/5,0`) can be typed or pasted; any +//! other character (the other separator too) is dropped and asks for the same alert, without +//! touching the text. Where the system's separator is not known, both `.` and `,` are taken. The +//! field shows its zoom with that separator (`33,33`). +//! * A second separator can be typed (`80,,2`), and pasted. Enter (or a click that applies) then +//! shows Photoshop's "Invalid numeric entry" message with the field's range and applies the number +//! before the second separator (`80`), as Photoshop does after its OK. +//! +//! The field itself is `widgets::number_edit` (with `hold_typed`), which calls [`before`] and +//! [`after`] around the egui `DragValue`. The rest is bookkeeping other parts of the app read: +//! [`editing`] (the shortcuts must not take Esc or Tab in the frame egui drops the focus), +//! [`is_focused`] (the menu bar must not take Alt) and [`tick`] (plays the alert, and tells the +//! field about a press on the pasteboard, which only the app can recognise). + +use egui::{Context, FocusDirection, Id, Key, Modifiers, SurrenderFocusOn, Ui}; + +fn editing_id() -> Id { + Id::unique("pc-zoom-field-editing") +} + +fn pasteboard_id() -> Id { + Id::unique("pc-zoom-field-pasteboard-press") +} + +fn menu_bar_rect_id() -> Id { + Id::unique("pc-zoom-field-menu-bar-rect") +} + +fn alert_id() -> Id { + Id::unique("pc-zoom-field-alert") +} + +fn taken_key_id() -> Id { + Id::unique("pc-zoom-field-taken-key") +} + +fn keep_pass_id() -> Id { + Id::unique("pc-zoom-field-keep-pass") +} + +fn select_until_id() -> Id { + Id::unique("pc-zoom-field-select-until") +} + +fn title_bar_rect_id() -> Id { + Id::unique("pc-zoom-field-title-bar-rect") +} + +/// What [`before`] decided for this frame, for [`after`] and the field. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Guard { + /// The setting to put back: egui's "surrender focus on" was switched off for this frame. + restore: Option, + /// Esc was pressed in the field: whatever was typed is dropped, the field leaves the keyboard. + esc: bool, + /// Whatever egui does with the typed text this frame, it must not reach the zoom: Esc, Tab, a + /// click that keeps the typed number, a window that has lost the keyboard. + veto: bool, +} + +impl Guard { + /// The field must not apply a typed number this frame (a scrub is not typed and is exempt). + pub(crate) fn vetoes_typed(self) -> bool { + self.veto + } +} + +/// Makes the `TextEdit` of the field being drawn select all of its text, the way Ctrl+A does: +/// the key is put in this frame's input, where the `TextEdit` reads it as typed. +fn select_all_by_key(ui: &Ui) { + let ctrl_a = egui::Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + ui.input_mut(|i| i.events.push(ctrl_a)); +} + +/// Can `c` be typed into a zoom field? Digits, `/` (for a ratio: `1/2` is 50 %, `5/2` is 250 %; +/// the `%` is drawn beside the field, never typed) and the decimal separator `sep` (both `.` and `,` +/// when it is not known). +fn accepted(c: char, sep: Option) -> bool { + c.is_ascii_digit() || c == '/' || sep.map_or(matches!(c, '.' | ','), |s| c == s) +} + +/// Takes out of this frame's input whatever a zoom field does not take ([`accepted`]) before the +/// field sees it; true when something was. A second separator is not refused here (Photoshop types +/// it, and refuses the number on Enter: [`read`]). +/// +/// egui keeps the text being edited in a private `EditState`, out of reach, so this module keeps +/// its own copy under [`mirror_id`], for Tab and a click that keeps the number to rewrite it +/// ([`canonical`]): what the field showed when it got the keyboard, with the keys applied that are +/// seen here (typed, pasted, cut, Backspace, Delete). A key that changes the text in a way not +/// followed (undo, a word deletion) makes the copy empty and marks it lost ([`lost_id`]): the text +/// is then only selected, never rewritten, until the field is left. The copy is kept with the `shown` it started from: when the value changes under the +/// focused field (ArrowUp, the wheel, another zoom), the field shows the new number, and the copy +/// starts again from it. +fn drop_unwanted_chars(ui: &Ui, id: Id, shown: &str) -> bool { + let mirror = mirror_id(id); + let stored = ui.data(|d| d.get_temp::<(String, String)>(mirror)).filter(|(from, _)| from == shown).map(|(_, text)| text); + let mut text: Vec = stored.clone().unwrap_or_else(|| shown.to_owned()).chars().collect(); + let len = text.len(); + let range = egui::TextEdit::load_state(ui.ctx(), id).and_then(|s| s.cursor.char_range()); + // Before the first key the whole number is selected, as the field is reached. + let mut sel = match (&stored, range) { + (Some(_), Some(r)) => { + let (a, b) = (r.primary.index.0, r.secondary.index.0); + (a.min(b).min(len), a.max(b).min(len)) + } + (Some(_), None) => (len, len), + (None, _) => (0, len), + }; + let sep = crate::i18n::decimal_separator(); + let allowed = |c: char| accepted(c, sep); + let mut stale = false; + let dropped = ui.input_mut(|i| { + let mut dropped = false; + i.events.retain_mut(|e| match e { + egui::Event::Text(t) | egui::Event::Paste(t) => { + let kept: String = t.chars().filter(|&c| allowed(c)).collect(); + dropped |= kept.chars().count() != t.chars().count(); + // Nothing kept: the event goes, and the text stays as it is. + if !kept.is_empty() { + text.drain(sel.0..sel.1); + for (n, c) in kept.chars().enumerate() { + text.insert(sel.0 + n, c); + } + let at = sel.0 + kept.chars().count(); + sel = (at, at); + } + *t = kept; + !t.is_empty() + } + egui::Event::Cut => { + text.drain(sel.0..sel.1); + sel = (sel.0, sel.0); + true + } + egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers, .. } => { + if modifiers.is_none() || (*key == Key::Backspace && modifiers.shift_only()) { + if sel.0 != sel.1 { + text.drain(sel.0..sel.1); + sel = (sel.0, sel.0); + } else if *key == Key::Backspace && sel.0 > 0 { + text.remove(sel.0 - 1); + sel = (sel.0 - 1, sel.0 - 1); + } else if *key == Key::Delete && sel.0 < text.len() { + text.remove(sel.0); + } + } else { + stale = true; + } + true + } + egui::Event::Key { key: Key::Z | Key::Y, pressed: true, modifiers, .. } => { + stale |= modifiers.command; + true + } + _ => true, + }); + dropped + }); + if stale { + text.clear(); + // Lost for good, until the field is left or shows another value: what is typed from here on + // is only part of the text. + ui.data_mut(|d| d.insert_temp(lost_id(id), shown.to_owned())); + } + ui.data_mut(|d| d.insert_temp(mirror, (shown.to_owned(), text.iter().collect::()))); + dropped +} + +/// Where this module keeps its copy of the text being edited in the field with `id`. +fn mirror_id(id: Id) -> Id { + id.with("pc-zoom-field-text") +} + +/// Where this module notes that its copy of the text lost track of it (with the `shown` it was +/// kept for), so the copy is never written back over the real text ([`rewrite`]). +fn lost_id(id: Id) -> Id { + id.with("pc-zoom-field-text-lost") +} + +/// Drops the copy of the typed text, and with it the note that it was lost. +fn forget_text(ui: &Ui, id: Id) { + ui.data_mut(|d| { + d.remove_temp::<(String, String)>(mirror_id(id)); + d.remove_temp::(lost_id(id)); + }); +} + +/// Call right before the field's `DragValue` is added; `id` is that `DragValue`'s id, `shown` the +/// text it shows when nothing was typed yet, and `canon` writes a typed text as the field would +/// show the zoom Enter makes of it (`None`: it makes none). +pub(crate) fn before(ui: &Ui, id: Id, shown: &str, canon: &dyn Fn(&str) -> Option) -> Guard { + let ctx = ui.ctx(); + let pass = ctx.cumulative_pass_nr(); + let focused = ui.memory(|m| m.has_focus(id)); + // Esc: take it (no shortcut, no job cancel, no canvas acts on it), and drop what was typed. + // Whether egui dropped the focus already (the key's own frame) or not, the field applies + // nothing this frame (`Guard::vetoes_typed`) and leaves the keyboard (`after`). + let owner = focused || editing_field(ctx) == Some(id); + // Esc is also looked for in the frame's raw input: something may have consumed it before the + // field was drawn (a shortcut, a job), and egui has dropped the focus on it already. + let raw_esc = |ui: &Ui| { + ui.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, modifiers, .. } if modifiers.is_none()))) + }; + // Only the field that owns the keyboard takes Esc: any other must leave it to whoever wants it. + if owner && (ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) | raw_esc(ui)) { + forget_text(ui, id); + return Guard { esc: true, veto: true, ..Default::default() }; + } + // The focus left before the field was drawn, and no click took it (egui drops the focus on + // some keys and a shortcut may then eat them, so the `DragValue` cannot rely on seeing them): + // the copy of the typed text is dropped. Nothing vetoes the `DragValue` here, so a click away + // still applies what was typed. + if !focused { + forget_text(ui, id); + } + // A click that keeps what was typed (pasteboard, title bar, open menu), or a window that has + // lost the keyboard: whatever egui does with the typed text, it does not reach the zoom. This + // holds without the focus too (the click may have given it to another field earlier in this + // frame, and egui hands a field its lost focus, with the text, when it is next drawn), and for + // two frames after the click (the focus can go in one frame and be noticed in the next). + let gesture = pasteboard_press(ctx); + if gesture { + ctx.data_mut(|d| d.insert_temp(keep_pass_id(), pass)); + } + let recent = ctx.data(|d| d.get_temp::(keep_pass_id())).is_some_and(|p| pass <= p + 2); + // Enter in the field is a commit of its own, however soon after the click: egui draws few frames + // with the mouse still, so the Enter can land in that window. + let enter = focused && ui.input(|i| i.key_pressed(Key::Enter)); + let veto = (recent && !enter) || ui.input(|i| i.viewport().focused) == Some(false); + if !focused { + return Guard { veto, ..Default::default() }; + } + drop_taken_text(ctx); + if drop_unwanted_chars(ui, id, shown) { + request_alert(ctx); + } + // Tab: egui has already planned the move to the next widget; cancel it. The text is selected + // now, and again in the next frames until something is typed: whatever moves the text cursor + // meanwhile does not undo it. + let tab = ui.input_mut(|i| i.consume_key(Modifiers::SHIFT, Key::Tab) | i.consume_key(Modifiers::NONE, Key::Tab)); + if tab { + ctx.memory_mut(|m| m.move_focus(FocusDirection::None)); + request_alert(ctx); + rewrite(ui, id, shown, canon); + select_all_by_key(ui); + ctx.data_mut(|d| d.insert_temp(select_until_id(), pass + 3)); + ctx.request_repaint(); + return Guard { veto: true, ..Default::default() }; + } + if let Some(until) = ctx.data(|d| d.get_temp::(select_until_id())) { + let acted = ui.input(|i| { + i.events.iter().any(|e| { + matches!( + e, + egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: true, .. } + ) + }) + }); + if acted || pass > until { + ctx.data_mut(|d| d.remove_temp::(select_until_id())); + } else { + select_all_by_key(ui); + ctx.request_repaint(); + } + } + if gesture { + // egui drops the focus of the focused widget when a click lands elsewhere, and the + // `DragValue` then applies the text; for this frame, neither. + let prev = ctx.options(|o| o.input_options.surrender_focus_on); + ctx.options_mut(|o| o.input_options.surrender_focus_on = SurrenderFocusOn::Never); + // Selected when the button goes down and when it comes up, not on every frame it is held. + if ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released()) { + rewrite(ui, id, shown, canon); + select_all_by_key(ui); + } + return Guard { restore: Some(prev), veto: true, ..Default::default() }; + } + Guard { veto, ..Default::default() } +} + +/// Writes the typed text as the field shows its zoom ([`canonical`]), as Photoshop does on Tab and +/// on a click that keeps it: all of it is selected and replaced in this frame's input. +fn rewrite(ui: &Ui, id: Id, shown: &str, canon: &dyn Fn(&str) -> Option) { + // The copy lost track of the text (an undo, a word deletion): the text is only selected. + if ui.data(|d| d.get_temp::(lost_id(id))).is_some_and(|from| from == shown) { + return; + } + let mirror = mirror_id(id); + let text = ui.data(|d| d.get_temp::<(String, String)>(mirror)).filter(|(from, _)| from == shown).map_or_else(|| shown.to_owned(), |(_, t)| t); + let Some(new) = canonical(&text, canon) else { return }; + select_all_by_key(ui); + ui.input_mut(|i| i.events.push(egui::Event::Text(new.clone()))); + ui.data_mut(|d| d.insert_temp(mirror, (shown.to_owned(), new))); +} + +/// What [`rewrite`] puts in place of `text`, when that changes it: `canon` of it, for text that +/// holds a number Enter takes as it is. Text Enter refuses ([`read`]) stays. +fn canonical(text: &str, canon: &dyn Fn(&str) -> Option) -> Option { + let Entry::Number(_) = read(text) else { return None }; + canon(text).filter(|new| new != text && !new.is_empty()) +} + +/// Call right after the field's `DragValue` was added. +pub(crate) fn after(ui: &Ui, id: Id, guard: Guard) { + let ctx = ui.ctx(); + if let Some(prev) = guard.restore { + ctx.options_mut(|o| o.input_options.surrender_focus_on = prev); + } + if guard.esc { + // Esc: the typed text goes, and so does the keyboard, so the field shows the zoom again. + forget_text(ui, id); + ui.memory_mut(|m| m.surrender_focus(id)); + ctx.request_repaint(); + return; + } + if !ui.memory(|m| m.has_focus(id)) { + return; + } + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(editing_id(), (id, pass))); +} + +/// How Enter reads the text of a zoom field. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Entry { + /// At most one decimal separator: this text (`,` made `.`) for the number parser. + Number(String), + /// More than one (`80,,2`, `1,5,2`): an invalid numeric entry. Photoshop says so and then takes + /// the number before the second separator: this text for the parser (`80,` as `80.`), if any. + Invalid(Option), +} + +/// Reads a zoom field's text for Enter: a decimal separator is `,` or `.` (only the system's one +/// can be typed, [`accepted`]), and the text holds at most one. +pub(crate) fn read(text: &str) -> Entry { + let is_sep = |c: char| matches!(c, '.' | ','); + let Some((second, _)) = text.char_indices().filter(|&(_, c)| is_sep(c)).nth(1) else { + return Entry::Number(text.replace(',', ".")); + }; + let lead = text.get(..second).unwrap_or_default(); + Entry::Invalid((!lead.is_empty()).then(|| lead.replace(',', "."))) +} + +/// `text` (a number formatted with `.`) with the system's decimal separator, as the field shows it. +pub(crate) fn localize(text: String) -> String { + match crate::i18n::decimal_separator() { + Some(',') => text.replace('.', ","), + _ => text, + } +} + +fn invalid_entry_id() -> Id { + Id::unique("pc-zoom-field-invalid-entry") +} + +/// The field with `id` read an invalid entry ([`Entry::Invalid`]) this frame; see [`take_invalid`]. +pub(crate) fn note_invalid(ctx: &Context, id: Id) { + ctx.data_mut(|d| d.insert_temp(invalid_entry_id().with(id), true)); +} + +/// Did the field with `id` read an invalid entry this frame ([`note_invalid`])? Taken once. +pub(crate) fn take_invalid(ctx: &Context, id: Id) -> bool { + ctx.data_mut(|d| d.remove_temp::(invalid_entry_id().with(id))).unwrap_or(false) +} + +/// Photoshop's "Invalid numeric entry" message for a field whose range is `range` (in %): [`tick`] +/// opens it in the next frame. +pub(crate) fn request_invalid_entry(ctx: &Context, range: (f32, f32)) { + ctx.data_mut(|d| d.insert_temp(invalid_entry_id(), range)); + ctx.request_repaint(); +} + +/// The message waiting to be shown, with its range (tests). +#[cfg(test)] +pub(crate) fn invalid_entry_pending(ctx: &Context) -> Option<(f32, f32)> { + ctx.data(|d| d.get_temp::<(f32, f32)>(invalid_entry_id())) +} + +/// The English text of the message; `{min}` and `{max}` are the range, with two decimals. +pub const INVALID_ENTRY: &str = "Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted."; + +/// The message for a range, in the UI language and with the system's decimal separator. +pub fn invalid_entry_message((min, max): (f32, f32)) -> String { + let pct = |v: f32| localize(format!("{v:.2}")); + crate::i18n::fmt(tl!(INVALID_ENTRY), &[("min", &pct(min)), ("max", &pct(max))]) +} + +fn editing_field(ctx: &Context) -> Option { + let (id, pass) = ctx.data(|d| d.get_temp::<(Id, u64)>(editing_id()))?; + // Drawn with the focus in the last frame (the one before this): in the frame egui drops + // the focus for Esc or Tab, nothing is focused any more, but the field is still the owner. + (pass + 1 >= ctx.cumulative_pass_nr()).then_some(id) +} + +/// A zoom field had the keyboard in the last frame. Like a layer rename, it keeps its keys even in +/// the frame egui drops its focus (Esc, Tab), so the shortcuts must treat it as text. +pub fn editing(ctx: &Context) -> bool { + editing_field(ctx).is_some() +} + +/// A zoom field has the keyboard right now. +pub fn is_focused(ctx: &Context) -> bool { + editing_field(ctx).is_some_and(|id| ctx.memory(|m| m.focused()) == Some(id)) +} + +/// A key the open menu took (a row's letter, Space) also arrives as typed text, in the same frame +/// or the next: while a zoom field has the keyboard, that text never reaches it, so the field +/// neither types it nor alerts. +pub(crate) fn drop_text(ctx: &Context, c: char) { + if is_focused(ctx) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(taken_key_id(), (c, pass))); + drop_taken_text(ctx); + } +} + +/// Takes the text of the key [`drop_text`] noted out of this frame's input, once, until the frame +/// after the key. +fn drop_taken_text(ctx: &Context) { + let Some((c, pass)) = ctx.data(|d| d.get_temp::<(char, u64)>(taken_key_id())) else { return }; + if pass + 1 < ctx.cumulative_pass_nr() { + ctx.data_mut(|d| d.remove_temp::<(char, u64)>(taken_key_id())); + return; + } + let same = |t: &str| t.chars().eq(c.to_lowercase()) || t.chars().eq(c.to_uppercase()); + let found = ctx.input_mut(|i| { + let at = i.events.iter().position(|e| matches!(e, egui::Event::Text(t) if same(t))); + at.map(|at| i.events.remove(at)).is_some() + }); + if found { + ctx.data_mut(|d| d.remove_temp::<(char, u64)>(taken_key_id())); + } +} + +/// The system alert (Windows: Asterisk) is wanted; [`tick`] plays it in the next frame. +pub fn request_alert(ctx: &Context) { + ctx.data_mut(|d| d.insert_temp(alert_id(), true)); + ctx.request_repaint(); +} + +/// Whether the alert is waiting to be played (tests). +#[cfg(test)] +pub(crate) fn alert_pending(ctx: &Context) -> bool { + ctx.data(|d| d.get_temp::(alert_id())).unwrap_or(false) +} + +/// Remembers the rectangle of the whole title bar (menus, the empty drag area, workspace switcher, +/// search and theme buttons, the caption buttons) for [`on_title_bar`]. +pub(crate) fn set_title_bar_rect(ctx: &Context, rect: egui::Rect) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(title_bar_rect_id(), (rect, pass))); +} + +/// Remembers the rectangle of the menu bar titles (`File` … `Help`) for [`on_menu_bar`]. +pub(crate) fn set_menu_bar_rect(ctx: &Context, rect: egui::Rect) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(menu_bar_rect_id(), (rect, pass))); +} + +/// Does the rectangle kept under `id` (with the pass it was drawn in) contain `pos`, and was it drawn +/// recently enough to still be there? Not drawn for a while (hidden by Tab or Full Screen Mode), its +/// rect is stale and must not catch a click. The one freshness rule of the title bar and the menu bar. +fn fresh_rect_contains(ctx: &Context, id: Id, pos: egui::Pos2) -> bool { + ctx.data(|d| d.get_temp::<(egui::Rect, u64)>(id)).is_some_and(|(r, pass)| pass + 2 >= ctx.cumulative_pass_nr() && r.contains(pos)) +} + +/// A button went down on the pasteboard while a zoom field was being edited, and is still down or +/// came up in this very frame. (egui drops the focus of a text field on the click, which is the +/// release, so the press alone is not the whole gesture.) +fn pasteboard_press(ctx: &Context) -> bool { + ctx.data(|d| d.get_temp::(pasteboard_id())).unwrap_or(false) +} + +/// Once per frame ([`tick`], tests): `pressed` says a button went down on the pasteboard in this +/// frame. The gesture lasts until the frame after the button came up. +pub(crate) fn track_pasteboard(ctx: &Context, pressed: bool) { + let (down, released) = ctx.input(|i| (i.pointer.any_down(), i.pointer.any_released())); + ctx.data_mut(|d| { + if pressed { + d.insert_temp(pasteboard_id(), true); + } else if !down && !released { + d.remove::(pasteboard_id()); + } + }); +} + +// ---- What needs the application: services and the document's geometry. ---- + +/// Once per frame, before the panels: shows a requested "Invalid numeric entry" message, plays a +/// requested alert and follows a press on the pasteboard. +pub fn tick(app: &mut crate::PhotocraftApp, ctx: &Context) { + if let Some(range) = ctx.data_mut(|d| d.remove_temp::<(f32, f32)>(invalid_entry_id())) { + let message = invalid_entry_message(range); + // One commit can be read in more than one pass of a frame: one message, not a stack of them. + let open = + app.ui.dialogs.iter().any(|d| d.kind == crate::state::DialogKind::Error && d.fields.get("message").and_then(|m| m.as_str()) == Some(&message)); + if !open { + app.ui.open_dialog(crate::state::DialogKind::Error, serde_json::json!({ "message": message }).as_object().cloned().unwrap_or_default()); + } + } + if ctx.data_mut(|d| d.remove_temp::(alert_id())).unwrap_or(false) + // The platform's alert, else its plain beep: an app that only sets `beep` still answers. + && let Some(play) = app.services.alert.or(app.services.beep) + { + play(); + } + let press = + if editing(ctx) { ctx.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin().or(i.pointer.interact_pos()) } else { None }) } else { None }; + track_pasteboard(ctx, press.is_some_and(|pos| on_pasteboard(app, ctx, pos) || on_menu_bar(ctx, pos) || on_title_bar(ctx, pos) || on_open_popup(ctx, pos))); +} + +/// Is screen point `pos` anywhere on the title bar: the menu titles and the gaps between them, the +/// workspace switcher (Essentials), search, the theme button, Minimise / Maximise / Close? +fn on_title_bar(ctx: &Context, pos: egui::Pos2) -> bool { + fresh_rect_contains(ctx, title_bar_rect_id(), pos) +} + +/// Is screen point `pos` on an open menu or dropdown (Help's "Search menus" field, a menu item, +/// the workspace list)? A menu or dropdown is open and `pos` is on a layer above the canvas. +fn on_open_popup(ctx: &Context, pos: egui::Pos2) -> bool { + (egui::Popup::is_any_open(ctx) || crate::menu_nav::is_open(ctx)) && ctx.layer_id_at(pos).is_some_and(|l| l.order != egui::Order::Background) +} + +/// Is screen point `pos` on one of the menu bar's top-level menu titles (`File`, `Edit`, …)? +fn on_menu_bar(ctx: &Context, pos: egui::Pos2) -> bool { + fresh_rect_contains(ctx, menu_bar_rect_id(), pos) +} + +/// Is screen point `pos` on the main canvas, outside the image, and not under a window or menu? +fn on_pasteboard(app: &crate::PhotocraftApp, ctx: &Context, pos: egui::Pos2) -> bool { + let (Some(xf), Some(st)) = (crate::canvas::ViewXform::active(app), app.session.active()) else { return false }; + let doc = xf.to_doc(pos); + let (w, h) = (f64::from(st.doc.size.width), f64::from(st.doc.size.height)); + xf.rect.contains(pos) + && doc.iter().all(|v| v.is_finite()) + && (doc[0] < 0.0 || doc[1] < 0.0 || doc[0] >= w || doc[1] >= h) + && ctx.layer_id_at(pos).is_some_and(|l| l.order == egui::Order::Background) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enter_reads_one_separator_and_the_number_before_a_second_one() { + let number = |t: &str| Entry::Number(t.into()); + assert_eq!(read("100,90"), number("100.90")); + assert_eq!(read("100.90"), number("100.90")); + assert_eq!(read("100/5,0"), number("100/5.0")); + assert_eq!(read("100/5.0"), number("100/5.0")); + assert_eq!(read("45"), number("45")); + assert_eq!(read(""), number("")); + let invalid = |t: Option<&str>| Entry::Invalid(t.map(str::to_owned)); + assert_eq!(read("80,,2"), invalid(Some("80."))); + assert_eq!(read("1,5,2"), invalid(Some("1.5"))); + assert_eq!(read("1,000.5"), invalid(Some("1.000"))); + assert_eq!(read("100/5,0,1"), invalid(Some("100/5.0"))); + assert_eq!(read(",,"), invalid(Some("."))); + assert_eq!(read("..5"), invalid(Some("."))); + } + + #[test] + fn only_the_systems_decimal_separator_can_be_typed() { + for (sep, point, comma) in [(Some(','), false, true), (Some('.'), true, false), (None, true, true)] { + assert_eq!(accepted('.', sep), point, "{sep:?}"); + assert_eq!(accepted(',', sep), comma, "{sep:?}"); + assert!(accepted('7', sep) && accepted('/', sep) && !accepted('%', sep) && !accepted('a', sep)); + } + } + + #[test] + fn the_invalid_entry_message_names_the_range_with_the_systems_separator() { + let want = |s: &str| format!("Invalid numeric entry. An integer between {s} is required. Previous value was inserted."); + crate::i18n::with_decimal_separator(Some(','), || { + assert_eq!(invalid_entry_message((0.25, 12800.0)), want("0,25% and 12800,00%")); + assert_eq!(localize("33.33".into()), "33,33"); + }); + crate::i18n::with_decimal_separator(Some('.'), || assert_eq!(invalid_entry_message((0.25, 12800.0)), want("0.25% and 12800.00%"))); + assert_eq!(localize("33.33".into()), "33.33", "unknown: as formatted"); + } + + #[test] + fn the_title_bar_keeps_a_click_wherever_it_is_drawn() { + let ctx = Context::default(); + let bar = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 32.0)); + assert!(!on_title_bar(&ctx, egui::pos2(10.0, 10.0)), "nothing drawn yet"); + set_title_bar_rect(&ctx, bar); + assert!(on_title_bar(&ctx, egui::pos2(790.0, 10.0)), "the far end: Essentials, Minimise, Close"); + assert!(on_title_bar(&ctx, egui::pos2(400.0, 10.0)), "the empty drag area between the menus and them"); + assert!(!on_title_bar(&ctx, egui::pos2(10.0, 100.0)), "below the bar is the app"); + } + + /// The menu bar rect goes stale like the title bar's: not drawn for a while, it catches nothing. + #[test] + fn a_menu_bar_that_is_no_longer_drawn_keeps_no_click() { + let ctx = Context::default(); + let bar = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 32.0)); + let inside = egui::pos2(50.0, 10.0); + assert!(!on_menu_bar(&ctx, inside), "nothing drawn yet"); + // Drawn in the pass the clock is at now. + set_menu_bar_rect(&ctx, bar); + assert!(on_menu_bar(&ctx, inside), "just drawn"); + assert!(!on_menu_bar(&ctx, egui::pos2(500.0, 10.0)), "outside the titles"); + // A few passes later, with the menu bar not drawn again (hidden): the rect is stale. + for _ in 0..3 { + // The output is dropped: its texture deltas must be cleared first, or egui panics. + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } + assert!(!on_menu_bar(&ctx, inside), "stale: the bar is no longer drawn"); + // Drawn again: fresh again. + set_menu_bar_rect(&ctx, bar); + assert!(on_menu_bar(&ctx, inside), "drawn again"); + } +} diff --git a/crates/ui-egui/src/canvas/zoom_levels.rs b/crates/ui-egui/src/canvas/zoom_levels.rs index a77f6cc35c..27572dbf9a 100644 --- a/crates/ui-egui/src/canvas/zoom_levels.rs +++ b/crates/ui-egui/src/canvas/zoom_levels.rs @@ -90,6 +90,17 @@ pub fn step(zoom: f32, dir: i32, size: [u32; 2]) -> f32 { step_in_range(zoom, dir, min(size), MAX) } +/// Applies the percent a zoom field reported as changed, as Zoom In/Out and Actual Pixels do: the +/// zoom is set to the target at once, and the camera glides there when General › Animated Zoom is +/// on. A scrub (a drag on the field or its label) follows the pointer, with no glide. Any later zoom +/// replaces the transition, and the zoom lands exactly on the target. +pub fn set_from_field(view: &mut crate::state::View, scrub: bool, percent: f32, animated_zoom: bool) { + view.zoom = clamp(percent / 100.0, view.doc_size); + view.fit_pending = false; + view.fill_pending = false; + view.request_zoom_animation(!scrub && animated_zoom); +} + #[cfg(test)] mod tests { use super::*; @@ -170,4 +181,22 @@ mod tests { assert_eq!(clamp(f32::INFINITY, PHOTO), 1.0); assert_eq!(clamp(3.3, PHOTO), 3.3); } + + #[test] + fn a_typed_zoom_lands_exactly_and_glides_only_with_animated_zoom() { + let mut v = crate::state::View { zoom: 1.0, doc_size: PHOTO, fit_pending: true, ..Default::default() }; + set_from_field(&mut v, false, 1600.0, true); + assert_eq!(v.zoom, 16.0, "the target, exactly"); + assert!(v.zoom_animating && !v.fit_pending, "Animated Zoom on: the camera glides there"); + let mut v = crate::state::View { zoom: 1.0 / 7.5, doc_size: PHOTO, ..Default::default() }; + set_from_field(&mut v, false, 6400.0, false); + assert_eq!(v.zoom, 64.0); + assert!(!v.zoom_animating, "Animated Zoom off: a jump"); + assert_eq!(v.display_camera, Some((64.0, v.center))); + set_from_field(&mut v, true, 200.0, true); + assert_eq!(v.zoom, 2.0); + assert!(!v.zoom_animating, "a scrub follows the pointer, with no glide"); + set_from_field(&mut v, false, 1.0e9, true); + assert_eq!(v.zoom, MAX, "the target is clamped to the zoom range"); + } } diff --git a/crates/ui-egui/src/canvas/zoom_window_fit.rs b/crates/ui-egui/src/canvas/zoom_window_fit.rs index 9667c95949..75e2f7495f 100644 --- a/crates/ui-egui/src/canvas/zoom_window_fit.rs +++ b/crates/ui-egui/src/canvas/zoom_window_fit.rs @@ -273,7 +273,7 @@ mod tests { 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 window = DocWindow { id: 3, document: 0, view: at(1.0), open: true, position: None, floating: false, focused: false }; 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); @@ -359,7 +359,7 @@ mod tests { 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 window = DocWindow { id: 4, document: 0, view: view([400, 300], 1.0), open: true, position: None, floating: false, focused: false }; 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. @@ -377,7 +377,7 @@ mod tests { 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 }; + let window = DocWindow { id: 5, document: 0, view: view([400, 300], 1.0), open: true, position: None, floating: false, focused: false }; for (maximized, fullscreen) in [(true, false), (false, true)] { let ctx = Context::default(); let mut raw = egui::RawInput::default(); diff --git a/crates/ui-egui/src/color_panel_tests.rs b/crates/ui-egui/src/color_panel_tests.rs index a3c1f4b9b0..c544aad4e3 100644 --- a/crates/ui-egui/src/color_panel_tests.rs +++ b/crates/ui-egui/src/color_panel_tests.rs @@ -560,3 +560,46 @@ fn every_slider_mode_recolours_the_selected_shape() { assert_eq!(shape_fill(h.state()), [255, 255, 255], "{mode:?}"); } } + +/// #9592: the Color panel's options survive a restart (they ride in the preferences), as in +/// Photoshop; preferences without them keep the defaults. +#[test] +fn color_panel_options_survive_a_restart() { + let mut first = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + first.ui.color_panel.mode = ColorPanelMode::HsbSliders; + first.ui.color_panel.ramp = ColorRamp::GrayscaleRamp; + crate::color_panel_ui::persist(&mut first); + let saved = first.session.prefs().clone(); + + let mut next = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + next.session.prefs.edit(|p| *p = saved); + crate::color_panel_ui::restore(&mut next); + assert_eq!((next.ui.color_panel.mode, next.ui.color_panel.ramp), (ColorPanelMode::HsbSliders, ColorRamp::GrayscaleRamp)); + + let mut fresh = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + crate::color_panel_ui::restore(&mut fresh); + assert_eq!(fresh.ui.color_panel.mode, ColorPanelMode::HueCube, "no saved options: the default"); +} + +/// CodeRabbit on #9606: a preference change made outside the panel (`prefs.set`, a reset) is kept. +/// The next frame takes it into the live panel instead of writing the panel's old options back. +#[test] +fn an_outside_preference_change_reaches_the_live_panel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.color_panel.mode = ColorPanelMode::HsbSliders; + crate::color_panel_ui::persist(&mut app); + // As prefs.set or a reset to defaults would: the stored options change under the panel. + let mut other = app.ui.color_panel; + other.mode = ColorPanelMode::GrayscaleSlider; + app.session.prefs.edit(|p| p.color_panel = serde_json::to_value(other).unwrap()); + crate::color_panel_ui::persist(&mut app); + assert_eq!(app.ui.color_panel.mode, ColorPanelMode::GrayscaleSlider, "the live panel follows the preference"); + let stored: crate::state::ColorPanelState = serde_json::from_value(app.session.prefs().color_panel.clone()).unwrap(); + assert_eq!(stored.mode, ColorPanelMode::GrayscaleSlider, "and the preference isn't overwritten"); + // A reset to defaults clears the stored options: the panel goes back to its defaults too. + app.session.prefs.edit(|p| p.color_panel = serde_json::Value::Null); + crate::color_panel_ui::persist(&mut app); + assert_eq!(app.ui.color_panel.mode, ColorPanelMode::HueCube, "reset: the default mode"); + crate::color_panel_ui::persist(&mut app); + assert_eq!(app.ui.color_panel.mode, ColorPanelMode::HueCube, "and it stays reset"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index aed074b11e..e979d3d829 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -181,3 +181,34 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest #[cfg(test)] mod tests; + +pub(super) const METHODS: &[&str] = &[ + "app.open", + "app.quit", + "app.save", + "engine.commands", + "engine.execute", + "jobs.cancel", + "jobs.list", + "ui.click", + "ui.context.choose", + "ui.dialog.apply", + "ui.dialog.cancel", + "ui.dialog.confirm", + "ui.dialog.open", + "ui.dialog.set", + "ui.focus", + "ui.gpu.simulateLoss", + "ui.inspect", + "ui.key", + "ui.menu.invoke", + "ui.menu.list", + "ui.move", + "ui.pointer", + "ui.resize", + "ui.screenshot", + "ui.set", + "ui.type", + "ui.window.close", + "ui.window.open", +]; diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index f3e2756071..262cd9d639 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -1,16 +1,18 @@ +use super::METHODS; use super::inspect::inspect; use super::params::{ BUTTONS, KEY_MODIFIERS, MAX_CLICKS, POINTER_MODIFIERS, UI_SET_FIELDS, bool_field, err, f32_field, f32_range, input_choice, input_modifiers, input_number, input_object, merged_object, modifiers_from, num_field, ok, screen_point, uint_field, whole_object, }; use super::{ControlRequest, DialogKind, Outcome, PhotocraftApp, SHELL_DIALOG, Tool, ToolEvent, Value, json, run_waiting, tool_event, wrap}; +use photocraft_engine::jobs::{CancelReport, JobId}; /// A domain handler: `Some` when it owns the request's method, `None` to let the next one try. type Handler = fn(&mut PhotocraftApp, &egui::Context, &ControlRequest) -> Option; pub(super) fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { const HANDLERS: [Handler; 5] = [engine_methods, shell_methods, dialog_methods, input_methods, app_methods]; - HANDLERS.iter().find_map(|handler| handler(app, ctx, req)).unwrap_or_else(|| err(format!("unknown method `{}`", req.method))) + HANDLERS.iter().find_map(|handler| handler(app, ctx, req)).unwrap_or_else(|| err(photocraft_engine::command_hints::unknown_method(&req.method, METHODS))) } fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { @@ -50,6 +52,9 @@ fn engine_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReq fn context_choose(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Outcome { let Some(id) = str_param(p, "id") else { return err("missing `id`") }; + if app.ui.canvas_tool_menu.is_none() && app.ui.brush_mode_menu.is_some() { + return mode_menu_choose(app, ctx, id); + } let Some(menu) = app.ui.canvas_tool_menu.as_ref() else { return err("no canvas context menu is open") }; if !crate::canvas_tool_menu::available(app, menu, id) { return err("context action is unavailable"); @@ -65,23 +70,42 @@ fn context_choose(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Ou ok(json!({"command": id, "dialog": app.ui.dialogs.last().map(|d| d.id)})) } +/// A row of the painting tools' Shift+right-click menu, by its English label (`brushModeMenu.choices`). +fn mode_menu_choose(app: &mut PhotocraftApp, ctx: &egui::Context, label: &str) -> Outcome { + let item = match crate::brush_mode_menu::find(app, label) { + Ok(item) => item, + Err(e) => return err(e), + }; + if let Some((command, params)) = crate::brush_mode_menu::engine_call(app, app.ui.tool, item) + && let Some(authorize) = app.services.automation_command.as_ref() + && let Err(error) = authorize(command, ¶ms) + { + return err(error); + } + crate::brush_mode_menu::choose(app, ctx, item); + ok(json!({"choice": label, "brushPicker": app.ui.brush_picker.is_some()})) +} + fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let Some(id) = str_param(p, "command").or(str_param(p, "id")) else { return err("missing `command`") }; + if req.method == "engine.execute" && METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() { + return err(photocraft_engine::command_hints::protocol_method_as_command(id)); + } let params = p.get("params").cloned().unwrap_or(json!({})); let wait = wait_param(p); // A control-channel menu click must respect the same modal gate as the native menu. // The documented `ui` requests of Camera Raw and the full-window workspaces operate // the already-open one rather than click a menu. Only that continuation may pass its - // own modal gate. engine.execute is deliberately not subject to the UI's menu-click semantics. + // own modal gate. Both command entry points respect document ownership. let open_modal = if app.camera_raw.is_some() { Some("filter.cameraRaw") } else { crate::distort_ui::modal_workspace(app) }; let continuation = open_modal == Some(id) && params.as_object().is_some_and(|fields| fields.len() == 1 && fields.get("ui").is_some_and(Value::is_object)) && app.ui.dialogs.is_empty() && app.discard.is_none() && !crate::crop_ui::blocks(app, id); - if req.method == "ui.menu.invoke" && !continuation && !crate::menus::modal_allows(app, id) { - return err("menu command is unavailable while a modal dialog is open"); + if !continuation && !crate::menus::modal_allows(app, id) { + return err("command is unavailable while a modal dialog is open"); } if let Some(authorize) = app.services.automation_command.as_ref() && let Err(error) = authorize(id, ¶ms) @@ -100,20 +124,29 @@ fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRe fn jobs_cancel(app: &mut PhotocraftApp, p: &Value) -> Outcome { let all = p.get("job").is_none_or(Value::is_null); match p.get("job").and_then(Value::as_u64) { - Some(j) if app.session.job(photocraft_engine::jobs::JobId(j)).is_some() => { - let cancelled = crate::jobs_ui::cancel(app, photocraft_engine::jobs::JobId(j)); - ok(json!({"cancelled": usize::from(cancelled)})) - } + // Same reply as the engine's `jobs.cancel`: `{cancelled: n}`, plus `notCancelled: + // [{job, reason}]` for the jobs left running (a save already writing its file, a print). + Some(j) if app.session.job(JobId(j)).is_some() => ok(cancel_each(app, vec![JobId(j)]).to_json()), Some(j) => err(format!("no running job {j}")), None if all => { let ids: Vec<_> = app.session.jobs().into_iter().map(|j| j.id).collect(); - let cancelled = ids.into_iter().filter(|j| crate::jobs_ui::cancel(app, *j)).count(); - ok(json!({"cancelled": cancelled})) + ok(cancel_each(app, ids).to_json()) } None => err("`job` must be a job id"), } } +fn cancel_each(app: &mut PhotocraftApp, ids: Vec) -> CancelReport { + let mut report = CancelReport::default(); + for id in ids { + match crate::jobs_ui::try_cancel(app, id) { + Ok(()) => report.cancelled.push(id), + Err(why) => report.refused.push((id, why)), + } + } + report +} + // ---- the shell: view state, windows, GPU, focus, screenshots ---- fn shell_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Option { @@ -272,6 +305,8 @@ struct ToolSet { cam_transform_on_drop: Option, tile_grid: Option, layer_filter: Option, + pen_mode: Option, + pen_path_op: Option, } impl ToolSet { @@ -291,6 +326,8 @@ impl ToolSet { // Developer overlay of the active layer's storage tiles (tile_overlay.rs). tile_grid: bool_field(p, "tileGrid")?, layer_filter: p.get("layerFilter").map(|v| crate::layer_filter_ui::patched(&app.ui, v)).transpose()?, + pen_mode: pen_mode_param(p)?, + pen_path_op: pen_path_op_param(p)?, }) } @@ -328,6 +365,12 @@ impl ToolSet { if let Some(f) = self.layer_filter { crate::layer_filter_ui::apply(&mut app.ui, f); } + if let Some(m) = self.pen_mode { + app.ui.tool_options.vector_mode = m; + } + if let Some(o) = self.pen_path_op { + app.ui.tool_options.pen_path_op = o; + } } fn apply_gradient_and_trace(&self, app: &mut PhotocraftApp) { @@ -347,6 +390,21 @@ impl ToolSet { } } +/// The Pen tools' options bar (#1397): Path or Shape mode. +fn pen_mode_param(p: &Value) -> std::result::Result, String> { + let Some(v) = p.get("penMode") else { return Ok(None) }; + v.as_str().filter(|m| matches!(*m, "path" | "shape")).map(|m| Some(m.to_string())).ok_or_else(|| "penMode must be \"path\" or \"shape\"".to_string()) +} + +/// The Pen tools' Path Operation. +fn pen_path_op_param(p: &Value) -> std::result::Result, String> { + let Some(v) = p.get("penPathOp") else { return Ok(None) }; + v.as_str() + .filter(|o| crate::vector_ui::PATH_OPS.iter().any(|(id, ..)| id == o)) + .map(|o| Some(o.to_string())) + .ok_or_else(|| "penPathOp must be one of newLayer, combine, subtract, intersect, exclude".to_string()) +} + fn gradient_blend_param(p: &Value) -> std::result::Result, String> { let Some(value) = p.get("gradientBlendMode") else { return Ok(None) }; let Some(name) = value.as_str() else { return Err("gradientBlendMode must be a blend mode name".into()) }; @@ -557,6 +615,9 @@ impl PanelSet { app.ui.mask_target = m; app.ui.properties_layer = None; app.ui.vector_mask_target &= !m; + if m { + app.target_mask_over_colour_rows(); + } app.sync_mask_targets(); } if let Some(open) = self.updates_open { @@ -893,7 +954,14 @@ fn color_picker_dialog(app: &mut PhotocraftApp, p: &Value) -> Outcome { let (Some(parent), Some(effect), Some(field)) = (u64_param(p, "parent"), str_param(p, "effect"), str_param(p, "field")) else { return err("need integer `parent` and string `effect` and `field`"); }; - return match crate::layer_style::color_picker::open(app, parent, effect, field) { + let stop = match p.get("stop") { + None | Some(Value::Null) => None, + Some(v) => match v.as_u64().and_then(|i| usize::try_from(i).ok()) { + Some(i) => Some(i), + None => return err("`stop` must be a non-negative integer"), + }, + }; + return match crate::layer_style::color_picker::open(app, parent, effect, field, stop) { Ok(id) => ok(json!({"dialog": id})), Err(error) => err(error), }; @@ -961,14 +1029,24 @@ fn discard_dialog_confirm(app: &mut PhotocraftApp, ctx: &egui::Context, choice: fn dialog_set(app: &mut PhotocraftApp, p: &Value) -> Outcome { let (Some(id), Some(field)) = (u64_param(p, "dialog"), str_param(p, "field")) else { return err("need `dialog` and `field`") }; let value = p.get("value").cloned().unwrap_or(Value::Null); + // A Color Picker previewing in this dialog owns its fields until its OK or Cancel: an edit now + // would be undone by the picker putting the dialog back as it was. + let previewed = crate::color_picker_ui::live_preview::previewed_dialogs(app).contains(&id); match app.ui.dialog_mut(id) { // A continuation belongs to the dialog that paused it, even when another // action has a valid token. Replacing it would resume or cancel that action. Some(_) if field == crate::actions::RESUME => err("the paused action token is read-only"), + Some(_) if previewed => err("confirm or cancel the color picker first"), + // A Color Picker's target is fixed when it opens: OK must set the field it previewed in. + Some(d) if matches!(field, "__colorPicker" | "__dialog" | "__field") && crate::color_picker_ui::owns(&d.fields) => { + err("a Color Picker's target is fixed when it opens") + } // A marker picks the handler OK runs: a dialog keeps the ones it was opened with. Some(d) if !d.fields.contains_key(field) && crate::dialogs::is_route_marker(field) => err(format!("`{field}` is set only by the dialog it belongs to")), Some(d) => { d.fields.insert(field.to_string(), value); + // A Color Picker's colour shows in its target dialog at once, as a drag would. + crate::color_picker_ui::live_preview::sync(app); ok(Value::Null) } None => err(format!("no dialog {id}")), @@ -1110,6 +1188,9 @@ fn pointer(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Outcome { mods: modifiers_from(&parsed.modifiers), }; for sample in parsed.events { + if crate::brush_mode_menu::blocks_control(app, ctx, sample.ev) { + continue; + } if !pointer_event_intercepted(app, ctx, &gesture, &sample) { forward_pointer_event(app, &gesture, sample); } @@ -1308,7 +1389,11 @@ fn app_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReques let p = &req.params; Some(match req.method.as_str() { "app.open" => match str_param(p, "path") { - Some(path) => app_open(app, path), + Some(path) => match p.get("approveHighMemory") { + None | Some(Value::Null) => app_open(app, path, false), + Some(Value::Bool(approved)) => app_open(app, path, *approved), + Some(_) => err("expected boolean for `approveHighMemory`"), + }, None => err("missing `path`"), }, "app.save" => match crate::FormatChoices::from_params(p, "app.save") { @@ -1327,7 +1412,9 @@ fn app_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReques }) } -fn app_open(app: &mut PhotocraftApp, path: &str) -> Outcome { +/// The `app.open` control method: opens a path through the configured automation read +/// capability, with optional high-memory approval. +fn app_open(app: &mut PhotocraftApp, path: &str, approved: bool) -> Outcome { if let Some(outcome) = open_pcraft_directory(app, path) { return outcome; } @@ -1337,7 +1424,7 @@ fn app_open(app: &mut PhotocraftApp, path: &str) -> Outcome { }; wrap(opened.and_then(|(name, bytes)| { let name = app.open_name(&name); - let warnings = app.open_automation_bytes(&name, &bytes)?; + let warnings = app.open_automation_bytes_with_approval(&name, &bytes, approved)?; // Brushes/gradients go to the preset libraries: no document, no Open Recent entry. if !crate::preset_files_ui::is_preset_file(&name) { app.opened_from(path); diff --git a/crates/ui-egui/src/control/inspect.rs b/crates/ui-egui/src/control/inspect.rs index 86b7d0d734..53df5d373f 100644 --- a/crates/ui-egui/src/control/inspect.rs +++ b/crates/ui-egui/src/control/inspect.rs @@ -31,16 +31,39 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { dialogs.extend(crate::discard_ui::inspect(app)); json!({ "window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()}, + "scratch": photocraft_raster::spill::stats(), + "memory": photocraft_raster::memory::stats(), "tool": app.ui.tool, "toolOptions": app.ui.tool_options, "perspectiveCrop": app.ui.perspective_crop, "strokeEditor": app.ui.stroke_editor, "magnetic": app.ui.magnetic, "textEdit": app.ui.text_edit, + // The paragraph box's overflow flag (set by the overlay each frame): agents + // learn the overflow without a screen; never persisted (serde skip). Gated on the + // session: the flag is a per-frame snapshot, and an `ui.inspect` running between the + // frame that ended the edit and the one that resets the flag would otherwise report + // overflow beside `"textEdit": null`. + "typeBoxOverflow": app.ui.text_edit.is_some() && app.ui.type_box_overflow, "typeTransform": app.ui.type_transform, "camTransform": app.ui.cam_transform, "layerMenu": app.ui.layer_menu, "layerPicker": app.ui.layer_picker, + "brushModeMenu": app.ui.brush_mode_menu.map(|pos| { + use crate::brush_mode_menu as m; + let tool = app.ui.tool; + let current = m::current(app, tool); + json!({ + "pos": pos, + "tool": tool, + "mode": current.and_then(m::Item::label), + "choices": m::labels(tool), + "items": m::items(tool).into_iter().map(|i| match i.label() { + Some(label) => json!({"label": label, "enabled": m::enabled(app, i), "checked": Some(i) == current}), + None => json!({"separator": true}), + }).collect::>(), + }) + }), "brushPicker": app.ui.brush_picker.map(|pos| json!({ "pos": pos, // The picker's content size once its corner grip was dragged (null: the default). @@ -88,7 +111,13 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "frame": app.frame, "session": photocraft_engine::inspect::session(&app.session), "document": app.session.active().map(photocraft_engine::inspect::document), - "perf": {"fps": app.fps, "timings": app.perf, "strokeTrace": app.stroke_trace.last, "renderPath": crate::render_path::inspect(app)}, + "perf": { + "fps": app.fps, + "timings": app.perf, + "strokeTrace": app.stroke_trace.last, + "renderPath": crate::render_path::inspect(app), + "slowFrames": app.slow_frames, + }, "brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity}, "distort": app.distort.describe(), "jobs": crate::jobs_ui::inspect(app), diff --git a/crates/ui-egui/src/control/params.rs b/crates/ui-egui/src/control/params.rs index b2d8668ecb..d294cd06fb 100644 --- a/crates/ui-egui/src/control/params.rs +++ b/crates/ui-egui/src/control/params.rs @@ -4,7 +4,7 @@ use super::{Outcome, PhotocraftApp, Value, json}; /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 43] = [ +pub const UI_SET_FIELDS: [&str; 45] = [ "updatesOpen", "tool", "strokeTrace", @@ -41,6 +41,8 @@ pub const UI_SET_FIELDS: [&str; 43] = [ "cropOverlayOrientation", "cropShield", "cropFill", + "penMode", + "penPathOp", "shapeStroke", "camTransformOnDrop", "tileGrid", diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index c7b9896e2f..d190d110b9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -8,6 +8,109 @@ fn call(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Valu } } +/// #3674: a guessed name gets a pointer to the right one, in both directions; and every +/// method listed for those hints is served. +#[test] +fn unknown_names_suggest_the_right_method_or_command() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let mut error = |method: &str, params: Value| { + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{reply}"); + reply["error"].as_str().unwrap_or_default().to_owned() + }; + let e = error("engine.execute", json!({"command": "engine.commands"})); + assert!(e.contains("did you mean `command.list`") && e.contains("protocol method"), "{e}"); + let e = error("engine.execute", json!({"command": "ui.inspect"})); + assert!(e.contains("`ui.inspect` is a protocol method"), "{e}"); + let e = error("engine.execute", json!({"command": "edit.undoo"})); + assert!(e.contains("did you mean `edit.undo`"), "{e}"); + let e = error("ui.inspct", json!({})); + assert!(e.contains("did you mean `ui.inspect`"), "{e}"); + let e = error("edit.undo", json!({})); + assert!(e.contains("engine.execute {\"command\": \"edit.undo\"}"), "{e}"); + for m in METHODS { + let (req, _rx) = ControlRequest::new(*m, json!({})); + if let Outcome::Done(v) = handle(&mut app, &ctx, &req) { + assert!(!v.to_string().contains("unknown method"), "{m} is listed but not served: {v}"); + } + } +} + +#[test] +fn shift_right_brush_does_not_open_presets_or_paint() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.ui.tool = crate::state::Tool::Brush; + let ctx = egui::Context::default(); + let before = app.session.journal.len(); + call( + &mut app, + &ctx, + "ui.pointer", + json!({"button": "right", "shift": true, + "events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}), + ); + assert!(app.ui.brush_picker.is_none(), "Shift+right opens Mode, not Brush Presets"); + assert!(app.drag.is_none()); + assert_eq!(app.session.journal.len(), before, "opening a menu does not paint or journal"); +} + +#[test] +fn brush_mode_control_routes_modifiers_and_reports_choices_without_history() { + let ctx = egui::Context::default(); + for (tool, modifiers, expected) in [ + ("brush", json!({"shift": true}), "mode"), + ("brush", json!({}), "erase"), + ("brush", json!({"alt": true}), "resize"), + ("brush", json!({"shift": true, "alt": true}), "erase"), + ("brush", json!({"shift": true, "ctrl": true}), if cfg!(target_os = "macos") { "erase" } else { "layer" }), + ("brush", json!({"shift": true, "command": true}), "layer"), + ("pencil", json!({"shift": true}), "mode"), + ("eraser", json!({"shift": true}), "mode"), + ("spotHealing", json!({"shift": true}), "preset"), + ] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + call( + &mut app, + &ctx, + "ui.pointer", + json!({"tool": tool, "button": "right", "modifiers": modifiers, + "events": [{"kind": "down", "x": 20, "y": 20}]}), + ); + assert_eq!(app.ui.brush_mode_menu.is_some(), expected == "mode", "{tool}: {modifiers}"); + match expected { + "mode" => { + assert!(app.drag.is_none() && !app.secondary_erase); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + let inspect = inspect(&app, &ctx); + assert_eq!(inspect["brushModeMenu"]["choices"], json!(crate::brush_mode_menu::labels(app.ui.tool))); + let journal = app.session.journal.len(); + call( + &mut app, + &ctx, + "ui.pointer", + json!({"events": [ + {"kind": "down", "x": 10, "y": 10}, {"kind": "move", "x": 30, "y": 30}, {"kind": "up", "x": 30, "y": 30}]}), + ); + assert!(app.ui.brush_mode_menu.is_none() && app.drag.is_none()); + assert_eq!(app.session.journal.len(), journal, "control dismissal cannot paint"); + } + "preset" => assert!(app.ui.brush_picker.is_some()), + "layer" => assert!(app.ui.layer_menu.is_some()), + "resize" => assert!(app.brush_resize.is_some()), + "erase" => { + assert!(app.drag.as_ref().is_some_and(|d| d.erase)); + assert!(app.brush_resize.is_none(), "Shift+Alt is not the resize gesture"); + } + _ => panic!("unknown fixture route: {expected}"), + } + } +} + #[test] fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -28,6 +131,39 @@ fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() { assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing"); } +#[test] +fn perf_output_includes_slow_frames() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["perf"]["slowFrames"], json!([])); + app.slow_frames.set_threshold_ms(0.0); + app.run("file.new", json!({"width": 64, "height": 32})).unwrap(); + app.finish_frame(1.0); + let frames = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["perf"]["slowFrames"].clone(); + let last = frames.as_array().and_then(|f| f.last()).expect("a slow frame").clone(); + assert_eq!(last["command"], "file.new", "{last}"); + for key in ["t", "frameMs", "inputMs", "commandMs", "damageMs", "compositeMs", "uploadMs", "gpuMs", "presentMs", "refresh", "refreshPx", "path"] { + assert!(last.get(key).is_some(), "{key} in {last}"); + } + // A nested run (`actions.play` → its view steps through `App::run`) is inside the outer + // call's time: the frame counts the outer command once, not the steps again. + app.session.actions.list.push(photocraft_engine::actions_cmds::Action { + name: "Zoom".into(), + steps: vec![("view.zoomIn".into(), json!({})), ("view.zoomOut".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + let played = app.run("actions.play", json!({"action": "Zoom"})).unwrap(); + assert_eq!(played["ran"], 2, "{played}"); + let outer_ms = app.perf.command_ms; + app.finish_frame(1.0); + let frames = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["perf"]["slowFrames"].clone(); + let last = frames.as_array().and_then(|f| f.last()).expect("a slow frame").clone(); + assert_eq!(last["command"], "actions.play", "{last}"); + let command_ms = last["commandMs"].as_f64().unwrap(); + assert!((command_ms - outer_ms).abs() < 1e-9, "the outer run's {outer_ms} ms, once: {last}"); +} + #[test] fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; @@ -134,7 +270,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { app.run("layer.new.layer", json!({})).unwrap(); app.run("edit.fill", json!({"color":"#808080"})).unwrap(); app.run("select.rect", json!({"x":8,"y":8,"width":8,"height":8})).unwrap(); - for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle"] { + for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle", "select.selectAndMask"] { let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":workspace})); assert_eq!(opened["ok"], true, "{workspace}: {opened}"); assert_eq!(crate::distort_ui::modal_workspace(&app), Some(workspace)); @@ -144,6 +280,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { json!({"id":"select.all"}), json!({"id":"view.zoomIn"}), json!({"id":"filter.cameraRaw"}), + json!({"id":"filter.liquify"}), json!({"id":workspace,"params":{"ui":null}}), ] { let blocked = call(&mut app, &ctx, "ui.menu.invoke", request.clone()); @@ -170,10 +307,16 @@ fn full_window_workspaces_are_modal_for_menu_invocations() { "{workspace}: listed as enabled again" ); } - // engine.execute stays programmatic: it is not a menu click. + // Programmatic edits cannot bypass the active workspace ownership gate. assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", json!({"id":"filter.liquify"}))["ok"], true); + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); let executed = call(&mut app, &ctx, "engine.execute", json!({"command":"select.all"})); - assert_eq!(executed["ok"], true, "{executed}"); + assert_eq!(executed["ok"], false, "{executed}"); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", json!({"id":"filter.liquify","params":{"ui":{"cancel":true}}}))["ok"], true); + assert_eq!(call(&mut app, &ctx, "engine.execute", json!({"command":"select.all"}))["ok"], true); } #[test] @@ -184,8 +327,9 @@ fn layer_style_picker_control_binds_previews_confirms_and_cancels() { let parent = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"layerStyle","effect":"gradientOverlay"}))["result"]["dialog"].as_u64(); let parent = parent.unwrap(); let before = app.ui.dialogs[0].fields.clone(); - for field in ["from", "to"] { - let reply = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":field})); + // The Gradient Overlay's colours are its stops (#5177): `stop` picks one. + for stop in [0, 1] { + let reply = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"stops","stop":stop})); assert_eq!(reply["ok"], true, "{reply}"); let picker = app.ui.dialogs.last().unwrap().id; let inspected = call(&mut app, &ctx, "ui.inspect", json!({})); @@ -193,10 +337,14 @@ fn layer_style_picker_control_binds_previews_confirms_and_cancels() { assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":parent}))["ok"], false); assert_eq!(call(&mut app, &ctx, "ui.dialog.set", json!({"dialog":picker,"field":"color","value":"#123456"}))["ok"], true); assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog":picker}))["ok"], true); - assert_eq!(app.ui.dialogs[0].fields["effects"][0]["params"][field], "#123456"); + assert_eq!(app.ui.dialogs[0].fields["effects"][0]["params"]["stops"][stop][1], "#123456"); } assert_ne!(app.ui.dialogs[0].fields, before); - call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"to"})); + let bad = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"stops","stop":"x"})); + assert_eq!(bad["ok"], false, "{bad}"); + let bad = call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"stops","stop":9})); + assert_eq!(bad["ok"], false, "{bad}"); + call(&mut app, &ctx, "ui.dialog.open", json!({"kind":"colorPicker","parent":parent,"effect":"fx1","field":"stops","stop":1})); assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog":parent}))["ok"], true); assert!(app.ui.dialogs.is_empty()); } @@ -669,12 +817,39 @@ fn menu_invoke_cannot_edit_document_behind_an_open_dialog() { assert!(app.session.active().unwrap().revision > revision); } +#[test] +fn engine_execute_cannot_change_document_behind_adjustment_dialog() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 60, "height": 40, "background": "#3366cc"})).unwrap(); + let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "image.adjustments.hueSaturation"})); + assert_eq!(opened["ok"], true, "{opened}"); + let dialog = opened["result"]["dialog"].as_u64().unwrap(); + let revision = app.session.active().unwrap().revision; + let fields = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["dialogs"][0]["fields"].clone(); + + for method in ["engine.execute", "ui.menu.invoke"] { + let blocked = call(&mut app, &ctx, method, json!({"command": "image.adjustments.invert"})); + assert_eq!(blocked["ok"], false, "{method}: {blocked}"); + assert_eq!(app.session.active().unwrap().revision, revision, "{method} changed the document"); + let snapshot = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(snapshot["result"]["dialogs"][0]["id"], dialog); + assert_eq!(snapshot["result"]["dialogs"][0]["fields"], fields); + } + + assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); + assert_eq!(call(&mut app, &ctx, "engine.execute", json!({"command": "image.adjustments.invert"}))["ok"], true); + assert!(app.session.active().unwrap().revision > revision); +} + #[test] fn ui_set_rejects_unknown_fields_before_changing_anything() { use crate::theme::ThemeKind; let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let ctx = egui::Context::default(); app.set_theme(&ctx, ThemeKind::StudioLight); + // Not the default tool, so a rejected `"tool": "move"` that still applied would show. + app.ui.tool = Tool::Brush; // `dark` was advertised to MCP clients but never read; a typo looked like success too. for params in [json!({"dark": true}), json!({"thme": "classic"}), json!({"tool": "move", "dark": true})] { let r = call(&mut app, &ctx, "ui.set", params.clone()); @@ -704,6 +879,46 @@ fn ui_set_gradient_blend_mode_validates_and_updates_options() { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } +/// The Layers panel's Artboard filter type is drivable; it has no criteria of its own. +#[test] +fn ui_set_drives_the_layers_panel_artboard_filter() { + use crate::layer_filter_ui::FilterBy; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboard"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard); + let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}})); + assert_eq!(bad["ok"], false, "{bad}"); + assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing"); +} + +/// The Layers panel's Smart Object filter takes a list of its toggles. +#[test] +fn ui_set_drives_the_layers_panel_smart_object_filter() { + use crate::layer_filter_ui::FilterBy; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "smartObject", "smart": ["missing", "embedded", "missing"]}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::SmartObject); + assert_eq!(app.ui.layer_filter_smart, ["missing", "embedded"], "toggles are kept once each"); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"smart": []}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert!(app.ui.layer_filter_smart.is_empty()); + for bad in [ + json!({"layerFilter": {"smart": ["library"]}}), + json!({"layerFilter": {"smart": "linked"}}), + json!({"layerFilter": {"by": "kind", "smart": [1]}}), + json!({"layerFilter": {"by": "smart"}}), + ] { + let r = call(&mut app, &ctx, "ui.set", bad.clone()); + assert_eq!(r["ok"], false, "{bad} → {r}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_smart.len()), (FilterBy::SmartObject, 0), "{bad} changed nothing"); + } +} + /// #5048: the Layers panel filter is drivable; a bad field changes nothing. #[test] fn ui_set_drives_the_layers_panel_filter() { @@ -713,9 +928,9 @@ fn ui_set_drives_the_layers_panel_filter() { let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "name", "name": "ink"}})); assert_eq!(ok["ok"], true, "{ok}"); assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_name.as_str()), (FilterBy::Name, "ink")); - let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "mode", "mode": "Pass Through", "kinds": ["type", "pixel", "type"]}})); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "mode", "mode": "Multiply", "kinds": ["type", "pixel", "type"]}})); assert_eq!(ok["ok"], true, "{ok}"); - assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_mode), (FilterBy::Mode, photocraft_color::BlendMode::PassThrough)); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_mode), (FilterBy::Mode, photocraft_color::BlendMode::Multiply)); assert_eq!(app.ui.layer_filter, ["type", "pixel"], "kinds are kept once each"); assert_eq!(app.ui.layer_filter_name, "ink", "fields left out keep their value"); for bad in [ @@ -725,13 +940,21 @@ fn ui_set_drives_the_layers_panel_filter() { json!({"layerFilter": {"kinds": "pixel"}}), json!({"layerFilter": {"name": 3}}), json!({"layerFilter": {"mode": "Nonsense"}}), + // The Mode list has no Pass Through (#10878). + json!({"layerFilter": {"mode": "Pass Through"}}), + json!({"layerFilter": {"color": "crimson"}}), json!({"layerFilter": {"by": "kind", "extra": true}}), json!({"layerFilter": {"by": "kind"}, "tileGrid": "yes"}), ] { let r = call(&mut app, &ctx, "ui.set", bad.clone()); assert_eq!(r["ok"], false, "{bad} → {r}"); assert_eq!(app.ui.layer_filter_by, FilterBy::Mode, "{bad} changed nothing"); + assert_eq!(app.ui.layer_filter_mode, photocraft_color::BlendMode::Multiply, "{bad} left the mode alone"); } + // #5898: the Color filter through the same path. + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "color", "color": "red"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_color), (FilterBy::Color, photocraft_doc::LabelColor::Red)); } #[test] @@ -812,6 +1035,20 @@ fn ui_set_drives_the_crop_fill() { assert!(!app.ui.tool_options.crop_content_aware); } +/// #1397: the Pen's mode and Path Operation over the control channel, reported by `ui.inspect`. +#[test] +fn ui_set_drives_the_pen_path_operation() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "pen", "penMode": "shape", "penPathOp": "subtract"}))["ok"], true); + let o = &call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["toolOptions"]; + assert_eq!((o["vector_mode"].clone(), o["pen_path_op"].clone()), (json!("shape"), json!("subtract"))); + for bad in [json!({"penPathOp": "melt"}), json!({"penPathOp": 1}), json!({"penMode": "pixels"}), json!({"penMode": "path", "penPathOp": "xor"})] { + assert_eq!(call(&mut app, &ctx, "ui.set", bad.clone())["ok"], false, "{bad}"); + assert_eq!((app.ui.tool_options.vector_mode.as_str(), app.ui.tool_options.pen_path_op.as_str()), ("shape", "subtract"), "{bad}: unchanged"); + } +} + /// #1919: the Crop tool's overlay menu over the control channel, reported by `ui.inspect`. #[test] fn ui_set_drives_the_crop_overlay_options() { @@ -2052,3 +2289,62 @@ fn ui_set_mask_target_ends_a_filter_mask_view() { let photocraft_doc::LayerContent::Smart(sm) = &l.content else { panic!("smart object") }; assert_eq!(sm.filter_mask.as_ref().unwrap().value(20, 20), 1.0, "not into the filter mask"); } + +fn shift_right(app: &mut PhotocraftApp, ctx: &egui::Context, tool: &str) { + call( + app, + ctx, + "ui.pointer", + json!({"tool": tool, "button": "right", "shift": true, + "events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}), + ); +} + +/// `ui.context.choose` picks a Shift+right-click row by label; a bad or greyed one is an error +/// that changes nothing. +#[test] +fn shift_right_menu_choose_rejects_bad_choices_and_sets_the_tool() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + // Nothing open yet. + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Multiply"}))["ok"], false); + shift_right(&mut app, &ctx, "brush"); + let menu = inspect(&app, &ctx)["brushModeMenu"].clone(); + assert_eq!(menu["tool"], json!("Brush")); + assert_eq!(menu["mode"], json!("Normal")); + assert_eq!(menu["items"][0], json!({"label": "Edit Brush…", "enabled": true, "checked": false})); + assert_eq!(menu["items"][1], json!({"separator": true})); + // The Background has no transparency: Behind and Clear are greyed. + assert!(menu["items"].as_array().unwrap().iter().any(|i| *i == json!({"label": "Clear", "enabled": false, "checked": false}))); + let journal = app.session.journal.len(); + for bad in [json!({"id": "Bogus"}), json!({"id": "Clear"}), json!({"id": "Behind"}), json!({"id": 3}), json!({}), json!({"id": "Desaturate"})] { + let reply = call(&mut app, &ctx, "ui.context.choose", bad.clone()); + assert_eq!(reply["ok"], false, "{bad}: {reply}"); + assert!(app.ui.brush_mode_menu.is_some(), "{bad}: a bad choice leaves the menu open"); + } + assert_eq!(app.session.journal.len(), journal, "bad choices change nothing"); + assert!(!app.ui.tool_options.brush_clear && !app.session.tools.brush.behind); + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Multiply"}))["ok"], true); + assert!(app.ui.brush_mode_menu.is_none()); + assert_eq!(app.session.tools.brush.mode, photocraft_doc::BlendMode::Multiply); + assert_eq!(app.session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply", "behind": false}}))); + // Another tool's rows. + shift_right(&mut app, &ctx, "eraser"); + assert_eq!(inspect(&app, &ctx)["brushModeMenu"]["choices"], json!(["Brush", "Pencil", "Block"])); + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Multiply"}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Pencil"}))["ok"], true); + assert_eq!(app.ui.tool_options.eraser_mode, "pencil"); + shift_right(&mut app, &ctx, "cloneStamp"); + let reply = call(&mut app, &ctx, "ui.context.choose", json!({"id": "Edit Brush…"})); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(app.ui.brush_picker.is_some() && app.ui.brush_mode_menu.is_none()); + // A tool change closes the menu: its rows are no longer chosen. + shift_right(&mut app, &ctx, "dodge"); + app.ui.tool = crate::state::Tool::Sponge; + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Saturate"}))["ok"], false); + assert_eq!(app.ui.tool_options.sponge_mode, "desaturate"); + // Magic Eraser has no menu. + shift_right(&mut app, &ctx, "magicEraser"); + assert!(inspect(&app, &ctx)["brushModeMenu"].is_null()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a14b42c37f..fc0c268097 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -229,6 +229,7 @@ fn show_modal( ctx: &egui::Context, id: egui::Id, resizable: Option<(egui::Vec2, egui::Vec2, egui::Vec2)>, + fixed: Option, interactive: bool, contents: impl FnOnce(&mut egui::Ui) -> T, ) -> egui::ModalResponse { @@ -237,7 +238,12 @@ fn show_modal( if !interactive { ui.disable(); } - if let Some((size, min, max)) = resizable { + if let Some(size) = fixed { + egui::Resize::default().id(id.with("resize")).with_stroke(false).fixed_size(size).show(ui, |ui| { + ui.set_clip_rect(ui.clip_rect().expand(8.0)); + contents(ui) + }) + } else if let Some((size, min, max)) = resizable { // No stroke: Resize outlines its contents 2 points out, an inner box inside the // dialog's frame that left the title and buttons flush against it (#8199). egui::Resize::default().id(id.with("resize")).with_stroke(false).default_size(size.min(max)).min_size(min).max_size(max).show(ui, contents) @@ -274,6 +280,7 @@ pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool { fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id, interactive: bool) -> Option { let mut outcome: Option = None; match d.kind { + _ if crate::open_prompt::owns_memory(fields) => crate::open_prompt::memory_body(ui, fields), _ if crate::open_prompt::owns_damage(fields) => crate::open_prompt::damage_body(ui, fields), _ if crate::guide_layout_ui::owns(fields) => outcome = crate::guide_layout_ui::body(app, ui, fields, interactive), _ if crate::brush_preset_dialog::owns(fields) => outcome = crate::brush_preset_dialog::body(app, ui, fields, interactive), @@ -288,7 +295,9 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::delete_layer_prompt::owns(fields) => crate::delete_layer_prompt::body(ui, fields), _ if crate::disk_change_prompt::owns(fields) => crate::disk_change_prompt::body(ui, fields), _ if crate::replace_prompt::owns(fields) => crate::replace_prompt::body(ui, fields), + _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), + _ if crate::actions::owns_new_set(fields) => crate::actions::new_set_body(ui, fields), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), _ if crate::mask_trash_prompt::owns(fields) => crate::mask_trash_prompt::body(ui, fields), _ if crate::rasterize_prompt::owns(fields) => crate::rasterize_prompt::body(ui, fields), @@ -324,7 +333,14 @@ fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fie _ if crate::blur_pins::owns(fields) => crate::blur_pins::body(ui, fields), _ if fields.contains_key("__filter") => filter_body(app, ui, d, fields, id), _ if crate::new_guide_ui::owns(fields) => crate::new_guide_ui::body(ui, fields), - _ if fields.contains_key("__form") => crate::view_cmds::form_body(ui, fields), + _ if fields.contains_key("__form") => { + if fields.get("__command").and_then(Value::as_str) == Some("file.fileInfo") { + let room = (ui.ctx().content_rect().height() - DIALOG_CHROME).max(120.0); + egui::ScrollArea::vertical().id_salt(("file-info-body", d.id)).max_height(room).show(ui, |ui| crate::view_cmds::form_body(ui, fields)); + } else { + crate::view_cmds::form_body(ui, fields); + } + } _ => {} } outcome @@ -349,7 +365,7 @@ fn dialog_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: & } } DialogKind::About => { - // Tabs About · Contributors · Models (craftrules standards/contributors.md). The + // Tabs About · Contributors · Models. The // tab is a dialog field, so automation can switch it with `ui.dialog.set`. let tab = about_tab(fields); let mut chosen = tab; @@ -367,7 +383,7 @@ fn dialog_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: & about_tab_body(app, ui, chosen); } DialogKind::Command => outcome = command_body(app, ui, d, fields, id, interactive), - DialogKind::LayerStyle => crate::layer_style::body(app, ui, fields), + DialogKind::LayerStyle => outcome = crate::layer_style::body_with_response(app, ui, fields, interactive), DialogKind::Error => { ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error")); } @@ -475,6 +491,17 @@ fn button_row( fields.insert("choice".into(), json!("rasterize")); } hit + } else if crate::preset_group_prompt::owns(fields) { + // Import as New Group / Cancel / Replace (#10640): Enter keeps the group. + let new = DialogButton::new(ButtonRole::Default, tl!("Import as New Group"), 150.0); + let replace = DialogButton::new(ButtonRole::Alternate, tl!("Replace"), 84.0); + let hit = dialog_buttons(ui, &[new, cancel, replace]); + if hit == Some(ButtonRole::Alternate) { + fields.insert("choice".into(), json!("replace")); + } else if hit == Some(ButtonRole::Default) { + fields.insert("choice".into(), json!("new")); + } + hit } else { dialog_buttons(ui, &[ok, cancel]) }; @@ -494,6 +521,8 @@ fn button_row( fn ok_label(d: &Dialog) -> &'static str { if d.kind == DialogKind::NewDocument { tl!("Create") + } else if crate::open_prompt::owns_memory(&d.fields) { + tl!("Open anyway") } else if crate::open_prompt::owns_damage(&d.fields) { tl!("Continue") } else if crate::delete_layer_prompt::owns(&d.fields) { @@ -584,7 +613,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { (remembered.unwrap_or(size), min, resizable_max(ctx, &fields, min)) }); let mut given = None; - let modal = show_modal(modal, ctx, id, resize, interactive, |ui| { + let fixed = (d.kind == DialogKind::LayerStyle) + .then(|| egui::vec2(800.0, 550.0).min((ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(640.0, 360.0)))); + let modal = show_modal(modal, ctx, id, resize, fixed, interactive, |ui| { sizing = ui.is_sizing_pass(); let available = ui.available_size(); given = Some(available); @@ -611,7 +642,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.set_min_width(700.0); } ui.set_max_width(wide.unwrap_or_else(|| default_max_width(&d, compact, curves, about_tabs, available.x))); - if web { + if web || fixed.is_some() { ui.set_max_width(available.x); ui.set_min_size(available); } else if let Some(w) = crate::file_ui::dialog_width(&d.fields) { @@ -625,7 +656,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.set_max_width(360.0); } if color_picker { - ui.set_width(crate::color_picker_ui::CONTENT_WIDTH); + ui.set_width(crate::color_picker_ui::content_width(ui)); } let label = egui::Label::new(egui::RichText::new(&title).font(crate::theme::semibold(15.0))).selectable(false); let t = if crate::color_picker_ui::owns(&fields) { ui.add_sized(egui::vec2(ui.available_width(), 22.0), label).rect } else { ui.add(label).rect }; @@ -651,7 +682,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if let Some(Value::String(key)) = fields.remove(BROWSE_FIELD) { browse_field(app, d.id, &fields, &key); } - if crate::guide_layout_ui::owns(&fields) || crate::brush_preset_dialog::owns(&fields) { + if d.kind == DialogKind::LayerStyle || crate::guide_layout_ui::owns(&fields) || crate::brush_preset_dialog::owns(&fields) { // These dialogs own their right-side actions and validate Enter themselves. } else if crate::color_picker_ui::owns(&fields) { if interactive && outcome.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -689,7 +720,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.data_mut(|m| m.insert_persisted(size_id(place), size)); } // Pin once laid out at its real size (the first frame is an invisible sizing pass). - if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) { + if !sizing && (web || fixed.is_some() || pinned.is_none() || drag != egui::Vec2::ZERO) { let screen = ctx.content_rect(); // Keep the whole dialog (and so its title bar) on screen. let room = (screen.size() - modal.response.rect.size()).max(egui::Vec2::ZERO); @@ -738,6 +769,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } ctx.data_mut(|m| m.insert_temp(egui::Id::unique(RECTS), shown)); + // A colour picked this frame shows in its target dialog on the next one. + if crate::color_picker_ui::live_preview::sync(app) { + ctx.request_repaint(); + } } /// The About window's tabs, as stored in its `tab` field. @@ -771,6 +806,11 @@ fn about_tab_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, tab: &str) { /// The dialog title as shown: [`title`] in the UI language. fn display_title(d: &Dialog) -> String { + match d.fields.get("__colorPicker").and_then(Value::as_str) { + Some("foreground") => return crate::color_picker_ui::fixed_title("Color Picker (Foreground Color)", "Foreground Color"), + Some("background") => return crate::color_picker_ui::fixed_title("Color Picker (Background Color)", "Background Color"), + _ => {} + } if crate::smart_filter_dialog::owns(&d.fields) { return crate::smart_filter_dialog::title(&d.fields); } @@ -813,6 +853,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::delete_layer_prompt::owns, crate::disk_change_prompt::owns, crate::actions::owns_insert_stop, + crate::actions::owns_new_set, crate::new_layer_ui::owns, crate::new_group_ui::owns, crate::mask_trash_prompt::owns, @@ -829,6 +870,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::prefs_ui::owns, crate::open_prompt::owns, crate::replace_prompt::owns, + crate::preset_group_prompt::owns, crate::layer_style::color_picker::owns, |f| f.contains_key("__export"), |f| f.contains_key("__smartFilter"), @@ -889,10 +931,11 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result edit, Err(e) => { app.filter_preview = None; + crate::actions::abandoned(app, &d.fields); return Err(e); } }; - let result = app.run(&cmd, params.clone()); + let result = crate::actions::run_step_dialog(app, &d.fields, &cmd, params.clone()); // A filter that runs as a background job keeps its preview on screen until the job // lands (`canvas::committed_filter_preview`) instead of flashing the unfiltered image. let running = @@ -908,6 +951,10 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Result Result<(), String> { +pub(crate) fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { if crate::layer_style::color_picker::has_child(app, id) { return Err("confirm or cancel the Layer Style color picker first".into()); } @@ -940,10 +987,15 @@ fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { { authorize(command, &Value::Object(d.fields.clone()))?; } + // A colour field shows the open picker's colour before its OK (`live_preview`): the dialog + // would otherwise commit a colour the picker may still cancel. + // The live preview's own record of the parent counts too: automation may rewrite `__dialog`. + if app.ui.dialogs.iter().any(|child| crate::color_picker_ui::owns(&child.fields) && child.fields.get("__dialog").and_then(Value::as_u64) == Some(id)) + || crate::color_picker_ui::live_preview::previewed_dialogs(app).contains(&id) + { + return Err("confirm or cancel the color picker first".into()); + } if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::guide_layout_ui::owns(&d.fields)) { - if app.ui.dialogs.iter().any(|child| crate::color_picker_ui::owns(&child.fields) && child.fields.get("__dialog").and_then(Value::as_u64) == Some(id)) { - return Err("confirm or cancel the guide color picker first".into()); - } crate::guide_layout_ui::validate(app, &d.fields)?; } Ok(()) @@ -954,6 +1006,7 @@ fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { /// and small editors are here, the rest in `confirm_command_more`. fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result { match d.kind { + _ if crate::open_prompt::owns_memory(&d.fields) => crate::open_prompt::answer_memory(app, true), _ if crate::open_prompt::owns_damage(&d.fields) => crate::open_prompt::answer_damage(app, true), _ if crate::guide_layout_ui::owns(&d.fields) => crate::guide_layout_ui::confirm(app, &d.fields), _ if crate::brush_preset_dialog::owns(&d.fields) => crate::brush_preset_dialog::confirm(app, &d.fields), @@ -968,7 +1021,9 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result _ if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), _ if crate::disk_change_prompt::owns(&d.fields) => crate::disk_change_prompt::confirm(app, &d.fields), _ if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app), + _ if crate::preset_group_prompt::owns(&d.fields) => crate::preset_group_prompt::confirm(app, &d.fields), _ if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), + _ if crate::actions::owns_new_set(&d.fields) => crate::actions::new_set_confirm(app, &d.fields), _ if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), _ if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), _ if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), @@ -986,7 +1041,11 @@ fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result crate::file_ui::confirm(app, &d.fields), _ if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields), - _ if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields), + _ if crate::color_picker_ui::owns(&d.fields) => { + // OK sets the target as it was before the live preview showed the colour in it. + crate::color_picker_ui::live_preview::end(app, d.id); + crate::color_picker_ui::confirm(app, &d.fields) + } _ if crate::color_range_ui::owns(&d.fields) => crate::color_range_ui::confirm(app, &d.fields), _ if crate::prefs_ui::owns(&d.fields) => crate::prefs_ui::confirm(app, &d.fields), _ if d.fields.contains_key("__export") => crate::export_dialog::confirm(app, &d.fields), @@ -1019,12 +1078,32 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { /// Cancel a dialog and its dependent Layer Style picker, without applying edits. pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { let closed = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; + // A Color Picker puts back what its live preview showed in its target. + crate::color_picker_ui::live_preview::end(app, id); + // A field Color Picker of this dialog has no target left: it closes with the dialog. + // A picker with a live target goes by the parent recorded when it opened; `__dialog` only + // names the parent of one without (e.g. its parent was closed before it could preview). + let mut pickers = crate::color_picker_ui::live_preview::pickers_of(app, id); + pickers.extend( + app.ui + .dialogs + .iter() + .filter(|d| crate::color_picker_ui::owns(&d.fields) && d.fields.get("__dialog").and_then(Value::as_u64) == Some(id)) + .filter(|d| !crate::color_picker_ui::live_preview::previews(app, d.id)) + .map(|d| d.id), + ); + for picker in &pickers { + crate::color_picker_ui::live_preview::end(app, *picker); + } + app.ui.dialogs.retain(|d| !pickers.contains(&d.id)); crate::actions::stopped(app, &closed.fields); if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } // Cancel on a profile prompt opens neither its file nor the ones still to come. - if crate::open_prompt::owns_damage(&closed.fields) { + if crate::open_prompt::owns_memory(&closed.fields) { + crate::open_prompt::answer_memory(app, false)?; + } else if crate::open_prompt::owns_damage(&closed.fields) { crate::open_prompt::answer_damage(app, false)?; } else if crate::open_prompt::owns(&closed.fields) { crate::open_prompt::answer(app, None)?; @@ -1033,6 +1112,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { if crate::replace_prompt::owns(&closed.fields) { crate::replace_prompt::cancel(app)?; } + // Cancel on the group-name question imports nothing. + if crate::preset_group_prompt::owns(&closed.fields) { + crate::preset_group_prompt::cancel(app); + } app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); app.filter_preview = None; app.color_range = None; @@ -1083,6 +1166,7 @@ pub(super) mod camera_raw_preview_ui; pub(super) mod camera_raw_scope_ui; pub mod camera_raw_ui; pub mod channel_options_ui; +pub(crate) mod color_picker_preview; pub mod color_picker_ui; pub mod color_range_ui; pub mod color_table_ui; @@ -1118,10 +1202,12 @@ pub mod new_layer_ui; pub mod open_prompt; pub mod palette; pub(crate) mod pattern_fill_ui; +pub mod preset_group_prompt; pub mod rasterize_prompt; pub mod replace_prompt; pub(super) mod rgb_histogram; pub mod screen_picker; +pub mod select_mask_ui; pub mod shape_dialog; pub(crate) mod shape_fill_ui; pub(super) mod sizing; diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index e1d61ab659..715c0860bb 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -73,7 +73,7 @@ pub fn sample_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let Some(dialog) = app.ui.dialog_mut(id) else { return true }; dialog.fields.insert(CURVE_PICKER_BASE.into(), base); if let Value::Object(params) = params { - dialog.fields.retain(|key, _| key.starts_with("__")); + dialog.fields.retain(|key, _| key.starts_with("__") || key == "target"); dialog.fields.extend(params); } true @@ -100,19 +100,40 @@ pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64 if values.get("pencil").and_then(Value::as_bool) == Some(true) { return true; } + if values.get("target").is_some_and(|target| matches!(target.as_str(), Some("mask" | "filterMask" | "quickMask")) || target.get("channel").is_some()) { + return true; + } let Some(mode) = app.session.active().map(|s| s.doc.mode) else { return true }; if !matches!(mode, photocraft_doc::ColorMode::Rgb | photocraft_doc::ColorMode::Grayscale) { app.ui.status = tl!("Unavailable in this color mode").into(); return true; } // A mask, saved channel or Quick Mask holds its own gray levels, not the composite's. - let isolated = isolated(app); + let isolated = if values.get("target").is_some() { + app.session + .active() + .and_then(|st| { + photocraft_engine::color_channels::explicit(&values, photocraft_engine::channel_cmds::color_count(&st.doc)) + .ok() + .flatten() + .and_then(|colors| photocraft_engine::channel_cmds::curve_channel_for_colors(&st.doc, colors)) + }) + .or_else(|| isolated(app)) + } else { + isolated(app) + }; if isolated.as_ref().is_some_and(|c| c.gray.is_some()) { return true; } let Some(sample) = crate::canvas::composite_color(app, x, y) else { return true }; let gray = adjust_editors::is_gray(mode); let (shown, shown_key) = match &isolated { + Some(c) if c.components.is_some() => { + let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0); + let keys = photocraft_engine::adjust_params::channel_keys(adjust_editors::space_of(&values)); + let key = ch.checked_sub(1).and_then(|i| c.components.and_then(|colors| colors.indices().nth(i))).and_then(|i| keys.get(i)).copied().unwrap_or(""); + (ch, key) + } Some(c) => (0, c.key), None => { let ch: usize = ctx.data(|d| d.get_temp(dialog_mem("curves").with("curves-ch"))).unwrap_or(0); @@ -164,7 +185,7 @@ pub fn add_point_at(app: &mut PhotocraftApp, ctx: &egui::Context, x: f64, y: f64 && let Some(dialog) = app.ui.dialog_mut(id) && let Value::Object(v) = values { - dialog.fields.retain(|k, _| k.starts_with("__")); + dialog.fields.retain(|k, _| k.starts_with("__") || k == "target"); dialog.fields.extend(v); if picker(dialog.fields.get(CURVE_PICKER)).is_some() { let base = crate::filter_dialog::params_of(&dialog.fields); @@ -241,13 +262,13 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { if let Value::Object(p) = photocraft_engine::adjust_params::to_params(&defaults) { fields.extend(p); } + if app.filter_mask_targeted() && photocraft_engine::channel_cmds::follows_filter_mask(command) { + fields.insert("target".into(), json!("filterMask")); + } else if app.layer_mask_targeted() { + fields.insert("target".into(), json!("mask")); + } else if let Some(colors) = photocraft_engine::color_channels::selected(&app.session) { + fields.insert("target".into(), colors.params()); + } Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// Whether the dialog's captured target is a set of several Lab components, whose Levels and +/// Curves settings are kept as a separate combined record. +fn combined_lab_target(fields: &Map) -> bool { + photocraft_engine::color_channels::explicit(&crate::filter_dialog::params_of(fields), 3).ok().flatten().is_some_and(|c| c.len() > 1) +} + /// Opens the dialog for `command` at the settings last confirmed in it (Photoshop's ⌥ plus the /// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they /// don't fit the current colour mode. @@ -288,8 +322,26 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { let mode = curve_mode(app, kind); let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) }; let Ok(adjustment) = photocraft_engine::adjust_params::from_params(kind, &last, None, mode) else { return Some(id) }; - if let (Some(dialog), Value::Object(values)) = (app.ui.dialog_mut(id), photocraft_engine::adjust_params::to_params(&adjustment)) { - dialog.fields.retain(|k, _| k.starts_with("__")); + let mut restored = photocraft_engine::adjust_params::to_params(&adjustment); + // The target `open` captured, as `remember` reads it: a viewed layer mask wins over the + // Channels panel's colour set, so its settings are restored as saved. + let captured_set = app.ui.dialog_mut(id).is_some_and(|d| combined_lab_target(&d.fields)); + if mode == photocraft_doc::ColorMode::Lab && matches!(kind, "levels" | "curves") && captured_set { + // The parser validates all records, but ordinary Lab serialization aliases the master + // to Lightness. A partial set keeps the separately saved combined record. + let neutral = photocraft_engine::adjust_params::default_for(kind, photocraft_doc::ColorMode::Grayscale) + .ok() + .map(|a| photocraft_engine::adjust_params::to_params(&a)) + .unwrap_or(Value::Null); + let combined = if last.get("__combinedLab").and_then(Value::as_bool) == Some(true) { &last } else { &neutral }; + for key in ["points", "inBlack", "inWhite", "gamma", "outBlack", "outWhite"] { + if let Some(value) = combined.get(key) { + restored[key] = value.clone(); + } + } + } + if let (Some(dialog), Value::Object(values)) = (app.ui.dialog_mut(id), restored) { + dialog.fields.retain(|k, _| k.starts_with("__") || k == "target"); dialog.fields.extend(values); } Some(id) @@ -302,7 +354,16 @@ pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { return; } let Some(command) = fields.get("__command").and_then(Value::as_str).filter(|c| has_dialog(c)) else { return }; - let values = crate::filter_dialog::params_of(fields); + let mut values = crate::filter_dialog::params_of(fields); + if let Some(o) = values.as_object_mut() { + o.remove("target"); + } + if app.session.active().is_some_and(|st| st.doc.mode == photocraft_doc::ColorMode::Lab) + && combined_lab_target(fields) + && matches!(command.strip_prefix(PREFIX), Some("levels" | "curves")) + { + values["__combinedLab"] = json!(true); + } app.session.prefs.edit(|prefs| prefs.dialogs.insert(command.into(), values)); } @@ -325,10 +386,25 @@ fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { photocraft_doc::ColorMode::Grayscale } else { mode } } +/// A dialog keeps its captured space even if the Channels panel changes before Reset or repaint. +fn curve_mode_for_fields(app: &PhotocraftApp, kind: &str, fields: &Map) -> photocraft_doc::ColorMode { + if kind == "curves" + && let Some(target) = fields.get("target") + { + if matches!(target.as_str(), Some("mask" | "filterMask" | "quickMask")) || target.get("channel").is_some() { + return photocraft_doc::ColorMode::Grayscale; + } + return app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |st| st.doc.mode); + } + curve_mode(app, kind) +} + /// Dialog body: the kind's editor over the fields, then the Preview checkbox. pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); - let mode = curve_mode(app, &kind); + let mask = fields.get("target").and_then(Value::as_str) == Some("mask"); + let filter_mask = fields.get("target").and_then(Value::as_str) == Some("filterMask"); + let mode = curve_mode_for_fields(app, &kind, fields); if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); @@ -340,7 +416,23 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map HistSource::Channel { layer: id, surface }, - None => HistSource::Layer(id), + None => { + if let Some(colors) = isolated_curve.as_ref().and_then(|c| c.components) { + HistSource::Colors { layer: id, colors } + } else { + HistSource::Layer(id) + } + } }; Some(tone::histograms(app, source, adjust_editors::space_of(&values))) } @@ -374,7 +472,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Harness<'static, PhotocraftApp> { fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec { h.query_all_by_role(egui::accesskit::Role::ComboBox).filter_map(|n| n.value().map(|s| s.to_string())).collect() } +#[test] +fn lab_subset_recall_preserves_combined_record_beside_native_lightness() { + for kind in ["levels", "curves"] { + let mut app = app_with_image(); + app.run("file.new", json!({"width":32,"height":24,"mode":"lab","depth":16})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + let command = format!("image.adjustments.{kind}"); + let id = open(&mut app, &command).unwrap(); + let dialog = app.ui.dialog_mut(id).unwrap(); + let (key, combined) = if kind == "curves" { ("points", json!([[0, 0], [100, 170], [255, 255]])) } else { ("gamma", json!(1.7)) }; + dialog.fields.insert(key.into(), combined.clone()); + let fields = dialog.fields.clone(); + let lightness = fields["lightness"].clone(); + remember(&mut app, &fields); + app.ui.dialogs.clear(); + let id = open_last(&mut app, &command).unwrap(); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(fields[key], combined); + assert_eq!(fields["lightness"], lightness); + assert_eq!(fields["target"], json!({"colors":[0,2]})); + } +} +#[test] +fn levels_recall_on_a_viewed_mask_keeps_its_settings_beside_a_lab_colour_set() { + let mut app = app_with_image(); + app.run("file.new", json!({"width":32,"height":24,"mode":"lab","depth":16})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.run("view.layerMask", json!({"mode":"gray"})).unwrap(); + let command = "image.adjustments.levels"; + let id = open(&mut app, command).unwrap(); + let dialog = app.ui.dialog_mut(id).unwrap(); + assert_eq!(dialog.fields["target"], json!("mask")); + // Lab Levels alias the master to the Lightness record; the editor keeps both in step. + let lightness = json!({"gamma":1.5,"inBlack":20.0,"inWhite":255.0,"outBlack":0.0,"outWhite":255.0}); + dialog.fields.insert("lightness".into(), lightness.clone()); + dialog.fields.insert("gamma".into(), json!(1.5)); + dialog.fields.insert("inBlack".into(), json!(20.0)); + let fields = dialog.fields.clone(); + remember(&mut app, &fields); + app.ui.dialogs.clear(); + let id = open_last(&mut app, command).unwrap(); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(fields["target"], json!("mask")); + assert_eq!(fields["gamma"], json!(1.5)); + assert_eq!(fields["inBlack"], json!(20.0)); + assert_eq!(fields["lightness"], lightness); +} +#[test] +fn curves_reset_keeps_the_captured_mask_or_native_space_after_retargeting() { + for mask in [false, true] { + let mut app = app_with_image(); + app.run("file.new", json!({"width":32,"height":24,"mode":"lab","depth":16})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + if mask { + app.run("view.layerMask", json!({"mode":"gray"})).unwrap(); + } + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + let target = fields["target"].clone(); + if mask { + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + } else { + app.run("view.layerMask", json!({"mode":"gray"})).unwrap(); + } + reset(&app, &egui::Context::default(), &mut fields); + assert_eq!(fields["target"], target); + assert_eq!(fields.contains_key("a"), !mask, "mask curves stay gray; native curves stay Lab"); + } +} /// FILE-215-3: Curves on a targeted filter mask edits a gray plane, and its histogram is the /// filter mask's, not the smart object's colours. @@ -849,3 +937,33 @@ fn curves_on_a_targeted_filter_mask_is_a_gray_plane() { assert!(h.ch[0][0] > 0 && h.ch[0][255] > 0, "black and white levels"); assert_eq!(curve_mode(&app, "curves"), photocraft_doc::ColorMode::Grayscale); } + +#[test] +fn captured_filter_mask_curves_stay_gray_after_selecting_native_channels() { + let mut app = app_with_image(); + app.run("file.new", json!({"width":32,"height":24,"mode":"lab","depth":16})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"contents":"white"})).unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius":2})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("paint.stroke", json!({"points":[[8,8]],"size":8,"color":"#000000","target":"filterMask"})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.run("view.layerMask", json!({"mode":"gray","mask":"filter"})).unwrap(); + assert_eq!(crate::canvas::paint_target(&app), json!("filterMask")); + assert!(app.filter_mask_targeted()); + assert!(!app.layer_mask_targeted()); + assert_eq!(app.ui.filter_mask_target, Some(layer.0)); + let id = open(&mut app, "image.adjustments.curves").unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + assert_eq!(fields["target"], json!("filterMask")); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + assert_eq!(curve_mode_for_fields(&app, "curves", &fields), photocraft_doc::ColorMode::Grayscale); + reset(&app, &egui::Context::default(), &mut fields); + assert_eq!(fields["target"], json!("filterMask")); + assert!(!fields.contains_key("a")); + let before = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields); + assert!(add_point_at(&mut app, &egui::Context::default(), 8.0, 8.0, false)); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before); +} diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index cdee0d6502..c2c503d05c 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -205,15 +205,17 @@ fn color32(c: [f32; 3]) -> Color32 { Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) } -/// A colour swatch button (egui's picker); returns the edit. -fn color_button(ui: &mut egui::Ui, c: &mut [f32; 3]) -> Edit { +/// A colour swatch that opens the Color Picker titled after `name` ("Color Picker (Filter +/// Color)"); the colour previews while the picker is open and commits once when it closes (OK, or +/// Cancel back to the opening colour after a preview). Cancel without a preview is no edit. +fn color_button(ui: &mut egui::Ui, c: &mut [f32; 3], name: &str) -> Edit { let mut srgb = [c[0], c[1], c[2]].map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8); - let r = crate::widgets::color_edit_button_srgb(ui, &mut srgb); + let r = crate::widgets::color_edit_button_srgb(ui, &mut srgb, &crate::color_picker_ui::title_for(name)); if r.changed() { *c = srgb.map(|x| f32::from(x) / 255.0); } - // The popup edits continuously; each change is a complete value. - Edit::discrete(r.changed()) + let previewing = crate::color_picker_ui::widget_picker_open(ui.ctx(), r.id); + Edit { changed: r.changed(), commit: r.changed() && !previewing, ..Default::default() } } fn label(ui: &mut egui::Ui, text: &str) { diff --git a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs index 18c740ab80..6a0e9694b6 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs @@ -176,7 +176,7 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed e.add(Edit::discrete(true)); } let mut c = rgb_of(v, "tintColor", [0.882, 0.827, 0.702]); - let r = ui.add_enabled_ui(on, |ui| color_button(ui, &mut c)).inner; + let r = ui.add_enabled_ui(on, |ui| color_button(ui, &mut c, tl!("Tint Color"))).inner; if r.changed { v["tintColor"] = json!(adjust_params::hex(c)); } @@ -221,7 +221,7 @@ pub(super) fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> E if ui.radio(use_color, tl!("Color:")).clicked() { use_color = true; } - let r = ui.add_enabled_ui(use_color, |ui| color_button(ui, &mut c)).inner; + let r = ui.add_enabled_ui(use_color, |ui| color_button(ui, &mut c, tl!("Photo Filter"))).inner; if r.changed { v["color"] = json!(adjust_params::hex(c)); } diff --git a/crates/ui-egui/src/dialogs/adjust_editors/curves.rs b/crates/ui-egui/src/dialogs/adjust_editors/curves.rs index 4b1867e83b..e04e9e4ce8 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/curves.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/curves.rs @@ -40,9 +40,32 @@ pub fn tone_channels(space: ToneSpace, gray: bool) -> &'static [ToneChannel] { } } -pub(super) fn channel_picker(ui: &mut egui::Ui, id: egui::Id, chans: &[ToneChannel], text: &str) -> usize { - let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); - channel_picker_opts(ui, id, &opts, text, true) +/// Visible tone records and their histogram indices. A partial selection has a neutral combined +/// entry followed by the selected native components; Properties keeps the complete editor. +pub(super) fn editor_channels(cx: &EditorCx, space: ToneSpace) -> Vec<(ToneChannel, String, usize)> { + let channels = tone_channels(space, cx.gray); + if let Some(iso) = &cx.isolated_curve { + if let Some(colors) = iso.components { + let keys = adjust_params::channel_keys(space); + let mut result = vec![(ToneChannel { key: "", label: "" }, iso.name.clone(), 0)]; + for k in colors.indices() { + if let Some(key) = keys.get(k) { + // HistSource::Colors reserves slot 0 for the combined set, including Lab. + let histogram = k + 1; + let channel = channels.iter().find(|c| c.key == *key).copied().unwrap_or(ToneChannel { key, label: key }); + result.push((channel, tl!(channel.label).to_owned(), histogram)); + } + } + return result; + } + return vec![(ToneChannel { key: iso.key, label: "" }, if iso.translate { tl!(&iso.name).to_owned() } else { iso.name.clone() }, iso.hist)]; + } + channels.iter().enumerate().map(|(i, c)| (*c, tl!(c.label).to_owned(), i)).collect() +} + +pub(super) fn editor_channel_picker(ui: &mut egui::Ui, id: egui::Id, channels: &[(ToneChannel, String, usize)]) -> usize { + let options: Vec<_> = channels.iter().enumerate().map(|(i, (_, name, _))| (i, name.as_str())).collect(); + channel_picker_opts(ui, id, &options, tl!("Channel:"), false) } /// The popup, with `translate` for whether the built-in labels follow the interface language; @@ -106,6 +129,47 @@ pub(super) const CURVE_HANDLE_SIZE: f32 = 7.0; const CURVE_GRAPH_PADDING: f32 = 5.0; +const CURVE_SAMPLES: usize = 256; + +/// One entry per editor/channel, independent of selection, tint and stroke width. +#[derive(Clone, Default)] +struct CurvePolyline { + points: Vec<[f32; 2]>, + graph: Option, + line: Vec, +} + +impl CurvePolyline { + /// Returns whether the screen geometry changed. Moving the graph also invalidates screen + /// coordinates (for example when scrolling the Properties panel or moving the dialog). + fn update(&mut self, points: &[[f32; 2]], graph: Rect) -> bool { + if self.points == points && self.graph == Some(graph) { + return false; + } + let cp: Vec = + points.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + self.line = photocraft_compose::adjust::curve_samples::(&cp) + .into_iter() + .enumerate() + .map(|(i, y)| pos2(graph.left() + i as f32 / (CURVE_SAMPLES - 1) as f32 * graph.width(), graph.bottom() - y * graph.height())) + .collect(); + self.points.clear(); + self.points.extend_from_slice(points); + self.graph = Some(graph); + true + } +} + +fn cached_curve_line(ctx: &egui::Context, id: egui::Id, points: &[[f32; 2]], graph: Rect) -> Vec { + ctx.data_mut(|d| { + let cache = d.get_temp_mut_or_default::(id); + cache.update(points, graph); + // egui owns the submitted path. Only this small copy is needed on a cache hit; + // normalization, spline solving, sampling and screen mapping are all skipped. + cache.line.clone() + }) +} + /// The room left on screen for the Curves dialog's other rows (title, channel and mode, Input and /// Output, eyedroppers, buttons) when its graph is as tall as it can be. const CURVES_CHROME: f32 = 360.0; @@ -180,16 +244,41 @@ fn set_pencil(v: &mut Value, pencil: bool, gray: bool) -> bool { true } +/// Partial sets have a combined record even in Lab. Convert each displayed curve separately, +/// so the normal Lab serializer (which aliases the composite to Lightness) cannot discard it. +fn set_editor_pencil(v: &mut Value, pencil: bool, cx: &EditorCx) -> bool { + if cx.isolated_curve.as_ref().is_none_or(|c| c.components.is_none()) { + return set_pencil(v, pencil, cx.gray); + } + let mut next = v.clone(); + for (channel, _, _) in editor_channels(cx, space_of(v)) { + let key = curve_key(&channel); + let mut scalar = json!({"pencil":v.get("pencil").and_then(Value::as_bool).unwrap_or(false)}); + if let Some(curve) = v.get(key) { + scalar["points"] = curve.clone(); + } + if !set_pencil(&mut scalar, pencil, true) { + return false; + } + if let Some(curve) = scalar.get("points") { + next[key] = curve.clone(); + } + } + next["pencil"] = json!(pencil); + *v = next; + true +} + /// Mode buttons above the graph: points, pencil, and Smooth for the shown channel's drawing. -fn curves_mode_row(ui: &mut egui::Ui, v: &mut Value, key: &str, gray: bool) -> Edit { +fn curves_mode_row(ui: &mut egui::Ui, v: &mut Value, key: &str, cx: &EditorCx) -> Edit { let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false); let mut e = Edit::default(); ui.horizontal(|ui| { if crate::icons::button(ui, "spline", 24.0, !pencil, tl!("Edit points to modify the curve")).clicked() && pencil { - e.add(Edit::discrete(set_pencil(v, false, gray))); + e.add(Edit::discrete(set_editor_pencil(v, false, cx))); } if crate::icons::button(ui, "pencil", 24.0, pencil, tl!("Draw to modify the curve")).clicked() && !pencil { - e.add(Edit::discrete(set_pencil(v, true, gray))); + e.add(Edit::discrete(set_editor_pencil(v, true, cx))); } let label = crate::i18n::tr_ctx(crate::i18n::current(), "curves", "Smooth"); let smooth = ui @@ -210,25 +299,19 @@ fn curves_mode_row(ui: &mut egui::Ui, v: &mut Value, key: &str, gray: bool) -> E pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let t = Tokens::get(ui.ctx()); let space = space_of(v); - let chans = tone_channels(space, cx.gray); + let channels = editor_channels(cx, space); let state_id = cx.mem.with("curves"); let mut st: CurveUi = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); let isolated = cx.isolated_curve.as_ref(); - let ch = if let Some(iso) = isolated { - channel_picker_opts(ui, cx.mem.with("curves-ch"), &[(0, iso.name.as_str())], tl!("Channel:"), iso.translate) - } else { - channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")) - }; + let ch = editor_channel_picker(ui, cx.mem.with("curves-ch"), &channels); if ch != st.channel { st = CurveUi { channel: ch, ..Default::default() }; } - let chan = - if let Some(iso) = isolated { ToneChannel { key: iso.key, label: "" } } else { chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }) }; + let (chan, _, hist_i) = channels.get(ch).cloned().unwrap_or((ToneChannel { key: "", label: "" }, String::new(), 0)); let overlay_others = isolated.is_none(); - let tinted = isolated.is_some_and(|c| c.tint) || (isolated.is_none() && ch != 0); - let hist_i = isolated.map_or(ch, |c| c.hist); + let tinted = isolated.is_some_and(|c| c.tint) || (isolated.is_none_or(|c| c.components.is_some()) && ch != 0); let key = curve_key(&chan); - let mut e = curves_mode_row(ui, v, key, cx.gray); + let mut e = curves_mode_row(ui, v, key, cx); let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false); let mut pts = read_curve(v, key); let mut map = read_map(v, key); @@ -290,13 +373,8 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let vbar = Rect::from_min_max(pos2(full.left(), graph.top()), pos2(full.left() + 8.0, graph.bottom())); gradient_bar(&p, hbar, Color32::BLACK, bar_color(&chan, &t), false); gradient_bar(&p, vbar, Color32::BLACK, bar_color(&chan, &t), true); - let draw_curve = |pts: &[[f32; 2]], color: Color32, width: f32| { - let cp: Vec = - pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); - let lut = photocraft_compose::adjust::curve_lut(&cp); - let n = lut.len().max(2); - let line: Vec = - (0..n).step_by((n / 256).max(1)).map(|i| to_scr([i as f32 / (n - 1) as f32 * 255.0, lut.get(i).copied().unwrap_or(0.0) * 255.0])).collect(); + let draw_curve = |key: &str, pts: &[[f32; 2]], color: Color32, width: f32| { + let line = cached_curve_line(ui.ctx(), cx.mem.with(("curves-polyline", key)), pts, graph); p.add(egui::Shape::line(line, Stroke::new(width, color))); }; let draw_map = |map: &[f32], color: Color32, width: f32| { @@ -306,7 +384,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { }; if overlay_others && ch == 0 { // Photoshop overlays the edited channel curves on the composite. - for c in chans.iter().skip(1) { + for c in tone_channels(space, cx.gray).iter().skip(1) { if pencil { let m = read_map(v, c.key); if m.iter().enumerate().any(|(k, o)| (o - k as f32).abs() > 0.5) { @@ -316,7 +394,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } let cp = read_curve(v, c.key); if cp.len() > 2 || cp.first().is_some_and(|q| q[1] != 0.0) || cp.last().is_some_and(|q| q[1] != 255.0) { - draw_curve(&cp, bar_color(c, &t).gamma_multiply(0.8), 1.0); + draw_curve(c.key, &cp, bar_color(c, &t).gamma_multiply(0.8), 1.0); } } } @@ -324,7 +402,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { if pencil { draw_map(&map, color, 1.5); } else { - draw_curve(&pts, color, 1.5); + draw_curve(key, &pts, color, 1.5); } for (i, q) in pts.iter().enumerate().filter(|_| !pencil) { let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE)); @@ -368,6 +446,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { if drawn { write_map(v, key, &map); if space == ToneSpace::Lab + && isolated.is_none_or(|c| c.components.is_none()) && let Some(o) = v.as_object_mut() { o.remove("points"); @@ -376,7 +455,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } if changed && !pencil { v[key] = json!(pts.iter().map(|q| [q[0].round(), q[1].round()]).collect::>()); - if space == ToneSpace::Lab { + if space == ToneSpace::Lab && isolated.is_none_or(|c| c.components.is_none()) { // Lab has no composite: "points" would alias lightness. if let Some(o) = v.as_object_mut() { o.remove("points"); @@ -386,3 +465,267 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } e } + +#[cfg(test)] +mod multi_channel_tests { + use super::super::levels::read_levels; + use super::super::{auto_levels, editor}; + use super::*; + use crate::{PhotocraftApp, tone::HistSource}; + use photocraft_engine::{Session, channel_cmds}; + use std::sync::Arc; + + fn context(mode: &str) -> EditorCx { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"mode":mode})).unwrap(); + s.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + EditorCx { + mem: egui::Id::unique("subset-tone-test"), + hist: None, + gray: false, + swatches: [[0.0; 3], [1.0; 3]], + gradients: Vec::new(), + dialog: true, + dynamic_color_sliders: false, + isolated_curve: channel_cmds::isolated_curve_channel(s.active().unwrap()), + } + } + #[test] + fn combined_tone_entry_is_neutral_and_only_selected_components_are_listed() { + for (mode, space, keys, label) in [ + ("rgb", ToneSpace::Rgb, vec!["", "red", "blue"], "RB"), + ("cmyk", ToneSpace::Cmyk, vec!["", "cyan", "yellow"], "CY"), + ("lab", ToneSpace::Lab, vec!["", "lightness", "b"], "Lb"), + ] { + let cx = context(mode); + let options = editor_channels(&cx, space); + assert_eq!(options.iter().map(|(c, _, _)| c.key).collect::>(), keys); + assert_eq!(options.first().unwrap().1, label); + assert!(!cx.isolated_curve.as_ref().unwrap().tint); + } + } + #[test] + fn both_tone_editors_render_subset_choices_without_mutating_values() { + for kind in ["levels", "curves"] { + let cx = context("rgb"); + let base = + photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for(kind, photocraft_doc::ColorMode::Rgb).unwrap()); + let mut values = base.clone(); + let mut harness = egui_kittest::Harness::new_ui(|ui| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + editor(ui, kind, &mut values, &cx); + } + }); + PhotocraftApp::setup_context(&harness.ctx, Default::default()); + harness.run(); + drop(harness); + assert_eq!(values, base); + } + } + #[test] + fn subset_histograms_match_both_editors_in_all_native_spaces_and_depths() { + use egui_kittest::kittest::Queryable; + use photocraft_geom::Rect as PixelRect; + + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"mode":mode,"depth":depth})).unwrap(); + s.edit("Distinct native histogram bins", |doc, id| { + let surface = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + let mut pixel = vec![0.25, 0.5, 0.75]; + pixel.resize(surface.format().mode.color_channels(), 0.0); + pixel.push(1.0); + surface.fill_rect(PixelRect::new(0, 0, 32, 24), &pixel); + pixel[0] = 0.375; + pixel[2] = 0.875; + surface.fill_rect(PixelRect::new(16, 0, 32, 24), &pixel); + Ok(()) + }) + .unwrap(); + s.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + let st = s.active().unwrap(); + let isolated = channel_cmds::isolated_curve_channel(st).unwrap(); + let colors = isolated.components.unwrap(); + let mut cx = context(mode); + cx.isolated_curve = Some(isolated); + for kind in ["levels", "curves"] { + let base = adjust_params::to_params(&adjust_params::default_for(kind, st.doc.mode).unwrap()); + let space = space_of(&base); + let hist = Arc::new(tone::compute_histograms(&st.doc, HistSource::Colors { layer: st.active_layer.unwrap(), colors }, space)); + // Native subset histograms have their own combined slot even in Lab, whose + // ordinary composite histograms instead start with Lightness at slot 0. + assert_eq!(hist.ch.len(), 1 + st.doc.pixel_format().mode.color_channels()); + assert_eq!(hist[0].iter().sum::(), 2 * 32 * 24); + assert_eq!(hist[1].iter().sum::(), 32 * 24); + assert_eq!(hist[2], [0; 256]); + assert_eq!(hist[3].iter().sum::(), 32 * 24); + assert_ne!(hist[1], hist[3]); + cx.hist = Some(hist.clone()); + for (choice, (channel, _, histogram)) in editor_channels(&cx, space).into_iter().enumerate() { + assert!(hist[histogram].iter().any(|n| *n > 0)); + let mut values = base.clone(); + let mut harness = egui_kittest::Harness::new_ui(|ui| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + editor(ui, kind, &mut values, &cx); + } + }); + PhotocraftApp::setup_context(&harness.ctx, Default::default()); + harness.ctx.data_mut(|d| d.insert_temp(cx.mem.with(format!("{kind}-ch")), choice)); + harness.run_steps(4); + if kind == "levels" { + harness.get_by_label("Auto").click(); + harness.run_steps(2); + } + drop(harness); + if kind == "levels" { + let (lo, hi) = auto_levels(&hist[histogram]); + let levels = read_levels(&values, channel.key); + assert_eq!((levels[0], levels[2]), (lo, hi), "{mode}@{depth} {}", channel.key); + } else { + assert_eq!(values, base); + } + } + } + } + } + } + #[test] + fn lab_subset_keeps_combined_curve_in_point_pencil_and_component_edits() { + for pencil in [false, true] { + for channel in [0usize, 2] { + let cx = context("lab"); + let mut values = adjust_params::to_params(&adjust_params::default_for("curves", photocraft_doc::ColorMode::Lab).unwrap()); + values["points"] = json!([[0, 0], [100, 150], [255, 255]]); + if pencil { + assert!(set_editor_pencil(&mut values, true, &cx)); + } + let combined = values["points"].clone(); + let component = values["b"].clone(); + let mut harness = egui_kittest::Harness::new_ui(|ui| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + editor(ui, "curves", &mut values, &cx); + } + }); + PhotocraftApp::setup_context(&harness.ctx, Default::default()); + harness.ctx.data_mut(|d| d.insert_temp(cx.mem.with("curves-ch"), channel)); + harness.run_steps(4); + let graph = harness.ctx.data(|d| d.get_temp::(cx.mem.with("curves-graph"))).unwrap(); + let pos = pos2(graph.left() + graph.width() * 0.45, graph.top() + graph.height() * 0.25); + harness.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + harness.step(); + } + harness.run_steps(2); + drop(harness); + assert!(values.get("points").is_some(), "combined record lost"); + if channel == 0 { + assert_ne!(values["points"], combined); + } else { + assert_eq!(values["points"], combined); + assert_ne!(values["b"], component); + } + assert!(set_editor_pencil(&mut values, !pencil, &cx)); + assert!(values.get("points").is_some()); + assert!(values.get("b").is_some()); + } + } + } +} + +#[cfg(test)] +mod polyline_tests { + use super::*; + + #[test] + fn curve_polyline_reuses_static_geometry_and_refreshes_edits_and_rect() { + let mut cache = CurvePolyline::default(); + let mut points = [[0.0, 0.0], [128.0, 180.0], [255.0, 255.0]]; + let graph = Rect::from_min_size(pos2(20.0, 30.0), vec2(256.0, 256.0)); + assert!(cache.update(&points, graph)); + assert_eq!(cache.line.len(), CURVE_SAMPLES); + assert_eq!(cache.line.first(), Some(&graph.left_bottom())); + assert_eq!(cache.line.last(), Some(&graph.right_top())); + let line = cache.line.clone(); + assert!(!cache.update(&points, graph)); + assert_eq!(cache.line, line); + + points[1][1] = 80.0; + assert!(cache.update(&points, graph)); + assert_ne!(cache.line, line); + points[1][1] = 180.0; // Undo/preset restore must also invalidate. + assert!(cache.update(&points, graph)); + assert_eq!(cache.line, line); + + let moved = graph.translate(vec2(13.0, 25.0)); + assert!(cache.update(&points, moved)); + for (before, after) in line.iter().zip(&cache.line) { + assert!((*after - (*before + vec2(13.0, 25.0))).length() < 1e-4); + } + let resized = Rect::from_min_size(moved.min, vec2(128.0, 180.0)); + assert!(cache.update(&points, resized)); + assert_eq!(cache.line.last(), Some(&resized.right_top())); + assert!(!cache.update(&points, resized)); + } + + #[test] + fn curve_polyline_memory_is_separate_per_editor_and_channel() { + let ctx = egui::Context::default(); + let graph = Rect::from_min_size(Pos2::ZERO, vec2(256.0, 256.0)); + let identity = [[0.0, 0.0], [255.0, 255.0]]; + let inverted = [[0.0, 255.0], [255.0, 0.0]]; + let editor = egui::Id::unique("dialog"); + let master = editor.with(("curves-polyline", "points")); + let red = editor.with(("curves-polyline", "red")); + let other_editor = egui::Id::unique("properties").with(("curves-polyline", "points")); + let line = cached_curve_line(&ctx, master, &identity, graph); + assert_ne!(cached_curve_line(&ctx, red, &inverted, graph), line); + assert_ne!(cached_curve_line(&ctx, other_editor, &inverted, graph), line); + assert_eq!(cached_curve_line(&ctx, master, &identity, graph), line); + ctx.data_mut(|d| { + assert!(!d.get_temp_mut_or_default::(master).update(&identity, graph)); + assert!(!d.get_temp_mut_or_default::(red).update(&inverted, graph)); + assert!(!d.get_temp_mut_or_default::(other_editor).update(&inverted, graph)); + }); + assert_ne!(cached_curve_line(&ctx, master, &inverted, graph), line); + } + + #[test] + #[ignore = "release timing probe; run on GitHub Actions with --release --ignored --nocapture"] + fn curve_polyline_release_comparison() { + use std::{hint::black_box, time::Instant}; + let graph = Rect::from_min_size(pos2(20.0, 30.0), vec2(300.0, 300.0)); + let points = [[0.0, 0.0], [64.0, 48.0], [192.0, 208.0], [255.0, 255.0]]; + let iterations = 10_000; + let start = Instant::now(); + for _ in 0..iterations { + let cp: Vec<_> = black_box(&points).iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(); + let lut = photocraft_compose::adjust::curve_lut(&cp); + let line: Vec<_> = lut + .iter() + .enumerate() + .step_by(16) + .map(|(i, y)| pos2(graph.left() + i as f32 / 4095.0 * graph.width(), graph.bottom() - y * graph.height())) + .collect(); + black_box(line); + } + let old = start.elapsed(); + let start = Instant::now(); + for _ in 0..iterations { + let mut cache = CurvePolyline::default(); + cache.update(black_box(&points), graph); + black_box(cache.line.clone()); + } + let miss = start.elapsed(); + let ctx = egui::Context::default(); + let id = egui::Id::unique("curve-perf"); + cached_curve_line(&ctx, id, &points, graph); + let start = Instant::now(); + for _ in 0..iterations { + black_box(cached_curve_line(&ctx, id, black_box(&points), graph)); + } + let hit = start.elapsed(); + println!("{iterations} polylines: old 4096 samples {old:?}; direct 256 samples {miss:?}; cached screen line {hit:?}"); + } +} diff --git a/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs b/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs index 74dc454bb7..7079b11972 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs @@ -213,7 +213,7 @@ pub(super) fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> E if let Some(s) = stops.get_mut(selected) { ui.horizontal(|ui| { label(ui, tl!("Color:")); - let r = color_button(ui, &mut s.1); + let r = color_button(ui, &mut s.1, tl!("Stop Color")); changed |= r.changed; e.add(r); ui.add_enabled_ui(can_remove, |ui| { diff --git a/crates/ui-egui/src/dialogs/adjust_editors/levels.rs b/crates/ui-egui/src/dialogs/adjust_editors/levels.rs index f1eac2a075..5970be1f47 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/levels.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/levels.rs @@ -1,4 +1,4 @@ -use super::curves::{ToneChannel, bar_color, channel_picker, gradient_bar, hist_color, tone_channels}; +use super::curves::{ToneChannel, bar_color, editor_channel_picker, editor_channels, gradient_bar, hist_color}; use super::{Color32, Edit, EditorCx, Sense, Stroke, StrokeKind, Tokens, Value, json, label, pos2, round, space_of, tone, vec2, widgets}; // --------------------------------------------------------------------------------------------- @@ -6,7 +6,7 @@ use super::{Color32, Edit, EditorCx, Sense, Stroke, StrokeKind, Tokens, Value, j type Lv = [f32; 5]; // in_black, gamma, in_white, out_black, out_white (0–255, gamma as is) -fn read_levels(v: &Value, key: &str) -> Lv { +pub(super) fn read_levels(v: &Value, key: &str) -> Lv { let o = if key.is_empty() { Some(v) } else { v.get(key) }; let g = |k: &str, d: f32| o.and_then(|o| o.get(k)).and_then(Value::as_f64).map_or(d, |x| x as f32); [g("inBlack", 0.0), g("gamma", 1.0), g("inWhite", 255.0), g("outBlack", 0.0), g("outWhite", 255.0)] @@ -62,21 +62,21 @@ pub(super) fn nearest(xs: &[f32], x: f32) -> Option { pub(super) fn levels(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let t = Tokens::get(ui.ctx()); - let chans = tone_channels(space_of(v), cx.gray); + let channels = editor_channels(cx, space_of(v)); let mut ch = 0; let mut auto = false; ui.horizontal(|ui| { - ch = channel_picker(ui, cx.mem.with("levels-ch"), chans, tl!("Channel:")); + ch = editor_channel_picker(ui, cx.mem.with("levels-ch"), &channels); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { auto = widgets::secondary_button(ui, tl!("Auto"), 0.0).clicked(); }); }); - let chan = chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }); + let (chan, _, hist_i) = channels.get(ch).cloned().unwrap_or((ToneChannel { key: "", label: "" }, String::new(), 0)); let mut l = read_levels(v, chan.key); let mut e = Edit::default(); let mut changed = false; if auto && let Some(h) = &cx.hist { - let (lo, hi) = auto_levels(&h[ch]); + let (lo, hi) = auto_levels(&h[hist_i]); l[0] = lo; l[2] = hi; l[1] = 1.0; @@ -90,7 +90,7 @@ pub(super) fn levels(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let p = ui.painter_at(hr.expand(1.0)); p.rect_filled(hr, 0.0, t.field); if let Some(h) = &cx.hist { - tone::draw_histogram(&p, hr, &h[ch], hist_color(&chan, &t)); + tone::draw_histogram(&p, hr, &h[hist_i], hist_color(&chan, &t)); } p.rect_stroke(hr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); // Input sliders: black, gamma, white. diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index 2bbff7d596..f41fe4eca0 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -912,3 +912,86 @@ fn row_values_round_to_their_own_decimals() { assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals"); assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input"); } + +fn photo_filter_color(a: &Adjustment) -> [f32; 3] { + match a { + Adjustment::PhotoFilter { color, .. } => *color, + other => panic!("{other:?}"), + } +} + +/// Click the Photo Filter swatch, wait for its Color Picker and show `hex` in it. +fn photo_filter_picker_shows(h: &mut Harness<'_, PhotocraftApp>, hex: &str) { + h.get_by_label("Color Picker (Photo Filter)").click(); + h.run_steps(3); + let d = h.state().ui.dialogs.last().expect("the swatch opens the Color Picker"); + assert_eq!(d.fields["__label"], "Color Picker (Photo Filter)"); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!(hex)); + h.run_steps(3); +} + +#[test] +fn photo_filter_colour_picker_previews_live_cancel_restores_and_ok_is_one_step() { + let mut h = app_harness("photoFilter", "rgb", 8); + let (id, _) = layer(&h); + h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "color": "#336699"})).unwrap(); + h.run_steps(4); + // The Color: radio enables the swatch. + h.get_by_label("Color:").click(); + h.run_steps(3); + let start = photo_filter_color(&layer(&h).1); + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let before = steps(&h); + + // While the picker is open, the canvas previews its colour; nothing is committed yet. + photo_filter_picker_shows(&mut h, "#ff0000"); + let live = h.state().live_adjust.clone(); + assert!(matches!(&live, Some((l, v)) if *l == id && v["color"] == "#ff0000"), "live preview while the picker is open: {live:?}"); + assert_eq!(photo_filter_color(&layer(&h).1), start); + assert_eq!(steps(&h), before); + + // Cancel: back to the opening colour, no history step. + h.get_by_label("Cancel").click(); + h.run_steps(4); + assert!(h.state().live_adjust.is_none(), "Cancel ends the preview"); + assert_eq!(photo_filter_color(&layer(&h).1), start); + assert_eq!(steps(&h), before); + + // OK: the colour, as one history step however long it previewed. + photo_filter_picker_shows(&mut h, "#00ff80"); + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#00ff00")); + h.run_steps(3); + assert_eq!(steps(&h), before); + h.get_by_label("OK").click(); + h.run_steps(4); + assert!(h.state().live_adjust.is_none()); + assert_eq!(photo_filter_color(&layer(&h).1), [0.0, 1.0, 0.0]); + assert_eq!(steps(&h), before + 1, "OK: one history step"); +} + +/// Opening a Gradient Map stop's Color Picker and closing it without a new colour (Cancel, or OK +/// unchanged) edits nothing: a stop at a location finer than the editor's 0.001 grid (a PSD's +/// 30 % is 1229/4096) stays as it is, with no history step. +#[test] +fn gradient_map_stop_picker_closed_without_a_colour_changes_nothing() { + let mut h = app_harness("gradientMap", "rgb", 16); + let (id, _) = layer(&h); + let fine = 1229.0 / 4096.0; + h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "stops": [[0, "#000000"], [fine, "#ff0000"], [1, "#ffffff"]]})).unwrap(); + h.run_steps(4); + let before = layer(&h).1; + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let history = steps(&h); + for button in ["Cancel", "OK"] { + h.get_by_label("Color Picker (Stop Color)").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.last().expect("the stop's Color Picker").fields["__label"], "Color Picker (Stop Color)"); + h.get_by_label(button).click(); + h.run_steps(4); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(layer(&h).1, before, "{button}: the stops are untouched"); + assert_eq!(steps(&h), history, "{button}: no history step"); + assert!(h.state().live_adjust.is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/adjust_preview.rs b/crates/ui-egui/src/dialogs/adjust_preview.rs index 69b8ea3568..5d060f2c6a 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview.rs @@ -22,7 +22,7 @@ //! - the target is not a plain pixel layer (smart object, type, shape, group: the command edits //! a smart object's filters or refuses), or is itself clipped (a clipped adjustment would act on //! the whole clipping group, not just the target); -//! - the target uses Blend If (its "This Layer" ranges test the layer's adjusted colour); +//! - the target uses Blend If (its "Current Layer" ranges test the layer's adjusted colour); //! - the target's untouched area is visible (a surface without alpha whose content doesn't cover //! the canvas: the command only edits content); //! - the command targets an alpha channel, the Quick Mask or a single colour channel; @@ -225,9 +225,13 @@ fn update(app: &mut PhotocraftApp, idx: usize) -> Option<&AdjustPreview> { let (doc_id, revision, target) = (st.doc.id, st.revision, st.active_layer?); // Commands that target a channel (Channels panel / Quick Mask) or a layer mask keep the proxy // preview. - let channel_target = st.channel_view.target != photocraft_engine::channel_cmds::ChannelTarget::Composite - || st.doc.quick_mask.is_some() - || !app.with_mask_target(&format!("image.adjustments.{kind}"), Value::Null).is_null(); + let channel_target = if let Some(target) = params.get("target") { + target.as_str() != Some("pixels") + } else { + st.channel_view.target != photocraft_engine::channel_cmds::ChannelTarget::Composite + || st.doc.quick_mask.is_some() + || !app.with_mask_target(&format!("image.adjustments.{kind}"), Value::Null).is_null() + }; let session = hash(&[&doc_id.0.to_le_bytes(), &revision.to_le_bytes(), &target.0.to_le_bytes(), &[u8::from(channel_target)]]); let h = hash(&[kind.as_bytes(), params.to_string().as_bytes(), &[u8::from(preview)]]); if let Some(p) = &app.adjust_preview diff --git a/crates/ui-egui/src/dialogs/adjust_preview/tests.rs b/crates/ui-egui/src/dialogs/adjust_preview/tests.rs index a90040125a..c83966066c 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview/tests.rs @@ -461,3 +461,27 @@ fn a_reveal_all_mask_does_not_empty_the_damage_region() { assert_eq!(region, bounds, "the whole document, not the mask's empty content"); assert!(!live_switch_damage(&mut app, k2, region, bounds).is_empty(), "something is composited"); } + +#[test] +fn captured_subset_preview_stays_on_the_command_path_when_the_panel_changes() { + for kind in ["levels", "curves"] { + let (doc, target) = document(ColorMode::Rgb, SampleType::U16, false); + let mut app = app_with(doc, target); + app.session.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + let command = format!("image.adjustments.{kind}"); + let id = crate::adjust_dialog::open(&mut app, &command).unwrap(); + if let Some(values) = sample(kind).as_object() { + app.ui.dialog_mut(id).unwrap().fields.extend(values.clone()); + } + app.session.execute("channel.target", json!({"channel":"composite"})).unwrap(); + assert!(!on_layer(&mut app, 0), "captured colour set cannot use an all-channel adjustment layer"); + let params = crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields); + let doc = app.session.active().unwrap().doc.clone(); + let preview = crate::filter_dialog::preview_document(&doc, Some(target), &command, ¶ms, 1).unwrap(); + let expected = destructive(&doc, target, kind, ¶ms).unwrap(); + assert!(max_diff(&preview, &expected) <= 1e-6); + app.session.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("target".into(), json!("pixels")); + assert!(on_layer(&mut app, 0), "explicit composite target overrides the panel too"); + } +} diff --git a/crates/ui-egui/src/dialogs/caf_ui/controls.rs b/crates/ui-egui/src/dialogs/caf_ui/controls.rs index edb3087d00..6752f68a5c 100644 --- a/crates/ui-egui/src/dialogs/caf_ui/controls.rs +++ b/crates/ui-egui/src/dialogs/caf_ui/controls.rs @@ -149,6 +149,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { } pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + if crate::color_picker_ui::any_open(app) { + return; + } let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { redo(app); diff --git a/crates/ui-egui/src/dialogs/caf_ui/tests.rs b/crates/ui-egui/src/dialogs/caf_ui/tests.rs index 84071e3856..4188d07c46 100644 --- a/crates/ui-egui/src/dialogs/caf_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/caf_ui/tests.rs @@ -472,3 +472,36 @@ fn strokes_past_the_engines_limits_are_refused_at_the_brush() { assert_eq!(d["fillEdits"], json!(0)); assert!(d["error"].as_str().is_some_and(|e| e.contains("at most")), "{d}"); } + +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear(); +} + +/// With the overlay colour's Color Picker open over the workspace, its keys (brush size, undo) +/// belong to the picker, not to the workspace under it. +#[test] +fn keys_wait_while_the_overlay_colour_picker_is_open() { + use egui::{Key, Modifiers}; + let mut app = app_with_selection(); + let ctx = egui::Context::default(); + open(&mut app).unwrap(); + control(&mut app, &json!({"sampling": "rectangular"})).unwrap(); + let all = ws(&app).describe()["sampledProxyPixels"].as_u64().unwrap(); + let less = control(&mut app, &json!({"stroke": {"points": [[20.0, 20.0], [80.0, 20.0]], "size": 10}})).unwrap()["sampledProxyPixels"].as_u64().unwrap(); + assert_ne!(less, all); + let size = ws(&app).brush_size; + crate::color_picker_ui::request_for_widget(&ctx, egui::Id::unique("caf-overlay-swatch"), "Color Picker (Overlay Color)", [0.0, 1.0, 0.0]); + crate::color_picker_ui::widget_tick(&mut app, &ctx); + assert!(crate::color_picker_ui::any_open(&app)); + press(&mut app, &ctx, Key::OpenBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, size, "[ went to the picker"); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND); + assert_eq!(ws(&app).describe()["sampledProxyPixels"].as_u64(), Some(less), "Ctrl+Z went to the picker"); + app.ui.dialogs.clear(); + press(&mut app, &ctx, Key::OpenBracket, Modifiers::NONE); + assert!(ws(&app).brush_size < size); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND); + assert_eq!(ws(&app).describe()["sampledProxyPixels"].as_u64(), Some(all)); +} diff --git a/crates/ui-egui/src/dialogs/caf_ui/view.rs b/crates/ui-egui/src/dialogs/caf_ui/view.rs index a2124c9ef5..ca0579bd1b 100644 --- a/crates/ui-egui/src/dialogs/caf_ui/view.rs +++ b/crates/ui-egui/src/dialogs/caf_ui/view.rs @@ -129,7 +129,7 @@ fn caf_props_panel( widgets::slider_row(ui, tl!("Opacity"), &mut w.opts.opacity, 0.0..=100.0, "%", None); ui.horizontal(|ui| { ui.label(tl!("Color")); - widgets::color_edit_button_srgb(ui, &mut w.opts.color); + widgets::color_edit_button_srgb(ui, &mut w.opts.color, &crate::color_picker_ui::title_for(tl!("Sampling Area Overlay"))); ui.add_space(8.0); ui.label(tl!("Indicates")); widgets::dropdown( diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs index 389af64d96..426872e347 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/controls.rs @@ -210,7 +210,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.painter().rect_filled(r, 0.0, Tokens::get(ui.ctx()).scrim); }); // A window of its own (title bar, moved and resized like any window), like Adobe Camera Raw. - let builder = egui::ViewportBuilder::default().with_title(window_title).with_inner_size([1360.0, 900.0]).with_min_inner_size([900.0, 600.0]); + let builder = crate::os_window::builder().with_title(window_title).with_inner_size([1360.0, 900.0]).with_min_inner_size([900.0, 600.0]); let mut close = false; ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("camera-raw-window"), builder, |ui, class| { if ui.ctx().input(|i| i.viewport().close_requested()) { diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 87e1035a85..41fbe6170e 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -469,6 +469,9 @@ pub(super) enum Coverage { } pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + if photocraft_engine::color_channels::selected(&app.session).is_some_and(|c| c.len() > 1) { + return Err("Camera Raw requires the composite or one colour channel".into()); + } photocraft_engine::commands::find("filter.cameraRaw").map(|c| (c.enabled)(&app.session)).unwrap_or(Err("unknown command".into()))?; let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::Selection, Target::NewFilter) diff --git a/crates/ui-egui/src/dialogs/color_picker_preview.rs b/crates/ui-egui/src/dialogs/color_picker_preview.rs new file mode 100644 index 0000000000..0cb7e03afb --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_picker_preview.rs @@ -0,0 +1,213 @@ +//! Live canvas preview for Color Pickers whose OK edits the document through a command: the +//! Type tool's text colour (`type.setStyle`) and a gradient fill's colour stop +//! (`gradient.fill.stop`). +//! +//! Photoshop shows the colour under the picker's cursor on the canvas while its Color Picker is +//! open, before OK. This runs the command OK will run, with the picker's colour, on a copy of the +//! document and shows that copy on the canvas. The session's document, its revision and its +//! history are never touched: Cancel just stops showing the copy, and OK commits the same edit as +//! before in one history step. (Solid Color and shape fills have their own previews.) + +use std::collections::VecDeque; +use std::sync::Arc; + +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_geom::Rect; +use serde_json::{Map, Value}; + +use crate::PhotocraftApp; +use crate::color_picker_ui::{hex, parse_hex}; + +/// The commands previewed: the text colour (`type_tool::open_color_picker`) and a gradient fill +/// layer's colour stop (`gradient_ui::edit_stop_color`). Only commands that edit the active +/// document belong here: a preset editor's picker (`gradient.presets.stop`) changes no pixels. +const COMMANDS: [&str; 2] = ["type.setStyle", photocraft_engine::gradient_fill_cmds::STOP]; +/// Preview cache keys carry this bit so they never equal a committed document's key (0). +const TAG: u64 = 1 << 60; +/// How many previewed colours keep their ink bounds for canvas invalidation. +const AREAS: usize = 128; + +/// The last previewed copy, and the ink bounds of every recent preview of the same document +/// revision (so the canvas can refresh only the target layers between two colours). +pub(crate) struct Preview { + document: DocId, + revision: u64, + key: u64, + shown: Option>, + /// The command params `shown` was built with (OK commits the same edit for the same params). + params: String, + /// The key of a preview the command refused at this revision, so it isn't tried every frame. + refused: Option, + original_area: Rect, + areas: VecDeque<(u64, Rect)>, +} + +/// The previewed command whose Color Picker these `fields` belong to, if any. +fn command_of(fields: &Map) -> Option<&'static str> { + if fields.get("__colorPicker").and_then(Value::as_str) != Some("command") { + return None; + } + let command = fields.get("__command").and_then(Value::as_str)?; + COMMANDS.into_iter().find(|c| *c == command) +} + +/// Whether `fields` belong to a Color Picker this module previews. +pub(crate) fn owns(fields: &Map) -> bool { + command_of(fields).is_some() +} + +/// The command and params OK would run for these picker fields, or `None` when the picker +/// still shows its opening colour (nothing to preview), its colour is not valid or its params +/// name no target layer. +fn preview_params(fields: &Map) -> Option<(&'static str, Value)> { + let command = command_of(fields)?; + let color = fields.get("color").and_then(Value::as_str).and_then(parse_hex).map(hex)?; + let orig = fields.get("__orig").and_then(Value::as_str).and_then(parse_hex).map(hex); + if orig.as_deref() == Some(color.as_str()) { + return None; + } + let mut params = fields.get("__params")?.as_object()?.clone(); + // Without named layers the command falls back to the session's selected layers, which the + // throwaway preview session doesn't share: it would show another layer than OK recolours. + if target_layers(&Value::Object(params.clone())).is_empty() { + return None; + } + // The preview is a throwaway session: it has no typing session to merge into. + params.remove("coalesce"); + params.insert("color".into(), Value::String(color)); + Some((command, Value::Object(params))) +} + +/// `doc` with `params` applied through the real command, so the preview is exactly what OK +/// commits. `Err` when the command refuses (the canvas then keeps showing the document). +pub(crate) fn preview_document(doc: &Document, command: &str, params: Value) -> Result { + let mut s = photocraft_engine::Session::new(); + s.add_document(doc.clone(), None); + s.execute(command, params).map_err(|e| e.to_string())?; + let shown = s.active().ok_or("the preview has no document")?.doc.as_ref().clone(); + Ok(photocraft_engine::mode_cmds::display_document(&shown).unwrap_or(shown)) +} + +/// The layers `params` recolour: the one being edited or the fill layer (`layer`), else every +/// selected type layer (`layers`), as `type_tool::formatting_params` builds them. +fn target_layers(params: &Value) -> Vec { + let one = params.get("layer").and_then(Value::as_u64); + let many = params.get("layers").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64); + one.into_iter().chain(many).map(LayerId).collect() +} + +/// The bounds of `layers` in `doc` (empty when none has pixels yet). +fn layer_area(doc: &Document, layers: &[LayerId]) -> Rect { + layers.iter().filter_map(|id| doc.layer(*id)?.surface()).fold(Rect::EMPTY, |r, s| r.union(&s.tile_bounds())) +} + +/// The document to draw while a previewed Color Picker is the top dialog: a copy with the picked +/// colour applied. `None` (draw the document itself) without such a picker, while it shows its +/// opening colour, for another document, or when the command refuses the preview. +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + // Under Free Transform the canvas shows the transform's preview, with the layer drawn where + // the box puts it: a copy of the document would show it at its old place as well. + let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields) && app.ui.transform.is_none()) else { + forget_shown(app); + return None; + }; + // The picker edits the active document: never show its colour in another tab. + if app.session.active_index() != Some(idx) { + return None; + } + let Some((command, params)) = preview_params(&d.fields) else { + forget_shown(app); + return None; + }; + let dialog = d.id; + let opened_on = d.fields.get("__document").and_then(Value::as_u64); + let st = app.session.documents().get(idx)?; + // Another document became active while the picker was open: its layer ids mean nothing here. + if opened_on.is_some_and(|id| id != st.doc.id.0) { + forget_shown(app); + return None; + } + let (document, revision) = (st.doc.id, st.revision); + let layers = target_layers(¶ms); + let params_text = params.to_string(); + let key = (egui::Id::unique((document.0, revision, dialog, ¶ms_text)).value() & (TAG - 1)) | TAG; + + // A new document revision starts a new set of areas; the same one keeps them. + let same = app.color_picker_preview.as_ref().is_some_and(|p| p.document == document && p.revision == revision); + if !same { + let original_area = layer_area(&st.doc, &layers); + app.color_picker_preview = + Some(Preview { document, revision, key: 0, shown: None, params: String::new(), refused: None, original_area, areas: VecDeque::new() }); + } + if let Some(p) = app.color_picker_preview.as_ref() { + if let Some(shown) = p.shown.as_ref().filter(|_| p.key == key) { + return Some((shown.clone(), key)); + } + if p.refused == Some(key) { + forget_shown(app); + return None; + } + } + + let doc = st.doc.clone(); + #[cfg(test)] + tests::BUILDS.with(|b| b.set(b.get() + 1)); + let shown = match preview_document(&doc, command, params) { + Ok(shown) => Arc::new(shown), + Err(_) => { + // OK reports the refusal; the preview quietly shows the document as it is. + forget_shown(app); + if let Some(p) = app.color_picker_preview.as_mut() { + p.refused = Some(key); + } + return None; + } + }; + let p = app.color_picker_preview.as_mut()?; + // A colour change doesn't move the content, but the copy's layer may not be rendered yet + // (empty bounds): its area always covers the layer's bounds in the document. + let area = layer_area(&shown, &layers).union(&p.original_area); + if p.areas.len() >= AREAS { + p.areas.pop_front(); + } + p.areas.push_back((key, area)); + p.key = key; + p.shown = Some(shown.clone()); + p.params = params_text; + Some((shown, key)) +} + +/// The active document and the preview key on screen when that preview is exactly what OK +/// commits for these picker `fields`. OK then counts the canvas as showing the document, so the +/// commit refreshes only the target layers instead of the whole canvas. +pub(crate) fn shown_for(app: &PhotocraftApp, fields: &Map) -> Option<(DocId, u64)> { + let (_, params) = preview_params(fields)?; + let st = app.session.active()?; + let (p, params) = (app.color_picker_preview.as_ref()?, params.to_string()); + let same = p.document == st.doc.id && p.revision == st.revision && p.shown.is_some() && p.params == params; + same.then_some((p.document, p.key)) +} + +/// Drop the previewed copy (the picker closed or went back to its opening colour). The areas +/// stay, so the canvas can still refresh just the text layer on its way back to the document. +fn forget_shown(app: &mut PhotocraftApp) { + if let Some(p) = app.color_picker_preview.as_mut() { + p.shown = None; + p.key = 0; + } +} + +/// The canvas area that differs between the frames keyed `before` and `after` (0 is the document +/// itself) of revision `revision`: the target layers' bounds. `None` when either isn't ours. +pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, before: u64, after: u64) -> Option { + if before == after { + return None; + } + let p = app.color_picker_preview.as_ref().filter(|p| p.document == doc && p.revision == revision)?; + let area = |key: u64| if key == 0 { Some(p.original_area) } else { p.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) }; + // Empty bounds would tell the canvas nothing changed: let it refresh everything instead. + Some(area(before)?.union(&area(after)?)).filter(|r| !r.is_empty()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/color_picker_preview/tests.rs b/crates/ui-egui/src/dialogs/color_picker_preview/tests.rs new file mode 100644 index 0000000000..4712394862 --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_picker_preview/tests.rs @@ -0,0 +1,435 @@ +use super::*; +use photocraft_doc::{LayerContent, TextLayer}; +use serde_json::json; + +thread_local! { + /// How often this thread built a preview copy (`display_doc` runs the command). + pub(super) static BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn app_at(depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "depth": depth})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Type; + app +} + +fn new_type_layer(app: &mut PhotocraftApp, text: &str) -> LayerId { + LayerId(app.run("type.create", json!({"text": text, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap()) +} + +fn text_in(doc: &Document, id: LayerId) -> TextLayer { + match &doc.layer(id).unwrap().content { + LayerContent::Text(t) => t.clone(), + _ => panic!("not a type layer"), + } +} + +/// The colour of character `ci` (a char index) in `doc`'s type layer `id`. +fn rgb_at(doc: &Document, id: LayerId, ci: usize) -> [u8; 4] { + let t = text_in(doc, id); + let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b); + let mut at = 0; + for r in t.char_runs() { + if b < at + r.len { + return r.style.color.to_rgba8(); + } + at += r.len; + } + [0; 4] +} + +/// Open the text Color Picker and move it to `color`, as dragging in its field does. +fn pick(app: &mut PhotocraftApp, color: &str) -> u64 { + let dialog = crate::type_tool::open_color_picker(app, [0.0; 3]); + set_color(app, color); + dialog +} + +fn set_color(app: &mut PhotocraftApp, color: &str) { + app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!(color)); +} + +/// What the session holds: document, revision, dirty flag and history length. +fn committed(app: &PhotocraftApp) -> (Arc, u64, bool, usize) { + let st = app.session.active().unwrap(); + (st.doc.clone(), st.revision, st.is_dirty(), st.history.entries().len()) +} + +#[test] +fn the_canvas_shows_the_picked_colour_before_ok_without_touching_the_session() { + for depth in [8, 16, 32] { + let mut app = app_at(depth); + let id = new_type_layer(&mut app, "Hello"); + let (doc, revision, dirty, history) = committed(&app); + pick(&mut app, "#e51c33"); + + let (shown, key) = crate::canvas::display_doc(&mut app, 0); + assert_ne!(key & TAG, 0, "{depth}-bit: the canvas draws the live preview, keyed apart from the document"); + assert_eq!(rgb_at(&shown, id, 0), [229, 28, 51, 255], "{depth}-bit: the canvas shows the picked colour"); + assert_eq!(rgb_at(&shown, id, 4), [229, 28, 51, 255]); + let after = committed(&app); + assert!(Arc::ptr_eq(&after.0, &doc), "{depth}-bit: the document is not edited"); + assert_eq!((after.1, after.2, after.3), (revision, dirty, history), "{depth}-bit: no revision, dirty flag or history step"); + assert_eq!(rgb_at(&app.session.active().unwrap().doc, id, 0), [0, 0, 0, 255]); + } +} + +#[test] +fn cancel_leaves_the_text_and_history_as_they_were_and_stops_the_preview() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + let (doc, revision, dirty, history) = committed(&app); + let dialog = pick(&mut app, "#00ff00"); + assert!(display_doc(&mut app, 0).is_some()); + + app.ui.close_dialog(dialog).unwrap(); + assert!(display_doc(&mut app, 0).is_none(), "Cancel: the canvas draws the document again"); + let after = committed(&app); + assert!(Arc::ptr_eq(&after.0, &doc)); + assert_eq!((after.1, after.2, after.3), (revision, dirty, history)); + assert_eq!(rgb_at(&after.0, id, 0), [0, 0, 0, 255]); +} + +#[test] +fn ok_commits_exactly_what_the_preview_showed_in_one_history_step() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + let history = committed(&app).3; + let dialog = pick(&mut app, "#ff8800"); + let (shown, _) = display_doc(&mut app, 0).unwrap(); + + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + assert_eq!(text_in(&doc, id).char_runs(), text_in(&shown, id).char_runs(), "the preview is the committed edit"); + assert_eq!(committed(&app).3, history + 1); + assert!(display_doc(&mut app, 0).is_none(), "no preview once the picker has closed"); +} + +#[test] +fn the_preview_follows_every_colour_and_stops_at_the_opening_one() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + pick(&mut app, "#ff0000"); + let (red, red_key) = display_doc(&mut app, 0).unwrap(); + assert_eq!(rgb_at(&red, id, 0), [255, 0, 0, 255]); + let (again, again_key) = display_doc(&mut app, 0).unwrap(); + assert!(Arc::ptr_eq(&red, &again) && red_key == again_key, "an unchanged colour reuses its preview"); + + set_color(&mut app, "#0000ff"); + let (blue, blue_key) = display_doc(&mut app, 0).unwrap(); + assert_ne!(blue_key, red_key); + assert_eq!(rgb_at(&blue, id, 0), [0, 0, 255, 255]); + + // Back to the colour the picker opened on: nothing to preview, the document shows. + set_color(&mut app, "#000000"); + assert!(display_doc(&mut app, 0).is_none()); +} + +#[test] +fn only_the_selected_characters_are_previewed_in_the_new_colour() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello world"); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + document: None, + caret: 11, + anchor: 6, + session: "type-color-preview-test".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let history = committed(&app).3; + pick(&mut app, "#00ff00"); + let (shown, _) = display_doc(&mut app, 0).unwrap(); + assert_eq!(rgb_at(&shown, id, 5), [0, 0, 0, 255], "outside the selection"); + assert_eq!(rgb_at(&shown, id, 6), [0, 255, 0, 255], "inside the selection"); + assert_eq!(rgb_at(&shown, id, 10), [0, 255, 0, 255]); + assert_eq!(committed(&app).3, history, "the typing session's history is untouched"); +} + +#[test] +fn no_preview_for_other_pickers_other_tabs_or_bad_fields() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + + // The foreground Color Picker previews nothing on the canvas. + let fg = crate::color_picker_ui::open(&mut app, "foreground"); + set_color(&mut app, "#ff0000"); + assert!(display_doc(&mut app, 0).is_none()); + app.ui.close_dialog(fg).unwrap(); + + // Another document's tab never shows this picker's colour. + pick(&mut app, "#ff0000"); + assert!(display_doc(&mut app, 1).is_none()); + + // Malformed fields: no preview, and no panic. + for bad in [json!("not a colour"), json!(""), json!(42), json!(null)] { + app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), bad); + assert!(display_doc(&mut app, 0).is_none()); + } + set_color(&mut app, "#ff0000"); + let f = &mut app.ui.dialogs.last_mut().unwrap().fields; + f.insert("__params".into(), json!({"layer": 999_999})); + assert!(display_doc(&mut app, 0).is_none(), "a layer that is gone: the command refuses, the document shows"); + let f = &mut app.ui.dialogs.last_mut().unwrap().fields; + f.insert("__params".into(), json!("not an object")); + assert!(display_doc(&mut app, 0).is_none()); + app.ui.dialogs.last_mut().unwrap().fields.remove("__params"); + assert!(display_doc(&mut app, 0).is_none()); + assert_eq!(rgb_at(&app.session.active().unwrap().doc, id, 0), [0, 0, 0, 255]); +} + +#[test] +fn damage_between_previews_is_the_text_layer_only() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + let (doc_id, revision) = { + let st = app.session.active().unwrap(); + (st.doc.id, st.revision) + }; + let bounds = app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().tile_bounds(); + pick(&mut app, "#ff0000"); + let (_, red) = display_doc(&mut app, 0).unwrap(); + set_color(&mut app, "#0000ff"); + let (_, blue) = display_doc(&mut app, 0).unwrap(); + + let between = damage(&app, doc_id, revision, red, blue).expect("both previews are ours"); + assert_eq!(between, bounds); + assert_eq!(damage(&app, doc_id, revision, 0, red), Some(bounds), "document to preview"); + assert_eq!(damage(&app, doc_id, revision, blue, 0), Some(bounds), "preview back to the document (Cancel)"); + assert!(damage(&app, doc_id, revision, red, red).is_none()); + assert!(damage(&app, doc_id, revision + 1, red, blue).is_none(), "another revision isn't ours"); + assert!(damage(&app, doc_id, revision, red, 12_345 | TAG).is_none(), "an unknown key isn't ours"); + + // The canvas asks through `damage_since`, with the display's key folded into what it cached. + let doc = app.session.active().unwrap().doc.clone(); + let dk = crate::canvas::canvas_display(&app, &doc, None).1; + let since = |seen: u64, now: u64| crate::canvas::damage_since(&app, 0, (revision, seen ^ dk), (revision, now), dk, None); + assert_eq!(since(red, blue), Some(bounds), "the canvas refreshes only the text layer between colours"); + assert_eq!(since(blue, 0), Some(bounds), "and on Cancel"); +} + +#[test] +fn free_transform_keeps_its_preview_while_the_text_colour_picker_is_open() { + let mut app = app_at(8); + new_type_layer(&mut app, "Hello"); + crate::transform_tool::begin(&mut app, &egui::Context::default()).expect("Free Transform opens on the type layer"); + pick(&mut app, "#ff0000"); + + // The transform's own preview shows the layer where the box puts it; a copy with the old + // placement under it would show the text twice. + let (_, key) = crate::canvas::display_doc(&mut app, 0); + let session = app.transform_preview.as_ref().expect("the transform preview").session; + assert_eq!(key, (1 << 40) + session, "the canvas keeps drawing the Free Transform preview"); + assert!(display_doc(&mut app, 0).is_none()); +} + +#[test] +fn ok_counts_the_previewed_canvas_as_the_document_so_only_the_layer_is_redrawn() { + for gradient in [false, true] { + let mut app = app_at(8); + let ctx = egui::Context::default(); + let dialog = if gradient { + let id = new_gradient_fill(&mut app); + crate::gradient_ui::edit_stop_color(&mut app, id, 0); + set_color(&mut app, "#ff0000"); + app.ui.dialogs.last().unwrap().id + } else { + new_type_layer(&mut app, "Hello"); + pick(&mut app, "#ff0000") + }; + // The canvas draws the preview, then OK commits it. + crate::canvas::ensure_texture(&mut app, &ctx, 0, None).unwrap(); + let doc_id = app.session.active().unwrap().doc.id; + let cached = |app: &PhotocraftApp| { + let c = &app.canvases[&crate::canvas::cache_key(doc_id, None)]; + (c.tex_revision, c.tex_preview_key) + }; + let doc = app.session.active().unwrap().doc.clone(); + let dk = crate::canvas::canvas_display(&app, &doc, None).1; + assert_ne!(cached(&app).1, dk, "gradient={gradient}: the canvas holds the preview"); + + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let revision = app.session.active().unwrap().revision; + let seen = cached(&app); + assert_eq!(seen.1, dk, "gradient={gradient}: OK's edit is what the canvas already shows"); + // So the commit's own damage is all the canvas refreshes, not the whole document. + let edit = photocraft_geom::Rect::new(1, 2, 3, 4); + let damage = crate::canvas::damage_since(&app, 0, seen, (revision, 0), dk, Some(edit)); + assert_eq!(damage, Some(edit), "gradient={gradient}"); + } +} + +#[test] +fn ok_after_a_colour_the_canvas_never_drew_redraws_as_before() { + let mut app = app_at(8); + let ctx = egui::Context::default(); + new_type_layer(&mut app, "Hello"); + let dialog = pick(&mut app, "#ff0000"); + crate::canvas::ensure_texture(&mut app, &ctx, 0, None).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let dk = crate::canvas::canvas_display(&app, &doc, None).1; + // The colour changes and OK lands before the canvas drew it: the shown copy is not the edit. + set_color(&mut app, "#0000ff"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let c = &app.canvases[&crate::canvas::cache_key(doc.id, None)]; + assert_ne!(c.tex_preview_key, dk, "a stale red preview is never counted as the blue document"); +} + +#[test] +fn a_refused_preview_is_not_rebuilt_every_frame() { + let mut app = app_at(8); + new_type_layer(&mut app, "Hello"); + pick(&mut app, "#ff0000"); + let fields = &mut app.ui.dialogs.last_mut().unwrap().fields; + fields["__params"]["layer"] = json!(999_999); + + BUILDS.with(|b| b.set(0)); + assert!(display_doc(&mut app, 0).is_none(), "the command refuses a missing layer"); + assert!(display_doc(&mut app, 0).is_none()); + assert_eq!(crate::canvas::display_doc(&mut app, 0).1, 0, "the canvas draws the document"); + assert_eq!(BUILDS.with(|b| b.get()), 1, "the refusal is remembered for this colour"); + + // Another colour is another preview: it is tried once. + set_color(&mut app, "#00ff00"); + assert!(display_doc(&mut app, 0).is_none()); + assert!(display_doc(&mut app, 0).is_none()); + assert_eq!(BUILDS.with(|b| b.get()), 2); +} + +#[test] +fn owns_only_the_previewed_pickers() { + let mut f = Map::new(); + assert!(!owns(&f)); + f.insert("__colorPicker".into(), json!("command")); + f.insert("__command".into(), json!("type.setStyle")); + assert!(owns(&f)); + f.insert("__command".into(), json!("gradient.fill.stop")); + assert!(owns(&f), "a gradient fill layer's colour stop"); + f.insert("__command".into(), json!("gradient.presets.stop")); + assert!(!owns(&f), "a preset's stop edits no pixels"); + f.insert("__command".into(), json!("layer.solidFill.set")); + assert!(!owns(&f), "Solid Color fills have their own preview"); + f.insert("__colorPicker".into(), json!(7)); + assert!(!owns(&f)); +} + +#[test] +fn every_selected_type_layer_is_previewed_and_refreshed() { + let mut app = app_at(8); + let a = new_type_layer(&mut app, "Hello"); + let b = new_type_layer(&mut app, "World"); + app.run("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + let (doc_id, revision, bounds) = { + let st = app.session.active().unwrap(); + let area = |id: LayerId| st.doc.layer(id).unwrap().surface().unwrap().tile_bounds(); + (st.doc.id, st.revision, area(a).union(&area(b))) + }; + pick(&mut app, "#ff0000"); + let (shown, red) = display_doc(&mut app, 0).unwrap(); + assert_eq!(rgb_at(&shown, a, 0), [255, 0, 0, 255]); + assert_eq!(rgb_at(&shown, b, 0), [255, 0, 0, 255]); + // The recoloured copies may re-render with wider tiles; the damage must cover both layers. + let d = damage(&app, doc_id, revision, 0, red).expect("both layers are ours"); + assert_eq!(d.union(&bounds), d, "covers both layers' bounds: {d:?} vs {bounds:?}"); +} + +/// Colour stop `i` (by location) of gradient fill layer `id`, as 8-bit RGB. +fn stop_rgb(doc: &Document, id: LayerId, i: usize) -> [u8; 3] { + let LayerContent::Fill(photocraft_doc::Fill::Gradient { stops, .. }) = &doc.layer(id).unwrap().content else { panic!("not a gradient fill") }; + let mut sorted = stops.clone(); + sorted.sort_by(|a, b| a.0.total_cmp(&b.0)); + sorted[i].1.to_rgb().map(|v| (v * 255.0).round() as u8) +} + +fn new_gradient_fill(app: &mut PhotocraftApp) -> LayerId { + LayerId(app.run("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap()["layer"].as_u64().unwrap()) +} + +#[test] +fn a_gradient_stop_colour_is_previewed_before_ok_and_cancel_changes_nothing() { + for depth in [8, 16, 32] { + let mut app = app_at(depth); + let id = new_gradient_fill(&mut app); + let (doc, revision, dirty, history) = committed(&app); + crate::gradient_ui::edit_stop_color(&mut app, id, 0); + let dialog = app.ui.dialogs.last().unwrap().id; + assert!(owns(&app.ui.dialogs.last().unwrap().fields), "{depth}-bit: the stop picker is previewed"); + set_color(&mut app, "#ff0000"); + + let (shown, key) = crate::canvas::display_doc(&mut app, 0); + assert_ne!(key & TAG, 0, "{depth}-bit: the canvas draws the live preview"); + assert_eq!(stop_rgb(&shown, id, 0), [255, 0, 0], "{depth}-bit: the canvas shows the picked stop colour"); + assert_eq!(stop_rgb(&shown, id, 1), [255, 255, 255], "{depth}-bit: the other stop is unchanged"); + let after = committed(&app); + assert!(Arc::ptr_eq(&after.0, &doc)); + assert_eq!((after.1, after.2, after.3), (revision, dirty, history), "{depth}-bit: the session is untouched"); + + app.ui.close_dialog(dialog).unwrap(); + assert!(display_doc(&mut app, 0).is_none(), "{depth}-bit: Cancel shows the document again"); + assert_eq!(crate::canvas::display_doc(&mut app, 0).1, 0, "{depth}-bit: the canvas draws the document"); + assert_eq!(stop_rgb(&app.session.active().unwrap().doc, id, 0), [0, 0, 0]); + } +} + +#[test] +fn ok_commits_the_previewed_gradient_stop_in_one_history_step() { + let mut app = app_at(8); + let id = new_gradient_fill(&mut app); + let history = committed(&app).3; + crate::gradient_ui::edit_stop_color(&mut app, id, 1); + let dialog = app.ui.dialogs.last().unwrap().id; + set_color(&mut app, "#00ff00"); + let (shown, _) = display_doc(&mut app, 0).unwrap(); + + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + assert_eq!(stop_rgb(&doc, id, 1), stop_rgb(&shown, id, 1), "the preview is the committed edit"); + assert_eq!(stop_rgb(&doc, id, 1), [0, 255, 0]); + assert_eq!(committed(&app).3, history + 1); +} + +#[test] +fn the_preview_never_shows_on_a_document_switched_to_while_the_picker_is_open() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + pick(&mut app, "#ff0000"); + assert!(display_doc(&mut app, 0).is_some()); + + // A second document becomes the active tab, with a type layer that may share the id. + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let other = new_type_layer(&mut app, "Other"); + let idx = app.session.active_index().unwrap(); + assert_ne!(idx, 0); + assert!(display_doc(&mut app, idx).is_none(), "the picker belongs to the first document"); + assert!(display_doc(&mut app, 0).is_none(), "an inactive tab shows no dialog preview"); + assert_eq!(rgb_at(&app.session.active().unwrap().doc, other, 0), [0, 0, 0, 255]); + + // Back on the document it was opened on, the preview returns. + assert!(app.session.set_active(0)); + let (shown, _) = display_doc(&mut app, 0).expect("preview on its own document"); + assert_eq!(rgb_at(&shown, id, 0), [255, 0, 0, 255]); +} + +#[test] +fn no_preview_when_the_params_name_no_layer() { + let mut app = app_at(8); + let id = new_type_layer(&mut app, "Hello"); + new_type_layer(&mut app, "Top"); + pick(&mut app, "#ff0000"); + // An agent's `ui.dialog.set`: OK would recolour the selected layers of the real session. + for params in [json!({}), json!({"range": [0, 3]}), json!({"layers": []}), json!({"layer": "1"})] { + app.ui.dialogs.last_mut().unwrap().fields.insert("__params".into(), params.clone()); + assert!(display_doc(&mut app, 0).is_none(), "{params}: no preview of a guessed layer"); + assert_eq!(crate::canvas::display_doc(&mut app, 0).1, 0, "{params}"); + } + // A named layer is previewed as before. + app.ui.dialogs.last_mut().unwrap().fields.insert("__params".into(), json!({"layer": id.0})); + let (shown, _) = display_doc(&mut app, 0).expect("a named layer is previewed"); + assert_eq!(rgb_at(&shown, id, 0), [255, 0, 0, 255]); +} diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs index 46ecbce0e8..1c0ce157e8 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui.rs +++ b/crates/ui-egui/src/dialogs/color_picker_ui.rs @@ -4,6 +4,8 @@ //! floats so hue survives greys), so `ui.dialog.set` drives it; OK runs `tools.setColors`. //! While it is the top dialog the image is its eyedropper, as in Photoshop: the pointer over the //! canvas is a pipette, and a click or drag there samples into the new colour ([`sample_at`]). +//! A picker for another dialog's colour field or the Crop shield shows its colour there before +//! OK, and Cancel puts it back ([`live_preview`]). use egui::{Color32, Mesh, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_color::convert::{hsb_to_rgb, rgb_to_hsb}; @@ -182,22 +184,35 @@ pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], com let mut f = fields_with_web(app, "command", label, rgb); f.insert("__command".into(), json!(command)); f.insert("__params".into(), params); + // The document the picker was opened on: its live canvas preview never shows on another + // (`color_picker_preview`). + if let Some(st) = app.session.active() { + f.insert("__document".into(), json!(st.doc.id.0)); + } app.ui.open_dialog(DialogKind::Command, f) } -/// Open the picker on `rgb` for a colour field of another dialog (e.g. Edit › Fill's Color…): OK -/// writes `"#rrggbb"` into `field` of dialog `dialog`, which stays open; Cancel leaves it alone. +/// Open the picker on `rgb` for a colour field of another dialog (e.g. Edit › Fill's Color…): the +/// field shows the picked colour while the picker is open ([`live_preview`]); OK writes +/// `"#rrggbb"` into `field` of dialog `dialog`, which stays open; Cancel puts it back as it was. pub fn open_for_field(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], dialog: u64, field: &str) -> u64 { let mut f = fields_with_web(app, "field", label, rgb); f.insert("__dialog".into(), json!(dialog)); f.insert("__field".into(), json!(field)); - app.ui.open_dialog(DialogKind::Command, f) + let id = app.ui.open_dialog(DialogKind::Command, f); + live_preview::begin_field(app, id, dialog, field); + id } /// Open the picker on `rgb` for a colour the shell keeps itself (e.g. the Crop shield's custom -/// colour): OK hands `"#rrggbb"` to `target`'s setter in [`confirm`]; Cancel leaves it alone. +/// colour): OK hands `"#rrggbb"` to `target`'s setter in [`confirm`]; Cancel leaves it alone. The +/// Crop shield shows the picked colour while the picker is open ([`live_preview`]). pub fn open_for_target(app: &mut PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> u64 { - app.ui.open_dialog(DialogKind::Command, fields_with_web(app, target, label, rgb)) + let id = app.ui.open_dialog(DialogKind::Command, fields_with_web(app, target, label, rgb)); + if target == "cropShield" { + live_preview::begin_crop_shield(app, id); + } + id } /// Set by a dialog body (to the picker's title) to open the Color Picker on its `color` field. @@ -221,6 +236,11 @@ pub fn title_for(name: &str) -> String { crate::i18n::fmt(tl!("Color Picker ({name})"), &[("name", name)]) } +/// Prefer a complete catalogued title; some locales only provide the named-colour template. +pub(crate) fn fixed_title(label: &str, name: &str) -> String { + if crate::i18n::has(crate::i18n::current(), label) { tl!(label).to_string() } else { title_for(tl!(name)) } +} + /// The `"#rrggbb"` at `field` of `f` (a key or a JSON pointer). fn field_value<'a>(f: &'a Map, field: &str) -> Option<&'a str> { match field.strip_prefix('/') { @@ -321,6 +341,13 @@ where (hex(to_rgb(v)) == hex(rgb)).then_some(v) } +/// The CMYK values typed into the picker, while they still match its colour: the inks a command +/// should keep in a CMYK document (#4196, #8568). +pub(crate) fn picked_cmyk(f: &Map) -> Option<[f32; 4]> { + let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex)?; + kept::<4>(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb) +} + /// Current colour, keeping the stored HSB, Lab or CMYK values while they still match `color`. pub fn current(f: &Map) -> Components { let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]); @@ -405,6 +432,13 @@ fn flat_grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> /// Room for both component columns, including units, without overflowing the title's width; with /// the frame and title the window fits Photoshop's (about 534 x 376 points). pub(crate) const CONTENT_WIDTH: f32 = 488.0; +/// Translated action labels can be substantially wider than their English equivalents. +pub(crate) fn content_width(ui: &egui::Ui) -> f32 { + let labels = ["OK", "Cancel", "Add to Swatches"]; + let english = crate::i18n::with_language(crate::i18n::Lang::EN, || widgets::button_width(ui, &labels)); + let translated = widgets::button_width(ui, &labels.map(crate::i18n::t)); + CONTENT_WIDTH + (translated - english).max(0.0) +} /// Side of the colour field (Photoshop's is about 256 points). const FIELD: f32 = 200.0; /// Width of the component fields. @@ -412,7 +446,7 @@ const VALUE_W: f32 = 42.0; /// Draw the picker and its top-right actions. `Some(true)` confirms, `Some(false)` cancels. pub fn body(ui: &mut egui::Ui, f: &mut Map) -> Option { - ui.set_width(CONTENT_WIDTH); + ui.set_width(content_width(ui)); ui.spacing_mut().item_spacing.y = 4.0; let mut outcome = None; ui.horizontal_top(|ui| { @@ -480,7 +514,8 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { ui.painter().add(mesh(field, &|x, y| field_color(&mode, fz, x, y))); ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let marker = pos2(field.left() + fx * field.width(), field.top() + fy * field.height()); - ui.painter().circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); + // Clipped to the field: at the extremes the marker sits on the edge (#8968). + ui.painter().with_clip_rect(field).circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); // Photoshop updates the colour on the press itself, not only when the button is released // (#6937): `is_pointer_button_down_on` is true from the press frame onwards (it covers // dragging too), while `clicked` fires on release and `dragged` only after a move. @@ -751,8 +786,164 @@ pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { } } -/// OK: set the foreground or background colour. +/// Whether a Color Picker dialog is open: a workspace under it (Content-Aware Fill, Select and +/// Mask) leaves the keys to the picker, as it did for the swatch popups the picker replaced. +pub fn any_open(app: &PhotocraftApp) -> bool { + app.ui.dialogs.iter().any(|d| owns(&d.fields)) +} + +/// A colour swatch widget asked for the Color Picker on its colour ([`request_for_widget`]). +#[derive(Clone)] +struct WidgetRequest { + widget: u64, + title: String, + rgb: [f32; 3], +} + +fn widget_requests() -> egui::Id { + egui::Id::unique("color-picker-widget-requests") +} + +fn widget_results() -> egui::Id { + egui::Id::unique("color-picker-widget-results") +} + +fn widget_events() -> egui::Id { + egui::Id::unique("color-picker-widget-events") +} + +fn widget_open() -> egui::Id { + egui::Id::unique("color-picker-widget-open") +} + +/// What a swatch's open Color Picker did since the last frame ([`take_widget_event`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum WidgetEvent { + /// The picker shows this colour now: preview it on the swatch. + Preview([f32; 3]), + /// The picker closed without a new colour (Cancel, Esc, the close box, OK unchanged): go back + /// to the colour the swatch had when it opened the picker. + Restore, +} + +/// A colour swatch (`widgets::color_edit_button*`) was clicked: open the Color Picker titled +/// `title` on `rgb` next frame ([`widget_tick`], which has the app). OK hands the colour back to the +/// swatch through [`take_widget_color`]; Cancel leaves it alone. +pub fn request_for_widget(ctx: &egui::Context, widget: egui::Id, title: &str, rgb: [f32; 3]) { + let rgb = rgb.map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }); + ctx.data_mut(|d| { + d.get_temp_mut_or_default::>(widget_requests()).push(WidgetRequest { widget: widget.value(), title: title.to_string(), rgb }) + }); + ctx.request_repaint(); +} + +/// The colour the Color Picker of swatch `widget` returned with OK, once. +pub fn take_widget_color(ctx: &egui::Context, widget: egui::Id) -> Option<[f32; 3]> { + ctx.data_mut(|d| { + let results = d.get_temp_mut_or_default::>(widget_results()); + let i = results.iter().position(|(w, _)| *w == widget.value())?; + Some(results.remove(i).1) + }) +} + +/// What the Color Picker of swatch `widget` did since the last frame, once: the colour it shows +/// now while it is open, or that it closed without a new colour. +pub fn take_widget_event(ctx: &egui::Context, widget: egui::Id) -> Option { + ctx.data_mut(|d| { + let events = d.get_temp_mut_or_default::>(widget_events()); + let i = events.iter().position(|(w, _)| *w == widget.value())?; + Some(events.remove(i).1) + }) +} + +/// Whether swatch `widget`'s Color Picker is open (as of this frame's [`widget_tick`]): its +/// colour changes are previews, and only the colour OK hands back is final. +pub fn widget_picker_open(ctx: &egui::Context, widget: egui::Id) -> bool { + ctx.data(|d| d.get_temp::>(widget_open()).is_some_and(|open| open.contains(&widget.value()))) +} + +/// Each frame, before the panels: hand colours confirmed last frame to their swatches, preview +/// the colour an open picker shows on its swatch (as Photoshop's dialogs do while you pick), tell +/// a swatch whose picker closed without a new colour to restore its own, and open the Color +/// Picker a swatch asked for. +pub fn widget_tick(app: &mut PhotocraftApp, ctx: &egui::Context) { + let done = std::mem::take(&mut app.widget_colors); + let mut events: Vec<(u64, WidgetEvent)> = Vec::new(); + let mut open_now: Vec = Vec::new(); + for d in app.ui.dialogs.iter_mut() { + if d.fields.get("__colorPicker").and_then(Value::as_str) != Some("widget") { + continue; + } + let Some(widget) = d.fields.get("__widget").and_then(Value::as_u64) else { continue }; + open_now.push(widget); + let shown = d.fields.get("__widgetShown").or_else(|| d.fields.get("__orig")).cloned(); + let Some(color) = d.fields.get("color").cloned() else { continue }; + if Some(&color) != shown.as_ref() + && let Some(rgb) = color.as_str().and_then(parse_hex) + { + events.push((widget, WidgetEvent::Preview(rgb))); + d.fields.insert("__widgetShown".into(), color); + } + } + let was_open = ctx.data(|d| d.get_temp::>(widget_open())).unwrap_or_default(); + for w in was_open { + if !open_now.contains(&w) && !done.iter().any(|(x, _)| *x == w) { + events.push((w, WidgetEvent::Restore)); + } + } + let changed = !events.is_empty(); + ctx.data_mut(|d| { + d.insert_temp(widget_open(), open_now); + if changed { + let all = d.get_temp_mut_or_default::>(widget_events()); + // Only the newest event per swatch counts; a swatch that is gone never takes its own. + all.retain(|(w, _)| !events.iter().any(|(x, _)| x == w)); + all.extend(events); + let excess = all.len().saturating_sub(16); + all.drain(..excess); + } + }); + if changed { + ctx.request_repaint(); + } + if !done.is_empty() { + ctx.data_mut(|d| { + // A preview not yet taken is replaced by the final colour. + d.get_temp_mut_or_default::>(widget_events()).retain(|(w, _)| !done.iter().any(|(x, _)| x == w)); + let results = d.get_temp_mut_or_default::>(widget_results()); + // A swatch that is gone never takes its colour: keep only the newest one per swatch. + results.retain(|(w, _)| !done.iter().any(|(x, _)| x == w)); + results.extend(done); + let excess = results.len().saturating_sub(16); + results.drain(..excess); + }); + ctx.request_repaint(); + } + let requests = ctx.data_mut(|d| { + let r = d.get_temp::>(widget_requests()); + d.remove::>(widget_requests()); + r + }); + // One picker per click; a second click before it opened asks for the same thing. + if let Some(r) = requests.and_then(|r| r.into_iter().last()) { + let mut f = fields_with_web(app, "widget", &r.title, r.rgb); + f.insert("__widget".into(), json!(r.widget)); + app.ui.open_dialog(DialogKind::Command, f); + } +} + +/// OK: apply the colour to what the picker was opened for, and add it to the Recently Used +/// Colors, as Photoshop does for a colour picked in the Color Picker. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let r = confirm_target(app, f)?; + if let Some(rgb) = f.get("color").and_then(Value::as_str).and_then(parse_hex) { + crate::paint_picker::remember(app, rgb); + } + Ok(r) +} + +/// OK: set the foreground or background colour, or whatever else the picker was opened for. +fn confirm_target(app: &mut PhotocraftApp, f: &Map) -> Result { if crate::type_panels_ui::color_picker::owns(f) { return crate::type_panels_ui::color_picker::confirm(app, f); } @@ -771,9 +962,15 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result) -> Result) -> Result }, + /// The Crop shield's colour choice and custom colour. + CropShield { color: ShieldColor, custom: [u8; 3] }, +} + +/// One open picker's live target. +pub(crate) struct Live { + picker: u64, + before: Before, + /// The colour shown in the target; `None` while it shows what it was. + shown: Option, +} + +/// Preview picker `picker` in `field` of dialog `parent` (a key or a JSON pointer). +pub(super) fn begin_field(app: &mut PhotocraftApp, picker: u64, parent: u64, field: &str) { + let Some(d) = app.ui.dialogs.iter().find(|d| d.id == parent) else { return }; + let before = Before::Field { parent, field: field.into(), fields: d.fields.clone() }; + app.color_picker_live.push(Live { picker, before, shown: None }); +} + +/// Preview picker `picker` in the Crop shield's custom colour. +pub(super) fn begin_crop_shield(app: &mut PhotocraftApp, picker: u64) { + let s = &app.ui.tool_options.crop_shield; + let before = Before::CropShield { color: s.color, custom: s.custom_color }; + app.color_picker_live.push(Live { picker, before, shown: None }); +} + +/// The dialogs whose fields an open picker is previewing in: they record no ⌘Z step meanwhile +/// (`edit_history`), so a Cancel leaves no trace and an OK is one step. +pub(crate) fn previewed_dialogs(app: &PhotocraftApp) -> Vec { + app.color_picker_live + .iter() + .filter_map(|l| match l.before { + Before::Field { parent, .. } => Some(parent), + Before::CropShield { .. } => None, + }) + .collect() +} + +/// The field pickers previewing in dialog `parent`: they close with it. +pub(crate) fn pickers_of(app: &PhotocraftApp, parent: u64) -> Vec { + app.color_picker_live.iter().filter(|l| matches!(l.before, Before::Field { parent: p, .. } if p == parent)).map(|l| l.picker).collect() +} + +/// Whether picker `picker` has a live target: then its parent is the one recorded here, not its +/// `__dialog` field, which automation could have rewritten. +pub(crate) fn previews(app: &PhotocraftApp, picker: u64) -> bool { + app.color_picker_live.iter().any(|l| l.picker == picker) +} + +/// The colour picker `picker` shows: `Some(None)` while it is the one it opened on (or not +/// a colour), `None` once the picker is closed. +fn picked(app: &PhotocraftApp, picker: u64) -> Option> { + let d = app.ui.dialogs.iter().find(|d| d.id == picker)?; + let colour = |key: &str| d.fields.get(key).and_then(Value::as_str).and_then(parse_hex).map(hex); + let (color, orig) = (colour("color"), colour("__orig")); + Some(color.filter(|c| orig.as_ref() != Some(c))) +} + +/// `"#rrggbb"` as the Crop shield stores it. +fn shield_rgb(color: &str) -> Option<[u8; 3]> { + parse_hex(color).map(|c| c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) +} + +/// Show `color` in the target, or what it was (`None`), where it shows `shown` now. `Err` when +/// the target is gone. +fn show(app: &mut PhotocraftApp, before: &mut Before, shown: Option<&str>, color: Option<&str>) -> Result<(), String> { + match before { + Before::Field { parent, field, fields } => { + let mut next = fields.clone(); + if let Some(c) = color { + set_field(&mut next, field, json!(c))?; + } + app.ui.dialog_mut(*parent).ok_or("the dialog this colour was for is closed")?.fields = next; + } + Before::CropShield { color: was, custom } => { + // A shield colour set elsewhere while the picker is open (`ui.set`) is what Cancel + // puts back, not the one the picker opened on. + let s = &app.ui.tool_options.crop_shield; + let expected = match shown { + Some(c) => (ShieldColor::Custom, shield_rgb(c)), + None => (*was, Some(*custom)), + }; + if (s.color, Some(s.custom_color)) != expected { + (*was, *custom) = (s.color, s.custom_color); + } + match color { + Some(c) => crate::crop_shield::set_custom_color(app, c)?, + None => { + let s = &mut app.ui.tool_options.crop_shield; + (s.color, s.custom_color) = (*was, *custom); + } + } + } + } + Ok(()) +} + +/// Show each open picker's colour in its target, and put back the target of a picker that closed +/// without [`end`]. True when a target changed (the caller repaints, so the parent redraws). +pub(crate) fn sync(app: &mut PhotocraftApp) -> bool { + if app.color_picker_live.is_empty() { + return false; + } + let mut live = std::mem::take(&mut app.color_picker_live); + let mut changed = false; + live.retain_mut(|l| { + let Some(color) = picked(app, l.picker) else { + if l.shown.is_some() { + changed |= show(app, &mut l.before, l.shown.as_deref(), None).is_ok(); + } + return false; + }; + if color == l.shown { + return true; + } + // A target that is gone (its dialog closed) stops the preview. + if show(app, &mut l.before, l.shown.as_deref(), color.as_deref()).is_err() { + return false; + } + l.shown = color; + changed = true; + true + }); + live.append(&mut app.color_picker_live); + app.color_picker_live = live; + changed +} + +/// Picker `picker` is closing (OK or Cancel): put its target back as it was when it opened and +/// stop previewing. OK then sets the target as it always has. +pub(crate) fn end(app: &mut PhotocraftApp, picker: u64) { + let Some(i) = app.color_picker_live.iter().position(|l| l.picker == picker) else { return }; + let mut l = app.color_picker_live.remove(i); + if l.shown.is_some() { + let _ = show(app, &mut l.before, l.shown.as_deref(), None); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/color_picker_ui/live_preview/tests.rs b/crates/ui-egui/src/dialogs/color_picker_ui/live_preview/tests.rs new file mode 100644 index 0000000000..041b6a8c45 --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_picker_ui/live_preview/tests.rs @@ -0,0 +1,343 @@ +use egui::vec2; +use egui_kittest::Harness; +use serde_json::{Map, Value, json}; + +use super::*; +use crate::color_picker_ui::{open_for_field, open_requested, request, request_field}; +use crate::crop_shield::ShieldColor; +use crate::state::DialogKind; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 16, "height": 12})).unwrap(); + app +} + +fn fields(app: &PhotocraftApp, id: u64) -> &Map { + &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields +} + +fn past(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +/// The Picker's colour changes, as a drag in it or `ui.dialog.set` changes it, and a frame passes. +fn pick(app: &mut PhotocraftApp, picker: u64, color: &str) { + app.ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!(color)); + sync(app); +} + +/// Edit › Fill on Color…, with its Color Picker open over it as its Color… choice opens it. +fn fill_with_picker(app: &mut PhotocraftApp) -> (u64, u64) { + let fill = crate::fill_ui::open(app); + let f = &mut app.ui.dialog_mut(fill).unwrap().fields; + f.insert("contents".into(), json!("color")); + f.insert("color".into(), json!("#20a040")); + request(f, "Color Picker (Fill Color)"); + open_requested(app, fill); + let picker = crate::color_picker_ui::top(app).expect("the picker opens over the Fill dialog"); + (fill, picker) +} + +/// #9886: the Fill dialog's colour follows the picker before OK, Cancel puts back exactly what +/// the dialog held, and nothing reaches the document's History. +#[test] +fn a_dialog_colour_follows_the_picker_and_cancel_restores_it() { + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + let before = fields(&app, fill).clone(); + for color in ["#336699", "#ff0000", "#00ff00"] { + pick(&mut app, picker, color); + assert_eq!(fields(&app, fill)["color"], json!(color), "shown before OK"); + } + assert_eq!(past(&app), 0, "previewing makes no history step"); + crate::dialogs::cancel(&mut app, picker).unwrap(); + assert_eq!(fields(&app, fill), &before, "Cancel puts the dialog back as it was"); + assert!(app.color_picker_live.is_empty()); + assert_eq!(past(&app), 0); +} + +/// OK keeps the colour in the dialog, still without a history step; the dialog's own OK then +/// makes exactly one, with the picked colour. +#[test] +fn ok_keeps_the_colour_and_the_dialog_makes_one_history_step() { + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + pick(&mut app, picker, "#ff0000"); + pick(&mut app, picker, "#336699"); + crate::dialogs::confirm(&mut app, picker).unwrap(); + assert_eq!(fields(&app, fill)["color"], json!("#336699")); + assert!(app.color_picker_live.is_empty()); + assert_eq!(past(&app), 0, "the picker's OK only sets the dialog's colour"); + crate::dialogs::confirm(&mut app, fill).unwrap(); + assert_eq!(past(&app), 1, "one step for the Fill"); + let st = app.session.active().unwrap(); + let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(3, 3); + let want = [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0]; + assert!((0..3).all(|i| (c[i] - want[i]).abs() < 1.5 / 255.0), "{c:?}"); +} + +/// Back on the colour it opened on, the target shows what it held, even a value that is not a +/// hex colour; a picker that never changes leaves it alone. +#[test] +fn the_opening_colour_shows_the_original_value() { + let mut app = app(); + let mut f = Map::new(); + f.insert("background".into(), json!("white")); + f.insert("other".into(), json!(3)); + let parent = app.ui.open_dialog(DialogKind::Command, f); + let before = fields(&app, parent).clone(); + let picker = open_for_field(&mut app, "Color Picker (Background Contents)", [1.0; 3], parent, "background"); + assert!(!sync(&mut app), "nothing to show yet"); + assert_eq!(fields(&app, parent), &before); + pick(&mut app, picker, "#808080"); + assert_eq!(fields(&app, parent)["background"], json!("#808080")); + pick(&mut app, picker, "#ffffff"); + assert_eq!(fields(&app, parent), &before, "the opening colour shows the original value"); + // Not a colour (a half-typed value from automation): the original value too. + pick(&mut app, picker, "#12"); + assert_eq!(fields(&app, parent), &before); +} + +/// A dialog that derives other settings from the shown colour (Replace Color's Result sets the +/// sliders and drops the colour) builds each preview on the settings it had, and Cancel undoes +/// what it derived. +#[test] +fn derived_settings_are_rebuilt_from_the_opening_ones() { + let mut app = app(); + let mut f = Map::new(); + f.insert("picked".into(), json!("#000000")); + f.insert("hue".into(), json!(0)); + let parent = app.ui.open_dialog(DialogKind::Command, f); + let before = fields(&app, parent).clone(); + let picker = open_for_field(&mut app, "Color Picker (Result)", [0.0; 3], parent, "picked"); + // The dialog's body consumes the colour each frame, as Replace Color's does. + let consume = |app: &mut PhotocraftApp| { + let f = &mut app.ui.dialog_mut(parent).unwrap().fields; + let picked = f.remove("picked").unwrap(); + let hue = f["hue"].as_i64().unwrap(); + f.insert("hue".into(), json!(hue + i64::from(picked != json!("#000000")))); + }; + pick(&mut app, picker, "#ff0000"); + consume(&mut app); + pick(&mut app, picker, "#00ff00"); + assert_eq!(fields(&app, parent)["hue"], json!(0), "rebuilt from the opening settings, not the last preview"); + assert_eq!(fields(&app, parent)["picked"], json!("#00ff00")); + consume(&mut app); + crate::dialogs::cancel(&mut app, picker).unwrap(); + assert_eq!(fields(&app, parent), &before); +} + +/// A picker closed without Cancel (a document closed, a parent cancelled) never leaves its +/// colour behind, and a closed parent ends the preview without an error or a panic. +#[test] +fn a_picker_or_parent_closed_any_other_way_ends_the_preview() { + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + let before = fields(&app, fill).clone(); + pick(&mut app, picker, "#336699"); + app.ui.close_dialog(picker); + assert!(sync(&mut app)); + assert_eq!(fields(&app, fill), &before); + assert!(app.color_picker_live.is_empty()); + + // A cancelled dialog takes its field picker with it, leaving no picker whose OK has no target. + let (fill, picker) = fill_with_picker(&mut app); + let other = open_for_field(&mut app, "Color Picker (Other)", [0.0; 3], 999_999, "color"); + pick(&mut app, picker, "#336699"); + crate::dialogs::cancel(&mut app, fill).unwrap(); + assert!(app.ui.dialogs.iter().all(|d| d.id != picker), "the picker closes with its dialog"); + assert!(app.ui.dialogs.iter().any(|d| d.id == other), "another dialog's picker stays"); + assert!(app.color_picker_live.is_empty(), "the preview ends with its dialog"); + assert_eq!(past(&app), 0); +} + +/// The dialog under a picker can't be confirmed (by automation) with a colour the picker still +/// only shows, even when the picker's `__dialog` was rewritten to point elsewhere. +#[test] +fn the_dialog_waits_for_its_picker() { + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + pick(&mut app, picker, "#336699"); + assert!(crate::dialogs::confirm(&mut app, fill).is_err()); + app.ui.dialog_mut(picker).unwrap().fields.insert("__dialog".into(), json!(999_999)); + assert!(crate::dialogs::confirm(&mut app, fill).is_err(), "the live preview's record of the dialog counts"); + assert_eq!(past(&app), 0); + assert!(app.ui.dialogs.iter().any(|d| d.id == fill), "the dialog stays open"); +} + +/// Automation can't edit a dialog under a previewing picker: Cancel would put the edit back. +#[test] +fn the_dialog_under_a_picker_refuses_control_edits() { + let ctx = egui::Context::default(); + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + let (req, _rx) = crate::control::ControlRequest::new("ui.dialog.set", json!({"dialog": fill, "field": "opacity", "value": 50})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("an immediate reply") }; + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(fields(&app, fill)["opacity"], json!(100.0)); + crate::dialogs::cancel(&mut app, picker).unwrap(); + let (req, _rx) = crate::control::ControlRequest::new("ui.dialog.set", json!({"dialog": fill, "field": "opacity", "value": 50})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("an immediate reply") }; + assert_eq!(r["ok"], true, "{r}"); +} + +/// A shield colour set elsewhere (`ui.set cropShield`) while its picker is open survives the +/// picker's Cancel. +#[test] +fn the_crop_shield_keeps_a_colour_set_while_its_picker_is_open() { + let mut app = app(); + let picker = crate::crop_shield::pick_custom_color(&mut app); + pick(&mut app, picker, "#336699"); + app.ui.tool_options.crop_shield.custom_color = [1, 2, 3]; + crate::dialogs::cancel(&mut app, picker).unwrap(); + let s = &app.ui.tool_options.crop_shield; + assert_eq!((s.color, s.custom_color), (ShieldColor::Custom, [1, 2, 3])); + + let picker = crate::crop_shield::pick_custom_color(&mut app); + app.ui.tool_options.crop_shield.color = ShieldColor::MatchCanvas; + pick(&mut app, picker, "#ff0000"); + crate::dialogs::cancel(&mut app, picker).unwrap(); + let s = &app.ui.tool_options.crop_shield; + assert_eq!((s.color, s.custom_color), (ShieldColor::MatchCanvas, [1, 2, 3]), "Cancel puts back the choice made while it was open"); +} + +/// The dialog's own ⌘Z history records no step for a previewed colour: none after Cancel, one +/// after OK. +#[test] +fn a_previewed_colour_is_no_dialog_undo_step() { + let ctx = egui::Context::default(); + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + crate::edit_history::track_dialogs(&mut app, &ctx); + let undo = |app: &mut PhotocraftApp| { + let mut now: Map = fields(app, fill).iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + app.dialog_edits.get_mut(&fill).unwrap().step(&mut now, crate::edit_history::Step::Undo).then_some(now) + }; + for color in ["#336699", "#ff0000"] { + pick(&mut app, picker, color); + crate::edit_history::track_dialogs(&mut app, &ctx); + } + crate::dialogs::cancel(&mut app, picker).unwrap(); + crate::edit_history::track_dialogs(&mut app, &ctx); + assert_eq!(undo(&mut app), None, "Cancel leaves nothing to undo"); + + request(&mut app.ui.dialog_mut(fill).unwrap().fields, "Color Picker (Fill Color)"); + open_requested(&mut app, fill); + let picker = crate::color_picker_ui::top(&app).unwrap(); + for color in ["#336699", "#ff0000"] { + pick(&mut app, picker, color); + crate::edit_history::track_dialogs(&mut app, &ctx); + } + crate::dialogs::confirm(&mut app, picker).unwrap(); + crate::edit_history::track_dialogs(&mut app, &ctx); + let back = undo(&mut app).expect("OK is one step"); + assert_eq!(back["color"], json!("#20a040"), "straight back to the colour before the picker"); +} + +/// The Crop shield's custom colour follows its picker; Cancel restores the colour and the +/// Match Canvas choice, OK keeps the custom one. +#[test] +fn the_crop_shield_colour_follows_its_picker() { + let mut app = app(); + app.ui.tool_options.crop_shield.custom_color = [10, 20, 30]; + let before = app.ui.tool_options.crop_shield.clone(); + assert_eq!(before.color, ShieldColor::MatchCanvas); + let picker = crate::crop_shield::pick_custom_color(&mut app); + pick(&mut app, picker, "#336699"); + let s = &app.ui.tool_options.crop_shield; + assert_eq!((s.color, s.custom_color), (ShieldColor::Custom, [0x33, 0x66, 0x99]), "shown before OK"); + crate::dialogs::cancel(&mut app, picker).unwrap(); + assert_eq!(app.ui.tool_options.crop_shield, before); + + let picker = crate::crop_shield::pick_custom_color(&mut app); + pick(&mut app, picker, "#ff0000"); + pick(&mut app, picker, "#336699"); + crate::dialogs::confirm(&mut app, picker).unwrap(); + let s = &app.ui.tool_options.crop_shield; + assert_eq!((s.color, s.custom_color), (ShieldColor::Custom, [0x33, 0x66, 0x99])); + assert!(app.color_picker_live.is_empty()); + assert_eq!(past(&app), 0); +} + +/// Agents: `ui.dialog.set` on the picker shows the colour in the dialog at once, and +/// `ui.dialog.cancel` puts it back. +#[test] +fn control_edits_preview_at_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + let call = |app: &mut PhotocraftApp, method: &str, params: Value| { + let (req, _rx) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &ctx, &req) { + crate::control::Outcome::Done(v) => v, + _ => panic!("{method}: expected an immediate reply"), + } + }; + let r = call(&mut app, "ui.dialog.set", json!({"dialog": picker, "field": "color", "value": "#336699"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(fields(&app, fill)["color"], json!("#336699")); + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": fill}))["ok"], false, "the picker answers first"); + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": picker}))["ok"], true); + assert_eq!(fields(&app, fill)["color"], json!("#20a040")); +} + +/// In the running dialogs: a colour typed into the picker shows in the Fill dialog's swatch field +/// before OK, and Esc (the picker's Cancel) puts it back. +#[test] +fn the_dialog_shows_the_colour_while_the_picker_is_open() { + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app()); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let (fill, picker) = fill_with_picker(h.state_mut()); + h.run_steps(3); + h.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#336699")); + h.run_steps(3); + assert_eq!(fields(h.state(), fill)["color"], json!("#336699")); + assert_eq!(past(h.state()), 0); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(crate::color_picker_ui::top(h.state()), None); + assert_eq!(fields(h.state(), fill)["color"], json!("#20a040")); +} + +/// A picker's target is fixed when it opens: automation can't point its OK at another dialog, and +/// cancelling a dialog its `__dialog` was rewritten to never closes it. +#[test] +fn a_pickers_target_stays_the_dialog_it_opened_on() { + let ctx = egui::Context::default(); + let mut app = app(); + let (fill, picker) = fill_with_picker(&mut app); + let other = app.ui.open_dialog(DialogKind::Command, Map::new()); + let set = |app: &mut PhotocraftApp, field: &str, value: Value| { + let (req, _rx) = crate::control::ControlRequest::new("ui.dialog.set", json!({"dialog": picker, "field": field, "value": value})); + let crate::control::Outcome::Done(r) = crate::control::handle(app, &ctx, &req) else { panic!("an immediate reply") }; + r["ok"].as_bool() + }; + assert_eq!(set(&mut app, "__dialog", json!(other)), Some(false)); + assert_eq!(set(&mut app, "__field", json!("opacity")), Some(false)); + assert_eq!(set(&mut app, "__colorPicker", json!("cropShield")), Some(false), "nor can its OK go to the Crop shield"); + assert_eq!(set(&mut app, "color", json!("#336699")), Some(true), "the colour itself stays drivable"); + assert_eq!(fields(&app, fill)["color"], json!("#336699")); + + // Rewritten another way, `__dialog` still doesn't make it another dialog's picker. + app.ui.dialog_mut(picker).unwrap().fields.insert("__dialog".into(), json!(other)); + crate::dialogs::cancel(&mut app, other).unwrap(); + assert!(app.ui.dialogs.iter().any(|d| d.id == picker), "the picker stays with its own dialog"); + assert_eq!(fields(&app, fill)["color"], json!("#336699"), "and keeps previewing there"); +} + +/// Preferences › Apply, like OK, waits for an open colour picker: it would otherwise save the +/// colour the picker previews and may still cancel. +#[test] +fn preferences_apply_waits_for_an_open_picker() { + let mut app = app(); + let prefs = crate::prefs_ui::open_preferences(&mut app, "guidesGridAndSlices"); + let before = app.session.prefs().guides_grid_and_slices.guide_color.clone(); + let f = &mut app.ui.dialog_mut(prefs).unwrap().fields; + request_field(f, "/values/guidesGridAndSlices/guideColor", "Color Picker (Guide Color)"); + open_requested(&mut app, prefs); + let picker = crate::color_picker_ui::top(&app).expect("the picker opens over Preferences"); + pick(&mut app, picker, "#ff8000"); + assert!(crate::prefs_ui::apply(&mut app, prefs).is_err(), "Apply refuses while the picker is open"); + assert_eq!(app.session.prefs().guides_grid_and_slices.guide_color, before, "nothing is saved"); +} diff --git a/crates/ui-egui/src/dialogs/color_picker_ui/tests.rs b/crates/ui-egui/src/dialogs/color_picker_ui/tests.rs index 0ef4eb1ec0..d513570917 100644 --- a/crates/ui-egui/src/dialogs/color_picker_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/color_picker_ui/tests.rs @@ -39,6 +39,33 @@ fn picker(color: &str) -> Harness<'static, Map> { h } +/// #8968: at the extremes the marker sits on the field's edge and must not paint past it. +#[test] +fn field_marker_stays_inside_the_field() { + // Black in the default "h" mode: the marker is at the bottom-left corner. + let h = picker("#000000"); + let mut field = None; + let mut circles = Vec::new(); + for clipped in &h.output().shapes { + match &clipped.shape { + egui::Shape::Mesh(m) if m.vertices.len() == 33 * 33 => { + // The component slider is a 33 x 33 mesh too, drawn after the field. + field.get_or_insert(m.calc_bounds()); + } + egui::Shape::Circle(c) => circles.push((c.center, c.radius, clipped.clip_rect)), + _ => {} + } + } + let field = field.expect("colour field drawn"); + // The radio buttons draw circles too: keep the ones on the field. + circles.retain(|(center, _, _)| field.expand(6.0).contains(*center)); + assert!(!circles.is_empty(), "the marker is drawn"); + for (center, radius, clip) in circles { + let visible = Rect::from_center_size(center, vec2(2.0 * radius, 2.0 * radius)).intersect(clip); + assert!(field.contains_rect(visible), "marker {visible:?} leaves the field {field:?}"); + } +} + /// Type `text` into the focused field one key per frame, like a person typing. fn type_keys(h: &mut Harness<'static, Map>, text: &str) { for ch in text.chars() { @@ -100,7 +127,7 @@ fn field_and_slider_pick_on_press_and_follow_small_moves() { press(&mut h, o + vec2(FIELD + gap + 9.0, FIELD * 0.95)); assert_ne!(color(&h), before, "the slider picks on press"); let hue = rgb_to_hsb(parse_hex(&color(&h)).unwrap())[0]; - assert!(hue < 30.0 || hue > 330.0, "hue {hue}"); + assert!(hue.is_finite() && !(30.0..=330.0).contains(&hue), "hue {hue}"); } /// Only the primary button picks: a right or middle press (and its release) on the field or diff --git a/crates/ui-egui/src/dialogs/color_range_ui.rs b/crates/ui-egui/src/dialogs/color_range_ui.rs index 75fe3c67b9..3b02806d3a 100644 --- a/crates/ui-egui/src/dialogs/color_range_ui.rs +++ b/crates/ui-egui/src/dialogs/color_range_ui.rs @@ -236,7 +236,7 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> surf.read_rgba_into(area, &mut px); px } - _ => photocraft_compose::render(proxy, area).px, + _ => photocraft_compose::render(proxy, area).into_px(), }; let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false); let mut mask: Vec = query diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 1ca9792e16..358241bfb8 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. UI strings go through `tl!` (column abbreviations like `+LOC` stay as they are). use std::cmp::Ordering; diff --git a/crates/ui-egui/src/dialogs/dds_options_ui.rs b/crates/ui-egui/src/dialogs/dds_options_ui.rs index 6864e098c6..a696e458b6 100644 --- a/crates/ui-egui/src/dialogs/dds_options_ui.rs +++ b/crates/ui-egui/src/dialogs/dds_options_ui.rs @@ -96,7 +96,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let settings = ExportSettings { dds: p.options, ..app.save_settings() }; let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; record(p.options, &mut step); - let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); + let written = app.with_document(p.doc, |app| { + // A Save As that keeps the document's own name records only its folder (#5305). + app.record_answered_save_as(p.record, &mut step); + 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. app.session.journal.push((p.record.into(), step)); diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests.rs index 5866a5ae12..a75a1aeb99 100644 --- a/crates/ui-egui/src/dialogs/discard_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/discard_ui/tests.rs @@ -578,6 +578,9 @@ fn saving_from_the_prompt_closes_after_an_unconfirmed_write() { #[path = "tests/tiff_tests.rs"] mod tiff_tests; +#[path = "tests/tiff_options_tests.rs"] +mod tiff_options_tests; + #[path = "tests/targa_tests.rs"] mod targa_tests; diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs index 0a05370ecf..9360bdde69 100644 --- a/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs +++ b/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs @@ -182,6 +182,40 @@ fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { ); } +/// #5305: a Targa Save As answered in Targa Options with the document's own name records only the +/// folder and extension (with the bits), so playback saves each document under its own name; a +/// Save a Copy keeps its path. +#[test] +fn recorded_targa_save_as_with_the_documents_name_keeps_only_the_folder() { + let (mut h, writes) = targa_app(false, "copy.tga"); + let ctx = h.ctx.clone(); + let name = h.state().session.active().unwrap().doc.name.clone(); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some(format!("out/{name}.tga"))).unwrap(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"folder": "out", "extension": "tga", "tgaBits": 24})), + ("file.saveACopy".into(), json!({"path": "copy.tga", "tgaBits": 24})) + ] + ); + writes.borrow_mut().clear(); + h.state_mut().run("file.new", json!({"width": 8, "height": 8, "name": "second"})).unwrap(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let second = std::path::Path::new("out").join("second.tga").to_string_lossy().into_owned(); + assert_eq!(writes.borrow().as_slice(), &[(second, asked(Some(24))), ("copy.tga".to_string(), asked(Some(24)))]); +} + /// Closing an unsaved document whose save goes to a Targa waits behind Targa Options, then closes. #[test] fn closing_after_a_targa_save_waits_for_targa_options() { diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs new file mode 100644 index 0000000000..15826375a4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs @@ -0,0 +1,308 @@ +//! TIFF Options on Save As and Save a Copy (#8827): Image Compression, Save Image Pyramid, Save +//! Transparency and the layers choice; the scripted params that skip it; recording and playback. +use super::*; +use crate::{FileDialogAnswer, tiff_options_ui}; +use egui_kittest::kittest::Queryable; +use photocraft_codecs::TiffCompression; +use std::{cell::RefCell, rc::Rc}; + +type Writes = Rc)>>>; + +/// What a write was asked for: compression, Save Transparency, pyramid, layers. +fn asked(compression: &str, transparency: Option, pyramid: bool, layers: bool) -> Vec { + serde_json::to_vec(&(compression, transparency, pyramid, layers)).unwrap() +} + +/// A harness with one 8×8 document (`background`: "white" or "transparent") whose exports record +/// their TIFF settings, showing TIFF Options and answering a save dialog with `to`. +fn tiff_app(background: &str, to: &str) -> (Prompted, Writes) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8, "background": background})).unwrap(); + app.sync_views(); + let writes: Writes = Rc::default(); + let written = writes.clone(); + app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(to.into()))]).0); + app.services.export = Some(Box::new(|_, _, s| { + Ok((asked(tiff_options_ui::compression_name(s.tiff_compression), s.tiff_transparency, s.tiff_pyramid, s.tiff_layers), Vec::new())) + })); + app.services.write = Some(Box::new(move |path, bytes| { + written.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)); + let builder = if std::env::var_os("PHOTOCRAFT_TIFF_SNAPSHOTS").is_some() { builder.wgpu() } else { builder }; + let h = builder.build_ui_state( + |ui, app| { + show(app, ui.ctx()); + tiff_options_ui::show(app, ui.ctx()); + app.poll_file_dialog(ui.ctx(), None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.ctx.set_os(egui::os::OperatingSystem::Windows); + (h, writes) +} + +/// `PHOTOCRAFT_TIFF_SNAPSHOTS=` saves the dialog as a PNG to look at. +fn capture(h: &mut Prompted, name: &str) { + if let Some(dir) = std::env::var_os("PHOTOCRAFT_TIFF_SNAPSHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +/// A flat TIFF (one Background) asks too: compression and pyramid, but no transparency or layers +/// to choose. The answer is written, recorded and remembered for the next TIFF. +#[test] +fn save_as_to_a_flat_tiff_asks_for_compression_and_pyramid() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "unused.tif"); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some("p.tif".into())).unwrap(); + h.run_steps(2); + capture(&mut h, "tiff-options-flat"); + let p = h.state().tiff_options.as_ref().expect("prompt"); + assert_eq!((p.compression, p.pyramid, p.has_transparency, p.layered, p.copy), (TiffCompression::None, false, false, false, false)); + assert!(h.query_by_label("Save Transparency").is_none(), "nothing transparent to save"); + assert!(h.query_by_label("Discard Layers and Save a Copy").is_none()); + assert!(writes.borrow().is_empty(), "nothing is written before the answer"); + h.get_by_label("LZW").click(); + h.get_by_label("Save Image Pyramid").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("p.tif".to_string(), asked("lzw", None, true, true))]); + assert!(h.state().tiff_options.is_none()); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("p.tif")); + // The next TIFF starts where this one ended. + h.state_mut().save_as(Some("q.tif".into())).unwrap(); + h.run_steps(2); + assert_eq!(h.state().tiff_options.as_ref().map(|p| (p.compression, p.pyramid)), Some((TiffCompression::Lzw, true))); + h.get_by_label("ZIP").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!( + h.state().session.actions.list[0].steps, + [ + ("file.saveAs".into(), json!({"path": "p.tif", "tiffCompression": "lzw", "tiffPyramid": true})), + ("file.saveAs".into(), json!({"path": "q.tif", "tiffCompression": "zip", "tiffPyramid": true})), + ] + ); + // Playback writes the same files without asking. + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + h.run_steps(2); + assert!(h.state().tiff_options.is_none()); + assert_eq!(writes.borrow().as_slice(), &[("p.tif".to_string(), asked("lzw", None, true, true)), ("q.tif".to_string(), asked("zip", None, true, true))]); +} + +/// A document without a Background offers Save Transparency, on to start; with an alpha or spot +/// channel it starts off, as an unset Save Transparency writes. Its layers can be discarded, +/// which saves a copy. +#[test] +fn layered_transparent_documents_choose_transparency_and_layers() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("transparent", "unused.tif"); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().save_as(Some("t.tif".into())).unwrap(); + h.run_steps(4); + capture(&mut h, "tiff-options-layered"); + let p = h.state().tiff_options.as_ref().expect("prompt"); + assert_eq!((p.has_transparency, p.transparency, p.layered), (true, true, true)); + assert!(h.query_by_label("Including layers will increase file size.").is_some(), "the preference is on by default"); + h.get_by_label("Save Transparency").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("t.tif".to_string(), asked("none", Some(false), false, true))]); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.tif")); + + // An alpha channel: Save Transparency starts off. Discarding the layers writes a copy. + h.state_mut().run("select.all", json!({})).unwrap(); + h.state_mut().run("select.saveSelection", json!({})).unwrap(); + assert!(!h.state().session.active().unwrap().doc.channels.is_empty(), "an alpha channel"); + h.state_mut().save_as(Some("u.tif".into())).unwrap(); + h.run_steps(4); + assert_eq!(h.state().tiff_options.as_ref().map(|p| p.transparency), Some(false)); + h.get_by_label("Discard Layers and Save a Copy").click(); + h.run_steps(2); + assert!(h.query_by_label("Including layers will increase file size.").is_none(), "no layers, no note"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().last().unwrap(), &("u.tif".to_string(), asked("none", Some(false), false, false))); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.tif"), "a copy: the document keeps its file"); +} + +/// "Don't show again" turns off the layered-file note for the next save. +#[test] +fn dont_show_again_clears_the_layered_tiff_preference() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, _) = tiff_app("transparent", "unused.tif"); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().save_as(Some("t.tif".into())).unwrap(); + h.run_steps(4); + h.get_by_label("Don't show again").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(!h.state().session.prefs().file_handling.ask_before_saving_layered_tiff); + h.state_mut().save_as(Some("t.tif".into())).unwrap(); + h.run_steps(4); + assert!(h.state().tiff_options.is_some(), "TIFF Options still asks"); + assert!(h.query_by_label("Including layers will increase file size.").is_none()); +} + +/// Save a Copy asks too, and its answer replays as a `file.saveACopy` step. +#[test] +fn save_a_copy_asks_and_replays() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "copy.tif"); + h.state_mut().run("actions.record", json!({})).unwrap(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().tiff_options.as_ref().map(|p| p.copy), Some(true)); + h.get_by_label("ZIP").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(writes.borrow().as_slice(), &[("copy.tif".to_string(), asked("zip", None, false, true))]); + assert_eq!(h.state().session.active().unwrap().path, None, "a copy"); + assert_eq!(h.state().session.actions.list[0].steps, [("file.saveACopy".into(), json!({"path": "copy.tif", "tiffCompression": "zip"}))]); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(writes.borrow().as_slice(), &[("copy.tif".to_string(), asked("zip", None, false, true))]); +} + +/// File › Save confirming the document's own TIFF asks TIFF Options, and an action being +/// recorded keeps it as a plain Save (no path) with the answer: played back, it writes the TIFF +/// the action is played on, not the one it was recorded on (#3531). +#[test] +fn recorded_plain_save_of_a_tiff_writes_the_file_it_is_played_on() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "own.tif"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.tif", "tiffCompression": "none"})).unwrap(); + h.state_mut().run("actions.record", json!({})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().tiff_options.is_some(), "confirming its own TIFF asks TIFF Options"); + h.get_by_label("LZW").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[0].steps, [("file.save".into(), json!({"tiffCompression": "lzw"}))]); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.tif", "tiffCompression": "none"})).unwrap(); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(h.state().tiff_options.is_none(), "playback doesn't ask"); + assert_eq!(writes.borrow().as_slice(), &[("other.tif".to_string(), asked("lzw", None, false, true))]); +} + +/// A scripted plain Save that passes the TIFF answers skips the prompt and is recorded the same +/// way: `file.save` with the answers and no path, so playback writes the file it is played on. +#[test] +fn recorded_scripted_plain_save_of_a_tiff_has_no_path() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "own.tif"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.tif", "tiffCompression": "none"})).unwrap(); + h.state_mut().run("actions.record", json!({})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({"tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true})).unwrap(); + h.run_steps(3); + assert!(h.state().tiff_options.is_none(), "the script answered"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!( + h.state().session.actions.list[0].steps, + [("file.save".into(), json!({"tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true}))] + ); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.tif", "tiffCompression": "none"})).unwrap(); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(writes.borrow().as_slice(), &[("other.tif".to_string(), asked("lzw", Some(false), true, true))]); +} + +/// OK writes the waiting document with its own save settings, even after another tab became +/// active: its path is what linked smart objects resolve against. +#[test] +fn confirm_uses_the_waiting_documents_settings_not_the_active_tabs() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, _) = tiff_app("white", "unused.tif"); + let seen: Rc>>> = Rc::default(); + let saw = seen.clone(); + h.state_mut().services.export = Some(Box::new(move |_, _, s| { + saw.borrow_mut().push(s.document_path.clone()); + Ok((Vec::new(), Vec::new())) + })); + h.state_mut().save_as(Some("a.tif".into())).unwrap(); + h.run_steps(4); + h.key_press(Key::Enter); + h.run_steps(3); + let a = h.state().active_doc_id().unwrap(); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("a.tif")); + h.state_mut().run("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + h.state_mut().sync_views(); + let b = h.state().active_doc_id().unwrap(); + h.state_mut().refocus(a).unwrap(); + h.state_mut().save_as(Some("b.tif".into())).unwrap(); + h.run_steps(4); + assert!(h.state().tiff_options.is_some()); + h.state_mut().refocus(b).unwrap(); + seen.borrow_mut().clear(); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(seen.borrow().as_slice(), &[Some(std::path::PathBuf::from("a.tif"))]); +} + +/// Scripted saves that pass TIFF answers skip the prompt; a bad one is an error, not a save. +#[test] +fn scripted_tiff_answers_skip_the_prompt() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("transparent", "unused.tif"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.tif", "tiffCompression": "zip", "tiffTransparency": true, "tiffPyramid": true})) + .unwrap(); + h.run_steps(2); + assert!(h.state().tiff_options.is_none()); + assert_eq!(writes.borrow().as_slice(), &[("s.tif".to_string(), asked("zip", Some(true), true, true))]); + for bad in [json!({"path": "b.tif", "tiffCompression": "jpeg"}), json!({"path": "b.tif", "tiffPyramid": "yes"})] { + assert!(crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(writes.borrow().len(), 1); + assert!(h.state().tiff_options.is_none()); + // Automation saves never prompt, and take the answers too. + let choices = crate::FormatChoices::from_params(&json!({"tiffCompression": "lzw"}), "app.save").unwrap(); + h.state_mut().services.automation_write = Some(Box::new({ + let w = writes.clone(); + move |path, bytes| { + w.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + } + })); + h.state_mut().save_automation_with(Some("a.tif".into()), choices).unwrap(); + assert_eq!(writes.borrow().last().unwrap(), &("a.tif".to_string(), asked("lzw", None, false, true))); +} + +/// Cancel writes nothing. +#[test] +fn cancel_writes_nothing() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "unused.tif"); + h.state_mut().save_as(Some("p.tif".into())).unwrap(); + h.run_steps(2); + h.key_press(Key::Escape); + h.run_steps(3); + assert!(h.state().tiff_options.is_none()); + assert!(writes.borrow().is_empty()); + assert_eq!(h.state().session.active().unwrap().path, None); +} diff --git a/crates/ui-egui/src/dialogs/disk_change_prompt.rs b/crates/ui-egui/src/dialogs/disk_change_prompt.rs index 34d83075e1..06727c7a8b 100644 --- a/crates/ui-egui/src/dialogs/disk_change_prompt.rs +++ b/crates/ui-egui/src/dialogs/disk_change_prompt.rs @@ -50,8 +50,9 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result, /// Edit › Content-Aware Fill… (caf_ui). pub caf: Option, + /// Select › Select and Mask… (select_mask_ui). + pub select_mask: Option, pub puppet: Option, pub perspective: Option, pub vanishing: Option, @@ -35,7 +37,12 @@ pub struct Distort { impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.caf.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() + self.liquify.is_some() + || self.caf.is_some() + || self.select_mask.is_some() + || self.puppet.is_some() + || self.perspective.is_some() + || self.vanishing.is_some() } /// Short description for `ui.inspect`. @@ -43,6 +50,7 @@ impl Distort { json!({ "liquify": self.liquify.as_ref().map(|d| d.describe()), "contentAwareFill": self.caf.as_ref().map(|d| d.describe()), + "selectAndMask": self.select_mask.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), "vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()), @@ -63,6 +71,107 @@ pub(crate) fn active_pixels(app: &PhotocraftApp) -> Result<(LayerId, Surface, bo } } +/// The colour set full-window filters capture, or Null when none is targeted: the command then +/// routes a targeted mask, filter mask, alpha channel or Quick Mask itself, as without colour sets. +pub(crate) fn colour_set_target(app: &PhotocraftApp) -> Value { + let target = crate::canvas::paint_target(app); + if target.get("colors").is_some() { target } else { Value::Null } +} + +/// `params` with the captured `target`, if any (see [`colour_set_target`]). +pub(crate) fn with_filter_target(mut params: Value, target: &Value) -> Value { + if !target.is_null() + && let Some(o) = params.as_object_mut() + { + o.insert("target".into(), target.clone()); + } + params +} + +/// Full-window filters capture their layer target; scalar planes use ordinary filter dialogs. +/// Without a colour set they open on the layer's pixels or smart object, as before colour sets. +pub(crate) fn active_filter_target(app: &PhotocraftApp) -> Result<(LayerId, Surface, Value), String> { + let target = colour_set_target(app); + if target.is_null() { + // Colour rows left selected on a smart object: the engine refuses the command, so refuse + // before any work is done in the workspace. + if let Some(st) = app.session.active() + && matches!(st.channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Color(_)) + && photocraft_engine::mask_view_cmds::current(st).is_none() + && st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Smart(_))) + { + return Err("Rasterize the layer before editing its colour channels".into()); + } + let (layer, surface, _) = active_pixels(app)?; + return Ok((layer, surface, Value::Null)); + } + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.ok_or("no active layer")?; + let params = serde_json::json!({"target":target}); + if photocraft_engine::color_channels::explicit(¶ms, photocraft_engine::channel_cmds::color_count(&st.doc)).map_err(|e| e.to_string())?.is_some() + && !matches!(st.doc.layer(layer).map(|l| &l.content), Some(LayerContent::Raster(_))) + { + return Err("Rasterize the layer before editing its colour channels".into()); + } + let surface = photocraft_engine::channel_cmds::read_target_surface(&st.doc, Some(layer), ¶ms).map_err(|e| e.to_string())?.clone(); + Ok((layer, surface, target)) +} + +#[cfg(test)] +pub(crate) mod component_tests { + use super::*; + + pub fn app(mode: &str, depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":64,"height":48,"mode":mode,"depth":depth})).unwrap(); + app.session + .edit("Native detail", |doc, id| { + let surface = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + let n = surface.format().mode.color_channels(); + for y in 0..48 { + for x in 0..64 { + let mut pixel: Vec<_> = (0..n).map(|k| ((x * 13 + y * 7 + k * 43) % 201 + 20) as f32 / 255.0).collect(); + pixel.push(0.65); + surface.write_pixel(x as i32, y as i32, &pixel); + } + } + Ok(()) + }) + .unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app + } + + pub fn without_alpha(mut app: PhotocraftApp) -> PhotocraftApp { + app.session + .edit("Alpha-less native raster", |doc, id| { + let bounds = doc.bounds(); + let surface = doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap(); + let old = surface.format(); + let format = photocraft_color::PixelFormat::new(old.mode, old.sample, false); + let raw = surface.read_region(bounds); + let raw: Vec<_> = raw.chunks_exact(old.channels()).flat_map(|pixel| pixel.iter().take(format.channels()).copied()).collect(); + let mut replacement = Surface::new(format); + replacement.write_region(bounds, &raw); + *surface = replacement; + Ok(()) + }) + .unwrap(); + app + } + + pub fn protected(before: &Surface, after: &Surface, bounds: Rect) { + assert_eq!(before.format(), after.format()); + for y in bounds.y0..bounds.y1 { + for x in bounds.x0..bounds.x1 { + for k in (0..before.channels()).filter(|k| ![0, 2].contains(k)) { + assert_eq!(before.sample_channel(x, y, k).to_bits(), after.sample_channel(x, y, k).to_bits(), "component {k} at {x},{y}"); + } + } + } + } +} + /// The document with layer `id` hidden (what shows behind a live preview of that layer). pub(crate) fn without_layer(doc: &Document, id: LayerId) -> Arc { let mut d = doc.clone(); @@ -95,7 +204,9 @@ pub(crate) fn surface_image(surf: &Surface, r: Rect, max_side: usize) -> ColorIm /// Adaptive Wide Angle). These are modal: other menu commands wait for OK or Cancel /// (`menus::modal_allows`). pub(crate) fn modal_workspace(app: &PhotocraftApp) -> Option<&'static str> { - if app.distort.liquify.is_some() { + if app.distort.select_mask.is_some() { + Some("select.selectAndMask") + } else if app.distort.liquify.is_some() { Some("filter.liquify") } else if app.distort.caf.is_some() { Some("edit.contentAwareFill") @@ -116,7 +227,7 @@ fn on_doc(app: &PhotocraftApp, document: DocId) -> bool { active_doc(app) == Some(document) } -/// Drops Liquify / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the +/// Drops Liquify / Select and Mask / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the /// document they were opened on is no longer active, so they cannot steal undo, block layer /// delete, or draw another document's pixels (#5115). pub fn cancel_stale(app: &mut PhotocraftApp) { @@ -136,6 +247,10 @@ pub fn cancel_stale(app: &mut PhotocraftApp) { if app.distort.gallery.as_ref().is_some_and(|g| Some(g.document) != active) { app.distort.gallery = None; } + // Its document, or the layer it refines, is no longer the active one (#8896 review). + if crate::select_mask_ui::stale(app).is_some() { + crate::select_mask_ui::cancel(app); + } } /// The document shown on the canvas while Puppet or Perspective Warp previews a layer, or @@ -171,6 +286,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))), // `ui` addresses the workspace: closed, that is an error, not a fill with the defaults. "edit.contentAwareFill" if ui.is_some() => Some(Err("Content-Aware Fill is not open".into())), + "select.selectAndMask" if ui.is_some() && app.distort.select_mask.is_some() => Some(crate::select_mask_ui::control(app, ui.unwrap_or(&Value::Null))), + "select.selectAndMask" if ui.is_some() => Some(Err("Select and Mask is not open".into())), + "select.selectAndMask" => Some(crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]}))), "edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => { Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]}))) } @@ -196,6 +314,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> crate::caf_ui::pointer(app, ev, mods); return true; } + if app.distort.select_mask.as_ref().is_some_and(|w| on_doc(app, w.document)) { + crate::select_mask_ui::pointer(app, ev, mods); + return true; + } if app.distort.liquify.as_ref().is_some_and(|d| on_doc(app, d.document)) { crate::liquify_ui::pointer(app, ev, mods); return true; @@ -238,6 +360,15 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.select_mask.is_some() { + // Enter closes a Polygonal Lasso polygon, else OK; Esc drops one, else Cancel. + if enter(ctx) { + crate::select_mask_ui::enter(app); + } else if esc(ctx) { + crate::select_mask_ui::escape(app); + } + return true; + } if app.distort.liquify.is_some() { if enter(ctx) { let _ = crate::liquify_ui::commit(app); @@ -348,6 +479,7 @@ pub fn drop_foreign(app: &mut PhotocraftApp) { app.distort.liquify = None; // Stops its background preview too; what Apply wrote stays, as with Cancel. crate::caf_ui::cancel(app); + crate::select_mask_ui::cancel(app); app.distort.puppet = None; app.distort.perspective = None; app.distort.vanishing = None; @@ -363,6 +495,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.distort.caf.is_some() { crate::caf_ui::show(app, ctx); } + if app.distort.select_mask.is_some() { + crate::select_mask_ui::show(app, ctx); + } if app.distort.gallery.is_some() { crate::gallery_ui::show(app, ctx); } diff --git a/crates/ui-egui/src/dialogs/edit_history.rs b/crates/ui-egui/src/dialogs/edit_history.rs index 2012f912c7..6d9e017e38 100644 --- a/crates/ui-egui/src/dialogs/edit_history.rs +++ b/crates/ui-egui/src/dialogs/edit_history.rs @@ -86,11 +86,13 @@ pub type DialogEdits = HashMap>>; /// Once a frame: records each open parameter dialog's settings and forgets closed dialogs. pub fn track_dialogs(app: &mut PhotocraftApp, ctx: &egui::Context) { let busy = ctx.input(|i| i.pointer.any_down()); + // A colour an open Color Picker only shows in a dialog is no step of it until the picker's OK. + let previewed = crate::color_picker_ui::live_preview::previewed_dialogs(app); let dialogs = &app.ui.dialogs; app.dialog_edits.retain(|id, _| dialogs.iter().any(|d| d.id == *id)); for d in dialogs.iter().filter(|d| d.kind == DialogKind::Command) { let now = settings(&d.fields); - app.dialog_edits.entry(d.id).or_insert_with(|| EditHistory::new(&now)).settle(&now, busy); + app.dialog_edits.entry(d.id).or_insert_with(|| EditHistory::new(&now)).settle(&now, busy || previewed.contains(&d.id)); } } @@ -101,17 +103,30 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } if let Some(top) = app.ui.dialogs.last() { - if top.kind != DialogKind::Command { + // A shortcut field waiting for keys (Keyboard Shortcuts) records ⌘Z like any other key + // (#9487); stepping back here undid the click that started the capture. + if top.kind != DialogKind::Command || top.fields.get("capture").and_then(Value::as_bool) == Some(true) { return; } let id = top.id; let mut now = settings(&top.fields); + let history = app.dialog_edits.entry(id).or_insert_with(|| EditHistory::new(&now)); if let Some(step) = take_step(ctx) - && app.dialog_edits.entry(id).or_insert_with(|| EditHistory::new(&now)).step(&mut now, step) - && let Some(d) = app.ui.dialog_mut(id) + && history.step(&mut now, step) { - d.fields.retain(|k, _| k.starts_with("__")); - d.fields.extend(now); + // A step back to before a recorded shortcut doesn't wait for keys again (and, like + // the dialog itself, keeps no capture modifiers then). That isn't an edit of its own: + // the step's settled settings say so too, or the next frame would record it and + // lose Redo. + if let Some(c) = now.get_mut("capture") { + *c = Value::Bool(false); + now.remove("captureMods"); + history.settled = now.clone(); + } + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.retain(|k, _| k.starts_with("__")); + d.fields.extend(now); + } } } else if let Some(d) = app.distort.gallery.as_mut() { if let Some(step) = take_step(ctx) { diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 7fd65f68c8..68f46af65f 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -6,7 +6,8 @@ use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use std::sync::Mutex; -use photocraft_doc::Document; +use photocraft_cms::Profile; +use photocraft_doc::{ColorMode, Document}; use serde_json::{Map, Value, json}; use photocraft_engine::export_preset_cmds::{DEFAULT_RESAMPLE, RESAMPLE, export_resample}; @@ -15,7 +16,7 @@ use crate::state::DialogKind; use crate::theme::Tokens; use crate::{ExportSettings, PhotocraftApp}; -const FORMATS: [(&str, &str); 6] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO")]; +const FORMATS: [(&str, &str); 7] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA"), ("ico", "ICO"), ("jxl", "JPEG XL")]; /// Preferences ▸ remembered dialog settings: Export As writes the settings it was used with /// last under its command id and seeds the next dialog from them, as Fill, Liquify and the @@ -90,7 +91,14 @@ fn remember(app: &mut PhotocraftApp, f: &Map) { } pub fn open(app: &mut PhotocraftApp) -> Result { - let st = app.session.active().ok_or("no document")?; + let size = app.session.active().ok_or("no document")?.doc.size; + open_sized(app, size.width, size.height) +} + +/// The dialog with its size fields fitted to a `src_w` × `src_h` source: the document, or the +/// layer Layer › Export As exports. Fitting the document first would replace a remembered pixel +/// size the document can't take with the percent, even when the layer can take it. +fn open_sized(app: &mut PhotocraftApp, src_w: u32, src_h: u32) -> Result { let mut f = Map::new(); f.insert("__export".into(), json!(true)); f.insert("__label".into(), json!("Export As")); @@ -106,42 +114,54 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("lockAspect".into(), json!(true)); f.insert("profile".into(), json!("document")); f.insert("embedProfile".into(), json!(true)); - f.insert("width".into(), json!(st.doc.size.width)); - f.insert("height".into(), json!(st.doc.size.height)); - f.insert("__w".into(), json!(st.doc.size.width)); - f.insert("__h".into(), json!(st.doc.size.height)); + f.insert("width".into(), json!(src_w)); + f.insert("height".into(), json!(src_h)); + f.insert("__w".into(), json!(src_w)); + f.insert("__h".into(), json!(src_h)); seed_remembered(app, &mut f); - let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height"); - if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) { + fit_size(&mut f, f64::from(src_w), f64::from(src_h)); + Ok(app.ui.open_dialog(DialogKind::Command, f)) +} + +/// The size fields for exporting a `src_w` × `src_h` source. A remembered percent scales it. +/// Remembered exact pixels with Constrain proportions keep their width and take the height from +/// this source, as ticking the checkbox does: the last export's proportions would stretch an +/// image of another shape (#8738). Unlinked, the exact pixels are the size the user asked for. +/// A linked width this source can't reach within the Scale field's 1..=1000 % falls back to the +/// remembered percent. +fn fit_size(f: &mut Map, src_w: f64, src_h: f64) { + if s(f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height") { + if !f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true) { + return; + } + if let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true) + && (1.0..=1000.0).contains(&size.scale) + { + write_size(f, size, "pixels"); + return; + } + } + if let Ok(size) = resize_from_scale(src_w, src_h, n(f, "scale", 100.0)) { f.insert("width".into(), json!(size.width)); f.insert("height".into(), json!(size.height)); - if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 { + if (n(f, "scale", 100.0) - size.scale).abs() > 1e-9 { f.insert("scale".into(), json!(size.scale)); } f.insert("sizing".into(), json!("scale")); } - Ok(app.ui.open_dialog(DialogKind::Command, f)) } /// Layer › Export As…: the same dialog for just the active layer (trimmed to its pixels). pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Result { let st = app.session.active().ok_or("no document")?; let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?; - let id = open(app)?; + // The size fields follow the layer's trimmed size, so the dialog shows and edits the size + // the layer exports at. + let id = open_sized(app, ldoc.size.width, ldoc.size.height)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { let f = &mut d.fields; f.insert("__layer".into(), json!(layer.0)); f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); - f.insert("__w".into(), json!(ldoc.size.width)); - f.insert("__h".into(), json!(ldoc.size.height)); - // `open` seeded width and height from the document; in scale mode they follow the layer's - // trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels - // (sizing "pixels") are a size the user asked for, so those stay. - if s(f, "sizing") != "pixels" - && let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0)) - { - write_size(f, size, "scale"); - } } Ok(id) } @@ -259,6 +279,14 @@ fn source_document(app: &PhotocraftApp, f: &Map) -> Result Arc { + doc.icc_profile + .as_ref() + .and_then(|bytes| photocraft_engine::color_cmds::profile_from_bytes(bytes).ok()) + .filter(|profile| profile.color_space == photocraft_engine::color_cmds::mode_space(doc.mode)) + .unwrap_or_else(|| app.session.color.working(doc.mode)) +} + fn s(f: &Map, k: &str) -> String { f.get(k).and_then(Value::as_str).unwrap_or_default().to_string() } @@ -285,11 +313,16 @@ fn nearest(f: &Map) -> bool { /// The document as exported: scaled (engine Image Size, with the dialog's Resample method) and /// flattened over white when transparency is off or the format has no alpha. -fn export_document(doc: &Document, f: &Map, max_side: Option) -> Result { +fn export_document(doc: &Document, f: &Map, max_side: Option, source_profile: &Profile) -> Result { let resample = resample_method(f, max_side)?; + let srgb = s(f, "profile") == "srgb"; + let mut copy = doc.clone(); + if srgb { + copy.icc_profile = Some(source_profile.to_bytes()); + } let mut session = photocraft_engine::Session::new(); - session.add_document(doc.clone(), None); - if s(f, "profile") == "srgb" && doc.icc_profile.is_some() { + session.add_document(copy, None); + if srgb && (doc.mode != ColorMode::Rgb || !source_profile.same_bytes(photocraft_cms::Builtin::Srgb.profile())) { session.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "perceptual"})).map_err(|e| e.to_string())?; } let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, max_side)?; @@ -342,17 +375,18 @@ pub(crate) fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result Result { let src_w = src_w.max(1.0); let src_h = src_h.max(1.0); let (width, height) = if edited_width { let width = finite_px(width)?; - let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? }; + let height = if lock { finite_px((src_h * width / src_w).max(1.0))? } else { finite_px(height)? }; (width, height) } else { let height = finite_px(height)?; - let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? }; + let width = if lock { finite_px((src_w * height / src_h).max(1.0))? } else { finite_px(width)? }; (width, height) }; let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 }; @@ -441,8 +475,8 @@ fn settings(f: &Map) -> ExportSettings { /// The preview and size estimate share a single reduced document. Resizing every layer twice is /// especially expensive for layered PSDs, even when the canvas itself is small. -fn preview_proxy(doc: &Document, f: &Map) -> Result { - export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 })) +fn preview_proxy(doc: &Document, f: &Map, source_profile: &Profile) -> Result { + export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 }), source_profile) } fn scaled_estimate(bytes: usize, doc: &Document, proxy: &Document, f: &Map) -> u64 { @@ -476,8 +510,8 @@ fn preview_pixels(proxy: &Document, estimate: Option) -> PreviewPixels { PreviewPixels { estimate, width: img.width as usize, height: img.height as usize, rgba: img.pixels } } -fn preview_sync(app: &PhotocraftApp, doc: &Document, f: &Map) -> Result { - let proxy = preview_proxy(doc, f)?; +fn preview_sync(app: &PhotocraftApp, doc: &Document, f: &Map, source_profile: &Profile) -> Result { + let proxy = preview_proxy(doc, f, source_profile)?; Ok(preview_pixels(&proxy, estimate(app, doc, &proxy, f))) } @@ -532,6 +566,7 @@ fn poll_or_start_preview( signature: &str, doc: Arc, fields: &Map, + source_profile: Arc, ) -> Option> { let pending_key = egui::Id::unique(("export-preview-pending", doc.id.0)); let pending: Option = ui.data(|d| d.get_temp(pending_key)); @@ -550,7 +585,7 @@ fn poll_or_start_preview( let started = std::thread::Builder::new().name("export-preview".into()).spawn(move || { let prepared = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let source = source_document_from(doc.clone(), &fields).unwrap_or(doc); - let proxy = preview_proxy(&source, &fields)?; + let proxy = preview_proxy(&source, &fields, &source_profile)?; let estimate = estimate_native(&source, &proxy, &fields); Ok::<_, String>(preview_pixels(&proxy, estimate)) })) @@ -583,14 +618,14 @@ const EXACT_ESTIMATE_PX: f64 = 40_000_000.0; /// The exported file's size in bytes, from encoding the full export, or `None` when it is too /// large to encode for an estimate or can't be encoded. #[cfg(not(target_arch = "wasm32"))] -fn exact_estimate(doc: &Document, f: &Map) -> Option { +fn exact_estimate(doc: &Document, f: &Map, source_profile: &Profile) -> Option { // The size `export_document` will make: exact pixels only in pixel mode, else the scale. let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, None).ok()?; let px = w * h; if !px.is_finite() || px > EXACT_ESTIMATE_PX { return None; } - let full = export_document(doc, f, None).ok()?; + let full = export_document(doc, f, None, source_profile).ok()?; let bytes = photocraft_io::export(&full, &format!("estimate.{}", s_fmt(f)), &settings(f).io_options()).ok()?.bytes; u64::try_from(bytes.len()).ok() } @@ -598,7 +633,14 @@ fn exact_estimate(doc: &Document, f: &Map) -> Option { /// The exact size for `signature`, worked out off the UI thread: `None` until it is ready (the /// proxy estimate shows meanwhile), then `Some(None)` when there is no exact size. #[cfg(not(target_arch = "wasm32"))] -fn poll_or_start_exact_estimate(ui: &mut egui::Ui, key: egui::Id, signature: &str, doc: Arc, fields: &Map) -> Option> { +fn poll_or_start_exact_estimate( + ui: &mut egui::Ui, + key: egui::Id, + signature: &str, + doc: Arc, + fields: &Map, + source_profile: Arc, +) -> Option> { type Done = Arc>>>; let done_key = key.with("exact-estimate"); if let Some((sig, size)) = ui.data(|d| d.get_temp::<(String, Option)>(done_key)) @@ -628,7 +670,7 @@ fn poll_or_start_exact_estimate(ui: &mut egui::Ui, key: egui::Id, signature: &st let started = std::thread::Builder::new().name("export-estimate".into()).spawn(move || { let size = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let source = source_document_from(doc.clone(), &fields).unwrap_or(doc); - exact_estimate(&source, &fields) + exact_estimate(&source, &fields, &source_profile) })) .unwrap_or(None); *shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(size); @@ -849,6 +891,7 @@ fn preset_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return }; + let source_profile = effective_source_profile(app, &doc); ui.horizontal_top(|ui| { // Left: settings. ui.vertical(|ui| { @@ -944,21 +987,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = ui.data(|d| d.get_temp(key)); let preview = match cached.filter(|cached| cached.signature == sig) { Some(cached) => Some(cached), None => { #[cfg(not(target_arch = "wasm32"))] let result = if background_preview_needed(&doc) { - poll_or_start_preview(ui, key, &sig, doc.clone(), f) + poll_or_start_preview(ui, key, &sig, doc.clone(), f, source_profile.clone()) } else { let source = source_document(app, f).unwrap_or_else(|_| doc.clone()); - Some(preview_sync(app, &source, f)) + Some(preview_sync(app, &source, f, &source_profile)) }; #[cfg(target_arch = "wasm32")] let result = { let source = source_document(app, f).unwrap_or_else(|_| doc.clone()); - Some(preview_sync(app, &source, f)) + Some(preview_sync(app, &source, f, &source_profile)) }; let filter = preview_filter(f); result.map(|result| cache_preview(ui, key, sig.clone(), result, filter)) @@ -979,7 +1023,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = None; let est = @@ -997,12 +1041,13 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result) -> Result, path: &str) -> Result { - let out = export_document(doc, f, None)?; +fn write_export(app: &mut PhotocraftApp, doc: &Document, f: &Map, path: &str, source_profile: &Profile) -> Result { + let out = export_document(doc, f, None, source_profile)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&out, path, &settings(f))?; let write = app.services.write.as_mut().ok_or("no writer configured")?; @@ -1044,7 +1089,8 @@ pub(crate) fn replay(app: &mut PhotocraftApp, params: &Value) -> Result Result, ids: &[photocraft_doc::LayerId]) -> Result { let st = app.session.active().ok_or("no document")?; + let source_profile = effective_source_profile(app, &st.doc); let docs: Vec = ids.iter().map(|id| photocraft_engine::layer_menu_cmds::layer_document(&st.doc, *id).map_err(|e| e.to_string())).collect::>()?; if docs.is_empty() { @@ -1063,7 +1110,7 @@ fn confirm_layers(app: &mut PhotocraftApp, f: &Map, ids: &[photoc app.pick_save(&suggested, move |app, path| { let mut exported = Vec::new(); for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { - let out = export_document(doc, &f, None)?; + let out = export_document(doc, &f, None, &source_profile)?; let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&out, &path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; diff --git a/crates/ui-egui/src/dialogs/export_dialog/tests.rs b/crates/ui-egui/src/dialogs/export_dialog/tests.rs index b015c97f85..c5a7bdfa2f 100644 --- a/crates/ui-egui/src/dialogs/export_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/export_dialog/tests.rs @@ -6,14 +6,39 @@ fn ico_export_is_available_and_rejects_oversize_output() { f.insert("format".into(), json!("ico")); f.insert("scale".into(), json!(100)); assert!(FORMATS.iter().any(|(ext, _)| *ext == "ico")); - assert!(export_document(&app.session.active().unwrap().doc, &f, None).err().is_some_and(|e| e.contains("256"))); + assert!(default_export_document(&app.session.active().unwrap().doc, &f, None).err().is_some_and(|e| e.contains("256"))); f.insert("scale".into(), json!(50)); - let resized = export_document(&app.session.active().unwrap().doc, &f, None).unwrap(); + let resized = default_export_document(&app.session.active().unwrap().doc, &f, None).unwrap(); assert_eq!((resized.size.width, resized.size.height), (150, 100)); } use super::*; +fn default_export_document(doc: &Document, f: &Map, max_side: Option) -> Result { + super::export_document(doc, f, max_side, &photocraft_engine::color_cmds::document_profile(doc)) +} + +fn default_preview_proxy(doc: &Document, f: &Map) -> Result { + super::preview_proxy(doc, f, &photocraft_engine::color_cmds::document_profile(doc)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn default_exact_estimate(doc: &Document, f: &Map) -> Option { + super::exact_estimate(doc, f, &photocraft_engine::color_cmds::document_profile(doc)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn default_poll_or_start_preview( + ui: &mut egui::Ui, + key: egui::Id, + signature: &str, + doc: Arc, + fields: &Map, +) -> Option> { + let profile = photocraft_engine::color_cmds::document_profile(&doc); + super::poll_or_start_preview(ui, key, signature, doc, fields, profile) +} + /// Export As reopens with the settings it was used with last (Preferences ▸ remembered /// dialog settings), and a value no control accepts keeps its default. #[test] @@ -74,8 +99,9 @@ fn a_replayed_export_needs_a_path_and_a_known_format() { } /// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the -/// size fields from the document, and the layer replaces them in scale mode. A remembered -/// percent scales the layer; remembered exact pixels stay the size the user asked for. +/// size fields to the document, and the layer refits them. A remembered percent scales the +/// layer; remembered exact pixels stay the size the user asked for, in the layer's proportions +/// when they are linked. #[test] fn layer_export_as_opens_with_the_layer_size() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -94,10 +120,105 @@ fn layer_export_as_opens_with_the_layer_size() { let f = &app.ui.dialog_mut(id).unwrap().fields; assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0))); - app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80}))); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80, "lockAspect": false}))); let id = open_layer(&mut app, layer).unwrap(); let f = &app.ui.dialog_mut(id).unwrap().fields; assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0))); + + // With Constrain proportions the remembered width stays and the height follows the layer. + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 50, "height": 80, "lockAspect": true}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(30.0))); + assert_eq!(f["sizing"], "pixels"); +} + +/// #8738: a pixel size remembered from a landscape export, with Constrain proportions on, +/// keeps its width and takes the height from the portrait image being exported, as ticking +/// the checkbox does; unlinked, both sides are the exact size asked for. A recorded action +/// step is not the remembered size: it replays with exactly the pixels it was recorded with. +#[test] +fn a_remembered_pixel_size_keeps_this_images_proportions() { + let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = sizes.clone(); + let services = crate::Services { + export: Some(Box::new(move |doc, _, _| { + log.borrow_mut().push((doc.size.width, doc.size.height)); + Ok((Vec::new(), Vec::new())) + })), + write: Some(Box::new(|_, _| Ok(()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 30, "height": 40})).unwrap(); + let landscape = json!({"sizing": "pixels", "width": 20, "height": 15, "scale": 50, "lockAspect": true}); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone())); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(27.0))); + assert_eq!(f["sizing"], "pixels"); + assert!((f["scale"].as_f64().unwrap() - 200.0 / 3.0).abs() < 1e-9, "{}", f["scale"]); + + let mut unlinked = landscape.clone(); + unlinked["lockAspect"] = json!(false); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), unlinked)); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(15.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone())); + let mut step = landscape; + step["path"] = json!("/x.png"); + step["format"] = json!("png"); + replay(&mut app, &step).unwrap(); + assert_eq!(*sizes.borrow(), [(20, 15)]); +} + +/// A remembered linked width this image can't take within the Scale field's 1..=1000 % +/// (2000 px from a 4000 px photo, on a 100 px image) falls back to the remembered percent. +#[test] +fn a_remembered_pixel_size_out_of_scale_falls_back_to_the_percent() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 2000, "height": 1500, "scale": 50, "lockAspect": true}))); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(50.0))); + assert_eq!((f["sizing"].as_str(), f["scale"].as_f64()), (Some("scale"), Some(50.0))); +} + +/// A remembered linked width whose height rounds below one pixel keeps its width: the height +/// is one pixel, as the Scale field gives at the same 10 %, and not the remembered percent. +#[test] +fn a_remembered_pixel_width_keeps_a_one_pixel_height() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1000, "height": 1})).unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 100, "height": 75, "scale": 100, "lockAspect": true}))); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(100.0), Some(1.0))); + assert_eq!(f["sizing"], "pixels"); + // The same for an edited height on a 1 × 1000 image. + let size = resize_from_side(1.0, 1000.0, 1.0, 100.0, true, false).unwrap(); + assert_eq!((size.width, size.height), (1.0, 100.0)); +} + +/// Layer › Export As fits the remembered size to the layer, not to the document: a remembered +/// linked width the tall document can't take (800 px wide is 320 000 px high) still fits the +/// 100 × 10 layer at 800 × 80. +#[test] +fn a_layer_export_fits_the_remembered_size_to_the_layer() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 40000})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 10})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 800, "height": 600, "scale": 50, "lockAspect": true}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(800.0), Some(80.0))); + assert_eq!(f["sizing"], "pixels"); } /// Only the dialog's own settings are remembered: not the destination, the document size or @@ -152,7 +273,7 @@ fn nearest_neighbor_doubles_each_pixel_into_an_exact_block() { f.insert("format".into(), json!("png")); f.insert("scale".into(), json!(200)); f.insert("resample".into(), json!("nearest")); - let out = export_document(&doc, &f, None).unwrap(); + let out = default_export_document(&doc, &f, None).unwrap(); assert_eq!((out.size.width, out.size.height), (10, 6)); assert_eq!(out.depth, depth); let surf = out.layers[0].surface().unwrap(); @@ -164,12 +285,12 @@ fn nearest_neighbor_doubles_each_pixel_into_an_exact_block() { assert_eq!(doc, before, "{depth:?}: export leaves the source document unchanged"); // The preview proxy keeps Nearest Neighbor's hard edges too. - let proxy = export_document(&doc, &f, Some(512)).unwrap(); + let proxy = default_export_document(&doc, &f, Some(512)).unwrap(); assert_eq!(proxy.layers[0].surface().unwrap().rgba(1, 1), checker(0, 0), "{depth:?}: preview proxy is nearest as well"); assert_eq!(preview_filter(&f), egui::TextureOptions::NEAREST); f.insert("resample".into(), json!(DEFAULT_RESAMPLE)); - let smooth = export_document(&doc, &f, None).unwrap(); + let smooth = default_export_document(&doc, &f, None).unwrap(); let blended = (0..6).flat_map(|y| (0..10).map(move |x| (x, y))).any(|(x, y)| { let p = smooth.layers[0].surface().unwrap().rgba(x, y); p[0] > 0.01 && p[2] > 0.01 @@ -188,14 +309,14 @@ fn resample_choices_are_checked() { f.insert("scale".into(), json!(300)); for (method, _) in RESAMPLE { f.insert("resample".into(), json!(method)); - let out = export_document(&doc, &f, None).unwrap(); + let out = default_export_document(&doc, &f, None).unwrap(); assert_eq!((out.size.width, out.size.height), (15, 9), "{method}"); } for bad in [json!("lanczos"), json!("NEAREST"), json!(""), json!(1), json!({"m": "nearest"})] { f.insert("resample".into(), bad.clone()); - let err = export_document(&doc, &f, None).unwrap_err(); + let err = default_export_document(&doc, &f, None).unwrap_err(); assert!(err.contains("resample must be"), "{bad}: {err}"); - assert!(export_document(&doc, &f, Some(512)).is_err(), "{bad}: the preview reports it too"); + assert!(default_export_document(&doc, &f, Some(512)).is_err(), "{bad}: the preview reports it too"); } f.remove("resample"); assert_eq!(resample_method(&f, None).unwrap(), "bicubicAutomatic"); @@ -244,7 +365,7 @@ fn export_document_scales_height_when_rounded_width_is_unchanged() { let mut f = Map::new(); f.insert("format".into(), json!("png")); f.insert("scale".into(), json!(scale)); - let out = export_document(&doc, &f, None).unwrap(); + let out = default_export_document(&doc, &f, None).unwrap(); assert_eq!((out.size.width, out.size.height), expected, "{width} × {height} at {scale}%, {depth:?}"); assert_eq!(out.depth, depth, "export preserves the document's depth"); assert_eq!(doc, before, "export leaves the source document unchanged"); @@ -264,7 +385,7 @@ fn export_document_proxy_limits_both_dimensions_before_rounding() { let mut f = Map::new(); f.insert("format".into(), json!("png")); f.insert("scale".into(), json!(scale)); - let out = export_document(&doc, &f, Some(max_side)).unwrap(); + let out = default_export_document(&doc, &f, Some(max_side)).unwrap(); assert_eq!((out.size.width, out.size.height), expected, "{width} × {height} at {scale}%, proxy limit {max_side}"); assert!(out.size.width.max(out.size.height) <= max_side, "proxy respects its longest-side limit"); assert_eq!(doc, before, "preview leaves the source document unchanged"); @@ -294,9 +415,12 @@ fn the_estimate_is_the_exported_files_size() { } } let doc = Arc::new(doc); - let f = Map::from_iter([("format".into(), json!("jpg")), ("scale".into(), json!(100)), ("quality".into(), json!(32))]); - let actual = photocraft_io::export(&export_document(&doc, &f, None).unwrap(), "x.jpg", &settings(&f).io_options()).unwrap().bytes.len() as u64; - let proxy = preview_proxy(&doc, &f).unwrap(); + // Without the embedded profile, whose fixed size the proxy would scale with the pixels + // (the untagged document is tagged sRGB on export, #9325). + let f = + Map::from_iter([("format".into(), json!("jpg")), ("scale".into(), json!(100)), ("quality".into(), json!(32)), ("embedProfile".into(), json!(false))]); + let actual = photocraft_io::export(&default_export_document(&doc, &f, None).unwrap(), "x.jpg", &settings(&f).io_options()).unwrap().bytes.len() as u64; + let proxy = default_preview_proxy(&doc, &f).unwrap(); let old = estimate_native(&doc, &proxy, &f).unwrap(); assert!((old as f64 / actual as f64 - 1.0).abs() > 0.1, "the proxy estimate was off: {old} vs {actual}"); @@ -306,7 +430,8 @@ fn the_estimate_is_the_exported_files_size() { let size = loop { let mut size = None; let mut output = ctx.run_ui(Default::default(), |ctx| { - egui::CentralPanel::default().show(ctx, |ui| size = poll_or_start_exact_estimate(ui, key, "jpg32", doc.clone(), &f)); + egui::CentralPanel::default() + .show(ctx, |ui| size = poll_or_start_exact_estimate(ui, key, "jpg32", doc.clone(), &f, photocraft_engine::color_cmds::document_profile(&doc))); }); output.textures_delta.clear(); if let Some(size) = size { @@ -323,7 +448,7 @@ fn the_estimate_is_the_exported_files_size() { ("width".into(), json!(10_000)), ("height".into(), json!(10_000)), ]); - assert_eq!(exact_estimate(&doc, &huge), None); + assert_eq!(default_exact_estimate(&doc, &huge), None); // In scale mode the size comes from the scale, as the export's does, not from stale pixels. let big = Arc::new(Document::with_background( "big", @@ -339,7 +464,7 @@ fn the_estimate_is_the_exported_files_size() { ("width".into(), json!(100)), ("height".into(), json!(100)), ]); - assert_eq!(exact_estimate(&big, &stale), None, "10000 × 10000 at 1000 %, over the limit"); + assert_eq!(default_exact_estimate(&big, &stale), None, "10000 × 10000 at 1000 %, over the limit"); let small = Map::from_iter([ ("format".into(), json!("png")), ("sizing".into(), json!("scale")), @@ -347,8 +472,9 @@ fn the_estimate_is_the_exported_files_size() { ("width".into(), json!(9000)), ("height".into(), json!(9000)), ]); - let at_10 = photocraft_io::export(&export_document(&big, &small, None).unwrap(), "x.png", &settings(&small).io_options()).unwrap().bytes.len() as u64; - assert_eq!(exact_estimate(&big, &small), Some(at_10), "100 × 100 at 10 %, within it"); + let at_10 = + photocraft_io::export(&default_export_document(&big, &small, None).unwrap(), "x.png", &settings(&small).io_options()).unwrap().bytes.len() as u64; + assert_eq!(default_exact_estimate(&big, &small), Some(at_10), "100 × 100 at 10 %, within it"); } /// #8341: Export As and its reduced preview carry the stroke width with the path. @@ -362,7 +488,7 @@ fn export_as_downscales_stroke_only_shapes_and_preview() { let doc = session.active().unwrap().doc.clone(); let fields = Map::from_iter([("format".into(), json!("png")), ("scale".into(), json!(25))]); for limit in [None, Some(128)] { - let out = export_document(&doc, &fields, limit).unwrap(); + let out = default_export_document(&doc, &fields, limit).unwrap(); let side = if limit.is_some() { 128 } else { 256 }; assert_eq!((out.size.width, out.size.height), (side, side)); let shape = out.layer(photocraft_doc::LayerId(id)).unwrap(); @@ -392,10 +518,10 @@ fn export_document_scales_and_flattens() { let mut f = Map::new(); f.insert("format".into(), json!("jpg")); f.insert("scale".into(), json!(50)); - let out = export_document(&doc, &f, None).unwrap(); + let out = default_export_document(&doc, &f, None).unwrap(); assert_eq!((out.size.width, out.size.height), (100, 50)); assert_eq!(out.layers.len(), 1); - let proxy = export_document(&doc, &f, Some(40)).unwrap(); + let proxy = default_export_document(&doc, &f, Some(40)).unwrap(); assert_eq!(proxy.size.width, 40); assert_eq!(settings(&f).jpeg_quality, Some(85)); } @@ -416,10 +542,10 @@ fn export_document_scales_when_only_the_height_changes() { photocraft_doc::SampleType::U8, photocraft_doc::Color::WHITE, ); - let out = export_document(&doc, &f, None).unwrap(); + let out = default_export_document(&doc, &f, None).unwrap(); assert_eq!((out.size.width, out.size.height), (1, want_h), "1 × {src_h} at 50%"); // The preview proxy takes the same path. - let proxy = export_document(&doc, &f, Some(40)).unwrap(); + let proxy = default_export_document(&doc, &f, Some(40)).unwrap(); assert_eq!((proxy.size.width, proxy.size.height), (1, 40), "proxy of 1 × {src_h}"); } // The label and the export agree, and neither side drops below 1 px. @@ -443,7 +569,7 @@ fn layered_preview_runs_outside_the_ui_frame() { let mut result = None; let mut output = ctx.run_ui(Default::default(), |ctx| { egui::CentralPanel::default().show(ctx, |ui| { - result = poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); + result = default_poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); }); }); output.textures_delta.clear(); @@ -453,7 +579,7 @@ fn layered_preview_runs_outside_the_ui_frame() { loop { let mut output = ctx.run_ui(Default::default(), |ctx| { egui::CentralPanel::default().show(ctx, |ui| { - result = poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); + result = default_poll_or_start_preview(ui, key, "png100", doc.clone(), &fields); }); }); output.textures_delta.clear(); @@ -469,7 +595,7 @@ fn layered_preview_runs_outside_the_ui_frame() { } let tiff_fields = Map::from_iter([("format".into(), json!("tif")), ("scale".into(), json!(100))]); - let proxy = preview_proxy(&doc, &tiff_fields).unwrap(); + let proxy = default_preview_proxy(&doc, &tiff_fields).unwrap(); let options = photocraft_io::ExportOptions { tiff_layers: true, xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; let actual_bytes = photocraft_io::export(&proxy, "estimate.tif", &options).unwrap().bytes.len(); assert_eq!(estimate_native(&doc, &proxy, &tiff_fields), Some(scaled_estimate(actual_bytes, &doc, &proxy, &tiff_fields))); @@ -558,13 +684,199 @@ fn exact_pixels_lock_aspect_and_leave_the_source() { f.insert("sizing".into(), json!("pixels")); f.insert("width".into(), json!(120)); f.insert("height".into(), json!(40)); - let out = export_document(&doc, &f, None).unwrap(); + let out = default_export_document(&doc, &f, None).unwrap(); assert_eq!((out.size.width, out.size.height), (120, 40), "explicit pixels win over the percent scale"); - let proxy = export_document(&doc, &f, Some(20)).unwrap(); + let proxy = default_export_document(&doc, &f, Some(20)).unwrap(); assert_eq!((proxy.size.width, proxy.size.height), (20, 7)); assert_eq!(doc, before, "export leaves the source document unchanged"); } +fn check_profile_only_export(width: u32, height: u32) -> std::time::Duration { + use photocraft_doc::{Color, ColorMode, SampleType, Size}; + let mut doc = Document::with_background("untagged", Size::new(width, height), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut pixels = vec![0u8; width as usize * height as usize * 4]; + for (i, p) in pixels.as_chunks_mut::<4>().0.iter_mut().enumerate() { + p.copy_from_slice(&[(i % 251) as u8, ((i / 251) % 251) as u8, ((i / 63001) % 251) as u8, 255]); + } + doc.layers[0].surface_mut().unwrap().write_interleaved(photocraft_geom::Rect::from_xywh(0, 0, width, height), &pixels); + let before = doc.clone(); + let fields = json!({"format": "png", "profile": "srgb", "transparency": true, "width": width, "height": height}).as_object().unwrap().clone(); + let start = std::time::Instant::now(); + let converted = default_export_document(&doc, &fields, None).unwrap(); + let elapsed = start.elapsed(); + let profile = photocraft_cms::Profile::parse(converted.icc_profile.as_deref().unwrap()).unwrap(); + assert!(profile.same_bytes(photocraft_cms::Builtin::Srgb.profile())); + let source = doc.layers[0].surface().unwrap(); + let output = converted.layers[0].surface().unwrap(); + assert!(source.tile_count() > 0); + assert_eq!(source.tile_count(), output.tile_count()); + assert_eq!(source.default_pixel(), output.default_pixel()); + for (coord, tile) in source.tiles() { + assert!(Arc::ptr_eq(tile, output.tile(*coord).unwrap()), "tagging must reuse pixel tiles"); + } + assert_eq!(doc, before); + elapsed +} + +#[test] +fn untagged_srgb_export_tags_without_rewriting_pixels() { + check_profile_only_export(8, 8); +} + +#[test] +#[ignore = "24 MP profile-preparation measurement"] +fn untagged_srgb_export_24mp_keeps_pixel_tiles() { + let elapsed = check_profile_only_export(6000, 4000); + println!("24 MP sRGB profile preparation (optimized test build): {elapsed:?}; all pixel tiles shared"); +} + +#[test] +fn untagged_jpeg_export_embeds_the_requested_srgb_profile() { + use photocraft_doc::{Color, ColorMode, SampleType, Size}; + for (mode, depth) in [ + (ColorMode::Rgb, SampleType::U8), + (ColorMode::Rgb, SampleType::U16), + (ColorMode::Rgb, SampleType::F32), + (ColorMode::Grayscale, SampleType::U8), + (ColorMode::Cmyk, SampleType::U8), + ] { + let doc = Document::with_background("untagged", Size::new(8, 8), mode, depth, Color::WHITE); + assert!(doc.icc_profile.is_none()); + let before = doc.clone(); + let mut fields = + json!({"format": "jpg", "profile": "srgb", "embedProfile": true, "metadata": "none", "width": 8, "height": 8}).as_object().unwrap().clone(); + let converted = default_export_document(&doc, &fields, None).unwrap(); + let bytes = photocraft_io::export(&converted, "out.jpg", &settings(&fields).io_options()).unwrap().bytes; + let reloaded = photocraft_io::import("out.jpg", &bytes).unwrap().document; + let profile = photocraft_cms::Profile::parse(reloaded.icc_profile.as_deref().expect("explicit sRGB export must embed its profile")).unwrap(); + assert!(profile.same_colors(photocraft_cms::Builtin::Srgb.profile()), "{depth:?}"); + fields.insert("embedProfile".into(), json!(false)); + let bytes = photocraft_io::export(&converted, "out.jpg", &settings(&fields).io_options()).unwrap().bytes; + assert!(photocraft_io::import("out.jpg", &bytes).unwrap().document.icc_profile.is_none(), "embedding remains optional"); + fields.insert("profile".into(), json!("document")); + fields.insert("embedProfile".into(), json!(true)); + let unchanged = default_export_document(&doc, &fields, None).unwrap(); + assert!(unchanged.icc_profile.is_none(), "Document Profile preserves an untagged source"); + assert_eq!(doc, before, "export must not tag or convert the source document"); + } +} + +#[test] +fn preview_cache_tracks_working_profiles_but_prefers_embedded_profiles() { + use photocraft_cms::Builtin; + use photocraft_doc::{Color, SampleType, Size}; + for embedded in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("edit.colorSettings", json!({"workingRgb": "display-p3", "policyRgb": "off"})).unwrap(); + let mut doc = Document::with_background("colour", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::rgba(0.35, 0.65, 0.45, 1.0)); + if embedded { + doc.icc_profile = Some(Builtin::DisplayP3.profile().to_bytes()); + } + app.session.open_document(doc, None); + let id = open(&mut app).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("profile".into(), json!("srgb")); + let st = app.session.active().unwrap(); + let revision = st.revision; + let key = egui::Id::unique(("export-preview", st.doc.id.0, revision, None::)); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro); + let draw = |app: &mut PhotocraftApp, fields: &mut Map| { + let mut output = ctx.run_ui(Default::default(), |ui| body(app, ui, fields)); + output.textures_delta.clear(); + ctx.data(|d| d.get_temp::(key)).unwrap() + }; + let before = draw(&mut app, &mut fields); + let settings = settings_of(&fields); + app.session.execute("edit.colorSettings", json!({"workingRgb": "adobe-rgb-compat"})).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision); + let after = draw(&mut app, &mut fields); + assert_eq!(settings_of(&fields), settings); + assert_eq!(before.signature != after.signature, !embedded); + assert_eq!(before.texture.unwrap().id() != after.texture.unwrap().id(), !embedded); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn preview_workers_keep_the_captured_source_profile() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("edit.colorSettings", json!({"workingRgb": "display-p3", "policyRgb": "off"})).unwrap(); + let doc = Document::with_background( + "colour", + photocraft_doc::Size::new(8, 8), + ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::rgba(0.35, 0.65, 0.45, 1.0), + ); + app.session.open_document(doc, None); + let doc = app.session.active().unwrap().doc.clone(); + let profile = effective_source_profile(&app, &doc); + let fields = json!({"format": "png", "profile": "srgb", "transparency": true}).as_object().unwrap().clone(); + let expected = preview_sync(&app, &doc, &fields, &profile).unwrap(); + let expected_size = super::exact_estimate(&doc, &fields, &profile).unwrap(); + let ctx = egui::Context::default(); + let key = egui::Id::unique("captured-profile-worker"); + let mut preview = None; + let mut size = None; + let mut poll = || { + let mut output = ctx.run_ui(Default::default(), |ui| { + if preview.is_none() { + preview = super::poll_or_start_preview(ui, key, "p3", doc.clone(), &fields, profile.clone()); + } + if size.is_none() { + size = poll_or_start_exact_estimate(ui, key, "p3", doc.clone(), &fields, profile.clone()); + } + }); + output.textures_delta.clear(); + preview.is_some() && size.is_some() + }; + poll(); + app.session.execute("edit.colorSettings", json!({"workingRgb": "srgb"})).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !poll() { + assert!(std::time::Instant::now() < deadline, "profile-aware preview workers must finish"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(preview.unwrap().unwrap().rgba, expected.rgba); + assert_eq!(size.unwrap(), Some(expected_size)); +} + +#[test] +fn replay_converts_untagged_pixels_from_the_original_working_profile() { + use photocraft_cms::{Builtin, Intent}; + use photocraft_doc::{Color, ColorMode, SampleType, Size}; + for working in [Builtin::DisplayP3, Builtin::AdobeRgbCompat] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("edit.colorSettings", json!({"workingRgb": working.id(), "policyRgb": "off"})).unwrap(); + let doc = Document::with_background("untagged", Size::new(8, 8), ColorMode::Rgb, depth, Color::rgba(0.35, 0.65, 0.45, 1.0)); + app.session.open_document(doc, None); + let source = app.session.active().unwrap().doc.clone(); + assert!(source.icc_profile.is_none()); + let mut expected = (*source).clone(); + expected.icc_profile = Some(working.profile().to_bytes()); + photocraft_engine::color_cmds::convert_document(&mut expected, Builtin::Srgb.profile(), Intent::Perceptual, true).unwrap(); + let original_pixel = source.layers[0].surface().unwrap().pixel(0, 0); + let expected_pixel = expected.layers[0].surface().unwrap().pixel(0, 0); + assert_ne!(original_pixel, expected_pixel, "the control must require a pixel conversion"); + let captured = Arc::new(std::sync::Mutex::new(None)); + let output = captured.clone(); + app.services.export = Some(Box::new(move |doc, _, _| { + *output.lock().unwrap() = Some(doc.clone()); + Ok((Vec::new(), Vec::new())) + })); + app.services.write = Some(Box::new(|_, _| Ok(()))); + replay(&mut app, &json!({"path": "out.png", "format": "png", "profile": "srgb", "transparency": true})).unwrap(); + let actual = captured.lock().unwrap().take().unwrap(); + assert_eq!(actual.layers[0].surface().unwrap().pixel(0, 0), expected_pixel, "{working:?} {depth:?}"); + let profile = photocraft_cms::Profile::parse(actual.icc_profile.as_deref().unwrap()).unwrap(); + assert!(profile.same_bytes(Builtin::Srgb.profile())); + assert_eq!(app.session.active().unwrap().doc, source); + } + } +} + #[test] fn profile_and_metadata_follow_the_existing_exporter() { use photocraft_cms::Builtin; @@ -600,7 +912,7 @@ fn profile_and_metadata_follow_the_existing_exporter() { f.insert("height".into(), json!(8)); f.insert("metadata".into(), json!("none")); f.insert("embedProfile".into(), json!(false)); - let converted = export_document(&doc, &f, None).unwrap(); + let converted = default_export_document(&doc, &f, None).unwrap(); let parsed = photocraft_cms::Profile::parse(converted.icc_profile.as_deref().unwrap()).unwrap(); assert!(parsed.same_colors(Builtin::Srgb.profile()), "Convert to sRGB replaces the document profile"); let stripped = photocraft_io::export(&converted, "out.jpg", &settings(&f).io_options()).unwrap().bytes; diff --git a/crates/ui-egui/src/dialogs/file_ui.rs b/crates/ui-egui/src/dialogs/file_ui.rs index 425d7eae44..ac92f17278 100644 --- a/crates/ui-egui/src/dialogs/file_ui.rs +++ b/crates/ui-egui/src/dialogs/file_ui.rs @@ -24,7 +24,7 @@ use print::{drag_print_position, driver_paper_is_wide, print_params, print_previ use print::{print_body, print_confirm}; pub use web::open_web; #[cfg(test)] -use web::{WEB_COMMAND, WebCache, WebProxy, advance_preview, download_for_web, web_frame_document}; +use web::{WEB_COMMAND, WebCache, WebProxy, advance_preview, download_for_web, variants, web_frame_document}; use web::{web_body, web_confirm}; fn no_params(p: &Value) -> bool { @@ -273,6 +273,28 @@ mod web_playback_tests { h } + /// WebP (#6391): Quality until Lossless is ticked; 4-Up halves a lossy quality like JPEG's. + #[test] + fn webp_settings_show_quality_until_lossless() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 24})).unwrap(); + let id = open_web(&mut app); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("format".into(), json!("webp")); + assert_eq!(fields["lossless"], false); + let mut h = harness(app, fields); + assert!(h.query_all_by_label("Quality").next().is_some()); + h.get_by_label("Lossless").click(); + h.run_steps(2); + assert_eq!(h.state().1["lossless"], true); + assert!(h.query_all_by_label("Quality").next().is_none(), "lossless has no quality"); + + let v = variants(&json!({"format": "webp", "quality": 80})); + assert_eq!((v[1]["format"].as_str(), v[1]["quality"].as_f64(), v[2]["quality"].as_f64()), (Some("webp"), Some(40.0), Some(20.0))); + let v = variants(&json!({"format": "webp", "lossless": true})); + assert_eq!((v[1]["format"].as_str(), v[2]["format"].as_str()), (Some("png8"), Some("gif"))); + } + /// #9174: Save for Web reopens on the preview tab chosen last, as Photoshop's does; 2-Up at /// first, and for a remembered value it doesn't know. #[test] diff --git a/crates/ui-egui/src/dialogs/file_ui/print.rs b/crates/ui-egui/src/dialogs/file_ui/print.rs index c077259f92..a25a70a35a 100644 --- a/crates/ui-egui/src/dialogs/file_ui/print.rs +++ b/crates/ui-egui/src/dialogs/file_ui/print.rs @@ -124,6 +124,11 @@ pub(super) fn take_turned_page(f: &mut Map, r: &Value) { }; f.insert("printerSettings".into(), json!(settings)); f.insert("paper".into(), json!([w, h])); + // The printable area turns with the paper; left portrait, it wouldn't fit the turned sheet. + match r.get("printable").filter(|v| v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(Value::is_number))) { + Some(area) => f.insert("printable".into(), area.clone()), + None => f.remove("printable"), + }; f.insert("orientation".into(), json!("portrait")); f.remove("__pageError"); } diff --git a/crates/ui-egui/src/dialogs/file_ui/tests.rs b/crates/ui-egui/src/dialogs/file_ui/tests.rs index 19f7b1ceff..5aa37806d6 100644 --- a/crates/ui-egui/src/dialogs/file_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/file_ui/tests.rs @@ -256,6 +256,16 @@ fn save_for_web_dialog_runs_the_command() { let d = app.ui.dialog_mut(r["dialog"].as_u64().unwrap()).unwrap(); assert_eq!(d.fields["format"], "gif"); assert!(!d.fields.contains_key("path")); + // WebP, with the dialog's own defaults (lossy at quality 60). + let id = r["dialog"].as_u64().unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + let path = dir.join("web.webp").to_string_lossy().into_owned(); + d.fields.insert("format".into(), json!("webp")); + d.fields.insert("path".into(), json!(path)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(&bytes[8..12], b"WEBP"); + assert!(bytes.windows(4).any(|w| w == b"VP8 "), "lossy by default"); } /// A fresh session's dialog seeds from the preferences: the last export before a restart is @@ -272,6 +282,65 @@ fn the_save_for_web_dialog_seeds_from_the_remembered_preferences() { assert_eq!(f["colors"], 64); } +/// #5305: Save for Web with the suggested name runs (and so is recorded) with only the +/// folder, so an action saves each document under its own name; another name is kept. +#[test] +fn save_for_web_with_the_suggested_name_records_only_the_folder() { + let dir = std::env::temp_dir().join(format!("pc-webui-kept-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, expected) in [ + ("web.png", json!({"folder": dir.to_string_lossy()})), + ("other.png", json!({"path": dir.join("other.png").to_string_lossy()})), + ("web.gif", json!({"path": dir.join("web.gif").to_string_lossy()})), + ] { + let (mut app, ctx) = app(); + let target = dir.join(name).to_string_lossy().into_owned(); + let (dialog, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::SaveTo(target.clone()))]); + app.services.file_dialog = Some(dialog); + let id = open_web(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png24")); + crate::dialogs::confirm(&mut app, id).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(std::path::Path::new(&target).exists(), "{target}"); + let (id, params) = app.session.journal.last().unwrap(); + assert_eq!(id, WEB_COMMAND); + for (k, v) in expected.as_object().unwrap() { + assert_eq!(¶ms[k], v, "{params}"); + } + assert!(params.get(if name == "web.png" { "path" } else { "folder" }).is_none(), "{params}"); + assert!(!dir.join("web.png").exists() || name == "web.png", "only the picked file is written"); + let _ = std::fs::remove_file(&target); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A null `folder` is no destination: the picker asks. A document renamed while the picker is +/// open writes the picked file, not one named after the new name. +#[test] +fn save_for_web_writes_the_picked_file_when_no_folder_is_given_or_the_name_changed() { + let dir = std::env::temp_dir().join(format!("pc-webui-renamed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for rename in [false, true] { + let (mut app, ctx) = app(); + let target = dir.join("web.png").to_string_lossy().into_owned(); + let (dialog, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::SaveTo(target.clone()))]); + app.services.file_dialog = Some(dialog); + let id = open_web(&mut app); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + fields.insert("format".into(), json!("png24")); + fields.insert("folder".into(), Value::Null); + crate::dialogs::confirm(&mut app, id).unwrap(); + if rename { + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).name = "renamed".into(); + } + app.poll_file_dialog(&ctx, None); + assert!(std::path::Path::new(&target).exists(), "rename {rename}: {target}"); + assert!(!dir.join("renamed.png").exists(), "rename {rename}"); + let _ = std::fs::remove_file(&target); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn save_for_web_starts_at_the_current_document_size() { let (mut app, _) = app(); @@ -465,6 +534,222 @@ fn save_for_web_authorizes_generated_palette_before_opening_a_picker() { } } +/// #8830: an invalid Matte (`#1111`, empty; set here the way `ui.dialog.set` would) makes the +/// preview's encode fail, and the error the dialog shows must not widen it. +#[test] +fn save_for_web_keeps_its_width_while_the_matte_is_invalid() { + let (mut app, _) = app(); + let id = open_web(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + app.ui.dialog_mut(id).unwrap().fields.insert("matte".into(), json!("#111111")); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + let dialog = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|m| m.get_temp::>(egui::Id::unique("pc-dialog-rects"))).unwrap()[0]; + let before = dialog(&h); + for matte in ["#1111", "", "#22222", "#111111"] { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("matte".into(), json!(matte)); + h.run_steps(4); + let after = dialog(&h); + assert_eq!(after.width(), before.width(), "matte {matte:?}: before {before:?}, after {after:?}"); + } +} + +/// #8830: a typed Matte reaches the settings only once it is a whole colour (or `none`), so the +/// previews never see a partial one such as `#1111` while the user is still typing, and the dialog +/// does not resize under them (the report: "the whole interface jumps wider"). +#[test] +fn save_for_web_matte_takes_only_whole_colours() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let dialog = + |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.ctx.data(|m| m.get_temp::>(egui::Id::unique("pc-dialog-rects"))).unwrap()[0]; + let before = dialog(&h); + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + for (typed, want) in [("#1111", "#ffffff"), ("", "#ffffff"), ("#22222g", "#ffffff"), ("#2A2a2A", "#2a2a2a"), ("None", "none")] { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + if !typed.is_empty() { + h.event(egui::Event::Text(typed.into())); + } + h.run_steps(2); + let now = dialog(&h); + assert_eq!((now.width(), now.height()), (before.width(), before.height()), "typing {typed:?} resized the dialog: {before:?} -> {now:?}"); + assert_eq!(matte(&h), json!(want), "typed {typed:?}"); + let shown = h.query_all_by_role(egui::accesskit::Role::TextInput).any(|n| n.accesskit_node().value().unwrap_or_default() == typed); + assert!(shown, "the field shows {typed:?} as typed"); + } +} + +/// #8830, review follow-ups: the field takes `#rrggbb` only (no `#rgb` short form: `parse_hex` +/// wants six digits), accepts upper case (stored lower case) and a paste, keeps the typed draft in +/// the field while the setting holds the last whole colour, and on blur shows that committed colour +/// instead of the invalid draft. +#[test] +fn save_for_web_matte_short_form_case_paste_and_blur() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let values = |h: &egui_kittest::Harness<'static, PhotocraftApp>| -> Vec { + h.query_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.accesskit_node().value().unwrap_or_default()).collect() + }; + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + let replace_with = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str, paste: bool| { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.event(if paste { egui::Event::Paste(text.into()) } else { egui::Event::Text(text.into()) }); + h.run_steps(2); + }; + // (text, pasted, setting afterwards) + for (typed, paste, want) in [ + ("#fff", false, "#ffffff"), // short form is not a colour here: draft only, setting unchanged + ("#ABCDEF", false, "#abcdef"), // upper case is accepted and stored lower case + ("#12AB3", true, "#abcdef"), // a partial paste is a draft like partial typing + ("#12AB34", true, "#12ab34"), // a whole pasted colour is taken + ] { + replace_with(&mut h, typed, paste); + assert_eq!(matte(&h), json!(want), "{typed:?} (paste: {paste})"); + assert!(values(&h).iter().any(|v| v == typed), "the field keeps {typed:?} as typed: {:?}", values(&h)); + } + // Blur with an invalid draft: the field shows the last committed colour, not the draft. + replace_with(&mut h, "#22222g", false); + assert_eq!(matte(&h), json!("#12ab34")); + assert!(values(&h).iter().any(|v| v == "#22222g"), "the draft is shown while focused"); + h.key_press(egui::Key::Tab); + h.run_steps(3); + let shown = values(&h); + assert!(shown.iter().any(|v| v == "#12ab34"), "blur restores the committed colour: {shown:?}"); + assert!(!shown.iter().any(|v| v == "#22222g"), "the invalid draft is gone after blur: {shown:?}"); + assert_eq!(matte(&h), json!("#12ab34")); +} + +/// #8830, the review follow-up: alt-tabbing away from the window and back must not throw away a +/// partial Matte that is still being typed. egui keeps keyboard focus on the field while the OS +/// window is unfocused (`Memory::has_focus` ignores the window, `Response::has_focus` does not), +/// so the side that caches the typed text has to ask the same question the read side asks. +#[test] +fn save_for_web_matte_keeps_typed_text_across_window_focus_loss() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let shown = |h: &egui_kittest::Harness<'static, PhotocraftApp>, want: &str| { + h.query_all_by_role(egui::accesskit::Role::TextInput).any(|n| n.accesskit_node().value().unwrap_or_default() == want) + }; + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.event(egui::Event::Text("#1111".into())); + h.run_steps(2); + assert!(shown(&h, "#1111"), "the field shows the partial value as typed"); + assert_eq!(matte(&h), json!("#ffffff"), "a partial value must not reach the settings"); + let focused = h.ctx.memory(|m| m.focused()); + assert!(focused.is_some(), "the Matte field holds egui focus"); + // The OS window loses focus (alt-tab away): only `RawInput::focused` goes false, egui itself + // keeps focus on the field. + h.input_mut().focused = false; + h.run_steps(1); + assert!(!h.ctx.input(|i| i.focused), "the frame ran with the window unfocused"); + assert_eq!(h.ctx.memory(|m| m.focused()), focused, "egui keeps focus on the field while the window is unfocused"); + // …and comes back to the same window. + h.input_mut().focused = true; + h.run_steps(2); + assert!( + shown(&h, "#1111"), + "the partial Matte is still in the field after the window came back: {:?}", + h.query_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.accesskit_node().value().unwrap_or_default()).collect::>() + ); + assert_eq!(matte(&h), json!("#ffffff"), "the settings still hold the last whole colour"); + // Finishing the colour takes it, so the field was live, not frozen. + h.event(egui::Event::Text("11".into())); + h.run_steps(2); + assert_eq!(matte(&h), json!("#111111"), "typing on completes as usual"); +} + +/// #8830, the second follow-up: a middle-click paste into an unfocused Matte field inserts the text +/// and moves focus to the field inside the same `ui.add`. In the pinned egui fork +/// (`crates/egui/src/widgets/text_edit/builder.rs:933-974`) the `MiddleClickPaste` branch writes the +/// text into the field's buffer and then calls `Memory::request_focus`, which takes effect the same +/// frame (`crates/egui/src/memory/mod.rs:945-950`). If the cache write side looks at the focus state +/// read before `add`, it drops the pasted partial text, and the following frame restores the saved +/// colour even though the field now has focus. +#[test] +fn save_for_web_matte_caches_a_middle_click_paste_that_arrives_with_focus() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let values = |h: &egui_kittest::Harness<'static, PhotocraftApp>| -> Vec { + h.query_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.accesskit_node().value().unwrap_or_default()).collect() + }; + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "the Matte field starts unfocused"); + h.hover_at(field.center()); + h.run_steps(1); + // One frame: the PRIMARY-selection "#1111" is pasted at the click pos while the field gains + // focus, all inside `ui.add` - run_steps(1) so the single stale frame is visible, not a run() + // that would settle past the restore. + h.event(egui::Event::MiddleClickPaste { pos: field.center(), text: "#1111".into() }); + h.run_steps(1); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the paste moved focus to the field"); + h.run_steps(1); + let shown = values(&h); + assert!(shown.iter().any(|v| v.contains("#1111")), "the pasted partial must survive into the next frame, not be restored to the saved colour: {shown:?}"); + assert_eq!(matte(&h), json!("#ffffff"), "a partial value must not reach the settings"); +} + #[test] fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { let (mut app, _) = app(); @@ -473,6 +758,7 @@ fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { app.ui.dialog_mut(id).unwrap().fields.insert("__view".into(), json!("optimized")); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( |ui, app| { + crate::color_picker_ui::widget_tick(app, ui.ctx()); if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { crate::dialogs::show(app, ui.ctx()); } @@ -518,18 +804,66 @@ fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { h.run_steps(3); assert_ne!(h.state().ui.dialogs[0].fields["__center"], json!([0.5, 0.5])); assert_eq!(h.state().session.active().unwrap().revision, revision); - // Enter while editing a swatch must not export and close the parent dialog. + // A colour table swatch opens the Color Picker over the dialog; Enter confirms the picker + // only: it must not export and close the parent dialog. h.state_mut().ui.dialogs[0].fields.insert("format".into(), json!("gif")); h.run_steps(4); use egui_kittest::kittest::Queryable; h.get_by_role(egui::accesskit::Role::ColorWell).click(); h.run_steps(3); - assert!(egui::Popup::is_any_open(&h.ctx)); + assert_eq!(h.state().ui.dialogs.len(), 2); + assert!(crate::color_picker_ui::owns(&h.state().ui.dialogs[1].fields)); h.key_press(egui::Key::Enter); h.run_steps(3); assert_eq!(h.state().ui.dialogs.len(), 1); } +/// #10675: moving the pointer over Save for Web repaints it, and each repaint must lay it out the +/// same way. At a fractional display scale the previews came out a fraction of a pixel wider than +/// the room they were given, so the dialog grew a little on every repaint and the fitted zoom +/// with it. +#[test] +fn web_preview_keeps_its_size_and_zoom_while_the_pointer_moves() { + let mut drifted = Vec::new(); + for ppp in [1.0, 1.25, 1.5, 1.75, 2.25] { + for screen in [egui::vec2(1600.0, 1100.0), egui::vec2(1097.0, 617.0), egui::vec2(1280.0, 720.0)] { + for view in ["2up", "4up", "original"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1980, "height": 1200})).unwrap(); + let id = open_web(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("__view".into(), json!(view)); + let mut h = egui_kittest::Harness::builder().with_size(screen).with_pixels_per_point(ppp).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(8); + let layout = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let f = &h.state().ui.dialogs[0].fields; + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::unique("pc-dialog-rects"))).unwrap(); + (rects[0], f["__previewRects"].clone(), f["__effectiveZoom"].clone()) + }; + let before = layout(&h); + let pane = &before.1[0]; + let at = egui::pos2(pane[0].as_f64().unwrap() as f32 + 20.0, pane[1].as_f64().unwrap() as f32 + 20.0); + for i in 0..30 { + h.event(egui::Event::PointerMoved(at + egui::vec2(i as f32 * 3.0, i as f32))); + h.step(); + } + let after = layout(&h); + if after != before { + drifted.push(format!("{view} at {ppp}x on {screen:?}: {before:?} -> {after:?}")); + } + } + } + } + assert!(drifted.is_empty(), "the dialog or its zoom changed while the pointer moved: {drifted:#?}"); +} + #[test] fn palette_draft_survives_navigation_and_resets_when_colors_change() { let (app, _) = app(); @@ -731,3 +1065,29 @@ fn a_turn_takes_settings_and_paper_together() { assert!(f.get("__pageError").is_some(), "{partial}"); } } + +/// #7782: the driver's printable area turns with the paper. Left portrait on a landscape +/// sheet, Scale to Fit Media refused the print instead of fitting it. +#[test] +fn a_turn_takes_the_printable_area_with_the_paper() { + let (mut app, ctx) = app(); + crate::menus::invoke(&mut app, &ctx, "file.new", json!({"width": 500, "height": 1000})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let mut f = Map::new(); + f.insert("scaleToFit".into(), json!(true)); + f.insert("dryRun".into(), json!(false)); + f.insert("printerSettings".into(), json!("old")); + f.insert("paper".into(), json!([842.29, 1191.14])); + f.insert("printable".into(), json!([12.0, 12.0, 818.29, 1167.14])); + f.insert("orientation".into(), json!("portrait")); + take_turned_page(&mut f, &json!({"printerSettings": "new", "paper": [1191.14, 842.29], "printable": [12.0, 12.0, 1167.14, 818.29]})); + let l = photocraft_engine::print_cmds::layout(&doc, &print_params(&f), "file.print"); + assert_eq!(l.map(|l| l.paper).map_err(|e| e.to_string()), Ok((1191.14, 842.29))); + assert_eq!(f.get("printable"), Some(&json!([12.0, 12.0, 1167.14, 818.29]))); + // An answer without a usable area drops the old one: the fixed margin, not a stale area. + for area in [Value::Null, json!([12.0, 12.0, 818.29]), json!("x")] { + f.insert("printable".into(), json!([12.0, 12.0, 1167.14, 818.29])); + take_turned_page(&mut f, &json!({"printerSettings": "newer", "paper": [842.29, 1191.14], "printable": area})); + assert!(f.get("printable").is_none(), "{area}"); + } +} diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index a370da2003..12fc335b4e 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -1,6 +1,6 @@ use super::{Arc, DialogKind, Map, PhotocraftApp, Tokens, Value, WebSettings, b, check, dropdown_str, json, n, number, params, s, web_cmds, web_preview}; -const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")]; +const WEB_FORMATS: [(&str, &str); 6] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("webp", "WebP"), ("wbmp", "WBMP")]; pub(super) const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; @@ -27,7 +27,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { .unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { - if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { + if !matches!(k.as_str(), "path" | "dir" | "folder" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { f.insert(k, v); } } @@ -35,6 +35,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { for (k, v) in [ ("format", json!("jpeg")), ("quality", json!(60)), + ("lossless", json!(false)), ("palette", json!("selective")), ("colors", json!(128)), ("dither", json!("diffusion")), @@ -57,7 +58,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { } /// Settings variants shown in 4-Up (current, then two smaller alternatives). -fn variants(p: &Value) -> Vec { +pub(super) fn variants(p: &Value) -> Vec { let mut out = vec![p.clone()]; let fmt = p["format"].as_str().unwrap_or("jpeg"); for k in [2.0, 4.0] { @@ -66,7 +67,7 @@ fn variants(p: &Value) -> Vec { m.remove("colorTable"); m.remove("colorTableEdits"); } - if fmt == "jpeg" { + if fmt == "jpeg" || (fmt == "webp" && !p["lossless"].as_bool().unwrap_or(false)) { v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0)); } else if fmt == "gif" || fmt == "png8" { v["colors"] = json!((p["colors"].as_f64().unwrap_or(128.0) / k).round().max(2.0)); @@ -118,7 +119,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< }; let buf = photocraft_compose::flatten(&wd); let v = ( - Arc::new(buf.px), + Arc::new(buf.into_px()), wd.size.width, wd.size.height, (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0), @@ -138,10 +139,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>(), None), Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) { Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))), - Err(error) => { - ui.label(error); - (vec![0; (w * h * 4) as usize], None) - } + // The error shows on the row above the panes; a label here sat beside the pane + // and widened the dialog (#8830). + Err(_) => (vec![0; (w * h * 4) as usize], None), }, }; let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); @@ -265,7 +265,12 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map web_pane(app, ui, area, Some(&p), f, 1, frame), "2up" => { ui.horizontal(|ui| { - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, f, 0, frame); - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), f, 1, frame); + web_pane(app, ui, egui::vec2(half_w, area.y), None, f, 0, frame); + web_pane(app, ui, egui::vec2(half_w, area.y), Some(&p), f, 1, frame); }); } _ => { let v = variants(&p); - let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 4.0); + let cell = egui::vec2(half_w, half_h); ui.horizontal(|ui| { web_pane(app, ui, cell, None, f, 0, frame); web_pane(app, ui, cell, Some(&v[0]), f, 1, frame); @@ -350,6 +355,14 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { + check(ui, f, "lossless", tl!("Lossless"), false); + if !b(f, "lossless", false) { + number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); + } + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); + } "wbmp" => { dropdown_str( ui, @@ -366,14 +379,8 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + let t = Tokens::get(ui.ctx()); + let label = ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); + let id = ui.make_persistent_id("web-matte"); + let was_focused = ui.memory(|m| m.has_focus(id)); + let typed = if was_focused { ui.data(|d| d.get_temp::(id)) } else { None }; + let mut text = typed.unwrap_or_else(|| s(f, "matte", "#ffffff")); + let resp = ui.add(egui::TextEdit::singleline(&mut text).id(id).desired_width(80.0)).labelled_by(label.id); + if ui.memory(|m| m.has_focus(id)) { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + if !resp.changed() { + return; + } + let matte = crate::color_picker_ui::parse_hex(&text) + .map(crate::color_picker_ui::hex) + .or_else(|| text.trim().eq_ignore_ascii_case("none").then(|| "none".to_string())); + if let Some(matte) = matte { + f.insert("matte".into(), json!(matte)); + } +} + /// A size row in a two-column grid: the label, then a field showing `shown`. Returns the typed /// value when it changed, compared with the value the widget was given so a field that only /// clamps does not count as an edit. @@ -518,7 +562,11 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re authorize(&prepared)?; p = prepared; } - if p.get("path").is_some() || p.get("dir").is_some() { + // A null or empty destination is none: the picker asks for one. + if let Some(fields) = p.as_object_mut() { + fields.retain(|key, value| !(matches!(key.as_str(), "path" | "dir" | "folder") && (value.is_null() || value.as_str() == Some("")))); + } + if p.get("path").is_some() || p.get("dir").is_some() || p.get("folder").is_some() { return save_for_web(app, p); } let doc = app.session.active().ok_or("no document")?.doc.clone(); @@ -533,6 +581,14 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re let dir = path.rfind(['/', '\\']).map(|i| path[..i].to_string()).unwrap_or_else(|| ".".into()); p["dir"] = json!(dir); p["html"] = json!(html); + } else if let Some(folder) = kept_name_folder(&path, &base, st.format.ext()) + // A save that renamed the document meanwhile: the engine would name the file after + // the new name, not the one picked. + && app.session.active().is_some_and(|state| photocraft_doc::slices::base_name(&state.doc) == base) + { + // The suggested name kept: only the folder, so a recorded action saves each + // document under its own name, as in Photoshop (#5305). + p["folder"] = json!(folder); } else { p["path"] = json!(path); } @@ -540,6 +596,15 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re }) } +/// The folder of `path` when its file is the one Save for Web suggests and writes from a folder +/// alone: `base` (a name usable on Windows as it is) with the format's own extension `ext`. +fn kept_name_folder(path: &str, base: &str, ext: &str) -> Option { + let file = std::path::Path::new(path); + let portable = photocraft_engine::file_cmds::portable_device_name(base.to_string()) == base; + let kept = portable && file.file_stem().is_some_and(|s| s.to_string_lossy() == base) && file.extension().is_some_and(|e| e.to_string_lossy() == ext); + kept.then(|| file.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default()) +} + /// Runs Save for Web with `p`, which says where to write. fn save_for_web(app: &mut PhotocraftApp, p: Value) -> Result { #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/dialogs/file_ui/web_preview.rs b/crates/ui-egui/src/dialogs/file_ui/web_preview.rs index ef2a6a5475..c2bdd4ad11 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web_preview.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web_preview.rs @@ -91,7 +91,7 @@ pub fn table(ui: &mut egui::Ui, f: &mut Map) { egui::Grid::new("web-color-table").spacing(egui::vec2(1.0, 1.0)).show(ui, |ui| { for (i, source) in table.iter().enumerate() { let mut color = settings.color_table_edits.get(&i).copied().unwrap_or(*source); - let response = ui.push_id(i, |ui| crate::widgets::color_edit_button_srgb(ui, &mut color)).inner; + let response = ui.push_id(i, |ui| crate::widgets::color_edit_button_srgb(ui, &mut color, tl!("Color Picker"))).inner; response.clone().on_hover_text(format!("Index {i}: {}", web_cmds::rgb_hex(color))); if response.clicked() { f.insert("__selectedColor".into(), json!(i)); diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index d042a1fb6b..ca448b9bf4 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -276,6 +276,23 @@ pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 { }, _ => k, }); + // Minimum and Maximum measure the radius in whole pixels (Squareness rounds it, Roundness + // floors it, and 0 is the identity), so at k = 4 a typed 1.5 px became 0.375 = 0 proxy px and + // the preview showed the untouched document while OK applied a real effect (#10474). Keep at + // least one proxy pixel of radius, and the largest k whose whole-pixel proxy radius, scaled + // back, is within 1 px of the one OK applies: Squareness at 6 px and k = 4 rounds 1.5 to 2 proxy + // px (8 px), and Roundness's disc reaches floor(1.5) = 1 proxy px along an axis (4 px). + // A large radius still previews on a proxy: at full size Roundness costs O(radius) per pixel. + if matches!(command, "filter.other.minimum" | "filter.other.maximum") { + let Some(r) = params.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite()) else { return k }; + let r = r.clamp(0.2, 500.0); + let whole: fn(f64) -> f64 = if params.get("preserve").and_then(Value::as_str) == Some("roundness") { f64::floor } else { f64::round }; + let fits = |d: u32| { + let d = f64::from(d); + d <= r.floor() && (whole(r / d) * d - whole(r)).abs() <= 1.0 + }; + return (1..=k).rev().find(|&d| fits(d)).unwrap_or(1); + } // Mosaic rounds its cells to whole pixels: 10 px cells at k = 4 became 3 proxy px = 12 px. // Divide the cell size evenly instead. match params.get("cellSize").and_then(Value::as_f64) { @@ -521,6 +538,15 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { } restore_remembered(app, command, spec.params, &mut fields); apply_command_defaults(app, command, &mut fields); + if photocraft_engine::channel_cmds::follows_target(command) { + if app.filter_mask_targeted() && photocraft_engine::channel_cmds::follows_filter_mask(command) { + fields.insert("target".into(), json!("filterMask")); + } else if app.layer_mask_targeted() { + fields.insert("target".into(), json!("mask")); + } else if let Some(colors) = photocraft_engine::color_channels::selected(&app.session) { + fields.insert("target".into(), colors.params()); + } + } if crate::blur_pins::handles(command) { crate::blur_pins::init(app, &mut fields); } @@ -610,6 +636,15 @@ pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec: }; fields.insert(p.key, v); } + if !fields.contains_key("target") && !fields.contains_key("__smartFilter") { + if photocraft_engine::channel_cmds::follows_filter_mask(command) && app.filter_mask_targeted() { + fields.insert("target".into(), json!("filterMask")); + } else if photocraft_engine::channel_cmds::follows_target(command) && app.layer_mask_targeted() { + fields.insert("target".into(), json!("mask")); + } else if let Some(colors) = photocraft_engine::color_channels::selected(&app.session).filter(|c| c.len() > 1) { + fields.insert("target".into(), colors.params()); + } + } app.ui.open_dialog(crate::state::DialogKind::Command, fields) } @@ -1094,7 +1129,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, 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); + let result = crate::actions::run_step_dialog(app, &fields, &cmd, params); if result.is_ok() { remember(app, &cmd, &fields); let _ = crate::actions::resume(app, &fields); @@ -1335,15 +1370,116 @@ impl FilterPreviewKey { /// parameters moved on. A reopened dialog has a new id, a different command or a different /// document, and a committed document a new revision, so neither survives this test. pub fn same_target(&self, other: &Self) -> bool { + self.k == other.k && self.same_target_at_any_scale(other) + } + + /// [`Self::same_target`] at any proxy factor: whether the canvas may keep showing this frame, + /// drawn at its own `k`, while the factor moves under a drag. A parameter can set the factor + /// (Minimum and Maximum's radius, Mosaic's cell size, #10474), and the frame for the old one is + /// still the closest thing to the result until the new one lands. + pub fn same_target_at_any_scale(&self, other: &Self) -> bool { self.doc == other.doc && self.revision == other.revision && self.dialog == other.dialog && self.active == other.active && self.target == other.target && self.command == other.command - && self.k == other.k } } #[cfg(test)] mod tests; + +#[cfg(test)] +mod multi_channel_dialog_tests { + use super::*; + #[test] + fn filter_mask_dialog_keeps_its_target_when_color_channels_change() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":32,"height":24,"mode":"lab","depth":16})).unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius":2})).unwrap(); + app.run("paint.stroke", json!({"points":[[8,0],[8,23]],"size":8,"hardness":1,"color":"#000000","target":"filterMask"})).unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.run("view.layerMask", json!({"mode":"gray","mask":"filter"})).unwrap(); + let command = "filter.blur.gaussianBlur"; + let id = open(&mut app, command).unwrap(); + let params = params_of(&app.ui.dialog_mut(id).unwrap().fields); + assert_eq!(params["target"], "filterMask"); + app.run("channel.target", json!({"channel":"composite"})).unwrap(); + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let preview = preview_document(&st.doc, Some(layer), command, ¶ms, 1).unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + let mask = |doc: &Document| match &doc.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.filter_mask.clone(), + _ => panic!("expected a smart object"), + }; + assert_eq!(mask(&app.session.active().unwrap().doc), mask(&preview)); + } + #[test] + fn mask_view_dialogs_capture_the_mask_for_preview_and_confirmation() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for command in ["image.adjustments.levels", "image.adjustments.curves", "filter.blur.gaussianBlur"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":32,"height":24,"mode":mode,"depth":depth})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.session + .edit("Mask edge", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().mask.as_mut().unwrap().surface.fill_rect(photocraft_geom::Rect::new(0, 0, 16, 24), &[0.25]); + Ok(()) + }) + .unwrap(); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.run("view.layerMask", json!({"mode":"gray"})).unwrap(); + let id = if command.starts_with("image.") { crate::adjust_dialog::open(&mut app, command) } else { open(&mut app, command) }.unwrap(); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + let (key, value) = match command { + "image.adjustments.levels" => ("gamma", json!(1.4)), + "image.adjustments.curves" => ("points", json!([[0, 0], [100, 160], [255, 255]])), + _ => ("radius", json!(2)), + }; + fields.insert(key.into(), value); + let params = params_of(fields); + assert_eq!(params["target"], json!("mask")); + app.run("channel.target", json!({"channel":{"color":1}})).unwrap(); + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let before = st.doc.layer(layer).unwrap().clone(); + let preview = preview_document(&st.doc, Some(layer), command, ¶ms, 1).unwrap(); + let shown = preview.layer(layer).unwrap(); + assert_eq!(shown.surface(), before.surface()); + assert_ne!(shown.mask.as_ref().unwrap().surface, before.mask.as_ref().unwrap().surface); + crate::dialogs::confirm(&mut app, id).unwrap(); + let actual = app.session.active().unwrap().doc.layer(layer).unwrap(); + assert_eq!(actual.surface(), before.surface()); + assert_eq!(actual.mask.as_ref().unwrap().surface, shown.mask.as_ref().unwrap().surface); + } + } + } + } + #[test] + fn ordinary_filter_captures_the_set_and_preview_equals_confirmation() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":32,"height":24,"depth":16})).unwrap(); + session + .edit("edge", |doc, id| { + doc.layer_mut(id.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 16, 24), &[0.2, 0.4, 0.6, 1.0]); + Ok(()) + }) + .unwrap(); + session.execute("channel.target", json!({"colors":[0,2]})).unwrap(); + let mut app = PhotocraftApp::new(session, Default::default()); + let command = "filter.blur.gaussianBlur"; + let id = open(&mut app, command).unwrap(); + let params = params_of(&app.ui.dialog_mut(id).unwrap().fields); + assert_eq!(params["target"], json!({"colors":[0,2]})); + app.session.execute("channel.target", json!({"channel":"green"})).unwrap(); + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let preview = preview_document(&st.doc, Some(layer), command, ¶ms, 1).unwrap(); + app.session.execute(command, params).unwrap(); + assert_eq!(preview.layer(layer).unwrap().surface(), app.session.active().unwrap().doc.layer(layer).unwrap().surface()); + } +} diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 92daea2528..dcaa32ddbd 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -22,6 +22,18 @@ fn a_unit_after_a_range_bound_does_not_reset_the_range_to_its_fallback() { assert!(matches!(size.map(|p| p.kind), Some(Kind::Range { max, .. }) if max == 5000.0)); } +#[test] +fn lens_flare_dialog_centre_is_a_slider_from_0_to_1_defaulting_to_the_middle() { + // `"centerX":number=0.5` read as an integer field defaulting to 0: the menu's Lens Flare + // landed in the top-left corner and an imported 0.4 centre rounded to 0. + let spec = photocraft_engine::commands::find("filter.render.lensFlare").expect("filter.render.lensFlare is registered"); + let params = parse_spec(spec.params); + for key in ["centerX", "centerY"] { + let kind = params.iter().find(|p| p.key == key).map(|p| p.kind.clone()); + assert_eq!(kind, Some(Kind::Range { min: 0.0, max: 1.0, default: 0.5 }), "{key}"); + } +} + #[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 @@ -121,6 +133,8 @@ fn same_target_ignores_parameters_but_nothing_else() { break_it(&mut other); assert!(!other.same_target(&base), "{other:?} must not pass as {base:?}"); assert!(!base.same_target(&other)); + // The canvas's display gate forgives the factor alone (#10474), nothing else. + assert_eq!(other.same_target_at_any_scale(&base), other.k != base.k, "{other:?}"); } } @@ -628,7 +642,7 @@ fn smart_displace_preview_uses_the_map_document() { 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 want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).into_px(); let preview = preview_document_for_target( &doc, layer, @@ -954,6 +968,13 @@ fn fixed_size_filters_preview_at_the_scale_they_apply() { ("filter.noise.dustAndScratches", json!({"radius": 4.0, "threshold": 0.0})), ("filter.noise.reduceNoise", json!({})), ("filter.other.custom", json!({"kernel": edge})), + // #10474: Minimum and Maximum work on whole-pixel radii (`round()` / `floor()`), so at + // k = 4 a typed 1.5 px radius previewed as 0.375 px = 0 px: the proxy showed the untouched + // document while OK writes a real 1.5 / 1 px effect. A radius under 2 px previews at k = 1. + ("filter.other.minimum", json!({"radius": 1.5, "preserve": "squareness"})), + ("filter.other.minimum", json!({"radius": 1.5, "preserve": "roundness"})), + ("filter.other.maximum", json!({"radius": 1.5, "preserve": "squareness"})), + ("filter.other.maximum", json!({"radius": 1.5, "preserve": "roundness"})), ("filter.video.deInterlace", json!({})), ("filter.stylize.traceContour", json!({})), ("filter.stylize.wind", json!({"method": "blast"})), @@ -1026,6 +1047,32 @@ fn proxy_previews_scale_only_lengths() { assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": "x"}), 4), 4); let (k, diff) = error("filter.pixelate.mosaic", json!({"cellSize": 10.0})); assert!(k == 2 && diff < 0.01, "Mosaic: {diff}"); + // Roundness at a fractional radius: at 6.5 px, k = 3 reaches floor(2.17) = 2 proxy px along an + // axis (6 px), where k = 4 reached floor(1.63) = 1 (4 px), so the chosen factor previews closer + // to what OK applies than k = 4 did (#10474). + for cmd in ["filter.other.minimum", "filter.other.maximum"] { + let p = json!({"radius": 6.5, "preserve": "roundness"}); + let (k, diff) = error(cmd, p.clone()); + let applied = preview_document(&doc, Some(bg), cmd, &p, 1).unwrap(); + let at_four = preview_document(&doc, Some(bg), cmd, &p, 4).unwrap(); + let four = mean_abs(&every_kth(&applied, 4), &every_kth(&at_four, 1)); + assert!(k == 3 && diff < four, "{cmd}: k = {k} differs by {diff}, k = 4 by {four}"); + } + // Minimum and Maximum keep at least one proxy pixel of radius (#10474), and a large radius + // keeps a proxy instead of a full-size preview that costs minutes at radius 500. Both keep + // their whole-pixel radius within 1 px of OK's. Squareness rounds: 6 px is 2 proxy px at k = 3 + // (6 px), not at k = 4 (8 px); 50 px is 17 at k = 3 (51 px), not 13 at k = 4 (52 px). Roundness's + // disc reaches floor(r) along an axis: 6 px is 2 at k = 3 (6 px), not 1 at k = 4 (4 px); 50 px + // is 25 at k = 2, not 12 at k = 4 or 16 at k = 3 (48 px). + let factors = |cmd: &str, preserve: &str| { + [0.2, 1.5, 2.5, 3.9, 6.0, 50.0, 500.0, 9999.0].map(|radius| preview_factor(cmd, &json!({"radius": radius, "preserve": preserve}), 4)) + }; + for cmd in ["filter.other.minimum", "filter.other.maximum"] { + assert_eq!(factors(cmd, "squareness"), [1, 1, 2, 3, 3, 3, 4, 4], "{cmd} squareness"); + assert_eq!(factors(cmd, "roundness"), [1, 1, 2, 3, 3, 2, 4, 4], "{cmd} roundness"); + assert_eq!(preview_factor(cmd, &json!({"radius": 6.0}), 4), 3, "{cmd}: Squareness is the default"); + assert_eq!(preview_factor(cmd, &json!({"radius": "x"}), 4), 4, "{cmd}: a junk radius keeps the proxy"); + } // Tree's and Picture Frame's sizes are relative, not pixels (and not shown as "px"). assert!(!is_pixel_param_of("filter.render.tree", "size") && !is_pixel_param_of("filter.render.pictureFrame", "size")); assert!(is_pixel_param_of("filter.stylize.extrude", "size")); diff --git a/crates/ui-egui/src/dialogs/gallery_ui.rs b/crates/ui-egui/src/dialogs/gallery_ui.rs index 2f2269b892..43c392c1e9 100644 --- a/crates/ui-egui/src/dialogs/gallery_ui.rs +++ b/crates/ui-egui/src/dialogs/gallery_ui.rs @@ -72,6 +72,7 @@ pub struct GalleryDialog { layer_name: String, canvas: Rect, src: Surface, + target: Value, /// Apply order: index 0 runs first (shown at the bottom of the list). pub effects: Vec, pub selected: usize, @@ -117,7 +118,9 @@ impl GalleryDialog { /// The command params for the current stack. pub fn params(&self) -> Value { - json!({"effects": self.effects.iter().map(EffectLayer::to_json).collect::>(), "foreground": self.foreground, "background": self.background}) + let p = + json!({"effects": self.effects.iter().map(EffectLayer::to_json).collect::>(), "foreground": self.foreground, "background": self.background}); + crate::distort_ui::with_filter_target(p, &self.target) } /// The progress (`0.0..=1.0`) of a preview still rendering. @@ -197,7 +200,13 @@ fn render(src: &Surface, params: &Value) -> Surface { let effects = photocraft_engine::gallery_cmds::effects_from_json(params).unwrap_or_default(); let b = src.content_bounds().union(&Rect::new(0, 0, 1, 1)); let b = Rect::new(0, 0, b.x1, b.y1); - photocraft_algo::apply_in(src, &FilterParams::FilterGallery { effects }, b, b, None, b) + let mut out = photocraft_algo::apply_in(src, &FilterParams::FilterGallery { effects }, b, b, None, b); + if let Ok(Some(colors)) = photocraft_engine::color_channels::explicit(params, src.format().mode.color_channels()) + && photocraft_engine::color_channels::restrict_surface(src, &mut out, colors).is_err() + { + return src.clone(); + } + out } /// The preview of `vis` at 1/`k` (#2502): the stack runs at full resolution exactly as OK runs it @@ -209,13 +218,16 @@ fn preview_pixels(src: &Surface, canvas: Rect, params: &Value, vis: Rect, k: u32 let fp = FilterParams::FilterGallery { effects }; let content = src.content_bounds(); let area = photocraft_algo::output_area(&fp, content, canvas, None).intersect(&vis); - let full = if area.is_empty() { src.clone() } else { photocraft_algo::apply_in_with(src, &fp, area, canvas, None, canvas.union(&content), ctl)? }; + let mut full = if area.is_empty() { src.clone() } else { photocraft_algo::apply_in_with(src, &fp, area, canvas, None, canvas.union(&content), ctl)? }; + if let Some(colors) = photocraft_engine::color_channels::explicit(params, src.format().mode.color_channels()).ok()? { + photocraft_engine::color_channels::restrict_region(src, &mut full, colors, area).ok()?; + } Some(reduce(&full, vis, k)) } /// `r` of `surf` scaled down by `k`, each pixel the average of a `k`×`k` block (colour weighted by /// alpha), placed at the origin. -fn reduce(surf: &Surface, r: Rect, k: u32) -> Surface { +pub(crate) fn reduce(surf: &Surface, r: Rect, k: u32) -> Surface { let k = k.clamp(1, 1 << 16) as usize; let (bw, bh) = (r.width() as usize, r.height() as usize); let (w, h) = (bw.div_ceil(k), bh.div_ceil(k)); @@ -304,7 +316,7 @@ fn colour_param(p: &Value, key: &str) -> Option<[f32; 4]> { /// Opens the dialog on the active layer, starting from the last gallery stack used. pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { - let (layer, src, _) = crate::distort_ui::active_pixels(app)?; + let (layer, src, _) = crate::distort_ui::active_filter_target(app)?; let last = app.session.journal.iter().rev().find(|(id, _)| id == "filter.filterGallery").map(|(_, p)| p.clone()); let effects = last.as_ref().map(effects_from).unwrap_or_default(); let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); @@ -355,12 +367,14 @@ fn open_on( let sel = effects.len() - 1; let open_cats = std::array::from_fn(|i| effects[sel].filter.category() == GALLERY_CATEGORIES[i]); let history = EditHistory::new(&effects); + let target = if smart_filter.is_some() { json!("pixels") } else { crate::distort_ui::colour_set_target(app) }; app.distort.gallery = Some(GalleryDialog { document: st.doc.id, layer, layer_name, canvas, src, + target, effects, selected: sel, open: open_cats, @@ -623,7 +637,8 @@ fn update_thumbs(d: &mut GalleryDialog, ctx: &egui::Context, budget: usize) { ctx.request_repaint(); return; } - let p = json!({"effects": [{"filter": f.key()}], "foreground": d.foreground, "background": d.background}); + let p = + crate::distort_ui::with_filter_target(json!({"effects": [{"filter": f.key()}], "foreground": d.foreground, "background": d.background}), &d.target); let out = render(&d.thumb_src, &p); let b = d.thumb_src.content_bounds(); let img = image(&out, b.x1.max(1) as usize, b.y1.max(1) as usize); @@ -817,7 +832,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut rgb = c.map_or([neon[0], neon[1], neon[2]], |v| [v[0], v[1], v[2]]); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Glow Color")).color(t.text_dim)); - crate::widgets::color_edit_button_rgb(ui, &mut rgb); + crate::widgets::color_edit_button_rgb(ui, &mut rgb, &crate::color_picker_ui::title_for(tl!("Glow Color"))); }); e.params.insert("glowColor".into(), json!([rgb[0], rgb[1], rgb[2], 1.0])); } diff --git a/crates/ui-egui/src/dialogs/gallery_ui/tests.rs b/crates/ui-egui/src/dialogs/gallery_ui/tests.rs index 456be95588..d31345bfac 100644 --- a/crates/ui-egui/src/dialogs/gallery_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/gallery_ui/tests.rs @@ -184,6 +184,33 @@ fn preview_and_thumbnails_render() { assert!(worst < 2e-3, "preview differs from the result by {worst}"); } +#[test] +fn native_component_gallery_previews_match_commit_and_capture_the_target() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for alpha in [true, false] { + let fixture = crate::distort_ui::component_tests::app(mode, depth); + let mut app = if alpha { fixture } else { crate::distort_ui::component_tests::without_alpha(fixture) }; + open(&mut app).unwrap(); + let d = app.distort.gallery.as_mut().unwrap(); + d.effects = vec![EffectLayer::new(GalleryFilter::Cutout)]; + let params = d.params(); + let before = d.src.clone(); + let bounds = d.canvas; + let layer = d.layer; + let preview = preview_pixels(&before, bounds, ¶ms, bounds, 1, &Interrupt::NONE).unwrap(); + crate::distort_ui::component_tests::protected(&before, &preview, bounds); + let thumbnail = render(&before, ¶ms); + crate::distort_ui::component_tests::protected(&before, &thumbnail, bounds); + app.run("channel.target", json!({"channel":{"color":1}})).unwrap(); + commit(&mut app).unwrap(); + let after = app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap(); + assert_eq!(after.read_region(bounds), preview.read_region(bounds)); + } + } + } +} + /// A 240×160 layer with gradients, edges and fine detail, so every gallery filter has /// something to draw. fn photo_app() -> PhotocraftApp { diff --git a/crates/ui-egui/src/dialogs/layer_style.rs b/crates/ui-egui/src/dialogs/layer_style.rs index 2b63ee3379..29396f6022 100644 --- a/crates/ui-egui/src/dialogs/layer_style.rs +++ b/crates/ui-egui/src/dialogs/layer_style.rs @@ -25,6 +25,7 @@ use crate::{PhotocraftApp, widgets}; mod bevel_modes_tests; pub(crate) mod color_picker; mod contour; +mod gradient_field; #[cfg(test)] mod gradient_method_tests; @@ -35,6 +36,7 @@ enum P { Blend, Choice(&'static [(&'static str, &'static str)]), GradientMethod, + Gradient, Check, Angle, /// A pattern from the dialog's `patternList` field (`[[id, name], …]`). @@ -66,6 +68,9 @@ pub const KINDS: &[(&str, &str)] = &[ /// Effect kinds Photoshop offers several instances of (stored as `…Multi` lists in the PSD). const MULTI: &[&str] = &["stroke", "dropShadow", "innerShadow", "colorOverlay", "gradientOverlay"]; +/// Field holding the user defaults (Make Default) that new instances start from. +const USER_DEFAULTS: &str = "userDefaults"; + fn multi(kind: &str) -> bool { MULTI.contains(&kind) } @@ -75,12 +80,22 @@ fn kind_of(e: &Effect) -> &'static str { } fn defaults(kind: &str) -> Value { - photocraft_engine::layer_style::effect_defaults(kind) + let mut p = photocraft_engine::layer_style::effect_defaults(kind); + if kind == "gradientOverlay" { + gradient_field::from_to_to_stops(&mut p); + } + p } -/// Factory params for a new instance, with the shared light angle filled in. -fn fresh_params(kind: &str, light: f32) -> Value { +/// Params for a new instance: the user default saved by Make Default over the factory +/// defaults (#9477), with the shared light angle filled in. +fn fresh_params(kind: &str, light: f32, user: Option<&Value>) -> Value { let mut p = defaults(kind); + if let (Some(o), Some(u)) = (p.as_object_mut(), user.and_then(Value::as_object)) { + for (k, v) in u { + o.insert(k.clone(), v.clone()); + } + } if p.get("useGlobalLight").and_then(Value::as_bool) == Some(true) { p["angle"] = json!(light); } @@ -158,8 +173,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { "gradientOverlay" => &[ ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), - ("from", "From", P::Color), - ("to", "To", P::Color), + ("stops", "Gradient", P::Gradient), ("reverse", "Reverse", P::Check), ("style", "Style", P::Choice(GSTYLE)), ("angle", "Angle", P::Angle), @@ -307,14 +321,8 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "method", json!(source.method)); set(&mut v, "smoothness", json!(source.smoothness)); } - // Endpoint colours and geometry always load into the dialog. Recorded - // interpolation exposes the original source ends; a baked curve without a - // record (PSD import) keeps its interior stops in `fx`, and an edit only - // retints its ends until a method is chosen. - if let (Some(first), Some(last)) = (stops.first(), stops.last()) { - set(&mut v, "from", json!(hex(&first.1))); - set(&mut v, "to", json!(hex(&last.1))); - } + set(&mut v, gradient_field::KEY, gradient_field::stops_json(stops)); + set(&mut v, gradient_field::OPACITY_KEY, gradient_field::opacity_json(&g.opacity_stops)); set(&mut v, "reverse", json!(g.reverse)); set( &mut v, @@ -463,7 +471,8 @@ fn next_id(f: &Map) -> String { fn add_instance(f: &mut Map, kind: &str) { let light = f.get("pendingGlobalLight").or_else(|| f.get("globalLight")).and_then(Value::as_f64).unwrap_or(120.0) as f32; let id = next_id(f); - let new = json!({"id": id, "kind": kind, "on": true, "params": fresh_params(kind, light)}); + let user = f.get(USER_DEFAULTS).and_then(|d| d.get(kind)); + let new = json!({"id": id, "kind": kind, "on": true, "params": fresh_params(kind, light, user)}); if let Some(a) = f.get_mut("effects").and_then(Value::as_array_mut) { let pos = a.iter().rposition(|e| e.get("kind").and_then(Value::as_str) == Some(kind)).map_or(a.len(), |i| i + 1); a.insert(pos, new); @@ -527,7 +536,13 @@ pub fn drag_shadow(f: &mut Map, start: bool, total: [f64; 2]) { } pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map { + initial_fields_with(layer, select, light, Map::new()) +} + +/// The dialog's fields, with the user defaults (`kind` → params) new instances start from. +pub fn initial_fields_with(layer: &Layer, select: Option<&str>, light: f32, user_defaults: Map) -> Map { let mut f = Map::new(); + f.insert(USER_DEFAULTS.into(), Value::Object(user_defaults)); f.insert("layer".into(), json!(layer.id.0)); f.insert("globalLight".into(), json!(light)); f.insert("preview".into(), json!(true)); @@ -583,7 +598,8 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option { let layer = st.doc.layer(st.active_layer?)?.clone(); let light = st.doc.global_light.angle; let mode = st.doc.mode; - let mut f = initial_fields(&layer, select, light); + let user = app.session.presets.layer_defaults.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + let mut f = initial_fields_with(&layer, select, light, user); // Channels and Blend If list the document's own colour channels. set_blending_fields(&mut f, &layer, mode); f.insert("__document".into(), json!(st.doc.id)); @@ -811,7 +827,6 @@ fn fx_label_color(enabled: bool, t: &Tokens) -> Color32 { if enabled { t.text } else { t.text_dim } } -/// Dialog body (left list, right parameters). /// The enable checkbox of an effect row. fn fx_checkbox_rect(row: egui::Rect) -> egui::Rect { egui::Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(14.0, 14.0)) @@ -829,347 +844,37 @@ fn fx_checkbox(p: &egui::Painter, cb: egui::Rect, on: bool, t: &Tokens) { } pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { - let t = Tokens::get(ui.ctx()); + let _ = body_with_response(app, ui, f, true); +} + +/// Layer Style's body, laid out like Photoshop's: the effect list over its fx / move / delete +/// bar, the selected page, and a column with OK, Cancel, New Style…, Preview and the preview +/// swatch. Returns OK (true) or Cancel (false) once chosen; Enter is OK. +pub fn body_with_response(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, interactive: bool) -> Option { let selected = f.get("selected").and_then(Value::as_str).unwrap_or("dropShadow").to_string(); let mut style_click: Option = None; let mut new_style = false; + let mut outcome = None; let invalid = validate_params(f).err(); + // One size on every page (`dialogs::sizing_of`): the list and the page fill its height and + // scroll when they are taller. + let height = ui.available_height().max(160.0); ui.horizontal_top(|ui| { - // Left: effect list. - ui.vertical(|ui| { - ui.set_width(190.0); - // Styles & Blending Options rows (matching the effect row layout & padding). - for (id, label) in [(STYLES_PAGE, "Styles"), (BLENDING, "Blending Options")] { - let is_sel = selected == id; - let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 26.0), Sense::click()); - if is_sel { - ui.painter().rect_filled(rect, t.radius_sm, t.row_selected.gamma_multiply(if t.pro { 1.0 } else { 0.0 }).max_alpha(t.hover)); - } else if resp.hovered() { - ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); - } - ui.painter().text( - rect.left_center() + vec2(28.0, 0.0), - egui::Align2::LEFT_CENTER, - tl!(label), - egui::FontId::proportional(12.5), - if is_sel { t.text } else { t.text_dim }, - ); - if resp.clicked() { - f.insert("selected".into(), json!(id)); - } - } - ui.add_space(4.0); - for &(kind, label) in KINDS { - let ids: Vec = effects_of(f) - .iter() - .filter(|e| e.get("kind").and_then(Value::as_str) == Some(kind)) - .filter_map(|e| e.get("id").and_then(Value::as_str).map(str::to_string)) - .collect(); - if ids.is_empty() { - // The effect isn't on the layer: a greyed row with an empty checkbox, as in - // Photoshop (#1357); a click on either adds it, turned on. - let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 26.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); - } - fx_checkbox(ui.painter(), fx_checkbox_rect(rect), false, &t); - ui.painter().text( - rect.left_center() + vec2(28.0, 0.0), - egui::Align2::LEFT_CENTER, - tl!(label), - egui::FontId::proportional(12.5), - t.text_faint, - ); - if resp.clicked() { - add_instance(f, kind); - } - continue; - } - let count = ids.len(); - for id in &ids { - let Some(e) = entry(f, id) else { continue }; - let kind_label = KINDS.iter().find(|k| k.0 == kind).map(|k| k.1).unwrap_or(label); - let mut on = e.get("on").and_then(Value::as_bool).unwrap_or(false); - let is_sel = id == &selected; - let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 26.0), Sense::click()); - if is_sel { - ui.painter().rect_filled(rect, t.radius_sm, t.row_selected.gamma_multiply(if t.pro { 1.0 } else { 0.0 }).max_alpha(t.hover)); - } else if resp.hovered() { - ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); - } - let cb = fx_checkbox_rect(rect); - let cresp = ui.interact(cb, ui.scope_id().with(("fxcb", kind, id)), Sense::click()); - fx_checkbox(ui.painter(), cb, on, &t); - ui.painter().text( - rect.left_center() + vec2(28.0, 0.0), - egui::Align2::LEFT_CENTER, - tl!(kind_label), - egui::FontId::proportional(12.5), - fx_label_color(on, &t), - ); - // + adds another instance; − removes one once there are several. - if multi(kind) { - let btn = |ui: &egui::Ui, at: egui::Pos2, glyph: &str, tag: &str| { - let r = egui::Rect::from_center_size(at, vec2(18.0, 18.0)); - let hresp = ui.interact(r, ui.scope_id().with(("fxbtn", tag, id)), Sense::click()); - let col = if hresp.hovered() { t.accent } else { t.text_faint }; - ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, glyph, egui::FontId::proportional(13.0), col); - hresp.clicked() - }; - if count > 1 && btn(ui, rect.right_center() - vec2(22.0, 0.0), "\u{2212}", "minus") { - remove_instance(f, id); - continue; - } - if btn(ui, rect.right_center() - vec2(8.0, 0.0), "+", "plus") { - add_instance(f, kind); - } - } - if cresp.clicked() { - on = !on; - if let Some(e) = entry_mut(f, id) { - e["on"] = json!(on); - } - f.insert("selected".into(), json!(id)); - } else if resp.clicked() { - f.insert("selected".into(), json!(id)); - } - } - } + boxed(ui, LIST_W, height, |ui, h| { + egui::ScrollArea::vertical().id_salt("layer-style-effects").max_height(h - BAR_H).auto_shrink(false).show(ui, |ui| effect_list(ui, f, &selected)); + list_bar(ui, f, &selected); + }); + let page_w = ui.available_width() - SIDE_W - ui.spacing().item_spacing.x; + boxed(ui, page_w, height, |ui, h| { + egui::ScrollArea::vertical() + .id_salt("layer-style-page") + .max_height(h) + .auto_shrink(false) + .show(ui, |ui| page(app, ui, f, &selected, invalid.as_deref(), &mut style_click)); }); - widgets::vline(ui, 330.0); - // Right: parameters of the selected page. ui.vertical(|ui| { - ui.set_width(330.0); - if selected == STYLES_PAGE { - ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Styles")).font(crate::theme::semibold(14.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::secondary_button(ui, tl!("New Style…"), 100.0).clicked() { - new_style = true; - } - }); - }); - ui.add_space(8.0); - let ctx = ui.ctx().clone(); - egui::ScrollArea::vertical().max_height(340.0).id_salt("layer-style-presets-main").show(ui, |ui| { - for group in &app.session.presets.styles { - ui.label(RichText::new(&group.name).size(12.0).color(t.text_dim)); - ui.add_space(3.0); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 4.0); - for st in &group.items { - let tex = crate::preset_panels::style_texture(app, &ctx, st); - let (rect, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); - ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); - let (stroke, kind) = if resp.hovered() { - (Stroke::new(1.5, t.accent), egui::StrokeKind::Outside) - } else { - (Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside) - }; - ui.painter().rect_stroke(rect, 2.0, stroke, kind); - if resp.on_hover_text(&st.name).clicked() { - style_click = Some(st.name.clone()); - } - } - }); - ui.add_space(10.0); - } - }); - return; - } - let sel_kind: String = if selected == BLENDING { - BLENDING.to_string() - } else { - entry(f, &selected).and_then(|e| e.get("kind").and_then(Value::as_str)).unwrap_or("").to_string() - }; - let label = KINDS.iter().find(|k| k.0 == sel_kind).map(|k| k.1).unwrap_or(if selected == BLENDING { "Blending Options" } else { "" }); - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(&label)).font(crate::theme::semibold(14.0)).color(t.text)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut preview = f.get("preview").and_then(Value::as_bool).unwrap_or(true); - if widgets::checkbox(ui, &mut preview, tl!("Preview")).changed() { - f.insert("preview".into(), json!(preview)); - } - }); - }); - ui.add_space(6.0); - // Only reachable through `ui.dialog.set`: say what is wrong once, keep the bad value. - if let Some(error) = &invalid { - ui.colored_label(t.danger, error); - return; - } - if selected == BLENDING { - // General Blending, Advanced Blending and Blend If, laid out like Photoshop's page. - let names = channel_names_of(f); - let key = format!("p:{BLENDING}"); - let Some(mut p) = f.get(&key).filter(|v| v.is_object()).cloned() else { return }; - blending_page(ui, &mut p, &names); - f.insert(key, p); - return; - } - // The page shows the factory defaults under the instance's values, - // but only real edits reach the stored params. - let disp = if selected == BLENDING { - f.get("p:blendingOptions").cloned().unwrap_or_else(|| json!({})) - } else { - entry(f, &selected).and_then(|e| e.get("params").cloned()).unwrap_or_else(|| json!({})) - }; - let Some(mut disp) = disp.as_object().map(|o| Value::Object(o.clone())) else { return }; - if selected != BLENDING { - let base = defaults(&sel_kind); - if let (Some(o), Some(b)) = (disp.as_object_mut(), base.as_object()) { - for (k, v) in b { - o.entry(k.clone()).or_insert_with(|| v.clone()); - } - } - } - for &(key, label, kind_p) in spec(&sel_kind) { - match kind_p { - P::Angle => { - let mut v = disp.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; - if widgets::angle_dial_row(ui, label, &mut v, -180.0..=180.0).changed() { - let value = json!(v); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - } - P::Slider(min, max, unit) => { - let mut v = disp.get(key).and_then(Value::as_f64).unwrap_or(min as f64) as f32; - if widgets::slider_row(ui, label, &mut v, min..=max, unit, None).changed() { - let value = json!(v.round()); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - } - P::Color => { - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let color = disp.get(key).and_then(Value::as_str).unwrap_or("#000000"); - let response = widgets::color_swatch(ui, parse_hex(color)).on_hover_text(tl!("Color Picker (Layer Style Color)")); - if response.clicked() { - f.insert(color_picker::REQUEST.into(), json!({"effect": selected, "field": key})); - } - }); - } - P::Blend => { - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut cur = disp.get(key).and_then(Value::as_str).unwrap_or("Normal").to_string(); - let opts: Vec<(String, &str)> = - photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); - // One id per mode: Bevel & Emboss has two (Highlight, Shadow), which must not share a list (#9368). - if widgets::dropdown(ui, &format!("fx-blend-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 150.0) { - let value = json!(cur); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - }); - } - P::Choice(options) => { - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut cur = disp.get(key).and_then(Value::as_str).unwrap_or(options[0].0).to_string(); - let opts: Vec<(String, &str)> = options.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 150.0) { - let value = json!(cur); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - }); - } - P::GradientMethod => { - ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Method")).color(t.text_dim)); - let mut current = disp.get(key).and_then(Value::as_str).unwrap_or("custom").to_string(); - let mut options = vec![ - ("smooth".to_string(), "Smooth"), - ("perceptual".to_string(), "Perceptual"), - ("linear".to_string(), "Linear"), - ("classic".to_string(), "Classic"), - ]; - // Old/imported curves have no method record. Preserve them until - // the user explicitly chooses an interpolation method. - if photocraft_io::GradientMethod::from_name(¤t).is_none() { - current = "custom".into(); - options.insert(0, ("custom".into(), "Custom")); - } - if widgets::dropdown(ui, "fx-gradientOverlay-method", tl!("Method"), &mut current, &options, 150.0) { - // The older dialog deliberately omits From/To for rich imported - // curves. Once a method is chosen, show the real source ends. - let snapshot = entry(f, &selected).and_then(|e| e.get("fx")).cloned(); - if let Some(Effect::GradientOverlay { gradient, interpolation, .. }) = - snapshot.and_then(|v| serde_json::from_value::(v).ok()) - { - let stops = interpolation.as_ref().map_or(&gradient.stops, |source| &source.stops); - for (key, stop) in [("from", stops.first()), ("to", stops.last())] { - if entry(f, &selected).and_then(|e| e.get("params")).and_then(|p| p.get(key)).is_none() - && let Some(stop) = stop - { - set_param(f, &selected, key, json!(hex(&stop.1))); - } - } - } - let value = json!(current); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - }); - } - P::Pattern => { - let list: Vec<(String, String)> = f - .get("patternList") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_str()?.to_string()))).collect()) - .unwrap_or_default(); - ui.horizontal(|ui| { - ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let mut cur = disp - .get(key) - .and_then(Value::as_str) - .filter(|c| !c.is_empty()) - .map(str::to_string) - .or_else(|| list.first().map(|l| l.0.clone())) - .unwrap_or_default(); - let opts: Vec<(String, &str)> = list.iter().map(|(id, n)| (id.clone(), n.as_str())).collect(); - if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 180.0) - || disp.get(key).and_then(Value::as_str).is_none_or(str::is_empty) - { - let value = json!(cur); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - }); - } - 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() { - let value = json!(b); - disp[key] = value.clone(); - set_param(f, &selected, key, value); - } - } - } - ui.add_space(2.0); - } - // Make Default stores this page as the user default; Reset to Default - // restores it (or the factory defaults before the first Make). - let page_kind = sel_kind != BLENDING && entry(f, &selected).is_some(); - ui.add_space(4.0); - // Wrap: translated labels (ru, de) can be wider than the page column. - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min).with_main_wrap(true), |ui| { - ui.add_enabled_ui(page_kind, |ui| { - if crate::widgets::secondary_button(ui, tl!("Reset to Default"), 130.0).clicked() - && let Ok(v) = app.run("layer.layerStyle.defaultFor", json!({"kind": sel_kind})) - && let Some(e) = entry_mut(f, &selected) - { - e["params"] = v["params"].clone(); - } - if crate::widgets::secondary_button(ui, tl!("Make Default"), 110.0).clicked() { - let _ = app.run("layer.layerStyle.makeDefault", json!({"kind": sel_kind, "params": disp})); - } - }); - }); + ui.set_width(SIDE_W); + outcome = side_column(app, ui, f, &mut new_style); }); }); // Style swatch clicks apply to the layer (Photoshop does this immediately); @@ -1181,6 +886,10 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = p.get("channels").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_bool().unwrap_or(true)).collect()).unwrap_or_default(); ch.resize(names.len(), true); @@ -1290,7 +999,7 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { } }); ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Knockout")).color(t.text_dim)); + widgets::row_label(ui, "Knockout", LABEL_W); let mut cur = p.get("knockout").and_then(Value::as_str).unwrap_or("none").to_string(); let opts: Vec<(String, &str)> = KNOCKOUT.iter().map(|(v, l)| (v.to_string(), *l)).collect(); if widgets::dropdown(ui, "fx-blendingOptions-knockout", tl!("Knockout"), &mut cur, &opts, 150.0) { @@ -1300,19 +1009,19 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { for &(key, label) in ADVANCED_CHECKS { let default = matches!(key, "blendClippedLayersAsGroup" | "transparencyShapesLayer"); let mut b = p.get(key).and_then(Value::as_bool).unwrap_or(default); - if widgets::checkbox(ui, &mut b, label).changed() { + if checkbox_line(ui, &mut b, label) { p[key] = json!(b); } } widgets::hairline(ui); - // Blend If: the channel, then This Layer / Underlying Layer split sliders (Alt-drag splits). + // Blend If: the channel, then Current Layer / Underlying Layer split sliders (Alt-drag splits). let entries = p.get("blendIf").and_then(Value::as_array).map_or(0, Vec::len); if entries == 0 { return; } let sel = p.get("blendIfChannel").and_then(Value::as_u64).map_or(0, |v| v as usize).min(entries - 1); ui.horizontal(|ui| { - ui.label(RichText::new(tl!("Blend If:")).color(t.text_dim)); + widgets::row_label(ui, "Blend If:", LABEL_W); // A colour document lists Gray first; a one-channel document only its channel (Gray). let label = |i: usize| -> String { if entries == names.len() { @@ -1330,7 +1039,8 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { p["blendIfChannel"] = json!(cur); } }); - for (key, label) in [("thisLayer", "This Layer:"), ("underlying", "Underlying Layer:")] { + // Photoshop 2026 calls the first bar Current Layer (it was This Layer). + for (key, label) in [("thisLayer", "Current Layer:"), ("underlying", "Underlying Layer:")] { let mut q = blend_quad(p.get("blendIf").and_then(|a| a.get(sel)).and_then(|e| e.get(key))); ui.horizontal(|ui| { ui.label(RichText::new(tl!(label)).color(t.text_dim)); @@ -1363,6 +1073,107 @@ fn blend_quad(v: Option<&Value>) -> [u8; 4] { q } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BlendDrag { + Pair { pair: usize, origin_v: u8 }, + Split { handle: usize, origin: u8 }, + Single(usize), +} + +pub(crate) fn step_blend_drag(mode: &mut BlendDrag, q: &[u8; 4], target: u8, alt: bool) -> [u8; 4] { + let mut n = *q; + match *mode { + BlendDrag::Pair { pair, origin_v } => { + if alt { + if pair == 0 { + if target >= origin_v { + *mode = BlendDrag::Split { handle: 1, origin: origin_v }; + n[0] = origin_v; + n[1] = target.clamp(origin_v, q[2]); + } else { + *mode = BlendDrag::Split { handle: 0, origin: origin_v }; + n[1] = origin_v; + n[0] = target.clamp(0, origin_v); + } + } else if target <= origin_v { + *mode = BlendDrag::Split { handle: 2, origin: origin_v }; + n[3] = origin_v; + n[2] = target.clamp(q[1], origin_v); + } else { + *mode = BlendDrag::Split { handle: 3, origin: origin_v }; + n[2] = origin_v; + n[3] = target.clamp(origin_v, 255); + } + } else { + let delta = i16::from(target) - i16::from(origin_v); + if pair == 0 { + let v = (i16::from(origin_v) + delta).clamp(0, q[2] as i16) as u8; + n[0] = v; + n[1] = v; + } else { + let v = (i16::from(origin_v) + delta).clamp(q[1] as i16, 255) as u8; + n[2] = v; + n[3] = v; + } + } + } + BlendDrag::Split { handle, origin } => match handle { + 1 => { + if target >= origin { + n[0] = origin; + n[1] = target.clamp(origin, q[2]); + } else { + *mode = BlendDrag::Split { handle: 0, origin }; + n[1] = origin; + n[0] = target.clamp(0, origin); + } + } + 0 => { + if target <= origin { + n[1] = origin; + n[0] = target.clamp(0, origin); + } else { + *mode = BlendDrag::Split { handle: 1, origin }; + n[0] = origin; + n[1] = target.clamp(origin, q[2]); + } + } + 2 => { + if target <= origin { + n[3] = origin; + n[2] = target.clamp(q[1], origin); + } else { + *mode = BlendDrag::Split { handle: 3, origin }; + n[2] = origin; + n[3] = target.clamp(origin, 255); + } + } + 3 => { + if target >= origin { + n[2] = origin; + n[3] = target.clamp(origin, 255); + } else { + *mode = BlendDrag::Split { handle: 2, origin }; + n[3] = origin; + n[2] = target.clamp(q[1], origin); + } + } + _ => {} + }, + BlendDrag::Single(h) => match h { + 0 => n[0] = target.clamp(0, n[1]), + 1 => n[1] = target.clamp(n[0], n[2]), + 2 => n[2] = target.clamp(n[1], n[3]), + 3 => n[3] = target.clamp(n[2], 255), + _ => {} + }, + } + for i in 1..4 { + n[i] = n[i].max(n[i - 1]); + } + n +} + /// A Blend If slider: a black-to-white ramp with a black and a white handle, each split in two /// halves by Alt-dragging (the layer fades between them). Returns whether `q` changed. fn blend_if_bar(ui: &mut egui::Ui, id: egui::Id, q: &mut [u8; 4]) -> bool { @@ -1383,47 +1194,44 @@ fn blend_if_bar(ui: &mut egui::Ui, id: egui::Id, q: &mut [u8; 4]) -> bool { ui.painter().rect_stroke(ramp, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); // Handles: triangles under the ramp; a split handle shows two half triangles. let mut changed = false; - let drag_key = id.with("handle"); + let drag_key = id.with("blend_drag"); + let alt = ui.input(|i| i.modifiers.alt); if resp.drag_started() && let Some(pos) = resp.interact_pointer_pos() { - let near = (0..4).min_by(|a, b| (x_of(q[*a]) - pos.x).abs().total_cmp(&(x_of(q[*b]) - pos.x).abs())).unwrap_or(0); - // Between two coinciding halves, pick by the side of the pointer. - let near = match near { - 0 | 1 if q[0] == q[1] => usize::from(pos.x > x_of(q[0])), - 2 | 3 if q[2] == q[3] => 2 + usize::from(pos.x > x_of(q[2])), - n => n, + let d_black = if q[0] == q[1] { (pos.x - x_of(q[0])).abs() } else { (pos.x - (x_of(q[0]) - 2.5)).abs().min((pos.x - (x_of(q[1]) + 2.5)).abs()) }; + let d_white = if q[2] == q[3] { (pos.x - x_of(q[2])).abs() } else { (pos.x - (x_of(q[2]) - 2.5)).abs().min((pos.x - (x_of(q[3]) + 2.5)).abs()) }; + let mode = if d_black <= d_white { + if q[0] == q[1] { + if alt { + let h = if pos.x >= x_of(q[0]) { 1 } else { 0 }; + BlendDrag::Split { handle: h, origin: q[0] } + } else { + BlendDrag::Pair { pair: 0, origin_v: q[0] } + } + } else { + let h = if (pos.x - x_of(q[0])).abs() <= (pos.x - x_of(q[1])).abs() { 0 } else { 1 }; + BlendDrag::Single(h) + } + } else if q[2] == q[3] { + if alt { + let h = if pos.x <= x_of(q[2]) { 2 } else { 3 }; + BlendDrag::Split { handle: h, origin: q[2] } + } else { + BlendDrag::Pair { pair: 2, origin_v: q[2] } + } + } else { + let h = if (pos.x - x_of(q[2])).abs() <= (pos.x - x_of(q[3])).abs() { 2 } else { 3 }; + BlendDrag::Single(h) }; - ui.data_mut(|d| d.insert_temp(drag_key, near)); + ui.data_mut(|d| d.insert_temp(drag_key, mode)); } if resp.dragged() - && let (Some(h), Some(pos)) = (ui.data(|d| d.get_temp::(drag_key)), resp.interact_pointer_pos()) - && let Some(cur) = q.get(h).copied() + && let (Some(mut mode), Some(pos)) = (ui.data(|d| d.get_temp::(drag_key)), resp.interact_pointer_pos()) { let target = v_of(pos.x); - let split = ui.input(|i| i.modifiers.alt); - let mut n = *q; - let pair = if h < 2 { 0 } else { 2 }; - if split { - n[h] = target; - } else { - // Unsplit: the pair moves together, keeping its spread. - let delta = i16::from(target) - i16::from(cur); - for v in &mut n[pair..pair + 2] { - *v = (i16::from(*v) + delta).clamp(0, 255) as u8; - } - } - // Keep the order black low <= black high <= white low <= white high, pushing the others. - for i in 0..4 { - if i < h { - n[i] = n[i].min(n[h]); - } else if i > h { - n[i] = n[i].max(n[h]); - } - } - for i in 1..4 { - n[i] = n[i].max(n[i - 1]); - } + let n = step_blend_drag(&mut mode, q, target, alt); + ui.data_mut(|d| d.insert_temp(drag_key, mode)); if n != *q { *q = n; changed = true; @@ -1472,3 +1280,508 @@ impl MaxAlpha for Color32 { #[cfg(test)] mod tests; + +/// Photoshop's column widths: the effect list, the right-hand column (OK, Cancel, New Style…, +/// Preview, the preview swatch) and a page row's label column. +const LIST_W: f32 = 200.0; + +const SIDE_W: f32 = 112.0; + +const LABEL_W: f32 = 104.0; + +/// The bar under the effect list (fx, move up, move down, delete). +const BAR_H: f32 = 30.0; + +/// A bordered column `w` × `h`, as Photoshop frames the effect list and the page; `add` gets the +/// height inside the border. +fn boxed(ui: &mut egui::Ui, w: f32, h: f32, add: impl FnOnce(&mut egui::Ui, f32)) { + const PAD: f32 = 4.0; + let t = Tokens::get(ui.ctx()); + egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(t.radius_sm).inner_margin(PAD).show(ui, |ui| { + let inner = (vec2(w, h) - egui::Vec2::splat(2.0 * PAD + 2.0)).max(egui::Vec2::splat(40.0)); + // Top-down inside, whatever the row it sits in lays out. + ui.vertical(|ui| { + ui.set_width(inner.x); + ui.set_height(inner.y); + add(ui, inner.y); + }); + }); +} + +/// The effect list: Styles, Blending Options, then a row per effect instance (a greyed row for a +/// kind the layer doesn't have), each as wide as the list. +fn effect_list(ui: &mut egui::Ui, f: &mut Map, selected: &str) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + // Styles & Blending Options rows (matching the effect row layout & padding). + for (id, label) in [(STYLES_PAGE, "Styles"), (BLENDING, "Blending Options")] { + let is_sel = selected == id; + let (rect, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + if is_sel { + ui.painter().rect_filled(rect, t.radius_sm, t.row_selected.gamma_multiply(if t.pro { 1.0 } else { 0.0 }).max_alpha(t.hover)); + } else if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); + } + ui.painter().text( + rect.left_center() + vec2(28.0, 0.0), + egui::Align2::LEFT_CENTER, + tl!(label), + egui::FontId::proportional(12.5), + if is_sel { t.text } else { t.text_dim }, + ); + if resp.clicked() { + f.insert("selected".into(), json!(id)); + } + } + ui.add_space(4.0); + for &(kind, label) in KINDS { + let ids: Vec = effects_of(f) + .iter() + .filter(|e| e.get("kind").and_then(Value::as_str) == Some(kind)) + .filter_map(|e| e.get("id").and_then(Value::as_str).map(str::to_string)) + .collect(); + if ids.is_empty() { + // The effect isn't on the layer: a greyed row with an empty checkbox, as in + // Photoshop (#1357); a click on either adds it, turned on. + let (rect, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); + } + fx_checkbox(ui.painter(), fx_checkbox_rect(rect), false, &t); + ui.painter().text(rect.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text_faint); + // Photoshop shows + on every kind that takes several instances. + if multi(kind) { + let col = if resp.hovered() { t.accent } else { t.text_faint }; + ui.painter().text(rect.right_center() - vec2(8.0, 0.0), egui::Align2::CENTER_CENTER, "+", egui::FontId::proportional(13.0), col); + } + if resp.clicked() { + add_instance(f, kind); + } + continue; + } + let count = ids.len(); + for id in &ids { + let Some(e) = entry(f, id) else { continue }; + let kind_label = KINDS.iter().find(|k| k.0 == kind).map(|k| k.1).unwrap_or(label); + let mut on = e.get("on").and_then(Value::as_bool).unwrap_or(false); + let is_sel = id.as_str() == selected; + let (rect, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + if is_sel { + ui.painter().rect_filled(rect, t.radius_sm, t.row_selected.gamma_multiply(if t.pro { 1.0 } else { 0.0 }).max_alpha(t.hover)); + } else if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); + } + let cb = fx_checkbox_rect(rect); + let cresp = ui.interact(cb, ui.scope_id().with(("fxcb", kind, id)), Sense::click()); + fx_checkbox(ui.painter(), cb, on, &t); + ui.painter().text( + rect.left_center() + vec2(28.0, 0.0), + egui::Align2::LEFT_CENTER, + tl!(kind_label), + egui::FontId::proportional(12.5), + fx_label_color(on, &t), + ); + // + adds another instance; − removes one once there are several. + if multi(kind) { + let btn = |ui: &egui::Ui, at: egui::Pos2, glyph: &str, tag: &str| { + let r = egui::Rect::from_center_size(at, vec2(18.0, 18.0)); + let hresp = ui.interact(r, ui.scope_id().with(("fxbtn", tag, id)), Sense::click()); + let col = if hresp.hovered() { t.accent } else { t.text_faint }; + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, glyph, egui::FontId::proportional(13.0), col); + hresp.clicked() + }; + if count > 1 && btn(ui, rect.right_center() - vec2(22.0, 0.0), "\u{2212}", "minus") { + remove_instance(f, id); + continue; + } + if btn(ui, rect.right_center() - vec2(8.0, 0.0), "+", "plus") { + add_instance(f, kind); + } + } + if cresp.clicked() { + on = !on; + if let Some(e) = entry_mut(f, id) { + e["on"] = json!(on); + } + f.insert("selected".into(), json!(id)); + } else if resp.clicked() { + f.insert("selected".into(), json!(id)); + } + } + } +} + +/// The ids of the instances of `kind`, in list order. +fn ids_of(f: &Map, kind: &str) -> Vec { + effects_of(f) + .iter() + .filter(|e| e.get("kind").and_then(Value::as_str) == Some(kind)) + .filter_map(|e| e.get("id").and_then(Value::as_str).map(str::to_string)) + .collect() +} + +/// Moves instance `id` `by` places among the instances of its kind (negative: up the list). +fn move_instance(f: &mut Map, id: &str, by: isize) { + let Some(a) = f.get_mut("effects").and_then(Value::as_array_mut) else { return }; + let kind_of = |e: &Value| e.get("kind").and_then(Value::as_str).map(str::to_string); + let Some(kind) = a.iter().find(|e| e.get("id").and_then(Value::as_str) == Some(id)).and_then(kind_of) else { return }; + let slots: Vec = a.iter().enumerate().filter(|(_, e)| kind_of(e).as_deref() == Some(kind.as_str())).map(|(i, _)| i).collect(); + let Some(at) = slots.iter().position(|&i| a.get(i).and_then(|e| e.get("id")).and_then(Value::as_str) == Some(id)) else { return }; + let Some(&other) = at.checked_add_signed(by).and_then(|j| slots.get(j)) else { return }; + if let Some(&here) = slots.get(at) + && here < a.len() + && other < a.len() + { + a.swap(here, other); + } +} + +/// Photoshop's bar under the effect list: fx adds an effect (or turns on and shows the one the +/// layer has, for a kind that takes one), the arrows move the selected instance up or down among +/// those of its kind, the bin deletes it. +fn list_bar(ui: &mut egui::Ui, f: &mut Map, selected: &str) { + let kind = entry(f, selected).and_then(|e| e.get("kind").and_then(Value::as_str)).map(str::to_string); + let siblings = kind.as_deref().map(|k| ids_of(f, k)).unwrap_or_default(); + let at = siblings.iter().position(|id| id == selected); + ui.horizontal(|ui| { + let fx = crate::panels::fx_button(ui, 24.0, tl!("Add a layer style")); + crate::press_menu::show(&fx, |ui, menu| { + ui.set_min_width(160.0); + for &(kind, label) in KINDS { + if menu.item(ui, tl!(label)) { + pick_from_fx_menu(f, kind); + } + } + }); + let up = at.is_some_and(|i| i > 0); + if ui.add_enabled_ui(up, |ui| crate::icons::button(ui, "chevron-up", 24.0, false, tl!("Move effect up"))).inner.clicked() { + move_instance(f, selected, -1); + } + let down = at.is_some_and(|i| i + 1 < siblings.len()); + if ui.add_enabled_ui(down, |ui| crate::icons::button(ui, "chevron-down", 24.0, false, tl!("Move effect down"))).inner.clicked() { + move_instance(f, selected, 1); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(at.is_some(), |ui| crate::icons::button(ui, "trash", 24.0, false, tl!("Delete effect"))).inner.clicked() { + remove_instance(f, selected); + } + }); + }); +} + +/// The fx menu's `kind`: another instance of a kind that takes several (or the first), else the +/// layer's one, turned on and shown. +fn pick_from_fx_menu(f: &mut Map, kind: &str) { + match ids_of(f, kind).first() { + Some(id) if !multi(kind) => { + if let Some(e) = entry_mut(f, id) { + e["on"] = json!(true); + } + f.insert("selected".into(), json!(id)); + } + _ => add_instance(f, kind), + } +} + +/// A check box in a page's control column, under the controls rather than the labels. +fn checkbox_line(ui: &mut egui::Ui, on: &mut bool, label: &str) -> bool { + ui.horizontal(|ui| { + ui.add_space(LABEL_W + ui.spacing().item_spacing.x); + widgets::checkbox(ui, on, label).changed() + }) + .inner +} + +/// The selected page: the Styles gallery, Blending Options, or an effect's parameters. +fn page(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, selected: &str, invalid: Option<&str>, style_click: &mut Option) { + let t = Tokens::get(ui.ctx()); + // Photoshop's rows sit close together: every page fits the dialog without scrolling. + ui.spacing_mut().item_spacing.y = 3.0; + if selected == STYLES_PAGE { + ui.label(RichText::new(tl!("Styles")).font(crate::theme::semibold(14.0)).color(t.text)); + ui.add_space(8.0); + let ctx = ui.ctx().clone(); + // The page scrolls, so the gallery takes the room the dialog has. + ui.vertical(|ui| { + for group in &app.session.presets.styles { + ui.label(RichText::new(&group.name).size(12.0).color(t.text_dim)); + ui.add_space(3.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + for st in &group.items { + let tex = crate::preset_panels::style_texture(app, &ctx, st); + let (rect, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + let (stroke, kind) = if resp.hovered() { + (Stroke::new(1.5, t.accent), egui::StrokeKind::Outside) + } else { + (Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside) + }; + ui.painter().rect_stroke(rect, 2.0, stroke, kind); + if resp.on_hover_text(&st.name).clicked() { + *style_click = Some(st.name.clone()); + } + } + }); + ui.add_space(10.0); + } + }); + return; + } + let sel_kind: String = if selected == BLENDING { + BLENDING.to_string() + } else { + entry(f, selected).and_then(|e| e.get("kind").and_then(Value::as_str)).unwrap_or("").to_string() + }; + let label = KINDS.iter().find(|k| k.0 == sel_kind).map(|k| k.1).unwrap_or(if selected == BLENDING { "Blending Options" } else { "" }); + ui.label(RichText::new(tl!(&label)).font(crate::theme::semibold(14.0)).color(t.text)); + ui.add_space(6.0); + // Only reachable through `ui.dialog.set`: say what is wrong once, keep the bad value. + if let Some(error) = invalid { + ui.colored_label(t.danger, error); + return; + } + if selected == BLENDING { + // General Blending, Advanced Blending and Blend If, laid out like Photoshop's page. + let names = channel_names_of(f); + let key = format!("p:{BLENDING}"); + let Some(mut p) = f.get(&key).filter(|v| v.is_object()).cloned() else { return }; + blending_page(ui, &mut p, &names); + f.insert(key, p); + return; + } + // The page shows the factory defaults under the instance's values, + // but only real edits reach the stored params. + let disp = if selected == BLENDING { + f.get("p:blendingOptions").cloned().unwrap_or_else(|| json!({})) + } else { + entry(f, selected).and_then(|e| e.get("params").cloned()).unwrap_or_else(|| json!({})) + }; + let Some(mut disp) = disp.as_object().map(|o| Value::Object(o.clone())) else { return }; + if selected != BLENDING { + let base = defaults(&sel_kind); + if let (Some(o), Some(b)) = (disp.as_object_mut(), base.as_object()) { + for (k, v) in b { + o.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + } + for &(key, label, kind_p) in spec(&sel_kind) { + match kind_p { + P::Angle => { + let mut v = disp.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; + if widgets::angle_dial_line(ui, label, Some(LABEL_W), &mut v, -180.0..=180.0).changed() { + let value = json!(v); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + } + P::Slider(min, max, unit) => { + let mut v = disp.get(key).and_then(Value::as_f64).unwrap_or(min as f64) as f32; + if widgets::slider_line(ui, label, LABEL_W, &mut v, min..=max, unit).changed() { + let value = json!(v.round()); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + } + P::Color => { + ui.horizontal(|ui| { + widgets::row_label(ui, label, LABEL_W); + let color = disp.get(key).and_then(Value::as_str).unwrap_or("#000000"); + let response = widgets::color_swatch(ui, parse_hex(color)).on_hover_text(tl!("Color Picker (Layer Style Color)")); + if response.clicked() { + f.insert(color_picker::REQUEST.into(), json!({"effect": selected, "field": key})); + } + }); + } + P::Blend => { + ui.horizontal(|ui| { + widgets::row_label(ui, label, LABEL_W); + let mut cur = disp.get(key).and_then(Value::as_str).unwrap_or("Normal").to_string(); + let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); + if widgets::dropdown(ui, &format!("fx-blend-{selected}-{key}"), tl!(&label), &mut cur, &opts, 150.0) { + let value = json!(cur); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + }); + } + P::GradientMethod => { + ui.horizontal(|ui| { + widgets::row_label(ui, label, LABEL_W); + let mut current = disp.get(key).and_then(Value::as_str).unwrap_or("custom").to_string(); + let mut options = vec![ + ("smooth".to_string(), "Smooth"), + ("perceptual".to_string(), "Perceptual"), + ("linear".to_string(), "Linear"), + ("classic".to_string(), "Classic"), + ]; + // Old/imported curves have no method record. Preserve them until + // the user explicitly chooses an interpolation method. + if photocraft_io::GradientMethod::from_name(¤t).is_none() { + current = "custom".into(); + options.insert(0, ("custom".into(), "Custom")); + } + if widgets::dropdown(ui, "fx-gradientOverlay-method", tl!("Method"), &mut current, &options, 150.0) { + // The older dialog deliberately omits From/To for rich imported + // curves. Once a method is chosen, show the real source ends. + let snapshot = entry(f, selected).and_then(|e| e.get("fx")).cloned(); + if let Some(Effect::GradientOverlay { gradient, interpolation, .. }) = snapshot.and_then(|v| serde_json::from_value::(v).ok()) { + let stops = interpolation.as_ref().map_or(&gradient.stops, |source| &source.stops); + for (key, stop) in [("from", stops.first()), ("to", stops.last())] { + if entry(f, selected).and_then(|e| e.get("params")).and_then(|p| p.get(key)).is_none() + && let Some(stop) = stop + { + set_param(f, selected, key, json!(hex(&stop.1))); + } + } + } + let value = json!(current); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + }); + } + P::Choice(options) => { + ui.horizontal(|ui| { + widgets::row_label(ui, label, LABEL_W); + let mut cur = disp.get(key).and_then(Value::as_str).unwrap_or(options[0].0).to_string(); + let opts: Vec<(String, &str)> = options.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 150.0) { + let value = json!(cur); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + }); + } + P::Pattern => { + let list: Vec<(String, String)> = f + .get("patternList") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|e| Some((e.get(0)?.as_str()?.to_string(), e.get(1)?.as_str()?.to_string()))).collect()) + .unwrap_or_default(); + ui.horizontal(|ui| { + widgets::row_label(ui, label, LABEL_W); + let mut cur = disp + .get(key) + .and_then(Value::as_str) + .filter(|c| !c.is_empty()) + .map(str::to_string) + .or_else(|| list.first().map(|l| l.0.clone())) + .unwrap_or_default(); + let opts: Vec<(String, &str)> = list.iter().map(|(id, n)| (id.clone(), n.as_str())).collect(); + if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 180.0) + || disp.get(key).and_then(Value::as_str).is_none_or(str::is_empty) + { + let value = json!(cur); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + }); + } + P::Gradient => gradient_field::row(app, ui, f, selected, label, &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 checkbox_line(ui, &mut b, label) { + let value = json!(b); + disp[key] = value.clone(); + set_param(f, selected, key, value); + } + } + } + } + // Make Default stores this page as the user default; Reset to Default + // restores it (or the factory defaults before the first Make). + let page_kind = sel_kind != BLENDING && entry(f, selected).is_some(); + ui.add_space(4.0); + // Wrap: translated labels (ru, de) can be wider than the page column. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min).with_main_wrap(true), |ui| { + ui.add_enabled_ui(page_kind, |ui| { + if crate::widgets::secondary_button(ui, tl!("Reset to Default"), 130.0).clicked() + && let Ok(v) = app.run("layer.layerStyle.defaultFor", json!({"kind": sel_kind})) + && let Some(e) = entry_mut(f, selected) + { + e["params"] = v["params"].clone(); + } + if crate::widgets::secondary_button(ui, tl!("Make Default"), 110.0).clicked() + && app.run("layer.layerStyle.makeDefault", json!({"kind": sel_kind, "params": disp})).is_ok() + && let Some(d) = f.get_mut(USER_DEFAULTS).and_then(Value::as_object_mut) + { + d.insert(sel_kind.to_string(), disp.clone()); + } + }); + }); +} + +/// Photoshop's right-hand column: OK, Cancel, New Style…, Preview and the preview swatch. +fn side_column(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, new_style: &mut bool) -> Option { + let mut outcome = None; + if widgets::primary_button(ui, tl!("OK"), SIDE_W).clicked() { + outcome = Some(true); + } + ui.add_space(2.0); + if widgets::secondary_button(ui, tl!("Cancel"), SIDE_W).clicked() { + outcome = Some(false); + } + ui.add_space(2.0); + if widgets::secondary_button(ui, tl!("New Style…"), SIDE_W).clicked() { + *new_style = true; + } + ui.add_space(6.0); + let mut preview = f.get("preview").and_then(Value::as_bool).unwrap_or(true); + if widgets::checkbox(ui, &mut preview, tl!("Preview")).changed() { + f.insert("preview".into(), json!(preview)); + } + ui.add_space(6.0); + preview_swatch(app, ui, f, SIDE_W); + outcome +} + +/// Photoshop's preview swatch: the dialog's pending style on a sample square, redrawn when the +/// style changes. +fn preview_swatch(app: &PhotocraftApp, ui: &mut egui::Ui, f: &Map, size: f32) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(egui::Vec2::splat(size), Sense::hover()); + ui.painter().rect_filled(rect, t.radius_sm, t.field); + // At point size while a drag (a slider) changes it every frame, a quarter of the work on a + // 2x screen; at the screen's resolution once it settles. + let scale = if ui.input(|i| i.pointer.any_down()) { 1.0 } else { ui.ctx().pixels_per_point() }; + let px = (size * scale).round().clamp(8.0, 256.0) as u32; + let key = (preview_hash(f), px); + let id = egui::Id::unique("layer-style-swatch"); + let cached = ui.data(|d| d.get_temp::<(u64, u32, egui::TextureHandle)>(id)).filter(|c| (c.0, c.1) == key).map(|c| c.2); + let tex = cached.or_else(|| { + let rgba = photocraft_engine::presets::styles::thumbnail(&app.session, &pending_style(app, f)?, px); + let side = px as usize; + (rgba.len() == side * side * 4).then(|| { + let tex = ui.ctx().load_texture("layer-style-swatch", egui::ColorImage::from_rgba_unmultiplied([side, side], &rgba), egui::TextureOptions::LINEAR); + ui.data_mut(|d| d.insert_temp(id, (key.0, key.1, tex.clone()))); + tex + }) + }); + if let Some(tex) = tex { + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.separator), StrokeKind::Inside); +} + +/// The dialog's pending style as a preset, built on a scratch layer the way the canvas preview +/// builds it on the real one (instance snapshots, blending options and patterns all apply). +fn pending_style(app: &PhotocraftApp, f: &Map) -> Option { + let mut s = photocraft_engine::Session::new(); + s.patterns = app.session.patterns.clone(); + s.execute("file.new", json!({"width": 8, "height": 8})).ok()?; + let layer = s.execute("layer.new.layer", json!({})).ok()?.get("layer")?.as_u64()?; + let mut f = f.clone(); + f.insert("layer".into(), json!(layer)); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).ok()?; + let l = s.active()?.doc.layer(photocraft_doc::LayerId(layer))?; + Some(photocraft_engine::presets::styles::StylePreset { + name: String::new(), + effects: l.effects.items.clone(), + blend: Some(l.blend), + opacity: Some(l.opacity), + fill_opacity: Some(l.fill_opacity), + }) +} diff --git a/crates/ui-egui/src/dialogs/layer_style/color_picker.rs b/crates/ui-egui/src/dialogs/layer_style/color_picker.rs index 341e659c9e..31dddfd088 100644 --- a/crates/ui-egui/src/dialogs/layer_style/color_picker.rs +++ b/crates/ui-egui/src/dialogs/layer_style/color_picker.rs @@ -17,6 +17,9 @@ struct Target { layer: photocraft_doc::LayerId, effect: String, field: String, + /// The colour stop edited when `field` is the Gradient Overlay's `stops` (#5177). + #[serde(default)] + stop: Option, color: String, // A reused effect id after loading another style must not redirect the transaction. source: Value, @@ -43,24 +46,50 @@ struct ColorField { color: String, } -fn color_value(fields: &Map, effect: &str, field: &str) -> Result { +fn color_value(fields: &Map, effect: &str, field: &str, stop: Option) -> Result { let mut matches = effects_of(fields).iter().filter(|e| e.get("id").and_then(Value::as_str) == Some(effect)); let e = matches.next().ok_or("Layer Style effect no longer exists")?; if matches.next().is_some() { return Err("ambiguous Layer Style effect id".into()); } let kind = e.get("kind").and_then(Value::as_str).ok_or("invalid effect kind")?; - if !spec(kind).iter().any(|(key, _, p)| *key == field && matches!(p, P::Color)) { - return Err("not a Layer Style color parameter".into()); - } let params = e.get("params").and_then(Value::as_object).ok_or("invalid effect parameters")?; - let original = params.get(field).cloned(); let base = defaults(kind); - let color = original.as_ref().or_else(|| base.get(field)).and_then(Value::as_str).ok_or("invalid effect color")?; - let rgb = color_picker_ui::parse_hex(color).ok_or("invalid effect color")?; + let original = params.get(field).or_else(|| base.get(field)); + let rgb = match stop { + // A gradient stop: the colour at that index of the effect's `stops`. + Some(i) => { + if !spec(kind).iter().any(|(key, _, p)| *key == field && matches!(p, P::Gradient)) { + return Err("not a Layer Style gradient parameter".into()); + } + let stops = super::gradient_field::stops_of(original).ok_or("invalid effect gradient")?; + stops.get(i).map(|s| s.1.to_rgb()).ok_or("no such gradient stop")? + } + None => { + if !spec(kind).iter().any(|(key, _, p)| *key == field && matches!(p, P::Color)) { + return Err("not a Layer Style color parameter".into()); + } + let color = original.and_then(Value::as_str).ok_or("invalid effect color")?; + color_picker_ui::parse_hex(color).ok_or("invalid effect color")? + } + }; Ok(ColorField { source: e.clone(), color: color_picker_ui::hex(rgb) }) } +/// The value the picker's colour becomes for the target: the colour itself, or the effect's +/// `stops` with stop `i` recoloured. +fn picked_value(fields: &Map, t: &Target, color: &str) -> Result { + let Some(i) = t.stop else { return Ok(json!(color)) }; + let e = effects_of(fields).iter().find(|e| e.get("id").and_then(Value::as_str) == Some(t.effect.as_str())).ok_or("Layer Style effect no longer exists")?; + let kind = e.get("kind").and_then(Value::as_str).ok_or("invalid effect kind")?; + let base = defaults(kind); + let current = e.get("params").and_then(|p| p.get(&t.field)).or_else(|| base.get(&t.field)); + let mut stops = super::gradient_field::stops_of(current).ok_or("invalid effect gradient")?; + let [r, g, b] = color_picker_ui::parse_hex(color).ok_or("invalid picker color")?; + stops.get_mut(i).ok_or("no such gradient stop")?.1 = photocraft_color::Color::rgb(r, g, b); + Ok(super::gradient_field::stops_json(&stops)) +} + fn validate<'a>(app: &'a PhotocraftApp, t: &Target) -> Result<&'a Map, String> { let st = app.session.active().ok_or("no active document")?; if st.doc.id != t.document || st.doc.layer(t.layer).is_none() { @@ -73,7 +102,7 @@ fn validate<'a>(app: &'a PhotocraftApp, t: &Target) -> Result<&'a Map(app: &'a PhotocraftApp, t: &Target) -> Result<&'a Map Result { +pub(crate) fn open(app: &mut PhotocraftApp, parent: u64, effect: &str, field: &str, stop: Option) -> Result { if app.ui.dialogs.last().map(|d| d.id) != Some(parent) || has_child(app, parent) { return Err("open the picker from the top Layer Style dialog".into()); } let d = app.ui.dialogs.iter().find(|d| d.id == parent && d.kind == DialogKind::LayerStyle).ok_or("no Layer Style dialog")?; let st = app.session.active().ok_or("no active document")?; let layer = photocraft_doc::LayerId(d.fields.get("layer").and_then(Value::as_u64).ok_or("invalid layer id")?); - let ColorField { source, color } = color_value(&d.fields, effect, field)?; - let t = Target { parent, document: st.doc.id, layer, effect: effect.into(), field: field.into(), color: color.clone(), source }; + let ColorField { source, color } = color_value(&d.fields, effect, field, stop)?; + let t = Target { parent, document: st.doc.id, layer, effect: effect.into(), field: field.into(), stop, color: color.clone(), source }; validate(app, &t)?; let rgb = color_picker_ui::parse_hex(&color).ok_or("invalid effect color")?; let mut fields = color_picker_ui::fields_with_web(app, "dialog", "Color Picker (Layer Style Color)", rgb); @@ -102,7 +131,11 @@ pub(crate) fn take_request(app: &mut PhotocraftApp, parent: u64) -> Result<(), S if let Some(r) = request { let effect = r.get("effect").and_then(Value::as_str).ok_or("invalid effect request")?; let field = r.get("field").and_then(Value::as_str).ok_or("invalid color request")?; - open(app, parent, effect, field)?; + let stop = match r.get("stop") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("invalid gradient stop")?), + }; + open(app, parent, effect, field, stop)?; } Ok(()) } @@ -120,8 +153,9 @@ pub(crate) fn preview_fields<'a>(app: &PhotocraftApp, parent: u64, fields: &'a M if t.color == color { return Cow::Borrowed(fields); } + let Ok(value) = picked_value(fields, &t, &color) else { return Cow::Borrowed(fields) }; let mut shown = fields.clone(); - set_param(&mut shown, &t.effect, &t.field, json!(color)); + set_param(&mut shown, &t.effect, &t.field, value); Cow::Owned(shown) } @@ -131,7 +165,8 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, fields: &Map) -> R let color = fields.get("color").and_then(Value::as_str).and_then(color_picker_ui::parse_hex).map(color_picker_ui::hex).ok_or("invalid picker color")?; if t.color != color { let parent = app.ui.dialog_mut(t.parent).ok_or("Layer Style dialog closed")?; - set_param(&mut parent.fields, &t.effect, &t.field, json!(color)); + let value = picked_value(&parent.fields, &t, &color)?; + set_param(&mut parent.fields, &t.effect, &t.field, value); } Ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs/layer_style/color_picker/tests.rs b/crates/ui-egui/src/dialogs/layer_style/color_picker/tests.rs index 2d87c81ef8..f98e02577b 100644 --- a/crates/ui-egui/src/dialogs/layer_style/color_picker/tests.rs +++ b/crates/ui-egui/src/dialogs/layer_style/color_picker/tests.rs @@ -34,7 +34,7 @@ fn every_exposed_color_field_uses_the_shared_picker_and_only_updates_its_target( let tools = app.session.tools.clone(); let doc = app.session.active().unwrap().doc.clone(); let history = app.session.active().unwrap().history.past_len(); - let picker = open(&mut app, parent, &effect, field).unwrap(); + let picker = open(&mut app, parent, &effect, field, None).unwrap(); assert!(color_picker_ui::owns(&fields(&app, picker))); assert_eq!(fields(&app, picker)["__label"], "Color Picker (Layer Style Color)"); set_color(&mut app, picker, "#23a567"); @@ -63,7 +63,7 @@ fn preview_cancel_ok_parent_cancel_and_apply_are_transactional_at_every_depth() let original = fields(&app, parent); let st = app.session.active().unwrap(); let (doc, history, revision, dirty) = (st.doc.clone(), st.history.past_len(), st.revision, st.is_dirty()); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); set_color(&mut app, picker, "#00ff00"); let (shown, key) = canvas::display_doc(&mut app, 0); assert!(!Arc::ptr_eq(&shown, &doc)); @@ -78,7 +78,7 @@ fn preview_cancel_ok_parent_cancel_and_apply_are_transactional_at_every_depth() dialogs::cancel(&mut app, picker).unwrap(); assert_eq!(fields(&app, parent), original); assert_ne!(canvas::display_doc(&mut app, 0).1, key); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); set_color(&mut app, picker, "#00ff00"); dialogs::confirm(&mut app, picker).unwrap(); assert_eq!(app.session.active().unwrap().history.past_len(), history); @@ -86,7 +86,7 @@ fn preview_cancel_ok_parent_cancel_and_apply_are_transactional_at_every_depth() assert!(Arc::ptr_eq(&canvas::display_doc(&mut app, 0).0, &doc)); let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); let effect = selected(&app, parent); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); set_color(&mut app, picker, "#00ff00"); dialogs::confirm(&mut app, picker).unwrap(); dialogs::confirm(&mut app, parent).unwrap(); @@ -108,11 +108,11 @@ fn unchanged_ok_preserves_missing_parameters_and_imported_effect_snapshot() { let parent = layer_style::open(&mut app, Some("stroke")).unwrap(); let original = fields(&app, parent); for effect in ["fx1", "fx2"] { - let picker = open(&mut app, parent, effect, "color").unwrap(); + let picker = open(&mut app, parent, effect, "color", None).unwrap(); dialogs::confirm(&mut app, picker).unwrap(); assert_eq!(fields(&app, parent), original); } - let picker = open(&mut app, parent, "fx2", "color").unwrap(); + let picker = open(&mut app, parent, "fx2", "color", None).unwrap(); set_color(&mut app, picker, "#223344"); dialogs::confirm(&mut app, picker).unwrap(); let current = fields(&app, parent); @@ -121,9 +121,9 @@ fn unchanged_ok_preserves_missing_parameters_and_imported_effect_snapshot() { dialogs::cancel(&mut app, parent).unwrap(); let parent = layer_style::open(&mut app, Some("gradientOverlay")).unwrap(); let effect = selected(&app, parent); - app.ui.dialog_mut(parent).unwrap().fields.get_mut("effects").unwrap().as_array_mut().unwrap()[0]["params"].as_object_mut().unwrap().remove("from"); + app.ui.dialog_mut(parent).unwrap().fields.get_mut("effects").unwrap().as_array_mut().unwrap()[0]["params"].as_object_mut().unwrap().remove("stops"); let original = fields(&app, parent); - let picker = open(&mut app, parent, &effect, "from").unwrap(); + let picker = open(&mut app, parent, &effect, "stops", Some(0)).unwrap(); dialogs::confirm(&mut app, picker).unwrap(); assert_eq!(fields(&app, parent), original); } @@ -134,7 +134,7 @@ fn stale_targets_and_invalid_colors_never_apply_or_leave_preview() { let mut app = app(8); let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); let effect = selected(&app, parent); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); set_color(&mut app, picker, "#00ff00"); match change { "document" => { @@ -174,11 +174,11 @@ fn stale_targets_and_invalid_colors_never_apply_or_leave_preview() { let parent = layer_style::open(&mut app, Some("colorOverlay")).unwrap(); let effect = selected(&app, parent); let original = fields(&app, parent); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); set_color(&mut app, picker, "bad"); assert!(dialogs::confirm(&mut app, picker).is_err()); assert_eq!(fields(&app, parent), original); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); app.run("file.new", json!({"width":3,"height":3})).unwrap(); prune(&mut app); assert!(!app.ui.dialogs.iter().any(|d| d.id == picker)); @@ -192,7 +192,7 @@ fn preview_off_and_parent_confirmation_and_cancellation_keep_their_contract() { let effect = selected(&app, parent); let original = fields(&app, parent); let doc = app.session.active().unwrap().doc.clone(); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); set_color(&mut app, picker, "#00ff00"); assert!(Arc::ptr_eq(&canvas::display_doc(&mut app, 0).0, &doc)); assert!(dialogs::confirm(&mut app, parent).is_err()); @@ -249,7 +249,7 @@ fn eyedropper_edits_only_the_picker_and_invalid_samples_leave_it_unchanged() { let original = fields(&app, parent); let doc = app.session.active().unwrap().doc.clone(); let tools = app.session.tools.clone(); - let picker = open(&mut app, parent, &effect, "color").unwrap(); + let picker = open(&mut app, parent, &effect, "color", None).unwrap(); color_picker_ui::sample_at(&mut app, 4.0, 4.0); assert_eq!(fields(&app, picker)["color"], "#406080"); color_picker_ui::sample_at(&mut app, -100.0, -100.0); @@ -259,3 +259,25 @@ fn eyedropper_edits_only_the_picker_and_invalid_samples_leave_it_unchanged() { assert_eq!(app.session.tools.foreground, tools.foreground); dialogs::cancel(&mut app, picker).unwrap(); } + +/// A Gradient Overlay's stop (#5177): the picker edits one colour of `stops`, previewing it, +/// and leaves the other stops alone; a stop that doesn't exist is refused. +#[test] +fn gradient_stop_colors_go_through_the_picker() { + let mut app = app(8); + let parent = layer_style::open(&mut app, Some("gradientOverlay")).unwrap(); + let effect = selected(&app, parent); + let three = json!([[0.0, "#ff0000"], [0.5, "#00ff00"], [1.0, "#0000ff"]]); + super::super::set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, "stops", three); + assert!(open(&mut app, parent, &effect, "stops", Some(3)).is_err(), "no fourth stop"); + assert!(open(&mut app, parent, &effect, "stops", None).is_err(), "stops is not a plain colour"); + let picker = open(&mut app, parent, &effect, "stops", Some(1)).unwrap(); + set_color(&mut app, picker, "#123456"); + let current = fields(&app, parent); + let shown = preview_fields(&app, parent, ¤t); + assert_eq!(super::super::entry(&shown, &effect).unwrap()["params"]["stops"][1][1], "#123456", "previewed"); + assert_eq!(super::super::entry(&fields(&app, parent), &effect).unwrap()["params"]["stops"][1][1], "#00ff00", "not stored yet"); + dialogs::confirm(&mut app, picker).unwrap(); + let stops = super::super::entry(&fields(&app, parent), &effect).unwrap()["params"]["stops"].clone(); + assert_eq!(stops, json!([[0.0, "#ff0000"], [0.5, "#123456"], [1.0, "#0000ff"]])); +} diff --git a/crates/ui-egui/src/dialogs/layer_style/contour.rs b/crates/ui-egui/src/dialogs/layer_style/contour.rs index 0c1c18b605..6860faa1bd 100644 --- a/crates/ui-egui/src/dialogs/layer_style/contour.rs +++ b/crates/ui-egui/src/dialogs/layer_style/contour.rs @@ -102,10 +102,11 @@ pub(super) fn row( ) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { + // In the page's label column, empty for Bevel & Emboss's Contour (its check box is above). if label.is_empty() { - ui.add_space(16.0); + ui.allocate_exact_size(vec2(super::LABEL_W, 1.0), Sense::hover()); } else { - ui.label(RichText::new(tl!(label)).color(t.text_dim)); + crate::widgets::row_label(ui, label, super::LABEL_W); } let c = contour_of(disp.get(key)); let (r, thumb) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs new file mode 100644 index 0000000000..8abbefbbd7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs @@ -0,0 +1,462 @@ +//! The Gradient Overlay page's gradient (#5177): a ramp with colour-stop markers under it, edited +//! in place as in Photoshop's Gradient Editor (click under the ramp to add a stop with the +//! gradient's colour there, drag a marker to move it, drag it away to delete it, double-click it +//! or the Color swatch for the Color Picker), a Location field and Delete for the selected stop, +//! and the Gradients panel's presets to start from. The dialog field `stops` is +//! `[[position, "#rrggbb"], …]` and `opacityStops` `[[position, opacity %], …]` (carried through +//! the dialog, not edited here): the engine command's own grammar, so what the page shows is what +//! `layer.layerStyle.gradientOverlay` receives. + +use egui::{Color32, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_color::Color; +use photocraft_compose::gradient_fill as gf; +use photocraft_doc::{Fill, GradientStyle}; +use photocraft_engine::gradient_fill_cmds as cmds; +use serde_json::{Map, Value, json}; + +use super::{color_picker, set_param}; +use crate::theme::Tokens; +use crate::{PhotocraftApp, color_picker_ui, widgets}; + +/// The dialog field holding the colour stops. +pub(super) const KEY: &str = "stops"; +/// The dialog field holding the transparency stops. +pub(super) const OPACITY_KEY: &str = "opacityStops"; + +/// Where the last drawn ramp bar is, for tests that drive it: `(effect id, bar rect)`. +pub(crate) fn bar_id() -> egui::Id { + egui::Id::unique("fx-gradient-bar") +} + +/// `[[t, "#rrggbb"], …]` → stops (at least two, sorted); `None` when the value isn't that. +pub(super) fn stops_of(v: Option<&Value>) -> Option> { + let a = v?.as_array()?; + let mut stops: Vec<(f32, Color)> = a + .iter() + .filter_map(|s| { + let t = s.get(0)?.as_f64()?; + let value = s.get(1)?; + let color = if let Some(hex) = value.as_str() { + let [r, g, b] = color_picker_ui::parse_hex(hex)?; + Color::rgb(r, g, b) + } else { + serde_json::from_value::(value.clone()).ok()? + }; + (t.is_finite() && color.c.iter().all(|v| v.is_finite()) && color.alpha.is_finite()).then(|| (t.clamp(0.0, 1.0) as f32, color)) + }) + .collect(); + if stops.len() < 2 { + return None; + } + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + Some(stops) +} + +/// `[[t, opacity %], …]` → transparency stops (empty = opaque); `None` when the value isn't that. +pub(super) fn opacity_of(v: Option<&Value>) -> Option> { + let a = v?.as_array()?; + let mut os: Vec<(f32, f32)> = a + .iter() + .filter_map(|s| { + let (t, o) = (s.get(0)?.as_f64()?, s.get(1)?.as_f64()?); + (t.is_finite() && o.is_finite()).then(|| (t.clamp(0.0, 1.0) as f32, (o / 100.0).clamp(0.0, 1.0) as f32)) + }) + .collect(); + if os.len() == 1 { + return None; + } + os.sort_by(|a, b| a.0.total_cmp(&b.0)); + Some(os) +} + +pub(super) fn stops_json(stops: &[(f32, Color)]) -> Value { + Value::Array( + stops + .iter() + .map(|(t, c)| { + let [r, g, b, _] = c.to_rgba8(); + let exact = Color::rgb(f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0); + if *c == exact { json!([t, super::hex(c)]) } else { json!([t, c]) } + }) + .collect(), + ) +} + +pub(super) fn opacity_json(os: &[(f32, f32)]) -> Value { + Value::Array(os.iter().map(|(t, o)| json!([t, f64::from(*o) * 100.0])).collect()) +} + +/// The page's gradient as a fill: the ramp, and what the stop edits are applied to. +pub(super) fn fill_of(disp: &Value) -> Fill { + let stops = stops_of(disp.get(KEY)).unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); + let mut f = Fill::gradient(stops, 0.0, 1.0, GradientStyle::Linear, false); + if let Fill::Gradient { opacity_stops, .. } = &mut f { + *opacity_stops = opacity_of(disp.get(OPACITY_KEY)).unwrap_or_default(); + } + f +} + +/// The engine's factory defaults name a From and To colour; the page models stops, so a fresh +/// instance starts from those two as stops (and never shows From/To). +pub(super) fn from_to_to_stops(p: &mut Value) { + let Some(o) = p.as_object_mut() else { return }; + if !o.contains_key(KEY) { + let hex = |k: &str, d: &str| o.get(k).and_then(Value::as_str).unwrap_or(d).to_string(); + let (from, to) = (hex("from", "#000000"), hex("to", "#ffffff")); + o.insert(KEY.into(), json!([[0.0, from], [1.0, to]])); + } + o.remove("from"); + o.remove("to"); + o.entry(OPACITY_KEY).or_insert_with(|| json!([])); +} + +/// Store `fill`'s stops as the effect's `stops` / `opacityStops` and show them. +fn commit(f: &mut Map, effect: &str, disp: &mut Value, fill: &Fill) { + let Fill::Gradient { stops, opacity_stops, .. } = fill else { return }; + let v = stops_json(stops); + disp[KEY] = v.clone(); + set_param(f, effect, KEY, v); + let v = opacity_json(opacity_stops); + disp[OPACITY_KEY] = v.clone(); + set_param(f, effect, OPACITY_KEY, v); +} + +/// Apply one stop edit (`cmds::apply_stop`'s grammar) to the page's gradient; the new index of +/// the edited stop, for the selection. +fn edit(app: &PhotocraftApp, f: &mut Map, effect: &str, disp: &mut Value, p: Value) -> Option { + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + let fill = fill_of(disp); + let new = cmds::apply_stop(&fill, &p, fg, bg).ok()?; + let Fill::Gradient { stops, .. } = &new else { return None }; + // The engine keeps stops sorted: the edited one is the last with its location. + let at = match p.get("action").and_then(Value::as_str) { + Some("add" | "move") => { + let loc = p.get("location").and_then(Value::as_f64).unwrap_or(0.0) as f32; + stops.iter().rposition(|s| (s.0 - loc).abs() < 1e-6).unwrap_or(0) + } + _ => 0, + }; + commit(f, effect, disp, &new); + Some(at) +} + +/// Use `preset` (the Gradients panel's) as the page's gradient, resolved with the tool colours. +pub(super) fn apply_preset(app: &PhotocraftApp, f: &mut Map, effect: &str, disp: &mut Value, name: &str) -> bool { + let Some(preset) = app.session.presets.gradients.iter().flat_map(|g| g.items.iter()).find(|p| p.name == name) else { return false }; + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + let (stops, os) = preset.fill_stops(fg, bg); + if stops.len() < 2 { + return false; + } + let mut fill = Fill::gradient(stops, 0.0, 1.0, GradientStyle::Linear, false); + if let Fill::Gradient { opacity_stops, .. } = &mut fill { + *opacity_stops = os; + } + commit(f, effect, disp, &fill); + true +} + +/// The page's row: the preset dropdown, the ramp with its stop markers, the selected stop's +/// Color, Location and Delete. +pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, effect: &str, label: &str, disp: &mut Value) { + let t = Tokens::get(ui.ctx()); + let sel_key = egui::Id::unique(("fx-gradient-selection", effect.to_string())); + let drag_key = egui::Id::unique(("fx-gradient-drag", effect.to_string())); + // Presets to start from. + ui.horizontal(|ui| { + ui.label(RichText::new(tl!(label)).color(t.text_dim)); + let names: Vec<(String, String)> = + app.session.presets.gradients.iter().flat_map(|g| g.items.iter()).map(|p| (p.name.clone(), p.name.clone())).collect(); + let mut cur = String::new(); + let mut opts: Vec<(String, &str)> = vec![(String::new(), "Custom")]; + opts.extend(names.iter().map(|(id, n)| (id.clone(), n.as_str()))); + if widgets::dropdown(ui, &format!("fx-gradient-preset-{effect}"), "Gradient preset", &mut cur, &opts, 180.0) && !cur.is_empty() { + apply_preset(app, f, effect, disp, &cur); + ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + } + }); + let fill = fill_of(disp); + let Fill::Gradient { stops, .. } = &fill else { return }; + let w = ui.available_width().max(120.0); + let (outer, resp) = ui.allocate_exact_size(vec2(w, 46.0), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Gradient stops"))); + let bar = Rect::from_min_max(pos2(outer.left() + 8.0, outer.top() + 2.0), pos2(outer.right() - 8.0, outer.top() + 24.0)); + ui.data_mut(|d| d.insert_temp(bar_id(), (effect.to_string(), bar))); + let x_of = |loc: f32| bar.left() + loc.clamp(0.0, 1.0) * bar.width(); + let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0); + if let Some(ramp) = gf::Ramp::new(&fill) { + crate::gradient_ui::paint_ramp(ui.painter(), bar, |u| ramp.sample(u)); + } + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + let selected: usize = ui.data(|d| d.get_temp(sel_key)).unwrap_or(0).min(stops.len().saturating_sub(1)); + // Markers: a triangle on the bar's lower edge over a chip of the stop's colour. + let hit = |p: egui::Pos2| -> Option { + if p.y < bar.bottom() - 2.0 || p.y > outer.bottom() + 4.0 { + return None; + } + stops.iter().enumerate().rev().find(|(_, s)| (x_of(s.0) - p.x).abs() <= 7.0).map(|(i, _)| i) + }; + for (i, (loc, c)) in stops.iter().enumerate() { + let x = x_of(*loc); + let tri = vec![pos2(x, bar.bottom() + 1.0), pos2(x - 5.0, bar.bottom() + 7.0), pos2(x + 5.0, bar.bottom() + 7.0)]; + let ink = if i == selected { t.accent } else { t.text_dim }; + ui.painter().add(egui::Shape::convex_polygon(tri, ink, Stroke::NONE)); + let chip = Rect::from_min_size(pos2(x - 5.0, bar.bottom() + 7.0), vec2(10.0, 10.0)); + let [r, g, b] = c.to_rgb(); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + ui.painter().rect_filled(chip, 0.0, Color32::from_rgb(q(r), q(g), q(b))); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(if i == selected { 1.5 } else { 1.0 }, ink), StrokeKind::Outside); + } + // Pointer: press on a marker selects it and starts a drag; a drag moves it, and a drag that + // leaves the strip deletes it on release (two stops always stay); a click under the bar with + // no marker adds a stop; a double-click on a marker opens the Color Picker. + if resp.drag_started() + && let Some(p) = ui.input(|i| i.pointer.press_origin()) + { + if let Some(i) = hit(p) { + ui.data_mut(|d| d.insert_temp(drag_key, i)); + ui.data_mut(|d| d.insert_temp(sel_key, i)); + } else { + ui.data_mut(|d| d.remove::(drag_key)); + } + } + let dragging: Option = ui.data(|d| d.get_temp(drag_key)); + if let (Some(i), Some(p)) = (dragging, resp.interact_pointer_pos()) { + let away = p.y > outer.bottom() + 24.0 || p.y < bar.top() - 24.0; + if resp.drag_stopped() { + ui.data_mut(|d| d.remove::(drag_key)); + if away && stops.len() > 2 { + edit(app, f, effect, disp, json!({"action": "delete", "index": i})); + ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + } + } else if resp.dragged() && !away { + let loc = loc_of(p.x); + if (loc - stops.get(i).map_or(loc, |s| s.0)).abs() > 1e-4 + && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": i, "location": loc})) + { + ui.data_mut(|d| d.insert_temp(drag_key, at)); + ui.data_mut(|d| d.insert_temp(sel_key, at)); + } + } + } else if resp.drag_stopped() { + ui.data_mut(|d| d.remove::(drag_key)); + } + if resp.double_clicked() + && let Some(p) = resp.interact_pointer_pos() + && let Some(i) = hit(p) + { + ui.data_mut(|d| d.insert_temp(sel_key, i)); + f.insert(color_picker::REQUEST.into(), json!({"effect": effect, "field": KEY, "stop": i})); + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + match hit(p) { + Some(i) => { + ui.data_mut(|d| d.insert_temp(sel_key, i)); + } + None if p.x >= bar.left() && p.x <= bar.right() => { + if let Some(at) = edit(app, f, effect, disp, json!({"action": "add", "location": loc_of(p.x)})) { + ui.data_mut(|d| d.insert_temp(sel_key, at)); + } + } + None => {} + } + } + // The selected stop. + let fill = fill_of(disp); + let Fill::Gradient { stops, .. } = &fill else { return }; + let selected = selected.min(stops.len().saturating_sub(1)); + let Some((loc, color)) = stops.get(selected).copied() else { return }; + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Color")).color(t.text_dim)); + let [r, g, b] = color.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8); + let swatch = widgets::color_swatch(ui, Color32::from_rgb(r, g, b)).on_hover_text(tl!("Color Picker (Layer Style Color)")); + if swatch.clicked() { + f.insert(color_picker::REQUEST.into(), json!({"effect": effect, "field": KEY, "stop": selected})); + } + ui.add_space(8.0); + ui.label(RichText::new(tl!("Location")).color(t.text_dim)); + let mut pct = loc * 100.0; + if widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 58.0).changed() + && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": selected, "location": pct / 100.0})) + { + ui.data_mut(|d| d.insert_temp(sel_key, at)); + } + ui.add_space(8.0); + if ui.add_enabled(stops.len() > 2, egui::Button::new(tl!("Delete"))).clicked() { + edit(app, f, effect, disp, json!({"action": "delete", "index": selected})); + ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + + fn dialog_app() -> (PhotocraftApp, u64) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let parent = crate::layer_style::open(&mut app, Some("gradientOverlay")).unwrap(); + (app, parent) + } + + /// The dialog body drawn from the real dialog's fields, edits written back to it. + fn harness(app: PhotocraftApp, parent: u64) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + let Some(mut f) = app.ui.dialogs.iter().find(|d| d.id == parent).map(|d| d.fields.clone()) else { return }; + crate::layer_style::body(app, ui, &mut f); + if let Some(d) = app.ui.dialog_mut(parent) { + d.fields = f; + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run_steps(3); + h + } + + fn stops_in(app: &PhotocraftApp, parent: u64) -> Vec<(f32, Color)> { + let f = &app.ui.dialogs.iter().find(|d| d.id == parent).unwrap().fields; + let e = crate::layer_style::effects_of(f).iter().find(|e| e["kind"] == "gradientOverlay").unwrap(); + stops_of(e["params"].get(KEY)).unwrap() + } + + fn press(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, p: egui::Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + } + fn release(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, p: egui::Pos2) { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + } + + /// Photoshop's Gradient Editor gestures on the page: a click under the ramp adds a stop with + /// the colour there, a drag moves one, a drag away from the strip deletes it, two stay. + #[test] + fn ramp_adds_moves_and_deletes_stops() { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + assert!(h.query_by_label("Gradient stops").is_some()); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + // Add in the middle: a mid grey. + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + press(&mut h, mid); + release(&mut h, mid); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.len(), 3, "{stops:?}"); + assert!((stops[1].0 - 0.5).abs() < 0.02 && (stops[1].1.to_rgb()[0] - 0.5).abs() < 0.06, "{stops:?}"); + // Drag it to a quarter. + let quarter = pos2(bar.left() + bar.width() * 0.25, mid.y); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x - 10.0, mid.y))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(quarter)); + h.run_steps(1); + release(&mut h, quarter); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.len(), 3); + assert!((stops[1].0 - 0.25).abs() < 0.03, "{stops:?}"); + // Drag it away: gone. + let away = pos2(quarter.x, bar.bottom() + 90.0); + press(&mut h, quarter); + h.event(egui::Event::PointerMoved(pos2(quarter.x, quarter.y + 20.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + release(&mut h, away); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.len(), 2, "{stops:?}"); + // The last two can't be dragged away. + let first = pos2(bar.left(), mid.y); + press(&mut h, first); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + release(&mut h, away); + assert_eq!(stops_in(h.state(), parent).len(), 2); + } + + /// A preset from the Gradients panel becomes the page's stops; the Delete button and the + /// Location field work on the selected stop. + #[test] + fn presets_delete_and_location() { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + let name = app + .session + .presets + .gradients + .iter() + .flat_map(|g| g.items.iter()) + .map(|p| p.name.clone()) + .find(|n| n.contains("Spectrum") || n.contains("Rainbow")) + .or_else(|| app.session.presets.gradients.first().and_then(|g| g.items.first()).map(|p| p.name.clone())) + .unwrap(); + let mut f = app.ui.dialog_mut(parent).unwrap().fields.clone(); + let mut disp = json!({}); + assert!(apply_preset(&app, &mut f, &effect, &mut disp, &name), "{name}"); + assert!(!apply_preset(&app, &mut f, &effect, &mut disp, "no such preset")); + let stops = stops_of(disp.get(KEY)).unwrap(); + assert!(stops.len() >= 2, "{name}: {stops:?}"); + app.ui.dialog_mut(parent).unwrap().fields = f; + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#ff0000"], [0.5, "#00ff00"], [1.0, "#0000ff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).unwrap(); + // Select the middle stop, then Delete. + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + press(&mut h, mid); + release(&mut h, mid); + h.get_by_label("Delete").click(); + h.run_steps(2); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.iter().map(|s| super::super::hex(&s.1)).collect::>(), ["#ff0000", "#0000ff"], "{stops:?}"); + } + + #[test] + fn stops_round_trip_through_the_field_grammar() { + let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.5, Color::rgb(0.0, 1.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))]; + let v = stops_json(&stops); + assert_eq!(v, json!([[0.0, "#ff0000"], [0.5, "#00ff00"], [1.0, "#0000ff"]])); + assert_eq!(stops_of(Some(&v)), Some(stops)); + // Unsorted input is sorted, bad entries are skipped, fewer than two is nothing. + let v = json!([[1.0, "#ffffff"], ["x", "#ff0000"], [0.25, "#000000"], [0.5, 7]]); + assert_eq!(stops_of(Some(&v)).map(|s| s.iter().map(|x| x.0).collect::>()), Some(vec![0.25, 1.0])); + assert_eq!(stops_of(Some(&json!([[0.0, "#000000"]]))), None); + assert_eq!(stops_of(Some(&json!("red"))), None); + assert_eq!(stops_of(None), None); + let os = vec![(0.0, 1.0), (1.0, 0.5)]; + assert_eq!(opacity_json(&os), json!([[0.0, 100.0], [1.0, 50.0]])); + assert_eq!(opacity_of(Some(&opacity_json(&os))), Some(os)); + assert_eq!(opacity_of(Some(&json!([]))), Some(vec![]), "[] is opaque"); + assert_eq!(opacity_of(Some(&json!([[0.0, 100.0]]))), None, "one stop is not a list"); + } + + #[test] + fn factory_from_to_become_two_stops() { + let mut p = json!({"from": "#ff0000", "to": "#0000ff", "angle": 90}); + from_to_to_stops(&mut p); + assert_eq!(p, json!({"stops": [[0.0, "#ff0000"], [1.0, "#0000ff"]], "opacityStops": [], "angle": 90})); + let mut p = json!({"stops": [[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]], "from": "#123456"}); + from_to_to_stops(&mut p); + assert_eq!(p["stops"].as_array().map(Vec::len), Some(3), "explicit stops win over From/To"); + assert!(p.get("from").is_none()); + } + + #[test] + fn the_fill_of_a_page_never_fails() { + for v in [json!({}), json!({"stops": "x"}), json!({"stops": [[0.0, "#ff0000"]], "opacityStops": [[0.5, 20.0]]}), json!({"stops": null})] { + let Fill::Gradient { stops, opacity_stops, .. } = fill_of(&v) else { panic!("{v}") }; + assert_eq!(stops.len(), 2, "{v}: the default black-to-white"); + assert!(opacity_stops.is_empty() || opacity_stops.len() >= 2, "{v}"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_method_tests.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_method_tests.rs index 2578c1ab51..74b4e6b91b 100644 --- a/crates/ui-egui/src/dialogs/layer_style/gradient_method_tests.rs +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_method_tests.rs @@ -36,7 +36,7 @@ fn method_picker_is_pending_until_confirm_and_survives_reopening() { ); PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); harness.run_steps(3); - harness.get_by_value("Custom").click(); + harness.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Method").click(); harness.run_steps(2); harness.get_by_label("Smooth").click(); harness.run_steps(2); @@ -51,7 +51,7 @@ fn method_picker_is_pending_until_confirm_and_survives_reopening() { assert_eq!(effect(app), &saved); let reopened = fields(app); assert_eq!(reopened["effects"][0]["params"]["method"], "smooth"); - assert_eq!(reopened["effects"][0]["params"]["from"], "#ff0000"); + assert_eq!(reopened["effects"][0]["params"]["stops"][0][1], "#ff0000"); confirm(app, &reopened).unwrap(); assert_eq!(effect(app), &saved, "OK without edits leaves the rendered curve exact"); } diff --git a/crates/ui-egui/src/dialogs/layer_style/tests.rs b/crates/ui-egui/src/dialogs/layer_style/tests.rs index 7fe1b40c49..ab221b53b1 100644 --- a/crates/ui-egui/src/dialogs/layer_style/tests.rs +++ b/crates/ui-egui/src/dialogs/layer_style/tests.rs @@ -70,7 +70,7 @@ fn every_effect_row_shows_a_checkbox() { return; } let mut f = fields.clone(); - body(app, ui, &mut f); + body_with_response(app, ui, &mut f, false); }, app, ); @@ -103,7 +103,7 @@ fn default_buttons_wrap_inside_the_page_column_with_long_labels() { app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); let fields = initial_fields(&l, Some("dropShadow"), 120.0); - let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(660.0, 700.0)).build_ui_state( move |ui, app: &mut PhotocraftApp| { if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { return; @@ -148,6 +148,30 @@ fn effect_row_of_another_layer_opens_that_layers_style() { assert!(fx[0]["fx"].is_object(), "the existing drop shadow, not a new instance"); } +/// Make Default (#9477): a stroke added afterwards, by opening the dialog on it or with the +/// list's checkbox or +, starts from the saved values; other kinds keep the factory ones. +#[test] +fn new_effects_start_from_the_made_default() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": {"size": 7, "position": "outside"}})).unwrap(); + + let id = open(&mut app, Some("stroke")).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + add_instance(&mut f, "stroke"); + add_instance(&mut f, "dropShadow"); + let fx = effects_of(&f); + let strokes: Vec<_> = fx.iter().filter(|e| e["kind"] == json!("stroke")).collect(); + assert_eq!(strokes.len(), 2); + for e in strokes { + assert_eq!((&e["params"]["size"], &e["params"]["position"]), (&json!(7), &json!("outside"))); + assert_eq!(e["params"]["opacity"], defaults("stroke")["opacity"], "unsaved keys keep the factory value"); + } + let shadow = fx.iter().find(|e| e["kind"] == json!("dropShadow")).unwrap(); + assert_eq!(shadow["params"]["size"], defaults("dropShadow")["size"]); +} + #[test] fn every_kind_has_spec_and_defaults() { for &(kind, _) in KINDS { @@ -252,6 +276,32 @@ fn add_and_remove_instances() { assert_eq!(strokes[0].size, 3.0); } +/// The list bar's arrows move an instance among those of its kind only, and stop at the ends. +#[test] +fn move_instance_reorders_within_its_kind() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap(); + let st = s.active().unwrap(); + let mut f = initial_fields(st.doc.layer(st.active_layer.unwrap()).unwrap(), None, 120.0); + add_instance(&mut f, "dropShadow"); + add_instance(&mut f, "stroke"); + assert_eq!(ids(&f), ["fx1", "fx3", "fx2"], "a new stroke goes after the last stroke"); + move_instance(&mut f, "fx3", -1); + assert_eq!(ids(&f), ["fx3", "fx1", "fx2"]); + move_instance(&mut f, "fx3", -1); + assert_eq!(ids(&f), ["fx3", "fx1", "fx2"], "already the first stroke"); + move_instance(&mut f, "fx2", 1); + assert_eq!(ids(&f), ["fx3", "fx1", "fx2"], "the only drop shadow stays put"); + for (id, by) in [("fx1", isize::MAX), ("fx1", isize::MIN), ("nope", 1)] { + move_instance(&mut f, id, by); + } + assert_eq!(ids(&f), ["fx3", "fx1", "fx2"], "out-of-range moves and unknown ids change nothing"); +} + +fn ids(f: &Map) -> Vec<&str> { + effects_of(f).iter().filter_map(|e| e.get("id").and_then(Value::as_str)).collect() +} + #[test] fn disabled_instances_are_kept() { let mut s = session(); @@ -269,6 +319,49 @@ fn disabled_instances_are_kept() { assert!(!items[0].enabled()); } +/// The preview swatch shows the dialog's pending style, built away from the document. +#[test] +fn pending_style_is_the_dialogs_and_leaves_the_document_alone() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let mut f = initial_fields(app.session.active().unwrap().doc.layer(id).unwrap(), Some("stroke"), 120.0); + set_param(&mut f, "fx1", "size", json!(9)); + let style = pending_style(&app, &f).expect("the pending style builds"); + let sizes: Vec = style + .effects + .iter() + .filter_map(|e| match e { + Effect::Stroke(s) => Some(s.size), + _ => None, + }) + .collect(); + assert_eq!(sizes, [9.0]); + assert!(app.session.active().unwrap().doc.layer(id).unwrap().effects.items.is_empty(), "the document is untouched"); + let rgba = photocraft_engine::presets::styles::thumbnail(&app.session, &style, 32); + assert_eq!(rgba.len(), 32 * 32 * 4); +} + +/// The fx menu adds another instance of a kind that takes several, and turns on and shows the +/// layer's one of a kind that takes one. +#[test] +fn fx_menu_adds_or_shows_an_effect() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap(); + s.execute("layer.layerStyle.satin", json!({"enabled": false})).unwrap(); + let st = s.active().unwrap(); + let mut f = initial_fields(st.doc.layer(st.active_layer.unwrap()).unwrap(), None, 120.0); + let before = effects_of(&f).len(); + pick_from_fx_menu(&mut f, "stroke"); + assert_eq!(effects_of(&f).len(), before + 1, "a second stroke"); + let satin = effects_of(&f).iter().find(|e| e["kind"] == "satin").and_then(|e| e["id"].as_str()).unwrap().to_string(); + pick_from_fx_menu(&mut f, "satin"); + assert_eq!(effects_of(&f).len(), before + 1, "no second satin"); + assert_eq!(f["selected"], json!(satin)); + assert_eq!(entry(&f, &satin).unwrap()["on"], json!(true)); +} + #[test] fn baked_gradient_overlay_keeps_endpoint_colours_on_reopen() { let mut s = session(); @@ -280,8 +373,9 @@ fn baked_gradient_overlay_keeps_endpoint_colours_on_reopen() { 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")); + let source_stops = gradient_field::stops_of(params.get("stops")).expect("complete gradient stops"); + assert_eq!(source_stops.first().unwrap().1, photocraft_color::Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(source_stops.last().unwrap().1, photocraft_color::Color::rgb(0.0, 0.0, 1.0)); 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); @@ -398,7 +492,9 @@ fn body_keeps_invalid_params_and_renders_without_panicking() { fields.insert("selected".into(), json!("fx1")); fields.insert("effects".into(), json!([{"id": "fx1", "kind": "dropShadow", "on": true, "params": bad.clone()}])); - let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut fields)); + let mut out = ctx.run_ui(Default::default(), |ui| { + body_with_response(&mut app, ui, &mut fields, false); + }); out.textures_delta.clear(); assert_eq!(entry(&fields, "fx1").unwrap()["params"], bad); @@ -408,7 +504,9 @@ fn body_keeps_invalid_params_and_renders_without_panicking() { fields.insert("selected".into(), json!(BLENDING)); fields.insert("p:blendingOptions".into(), bad.clone()); fields.insert("effects".into(), bad.clone()); - let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut fields)); + let mut out = ctx.run_ui(Default::default(), |ui| { + body_with_response(&mut app, ui, &mut fields, false); + }); out.textures_delta.clear(); assert_eq!(fields.get("p:blendingOptions"), Some(&bad)); } @@ -435,7 +533,9 @@ fn blending_page_renders_for_every_mode_and_odd_fields() { { let mut f = initial_fields(&layer, Some(BLENDING), 120.0); set_blending_fields(&mut f, &layer, mode); - let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f)); + let mut out = ctx.run_ui(Default::default(), |ui| { + body_with_response(&mut app, ui, &mut f, false); + }); out.textures_delta.clear(); assert_eq!(f["p:blendingOptions"]["knockout"], "deep", "{mode:?}"); // Hostile dialog state (only reachable through `ui.dialog.set`) still renders. @@ -444,7 +544,9 @@ fn blending_page_renders_for_every_mode_and_odd_fields() { bo["channels"] = json!("x"); bo["blendIf"] = json!([{"thisLayer": [300, -5, "a"]}]); f.insert("channelNames".into(), json!([])); - let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f)); + let mut out = ctx.run_ui(Default::default(), |ui| { + body_with_response(&mut app, ui, &mut f, false); + }); out.textures_delta.clear(); } } @@ -515,6 +617,39 @@ fn blend_if_values_stay_ordered() { assert_eq!(blend_quad(Some(&json!(["x", null]))), [0, 0, 255, 255]); } +#[test] +fn blend_if_alt_split_and_drag_behavior() { + // Case 1: Alt-drag on unsplit black handle [0, 0, 255, 255] splits the right half + let mut mode = BlendDrag::Pair { pair: 0, origin_v: 0 }; + let q = [0, 0, 255, 255]; + let q1 = step_blend_drag(&mut mode, &q, 45, true); + assert_eq!(q1, [0, 45, 255, 255], "Alt-drag right must split black triangle into [0, 45]"); + + // Case 2: Moving the split right half further to 70 + let q2 = step_blend_drag(&mut mode, &q1, 70, false); + assert_eq!(q2, [0, 70, 255, 255]); + + // Case 3: Dragging the split half back to 0 merges the handle + let q3 = step_blend_drag(&mut mode, &q2, 0, false); + assert_eq!(q3, [0, 0, 255, 255], "Dragging back to origin reunites the triangle"); + + // Case 4: Alt-drag on unsplit white handle [0, 0, 255, 255] splits the left half + let mut mode_w = BlendDrag::Pair { pair: 2, origin_v: 255 }; + let qw1 = step_blend_drag(&mut mode_w, &q, 200, true); + assert_eq!(qw1, [0, 0, 200, 255], "Alt-drag left must split white triangle into [200, 255]"); + + // Case 5: Dragging without Alt moves both halves together + let mut mode_solid = BlendDrag::Pair { pair: 0, origin_v: 0 }; + let qs1 = step_blend_drag(&mut mode_solid, &q, 35, false); + assert_eq!(qs1, [35, 35, 255, 255], "Drag without Alt moves solid handle together"); + + // Case 6: Clamping prevents black handle from crossing white handle + let q_close = [0, 100, 150, 255]; + let mut mode_single = BlendDrag::Single(1); + let q_clamped = step_blend_drag(&mut mode_single, &q_close, 180, false); + assert_eq!(q_clamped, [0, 150, 150, 255], "Black max cannot exceed white min"); +} + #[test] fn contour_picker_and_editor_edit_the_effect_curve() { let mut s = session(); diff --git a/crates/ui-egui/src/dialogs/liquify_ui.rs b/crates/ui-egui/src/dialogs/liquify_ui.rs index 29536064a5..5a6725bcc5 100644 --- a/crates/ui-egui/src/dialogs/liquify_ui.rs +++ b/crates/ui-egui/src/dialogs/liquify_ui.rs @@ -27,6 +27,68 @@ const LEFT_W: f32 = 48.0; const RIGHT_W: f32 = 292.0; const REDO_STACK_LIMIT: usize = 100; +/// Native component sampling keeps the other components and opacity at the destination. Work +/// stays bounded by the dirty proxy/viewport rectangle; a dab never scans the full source. +#[allow(clippy::too_many_arguments)] +fn render_components( + source: &Surface, + source_bounds: Rect, + field: &LiquifyField, + scale: f64, + target: &Value, + bounds: Rect, + dirty: Rect, + out: &mut [[u8; 4]], +) -> bool { + let Ok(Some(colors)) = photocraft_engine::color_channels::explicit(&json!({"target":target}), source.format().mode.color_channels()) else { return false }; + let width = bounds.width() as usize; + if source_bounds.is_empty() || bounds.is_empty() || !scale.is_finite() || scale <= 0.0 || width.checked_mul(bounds.height() as usize) != Some(out.len()) { + return false; + } + let fmt = source.format(); + let mut native = vec![0.0; fmt.channels()]; + let dirty = dirty.intersect(&bounds); + for y in dirty.y0..dirty.y1 { + for x in dirty.x0..dirty.x1 { + let px = f64::from(field.bounds.x0) + (f64::from(x.saturating_sub(source_bounds.x0)) + 0.5) * scale; + let py = f64::from(field.bounds.y0) + (f64::from(y.saturating_sub(source_bounds.y0)) + 0.5) * scale; + let displacement = field.sample(px, py); + let sx = (f64::from(x) + displacement[0] / scale).clamp(f64::from(source_bounds.x0), f64::from(source_bounds.x1 - 1)); + let sy = (f64::from(y) + displacement[1] / scale).clamp(f64::from(source_bounds.y0), f64::from(source_bounds.y1 - 1)); + let (x0, y0) = (sx.floor() as i32, sy.floor() as i32); + let (fx, fy) = ((sx - f64::from(x0)) as f32, (sy - f64::from(y0)) as f32); + let taps = [ + (x0, y0, (1.0 - fx) * (1.0 - fy)), + (x0.saturating_add(1).min(source_bounds.x1 - 1), y0, fx * (1.0 - fy)), + (x0, y0.saturating_add(1).min(source_bounds.y1 - 1), (1.0 - fx) * fy), + (x0.saturating_add(1).min(source_bounds.x1 - 1), y0.saturating_add(1).min(source_bounds.y1 - 1), fx * fy), + ]; + let alpha_index = fmt.mode.color_channels(); + let alpha = |tx, ty| if fmt.alpha { source.sample_channel(tx, ty, alpha_index) } else { 1.0 }; + let warped_alpha: f32 = taps.iter().map(|(tx, ty, w)| alpha(*tx, *ty) * w).sum(); + for (channel, value) in native.iter_mut().enumerate() { + *value = if colors.contains(channel) { + if warped_alpha > 0.0 { + taps.iter().map(|(tx, ty, w)| source.sample_channel(*tx, *ty, channel) * alpha(*tx, *ty) * w).sum::() / warped_alpha + } else { + 0.0 + } + } else { + source.sample_channel(x, y, channel) + }; + } + let rgba = photocraft_raster::to_rgba(&fmt, &native); + let a = rgba[3].clamp(0.0, 1.0); + let encoded = [rgba[0] * a, rgba[1] * a, rgba[2] * a, a].map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8); + let index = (y - bounds.y0) as usize * width + (x - bounds.x0) as usize; + if let Some(pixel) = out.get_mut(index) { + *pixel = encoded; + } + } + } + true +} + /// Options shown in the properties panel (and settable over the control channel). #[derive(Clone, Debug)] pub struct LiquifyOpts { @@ -127,7 +189,12 @@ impl LiquifyOpts { }; } if let Some(t) = v.get("tool") { - self.tool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?; + let tool: LiquifyTool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?; + // Strokes can twirl counterclockwise, but no tool does: hold Alt with Twirl Clockwise. + if !LiquifyTool::ALL.contains(&tool) { + return Err(format!("{t} is not a Liquify tool: choose twirlCw and hold Alt (Option) to twirl counterclockwise")); + } + self.tool = tool; } Ok(()) } @@ -168,6 +235,8 @@ pub struct LiquifyDialog { lasso: Option<(bool, Vec<[f64; 2]>)>, proxy: ProxyImage, source: Surface, + target: Value, + native_proxy: Option, out: Vec<[u8; 4]>, tex: Option, /// Proxy pixels to re-render and upload (x0, y0, x1, y1). @@ -369,7 +438,13 @@ impl LiquifyDialog { /// Re-renders the dirty proxy pixels into `out` (the texture upload happens in `show`). fn render_dirty(&mut self) { if let Some(r) = self.dirty { - self.proxy.render(&self.field, r, &mut self.out); + if let Some(source) = &self.native_proxy { + let bounds = Rect::new(0, 0, self.proxy.w as i32, self.proxy.h as i32); + let dirty = Rect::new(r[0] as i32, r[1] as i32, r[2] as i32, r[3] as i32); + render_components(source, bounds, &self.field, self.proxy.scale, &self.target, bounds, dirty, &mut self.out); + } else { + self.proxy.render(&self.field, r, &mut self.out); + } } } @@ -419,7 +494,11 @@ impl LiquifyDialog { return; } let started = crate::gpu_canvas::now_ms(); - let rendered = ProxyImage::render_full_resolution(&self.source, &self.field, bounds, dirty, &mut self.view_out, &mut self.view_source_scratch); + let rendered = if self.native_proxy.is_some() { + render_components(&self.source, self.canvas, &self.field, 1.0, &self.target, bounds, dirty, &mut self.view_out) + } else { + ProxyImage::render_full_resolution(&self.source, &self.field, bounds, dirty, &mut self.view_out, &mut self.view_source_scratch) + }; if !rendered { self.view_bounds = None; self.view_tex = None; @@ -456,9 +535,12 @@ impl LiquifyDialog { fn upload(&mut self, ctx: &egui::Context) { let (w, h) = (self.proxy.w, self.proxy.h); + if self.tex.is_none() { + self.dirty = Some([0, 0, w, h]); + self.render_dirty(); + } match (&mut self.tex, self.dirty.take()) { (None, _) => { - self.proxy.render(&self.field, [0, 0, w, h], &mut self.out); let img = egui::ColorImage::new([w, h], self.out.iter().map(|p| Color32::from_rgba_premultiplied(p[0], p[1], p[2], p[3])).collect()); self.tex = Some(ctx.load_texture("liquify-proxy", img, egui::TextureOptions::LINEAR)); } @@ -491,7 +573,7 @@ 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 (layer, surf, _) = crate::distort_ui::active_filter_target(app)?; let canvas = app.session.active().ok_or("no document")?.doc.bounds(); open_on(app, ctx, layer, surf, Vec::new(), auto_cell(canvas), None) } @@ -529,6 +611,9 @@ fn open_on( let canvas = st.doc.bounds(); let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); let proxy = ProxyImage::new(&surf, canvas, PROXY_SIDE); + let target = if smart_filter.is_some() { json!("pixels") } else { crate::distort_ui::colour_set_target(app) }; + let colors = photocraft_engine::color_channels::explicit(&json!({"target":target}), surf.format().mode.color_channels()).map_err(|e| e.to_string())?; + let native_proxy = colors.map(|_| crate::gallery_ui::reduce(&surf, canvas, proxy.scale as u32)); 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]; let mut d = LiquifyDialog { @@ -544,6 +629,8 @@ fn open_on( lasso: None, proxy, source: surf, + target, + native_proxy, out, tex: None, dirty: None, @@ -607,7 +694,7 @@ pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> { if d.strokes.is_empty() { return Ok(()); } - let p = json!({"strokes": d.strokes, "meshSize": d.cell, "layer": d.layer.0}); + let p = crate::distort_ui::with_filter_target(json!({"strokes": d.strokes, "meshSize": d.cell, "layer": d.layer.0}), &d.target); app.run("filter.liquify", p).map(|_| ()) } @@ -653,8 +740,9 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { Ok(d.describe()) } -/// Photoshop's Alt (Option) variant of a Liquify brush: the twirls reverse direction and Pucker -/// and Bloat swap. Other tools are unchanged. +/// The Alt (Option) variant of a Liquify brush: Twirl Clockwise twirls counterclockwise (the +/// reference application has no separate counterclockwise tool) and Pucker and Bloat swap. +/// Other tools are unchanged. fn alt_tool(t: LiquifyTool) -> LiquifyTool { match t { LiquifyTool::TwirlCw => LiquifyTool::TwirlCcw, @@ -674,17 +762,17 @@ fn brush_circle_at(resize: Option<(Pos2, f32, f32)>, hover: Option) -> Opt /// 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) + if secondary { crate::brush_resize::is_right_gesture(mods) } else { crate::brush_resize::is_gesture(mods) } } /// Horizontal motion sets the diameter (the circle's edge follows the pointer). Vertical motion /// sets density, Liquify's edge falloff, over the same 200 px travel the canvas uses for hardness. -fn resized_brush(start_size: f32, start_density: f32, dx: f32, dy: f32) -> (f32, f32) { - let dx = if dx.is_finite() { dx } else { 0.0 }; - let dy = if dy.is_finite() { dy } else { 0.0 }; - let size = (start_size + dx).round().clamp(1.0, 15000.0); - let density = (start_density + dy * 100.0 / crate::brush_resize::HARDNESS_TRAVEL).clamp(0.0, 100.0); - (size, (density * 10.0).round() / 10.0) +fn resized_brush(start_size: f32, start_density: f32, dx: f32, dy: f32, zoom: f32) -> (f32, f32) { + // Liquify stores density as 0–100; the shared brush gesture works on normalized hardness. + // Use the same zoom-corrected screen movement and size limits as the main canvas. + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let (size, density) = crate::brush_resize::resized((start_size, start_density / 100.0), f64::from(dx / zoom), f64::from(dy / zoom), zoom); + (size, (density * 100.0).round()) } /// Pointer in document coordinates (from the preview or the control channel). Alt at @@ -794,7 +882,6 @@ fn tool_icon(t: LiquifyTool) -> &'static str { LiquifyTool::Reconstruct => "brush-cleaning", LiquifyTool::Smooth => "droplet", LiquifyTool::TwirlCw => "rotate-cw", - LiquifyTool::TwirlCcw => "undo-2", LiquifyTool::Pucker => "scan", LiquifyTool::Bloat => "maximize-2", LiquifyTool::PushLeft => "chevrons-left", @@ -1152,9 +1239,27 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // 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 center = d.brush_resize.map_or(hp, |(anchor, _, _)| anchor); let r = d.opts.size * 0.5 * d.zoom; - clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); - clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + if d.brush_resize.is_some() { + crate::brush_resize::liquify_tip_preview( + &clip, + center, + r, + d.opts.density / 100.0, + Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110), + ); + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + crate::canvas::draw_readout( + ui.ctx(), + "liquify-brush-resize-readout", + hp, + [tl!("Diameter:"), tl!("Density:")], + [format!("{} px", d.opts.size.round() as i64), format!("{}%", d.opts.density.round() as i64)], + ); + } + clip.circle_stroke(center, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(center, r, Stroke::new(0.75, Color32::WHITE)); } d.hover = Some(to_doc(d, area, hp)); } @@ -1175,13 +1280,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } if let (Some((anchor, size0, dens0)), Some(p)) = (d.brush_resize, resp.interact_pointer_pos()) { let delta = p - anchor; - let (size, density) = resized_brush(size0, dens0, delta.x, delta.y); + let (size, density) = resized_brush(size0, dens0, delta.x, delta.y, d.zoom); d.opts.size = size; d.opts.density = density; } if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { d.brush_resize = None; } + ctx.request_repaint(); } else if pan && resp.dragged() { let dl = resp.drag_delta(); d.center = [d.center[0] - f64::from(dl.x / d.zoom), d.center[1] - f64::from(dl.y / d.zoom)]; diff --git a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs index 9b50c50677..546cd00f91 100644 --- a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs @@ -15,10 +15,13 @@ fn alt_right_drag_resizes_the_liquify_brush_and_does_not_paint() { assert!(brush_resize_gesture(egui::Modifiers::CTRL | egui::Modifiers::ALT, false)); assert!(!brush_resize_gesture(egui::Modifiers::ALT, false), "Alt+left stays the tool's opposite"); assert!(!brush_resize_gesture(egui::Modifiers::NONE, true)); - let (size, density) = resized_brush(40.0, 50.0, 25.0, crate::brush_resize::HARDNESS_TRAVEL); - assert_eq!((size, density), (65.0, 100.0)); - let (size, density) = resized_brush(40.0, 50.0, -100.0, 0.0); + let (size, density) = resized_brush(40.0, 50.0, 25.0, crate::brush_resize::HARDNESS_TRAVEL, 1.0); + assert_eq!((size, density), (90.0, 100.0), "native brush movement grows the radius by screen pixels"); + let (size, density) = resized_brush(40.0, 50.0, -100.0, 0.0, 1.0); assert_eq!((size, density), (1.0, 50.0)); + // Screen travel changes density identically at different preview zoom levels. + assert_eq!(resized_brush(40.0, 50.0, 25.0, 100.0, 2.0).1, 100.0); + assert_eq!(resized_brush(40.0, 50.0, 0.0, -100.0, 1.0).1, 0.0); } fn app_with_layer() -> PhotocraftApp { @@ -41,6 +44,50 @@ fn app_with_layer() -> PhotocraftApp { app } +#[test] +fn native_component_liquify_preserves_proxy_detail_and_captured_commit_targets() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for alpha in [true, false] { + let fixture = crate::distort_ui::component_tests::app(mode, depth); + let mut app = if alpha { fixture } else { crate::distort_ui::component_tests::without_alpha(fixture) }; + open(&mut app, &egui::Context::default()).unwrap(); + let d = app.distort.liquify.as_mut().unwrap(); + let layer = d.layer; + let before = d.source.clone(); + let bounds = d.canvas; + let mut field = LiquifyField::new(bounds, 8.0); + field.d.fill([2.0, 0.0]); + let mut out = vec![[0; 4]; 64 * 48]; + assert!(render_components(&before, bounds, &field, 1.0, &d.target, bounds, bounds, &mut out)); + for y in 0..48 { + for x in 0..64 { + let mut native = before.pixel(x, y); + for k in [0, 2] { + native[k] = before.sample_channel((x + 2).min(63), y, k); + } + let rgba = photocraft_raster::to_rgba(&before.format(), &native); + let expected = [rgba[0] * rgba[3], rgba[1] * rgba[3], rgba[2] * rgba[3], rgba[3]].map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8); + assert!(out[(y * 64 + x) as usize].iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1), "{mode}@{depth}"); + } + } + d.strokes + .push(LiquifyStroke { points: vec![vec![24.0, 24.0, 1.0], vec![34.0, 24.0, 1.0]], ..LiquifyStroke::new(LiquifyTool::ForwardWarp, 30.0) }); + d.rebuild(); + d.render_dirty(); + let mut detail = vec![[0; 4]; 64 * 48]; + assert!(render_components(&before, bounds, &d.field, 1.0, &d.target, bounds, bounds, &mut detail)); + assert_eq!(d.out, detail, "proxy at 100% equals the detail path"); + app.run("channel.target", json!({"channel":{"color":1}})).unwrap(); + commit(&mut app).unwrap(); + let after = app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap(); + crate::distort_ui::component_tests::protected(&before, after, bounds); + assert_ne!(after.read_region(bounds), before.read_region(bounds)); + } + } + } +} + #[test] fn zoomed_liquify_preview_uses_only_the_visible_full_resolution_crop() { let ctx = egui::Context::default(); @@ -151,6 +198,60 @@ fn brush_settings_are_remembered_between_uses() { assert!(open(&mut app, &ctx).is_ok()); } +/// The reference application's Liquify has Twirl Clockwise only: no Twirl Counterclockwise +/// tool on the strip or a shortcut, and a script or saved setting naming it is refused with a +/// clear error instead of selecting it (Alt with Twirl Clockwise twirls counterclockwise). +#[test] +fn the_tool_strip_has_no_twirl_counterclockwise() { + assert!(!LiquifyTool::ALL.contains(&LiquifyTool::TwirlCcw)); + assert!(LiquifyTool::ALL.contains(&LiquifyTool::TwirlCw)); + for t in LiquifyTool::ALL { + assert_ne!(tl!(t.label()), tl!("Twirl Counterclockwise"), "{t:?}"); + let svg = crate::icon_data::ICONS.iter().any(|(n, _)| *n == tool_icon(t)); + assert!(svg, "{t:?}: {}", tool_icon(t)); + } + assert_eq!(shortcut(LiquifyTool::TwirlCw), "C"); + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "pucker"})).unwrap(); + let err = control(&mut app, &json!({"tool": "twirlCcw"})).unwrap_err(); + assert!(err.contains("twirlCw") && err.contains("Alt"), "{err}"); + assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays"); + // A preference saved by an older version that names it opens, and on another tool. + cancel(&mut app); + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"tool": "twirlCcw", "density": 70}))); + open(&mut app, &ctx).unwrap(); + let o = &app.distort.liquify.as_ref().unwrap().opts; + assert_ne!(o.tool, LiquifyTool::TwirlCcw); + assert_eq!(o.density, 70.0, "the rest of the saved settings still apply"); +} + +/// Alt held with Twirl Clockwise twirls the other way: at the same point, the displacement a +/// dab leaves points the opposite way from one without Alt. +#[test] +fn alt_with_twirl_clockwise_twirls_counterclockwise() { + let ctx = egui::Context::default(); + let displacement = |mods: egui::Modifiers| { + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "twirlCw", "size": 40, "rate": 80})).unwrap(); + pointer(&mut app, ToolEvent::Down { x: 60.0, y: 40.0, pressure: 1.0 }, mods); + for _ in 0..4 { + pointer(&mut app, ToolEvent::Move { x: 60.0, y: 40.0, pressure: 1.0 }, mods); + } + pointer(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, mods); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.opts.tool, LiquifyTool::TwirlCw); + // Right of the centre: clockwise (y down) moves content down, so it samples from above. + d.field.sample(70.0, 40.0) + }; + let (plain, alt) = (displacement(egui::Modifiers::NONE), displacement(egui::Modifiers::ALT)); + assert!(plain[1] < -0.1, "clockwise samples from above: {plain:?}"); + assert!(alt[1] > 0.1, "Alt twirls counterclockwise and samples from below: {alt:?}"); + assert!((plain[1] + alt[1]).abs() < 0.05 * plain[1].abs(), "the same twirl, mirrored: {plain:?} vs {alt:?}"); +} + /// Follow-up to #1494 / #1484: Alt is captured at stroke-down and stays recorded /// through undo, redo and deterministic Liquify field reconstruction. #[test] @@ -358,7 +459,6 @@ fn alt_reverses_twirl_and_swaps_pucker_and_bloat() { }; for (tool, plain, alt) in [ ("twirlCw", LiquifyTool::TwirlCw, LiquifyTool::TwirlCcw), - ("twirlCcw", LiquifyTool::TwirlCcw, LiquifyTool::TwirlCw), ("pucker", LiquifyTool::Pucker, LiquifyTool::Bloat), ("bloat", LiquifyTool::Bloat, LiquifyTool::Pucker), ("forwardWarp", LiquifyTool::ForwardWarp, LiquifyTool::ForwardWarp), @@ -837,3 +937,83 @@ fn liquify_that_cannot_be_applied_reports_the_error() { assert_eq!(app.session.active().unwrap().history.past_len(), steps); assert_eq!(r, Err(app.ui.status.clone())); } + +/// Liquify, Adaptive Wide Angle and the Filter Gallery open on the layer when no colour set is +/// targeted, as before colour sets: with its mask, an alpha channel or the Quick Mask targeted. +/// OK sends the command no `target`, so it routes the targeted surface exactly as it did then. +#[test] +fn workspaces_open_on_the_layer_with_a_mask_channel_or_quick_mask_targeted() { + let setup = |case: &str| { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 16, "width": 16, "height": 16})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#0000ff"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + match case { + "mask" => drop(app.run("layer.layerMask.revealAll", json!({})).unwrap()), + "alpha" => { + app.run("channel.new", json!({"fill": "black"})).unwrap(); + app.run("channel.target", json!({"channel": 0})).unwrap(); + } + _ => drop(app.run("select.editInQuickMaskMode", json!({"on": true})).unwrap()), + } + app.sync_views(); + app + }; + let surfaces = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + ( + l.surface().cloned(), + l.mask.as_ref().map(|m| m.surface.clone()), + st.doc.channels.iter().map(|c| c.surface.clone()).collect::>(), + st.doc.quick_mask.as_ref().map(|c| c.surface.clone()), + ) + }; + let ctx = egui::Context::default(); + for case in ["mask", "alpha", "quickMask"] { + let mut app = setup(case); + assert!(crate::canvas::paint_target(&app).get("colors").is_none(), "{case}"); + crate::wide_angle_ui::open(&mut app, &ctx).unwrap_or_else(|e| panic!("{case}: Adaptive Wide Angle: {e}")); + crate::gallery_ui::open(&mut app).unwrap_or_else(|e| panic!("{case}: Filter Gallery: {e}")); + open(&mut app, &ctx).unwrap_or_else(|e| panic!("{case}: Liquify: {e}")); + let d = app.distort.liquify.as_mut().unwrap(); + let stroke = LiquifyStroke { points: vec![vec![20.0, 24.0, 1.0], vec![36.0, 24.0, 1.0]], ..LiquifyStroke::new(LiquifyTool::ForwardWarp, 30.0) }; + d.strokes.push(stroke.clone()); + let cell = d.cell; + commit(&mut app).unwrap(); + let mut before = setup(case); + let layer = before.session.active().unwrap().active_layer.unwrap(); + before.run("filter.liquify", json!({"strokes": [stroke], "meshSize": cell, "layer": layer.0})).unwrap(); + assert!(surfaces(&app) == surfaces(&before), "{case}: OK edits what the command without a target edits"); + assert!(surfaces(&app) != surfaces(&setup(case)), "{case}: the stroke changed something"); + } +} + +/// A smart object's targeted filter mask: the workspaces open on the smart object, and Liquify's +/// OK adds a smart filter, as the filter mask doesn't take it. +#[test] +fn workspaces_open_on_a_smart_object_with_its_filter_mask_targeted() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.ui.filter_mask_target = Some(layer.0); + app.sync_views(); + assert!(app.filter_mask_targeted()); + let ctx = egui::Context::default(); + crate::wide_angle_ui::open(&mut app, &ctx).unwrap(); + crate::gallery_ui::open(&mut app).unwrap(); + open(&mut app, &ctx).unwrap(); + let d = app.distort.liquify.as_mut().unwrap(); + d.strokes.push(LiquifyStroke { points: vec![vec![20.0, 24.0, 1.0], vec![36.0, 24.0, 1.0]], ..LiquifyStroke::new(LiquifyTool::ForwardWarp, 30.0) }); + commit(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { panic!("not a smart object") }; + assert_eq!(sm.smart_filters.iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", photocraft_engine::distort_cmds::LIQUIFY]); +} diff --git a/crates/ui-egui/src/dialogs/new_group_ui/tests.rs b/crates/ui-egui/src/dialogs/new_group_ui/tests.rs index 2ff93afd3d..788e566d5e 100644 --- a/crates/ui-egui/src/dialogs/new_group_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/new_group_ui/tests.rs @@ -124,13 +124,3 @@ fn typing_replaces_the_selected_name_and_enter_confirms() { assert!(h.state().ui.dialogs.is_empty()); assert_eq!(groups(h.state())[0].name, "Clouds"); } - -#[test] -fn new_group_dialog_labels_are_translated_in_every_catalog() { - const LABELS: &[&str] = &["New Group…", "New Group from Layers…", "Name:", "Color:", "Mode:", "Opacity:", "Pass Through"]; - for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) { - for label in LABELS { - assert!(crate::i18n::has(lang, label), "{} is missing New Group dialog label: {label}", lang.code()); - } - } -} diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index ef04f0b576..2da15f9fb4 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -33,6 +33,10 @@ pub struct Held { pub bytes: Option>>, /// The tab position a reopened file keeps. pub slot: Option, + /// The file's place in a set opened together, when it is one of several + /// ([`crate::jobs_ui::OpenOrder`]): kept while this file waits, so its tab still lands in the + /// set's order. + pub order: Option, /// The profile question ([`photocraft_engine::color_cmds::ColorState::open_question`]), or /// `None` to open without asking. pub question: Option, @@ -41,35 +45,95 @@ pub struct Held { pub policy_applied: bool, } +/// Dialog field for the high-memory confirmation prompt. +pub const MEMORY_FIELD: &str = "__psdMemoryOpen"; + +/// Item kind in the arrival-ordered open prompt queue. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PromptKind { + Memory, + Open, +} + +/// An un-decoded PSD file whose preflight estimate requires user approval. +#[derive(Clone)] +pub struct MemoryPrompt { + pub id: u64, + pub name: String, + pub path: Option, + pub slot: Option, + pub order: Option, + pub estimated: u64, + /// The capacity measured when the estimate was made; `None` when it could not be measured. + pub available: Option, + /// The engine could not measure available memory for this estimate. No number is shown. + pub capacity_unknown: bool, + /// The estimate is above the unknown-capacity warning threshold (8 GiB), so the prompt also + /// says the process or tab may run out of memory. Approval stays possible. + pub uncertain_capacity_warning: bool, +} + /// Whether a file that just finished reading has to wait: one earlier in the queue does. pub fn waiting(app: &PhotocraftApp) -> bool { - !app.held_opens.is_empty() + !app.held_opens.is_empty() || !app.held_memory_prompts.is_empty() } /// Opens `held` now, or queues it behind the file whose prompt is up. pub fn arrive(app: &mut PhotocraftApp, held: Held) { app.held_opens.push_back(held); + app.prompt_order.push_back(PromptKind::Open); advance(app); } +/// Whether any open prompt (damage, profile or high-memory) is currently on screen. +pub fn has_open_dialog(app: &PhotocraftApp) -> bool { + app.ui.dialogs.iter().any(|d| owns(&d.fields) || owns_damage(&d.fields) || owns_memory(&d.fields)) +} + /// Opens queued files in order until one needs its prompt, which is shown if it isn't yet. -fn advance(app: &mut PhotocraftApp) { - while let Some(front) = app.held_opens.front() { - if front.damage_pending { - if !app.ui.dialogs.iter().any(|d| owns_damage(&d.fields)) { - show_damage(app); +pub fn advance(app: &mut PhotocraftApp) { + // Reconcile legacy callers that populated held queues directly. + if app.prompt_order.is_empty() { + app.prompt_order.extend(std::iter::repeat_n(PromptKind::Open, app.held_opens.len())); + app.prompt_order.extend(std::iter::repeat_n(PromptKind::Memory, app.held_memory_prompts.len())); + } + if has_open_dialog(app) { + return; + } + loop { + match app.prompt_order.front() { + None => return, + Some(PromptKind::Memory) => { + if let Some(front) = app.held_memory_prompts.front().cloned() { + show_memory(app, &front); + return; + } else { + app.prompt_order.pop_front(); + } } - return; - } - if front.question.is_some() { - if !app.ui.dialogs.iter().any(|d| d.fields.contains_key(FIELD)) { - show(app); + Some(PromptKind::Open) => { + let Some(front) = app.held_opens.front() else { + app.prompt_order.pop_front(); + continue; + }; + if front.damage_pending { + show_damage(app); + return; + } + if front.question.is_some() { + show(app); + return; + } + let Some(held) = app.held_opens.pop_front() else { + app.prompt_order.pop_front(); + continue; + }; + app.prompt_order.pop_front(); + let _ = open(app, held, None); + if has_open_dialog(app) { + return; + } } - return; - } - if let Some(held) = app.held_opens.pop_front() { - // A failure is shown to the user (`open_failed`); the next file still opens. - let _ = open(app, held, None); } } } @@ -129,19 +193,31 @@ pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result>() { app.session.cancel_job(job); } + app.jobs.opens.clear(); + app.jobs.batches.clear(); + app.ui.dialogs.retain(|d| !owns(&d.fields) && !owns_damage(&d.fields) && !owns_memory(&d.fields)); app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); app.ui.status_error = false; return Ok(json!({"cancelled": dropped})); }; let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) }; + if let Some(pos) = app.prompt_order.iter().position(|p| *p == PromptKind::Open) { + app.prompt_order.remove(pos); + } let r = open(app, held, Some(action)); advance(app); + crate::jobs_ui::end_batch(app); r } @@ -164,14 +240,19 @@ pub fn answer_remembering(app: &mut PhotocraftApp, action: &str) -> Result Result) -> Result) -> Result app .run("document.move", json!({"document": index, "to": slot})) @@ -267,3 +353,134 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result, + slot: Option, + order: Option, + estimated: u64, + available: Option, + capacity_unknown: bool, + uncertain_capacity_warning: bool, +) { + app.held_memory_prompts.push_back(MemoryPrompt { id, name, path, slot, order, estimated, available, capacity_unknown, uncertain_capacity_warning }); + app.prompt_order.push_back(PromptKind::Memory); + advance_memory(app); +} + +/// Whether a dialog's fields mark it as the high-memory confirmation. +pub fn owns_memory(fields: &serde_json::Map) -> bool { + fields.contains_key(MEMORY_FIELD) +} + +/// Draws the high-memory prompt's message body from its dialog fields. +pub(crate) fn memory_body(ui: &mut egui::Ui, fields: &serde_json::Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); +} + +/// Advances the shared open-prompt queue; memory prompts take their turn with damage and profile +/// prompts in arrival order. +pub fn advance_memory(app: &mut PhotocraftApp) { + advance(app); +} + +/// Builds and opens the confirmation dialog for a queued high-memory preflight. +fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { + let est_gb = prompt.estimated as f64 / (1u64 << 30) as f64; + // An unmeasured capacity is never printed as a number, even if a stale measurement is + // attached: the engine's `capacityUnknown` wins. + let available = prompt.available.filter(|_| !prompt.capacity_unknown); + let mut message = match available { + Some(a) => crate::i18n::fmt( + tl!("Opening “{name}” requires an estimated {est} GiB of memory ({avail} GiB available)."), + &[("name", &prompt.name), ("est", &format!("{est_gb:.1}")), ("avail", &format!("{:.1}", a as f64 / (1u64 << 30) as f64))], + ), + None => crate::i18n::fmt( + tl!("Opening “{name}” requires an estimated {est} GiB of memory. The available memory could not be measured."), + &[("name", &prompt.name), ("est", &format!("{est_gb:.1}"))], + ), + }; + message.push_str("\n\n"); + message.push_str(tl!("Opening this document uses more memory and may slow down your computer or other running applications.")); + if prompt.uncertain_capacity_warning { + message.push_str("\n\n"); + message.push_str(tl!( + "The estimate is above 8 GiB and the available memory is unknown, so this process or browser tab may run out of memory. You can still try to open it." + )); + } + + let fields = [ + (MEMORY_FIELD.into(), json!(true)), + ("__label".into(), json!(tl!("High Memory Photoshop Document"))), + ("name".into(), json!(prompt.name)), + ("id".into(), json!(prompt.id)), + ("message".into(), json!(message)), + ("estimated".into(), json!(prompt.estimated)), + ("available".into(), json!(available)), + ("capacityUnknown".into(), json!(prompt.capacity_unknown)), + ("uncertainCapacityWarning".into(), json!(prompt.uncertain_capacity_warning)), + ] + .into_iter() + .collect(); + + app.ui.open_dialog(crate::state::DialogKind::Command, fields); +} + +/// Answers the front high-memory prompt: accepts and starts the approved open, or declines and +/// advances the queue. The engine command revalidates the source identity and the hard limit. +pub(crate) fn answer_memory(app: &mut PhotocraftApp, accept: bool) -> Result { + let Some(front) = app.held_memory_prompts.pop_front() else { + advance(app); + return Err("no high-memory PSD is waiting for confirmation".into()); + }; + if let Some(pos) = app.prompt_order.iter().position(|p| *p == PromptKind::Memory) { + app.prompt_order.remove(pos); + } + let id = front.id; + let name = front.name.clone(); + let path = front.path.clone(); + let slot = front.slot; + let order = front.order; + + let res = app.run("file.approveMemory", json!({"id": id, "approve": accept, "background": app.background_jobs})).map_err(|e| format!("{}: {e}", name)); + + match res { + Ok(r) => { + if accept { + if let Some(job) = r.get("job").and_then(Value::as_u64) { + app.jobs.opens.push(crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(job), name: name.clone(), path, slot, order }); + app.jobs.focus = Some(photocraft_engine::jobs::JobId(job)); + app.ui.chrome.home = None; + app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &name)]); + app.ui.status_error = false; + } else if let Some(result) = r.get("result") { + let tab = crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(0), name: name.clone(), path: path.clone(), slot, order }; + if let Err(e) = crate::jobs_ui::finish_open_tab(app, &tab, result) { + app.ui.status = e; + app.ui.status_error = true; + } + } + } else { + app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); + app.ui.status_error = false; + crate::jobs_ui::end_batch(app); + } + advance(app); + crate::jobs_ui::end_batch(app); + Ok(r) + } + Err(err) => { + app.ui.status = err.clone(); + app.ui.status_error = true; + crate::jobs_ui::end_batch(app); + advance(app); + crate::jobs_ui::end_batch(app); + Err(err) + } + } +} diff --git a/crates/ui-egui/src/dialogs/pattern_fill_ui.rs b/crates/ui-egui/src/dialogs/pattern_fill_ui.rs index 9404bf6b54..76e8e47620 100644 --- a/crates/ui-egui/src/dialogs/pattern_fill_ui.rs +++ b/crates/ui-egui/src/dialogs/pattern_fill_ui.rs @@ -65,8 +65,8 @@ pub(crate) fn open_new(app: &mut PhotocraftApp) -> Result { return Err(why); } let st = app.session.active().ok_or("no document open")?; - let pattern = photocraft_engine::pattern_cmds::resolve(&app.session, "").ok_or("no patterns")?; - let values = json!({"pattern": pattern.id, "scale": 100.0, "angle": 0.0, "link": true, "phase": [0.0, 0.0]}); + let pattern = photocraft_engine::pattern_cmds::resolve_id(&app.session, "").ok_or("no patterns")?; + let values = json!({"pattern": pattern, "scale": 100.0, "angle": 0.0, "link": true, "phase": [0.0, 0.0]}); let f = fields(cmds::CREATE, json!({"document": st.doc.id.0}), values); Ok(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } diff --git a/crates/ui-egui/src/dialogs/preset_group_prompt.rs b/crates/ui-egui/src/dialogs/preset_group_prompt.rs new file mode 100644 index 0000000000..d33c789b8e --- /dev/null +++ b/crates/ui-egui/src/dialogs/preset_group_prompt.rs @@ -0,0 +1,98 @@ +//! Importing a brush (`.abr`) or gradient (`.grd`) file asks first when the library already has +//! a group of the file's name with presets of the user's (#10640). The group key is only the file +//! name, so it can be another author's set, or hold brushes the user edited or moved there, and +//! the preset libraries are outside document history. +//! +//! Import as New Group (the default) imports beside it as "Kit (2)"; Replace deletes the group's +//! presets first, which can't be undone; Cancel imports nothing. Without a clash the file imports +//! at once, as before. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.inspect` shows it, `ui.dialog.confirm` +//! imports as a new group, `ui.dialog.set` with `"choice": "replace"` then `ui.dialog.confirm` +//! replaces, and `ui.dialog.cancel` imports nothing. + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; + +const MARK: &str = "__presetGroupClash"; + +/// An import held back while the prompt is up. +pub struct Held { + pub cmd: &'static str, + pub what: &'static str, + pub stem: String, + pub params: Value, +} + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// How many of the user's presets a group named `group` holds in the library `cmd` imports to +/// (built-in brushes are never replaced, so they don't count). +fn clashing(app: &PhotocraftApp, cmd: &str, group: &str) -> usize { + match cmd { + "brush.presets.importAbr" => app.session.tools.presets.iter().filter(|x| !x.builtin && x.group == group).count(), + "gradient.presets.importGrd" => app.session.presets.gradients.iter().filter(|g| g.name == group).map(|g| g.items.len()).sum(), + _ => 0, + } +} + +/// Opens the prompt for `held` when its group clashes; otherwise hands `held` back to import now. +pub(crate) fn intercept(app: &mut PhotocraftApp, held: Held) -> Result, String> { + let n = clashing(app, held.cmd, &held.stem); + if n == 0 { + return Ok(Some(held)); + } + if app.ui.dialogs.iter().any(|d| owns(&d.fields)) { + return Err(tl!("Answer the open import question first, then import this file again.").into()); + } + let (label, message) = if held.what == "gradients" { + let text = crate::i18n::trn( + crate::i18n::current(), + n as u64, + "The gradient group “{group}” already exists ({n} gradient).", + "The gradient group “{group}” already exists ({n} gradients).", + ); + ("Import Gradients", text) + } else { + let text = crate::i18n::trn( + crate::i18n::current(), + n as u64, + "The brush group “{group}” already has {n} brush of yours.", + "The brush group “{group}” already has {n} brushes of yours.", + ); + ("Import Brushes", text) + }; + let message = crate::i18n::fmt(&message, &[("group", &held.stem)]); + let fields = json!({MARK: true, "__label": label, "message": message, "group": held.stem, "kind": held.what, "choice": "new"}); + app.held_preset_import = Some(held); + app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Ok(None) +} + +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); + let group = fields.get("group").and_then(Value::as_str).unwrap_or_default(); + ui.label(crate::i18n::fmt(tl!("Import as New Group keeps “{group}” and adds the file as a new group beside it."), &[("group", group)])); + ui.add_space(4.0); + ui.label(crate::i18n::fmt( + tl!("Replace deletes everything in “{group}” first, including presets you edited or moved there. This can't be undone."), + &[("group", group)], + )); +} + +/// The held import: as a new group, or replacing the group when `choice` is "replace". +pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let Held { cmd, what, stem, mut params } = app.held_preset_import.take().ok_or(tl!("No import is waiting. Import the file again."))?; + params["replace"] = json!(fields.get("choice").and_then(Value::as_str) == Some("replace")); + crate::preset_files_ui::run_import(app, cmd, what, &stem, params).map(|()| Value::Null) +} + +/// Cancel: nothing is imported. +pub fn cancel(app: &mut PhotocraftApp) { + app.held_preset_import = None; +} diff --git a/crates/ui-egui/src/dialogs/screen_picker.rs b/crates/ui-egui/src/dialogs/screen_picker.rs index 59e36636e6..25c948a6c7 100644 --- a/crates/ui-egui/src/dialogs/screen_picker.rs +++ b/crates/ui-egui/src/dialogs/screen_picker.rs @@ -261,7 +261,7 @@ pub fn cancel(ctx: &Context) { /// The picker window: hidden until its snapshot has been painted (#3210). fn viewport_builder(ctx: &Context, image: &ScreenImage) -> egui::ViewportBuilder { - let mut builder = egui::ViewportBuilder::default() + let mut builder = crate::os_window::builder() .with_title(tl!("PhotoCraft — Screen Color Picker")) .with_decorations(false) .with_resizable(false) diff --git a/crates/ui-egui/src/dialogs/select_mask_ui.rs b/crates/ui-egui/src/dialogs/select_mask_ui.rs new file mode 100644 index 0000000000..ebef97f740 --- /dev/null +++ b/crates/ui-egui/src/dialogs/select_mask_ui.rs @@ -0,0 +1,1977 @@ +//! Select › Select and Mask…: Photoshop's Select and Mask workspace (#7069, #8148, TOOL-214-1), a +//! full-window workspace like Content-Aware Fill's. +//! +//! Laid out as Photoshop's: the tool's options bar across the top, the tools on the left (Quick +//! Selection W, Refine Edge Brush R, Brush B, Object Selection W, Lasso and Polygonal Lasso L in +//! one slot, Hand H, Zoom Z; Shift+W and Shift+L cycle), the document under its tab in the middle, +//! shown in the chosen view, and the Properties panel on the right: Remember Settings, View Mode +//! (the views with their letters, Show Edge J, Show Original P, Real-Time Refinement, High Quality +//! Preview, Transparency or Opacity, Overlay's colour and what it indicates), Refine Mode, Edge +//! Detection, Global Refinements (with Clear Selection and Invert) and Output Settings, then +//! Reset, OK and Cancel. The view keys (O M V A T K Y, F cycles, hold X for the image) work as in +//! the dialog this replaces. +//! +//! What the workspace holds is plain data: the tools' edits (`select.refineEdge`'s `edits`), the +//! Refine Edge Brush strokes (`refineStrokes`) and the settings. OK runs `select.refineEdge` with +//! them, one history step; Cancel drops them; Ctrl+Z undoes the last edit or stroke. The preview +//! runs the same edits ([`photocraft_engine::select_mask_edits`]) on a reduced copy (full size +//! with High Quality Preview) in the background and keeps the selection after the edits it has +//! applied, so a new stroke costs only that stroke; then it shows the refined selection in the +//! view ([`crate::select_mask_view::preview_proxy`]). With Real-Time Refinement the stroke being +//! drawn joins the preview while the pointer moves; without, when it is released. +//! +//! Not here yet: Refine Hair (#2671) and Object Aware (a learned model, #2788) are shown but greyed +//! out, and Photoshop's Presets. + +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_engine::select_mask_edits::{self, MAX_EDIT_POINTS, MAX_EDITS}; +use photocraft_engine::smartselect_cmds::{MAX_STROKE_POINTS, MAX_STROKES}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::ToolEvent; +use crate::select_mask_view::Mode; +use crate::theme::Tokens; +use crate::widgets; + +/// Longest side of the reduced copy the preview runs on (px), unless High Quality Preview. +const PROXY_SIDE: usize = 1600; +const LEFT_W: f32 = 48.0; +const BAR_H: f32 = 36.0; +const TAB_H: f32 = 28.0; +const PANEL_W: f32 = 350.0; +const UNDO_LIMIT: usize = 100; +/// Marching ants with more edges than this are not drawn. +const MAX_ANT_EDGES: usize = 200_000; +/// Bound the temporary dense mask used for marching ants. +const MAX_ANT_PIXELS: usize = 4_000_000; +/// The `prefs.dialogs` key under which Remember Settings keeps the settings. +const REMEMBERED: &str = "select.refineEdge.workspace"; +const CMD: &str = crate::select_mask_view::COMMAND; +/// The Output To targets, in Photoshop's order. +const OUTPUTS: [&str; 6] = ["selection", "layerMask", "newLayer", "newLayerWithMask", "newDocument", "newDocumentWithMask"]; +/// Photoshop's brush sizes (px). +const SIZES: std::ops::RangeInclusive = 1.0..=5000.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SmTool { + Quick, + RefineEdge, + Brush, + Object, + Lasso, + PolygonalLasso, + Hand, + Zoom, +} + +impl SmTool { + fn name(self) -> &'static str { + match self { + SmTool::Quick => "quick", + SmTool::RefineEdge => "refineEdge", + SmTool::Brush => "brush", + SmTool::Object => "object", + SmTool::Lasso => "lasso", + SmTool::PolygonalLasso => "polygonalLasso", + SmTool::Hand => "hand", + SmTool::Zoom => "zoom", + } + } + + fn from_name(s: &str) -> Option { + Some(match s { + "quick" => SmTool::Quick, + "refineEdge" => SmTool::RefineEdge, + "brush" => SmTool::Brush, + "object" => SmTool::Object, + "lasso" => SmTool::Lasso, + "polygonalLasso" => SmTool::PolygonalLasso, + "hand" => SmTool::Hand, + "zoom" => SmTool::Zoom, + _ => return None, + }) + } + + fn icon(self) -> &'static str { + match self { + SmTool::Quick => "quick-selection", + SmTool::RefineEdge => "brush-cleaning", + SmTool::Brush => "brush", + SmTool::Object => "square-dashed-mouse-pointer", + SmTool::Lasso => "lasso", + SmTool::PolygonalLasso => "pentagon", + SmTool::Hand => "hand", + SmTool::Zoom => "zoom-in", + } + } + + fn tip(self) -> String { + let (label, key) = match self { + SmTool::Quick => (tl!("Quick Selection Tool"), "W"), + SmTool::RefineEdge => (tl!("Refine Edge Brush Tool"), "R"), + SmTool::Brush => (tl!("Brush Tool"), "B"), + SmTool::Object => (tl!("Object Selection Tool"), "W"), + SmTool::Lasso => (tl!("Lasso Tool"), "L"), + SmTool::PolygonalLasso => (tl!("Polygonal Lasso Tool"), "L"), + SmTool::Hand => (tl!("Hand Tool"), "H"), + SmTool::Zoom => (tl!("Zoom Tool"), "Z"), + }; + format!("{label} ({key})") + } + + /// Paints with a round brush (size from `[` and `]`). + fn brushes(self) -> bool { + matches!(self, SmTool::Quick | SmTool::RefineEdge | SmTool::Brush) + } + + /// Adds to or subtracts from the selection (the options bar's + and −; Alt swaps them). + fn edits(self) -> bool { + !matches!(self, SmTool::Hand | SmTool::Zoom) + } +} + +/// The Properties panel's settings (kept between uses with Remember Settings). +#[derive(Clone, Debug, PartialEq)] +pub struct SmOpts { + pub view: Mode, + /// Each view's Transparency / Opacity (percent), in [`Mode::ALL`] order. + pub opacity: [f32; 7], + pub show_edge: bool, + pub show_original: bool, + /// Real-Time Refinement: the stroke being drawn joins the preview while the pointer moves. + pub real_time: bool, + /// High Quality Preview: the preview runs on the document at full size. + pub high_quality: bool, + /// Overlay's colour and whether it marks the selected areas (else the masked ones). + pub overlay_color: [u8; 3], + pub indicates_selected: bool, + pub radius: f32, + pub smart_radius: bool, + pub smooth: f32, + pub feather: f32, + pub contrast: f32, + pub shift_edge: f32, + pub decontaminate: bool, + pub amount: f32, + pub output: String, + pub remember: bool, +} + +impl Default for SmOpts { + fn default() -> Self { + SmOpts { + view: Mode::OnionSkin, + opacity: Mode::ALL.map(|m| m.default_opacity().unwrap_or(100.0)), + show_edge: false, + show_original: false, + real_time: true, + high_quality: false, + overlay_color: [255, 0, 0], + indicates_selected: false, + radius: 0.0, + smart_radius: false, + smooth: 0.0, + feather: 0.0, + contrast: 0.0, + shift_edge: 0.0, + decontaminate: false, + amount: 100.0, + output: "selection".into(), + remember: false, + } + } +} + +fn mode_index(m: Mode) -> usize { + Mode::ALL.iter().position(|x| *x == m).unwrap_or(0) +} + +impl SmOpts { + /// Applies the settings named in `v` (control channel and remembered keys); others stay. + pub fn apply(&mut self, v: &Value) -> Result<(), String> { + let flag = |k: &str| v.get(k).and_then(Value::as_bool); + for (k, slot) in [ + ("showEdge", &mut self.show_edge), + ("showOriginal", &mut self.show_original), + ("realTimeRefinement", &mut self.real_time), + ("highQualityPreview", &mut self.high_quality), + ("smartRadius", &mut self.smart_radius), + ("decontaminate", &mut self.decontaminate), + ("remember", &mut self.remember), + ] { + if let Some(b) = flag(k) { + *slot = b; + } + } + for (k, slot, lo, hi) in [ + ("radius", &mut self.radius, 0.0, 250.0), + ("smooth", &mut self.smooth, 0.0, 100.0), + ("feather", &mut self.feather, 0.0, 1000.0), + ("contrast", &mut self.contrast, 0.0, 100.0), + ("shiftEdge", &mut self.shift_edge, -100.0, 100.0), + ("amount", &mut self.amount, 0.0, 100.0), + ] { + if let Some(n) = v.get(k).and_then(Value::as_f64).filter(|n| n.is_finite()) { + *slot = (n as f32).clamp(lo, hi); + } + } + if let Some(m) = v.get("view").and_then(Value::as_str) { + self.view = Mode::from_id(m).ok_or_else(|| format!("bad view `{m}` ({})", Mode::ALL.map(|m| m.id()).join("|")))?; + } + match v.get("opacity") { + // A number: the current view's; an object: per view. + Some(Value::Number(n)) => { + if let Some(n) = n.as_f64().filter(|n| n.is_finite()) { + self.opacity[mode_index(self.view)] = (n as f32).clamp(0.0, 100.0); + } + } + Some(Value::Object(o)) => { + for (k, n) in o { + if let (Some(m), Some(n)) = (Mode::from_id(k), n.as_f64().filter(|n| n.is_finite())) { + self.opacity[mode_index(m)] = (n as f32).clamp(0.0, 100.0); + } + } + } + _ => {} + } + if let Some(c) = v.get("overlayColor").and_then(Value::as_array).filter(|c| c.len() == 3) { + let ch = |i: usize| c.get(i).and_then(Value::as_u64).map(|v| v.min(255) as u8); + if let (Some(r), Some(g), Some(b)) = (ch(0), ch(1), ch(2)) { + self.overlay_color = [r, g, b]; + } + } + match v.get("indicates").and_then(Value::as_str) { + None => {} + Some("masked") => self.indicates_selected = false, + Some("selected") => self.indicates_selected = true, + Some(s) => return Err(format!("bad indicates `{s}` (masked|selected)")), + } + if let Some(o) = v.get("output").and_then(Value::as_str) { + if !OUTPUTS.contains(&o) { + return Err(format!("bad output `{o}` ({})", OUTPUTS.join("|"))); + } + self.output = o.to_string(); + } + self.follow_decontaminate(); + Ok(()) + } + + /// Decontaminating needs new pixels, so (as in Photoshop) it outputs to a new layer or document. + fn follow_decontaminate(&mut self) { + if self.decontaminate && matches!(self.output.as_str(), "selection" | "layerMask") { + self.output = "newLayerWithMask".into(); + } + } + + /// The settings as [`SmOpts::apply`] reads them. + pub fn to_json(&self) -> Value { + let opacity: Map = Mode::ALL.iter().zip(self.opacity).map(|(m, o)| (m.id().to_string(), json!(o))).collect(); + json!({ + "view": self.view.id(), + "opacity": opacity, + "showEdge": self.show_edge, + "showOriginal": self.show_original, + "realTimeRefinement": self.real_time, + "highQualityPreview": self.high_quality, + "overlayColor": self.overlay_color, + "indicates": if self.indicates_selected { "selected" } else { "masked" }, + "radius": self.radius, + "smartRadius": self.smart_radius, + "smooth": self.smooth, + "feather": self.feather, + "contrast": self.contrast, + "shiftEdge": self.shift_edge, + "decontaminate": self.decontaminate, + "amount": self.amount, + "output": self.output, + "remember": self.remember, + }) + } + + /// `select.refineEdge`'s refinement and output params. + fn refine_params(&self) -> Map { + let mut p = Map::new(); + p.insert("radius".into(), json!(self.radius)); + p.insert("smartRadius".into(), json!(self.smart_radius)); + p.insert("smooth".into(), json!(self.smooth)); + p.insert("feather".into(), json!(self.feather)); + p.insert("contrast".into(), json!(self.contrast)); + p.insert("shiftEdge".into(), json!(self.shift_edge)); + p.insert("decontaminate".into(), json!(self.decontaminate)); + p.insert("amount".into(), json!(self.amount)); + p.insert("output".into(), json!(self.output)); + p + } + + /// The preview's view (`select_mask_view`'s `__view`). + fn view_params(&self) -> Value { + let c = self.overlay_color.map(|v| f32::from(v) / 255.0); + json!({ + "mode": self.view.id(), + "opacity": self.opacity[mode_index(self.view)] / 100.0, + "showEdge": self.show_edge, + "showOriginal": self.show_original, + "color": c, + "indicates": if self.indicates_selected { "selected" } else { "masked" }, + }) + } +} + +/// One undoable step. +#[derive(Clone, Debug)] +enum Step { + Edit(Value), + Stroke(Value), + /// A reset, with what it replaced. + State { + edits: Vec, + strokes: Vec, + opts: Box, + }, +} + +/// A press-and-drag on the view with a tool, in document px. +#[derive(Clone, Debug)] +struct Gesture { + tool: SmTool, + add: bool, + points: Vec<[f64; 2]>, +} + +/// The selection after the first `edits` (document coordinates) on the copy reduced by `k`. +#[derive(Clone)] +struct BaseCache { + k: u32, + /// The document revision the base was computed from. + revision: u64, + edits: Vec, + base: Option, +} + +/// A computed preview, ready to upload. +struct Shown { + image: egui::ColorImage, + /// Marching ants (document px) in the Marching Ants view. + ants: Option>, + /// Reduced-copy pixels at least half selected. + selected: usize, + /// How many points of the stroke being drawn it includes ([`usize::MAX`]: every stroke is in). + trail: usize, + /// The document px the image covers (the reduced copy's size × `k`). + covers: [f32; 2], +} + +#[cfg(not(target_arch = "wasm32"))] +type Outcome = Result<(Shown, BaseCache, Arc), String>; + +/// The preview being computed or shown. +#[derive(Default)] +struct Preview { + /// Bumped whenever the state changes; a result for an older generation is dropped. + want: u64, + #[cfg(not(target_arch = "wasm32"))] + running: Option<(u64, u64, std::sync::mpsc::Receiver, photocraft_engine::jobs::JobCtx)>, + shown: u64, + pending: Option, + tex: Option, + ants: Option>, + covers: [f32; 2], + selected: usize, + /// [`Shown::trail`] of the preview shown. + trail: usize, + cache: Option, + /// The reduced copy of the document (its factor and the revision it was made from). + proxy: Option<(u32, u64, Arc)>, + error: Option, +} + +pub struct SelectMaskWorkspace { + /// The document and layer the workspace was opened on: its work applies to them only. + layer: Option, + pub(crate) document: DocId, + canvas: Rect, + /// The document revision the textures were made from. + revision: u64, + /// The targeted layer mask is what is refined (`"target": "mask"`). + target_mask: bool, + pub opts: SmOpts, + pub tool: SmTool, + /// The tools W and L pick: the last of each slot used. + w_tool: SmTool, + l_tool: SmTool, + pub add: bool, + pub quick_size: f64, + pub refine_size: f64, + pub brush_size: f64, + pub hardness: f32, + pub sample_all: bool, + pub edits: Vec, + pub strokes: Vec, + undo: Vec, + redo: Vec, + gesture: Option, + /// The stroke last drawn, until a preview with it arrives (its newest part is drawn over the + /// preview meanwhile, so painting looks continuous). + trail: Option, + /// The Polygonal Lasso's vertices so far, and whether it adds. + poly: Option<(bool, Vec<[f64; 2]>)>, + /// X is held: the image without the view. + hide_view: bool, + /// Document px per reduced-copy px (High Quality Preview uses 1). + k: u32, + pressed: bool, + /// The device's texture side limit, as the context reports it each frame. + texture_limit: usize, + image_tex: Option, + zoom: f32, + center: [f64; 2], + hover: Option<[f64; 2]>, + preview: Preview, + pub error: Option, +} + +impl SelectMaskWorkspace { + pub fn describe(&self) -> Value { + json!({ + "tool": self.tool.name(), + "mode": if self.add { "add" } else { "subtract" }, + "size": self.size(), + "hardness": self.hardness, + "sampleAllLayers": self.sample_all, + "edits": self.edits.len(), + "strokes": self.strokes.len(), + "undo": self.undo.len(), + "redo": self.redo.len(), + "polygon": self.poly.as_ref().map_or(0, |(_, p)| p.len()), + "settings": self.opts.to_json(), + "proxyScale": self.proxy_k(), + "selectedProxyPixels": self.preview.selected, + "previewReady": (self.preview.tex.is_some() || self.preview.pending.is_some()) && self.preview.shown == self.preview.want, + "previewError": self.preview.error, + "error": self.error, + }) + } + + /// The factor the preview runs at. + fn proxy_k(&self) -> u32 { + if self.opts.high_quality { 1 } else { self.k } + } + + /// The current brush tool's size (Quick Selection, Refine Edge Brush, Brush; else the brush's). + fn size(&self) -> f64 { + match self.tool { + SmTool::Quick => self.quick_size, + SmTool::RefineEdge => self.refine_size, + _ => self.brush_size, + } + } + + fn set_size(&mut self, v: f64) { + let v = if v.is_finite() { v.round().clamp(*SIZES.start(), *SIZES.end()) } else { return }; + match self.tool { + SmTool::Quick => self.quick_size = v, + SmTool::RefineEdge => self.refine_size = v, + SmTool::Brush => self.brush_size = v, + _ => {} + } + } + + fn pick(&mut self, t: SmTool) { + self.tool = t; + match t { + SmTool::Quick | SmTool::Object => self.w_tool = t, + SmTool::Lasso | SmTool::PolygonalLasso => self.l_tool = t, + _ => {} + } + if t != SmTool::PolygonalLasso { + self.poly = None; + } + } + + /// The preview is out of date. + fn stale(&mut self) { + self.preview.want += 1; + } + + fn push(&mut self, s: Step) { + self.redo.clear(); + if self.undo.len() == UNDO_LIMIT { + self.undo.remove(0); + } + self.undo.push(s); + } + + fn points_in(list: &[Value]) -> usize { + list.iter().map(|e| e.get("points").and_then(Value::as_array).map_or(0, Vec::len)).sum() + } + + /// An item the engine's limits refuse: say why, and drop what was drawn for it. + fn reject(&mut self, why: String) { + self.error = Some(why); + self.trail = None; + self.stale(); + } + + /// Adds a tool edit (undoable), within the engine's limits. + fn add_edit(&mut self, e: Value) { + if self.edits.len() >= MAX_EDITS { + self.reject(format!("at most {MAX_EDITS} edits: undo some, or reset")); + return; + } + if Self::points_in(&self.edits) + Self::points_in(std::slice::from_ref(&e)) > MAX_EDIT_POINTS { + self.reject(format!("at most {MAX_EDIT_POINTS} points in all edits: undo some, or reset")); + return; + } + self.error = None; + self.edits.push(e.clone()); + self.push(Step::Edit(e)); + self.stale(); + } + + /// Adds a Refine Edge Brush stroke (undoable), within the engine's limits. + fn add_stroke(&mut self, s: Value) { + if self.strokes.len() >= MAX_STROKES { + self.reject(format!("at most {MAX_STROKES} Refine Edge Brush strokes: undo some, or reset")); + return; + } + if Self::points_in(&self.strokes) + Self::points_in(std::slice::from_ref(&s)) > MAX_STROKE_POINTS { + self.reject(format!("at most {MAX_STROKE_POINTS} points in all Refine Edge Brush strokes: undo some, or reset")); + return; + } + self.error = None; + self.strokes.push(s.clone()); + self.push(Step::Stroke(s)); + self.stale(); + } + + /// What `g` adds when it ends: a tool edit or a Refine Edge Brush stroke (`None`: nothing). + fn finish(&self, g: &Gesture) -> Option> { + let round = |p: &[f64; 2]| json!([(p[0] * 10.0).round() / 10.0, (p[1] * 10.0).round() / 10.0]); + let pts: Vec = g.points.iter().map(round).collect(); + let mode = if g.add { "add" } else { "subtract" }; + let first = g.points.first()?; + Some(match g.tool { + SmTool::Quick => Ok(json!({"tool": "quick", "points": pts, "size": self.quick_size, "mode": mode, "sampleAllLayers": self.sample_all})), + SmTool::Brush => Ok(json!({"tool": "brush", "points": pts, "size": self.brush_size, "hardness": self.hardness, "mode": mode})), + SmTool::RefineEdge => Err(json!({"points": pts, "size": self.refine_size, "mode": mode})), + SmTool::Lasso | SmTool::PolygonalLasso if pts.len() >= 3 => Ok(json!({"tool": "lasso", "points": pts, "mode": mode})), + SmTool::Object => { + let last = g.points.last().unwrap_or(first); + let (w, h) = (last[0] - first[0], last[1] - first[1]); + // A click (or a drag of a couple of pixels) picks the object under the pointer. + if w.abs().max(h.abs()) * f64::from(self.zoom.max(0.01)) < 4.0 { + Ok(json!({"tool": "object", "point": round(first), "mode": mode, "sampleAllLayers": self.sample_all})) + } else { + let r = |v: f64| (v * 10.0).round() / 10.0; + Ok(json!({"tool": "object", "rect": [r(first[0]), r(first[1]), r(w), r(h)], "mode": mode, "sampleAllLayers": self.sample_all})) + } + } + _ => return None, + }) + } + + fn end_gesture(&mut self) { + let Some(g) = self.gesture.take() else { return }; + self.trail = Some(g.clone()); + match self.finish(&g) { + Some(Ok(e)) => self.add_edit(e), + Some(Err(s)) => self.add_stroke(s), + None => self.stale(), + } + } + + /// The Polygonal Lasso's polygon closes into an edit. + fn close_polygon(&mut self) { + if let Some((add, points)) = self.poly.take() + && points.len() >= 3 + { + self.gesture = Some(Gesture { tool: SmTool::PolygonalLasso, add, points }); + self.end_gesture(); + } + } + + fn begin(&mut self, p: [f64; 2], alt: bool) { + let add = self.add != alt; + match self.tool { + SmTool::PolygonalLasso => { + let near = |q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) * f64::from(self.zoom.max(0.01)) < 6.0; + match &mut self.poly { + // Clicking the first vertex again closes the polygon. + Some((_, pts)) if pts.len() >= 3 && pts.first().is_some_and(near) => self.close_polygon(), + Some((_, pts)) => pts.push(p), + None => self.poly = Some((add, vec![p])), + } + } + t if t.edits() => { + self.gesture = Some(Gesture { tool: t, add, points: vec![p] }); + self.trail = None; + self.preview.trail = 0; + } + _ => {} + } + } + + fn extend(&mut self, p: [f64; 2]) { + let step = if self.tool.brushes() { (self.size() / 8.0).max(1.0) } else { 0.5 }; + let real_time = self.opts.real_time; + let Some(g) = self.gesture.as_mut() else { return }; + match g.tool { + // Object Selection keeps the press and the current corner. + SmTool::Object => { + g.points.truncate(1); + g.points.push(p); + } + _ => { + if g.points.last().is_some_and(|l| (l[0] - p[0]).hypot(l[1] - p[1]) < step) { + return; + } + g.points.push(p); + } + } + if real_time { + self.stale(); + } + } +} + +/// Undo the last edit, stroke or reset. +fn undo(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + w.poly = None; + let Some(step) = w.undo.pop() else { return }; + let back = match step { + Step::Edit(e) => { + w.edits.pop(); + Step::Edit(e) + } + Step::Stroke(s) => { + w.strokes.pop(); + Step::Stroke(s) + } + Step::State { edits, strokes, opts } => { + let now = Step::State { + edits: std::mem::replace(&mut w.edits, edits), + strokes: std::mem::replace(&mut w.strokes, strokes), + opts: Box::new(w.opts.clone()), + }; + w.opts = *opts; + now + } + }; + w.redo.push(back); + w.error = None; + w.stale(); +} + +fn redo(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + let Some(step) = w.redo.pop() else { return }; + let back = match step { + Step::Edit(e) => { + w.edits.push(e.clone()); + Step::Edit(e) + } + Step::Stroke(s) => { + w.strokes.push(s.clone()); + Step::Stroke(s) + } + Step::State { edits, strokes, opts } => { + let now = Step::State { + edits: std::mem::replace(&mut w.edits, edits), + strokes: std::mem::replace(&mut w.strokes, strokes), + opts: Box::new(w.opts.clone()), + }; + w.opts = *opts; + now + } + }; + w.undo.push(back); + w.error = None; + w.stale(); +} + +/// Reset (the panel's ↺): the settings back to Photoshop's defaults and the edits and strokes +/// dropped, as one undoable step. +fn reset(w: &mut SelectMaskWorkspace) { + w.end_gesture(); + w.poly = None; + let opts = SmOpts { remember: w.opts.remember, ..SmOpts::default() }; + let prev = + Step::State { edits: std::mem::take(&mut w.edits), strokes: std::mem::take(&mut w.strokes), opts: Box::new(std::mem::replace(&mut w.opts, opts)) }; + w.push(prev); + w.error = None; + w.stale(); +} + +/// Opens the workspace on the selection (or the active layer's mask). +pub fn open(app: &mut PhotocraftApp) -> Result<(), String> { + // Opening again would drop the open workspace's work: edit it with `ui`, or cancel it. + if app.distort.select_mask.is_some() { + return Err("Select and Mask is already open".into()); + } + if let Some(Err(why)) = photocraft_engine::commands::find(CMD).map(|c| (c.enabled)(&app.session)) { + return Err(format!("Select and Mask needs a selection or a layer mask ({why})")); + } + // A targeted layer mask is what is refined (Photoshop opens on it from the mask's thumbnail). + let target_mask = app.layer_mask_targeted(); + let st = app.session.active().ok_or("no document")?; + let document = st.doc.id; + let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); + let canvas = st.doc.bounds(); + let revision = st.revision; + let mask_only = st.doc.selection.is_none() && layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); + let k = u32::try_from((canvas.width().max(canvas.height()) as usize).div_ceil(PROXY_SIDE).max(1)).unwrap_or(u32::MAX); + let mut opts = SmOpts::default(); + match app.session.prefs().dialogs.get(REMEMBERED) { + Some(saved) if saved.get("remember").and_then(Value::as_bool) == Some(true) => { + let _ = opts.apply(saved); + } + _ if mask_only || target_mask => opts.output = "layerMask".into(), + _ => {} + } + let mut w = SelectMaskWorkspace { + layer, + document, + canvas, + revision, + target_mask, + opts, + tool: SmTool::Quick, + w_tool: SmTool::Quick, + l_tool: SmTool::Lasso, + add: true, + quick_size: 30.0, + refine_size: 30.0, + brush_size: 30.0, + hardness: 100.0, + sample_all: false, + edits: Vec::new(), + strokes: Vec::new(), + undo: Vec::new(), + redo: Vec::new(), + gesture: None, + trail: None, + poly: None, + hide_view: false, + k, + pressed: false, + texture_limit: 2048, + image_tex: None, + zoom: 0.0, + center: [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0], + hover: None, + preview: Preview::default(), + error: None, + }; + w.stale(); + app.distort.select_mask = Some(w); + Ok(()) +} + +/// Why the open workspace's work no longer applies: the active document or layer is not the one it +/// was opened on (another tab, or another layer selected by a shortcut or the control channel). +/// `select.refineEdge` works on the active document and layer, so its edits must not run there. +pub(crate) fn stale(app: &PhotocraftApp) -> Option<&'static str> { + let w = app.distort.select_mask.as_ref()?; + let Some(st) = app.session.active().filter(|st| st.doc.id == w.document) else { + return Some(tl!("Select and Mask was closed: its document is no longer the active one")); + }; + let layer = st.active_layer.filter(|id| st.doc.layer(*id).is_some()); + (layer != w.layer).then_some(tl!("Select and Mask was closed: another layer was selected")) +} + +/// Closes a [`stale`] workspace (its work is dropped, the new target untouched): the reason. +fn drop_stale(app: &mut PhotocraftApp) -> Result<(), String> { + match stale(app) { + Some(why) => { + close(app); + app.ui.status = why.into(); + app.ui.status_error = true; + Err(why.into()) + } + None => Ok(()), + } +} + +/// The params OK runs `select.refineEdge` with. +fn commit_params(w: &SelectMaskWorkspace) -> Value { + let mut p = w.opts.refine_params(); + p.insert("sampleAllLayers".into(), json!(w.sample_all)); + if !w.edits.is_empty() { + p.insert("edits".into(), Value::Array(w.edits.clone())); + } + if !w.strokes.is_empty() { + p.insert("refineStrokes".into(), Value::Array(w.strokes.clone())); + } + if w.target_mask { + p.insert("target".into(), json!("mask")); + } + Value::Object(p) +} + +fn remember(app: &mut PhotocraftApp, opts: &SmOpts) { + let v = if opts.remember { opts.to_json() } else { json!({"remember": false}) }; + app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), v)); +} + +/// OK: runs `select.refineEdge` and closes. A failure leaves the workspace open with the error in +/// its panel, so nothing painted is lost. +pub fn commit(app: &mut PhotocraftApp) -> Result { + drop_stale(app)?; + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + w.end_gesture(); + w.close_polygon(); + let (p, opts) = (commit_params(w), w.opts.clone()); + match app.run(CMD, p) { + Ok(v) => { + remember(app, &opts); + close(app); + Ok(v) + } + Err(e) => { + if let Some(w) = app.distort.select_mask.as_mut() { + w.error = Some(e.clone()); + } + app.ui.status = e.clone(); + app.ui.status_error = true; + Err(e) + } + } +} + +/// Cancel (button, Esc): closes; the document is untouched. +pub fn cancel(app: &mut PhotocraftApp) { + close(app); +} + +fn close(app: &mut PhotocraftApp) { + if let Some(w) = app.distort.select_mask.take() { + cancel_preview(w); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn cancel_preview(w: SelectMaskWorkspace) { + if let Some((_, _, _, job)) = w.preview.running { + job.cancel(); + } +} + +#[cfg(target_arch = "wasm32")] +fn cancel_preview(_: SelectMaskWorkspace) {} + +/// Enter: closes a Polygonal Lasso polygon, else OK. True when it closed a polygon. +pub fn enter(app: &mut PhotocraftApp) -> bool { + let Some(w) = app.distort.select_mask.as_mut() else { return false }; + if w.poly.is_some() { + w.close_polygon(); + return true; + } + let _ = commit(app); + false +} + +/// Esc: drops a Polygonal Lasso polygon being drawn, else Cancel. +pub fn escape(app: &mut PhotocraftApp) { + match app.distort.select_mask.as_mut() { + Some(w) if w.poly.is_some() => w.poly = None, + _ => cancel(app), + } +} + +/// Control channel: `select.selectAndMask {"ui": {...}}` while the workspace is open. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + let flag = |k: &str| ui.get(k).and_then(Value::as_bool) == Some(true); + if flag("cancel") { + cancel(app); + return Ok(json!({"cancelled": true})); + } + drop_stale(app)?; + { + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + let before = w.opts.clone(); + // Everything is read before anything changes, so rejected params change nothing. + let mut o = w.opts.clone(); + o.apply(ui)?; + let tool = match ui.get("tool").and_then(Value::as_str) { + Some(t) => Some(SmTool::from_name(t).ok_or(format!("bad tool `{t}` (quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom)"))?), + None => None, + }; + let add = match ui.get("mode").and_then(Value::as_str) { + None => None, + Some("add") => Some(true), + Some("subtract") => Some(false), + Some(m) => return Err(format!("bad mode `{m}` (add|subtract)")), + }; + let stroke = match ui.get("stroke") { + Some(s) => { + let pts: Vec<[f64; 2]> = + serde_json::from_value(s.get("points").cloned().unwrap_or(Value::Null)).map_err(|e| format!("bad stroke points: {e}"))?; + if pts.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) { + return Err("bad stroke points: not finite".into()); + } + Some((pts, s.get("alt").and_then(Value::as_bool).unwrap_or(false))) + } + None => None, + }; + if let Some(t) = tool { + w.pick(t); + } + w.opts = o; + if let Some(add) = add { + w.add = add; + } + if let Some(n) = ui.get("size").and_then(Value::as_f64) { + w.set_size(n); + } + if let Some(n) = ui.get("hardness").and_then(Value::as_f64).filter(|n| n.is_finite()) { + w.hardness = (n as f32).clamp(0.0, 100.0); + } + if let Some(b) = ui.get("sampleAllLayers").and_then(Value::as_bool) { + w.sample_all = b; + } + if let Some(b) = ui.get("hideView").and_then(Value::as_bool) { + w.hide_view = b; + } + if w.opts != before { + w.stale(); + } + if let Some((pts, alt)) = stroke { + if w.tool == SmTool::PolygonalLasso { + w.poly = Some((w.add != alt, pts)); + w.close_polygon(); + } else if let Some((first, rest)) = pts.split_first() { + w.begin(*first, alt); + for p in rest { + w.extend(*p); + } + w.end_gesture(); + } + } + let sample_all = w.sample_all; + for (key, edit) in [ + ("selectSubject", json!({"tool": "subject", "sampleAllLayers": sample_all})), + ("clearSelection", json!({"tool": "clear"})), + ("invert", json!({"tool": "invert"})), + ] { + if flag(key) { + w.add_edit(edit); + } + } + if flag("reset") { + reset(w); + } + if flag("undo") { + undo(w); + } + if flag("redo") { + redo(w); + } + } + if flag("waitPreview") { + wait_preview(app)?; + } + if flag("commit") { + return commit(app).map(|v| json!({"committed": true, "result": v})); + } + Ok(app.distort.select_mask.as_ref().map(SelectMaskWorkspace::describe).unwrap_or(Value::Null)) +} + +/// `e` (document coordinates) on the copy reduced by `k`: points, sizes and rectangles scale by +/// `1 / k`. +fn scale_edit(e: &Value, k: u32) -> Value { + let k = f64::from(k.max(1)); + let mut e = e.clone(); + let Some(o) = e.as_object_mut() else { return e }; + let scale = |v: &mut Value| { + if let Some(n) = v.as_f64() { + *v = json!(n / k); + } + }; + if let Some(pts) = o.get_mut("points").and_then(Value::as_array_mut) { + for p in pts.iter_mut().filter_map(Value::as_array_mut) { + p.iter_mut().for_each(scale); + } + } + for key in ["point", "rect"] { + if let Some(a) = o.get_mut(key).and_then(Value::as_array_mut) { + a.iter_mut().for_each(scale); + } + } + if let Some(size) = o.get("size").and_then(Value::as_f64) { + o.insert("size".into(), json!((size / k).max(1.0))); + } + e +} + +/// What a preview computation needs (moved to a worker thread). +struct Job { + doc: Arc, + revision: u64, + layer: Option, + k: u32, + target_mask: bool, + /// The committed edits and, with Real-Time Refinement, the one being drawn. + edits: Vec, + extra: Option, + /// The refinement params with `__view` and `refineStrokes`. + params: Value, + cache: Option, + proxy: Option>, + display: Option>, + texture_limit: usize, + /// [`Shown::trail`] of this preview. + trail: usize, +} + +fn job(app: &PhotocraftApp) -> Option { + if stale(app).is_some() { + return None; + } + let w = app.distort.select_mask.as_ref()?; + let st = app.session.active()?; + let k = w.proxy_k(); + let mut params = w.opts.refine_params(); + params.insert("sampleAllLayers".into(), json!(w.sample_all)); + params.insert("__view".into(), w.opts.view_params()); + let gesture = w.gesture.as_ref().and_then(|g| w.finish(g)); + let mut strokes = w.strokes.clone(); + let mut extra = None; + match gesture { + Some(Ok(e)) if w.opts.real_time => extra = Some(e), + Some(Err(s)) if w.opts.real_time => strokes.push(s), + _ => {} + } + if !strokes.is_empty() { + params.insert("refineStrokes".into(), Value::Array(strokes)); + } + if w.target_mask { + params.insert("target".into(), json!("mask")); + } + let proxy = w.preview.proxy.as_ref().filter(|(pk, rev, _)| *pk == k && *rev == st.revision).map(|(_, _, d)| Arc::clone(d)); + Some(Job { + doc: Arc::clone(&st.doc), + revision: st.revision, + layer: w.layer, + k, + target_mask: w.target_mask, + edits: w.edits.clone(), + extra, + params: Value::Object(params), + cache: w.preview.cache.clone(), + proxy, + display: crate::canvas::canvas_display(app, &st.doc, None).0, + texture_limit: w.texture_limit, + trail: match &w.gesture { + Some(g) if w.opts.real_time => g.points.len(), + Some(_) => 0, + None => usize::MAX, + }, + }) +} + +/// Unit edges between selected (≥ ½) and unselected pixels of `m` (`w × h`, reduced by `k`), in +/// document px; `None` past [`MAX_ANT_EDGES`]. +fn ant_edges(m: &[f32], w: usize, h: usize, k: f32) -> Option> { + let at = |x: i64, y: i64| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m.get(y as usize * w + x as usize).is_some_and(|v| *v >= 0.5); + let mut out = Vec::new(); + for y in 0..h as i64 { + for x in 0..w as i64 { + if !at(x, y) { + continue; + } + let (fx, fy) = (x as f32, y as f32); + for (n, a, b) in [ + (at(x - 1, y), [fx, fy], [fx, fy + 1.0]), + (at(x + 1, y), [fx + 1.0, fy], [fx + 1.0, fy + 1.0]), + (at(x, y - 1), [fx, fy], [fx + 1.0, fy]), + (at(x, y + 1), [fx, fy + 1.0], [fx + 1.0, fy + 1.0]), + ] { + if !n { + out.push([[a[0] * k, a[1] * k], [b[0] * k, b[1] * k]]); + } + } + if out.len() > MAX_ANT_EDGES { + return None; + } + } + } + Some(out) +} + +/// Count selected pixels without allocating a canvas-sized mask. Missing tiles use the surface's +/// default value, including an inverted selection whose default is selected. +fn selected_pixels(selection: Option<&Surface>, area: Rect) -> usize { + let Some(surface) = selection else { return 0 }; + let default_selected = surface.default_pixel().first().is_some_and(|v| *v >= 0.5); + let area_pixels = (area.width() as usize).saturating_mul(area.height() as usize); + let mut count = if default_selected { area_pixels } else { 0 }; + for (coord, _) in surface.tiles() { + let tile_area = coord.rect().intersect(&area); + if tile_area.is_empty() { + continue; + } + let pixels = (tile_area.width() as usize).saturating_mul(tile_area.height() as usize); + if default_selected { + count = count.saturating_sub(pixels); + } + let values = photocraft_algo::selection::mask_from_surface(Some(surface), tile_area); + count = count.saturating_add(values.iter().filter(|v| **v >= 0.5).count()); + } + count +} + +/// Generate ants only when the dense temporary mask fits the preview budget. +fn preview_ants(selection: Option<&Surface>, area: Rect, k: f32) -> Option> { + let (w, h) = (area.width() as usize, area.height() as usize); + if w.checked_mul(h).is_none_or(|n| n > MAX_ANT_PIXELS) { + return sparse_preview_ants(selection?, area, k); + } + let coverage = photocraft_algo::selection::mask_from_surface(selection, area); + ant_edges(&coverage, w, h, k) +} + +/// Follow sparse selections on large canvases without scanning the empty canvas. A selected +/// default or too many stored pixels falls back to omitting ants, as for too many edges. +fn sparse_preview_ants(surface: &Surface, area: Rect, k: f32) -> Option> { + if surface.default_pixel().first().is_some_and(|v| *v >= 0.5) { + return None; + } + let mut work = 0usize; + let mut edges = Vec::new(); + let mut sample = vec![0.0; surface.channels()]; + for (coord, _) in surface.tiles() { + let tile = coord.rect().intersect(&area); + if tile.is_empty() { + continue; + } + work = work.checked_add((tile.width() as usize).checked_mul(tile.height() as usize)?)?; + if work > MAX_ANT_PIXELS { + return None; + } + let coverage = photocraft_algo::selection::mask_from_surface(Some(surface), tile); + for y in tile.y0..tile.y1 { + for x in tile.x0..tile.x1 { + let index = (y - tile.y0) as usize * tile.width() as usize + (x - tile.x0) as usize; + if !coverage.get(index).is_some_and(|v| *v >= 0.5) { + continue; + } + let (fx, fy) = ((x - area.x0) as f32, (y - area.y0) as f32); + for (nx, ny, a, b) in [ + (x - 1, y, [fx, fy], [fx, fy + 1.0]), + (x + 1, y, [fx + 1.0, fy], [fx + 1.0, fy + 1.0]), + (x, y - 1, [fx, fy], [fx + 1.0, fy]), + (x, y + 1, [fx, fy + 1.0], [fx + 1.0, fy + 1.0]), + ] { + let selected = if nx < area.x0 || nx >= area.x1 || ny < area.y0 || ny >= area.y1 { + false + } else { + surface.read_pixel(nx, ny, &mut sample); + sample.first().is_some_and(|v| *v >= 0.5) + }; + if !selected { + edges.push([[a[0] * k, a[1] * k], [b[0] * k, b[1] * k]]); + if edges.len() > MAX_ANT_EDGES { + return None; + } + } + } + } + } + } + Some(edges) +} + +/// The preview of `job`: the edits applied (from where the cache left off), the edge refined and +/// the result shown in the view, as an image no wider than the device allows. +fn compute(job: Job, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Result<(Shown, BaseCache, Arc), String> { + let k = job.k.max(1); + let proxy = job.proxy.unwrap_or_else(|| Arc::new(crate::proxy::proxy_document(&job.doc, k))); + let mut doc = (*proxy).clone(); + let layer = job.layer.filter(|id| doc.layer(*id).is_some()); + let mut params = job.params; + let start = || { + let mask = layer.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()); + if job.target_mask { mask.or_else(|| doc.selection.clone()) } else { doc.selection.clone().or(mask) } + }; + let scaled = |list: &[Value]| -> Result, String> { + let list: Vec = list.iter().map(|e| scale_edit(e, k)).collect(); + select_mask_edits::parse(&json!({ "edits": list })).map_err(|e| e.to_string()) + }; + // With no edits yet, the selection Select and Mask starts from is where the first one starts. + let mut cache = BaseCache { k, revision: job.revision, edits: Vec::new(), base: start() }; + if !job.edits.is_empty() || job.extra.is_some() { + // The selection after the edits already applied, when they still lead the list. + let (from, done) = match job.cache.filter(|c| c.k == k && c.revision == job.revision && job.edits.starts_with(&c.edits)) { + Some(c) => (c.base, c.edits.len()), + None => (start(), 0), + }; + let rest = scaled(job.edits.get(done..).unwrap_or_default())?; + let base = select_mask_edits::apply(&doc, layer, from, &rest); + cache = BaseCache { k, revision: job.revision, edits: job.edits.clone(), base: base.clone() }; + let base = match &job.extra { + Some(e) => select_mask_edits::apply(&doc, layer, base, &scaled(std::slice::from_ref(e))?), + None => base, + }; + // Nothing selected is an empty selection, not "use the layer mask". + doc.selection = Some(base.unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8))); + if let Some(o) = params.as_object_mut() { + o.remove("target"); + } + } + let shown = crate::select_mask_view::preview_proxy(doc, layer, ¶ms, k, cancel); + if cancel.is_some_and(photocraft_engine::jobs::JobCtx::cancelled) { + return Err("cancelled".into()); + } + let shown = shown.ok_or("the preview failed")?; + let (pw, ph) = (shown.size.width as usize, shown.size.height as usize); + let f = crate::filter_preview_ui::texture_factor(shown.size.width, shown.size.height, job.texture_limit) as usize; + let (tw, th) = (pw.div_ceil(f.max(1)), ph.div_ceil(f.max(1))); + let buf = photocraft_compose::render_reduced(&shown, u32::try_from(tw).unwrap_or(1), u32::try_from(th).unwrap_or(1)); + let image = crate::canvas::display_image(job.display.as_deref(), &buf); + let selected = selected_pixels(shown.selection.as_ref(), shown.bounds()); + let mode = params.get("__view").and_then(|v| v.get("mode")).and_then(Value::as_str); + let ants = if mode == Some(Mode::MarchingAnts.id()) { preview_ants(shown.selection.as_ref(), shown.bounds(), k as f32) } else { None }; + let covers = [(pw as f32) * k as f32, (ph as f32) * k as f32]; + Ok((Shown { image, ants, selected, trail: job.trail, covers }, cache, proxy)) +} + +/// Takes the preview of state `generation` (made from document `revision`): the image when it is +/// still the state wanted (or a stroke is being drawn: Real-Time Refinement shows the latest one +/// it has), and the selection after the edits and the reduced copy for the next preview. +fn take_result(w: &mut SelectMaskWorkspace, generation: u64, revision: u64, r: Result<(Shown, BaseCache, Arc), String>) { + // Made from pixels the document no longer has (even mid-stroke). + if revision != w.revision { + return; + } + let pv = &mut w.preview; + match r { + Ok((shown, cache, proxy)) => { + pv.proxy = Some((cache.k, revision, proxy)); + pv.cache = Some(cache); + if generation == pv.want || w.pressed { + pv.trail = shown.trail; + if shown.trail == usize::MAX { + w.trail = None; + } + pv.shown = generation; + pv.selected = shown.selected; + pv.error = None; + pv.pending = Some(shown); + } + } + Err(e) if e == "cancelled" => {} + Err(e) => { + pv.shown = generation; + pv.error = Some(e); + } + } +} + +/// Computes the preview now, on this thread (tests and the control channel). +fn wait_preview(app: &mut PhotocraftApp) -> Result<(), String> { + drop_stale(app)?; + follow_revision(app); + let j = job(app).ok_or("Select and Mask is not open")?; + let revision = j.revision; + let r = compute(j, None); + let w = app.distort.select_mask.as_mut().ok_or("Select and Mask is not open")?; + let err = r.as_ref().err().cloned(); + let generation = w.preview.want; + take_result(w, generation, revision, r); + err.map_or(Ok(()), Err) +} + +/// Starts a background preview when the shown one is out of date, and collects a finished one. +/// While a stroke is drawn with Real-Time Refinement, a running preview is let finish (cancelling +/// it on every move would never show one) and the next starts from the latest state. +#[cfg(not(target_arch = "wasm32"))] +fn drive_preview(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if let Some((generation, revision, rx, _)) = &w.preview.running { + match rx.try_recv() { + Ok(r) => { + let (generation, revision) = (*generation, *revision); + w.preview.running = None; + take_result(w, generation, revision, r); + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + if *generation != w.preview.want + && !w.pressed + && let Some((_, _, _, job)) = &w.preview.running + { + job.cancel(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + return; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => w.preview.running = None, + } + } + let Some(w) = app.distort.select_mask.as_ref() else { return }; + let waiting = w.pressed && !w.opts.real_time; + if w.preview.running.is_some() || w.preview.shown == w.preview.want || waiting { + return; + } + let generation = w.preview.want; + let Some(j) = job(app) else { return }; + let revision = j.revision; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let ctl = photocraft_engine::jobs::JobCtx::new(); + let work = ctl.clone(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("select-mask-preview".into()).spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(j, Some(&work)))).unwrap_or_else(|_| Err("the preview failed".into())); + let _ = tx.send(r); + repaint.request_repaint(); + }); + let Some(w) = app.distort.select_mask.as_mut() else { return }; + match spawned { + Ok(_) => w.preview.running = Some((generation, revision, rx, ctl)), + Err(e) => w.preview.error = Some(format!("could not start the preview: {e}")), + } +} + +/// The web build has no threads: the preview is computed when the pointer comes up. +#[cfg(target_arch = "wasm32")] +fn drive_preview(app: &mut PhotocraftApp, _ctx: &egui::Context) { + let due = app.distort.select_mask.as_ref().is_some_and(|w| w.preview.shown != w.preview.want && !w.pressed); + if due { + let _ = wait_preview(app); + } +} + +/// Pointer in document coordinates (from the view or the control channel). +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + if drop_stale(app).is_err() { + return; + } + let Some(w) = app.distort.select_mask.as_mut() else { return }; + match ev { + ToolEvent::Down { x, y, .. } if w.tool == SmTool::Zoom => { + let f = if mods.alt { 0.5 } else { 2.0 }; + w.zoom = (w.zoom * f).clamp(0.01, 32.0); + w.center = [x, y]; + } + ToolEvent::Down { x, y, .. } => w.begin([x, y], mods.alt), + ToolEvent::Move { x, y, .. } => { + w.hover = Some([x, y]); + w.extend([x, y]); + } + ToolEvent::Up { x, y } => { + w.extend([x, y]); + w.end_gesture(); + } + } +} + +/// The workspace's keys (unless a text field has the keyboard): tools, brush size, views, undo. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + use egui::{Key, Modifiers}; + let picker = crate::color_picker_ui::any_open(app); + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if picker { + // The overlay colour's Color Picker takes the keys, as the swatch popup did. + w.hide_view = false; + return; + } + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + if ctx.input_mut(|i| i.consume_key(cmd_shift, Key::Z)) { + redo(w); + return; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::Z)) { + undo(w); + return; + } + if egui::Popup::is_any_open(ctx) { + w.hide_view = false; + return; + } + let pressed = |k: Key, m: Modifiers| ctx.input_mut(|i| i.consume_key(m, k)); + if pressed(Key::W, Modifiers::SHIFT) { + w.pick(if w.w_tool == SmTool::Quick { SmTool::Object } else { SmTool::Quick }); + } + if pressed(Key::L, Modifiers::SHIFT) { + w.pick(if w.l_tool == SmTool::Lasso { SmTool::PolygonalLasso } else { SmTool::Lasso }); + } + for (k, t) in + [(Key::W, w.w_tool), (Key::R, SmTool::RefineEdge), (Key::B, SmTool::Brush), (Key::L, w.l_tool), (Key::H, SmTool::Hand), (Key::Z, SmTool::Zoom)] + { + if pressed(k, Modifiers::NONE) { + w.pick(t); + } + } + if pressed(Key::OpenBracket, Modifiers::NONE) { + let s = w.size(); + w.set_size((s * 0.9).min(s - 1.0)); + } + if pressed(Key::CloseBracket, Modifiers::NONE) { + let s = w.size(); + w.set_size((s * 1.1).max(s + 1.0)); + } + if (pressed(Key::Backspace, Modifiers::NONE) || pressed(Key::Delete, Modifiers::NONE)) + && let Some((_, pts)) = w.poly.as_mut() + { + pts.pop(); + if pts.is_empty() { + w.poly = None; + } + } + // The views: their letters, F cycles, J Show Edge, P Show Original; holding X shows the image. + let before = w.opts.clone(); + if pressed(Key::F, Modifiers::NONE) { + w.opts.view = w.opts.view.next(); + } + for m in Mode::ALL { + if pressed(m.key(), Modifiers::NONE) { + w.opts.view = m; + } + } + if pressed(Key::J, Modifiers::NONE) { + w.opts.show_edge = !w.opts.show_edge; + } + if pressed(Key::P, Modifiers::NONE) { + w.opts.show_original = !w.opts.show_original; + } + if w.opts != before { + w.stale(); + } + w.hide_view = ctx.input(|i| i.key_down(Key::X) && !i.modifiers.command && !i.modifiers.alt && !i.modifiers.ctrl); +} + +/// A view: screen px per document px and the document point at the centre of `area`. +#[derive(Clone, Copy)] +struct View { + area: ERect, + zoom: f32, + center: [f64; 2], +} + +impl View { + fn to_screen(self, p: [f64; 2]) -> Pos2 { + let c = self.area.center(); + pos2(c.x + ((p[0] - self.center[0]) as f32) * self.zoom, c.y + ((p[1] - self.center[1]) as f32) * self.zoom) + } + fn to_doc(self, s: Pos2) -> [f64; 2] { + let c = self.area.center(); + [self.center[0] + f64::from((s.x - c.x) / self.zoom), self.center[1] + f64::from((s.y - c.y) / self.zoom)] + } + fn rect(self, r: Rect) -> ERect { + ERect::from_min_max(self.to_screen([f64::from(r.x0), f64::from(r.y0)]), self.to_screen([f64::from(r.x1), f64::from(r.y1)])) + } +} + +/// Upload what changed: the document (for X and until the first preview) and a finished preview. +fn upload(app: &mut PhotocraftApp, ctx: &egui::Context) { + let need_image = app.distort.select_mask.as_ref().is_some_and(|w| w.image_tex.is_none()); + if need_image { + let img = app.session.active().map(|st| { + let (cw, ch) = (st.doc.size.width, st.doc.size.height); + let f = crate::filter_preview_ui::texture_factor(cw, ch, PROXY_SIDE); + let buf = photocraft_compose::render_reduced(&st.doc, cw.div_ceil(f.max(1)), ch.div_ceil(f.max(1))); + let (display, _) = crate::canvas::canvas_display(app, &st.doc, None); + crate::canvas::display_image(display.as_deref(), &buf) + }); + if let (Some(w), Some(img)) = (app.distort.select_mask.as_mut(), img) { + w.image_tex = Some(ctx.load_texture("select-mask-image", img, egui::TextureOptions::LINEAR)); + } + } + let Some(w) = app.distort.select_mask.as_mut() else { return }; + if let Some(s) = w.preview.pending.take() { + match &mut w.preview.tex { + Some(t) => t.set(s.image, egui::TextureOptions::LINEAR), + None => w.preview.tex = Some(ctx.load_texture("select-mask-preview", s.image, egui::TextureOptions::LINEAR)), + } + w.preview.ants = s.ants; + w.preview.covers = s.covers; + } +} + +/// A section of the Properties panel: a bold title that folds it, open by default. +fn section(ui: &mut egui::Ui, id: &str, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::CollapsingHeader::new(egui::RichText::new(title).strong()).id_salt(id).default_open(true).show(ui, body); +} + +/// The options bar of the current tool. +fn options_bar(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace) -> Option { + let mut edit = None; + crate::icons::button(ui, w.tool.icon(), 26.0, false, &w.tool.tip()); + ui.add_space(6.0); + if w.tool.edits() { + if crate::icons::button(ui, "plus", 26.0, w.add, tl!("Add")).clicked() { + w.add = true; + } + if crate::icons::button(ui, "minus", 26.0, !w.add, tl!("Subtract")).clicked() { + w.add = false; + } + ui.add_space(8.0); + } + if w.tool.brushes() { + ui.label(tl!("Size:")); + let mut size = w.size() as f32; + if widgets::value_field(ui, &mut size, 1.0..=5000.0, " px", 64.0).changed() { + w.set_size(f64::from(size)); + } + ui.add_space(6.0); + } + if w.tool == SmTool::Brush { + ui.label(tl!("Hardness:")); + widgets::value_field(ui, &mut w.hardness, 0.0..=100.0, "%", 56.0); + ui.add_space(6.0); + } + if matches!(w.tool, SmTool::Quick | SmTool::RefineEdge | SmTool::Object) && widgets::checkbox(ui, &mut w.sample_all, tl!("Sample All Layers")).changed() { + w.stale(); + } + if w.tool.edits() { + ui.add_space(8.0); + if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() { + edit = Some(json!({"tool": "subject", "sampleAllLayers": w.sample_all})); + } + // Refine Hair needs hair detection PhotoCraft doesn't have yet (#2671): greyed, saying so. + ui.add_enabled_ui(false, |ui| widgets::secondary_button(ui, tl!("Refine Hair"), 0.0)) + .inner + .on_disabled_hover_text(tl!("Refine Hair needs hair detection, which isn't available yet")); + } + edit +} + +/// The Properties panel. Returns the tool edit its buttons made (Clear Selection, Invert). +fn properties(ui: &mut egui::Ui, w: &mut SelectMaskWorkspace, t: &Tokens) -> Option { + let mut edit = None; + ui.spacing_mut().item_spacing.y = 6.0; + widgets::checkbox(ui, &mut w.opts.remember, tl!("Remember Settings")); + widgets::hairline(ui); + section(ui, "sm-view", tl!("View Mode"), |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("View")).color(t.text_dim)); + let labels: Vec = Mode::ALL.iter().map(|m| format!("{} ({:?})", m.label(), m.key())).collect(); + let options: Vec<(Mode, &str)> = Mode::ALL.iter().zip(&labels).map(|(m, l)| (*m, l.as_str())).collect(); + // The labels carry the view's letter, already translated. + widgets::dropdown_literal(ui, "sm-view-mode", tl!("View"), &mut w.opts.view, &options, 112.0); + }); + // Beside the view list, as Photoshop's; long labels are cut rather than widening the + // panel past its edge (every row below lines up on that edge). + let room = ui.available_width(); + ui.vertical(|ui| { + ui.set_max_width(room); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + widgets::checkbox(ui, &mut w.opts.show_edge, &format!("{} (J)", tl!("Show Edge"))); + widgets::checkbox(ui, &mut w.opts.show_original, &format!("{} (P)", tl!("Show Original"))); + widgets::checkbox(ui, &mut w.opts.real_time, tl!("Real-Time Refinement")); + widgets::checkbox(ui, &mut w.opts.high_quality, tl!("High Quality Preview")); + }); + }); + let i = mode_index(w.opts.view); + if w.opts.view.default_opacity().is_some() { + let label = if w.opts.view == Mode::OnionSkin { tl!("Transparency") } else { tl!("Opacity") }; + if let Some(o) = w.opts.opacity.get_mut(i) { + widgets::slider_row(ui, label, o, 0.0..=100.0, "%", None); + } + } + if w.opts.view == Mode::Overlay { + ui.horizontal(|ui| { + ui.label(tl!("Color")); + widgets::color_edit_button_srgb(ui, &mut w.opts.overlay_color, &crate::color_picker_ui::title_for(tl!("Overlay Color"))); + ui.add_space(8.0); + ui.label(tl!("Indicates")); + widgets::dropdown( + ui, + "sm-indicates", + tl!("Indicates"), + &mut w.opts.indicates_selected, + &[(false, tl!("Masked Areas")), (true, tl!("Selected Areas"))], + 120.0, + ); + }); + } + }); + section(ui, "sm-refine-mode", tl!("Refine Mode"), |ui| { + ui.horizontal(|ui| { + let _ = ui.selectable_label(true, tl!("Color Aware")); + // Object Aware needs a learned segmentation model (#2788). + ui.add_enabled_ui(false, |ui| ui.selectable_label(false, tl!("Object Aware"))) + .inner + .on_disabled_hover_text(tl!("Object Aware needs a segmentation model, which isn't available yet")); + }); + }); + section(ui, "sm-edge", tl!("Edge Detection"), |ui| { + widgets::slider_row(ui, tl!("Radius"), &mut w.opts.radius, 0.0..=250.0, " px", None); + widgets::checkbox(ui, &mut w.opts.smart_radius, tl!("Smart Radius")); + }); + section(ui, "sm-global", tl!("Global Refinements"), |ui| { + widgets::slider_row(ui, tl!("Smooth"), &mut w.opts.smooth, 0.0..=100.0, "", None); + widgets::slider_row(ui, tl!("Feather"), &mut w.opts.feather, 0.0..=1000.0, " px", None); + widgets::slider_row(ui, tl!("Contrast"), &mut w.opts.contrast, 0.0..=100.0, "%", None); + widgets::slider_row(ui, tl!("Shift Edge"), &mut w.opts.shift_edge, -100.0..=100.0, "%", None); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Clear Selection"), 0.0).clicked() { + edit = Some(json!({"tool": "clear"})); + } + if widgets::secondary_button(ui, tl!("Invert"), 0.0).clicked() { + edit = Some(json!({"tool": "invert"})); + } + }); + }); + section(ui, "sm-output", tl!("Output Settings"), |ui| { + widgets::checkbox(ui, &mut w.opts.decontaminate, tl!("Decontaminate Colors")); + ui.add_enabled_ui(w.opts.decontaminate, |ui| widgets::slider_row(ui, tl!("Amount"), &mut w.opts.amount, 0.0..=100.0, "%", None)); + w.opts.follow_decontaminate(); + ui.horizontal(|ui| { + ui.label(tl!("Output To")); + let labels = + [tl!("Selection"), tl!("Layer Mask"), tl!("New Layer"), tl!("New Layer With Mask"), tl!("New Document"), tl!("New Document With Mask")]; + // Decontaminating makes new pixels: only the new layer and document targets apply. + let options: Vec<(String, &str)> = OUTPUTS + .iter() + .zip(labels) + .filter(|(o, _)| !w.opts.decontaminate || !matches!(**o, "selection" | "layerMask")) + .map(|(o, l)| (o.to_string(), l)) + .collect(); + widgets::dropdown(ui, "sm-output-to", tl!("Output To"), &mut w.opts.output, &options, 170.0); + }); + }); + if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) { + ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0)); + } + edit +} + +/// The document changed under the workspace: new textures, a new preview, and nothing of the old +/// one shown or reused. +fn follow_revision(app: &mut PhotocraftApp) { + let rev = app.session.active().map(|s| s.revision); + if let Some(w) = app.distort.select_mask.as_mut() + && Some(w.revision) != rev + { + w.revision = rev.unwrap_or(w.revision); + w.image_tex = None; + w.preview.tex = None; + w.preview.pending = None; + w.preview.cache = None; + w.stale(); + } +} + +/// Draws the document, the refined edge, the stroke being drawn and the polygon in progress. +fn paint_canvas(ui: &mut egui::Ui, ctx: &egui::Context, w: &mut SelectMaskWorkspace, t: &Tokens, painter: &egui::Painter, resp: &egui::Response, area: ERect) { + let v = View { area, zoom: w.zoom, center: w.center }; + let clip = painter.with_clip_rect(area); + let img = v.rect(w.canvas); + let uv = ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + widgets::checker(&clip, img.intersect(area), 8.0); + let preview = w.preview.tex.as_ref().filter(|_| !w.hide_view); + match (preview, &w.image_tex) { + (Some(tex), _) => { + // The reduced copy's last row and column may reach past the canvas. + let covers = ERect::from_min_size(img.min, vec2(w.preview.covers[0] * v.zoom, w.preview.covers[1] * v.zoom)); + clip.with_clip_rect(img.intersect(area)).image(tex.id(), covers, uv, Color32::WHITE); + } + (None, Some(tex)) => { + clip.image(tex.id(), img, uv, Color32::WHITE); + } + (None, None) => {} + } + // Marching Ants: the refined selection's edge, black dashes on white. + if !w.hide_view + && w.opts.view == Mode::MarchingAnts + && let Some(edges) = &w.preview.ants + { + let phase = (ui.input(|i| i.time) * 8.0) as usize; + for e in edges { + let (a, b) = (v.to_screen([f64::from(e[0][0]), f64::from(e[0][1])]), v.to_screen([f64::from(e[1][0]), f64::from(e[1][1])])); + let dark = ((e[0][0] + e[0][1]) as usize / 4 + phase).is_multiple_of(2); + clip.line_segment([a, b], Stroke::new(1.0, if dark { Color32::BLACK } else { Color32::WHITE })); + } + ctx.request_repaint_after(std::time::Duration::from_millis(120)); + } + if w.preview.shown != w.preview.want && !w.pressed { + let sp = ERect::from_min_size(area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0)); + ui.put(sp, egui::Spinner::new().size(16.0)); + } + // The stroke being drawn: the part the preview doesn't show yet (all of it without + // Real-Time Refinement), joined to the part it does, so the stroke follows the pointer. + let ink = Color32::from_rgba_unmultiplied(t.accent.r(), t.accent.g(), t.accent.b(), 110); + if let Some(g) = w.gesture.as_ref().or(w.trail.as_ref()) { + let line: Vec = g.points.iter().map(|p| v.to_screen(*p)).collect(); + match g.tool { + SmTool::Quick | SmTool::Brush | SmTool::RefineEdge if w.preview.trail != usize::MAX => { + let tail = line.get(w.preview.trail.saturating_sub(1)..).unwrap_or_default(); + let r = w.size() as f32 * 0.5 * v.zoom; + for p in tail { + clip.circle_filled(*p, r, ink); + } + if tail.len() > 1 { + clip.add(egui::Shape::line(tail.to_vec(), Stroke::new(2.0 * r, ink))); + } + } + SmTool::Lasso => { + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } + SmTool::Object => { + if let (Some(a), Some(b)) = (line.first(), line.last()) { + clip.rect_stroke(ERect::from_two_pos(*a, *b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); + } + } + _ => {} + } + } + if let Some((_, pts)) = &w.poly { + let mut line: Vec = pts.iter().map(|p| v.to_screen(*p)).collect(); + if let Some(hp) = resp.hover_pos() { + line.push(hp); + } + clip.add(egui::Shape::line(line, Stroke::new(1.0, t.accent))); + } +} + +/// The cursor over the document, and the image's outline. +fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::Painter, resp: &egui::Response, v: View, pan: bool, alt: bool) { + let (img, t) = (v.rect(w.canvas), Tokens::get(ctx)); + // The cursor: Photoshop's hand while panning (closed while dragging), the brush's circle + // (with + or −), the zoom glass, else a crosshair. + if let Some(hp) = resp.hover_pos() { + w.hover = Some(v.to_doc(hp)); + } + if pan && (resp.dragged() || resp.is_pointer_button_down_on()) { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } else if let Some(hp) = resp.hover_pos() { + if pan { + ctx.set_cursor_icon(egui::CursorIcon::Grab); + } else if w.tool.brushes() { + let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0); + clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); + clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE)); + let add = w.add != alt; + for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] { + clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s); + if add { + clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s); + } + } + ctx.set_cursor_icon(egui::CursorIcon::None); + } else { + ctx.set_cursor_icon(match w.tool { + SmTool::Zoom if alt => egui::CursorIcon::ZoomOut, + SmTool::Zoom => egui::CursorIcon::ZoomIn, + _ => egui::CursorIcon::Crosshair, + }); + } + } + clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); +} + +/// Pan, zoom-click and tool gestures on the document, as events for `pointer`. +#[allow(clippy::too_many_arguments)] +fn canvas_input( + ui: &egui::Ui, + ctx: &egui::Context, + w: &mut SelectMaskWorkspace, + resp: &egui::Response, + v: View, + pan: bool, + events: &mut Vec, + mods: &mut egui::Modifiers, +) { + if pan && resp.dragged() { + let dl = resp.drag_delta(); + w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)]; + } else if w.tool == SmTool::PolygonalLasso && !pan { + *mods = ui.input(|i| i.modifiers); + if resp.double_clicked() { + w.close_polygon(); + } else if resp.clicked() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } else if !pan + && resp.is_pointer_button_down_on() + && let Some(pp) = resp.interact_pointer_pos() + { + let q = v.to_doc(pp); + *mods = ui.input(|i| i.modifiers); + if w.pressed { + events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: 1.0 }); + ctx.request_repaint(); + } else { + w.pressed = true; + events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: 1.0 }); + } + } + if w.pressed && !resp.is_pointer_button_down_on() { + w.pressed = false; + let q = w.hover.unwrap_or(w.center); + events.push(ToolEvent::Up { x: q[0], y: q[1] }); + } +} + +/// Draws the workspace (a full-window layer over the app). +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + follow_revision(app); + upload(app, ctx); + let title = app.distort.select_mask.as_ref().map(|w| { + crate::canvas::tab_title_at( + app, + app.session.documents().iter().position(|d| d.doc.id == w.document).unwrap_or(0), + if w.zoom > 0.0 { w.zoom * 100.0 } else { 100.0 }, + ) + }); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let mut action: Option<&str> = None; + let mut events: Vec = Vec::new(); + let mut mods = egui::Modifiers::NONE; + let mut edit: Option = None; + let mut do_reset = false; + egui::Area::new(egui::Id::unique("select-mask-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + let Some(w) = app.distort.select_mask.as_mut() else { return }; + let before = (w.opts.clone(), w.sample_all); + w.texture_limit = ctx.input(|i| i.max_texture_side); + let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); + let painter = ui.painter().clone(); + painter.rect_filled(full, 0.0, t.chrome); + // The options bar across the top, as Photoshop's. + let bar = ERect::from_min_size(full.min, vec2(full.width(), BAR_H)); + painter.rect_filled(bar, 0.0, t.dock); + painter.line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.separator)); + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(8.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + if let Some(e) = options_bar(&mut bui, w) { + edit = Some(e); + } + let body = ERect::from_min_max(pos2(full.left(), bar.bottom()), full.max); + // The tools on the left: Lasso and Polygonal Lasso share a slot (right-click to pick). + let left = ERect::from_min_size(body.min, vec2(LEFT_W, body.height())); + painter.rect_filled(left, 0.0, t.dock); + painter.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let mut strip = ui.new_child(egui::UiBuilder::new().max_rect(left.shrink2(vec2(6.0, 8.0)))); + strip.spacing_mut().item_spacing.y = 4.0; + for slot in [SmTool::Quick, SmTool::RefineEdge, SmTool::Brush, SmTool::Object, w.l_tool, SmTool::Hand, SmTool::Zoom] { + let r = crate::icons::button(&mut strip, slot.icon(), 34.0, w.tool == slot, &slot.tip()); + if r.clicked() { + w.pick(slot); + } + if matches!(slot, SmTool::Lasso | SmTool::PolygonalLasso) { + // The slot's flyout triangle, as the main toolbar's. + let c = r.rect.right_bottom() - vec2(3.0, 3.0); + painter.add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.icon, Stroke::NONE)); + r.context_menu(|ui| { + for l in [SmTool::Lasso, SmTool::PolygonalLasso] { + if ui.selectable_label(w.tool == l, l.tip()).clicked() { + w.pick(l); + ui.close(); + } + } + }); + } + } + // The Properties panel on the right. + let right = ERect::from_min_size(pos2(body.right() - PANEL_W, body.top()), vec2(PANEL_W, body.height())); + painter.rect_filled(right, 0.0, t.dock); + painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator)); + let head = ERect::from_min_size(right.min, vec2(PANEL_W, TAB_H)); + painter.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); + painter.text(head.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, tl!("Properties"), FontId::proportional(12.0), t.text); + let mut props = ui.new_child( + egui::UiBuilder::new().max_rect(ERect::from_min_max(head.left_bottom() + vec2(14.0, 10.0), pos2(right.right() - 14.0, right.bottom() - 56.0))), + ); + egui::ScrollArea::vertical().id_salt("sm-props").show(&mut props, |ui| { + if let Some(e) = properties(ui, w, &t) { + edit = Some(e); + } + }); + // Reset at the left, OK and Cancel against the right edge. + let foot = ERect::from_min_size(pos2(right.left() + 10.0, right.bottom() - 46.0), vec2(PANEL_W - 20.0, 32.0)); + painter.line_segment([pos2(right.left(), foot.top() - 6.0), pos2(right.right(), foot.top() - 6.0)], Stroke::new(1.0, t.separator)); + let mut fb = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::right_to_left(egui::Align::Center))); + if widgets::secondary_button(&mut fb, tl!("Cancel"), 70.0).clicked() { + action = Some("cancel"); + } + if widgets::primary_button(&mut fb, tl!("OK"), 64.0).clicked() { + action = Some("ok"); + } + fb.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if crate::icons::button(ui, "undo-2", 24.0, false, tl!("Reset")).clicked() { + do_reset = true; + } + }); + // The document under its tab. + let doc_area = ERect::from_min_max(pos2(left.right(), body.top()), pos2(right.left(), body.bottom())); + let tab = ERect::from_min_size(doc_area.min, vec2(doc_area.width(), TAB_H)); + painter.rect_filled(tab, 0.0, t.dock); + painter.line_segment([tab.left_bottom(), tab.right_bottom()], Stroke::new(1.0, t.separator)); + let title = title.clone().unwrap_or_default(); + let galley = painter.layout_no_wrap(title, FontId::proportional(12.0), t.text); + let tab_w = (galley.size().x + 28.0).min(tab.width()); + let active_tab = ERect::from_min_size(tab.min, vec2(tab_w, TAB_H)); + painter.rect_filled(active_tab, 0.0, t.canvas); + painter.galley(pos2(active_tab.left() + 14.0, active_tab.center().y - galley.size().y / 2.0), galley, t.text); + let area = ERect::from_min_max(pos2(doc_area.left(), tab.bottom()), doc_area.max); + painter.rect_filled(area, 0.0, t.canvas); + let (cw, chh) = (w.canvas.width() as f32, w.canvas.height() as f32); + if w.zoom <= 0.0 { + w.zoom = ((area.width() - 40.0) / cw.max(1.0)).min((area.height() - 40.0) / chh.max(1.0)).clamp(0.01, 4.0); + } + let resp = ui.interact(area, egui::Id::unique("select-mask-view"), Sense::click_and_drag()); + // Over the image the keys are the workspace's: a panel button clicked last keeps the focus + // otherwise, and Space (held to pan) would press it again. A number being typed keeps it. + if resp.hovered() + && !ctx.text_edit_focused() + && let Some(focus) = ctx.memory(|m| m.focused()) + { + ctx.memory_mut(|m| m.surrender_focus(focus)); + } + if resp.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0.0 + && let Some(hp) = resp.hover_pos() + { + let v = View { area, zoom: w.zoom, center: w.center }; + let at = v.to_doc(hp); + w.zoom = (w.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + let after = View { area, zoom: w.zoom, center: w.center }.to_doc(hp); + w.center = [w.center[0] + at[0] - after[0], w.center[1] + at[1] - after[1]]; + } + } + let v = View { area, zoom: w.zoom, center: w.center }; + paint_canvas(ui, ctx, w, &t, &painter, &resp, area); + // Space, the middle button or the Hand pan. The Polygonal Lasso takes clicks: each adds a + // vertex, a double-click closes. + let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()) || w.tool == SmTool::Hand; + paint_cursor(ctx, w, &painter.with_clip_rect(area), &resp, v, pan, ui.input(|i| i.modifiers.alt)); + canvas_input(ui, ctx, w, &resp, v, pan, &mut events, &mut mods); + if (w.opts.clone(), w.sample_all) != before { + w.stale(); + } + }); + for ev in events { + pointer(app, ev, mods); + } + if let Some(w) = app.distort.select_mask.as_mut() { + if let Some(e) = edit { + w.add_edit(e); + } + if do_reset { + reset(w); + } + } + drive_preview(app, ctx); + match action { + Some("ok") => { + let _ = commit(app); + } + Some("cancel") => cancel(app), + _ => {} + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs new file mode 100644 index 0000000000..95a3a2ace9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/select_mask_ui/tests.rs @@ -0,0 +1,810 @@ +use super::*; +use egui::{Key, Modifiers}; +use egui_kittest::kittest::{NodeT, Queryable}; + +/// A 160 × 100 document: warm on the left (x < 80), cool on the right, with the top-left +/// 20 × 20 selected. +fn app_with_selection() -> PhotocraftApp { + app_sized(160, 100) +} + +fn app_sized(w: i32, h: i32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": w, "height": h, "depth": 8})).unwrap(); + app.session + .edit("paint", |doc, _| { + let s = doc.layers[0].surface_mut().unwrap(); + s.fill_rect(Rect::new(0, 0, w / 2, h), &[0.85, 0.55, 0.25, 1.0]); + s.fill_rect(Rect::new(w / 2, 0, w, h), &[0.15, 0.35, 0.75, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.remove(REMEMBERED); + }); + app.sync_views(); + app +} + +fn ws(app: &PhotocraftApp) -> &SelectMaskWorkspace { + app.distort.select_mask.as_ref().unwrap() +} + +fn ui(app: &mut PhotocraftApp, v: Value) -> Value { + control(app, &v).unwrap() +} + +fn selected(app: &mut PhotocraftApp) -> u64 { + ui(app, json!({"waitPreview": true}))["selectedProxyPixels"].as_u64().unwrap() +} + +fn cov(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)) +} + +/// Photoshop opens on Quick Selection adding, the Onion Skin view, Real-Time Refinement on, and +/// output to the selection. +#[test] +fn opens_like_photoshop() { + let mut app = app_with_selection(); + crate::menus::invoke(&mut app, &egui::Context::default(), "select.selectAndMask", json!({})).unwrap(); + let d = ws(&app).describe(); + assert_eq!((d["tool"].as_str(), d["mode"].as_str(), d["size"].as_f64()), (Some("quick"), Some("add"), Some(30.0))); + let s = &d["settings"]; + assert_eq!((s["view"].as_str(), s["output"].as_str()), (Some("onionSkin"), Some("selection"))); + assert_eq!((s["realTimeRefinement"].as_bool(), s["highQualityPreview"].as_bool(), s["remember"].as_bool()), (Some(true), Some(false), Some(false))); + assert_eq!((s["opacity"]["onionSkin"].as_f64(), s["opacity"]["onBlack"].as_f64()), (Some(50.0), Some(100.0))); + assert_eq!(selected(&mut app), 400, "the 20 × 20 selection"); + assert!(ws(&app).describe()["previewReady"].as_bool().unwrap()); +} + +#[test] +fn opening_needs_a_selection_or_a_layer_mask() { + let mut app = app_with_selection(); + app.session.execute("select.deselect", json!({})).unwrap(); + assert!(open(&mut app).is_err()); + assert!(app.distort.select_mask.is_none()); + // `ui` addresses an open workspace only. + let e = crate::distort_ui::menu(&mut app, &egui::Context::default(), "select.selectAndMask", &json!({"ui": {}})).unwrap(); + assert!(e.is_err()); +} + +/// Every tool's stroke is an edit (the Refine Edge Brush's a stroke), each one undoable. +#[test] +fn tools_make_edits_and_undo_and_redo_them() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let before = selected(&mut app); + // Quick Selection across the cool half grows the selection over it. + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let quick = selected(&mut app); + assert!(quick > before + 3000, "{before} → {quick}"); + // Brush, Lasso and Polygonal Lasso subtract with Alt. + ui(&mut app, json!({"tool": "brush", "size": 10, "hardness": 50, "stroke": {"points": [[100.0, 10.0], [100.0, 90.0]], "alt": true}})); + let brushed = selected(&mut app); + assert!(brushed < quick, "{quick} → {brushed}"); + ui(&mut app, json!({"tool": "lasso", "mode": "subtract", "stroke": {"points": [[140.0, 0.0], [160.0, 0.0], [160.0, 100.0], [140.0, 100.0]]}})); + ui(&mut app, json!({"tool": "polygonalLasso", "mode": "add", "stroke": {"points": [[30.0, 30.0], [50.0, 30.0], [50.0, 50.0]]}})); + ui(&mut app, json!({"tool": "refineEdge", "stroke": {"points": [[80.0, 20.0], [80.0, 80.0]]}})); + let d = ws(&app).describe(); + assert_eq!((d["edits"].as_u64(), d["strokes"].as_u64(), d["undo"].as_u64()), (Some(4), Some(1), Some(5))); + let e = &ws(&app).edits; + assert_eq!( + (e[1]["tool"].as_str(), e[1]["mode"].as_str(), e[1]["hardness"].as_f64(), e[1]["size"].as_f64()), + (Some("brush"), Some("subtract"), Some(50.0), Some(10.0)) + ); + assert_eq!((e[2]["tool"].as_str(), e[2]["mode"].as_str()), (Some("lasso"), Some("subtract"))); + assert_eq!((e[3]["tool"].as_str(), e[3]["points"].as_array().map(Vec::len)), (Some("lasso"), Some(3))); + // Undo everything but the Quick Selection stroke, then redo the Brush's. + for _ in 0..4 { + ui(&mut app, json!({"undo": true})); + } + assert_eq!(selected(&mut app), quick); + ui(&mut app, json!({"redo": true})); + assert_eq!(selected(&mut app), brushed); + // A new edit drops what could be redone. + ui(&mut app, json!({"invert": true})); + assert_eq!(ws(&app).describe()["redo"].as_u64(), Some(0)); +} + +/// Object Selection: a click picks the object under it, a drag the object in the rectangle. +#[test] +fn object_selection_clicks_and_drags() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ws_mut(&mut app).zoom = 1.0; + ui(&mut app, json!({"tool": "object", "stroke": {"points": [[120.0, 50.0], [121.0, 50.0]]}})); + ui(&mut app, json!({"stroke": {"points": [[90.0, 10.0], [150.0, 90.0]]}})); + let e = &ws(&app).edits; + assert_eq!(e[0]["point"], json!([120.0, 50.0])); + assert_eq!(e[1]["rect"], json!([90.0, 10.0, 60.0, 80.0])); +} + +fn ws_mut(app: &mut PhotocraftApp) -> &mut SelectMaskWorkspace { + app.distort.select_mask.as_mut().unwrap() +} + +/// Select Subject, Clear Selection and Invert are edits too; Reset drops everything at once, +/// undoably. +#[test] +fn buttons_and_reset() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"clearSelection": true})); + assert_eq!(selected(&mut app), 0); + ui(&mut app, json!({"invert": true})); + assert_eq!(selected(&mut app), 160 * 100); + ui(&mut app, json!({"selectSubject": true})); + assert_eq!(ws(&app).edits.last().and_then(|e| e["tool"].as_str()), Some("subject")); + ui(&mut app, json!({"radius": 12, "view": "blackWhite", "remember": true})); + let d = ui(&mut app, json!({"reset": true})); + assert_eq!((d["edits"].as_u64(), d["settings"]["radius"].as_f64(), d["settings"]["view"].as_str()), (Some(0), Some(0.0), Some("onionSkin"))); + assert_eq!(d["settings"]["remember"].as_bool(), Some(true), "Reset keeps Remember Settings"); + assert_eq!(selected(&mut app), 400); + let d = ui(&mut app, json!({"undo": true})); + assert_eq!((d["edits"].as_u64(), d["settings"]["radius"].as_f64()), (Some(3), Some(12.0))); +} + +/// OK runs `select.refineEdge` with the edits, strokes and settings: one history step, the same +/// result as the command with those params, and the workspace closes. +#[test] +fn ok_is_one_step_and_matches_the_command() { + let mut app = app_with_selection(); + let mut direct = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"radius": 2, "stroke": {"points": [[120.0, 50.0]]}})); + ui(&mut app, json!({"tool": "brush", "stroke": {"points": [[40.0, 60.0], [60.0, 60.0]]}})); + let params = commit_params(ws(&app)); + let steps = app.session.active().unwrap().history.past_len(); + let r = ui(&mut app, json!({"commit": true})); + assert_eq!(r["committed"], json!(true)); + assert!(app.distort.select_mask.is_none(), "OK closes"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Select and Mask")); + direct.session.execute(CMD, params).unwrap(); + for (x, y) in [(5, 5), (120, 50), (50, 60), (50, 80), (79, 50)] { + assert_eq!(cov(&app, x, y), cov(&direct, x, y), "({x}, {y})"); + } + assert!(cov(&app, 120, 50) > 0.5 && cov(&app, 50, 60) > 0.5 && cov(&app, 50, 80) < 0.5); +} + +/// Cancel leaves the document as it was. +#[test] +fn cancel_changes_nothing() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"invert": true, "radius": 10})); + let steps = app.session.active().unwrap().history.past_len(); + let r = ui(&mut app, json!({"cancel": true})); + assert_eq!(r["cancelled"], json!(true)); + assert!(app.distort.select_mask.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert!(cov(&app, 5, 5) > 0.99 && cov(&app, 100, 50) < 0.01); +} + +/// Remember Settings keeps the settings for the next time; without it the workspace opens on +/// Photoshop's defaults. +#[test] +fn remember_settings() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"remember": true, "radius": 7, "view": "overlay", "overlayColor": [0, 0, 255], "commit": true})); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + open(&mut app).unwrap(); + let s = ws(&app).describe()["settings"].clone(); + assert_eq!((s["radius"].as_f64(), s["view"].as_str(), s["overlayColor"].clone()), (Some(7.0), Some("overlay"), json!([0, 0, 255]))); + ui(&mut app, json!({"remember": false, "commit": true})); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + open(&mut app).unwrap(); + let s = ws(&app).describe()["settings"].clone(); + assert_eq!((s["radius"].as_f64(), s["view"].as_str()), (Some(0.0), Some("onionSkin"))); +} + +/// Decontaminate Colors makes new pixels, so it outputs to a new layer (as Photoshop does). +#[test] +fn decontaminate_outputs_to_a_new_layer() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let d = ui(&mut app, json!({"decontaminate": true})); + assert_eq!(d["settings"]["output"].as_str(), Some("newLayerWithMask")); + let d = ui(&mut app, json!({"output": "newDocument"})); + assert_eq!(d["settings"]["output"].as_str(), Some("newDocument")); + let d = ui(&mut app, json!({"output": "selection"})); + assert_eq!(d["settings"]["output"].as_str(), Some("newLayerWithMask")); +} + +/// A layer mask without a selection (or the targeted mask) is refined and output to the mask. +#[test] +fn a_layer_mask_opens_on_the_mask() { + let mut app = app_with_selection(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + // Revealing the selection uses it up (#8381), leaving the mask alone. + app.session.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_none()); + open(&mut app).unwrap(); + assert_eq!(ws(&app).describe()["settings"]["output"].as_str(), Some("layerMask")); + assert_eq!(selected(&mut app), 400, "the mask is what is shown"); +} + +/// Bad control params are errors, never a panic, and change nothing. +#[test] +fn hostile_control_params_are_errors() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + for bad in [ + json!({"tool": "wand"}), + json!({"mode": "intersect"}), + json!({"view": "sideways"}), + json!({"output": "printer"}), + json!({"indicates": "both"}), + json!({"stroke": {"points": "x"}}), + json!({"stroke": {"points": [[1.0]]}}), + ] { + assert!(control(&mut app, &bad).is_err(), "{bad}"); + } + for odd in [json!({"size": -5}), json!({"size": 1e12}), json!({"hardness": 500}), json!({"radius": 1e9, "opacity": -3}), json!({"stroke": {"points": []}})] + { + control(&mut app, &odd).unwrap(); + } + let d = ws(&app).describe(); + assert_eq!((d["tool"].as_str(), d["edits"].as_u64()), (Some("quick"), Some(0))); + assert_eq!((d["size"].as_f64(), d["hardness"].as_f64(), d["settings"]["radius"].as_f64()), (Some(5000.0), Some(100.0), Some(250.0))); + assert!(control(&mut app, &json!({"waitPreview": true})).is_ok(), "the clamped settings still preview"); +} + +/// High Quality Preview runs on the document at full size; otherwise on a copy at most 1600 px. +#[test] +fn high_quality_preview_is_full_size() { + let mut app = app_sized(3300, 60); + open(&mut app).unwrap(); + assert_eq!(ws(&app).describe()["proxyScale"].as_u64(), Some(3)); + let reduced = selected(&mut app); + assert!((40..=60).contains(&reduced), "{reduced} reduced px of the 20 × 20 selection"); + assert_eq!(ui(&mut app, json!({"highQualityPreview": true}))["proxyScale"].as_u64(), Some(1)); + assert_eq!(selected(&mut app), 400); +} + +/// Edits scale with the reduced copy: points, rectangles and sizes by 1 / k. +#[test] +fn edits_scale_to_the_reduced_copy() { + let e = json!({"tool": "quick", "points": [[30.0, 60.0]], "size": 30.0, "rect": [3, 6, 9, 12], "point": [15, 3]}); + assert_eq!(scale_edit(&e, 3), json!({"tool": "quick", "points": [[10.0, 20.0]], "size": 10.0, "rect": [1.0, 2.0, 3.0, 4.0], "point": [5.0, 1.0]})); + assert_eq!(scale_edit(&json!({"size": 2.0}), 4)["size"], json!(1.0), "never below a pixel"); + assert_eq!(scale_edit(&json!("x"), 2), json!("x")); +} + +/// With Real-Time Refinement the stroke being drawn is in the preview before it is released. +#[test] +fn real_time_refinement_previews_the_stroke_being_drawn() { + for real_time in [true, false] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"realTimeRefinement": real_time})); + let before = selected(&mut app); + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Move { x: 130.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + let during = selected(&mut app); + assert_eq!(during > before, real_time, "real time {real_time}: {before} → {during}"); + // The preview knows how much of the stroke it shows: the rest is drawn over it. + assert_eq!(ws(&app).preview.trail, if real_time { 2 } else { 0 }); + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: 130.0, y: 50.0 }, Modifiers::NONE); + assert!(ws(&app).trail.is_some(), "the released stroke stays drawn until its preview comes"); + assert!(selected(&mut app) > before); + assert_eq!(ws(&app).edits.len(), 1); + assert!(ws(&app).trail.is_none() && ws(&app).preview.trail == usize::MAX, "then the preview shows all of it"); + } +} + +/// A stroke refused at the engine's limits leaves nothing drawn: no trail, and a preview without it. +#[test] +fn a_refused_stroke_leaves_no_trail_or_preview() { + for tool in ["quick", "refineEdge"] { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": tool})); + let w = ws_mut(&mut app); + if tool == "quick" { + w.edits = vec![json!({"tool": "invert"}); MAX_EDITS]; + } else { + w.strokes = vec![json!({"points": []}); MAX_STROKES]; + } + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Move { x: 130.0, y: 50.0, pressure: 1.0 }, Modifiers::NONE); + let want = ws(&app).preview.want; + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: 130.0, y: 50.0 }, Modifiers::NONE); + let w = ws(&app); + assert!(w.error.as_deref().is_some_and(|e| e.starts_with("at most")), "{tool}: {:?}", w.error); + assert!(w.trail.is_none(), "{tool}: the refused stroke is not drawn"); + assert!(w.preview.want > want, "{tool}: a new preview drops the refused stroke"); + } +} + +/// Opening again while the workspace is open keeps its work and says why. +#[test] +fn opening_again_keeps_the_open_workspace() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let e = crate::menus::invoke(&mut app, &egui::Context::default(), "select.selectAndMask", json!({})); + assert!(e.is_err_and(|e| e.contains("already open"))); + assert_eq!(ws(&app).edits.len(), 1, "the edit survives"); +} + +/// The preview keeps the selection after the edits it applied and starts the next from there: +/// after undo it starts over. +#[test] +fn the_preview_reuses_the_edits_it_applied() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0]]}})); + selected(&mut app); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); + ui(&mut app, json!({"tool": "brush", "stroke": {"points": [[40.0, 60.0]]}})); + let both = selected(&mut app); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(2)); + ui(&mut app, json!({"undo": true})); + let one = selected(&mut app); + assert!(one < both); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.edits.len()), Some(1)); +} + +/// The selection the edits made on an older document is not reused: a Quick Selection stroke +/// on a repainted image previews what OK would commit. +#[test] +fn the_preview_recomputes_the_edits_after_the_document_changes() { + let warm = |app: &mut PhotocraftApp| { + app.session + .edit("paint", |doc, _| { + doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 160, 100), &[0.85, 0.55, 0.25, 1.0]); + Ok(()) + }) + .unwrap(); + }; + let stroke = json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}}); + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, stroke.clone()); + let two_tone = selected(&mut app); + // The pixels change under the workspace (a control-channel command) before it redraws. + warm(&mut app); + let after = selected(&mut app); + let mut fresh = app_with_selection(); + warm(&mut fresh); + open(&mut fresh).unwrap(); + ui(&mut fresh, stroke); + let expected = selected(&mut fresh); + assert_ne!(two_tone, expected, "the repaint changes what the stroke selects"); + assert_eq!(after, expected); + assert_eq!(ws(&app).preview.cache.as_ref().map(|c| c.revision), app.session.active().map(|s| s.revision)); +} + +/// A request with a bad field is refused whole: the valid fields before it change nothing either. +#[test] +fn a_refused_request_changes_nothing() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let before = ws(&app).describe(); + for bad in [ + json!({"tool": "brush", "mode": "invalid"}), + json!({"tool": "brush", "mode": "subtract", "size": 80, "radius": 40, "stroke": {"points": "x"}}), + json!({"hardness": 10, "sampleAllLayers": true, "hideView": true, "stroke": {"points": [[1.0]]}}), + ] { + assert!(control(&mut app, &bad).is_err(), "{bad}"); + assert_eq!(ws(&app).describe(), before, "{bad}"); + assert!(!ws(&app).hide_view && !ws(&app).sample_all, "{bad}"); + } +} + +/// With nothing selected OK refines nothing, so Show Edge shows no Refine Edge Brush stroke. +#[test] +fn show_edge_shows_no_stroke_without_a_selection() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"clearSelection": true, "showEdge": true})); + let empty = selected(&mut app); + ui(&mut app, json!({"tool": "refineEdge", "stroke": {"points": [[40.0, 20.0], [40.0, 80.0]]}})); + assert_eq!(selected(&mut app), empty); + ui(&mut app, json!({"commit": true})); + assert_eq!(cov(&app, 40, 50), 0.0, "and OK selects nothing there"); +} + +/// A preview of pixels the document no longer has is never shown, even mid-stroke, and the old one +/// goes when the document changes. +#[test] +fn a_preview_of_an_old_revision_is_dropped() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + selected(&mut app); + assert!(ws(&app).preview.pending.is_some()); + let j = job(&app).unwrap(); + let old = j.revision; + let late = compute(j, None); + app.session.execute("select.all", json!({})).unwrap(); + follow_revision(&mut app); + assert!(ws(&app).preview.pending.is_none() && ws(&app).preview.tex.is_none(), "the old preview is gone"); + let w = ws_mut(&mut app); + w.pressed = true; + let generation = w.preview.want; + take_result(w, generation, old, late); + assert!(w.preview.pending.is_none() && w.preview.cache.is_none() && w.preview.shown != generation); +} + +/// Sends `key` with `modifiers` through the workspace's key handling. +fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| keys(app, ui.ctx())).textures_delta.clear(); +} + +/// With the overlay colour's Color Picker open over the workspace, its keys (tool letters, undo) +/// belong to the picker, not to the tools under it. +#[test] +fn keys_wait_while_the_overlay_colour_picker_is_open() { + let mut app = app_with_selection(); + let ctx = egui::Context::default(); + open(&mut app).unwrap(); + press(&mut app, &ctx, Key::R, Modifiers::NONE); + assert_eq!(ws(&app).tool, SmTool::RefineEdge); + crate::color_picker_ui::request_for_widget(&ctx, egui::Id::unique("overlay-swatch"), "Color Picker (Overlay Color)", [1.0, 0.0, 0.0]); + crate::color_picker_ui::widget_tick(&mut app, &ctx); + assert!(crate::color_picker_ui::any_open(&app)); + press(&mut app, &ctx, Key::B, Modifiers::NONE); + assert_eq!(ws(&app).tool, SmTool::RefineEdge, "B went to the picker"); + app.ui.dialogs.clear(); + press(&mut app, &ctx, Key::B, Modifiers::NONE); + assert_eq!(ws(&app).tool, SmTool::Brush); +} + +/// Photoshop's keys: W, R, B, L, H, Z pick the tools (Shift+W and Shift+L cycle their slots), +/// [ and ] size the brush, the view letters, F, J and P the view, Ctrl+Z undoes. +#[test] +fn keys_like_photoshop() { + let mut app = app_with_selection(); + let ctx = egui::Context::default(); + open(&mut app).unwrap(); + let tool = |app: &PhotocraftApp| ws(app).tool; + for (key, mods, want) in [ + (Key::R, Modifiers::NONE, SmTool::RefineEdge), + (Key::B, Modifiers::NONE, SmTool::Brush), + (Key::W, Modifiers::NONE, SmTool::Quick), + (Key::W, Modifiers::SHIFT, SmTool::Object), + (Key::H, Modifiers::NONE, SmTool::Hand), + (Key::W, Modifiers::NONE, SmTool::Object), + (Key::L, Modifiers::NONE, SmTool::Lasso), + (Key::L, Modifiers::SHIFT, SmTool::PolygonalLasso), + (Key::Z, Modifiers::NONE, SmTool::Zoom), + (Key::L, Modifiers::NONE, SmTool::PolygonalLasso), + (Key::B, Modifiers::NONE, SmTool::Brush), + ] { + press(&mut app, &ctx, key, mods); + assert_eq!(tool(&app), want, "{mods:?}+{key:?}"); + } + press(&mut app, &ctx, Key::CloseBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, 33.0); + press(&mut app, &ctx, Key::OpenBracket, Modifiers::NONE); + assert_eq!(ws(&app).brush_size, 30.0); + let view = |app: &PhotocraftApp| ws(app).opts.view; + for (key, want) in [ + (Key::K, Mode::BlackWhite), + (Key::A, Mode::OnBlack), + (Key::T, Mode::OnWhite), + (Key::V, Mode::Overlay), + (Key::Y, Mode::OnLayers), + (Key::M, Mode::MarchingAnts), + (Key::F, Mode::Overlay), + (Key::O, Mode::OnionSkin), + ] { + press(&mut app, &ctx, key, Modifiers::NONE); + assert_eq!(view(&app), want, "{key:?}"); + } + press(&mut app, &ctx, Key::J, Modifiers::NONE); + press(&mut app, &ctx, Key::P, Modifiers::NONE); + assert!(ws(&app).opts.show_edge && ws(&app).opts.show_original); + ui(&mut app, json!({"invert": true})); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND); + assert_eq!(ws(&app).edits.len(), 0); + press(&mut app, &ctx, Key::Z, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(ws(&app).edits.len(), 1); +} + +/// The Polygonal Lasso takes clicks: Enter closes the polygon (not OK), Esc drops it (not Cancel), +/// clicking the first vertex closes it too. +#[test] +fn polygonal_lasso_enter_and_escape() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ws_mut(&mut app).zoom = 1.0; + ui(&mut app, json!({"tool": "polygonalLasso"})); + let click = |app: &mut PhotocraftApp, x: f64, y: f64| crate::distort_ui::pointer(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + for (x, y) in [(30.0, 30.0), (60.0, 30.0), (60.0, 60.0)] { + click(&mut app, x, y); + } + assert_eq!(ws(&app).describe()["polygon"].as_u64(), Some(3)); + assert!(enter(&mut app), "Enter closes the polygon"); + assert!(app.distort.select_mask.is_some(), "and leaves the workspace open"); + assert_eq!(ws(&app).edits.len(), 1); + click(&mut app, 30.0, 30.0); + click(&mut app, 60.0, 30.0); + escape(&mut app); + assert!(app.distort.select_mask.is_some() && ws(&app).poly.is_none(), "Esc drops the polygon only"); + for (x, y) in [(70.0, 70.0), (90.0, 70.0), (90.0, 90.0), (71.0, 71.0)] { + click(&mut app, x, y); + } + assert_eq!(ws(&app).edits.len(), 2, "clicking the first vertex closes it"); + escape(&mut app); + assert!(app.distort.select_mask.is_none(), "Esc with no polygon cancels"); +} + +/// The workspace draws: tools on the left, the options bar, the Properties panel with Photoshop's +/// sections and buttons; clicking a tool picks it, OK commits. +#[test] +fn renders_photoshops_layout() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for label in [ + "Remember Settings", + "View Mode", + "Real-Time Refinement", + "High Quality Preview", + "Refine Mode", + "Color Aware", + "Edge Detection", + "Smart Radius", + "Global Refinements", + "Clear Selection", + "Invert", + "Output Settings", + "Decontaminate Colors", + "Select Subject", + "Refine Hair", + "Sample All Layers", + "OK", + "Cancel", + ] { + assert!(h.query_by_label_contains(label).is_some(), "{label} is shown"); + } + assert!(h.get_by_label("Refine Hair").accesskit_node().is_disabled(), "Refine Hair is greyed out"); + h.get_by_label("Brush Tool (B)").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.tool), Some(SmTool::Brush)); + assert!(h.query_by_label_contains("Hardness").is_some(), "the Brush's options bar"); + h.get_by_label("Invert").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1)); + h.get_by_label("OK").click(); + h.run_steps(2); + assert!(h.state().distort.select_mask.is_none(), "OK closes"); + let app = h.state(); + assert!(cov(app, 5, 5) < 0.01 && cov(app, 100, 50) > 0.99, "the inverted selection"); +} + +/// Panning shows Photoshop's hand: open while Space is held (or the Hand tool is picked), closed +/// while dragging; and Space doesn't press the panel button clicked last. +#[test] +fn panning_shows_the_hand() { + use egui::{PointerButton, Pos2}; + let mut app = app_with_selection(); + open(&mut app).unwrap(); + ui(&mut app, json!({"tool": "brush"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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::distort_ui::keys(app, &ctx); + keys(app, &ctx); + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + // Click Invert, then move over the image. + h.get_by_label("Invert").click(); + h.run_steps(2); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1)); + let over = Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the brush's own cursor"); + let space = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, pressed: bool| { + h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + }; + space(&mut h, true); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "Space: the open hand"); + assert_eq!(h.state().distort.select_mask.as_ref().map(|w| w.edits.len()), Some(1), "Space didn't press Invert again"); + let centre = h.state().distort.select_mask.as_ref().map(|w| w.center).unwrap(); + h.event(egui::Event::PointerButton { pos: over, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for dx in [5.0, 20.0, 40.0] { + h.event(egui::Event::PointerMoved(Pos2::new(over.x + dx, over.y))); + h.run_steps(1); + } + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "dragging: the closed hand"); + h.event(egui::Event::PointerButton { pos: Pos2::new(over.x + 40.0, over.y), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + space(&mut h, false); + let w = h.state().distort.select_mask.as_ref().unwrap(); + assert!(w.center[0] < centre[0], "the view panned: {:?} → {:?}", centre, w.center); + assert!(w.edits.len() == 1 && w.strokes.is_empty(), "panning painted nothing"); + // The Hand tool shows the open hand without Space. + h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Hand); + h.event(egui::Event::PointerMoved(over)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab); +} + +/// The greyed Object Aware and Refine Hair say why they can't be used. +#[test] +fn greyed_controls_explain_themselves() { + let mut app = app_with_selection(); + open(&mut app).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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; + } + show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + for (label, why) in [("Object Aware", "segmentation model"), ("Refine Hair", "hair detection")] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} is greyed"); + assert!(h.query_by_label_contains(why).is_none(), "{label}: no tooltip before hovering"); + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + assert!(h.query_by_label_contains(why).is_some(), "{label}: hovering says why"); + h.hover_at(egui::pos2(700.0, 450.0)); + h.run_steps(4); + } +} + +fn call(app: &mut PhotocraftApp, method: &str, params: Value) -> Value { + let (req, _rx) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &egui::Context::default(), &req) { + crate::control::Outcome::Done(v) => v, + _ => panic!("{method}: expected an immediate reply"), + } +} + +/// A second document with its own selection, the first one active again. +fn two_documents() -> PhotocraftApp { + let mut app = app_with_selection(); + app.session.execute("file.new", json!({"width": 160, "height": 100, "depth": 8})).unwrap(); + app.session.execute("select.rect", json!({"x": 100, "y": 50, "width": 30, "height": 30})).unwrap(); + assert!(app.session.set_active(0)); + app.sync_views(); + app +} + +fn fingerprint(app: &PhotocraftApp, i: usize) -> (u64, Option, Option) { + let st = &app.session.documents()[i]; + let sel = st.doc.selection.as_ref(); + (st.revision, sel.map(|s| s.sample_channel(10, 10, 0)), sel.map(|s| s.sample_channel(120, 50, 0))) +} + +/// The #8896 review: a document switch and OK queued on the control channel, with no frame +/// between them, must not apply the first document's edits to the second. +#[test] +fn ok_after_a_document_switch_changes_neither_document() { + let mut app = two_documents(); + open(&mut app).unwrap(); + ui(&mut app, json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})); + let before = (fingerprint(&app, 0), fingerprint(&app, 1)); + let switched = call(&mut app, "engine.execute", json!({"command": "document.activate", "params": {"document": 1}})); + assert_eq!(switched["ok"], false, "external edits respect the modal gate"); + assert_eq!(app.session.active_index(), Some(0)); + assert!(app.session.set_active(1), "simulate a trusted native target change before the next frame"); + let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); + assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); + assert_eq!((fingerprint(&app, 0), fingerprint(&app, 1)), before, "neither document changed"); + assert!(app.distort.select_mask.is_none(), "the stale workspace is closed"); +} + +/// Every route into the workspace checks its document: control edits, the preview and pointer +/// events on another document are refused, and none of them edits that document. +#[test] +fn control_preview_and_pointer_on_another_document_are_refused() { + for route in ["control", "preview", "pointer"] { + let mut app = two_documents(); + open(&mut app).unwrap(); + let before = fingerprint(&app, 1); + assert!(app.session.set_active(1)); + match route { + "control" => assert!(control(&mut app, &json!({"stroke": {"points": [[120.0, 50.0], [130.0, 50.0]]}})).is_err()), + "preview" => assert!(control(&mut app, &json!({"waitPreview": true})).is_err()), + _ => { + let ev = ToolEvent::Down { x: 120.0, y: 50.0, pressure: 1.0 }; + crate::distort_ui::pointer(&mut app, ev, egui::Modifiers::NONE); + } + } + assert!(app.distort.select_mask.is_none(), "{route}: the stale workspace is closed"); + assert_eq!(fingerprint(&app, 1), before, "{route}: the other document is untouched"); + } +} + +/// Selecting another layer in the same document (a shortcut, or the control channel between +/// requests) must not send a layer-mask OK to that layer. +#[test] +fn ok_after_a_layer_switch_leaves_the_new_layer_alone() { + let mut app = app_with_selection(); + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + ui(&mut app, json!({"output": "layerMask"})); + let revision = app.session.active().unwrap().revision; + let blocked = call(&mut app, "engine.execute", json!({"command": "layer.select", "params": {"layer": other.0}})); + assert_eq!(blocked["ok"], false, "external edits respect the modal gate"); + app.session.execute("layer.select", json!({"layer":other.0})).unwrap(); + let revision_after_select = app.session.active().unwrap().revision; + let r = call(&mut app, "ui.menu.invoke", json!({"command": "select.selectAndMask", "params": {"ui": {"commit": true}}})); + assert!(r.get("error").is_some(), "the stale OK is refused: {r}"); + let st = app.session.active().unwrap(); + assert!(st.doc.layer(other).unwrap().mask.is_none(), "no mask on the newly selected layer"); + assert!(st.doc.layer(first).unwrap().mask.is_none(), "nor on the first"); + assert!(st.doc.selection.is_some(), "the selection stands"); + assert!(revision_after_select >= revision && st.revision == revision_after_select, "nothing was edited after the switch"); + assert!(app.distort.select_mask.is_none()); +} + +/// A stale workspace is closed with a reason that is shown in the status bar and returned to the +/// control channel (#8896). +#[test] +fn the_reason_a_stale_workspace_closed_is_reported() { + let document = "Select and Mask was closed: its document is no longer the active one"; + let layer = "Select and Mask was closed: another layer was selected"; + for (reason, english) in [("document", document), ("layer", layer)] { + let mut app = if reason == "document" { two_documents() } else { app_with_selection() }; + let first = app.session.active().unwrap().active_layer.unwrap(); + let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap()); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + open(&mut app).unwrap(); + match other { + Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))), + None => assert!(app.session.set_active(1)), + } + let err = commit(&mut app).expect_err(&format!("{reason}: the stale OK is refused")); + assert_eq!(err, english, "{reason}: the error"); + assert_eq!(app.ui.status, english, "{reason}: the status"); + } +} + +#[test] +fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() { + let area = Rect::new(0, 0, 100_000, 100_000); + let mut selection = Surface::new(photocraft_color::PixelFormat::GRAY8); + selection.write_pixel(99_999, 99_999, &[1.0]); + selection.write_pixel(2, 3, &[0.5]); + selection.write_pixel(-1, -1, &[1.0]); + assert_eq!(selected_pixels(Some(&selection), area), 2); + assert_eq!(preview_ants(Some(&selection), area, 1.0).unwrap().len(), 8); + let small = Rect::new(0, 0, 10, 10); + assert_eq!(selected_pixels(Some(&selection), small), 1); + assert_eq!(preview_ants(Some(&selection), small, 1.0).unwrap().len(), 4); + + let mut inverted = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); + inverted.write_pixel(2, 3, &[0.0]); + let smaller = Rect::new(0, 0, 1000, 1000); + assert_eq!(selected_pixels(Some(&inverted), smaller), 999_999); + assert_eq!(selected_pixels(None, area), 0); +} diff --git a/crates/ui-egui/src/dialogs/shape_fill_ui.rs b/crates/ui-egui/src/dialogs/shape_fill_ui.rs index 37731ce243..ed1d937266 100644 --- a/crates/ui-egui/src/dialogs/shape_fill_ui.rs +++ b/crates/ui-egui/src/dialogs/shape_fill_ui.rs @@ -1,6 +1,8 @@ -//! A solid-colour shape layer's thumbnail (or Layer › Layer Content Options…) opens the shared -//! Color Picker on the shape's fill, like Photoshop (#2812). The canvas previews a shallow -//! document snapshot; Cancel changes nothing and OK commits exactly one `shape.edit`. +//! The shared Color Picker on a shape's paint, like Photoshop (#2812): a solid-colour shape +//! layer's thumbnail (or Layer › Layer Content Options…) opens it on the fill, and the Fill and +//! Stroke popovers' Color Picker button ([`crate::paint_picker`]) on the fill or the stroke of the +//! selected shape and/or the next shape's colours. The canvas previews a shallow document +//! snapshot; Cancel changes nothing and OK commits exactly one `shape.edit`. use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -24,13 +26,38 @@ pub(crate) struct Preview { key: u64, } +/// Which of a shape's paints the picker edits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Paint { + Fill, + Stroke, +} + +impl Paint { + fn key(self) -> &'static str { + match self { + Paint::Fill => "fill", + Paint::Stroke => "stroke", + } + } +} + +/// The paint `which` of shape layer `layer`: `Some(None)` when the shape has none (No Color), +/// `None` when the layer is gone or not a shape. +fn paint_of(doc: &Document, layer: LayerId, which: Paint) -> Option> { + match &doc.layer(layer)?.content { + LayerContent::Shape(sh) => Some(match which { + Paint::Fill => sh.fill.clone(), + Paint::Stroke => sh.stroke.as_ref().filter(|s| s.width > 0.0).map(|s| s.paint.clone()), + }), + _ => None, + } +} + /// The solid fill colour of shape layer `layer`, if it has one (not a gradient, pattern or none). fn solid_fill(doc: &Document, layer: LayerId) -> Option { - match &doc.layer(layer)?.content { - LayerContent::Shape(sh) => match sh.fill { - Some(Fill::Solid(c)) => Some(c), - _ => None, - }, + match paint_of(doc, layer, Paint::Fill)? { + Some(Fill::Solid(c)) => Some(c), _ => None, } } @@ -40,7 +67,7 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { let st = app.session.active()?; let layer = st.active_layer?; let color = solid_fill(&st.doc, layer)?; - let params = json!({"layer": layer.0, "document": st.doc.id.0}); + let params = json!({"layer": layer.0, "document": st.doc.id.0, "which": "fill", "alpha": color.alpha}); let id = color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), COMMAND, params); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert(MARKER.into(), json!(true)); @@ -48,38 +75,130 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { Some(id) } +/// Opens the picker from a Fill or Stroke popover on `which` paint of shape `layer` (the +/// selected shape), and of the next shape's colours too when `tool` (the options bar). It opens +/// on the paint's solid colour, else on `fallback`, whose alpha a pick keeps. OK makes the paint +/// that colour, also from No Color or a gradient. +pub(crate) fn open_paint(app: &mut PhotocraftApp, which: Paint, layer: Option, tool: bool, fallback: [f32; 4]) -> Option { + if layer.is_none() && !tool { + return None; + } + let mut params = json!({"which": which.key(), "tool": tool}); + let mut start = Color::rgba(fallback[0], fallback[1], fallback[2], fallback[3]); + if let Some(layer) = layer { + let st = app.session.active()?; + if let Some(Fill::Solid(c)) = paint_of(&st.doc, layer, which)? { + start = c; + } + params["layer"] = json!(layer.0); + params["document"] = json!(st.doc.id.0); + } + params["alpha"] = json!(if start.alpha.is_finite() { start.alpha.clamp(0.0, 1.0) } else { 1.0 }); + let label = match which { + Paint::Fill => "Color Picker (Fill Color)", + Paint::Stroke => "Color Picker (Stroke Color)", + }; + let id = color_picker_ui::open_for_command(app, label, start.to_rgb(), COMMAND, params); + if let Some(d) = app.ui.dialog_mut(id) { + d.fields.insert(MARKER.into(), json!(true)); + // A paint that isn't solid yet changes on OK even with the colour it opened on. + let solid = layer.is_some_and(|l| app.session.active().and_then(|st| paint_of(&st.doc, l, which)).is_some_and(|p| matches!(p, Some(Fill::Solid(_))))); + d.fields.insert("__shapePaintSolid".into(), json!(solid || layer.is_none())); + } + Some(id) +} + pub(crate) fn owns(f: &Map) -> bool { f.get(MARKER).and_then(Value::as_bool) == Some(true) } -/// The picker's colour as `shape.edit` stores it: RGB with the original fill's alpha. -fn picked(f: &Map, original: Color) -> Option { - let [r, g, b] = color_picker_ui::parse_hex(f.get("color")?.as_str()?)?; - Some(Color::rgba(r, g, b, original.alpha)) +fn which(f: &Map) -> Paint { + match f.get("__params").and_then(|p| p.get("which")).and_then(Value::as_str) { + Some("stroke") => Paint::Stroke, + _ => Paint::Fill, + } } -/// The target layer and its current fill, unless the dialog is stale (another document is -/// active, or the layer is gone or no longer a solid-colour shape). -fn target(app: &PhotocraftApp, f: &Map, idx: usize) -> Result<(LayerId, Color), String> { +/// The picker's colour as `shape.edit` stores it: RGB with the original paint's alpha. +fn picked(f: &Map) -> Option { + let [r, g, b] = color_picker_ui::parse_hex(f.get("color")?.as_str()?)?; + let alpha = f.get("__params").and_then(|p| p.get("alpha")).and_then(Value::as_f64).map_or(1.0, |a| a as f32); + Some(Color::rgba(r, g, b, if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 })) +} + +/// The target layer, unless the dialog has none or is stale (another document is active, or the +/// layer is gone or no longer a shape). `Ok(None)`: only the next shape's colours, which need no +/// document (`idx` is the active one's). +fn target(app: &PhotocraftApp, f: &Map, idx: Option) -> Result, String> { let params = f.get("__params").ok_or("missing shape fill target")?; - let st = app.session.documents().get(idx).ok_or("no document open")?; + let Some(layer) = params.get("layer").and_then(Value::as_u64).map(LayerId) else { + return if params.get("tool").and_then(Value::as_bool) == Some(true) { Ok(None) } else { Err("missing shape layer".into()) }; + }; + let st = idx.and_then(|i| app.session.documents().get(i)).ok_or("no document open")?; if params.get("document").and_then(Value::as_u64) != Some(st.doc.id.0) { return Err("the target document is no longer active".into()); } - let layer = LayerId(params.get("layer").and_then(Value::as_u64).ok_or("missing shape layer")?); - let original = solid_fill(&st.doc, layer).ok_or("the target shape layer no longer has a solid colour fill")?; - Ok((layer, original)) + let paint = paint_of(&st.doc, layer, which(f)).ok_or("the target layer is no longer a shape")?; + // The thumbnail's picker (no popover `tool` flag) edits a solid fill only, as it opened on one. + if params.get("tool").is_none() && !matches!(paint, Some(Fill::Solid(_))) { + return Err("the target shape layer no longer has a solid colour fill".into()); + } + Ok(Some(layer)) } -/// OK: one `shape.edit` with the new fill; an unchanged colour keeps the stored one as it is. +/// OK: one `shape.edit` with the new paint, then the next shape's colour when the picker came from +/// the options bar. A refused edit (a locked layer, a job running on the document) changes neither. pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - let idx = app.session.active_index().ok_or("no document open")?; - let (layer, original) = target(app, f, idx)?; - if f.get("color") == f.get("__orig") { + let layer = target(app, f, app.session.active_index())?; + let color = picked(f).ok_or("not a colour")?; + let rgba = [color.c[0], color.c[1], color.c[2], color.alpha]; + let which = which(f); + // A locked shape refuses the edit: check first, so the next shape's colour isn't changed either. + if let Some(layer) = layer + && app.session.active().is_some_and(|st| { + let locks = st.doc.effective_locks(layer); + locks.all || locks.pixels + }) + { + return Err("shape layer is locked".into()); + } + let tool = f.get("__params").and_then(|p| p.get("tool")).and_then(Value::as_bool) == Some(true); + let result = match layer { + None => json!({"color": rgba}), + Some(layer) => edit_paint(app, f, layer, which, rgba)?, + }; + // Only once the shape took the colour (or there is none): a refused edit changes nothing. + if tool { + crate::shape_options::set_tool_paint(app, which == Paint::Stroke, crate::shape_paint_ui::PaintChange::Color(rgba))?; + } + Ok(result) +} + +/// The `shape.edit` that gives `layer`'s paint `which` the colour `rgba`; an unchanged solid +/// colour keeps the stored one as it is. +fn edit_paint(app: &mut PhotocraftApp, f: &Map, layer: LayerId, which: Paint, rgba: [f32; 4]) -> Result { + let solid = f.get("__shapePaintSolid").and_then(Value::as_bool).unwrap_or(true); + if solid && f.get("color") == f.get("__orig") { return Ok(json!({"layer": layer.0, "unchanged": true})); } - let color = picked(f, original).ok_or("not a colour")?; - app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]})) + let paint = match which { + Paint::Fill => json!(rgba), + Paint::Stroke => { + let width = app + .session + .active() + .and_then(|st| st.doc.layer(layer)) + .and_then(|l| match &l.content { + LayerContent::Shape(sh) => sh.stroke.as_ref().map(|s| s.width), + _ => None, + }) + .unwrap_or(0.0); + json!({"color": rgba, "width": crate::vector_ui::enabled_stroke_width(width)}) + } + }; + let mut p = json!({"layer": layer.0}); + p[which.key()] = paint; + app.run(COMMAND, p) } /// The canvas preview while the picker is open: the document with the shape re-rendered in the @@ -89,11 +208,15 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option<(Arc sh.fill = Some(Fill::Solid(color)), + // A stroke that is off (none, or 0 px) previews as OK makes it: on, at its enabled + // width, with `shape.edit`'s default style when the shape has none. + Paint::Stroke => { + let stroke = sh.stroke.get_or_insert_with(Default::default); + stroke.width = crate::vector_ui::enabled_stroke_width(stroke.width); + stroke.paint = Fill::Solid(color); + } + } photocraft_engine::vector_cmds::refresh_shape(&st.doc, sh); } let mut hash = std::collections::hash_map::DefaultHasher::new(); - (st.doc.id.0, st.revision, d.id, layer.0).hash(&mut hash); + (st.doc.id.0, st.revision, d.id, layer.0, which.key()).hash(&mut hash); for component in color.c.into_iter().chain([color.alpha]) { component.to_bits().hash(&mut hash); } diff --git a/crates/ui-egui/src/dialogs/shape_fill_ui/tests.rs b/crates/ui-egui/src/dialogs/shape_fill_ui/tests.rs index 95fd0dd7c8..5086a94e62 100644 --- a/crates/ui-egui/src/dialogs/shape_fill_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/shape_fill_ui/tests.rs @@ -70,3 +70,173 @@ fn shape_thumbnail_double_click_picks_the_fill_colour() { app.run("edit.undo", json!({})).unwrap(); assert_eq!(*app.session.active().unwrap().doc, *before); } + +/// The popovers' Color Picker edits the stroke too, previews it on the canvas, and fails +/// gracefully (never panics) when its target goes stale or its fields are garbage. +#[test] +fn stroke_picker_previews_commits_and_rejects_stale_or_bad_targets() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId( + app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699", "stroke": {"width": 4, "color": [1.0, 0.0, 0.0, 0.5]}})).unwrap()["layer"] + .as_u64() + .unwrap(), + ); + let stroke = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.stroke.clone(), + _ => None, + }; + let dialog = open_paint(&mut app, Paint::Stroke, Some(id), false, [0.0, 0.0, 0.0, 1.0]).unwrap(); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["color"], "#ff0000", "opens on the stroke colour"); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#00ff00")); + let (shown, _) = display_doc(&mut app, 0).unwrap(); + let LayerContent::Shape(sh) = &shown.layer(id).unwrap().content else { panic!() }; + assert_eq!(sh.stroke.as_ref().unwrap().paint, Fill::Solid(Color::rgba(0.0, 1.0, 0.0, 0.5)), "previewed with the stroke's alpha"); + assert_eq!(sh.fill, fill(&app, id), "the fill is untouched"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let after = stroke(&app).unwrap(); + assert_eq!((after.paint, after.width), (Fill::Solid(Color::rgba(0.0, 1.0, 0.0, 0.5)), 4.0)); + + // Neither a layer nor the tool options: nothing to open. + assert!(open_paint(&mut app, Paint::Fill, None, false, [0.0; 4]).is_none()); + // A deleted layer, another document, garbage fields: errors, not panics. + let dialog = open_paint(&mut app, Paint::Fill, Some(id), false, [0.0; 4]).unwrap(); + let mut f = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + for (key, value) in [ + ("__params", json!(null)), + ("__params", json!({"layer": "x"})), + ("__params", json!({"layer": 9999, "document": 0, "tool": false})), + ("color", json!(12)), + ] { + let mut bad = f.clone(); + bad.insert(key.into(), value); + assert!(confirm(&mut app, &bad).is_err(), "{key}"); + } + f.insert("color".into(), json!("#0000ff")); + app.run("layer.delete", json!({})).unwrap(); + assert!(confirm(&mut app, &f).is_err(), "the layer is gone"); + assert!(display_doc(&mut app, 0).is_none()); +} + +/// A shape without a stroke, or with a 0 px one, previews the stroke the picker's OK turns on: +/// the picked colour at the enabled width, even for the colour it opened on. +#[test] +fn stroke_picker_previews_a_stroke_that_is_off_as_ok_turns_it_on() { + for stroke in [json!(null), json!({"width": 0, "color": "#ff0000"})] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699", "stroke": stroke})).unwrap()["layer"].as_u64().unwrap()); + let dialog = open_paint(&mut app, Paint::Stroke, Some(id), false, [0.0, 0.0, 0.0, 1.0]).unwrap(); + let (shown, _) = display_doc(&mut app, 0).expect("the unchanged colour still previews the stroke OK adds"); + let LayerContent::Shape(sh) = &shown.layer(id).unwrap().content else { panic!() }; + let previewed = sh.stroke.clone().expect("a previewed stroke"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let LayerContent::Shape(sh) = &app.session.active().unwrap().doc.layer(id).unwrap().content else { panic!() }; + let committed = sh.stroke.clone().expect("OK turned the stroke on"); + assert_eq!((previewed.paint, previewed.width), (committed.paint, committed.width), "{stroke}: the preview is what OK commits"); + assert_eq!(committed.width, 3.0); + } +} + +/// From the options bar without a selected shape, OK sets the next shape's colour and turns the +/// paint on, and edits no document. +#[test] +fn tool_only_picker_sets_the_next_shapes_colour() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.ui.tool_options.shape_fill = false; + let history = app.session.active().unwrap().history.past_len(); + let dialog = open_paint(&mut app, Paint::Fill, None, true, [0.1, 0.2, 0.3, 1.0]).unwrap(); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["__label"], "Color Picker (Fill Color)"); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.ui.tool_options.shape_fill); + assert_eq!(color_picker_ui::hex([app.session.tools.foreground[0], app.session.tools.foreground[1], app.session.tools.foreground[2]]), "#ff8800"); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +/// With no document open, the options bar's Color Picker still sets the next shape's colour. +#[test] +fn tool_only_picker_works_without_a_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + assert!(app.session.active().is_none()); + for which in [Paint::Fill, Paint::Stroke] { + let dialog = open_paint(&mut app, which, None, true, [0.1, 0.2, 0.3, 1.0]).unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + } + assert!(app.ui.tool_options.shape_fill); + assert_eq!(color_picker_ui::hex([app.session.tools.foreground[0], app.session.tools.foreground[1], app.session.tools.foreground[2]]), "#ff8800"); + assert_eq!(color_picker_ui::hex([app.session.tools.background[0], app.session.tools.background[1], app.session.tools.background[2]]), "#ff8800"); + // A picker for a shape layer still needs its document. + let mut f = Map::new(); + f.insert("__params".into(), json!({"layer": 3, "document": 1, "which": "fill"})); + f.insert("color".into(), json!("#ff8800")); + assert_eq!(confirm(&mut app, &f).unwrap_err(), "no document open"); +} + +/// A locked selected shape refuses the options bar's Color Picker before anything changes: the +/// next shape's colour stays as it was, as does the document. +#[test] +fn locked_shape_refuses_the_options_bar_picker_without_changing_the_next_shapes_colour() { + for lock in ["all", "pixels"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.lockLayers", json!({lock: true})).unwrap(); + let (fg, options) = (app.session.tools.foreground, app.ui.tool_options.clone()); + let history = app.session.active().unwrap().history.past_len(); + let dialog = open_paint(&mut app, Paint::Fill, Some(id), true, fg).unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + let f = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + assert_eq!(confirm(&mut app, &f).unwrap_err(), "shape layer is locked", "{lock}"); + assert_eq!(app.session.tools.foreground, fg, "{lock}: the next shape's colour is unchanged"); + assert_eq!(app.ui.tool_options, options); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(fill(&app, id), Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.6)))); + } +} + +/// A shape edit refused for another reason (here a job holding the document) leaves the next +/// shape's colour and stroke as they were: the edit runs first, the tool defaults only after it. +#[test] +fn a_refused_shape_edit_leaves_the_next_shapes_colour_alone() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + for which in [Paint::Fill, Paint::Stroke] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap()); + let (tools, options) = (app.session.tools.clone(), app.ui.tool_options.clone()); + let dialog = open_paint(&mut app, which, Some(id), true, [0.0, 0.0, 0.0, 1.0]).unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + let f = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + let gate = Arc::new(AtomicBool::new(false)); + let open = gate.clone(); + let started = app + .session + .start_job( + "test.hold", + json!({}), + "Hold", + true, + move |ctx| { + while !open.load(Ordering::Relaxed) { + ctx.check()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Ok(()) + }, + |_, ()| Ok(json!({})), + ) + .unwrap(); + let photocraft_engine::jobs::Started::Job(job) = started else { panic!("the job ran inline") }; + assert!(confirm(&mut app, &f).unwrap_err().contains("still running"), "{which:?}"); + assert_eq!(app.session.tools.foreground, tools.foreground, "{which:?}: the next shape's colours are unchanged"); + assert_eq!(app.session.tools.background, tools.background); + assert_eq!(app.ui.tool_options, options, "{which:?}: the stroke stays off"); + gate.store(true, Ordering::Relaxed); + app.session.cancel_job(job); + app.session.join_cancelled_jobs(); + } +} diff --git a/crates/ui-egui/src/dialogs/solid_fill_ui.rs b/crates/ui-egui/src/dialogs/solid_fill_ui.rs index 5d9112abab..37844b86c2 100644 --- a/crates/ui-egui/src/dialogs/solid_fill_ui.rs +++ b/crates/ui-egui/src/dialogs/solid_fill_ui.rs @@ -27,7 +27,14 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { let layer = st.active_layer?; let LayerContent::Fill(Fill::Solid(color)) = st.doc.layer(layer)?.content else { return None }; let params = json!({"layer": layer.0, "document": st.doc.id.0}); - Some(color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params)) + let id = color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params); + // A CMYK fill reopens showing its own inks, not ones recomputed from its sRGB stand-in (#8568). + if color.mode == photocraft_color::ColorMode::Cmyk + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.insert("__cmyk".into(), json!(color.c)); + } + Some(id) } /// Creating a Solid Color fill is transactional: the document is unchanged until OK. @@ -57,7 +64,8 @@ pub(crate) fn confirm_new(app: &mut PhotocraftApp, f: &Map) -> Re return Err("the target document is no longer active".into()); } let color = f.get("color").and_then(Value::as_str).ok_or("missing Solid Color colour")?; - app.run("layer.newFillLayer.solidColor", json!({"color": color})) + // CMYK typed into the picker stays those inks in a CMYK document (#8568). + app.run("layer.newFillLayer.solidColor", json!({"color": color, "cmyk": color_picker_ui::picked_cmyk(f)})) } pub(crate) fn owns(f: &Map) -> bool { @@ -72,14 +80,39 @@ pub(crate) fn thumbnail_color(app: &PhotocraftApp, document: DocId, layer: Layer .filter(|d| owns(&d.fields)) .find_map(|d| { let params = d.fields.get("__params")?; - if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 || d.fields.get("color") == d.fields.get("__orig") { + if params.get("document")?.as_u64()? != document.0 || params.get("layer")?.as_u64()? != layer.0 { return None; } - cmds::color_param(d.fields.get("color")?, original, app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode).ok() + let mode = app.session.documents().iter().find(|st| st.doc.id == document)?.doc.mode; + picked_color(&d.fields, original, mode) }) .unwrap_or(original) } +/// The colour the picker would commit to a fill of `original` in a `mode` document, built as the +/// engine builds it: the picked hex, and in a CMYK document the inks it was picked as (#8568). +/// `None` while the picker still shows the fill as it is ([`is_edit`]). +fn picked_color(f: &Map, original: Color, mode: photocraft_color::ColorMode) -> Option { + if !is_edit(f, original, mode) { + return None; + } + let color = cmds::color_param(f.get("color")?, original, mode).ok()?; + Some(color_picker_ui::picked_cmyk(f).filter(|_| mode == photocraft_color::ColorMode::Cmyk).map_or(color, |c| Color { + mode: photocraft_color::ColorMode::Cmyk, + c, + alpha: color.alpha, + })) +} + +/// Whether the picker differs from the fill it edits, inks included: different inks can share a +/// display colour (CMYK 0/0/0/100 and 50/50/50/100 both read black), so the hex alone doesn't +/// tell, and an RGB or Lab fill given inks in a CMYK document is an edit too. +fn is_edit(f: &Map, original: Color, mode: photocraft_color::ColorMode) -> bool { + let cmyk = photocraft_color::ColorMode::Cmyk; + let inks_differ = mode == cmyk && color_picker_ui::picked_cmyk(f).is_some_and(|k| original.mode != cmyk || original.c != k); + f.get("color") != f.get("__orig") || inks_differ +} + /// No-op OK preserves imported high-precision / non-RGB colour values rather than quantising /// them to the picker's display hex. A stale document must never receive the edit. pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { @@ -89,15 +122,18 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result return Err("the target document is no longer active".into()); } let layer = LayerId(params.get("layer").and_then(Value::as_u64).ok_or("missing Solid Color layer")?); - if !st.doc.layer(layer).is_some_and(|l| matches!(l.content, LayerContent::Fill(Fill::Solid(_)))) { + let Some(LayerContent::Fill(Fill::Solid(original))) = st.doc.layer(layer).map(|l| &l.content) else { return Err("the target Solid Color Fill layer no longer exists".into()); - } - if f.get("color") == f.get("__orig") { + }; + if !is_edit(f, *original, st.doc.mode) { return Ok(json!({"layer": layer.0, "unchanged": true})); } let color = f.get("color").cloned().ok_or("missing color")?; let object = params.as_object_mut().ok_or("invalid Solid Color target")?; object.insert("color".into(), color); + if let Some(inks) = color_picker_ui::picked_cmyk(f) { + object.insert("cmyk".into(), json!(inks)); + } app.run(cmds::SET, params) } @@ -109,12 +145,12 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, inks: [f32; 4]| { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}"))); + f.insert("__cmyk".into(), json!(inks)); + }; + let fill = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Fill(Fill::Solid(c)) => c, + _ => panic!("not a solid fill"), + } + }; + let inks = [0.5, 0.5, 0.5, 1.0]; + let id = open_new(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, inks); + confirm_new(&mut app, &f).unwrap(); + assert_eq!(fill(&app), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 }); + + let edit = [0.25, 0.75, 0.0, 0.1]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, edit); + confirm(&mut app, &f).unwrap(); + assert_eq!(fill(&app).c, edit); + // Reopening the picker shows the stored inks. + let id = open(&mut app).unwrap(); + let shown = color_picker_ui::current(&app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields); + assert_eq!(shown.cmyk, edit); + + // Other inks with the same display colour are still an edit. + let near = [0.5, 0.5, 0.499, 1.0]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, near); + confirm(&mut app, &f).unwrap(); + let rich = [0.5, 0.5, 0.5, 1.0]; + let id = open(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + typed(&mut f, rich); + assert_eq!(f.get("color"), f.get("__orig"), "both inks show as the same colour"); + confirm(&mut app, &f).unwrap(); + assert_eq!(fill(&app).c, rich); +} + +fn with_inks(f: &mut serde_json::Map, inks: [f32; 4]) { + let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(inks).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8); + f.insert("color".into(), json!(format!("#{r:02x}{g:02x}{b:02x}"))); + f.insert("__cmyk".into(), json!(inks)); +} + +/// Inks that share the picker's hex with the fill's own are still a preview and a thumbnail, as +/// they are an edit on OK (#8568). +#[test] +fn same_hex_inks_preview_and_thumbnail_as_ok_commits_them() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let near = [0.5, 0.5, 0.499, 1.0]; + let id = open_new(&mut app).unwrap(); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + with_inks(&mut f, near); + confirm_new(&mut app, &f).unwrap(); + + let rich = [0.5, 0.5, 0.5, 1.0]; + let id = open(&mut app).unwrap(); + with_inks(&mut app.ui.dialog_mut(id).unwrap().fields, rich); + let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!(fields.get("color"), fields.get("__orig"), "both inks show as the same colour"); + + let st = app.session.active().unwrap(); + let (document, layer) = (st.doc.id, st.active_layer.unwrap()); + let LayerContent::Fill(Fill::Solid(original)) = st.doc.layer(layer).unwrap().content else { panic!("not a solid fill") }; + assert_eq!(thumbnail_color(&app, document, layer, original).c, rich); + let (shown, _) = display_doc(&mut app, 0).expect("a preview of the new inks"); + let LayerContent::Fill(Fill::Solid(previewed)) = shown.layer(layer).unwrap().content else { panic!("not a solid fill") }; + assert_eq!(previewed.c, rich); +} + +/// An RGB fill inside a CMYK document that the picker gives inks at the unchanged hex is an edit; +/// in an RGB document, which ignores the inks, it is not. +#[test] +fn inks_given_to_a_non_cmyk_fill_in_a_cmyk_document_are_an_edit() { + let original = Color::rgba(0.2, 0.3, 0.4, 1.0); + let mut f = serde_json::Map::new(); + with_inks(&mut f, [0.3, 0.2, 0.1, 0.4]); + f.insert("__orig".into(), f["color"].clone()); + assert!(is_edit(&f, original, ColorMode::Cmyk)); + assert!(!is_edit(&f, original, ColorMode::Rgb)); + assert!(picked_color(&f, original, ColorMode::Cmyk).is_some_and(|c| c.mode == ColorMode::Cmyk && c.c == [0.3, 0.2, 0.1, 0.4])); +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5635b56ead..829e59e99e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -219,12 +219,31 @@ fn layer_style_title_gutters_can_start_drags() { } } +/// #9515: Bevel & Emboss is taller than a small window (1435 × 858, the reporter's): its page +/// scrolls, so OK and Cancel stay on screen. +#[test] +fn layer_style_buttons_stay_on_a_short_window() { + use egui_kittest::{Harness, kittest::Queryable}; + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap(); + app.run("layer.new.layer", serde_json::json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1435.0, 858.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + crate::layer_style::open(h.state_mut(), Some("bevelEmboss")).unwrap(); + h.run_steps(4); + for label in ["OK", "Cancel"] { + let r = h.get_by_label(label).rect(); + assert!(r.bottom() <= 858.0, "{label} is off the window: {r:?}"); + } +} + #[test] fn dragging_the_title_bar_moves_the_dialog() { 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(1400.0, 900.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + let mut harness = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); harness.state_mut().ui.open_dialog(DialogKind::LayerStyle, serde_json::Map::new()); harness.run_steps(3); @@ -247,6 +266,21 @@ fn dragging_the_title_bar_moves_the_dialog() { assert_eq!(harness.state().ui.dialogs.len(), 1, "dragging must not close the dialog"); } +/// Layer Style open on Bevel & Emboss (its tallest page) for a pixel layer. +fn layer_style_on_bevel(size: egui::Vec2) -> egui_kittest::Harness<'static, PhotocraftApp> { + 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.run("layer.new.layer", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + crate::layer_style::open(h.state_mut(), Some("bevelEmboss")).expect("Layer Style opens"); + h.run_steps(4); + assert!(h.query_by_label("Gloss Contour").is_some(), "the Bevel & Emboss page is showing"); + h +} + /// #4872: an alert (Error, About) closes with ↩ as with OK, but not on the ↩ that raised it. #[test] fn enter_closes_an_alert_but_not_the_enter_that_opened_it() { @@ -273,6 +307,97 @@ fn enter_closes_an_alert_but_not_the_enter_that_opened_it() { assert!(h.state().ui.dialogs.is_empty(), "Esc too"); } +/// The bin under the effect list deletes the selected effect from the dialog. +#[test] +fn layer_style_bin_deletes_the_selected_effect() { + use egui_kittest::kittest::Queryable; + + let mut h = layer_style_on_bevel(egui::vec2(1400.0, 900.0)); + let effects = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().ui.dialogs[0].fields["effects"].as_array().map_or(0, Vec::len); + assert_eq!(effects(&h), 1); + h.get_by_label("Delete effect").click(); + h.run_steps(3); + assert_eq!(effects(&h), 0, "Bevel & Emboss is gone from the list"); + assert_eq!(h.state().ui.dialogs.len(), 1, "the dialog stays open"); +} + +/// The right-hand column's OK applies the style and Cancel drops it; Enter is OK. +#[test] +fn layer_style_ok_cancel_and_enter() { + let bevels = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + layer.effects.items.iter().filter(|e| matches!(e, photocraft_doc::Effect::BevelEmboss { .. })).count() + }; + for (how, applied) in [("OK", 1), ("Cancel", 0), ("Enter", 1)] { + use egui_kittest::kittest::Queryable; + + let mut h = layer_style_on_bevel(egui::vec2(1400.0, 900.0)); + if how == "Enter" { + h.key_press(egui::Key::Enter); + } else { + h.get_by_label(how).click(); + } + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty(), "{how} closes the dialog"); + assert_eq!(bevels(&h), applied, "{how}"); + } +} + +/// On a short screen Layer Style fits, its page scrolling, so OK and Cancel stay reachable +/// (the Bevel & Emboss page used to push them off the bottom). +#[test] +fn layer_style_fits_a_short_screen() { + use egui_kittest::kittest::Queryable; + + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1366.0, 640.0)); + let h = layer_style_on_bevel(screen.size()); + assert!(screen.contains_rect(frame(&h)), "{:?} fits on {screen:?}", frame(&h)); + for button in ["OK", "Cancel"] { + assert!(screen.contains_rect(h.get_by_label(button).rect()), "{button} is on screen"); + } +} + +/// Layer Style is laid out like Photoshop's: wide and short, the effect list on the left, the +/// page in the middle and OK, Cancel, New Style…, Preview in a column on the right. +#[test] +fn layer_style_is_laid_out_like_photoshops() { + use egui_kittest::kittest::Queryable; + + let h = layer_style_on_bevel(egui::vec2(1400.0, 900.0)); + let at = |label: &str| h.get_by_label(label).rect(); + let frame = frame(&h); + assert!(frame.width() > frame.height() * 1.3, "wide and short: {:?}", frame.size()); + // The list's rows are painted; its bar's fx button marks the column. + let (list, page) = (at("Add a layer style"), at("Depth")); + let [ok, cancel, new_style, preview] = ["OK", "Cancel", "New Style…", "Preview"].map(at); + assert!(list.right() < page.left(), "the list is left of the page"); + for (name, r) in [("OK", ok), ("Cancel", cancel), ("New Style…", new_style), ("Preview", preview)] { + assert!(r.left() > page.right(), "{name} is right of the page"); + } + assert!(ok.top() < cancel.top() && cancel.top() < new_style.top() && new_style.top() < preview.top(), "top to bottom"); +} + +/// Layer Style keeps one size on every page, as Photoshop's does (it used to grow and shrink +/// with each page), and has no resize grip: Photoshop's isn't resizable either. +#[test] +fn layer_style_keeps_one_size_on_every_page() { + let mut h = layer_style_on_bevel(egui::vec2(1400.0, 900.0)); + let size = frame(&h).size(); + let dialog = h.state().ui.dialogs[0].id; + for page in ["blendingOptions", "styles"] { + h.state_mut().ui.dialog_mut(dialog).expect("still open").fields.insert("selected".into(), json!(page)); + h.run_steps(3); + assert_eq!(frame(&h).size(), size, "{page}"); + } + let grip = egui::Id::unique(("dialog", dialog)).with("resize").with("__resize_corner"); + assert!(h.ctx.read_response(grip).is_none(), "no resize grip"); +} + +fn frame(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Rect { + rects(&h.ctx).first().copied().expect("the dialog is drawn") +} + fn dialog_harness(size: egui::Vec2, command: &str) -> egui_kittest::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(); @@ -429,3 +554,89 @@ fn fit_image_is_as_narrow_as_its_content() { assert!(window.contains_rect(node.rect()), "{label} inside the window"); } } + +#[test] +fn german_color_picker_fits_its_actions_and_translates_the_title() { + use egui_kittest::{Harness, kittest::Queryable}; + let lang = crate::i18n::Lang::from_pref("de"); + let text = |key| crate::i18n::with_language(lang, || crate::i18n::t(key).to_owned()); + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = + Harness::builder().with_size(egui::vec2(760.0, 480.0)).build_ui_state(move |ui, app| crate::i18n::with_language(lang, || show(app, ui.ctx())), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + crate::color_picker_ui::open(h.state_mut(), "foreground"); + h.run_steps(4); + let title = h.get_by_label("Farbwähler (Vordergrundfarbe)").rect(); + let swatch = h.get_by_label(&text("Click to restore the current colour")).rect(); + let frame = rects(&h.ctx)[0]; + for label in ["OK", "Cancel", "Add to Swatches"] { + let r = h.get_by_label(&text(label)).rect(); + assert!(r.left() > swatch.right(), "{label} must stay beside the swatches: {r:?}, {swatch:?}"); + assert!((r.right() - title.right()).abs() < 1.0, "{label} aligns with the title edge"); + } + assert!(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(760.0, 480.0)).contains_rect(frame), "{frame:?}"); +} + +#[test] +fn file_info_multiline_fields_keep_enter_and_cancel_discards_edits() { + use egui_kittest::kittest::Queryable; + + crate::i18n::with_language(crate::i18n::Lang::EN, || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.fileInfo", json!({})).unwrap(); + h.run_steps(4); + use egui::accesskit::Role; + let fields = [ + (Role::TextInput, "Document Title"), + (Role::TextInput, "Author"), + (Role::TextInput, "Author title"), + (Role::MultilineTextInput, "Description"), + (Role::MultilineTextInput, "Keywords"), + (Role::ComboBox, "Copyright status"), + (Role::MultilineTextInput, "Copyright Notice"), + (Role::TextInput, "Copyright Info URL"), + ]; + for pair in fields.windows(2) { + assert!( + h.get_by_role_and_label(pair[0].0, pair[0].1).rect().center().y < h.get_by_role_and_label(pair[1].0, pair[1].1).rect().center().y, + "field order: {pair:?}" + ); + } + for (label, key) in [("Description", "description"), ("Keywords", "keywords"), ("Copyright Notice", "copyright")] { + h.get_by_role_and_label(Role::MultilineTextInput, label).click(); + h.run_steps(1); + h.event(egui::Event::Text("First".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(1); + h.event(egui::Event::Text("Second".into())); + h.run_steps(1); + let dialog = h.state().ui.dialogs.last().expect("Enter edits the field, not OK"); + assert_eq!(dialog.fields[key], "First\nSecond", "{label}"); + } + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(h.state_mut().run("file.fileInfo", json!({})).unwrap()["description"], ""); + }); +} + +#[test] +fn file_info_keeps_multiline_metadata_on_confirm_and_reopen() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let ctx = egui::Context::default(); + let id = crate::menus::invoke(&mut app, &ctx, "file.fileInfo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + for key in ["description", "copyright"] { + app.ui.dialog_mut(id).unwrap().fields.insert(key.into(), json!("First line\nSecond line")); + } + confirm(&mut app, id).unwrap(); + let id = crate::menus::invoke(&mut app, &ctx, "file.fileInfo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + for key in ["description", "copyright"] { + assert_eq!(app.ui.dialog_mut(id).unwrap().fields[key], "First line\nSecond line"); + } +} diff --git a/crates/ui-egui/src/dialogs/tiff_options_ui.rs b/crates/ui-egui/src/dialogs/tiff_options_ui.rs index b387f1aa41..90f33aaf61 100644 --- a/crates/ui-egui/src/dialogs/tiff_options_ui.rs +++ b/crates/ui-egui/src/dialogs/tiff_options_ui.rs @@ -1,22 +1,118 @@ -//! TIFF Options: the prompt File › Save As shows before writing a layered TIFF, as Photoshop -//! does when "Ask Before Saving Layered TIFF Files" is on in Preferences › File Handling. +//! TIFF Options: what File › Save As and Save a Copy ask before writing a TIFF, as Photoshop's +//! TIFF Options dialog does (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid, +//! Save Transparency (offered when the document has no Background layer) and, for a document a +//! TIFF stores with its layers, "Discard Layers and Save a Copy". Photoshop's JPEG compression, +//! Pixel Order and Byte Order aren't offered: the TIFF encoder writes interleaved samples in the +//! machine's byte order without JPEG. +//! +//! With "Ask Before Saving Layered TIFF Files" on (Preferences › File Handling), a layered save +//! also notes that layers increase the file size; "Don't show again" clears the preference. //! //! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written -//! once answered: with the layers (Photoshop layer data in the file), or flattened ("Discard -//! Layers and Save a Copy"). "Don't show again" clears the preference. Cancel drops the save. +//! once answered, journalled as the `record` command passed to [`park`] with its path, +//! `tiffLayers` (layered documents) and [`TiffChoice::record`]'s params. Cancel drops the save. +//! Scripts skip the prompt by passing any of `tiffCompression`, `tiffTransparency`, +//! `tiffPyramid` or `tiffLayers`. The answer is remembered for the next TIFF save of the session. + +use photocraft_codecs::TiffCompression; +use serde_json::Value; use crate::{ExportSettings, PhotocraftApp}; +/// A TIFF Options answer, from the prompt or from a command's params. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TiffChoice { + pub compression: TiffCompression, + /// Save Transparency; `None` for the default (see [`ExportSettings::tiff_transparency`]). + pub transparency: Option, + /// Save Image Pyramid. + pub pyramid: bool, + /// `Some(false)`: "Discard Layers and Save a Copy". `None` keeps what the save would do. + pub layers: Option, +} + +impl TiffChoice { + /// `settings` writing this choice. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + ExportSettings { + tiff_compression: self.compression, + tiff_transparency: self.transparency, + tiff_pyramid: self.pyramid, + tiff_layers: self.layers.unwrap_or(settings.tiff_layers), + ..settings + } + } + + /// Adds `tiffCompression`, and `tiffTransparency` and `tiffPyramid` when set, to a recorded + /// save step, so replaying it doesn't ask again. `tiffLayers` is recorded by the save itself. + pub fn record(self, step: &mut Value) { + step["tiffCompression"] = compression_name(self.compression).into(); + if let Some(t) = self.transparency { + step["tiffTransparency"] = t.into(); + } + if self.pyramid { + step["tiffPyramid"] = true.into(); + } + } +} + +/// The `tiffCompression` name of `c` (PackBits, which the dialog doesn't offer, as none). +pub fn compression_name(c: TiffCompression) -> &'static str { + match c { + TiffCompression::Lzw => "lzw", + TiffCompression::Deflate => "zip", + TiffCompression::None | TiffCompression::PackBits => "none", + } +} + +/// The TIFF Options a command or control call gives (`tiffCompression`, `tiffTransparency`, +/// `tiffPyramid`, `tiffLayers`), which skip the prompt. None given: `None`. A bad value is an +/// error, as in the engine's saves. +pub fn choice_param(params: &Value, cmd: &str) -> Result, String> { + use photocraft_engine::file_cmds::{tiff_bool_param, tiff_compression_param}; + let compression = tiff_compression_param(params, cmd).map_err(|e| e.to_string())?; + let transparency = tiff_bool_param(params, "tiffTransparency", cmd).map_err(|e| e.to_string())?; + let pyramid = tiff_bool_param(params, "tiffPyramid", cmd).map_err(|e| e.to_string())?; + let layers = match params.get("tiffLayers") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), + Some(v) => return Err(format!("{cmd}: `tiffLayers` must be true or false, got {v}")), + }; + let any = compression.is_some() || transparency.is_some() || pyramid.is_some() || layers.is_some(); + Ok(any.then(|| TiffChoice { compression: compression.unwrap_or_default(), transparency, pyramid: pyramid.unwrap_or(false), layers })) +} + +/// What the last TIFF Options answer of the session chose, where the next prompt starts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Remembered { + pub compression: TiffCompression, + pub pyramid: bool, +} + /// A save waiting on the TIFF 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, + pub compression: TiffCompression, + pub pyramid: bool, + /// Save Transparency, offered when [`Self::has_transparency`]. + pub transparency: bool, + /// The document has no Background layer, so its composite can be transparent. + pub has_transparency: bool, + /// The TIFF would carry the document's layers: "Discard Layers and Save a Copy" is offered. + pub layered: bool, /// "Discard Layers and Save a Copy". pub discard_layers: bool, + /// Show the layered-file note ("Ask Before Saving Layered TIFF Files"). + pub layer_note: bool, /// Clears `ask_before_saving_layered_tiff` when the save goes ahead. pub dont_ask_again: bool, + /// Save a Copy: the document keeps its path and unsaved changes. + pub copy: bool, + /// The step the answered save is recorded as: `file.saveAs` (#2032) or `file.saveACopy` (#4373). + pub record: &'static str, } /// `true` when `path` is a TIFF name (`.tif` / `.tiff`). @@ -25,36 +121,70 @@ pub fn is_tiff_path(path: &str) -> bool { name.rsplit_once('.').is_some_and(|(stem, ext)| !stem.is_empty() && (ext.eq_ignore_ascii_case("tif") || ext.eq_ignore_ascii_case("tiff"))) } -/// Whether saving the active document to `path` should go through the prompt: a TIFF that -/// would carry layer data, with the preference on. +/// Whether saving the active document to `path` asks for TIFF Options: any TIFF. pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool { - if !is_tiff_path(path) || !app.session.prefs().file_handling.ask_before_saving_layered_tiff { - return false; - } - app.session.active().is_some_and(|st| photocraft_engine::file_cmds::tiff_would_write_layers(&st.doc)) + is_tiff_path(path) && app.session.active().is_some() } -/// Parks the save behind the prompt. -pub fn park(app: &mut PhotocraftApp, path: String) -> Result<(), String> { +/// Parks the save of the active document to `path` behind the prompt. A prompt already waiting +/// is never replaced. +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { + if app.save_options_open() { + return Err(crate::SAVE_OPTIONS_OPEN.into()); + } let doc = app.active_doc_id()?; - app.tiff_options = Some(Prompt { doc, path, discard_layers: false, dont_ask_again: false }); + let st = app.session.active().ok_or("no document")?; + let layered = photocraft_engine::file_cmds::tiff_would_write_layers(&st.doc); + let has_transparency = st.doc.background().is_none(); + // Starts where an unset Save Transparency writes: kept unless there are alpha or spot channels. + let transparency = st.doc.channels.is_empty() && st.doc.quick_mask.is_none(); + let layer_note = layered && app.session.prefs().file_handling.ask_before_saving_layered_tiff; + let Remembered { compression, pyramid } = app.tiff_remembered; + app.tiff_options = Some(Prompt { + doc, + path, + compression, + pyramid, + transparency, + has_transparency, + layered, + discard_layers: false, + layer_note, + dont_ask_again: false, + copy, + record, + }); Ok(()) } pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(p) = &mut app.tiff_options else { return }; let (mut ok, mut cancel) = (false, false); - let (mut discard, mut dont_ask) = (p.discard_layers, p.dont_ask_again); let modal = egui::Modal::new(egui::Id::unique("tiff-options")).show(ctx, |ui| { ui.set_max_width(420.0); ui.label(egui::RichText::new(tl!("TIFF Options")).font(crate::theme::semibold(15.0))); ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(8.0); - ui.label(tl!("Including layers will increase file size.")); + crate::widgets::section_label(ui, tl!("Image Compression")); + ui.radio_value(&mut p.compression, TiffCompression::None, tl!("None")); + ui.radio_value(&mut p.compression, TiffCompression::Lzw, tl!("LZW")); + ui.radio_value(&mut p.compression, TiffCompression::Deflate, tl!("ZIP")); ui.add_space(8.0); - crate::widgets::checkbox(ui, &mut discard, tl!("Discard Layers and Save a Copy")); - crate::widgets::checkbox(ui, &mut dont_ask, tl!("Don't show again")); + crate::widgets::checkbox(ui, &mut p.pyramid, tl!("Save Image Pyramid")); + if p.has_transparency { + crate::widgets::checkbox(ui, &mut p.transparency, tl!("Save Transparency")); + } + if p.layered { + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Layers")); + crate::widgets::checkbox(ui, &mut p.discard_layers, tl!("Discard Layers and Save a Copy")); + if p.layer_note && !p.discard_layers { + ui.add_space(4.0); + ui.label(tl!("Including layers will increase file size.")); + crate::widgets::checkbox(ui, &mut p.dont_ask_again, tl!("Don't show again")); + } + } 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; @@ -63,30 +193,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }); }); cancel |= modal.should_close(); - p.discard_layers = discard; - p.dont_ask_again = dont_ask; // 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))) { - let Some(p) = app.tiff_options.take() else { return }; - if p.dont_ask_again { - app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); - } - let settings = ExportSettings { tiff_layers: !p.discard_layers, ..app.save_settings() }; - let step = serde_json::json!({"path": p.path, "tiffLayers": !p.discard_layers}); - let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.discard_layers)); - if written.is_ok() { - // An action being recorded keeps the Save As with this answer (#2032). - app.session.journal.push(("file.saveAs".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.discard_layers => crate::discard_ui::saved_document(app, ctx, p.doc), - // A background save releases the close prompt when it finishes (`jobs_ui`). - Ok(_) => {} - } + confirm(app, ctx); } else if cancel { app.tiff_options = None; app.ui.status = tl!("Save cancelled").into(); @@ -94,9 +203,41 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// The answer the prompt gives as it stands. +pub fn choice(p: &Prompt) -> TiffChoice { + TiffChoice { + compression: p.compression, + transparency: p.has_transparency.then_some(p.transparency), + pyramid: p.pyramid, + layers: p.layered.then_some(!p.discard_layers), + } +} + +/// OK: writes the waiting save with the prompt's answer, and journals it. +pub fn confirm(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = app.tiff_options.take() else { return }; + if p.layer_note && p.dont_ask_again && !p.discard_layers { + app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); + } + app.tiff_remembered = Remembered { compression: p.compression, pyramid: p.pyramid }; + let choice = choice(&p); + // 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}) }; + if let Some(layers) = choice.layers { + step["tiffLayers"] = layers.into(); + } + choice.record(&mut step); + // The waiting document's own settings (its path resolves linked smart objects), even if + // another tab is active now. Closed meanwhile, the write below reports it. + let settings = app.with_document(p.doc, |app| Ok(app.save_settings())).unwrap_or_else(|_| app.save_settings()); + let settings = choice.apply(settings); + app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy || p.discard_layers, (p.record, step)); +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn tiff_names() { @@ -108,4 +249,55 @@ mod tests { assert!(!is_tiff_path("tif")); assert!(!is_tiff_path("a.png")); } + + #[test] + fn choice_params() { + assert_eq!(choice_param(&json!({}), "file.saveAs"), Ok(None)); + assert_eq!(choice_param(&json!({"tiffCompression": null, "tiffPyramid": null}), "file.saveAs"), Ok(None)); + assert_eq!( + choice_param(&json!({"tiffCompression": "lzw"}), "file.saveAs"), + Ok(Some(TiffChoice { compression: TiffCompression::Lzw, ..Default::default() })) + ); + assert_eq!( + choice_param(&json!({"tiffTransparency": false, "tiffPyramid": true, "tiffLayers": false}), "file.saveAs"), + Ok(Some(TiffChoice { compression: TiffCompression::None, transparency: Some(false), pyramid: true, layers: Some(false) })) + ); + for bad in [ + json!({"tiffCompression": "jpeg"}), + json!({"tiffCompression": 1}), + json!({"tiffTransparency": "yes"}), + json!({"tiffTransparency": 1}), + json!({"tiffPyramid": [true]}), + json!({"tiffLayers": "false"}), + ] { + assert!(choice_param(&bad, "file.saveAs").is_err(), "{bad}"); + } + } + + #[test] + fn recorded_steps_replay_the_same_answer() { + for choice in [ + TiffChoice::default(), + TiffChoice { compression: TiffCompression::Deflate, transparency: Some(true), pyramid: true, layers: None }, + TiffChoice { compression: TiffCompression::Lzw, transparency: Some(false), pyramid: false, layers: None }, + ] { + let mut step = json!({"path": "a.tif"}); + choice.record(&mut step); + assert_eq!(choice_param(&step, "file.saveAs"), Ok(Some(choice)), "{step}"); + } + } + + #[test] + fn apply_sets_the_export_settings() { + let base = ExportSettings::default(); + let s = TiffChoice { compression: TiffCompression::Deflate, transparency: Some(false), pyramid: true, layers: Some(false) }.apply(base.clone()); + assert_eq!((s.tiff_compression, s.tiff_transparency, s.tiff_pyramid, s.tiff_layers), (TiffCompression::Deflate, Some(false), true, false)); + let io = s.io_options(); + assert_eq!( + (io.encode.tiff_compression, io.tiff_transparency, io.encode.tiff_pyramid, io.tiff_layers), + (TiffCompression::Deflate, Some(false), true, false) + ); + // No layers answer keeps what the save would do. + assert!(TiffChoice::default().apply(base).tiff_layers); + } } diff --git a/crates/ui-egui/src/dialogs/vp_ui.rs b/crates/ui-egui/src/dialogs/vp_ui.rs index f057fd2bcf..d67310e8cb 100644 --- a/crates/ui-egui/src/dialogs/vp_ui.rs +++ b/crates/ui-egui/src/dialogs/vp_ui.rs @@ -986,7 +986,7 @@ fn parse_planes(v: &Value) -> Result<(Vec, Vec>), String> }; let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0); let depth = p.get("depth").and_then(Value::as_f64).unwrap_or(1.0); - if !(0.0..=360.0).contains(&angle) || !(depth > 0.0 && depth <= 1000.0) { + if !((0.0..=360.0).contains(&angle) && depth > 0.0 && depth <= 1000.0) { return Err(format!("plane {i}: angle 0..360, depth above 0 and at most 1000")); } planes.push(VpPlane { corners: [[0.0; 2]; 4] }); diff --git a/crates/ui-egui/src/dialogs/vp_ui/tests.rs b/crates/ui-egui/src/dialogs/vp_ui/tests.rs index 19f7786e33..f45063d35c 100644 --- a/crates/ui-egui/src/dialogs/vp_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/vp_ui/tests.rs @@ -180,7 +180,7 @@ fn overlapping_stamp_preview_matches_commit_across_batches() { s.strokes[0].points.extend_from_slice(&points[2..]); assert!(s.paint_preview(|| 2)); let (shown, _) = s.shown().unwrap(); - let expected = photocraft_compose::flatten(&shown).px; + let expected = photocraft_compose::flatten(&shown).into_px(); assert_ne!(expected, photocraft_compose::flatten(&app.session.active().unwrap().doc).px); commit(&mut app).unwrap(); assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}"); @@ -217,7 +217,7 @@ fn stamp_preview_paints_over_the_paste_like_ok() { let s = app.distort.vanishing.as_ref().unwrap(); assert!(s.preview_has_paste(), "the preview shows the paste, so the overlay isn't drawn over the strokes"); let (shown, _) = s.shown().unwrap(); - let expected = photocraft_compose::flatten(&shown).px; + let expected = photocraft_compose::flatten(&shown).into_px(); commit(&mut app).unwrap(); assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}"); } @@ -349,7 +349,7 @@ fn ok_paints_the_layer_the_mode_was_opened_on() { app.session.execute("layer.new.layer", json!({"name": "other"})).unwrap(); let other = app.session.active().unwrap().active_layer.unwrap(); sync_preview(&mut app); - let expected = photocraft_compose::flatten(&crate::distort_ui::display_doc(&app, 0).unwrap().0).px; + let expected = photocraft_compose::flatten(&crate::distort_ui::display_doc(&app, 0).unwrap().0).into_px(); commit(&mut app).unwrap(); let d = &app.session.active().unwrap().doc; assert!(d.layer(other).unwrap().surface().unwrap().content_bounds().is_empty(), "newLayer={new_layer}"); @@ -389,7 +389,7 @@ fn an_edit_under_the_mode_shows_on_the_canvas() { let px = shown.layer(layer).unwrap().surface().unwrap(); assert!(px.rgba(210, 30)[1] > 0.9, "the edit shows: {:?}", px.rgba(210, 30)); assert!(px.rgba(120, 80) != st.doc.layer(layer).unwrap().surface().unwrap().rgba(120, 80), "and so does the stroke"); - let expected = photocraft_compose::flatten(&shown).px; + let expected = photocraft_compose::flatten(&shown).into_px(); commit(&mut app).unwrap(); assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected); } diff --git a/crates/ui-egui/src/dialogs/wide_angle_ui.rs b/crates/ui-egui/src/dialogs/wide_angle_ui.rs index 11740b3a30..b979f71cf4 100644 --- a/crates/ui-egui/src/dialogs/wide_angle_ui.rs +++ b/crates/ui-egui/src/dialogs/wide_angle_ui.rs @@ -12,7 +12,6 @@ use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::transform::Interp; use photocraft_algo::wideangle::{self, Camera, Constraint, Orientation, WideAngle, WideModel}; -use photocraft_color::PixelFormat; use photocraft_doc::LayerId; use photocraft_geom::Rect; use photocraft_raster::Surface; @@ -34,6 +33,7 @@ pub struct WideAngleDialog { frame: Rect, scale: f64, proxy: Surface, + target: Value, pw: usize, ph: usize, src_tex: Option, @@ -71,13 +71,23 @@ impl WideAngleDialog { p } - fn render(&mut self, ctx: &egui::Context) { - let t0 = crate::gpu_canvas::now_ms(); + fn preview_surface(&mut self) -> Result { let pf = Rect::new(0, 0, self.pw as i32, self.ph as i32); let p = self.proxy_params(); let mesh = wideangle::solve(&p, pf); self.residual = mesh.residual / self.scale; - let out = photocraft_algo::warp::warp_triangles(&self.proxy, pf, &mesh.verts, &mesh.triangles(), Interp::Bilinear); + let mut out = photocraft_algo::warp::warp_triangles(&self.proxy, pf, &mesh.verts, &mesh.triangles(), Interp::Bilinear); + if let Some(colors) = + photocraft_engine::color_channels::explicit(&json!({"target":self.target}), self.proxy.format().mode.color_channels()).map_err(|e| e.to_string())? + { + photocraft_engine::color_channels::restrict_surface_in(&self.proxy, &mut out, colors, pf).map_err(|e| e.to_string())?; + } + Ok(out) + } + + fn render(&mut self, ctx: &egui::Context) { + let t0 = crate::gpu_canvas::now_ms(); + let Ok(out) = self.preview_surface() else { return }; let img = tex_image(&out, self.pw, self.ph); match &mut self.out_tex { Some(t) => t.set(img, egui::TextureOptions::LINEAR), @@ -100,27 +110,26 @@ impl WideAngleDialog { fn command_params(&self) -> Value { let mut v = serde_json::to_value(&self.params).unwrap_or(json!({})); v["layer"] = json!(self.layer.0); - v + crate::distort_ui::with_filter_target(v, &self.target) } } pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { photocraft_engine::commands::find("filter.adaptiveWideAngle").map(|c| (c.enabled)(&app.session)).unwrap_or(Err("unknown command".into()))?; - let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; + let (layer, surf, _) = crate::distort_ui::active_filter_target(app)?; let st = app.session.active().ok_or("no document")?; let frame = st.doc.bounds(); let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); let (w, h) = (frame.width() as usize, frame.height() as usize); let k = w.max(h).div_ceil(PROXY_SIDE).max(1); let (pw, ph) = (w.div_ceil(k), h.div_ceil(k)); - let proxy = photocraft_algo::resample::resize_surface( - &surf.convert(PixelFormat::RGBA8), - 1.0 / k as f64, - 1.0 / k as f64, - photocraft_algo::resample::Resample::Bilinear, - ); + let proxy = if k == 1 { + surf.clone() + } else { + photocraft_algo::resample::resize_surface(&surf, 1.0 / k as f64, 1.0 / k as f64, photocraft_algo::resample::Resample::Bilinear) + }; // Normalise the proxy to start at the origin. - let mut px = Surface::new(PixelFormat::RGBA8); + let mut px = Surface::new(surf.format()); let src_r = Rect::new( (frame.x0 as f64 / k as f64) as i32, (frame.y0 as f64 / k as f64) as i32, @@ -139,6 +148,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> frame, scale: 1.0 / k as f64, proxy: px, + target: crate::distort_ui::colour_set_target(app), pw, ph, src_tex: None, @@ -466,6 +476,34 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn native_component_wide_angle_previews_and_commit_keep_unselected_samples() { + for mode in ["rgb", "cmyk", "lab"] { + for depth in [8, 16, 32] { + for alpha in [true, false] { + let fixture = crate::distort_ui::component_tests::app(mode, depth); + let mut app = if alpha { fixture } else { crate::distort_ui::component_tests::without_alpha(fixture) }; + open(&mut app, &egui::Context::default()).unwrap(); + let d = app.wide_angle.as_mut().unwrap(); + d.params.model = WideModel::Fisheye; + d.params.focal_length = 10.0; + d.params.constraints.push(Constraint { a: [8.0, 8.0], b: [56.0, 16.0], orientation: Orientation::Horizontal }); + let layer = d.layer; + let before = d.proxy.clone(); + let bounds = Rect::new(0, 0, d.pw as i32, d.ph as i32); + let preview = d.preview_surface().unwrap(); + crate::distort_ui::component_tests::protected(&before, &preview, bounds); + assert_ne!(preview.read_region(bounds), before.read_region(bounds)); + app.run("channel.target", json!({"channel":{"color":1}})).unwrap(); + commit(&mut app).unwrap(); + let after = app.session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap(); + crate::distort_ui::component_tests::protected(&before, after, bounds); + assert_ne!(after.read_region(bounds), before.read_region(bounds)); + } + } + } + } + #[test] fn dialog_adds_constraints_and_commits() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/dock/tests.rs b/crates/ui-egui/src/dock/tests.rs index c03c067f6d..d3884e20fa 100644 --- a/crates/ui-egui/src/dock/tests.rs +++ b/crates/ui-egui/src/dock/tests.rs @@ -109,7 +109,9 @@ fn switching_layer_kinds_keeps_the_layers_panel_still() { /// A group height dragged in the dock is part of the UI state and the remembered layout. #[test] fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { - let (app, _, _) = app_with_layers(); + let (mut app, _, _) = app_with_layers(); + // CodeRabbit on #9606: the Color panel's options ride the same frames and preferences. + app.ui.color_panel.mode = crate::state::ColorPanelMode::HsbSliders; let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); let props = rect_of(&h, Group::Properties); let color = rect_of(&h, Group::Color); @@ -137,6 +139,8 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() { restore(&mut app3); assert!(app3.ui.docking.is_some()); assert_eq!(app3.ui.docking.as_ref().map(|w| &w.layout), h.state().ui.docking.as_ref().map(|w| &w.layout)); + crate::color_panel_ui::restore(&mut app3); + assert_eq!(app3.ui.color_panel.mode, crate::state::ColorPanelMode::HsbSliders, "the Color panel's mode too"); // …unless Remember Workspace Changes is off. let mut s3 = photocraft_engine::Session::new(); s3.load_prefs_json(&prefs).unwrap(); diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index a70d6acd8e..52149ab8e0 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1472,3 +1472,15 @@ fn save_a_copy_appends_copy_unless_the_preference_says_not_to() { ); } } + +#[test] +fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + assert_eq!(crate::menus::invoke(&mut app, &ctx, "file.import.variableDataSets", json!({})).unwrap(), json!({"fileDialog": "open"})); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(e), .. }, _)] if e == &["csv", "txt"])); + answer(&open, Some(FileDialogAnswer::Contents("sets.csv".into(), b"headline\nHello\nWorld\n".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 19d8819e33..814fc4e114 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -93,16 +93,27 @@ impl PhotocraftApp { /// `path` for File › Save unless it is a derivative preview, and `path` goes to the top of Open Recent. Returns the import /// warnings (also shown to the user). pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result, String> { + self.open_file_placed(path, bytes, None, None) + } + + /// [`Self::open_file`] for a file dropped on a tab slot or opened as one of a set. + pub(crate) fn open_file_placed( + &mut self, + path: &str, + bytes: &[u8], + slot: Option, + order: Option, + ) -> Result, String> { // Brushes/gradients go to the preset libraries (no document, no Open Recent entry). if let Some(r) = crate::preset_files_ui::open(self, path, bytes) { return r.map(|()| Vec::new()); } if self.background_jobs { // The path and Open Recent are recorded when the background open finishes. - crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes))?; + crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes), slot, order)?; return Ok(Vec::new()); } - self.open_bytes_from(&display_name(path), bytes, Some(path)) + self.open_bytes_placed(&display_name(path), bytes, Some(path), slot, order) } /// Record that the active document was just opened from `path`: File › Save writes back to it @@ -131,6 +142,13 @@ impl PhotocraftApp { /// Read and open the file at `path` (see [`open_file`](Self::open_file)). #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) -> Result, String> { + self.open_path_placed(path, None, None) + } + + /// [`Self::open_path`] for a file dropped on a tab slot or opened as one of a set: the slot + /// and order survive an inline memory/profile prompt and the document's final placement. + #[cfg(not(target_arch = "wasm32"))] + fn open_path_placed(&mut self, path: &str, slot: Option, order: Option) -> Result, String> { if self.show_if_open(path) { return Ok(Vec::new()); } @@ -138,11 +156,18 @@ impl PhotocraftApp { // would block the window). Preset files (brushes, gradients) go the usual way. let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); if self.background_jobs && !crate::preset_files_ui::PRESET_EXTS.contains(&ext.as_str()) { - crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), photocraft_engine::jobs::OpenSource::Path(path.to_string()))?; + crate::jobs_ui::start_open( + self, + &display_name(path), + Some(path.to_string()), + photocraft_engine::jobs::OpenSource::Path(path.to_string()), + slot, + order, + )?; return Ok(Vec::new()); } let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - self.open_file(path, &bytes) + self.open_file_placed(path, &bytes, slot, order) } #[cfg(target_arch = "wasm32")] @@ -150,11 +175,20 @@ impl PhotocraftApp { Err(format!("cannot open paths on the web: {path}")) } + /// The web has no filesystem paths: a drop read by the shell arrives as bytes. + #[cfg(target_arch = "wasm32")] + fn open_path_placed(&mut self, path: &str, _slot: Option, _order: Option) -> Result, String> { + Err(format!("cannot open paths on the web: {path}")) + } + /// Open several files, reporting each failure to the user instead of stopping. Relative paths /// are resolved against the working directory so the remembered path stays valid. The files /// open in name order ([`natural_cmp`]: `File_2` before `File_10`), and their tabs keep that /// order however long each takes to open in the background (#3248). Returns how many opened. pub fn open_paths(&mut self, paths: &[String]) -> usize { + // The command line's files open before the first frame, which otherwise applies the + // interface language: their status lines and notices would come out in English. + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.session.prefs().interface.language)); let mut paths: Vec = paths.iter().map(|p| absolute(p)).collect(); paths.sort_by(|a, b| natural_cmp(a, b)); let batch = crate::jobs_ui::begin_batch(self); @@ -165,21 +199,14 @@ impl PhotocraftApp { continue; } let order = crate::jobs_ui::OpenOrder { batch, seq }; - let pending = self.jobs.opens.len(); - match self.open_path(abs) { - Ok(_) => { - opened += 1; - // A background open's tab takes its place when it finishes; a document opened - // at once is in place now. - match self.jobs.opens.get_mut(pending) { - Some(tab) => tab.order = Some(order), - None => crate::jobs_ui::opened_in_order(&mut self.jobs, order), - } - } + // The order travels with the open: a background tab, an inline document, or a memory + // prompt that is answered later all place the file at its place in the set. + match self.open_path_placed(abs, None, Some(order)) { + Ok(_) => opened += 1, Err(e) => self.open_failed(&display_name(abs), &e), } } - crate::jobs_ui::end_batch(&mut self.jobs); + crate::jobs_ui::end_batch(self); opened } @@ -195,6 +222,13 @@ impl PhotocraftApp { } else if let Some(o) = self.jobs.opens.iter().find(|o| o.path.as_deref().is_some_and(|p| !is_template(p) && same(p))) { // A template still loading opens as another Untitled, as it does once loaded. self.jobs.focus = Some(o.job); + } else if self.held_memory_prompts.iter().any(|m| m.path.as_deref().is_some_and(|p| !is_template(p) && same(p))) { + // The file's high-memory prompt is already waiting: show it, don't queue a second + // prompt and a second open for the same file. A template still opens again as + // another Untitled, like one that is still loading. + crate::open_prompt::advance_memory(self); + self.ui.chrome.home = None; + return true; } else { return false; } @@ -279,22 +313,23 @@ impl PhotocraftApp { DropTarget::Elsewhere } - /// Open one dropped file; with `slot`, its tab goes there (when it finishes opening, for - /// background opens). True when it took the slot. + /// Open one dropped file; with `slot`, its tab goes there (at once, or when a held prompt is + /// answered). True when it took the slot. fn open_dropped_file(&mut self, f: &egui::DroppedFileHandle, name: &str, slot: Option) -> Result { - // Opens append: a background open's tab, or a document. let (docs, opens) = (self.session.documents().len(), self.jobs.opens.len()); + let held = self.held_opens.len() + self.held_memory_prompts.len(); + // The slot travels with the open through the inline document, a background tab and a + // memory/profile prompt, so no separate move here can land on another document. if f.path().is_absolute() { - self.open_path(&path_text(f.path(), false)?)?; + self.open_path_placed(&path_text(f.path(), false)?, slot, None)?; } else { - self.open_bytes(name, &crate::read_dropped(&**f)?)?; + self.open_bytes_placed(name, &crate::read_dropped(&**f)?, None, slot, None)?; } - let Some(slot) = slot else { return Ok(false) }; - if let Some(tab) = self.jobs.opens.get_mut(opens) { - tab.slot = Some(slot); - return Ok(true); + if slot.is_none() { + return Ok(false); } - Ok(self.run("document.move", serde_json::json!({"document": docs, "to": slot})).is_ok()) + let took = self.jobs.opens.len() > opens || self.session.documents().len() > docs || self.held_opens.len() + self.held_memory_prompts.len() > held; + Ok(took) } /// Place the next file dropped on the canvas and start Free Transform on it, once no other diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 099caf58c5..bd74eceb9e 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -713,8 +713,17 @@ fn views_and_windows_stay_with_their_documents() { for (i, v) in app.ui.views.iter_mut().enumerate() { v.zoom = i as f32 + 1.0; } - app.ui.windows = - (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true, position: None }).collect(); + app.ui.windows = (0..4) + .map(|document| crate::state::DocWindow { + id: document as u64, + document, + view: Default::default(), + open: true, + floating: false, + focused: false, + position: None, + }) + .collect(); let zooms = |app: &PhotocraftApp| app.ui.views.iter().map(|v| v.zoom).collect::>(); let windows = |app: &PhotocraftApp| app.ui.windows.iter().map(|w| (w.id, w.document)).collect::>(); assert_eq!(app.run("document.move", json!({"document": 0, "to": 3})).unwrap(), json!({"document": 3})); @@ -818,6 +827,31 @@ fn natural_order_counts_numbers_by_value() { assert_eq!(names, ["A.psd", "b.psd", "file_1.psd", "File_03.psd", "File_9.psd", "File_10.psd", "File_100.psd"]); } +/// Files on the command line open before the first frame applies the interface language: their +/// status line follows the preference, not the English default. +#[test] +fn open_paths_reports_in_the_interface_language_before_the_first_frame() { + let pattern = |i: u8| { + let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[f32::from(i) / 3.0, 0.5, 0.2, 1.0]); + let mut p = photocraft_doc::Pattern::new("Launch", surface, 4, 4); + p.id = format!("6f1c2c1e-0000-4000-8000-00000000000{i}"); + p + }; + let bytes = photocraft_io::pattern_map::write_pat(&[pattern(1), pattern(2), pattern(3)]).unwrap(); + let (dir, paths) = files("launch-language", &["launch.pat"], &bytes); + let (mut app, _) = app_with(Vec::new()); + app.session.edit_prefs(|p| p.interface.language = "ar".into()); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(app.open_paths(&paths), 1); + let ar = crate::i18n::Lang::from_code("ar").unwrap(); + let want = + crate::i18n::fmt(&crate::i18n::trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), &[("n", "3"), ("file", "launch")]); + assert_eq!(app.ui.status, want); + crate::i18n::set_current(crate::i18n::Lang::EN); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3248: several files opened at once (File › Open, the command line, Finder) open in name order. #[test] fn open_paths_opens_a_set_in_name_order() { @@ -851,7 +885,7 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { let index = app.session.add_document(Document::new("f1.psd", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), None); assert_eq!(index, 1); jobs_ui::opened_in_order(&mut app.jobs, OpenOrder { batch, seq: 1 }); - jobs_ui::end_batch(&mut app.jobs); + jobs_ui::end_batch(&mut app); assert_eq!(app.jobs.batches.len(), 1, "kept while members are opening"); // The rest finish out of order: last, first, third. for seq in [3usize, 0, 2] { diff --git a/crates/ui-egui/src/frame_stages.rs b/crates/ui-egui/src/frame_stages.rs new file mode 100644 index 0000000000..c2b4fc5fb9 --- /dev/null +++ b/crates/ui-egui/src/frame_stages.rs @@ -0,0 +1,323 @@ +//! Where each frame's time goes (#9328), gathered every frame, and the slow ones kept. +//! +//! [`FrameStages`] is one frame's record. Each stage has one writer: the raw input hook starts +//! the frame, `App::run` adds commands, the GPU canvas path adds damage tracking and budgeting, +//! [`crate::gpu_canvas::Perf::record`] adds composites and uploads, and the canvas adds stroke +//! rendering. `App::ui` closes it ([`SlowFrames::finish`]). A frame over [`SLOW_FRAME_MS`] goes +//! into [`SlowFrames`], the last [`SLOW_FRAMES`] of them, which the control channel's `perf` +//! output lists as `slowFrames`; one `info` log line per second names them. The stroke trace +//! (`stroke_trace`) reads the same record, so the app times a frame in one place. +//! +//! On the web `now_ms()` is 0, so no frame is ever slow and the ring stays empty. + +use std::collections::VecDeque; + +/// A frame slower than this (ms) is kept and logged. +pub const SLOW_FRAME_MS: f64 = 50.0; +/// How many slow frames are kept. +pub const SLOW_FRAMES: usize = 64; +/// At most one slow-frame log line per this many ms. +const LOG_EVERY_MS: f64 = 1000.0; + +/// One frame's stages. Times in ms. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameStages { + /// Frame start (the raw input hook), from [`crate::gpu_canvas::now_ms`]. + pub t: f64, + /// The whole frame: from its start until its UI is built. Never less than the stages below + /// (a command run between frames counts toward the next one). + pub frame_ms: f64, + /// From the raw input hook to the start of the UI build (input handling, minus commands). + pub input_ms: f64, + /// Commands run through the app since the previous frame ended. + pub command_ms: f64, + /// The last of those commands ("" for none). + pub command: String, + /// Damage tracking and GPU memory budgeting on the GPU canvas path. + pub damage_ms: f64, + /// Canvas composites, CPU side (on the GPU path: encoding and submitting them). + pub composite_ms: f64, + /// Canvas conversions and uploads. + pub upload_ms: f64, + /// The GPU's own time for a recent composite, when the canvas is on the GPU and it reports one. + pub gpu_ms: Option, + /// eframe's CPU time for the previous frame minus that frame's UI build: tessellation and + /// GPU submission (the previous frame's, so it isn't part of `frame_ms`). + pub present_ms: Option, + /// The last canvas refresh's kind ("full", "rect", "gpu-full", …; "" for none). + pub refresh: &'static str, + /// Pixels the frame's canvas refreshes covered. + pub refresh_px: u64, + /// "gpu" or "cpu": who drew the canvas. + pub path: &'static str, + /// Building the UI (`App::ui`), the canvas refresh included. + #[serde(skip)] + pub ui_ms: f64, + /// Rendering stroke points (the stroke trace's `stroke`). + #[serde(skip)] + pub stroke_ms: f64, + /// Pointer moves the frame delivered. + #[serde(skip)] + pub moves: usize, + /// eframe's CPU time for the previous frame. + #[serde(skip)] + pub cpu_prev_ms: Option, + /// The raw input hook ran for this frame. + #[serde(skip)] + started: bool, +} + +impl FrameStages { + /// The raw input hook: the frame starts at `now` with `moves` pointer moves. + pub fn start(&mut self, now: f64, moves: usize) { + self.t = now; + self.moves = moves; + self.started = true; + } + + /// The UI build starts at `now`; `cpu_prev` is eframe's CPU time for the previous frame, in s. + pub fn ui_start(&mut self, now: f64, cpu_prev: Option) { + if !self.started { + self.start(now, 0); + } + self.input_ms = (now - self.t - self.command_ms).max(0.0); + self.cpu_prev_ms = cpu_prev.map(|s| f64::from(s) * 1000.0); + } + + /// A command `id` ran for `ms`. + pub fn command(&mut self, id: &str, ms: f64) { + self.command_ms += ms; + id.clone_into(&mut self.command); + } + + /// A canvas refresh of kind `kind` and `px` pixels. + pub fn refresh(&mut self, kind: &'static str, px: u64, composite_ms: f64, upload_ms: f64) { + self.composite_ms += composite_ms; + self.upload_ms += upload_ms; + self.refresh = kind; + self.refresh_px += px; + } + + /// The stages that make up `frame_ms` (`present_ms` belongs to the previous frame). + pub fn stage_ms(&self) -> f64 { + self.input_ms + self.command_ms + self.damage_ms + self.composite_ms + self.upload_ms + } + + /// The frame ends at `now`. + fn end(&mut self, now: f64) { + if !self.started { + self.t = now; + } + self.frame_ms = (now - self.t).max(self.stage_ms()); + } + + /// One line for the log. + pub fn line(&self) -> String { + let opt = |v: Option| v.map_or("-".to_string(), |v| format!("{v:.1}")); + let command = if self.command.is_empty() { String::new() } else { format!(" ({})", self.command) }; + format!( + "slow frame: {:.1} ms on the {} path: input {:.1}, command {:.1}{command}, damage {:.1}, composite {:.1}, upload {:.1}, gpu {}, present {}; refresh {} {} px", + self.frame_ms, + self.path, + self.input_ms, + self.command_ms, + self.damage_ms, + self.composite_ms, + self.upload_ms, + opt(self.gpu_ms), + opt(self.present_ms), + if self.refresh.is_empty() { "none" } else { self.refresh }, + self.refresh_px, + ) + } +} + +/// The last [`SLOW_FRAMES`] frames over the threshold. +#[derive(Debug)] +pub struct SlowFrames { + frames: VecDeque, + threshold_ms: f64, + /// The previous frame's UI build (for its `present_ms`). + prev_ui_ms: Option, + /// When the last log line went out, and the slow frames since that weren't logged. + logged_at: Option, + unlogged: usize, +} + +impl Default for SlowFrames { + fn default() -> Self { + Self { frames: VecDeque::with_capacity(SLOW_FRAMES), threshold_ms: SLOW_FRAME_MS, prev_ui_ms: None, logged_at: None, unlogged: 0 } + } +} + +impl SlowFrames { + /// The kept frames, oldest first. + pub fn frames(&self) -> impl DoubleEndedIterator { + self.frames.iter() + } + + /// Keep frames slower than `ms` instead of [`SLOW_FRAME_MS`] (tests). + #[cfg(test)] + pub(crate) fn set_threshold_ms(&mut self, ms: f64) { + self.threshold_ms = ms; + } + + /// Close frame `s` at `now`: `gpu` says the canvas is on the GPU, `gpu_ms` is the GPU's + /// latest composite time. A slow frame is kept, and logged at most once a second. + pub fn finish(&mut self, mut s: FrameStages, now: f64, gpu: bool, gpu_ms: Option) -> FrameStages { + s.end(now); + let on_gpu = if s.refresh.is_empty() { gpu } else { s.refresh.starts_with("gpu") }; + s.path = if on_gpu { "gpu" } else { "cpu" }; + s.gpu_ms = gpu_ms.filter(|_| on_gpu); + s.present_ms = s.cpu_prev_ms.zip(self.prev_ui_ms).map(|(cpu, ui)| (cpu - ui).max(0.0)); + self.prev_ui_ms = Some(s.ui_ms); + if let Some(line) = self.push(&s) { + log::info!("{line}"); + } + s + } + + /// Keep `s` if it's slow; the log line to write, if one is due. + fn push(&mut self, s: &FrameStages) -> Option { + if s.frame_ms.is_nan() || s.frame_ms <= self.threshold_ms { + return None; + } + if self.frames.len() == SLOW_FRAMES { + self.frames.pop_front(); + } + self.frames.push_back(s.clone()); + if self.logged_at.is_some_and(|at| s.t - at < LOG_EVERY_MS) { + self.unlogged += 1; + return None; + } + let mut line = s.line(); + if self.unlogged > 0 { + line.push_str(&format!(" ({} more slow frames)", self.unlogged)); + } + self.logged_at = Some(s.t); + self.unlogged = 0; + Some(line) + } +} + +impl serde::Serialize for SlowFrames { + fn serialize(&self, ser: S) -> Result { + ser.collect_seq(self.frames.iter()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(t: f64, ms: f64) -> FrameStages { + let mut s = FrameStages::default(); + s.start(t, 0); + s.ui_start(t + 1.0, None); + s.refresh("full", 100, ms / 2.0, 1.0); + s + } + + #[test] + fn slow_frames_keep_the_last_64_over_the_threshold() { + let mut r = SlowFrames::default(); + for k in 0..100 { + let t = f64::from(k) * 100.0; + r.finish(frame(t, 0.0), t + 60.0 + f64::from(k), false, None); + } + assert_eq!(r.frames().count(), SLOW_FRAMES); + let first = r.frames().next().unwrap(); + assert_eq!(first.t, 3600.0, "the oldest are dropped"); + assert_eq!(r.frames().last().unwrap().frame_ms, 159.0); + assert!(r.frames().all(|f| f.path == "cpu" && f.refresh_px == 100)); + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json.as_array().map(Vec::len), Some(SLOW_FRAMES)); + assert_eq!(json[0]["frameMs"], 96.0); + } + + #[test] + fn a_fast_frame_is_not_recorded() { + let mut r = SlowFrames::default(); + let s = r.finish(frame(0.0, 0.0), 49.0, true, Some(3.0)); + assert_eq!(s.frame_ms, 49.0); + assert_eq!(s.path, "cpu", "the refresh says who drew it"); + assert_eq!(s.gpu_ms, None, "no GPU time for a CPU refresh"); + r.finish(frame(100.0, 0.0), 150.0, false, None); + assert_eq!(r.frames().count(), 0, "exactly 50 ms isn't over"); + } + + #[test] + fn stages_never_exceed_the_frame() { + let mut r = SlowFrames::default(); + // A command run between frames (before the hook) makes the stages longer than the clock. + let mut s = FrameStages::default(); + s.command("layer.setProps", 80.0); + s.start(1000.0, 2); + s.ui_start(1005.0, Some(0.004)); + s.damage_ms = 2.0; + s.refresh("gpu-full", 12, 30.0, 10.0); + s.ui_ms = 40.0; + let s = r.finish(s, 1050.0, true, Some(7.0)); + assert!(s.stage_ms() <= s.frame_ms, "{s:?}"); + assert_eq!((s.input_ms, s.frame_ms, s.path, s.gpu_ms), (0.0, 122.0, "gpu", Some(7.0))); + assert_eq!(s.present_ms, None, "no previous frame"); + let mut next = frame(1100.0, 4.0); + next.cpu_prev_ms = Some(45.0); + let next = r.finish(next, 1110.0, true, None); + assert_eq!(next.present_ms, Some(5.0), "the previous frame's CPU time minus its UI build"); + assert!(next.stage_ms() <= next.frame_ms); + } + + #[test] + fn slow_frame_log_is_rate_limited() { + let mut r = SlowFrames::default(); + let lines: Vec = (0..10) + .filter_map(|k| { + let mut s = frame(f64::from(k) * 100.0, 0.0); + s.end(s.t + 60.0); + r.push(&s) + }) + .collect(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(lines[0].starts_with("slow frame: 60.0 ms") && !lines[0].contains("more"), "{}", lines[0]); + let mut s = frame(1000.0, 0.0); + s.end(1070.0); + let line = r.push(&s).expect("a second later, the next line"); + assert!(line.ends_with("(9 more slow frames)"), "{line}"); + assert_eq!(r.frames().count(), 11); + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod app_tests { + use serde_json::json; + + use crate::PhotocraftApp; + + #[test] + fn an_app_frame_with_a_full_refresh_records_its_stages() { + let (w, h) = (4000u64, 3000u64); + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "background": "white"})).unwrap(); + let layer = s.active().and_then(|d| d.active_layer).expect("the background").0; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(4).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + harness.run_steps(6); + // Every frame counts as slow, so the test doesn't depend on the machine's speed. + harness.state_mut().slow_frames.set_threshold_ms(0.0); + harness.state_mut().run("layer.setProps", json!({"layer": layer, "visible": false})).unwrap(); + harness.step(); + let app = harness.state(); + let f = app.slow_frames.frames().rev().find(|f| f.command == "layer.setProps").expect("the toggle's frame is kept").clone(); + // The CPU canvas (kittest has no wgpu) redraws the whole document. A damage refresh counts + // reduced texels times the factor squared, which can fall a few rows short (see P80). + assert_eq!(f.path, "cpu", "{f:?}"); + assert!(matches!(f.refresh, "full" | "rect"), "{f:?}"); + assert!(f.refresh_px * 100 >= w * h * 95 && f.refresh_px <= w * h, "{f:?}"); + assert!(f.composite_ms > 0.0 && f.command_ms > 0.0, "{f:?}"); + assert!(f.stage_ms() <= f.frame_ms, "{f:?}"); + } +} diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 75f1a05e85..ca832645b7 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -154,6 +154,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { app.gpu_fault = Some(fault.clone()); // Canvas caches pointed at GPU textures: rebuild them as egui textures. app.canvases.clear(); + // Viewport pages lived on the GPU too: their work stops, and the CPU canvas draws instead. + app.streams.clear(); app.proxy_uploaded = None; app.prefs_rt.gpu_style = None; app.perf.gpu = false; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 51f6d1872f..a8fa5c2bcf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -23,7 +23,9 @@ mod catalog; mod system; -pub use system::system_lang; +#[cfg(test)] +pub(crate) use system::with_decimal_separator; +pub use system::{decimal_separator, system_lang}; use std::cell::Cell; use std::sync::OnceLock; @@ -115,6 +117,18 @@ fn plural_polish(n: u64) -> usize { } } +/// Arabic (CLDR): 0 → zero, 1 → one, 2 → two, n % 100 in 3–10 → few, in 11–99 → many, else other. +fn plural_arabic(n: u64) -> usize { + match (n, n % 100) { + (0, _) => 0, + (1, _) => 1, + (2, _) => 2, + (_, 3..=10) => 3, + (_, 11..=99) => 4, + _ => 5, + } +} + /// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB /// of translations, which the web build's size gate can't afford). See [`unpack`]. macro_rules! catalog { @@ -187,7 +201,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 22] = [ +pub static LANGUAGES: [LangInfo; 27] = [ 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() }, @@ -222,6 +236,19 @@ pub static LANGUAGES: [LangInfo; 22] = [ 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() }, + // Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The + // chrome mirrors right to left (`dir_for_code`), and egui lays each label out right to left. + LangInfo { code: "ar", name: "العربية", source: catalog!("ar"), plural: plural_arabic, complete_menus: true, catalog: OnceLock::new() }, + // Swedish; `sv-SE` and `sv-FI` locales resolve here. + LangInfo { code: "sv", name: "Svenska", source: catalog!("sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Finnish; `fi-FI` resolves here (Swedish in Finland, `sv-FI`, stays Swedish). One/other: the + // partitive singular follows any number but 1 ("1 taso", "2 tasoa"). + LangInfo { code: "fi", name: "Suomi", source: catalog!("fi"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Danish; `da-DK` and the other `da-*` locales resolve here. + LangInfo { code: "da", name: "Dansk", source: catalog!("da"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Norwegian Bokmål; `nb-NO`, and the macrolanguage `no` and Nynorsk `nn` from POSIX locales, + // resolve here (see `candidates`). + LangInfo { code: "nb", name: "Norsk bokmål", source: catalog!("nb"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -351,6 +378,11 @@ fn candidates(tag: &str) -> Vec { // The only Portuguese catalog is Brazilian; other regions use it rather than English. out.insert(out.len() - 1, "pt-br".to_string()); } + if matches!(primary, "no" | "nn") && !out.iter().any(|c| c == "nb") { + // The only Norwegian catalog is Bokmål; the macrolanguage code and Nynorsk use it rather + // than English. + out.insert(out.len() - 1, "nb".to_string()); + } out } diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 0e9c354446..bcc690f7b7 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, Serbian and Russian UI translation catalogs are licensed under MIT OR -Apache-2.0, at your option. +Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish and Norwegian Bokmål 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 @@ -40,3 +40,20 @@ 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. + +Arabic translations: PhotoCraft contributors, combined from the catalogs contributed by @azizr12, +@hicbest, @hasan-sdaif, @NibrasAz7, @vfggf95565, @syrrevshin4202-prog and @test-bit-sys and +reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's +English labels, and no proprietary translation resources were used. +Swedish translations: PhotoCraft contributors, combined from the catalogs contributed by @kler and +@davvarn and reviewed with Claude (Anthropic) as an AI contributor; original translations of +PhotoCraft's English labels, and no proprietary translation resources were used. +Finnish translations: PhotoCraft contributors, combined from the catalogs contributed by @Janiz-zzz +and @metuuu and reviewed with Claude (Anthropic) as an AI contributor; original translations of +PhotoCraft's English labels, and no proprietary translation resources were used. +Danish translations: PhotoCraft contributors, based on the catalog contributed by +@pechnikov2026-star, completed and reviewed with Claude (Anthropic) as an AI contributor; original +translations of PhotoCraft's English labels, and no proprietary translation resources were used. +Norwegian Bokmål translations: PhotoCraft contributors, combined from the catalogs contributed by +@nikolainyegaard and @Bolla76 and reviewed with Claude (Anthropic) as an AI contributor; original +translations of PhotoCraft's English labels, and no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv new file mode 100644 index 0000000000..0c67a91ec6 --- /dev/null +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -0,0 +1,3551 @@ +# Arabic (ar) 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. +# +# Combined from the community Arabic catalogs in #2505, #4520, #4569, #5593, #5915, #6092 and +# #6287: each row takes the best of their translations, with one term per concept across the UI. +# Modern Standard Arabic; menu commands use verbal nouns (فتح، حفظ باسم…). Product and technology +# names (PhotoCraft, ArtCraft, GPU, CMYK, OpenType …) and key names stay in Latin letters; the +# trailing "…" of dialog commands is kept. Clean-room: written from the plain meaning of the English +# labels; no proprietary localisation resource was consulted. Plural rule (CLDR): +# zero|one|two|few|many|other. Reviewed for consistency by an AI model; native-speaker review of +# the specialist terms is welcome. + +# 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: اللون يشير إلى: + Solidity: الصلابة: + Masked Areas المناطق المقنعة + Selected Areas المناطق المحددة + Ink Characteristics خصائص الحبر + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width عرض الورق + Paper Height ارتفاع الورق + Columns أعمدة + Rows صفوف + Number عدد + Margin هامش + Top: أعلى: + Left: يسار: + Bottom: أسفل: + Right: يمين: + Target: الهدف: + Selected Artboards ألواح التصميم المحددة + Center Columns توسيط الأعمدة + Clear Existing Guides مسح خطوط التوجيه الموجودة + Invalid guide layout تخطيط خطوط توجيه غير صالح + Leave empty for automatic size اتركه فارغًا للحجم التلقائي + 5 Columns, 3 Rows 5 أعمدة، 3 صفوف + 8 Columns 8 أعمدة + 12 Columns 12 عمودًا + + File ملف + Edit تحرير + Image صورة + Layer طبقة + Type نص + Select تحديد + Selected محدد + Scope النطاق + Top Left أعلى اليسار + Top Right أعلى اليمين + Bottom Left أسفل اليسار + Bottom Right أسفل اليمين + Filter فلتر + View عرض + Window نافذة + Help تعليمات + (coming soon) (قريبًا) + New… جديد… + New from Clipboard جديد من الحافظة + Open… فتح… + Open As… فتح باسم… + Open EXR Parts… فتح أجزاء EXR… + Add beauty precomp from the AOVs إضافة تركيبة جمالية مسبقة من AOVs + Both كلاهما + Left يسار + Right يمين + Open Recent فتح الملفات الأخيرة + Clear Recent Files مسح الملفات الأخيرة + Remove from Recent إزالة من الملفات الأخيرة + Remove {name} from Recent إزالة {name} من الملفات الأخيرة + Close إغلاق + Minimize تصغير + Maximize تكبير + Restore استعادة + Close All إغلاق الكل + Close Others إغلاق المستندات الأخرى + Save حفظ + Save As… حفظ باسم… + Save a Copy… حفظ نسخة… + {name} can't keep everything in this document لا يستطيع {name} الاحتفاظ بكل شيء في هذا المستند + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. اختر تنسيقًا يحتفظ بذلك، مثل Photoshop (PSD)، أو احفظ نسخة. + Revert الرجوع إلى المحفوظ + Export تصدير + Quick Export as PNG تصدير سريع بتنسيق PNG + Export As… تصدير باسم… + Export Preferences… تفضيلات التصدير… + Save for Web (Legacy)… حفظ للويب (قديم)… + Artboards to Files… ألواح التصميم إلى ملفات… + Artboards to PDF… ألواح التصميم إلى PDF… + Layers to Files… الطبقات إلى ملفات… + Layer Comps to Files… تركيبات الطبقات إلى ملفات… + Color Lookup Tables… جداول بحث الألوان… + Data Sets as Files… مجموعات البيانات كملفات… + Paths to Illustrator… المسارات إلى Illustrator… + Render Video… تصيير الفيديو… + Generate توليد + Image Assets أصول الصور + Place Embedded… وضع مضمن… + Place Linked… وضع مرتبط… + Package… تجميع الحزمة… + Automate أتمتة + Batch… دفعة… + Create Droplet… إنشاء قُطيرة… + Crop and Straighten Photos اقتصاص الصور وتقويمها + Contact Sheet II… ورقة اتصال II… + Conditional Mode Change… تغيير الوضع الشرطي… + Fit Image… ملاءمة الصورة… + Lens Correction… تصحيح العدسة… + Merge to HDR Pro… دمج إلى HDR Pro… + Photomerge… Photomerge… + Scripts البرامج النصية + Image Processor… معالج الصور… + Delete All Empty Layers حذف كل الطبقات الفارغة + Flatten All Layer Effects تسطيح كل تأثيرات الطبقات + Flatten All Masks تسطيح كل الأقنعة + Script Events Manager… مدير أحداث البرامج النصية… + Import استيراد + Browse… استعراض… + Source Files الملفات المصدر + Use: الاستخدام: + Files الملفات + Folder المجلد + Add Open Files إضافة الملفات المفتوحة + Sort By Name فرز حسب الاسم + Attempt to Automatically Align Source Images محاولة محاذاة الصور المصدر تلقائيًا + Create Smart Object after Loading Layers إنشاء كائن ذكي بعد تحميل الطبقات + Reveal in Finder إظهار في Finder + Show in Explorer إظهار في Explorer + Show in Folder إظهار في المجلد + File Info… معلومات الملف… + Print… طباعة… + Print One Copy طباعة نسخة واحدة + Exit خروج + Undo تراجع + Redo إعادة + Toggle Last State تبديل الحالة الأخيرة + Fade… تلاشي… + Cut قص + Copy نسخ + Copy Merged نسخ مدمج + Paste لصق + Paste Special لصق خاص + Paste in Place لصق في المكان + Paste Into لصق داخل + Paste Outside لصق خارج + Paste Lorem Ipsum لصق نص Lorem Ipsum + Clear مسح + Search… بحث… + Check Spelling… تدقيق إملائي… + Find and Replace Text… البحث عن النص واستبداله… + Add to Dictionary إضافة إلى القاموس + No Spelling Suggestions لا توجد اقتراحات إملائية + Fill… تعبئة… + Stroke… حدود… + Content-Aware Fill… تعبئة مراعية للمحتوى… + Delete and Fill Selection حذف التحديد وتعبئته + Content-Aware Scale تحجيم مراعٍ للمحتوى + Puppet Warp التواء الدمية + Perspective Warp التواء المنظور + Free Transform تحويل حر + Free Transform a Copy تحويل حر لنسخة + Transform تحويل + Again مرة أخرى + Transform Again on a Copy تحويل مرة أخرى على نسخة + Scale تحجيم + Rotate تدوير + Set horizontal skew ضبط الإمالة الأفقية + Set vertical skew ضبط الإمالة العمودية + Skew إمالة + Distort تشويه + Perspective منظور + Warp التواء + Flip Horizontal قلب أفقي + Flip Vertical قلب عمودي + Rotate 180° تدوير 180° + Rotate 90° Clockwise تدوير 90° باتجاه عقارب الساعة + Rotate 90° Counter Clockwise تدوير 90° عكس اتجاه عقارب الساعة + 180° 180° + 90° Clockwise 90° باتجاه عقارب الساعة + 90° Counter Clockwise 90° عكس اتجاه عقارب الساعة + Arbitrary… مخصص… + Auto-Align Layers… محاذاة الطبقات تلقائيًا… + Auto-Blend Layers… مزج الطبقات تلقائيًا… + Define Brush Preset… تعريف إعداد فرشاة مسبق… + Define Pattern… تعريف نقشة… + Define Custom Shape… تعريف شكل مخصص… + Purge تفريغ + Clipboard الحافظة + Histories السجلات + Video Cache ذاكرة الفيديو المؤقتة + All الكل + Presets إعدادات مسبقة + Preset Manager… مدير الإعدادات المسبقة… + Migrate Presets ترحيل الإعدادات المسبقة + Export/Import Presets… تصدير/استيراد الإعدادات المسبقة… + Color Settings… إعدادات الألوان… + Assign Profile… تعيين ملف تعريف… + Convert to Profile… تحويل إلى ملف تعريف… + Keyboard Shortcuts… اختصارات لوحة المفاتيح… + Menus… القوائم… + Toolbar… شريط الأدوات… + Preferences التفضيلات + General… عام… + Interface… الواجهة… + Workspace… مساحة العمل… + Tools… الأدوات… + History Log… سجل العمليات… + File Handling… معالجة الملفات… + Export… تصدير… + Performance… الأداء… + Scratch Disks… أقراص التخزين المؤقت… + Cursors… المؤشرات… + Transparency & Gamut… الشفافية والنطاق اللوني… + Units & Rulers… الوحدات والمساطر… + Guides, Grid & Slices… خطوط التوجيه والشبكة والشرائح… + Plug-ins… المكونات الإضافية… + Type… النص… + Enhanced Controls… عناصر التحكم المحسنة… + Camera Raw… Camera Raw… + Raw Defaults… إعدادات Raw الافتراضية… + Integrations… التكاملات… + AI Integrations… تكاملات الذكاء الاصطناعي… + Install Plug-in… تثبيت مكون إضافي… + Language Options خيارات اللغة + Settings… إعدادات… + Mode الوضع + Bitmap صورة نقطية + Grayscale تدرج الرمادي + Duotone ثنائي اللون + Indexed Color ألوان مفهرسة + RGB Color ألوان RGB + CMYK Color ألوان CMYK + Lab Color ألوان Lab + Multichannel متعدد القنوات + 8 Bits/Channel 8 بت/قناة + 8 bit 8 بت + Integer عدد صحيح + 16 Bits/Channel 16 بت/قناة + 16 bit 16 بت + 32 Bits/Channel 32 بت/قناة + 32 bit (float) 32 بت (فاصلة عائمة) + Floating point فاصلة عائمة + Color Table… جدول الألوان… + Bitmap… صورة نقطية… + Duotone… ثنائي اللون… + Indexed Color… ألوان مفهرسة… + Adjustments التعديلات + Brightness/Contrast… السطوع/التباين… + Levels… المستويات… + Curves… المنحنيات… + Exposure… التعريض… + Vibrance… الحيوية… + Hue/Saturation… درجة اللون/التشبع… + Color Balance… توازن الألوان… + Black & White… أبيض وأسود… + Photo Filter… فلتر الصور… + Channel Mixer… مازج القنوات… + Color Lookup… بحث الألوان… + Invert عكس + Posterize… تقليل درجات اللون… + Threshold… العتبة… + Gradient Map… خريطة التدرج… + Selective Color… لون انتقائي… + Shadows/Highlights… الظلال/الإضاءات… + HDR Toning… ضبط درجات HDR… + Desaturate إزالة التشبع + Match Color… مطابقة اللون… + Replace Color… استبدال اللون… + Equalize تسوية + Auto Tone درجة لونية تلقائية + Auto Contrast تباين تلقائي + Auto Color لون تلقائي + Image Size… حجم الصورة… + Canvas Size… حجم مساحة الرسم… + Image Rotation تدوير الصورة + Flip Canvas Horizontal قلب مساحة الرسم أفقيًا + Flip Canvas Vertical قلب مساحة الرسم عموديًا + Crop اقتصاص + Trim… تشذيب… + Reveal All إظهار الكل + Duplicate… تكرار… + Apply Image… تطبيق الصورة… + Calculations… العمليات الحسابية… + Variables المتغيرات + Define… تعريف… + Data Sets… مجموعات البيانات… + Apply Data Set… تطبيق مجموعة البيانات… + Variable Data Sets… مجموعات بيانات متغيرة… + Trap… حصر الألوان… + Analysis التحليل + Set Measurement Scale… تعيين مقياس القياس… + Select Data Points… تحديد نقاط البيانات… + Record Measurements تسجيل القياسات + Ruler Tool أداة المسطرة + Count Tool أداة العد + Place Scale Marker… وضع علامة المقياس… + Custom… مخصص… + New جديد + Layer… طبقة… + Group… مجموعة… + Group from Layers… مجموعة من الطبقات… + New Group from Layers… مجموعة جديدة من الطبقات… + Artboard… لوح التصميم… + Artboard from Group… لوح تصميم من مجموعة… + Artboard from Layers… لوح تصميم من طبقات… + Layer via Copy طبقة عبر النسخ + Layer via Cut طبقة عبر القص + Layer From Background… طبقة من الخلفية… + Layer from Background… طبقة من الخلفية… + Background from Layer خلفية من طبقة + Background From Layer خلفية من طبقة + New Shape Layer طبقة شكل جديدة + New Type Layer طبقة نص جديدة + New Fill Layer طبقة تعبئة جديدة + New Adjustment Layer طبقة تعديل جديدة + New Layer Based Slice شريحة جديدة مبنية على طبقة + New Smart Object via Copy كائن ذكي جديد عبر النسخ + New Blank Video Layer طبقة فيديو فارغة جديدة + New Video Layer from File… طبقة فيديو جديدة من ملف… + Duplicate Layer… تكرار الطبقة… + Delete Layer حذف الطبقة + Delete حذف + Rename Layer إعادة تسمية الطبقة + Create Layer إنشاء طبقة + Layer Style نمط الطبقة + Blending Options… خيارات المزج… + Bevel & Emboss… شطب ونقش… + Use Global Light استخدام الإضاءة العامة + Overprint طباعة فوقية + Inner Shadow… ظل داخلي… + Inner Glow… توهج داخلي… + Satin… ساتان… + Color Overlay… تراكب لوني… + Gradient Overlay… تراكب التدرج… + Pattern Overlay… تراكب النقشة… + Outer Glow… توهج خارجي… + Drop Shadow… ظل مسقط… + Copy Layer Style نسخ نمط الطبقة + Paste Layer Style لصق نمط الطبقة + Clear Layer Style مسح نمط الطبقة + Global Light… إضاءة عامة… + Hide All Effects إخفاء كل التأثيرات + Scale Effects… تحجيم التأثيرات… + Smart Filter فلتر ذكي + Convert for Smart Filters تحويل للفلاتر الذكية + Clear Smart Filters مسح الفلاتر الذكية + Disable Smart Filters تعطيل الفلاتر الذكية + Delete Filter Mask حذف قناع الفلتر + Disable Filter Mask تعطيل قناع الفلتر + Solid Color… لون مصمت… + Gradient… تدرج… + Pattern… نقشة… + Layer Mask قناع الطبقة + Vector Mask قناع متجه + Reveal Selection إظهار التحديد + Hide Selection إخفاء التحديد + Hide All إخفاء الكل + From Transparency من الشفافية + Delete Vector Mask حذف القناع المتجه + Enable Layer Mask تمكين قناع الطبقة + Disable Layer Mask تعطيل قناع الطبقة + Enable Vector Mask تمكين القناع المتجه + Link Layer Mask ربط قناع الطبقة + Unlink Layer Mask إلغاء ربط قناع الطبقة + Link Vector Mask ربط القناع المتجه + Add Vector Mask إضافة قناع متجه + Vector Mask from Current Path قناع متجه من المسار الحالي + Create Clipping Mask إنشاء قناع اقتطاع + Release Clipping Mask تحرير قناع القص + Smart Objects كائنات ذكية + Smart Object كائن ذكي + Convert to Smart Object تحويل إلى كائن ذكي + Convert to Embedded تحويل إلى مضمّن + Convert to Layers تحويل إلى طبقات + Convert to Linked… تحويل إلى مرتبط… + Edit Contents تحرير المحتويات + Replace Contents… استبدال المحتويات… + Relink to File… إعادة الربط بملف… + Export Contents… تصدير المحتويات… + Update Modified Content تحديث المحتوى المعدل + Update All Modified Content تحديث كل المحتوى المعدّل + Stack Mode وضع التكديس + Choose Stack Mode: اختر وضع التكديس: + Entropy الإنتروبيا + Kurtosis التفرطح + Maximum الحد الأقصى + Mean المتوسط + Median وسيط + Minimum الحد الأدنى + Range النطاق + Skewness الالتواء + Standard Deviation الانحراف المعياري + Summation المجموع + Variance التباين الإحصائي + Video Layers طبقات الفيديو + Interpret Footage… تفسير اللقطات… + Replace Footage… استبدال اللقطات… + Reload Frame إعادة تحميل الإطار + Show Altered Video إظهار الفيديو المعدل + Restore Frame استعادة الإطار + Restore All Frames استعادة كل الإطارات + Insert Blank Frame إدراج إطار فارغ + Delete Frame حذف الإطار + Duplicate Frame تكرار الإطار + Frame from Layers إطار من الطبقات + Video Frames to Layers… إطارات الفيديو إلى طبقات… + Rasterize تنقيط + Rasterize Type تنقيط النص + Rasterize Type Layer تنقيط طبقة النص + Rasterize Shape تنقيط الشكل + New Guide Layout… تخطيط خطوط توجيه جديد… + New Guides From Shape خطوط توجيه جديدة من الشكل + Lock Layers… قفل الطبقات… + Link Layers ربط الطبقات + Select Linked Layers تحديد الطبقات المرتبطة + Merge Layers دمج الطبقات + Merge Down دمج للأسفل + Merge Group دمج المجموعة + Merge Visible دمج المرئي + Stamp Visible ختم المرئي + Select Layer Above تحديد الطبقة أعلاه + Select Layer Below تحديد الطبقة أدناه + Select Top Layer تحديد الطبقة العليا + Select Bottom Layer تحديد الطبقة السفلى + Add Layer Above to Selection إضافة الطبقة أعلاه إلى التحديد + Add Layer Below to Selection إضافة الطبقة أدناه إلى التحديد + Stamp Down ختم للأسفل + Flatten Image تسطيح الصورة + Matting تنقية الحواف + Color Decontaminate… إزالة تلوث اللون… + Remove Black Matte إزالة لون الحافة الأسود + Remove White Matte إزالة لون الحافة الأبيض + Defringe… إزالة الهالة… + Group Layers تجميع الطبقات + Ungroup Layers فك تجميع الطبقات + Hide Layers إخفاء الطبقات + Arrange ترتيب + Bring to Front إحضار إلى المقدمة + Bring Forward إحضار للأمام + Send Backward إرسال للخلف + Send to Back إرسال إلى المؤخرة + Reverse عكس الترتيب + Combine Shapes دمج الأشكال + Unite Shapes توحيد الأشكال + Subtract Front Shape طرح الشكل الأمامي + Intersect Shape Areas تقاطع مناطق الأشكال + Exclude Overlapping Shapes استبعاد الأشكال المتداخلة + Merge Shape Components دمج مكونات الشكل + Align محاذاة + Top Edges الحواف العلوية + Vertical Centers المراكز العمودية + Bottom Edges الحواف السفلية + Left Edges الحواف اليسرى + Horizontal Centers المراكز الأفقية + Right Edges الحواف اليمنى + Distribute توزيع + Vertically عموديًا + Horizontally أفقيًا + Layer Edges حواف الطبقة + Isolate Layers عزل الطبقات + Layer Content Options… خيارات محتوى الطبقة… + Fill Content محتوى التعبئة + Deselect Layers إلغاء تحديد الطبقات + Find Layers البحث عن الطبقات + Layer Comps تركيبات الطبقات + Layers طبقات + All Layers كل الطبقات + Hidden Layers الطبقات المخفية + Mask All Objects تقنيع كل الكائنات + Subject العنصر الرئيسي + Sky السماء + Panels اللوحات + Character Panel لوحة الأحرف + Paragraph Panel لوحة الفقرة + Character Styles Panel لوحة أنماط الأحرف + Paragraph Styles Panel لوحة أنماط الفقرات + Glyphs Panel لوحة الرموز + Anti-Alias إزالة التسنن + Sharp حاد + Crisp واضح + Strong قوي + Smooth ناعم + Windows LCD Windows LCD + Windows Windows + Orientation الاتجاه + Horizontal أفقي + Vertical عمودي + OpenType OpenType + Standard Ligatures حروف مركبة قياسية + Contextual Alternates بدائل سياقية + Discretionary Ligatures حروف مركبة اختيارية + Swash حروف مزخرفة + Oldstyle أرقام قديمة الطراز + Stylistic Alternates بدائل أسلوبية + Titling Alternates بدائل العناوين + Ornaments زخارف + Ordinals أرقام ترتيبية + Fractions كسور + Create Work Path إنشاء مسار عمل + Convert to Shape تحويل إلى شكل + Warp Text… التواء النص… + Convert to Point Text تحويل إلى نص نقطي + Convert to Paragraph Text تحويل إلى نص فقرة + Default Features الميزات الافتراضية + East Asian Features ميزات شرق آسيا + Middle Eastern Features ميزات الشرق الأوسط + Middle Eastern & South Asian Composer محرك التنسيق للشرق الأوسط وجنوب آسيا + Update All Text Layers تحديث كل طبقات النص + Replace All Missing Fonts استبدال كل الخطوط المفقودة + Resolve Missing Fonts… حل مشكلة الخطوط المفقودة… + Font Preview Size حجم معاينة الخط + None بلا + Small صغير + Medium متوسط + Large كبير + Extra Large كبير جدًا + Huge ضخم + Load Default Type Styles تحميل أنماط النص الافتراضية + Save Default Type Styles حفظ أنماط النص الافتراضية + Deselect إلغاء التحديد + Reselect إعادة التحديد + Inverse عكس التحديد + Inverse Selection عكس التحديد + Similar مشابه + Color Range… نطاق الألوان… + Focus Area… منطقة التركيز… + Select and Mask… تحديد وقناع… +# Select and Mask dialog labels + Smart Radius نصف قطر ذكي + Shift Edge إزاحة الحافة + Decontaminate إزالة التلوث اللوني + Output الإخراج + Refine Edge تحسين الحافة + Clear Selection مسح التحديد + Color Aware مراعٍ للألوان + Decontaminate Colors إزالة تلوث الألوان + Edge Detection اكتشاف الحواف + Global Refinements تحسينات عامة + High Quality Preview معاينة عالية الجودة + Object Aware مراعٍ للكائنات + Object Aware needs a segmentation model, which isn't available yet يتطلب «مراعٍ للكائنات» نموذج تجزئة، وهو غير متوفر بعد + Real-Time Refinement تحسين في الوقت الفعلي + Refine Edge Brush Tool أداة فرشاة تحسين الحافة + Refine Hair تحسين الشعر + Refine Hair needs hair detection, which isn't available yet يتطلب «تحسين الشعر» اكتشاف الشعر، وهو غير متوفر بعد + Refine Mode وضع التحسين + Select and Mask was closed: another layer was selected تم إغلاق «تحديد وقناع»: حُدِّدت طبقة أخرى + Select and Mask was closed: its document is no longer the active one تم إغلاق «تحديد وقناع»: لم يعد مستنده هو المستند النشط + View Mode وضع العرض + Modify تعديل + Border… حدود التحديد… + Smooth… تنعيم… + Expand… توسيع… + Contract… تقليص… + Feather… تلاشي الحواف… + Grow توسيع النطاق + Transform Selection تحويل التحديد + Edit in Quick Mask Mode تحرير في وضع القناع السريع + Load Selection… تحميل التحديد… + Save Selection… حفظ التحديد… + Match All مطابقة الكل + Last Filter آخر فلتر + Filter Gallery… معرض الفلاتر… + Adaptive Wide Angle… زاوية واسعة تكيفية… + Camera Raw Filter… فلتر Camera Raw… + Liquify… تمويع… + Vanishing Point… نقطة التلاشي… + Blur ضبابية + Blur Gallery معرض الضبابية + Field Blur… ضبابية الحقل… +# Field Blur pins (#3373) + No pin selected لم يتم تحديد أي دبوس + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. انقر على الصورة لإضافة دبوس؛ اسحب الدبوس لتحريكه، أو حلقته لتغيير الضبابية. يؤدي Delete إلى إزالة الدبوس المحدد. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. اسحب المقابض على المخطط لتغيير حجمه وتدويره، والمربع لتدوير زواياه، والنقاط في الداخل لتحديد مكان بدء الضبابية. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. اسحب الدبوس لتحريك الشريط، أو حلقته لتغيير الضبابية. اسحب الخطوط المتصلة لتوسيع الشريط الحاد، والخطوط المتقطعة لضبط الانتقال، والنقاط لتدويره. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. اسحب المقابض على المخطط لتغيير حجمه وتدويره؛ وتحدد الحلقة زاوية الضبابية. + Iris Blur… ضبابية القزحية… + Tilt-Shift… إمالة وإزاحة… + Path Blur… ضبابية المسار… + Spin Blur… ضبابية الدوران… + Blur More ضبابية أكثر + Box Blur… ضبابية صندوقية… + Gaussian Blur… ضبابية غاوسية… + Lens Blur… ضبابية العدسة… + Motion Blur… ضبابية الحركة… + Radial Blur… ضبابية شعاعية… + Shape Blur… ضبابية الشكل… + Smart Blur… ضبابية ذكية… + Surface Blur… ضبابية السطح… + Average متوسط + Displace… إزاحة… + Pinch… قرص… + Polar Coordinates… إحداثيات قطبية… + Ripple… تموج… + Shear… قص مائل… + Spherize… تكوير… + Twirl… دوامة… + Wave… موجة… + ZigZag… متعرج… + Noise تشويش + Add Noise… إضافة تشويش… + Despeckle إزالة البقع + Dust & Scratches… غبار وخدوش… + Median… وسيط… + Reduce Noise… تقليل التشويش… + Pixelate بكسلة + Color Halftone… أنصاف ألوان… + Crystallize… تبلور… + Facet أوجه + Fragment تجزئة + Mezzotint… ميزوتينت… + Mosaic… فسيفساء… + Pointillize… تنقيط… + Render تصيير + Clouds غيوم + Difference Clouds غيوم الفرق + Fibers… ألياف… + Flame… لهب… + Lens Flare… وميض العدسة… + Lighting Effects… تأثيرات الإضاءة… + Relight… إعادة الإضاءة… + Picture Frame… إطار صورة… + Tree… شجرة… + Sharpen زيادة الحدة + Sharpen Edges زيادة حدة الحواف + Sharpen More زيادة الحدة أكثر + Smart Sharpen… زيادة حدة ذكية… + Unsharp Mask… قناع الحدة… + High Pass… تمرير عالٍ… + Stylize تنميط + Diffuse… انتشار… + Emboss… نقش بارز… + Extrude… بثق… + Find Edges إيجاد الحواف + Oil Paint… طلاء زيتي… + Solarize تشميس + Tiles… بلاطات… + Trace Contour… تتبع المحيط… + Wind… رياح… + Video فيديو + De-Interlace… إزالة التشابك… + NTSC Colors ألوان NTSC + Other أخرى + Maximum… الحد الأقصى… + Minimum… الحد الأدنى… + Offset… إزاحة… + Plug-ins المكونات الإضافية + Proof Setup إعداد المعاينة الطباعية + Proof Colors ألوان المعاينة الطباعية + Gamut Warning تحذير النطاق اللوني + Working CMYK مساحة عمل CMYK + Working Cyan Plate لوح السماوي العامل + Working Magenta Plate لوح الأرجواني العامل + Working Yellow Plate لوح الأصفر العامل + Working Black Plate لوح الأسود العامل + Working CMY Plate لوح CMY العامل + Legacy Macintosh RGB RGB القديم لنظام Macintosh + Internet Standard RGB RGB القياسي للإنترنت + Monitor RGB RGB الشاشة + Color Blindness — Protanopia-type عمى الألوان — نوع بروتانوبيا + Color Blindness — Deuteranopia-type عمى الألوان — نوع ديوتيرانوبيا + Zoom In تكبير + Zoom Out تصغير + Fit on Screen ملاءمة للشاشة + Fit Layer(s) on Screen ملاءمة الطبقة/الطبقات للشاشة + Fit Artboard on Screen ملاءمة لوح التصميم للشاشة + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + Print Size حجم الطباعة + Pixel Aspect Ratio نسبة أبعاد البكسل + Pixel Aspect Ratio Correction تصحيح نسبة أبعاد البكسل + Custom Pixel Aspect Ratio… نسبة أبعاد البكسل المخصصة… + Square مربع + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC عريض (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL عريض (1.46) + Anamorphic 2:1 (2) أنامورفيك 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… خيارات معاينة 32 بت… + Screen Mode وضع الشاشة + Standard Screen Mode وضع الشاشة القياسي + Full Screen Mode With Menu Bar وضع ملء الشاشة مع شريط القوائم + Full Screen Mode وضع ملء الشاشة + Cycle Screen Mode تبديل وضع الشاشة + Show إظهار + Extras الإضافات + Selection Edges حواف التحديد + Target Path المسار المستهدف + Current Path المسار الحالي + Grid الشبكة + Guides خطوط التوجيه + Canvas Guides خطوط توجيه مساحة الرسم + Artboard Guides خطوط توجيه لوح التصميم + Count العدد + Smart Guides خطوط التوجيه الذكية + Slices الشرائح + Notes الملاحظات + Pixel Grid شبكة البكسل + Pixel Art فن البكسل + Pixel Art Preview معاينة فن البكسل + Pattern Preview معاينة النقشة + Brush Preview معاينة الفرشاة + Mesh شبكة + Edit Pins تحرير الدبابيس + Document Bounds حدود المستند + Show Extras Options… خيارات إظهار الإضافات… + Rulers المساطر + Snap المحاذاة التلقائية + Snap To المحاذاة التلقائية إلى + Lock Guides قفل خطوط التوجيه + Clear Guides مسح خطوط التوجيه + Clear Selected Artboard Guides مسح خطوط توجيه لوح التصميم المحدد + Clear Canvas Guides مسح خطوط توجيه مساحة الرسم + New Guide… خط توجيه جديد… + Lock Slices قفل الشرائح + Clear Slices مسح الشرائح + Match Zoom مطابقة التكبير + Match Location مطابقة الموقع + Match Rotation مطابقة التدوير + Cascade تتالي + Tile تجانب + Tile All Horizontally تجانب الكل أفقيًا + Tile All Vertically تجانب الكل عموديًا + 2-up Horizontal 2 أفقي + 2-up Vertical 2 عمودي + 3-up Horizontal 3 أفقي + 3-up Stacked 3 مكدس + 3-up Vertical 3 عمودي + 4-up 4 + 6-up 6 + Consolidate All to Tabs توحيد الكل في علامات تبويب + Float in Window تعويم في نافذة + Float All in Windows تعويم الكل في نوافذ + New Window for Document نافذة جديدة للمستند + Workspace مساحة العمل + Essentials الأساسيات + Graphic and Web الرسومات والويب + Painting الرسم + Photography التصوير الفوتوغرافي + New Workspace… مساحة عمل جديدة… + Delete Workspace… حذف مساحة العمل… + Reset Workspace إعادة تعيين مساحة العمل + Reset Tool إعادة تعيين الأداة + Reset All Tools إعادة تعيين كل الأدوات + Reset all tools to their default settings? هل تريد إعادة تعيين كل الأدوات إلى إعداداتها الافتراضية؟ + Lock Workspace قفل مساحة العمل + Theme السمة + Pro Theme سمة Pro + Pro Medium Gray Theme سمة Pro الرمادية المتوسطة + Pro Light Gray Theme سمة Pro الرمادية الفاتحة + Pro Light Theme سمة Pro الفاتحة + Studio Theme سمة Studio + Studio Light Theme سمة Studio الفاتحة + Adwaita Light Theme سمة Adwaita الفاتحة + Adwaita Dark Theme سمة Adwaita الداكنة + Classic Theme السمة الكلاسيكية + Solarized dark Solarized داكن + Solarized Dark Theme سمة Solarized الداكنة + Breeze dark Breeze داكن + Breeze Dark Theme سمة Breeze الداكنة + Breeze light Breeze فاتح + Breeze Light Theme سمة Breeze الفاتحة + Soft dark داكن ناعم + Soft light فاتح ناعم + Soft Dark Theme سمة داكنة ناعمة + Soft Light Theme سمة فاتحة ناعمة + Next Theme السمة التالية + Actions الإجراءات + Brush Settings إعدادات الفرشاة + Brushes الفُرش + Channels القنوات + Character الحرف + Character Styles أنماط الحروف + Clone Source مصدر النسخ + Color اللون + Glyphs الرموز + Gradients التدرجات + Histogram المدرج التكراري + History السجل + Info معلومات + Measurement Log سجل القياسات + Modifier Keys مفاتيح التعديل + Navigator الملاح + Options الخيارات + Paragraph الفقرة + Paragraph Styles أنماط الفقرات + Paths المسارات + Patterns النقشات + Properties الخصائص + Shapes الأشكال + Styles الأنماط + Swatches عينات الألوان + Timeline المخطط الزمني + Tool Presets الإعدادات المسبقة للأدوات + Tools الأدوات + About PhotoCraft حول PhotoCraft + PhotoCraft Website موقع PhotoCraft + PhotoCraft on GitHub PhotoCraft على GitHub + ArtCraft Website موقع ArtCraft + Join the ArtCraft Discord… الانضمام إلى ArtCraft على Discord… + Report an Issue… الإبلاغ عن مشكلة… + WIA Support… دعم WIA… + Apply تطبيق + HSB/HSL HSB/HSL + Invert… عكس… + Load Files into Stack… تحميل الملفات في مكدس… + Motion الحركة + Notes… ملاحظات… + Other… أخرى… + Remove Warp Split إزالة تقسيم الالتواء + Shape شكل + Split Warp Crosswise تقسيم الالتواء بشكل متقاطع + Split Warp Horizontally تقسيم الالتواء أفقيًا + Split Warp Vertically تقسيم الالتواء عموديًا + Statistics… إحصائيات… + Refine Strokes تحسين الضربات + Edits التحريرات +@plural {n} item|{n} items {n} عنصر|{n} عنصر|{n} عنصران|{n} عناصر|{n} عنصرًا|{n} عنصر + Auto تلقائي + (print to the printer) (طباعة إلى الطابعة) + + Add variable + إضافة متغير + 30 frames @ 30 fps 30 إطارًا بمعدل 30 إطار/ثانية + 3D LUT File ملف 3D LUT + Absolute مطلق + Absolute Colorimetric قياس لوني مطلق + Action إجراء + Adaptive تكيفي + Adaptive Wide Angle isn't open الزاوية الواسعة التكيفية ليست مفتوحة + Add إضافة + Add Layer Mask إضافة قناع طبقة + Add Stop إضافة نقطة توقف + Add a layer style إضافة نمط طبقة + Add a mask (from the selection; {key} inverts) إضافة قناع (من التحديد؛ {key} يعكسه) + Add an adjustment إضافة تعديل + Add disk إضافة قرص + Add space after paragraph إضافة مسافة بعد الفقرة + Add space before paragraph إضافة مسافة قبل الفقرة + Add to Sample إضافة إلى العينة + Add to selection ({key}) إضافة إلى التحديد ({key}) + Adjust for Zoom ضبط حسب التكبير + Adjustment visible التعديل مرئي + Adobe RGB (1998) compatible متوافق مع Adobe RGB (1998) + Align and Distribute محاذاة وتوزيع + Align and distribute محاذاة وتوزيع + Align horizontal centers محاذاة المراكز الأفقية + Align left edges محاذاة الحواف اليسرى + Align right edges محاذاة الحواف اليمنى + Aligned متحاذٍ + All Caps جميع الأحرف كبيرة + All Slices كل الشرائح + All User Slices كل شرائح المستخدم + All shortcuts reset to PhotoCraft defaults. تمت إعادة تعيين جميع الاختصارات إلى إعدادات PhotoCraft الافتراضية. + Alt Alt + Always use pressure for opacity استخدام الضغط دائمًا للعتامة + Always use pressure for size استخدام الضغط دائمًا للحجم + Amount المقدار + Anchor: نقطة الارتكاز: + Angle الزاوية + Angle Gradient تدرج زاوي + Angle Jitter تذبذب الزاوية + Answer the unsaved-changes prompt first أجب عن تنبيه التغييرات غير المحفوظة أولًا + Anti-alias إزالة التسنن + Appearance المظهر + Appearance (Layer Style) المظهر (نمط الطبقة) + Apply Layer Mask تطبيق قناع الطبقة + Add Mask to Selection إضافة القناع إلى التحديد + Subtract Mask from Selection طرح القناع من التحديد + Intersect Mask with Selection تقاطع القناع مع التحديد + Apply Per Tip تطبيق لكل رأس + Apply next selected layer comp تطبيق تركيبة الطبقات المحددة التالية + Apply previous selected layer comp تطبيق تركيبة الطبقات المحددة السابقة + Artboard لوح التصميم + Artboard Background Color لون خلفية لوح التصميم + Assign working space تعيين مساحة العمل اللونية + Author: المؤلف: + Auto Hide إخفاء تلقائي + Auto-Select: تحديد تلقائي: + Automatically level near-horizontal line segments تسوية المقاطع الخطية شبه الأفقية تلقائيًا + Automatically straighten and level تقويم وتسوية تلقائيًا + Automatically straighten near-vertical line segments تقويم المقاطع الخطية شبه العمودية تلقائيًا + Background الخلفية + Background Color لون الخلفية + Background Contents محتويات الخلفية + Custom background color لون خلفية مخصص + Balance التوازن + Baseline خط الأساس + Baseline shift إزاحة خط الأساس + Basic أساسي + Before (Y) قبل (Y) + Save Settings… حفظ الإعدادات… + Load Settings… تحميل الإعدادات… + Save these settings as a Camera Raw .xmp file حفظ هذه الإعدادات كملف .xmp لـ Camera Raw + Load settings from a Camera Raw .xmp file تحميل الإعدادات من ملف .xmp لـ Camera Raw + Loaded Camera Raw settings from {file} تم تحميل إعدادات Camera Raw من {file} + Loaded Camera Raw settings from {file} ({n} not supported) تم تحميل إعدادات Camera Raw من {file} ({n} غير مدعومة) + Begin recording بدء التسجيل + Bend: الانحناء: + Bicubic ثنائي التكعيب + Bicubic (smooth gradients) ثنائي التكعيب (تدرجات ناعمة) + Automatic تلقائي + Bicubic Smoother (enlargement) ثنائي التكعيب أكثر نعومة (تكبير) + Bicubic Sharper (reduction) ثنائي التكعيب أكثر حدة (تصغير) + Bilinear ثنائي الخطية + Black أسود + Black & White أبيض وأسود + Black Point Compensation تعويض النقطة السوداء + Black, White أسود، أبيض + Blacks درجات السواد + Blending المزج + Blending Options خيارات المزج + Blue أزرق + Blue, Red, Yellow أزرق، أحمر، أصفر + Both Axes كلا المحورين + Bottom أسفل + Brightness السطوع + Brightness Jitter تذبذب السطوع + Brightness/Contrast السطوع/التباين + Bring the selected pin forward إحضار الدبوس المحدد للأمام + Brush Preset picker منتقي الإعدادات المسبقة للفرشاة + Brush Reconstruct Options خيارات إعادة بناء الفرشاة + Brush Tool Options خيارات أداة الفرشاة + Cache Level: مستوى ذاكرة التخزين المؤقت: + Camera Raw isn't open Camera Raw غير مفتوح + Cancel إلغاء + Reset إعادة تعيين + Cancel (Esc) إلغاء (Esc) + Manage submitted jobs in the system print queue إدارة المهام المرسلة في قائمة انتظار طباعة النظام + Cancel any current edits (Esc) إلغاء أي تعديلات حالية (Esc) + Cancel current crop operation (Esc) إلغاء عملية الاقتصاص الحالية (Esc) + Cancel transform (Esc) إلغاء التحويل (Esc) + Cancel warp (Esc) إلغاء الالتواء (Esc) + Canvas مساحة الرسم + Canvas extension color: لون تمديد مساحة الرسم: + Capture التقاط + Capture the visibility, position and style of every layer with + below, then switch between versions. التقط رؤية كل طبقة وموضعها ونمطها بزر + أدناه، ثم بدّل بين الإصدارات. + Catch-up on Stroke End اللحاق عند نهاية الضربة + Center مركز + Center Crop Marks علامات اقتصاص المنتصف + Center text توسيط النص + Change تغيير + Change All تغيير الكل + Change Screen Mode (F) تغيير وضع الشاشة (F) + Change To: تغيير إلى: + Channel Mixer مازج القنوات + Channel: القناة: + Check All Layers فحص كل الطبقات + Check Spelling تدقيق إملائي + Clarity الوضوح + Clear All مسح الكل + Clear Override مسح التجاوز + Clear Samples مسح العينات + Click sets foreground · right-click sets background النقر يعيّن لون المقدمة · والنقر بزر الماوس الأيمن يعيّن لون الخلفية + Click the first point or press {key} to close · Esc cancels انقر على النقطة الأولى أو اضغط {key} للإغلاق · Esc للإلغاء + Click to add a point · drag off or {key}-click or Delete to remove انقر لإضافة نقطة · اسحبها بعيدًا أو {key}+نقر أو Delete للإزالة + Click to apply, {key}-click to clear overrides, double-click for Style Options انقر للتطبيق، {key}+نقر لمسح التجاوزات، نقر مزدوج لخيارات النمط + Click to edit the gradient انقر لتحرير التدرج + Click to restore the current colour انقر لاستعادة اللون الحالي + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out انقر للتكبير · {key}+نقر للتصغير · اسحب يمينًا/يسارًا للتكبير/التصغير + Click with the Note tool to add a note. انقر بأداة الملاحظة لإضافة ملاحظة. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel نقر: زاوية · سحب: ناعم · نقر على النقطة الأولى: إغلاق · {key} إنهاء · Esc إلغاء + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel نقر: زاوية · سحب: ناعم · {alt}+سحب: كسر المقبض · نقر أو سحب النقطة الأولى: إغلاق · {key} إنهاء · Esc إلغاء + Clip to layer below اقتطاع إلى الطبقة السفلية + Clipped مقتطع + Close Style Options إغلاق خيارات النمط + Cmd Cmd + Coated CMYK CMYK للورق المطلي + Color & Swatches اللون وعينات الألوان + Color Balance توازن الألوان + Color Detail تفاصيل اللون + Color Grading تدريج الألوان + Color Lookup بحث الألوان + Color Management إدارة الألوان + Color Mixer مازج الألوان + Color Mode وضع الألوان + Color Priority أولوية اللون + Color Range preview معاينة نطاق الألوان + Color: اللون: + Colorize تلوين + Colors الألوان + Commit Perspective Warp ({key}) تأكيد التواء المنظور ({key}) + Commit Puppet Warp ({key}) تأكيد التواء الدمية ({key}) + Commit any current edits ({key}) تأكيد أي تعديلات حالية ({key}) + Commit current crop operation ({key}) تأكيد عملية الاقتصاص الحالية ({key}) + Commit transform ({key}) تأكيد التحويل ({key}) + Commit warp ({key}) تأكيد الالتواء ({key}) + Constant ثابت + Constrain proportions تقييد النسب + Contact Sheet II ورقة اتصال II + Content-Aware مراعٍ للمحتوى + Contiguous متجاور + Contrast التباين + Control: التحكم: + Convert to sRGB تحويل إلى sRGB + Copies النسخ + Copper نحاسي + Copyright حقوق النشر + Copyright and Contact Info حقوق النشر ومعلومات الاتصال + Corner Crop Marks علامات اقتصاص الزوايا + Corner radius نصف قطر الزاوية + Correction تصحيح + Count Jitter تذبذب العدد + Create إنشاء + Create Droplet إنشاء قُطيرة + Create Texture إنشاء ملمس + Create Video Timeline إنشاء مخطط زمني للفيديو + Create a new count group إنشاء مجموعة عد جديدة + Create a new document or open an existing file. أنشئ مستندًا جديدًا أو افتح ملفًا موجودًا. + Create a new group إنشاء مجموعة جديدة + Create a new layer إنشاء طبقة جديدة + Create new action إنشاء إجراء جديد + Create new channel إنشاء قناة جديدة + Create new document from current state إنشاء مستند جديد من الحالة الحالية + Create new fill or adjustment layer إنشاء طبقة تعبئة أو تعديل جديدة + Create new gradient إنشاء تدرج جديد + Create new group إنشاء مجموعة جديدة + Create new layer comp إنشاء تركيبة طبقات جديدة + Create new pattern from the selection إنشاء نقشة جديدة من التحديد + Create new shape from the current path إنشاء شكل جديد من المسار الحالي + Create new snapshot إنشاء لقطة جديدة + From: من: + Full Document المستند الكامل + Merged Layers الطبقات المدمجة + New Snapshot لقطة جديدة + Create new style (from the selected type) إنشاء نمط جديد (من النص المحدد) + Create new style from the selected layer إنشاء نمط جديد من الطبقة المحددة + Create new tool preset إنشاء إعداد مسبق جديد للأداة + Crop Factor معامل الاقتصاص + Ctrl Ctrl + Current & Below الحالية وما تحتها + Current Layer الطبقة الحالية + Current Size الحجم الحالي + Current Tool Only الأداة الحالية فقط + Curve منحنى + Curves المنحنيات + Custom مخصص + Custom Shapes أشكال مخصصة + Cyan سماوي + Cyan · Red سماوي · أحمر + Darken تغميق + Darks الدرجات الداكنة + Data Sets مجموعات البيانات + Default افتراضي + Default colours (D) الألوان الافتراضية (D) + Default printer الطابعة الافتراضية + Default restored. تمت استعادة الإعدادات الافتراضية. + Define تعريف + Define at least one variable first. عرّف متغيرًا واحدًا على الأقل أولًا. + Dehaze إزالة الضباب + Delete Cropped Pixels حذف البكسلات المقتصة + Delete Group حذف المجموعة + Delete Layer Mask حذف قناع الطبقة + Delete Layers حذف الطبقات + Add Shortcut إضافة اختصار + Delete Shortcut حذف الاختصار + Delete Timeline حذف المخطط الزمني + Delete Tool Preset حذف الإعداد المسبق للأداة + Delete count group حذف مجموعة العد + Delete current channel حذف القناة الحالية + Delete current state حذف الحالة الحالية + Delete effect layer حذف طبقة التأثير + +# Layers-panel trash confirmation + Delete layer ‘{name}’? هل تريد حذف الطبقة «{name}»؟ + Delete {count} layers? {names} هل تريد حذف {count} طبقة؟ {names} + Delete layer? هل تريد حذف الطبقة؟ + 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} + + Delete layer حذف الطبقة + Delete layer comp حذف تركيبة الطبقات + Delete note حذف الملاحظة + Delete selected measurements حذف القياسات المحددة + Delete style حذف النمط + Delete tool preset حذف الإعداد المسبق للأداة + Density الكثافة + Density: الكثافة: + Depth العمق + Depth Jitter تذبذب العمق + Description الوصف + Deselect All إلغاء تحديد الكل + Detail التفاصيل + Diamond Gradient تدرج ماسي + Difference فرق + Diffusion الانتشار + Discard the embedded profile (don't color manage) تجاهل ملف التعريف المضمّن (دون إدارة الألوان) + Discontiguous غير متجاور + Discord Discord + Dismiss تجاهل + Display P3 Display P3 + Display Text نص العرض + Dither ترقيش + Divide… قسمة… + Document المستند + Don't Save عدم الحفظ + No لا + Yes نعم + Done تم + Dots نقاط + Drag a rectangle around the object اسحب مستطيلاً حول الكائن + 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) أو أنشئ مسار عمل من تحديد. + Drop an image or PSD anywhere to open it. أفلت صورة أو ملف PSD في أي مكان لفتحه. + 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}` لإفلات الملفات، شغّل PhotoCraft تحت XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. أو اضبط التفضيلات › الأداء › خادم عرض Linux على X11: عندها يبدأ PhotoCraft دائمًا تحت XWayland. + Edit › Paste تحرير › لصق + Duplicate Layers… تكرار الطبقات… + Duplicate style تكرار النمط + Edit Toolbar… تحرير شريط الأدوات… + Use File › Open, or paste a copied image with {paste}. استخدم ملف › فتح، أو الصق صورة منسوخة باستخدام {paste}. + Edit in Quick Mask Mode (Q) تحرير في وضع القناع السريع (Q) + Edit in Standard Mode (Q) تحرير في الوضع القياسي (Q) + Effects التأثيرات + Embed Color Profile تضمين ملف تعريف الألوان + Embedded Profile Mismatch عدم تطابق ملف التعريف المضمن + Enable airbrush-style build-up effects تمكين تأثيرات التراكم بأسلوب الفرشاة الهوائية + Enhance Edge تحسين الحافة + Entire Font الخط بالكامل + Exact دقيق + Expansion: التوسيع: + Export Presets تصدير الإعدادات المسبقة + Export selected measurements (CSV) تصدير القياسات المحددة (CSV) + Exposure التعريض + Exposure and Gamma التعريض وغاما + Exposure: التعريض: + Eyedropper القطارة + Factor: المعامل: + Faux Bold غامق زائف + Faux Italic مائل زائف + Feather تلاشي الحواف + Fewer Points نقاط أقل + File Settings إعدادات الملف + Fill تعبئة + Fill: التعبئة: + Filter for adjustment layers تصفية طبقات التعديل + Filter for pixel layers تصفية طبقات البكسل + Filter for shape layers تصفية طبقات الأشكال + Filter for smart objects تصفية الكائنات الذكية + Filter for type layers تصفية طبقات النص + Filter: تصفية: + Finger Painting الرسم بالإصبع + Fisheye عين السمكة + Fit ملاءمة + Fit Screen ملاءمة الشاشة + Fill Screen ملء الشاشة + Fit in View ملاءمة في العرض + Fixed ثابت + Fixed Ratio نسبة ثابتة + Fixed Size حجم ثابت + Flip قلب + Flip X قلب X + Flip X Jitter تذبذب قلب X + Flip Y قلب Y + Flip Y Jitter تذبذب قلب Y + Flow التدفق + Flow Jitter تذبذب التدفق + Focal Length البعد البؤري + Font الخط + Font Size: حجم الخط: + Font size حجم الخط + Foreground المقدمة + Foreground to Background من المقدمة إلى الخلفية + Foreground/Background Jitter تذبذب المقدمة/الخلفية + Format التنسيق + Frame from Layers… إطار من الطبقات… + Free حر + Full Spherical كروي كامل + Fuzziness: التضبيب: + GPU GPU + Gamma Correction تصحيح غاما + Gamma: غاما: + Global شامل + Glow Color لون التوهج + Gradient Map خريطة التدرج + Grain حبيبات + Gray رمادي + Gray Gamma 2.2 رمادي غاما 2.2 + Green أخضر + Group مجموعة + Hardness الصلابة + Heart قلب + Height الارتفاع + Height: الارتفاع: + Hide Auto Slices إخفاء الشرائح التلقائية + Hide Properties إخفاء الخصائص + Highlight Compression ضغط الإضاءات + Highlight Priority أولوية الإضاءات + Highlights الإضاءات + Home الرئيسية + Horizontal scale التحجيم الأفقي + Hue درجة اللون + Hue Jitter تذبذب درجة اللون + Hue/Saturation درجة اللون/التشبع + Hyphenate وصل الكلمات + Ignore تجاهل + Ignore All تجاهل الكل + Image Size حجم الصورة + Import Brushes… استيراد الفُرش… + Gradient Preset Options خيارات الإعداد المسبق للتدرج + Import .GRD استيراد .GRD + Choose a gradient file (.grd). اختر ملف تدرج (.grd). + Import brushes (.abr) استيراد الفُرش (.abr) + Import Presets استيراد الإعدادات المسبقة + Imported مستورد + Indent First مسافة بادئة للسطر الأول + Indent Left مسافة بادئة لليسار + Indent Right مسافة بادئة لليمين + Indent first line مسافة بادئة للسطر الأول + Indent left margin مسافة بادئة للهامش الأيسر + Indent right margin مسافة بادئة للهامش الأيمن + Input: الإدخال: + Inside داخل + Interlaced متشابك + Interpolation: الاستيفاء: + Intersect with selection ({key}) تقاطع مع التحديد ({key}) + Invert All عكس الكل + Invert has no settings. «عكس» ليس له إعدادات. + Italic مائل + Join us on Discord انضم إلينا على Discord + Justify all ضبط الكل + Justify last centered ضبط مع توسيط السطر الأخير + Justify last left ضبط مع محاذاة السطر الأخير لليسار + Justify last right ضبط مع محاذاة السطر الأخير لليمين + Kerning تقارب الأحرف + Keyboard Shortcuts اختصارات لوحة المفاتيح + Keyboard Shortcuts and Menus اختصارات لوحة المفاتيح والقوائم + Kind النوع + Label Size حجم التسمية + Labels التسميات + Lanczos (sharp) Lanczos (حاد) + Landscape أفقي + Last Document State حالة المستند الأخيرة + Layout التخطيط + Leading تباعد الأسطر + Leading (set to the font size × auto-leading when Auto) تباعد الأسطر (يُضبط على حجم الخط × التباعد التلقائي عند اختيار تلقائي) + Leave as is (don't color manage) الإبقاء كما هو (عدم إدارة الألوان) + Left align text محاذاة النص لليسار + Length: الطول: + Level المستوى + Levels المستويات + Lighten تفتيح + Lightness الإضاءة + Lights المناطق الفاتحة + Limits: الحدود: + Linear Gradient تدرج خطي + Link layers ربط الطبقات + Link width and height ربط العرض والارتفاع + Liquify is not open أداة التمويع غير مفتوحة + Load تحميل + Load 3D LUT… تحميل 3D LUT… + Load channel as selection تحميل القناة كتحديد + Load… تحميل… + Localized Color Clusters عناقيد ألوان موضعية + Lock قفل + Lock all قفل الكل + Lock image pixels قفل بكسلات الصورة + Lock layer قفل الطبقة + Lock position قفل الموضع + Lock transparent pixels قفل البكسلات الشفافة + Lock: القفل: + Logical Length: الطول المنطقي: + Logical Units: الوحدات المنطقية: + Luminance اللمعان + Luminance Detail تفاصيل اللمعان + Magenta أرجواني + Magenta · Green أرجواني · أخضر + Maintain aspect ratio الحفاظ على نسبة العرض إلى الارتفاع + Marker Size حجم العلامة + Mask All تقنيع الكل + Mask Options خيارات القناع + Masking التقنيع + Master رئيسي + Matte لون الحافة + Median: الوسيط: + Menus القوائم + Mesh Size حجم الشبكة + Metadata البيانات الوصفية + Metrics المقاييس + Midpoint نقطة المنتصف + Midtones الدرجات المتوسطة + Midtones from: الدرجات المتوسطة من: + Midtones to: الدرجات المتوسطة إلى: + Minimum Depth أدنى عمق + Minimum Diameter أدنى قطر + Minimum Roundness أدنى استدارة + Mode: الوضع: + Monochrome أحادي اللون + More Points نقاط أكثر + Move effect layer down تحريك طبقة التأثير لأسفل + Move effect layer up تحريك طبقة التأثير لأعلى + Move to نقل إلى + Name الاسم + Name: الاسم: + Nearest Neighbor أقرب جار + Nearest Neighbor (hard edges) أقرب جار (حواف صلبة) + New Size الحجم الجديد + New document… مستند جديد… + New effect layer طبقة تأثير جديدة + New selection تحديد جديد + Next frame الإطار التالي + Next note الملاحظة التالية + No Color بلا لون + No Color Management بدون إدارة ألوان + No Dither بلا ترقيش + No author بلا مؤلف + No character style بلا نمط حرف + No document لا يوجد مستند + No matching commands لا توجد أوامر مطابقة + No presets for the current tool. لا توجد إعدادات مسبقة للأداة الحالية. + No presets of this type. لا توجد إعدادات مسبقة من هذا النوع. + No properties لا توجد خصائص + No tool presets. لا توجد إعدادات مسبقة للأدوات. + Noise Reduction تقليل التشويش + Normal عادي + Not in Dictionary: غير موجود في القاموس: + OK موافق + Offset إزاحة + Only Web Colors ألوان الويب فقط + Opacity العتامة + Opacity Jitter تذبذب العتامة + Opacity: العتامة: + Open a document to define variables. افتح مستندًا لتعريف المتغيرات. + Open a document to select a colour range. افتح مستندًا لتحديد نطاق ألوان. + Open a document to use the timeline. افتح مستندًا لاستخدام المخطط الزمني. + Optical بصري + Optimized محسّن + Option-click to define a source point to clone from انقر مع Option لتحديد نقطة مصدر للنسخ منها + Original الأصلي + Output Channel: قناة الإخراج: + Output Levels: مستويات الإخراج: + Output: الإخراج: + Outside خارج + Overlay Color لون التراكب + Overlay Opacity عتامة التراكب + Overlay: Rule of Thirds التراكب: قاعدة الأثلاث + Override Pressure تجاوز الضغط + Override Rotation تجاوز التدوير + Override Tilt تجاوز الإمالة + Override Tilt X تجاوز الإمالة X + Override Tilt Y تجاوز الإمالة Y + PRESET DETAILS تفاصيل الإعداد المسبق + PSD PSD + Paint Overlay تراكب الطلاء + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. تُحفظ مواقع اللوحات في مساحة العمل. اختصارات لوحة المفاتيح والقوائم وشريط الأدوات اختيارية. + Paper الورق + Path مسار + Pattern النقشة + Pattern Size حجم النقشة + Pause إيقاف مؤقت + Percent النسبة المئوية + Perceptual إدراكي + Perspective Warp is not active التواء المنظور غير نشط + Perspective Warp: draw planes along the image's perspective, then switch to Warp التواء المنظور: ارسم مستويات وفق منظور الصورة، ثم انتقل إلى الالتواء + Photo Filter فلتر الصور + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft يدير الألوان + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — محرر صور أصلي ومفتوح المصدر مكتوب بلغة Rust. + Pin Depth: عمق الدبوس: + Pixel Length: الطول بالبكسل: + Pixels/Centimeter بكسل/سنتيمتر + Pixels/Inch بكسل/بوصة + Pixels: البكسلات: + Play تشغيل + Play selection تشغيل التحديد + Portrait عمودي + Position الموضع + Position and Size الموضع والحجم + Posterize تقليل درجات اللون + Preserve Details الحفاظ على التفاصيل + Preserve Luminosity الحفاظ على اللمعان + Preset إعداد مسبق + Preset Type نوع الإعداد المسبق + Preset: الإعداد المسبق: + Presets: الإعدادات المسبقة: + Press keys… اضغط المفاتيح… + Pressure الضغط + Pressure for Opacity الضغط للعتامة + Pressure for Size الضغط للحجم + Prevent auto-nesting in and out of Artboards and Frames منع التداخل التلقائي داخل وخارج ألواح التصميم والإطارات + Preview معاينة + Previous frame الإطار السابق + Previous note الملاحظة السابقة + Print طباعة + Printer الطابعة + Printer Manages Colors الطابعة تدير الألوان + Printer Setup إعداد الطابعة + Printing Marks علامات الطباعة + Progressive تدريجي + Promote ترقية + Protect Detail حماية التفاصيل + Protect Foreground Color حماية لون المقدمة + Protect Tones حماية الدرجات اللونية + Proximity Match مطابقة التقارب + Pulled String Mode وضع الخيط المشدود + Puppet Warp is not active التواء الدمية غير نشط + Purity النقاء + Quality الجودة + Quick Actions إجراءات سريعة + Quick Export As PNG تصدير سريع بتنسيق PNG + Radial Gradient تدرج شعاعي + Radius نصف القطر + Radius: نصف القطر: + Range: النطاق: + Rate المعدل + Recently Used المستخدمة مؤخرًا + Reconstruct إعادة بناء + Record ● a sequence of edits, then play ▶ it on any document. سجّل ● سلسلة من التعديلات، ثم شغّلها ▶ على أي مستند. + Red أحمر + Redefine style by current selection إعادة تعريف النمط بالتحديد الحالي + Refine تحسين + Reflected Gradient تدرج منعكس + Registration Marks علامات التسجيل + Regular عادي + Relative نسبي + Relative Colorimetric قياس لوني نسبي + Remove إزالة + Remove All Pins إزالة كل الدبابيس + Remove Background إزالة الخلفية + Remove Planes إزالة المستويات + Remove Tool أداة الإزالة + Remove stop إزالة نقطة التوقف + Remove variable إزالة المتغير + Rename إعادة تسمية + Rename Group… إعادة تسمية المجموعة… + Rename Layer… إعادة تسمية الطبقة… + Rename Tool Preset… إعادة تسمية الإعداد المسبق للأداة… + Rename… إعادة تسمية… + Resample إعادة أخذ العينات + Reset All to Defaults إعادة تعيين الكل إلى الافتراضيات + Import Shortcuts… استيراد الاختصارات… + Load a keyboard shortcut set saved as .kys تحميل مجموعة اختصارات لوحة مفاتيح محفوظة بتنسيق .kys + Imported {n} shortcuts from {file}. Click OK to keep them. تم استيراد {n} من الاختصارات من {file}. انقر «موافق» للاحتفاظ بها. + {n} commands are not in PhotoCraft. {n} من الأوامر غير موجودة في PhotoCraft. + Keyboard shortcuts imported تم استيراد اختصارات لوحة المفاتيح + 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} الخاصة بك قيد الاستخدام هنا: {n} من الاختصارات تختلف عن الافتراضيات. يعرضها «تحرير › اختصارات لوحة المفاتيح»؛ ويتراجع «إعادة تعيين الكل إلى الافتراضيات» عن ذلك. + Import keyboard shortcuts? هل تريد استيراد اختصارات لوحة المفاتيح؟ + 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} الخاصة بك من محرر صور آخر على هذا الكمبيوتر: {n} من اختصاراتها تختلف عن اختصارات PhotoCraft. هل تريد استيرادها؟ + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. تحرير › اختصارات لوحة المفاتيح › إعادة تعيين الكل إلى الافتراضيات يؤدي إلى التراجع عن الاستيراد. + Don't Import عدم الاستيراد + The keyboard shortcut set was not imported: {error} لم يتم استيراد مجموعة اختصارات لوحة المفاتيح: {error} + Reset Section إعادة تعيين القسم + Reset Transform إعادة تعيين التحويل + Reset to defaults إعادة تعيين إلى الافتراضيات + Resolution الدقة + Resolution: الدقة: + Restore All استعادة الكل + Restore Defaults استعادة الافتراضيات + Restrictive (Web) مقيّد (ويب) + Right align text محاذاة النص لليمين + Rigid صلب + Rotate: تدوير: + Rotation تدوير + Roughness الخشونة + Roundness الاستدارة + Roundness Jitter تذبذب الاستدارة + Rulers & Grids المساطر والشبكات + Sample All Layers أخذ عينات من كل الطبقات + Sample: العينة: + Sampling: Background Swatch أخذ العينات: عينة لون الخلفية + Sampling: Continuous أخذ العينات: مستمر + Sampling: Once أخذ العينات: مرة واحدة + Saturate زيادة التشبع + Saturation التشبع + Saturation Jitter تذبذب التشبع + Save HTML and Images حفظ HTML والصور + Save as PDF حفظ بتنسيق PDF + Save selection as channel حفظ التحديد كقناة + Save… حفظ… + Scale to Fit Media تحجيم لملاءمة الوسائط + Scatter تبعثر + Scratch disks أقراص التخزين المؤقت + Search بحث + Search commands البحث في الأوامر + Search commands, tools and panels… البحث في الأوامر والأدوات واللوحات… + Search fonts البحث في الخطوط + Select All تحديد الكل + Select Subject تحديد الموضوع + Select a type layer or click into text to insert glyphs حدد طبقة نص أو انقر داخل النص لإدراج رموز + Select text to apply a character style حدد نصًا لتطبيق نمط حرف + Select: التحديد: + Selections التحديدات + Selective انتقائي + Selective Color لون انتقائي + Send the selected pin backward إرسال الدبوس المحدد للخلف + Set additional smoothing options تعيين خيارات تنعيم إضافية + Set background color تعيين لون الخلفية + Set foreground color تعيين لون المقدمة + Set painting symmetry options تعيين خيارات تناظر الرسم + Symmetry Off إيقاف التناظر + Set shape fill type تعيين نوع تعبئة الشكل + Set shape stroke type تعيين نوع حدود الشكل + Set the text color تعيين لون النص + Create warped text إنشاء نص ملتوٍ + Shadows الظلال + Shape: الشكل: + Sharpening زيادة الحدة + Shift Shift + Shortcut removed. تمت إزالة الاختصار. + Show All Menu Items إظهار كل عناصر القائمة + Show Backdrop إظهار الخلفية + Show Guides إظهار خطوط التوجيه + Show Mask إظهار القناع + Show Mesh إظهار الشبكة + Show Overlay إظهار التراكب + Show Transform Controls إظهار عناصر التحكم في التحويل + Show or hide the Notes panel إظهار لوحة الملاحظات أو إخفاؤها + Show: إظهار: + Sides الأضلاع + Sides: الأضلاع: + Size الحجم + Size Jitter تذبذب الحجم + Size: الحجم: + Slice Options… خيارات الشريحة… + Slices From Guides شرائح من خطوط التوجيه + Slices are locked (View › Lock Slices) الشرائح مقفلة (عرض › قفل الشرائح) + Small Caps أحرف استهلالية صغيرة + Smoothing التنعيم + Space After المسافة بعد + Space Before المسافة قبل + Spacing التباعد + Spell check complete. اكتمل التدقيق الإملائي. + Split: التقسيم: + Star ratio نسبة النجمة + Stop playing/recording إيقاف التشغيل/التسجيل + Straighten تقويم + Straighten Layer تقويم الطبقة + Strength: القوة: + Strikethrough يتوسطه خط + Stroke حدود + Stroke Catch-up لحاق الضربة + Stroke: حدود: + Location الموقع + Location: الموقع: + Style النمط + Style Options خيارات النمط + Style Options… خيارات النمط… + Style: Normal النمط: عادي + Subtract from Sample طرح من العينة + Subtract from selection ({key}) طرح من التحديد ({key}) + Suggestions: الاقتراحات: + Swap colours (X) تبديل الألوان (X) + Swaps height and width يبدّل الارتفاع والعرض + Switch between free transform and warp modes التبديل بين وضعي التحويل الحر والالتواء + Switch theme تبديل السمة + Temperature درجة الحرارة + Tetrahedral رباعي السطوح + Text Position: موضع النص: + Texture الملمس + Texture Each Tip ملمس لكل رأس + Threshold العتبة + Threshold Level مستوى العتبة + Thumbnail size (smallest: list) حجم الصورة المصغرة (الأصغر: قائمة) + Tilt X الإمالة X + Tilt Y الإمالة Y + Tint صبغة + Toggle count group visibility تبديل رؤية مجموعة العد + Toggle text orientation تبديل اتجاه النص + Tolerance التفاوت + Tolerance: التفاوت: + Tone: الدرجة اللونية: + Toolbar شريط الأدوات + Top أعلى + Total: الإجمالي: + Tracking تباعد الأحرف + Tracking (1/1000 em) تباعد الأحرف (1/1000 em) + Transparency الشفافية + Transparent شفاف + Trilinear ثلاثي الخطية + Type Styles أنماط النص + Type: النوع: + Underline تسطير + Untitled بدون عنوان + Untitled-1 بدون عنوان-1 + Update layer comp (visibility, position and appearance) تحديث تركيبة الطبقات (الرؤية والموضع والمظهر) + Use Default استخدام الافتراضي + Use Legacy استخدام القديم + Use Measurement Scale استخدام مقياس القياس + Variables bind a layer's visibility, text or pixels to a named data slot. تربط المتغيرات رؤية الطبقة أو نصها أو بكسلاتها بخانة بيانات مسماة. + Vertical scale التحجيم العمودي + Vibrance الحيوية + View Options خيارات العرض + Vignetting تظليل الحواف + Violet, Orange بنفسجي، برتقالي + Visibility الرؤية + Visible مرئي + Warp: الالتواء: + Web Snap المحاذاة التلقائية للويب + Weight الوزن + Weight: الوزن: + White أبيض + White Balance: As Shot توازن الأبيض: كما تم التقاطه + Whites درجات البياض + Width العرض + Slant الميل + Width: العرض: + Work Path مسار العمل + Yellow أصفر + Yellow · Blue أصفر · أزرق + Zoom التكبير/التصغير + Zoom in تكبير + Zoom out تصغير + command or shortcut أمر أو اختصار + current الحالي + image file path مسار ملف الصورة + modified معدّل + name الاسم + new جديد + {name} options خيارات {name} + ↑↓ navigate {key} run esc close ↑↓ تنقل {key} تشغيل esc إغلاق + Version {version} الإصدار {version} + New Document مستند جديد + New Document With Mask مستند جديد مع قناع + New Layer With Mask طبقة جديدة مع قناع + New Layer طبقة جديدة + Save Preset… حفظ الإعداد المسبق… + Preset Name اسم الإعداد المسبق + No saved presets yet. لا توجد إعدادات مسبقة محفوظة بعد. + Error خطأ + Command الأمر + Adjustment layers have nothing to transform ليس لدى طبقات التعديل ما يمكن تحويله + Altitude الارتفاع + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua أزرق مائي + Art & Illustration الفن والرسم التوضيحي + ArtCraft ArtCraft + Background Eraser Tool أداة ممحاة الخلفية + Bevel & Emboss شطب ونقش + Blend Mode وضع المزج + Blues درجات الأزرق + Blur Tool أداة الضبابية + Bold غامق + Brush فرشاة + Block كتلة + Brush Pose وضعية الفرشاة + Brush Tip Shape شكل رأس الفرشاة + Brush Tool أداة الفرشاة + Build-up تراكم + Burn Tool أداة التغميق + Centimeters سنتيمتر + Choke تضييق + Classic كلاسيكي + Click to add text انقر لإضافة نص + Click to fill similar colours انقر لتعبئة الألوان المتشابهة + Clone Stamp Tool أداة ختم النسخ + Color Burn تغميق اللون + Color Dodge تفتيح اللون + Color Dynamics ديناميكيات اللون + Color Overlay تراكب اللون + Comic Book قصص مصورة + Conform مطابقة + Content-Aware Fill needs a selection on a pixel layer تتطلب التعبئة المراعية للمحتوى تحديدًا على طبقة بكسل + 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 تعذر استخدام التواء الدمية: الطبقة فارغة + Crop Tool أداة الاقتصاص + Crosswise بشكل متقاطع + Current Tool الأداة الحالية + Custom Shape Tool أداة الشكل المخصص + Cyans درجات السماوي + Default PhotoCraft Size حجم PhotoCraft الافتراضي + Delete Workspace حذف مساحة العمل + Diamond ماسي + Dimensions: الأبعاد: + Direction الاتجاه + Distance المسافة + Document Dimensions أبعاد المستند + Document Profile ملف تعريف المستند + Document Sizes أحجام المستند + Dodge Tool أداة التفتيح + Down أسفل + Drag to draw a gradient اسحب لرسم تدرج + Drag to move the active layer اسحب لتحريك الطبقة النشطة + Drag to pan · hold Space with any tool اسحب للتحريك · اضغط مع الاستمرار على Space مع أي أداة + Drop Shadow ظل مسقط + Dual Brush فرشاة مزدوجة + Edge الحافة + Efficiency الكفاءة + Efficiency: 100% الكفاءة: 100% + Ellipse Tool أداة الشكل البيضاوي + Elliptical Marquee Tool أداة إطار التحديد البيضاوي + Single Row Marquee Tool أداة إطار تحديد صف واحد + Single Column Marquee Tool أداة إطار تحديد عمود واحد + Emboss نقش بارز + Eraser الممحاة + Eraser Tool أداة الممحاة + ExtraBold غامق جدًا + ExtraLight خفيف جدًا + Eyedropper Tool أداة القطارة + Fade تلاشي + Fill Opacity عتامة التعبئة + Film & Video الأفلام والفيديو + From من + General عام + GitHub GitHub + Gradient Overlay تراكب التدرج + Gradient Tool أداة التدرج + Greens درجات الأخضر + Hand Tool أداة اليد + Hard صلب + Hard Mix مزج صلب + Healing Brush Tool أداة فرشاة التصحيح + Highlights from: الإضاءات من: + History Brush Tool أداة فرشاة السجل + Horizontal Type Tool أداة النص الأفقي + Horizontal Type Mask Tool أداة قناع النص الأفقي + Vertical Type Mask Tool أداة قناع النص العمودي + Image Size: حجم الصورة: + Inches بوصة + Indexed مفهرس + Initial Direction الاتجاه الأولي + Inner Bevel شطب داخلي + Inner Glow توهج داخلي + Inner Shadow ظل داخلي + Lab Lab + Landscape, 10 x 8 أفقي، 10 × 8 + Landscape, 6 x 4 أفقي، 6 × 4 + Landscape, 7 x 5 أفقي، 7 × 5 + Lasso الحبل + Lasso Tool أداة الحبل + Layer Count عدد الطبقات + Layer Knocks Out Drop Shadow الطبقة تقتطع الظل المسقط + Legal Legal + Letter Letter + Light فاتح + Line Tool أداة الخط + Linear خطي + Linear Burn تغميق خطي + Linear Height ارتفاع خطي + Link with Layer ربط مع الطبقة + Lorem Ipsum Lorem Ipsum + Magentas درجات الأرجواني + Magic Eraser Tool أداة الممحاة السحرية + Magic Wand Tool أداة العصا السحرية + Marquee إطار التحديد + Mean: المتوسط: + Measurement Scale مقياس القياس + Measurement Scale Marker علامة مقياس القياس + Millimeters مليمتر + Mobile الجوال + Move Tool أداة التحريك + Could not use the move tool because the layer is locked. تعذر استخدام أداة التحريك لأن الطبقة مقفلة. + Could not complete the Free Transform command because the layer is locked. تعذر إكمال أمر التحويل الحر لأن الطبقة مقفلة. + Multiply ضرب + My Brushes الفُرش الخاصة بي + Neutrals الدرجات المحايدة + New Workspace مساحة عمل جديدة + Note Tool أداة الملاحظة + Object Selection Tool أداة تحديد الكائنات + Off إيقاف + Open فتح + Orange برتقالي + Original Ratio النسبة الأصلية + Out Of Gamut خارج النطاق اللوني + Outer Bevel شطب خارجي + Outer Glow توهج خارجي + Overlay تراكب + Paint Bucket Tool أداة دلو الطلاء + Paints from the document's opening state يرسم من حالة المستند عند فتحه + Path Selection Tool أداة تحديد المسار + Direct Selection Tool أداة التحديد المباشر + Add Anchor Point Tool أداة إضافة نقطة ارتكاز + Delete Anchor Point Tool أداة حذف نقطة ارتكاز + Click a segment to add an anchor point · {alt}-click an anchor to delete it انقر على مقطع لإضافة نقطة ارتكاز · {alt}+نقر على نقطة ارتكاز لحذفها + Click an anchor point to delete it · {alt}-click a segment to add one انقر على نقطة ارتكاز لحذفها · {alt}+نقر على مقطع لإضافة نقطة + Pattern Overlay تراكب النقشة + Pen Pressure ضغط القلم + Pen Tilt إمالة القلم + Pen Tool أداة القلم + Convert Point Tool أداة تحويل النقطة + Photo صورة فوتوغرافية + PhotoCraft website موقع PhotoCraft + Picas بيكا + Pillow Emboss نقش وسادي + Pixel Replacement استبدال البكسل + Pixels بكسل + Points نقطة + Polygon Tool أداة المضلع + Polygonal Lasso Tool أداة الحبل المضلع + Portrait, 4 x 6 عمودي، 4 × 6 + Portrait, 5 x 7 عمودي، 5 × 7 + PostScript (72 points/inch) PostScript (72 نقطة/بوصة) + Postcard بطاقة بريدية + Poster ملصق + Pro (Dark) Pro (داكن) + Pro (Light) Pro (فاتح) + Pro (Light Gray) Pro (رمادي فاتح) + Pro (Medium Gray) Pro (رمادي متوسط) + Protect Texture حماية الملمس + Purple بنفسجي + Quick Mask قناع سريع + Quick Selection Tool أداة التحديد السريع + Radial شعاعي + Rasterize Layer تنقيط الطبقة + Rasterize Layers تنقيط الطبقات + Ratio النسبة + Recent الأخيرة + Rectangle Tool أداة المستطيل + Rectangular Marquee Tool أداة إطار التحديد المستطيل + Reds درجات الأحمر + Reflected منعكس + Sample: foreground colour العينة: لون المقدمة + Sampled Colors الألوان المأخوذة كعينات + Satin ساتان + Save Pixel Aspect Ratio حفظ نسبة أبعاد البكسل + Scattering التبعثر + Scratch Sizes أحجام التخزين المؤقت + Select Data Points تحديد نقاط البيانات + Selection التحديد + SemiBold شبه غامق + Shadows up to: الظلال حتى: + Shape Dynamics ديناميكيات الشكل + Sharpen Tool أداة زيادة الحدة + Show Extras Options خيارات إظهار الإضافات + Slice Select Tool أداة تحديد الشريحة + Slice Tool أداة الشريحة + Smart Filters فلاتر ذكية + Smudge Tool أداة التلطيخ + Soft ناعم + Soften تنعيم + Source المصدر + Source: set المصدر: مضبوط + Sponge Tool أداة الإسفنجة + Spot Healing Brush Tool أداة فرشاة تصحيح البقع + Spread انتشار + Square, 12 x 12 مربع، 12 × 12 + Square, 5 x 5 مربع، 5 × 5 + Std Dev: الانحراف المعياري: + Studio (Dark) Studio (داكن) + Studio (Light) Studio (فاتح) + Stylus Wheel عجلة القلم + Subtract طرح + Tabloid Tabloid + Text Replacement استبدال النص + Thin رفيع + Traditional (72.27 points/inch) تقليدي (72.27 نقطة/بوصة) + Transfer نقل + Triangle Tool أداة المثلث + Unsaved changes تغييرات غير محفوظة + Uses the foreground and background colours. يستخدم لوني المقدمة والخلفية. + Web الويب + Web Large ويب كبير + Web Minimum ويب أدنى + Web Most Common ويب الأكثر شيوعًا + WebP (lossless) WebP (بدون فقدان) + Wet Edges حواف رطبة + Yellows درجات الأصفر + Zoom Tool أداة التكبير/التصغير + {name} Properties خصائص {name} + Revert “{name}” to the last saved version? Your changes will be lost. هل تريد إرجاع «{name}» إلى آخر نسخة محفوظة؟ ستفقد تغييراتك. + Do you want to save the changes you made to “{name}” before quitting? هل تريد حفظ التغييرات التي أجريتها على «{name}» قبل الخروج؟ + Do you want to save the changes you made to “{name}” before closing? هل تريد حفظ التغييرات التي أجريتها على «{name}» قبل الإغلاق؟ + Doc: {flat}/{layered} المستند: {flat}/{layered} + Scratch: {size} التخزين المؤقت: {size} + Untagged {mode} {mode} غير موسوم + Invalid {mode} profile ملف تعريف {mode} غير صالح + Unnamed {mode} profile ملف تعريف {mode} بلا اسم + {w} px x {h} px ({ppi} ppi) {w} بكسل × {h} بكسل ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} × {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch الدقة: {dpi} بكسل/بوصة + Doc: {w} × {h} px المستند: {w} × {h} بكسل + Frame {current} / {total} الإطار {current} / {total} + Count: {n} العدد: {n} + BLANK DOCUMENT PRESETS ({n}) إعدادات مسبقة لمستند فارغ ({n}) + Constraints ({n}) القيود ({n}) + Align {what} محاذاة {what} +@plural {n} Layer|{n} Layers {n} طبقة|{n} طبقة|{n} طبقتان|{n} طبقات|{n} طبقةً|{n} طبقة +@plural {n} layer|{n} layers {n} طبقة|{n} طبقة|{n} طبقتان|{n} طبقات|{n} طبقةً|{n} طبقة +@plural Group · {n} layer|Group · {n} layers مجموعة · {n} طبقة|مجموعة · {n} طبقة|مجموعة · {n} طبقتان|مجموعة · {n} طبقات|مجموعة · {n} طبقةً|مجموعة · {n} طبقة + Additional plugins folder مجلد المكونات الإضافية الإضافي + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control السماح بتحكم الوكيل + Allow scripts to connect السماح للبرامج النصية بالاتصال + Always دائمًا + Always create smart objects when placing إنشاء كائنات ذكية دائمًا عند الإدراج + Animated zoom تكبير متحرك + Apply auto tone تطبيق النغمة التلقائية + Ask سؤال + Ask before saving layered tiff السؤال قبل حفظ TIFF متعدد الطبقات + Auto collapse icon panels طي لوحات الأيقونات تلقائيًا + Auto show hidden panels إظهار اللوحات المخفية تلقائيًا + Auto show home screen إظهار الشاشة الرئيسية تلقائيًا + Autosave حفظ تلقائي + Autosave minutes دقائق الحفظ التلقائي + Beep when done إصدار صوت تنبيه عند الانتهاء + Bicubic automatic ثنائي التكعيب تلقائي + Bicubic sharper ثنائي التكعيب أكثر حدة + Bicubic smoother ثنائي التكعيب أكثر نعومة + Bit depth عمق البت + Brush preview color لون معاينة الفرشاة + Cache levels مستويات ذاكرة التخزين المؤقت + Cache tile size حجم بلاطة ذاكرة التخزين المؤقت + Camera Raw Defaults إعدادات Camera Raw الافتراضية + Canvas border حدود مساحة الرسم + Show canvas dots إظهار نقاط مساحة الرسم + Clip thumbnails to layer bounds قصر الصور المصغرة على حدود الطبقة + Clip Thumbnails to Layer Bounds قصر الصور المصغرة على حدود الطبقة + Canvas color لون مساحة الرسم + Canvas custom color لون مخصص لمساحة الرسم + Color picker منتقي الألوان + Color space مساحة الألوان + Column width عرض العمود + Concise موجز + Control port منفذ التحكم + Convert to srgb تحويل إلى sRGB + Cursors المؤشرات + Custom dark داكن مخصص + Custom light فاتح مخصص + Dark داكن + Dark gray رمادي داكن + Dashed lines خطوط متقطعة + Destination الوجهة + Detailed مفصل + Disks الأقراص + Display p3 Display P3 + Double click layer mask launches select and mask النقر المزدوج على قناع الطبقة يفتح «تحديد وقناع» + Drop shadow ظل مسقط + Dynamic color sliders منزلقات ألوان ديناميكية + East asian شرق آسيوي + Effect cache mb ذاكرة التأثيرات المؤقتة (ميغابايت) + Enable flick panning تمكين التحريك السريع + Enable floating document window docking تمكين إرساء نوافذ المستندات العائمة + Enable narrow options bar تمكين شريط الخيارات الضيق + Enabled ممكّن + Enhanced Controls عناصر تحكم محسنة + Export clipboard تصدير الحافظة + Extra large كبير جدًا + File Handling معالجة الملفات + File path مسار الملف + Fill new type layers with placeholder تعبئة طبقات النص الجديدة بنص نائب + Font preview معاينة الخط + Full size tip رأس بالحجم الكامل + Gamut warning color لون تحذير النطاق اللوني + Gamut warning opacity عتامة تحذير النطاق اللوني + Enable Generator تمكين المولّد + Gif GIF + Glossy لامع + Grid color لون الشبكة + Grid colors ألوان الشبكة + Grid size حجم الشبكة + Grid style نمط الشبكة + Grid unit وحدة الشبكة + Gridline every خط الشبكة كل + Guide color لون خطوط التوجيه + Guide style نمط خطوط التوجيه + Guides, Grid & Slices خطوط التوجيه والشبكة والشرائح + Gutter الفاصل بين الأعمدة + History Log سجل التاريخ + History states حالات السجل + Ignore exif profile tag تجاهل وسم ملف تعريف EXIF + Image interpolation استيفاء الصورة + Image previews معاينات الصور + Integrations التكاملات + Interface الواجهة + Jpeg quality جودة JPEG + Jpg JPG + Language اللغة + Large tabs علامات تبويب كبيرة + Legacy compositing التركيب القديم + Light gray رمادي فاتح + Line خط + Lines خطوط + Lossless بدون فقدان + Lowercase extension امتداد بأحرف صغيرة + Maximize psd compatibility زيادة توافق PSD إلى أقصى حد + Medium gray رمادي متوسط + Memory usage mb استخدام الذاكرة (ميغابايت) + Low resolution previews معاينات منخفضة الدقة + Missing glyph protection حماية الرموز المفقودة + Modern حديث + Nearest neighbor أقرب جار + Never أبدًا + Normal tip رأس عادي + Open as smart object فتح ككائن ذكي + Open in camera raw فتح في Camera Raw + Open documents as tabs فتح المستندات كعلامات تبويب + Overscroll التمرير الزائد + Performance الأداء + Png PNG + Point size حجم النقطة + Precise دقيق + Preserve details الحفاظ على التفاصيل + Print resolution دقة الطباعة + Pro Pro + Pro light Pro فاتح + Pro light gray Pro رمادي فاتح + Pro medium Pro متوسط + Pro photo ProPhoto + Quick export format تنسيق التصدير السريع + Quick export location موقع التصدير السريع + Recent file count عدد الملفات الأخيرة + LUT folder مجلد LUT + Recent files الملفات الأخيرة + Recent fonts الخطوط الأخيرة + Recover on launch الاسترداد عند التشغيل + Remember workspace changes تذكر تغييرات مساحة العمل + Resize image during place تغيير حجم الصورة أثناء الإدراج + Rotate view with trackpad تدوير العرض بلوحة اللمس + Same folder المجلد نفسه + Save in background حفظ في الخلفية + Scratch Disks أقراص التخزين المؤقت + Screen شاشة + Screen resolution دقة الشاشة + Scrubby slider acceleration تسارع المنزلق السحبي + Sessions only الجلسات فقط + Sharpen for زيادة الحدة لـ + Show channels in color إظهار القنوات بالألوان + Show crosshair in brush tip إظهار نقطة التصويب في رأس الفرشاة + Show extension panels إظهار لوحات الامتدادات + Show font names in english إظهار أسماء الخطوط بالإنجليزية + Show menu colors إظهار ألوان القوائم + Show only crosshair while painting إظهار نقطة التصويب فقط أثناء الرسم + Show slice numbers إظهار أرقام الشرائح + Show tooltips إظهار تلميحات الأدوات + Enable animations تمكين الحركات + Show highlight on rollover إظهار التمييز عند التمرير + Show bounding box when dragging layer إظهار مربع الإحاطة عند سحب الطبقة + System title bar شريط عنوان النظام + Show transformation values إظهار قيم التحويل + Slice color لون الشريحة + Smart guide color لون خطوط التوجيه الذكية + Smart quotes علامات اقتباس ذكية + Snap vector tools and transforms to pixel grid المحاذاة التلقائية للأدوات المتجهة والتحويلات إلى شبكة البكسل + Srgb sRGB + Standard قياسي + Studio Studio + Studio light Studio فاتح + Adwaita Adwaita + Adwaita dark Adwaita داكن + Subdivisions التقسيمات الفرعية + System النظام + Text engine محرك النص + Text file ملف نصي + Tiny ضئيل + Touch gestures إيماءات اللمس + Transparency & Gamut الشفافية والنطاق اللوني + Type units وحدات النص + UI font size حجم خط الواجهة + UI scale مقياس الواجهة + Units & Rulers الوحدات والمساطر + Use additional plugins folder استخدام مجلد المكونات الإضافية الإضافي + Use esc to commit استخدام Esc للتأكيد + Use gpu استخدام GPU + Use legacy free transform استخدام التحويل الحر القديم + Use shift key for tool switch استخدام مفتاح Shift للتبديل بين الأدوات + Vary round brush hardness on hud تغيير صلابة الفرشاة المستديرة على HUD + Webp WebP + World ready جاهز عالميًا + Zoom clicked point to center تكبير النقطة المنقورة إلى المركز + Zoom resizes windows التكبير يغير حجم النوافذ + Zoom with scroll wheel التكبير بعجلة التمرير + Zoom with trackpad pinch التكبير بقرص لوحة اللمس + Pass Through مرور + Dissolve ذوبان + Darker Color لون أغمق + Linear Dodge (Add) تفتيح خطي (إضافة) + Lighter Color لون أفتح + Soft Light ضوء ناعم + Hard Light ضوء قاسٍ + Vivid Light ضوء زاهٍ + Linear Light ضوء خطي + Pin Light ضوء نقطي + Exclusion استبعاد + Divide قسمة + Luminosity اللمعان +@plural {n} sample|{n} samples {n} عينة|{n} عينة|{n} عينتان|{n} عينات|{n} عينةً|{n} عينة + {added}, {removed} subtracted {added}، وطُرح {removed} + Adjustment Preview معاينة التعديل + Airbrush: keeps depositing paint while the pointer rests. البخاخة: تواصل إضافة الطلاء أثناء استقرار المؤشر. + Angle: الزاوية: + Blend If المزج إذا كان + Close Tab Group إغلاق مجموعة علامات التبويب + Collapse طي + Collapse All Groups طي كل المجموعات + Collapse Panel Group طي مجموعة اللوحات + Collapse group ({key}-click: all groups) طي المجموعة ({key}+نقر: كل المجموعات) + Create new brush from the current settings إنشاء فرشاة جديدة من الإعدادات الحالية + Create new brush from these settings إنشاء فرشاة جديدة من هذه الإعدادات + Update the current brush with these settings تحديث الفرشاة الحالية بهذه الإعدادات + Delete brush حذف الفرشاة + Drag the arrow to set the angle, the dots to set the roundness اسحب السهم لتعيين الزاوية، والنقاط لتعيين الاستدارة + Expand توسيع + Expand Panel Group توسيع مجموعة اللوحات + Expand group ({key}-click: all groups) توسيع المجموعة ({key}+نقر: كل المجموعات) + Fade steps خطوات التلاشي + Hide the layer's effects ({key}-click: all layers) إخفاء تأثيرات الطبقة ({key}+نقر: كل الطبقات) + Import Brushes… (.abr or image) استيراد الفُرش… (.abr أو صورة) + Import Brush from Image… استيراد فرشاة من صورة… + Move Group Down تحريك المجموعة لأسفل + Move Group Up تحريك المجموعة لأعلى + Reset the brush to the defaults إعادة تعيين الفرشاة إلى الإعدادات الافتراضية + Restore original size استعادة الحجم الأصلي + Roundness: الاستدارة: + Search Brushes البحث في الفُرش + Show the layer's effects ({key}-click: all layers) إظهار تأثيرات الطبقة ({key}+نقر: كل الطبقات) + Brush picker منتقي الفُرش + Erase محو + Right click with painting tools النقر بالزر الأيمن مع أدوات الرسم + System Info… معلومات النظام… + System Info معلومات النظام + More panels المزيد من اللوحات + More documents المزيد من المستندات + These settings aren't available in PhotoCraft yet. هذه الإعدادات غير متاحة في PhotoCraft بعد. + Graphics الرسومات + Reset GPU Backend إعادة تعيين واجهة GPU الخلفية + Applies at next launch. يُطبّق عند التشغيل التالي. + Click to sample the foreground colour · {key}-click for background انقر لأخذ عينة من لون المقدمة · {key}+نقر للون الخلفية + {key}-click to set the source {key}+نقر لضبط المصدر + {key} to subtract {key} للطرح + Clone source {n} ({key}-click with Clone Stamp to set) مصدر النسخ {n} ({key}+نقر بأداة ختم النسخ للضبط) + Source: not set ({key}-click with Clone Stamp) المصدر: غير مضبوط ({key}+نقر بأداة ختم النسخ) + Pixel Layer طبقة بكسل + Type Layer طبقة نص + Shape Layer طبقة شكل + Group Layer طبقة مجموعة + Fill Layer طبقة تعبئة + Smart Object Layer طبقة كائن ذكي + Create Warped Text إنشاء نص ملتوٍ + Rasterize Smart Object تنقيط الكائن الذكي + Rasterize Fill Content تنقيط محتوى التعبئة + Rasterize Layer Style تنقيط نمط الطبقة + Unlock: picking a preset replaces these settings إلغاء القفل: اختيار إعداد مسبق يستبدل هذه الإعدادات + Lock: keep these settings when picking another preset قفل: الاحتفاظ بهذه الإعدادات عند اختيار إعداد مسبق آخر + Diameter: القطر: + Hardness: الصلابة: + No brush presets لا توجد إعدادات مسبقة للفرشاة + Toggle the Brush Settings panel (F5) تبديل لوحة إعدادات الفرشاة (F5) + Create new brush preset from the current settings إنشاء إعداد مسبق جديد للفرشاة من الإعدادات الحالية + Brush Settings… (F5): every option of this brush إعدادات الفرشاة… (F5): كل خيارات هذه الفرشاة + Rename Brush… إعادة تسمية الفرشاة… + Delete Brush حذف الفرشاة + New Brush Preset… إعداد مسبق جديد للفرشاة… + Capture Brush Size in Preset التقاط حجم الفرشاة في الإعداد المسبق + Include Tool Settings تضمين إعدادات الأداة + Include Color تضمين اللون + Enter a name for the brush preset أدخل اسمًا للإعداد المسبق للفرشاة + A brush preset with this name already exists يوجد إعداد مسبق للفرشاة بهذا الاسم بالفعل + Brush Preset Options خيارات الإعداد المسبق للفرشاة + Group name اسم المجموعة + Brush name اسم الفرشاة + Brush Name اسم الفرشاة + Brush Stroke ضربة الفرشاة + Brush Tip رأس الفرشاة + Off: the speed of the pointer sets the spacing إيقاف: سرعة المؤشر تحدد التباعد + Tilt Scale مقياس الإمالة + Brush Projection إسقاط الفرشاة + Apply the pen's tilt and rotation to the tip shape تطبيق إمالة القلم وتدويره على شكل رأس الفرشاة + Mixer Brush فرشاة المزج + Mixer Brush Tool أداة فرشاة المزج + Color Replacement Tool أداة استبدال اللون + Wet رطب + Current Brush Load حمولة الفرشاة الحالية + Load Brush تحميل الفرشاة + Clean Brush تنظيف الفرشاة + Load the brush after each stroke تحميل الفرشاة بعد كل ضربة + Clean the brush after each stroke تنظيف الفرشاة بعد كل ضربة + Mix مزج + Wetness Jitter تذبذب الرطوبة + Mix Jitter تذبذب المزج + Show the layer mask as an overlay إظهار قناع الطبقة كتراكب + Toggle visibility تبديل الرؤية + Unlink the mask from the layer إلغاء ربط القناع بالطبقة + Link the mask to the layer ربط القناع بالطبقة + Gradient تدرج + Gradient Options خيارات التدرج + Align with layer محاذاة مع الطبقة + Reset Alignment إعادة تعيين المحاذاة + Centre the gradient (offset 0, 0) توسيط التدرج (إزاحة 0، 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor محرر التدرج + Gradient picker منتقي التدرج + Gradient stops نقاط توقف التدرج + Save As Preset حفظ كإعداد مسبق + The gradient the Gradient tool paints with التدرج الذي ترسم به أداة التدرج + Adds the current gradient to the Gradients panel يضيف التدرج الحالي إلى لوحة التدرجات + The current gradient (pick one in Window › Gradients) التدرج الحالي (اختر واحدًا من نافذة › التدرجات) + Select a type layer to edit its character and paragraph settings. حدد طبقة نص لتحرير إعدادات الحرف والفقرة الخاصة بها. + Text color لون النص + Type Options خيارات النص + Anti-aliasing method طريقة إزالة التسنن + Horizontal type نص أفقي + Vertical type نص عمودي + Fill path with foreground color تعبئة المسار بلون المقدمة + Stroke path with brush رسم حدود للمسار بالفرشاة + Load path as a selection تحميل المسار كتحديد + Make work path from selection إنشاء مسار عمل من التحديد + Add vector mask إضافة قناع متجه + Create new path إنشاء مسار جديد + Delete path حذف المسار + Delete vector mask حذف القناع المتجه + CPU (no GPU acceleration) CPU (بدون تسريع GPU) + DirectX 12 DirectX 12 + GPU backend واجهة GPU الخلفية + Linux display server خادم عرض Linux + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure استخدام ضغط الجهاز اللوحي + Vulkan Vulkan + Alt-click to define a source point to clone from انقر مع Alt لتحديد نقطة مصدر للنسخ منها + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. لا يوجد مسار لإنشاء تحديد منه: ارسم مسارًا بأداة القلم أو حدد مسارًا في لوحة المسارات. + Cancel opening إلغاء الفتح + Cancelled opening {name} تم إلغاء فتح {name} + Damaged Photoshop document مستند Photoshop تالف + The document “{name}” may be damaged. قد يكون المستند «{name}» تالفًا. + Some layers, pixels or masks could not be read. Do you want to continue opening it? تعذرت قراءة بعض الطبقات أو البكسلات أو الأقنعة. هل تريد متابعة فتحه؟ + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. إذا تابعت، فقد يؤدي الحفظ فوق الأصل إلى فقدان البيانات غير القابلة للقراءة نهائيًا. احفظ نسخة للاحتفاظ بالأصل. + Continue متابعة + The document “{name}” does not have an embedded {mode} profile. لا يحتوي المستند «{name}» على ملف تعريف {mode} مضمّن. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. يحتوي المستند «{name}» على ملف تعريف ألوان مضمّن لا يطابق مساحة عمل {mode} الحالية. + Working: {working} مساحة العمل: {working} + Developing {name} جارٍ معالجة {name} + Cancelled {label} تم إلغاء {label} + Esc to cancel Esc للإلغاء + Opened {name} تم فتح {name} + Opening {name} جارٍ فتح {name} + Saved {name} تم حفظ {name} + Saving {name} جارٍ حفظ {name} + Saving {percent} جارٍ الحفظ {percent} + A save to {name} is still running; try again when it finishes لا تزال عملية حفظ في {name} جارية؛ حاول مرة أخرى عند انتهائها + {label} is writing the file and can no longer be cancelled {label} يكتب الملف ولم يعد بالإمكان إلغاؤه + Opening {name}… جارٍ فتح {name}… + Opening… جارٍ الفتح… + Reading… جارٍ القراءة… + Working… جارٍ العمل… + Color Range نطاق الألوان + Adaptive Wide Angle زاوية واسعة تكيفية + Chalk & Charcoal طباشير وفحم + Charcoal فحم + Chrome كروم + Conté Crayon قلم Conté + Graphic Pen قلم رسومي + Halftone Pattern نقشة أنصاف الألوان + Note Paper ورق ملاحظات + Photocopy نسخ ضوئي + Plaster جبس + Reticulation تشبيك + Stamp ختم + Torn Edges حواف ممزقة + Water Paper ورق مائي + Move تحريك + Pencil قلم رصاص + Clone Stamp ختم النسخ + Dodge تفتيح + Burn تغميق + Quick Selection تحديد سريع + History Brush فرشاة السجل + Color Replacement استبدال اللون + New Group مجموعة جديدة + Layer Comp تركيبة طبقات + Alpha ألفا + Make الشركة المصنعة + Model الطراز + F F + Artist الفنان + Spot Color لون موضعي + Roman روماني + Bold Italic غامق مائل + Justification الضبط + Symbols الرموز + Punctuation علامات الترقيم + Make Work Path إنشاء مسار عمل + Make Selection إنشاء تحديد + Fill Path تعبئة المسار + Stroke Path رسم حدود للمسار + Delete Path حذف المسار + Rename Path… إعادة تسمية المسار… + W ع + H ا + Ruler المسطرة + New Layer Comp تركيبة طبقات جديدة + Duplicate Layer Comp تكرار تركيبة الطبقات + Rename Layer Comp إعادة تسمية تركيبة الطبقات + Apply Layer Comp تطبيق تركيبة الطبقات + Update Layer Comp تحديث تركيبة الطبقات + Method الطريقة + Author المؤلف + Software البرنامج + Pencil Tool أداة القلم الرصاص + Pixel بكسل + Adjustment تعديل + 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 للإلغاء + {mode} Color · {bits} bit لون {mode} · {bits} بت + Scrubby Zoom تكبير سحبي + Auto Erase محو تلقائي + Color Adaptation تكيّف اللون + Preserve Transparency الحفاظ على الشفافية + New Style… نمط جديد… + shortcut اختصار + Saved تم الحفظ + Arc قوس + Arch قوس معماري + Bulge انتفاخ + Shell Lower صدفة سفلية + Shell Upper صدفة علوية + Flag علم + Fish سمكة + Wave موجة + Rise ارتفاع + Inflate نفخ + Twist لف + Squeeze ضغط + Mask قناع + Channel قناة + Rectangle مستطيل + New Channel قناة جديدة + Save Selection حفظ التحديد + Load Selection تحميل التحديد +@id edit.purge.undo سجل التراجع + Rendering Mode وضع التصيير + Automatic (recommended) تلقائي (موصى به) + CPU / Compatibility CPU / التوافق + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. يستخدم الوضع التلقائي تسريع GPU عند توفره ويعود إلى تصيير CPU عند حدوث أخطاء. + Switched to CPU rendering تم التبديل إلى تصيير CPU + 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 متقدم + GPU Backend واجهة GPU الخلفية + Details التفاصيل + Rendering mode وضع التصيير + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) حبل التجميد: اسحب لتجميد منطقة، واسحب مع Alt لإذابتها (L) + 3 x 3 3 × 3 + 4 x 4 4 × 4 + 5 x 5 5 × 5 + Camera Raw Filter ({layer}) فلتر Camera Raw ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Edit Type تحرير النص + Grid: الشبكة: + Highlight Clipping Warning (O) تحذير اقتطاع الإضاءات (O) + Lasso around an area, then drag the selection حدّد منطقة بأداة الحبل ثم اسحب التحديد + Transform On Drop تحويل عند الإفلات + Scale or rotate the moved area, then press {key} حجّم المنطقة المنقولة أو دوّرها، ثم اضغط {key} + Now drag the selection onto another area الآن اسحب التحديد إلى منطقة أخرى + Patch: الرقعة: + Shadow Clipping Warning (U) تحذير اقتطاع الظلال (U) + Show Lab Color Readouts إظهار قراءات ألوان Lab + Show Red at 3 o'clock إظهار الأحمر عند الساعة 3 + Show Selected Region إظهار المنطقة المحددة + Show Skin Tone Indicator إظهار مؤشر لون البشرة + Show Vectorscope إظهار راسم المتجهات + Tone Histogram المدرج التكراري للدرجات + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + No patterns لا توجد نقشات + Enable {name} تمكين {name} + Hold Alt while drawing for straight segments اضغط مع الاستمرار على Alt أثناء الرسم للحصول على مقاطع مستقيمة + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts اسحب أو انقر نقاط المضلع · اضغط مع الاستمرار على Alt أثناء التحديد بالحبل للمقاطع المستقيمة · {add} للإضافة · {sub} قبل الرسم للطرح + Extend تمديد + Structure: البنية: + 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. سيؤدي تضمين الطبقات إلى زيادة حجم الملف. + Discard Layers and Save a Copy تجاهل الطبقات وحفظ نسخة + Don't show again عدم الإظهار مرة أخرى + Save failed: {error} فشل الحفظ: {error} + Save cancelled تم إلغاء الحفظ + +# Layer Style dialog: style presets and per-effect defaults + Style {name} saved تم حفظ النمط {name} + Make Default تعيين كافتراضي + Reset to Default إعادة تعيين إلى الافتراضي + Couldn't place {name}: {error} تعذر وضع {name}: {error} + Full-resolution preview unavailable المعاينة بالدقة الكاملة غير متاحة + Magnetic Lasso Tool أداة الحبل المغناطيسي + Frequency التردد + Use tablet pressure to change pen width استخدام ضغط الجهاز اللوحي لتغيير عرض القلم + +# About ▸ Contributors / Models (credits) + About حول + Contributors المساهمون + Models النماذج + Sort فرز + Username اسم المستخدم + Display name الاسم المعروض + Real name الاسم الحقيقي + Reverse the order عكس الترتيب + Grab bag مزيج عشوائي + Table جدول + Name (A–Z) الاسم (أ–ي) + Merged PRs طلبات السحب المدمجة + Commits الإيداعات + Lines added الأسطر المضافة + Lines deleted الأسطر المحذوفة + Line delta صافي الأسطر + Binary assets added الأصول الثنائية المضافة + Binary assets removed الأصول الثنائية المحذوفة + First commit أول إيداع + First الأول + Last commit آخر إيداع + Last الأخير + Contributors: {contributors} · Commits: {commits} المساهمون: {contributors} · الإيداعات: {commits} + No contributor data was built into this copy. لم يتم تضمين بيانات المساهمين في هذه النسخة. + No model credits were built into this copy. لم يتم تضمين بيانات الإشادة بالنماذج في هذه النسخة. + Company الشركة + Version الإصدار + % of all commits % من كل الإيداعات + Lines +/− الأسطر +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} طلب سحب، {commits} إيداع، +{added} / −{deleted} سطر (Δ {delta})، +{binAdded} / −{binDeleted} أصل ثنائي، {first} – {last} + +# Swatches panel + Create new swatch إنشاء عينة لون جديدة + New Swatch… عينة لون جديدة… + New Swatch Group… مجموعة عينات ألوان جديدة… + Import Swatches… استيراد عينات الألوان… + Replace Swatches… استبدال عينات الألوان… + Export Swatches… تصدير عينات الألوان… + Export Swatches for Exchange… تصدير عينات الألوان للتبادل… + Reset Swatches إعادة تعيين عينات الألوان + Add to Swatches إضافة إلى عينات الألوان +# Layer Style > Blending Options (Advanced Blending) + Blend If: المزج إذا: + Channels: القنوات: + Knockout تفريغ + Apply At Canvas Bounds تطبيق عند حدود مساحة الرسم + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) منتقي الألوان (لون النص) + Color Picker ({name}) منتقي الألوان ({name}) + Color Picker (Fill Color) منتقي الألوان (لون التعبئة) + Fill color لون التعبئة + Color Picker (Stroke Color) منتقي الألوان (لون الحدود) + Stroke color لون الحدود + Pick screen color اختيار لون من الشاشة + Sample a pixel anywhere on the screen أخذ عينة من بكسل في أي مكان على الشاشة + Click to pick · Esc to cancel انقر للاختيار · Esc للإلغاء + Pick a screen pixel or press Esc to cancel اختر بكسلًا على الشاشة أو اضغط Esc للإلغاء + Scroll to zoom مرّر للتكبير والتصغير + Drag to pan · Scroll to zoom · Double-click to fit اسحب للتحريك · مرّر للتكبير والتصغير · انقر نقرًا مزدوجًا للملاءمة + Show/Hide All Panels إظهار/إخفاء كل اللوحات + Show/Hide Panels إظهار/إخفاء اللوحات + Could not open تعذر الفتح + {path}: the path is not valid Unicode; rename the file and open it again. {path}: المسار ليس Unicode صالحًا؛ أعد تسمية الملف وافتحه مرة أخرى. + +# Fill dialog, layer style contours and Image Size + Contents: المحتويات: + Custom Pattern: نقشة مخصصة: + Foreground Color لون المقدمة + Color… اللون… + 50% Gray رمادي 50% + {size} (was {old}) {size} (كان {old}) + Cone مخروط + Cone (Inverted) مخروط (معكوس) + Domed مقبب + Domed (Inverted) مقبب (معكوس) + Diagonal (Descending) قطري (تنازلي) + Contour Editor محرر المحيط + Anti-aliased مزيل التسنن + Corner زاوية + Gloss Contour محيط اللمعان + Contour المحيط + Cone - Inverted مخروط - معكوس + Cove - Deep تجويف - عميق + Cove - Shallow تجويف - ضحل + Gaussian غاوسي + Half Round نصف دائري + Ring حلقة + Ring - Double حلقة - مزدوجة + Rolling Slope - Descending منحدر متموج - تنازلي + Rounded Steps درجات مستديرة + Sawtooth 1 سن المنشار 1 + + Red Eye Tool أداة العين الحمراء + Red Eye العين الحمراء + Pupil Size حجم البؤبؤ + Darken Amount مقدار التغميق + Click a red pupil to neutralize it انقر على بؤبؤ أحمر لتحييده + open source مفتوح المصدر + …and {n} more …و{n} أخرى + + Eyedroppers: القطارات: + Set Black Point تعيين النقطة السوداء + Set Neutral Gray Point تعيين نقطة الرمادي المحايد + Set White Point تعيين النقطة البيضاء + Unavailable in this color mode غير متاح في وضع الألوان هذا + Selection Preview: معاينة التحديد: + Black Matte لون الحافة الأسود + White Matte لون الحافة الأبيض + +# Layer colour labels. +layerLabel No Color بلا لون + Seafoam رغوة البحر + Indigo نيلي + Fuchsia فوشيا + Violet بنفسجي + Large document: redrawing on the CPU مستند كبير: جارٍ إعادة الرسم على CPU + Redrawing جارٍ إعادة الرسم + This redraw took {seconds} s on the CPU. استغرقت إعادة الرسم هذه {seconds} ث على CPU. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. التعديلات التي تغير المستند بأكمله، مثل وضع مزج الطبقة أو عتامتها أو رؤيتها، تعيد رسمه بالكامل. + The GPU compositor wasn't used: {reason} لم يُستخدم مُركِّب GPU: {reason} +cameraRaw Light الضوء +fontWeight Thin رفيع +fontWeight ExtraLight خفيف جدًا +fontWeight Light خفيف +fontWeight Regular عادي +fontWeight Medium متوسط +fontWeight SemiBold شبه غامق +fontWeight Bold غامق +fontWeight ExtraBold غامق جدًا +fontWeight Black أسود +fontWeight Italic مائل + +# Pattern Stamp tool + Pattern Stamp Tool أداة ختم النقشة + Pattern Stamp ختم النقشة + Impressionist انطباعي + Rotate View تدوير العرض + Rotate View Tool أداة تدوير العرض + Reset View إعادة تعيين العرض + Drag around the centre to rotate the view · Shift constrains to 15° اسحب حول المركز لتدوير العرض · Shift يقيّد الزاوية بخطوات 15° + +# Painting symmetry presets. + Dual Axis محور مزدوج + Diagonal قطري + Transform Symmetry تحويل التناظر + +# Eyedropper Sample Size and Sample (#1649). + Point Sample عينة نقطية + 3 by 3 Average متوسط 3 × 3 + 5 by 5 Average متوسط 5 × 5 + 11 by 11 Average متوسط 11 × 11 + 31 by 31 Average متوسط 31 × 31 + 51 by 51 Average متوسط 51 × 51 + 101 by 101 Average متوسط 101 × 101 + Sample Size: حجم العينة: + All Layers No Adjustments كل الطبقات بدون تعديلات + Current & Below No Adjustments الحالية وما تحتها دون تعديلات + Show Sampling Ring إظهار حلقة أخذ العينات + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour خارج النطاق اللوني للطباعة: انقر لأقرب لون قابل للطباعة + Not a web colour: click for the nearest web colour ليس لون ويب: انقر لأقرب لون ويب + Color to Alpha… اللون إلى ألفا… + Transparency Threshold عتبة الشفافية + Opacity Threshold عتبة العتامة + PhotoCraft — Screen Color Picker PhotoCraft — منتقي ألوان الشاشة + + Rasterize svg groups deeper than تنقيط مجموعات SVG إذا كانت أعمق من + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 يعطّل هذه العتبة؛ تُنقَّط مجموعات SVG المتداخلة بعمق أكبر من 100 مستوى دائمًا. + Rule of Thirds قاعدة الأثلاث + Triangle مثلث + Golden Ratio النسبة الذهبية + Golden Spiral اللولب الذهبي + Auto Show Overlay إظهار التراكب تلقائيًا + Always Show Overlay إظهار التراكب دائمًا + Never Show Overlay عدم إظهار التراكب أبدًا + Cycle Overlay تبديل التراكب + Cycle Orientation تبديل الاتجاه + Set the overlay options for the Crop tool تعيين خيارات التراكب لأداة الاقتصاص + 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 إلى سطح المكتب الذي ينقل النوافذ بالسحب مع Alt. اضبط مفتاح نقل النوافذ على Super، أو ثبّت Alt في نافذة › مفاتيح التعديل. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) منتقي الألوان (لون نمط الطبقة) + Auto Hide Notifications إخفاء الإشعارات تلقائيًا + Notification Duration (seconds) مدة الإشعار (بالثواني) + Bundle Arrow-Key Nudges تجميع الإزاحات بمفاتيح الأسهم + Nudge Bundle Pause (ms) مهلة تجميع الإزاحات (مللي ثانية) + Appearance Mode وضع المظهر + Dark Theme سمة داكنة + Light Theme سمة فاتحة + Pro Medium Pro متوسط + Studio Light Studio فاتح + Next Appearance Mode وضع المظهر التالي + Sync with system المزامنة مع النظام + Active نشط + Color Picker (Solid Color) منتقي الألوان (لون مصمت) + Straighten the image by drawing a line on it تقويم الصورة برسم خط عليها + Set additional Crop options تعيين خيارات اقتصاص إضافية + Show Cropped Area إظهار المنطقة المقتصة + Enable Crop Shield تمكين درع الاقتصاص + Match Canvas مطابقة مساحة الرسم + Auto Adjust Opacity ضبط العتامة تلقائيًا + Crop Shield color لون درع الاقتصاص + Color Picker (Crop Shield Color) منتقي الألوان (لون درع الاقتصاص) + From Center من المركز + Link radii ربط أنصاف الأقطار + Star Ratio: نسبة النجمة: + Create Rectangle إنشاء مستطيل + Create Ellipse إنشاء شكل بيضاوي + Create Triangle إنشاء مثلث + Create Polygon إنشاء مضلع + Pressure curve منحنى الضغط + Click to add a point, right-click to remove انقر لإضافة نقطة، وانقر بالزر الأيمن للإزالة + Some brush presets could not be loaded تعذر تحميل بعض الإعدادات المسبقة للفرشاة + Retry saving presets إعادة محاولة حفظ الإعدادات المسبقة + Brush preset changes are not saved: {error} لم تُحفظ تغييرات إعدادات الفرشاة المسبقة: {error} + Keep this tab open to retain your brushes. أبقِ علامة التبويب هذه مفتوحة للاحتفاظ بفرشك. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. تخزين المتصفح غير متاح؛ الفرش للجلسة الحالية فقط. أبقِ علامة التبويب هذه مفتوحة للاحتفاظ بها. + Saving brush presets… Keep this tab open until saving finishes. جارٍ حفظ إعدادات الفرشاة المسبقة… أبقِ علامة التبويب هذه مفتوحة حتى ينتهي الحفظ. + Reflect انعكاس + Glow توهج + Negation نفي + XOR XOR + Color Burn (Paint.NET) تغميق اللون (Paint.NET) + Color Dodge (Paint.NET) تفتيح اللون (Paint.NET) + Stroke Options خيارات الحدود + Solid مصمت + Dashed متقطع + Dotted منقط + Alignment المحاذاة + Caps الأطراف + Butt / Flat مسطح / مقطوع + Corners الزوايا + Miter حادة + Round مستديرة + Bevel مشطوفة + Miter limit حد الزاوية الحادة + Dashed line خط متقطع + Dash شرطة + Gap فجوة + Dash / gap lengths × stroke width أطوال الشرطة / الفجوة × عرض الحدود + Add pair إضافة زوج + Remove pair إزالة زوج + Dash offset إزاحة الشرطة + Open paths use centered strokes المسارات المفتوحة تستخدم حدودًا مركزية + Use finite lengths and a positive pattern total استخدم أطوالًا منتهية ومجموعًا موجبًا للشرطات والفجوات + No subject found — try Object Selection or the Lasso لم يُعثر على موضوع — جرّب تحديد الكائنات أو الحبل + Quick Selection found nothing there لم يجد التحديد السريع شيئًا هناك + Object Selection found nothing there — try a tighter rectangle لم يجد تحديد الكائنات شيئًا هناك — جرّب مستطيلًا أضيق + No subject found — try a rectangle around the object لم يُعثر على موضوع — جرّب مستطيلًا حول الكائن + Click the first point or press {key} to close · {del} removes a point · Esc cancels انقر على النقطة الأولى أو اضغط {key} للإغلاق · {del} يزيل نقطة · Esc للإلغاء + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close انقر ثم تحرك على طول حافة · انقر لتثبيت نقطة · {alt}+نقر لمقطع مستقيم · نقر مزدوج للإغلاق + Follows only edges this close to the pointer ([ and ] change it) يتتبع الحواف القريبة من المؤشر بهذا القدر فقط ([ و ] لتغييره) + Higher values follow only edges that contrast sharply with their surroundings القيم الأعلى تتتبع فقط الحواف التي تتباين بشدة مع محيطها + How often fastening points are placed by themselves عدد مرات وضع نقاط التثبيت تلقائيًا + Appearance Settings… إعدادات المظهر… + Add to favorites إضافة إلى المفضلة + Remove from favorites إزالة من المفضلة +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). + Camera Data بيانات الكاميرا +cameraData Make الشركة المصنعة +cameraData Model الطراز +cameraData Date Taken تاريخ الالتقاط +cameraData Exposure التعريض +cameraData F-Stop فتحة العدسة +cameraData ISO Speed سرعة ISO +cameraData Focal Length البعد البؤري +cameraData Focal Length (35 mm) البعد البؤري (35 مم) +cameraData Lens العدسة +# Shared panel docking + Activate Panel تنشيط اللوحة + Change Panel Group تغيير مجموعة اللوحات + Change Panel Layout تغيير تخطيط اللوحات + Close Panel إغلاق اللوحة + Dock Panel إرساء اللوحة + Float Panel تعويم اللوحة + Move Panel تحريك اللوحة + Group with تجميع مع + Panel menu قائمة اللوحة + Resize panel window تغيير حجم نافذة اللوحة + Resize panels تغيير حجم اللوحات + Use Classic Mode استخدام الوضع الكلاسيكي + Auto Center Preview توسيط المعاينة تلقائيًا + Next Document المستند التالي + Previous Document المستند السابق + W x H x Resolution العرض × الارتفاع × الدقة + Front Image الصورة الأمامية + Frame Tool أداة الإطار + Create a new rectangular frame إنشاء إطار مستطيل جديد + Create a new elliptical frame إنشاء إطار بيضاوي جديد + Include in playback تضمين في التشغيل + Perspective Crop Tool أداة اقتصاص المنظور + Show Grid إظهار الشبكة + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels اسحب مربعًا أو انقر على أربع زوايا · اسحب الزوايا إلى حواف شكل مائل · {commit} للتأكيد · Esc للإلغاء + Artboard Tool أداة لوح التصميم + Make Portrait جعله عموديًا + Make Landscape جعله أفقيًا + Add New Artboard إضافة لوح تصميم جديد + Freeform Pen Tool أداة القلم الحر + Curvature Pen Tool أداة قلم الانحناء + Curve Fit: ملاءمة المنحنى: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close سحب: رسم حر · {alt}+نقر: مقطع مستقيم · الإفلات على نقطة البداية: إغلاق + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish نقر: نقطة ناعمة · نقر مزدوج: زاوية · سحب نقطة: تحريك · نقر النقطة الأولى: إغلاق · {key} إنهاء + New Crop Preset… إعداد اقتصاص مسبق جديد… + Delete Crop Preset… حذف إعداد الاقتصاص المسبق… + Select the crop presets to delete: حدد إعدادات الاقتصاص المسبقة المراد حذفها: + Select the crop presets to delete حدد إعدادات الاقتصاص المسبقة المراد حذفها + Enter a name for the crop preset أدخل اسمًا لإعداد الاقتصاص المسبق + There is no crop size or ratio to save لا يوجد حجم اقتصاص أو نسبة للحفظ + Scale: التحجيم: + Snap to Origin المحاذاة إلى نقطة الأصل + Search settings… البحث في الإعدادات… + No matching settings لا توجد إعدادات مطابقة +# Edit › Content-Aware Fill workspace + Sampling Brush Tool أداة فرشاة أخذ العينات + Sampling Area Overlay تراكب منطقة أخذ العينات + Show Sampling Area إظهار منطقة أخذ العينات + Indicates يشير إلى + Sampling Area منطقة أخذ العينات + Excluded Area المنطقة المستثناة + Sampling Area Options خيارات منطقة أخذ العينات + Rectangular مستطيل + Fill Settings إعدادات التعبئة + Rotation Adaptation تكيّف الدوران + Very High عالٍ جدًا + Full كامل + Mirror مرآة + Output Settings إعدادات الإخراج + Output To الإخراج إلى + Duplicate Layer تكرار الطبقة + Contract تقليص + + Animation Options خيارات الرسوم المتحركة + Entire Document المستند بالكامل + Filter Options خيارات الفلتر + Layer Bounds حدود الطبقة + Layers Panel Options خيارات لوحة الطبقات + Lock Layers قفل الطبقات + New Artboard… لوح تصميم جديد… + 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 إضافة “copy” إلى الطبقات والمجموعات المنسوخة + 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 أداة + Simulate Pressure محاكاة الضغط + Stroke Path… رسم حدود للمسار… + Background Eraser ممحاة الخلفية + Healing Brush فرشاة التصحيح + Spot Healing Brush فرشاة تصحيح البقع + Smudge تلطيخ + Sponge إسفنجة + Amount: المقدار: + Protect: حماية: + Protect skin tones حماية درجات لون البشرة + Edit points to modify the curve حرر النقاط لتعديل المنحنى + Draw to modify the curve ارسم لتعديل المنحنى +curves Smooth تنعيم + Smooth the curve values تنعيم قيم المنحنى + Drag to draw the curve · {key}-click draws a straight line اسحب لرسم المنحنى · {key}+نقر يرسم خطًا مستقيمًا + Edit Plane تحرير المستوى + Create Plane إنشاء مستوى + Grid Size: حجم الشبكة: + Vanishing Point: click four corners to create a plane نقطة التلاشي: انقر على أربع زوايا لإنشاء مستوى + Vanishing Point is not active نقطة التلاشي غير نشطة + Apply Vanishing Point ({key}) تطبيق نقطة التلاشي ({key}) + Fix the red plane first: its corners must form a convex quad أصلح المستوى الأحمر أولًا: يجب أن تشكل زواياه رباعيًا محدبًا + Alt-click a plane to set the clone source انقر مع Alt على مستوى لتعيين مصدر النسخ + Too many clone strokes: apply Vanishing Point first ضربات نسخ كثيرة جدًا: طبّق نقطة التلاشي أولًا +# Select and Mask view modes (#1754) + Onion Skin قشرة البصل + Marching Ants النمل الزاحف + On Black على أسود + On White على أبيض + On Layers على الطبقات + Show Edge إظهار الحافة + Show Original إظهار الأصل + F cycles the views; hold X to see the image F يبدّل بين العروض؛ اضغط مطولًا على X لرؤية الصورة +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) فرشاة تحسين الحافة (R) + Expand Detection Area توسيع منطقة الكشف + Restore Original Edge استعادة الحافة الأصلية + Paint over hair or fur on the image; Alt paints the other mode ارسم فوق الشعر أو الفرو في الصورة؛ Alt يرسم بالوضع الآخر + Clear Brush Strokes مسح ضربات الفرشاة + Show rulers إظهار المساطر + Presentation عرض تقديمي + +# Color Sampler (added with the i18n coverage repair) + 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) + Step Forward خطوة للأمام + Step Backward خطوة للخلف + Clear History مسح السجل + New Snapshot… لقطة جديدة… + History Options… خيارات السجل… +# History panel › History Options… (#2966) + Allow Non-Linear History السماح بالسجل غير الخطي + Make Layer Visibility Changes Undoable جعل تغييرات رؤية الطبقة قابلة للتراجع +# Print dialog (#2697) + Emulsion Down المستحلب لأسفل + Functions الوظائف + Print Resolution: دقة الطباعة: + Use Previous Layer to Create Clipping Mask استخدام الطبقة السابقة لإنشاء قناع اقتطاع + Fill with {mode}-neutral color (white) تعبئة بلون محايد لوضع {mode} (أبيض) + Fill with {mode}-neutral color (black) تعبئة بلون محايد لوضع {mode} (أسود) + Fill with {mode}-neutral color (50% gray) تعبئة بلون محايد لوضع {mode} (رمادي 50%) + (No neutral color exists for {mode} mode.) (لا يوجد لون محايد لوضع {mode}.) + Apply mask to layer before removing? هل تريد تطبيق القناع على الطبقة قبل إزالته؟ + The layer or its mask changed. Drag the mask onto the Trash again. تغيرت الطبقة أو قناعها. اسحب القناع إلى سلة المهملات مرة أخرى. + Delete the vector mask? هل تريد حذف القناع المتجه؟ +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} تم تصدير {n} عينة لون إلى {file}|تم تصدير {n} عينة لون إلى {file}|تم تصدير {n} عينتي لون إلى {file}|تم تصدير {n} عينات لون إلى {file}|تم تصدير {n} عينة لون إلى {file}|تم تصدير {n} عينة لون إلى {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} تم استيراد {n} فرشاة من {group}|تم استيراد {n} فرشاة من {group}|تم استيراد {n} فرشاتين من {group}|تم استيراد {n} فرش من {group}|تم استيراد {n} فرشاة من {group}|تم استيراد {n} فرشاة من {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} تم استيراد {n} عينة لون من {file}|تم استيراد {n} عينة لون من {file}|تم استيراد {n} عينتي لون من {file}|تم استيراد {n} عينات لون من {file}|تم استيراد {n} عينة لون من {file}|تم استيراد {n} عينة لون من {file} +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} تم استيراد {n} فرشاة من {file}|تم استيراد {n} فرشاة من {file}|تم استيراد {n} فرشاتين من {file}|تم استيراد {n} فرش من {file}|تم استيراد {n} فرشاة من {file}|تم استيراد {n} فرشاة من {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} تم استيراد {n} نقشة من {file}|تم استيراد {n} نقشة من {file}|تم استيراد {n} نقشتين من {file}|تم استيراد {n} نقشات من {file}|تم استيراد {n} نقشةً من {file}|تم استيراد {n} نقشة من {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} تم استيراد {n} تدرج من {file}|تم استيراد {n} تدرج من {file}|تم استيراد {n} تدرجين من {file}|تم استيراد {n} تدرجات من {file}|تم استيراد {n} تدرجًا من {file}|تم استيراد {n} تدرج من {file} +@plural Played {n} step|Played {n} steps تم تشغيل {n} خطوة|تم تشغيل {n} خطوة|تم تشغيل {n} خطوتين|تم تشغيل {n} خطوات|تم تشغيل {n} خطوة|تم تشغيل {n} خطوة +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk تم تحديث {n} كائن ذكي مرتبط تم تغييره على القرص|تم تحديث {n} كائن ذكي مرتبط تم تغييره على القرص|تم تحديث {n} كائنين ذكيين مرتبطين تم تغييرهما على القرص|تم تحديث {n} كائنات ذكية مرتبطة تم تغييرها على القرص|تم تحديث {n} كائنًا ذكيًا مرتبطًا تم تغييره على القرص|تم تحديث {n} كائن ذكي مرتبط تم تغييره على القرص +@plural Saved for Web: {n} file|Saved for Web: {n} files تم الحفظ للويب: {n} ملف|تم الحفظ للويب: {n} ملف|تم الحفظ للويب: {n} ملفان|تم الحفظ للويب: {n} ملفات|تم الحفظ للويب: {n} ملفًا|تم الحفظ للويب: {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} طبقة مسجلة في هذه التركيبة. حدّث التركيبة أو امسح التحذير.|تم حذف {n} طبقة مسجلة في هذه التركيبة. حدّث التركيبة أو امسح التحذير.|تم حذف {n} طبقتين مسجلتين في هذه التركيبة. حدّث التركيبة أو امسح التحذير.|تم حذف {n} طبقات مسجلة في هذه التركيبة. حدّث التركيبة أو امسح التحذير.|تم حذف {n} طبقة مسجلة في هذه التركيبة. حدّث التركيبة أو امسح التحذير.|تم حذف {n} طبقة مسجلة في هذه التركيبة. حدّث التركيبة أو امسح التحذير. +@plural {n} plane|{n} planes {n} مستوى|{n} مستوى|{n} مستويان|{n} مستويات|{n} مستوى|{n} مستوى +@plural {n} preset|{n} presets {n} إعداد مسبق|{n} إعداد مسبق|{n} إعدادان مسبقان|{n} إعدادات مسبقة|{n} إعدادًا مسبقًا|{n} إعداد مسبق +@plural {n} sample point removed|{n} sample points removed تمت إزالة {n} نقطة عينة|تمت إزالة {n} نقطة عينة|تمت إزالة {n} نقطتي عينة|تمت إزالة {n} نقاط عينة|تمت إزالة {n} نقطة عينة|تمت إزالة {n} نقطة عينة +@plural {n} slice|{n} slices {n} شريحة|{n} شريحة|{n} شريحتان|{n} شرائح|{n} شريحة|{n} شريحة +@plural {n} step|{n} steps {n} خطوة|{n} خطوة|{n} خطوتان|{n} خطوات|{n} خطوة|{n} خطوة +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} تحذير إضافي لإعدادات الفرشاة المسبقة)|{warning} (+{n} تحذير إضافي لإعدادات الفرشاة المسبقة)|{warning} (+{n} تحذيران إضافيان لإعدادات الفرشاة المسبقة)|{warning} (+{n} تحذيرات إضافية لإعدادات الفرشاة المسبقة)|{warning} (+{n} تحذيرًا إضافيًا لإعدادات الفرشاة المسبقة)|{warning} (+{n} تحذير إضافي لإعدادات الفرشاة المسبقة) +@plural {what} with {n} warning|{what} with {n} warnings {what} مع {n} تحذير|{what} مع {n} تحذير|{what} مع {n} تحذيرين|{what} مع {n} تحذيرات|{what} مع {n} تحذيرًا|{what} مع {n} تحذير +# View › Guides › New Guide… › Color + Light Blue أزرق فاتح + Light Red أحمر فاتح + Medium Blue أزرق متوسط + Light Gray رمادي فاتح + Stop إيقاف +# Status messages, system info and tool names + Join the ArtCraft Discord ({url}) انضم إلى مجتمع ArtCraft على Discord ({url}) + Nothing to paste: the clipboard holds no image لا يوجد شيء للصقه: لا تحتوي الحافظة على أي صورة + Graphics adapter: {adapter} محول الرسومات: {adapter} + Backend: {backend} ({type}) الواجهة الخلفية: {backend} ({type}) + Driver: {driver} برنامج التشغيل: {driver} + GPU backend preference: {backend} تفضيل واجهة GPU الخلفية: {backend} + Selected at launch: {backend} المحددة عند التشغيل: {backend} + Image compositor: {processor} مركب الصور: {processor} + Window renderer: {renderer} مصيّر النافذة: {renderer} + Graphics adapter محول الرسومات + Platform: {os} {arch} المنصة: {os} {arch} + Monitor profile: {profile} ملف تعريف الشاشة: {profile} + Opened {url} تم فتح {url} + {index} of {total} {index} من {total} + Export As: {name} تصدير باسم: {name} + Color Sampler Tool أداة أخذ عينات الألوان + Vertical Type Tool أداة النص العمودي + Patch Tool أداة الرقعة + Grid view عرض الشبكة + List view عرض القائمة + Proof Setup… إعداد البروفة… + 32-bit Preview Options خيارات معاينة 32 بت +# Shortcut editor commands without a menu item + Toggle Layer Mask Overlay تبديل تراكب قناع الطبقة + Increase Type Size زيادة حجم النص + Decrease Type Size تقليل حجم النص + Increase Type Size by 10 زيادة حجم النص بمقدار 10 + Decrease Type Size by 10 تقليل حجم النص بمقدار 10 + Make Selection from Path إنشاء تحديد من المسار + Target Composite Channel استهداف القناة المركبة + 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 + Switch Foreground and Background Colors تبديل لوني المقدمة والخلفية + Default Foreground and Background Colors لونا المقدمة والخلفية الافتراضيان + Fill with Foreground Color تعبئة بلون المقدمة + Fill with Background Color تعبئة بلون الخلفية + Fill with Foreground Color, Preserve Transparency تعبئة بلون المقدمة مع الحفاظ على الشفافية + Fill with Background Color, Preserve Transparency تعبئة بلون الخلفية مع الحفاظ على الشفافية + Decrease Brush Size تقليل حجم الفرشاة + Increase Brush Size زيادة حجم الفرشاة + Decrease Brush Hardness تقليل صلابة الفرشاة + Increase Brush Hardness زيادة صلابة الفرشاة + Previous Brush الفرشاة السابقة + Next Brush الفرشاة التالية + First Brush الفرشاة الأولى + Last Brush الفرشاة الأخيرة + Preview size حجم المعاينة + Temporary مؤقت + Space Space + Hand Tool (hold) أداة اليد (اضغط مطولًا) + Zoom In (hold, drag to scrub) تكبير (اضغط مطولًا، واسحب للتكبير السريع) + Zoom Out (hold) تصغير (اضغط مطولًا) + Erase with Brush or Pencil (hold) المحو بالفرشاة أو قلم الرصاص (اضغط مطولًا) +# Document Info and color profiles + Author Title منصب المؤلف + Copyright Status حالة حقوق النشر + Copyright Info URL رابط معلومات حقوق النشر + Copyright Notice إشعار حقوق النشر + Document Title عنوان المستند + Unknown غير معروف + Copyrighted محمي بحقوق النشر + Public Domain ملكية عامة + Copyright Url رابط حقوق النشر + Keywords الكلمات المفتاحية + Title العنوان + Profile ملف التعريف + Working مساحة العمل + Working-cmyk مساحة عمل CMYK + Coated-cmyk CMYK للورق المصقول + Linear-srgb sRGB خطي + Gray-gamma-2.2 رمادي غاما 2.2 + Adobe-rgb-compat متوافق مع Adobe RGB + Prophoto-compat متوافق مع ProPhoto + Display-p3 Display P3 + Intent القصد + Bpc تعويض النقطة السوداء + Simulate Paper محاكاة الورق +# Color Lookup, Shadows/Highlights, channels and Calculations + Warming Filter (85) فلتر تدفئة (85) + Lut LUT + Cool بارد + Teal Orange أزرق مخضر وبرتقالي + Faded Film فيلم باهت + Day For Night النهار كأنه ليل + Mono Contrast تباين أحادي + Cross Process معالجة متقاطعة + Shadow Amount مقدار الظلال + Shadow Tone نغمة الظلال + Shadow Radius نصف قطر الظلال + Highlight Amount مقدار الإضاءات + Highlight Tone نغمة الإضاءات + Highlight Radius نصف قطر الإضاءات + Midtone الدرجة المتوسطة + Black Clip اقتطاع الأسود + White Clip اقتطاع الأبيض + Strength القوة + Shadow الظل + Highlight الإضاءات + Intensity الشدة + Neutralize تحييد + Use Selection In Source استخدام التحديد في المصدر + Use Selection In Target استخدام التحديد في الهدف + Cw مع عقارب الساعة + Ccw عكس عقارب الساعة + Nearest أقرب جار + Linear Dodge تفتيح خطي + Mask Channel قناة القناع + R G B RGB + Source Channel القناة المصدر + Source Invert عكس المصدر + Result النتيجة + Source1 Channel القناة المصدر 1 + Source1 Invert عكس المصدر 1 + Source2 Channel القناة المصدر 2 + Source2 Invert عكس المصدر 2 + Channel1 قناة 1 + Channel2 قناة 2 + Channel3 قناة 3 + Channel4 قناة 4 +# Measurement log + Label تسمية + Date and Time التاريخ والوقت + Scale Units وحدات المقياس + Scale Factor معامل المقياس + Area المساحة + Perimeter المحيط + Circularity الدائرية + Gray Value (Minimum) قيمة الرمادي (الحد الأدنى) + Gray Value (Maximum) قيمة الرمادي (الحد الأقصى) + Gray Value (Mean) قيمة الرمادي (المتوسط) + Gray Value (Median) قيمة الرمادي (الوسيط) + Integrated Density الكثافة المتكاملة + Length الطول + W: العرض: + A: الزاوية: +# Lens Correction, Blur Gallery and distortion filters + Pattern Fill… تعبئة بنقشة… + Generic عام + Correct Distortion تصحيح التشويه + Correct Vignette تصحيح تظليل الحواف + Correct C A تصحيح الزيغ اللوني + Auto Scale تحجيم تلقائي + Distortion التشويه + Red Cyan أحمر/سماوي + Blue Yellow أزرق/أصفر + Vignette Amount مقدار تظليل الحواف + Vignette Midpoint نقطة منتصف تظليل الحواف + Edge Extension تمديد الحافة + Hexagon سداسي + Pentagon خماسي + Heptagon سباعي + Octagon ثماني + Blade Curvature انحناء الشفرات + Depth Map خريطة العمق + Focal Distance المسافة البؤرية + Distribution التوزيع + Uniform منتظم + Monochromatic أحادي اللون + Seed البذرة + Spin دوران + Draft مسودة + Good جيد + Best الأفضل + Center X المركز X + Center Y المركز Y + Circle دائرة + Star نجمة + Cross صليب + High عالٍ + Low منخفض + Edge Only الحافة فقط + Overlay Edge تراكب الحافة + Focus التركيز + Transition الانتقال + Speed السرعة + Taper التناقص التدريجي + End X النهاية X + End Y النهاية Y + Blur Angle زاوية الضبابية + Rectangular To Polar من مستطيل إلى قطبي + Polar To Rectangular من قطبي إلى مستطيل + Undefined Areas المناطق غير المعرّفة + Wrap التفاف + Repeat تكرار + Horizontal Only أفقي فقط + Vertical Only عمودي فقط + Generators المولدات + Wavelength Min أدنى طول موجي + Wavelength Max أقصى طول موجي + Amplitude Min أدنى سعة + Amplitude Max أقصى سعة + Sine جيبي + Ridges الحواف البارزة + Pond Ripples تموجات البركة + Out From Center من المركز إلى الخارج + Around Center حول المركز + Reduce Color Noise تقليل تشويش الألوان + Sharpen Details زيادة حدة التفاصيل + Remove Jpeg Artifact إزالة تشوهات JPEG + Max Radius أقصى نصف قطر + Cell Size حجم الخلية + Fine Dots نقاط دقيقة + Medium Dots نقاط متوسطة + Grainy Dots نقاط حبيبية + Coarse Dots نقاط خشنة + Short Lines خطوط قصيرة + Medium Lines خطوط متوسطة + Long Lines خطوط طويلة + Short Strokes ضربات قصيرة + Medium Strokes ضربات متوسطة + Long Strokes ضربات طويلة +# Render filters: Flame, Picture Frame, Tree + Flame Type نوع اللهب + One Flame Along Path لهب واحد على طول المسار + Multiple Flames Along Path ألسنة لهب متعددة على طول المسار + Multiple Flames Path Directions ألسنة لهب متعددة وفق اتجاهات المسار + Multiple Flames Various Length ألسنة لهب متعددة بأطوال مختلفة + Candle Light ضوء شمعة + Multiple Flames One Direction ألسنة لهب متعددة في اتجاه واحد + Randomize Length طول عشوائي + Interval الفاصل + Adjust Interval For Loops ضبط الفاصل للحلقات + Use Custom Color استخدام لون مخصص + Jag تسنن + Flame Lines خطوط اللهب + Flame Bottom Alignment محاذاة أسفل اللهب + Flame Style نمط اللهب + Violent عنيف + Flat مسطح + Flame Shape شكل اللهب + To Center إلى المركز + Pointed مدبب + Randomize Shapes أشكال عشوائية + Fine دقيق + Frame إطار + Vine With Flowers كرمة بالزهور + Vine With Leaves كرمة بالأوراق + Ivy لبلاب + Roses ورود + Daisies أقحوان + Berries توت + Bamboo خيزران + Waves أمواج + Zigzag متعرج + Rope حبل + Scallops حواف صدفية + Double Line خط مزدوج + Snowflakes ندف الثلج + Stars نجوم + Hearts قلوب + Arrangement الترتيب + Thickness السماكة + Vine Color لون الكرمة + Flower Color لون الزهرة + Leaf Color لون الورقة + Base Tree Type نوع الشجرة الأساسي + Light Direction اتجاه الضوء + Leaves Amount كمية الأوراق + Leaves Size حجم الأوراق + Branches Height ارتفاع الأغصان + Branches Thickness سماكة الأغصان + Default Leaves الأوراق الافتراضية + Leaves Color لون الأوراق + Custom Branch Color لون أغصان مخصص + Branches Color لون الأغصان + Flat Shading تظليل مسطح +# Lens, Lighting Effects, depth and mosaic filters + Lens العدسة + Prime35 عدسة ثابتة 35 + Prime105 عدسة ثابتة 105 + Movie Prime عدسة سينمائية ثابتة + Light Type نوع الضوء + Point نقطة + Infinite لانهائي + Light X الضوء X + Light Y الضوء Y + Light Z الضوء Z + Target X الهدف X + Target Y الهدف Y + Elevation الارتفاع + Gloss اللمعان + Metallic معدني + Ambience الأجواء + White Is High الأبيض يمثل الارتفاع + Ambient الضوء المحيط + Warmth الدفء + Softness النعومة + Reduce Noise تقليل التشويش + Darken Only تغميق فقط + Lighten Only تفتيح فقط + Anisotropic متباين الخواص + Blocks كتل + Pyramids أهرامات + Depth Mode وضع العمق + Random عشوائي + Level Based مبني على المستوى + Solid Front Faces أوجه أمامية مصمتة + Mask Incomplete Blocks إخفاء الكتل غير المكتملة بقناع + Stylization الأسلبة + Cleanliness النظافة + Bristle Detail تفاصيل الشعيرات + Lighting الإضاءة + Shine اللمعان + Max Offset أقصى إزاحة + Unaltered دون تغيير + Lower سفلي + Upper علوي + Blast عصف + Stagger ترنّح + From Right من اليمين + From Left من اليسار + Eliminate إزالة + Odd Fields حقول فردية + Even Fields حقول زوجية + Create By إنشاء بواسطة + Duplication تكرار + Kernel النواة + Input Mode وضع الإدخال + Rgb RGB + Hsb HSB + Hsl HSL + Row Order ترتيب الصفوف + Preserve الاحتفاظ + Squareness تربيع +# Find and Replace, image size, Warp and Layer Style blending options + Any أي + Cmyk CMYK + To إلى + Dont Enlarge عدم التكبير + Find بحث + Replace استبدال + Change Find تغيير وبحث + Case Sensitive حساس لحالة الأحرف + Whole Word كلمة كاملة + Forward للأمام + Based On بناءً على + General Blending مزج عام + Advanced Blending مزج متقدم + Blend Interior Effects as Group مزج التأثيرات الداخلية كمجموعة + Blend Clipped Layers as Group مزج الطبقات المقصوصة كمجموعة + Transparency Shapes Layer الشفافية تشكّل الطبقة + Layer Mask Hides Effects قناع الطبقة يخفي التأثيرات + Vector Mask Hides Effects القناع المتجه يخفي التأثيرات + This Layer: هذه الطبقة: + Underlying Layer: الطبقة السفلية: + Up لأعلى + Checkerboard رقعة الشطرنج + Arc Lower قوس سفلي + Arc Upper قوس علوي + Bend انحناء + Horizontal Distortion تشويه أفقي + Vertical Distortion تشويه عمودي + Stretch تمديد + Map Document مستند الخريطة + Map Path مسار الخريطة + Fuzziness التضبيب + 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 إعادة تعيين كل الإعدادات المقفلة + Copy Texture to Other Tools نسخ الملمس إلى الأدوات الأخرى + +# 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}|تم تحميل {n} إجراءات من {file}|تم تحميل {n} إجراءً من {file}|تم تحميل {n} إجراء من {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} خطوة ليس لها مكافئ في PhotoCraft بعد|{n} خطوة ليس لها مكافئ في PhotoCraft بعد|{n} خطوتان ليس لهما مكافئ في PhotoCraft بعد|{n} خطوات ليس لها مكافئ في PhotoCraft بعد|{n} خطوةً ليس لها مكافئ في PhotoCraft بعد|{n} خطوة ليس لها مكافئ في PhotoCraft بعد +@plural Saved {n} action to {file}|Saved {n} actions to {file} تم حفظ {n} إجراء في {file}|تم حفظ {n} إجراء في {file}|تم حفظ {n} إجراءين في {file}|تم حفظ {n} إجراءات في {file}|تم حفظ {n} إجراءً في {file}|تم حفظ {n} إجراء في {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out تم استبعاد {n} خطوة ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوة ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوتين ليس لهما مكافئ في Photoshop|تم استبعاد {n} خطوات ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوةً ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوة ليس لها مكافئ في Photoshop +@plural {n} action file loaded|{n} action files loaded تم تحميل {n} ملف إجراءات|تم تحميل {n} ملف إجراءات|تم تحميل {n} ملفي إجراءات|تم تحميل {n} ملفات إجراءات|تم تحميل {n} ملفًا للإجراءات|تم تحميل {n} ملف إجراءات + {set} from {file} replaced the one from {earlier} حلّت المجموعة {set} من {file} محل المجموعة من {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} تعذّر تحميل {n} ملف: {errors}|تعذّر تحميل {n} ملف: {errors}|تعذّر تحميل {n} ملفين: {errors}|تعذّر تحميل {n} ملفات: {errors}|تعذّر تحميل {n} ملفًا: {errors}|تعذّر تحميل {n} ملف: {errors} + + File Naming تسمية الملفات + Starting serial الرقم التسلسلي الأولي +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… إدراج نقطة توقف… + Save Set… حفظ المجموعة… + Message: الرسالة: + Allow Continue السماح بالمتابعة + Show dialog during playback إظهار مربع الحوار أثناء التشغيل + Select an action first حدد إجراءً أولًا + Action playback stopped توقف تشغيل الإجراء + Step {step} is waiting for its dialog الخطوة {step} بانتظار مربع الحوار الخاص بها +@plural {n} action|{n} actions {n} إجراء|{n} إجراء|{n} إجراءان|{n} إجراءات|{n} إجراءً|{n} إجراء + Background (Default) الخلفية (الافتراضية) + Content-Aware Fill تعبئة مراعية للمحتوى +# Batch / Image Processor + Include All Subfolders تضمين كل المجلدات الفرعية + Auto Add/Delete إضافة/حذف تلقائي + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. لا تُحدد أيضًا صورة معكوسة في إعدادات الطباعة…: فالانعكاسان يلغي كل منهما الآخر. + Print Settings… إعدادات الطباعة… + Remember to turn off the printer's own colour management in Print Settings… تذكر إيقاف إدارة الألوان الخاصة بالطابعة في إعدادات الطباعة… + Sent to {printer} تم الإرسال إلى {printer} + set in Print Settings… يتم ضبطه في إعدادات الطباعة… + ICO is limited to 256 × 256 pixels ملفات ICO محدودة بـ 256 × 256 بكسل + Show Favorite Fonts إظهار الخطوط المفضلة + No favorite fonts yet: click the star beside a font to add it. لا توجد خطوط مفضلة بعد: انقر على النجمة بجانب الخط لإضافته. + Favorite fonts الخطوط المفضلة + Add to Favorites إضافة إلى المفضلة + Remove from Favorites إزالة من المفضلة + Show All Fonts إظهار كل الخطوط + All Classes كل الفئات + Blackletter قوطي + Decorative زخرفي + Font preview: معاينة الخط: + Handwritten بخط اليد + Hide Font Classes إخفاء فئات الخطوط + Hide Styles إخفاء الأنماط + Monospace أحادي المسافة + No fonts match these filters. لا توجد خطوط تطابق عوامل التصفية هذه. + No similar fonts: the current font doesn't say what it looks like. لا توجد خطوط مشابهة: الخط الحالي لا يحدد شكله. + OpenType font خط OpenType + Sans Serif بدون زوائد + Script مخطوطة + Serif ذو زوائد + Show Font Classes إظهار فئات الخطوط + Show Similar Fonts إظهار الخطوط المشابهة + Show Styles إظهار الأنماط + Show Variable Fonts إظهار الخطوط المتغيرة + Slab Serif ذو زوائد سميكة + TrueType font خط TrueType + Variable font خط متغير + Calibration المعايرة + Red Primary اللون الأساسي الأحمر + Green Primary اللون الأساسي الأخضر + Blue Primary اللون الأساسي الأزرق + Write xmp sidecars كتابة ملفات XMP الجانبية + Set the tone sliders from the image ضبط منزلقات النغمة من الصورة + Settings from {name} إعدادات من {name} + Settings from {name}; {count} not supported: {names} إعدادات من {name}؛ {count} غير مدعومة: {names} + Could not write {name}: {error} تعذرت كتابة {name}: {error} + Kept {name}: written by another application تم الاحتفاظ بـ {name}: تمت كتابته بواسطة تطبيق آخر + Shadow Tint صبغة الظلال +# PhotoCraft release checks. + Check for Updates… التحقق من وجود تحديثات… + Open the release page to choose an installer for your system. افتح صفحة الإصدار لاختيار برنامج التثبيت المناسب لنظامك. + Automatically Check for Updates التحقق من وجود تحديثات تلقائيًا +# 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}) + Remember this choice for future images تذكر هذا الاختيار للصور المستقبلية + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. أعد تشغيل المطالبة باستخدام «السؤال عند عدم التطابق» في تحرير › إعدادات الألوان. + Button Mode وضع الأزرار + Button Mode On وضع الأزرار قيد التشغيل + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface تغميق واجهة المستخدم + Lighten Interface تفتيح واجهة المستخدم +# Bevel & Emboss Technique. + Technique التقنية + Chisel Hard إزميل صلب + Chisel Soft إزميل ناعم + Open color-less EXR as ACEScg فتح ملفات EXR الخالية من بيانات اللون كـ ACEScg + Do not append "copy" to filename when saving a copy عدم إضافة «نسخة» إلى اسم الملف عند حفظ نسخة +# Export As metadata and profile options + Color Profile ملف تعريف الألوان + 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، يُخفي صندوق الحماية Flatpak البروتوكول الذي تُقرأ عبره الصور من التطبيقات الأخرى. + To paste from other apps, start PhotoCraft under XWayland: `{command}` للصق من التطبيقات الأخرى، شغّل PhotoCraft ضمن XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here لا يوجد شيء للصق: لا يستطيع PhotoCraft قراءة حافظة النظام هنا + Delete this set حذف هذه المجموعة + New Set… مجموعة جديدة… + PhotoCraft Defaults الإعدادات الافتراضية لـ PhotoCraft + Save a list of these shortcuts as a web page حفظ قائمة بهذه الاختصارات كصفحة ويب + Save the changes to this set حفظ التغييرات في هذه المجموعة + Save these shortcuts as a .kys file حفظ هذه الاختصارات كملف .kys + Save these shortcuts as a new set حفظ هذه الاختصارات كمجموعة جديدة + Set deleted. تم حذف المجموعة. + Set saved. تم حفظ المجموعة. + Set: المجموعة: + Summarize… تلخيص… + There are too many sets: delete one first. يوجد عدد كبير جدًا من المجموعات: يُرجى حذف إحداها أولًا. + 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. اختر اختصارًا جديدًا لهذا الأمر، أو اتركه بدون اختصار. + Put this command's shortcuts back as they were إعادة اختصارات هذا الأمر كما كانت + Take the shortcut from the other command and select it خذ الاختصار من الأمر الآخر وحدده + Undo Changes التراجع عن التغييرات + {shortcut} is reserved by the system and can't be assigned. {shortcut} محجوز من قبل النظام ولا يمكن تعيينه. + Low-Latency Drawing (vsync off, applies at next launch) رسم بزمن استجابة منخفض (إيقاف vsync، يسري عند التشغيل التالي) +# 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 + Its layers need {needed} of GPU memory; the budget leaves them {budget}. تحتاج طبقاته إلى {needed} من ذاكرة GPU؛ وتتيح الميزانية لها {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. اضبط ذاكرة GPU في التفضيلات › الأداء على ما تستطيع بطاقة الرسومات استيعابه، بحد أقصى 3/8 من ذاكرتها. يتبع الوضع التلقائي استخدام الذاكرة، ضمن ربع ذاكرة هذا الكمبيوتر و3/8 من ذاكرة البطاقة، ولا يحتسب المستند على بطاقة معروفة الذاكرة. يعرض شريط الحالة «ذاكرة GPU ممتلئة» طالما لا تتسع لها. + 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}، لذلك تتم التعديلات التي تعيد رسم المستند بالكامل على وحدة المعالجة المركزية. انقر لضبط ذاكرة 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}: تستخدم إعادة رسم المستند بالكامل وحدة المعالجة المركزية + GPU Memory (MB, 0 = Automatic) ذاكرة GPU (ميغابايت، 0 = تلقائي) + Scratch Size Limit (MB, 0 = No Limit) حد حجم ملف التسويد (ميغابايت، 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. استبدله بهذا المستند، أو ألغِ واستخدم ملف › حفظ باسم للاحتفاظ بكليهما. + 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 تحريك لأسفل + Memory Usage استخدام الذاكرة + Per-document RAM limit (MB) حد RAM لكل مستند (ميغابايت) + Installed RAM: {ram} MB; this limit: {percent}% ذاكرة RAM المثبتة: {ram} ميغابايت؛ هذا الحد: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. يحدد بكسلات كل مستند مفتوح بالإضافة إلى السجل. تُهمل أقدم حالات التراجع عند تجاوز الحد. + Applies immediately when saved. Memory is not reserved in advance. يسري فورًا عند الحفظ. لا يتم حجز الذاكرة مسبقًا. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. عند ضبط ذاكرة GPU على تلقائي، فإن هذا يحد أيضًا من ميزانية طبقات GPU. تقتصر هذه الميزانية على ربع ذاكرة RAM المثبتة، وعند معرفتها، ثلاثة أثمان ذاكرة بطاقة الرسومات، بحد أدنى 512 ميغابايت. لا يؤدي رفع هذا الحد فوق هذه الحدود القصوى إلى زيادة ميزانية GPU. + Use استخدام + In Front أمام + Behind خلف + Blend مزج + +# DDS Options (#1327) + DDS Options خيارات DDS + Compression الضغط + Generate Mipmaps إنشاء Mipmaps + +# 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}|تم حفظ {n} ألوان في {file}|تم حفظ {n} لونًا في {file}|تم حفظ {n} لون في {file} + +# Graphics Adapter preference (#1885) + GPU adapter محول GPU + +# Styles panel: Import / Export Styles (.asl) + Import Styles… استيراد الأنماط… + Export Selected Styles… تصدير الأنماط المحددة… + Export Styles… تصدير الأنماط… + {file} is not a style library (.asl) {file} ليس مكتبة أنماط (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} تم استيراد {n} نمط من {file}|تم استيراد {n} نمط من {file}|تم استيراد {n} نمطين من {file}|تم استيراد {n} أنماط من {file}|تم استيراد {n} نمطًا من {file}|تم استيراد {n} نمط من {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} تم تصدير {n} نمط إلى {file}|تم تصدير {n} نمط إلى {file}|تم تصدير {n} نمطين إلى {file}|تم تصدير {n} أنماط إلى {file}|تم تصدير {n} نمطًا إلى {file}|تم تصدير {n} نمط إلى {file} +# 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 معاينة صورة الطباعة +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube مكعب درجة اللون + Brightness Cube مكعب السطوع + Color Wheel عجلة الألوان + Grayscale Slider منزلق التدرج الرمادي + RGB Sliders منزلقات RGB + HSB Sliders منزلقات HSB + CMYK Sliders منزلقات CMYK + Lab Sliders منزلقات Lab + Web Color Sliders منزلقات ألوان الويب + Copy Color as HTML نسخ اللون بتنسيق HTML + Copy Color's Hex Code نسخ الرمز السداسي عشري للون + RGB Spectrum طيف RGB + CMYK Spectrum طيف CMYK + Grayscale Ramp شريط التدرج الرمادي + Current Colors الألوان الحالية + Make Ramp Web Safe جعل الشريط آمنًا للويب +# Art History Brush (#213). + Art History Brush فرشاة السجل الفني + Art History Brush Tool أداة فرشاة السجل الفني + Tight Short قصير ضيق + Tight Medium متوسط ضيق + Tight Long طويل ضيق + Loose Medium متوسط فضفاض + Loose Long طويل فضفاض + Dab لمسة + Tight Curl تجعيد ضيق + Loose Curl تجعيد فضفاض + Stylized dabs from a history state لمسات منمقة من حالة في السجل + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio نسبة العرض إلى الارتفاع + Category الفئة + Color Table جدول الألوان + Gamma غاما + Interpolation الاستيفاء + Target الهدف + Units الوحدات + Font size presets الإعدادات المسبقة لحجم الخط + Leading presets الإعدادات المسبقة لتباعد الأسطر + Tracking presets الإعدادات المسبقة لتباعد الأحرف + +# Histogram panel, CPU rendering notice and update prompt + Artboards ألواح التصميم + CPU Rendering التصيير عبر CPU + Calculating… جارٍ الحساب… + Click to refresh histogram with uncached data انقر لتحديث المدرج التكراري ببيانات غير مخزنة مؤقتًا + Count: العدد: + Drawn on the CPU: {reason}. Click for System Info. يُرسم على CPU: {reason}. انقر لعرض معلومات النظام. + Level: المستوى: + Percentile: المئين: + Remember Settings تذكر الإعدادات + Show Channels in Color عرض القنوات بالألوان + Show Statistics عرض الإحصائيات + Source: المصدر: + Uncached Refresh تحديث دون ذاكرة التخزين المؤقت + X X + Y Y + +# Select and Mask target ownership diagnostics (#8896 integration) + + The path was kept as the Work Path تم الاحتفاظ بالمسار كمسار عمل + The path was discarded تم تجاهل المسار + Edit Brush… تحرير الفرشاة… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ad024ccd97..8cd95ed30d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -20,18 +20,142 @@ # 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: - Solidity: Plnost: - Ink Characteristics Vlastnosti inkoustu # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Command-id overrides: the same English label reads differently in Czech in these menus. + + +# Dialogs and panels + +# Layers-panel trash confirmation + + +# Tool names, brush and style option tables + +# Templates with placeholders + +# Preferences (labels are generated from the setting names) + +# Added with the rebase onto the dock / brush picker work + +# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). +# Gradient Editor window, opened from the options bar's gradient swatch. +# Rendering mode and recovery +# Camera Raw: contextual controls and window title + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors + +# 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 +# above, from the plain meaning of the English label. +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Canvas context menus, Channels panel menu, Layer Style blending page (labels rendered through a variable). + +# Status messages and notices + +# Menus, tooltips and hardware information + +# Composed labels and Navigator zoom context +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# 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. + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + + Mask Options… Možnosti masky… + Layer Mask Display Options… Možnosti zobrazení masky vrstvy… + Channel Color Barva kanálu + Color Indicates: Barva označuje: + Solidity: Plnost: + Ink Characteristics Vlastnosti inkoustu Paper Width Šířka papíru Paper Height Výška papíru Columns Sloupce @@ -51,7 +175,6 @@ 5 Columns, 3 Rows 5 sloupců, 3 řádky 8 Columns 8 sloupců 12 Columns 12 sloupců - File Soubor Edit Úpravy Image Obraz @@ -459,6 +582,7 @@ Intersect Shape Areas Průnik oblastí tvarů Exclude Overlapping Shapes Vyloučit překrývající se tvary Merge Shape Components Sloučit součásti tvaru + Path operations Operace s cestami Align Zarovnat Top Edges Horní okraje Vertical Centers Svislé středy @@ -538,7 +662,6 @@ Color Range… Rozsah barev… Focus Area… Oblast zaostření… Select and Mask… Vybrat a maskovat… -# Select and Mask dialog labels Smart Radius Chytrý poloměr Shift Edge Posunout okraj Decontaminate Dekontaminovat @@ -565,7 +688,6 @@ Blur Rozostření Blur Gallery Galerie rozostření Field Blur… Rozostření pole… -# Field Blur pins (#3373) No pin selected Není vybrán žádný špendlík Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknutím do obrazu přidáte špendlík; tažením špendlík přesunete, tažením jeho prstence změníte rozostření. Delete odstraní vybraný špendlík. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Tažením úchytů na obrysu ho změníte a otočíte, čtverečkem zaoblíte rohy a body uvnitř nastavíte, kde začíná rozostření. @@ -830,15 +952,10 @@ Edits Úpravy @plural {n} item|{n} items {n} položka|{n} položky|{n} položek Auto Automaticky - -# Command-id overrides: the same English label reads differently in Czech in these menus. @id select.all Vybrat vše @id edit.stroke Obtáhnout… @id edit.transform.scale Změnit velikost @id edit.purge.undo Historie kroků zpět - - -# Dialogs and panels (print to the printer) (tisk na tiskárnu) + Add variable + Přidat proměnnou 30 frames @ 30 fps 30 snímků při 30 fps @@ -852,6 +969,9 @@ Add Layer Mask Přidat masku vrstvy Add Stop Přidat zarážku Add a layer style Přidat styl vrstvy + Move effect up Posunout efekt nahoru + Move effect down Posunout efekt dolů + Delete effect Odstranit efekt Add a mask (from the selection; {key} inverts) Přidat masku (z výběru; {key} invertuje) Add an adjustment Přidat korekci Add disk Přidat disk @@ -1076,8 +1196,6 @@ Delete current channel Odstranit aktuální kanál Delete current state Odstranit aktuální stav Delete effect layer Odstranit vrstvu efektu - -# Layers-panel trash confirmation Delete layer ‘{name}’? Smazat vrstvu „{name}“? Delete {count} layers? {names} Smazat vrstvy ({count})? {names} Delete layer? Smazat vrstvu? @@ -1086,7 +1204,6 @@ Missing delete command Chybí příkaz ke smazání Invalid delete command Neplatný příkaz ke smazání Layer deleted, but confirmation preference could not be saved: {error} Vrstva byla smazána, ale nastavení potvrzení se nepodařilo uložit: {error} - Delete layer Odstranit vrstvu Delete layer comp Odstranit kompozici vrstev Delete note Odstranit poznámku @@ -1119,7 +1236,6 @@ 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 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. Drop an image or PSD anywhere to open it. Přetáhněte sem obrázek nebo PSD a otevře se. @@ -1643,8 +1759,6 @@ No saved presets yet. Zatím nejsou uloženy žádné předvolby. Error Chyba Command Příkaz - -# Tool names, brush and style option tables Adjustment layers have nothing to transform Korekční vrstvy nemají co transformovat Altitude Elevace Android 1080p Android 1080p @@ -1877,8 +1991,6 @@ Yellows Žluté Zoom Tool Nástroj lupa {name} Properties Vlastnosti – {name} - -# Templates with placeholders Revert “{name}” to the last saved version? Your changes will be lost. Vrátit „{name}“ k poslední uložené verzi? Vaše změny budou ztraceny. Do you want to save the changes you made to “{name}” before quitting? Chcete před ukončením uložit změny v „{name}“? Do you want to save the changes you made to “{name}” before closing? Chcete před zavřením uložit změny v „{name}“? @@ -1899,8 +2011,6 @@ @plural {n} Layer|{n} Layers {n} vrstva|{n} vrstvy|{n} vrstev @plural {n} layer|{n} layers {n} vrstva|{n} vrstvy|{n} vrstev @plural Group · {n} layer|Group · {n} layers Skupina · {n} vrstva|Skupina · {n} vrstvy|Skupina · {n} vrstev - -# Preferences (labels are generated from the setting names) Additional plugins folder Složka dalších zásuvných modulů Adobe Adobe Adobe rgb Adobe RGB @@ -2100,8 +2210,6 @@ Luminosity Světlost @plural {n} sample|{n} samples {n} vzorek|{n} vzorky|{n} vzorků {added}, {removed} subtracted {added}, odebráno: {removed} - -# Added with the rebase onto the dock / brush picker work Adjustment Preview Náhled korekce Airbrush: keeps depositing paint while the pointer rests. Airbrush: nanáší barvu i tehdy, když ukazatel stojí. Angle: Úhel: @@ -2133,8 +2241,6 @@ Brush picker Výběr štětce Erase Gumovat Right click with painting tools Pravé tlačítko s malířskými nástroji - -# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). System Info… Informace o systému… System Info Informace o systému More panels Další panely @@ -2206,7 +2312,6 @@ Align with layer Zarovnat s vrstvou Reset Alignment Obnovit zarovnání Centre the gradient (offset 0, 0) Vystředit přechod (posun 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Editor přechodů Gradient picker Výběr přechodu Save As Preset Uložit jako preset @@ -2265,7 +2370,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Mřížka: -# Rendering mode and recovery Rendering Mode Režim vykreslování Automatic (recommended) Automaticky (doporučeno) CPU / Compatibility CPU / Kompatibilita @@ -2292,7 +2396,6 @@ Tone Histogram Histogram tónů Show Selected Region Zobrazit vybranou oblast cameraRaw Light Světlo -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Filtr Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) cameraRaw Red Červené @@ -2314,14 +2417,10 @@ cameraRaw Color Barvy Scale or rotate the moved area, then press {key} Změňte velikost nebo otočte přesunutou oblast a pak stiskněte {key} Now drag the selection onto another area Nyní přetáhněte výběr na jiné místo Edit Type Upravit text - - New Style… Nový styl… Style {name} saved Styl {name} uložen - Create new style (from the selected type) Vytvořit nový styl (z vybraného textu) Create new style from the selected layer Vytvořit nový styl z vybrané vrstvy - Make Default Nastavit jako výchozí Reset to Default Obnovit výchozí Full-resolution preview unavailable Náhled v plném rozlišení není dostupný @@ -2351,9 +2450,6 @@ cameraRaw Color Barvy Magnetic Lasso Tool Nástroj magnetické laso Frequency Frekvence Use tablet pressure to change pen width Měnit šířku pera přítlakem tabletu - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About O aplikaci Contributors Přispěvatelé Models Modely @@ -2385,8 +2481,6 @@ cameraRaw Color Barvy % of all commits % všech commitů Lines +/− Řádky +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, řádky +{added} / −{deleted} (Δ {delta}), binární soubory +{binAdded} / −{binDeleted}, {first} – {last} - -# Swatches panel Create new swatch Vytvořit nový vzorek New Swatch… Nový vzorek… New Swatch Group… Nová skupina vzorků… @@ -2396,13 +2490,10 @@ cameraRaw Color Barvy Export Swatches for Exchange… Exportovat vzorky pro výměnu… Reset Swatches Obnovit vzorky Add to Swatches Přidat do vzorníku -# Layer Style > Blending Options (Advanced Blending) Blend If: Prolnout, pokud: Channels: Kanály: Knockout Vyřazení 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 ({name}) Výběr barvy ({name}) Color Picker (Fill Color) Výběr barvy (barva výplně) @@ -2419,8 +2510,6 @@ cameraRaw Color Barvy Show/Hide Panels Zobrazit/skrýt panely Could not open Nelze otevřít {path}: the path is not valid Unicode; rename the file and open it again. {path}: cesta není platný Unicode; přejmenujte soubor a otevřete jej znovu. - -# Fill dialog, layer style contours and Image Size Contents: Obsah: Custom Pattern: Vlastní vzorek: Foreground Color Barva popředí @@ -2448,7 +2537,6 @@ cameraRaw Color Barvy Rolling Slope - Descending Zvlněný svah - sestupný Rounded Steps Zaoblené stupně Sawtooth 1 Pila 1 - Red Eye Tool Nástroj červené oči Red Eye Červené oči Pupil Size Velikost zornice @@ -2456,7 +2544,6 @@ cameraRaw Color Barvy Click a red pupil to neutralize it Klepněte na červenou zornici open source otevřený zdrojový kód …and {n} more …a dalších {n} - Eyedroppers: Kapátka: Set Black Point Nastavit černý bod Set Neutral Gray Point Nastavit neutrální šedý bod @@ -2465,8 +2552,6 @@ cameraRaw Color Barvy Selection Preview: Náhled výběru: Black Matte Černý podklad White Matte Bílý podklad - -# Layer colour labels. Seafoam Mořská pěna Indigo Indigová Fuchsia Fuchsiová @@ -2487,8 +2572,6 @@ fontWeight Bold Tučná fontWeight ExtraBold Extratučná fontWeight Black Černá fontWeight Italic Kurzíva - -# Pattern Stamp tool Pattern Stamp Tool Nástroj razítko vzorku Pattern Stamp Razítko vzorku Impressionist Impresionista @@ -2496,13 +2579,9 @@ fontWeight Italic Kurzíva Rotate View Tool Nástroj otočení zobrazení Reset View Obnovit zobrazení Drag around the centre to rotate the view · Shift constrains to 15° Tažením kolem středu otočíte zobrazení · Shift omezí na 15° - -# Painting symmetry presets. Dual Axis Dvojitá osa Diagonal Diagonální Transform Symmetry Transformovat symetrii - -# Eyedropper Sample Size and Sample (#1649). Point Sample Bodový vzorek 3 by 3 Average Průměr 3 × 3 5 by 5 Average Průměr 5 × 5 @@ -2514,15 +2593,12 @@ fontWeight Italic Kurzíva All Layers No Adjustments Všechny vrstvy bez korekcí Current & Below No Adjustments Aktuální a pod ní bez korekcí Show Sampling Ring Zobrazit vzorkovací kroužek - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu Color to Alpha… Barva na alfu… Transparency Threshold Práh průhlednosti Opacity Threshold Práh krytí PhotoCraft — Screen Color Picker PhotoCraft — Výběr barvy obrazovky - Rasterize svg groups deeper than Rasterizovat skupiny SVG hlubší než tato úroveň 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 vypne tento práh; skupiny SVG vnořené hlouběji než 100 úrovní se rastrují vždy. Rule of Thirds Pravidlo třetin @@ -2536,8 +2612,6 @@ fontWeight Italic Kurzíva Cycle Orientation Přepnout orientaci Set the overlay options for the Crop tool Nastavit volby překryvu pro nástroj Oříznutí 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+klik převzalo pracovní prostředí, které pomocí Alt+tažení přesouvá okna. Nastavte jeho klávesu pro přesun oken na Super, nebo zamkněte Alt v nabídce Okno › Modifikační klávesy. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Výběr barvy (barva stylu vrstvy) Auto Hide Notifications Automaticky skrývat oznámení Notification Duration (seconds) Trvání oznámení (sekundy) @@ -2618,7 +2692,6 @@ fontWeight Italic Kurzíva Appearance Settings… Nastavení vzhledu… Add to favorites Přidat do oblíbených Remove from favorites Odebrat z oblíbených -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Údaje fotoaparátu cameraData Make Výrobce cameraData Model Model @@ -2629,7 +2702,6 @@ cameraData ISO Speed Citlivost ISO cameraData Focal Length Ohnisková vzdálenost cameraData Focal Length (35 mm) Ohnisková vzdálenost (35 mm) cameraData Lens Objektiv -# Shared panel docking Activate Panel Aktivovat panel Change Panel Group Změnit skupinu panelů Change Panel Layout Změnit rozložení panelů @@ -2675,7 +2747,6 @@ cameraData Lens Objektiv Snap to Origin Přichytit k počátku 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 Area Overlay Překrytí oblasti vzorkování Show Sampling Area Zobrazit oblast vzorkování @@ -2695,7 +2766,6 @@ cameraData Lens Objektiv Output To Výstup do Duplicate Layer Duplikovat vrstvu Contract Zúžit - Animation Options Možnosti animace Entire Document Celý dokument Filter Options Možnosti filtrování @@ -2716,8 +2786,6 @@ cameraData Lens Objektiv 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) @@ -2725,11 +2793,6 @@ cameraData Lens Objektiv 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 -# above, from the plain meaning of the English label. 32-bit Preview Options Možnosti 32bitového náhledu 3D 3D About {app} O aplikaci {app} @@ -3591,7 +3654,6 @@ curves Smooth Vyhladit Fix the red plane first: its corners must form a convex quad Nejprve opravte červenou rovinu: její rohy musí tvořit konvexní čtyřúhelník Alt-click a plane to set the clone source Klepnutím s Alt na rovinu nastavte zdroj klonování Too many clone strokes: apply Vanishing Point first Příliš mnoho tahů klonování: nejprve použijte Úběžník -# Select and Mask view modes (#1754) Onion Skin Cibulová slupka Marching Ants Pochodující mravenci On Black Na černé @@ -3600,7 +3662,6 @@ curves Smooth Vyhladit Show Edge Zobrazit okraj Show Original Zobrazit originál F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Štětec pro upřesnění okraje (R) Expand Detection Area Rozšířit oblast detekce Restore Original Edge Obnovit původní okraj @@ -3608,23 +3669,17 @@ curves Smooth Vyhladit Clear Brush Strokes Vymazat tahy štětce Show rulers Zobrazit pravítka Presentation Prezentace - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Povolit nelineární historii Make Layer Visibility Changes Undoable Umožnit vrácení změn viditelnosti vrstev 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). Make Work Path… Vytvořit pracovní cestu… Create Vector Mask Vytvořit vektorovou masku Make Selection… Vytvořit výběr… @@ -3669,10 +3724,8 @@ curves Smooth Vyhladit Layer Mask Hides Effects Maska vrstvy skrývá efekty Vector Mask Hides Effects Vektorová maska skrývá efekty General Blending Obecné prolnutí - This Layer: Tato vrstva: + Current Layer: Aktuální vrstva: Underlying Layer: Podkladová vrstva: - -# Status messages and notices Nothing to paste: the clipboard holds no image Není co vložit: schránka neobsahuje obraz Preferences saved Předvolby uloženy Invalid panel layout; restored the default dock Neplatné rozložení panelů; obnoveno výchozí ukotvení @@ -3715,8 +3768,6 @@ curves Smooth Vyhladit GPU error; using the CPU renderer. Chyba GPU; používá se vykreslování pomocí CPU. @plural {what} with {n} warning|{what} with {n} warnings {what} ({n} upozornění)|{what} ({n} upozornění)|{what} ({n} upozornění) Couldn't open {name}: {error} Soubor {name} se nepodařilo otevřít: {error} - -# Menus, tooltips and hardware information Toggle the Brush Settings panel Zobrazit/skrýt panel Nastavení štětce New Brush Preset Nové přednastavení štětce Grid view Zobrazení mřížky @@ -3770,8 +3821,6 @@ curves Smooth Vyhladit Monitor profile on {display}: {profile} Profil monitoru {display}: {profile} manual ruční fallback náhradní - -# Composed labels and Navigator zoom context Importing {what} from {file}… Importují se {what} ze souboru {file}… brushes štětce gradients přechody @@ -3794,7 +3843,6 @@ navigator Zoom Zvětšení @plural {n} step|{n} steps {n} krok|{n} kroky|{n} kroků {shortcut} assigned. Zkratka {shortcut} přiřazena. {shortcut} is already in use by {commands} and will be removed from it when you click OK. Zkratka {shortcut} se již používá pro {commands}. Kliknutím na OK původní přiřazení zrušíte. -# Print dialog (#2697) Emulsion Down Emulze dolů Functions Funkce Print Resolution: Rozlišení tisku: @@ -3806,7 +3854,6 @@ navigator Zoom Zvětšení Apply mask to layer before removing? Použít masku na vrstvu před odstraněním? The layer or its mask changed. Drag the mask onto the Trash again. Vrstva nebo její maska se změnila. Přetáhněte masku do koše znovu. Delete the vector mask? Odstranit vektorovou masku? -# View › Guides › New Guide… › Color Light Blue Světle modrá Light Red Světle červená Medium Blue Středně modrá @@ -3826,8 +3873,6 @@ navigator Zoom Zvětšení Clear Brush Controls Obnovit ovládací prvky stopy Reset All Locked Settings Obnovit všechna zamknutá nastavení Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Načíst akce… Save Actions… Uložit akce… New Action… Nová akce… @@ -3839,10 +3884,11 @@ navigator Zoom Zvětšení @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok zatím nemá v PhotoCraftu obdobu|{n} kroky zatím nemají v PhotoCraftu obdobu|{n} kroků zatím nemá v PhotoCraftu obdobu @plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu - +@plural {n} action file loaded|{n} action files loaded Načten {n} soubor akcí|Načteny {n} soubory akcí|Načteno {n} souborů akcí + {set} from {file} replaced the one from {earlier} Sada {set} ze souboru {file} nahradila sadu ze souboru {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} soubor nelze načíst: {errors}|{n} soubory nelze načíst: {errors}|{n} souborů nelze načíst: {errors} File Naming Pojmenování souborů Starting serial Počáteční pořadové číslo -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Vložit zastavení… Save Set… Uložit sadu… Message: Zpráva: @@ -3853,17 +3899,15 @@ navigator Zoom Zvětšení Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí Background (Default) Pozadí (výchozí) -# Batch / Image Processor Include All Subfolders Zahrnout všechny podsložky Auto Add/Delete Automaticky přidat/odstranit - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nezapínejte současně zrcadlení v nastavení tisku: obě převrácení se navzájem zruší. Print Settings… Nastavení tisku… Remember to turn off the printer's own colour management in Print Settings… Nezapomeňte v nastavení tisku vypnout vlastní správu barev tiskárny… 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ů + Memory usage percent Využití paměti (%) Show Favorite Fonts Zobrazit oblíbená písma No favorite fonts yet: click the star beside a font to add it. Zatím žádná oblíbená písma: přidejte písmo kliknutím na hvězdičku vedle něj. Favorite fonts Oblíbená písma @@ -3902,48 +3946,31 @@ navigator Zoom Zvětšení Could not write {name}: {error} Nelze zapsat {name}: {error} Kept {name}: written by another application Soubor {name} ponechán: zapsala ho jiná aplikace Shadow Tint Odstín stínů -# PhotoCraft release checks. Check for Updates… Zkontrolovat aktualizace… - PhotoCraft Updates Aktualizace PhotoCraft - Checking for updates… Kontrola aktualizací… Open the release page to choose an installer for your system. Otevřete stránku vydání a vyberte instalační program pro svůj systém. - Open Download Page Otevřít stránku ke stažení - PhotoCraft is up to date. PhotoCraft je aktuální. - Could not check for updates. Aktualizace se nepodařilo zkontrolovat. - Updates have not been checked yet. Aktualizace ještě nebyly zkontrolovány. - Check Again Zkontrolovat znovu Automatically Check for Updates Automaticky kontrolovat aktualizace - Installed version: {version} Nainstalovaná verze: {version} - PhotoCraft {version} is available. PhotoCraft {version} je k dispozici. -# 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 „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ý - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Ztmavit rozhraní Lighten Interface Zesvětlit rozhraní -# Bevel & Emboss Technique. Technique Technika 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 + Save Full-Size Composite in .pcraft Files Ukládat složený obraz v plné velikosti do souborů .pcraft Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka Bicubic Automatic Bikubická automatická @@ -3968,8 +3995,6 @@ 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 @@ -3987,11 +4012,9 @@ navigator Zoom Zvětšení Undo Changes Vrátit změny {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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme, nejvýše 3/8 její paměti. Automaticky se řídí Využitím paměti, v rámci čtvrtiny paměti tohoto počítače a 3/8 paměti karty, a u karty se známou pamětí do ní dokument nezapočítává. Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. GPU Memory Full Paměť GPU je plná @@ -4001,6 +4024,7 @@ navigator Zoom Zvětšení 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) + Scratch Size Limit (MB, 0 = No Limit) Limit velikosti odkládacího disku (MB, 0 = bez limitu) {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í. @@ -4017,11 +4041,6 @@ navigator Zoom Zvětšení 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 @@ -4149,8 +4168,6 @@ navigator Zoom Zvětšení 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 @@ -4165,17 +4182,12 @@ cameraRaw Dehaze Odstranění oparu Use Použít In Front Vpředu Behind Vzadu - -# DDS Options (#1327) 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Obsah AI/PDF/EPS se bude upravovat jako SVG. Uložení nahradí vložený zdroj; vlastnosti specifické pro formát se mohou změnit. # Styles panel: Import / Export Styles (.asl) Import Styles… Importovat styly… @@ -4184,7 +4196,6 @@ cameraRaw Dehaze Odstranění oparu {file} is not a style library (.asl) {file} není knihovna stylů (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Importován {n} styl ze souboru {file}|Importovány {n} styly ze souboru {file}|Importováno {n} stylů ze souboru {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Exportován {n} styl do souboru {file}|Exportovány {n} styly do souboru {file}|Exportováno {n} stylů do souboru {file} -# 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 @@ -4193,14 +4204,11 @@ cameraRaw Dehaze Odstranění oparu 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Krychle odstínu Brightness Cube Krychle jasu Color Wheel Barevné kolo @@ -4217,7 +4225,8 @@ cameraRaw Dehaze Odstranění oparu Grayscale Ramp Přechod stupňů šedi Current Colors Aktuální barvy Make Ramp Web Safe Omezit pruh na webové barvy -# Art History Brush (#213). + The path was kept as the Work Path Cesta byla zachována jako pracovní cesta + The path was discarded Cesta byla zahozena Art History Brush Štětec umělecké historie Art History Brush Tool Nástroj štětec umělecké historie Tight Short Těsný krátký @@ -4229,9 +4238,31 @@ cameraRaw Dehaze Odstranění oparu Tight Curl Těsná smyčka Loose Curl Volná smyčka Stylized dabs from a history state Stylizované šmouhy ze stavu historie - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio Poměr stran Font size presets Předvolby velikosti písma Leading presets Předvolby prokladu Tracking presets Předvolby prostrkání + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neplatná číselná hodnota. Je vyžadováno celé číslo mezi {min}% a {max}%. Byla vložena předchozí hodnota. + Color Aware Podle barev + Decontaminate Colors Dekontaminovat barvy + Edge Detection Detekce okraje + Global Refinements Globální upřesnění + High Quality Preview Náhled ve vysoké kvalitě + Object Aware Podle objektů + Real-Time Refinement Upřesnění v reálném čase + Refine Edge Brush Tool Nástroj štětec pro upřesnění okraje + Refine Hair Upřesnit vlasy + Refine Mode Režim upřesnění + Remember Settings Zapamatovat nastavení + View Mode Režim zobrazení + Object Aware needs a segmentation model, which isn't available yet Režim Podle objektů vyžaduje segmentační model, který zatím není k dispozici + Refine Hair needs hair detection, which isn't available yet Upřesnit vlasy vyžaduje detekci vlasů, která zatím není k dispozici + Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní + Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva + Drag to move the selected path Tažením přesunete vybranou cestu + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Nedávno použité barvy + Stop Color Barva zarážky + Shapes can't use patterns yet Tvary zatím nemohou používat vzorky + Edit Brush… Upravit štětec… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv new file mode 100644 index 0000000000..46e71ed2cf --- /dev/null +++ b/crates/ui-egui/src/i18n/da.tsv @@ -0,0 +1,4775 @@ +# Danish (da) 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.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. +# +# Based on the community Danish catalog in #7267, completed for the current UI: its rows were +# checked and corrected, the strings added since were translated, and one term is used per concept +# across the UI (lag, markering, maske, gradient, blød kant, transformér/transformering, vridning, +# gitter, hjælpelinje, tavle, lærred, udklipningsmaske, arbejdsområde, pensel, blandingstilstand, +# justering, kanal, sti, historik, forudindstilling, øjebliksbillede, form, kontur, nål …). +# Clean-room: written from the plain meaning of the English labels using ordinary Danish computing +# vocabulary; no proprietary localisation resource, string table or product UI was consulted or +# copied. Labels use sentence case, commands are imperatives and the user is addressed as "du". +# Product, format and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab …) and key caps +# keep their spelling; the trailing "…" of dialog commands is kept. Plural rule: one|other +# (n != 1 -> other). Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskeindstillinger… + Layer Mask Display Options… Visningsindstillinger for lagmaske… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfarve + Color Indicates: Farve angiver: + Solidity: Dækning: + Ink Characteristics Trykfarveegenskaber + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papirbredde + Paper Height Papirhøjde + Rows Rækker + Number Antal + Margin Margen + Top: Øverst: + Left: Venstre: + Bottom: Nederst: + Right: Højre: + Target: Mål: + Selected Artboards Markerede tavler + Center Columns Centrér kolonner + Clear Existing Guides Ryd eksisterende hjælpelinjer + Invalid guide layout Ugyldigt layout for hjælpelinjer + Leave empty for automatic size Lad stå tom for automatisk størrelse + 5 Columns, 3 Rows 5 kolonner, 3 rækker + 8 Columns 8 kolonner + 12 Columns 12 kolonner + + File Filer + Edit Redigér + Image Billede + Layer Lag + Type Tekst + Select Markér + Selected Markeret + Scope Omfang + Top Left Øverst til venstre + Top Right Øverst til højre + Bottom Left Nederst til venstre + Bottom Right Nederst til højre + Filter Filter + View Vis + Window Vindue + Help Hjælp + +# File + New… Ny… + New from Clipboard Ny fra udklipsholder + New Ny + Open… Åbn… + Open Åbn + Open As… Åbn som… + Open EXR Parts… Åbn EXR-dele… + Add beauty precomp from the AOVs Tilføj beauty-precomp fra AOV'erne + Both Begge + Left Venstre + Right Højre + Open Recent Åbn seneste + Clear Recent Files Ryd seneste filer + Remove from Recent Fjern fra seneste + Remove {name} from Recent Fjern {name} fra seneste + Close Luk + Minimize Minimér + Maximize Maksimér + Restore Gendan + Close All Luk alle + Close Others Luk andre + Save Gem + Save As… Gem som… + Save a Copy… Gem en kopi… + {name} can't keep everything in this document {name} kan ikke bevare alt i dette dokument + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Vælg et format, der bevarer det, f.eks. Photoshop (PSD), eller gem en kopi. + Save… Gem… + Revert Vend tilbage + Import Importér + Import Presets Importér forudindstillinger + Export As… Eksportér som… + Export Eksportér + Export… Eksportér… + Quick Export As PNG Hurtig eksport som PNG + Quick Export as PNG Hurtig eksport som PNG + Place Embedded… Placér indlejret… + Place Linked… Placér sammenkædet… + Package… Pak… + Save for Web (Legacy)… Gem til web (ældre)… + Generate Generér + Image Assets Billedaktiver + +# Edit + Undo Fortryd + Redo Annullér fortryd + Fade… Ton ud… + Cut Klip + Copy Kopiér + Copy Merged Kopiér flettet + Paste Indsæt + Paste Special Indsæt speciel + Paste Into Indsæt i + Paste Outside Indsæt udenfor + Paste in Place Indsæt på stedet + Clear Ryd + Search Søg + Search… Søg… + Check Spelling… Kontrollér stavning… + Fill… Udfyld… + Stroke… Kontur… + Content-Aware Fill… Indholdsbevidst udfyldning… + Delete and Fill Selection Slet og udfyld markering + Define Brush Preset… Definér penselforudindstilling… + Define Pattern… Definér mønster… + Define Custom Shape… Definér brugerdefineret form… + Purge Tøm + +# Image + Image Size… Billedstørrelse… + Image Size Billedstørrelse + Canvas Size… Lærredsstørrelse… + Image Rotation Billedrotation + Rotate 90° Clockwise Rotér 90° med uret + Rotate 90° Counter Clockwise Rotér 90° mod uret + Rotate 180° Rotér 180° + Flip Canvas Horizontal Vend lærredet vandret + Flip Canvas Vertical Vend lærredet lodret + Crop Beskær + Crop and Straighten Photos Beskær og udret fotos + Trim… Trim… + Reveal All Vis alt + Hide Selection Skjul markering + Auto Tone Automatisk tone + Auto Contrast Automatisk kontrast + Auto Color Automatisk farve + Adjustments Justeringer + 16 Bits/Channel 16 bit/kanal + 32 Bits/Channel 32 bit/kanal + 8 Bits/Channel 8 bit/kanal + 8 bit 8-bit + Integer Heltal + 16 bit 16-bit + 32 bit (float) 32 bit (flydende komma) + Floating point Flydende komma + Mode Tilstand + Grayscale Gråtoner + RGB Color RGB-farve + CMYK Color CMYK-farve + Lab Color Lab-farve + Indexed Color Indekserede farver + Multichannel Multikanal + Bitmap… Bitmap… + Bitmap Bitmap + Duotone… Duotone… + Duotone Duotone + Trap… Trap… + +# Layer + Layer via Copy Lag via kopiering + Layer via Cut Lag via udklipning + Duplicate Layer… Duplikér lag… + Duplicate… Duplikér… + Delete Slet + Delete Layer Slet lag + Delete Layers Slet lag + Rename Layer Omdøb lag + Layer From Background… Lag fra baggrund… + Layer from Background… Lag fra baggrund… + Background from Layer Baggrund fra lag + Background From Layer Baggrund fra lag + Group Layers Gruppér lag + Group… Gruppe… + Ungroup Layers Ophæv gruppering af lag + Group from Layers… Gruppe fra lag… + New Group from Layers… Ny gruppe fra lag… + Hide Layers Skjul lag + Arrange Arrangér + Bring to Front Placér forrest + Bring Forward Flyt fremad + Send Backward Flyt bagud + Send to Back Placér bagerst + Convert to Smart Object Konvertér til smartobjekt + Convert for Smart Filters Konvertér til smartfiltre + New Smart Object via Copy Nyt smartobjekt via kopi + Edit Contents Redigér indhold + Replace Contents… Erstat indhold… + Export Contents… Eksportér indhold… + Rasterize Rasterisér + Rasterize Type Rasterisér tekst + Rasterize Shape Rasterisér form + Rasterize Layer Rasterisér lag + Rasterize Layers Rasterisér lag + New Adjustment Layer Nyt justeringslag + New Fill Layer Nyt udfyldningslag + Layer Mask Lagmaske + Add Layer Mask Tilføj lagmaske + Delete Layer Mask Slet lagmaske + Enable Layer Mask Aktivér lagmaske + Disable Layer Mask Deaktivér lagmaske + Apply Layer Mask Anvend lagmaske + Add Mask to Selection Tilføj maske til markering + Subtract Mask from Selection Træk maske fra markering + Intersect Mask with Selection Skær maske med markering + Link Layer Mask Sammenkæd lagmaske + Unlink Layer Mask Fjern sammenkædning af lagmaske + Vector Mask Vektormaske + Add Vector Mask Tilføj vektormaske + Delete Vector Mask Slet vektormaske + Enable Vector Mask Aktivér vektormaske + Link Vector Mask Sammenkæd vektormaske + Create Clipping Mask Opret udklipningsmaske + Release Clipping Mask Ophæv udklipningsmaske + New Shape Layer Nyt formlag + New Type Layer Nyt tekstlag + New Video Layer from File… Nyt videolag fra fil… + New Blank Video Layer Nyt tomt videolag + Smart Filters Smartfiltre + Delete Smart Filters Slet smartfiltre + Enable Smart Filters Aktivér smartfiltre + Disable Smart Filters Deaktivér smartfiltre + Clear Smart Filters Ryd smartfiltre + Delete Filter Mask Slet filtermaske + Disable Filter Mask Deaktivér filtermaske + New Layer Based Slice Nyt lagbaseret udsnit + Layer Style Lagstil + Blending Options… Blandingsindstillinger… + Blending Options Blandingsindstillinger + Drop Shadow… Slagskygge… + Inner Shadow… Indre skygge… + Outer Glow… Ydre glød… + Inner Glow… Indre glød… + Bevel & Emboss… Facet og relief… + Use Global Light Brug globalt lys + Overprint Overtryk + Satin… Satin… + Color Overlay… Farveoverlejring… + Gradient Overlay… Gradientoverlejring… + Pattern Overlay… Mønsteroverlejring… + Bevel & Emboss Facet og relief + Inner Shadow Indre skygge + Inner Glow Indre glød + Satin Satin + Color Overlay Farveoverlejring + Gradient Overlay Gradientoverlejring + Pattern Overlay Mønsteroverlejring + Outer Glow Ydre glød + Drop Shadow Slagskygge + Copy Layer Style Kopiér lagstil + Paste Layer Style Indsæt lagstil + Clear Layer Style Ryd lagstil + Layer Content Options… Indstillinger for lagindhold… + Scale Effects… Skalér effekter… + New Layer… Nyt lag… + Delete Layer… Slet lag… + +# Type + Font Skrifttype + Font Preview Size Størrelse på skrifttypeeksempler + Orientation Orientering + Horizontal Vandret + Vertical Lodret + Rasterize Type Layer Rasterisér tekstlag + Create Work Path Opret arbejdssti + Convert to Shape Konvertér til form + Convert to Paragraph Text Konvertér til afsnitstekst + Convert to Point Text Konvertér til punkttekst + Warp Text… Vrid tekst… + Character Panel Tegnpanel + Paragraph Panel Afsnitspanel + Check Spelling Kontrollér stavning + Language Options Sprogindstillinger + Find and Replace Text… Find og erstat tekst… + Add to Dictionary Føj til ordbog + No Spelling Suggestions Ingen staveforslag + Paste Lorem Ipsum Indsæt Lorem Ipsum + Glyphs Panel Glyfpanel + Type Styles Tekststile + Character Styles Tegnstile + Paragraph Styles Afsnitsstile + Load Default Type Styles Indlæs standardtekststile + Save Default Type Styles Gem standardtekststile + Type… Tekst… + +# Select + All Alle + Deselect Fjern markering + Reselect Markér igen + Inverse Selection Invertér markering + Inverse Invertér + Color Range… Farveområde… + Select and Mask… Markér og maskér… +# Select and Mask dialog labels + Smart Radius Smart radius + Shift Edge Forskyd kant + Decontaminate Fjern forurening + Output Output + Refine Edge Forfin kant + Select Subject Markér motiv + Sky Himmel + Focus Area… Fokusområde… + Subject Motiv + Color Range Farveområde + Similar Lignende + Grow Forøg + Select Similar Colors Markér lignende farver + Load Selection… Indlæs markering… + Save Selection… Gem markering… + New Selection Ny markering + Select All Markér alt + Deselect All Fjern markering af alle + Deselect Layers Fjern markering af lag + Find Layers Find lag + Isolate Layers Isolér lag + Select Linked Layers Markér sammenkædede lag + All Layers Alle lag + Current & Below Aktuelt og nedenunder + Current Layer Aktuelt lag + Refine Strokes Finjustér strøg + Edits Redigeringer + +# Filter + Last Filter Seneste filter + Filter Gallery… Filtergalleri… + Liquify… Gør flydende… + Camera Raw Filter… Camera Raw-filter… + Adaptive Wide Angle… Adaptiv vidvinkel… + Lens Correction… Objektivkorrektion… + Vanishing Point… Forsvindingspunkt… + Oil Paint… Oliemaleri… + Blur Gallery Sløringsgalleri + Field Blur… Feltsløring… +# Field Blur pins (#3373) + No pin selected Ingen nål markeret + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik på billedet for at tilføje en nål; træk i en nål for at flytte den eller i dens ring for at ændre dens sløring. Delete fjerner den markerede nål. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Træk i håndtagene på konturen for at ændre størrelsen og dreje den, i firkanten for at afrunde hjørnerne og i punkterne indeni for at angive, hvor sløringen starter. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Træk i nålen for at flytte båndet eller i dens ring for at ændre dens sløring. Træk i de ubrudte linjer for at udvide det skarpe bånd, i de stiplede linjer for at angive overgangen og i punkterne for at dreje det. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Træk i håndtagene på konturen for at ændre størrelsen og dreje den; ringen angiver sløringsvinklen. + Blur Angle Sløringsvinkel + Iris Blur… Irissløring… + Tilt-Shift… Tilt-Shift… + Path Blur… Stisløring… + Spin Blur… Rotationssløring… + Auto-Align Layers… Justér lag automatisk… + Auto-Blend Layers… Bland lag automatisk… + Photomerge… Photomerge… + Content-Aware Scale Indholdsbevidst skalering + Adaptive Wide Angle Adaptiv vidvinkel + Apply Image… Anvend billede… + Calculations… Beregninger… + Crystallize… Krystallisér… + Facet Facet + Fragment Fragment + Mosaic… Mosaik… + Pointillize… Pointillér… + Chalk & Charcoal Kridt og kul + Charcoal Kul + Chrome Krom + Conté Crayon Conté-crayon + Graphic Pen Grafisk pen + Halftone Pattern Rastermønster + Note Paper Notespapir + Photocopy Fotokopi + Plaster Puds + Reticulation Retikulering + Stamp Stempel + Torn Edges Flossede kanter + Water Paper Vandfarvepapir + Clouds Skyer + Difference Clouds Differenceskyer + Fibers… Fibre… + Lens Flare… Objektivblænding… + Picture Frame… Billedramme… + Tree… Træ… + Flame… Flamme… + Sharpen Edges Skærp kanter + Dust & Scratches… Støv og ridser… + Noise Støj + Add Noise… Tilføj støj… + Reduce Noise… Reducér støj… + Despeckle Fjern pletter + Median… Median… + Median Median + Maximum… Maksimum… + Maximum Maksimum + Minimum… Minimum… + Minimum Minimum + Custom… Brugerdefineret… + High Pass… Højpas… + Emboss… Relief… + Extrude… Ekstrudér… + Find Edges Find kanter + Solarize Solarisér + Tiles… Fliser… + Trace Contour… Spor kontur… + Wind… Vind… + Offset… Forskydning… + Pinch… Klem… + Polar Coordinates… Polære koordinater… + Ripple… Krusning… + Shear… Skævvridning… + Spherize… Kugleform… + Twirl… Hvirvel… + Wave… Bølge… + ZigZag… Zigzag… + Average Gennemsnit + Blur More Slør mere + Box Blur… Bokssløring… + Gaussian Blur… Gaussisk sløring… + Motion Blur… Bevægelsessløring… + Radial Blur… Radial sløring… + Shape Blur… Formsløring… + Smart Blur… Smart sløring… + Surface Blur… Overfladesløring… + Sharpen Skærp + Sharpen More Skærp mere + Unsharp Mask… Uskarp maskering… + Smart Sharpen… Smart skærpning… + Diffuse… Diffundér… + Mezzotint… Mezzotint… + Color Halftone… Farverastrering… + Layer… Lag… + +# View + Proof Setup Korrekturopsætning + Proof Colors Korrekturfarver + Pixel Aspect Ratio Pixelsideforhold + Pixel Aspect Ratio Correction Korrektion af pixelsideforhold + Zoom In Zoom ind + Zoom Out Zoom ud + Fit on Screen Tilpas til skærmen + 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Udskriftsstørrelse + Screen Mode Skærmtilstand + Standard Screen Mode Standardskærmtilstand + Full Screen Mode With Menu Bar Fuldskærmstilstand med menulinje + Full Screen Mode Fuldskærmstilstand + Extras Ekstraer + Show Vis + Rulers Linealer + Grid Gitter + Guides Hjælpelinjer + Slices Udsnit + Snap Fastgør + Snap To Fastgør til + Lock Guides Lås hjælpelinjer + Clear Guides Ryd hjælpelinjer + New Guide… Ny hjælpelinje… + Lock Slices Lås udsnit + Clear Slices Ryd udsnit + New Guide Layout… Nyt layout for hjælpelinjer… + New Guides From Shape Nye hjælpelinjer fra form + Canvas rotation Lærredsrotation + Zoom in Zoom ind + Zoom out Zoom ud + +# Window + Cascade Kaskade + Tile Side om side + 2-up Horizontal 2-up vandret + 3-up Horizontal 3-up vandret + 2-up Vertical 2-up lodret + 3-up Vertical 3-up lodret + 3-up Stacked 3-up stablet + 4-up 4-up + 6-up 6-up + Consolidate All to Tabs Saml alle i faner + Match Zoom Afstem zoom + Match Location Afstem placering + Match Rotation Afstem rotation + Match All Afstem alle + New Window for Document Nyt vindue til dokument + Workspace Arbejdsområde + Extensions Udvidelser + Panels Paneler + Actions Handlinger + Brush Settings Penselindstillinger + Brushes Pensler + Channels Kanaler + Character Tegn + Clone Source Kloningskilde + Color Mixer Farvemikser + Glyphs Glyffer + Gradients Gradienter + Histogram Histogram + History Historik + Info Info + Layer Comps Lagkompositioner + Layers Lag + Measurement Log Målingslog + Notes Noter + Navigator Navigator + Paragraph Afsnit + Paths Stier + Patterns Mønstre + Properties Egenskaber + Styles Stile + Swatches Farveprøver + Timeline Tidslinje + Tool Presets Værktøjsforudindstillinger + Tools Værktøjer + Libraries Biblioteker + 3D 3D + Toolbars Værktøjslinjer + Hide All Skjul alt + +# Help + About PhotoCraft Om PhotoCraft + PhotoCraft Website PhotoCrafts websted + PhotoCraft on GitHub PhotoCraft på GitHub + Report an Issue… Rapportér et problem… + ArtCraft Website ArtCrafts websted + Join the ArtCraft Discord… Deltag i ArtCrafts Discord… + System Info… Systemoplysninger… + +# Tools and options bar + Move Flyt + Marquee Markeringsramme + Lasso Lasso + Wand Tryllestav + Eyedropper Pipette + Brush Pensel + Block Blok + Pencil Blyant + Eraser Viskelæder + Clone Stamp Kloningsstempel + Healing Reparation + Blur Sløring + Dodge Lysne + Burn Mørkne + Gradient Gradient + Shape Form + Pen Pen + Hand Hånd + Zoom Zoom + Sampler Prøvetager + Ruler Tool Linealværktøj + Count Tool Tælleværktøj + Note tool Noteværktøj + Quick Selection Hurtig markering + Magnetic Lasso Magnetisk lasso + Mixer Brush Blandingspensel + Mixer Brush Tool Blandingspenselværktøj + Color Replacement Tool Farveerstatningsværktøj + History Brush Historikpensel + Art History Brush Kunstnerisk historikpensel + Pattern Stamp Mønsterstempel + Spot Healing Pletreparation + Red Eye Røde øjne + Patch Lap + Content-Aware Move Indholdsbevidst flytning + Rotate View Rotér visning + Perspective Crop Perspektivbeskæring + Color Replacement Farveerstatning + Dodge/Burn Lysne/Mørkne + Foreground Forgrund + Background Baggrund + Set foreground color Angiv forgrundsfarve + Set background color Angiv baggrundsfarve + Swap colours (X) Ombyt farver (X) + Default colours (D) Standardfarver (D) + Brush Tool Options Penselværktøjsindstillinger + Opacity Opacitet + Flow Gennemstrømning + Smoothing Udjævning + Size Størrelse + Hardness Hårdhed + Angle: Vinkel: + Roundness: Rundhed: + Spacing Afstand + Dynamics Dynamik + Pressure Tryk + Tilt Hældning + Rotation Rotation + Align Justér + Aligned Justeret + +# Panels, dialogs and common widgets + OK OK + Cancel Annullér + Cancel (Esc) Annullér (Esc) + Manage submitted jobs in the system print queue Administrér sendte job i systemets udskriftskø + Done Færdig + Apply Anvend + Reset Nulstil + Reset Section Nulstil sektion + Reset All to Defaults Nulstil alle til standardværdierne + Import Shortcuts… Importér genveje… + Load a keyboard shortcut set saved as .kys Indlæs et tastaturgenvejssæt, der er gemt som .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Importerede {n} genveje fra {file}. Klik på OK for at beholde dem. + {n} commands are not in PhotoCraft. {n} kommandoer findes ikke i PhotoCraft. + Keyboard shortcuts imported Tastaturgenveje importeret + 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. Dit tastaturgenvejssæt {set} er i brug her: {n} genveje afviger fra standarden. Redigér › Tastaturgenveje viser dem; Nulstil alle til standardværdierne fortryder dette. + Import keyboard shortcuts? Importér tastaturgenveje? + 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 fandt dit tastaturgenvejssæt {set} fra et andet billedredigeringsprogram på denne computer: {n} af genvejene afviger fra PhotoCrafts. Vil du importere det? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Redigér › Tastaturgenveje › Nulstil alle til standardværdierne fortryder en import. + Don't Import Importér ikke + The keyboard shortcut set was not imported: {error} Sættet med tastaturgenveje blev ikke importeret: {error} + Defaults restored. Standardindstillingerne er gendannet. + Restore Defaults Gendan standardindstillinger + Restore original size Gendan oprindelig størrelse + Use Default Brug standard + Dismiss Luk + Preview Forhåndsvisning + Options Indstillinger + Settings… Indstillinger… + Rename… Omdøb… + Load… Indlæs… + Define… Definér… + Custom Brugerdefineret + Default Standard + None Ingen + Add Tilføj + Remove Fjern + Remove Background Fjern baggrund + Rename Omdøb + Ignore Ignorér + Ignore All Ignorér alle + Change Ændr + Change All Ændr alle + Next frame Næste billede + Previous frame Forrige billede + Play Afspil + Pause Pause + Stop Stop + Begin recording Start optagelse + Stop playing/recording Stop afspilning/optagelse + Record ● a sequence of edits, then play ▶ it on any document. Optag ● en række redigeringer, og afspil ▶ den derefter på et hvilket som helst dokument. + Create new action Opret ny handling + Play selection Afspil det markerede + Create new set Opret nyt sæt + Export selected measurements (CSV) Eksportér markerede målinger (CSV) + Record Measurements Registrér målinger + Delete selected measurements Slet markerede målinger + Select Data Points… Markér datapunkter… + Set Measurement Scale… Angiv måleskala… + Use Measurement Scale Brug måleskala + +# Preferences dialog + Preferences Præferencer + General… Generelt… + Interface… Grænseflade… + File Handling… Filhåndtering… + Performance… Ydeevne… + Cursors… Markører… + Transparency & Gamut… Gennemsigtighed og gamut… + Units & Rulers… Enheder og linealer… + Guides, Grid & Slices… Hjælpelinjer, gitter og udsnit… + Plug-ins… Plug-ins… + Technology Previews Teknologieksempler + Scratch Disks… Arbejdsdiske… + Export Preferences… Eksportpræferencer… + Keyboard Shortcuts… Tastaturgenveje… + Menus… Menuer… + Export/Import Presets… Eksportér/importér forudindstillinger… + Additional plugins folder Ekstra plug-in-mappe + Install Plug-in… Installér plug-in… + Export Presets Eksportér forudindstillinger + Import brushes (.abr) Importér pensler (.abr) + Migrate Presets Overfør forudindstillinger + Thumbnail size (smallest: list) Miniaturestørrelse (mindste: liste) + Reset GPU Backend Nulstil GPU-backend + Applies at next launch. Anvendes ved næste opstart. + +# Document and canvas status bar + Untitled Uden navn + Untitled-1 Uden navn-1 + Doc: {flat}/{layered} Dok.: {flat}/{layered} + Scratch: {size} Arbejdsdisk: {size} + Efficiency: 100% Effektivitet: 100 % + Dimensions: Dimensioner: + Width Bredde + Slant Skråning + Height Højde + Width: Bredde: + Height: Højde: + Resolution Opløsning + Resolution: Opløsning: + Resolution: {dpi} pixels/inch Opløsning: {dpi} pixels/tomme + {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) + Pixels: Pixels: + Pixel Length: Pixellængde: + Logical Length: Logisk længde: + Logical Units: Logiske enheder: + Length: Længde: + Interpolation: Interpolation: + Resample Omsampling + Constrain proportions Bevar proportioner + Link width and height Sammenkæd bredde og højde + Maintain aspect ratio Bevar sideforhold + Current Size Aktuel størrelse + New Size Ny størrelse + Fit Tilpas + Percent Procent + Pixels/Inch Pixels/tomme + Pixels/Centimeter Pixels/centimeter + Relative Relativ + Canvas extension color: Farve til udvidelse af lærred: + Anchor: Anker: + Both Axes Begge akser + Horizontally Vandret + Vertically Lodret + +# Layers panel + Layers to Files… Lag til filer… + Duplicate Layers… Duplikér lag… + Group name Gruppenavn + New Group Ny gruppe + Layer Comp Lagkomposition + Create new layer Opret nyt lag + Create new group Opret ny gruppe + Create new channel Opret ny kanal + Create new path Opret ny sti + Toggle visibility Slå synlighed til/fra + Clipped Klippet + Group · {n} layer Gruppe · {n} lag + Lock: keep these settings when picking another preset Låst: bevar disse indstillinger, når du vælger en anden forudindstilling + Unlock: picking a preset replaces these settings Låst op: valg af en forudindstilling erstatter disse indstillinger + Show the layer's effects ({key}-click: all layers) Vis lagets effekter ({key}-klik: alle lag) + Hide the layer's effects ({key}-click: all layers) Skjul lagets effekter ({key}-klik: alle lag) + Expand group ({key}-click: all groups) Udvid gruppe ({key}-klik: alle grupper) + Collapse group ({key}-click: all groups) Fold gruppe sammen ({key}-klik: alle grupper) + Effects Effekter + Blending Blanding + Blend If Bland hvis + General Blend Generel blanding + Advanced Blending Avanceret blanding + Opacity: Opacitet: + Fill: Udfyldning: + Lock: Lås: + Smart Filter Smartfilter + Fill Content Udfyldningsindhold + Adjustment Preview Justeringsforhåndsvisning + +# Channels panel + Channel: Kanal: + Channel Mixer… Kanalmikser… + Output Channel: Outputkanal: + RGB RGB + Red Rød + Green Grøn + Blue Blå + Alpha Alfa + Load channel as selection Indlæs kanal som markering + Save selection as channel Gem markering som kanal + Delete current channel Slet aktuel kanal + Spot Channel Spotkanal + Mask All Maskér alt + Mask All Objects Maskér alle objekter + +# Color picker and swatches + Color Farve + Color: Farve: + Colors Farver + HSB/HSL HSB/HSL + No Color Ingen farve + Set shape fill type Angiv udfyldningstype for form + Set shape stroke type Angiv konturtype for form + Set the text color Angiv tekstfarven + Create warped text Opret tekst med vridning + Only Web Colors Kun webfarver + Overlay Color Overlejringsfarve + Overlay Opacity Overlejringsopacitet + Glow Color Glødefarve + Web Snap Webtilpasning + +# Filters and adjustments dialogs + Brightness Lysstyrke + Contrast Kontrast + Brightness/Contrast… Lysstyrke/kontrast… + Levels… Niveauer… + Curves… Kurver… + Curve Kurve + Exposure… Eksponering… + Exposure Eksponering + Vibrance… Livskraft… + Vibrance Livskraft + Hue/Saturation… Farvetone/mætning… + Hue Farvetone + Saturation Mætning + Lightness Lyshed + Color Balance… Farvebalance… + Black & White… Sort/hvid… + Photo Filter… Fotofilter… + Color Lookup… Farveopslag… + Color Lookup Tables… Farveopslagstabeller… + 3D LUT File 3D LUT-fil + Load 3D LUT… Indlæs 3D LUT… + Invert… Invertér… + Invert Invertér + Invert has no settings. Invertér har ingen indstillinger. + Threshold… Tærskel… + Threshold Level Tærskelniveau + Posterize… Posterisér… + Gradient Map… Gradientkort… + Selective Color… Selektiv farve… + Match Color… Afstem farve… + Replace Color… Erstat farve… + Equalize Udlign + HDR Toning… HDR-toning… + Shadows/Highlights… Skygger/højlys… + Amount Mængde + Radius Radius + Radius: Radius: + Tolerance: Tolerance: + Density Tæthed + Density: Tæthed: + Feather Blød kant + Feather… Blød kant… + Anti-alias Antialiasering + Anti-Alias Antialiasering + Contiguous Sammenhængende + Sample All Layers Prøvetag alle lag + Protect Foreground Color Beskyt forgrundsfarven + Matte Mat + Matting Kantbehandling + Color Decontaminate… Fjern farveforurening… + Remove Black Matte Fjern sort mat + Remove White Matte Fjern hvid mat + Defringe… Fjern kantfarver… + Source Kilde + Working CMYK Arbejds-CMYK + Working Cyan Plate Arbejdscyanplade + Working Magenta Plate Arbejdsmagentaplade + Working Yellow Plate Arbejdsgulplade + Working Black Plate Arbejdssortplade + Working Gray Plate Arbejdsgråplade + +# Camera Raw and lens dialogs + Basic Grundlæggende + Detail Detalje + HSL/Grayscale HSL/Gråtoner + Tone Curve Tonekurve + Split Toning Opdelt toning + Lens Corrections Objektivkorrektioner + Camera Calibration Kamerakalibrering + Presets Forudindstillinger + Snapshots Øjebliksbilleder + Temperature Temperatur + Tint Nuance + Exposure: Eksponering: + Recovery Gendannelse + Fill Light Udfyldningslys + Blacks Sorte + Clarity Klarhed + Convert to Grayscale Konvertér til gråtoner + Auto Auto + Auto collapse icon panels Fold ikonpaneler sammen automatisk + Auto show hidden panels Vis skjulte paneler automatisk + Auto show home screen Vis startskærm automatisk + Sharpness Skarphed + Noise Reduction Støjreduktion + Color Noise Reduction Farvestøjreduktion + Luminance Detail Luminansdetalje + Color Detail Farvedetalje + Luminance Smoothness Luminansglathed + Vignetting Vignettering + White Balance: As Shot Hvidbalance: Som fotograferet + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw er ikke åben + Dehaze Fjern dis + Texture Tekstur + Focal Length Brændvidde + Make Producent + Model Model + Lens Objektiv + F F + ISO ISO + Focal Length35mm Brændvidde 35 mm + Aspect Ratio Sideforhold + Time Tid + Artist Kunstner + Copyright Ophavsret + Enter description… Angiv beskrivelse… + Adobe RGB (1998) compatible Adobe RGB (1998)-kompatibel + Gray Gamma 2.2 Grå gamma 2.2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Industristandard + Web Web + Internet Standard RGB Internetstandard-RGB + Legacy Macintosh RGB Ældre Macintosh RGB + Monitor RGB Skærm-RGB + Windows LCD Windows LCD + Coated CMYK Bestrøget CMYK + Uncoated CMYK Ubestrøget CMYK + Newspaper Avis + Working Spaces Arbejdsfarverum + Color Management Policies Farvestyringspolitikker + Assign working space Tildel arbejdsfarverum + Leave as is (don't color manage) Lad være som det er (brug ikke farvestyring) + Discard the embedded profile (don't color manage) Kassér den indlejrede profil (brug ikke farvestyring) + Embed Color Profile Indlejr farveprofil + Assign Profile… Tildel profil… + Convert to Profile… Konvertér til profil… + PhotoCraft Manages Colors PhotoCraft styrer farver + Printer Manages Colors Printeren styrer farver + (print to the printer) (udskriv til printeren) + Absolute Colorimetric Absolut kolorimetrisk + Relative Colorimetric Relativ kolorimetrisk + Perceptual Perceptuel + Black Point Compensation Sortpunktskompensation + Use Legacy Brug ældre + Dither Dither + No Dither Ingen dither + Diffusion Diffusion + Pattern Mønster + Fuzziness: Uklarhed: + Range: Område: + Select: Markér: + Detect Faces Registrér ansigter + Localize Color Clusters Lokalisér farveklynger + Sample: Prøve: + Add to Sample Tilføj til prøve + Subtract from Sample Træk fra prøve + Selections Markeringer + Duplicated selection name Duplikeret markeringsnavn + Operation Operation + New channel only Kun ny kanal + Masked Areas Maskerede områder + Selected Areas Markerede områder + Spot Color Spotfarve + Color indicates Farve angiver + +# Adjustments panel + Curves Kurver + Levels Niveauer + Color Balance Farvebalance + Black & White Sort/hvid + Photo Filter Fotofilter + Color Lookup Farveopslag + Threshold Tærskel + Posterize Posterisér + Brightness/Contrast Lysstyrke/kontrast + Channel Mixer Kanalmikser + Gradient Map Gradientkort + Selective Color Selektiv farve + +# History panel + History Log… Historiklog… + Create Snapshot Opret øjebliksbillede + Delete Snapshot Slet øjebliksbillede + New Snapshot Nyt øjebliksbillede + Snapshot 1 Øjebliksbillede 1 + Step Forward Trin fremad + Step Backward Trin tilbage + Histories Historik + Nonlinear History Ikke-lineær historik + Set New Snapshot as Default Angiv nyt øjebliksbillede som standard + Automatically Create First Snapshot Opret automatisk det første øjebliksbillede + Automatically Create New Snapshot When Saving Opret automatisk et nyt øjebliksbillede, når der gemmes + Show New Snapshot Dialog by Default Vis dialogen Nyt øjebliksbillede som standard + Clear History Ryd historik + +# Navigator panel + Panel Options… Panelindstillinger… + View menu Menuen Vis + Keep view in sync Hold visningen synkroniseret + {zoom}% {zoom}% + +# Brush Settings panel + Brush name Penselnavn + Brush Preview Penselforhåndsvisning + Brush Tip Shape Penselspidsform + Shape Dynamics Formdynamik + Scattering Spredning + Dual Brush Dobbelt pensel + Color Dynamics Farvedynamik + Transfer Overførsel + Brush Pose Penselholdning + Mouse Controls Musestyring + Wet Edges Våde kanter + Build-up Opbygning + Protect Texture Beskyt tekstur + Blend Mode Blandingstilstand + Size Jitter Størrelsesjitter + Minimum Diameter Minimumsdiameter + Tilt Scale Tilt-skala + Angle Jitter Vinkeljitter + Roundness Jitter Rundhedsjitter + Minimum Roundness Minimumsrundhed + Flip X Jitter Vend X-jitter + Flip Y Jitter Vend Y-jitter + Brush Projection Penselprojektion + Control: Kontrol: + Off: the speed of the pointer sets the spacing Fra: markørens hastighed bestemmer afstanden + Fade steps Udtoningstrin + Pen Pressure Pennetryk + Pen Tilt Pennehældning + Stylus Wheel Stylushjul + Initial Direction of Stroke Startretning for strøg + Direction of Stroke Strøgretning + Opacity Jitter Opacitetsjitter + Flow Jitter Gennemstrømningsjitter + Humidity Fugtighed + Load Indlæs + Mix Blanding + Auto Clean Brush Rengør penslen automatisk + Wet Våd + Current Brush Load Aktuel penselfyldning + Load Brush Fyld pensel + Clean Brush Rengør pensel + Load the brush after each stroke Fyld penslen efter hvert strøg + Clean the brush after each stroke Rengør penslen efter hvert strøg + Rate Hastighed + Airbrush: keeps depositing paint while the pointer rests. Airbrush: bliver ved med at afsætte maling, mens markøren hviler. + Pulled String Mode Trukket tråd-tilstand + Catch-up on Stroke End Indhent ved strøgets afslutning + Adjust for Zoom Justér for zoom + Apply Per Tip Anvend pr. penselspids + Hardness: Hårdhed: + Spacing: Afstand: + Size: Størrelse: + Flip X Vend X + Flip Y Vend Y + Brush Tip Penselspids + Diameter: Diameter: + New Layer from Brush Nyt lag fra pensel + Rename Brush… Omdøb pensel… + Delete Brush Slet pensel + Import Brushes… Importér pensler… + Import Brushes… (.abr or image) Importér pensler… (.abr eller billede) + Import Brush from Image… Importér pensel fra billede… + Gradient Preset Options Indstillinger for gradientforudindstilling + Import .GRD Importér .GRD + Choose a gradient file (.grd). Vælg en gradientfil (.grd). + Brush Name Penselnavn + Brush Stroke Penselstrøg + New Brush Preset… Ny penselforudindstilling… + Capture Brush Size in Preset Gem penselstørrelse i forudindstillingen + Include Tool Settings Medtag værktøjsindstillinger + Include Color Medtag farve + Enter a name for the brush preset Angiv et navn for penselforudindstillingen + A brush preset with this name already exists Der findes allerede en penselforudindstilling med dette navn + Brush Preset Options Indstillinger for penselforudindstilling + New Brush Ny pensel + Create new brush from these settings Opret ny pensel ud fra disse indstillinger + Update the current brush with these settings Opdatér den aktuelle pensel med disse indstillinger + Reset the brush to the defaults Nulstil penslen til standardindstillingerne + No brush presets Ingen penselforudindstillinger + Override Pressure Tilsidesæt tryk + Override Tilt Tilsidesæt hældning + Override Tilt X Tilsidesæt hældning X + Override Tilt Y Tilsidesæt hældning Y + Override Rotation Tilsidesæt rotation + Apply the pen's tilt and rotation to the tip shape Anvend pennens hældning og rotation på spidsens form + +# Tool presets + Preset Type Forudindstillingstype + Preset Manager… Forudindstillingsadministrator… + Rename Tool Preset… Omdøb værktøjsforudindstilling… + Delete Tool Preset Slet værktøjsforudindstilling + New Tool Preset Ny værktøjsforudindstilling + Create new tool preset Opret ny værktøjsforudindstilling + Current Tool Only Kun aktuelt værktøj + No tool presets. Ingen værktøjsforudindstillinger. + No presets for the current tool. Ingen forudindstillinger for det aktuelle værktøj. + No presets of this type. Ingen forudindstillinger af denne type. + Preset: Forudindstilling: + Preset Forudindstilling + Presets: Forudindstillinger: + +# Paragraph and character panels + Font Size: Skriftstørrelse: + Font size Skriftstørrelse + Type: Type: + Style Stil + Style: Normal Stil: Normal + Tracking Spatiering + Kerning Knibning + Leading Linjeafstand + Auto-Leading Automatisk linjeafstand + Scale Horizontally Skalér vandret + Scale Vertically Skalér lodret + Baseline Shift Grundlinjeforskydning + Language Sprog + Anti-aliasing method Antialiasingmetode + Sharp Skarp + Crisp Klar + Strong Stærk + Smooth Blød + Mac LCD Mac LCD + Mac Sharp Mac skarp + Mac Crisp Mac klar + Mac Strong Mac stærk + Mac Smooth Mac blød + Faux Bold Falsk fed + Faux Italic Falsk kursiv + All Caps Versaler + Small Caps Små versaler + Superscript Hævet skrift + Subscript Sænket skrift + Underline Understreget + Strikethrough Gennemstreget + Oldstyle Medievalcifre + Ligature Ligatur + Alternative Alternativ + Hyphenate Orddeling + Roman Roman + Italic Kursiv + Bold Fed + Bold Italic Fed kursiv + Light Lys + Medium Mellem + Black Sort + Thin Tynd + Ultra Light Ultralet + Extra Light Ekstra let + Semi Bold Halvfed + Extra Bold Ekstra fed + Ultra Bold Ultrafed + Condensed Smal + Left align text Venstrejustér tekst + Center text Centrér tekst + Right align text Højrejustér tekst + Justify last left Justér med sidste linje til venstre + Justify last centered Justér med sidste linje centreret + Justify last right Justér med sidste linje til højre + Justify all Justér alle linjer + Indent Left Indryk venstre + Indent Right Indryk højre + First Line Indent Indrykning af første linje + Space Before Mellemrum før + Space After Mellemrum efter + Hanging Punctuation Hængende tegnsætning + Roman Hanging Punctuation Romersk hængende tegnsætning + Avoid Widows/Orphans Undgå enlige linjer + Compose Komponér + Adobe Single-line Composer Adobe-enkeltlinjeopsætter + Adobe Every-line Composer Adobe-flerlinjeopsætter + Justification Justering + Word Spacing Ordafstand + Letter Spacing Bogstavafstand + Glyph Scaling Glyfskalering + Auto-leading % Automatisk linjeafstand % + Single Word Justification Enkelordsjustering + Alignment Justering + Indent First Indryk første linje + Center Centrér + Justify Justér + +# Glyphs panel + Glyph Glyf + Select a type layer or click into text to insert glyphs Markér et tekstlag, eller klik i teksten for at indsætte glyffer + Entire Font Hele skrifttypen + Alternates for Current Selection Alternativer til den aktuelle markering + Recently Used Senest brugte + Digits Cifre + Symbols Symboler + Punctuation Tegnsætning + OpenType OpenType + Standard Ligatures Standardligaturer + Discretionary Ligatures Valgfrie ligaturer + Contextual Alternates Kontekstuelle alternativer + Swash Snirkler + Titling Alternates Titelalternativer + Ornaments Ornamenter + Fractions Brøker + Ordinals Ordenstal + East Asian Features Østasiatiske funktioner + Middle Eastern Features Mellemøstlige funktioner + +# Paths panel + Path Sti + Path: Sti: + Work Path Arbejdssti + Path to Illustrator… Sti til Illustrator… + Make Work Path Opret arbejdssti + Make Selection Opret markering + Fill Path Udfyld sti + Stroke Path Lav kontur på sti + Clipping Path Udklipningssti + New Path Ny sti + Duplicate Path Duplikér sti + Rename Path… Omdøb sti… + Delete Path Slet sti + Current Path Aktuel sti + Vector Mask from Current Path Vektormaske fra aktuel sti + Create new shape from the current path Opret ny form fra den aktuelle sti + Drag to move the active shape's path or the Work Path Træk for at flytte den aktive forms sti eller arbejdsstien + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: markér anker · Træk: flyt anker, håndtag eller segment · {shift}-klik: tilføj · {alt}-klik: hele delstien + Draw with the Pen tool (P) or make a work path from a selection. Tegn med penværktøjet (P), eller opret en arbejdssti ud fra en markering. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Kilde: ikke angivet ({key}-klik med kloningsstempel) + Clone source {n} ({key}-click with Clone Stamp to set) Kloningskilde {n} ({key}-klik med kloningsstempel for at angive) + {key}-click to set the source {key}-klik for at angive kilden + Option-click to define a source point to clone from Option-klik for at angive et kildepunkt at klone fra + Show Overlay Vis overlejring + Auto-Hide Skjul automatisk + W B + H H + Angle Vinkel + Scale Skalér + X X + Y Y + Offset Forskydning + Reset Transform Nulstil transformering + Rotate Rotér + Set horizontal skew Angiv vandret skævhed + Set vertical skew Angiv lodret skævhed + Flip Horizontal Vend vandret + Flip Vertical Vend lodret + Frame Sync Billedsynkronisering + Quick Sync Hurtig synkronisering + Enabled Aktiveret + +# Timeline panel + Create Video Timeline Opret videotidslinje + Open a document to use the timeline. Åbn et dokument for at bruge tidslinjen. + Video Layers Videolag + Video Video + Audio Lyd + Frame Duration Billedvarighed + 30 frames @ 30 fps 30 billeder @ 30 fps + Loop Sløjfe + Render Video… Gengiv video… + Split at Playhead Opdel ved afspilningsmarkør + Delete Timeline Slet tidslinje + Reload Frame Genindlæs billede + Restore Frame Gendan billede + Insert Blank Frame Indsæt tomt billede + Duplicate Frame Duplikér billede + Delete Frame Slet billede + Restore All Frames Gendan alle billeder + Flatten Frames Into Clips Fladgør billeder til klip + Flatten Frames Into Layers Fladgør billeder til lag + Convert Frames > Video Layers Konvertér billeder > videolag + New Video Layer Nyt videolag + New Video Group Ny videogruppe + Interpret Footage… Tolk materiale… + Replace Footage… Erstat materiale… + Show Altered Video Vis ændret video + Limit Playback Begræns afspilning + Enable Timeline Quick Config Aktivér hurtigopsætning af tidslinjen + Set Start Timecode… Angiv starttidskode… + Timecode Tidskode + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Aktuel tid + Out Ud + In Ind + Duration Varighed + Repeat Gentag + Play Once Afspil én gang + Video Cache Videocache + +# Info panel + Info Options… Infoindstillinger… + First Color Readout Første farveaflæsning + Second Color Readout Anden farveaflæsning + Actual Color Faktisk farve + Grayscale Slider Gråtoneskyder + Web Color Webfarve + HSB Color HSB-farve + Total Ink Samlet trykfarve + Opacity Mask Opacitetsmaske + Ruler Lineal + Current Tool Aktuelt værktøj + Document Size Dokumentstørrelse + Document Profile Dokumentprofil + Document Dimensions Dokumentdimensioner + Scratch Sizes Arbejdsdiskstørrelser + Efficiency Effektivitet + Timing Tidsmåling + Status Information Statusinformation + Saved Multichannel Gemt som multikanal + Mouse Coordinates Musekoordinater + Ruler Units Linealenheder + Hide Skjul + +# Layer Comps panel + Layer Comps to Files… Lagkompositioner til filer… + Apply next selected layer comp Anvend næste markerede lagkomposition + Apply previous selected layer comp Anvend forrige markerede lagkomposition + Update layer comp (visibility, position and appearance) Opdatér lagkomposition (synlighed, placering og udseende) + New Layer Comp Ny lagkomposition + Duplicate Layer Comp Duplikér lagkomposition + Delete layer comp Slet lagkomposition + Rename Layer Comp Omdøb lagkomposition + Create new layer comp Opret ny lagkomposition + Capture the visibility, position and style of every layer with + below, then switch between versions. Optag synlighed, placering og stil for hvert lag med + nedenfor, og skift derefter mellem versionerne. + Apply Layer Comp Anvend lagkomposition + Update Layer Comp Opdatér lagkomposition + Restore last viewed comp Gendan senest viste komposition + Last Document State Sidste dokumenttilstand + Visibility Synlighed + Position Placering + Appearance (Layer Style) Udseende (lagstil) + +# Analysis and variables + Analysis Analyse + Variables Variabler + Variable Data Sets… Variable datasæt… + Data Sets… Datasæt… + Data Sets Datasæt + Data Sets as Files… Datasæt som filer… + Apply Data Set… Anvend datasæt… + Define Definér + + Add variable + Tilføj variabel + Remove variable Fjern variabel + Define at least one variable first. Definér mindst én variabel først. + Open a document to define variables. Åbn et dokument for at definere variabler. + Variables bind a layer's visibility, text or pixels to a named data slot. Variabler binder et lags synlighed, tekst eller pixels til en navngiven dataplads. + Pixel Replacement Pixeludskiftning + Text Replacement Teksterstatning + Name Navn + Method Metode + Select All Layers Markér alle lag + Fill Udfyld + Keep proportions Bevar proportioner + Interpolation Interpolation + Author: Forfatter: + Author Forfatter + Name: Navn: + Caption Billedtekst + Caption Credits Billedtekstkrediteringer + Description Beskrivelse + Keywords Nøgleord + Copyright Info URL URL til ophavsretsinformation + Copyright Status Ophavsretsstatus + Copyright Notice Ophavsretsmeddelelse + Document Title Dokumenttitel + Copyrighted Ophavsretligt beskyttet + Public Domain Offentlig ejendom + Unknown Ukendt + Copyright and Contact Info Ophavsret og kontaktoplysninger + Credit Kreditering + Transmission Reference Transmissionsreference + Urgency Prioritet + High Høj + Normal Normal + Lossless Tabsfri + Low Lav + Category Kategori + Supplemental Categories Supplerende kategorier + Object Name / Title Objektnavn / titel + Date Created Oprettelsesdato + City By + Location Placering + Province / State Provins / delstat + Country Land + Byline Byline + Byline Title Byline-titel + Phone Telefon + Email E-mail + URLs URL'er + Last Modified Senest ændret + EXIF EXIF + GPS GPS + Camera Data Kameradata + Camera Model Kameramodel + Camera Make Kameramærke + EXIF Version EXIF-version + Exposure Program Eksponeringsprogram + Aperture Blænde + Shutter Speed Lukkerhastighed + Metering Mode Måletilstand + Light Source Lyskilde + Flash Blitz + Lens Info Objektivinfo + ISO Speed ISO-hastighed + Subject Distance Motivafstand + DateTime Original Oprindelig dato/tid + Create Date Oprettelsesdato + Modify Date Ændringsdato + Software Software + Raw Data Rådata + Version {version} Version {version} + Advanced Avanceret + Import… Importér… + +# Automate + Automate Automatér + Batch… Batch… + Create Droplet… Opret droplet… + Create Droplet Opret droplet + PDF Presentation… PDF-præsentation… + Create Multiple Picture Packages Opret flere billedpakker + Contact Sheet II Kontaktark II + Contact Sheet II… Kontaktark II… + Conditional Mode Change… Betinget tilstandsændring… + Fit Image… Tilpas billede… + Picture Package… Billedpakke… + Web Photo Gallery… Webfotogalleri… + Merge to HDR Pro… Flet til HDR Pro… + Load Files into Stack… Indlæs filer i stak… + Scripts Scripts + Script Events Manager… Scripthændelsesadministrator… + Image Processor… Billedprocessor… + Destination Destination + Choose… Vælg… + Select Folder… Vælg mappe… + Override Action "Save" Commands Tilsidesæt handlingens "Gem"-kommandoer + Compatibility Kompatibilitet + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Medtag ICC-profil + Error File Fejlfil + Stop For Errors Stop ved fejl + Log Errors To File Skriv fejl til fil + Errors Fejl + +# Color Settings dialog + Color Settings… Farveindstillinger… + Settings Indstillinger + Settings: Indstillinger: + Working Space Arbejdsfarverum + RGB: RGB: + CMYK: CMYK: + Gray: Grå: + Spot: Spot: + Advanced Controls Avancerede kontrolelementer + Desaturate Monitor Colors By Reducér skærmfarver med + Blend RGB Colors Using Gamma 1.0 Bland RGB-farver ved hjælp af gamma 1,0 + Profile Mismatches: Ask When Opening Profiluoverensstemmelser: Spørg ved åbning + Ask When Pasting Spørg ved indsættelse + Missing Profiles: Ask When Opening Manglende profiler: Spørg ved åbning + Embed profiles Indlejr profiler + Ask Before Saving Saved Embedded Spørg, før gemt indlejret gemmes + Coerce to sRGB Tving til sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + +# Themes + Theme Tema + Switch theme Skift tema + Next Theme Næste tema + Classic Theme Klassisk tema + Solarized dark Solarized mørk + Solarized Dark Theme Solarized mørkt tema + Breeze dark Breeze mørk + Breeze Dark Theme Breeze mørkt tema + Breeze light Breeze lys + Breeze Light Theme Breeze lyst tema + Soft dark Soft mørk + Soft light Soft lys + Soft Dark Theme Soft mørkt tema + Soft Light Theme Soft lyst tema + Studio Theme Studio-tema + Studio Light Theme Studio lyst tema + Adwaita Light Theme Adwaita lyst tema + Adwaita Dark Theme Adwaita mørkt tema + Pro Theme Pro-tema + Pro Medium Gray Theme Pro mellemgråt tema + Pro Light Gray Theme Pro lysegråt tema + Pro Light Theme Pro lyst tema + +# Workspaces + Workspace… Arbejdsområde… + Essentials Grundlæggende + New Workspace… Nyt arbejdsområde… + Delete Workspace… Slet arbejdsområde… + Reset Workspace Nulstil arbejdsområde + Reset Tool Nulstil værktøj + Reset All Tools Nulstil alle værktøjer + Reset all tools to their default settings? Nulstil alle værktøjer til deres standardindstillinger? + Lock Workspace Lås arbejdsområde + Photography Fotografering + Graphic and web Grafik og web + Painting Maling + Motion Bevægelse + Minimalistic Minimalistisk + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panelplaceringer gemmes i arbejdsområdet. Tastaturgenveje, menuer og værktøjslinje er valgfrie. + Keyboard Shortcuts Tastaturgenveje + Keyboard Shortcuts and Menus Tastaturgenveje og menuer + Menus Menuer + More panels Flere paneler + More documents Flere dokumenter + Collapse All Groups Fold alle grupper sammen + Expand All Groups Udvid alle grupper + Collapse Fold sammen + Expand Udvid + Collapse Panel Group Fold panelgruppe sammen + Expand Panel Group Udvid panelgruppe + Close Tab Group Luk fanegruppe + +# New Document dialog + PRESET DETAILS FORUDINDSTILLINGSDETALJER + BLANK DOCUMENT PRESETS ({n}) TOMME DOKUMENTFORUDINDSTILLINGER ({n}) + Create a new document or open an existing file. Opret et nyt dokument, eller åbn en eksisterende fil. + Drop an image or PSD anywhere to open it. Slip et billede eller en PSD hvor som helst for at åbne det. + Use File › Open, or paste a copied image with {paste}. Brug Filer › Åbn, eller indsæt et kopieret billede med {paste}. + Native file drag-and-drop is unavailable Indbygget træk-og-slip af filer er ikke tilgængeligt + 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}. Indbygget træk-og-slip af filer understøttes endnu ikke på Wayland. Brug Filer › Åbn, eller kopiér billedet i din filhåndtering, og indsæt det med {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` For at slippe filer skal du starte PhotoCraft under XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Eller sæt Præferencer › Ydeevne › Linux-visningsserver til X11: PhotoCraft starter derefter altid under XWayland. + Edit › Paste Redigér › Indsæt + Photo Foto + Print Udskriv + Art & Illustration Kunst og illustration + Mobile Mobil + Film & Video Film og video + Default PhotoCraft Size PhotoCraft-standardstørrelse + Clipboard Udklipsholder + Photo (2 × 3 in) Foto (2 × 3 tommer) + Photo (4 × 6 in) Foto (4 × 6 tommer) + Photo (5 × 7 in) Foto (5 × 7 tommer) + Photo (8 × 10 in) Foto (8 × 10 tommer) + Landscape Liggende + Portrait Stående + Recent Items Seneste elementer + Save Preset… Gem forudindstilling… + Delete Preset… Slet forudindstilling… + Preset Name: Navn på forudindstilling: + Width and Height Bredde og højde + Background Contents Baggrundsindhold + Custom background color Brugerdefineret baggrundsfarve + White Hvid + Background Color Baggrundsfarve + Transparent Gennemsigtig + Color Mode Farvetilstand + Depth Dybde + Color Profile Farveprofil + Create Opret + Document Dokument + Units Enheder + Pixels Pixels + Inches Tommer + Centimeters Centimeter + Millimeters Millimeter + Points Punkter + Picas Pica + Columns Kolonner + +# Placeholders, tooltips, status and keyboard hints + {name} options {name}-indstillinger + Align {what} Justér {what} + Untagged {mode} {mode} uden profil + Invalid {mode} profile Ugyldig {mode}-profil + Unnamed {mode} profile Unavngiven {mode}-profil + Constraints ({n}) Begrænsninger ({n}) + Count: {n} Antal: {n} + Click to add a point · drag off or {key}-click or Delete to remove Klik for at tilføje et punkt · træk væk, {key}-klik eller Delete for at fjerne + Click to apply, {key}-click to clear overrides, double-click for Style Options Klik for at anvende, {key}-klik for at fjerne tilsidesættelser, dobbeltklik for stilindstillinger + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: hjørne · Træk: glat · Klik på det første punkt: luk · {key} afslut · Esc annullér + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klik: hjørne · Træk: glat · {alt}-træk: knæk håndtaget · Klik eller træk i det første punkt: luk · {key} afslut · Esc annullér + Commit Perspective Warp ({key}) Anvend perspektivvridning ({key}) + Commit Puppet Warp ({key}) Anvend marionetvridning ({key}) + Commit any current edits ({key}) Anvend alle aktuelle redigeringer ({key}) + Commit transform ({key}) Anvend transformering ({key}) + Commit warp ({key}) Anvend vridning ({key}) + Cancel any current edits (Esc) Annullér alle aktuelle redigeringer (Esc) + Cancel transform (Esc) Annullér transformering (Esc) + Cancel warp (Esc) Annullér vridning (Esc) + Do you want to save the changes you made to “{name}” before closing? Vil du gemme de ændringer, du har foretaget i »{name}«, før du lukker? + Revert “{name}” to the last saved version? Your changes will be lost. Gendan »{name}« til den sidst gemte version? Dine ændringer går tabt. + {key} to subtract {key} for at trække fra + ↑↓ navigate {key} run esc close ↑↓ navigér {key} kør esc luk + Click to restore the current colour Klik for at gendanne den aktuelle farve + Click with the Note tool to add a note. Klik med noteværktøjet for at tilføje en note. + Drag a rectangle around the object Træk et rektangel omkring objektet + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Træk på billedet for at tilføje en begrænsning; Shift for vandret/lodret; højreklik på en begrænsning for at ændre eller slette den. + Drag the arrow to set the angle, the dots to set the roundness Træk i pilen for at indstille vinklen, og i prikkerne for at indstille rundheden + Create new pattern from the selection Opret nyt mønster fra markeringen + Create new gradient Opret ny gradient + Create new count group Opret ny tællegruppe + Search commands, tools and panels… Søg i kommandoer, værktøjer og paneler… + Search Brushes Søg i pensler + Search fonts Søg i skrifttyper + No matching commands Ingen kommandoer passer + No document Intet dokument + Pixel Layer Pixellag + Type Layer Tekstlag + Shape Layer Formlag + Group Layer Gruppelag + Fill Layer Udfyldningslag + Smart Object Layer Smartobjektlag + Adjustment Layer Justeringslag + {name} Properties Egenskaber for {name} + Move Tool Flytteværktøj + Could not use the move tool because the layer is locked. Kunne ikke bruge flytteværktøjet, fordi laget er låst. + Could not complete the Free Transform command because the layer is locked. Kunne ikke fuldføre kommandoen Fri transformering, fordi laget er låst. + Rectangular Marquee Tool Rektangulært markeringsværktøj + Elliptical Marquee Tool Elliptisk markeringsværktøj + Single Row Marquee Tool Markeringsværktøj til enkelt række + Single Column Marquee Tool Markeringsværktøj til enkelt kolonne + Brush Tool Penselværktøj + Pencil Tool Blyantværktøj + Eraser Tool Viskelæderværktøj + Background Eraser Tool Baggrundsviskelæderværktøj + Magic Eraser Tool Magisk viskelæderværktøj + Eyedropper Tool Pipetteværktøj + Note Tool Noteværktøj + Lasso Tool Lassoværktøj + Polygonal Lasso Tool Polygonlassoværktøj + Magic Wand Tool Tryllestavsværktøj + Crop Tool Beskæringsværktøj + Slice Tool Udsnitsværktøj + Slice Select Tool Udsnitsmarkeringsværktøj + Gradient Tool Gradientværktøj + Paint Bucket Tool Malerspandsværktøj + Horizontal Type Tool Vandret tekstværktøj + Horizontal Type Mask Tool Vandret tekstmaskeværktøj + Vertical Type Mask Tool Lodret tekstmaskeværktøj + Hand Tool Håndværktøj + Zoom Tool Zoomværktøj + Spot Healing Brush Tool Pletreparationspenselværktøj + Healing Brush Tool Reparationspenselværktøj + Clone Stamp Tool Kloningsstempelværktøj + History Brush Tool Historikpenselværktøj + Blur Tool Sløringsværktøj + Sharpen Tool Skærpeværktøj + Smudge Tool Udtværingsværktøj + Dodge Tool Lysningsværktøj + Burn Tool Mørkningsværktøj + Sponge Tool Svampværktøj + Quick Selection Tool Hurtigt markeringsværktøj + Object Selection Tool Objektmarkeringsværktøj + Pen Tool Penværktøj + Convert Point Tool Punktkonverteringsværktøj + Path Selection Tool Stimarkeringsværktøj + Direct Selection Tool Direkte markeringsværktøj + Add Anchor Point Tool Værktøjet Tilføj ankerpunkt + Delete Anchor Point Tool Værktøjet Slet ankerpunkt + Click a segment to add an anchor point · {alt}-click an anchor to delete it Klik på et segment for at tilføje et ankerpunkt · {alt}-klik på et anker for at slette det + Click an anchor point to delete it · {alt}-click a segment to add one Klik på et ankerpunkt for at slette det · {alt}-klik på et segment for at tilføje et + Rectangle Tool Rektangelværktøj + Ellipse Tool Ellipseværktøj + Triangle Tool Trekantværktøj + Polygon Tool Polygonværktøj + Line Tool Linjeværktøj + Custom Shape Tool Brugerdefineret formværktøj + Pixel Pixel + Group Gruppe + Adjustment Justering + Smart Object Smartobjekt + Drag to move the active layer Træk for at flytte det aktive lag + Drag to pan · hold Space with any tool Træk for at panorere · hold mellemrumstasten nede med et hvilket som helst værktøj + Drag to draw a gradient Træk for at tegne en gradient + Click to fill similar colours Klik for at udfylde lignende farver + Click to add text Klik for at tilføje tekst + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Træk (lasso) eller klik på punkter (polygonalt) · {add} tilføj · {sub} træk fra + 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 Træk en beskæringsramme · træk inde i den for at flytte · kanter ændrer størrelse ({ratio}-forhold, {centre} centreret) · Mellemrumstasten flytter, mens du tegner · piletaster skubber · X bytter orientering · {commit} bekræfter · Esc annullerer + {mode} Color · {bits} bit {mode}-farve · {bits} bit + Drag to move the layer Træk for at flytte laget + Fit Screen Tilpas til skærm + Fill Screen Fyld skærm + Scrubby Zoom Zoom ved træk + Auto Erase Automatisk sletning + Color Adaptation Farvetilpasning + Preserve Transparency Bevar gennemsigtighed + Reverse Omvendt + Align with layer Justér med laget + Join us on Discord Mød os på Discord + PhotoCraft website PhotoCraft-websted + ArtCraft ArtCraft + GitHub GitHub + No character style Ingen tegnstil + No author Ingen forfatter + Not in Dictionary: Ikke i ordbogen: + Suggestions: Forslag: + Spell check complete. Stavekontrol fuldført. + Check All Layers Kontrollér alle lag + Select a type layer to edit its character and paragraph settings. Markér et tekstlag for at redigere dets tegn- og afsnitsindstillinger. + Select text to apply a character style Markér tekst for at anvende en tegnstil + Redefine style by current selection Omdefinér stil ud fra aktuel markering + Duplicate style Duplikér stil + Delete style Slet stil + Style Options Stilindstillinger + Style Options… Stilindstillinger… + Close Style Options Luk stilindstillinger + Appearance Udseende + Text Position: Tekstplacering: + Text color Tekstfarve + Uses the foreground and background colours. Bruger forgrundsfarven og baggrundsfarven. + Answer the unsaved-changes prompt first Besvar først dialogen om ikke-gemte ændringer + Unsaved changes Ugemte ændringer + Don't Save Gem ikke + No Nej + Yes Ja + These settings aren't available in PhotoCraft yet. Disse indstillinger er endnu ikke tilgængelige i PhotoCraft. + Shortcut removed. Genvej fjernet. + All shortcuts reset to PhotoCraft defaults. Alle genveje er nulstillet til PhotoCrafts standardindstillinger. + Add Shortcut Tilføj genvej + Delete Shortcut Slet genvej + Press keys… Tryk på taster… + shortcut genvej + command or shortcut kommando eller genvej + current aktuel + image file path sti til billedfil + modified ændret + name navn + new ny + Go to Layer Gå til lag + Open a document to select a colour range. Åbn et dokument for at markere et farveområde. + Saved Gemt + Reveal in Finder Vis i Finder + Show in Explorer Vis i Stifinder + Show in Folder Vis i mappe + Before (Y) Før (Y) + Save Settings… Gem indstillinger… + Load Settings… Indlæs indstillinger… + Save these settings as a Camera Raw .xmp file Gem disse indstillinger som en Camera Raw .xmp-fil + Load settings from a Camera Raw .xmp file Indlæs indstillinger fra en Camera Raw .xmp-fil + Loaded Camera Raw settings from {file} Indlæste Camera Raw-indstillinger fra {file} + Loaded Camera Raw settings from {file} ({n} not supported) Indlæste Camera Raw-indstillinger fra {file} ({n} understøttes ikke) + After (X) Efter (X) + Original Original + Current Aktuel + {added}, {removed} subtracted {added}, {removed} fratrukket + Filter: Filter: + +# Transform / warp overlays + Free Transform Fri transformering + Free Transform a Copy Fri transformering af en kopi + Transform Transformér + Transform Selection Transformér markering + Switch between free transform and warp modes Skift mellem fri transformering og vridning + Perspective Perspektiv + Skew Skævvrid + Distort Forvræng + Rotate: Rotér: + Flip Vend + Warp: Vridning: + Warp Vridning + Bend: Bøjning: + Reference point location Referencepunktets placering + Toggle reference point Slå referencepunkt til/fra + Free Fri + Arc Bue + Arch Hvælving + Bulge Bulning + Shell Lower Nedre skal + Shell Upper Øvre skal + Flag Flag + Fish Fisk + Wave Bølge + Rise Hævning + Fisheye Fiskeøje + Inflate Opblæs + Twist Snoning + Squeeze Sammenpresning + Corner radius Hjørneradius + Split Warp Crosswise Opdel vridning på tværs + Split Warp Horizontally Opdel vridning vandret + Split Warp Vertically Opdel vridning lodret + Remove Warp Split Fjern vridningsopdeling + Puppet Warp Marionetvridning + Puppet Warp is not active Marionetvridning er ikke aktiv + Perspective Warp Perspektivvridning + Perspective Warp is not active Perspektivvridning er ikke aktiv + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivvridning: tegn planer langs billedets perspektiv, og skift derefter til Vridning + Remove Planes Fjern planer + Edit Pins Redigér nåle + Add Pins Tilføj nåle + Delete Pins Slet nåle + Remove All Pins Fjern alle nåle + Remove Tool Fjernelsesværktøj + Pin Depth: Nåledybde: + Bring the selected pin forward Flyt den markerede nål fremad + Send the selected pin backward Flyt den markerede nål bagud + Rotate Auto Rotér automatisk + Rotate Fixed Rotér med fast vinkel + Adaptive Wide Angle isn't open Adaptiv vidvinkel er ikke åben + Liquify is not open Gør flydende er ikke åben + +# Vanishing Point + Create Plane Opret plan + Edit Plane Redigér plan + Select Plane Markér plan + Measure Mål + Heal Reparér + Off Fra + Symmetry Off Ingen symmetri + Heal Off Reparation fra + Heal On Reparation til + Heal Auto Reparation automatisk + Blend Blanding + Brush Size Penselstørrelse + Heal Color Reparationsfarve + +# Misc dialog strings + Mask Maske + Channel Kanal + Rectangle Rektangel + Subtract Fratræk + Multiply Multiplicer + Divide Dividér + Result Resultat + New Document Nyt dokument + New Document With Mask Nyt dokument med maske + New Layer With Mask Nyt lag med maske + New Layer Nyt lag + Preset Name Navn på forudindstilling + No saved presets yet. Der er endnu ingen gemte forudindstillinger. + New Channel Ny kanal + Selection Markering + Save Selection Gem markering + Load Selection Indlæs markering + Duplicate Duplikér + Fast Hurtig + Accurate Nøjagtig + Add to Føj til + Subtract from Træk fra + Intersect with Skær med + +# Blend modes + Pass Through Passér igennem + Dissolve Opløst + Darken Mørkere + Color Burn Farvebrænding + Linear Burn Lineær brænding + Darker Color Mørkere farve + Lighten Lysere + Screen Skærm + Color Dodge Farvelysning + Linear Dodge (Add) Lineær lysning (addition) + Lighter Color Lysere farve + Overlay Overlejring + Soft Light Blødt lys + Hard Light Hårdt lys + Vivid Light Levende lys + Linear Light Lineært lys + Pin Light Nålelys + Hard Mix Hård blanding + Difference Differens + Exclusion Udelukkelse + Luminosity Luminans + +# Saved views and other bits + Capture Comp Gem komposition + No Layer Comps Ingen lagkompositioner + +# Plurals +@plural {n} item|{n} items {n} element|{n} elementer +@plural {n} Layer|{n} Layers {n} lag|{n} lag +@plural {n} layer|{n} layers {n} lag|{n} lag +@plural Group · {n} layer|Group · {n} layers Gruppe · {n} lag|Gruppe · {n} lag +@plural {n} sample|{n} samples {n} prøve|{n} prøver + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… Indstillinger for 32-bit-forhåndsvisning… + 90° Clockwise 90° med uret + 90° Counter Clockwise 90° mod uret + AI Integrations… AI-integrationer… + Again Igen + Transform Again on a Copy Transformér igen på en kopi + Anamorphic 2:1 (2) Anamorfisk 2:1 (2) + Arbitrary… Vilkårlig vinkel… + Artboard Guides Tavlehjælpelinjer + Artboard from Group… Tavle fra gruppe… + Artboard from Layers… Tavle fra lag… + Artboards to Files… Tavler til filer… + Artboards to PDF… Tavler til PDF… + Artboard… Tavle… + Border… Kant… + Bottom Edges Nederste kanter + Browse… Gennemse… + Source Files Kildefiler + Use: Brug: + Files Filer + Folder Mappe + Add Open Files Tilføj åbne filer + Sort By Name Sortér efter navn + Attempt to Automatically Align Source Images Forsøg at justere kildebilleder automatisk + Create Smart Object after Loading Layers Opret smartobjekt efter indlæsning af lag + Canvas Guides Lærredshjælpelinjer + Character Styles Panel Tegnstilpanel + Clear Canvas Guides Ryd lærredshjælpelinjer + Clear Selected Artboard Guides Ryd hjælpelinjer på markeret tavle + Color Blindness — Deuteranopia-type Farveblindhed — deuteranopi-type + Color Blindness — Protanopia-type Farveblindhed — protanopi-type + Color Table… Farvetabel… + Combine Shapes Kombinér former + Contract… Formindsk… + Convert to Embedded Konvertér til indlejret + Convert to Layers Konvertér til lag + Convert to Linked… Konvertér til sammenkædet… + Count Optælling + Create Layer Opret lag + Custom Pixel Aspect Ratio… Brugerdefineret pixelsideforhold… + Cycle Screen Mode Skift skærmtilstand + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC Widescreen (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL Widescreen (1.46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + De-Interlace… Fjern interlace… + Default Features Standardfunktioner + Delete All Empty Layers Slet alle tomme lag + Desaturate Fjern mætning + Displace… Forskyd… + Distribute Fordel + Document Bounds Dokumentgrænser + Edit in Quick Mask Mode Redigér i hurtigmasketilstand + Enhanced Controls… Udvidede kontrolelementer… + Entropy Entropi + Exclude Overlapping Shapes Udeluk overlappende former + Exit Afslut + Expand… Udvid… + Extra Large Ekstra stor + File Info… Filoplysninger… + Fit Artboard on Screen Tilpas tavle til skærmen + Fit Layer(s) on Screen Tilpas lag til skærmen + Flatten All Layer Effects Fladgør alle lageffekter + Flatten All Masks Fladgør alle masker + Flatten Image Fladgør billede + Float All in Windows Frigør alle i vinduer + Float in Window Frigør i vindue + Frame from Layers Billede fra lag + From Transparency Fra gennemsigtighed + Gamut Warning Gamutadvarsel + Global Light… Globalt lys… + Gradient… Gradient… + Graphic and Web Grafik og web + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + Hidden Layers Skjulte lag + Hide All Effects Skjul alle effekter + Horizontal Centers Vandrette midtpunkter + Huge Enorm + Indexed Color… Indekserede farver… + Integrations… Integrationer… + Intersect Shape Areas Skær formområder + Kurtosis Kurtosis + Large Stor + Layer Edges Lagkanter + Left Edges Venstre kanter + Lens Blur… Objektivsløring… + Lighting Effects… Lyseffekter… + Link Layers Sammenkæd lag + Lock Layers… Lås lag… + Mean Middelværdi + Merge Down Flet ned + Merge Group Flet gruppe + Merge Layers Flet lag + Merge Shape Components Flet formkomponenter + Merge Visible Flet synlige + Stamp Visible Stempl synlige + Select Layer Above Markér lag ovenover + Select Layer Below Markér lag nedenunder + Select Top Layer Markér øverste lag + Select Bottom Layer Markér nederste lag + Add Layer Above to Selection Føj lag ovenover til markeringen + Add Layer Below to Selection Føj lag nedenunder til markeringen + Stamp Down Stempl ned + Mesh Net + Middle Eastern & South Asian Composer Mellemøstlig og sydasiatisk opsætter + Modifier Keys Modifikationstaster + Modify Modificér + NTSC Colors NTSC-farver + Notes… Noter… + Other Andre + Other… Andet… + Paragraph Styles Panel Afsnitsstilpanel + Paths to Illustrator… Stier til Illustrator… + Pattern Preview Mønsterforhåndsvisning + Pattern… Mønster… + Pixel Art Pixelkunst + Pixel Art Preview Pixelkunstforhåndsvisning + Pixel Grid Pixelgitter + Pixelate Pixelér + Place Scale Marker… Placér skalamarkør… + Plug-ins Plug-ins + Print One Copy Udskriv én kopi + Print… Udskriv… + Range Spændvidde + Raw Defaults… Raw-standarder… + Relink to File… Sammenkæd med fil igen… + Relight… Belys igen… + Render Gengiv + Replace All Missing Fonts Erstat alle manglende skrifttyper + Resolve Missing Fonts… Håndtér manglende skrifttyper… + Reveal Selection Vis markering + Right Edges Højre kanter + Selection Edges Markeringskanter + Shapes Former + Show Extras Options… Indstillinger for ekstraer… + Skewness Skævhed + Small Lille + Smart Guides Smarte hjælpelinjer + Smart Objects Smartobjekter + Smooth… Udjævn… + Solid Color… Ensfarvet… + Square Kvadratisk + Stack Mode Staktilstand + Choose Stack Mode: Vælg staktilstand: + Standard Deviation Standardafvigelse + Statistics… Statistik… + Stylistic Alternates Stilistiske alternativer + Stylize Stilisér + Subtract Front Shape Træk forreste form fra + Summation Sum + Target Path Målsti + Tile All Horizontally Alle vandret side om side + Tile All Vertically Alle lodret side om side + Toggle Last State Skift til seneste tilstand + Toolbar… Værktøjslinje… + Tools… Værktøjer… + Top Edges Øverste kanter + Unite Shapes Foren former + Update All Modified Content Opdatér alt ændret indhold + Update All Text Layers Opdatér alle tekstlag + Update Modified Content Opdatér ændret indhold + Variance Varians + Vertical Centers Lodrette midtpunkter + Video Frames to Layers… Videobilleder til lag… + WIA Support… WIA-support… + Working CMY Plate Arbejds-CMY-plade + +# tl! literals (third pass) + Absolute Absolut + Action Handling + Adaptive Adaptiv + Add Stop Tilføj stop + Add disk Tilføj disk + Align and Distribute Justér og fordel + All Slices Alle udsnit + All User Slices Alle brugerudsnit + Alt Alt + Artboard Tavle + Artboard Background Color Baggrundsfarve for tavle + Auto Hide Skjul automatisk + Automatically level near-horizontal line segments Ret automatisk næsten vandrette linjesegmenter ud + Automatically straighten and level Ret ud og gør vandret automatisk + Automatically straighten near-vertical line segments Ret automatisk næsten lodrette linjesegmenter ud + Balance Balance + Baseline Grundlinje + Bicubic Bikubisk + Bicubic (smooth gradients) Bikubisk (glatte gradienter) + Automatic Automatisk + Bicubic Smoother (enlargement) Bikubisk glattere (forstørrelse) + Bicubic Sharper (reduction) Bikubisk skarpere (formindskelse) + Bilinear Bilineær + Black, White Sort, hvid + Blue, Red, Yellow Blå, rød, gul + Bottom Nederst + Brightness Jitter Lysstyrkejitter + Brush Reconstruct Options Indstillinger for penselrekonstruktion + Cache Level: Cacheniveau: + Canvas Lærred + Capture Optag + Center Crop Marks Centrerede beskæringsmærker + Centre the gradient (offset 0, 0) Centrér gradienten (forskydning 0, 0) + Change Screen Mode (F) Skift skærmtilstand (F) + Change To: Ændr til: + Clear All Ryd alt + Clear Override Fjern tilsidesættelse + Clear Samples Ryd prøver + Cmd Cmd + Color Grading Farvegradering + Color Management Farvestyring + Color Priority Farveprioritet + Color Range preview Forhåndsvisning af farveområde + Colorize Farvelæg + Constant Konstant + Content-Aware Indholdsbevidst + Convert to sRGB Konvertér til sRGB + Copies Kopier + Copper Kobber + Corner Crop Marks Hjørnebeskæringsmærker + Correction Korrektion + Count Jitter Antalsjitter + Create Texture Opret tekstur + Create a new count group Opret en ny optællingsgruppe + Crop Factor Beskæringsfaktor + Ctrl Ctrl + Custom Shapes Brugerdefinerede former + Cyan Cyan + Cyan · Red Cyan · Rød + Darks Mørke + Default printer Standardprinter + Default restored. Standarden er gendannet. + Delete Group Slet gruppe + Delete count group Slet optællingsgruppe + Delete effect layer Slet effektlag + Delete note Slet note + Delete tool preset Slet værktøjsforudindstilling + Depth Jitter Dybdejitter + Discontiguous Usammenhængende + Discord Discord + Display P3 Display P3 + Display Text Vis tekst + Divide… Opdel… + Do you want to save the changes you made to “{name}” before quitting? Vil du gemme de ændringer, du har foretaget i »{name}«, før du afslutter? + Edit Toolbar… Redigér værktøjslinje… + Edit in Quick Mask Mode (Q) Redigér i hurtigmasketilstand (Q) + Edit in Standard Mode (Q) Redigér i standardtilstand (Q) + Embedded Profile Mismatch Indlejret profil matcher ikke + Enhance Edge Forbedr kant + Exact Nøjagtig + Expansion: Udvidelse: + Exposure and Gamma Eksponering og gamma + Factor: Faktor: + Fewer Points Færre punkter + File Settings Filindstillinger + Finger Painting Fingermaling + Fit in View Tilpas til visning + Fixed Fast + Foreground to Background Forgrund til baggrund + Foreground/Background Jitter Forgrund/baggrund-jitter + Format Format + Frame from Layers… Billede fra lag… + Full Spherical Fuld sfærisk + Gamma Correction Gammakorrektion + Gamma: Gamma: + Global Global + Grain Korn + Graphics Grafik + Gray Grå + Heart Hjerte + Hide Auto Slices Skjul automatiske udsnit + Highlight Compression Højlyskomprimering + Highlight Priority Højlysprioritet + Highlights Højlys + Home Startside + Hue Jitter Farvetonejitter + Image Size: Billedstørrelse: + Imported Importeret + Input: Input: + Interlaced Interlace + Invert All Invertér alle + Label Size Etiketstørrelse + Labels Etiketter + Lanczos (sharp) Lanczos (skarp) + Level Niveau + Lights Lyse + Limits: Grænser: + Link the mask to the layer Sammenkæd masken med laget + Localized Color Clusters Lokaliserede farveklynger + Luminance Luminans + Magenta Magenta + Magenta · Green Magenta · Grøn + Marker Size Mærkestørrelse + Mask Options Maskeindstillinger + Masking Maskering + Master Master + Mean: Middelværdi: + Median: Median: + Mesh Size Netstørrelse + Metadata Metadata + Midpoint Midtpunkt + Midtones Mellemtoner + Midtones from: Mellemtoner fra: + Midtones to: Mellemtoner til: + Minimum Depth Minimumsdybde + Mix Jitter Blandingsjitter + Mode: Tilstand: + Monochrome Monokrom + More Points Flere punkter + Move Group Down Flyt gruppe ned + Move Group Up Flyt gruppe op + Move effect layer down Flyt effektlag ned + Move effect layer up Flyt effektlag op + Move to Flyt til + Nearest Neighbor Nærmeste nabo + Nearest Neighbor (hard edges) Nærmeste nabo (hårde kanter) + New effect layer Nyt effektlag + Next note Næste note + No Color Management Ingen farvestyring + Optimized Optimeret + Output Levels: Outputniveauer: + Output: Output: + Paint Overlay Malingsoverlejring + Pattern Size Mønsterstørrelse + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — et native open source-billedredigeringsprogram skrevet i Rust. + Position and Size Placering og størrelse + Preserve Details Bevar detaljer + Preserve Luminosity Bevar luminans + Previous note Forrige note + Printer Printer + Printer Setup Printeropsætning + Printing Marks Trykmærker + Progressive Progressiv + Promote Forfrem + Protect Detail Beskyt detaljer + Protect Tones Beskyt toner + Proximity Match Nærhedsmatchning + Purity Renhed + Quality Kvalitet + Recent Seneste + Reconstruct Rekonstruér + Refine Finjustér + Registration Marks Registreringsmærker + Regular Normal + Remove stop Fjern stop + Rename Group… Omdøb gruppe… + Rename Layer… Omdøb lag… + Restore All Gendan alle + Restrictive (Web) Restriktiv (web) + Rigid Stiv + Roughness Ruhed + Roundness Rundhed + Rulers & Grids Linealer og gitter + Sample: foreground colour Prøve: forgrundsfarve + Sampling: Background Swatch Prøvetagning: baggrundsfarveprøve + Sampling: Continuous Prøvetagning: fortløbende + Sampling: Once Prøvetagning: én gang + Saturate Øg mætning + Saturation Jitter Mætningsjitter + Save HTML and Images Gem HTML og billeder + Save as PDF Gem som PDF + Scale to Fit Media Skalér til medie + Scatter Spredning + Scratch disks Arbejdsdiske + Selective Selektiv + Shadows Skygger + Shape: Form: + Sharpening Skærpning + Shift Shift + Show All Menu Items Vis alle menupunkter + Show Backdrop Vis baggrund + Show Mask Vis maske + Show Mesh Vis net + Show or hide the Notes panel Vis eller skjul panelet Noter + Show the layer mask as an overlay Vis lagmasken som en overlejring + Show: Vis: + Sides Sider + Sides: Sider: + Slice Options… Udsnitsindstillinger… + Slices From Guides Udsnit fra hjælpelinjer + Slices are locked (View › Lock Slices) Udsnit er låst (Vis › Lås udsnit) + Split: Opdel: + Star ratio Stjerneforhold + Std Dev: Std.afv.: + Straighten Ret ud + Straighten Layer Ret lag ud + Strength: Styrke: + Stroke Kontur + Stroke Catch-up Strøgindhentning + Stroke: Kontur: + Location: Placering: + Inside Indvendig + Outside Udvendig + Tetrahedral Tetraedrisk + Texture Each Tip Tekstur pr. spids +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradienteditor + Gradient picker Gradientvælger + Save As Preset Gem som forudindstilling + The gradient the Gradient tool paints with Den gradient, som gradientværktøjet maler med + Adds the current gradient to the Gradients panel Føjer den aktuelle gradient til gradientpanelet + Gradient stops Gradientstop + Click to edit the gradient Klik for at redigere gradienten + Tilt X Hældning X + Tilt Y Hældning Y + Toggle count group visibility Slå synlighed for optællingsgruppe til/fra + Toggle text orientation Skift tekstretning + Tone: Tone: + Toolbar Værktøjslinje + Top Øverst + Total: I alt: + Transparency Gennemsigtighed + Trilinear Trilineær + Unlink the mask from the layer Fjern sammenkædningen mellem masken og laget + View Options Visningsindstillinger + Violet, Orange Lilla, orange + Visible Synlig + Weight Vægt + Weight: Vægt: + Wetness Jitter Vådhedsjitter + Whites Hvide + Yellow Gul + Yellow · Blue Gul · Blå + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe rgb + Allow agent control Tillad agentstyring + Allow scripts to connect Tillad scripts at oprette forbindelse + Always Altid + Always create smart objects when placing Opret altid smartobjekter ved placering + Animated zoom Animeret zoom + Apply auto tone Anvend automatisk tone + Ask Spørg + Ask before saving layered tiff Spørg, før TIFF med lag gemmes + Autosave Gem automatisk + Autosave minutes Minutter mellem automatisk gemning + Beep when done Bip ved fuldførelse + Bicubic automatic Bikubisk automatisk + Bicubic sharper Bikubisk skarpere + Bicubic smoother Bikubisk glattere + Bit depth Bitdybde + Brush picker Penselvælger + Brush preview color Forhåndsvisningsfarve for pensel + CPU (no GPU acceleration) CPU (ingen GPU-acceleration) + Cache levels Cacheniveauer + Cache tile size Flisestørrelse for cache + Camera Raw Defaults Camera Raw-standarder + Canvas border Lærredskant + Show canvas dots Vis lærredsprikker + Clip thumbnails to layer bounds Klip miniaturer til laggrænser + Clip Thumbnails to Layer Bounds Klip miniaturer til laggrænser + Canvas color Lærredsfarve + Canvas custom color Brugerdefineret lærredsfarve + Classic Klassisk + Color picker Farvevælger + Color space Farverum + Column width Kolonnebredde + Concise Kortfattet + Control port Kontrolport + Convert to srgb Konvertér til srgb + Cursors Markører + Custom dark Brugerdefineret mørk + Custom light Brugerdefineret lys + Dark Mørk + Dark gray Mørkegrå + Dashed lines Stiplede linjer + Detailed Detaljeret + DirectX 12 DirectX 12 + Disks Diske + Display p3 Display p3 + Dots Prikker + Double click layer mask launches select and mask Dobbeltklik på lagmaske åbner Markér og maskér + Drop shadow Slagskygge + Dynamic color sliders Dynamiske farveskydere + East asian Østasiatisk + Effect cache mb Effektcache mb + Enable flick panning Aktivér panorering med svirp + Enable floating document window docking Aktivér dokning af flydende dokumentvinduer + Enable narrow options bar Aktivér smal indstillingslinje + Enhanced Controls Udvidede kontrolelementer + Erase Visk ud + Export clipboard Eksportér udklipsholder + Extra large Ekstra stor + File Handling Filhåndtering + File path Filsti + Fill new type layers with placeholder Udfyld nye tekstlag med pladsholder + Font preview Skrifttypeforhåndsvisning + Full size tip Spids i fuld størrelse + GPU backend GPU-backend + Linux display server Linux-visningsserver + X11 X11 + Gamut warning color Farve til gamutadvarsel + Gamut warning opacity Opacitet for gamutadvarsel + General Generelt + Enable Generator Aktivér Generator + Gif Gif + Glossy Blank + Grid color Gitterfarve + Grid colors Gitterfarver + Grid size Gitterstørrelse + Grid style Gitterstil + Grid unit Gitterenhed + Gridline every Gitterlinje hver + Guide color Hjælpelinjefarve + Guide style Hjælpelinjestil + Guides, Grid & Slices Hjælpelinjer, gitter og udsnit + Gutter Spaltemellemrum + History Log Historiklog + History states Historiktilstande + Ignore exif profile tag Ignorér exif-profiltag + Image interpolation Billedinterpolation + Image previews Billedforhåndsvisninger + Integrations Integrationer + Interface Grænseflade + Jpeg quality Jpeg-kvalitet + Jpg Jpg + Large tabs Store faner + Legacy compositing Ældre sammensætning + Light gray Lysegrå + Line Linje + Lines Linjer + Lowercase extension Filendelse med små bogstaver + Maximize psd compatibility Maksimér psd-kompatibilitet + Medium gray Mellemgrå + Memory usage mb Hukommelsesforbrug mb + Low resolution previews Forhåndsvisninger i lav opløsning + Metal Metal + Missing glyph protection Beskyttelse mod manglende glyffer + Modern Moderne + Nearest neighbor Nærmeste nabo + Never Aldrig + Normal tip Normal spids + Open as smart object Åbn som smartobjekt + Open in camera raw Åbn i Camera Raw + Open documents as tabs Åbn dokumenter som faner + OpenGL OpenGL + Orange Orange + Overscroll Rul forbi kanten + Performance Ydeevne + Png Png + Point size Punktstørrelse + PostScript (72 points/inch) PostScript (72 punkter/tomme) + Precise Præcis + Preserve details Bevar detaljer + Print resolution Udskriftsopløsning + Pro Pro + Pro light Pro lys + Pro light gray Pro lysegrå + Pro medium Pro mellem + Pro photo Pro photo + Purple Lilla + Quick export format Format til hurtig eksport + Quick export location Placering for hurtig eksport + Recent file count Antal seneste filer + LUT folder LUT-mappe + Recent files Seneste filer + Recent fonts Seneste skrifttyper + Recover on launch Gendan ved opstart + Remember workspace changes Husk ændringer i arbejdsområde + Resize image during place Ændr billedstørrelse ved placering + Right click with painting tools Højreklik med maleværktøjer + Rotate view with trackpad Rotér visning med pegeplade + Same folder Samme mappe + Save in background Gem i baggrunden + Scratch Disks Arbejdsdiske + Screen resolution Skærmopløsning + Scrubby slider acceleration Acceleration af skydere ved træk + Sessions only Kun sessionen + Sharpen for Skærp til + Show channels in color Vis kanaler i farve + Show crosshair in brush tip Vis trådkors i penselspids + Show extension panels Vis udvidelsespaneler + Show font names in english Vis skrifttypenavne på engelsk + Show menu colors Vis menufarver + Show only crosshair while painting Vis kun trådkors under maling + Show slice numbers Vis udsnitsnumre + Show tooltips Vis værktøjstips + Enable animations Aktivér animationer + Show highlight on rollover Fremhæv ved pegning + Show bounding box when dragging layer Vis afgrænsningsramme, når et lag trækkes + System title bar Systemtitellinje + Show transformation values Vis transformeringsværdier + Slice color Udsnitsfarve + Smart guide color Farve til smarte hjælpelinjer + Smart quotes Smarte anførselstegn + Snap vector tools and transforms to pixel grid Fastgør vektorværktøjer og transformeringer til pixelgitter + Srgb Srgb + Standard Standard + Studio Studio + Studio light Studio lys + Adwaita Adwaita + Adwaita dark Adwaita mørk + Subdivisions Underopdelinger + System System + Text engine Tekstmotor + Text file Tekstfil + Tiny Meget lille + Touch gestures Berøringsbevægelser + Traditional (72.27 points/inch) Traditionel (72,27 punkter/tomme) + Transparency & Gamut Gennemsigtighed og gamut + Type units Tekstenheder + UI font size UI-skriftstørrelse + UI scale UI-skala + Units & Rulers Enheder og linealer + Use additional plugins folder Brug ekstra plug-in-mappe + Use esc to commit Brug Esc til at bekræfte + Use gpu Brug GPU + Use legacy free transform Brug ældre fri transformering + Use shift key for tool switch Brug Shift-tasten til at skifte værktøj + Use tablet pressure Brug tablettryk + Vary round brush hardness on hud Variér hårdhed for rund pensel i HUD + Vulkan Vulkan + Webp Webp + World ready World ready + Zoom clicked point to center Zoom, så det klikkede punkt kommer i midten + Zoom resizes windows Zoom ændrer vinduesstørrelse + Zoom with scroll wheel Zoom med rullehjul + Zoom with trackpad pinch Zoom med knibning på pegeplade + Alt-click to define a source point to clone from Alt-klik for at definere et kildepunkt at klone fra + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Ingen sti at lave en markering ud fra: tegn én med penværktøjet, eller markér én i panelet Stier. + Cancel opening Annullér åbning + Cancelled opening {name} Annullerede åbning af {name} + Damaged Photoshop document Beskadiget Photoshop-dokument + The document “{name}” may be damaged. Dokumentet »{name}« er muligvis beskadiget. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Nogle lag, pixels eller masker kunne ikke læses. Vil du fortsætte med at åbne det? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Hvis du fortsætter, kan du ved at gemme oven i originalen miste data, der ikke kunne læses, for altid. Gem en kopi for at bevare originalen. + Continue Fortsæt + The document “{name}” does not have an embedded {mode} profile. Dokumentet »{name}« har ikke en indlejret {mode}-profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumentet »{name}« har en indlejret farveprofil, der ikke stemmer overens med det aktuelle {mode}-arbejdsfarverum. + Working: {working} Arbejdsfarverum: {working} + Developing {name} Fremkalder {name} + Cancelled {label} Annullerede {label} + Esc to cancel Esc for at annullere + Opened {name} Åbnede {name} + Opening {name} Åbner {name} + Saved {name} Gemte {name} + Saving {name} Gemmer {name} + Saving {percent} Gemmer {percent} + A save to {name} is still running; try again when it finishes Der gemmes stadig til {name}; prøv igen, når det er afsluttet + {label} is writing the file and can no longer be cancelled {label} skriver filen og kan ikke længere annulleres + Opening {name}… Åbner {name}… + Opening… Åbner… + Reading… Læser… + Working… Arbejder… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Gitter: +# Rendering mode and recovery + Rendering Mode Gengivelsestilstand + Automatic (recommended) Automatisk (anbefalet) + CPU / Compatibility CPU / kompatibilitet + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatisk bruger GPU-acceleration, når det er muligt, og falder tilbage til CPU-gengivelse ved fejl. + Switched to CPU rendering Skiftede til CPU-gengivelse + GPU acceleration could not continue. Your documents are unchanged. GPU-accelerationen kunne ikke fortsætte. Dine dokumenter er uændrede. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft bruger CPU-billedsammensætteren. Vinduet bruger muligvis stadig din grafikadapter. + Retry GPU requires a restart. Save your work first. Genstart kræves for at prøve GPU igen. Gem dit arbejde først. + Retry GPU tries the graphics processor again now. Prøv GPU igen forsøger at bruge grafikprocessoren igen nu. + GPU acceleration resumed. GPU-accelerationen er genoptaget. + Keep Using CPU Fortsæt med CPU + Retry GPU Prøv GPU igen + GPU Backend GPU-backend + Details Detaljer + GPU GPU + Rendering mode Gengivelsestilstand + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Fryselasso: træk for at fryse et område, Alt-træk for at tø det op (L) + Shadow Clipping Warning (U) Advarsel om skyggeklipning (U) + Highlight Clipping Warning (O) Advarsel om højlysklipning (O) + Show Lab Color Readouts Vis Lab-farveaflæsninger + Show Vectorscope Vis vektorskop + Show Red at 3 o'clock Vis rødt kl. 3 + Show Skin Tone Indicator Vis indikator for hudtone + Tone Histogram Tonehistogram + Show Selected Region Vis markeret område +cameraRaw Light Lys +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Turkis +cameraRaw Amount Mængde +cameraRaw Blacks Sorte +cameraRaw Vibrance Livskraft +cameraRaw Color Grading Farvegradering +cameraRaw Lights Lyse +cameraRaw Darks Mørke +cameraRaw Global Global +cameraRaw Red Rød +cameraRaw Orange Orange +cameraRaw Yellow Gul +cameraRaw Green Grøn +cameraRaw Aqua Turkis +cameraRaw Blue Blå +cameraRaw Purple Lilla +cameraRaw Magenta Magenta +cameraRaw Hue Farvetone +cameraRaw Blending Blanding +cameraRaw Luminance Detail Luminansdetaljer +cameraRaw Color Detail Farvedetaljer +cameraRaw Roughness Ruhed + Patch Tool Lappeværktøj + Patch: Lap: + Lasso around an area, then drag the selection Tegn med lassoen rundt om et område, og træk derefter markeringen + Transform On Drop Transformér ved slip + Scale or rotate the moved area, then press {key} Skalér eller rotér det flyttede område, og tryk derefter på {key} + Now drag the selection onto another area Træk nu markeringen hen til et andet område + Edit Type Redigér tekst + + New Style… Ny stil… + Style {name} saved Stilen {name} er gemt + + Create new style (from the selected type) Opret ny stil (fra den markerede tekst) + Create new style from the selected layer Opret ny stil ud fra det markerede lag + + Make Default Gør til standard + Reset to Default Nulstil til standard + Full-resolution preview unavailable Forhåndsvisning i fuld opløsning er ikke tilgængelig + No patterns Ingen mønstre + Enable {name} Aktivér {name} + Hold Alt while drawing for straight segments Hold Alt nede, mens du tegner, for at få lige segmenter + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Træk eller klik polygonpunkter · hold Alt nede under lassoering for lige segmenter · {add} tilføj · {sub} før du tegner trækker fra + Extend Udvid + Structure: Struktur: + Content-Aware Move Tool Indholdsbevidst flytteværktøj + TIFF Options TIFF-indstillinger + Targa Options Targa-indstillinger + JPEG Options JPEG-indstillinger + Image Options Billedindstillinger + Format Options Formatindstillinger + Baseline ("Standard") Grundlæggende ("Standard") + Baseline Optimized Grundlæggende optimeret + 24 bits/pixel 24 bit/pixel + 32 bits/pixel 32 bit/pixel + Including layers will increase file size. Medtagning af lag øger filstørrelsen. + Discard Layers and Save a Copy Kassér lag, og gem en kopi + Don't show again Vis ikke igen + Save failed: {error} Kunne ikke gemme: {error} + Save cancelled Gemning annulleret + Couldn't place {name}: {error} Kunne ikke placere {name}: {error} + Magnetic Lasso Tool Magnetisk lassoværktøj + Frequency Frekvens + Use tablet pressure to change pen width Brug tablettryk til at ændre penbredden + +# About ▸ Contributors / Models (credits) + About Om + Contributors Bidragydere + Models Modeller + Sort Sortér + Username Brugernavn + Display name Visningsnavn + Real name Rigtigt navn + Reverse the order Vend rækkefølgen om + Grab bag Lidt af hvert + Table Tabel + Name (A–Z) Navn (A–Z) + Merged PRs Flettede PR'er + Commits Commits + Lines added Tilføjede linjer + Lines deleted Slettede linjer + Line delta Linjedelta + Binary assets added Tilføjede binære filer + Binary assets removed Fjernede binære filer + First commit Første commit + First Første + Last commit Seneste commit + Last Seneste + Contributors: {contributors} · Commits: {commits} Bidragydere: {contributors} · Commits: {commits} + No contributor data was built into this copy. Der blev ikke bygget bidragyderdata ind i denne kopi. + No model credits were built into this copy. Der blev ikke bygget modelkreditter ind i denne kopi. + Company Virksomhed + Version Version + % of all commits % af alle commits + Lines +/− Linjer +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR'er, {commits} commits, +{added} / −{deleted} linjer (Δ {delta}), +{binAdded} / −{binDeleted} binære filer, {first} – {last} + +# Swatches panel + Create new swatch Opret ny farveprøve + New Swatch… Ny farveprøve… + New Swatch Group… Ny farveprøvegruppe… + Import Swatches… Importér farveprøver… + Replace Swatches… Erstat farveprøver… + Export Swatches… Eksportér farveprøver… + Export Swatches for Exchange… Eksportér farveprøver til udveksling… + Reset Swatches Nulstil farveprøver + Add to Swatches Føj til farveprøver +# Layer Style > Blending Options (Advanced Blending) + Blend If: Bland hvis: + Channels: Kanaler: + Knockout Udstansning + Apply At Canvas Bounds Anvend ved lærredets grænser + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Farvevælger (tekstfarve) + Color Picker ({name}) Farvevælger ({name}) + Color Picker (Fill Color) Farvevælger (udfyldningsfarve) + Fill color Udfyldningsfarve + Color Picker (Stroke Color) Farvevælger (konturfarve) + Stroke color Konturfarve + Pick screen color Vælg skærmfarve + Sample a pixel anywhere on the screen Tag en prøve af en pixel hvor som helst på skærmen + Click to pick · Esc to cancel Klik for at vælge · Esc for at annullere + Pick a screen pixel or press Esc to cancel Vælg en pixel på skærmen, eller tryk på Esc for at annullere + Scroll to zoom Rul for at zoome + Drag to pan · Scroll to zoom · Double-click to fit Træk for at panorere · Rul for at zoome · Dobbeltklik for at tilpasse + Show/Hide All Panels Vis/skjul alle paneler + Show/Hide Panels Vis/skjul paneler + Could not open Kunne ikke åbne + {path}: the path is not valid Unicode; rename the file and open it again. {path}: stien er ikke gyldig Unicode; omdøb filen, og åbn den igen. + +# Fill dialog, layer style contours and Image Size + Contents: Indhold: + Custom Pattern: Brugerdefineret mønster: + Foreground Color Forgrundsfarve + Color… Farve… + 50% Gray 50 % grå + {size} (was {old}) {size} (var {old}) + Cone Kegle + Cone (Inverted) Kegle (omvendt) + Domed Kuppelformet + Domed (Inverted) Kuppelformet (omvendt) + Diagonal (Descending) Diagonal (faldende) + Contour Editor Kontureditor + Anti-aliased Antialiaseret + Corner Hjørne + Gloss Contour Glanskontur + Contour Kontur + Cone - Inverted Kegle - omvendt + Cove - Deep Bugt - dyb + Cove - Shallow Bugt - lav + Gaussian Gaussisk + Half Round Halvrund + Ring Ring + Ring - Double Ring - dobbelt + Rolling Slope - Descending Bølgende skråning - faldende + Rounded Steps Afrundede trin + Sawtooth 1 Savtand 1 + Linear Lineær + + Red Eye Tool Værktøj til røde øjne + Pupil Size Pupilstørrelse + Darken Amount Mørkningsgrad + Click a red pupil to neutralize it Klik på en rød pupil for at neutralisere den + open source open source + …and {n} more …og {n} flere + + Eyedroppers: Pipetter: + Set Black Point Angiv sortpunkt + Set Neutral Gray Point Angiv neutralt gråpunkt + Set White Point Angiv hvidpunkt + Unavailable in this color mode Ikke tilgængelig i denne farvetilstand + Selection Preview: Forhåndsvisning af markering: + Black Matte Sort mat + White Matte Hvid mat + Quick Mask Hurtig maske + +# Layer colour labels. + Seafoam Havskum + Indigo Indigo + Fuchsia Fuchsia + Violet Violet + Large document: redrawing on the CPU Stort dokument: gentegner på CPU'en + Redrawing Gentegner + This redraw took {seconds} s on the CPU. Denne gentegning tog {seconds} s på CPU'en. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Redigeringer, der ændrer hele dokumentet, f.eks. et lags blandingsstilstand, opacitet eller synlighed, gentegner det hele. + The GPU compositor wasn't used: {reason} GPU-sammensætteren blev ikke brugt: {reason} +fontWeight Thin Tynd +fontWeight ExtraLight Ekstra let +fontWeight Light Let +fontWeight Regular Normal +fontWeight Medium Medium +fontWeight SemiBold Halvfed +fontWeight Bold Fed +fontWeight ExtraBold Ekstra fed +fontWeight Black Sort +fontWeight Italic Kursiv + +# Pattern Stamp tool + Pattern Stamp Tool Mønsterstempelværktøj + Impressionist Impressionistisk + Rotate View Tool Værktøj til at rotere visningen + Reset View Nulstil visning + Drag around the centre to rotate the view · Shift constrains to 15° Træk rundt om midtpunktet for at rotere visningen · Shift låser til 15° + +# Painting symmetry presets. + Dual Axis Dobbelt akse + Diagonal Diagonal + Transform Symmetry Transformeringssymmetri + Set painting symmetry options Angiv indstillinger for symmetri ved maling + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprøve + 3 by 3 Average 3 x 3 gennemsnit + 5 by 5 Average 5 x 5 gennemsnit + 11 by 11 Average 11 x 11 gennemsnit + 31 by 31 Average 31 x 31 gennemsnit + 51 by 51 Average 51 x 51 gennemsnit + 101 by 101 Average 101 x 101 gennemsnit + Sample Size: Prøvestørrelse: + All Layers No Adjustments Alle lag uden justeringer + Current & Below No Adjustments Aktuelt og nedenunder uden justeringer + Show Sampling Ring Vis prøvetagningsring + Click to sample the foreground colour · {key}-click for background Klik for at tage prøve af forgrundsfarven · {key}-klik for baggrundsfarven + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Uden for gamut ved udskrivning: klik for at få den nærmeste udskrivbare farve + Not a web colour: click for the nearest web colour Ikke en webfarve: klik for at få den nærmeste webfarve + Color to Alpha… Farve til alfa… + Transparency Threshold Grænseværdi for gennemsigtighed + Opacity Threshold Grænseværdi for opacitet + PhotoCraft — Screen Color Picker PhotoCraft — Skærmfarvevælger + + Rasterize svg groups deeper than Rasterisér svg-grupper dybere end + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 deaktiverer denne grænseværdi; SVG-grupper, der er indlejret dybere end 100 niveauer, rasteriseres altid. + Rule of Thirds Tredjedelsregel + Triangle Trekant + Golden Ratio Gyldne snit + Golden Spiral Gylden spiral + Auto Show Overlay Vis overlejring automatisk + Always Show Overlay Vis altid overlejring + Never Show Overlay Vis aldrig overlejring + Cycle Overlay Skift mellem overlejringer + Cycle Orientation Skift orientering + Set the overlay options for the Crop tool Angiv overlejringsindstillingerne for beskæringsværktøjet + 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-klik gik til skrivebordet, som flytter vinduer med Alt-træk. Sæt tasten til vinduesflytning til Super, eller lås Alt fast i Vindue › Modifikationstaster. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Farvevælger (lagstilsfarve) + Auto Hide Notifications Skjul meddelelser automatisk + Notification Duration (seconds) Varighed af meddelelser (sekunder) + Bundle Arrow-Key Nudges Saml piletasteskub + Nudge Bundle Pause (ms) Pause mellem piletasteskub (ms) + Appearance Mode Udseendetilstand + Dark Theme Mørkt tema + Light Theme Lyst tema + Pro Medium Pro Medium + Studio Light Studio Light + Next Appearance Mode Næste udseendetilstand + Sync with system Synkronisér med systemet + Active Aktiv + Color Picker (Solid Color) Farvevælger (ensfarvet) + Straighten the image by drawing a line on it Ret billedet ud ved at tegne en linje på det + Set additional Crop options Angiv flere beskæringsindstillinger + Show Cropped Area Vis beskåret område + Enable Crop Shield Aktivér beskæringsafskærmning + Match Canvas Afstem med lærred + Auto Adjust Opacity Justér opacitet automatisk + Crop Shield color Farve til beskæringsafskærmning + Color Picker (Crop Shield Color) Farvevælger (farve til beskæringsafskærmning) + From Center Fra midten + Link radii Sammenkæd radier + Star Ratio: Stjerneforhold: + Create Rectangle Opret rektangel + Create Ellipse Opret ellipse + Create Triangle Opret trekant + Create Polygon Opret polygon + Pressure curve Trykkurve + Click to add a point, right-click to remove Klik for at tilføje et punkt, højreklik for at fjerne + Soft Blød + Hard Hård + Some brush presets could not be loaded Nogle penselforudindstillinger kunne ikke indlæses + Retry saving presets Prøv at gemme forudindstillinger igen + Brush preset changes are not saved: {error} Ændringer i penselforudindstillinger gemmes ikke: {error} + Keep this tab open to retain your brushes. Hold denne fane åben for at bevare dine pensler. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Browserlager er utilgængeligt; penslerne gælder kun for sessionen. Hold denne fane åben for at bevare dem. + Saving brush presets… Keep this tab open until saving finishes. Gemmer penselforudindstillinger… Hold denne fane åben, indtil gemningen er afsluttet. + Reflect Refleksion + Glow Glød + Negation Negation + XOR XOR + Color Burn (Paint.NET) Farvebrænding (Paint.NET) + Color Dodge (Paint.NET) Farvelysning (Paint.NET) + Stroke Options Konturindstillinger + Solid Ubrudt + Dashed Stiplet + Dotted Prikket + Caps Linjeender + Butt / Flat Stump / Flad + Corners Hjørner + Miter Spids + Round Rund + Bevel Affaset + Miter limit Spidsgrænse + Dashed line Stiplet linje + Dash Streg + Gap Mellemrum + Dash / gap lengths × stroke width Længder for streg/mellemrum × konturbredde + Add pair Tilføj par + Remove pair Fjern par + Dash offset Forskydning af stregmønster + Open paths use centered strokes Åbne stier bruger centreret kontur + Use finite lengths and a positive pattern total Brug endelige længder og en positiv samlet mønsterværdi + No subject found — try Object Selection or the Lasso Fandt intet motiv — prøv Objektmarkering eller lassoen + Quick Selection found nothing there Hurtig markering fandt intet der + Object Selection found nothing there — try a tighter rectangle Objektmarkering fandt intet der — prøv et strammere rektangel + No subject found — try a rectangle around the object Fandt intet motiv — prøv et rektangel rundt om objektet + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Slet lag »{name}«? + Delete {count} layers? {names} Slet {count} lag? {names} + Delete layer? Slet lag? + The document is closed Dokumentet er lukket + The document or selected layers changed. Please request deletion again. Dokumentet eller de markerede lag er ændret. Prøv at slette igen. + Missing delete command Slettekommando mangler + Invalid delete command Ugyldig slettekommando + Layer deleted, but confirmation preference could not be saved: {error} Lag slettet, men bekræftelsesindstillingen kunne ikke gemmes: {error} + Add a layer style Tilføj en lagstil + Add a mask (from the selection; {key} inverts) Tilføj en maske (fra markeringen; {key} inverterer) + Add an adjustment Tilføj en justering + Add to selection ({key}) Tilføj til markering ({key}) + Adjustment visible Justering synlig + Align and distribute Justér og fordel + Align horizontal centers Justér vandrette midtpunkter + Align left edges Justér venstre kanter + Align right edges Justér højre kanter + Always use pressure for opacity Brug altid tryk til opacitet + Always use pressure for size Brug altid tryk til størrelse + Angle Gradient Vinkelgradient + Auto-Select: Markér automatisk: + Brush Preset picker Penselforudindstillingsvælger + Cancel current crop operation (Esc) Annullér aktuel beskæringshandling (Esc) + Click the first point or press {key} to close · Esc cancels Klik på det første punkt, eller tryk på {key} for at lukke · Esc annullerer + Click the first point or press {key} to close · {del} removes a point · Esc cancels Klik på det første punkt, eller tryk på {key} for at lukke · {del} fjerner et punkt · Esc annullerer + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klik, og bevæg dig derefter langs en kant · klik for at fastgøre et punkt · {alt}-klik for et lige segment · dobbeltklik for at lukke + Clip to layer below Klip til laget nedenunder + Color & Swatches Farve og farveprøver + Commit current crop operation ({key}) Anvend aktuel beskæringshandling ({key}) + Create a new group Opret en ny gruppe + Create a new layer Opret et nyt lag + Create new document from current state Opret nyt dokument fra den aktuelle tilstand + Create new fill or adjustment layer Opret nyt udfyldnings- eller justeringslag + Create new snapshot Opret nyt øjebliksbillede + From: Fra: + Full Document Hele dokumentet + Merged Layers Flettede lag + Delete Cropped Pixels Slet beskårne pixels + Delete current state Slet aktuel tilstand + Delete layer Slet lag + Diamond Gradient Diamantgradient + Doc: {w} × {h} px Dok.: {w} × {h} px + Enable airbrush-style build-up effects Aktivér ophobningseffekter i airbrush-stil + Filter for adjustment layers Filtrér efter justeringslag + Filter for pixel layers Filtrér efter pixellag + Filter for shape layers Filtrér efter formlag + Filter for smart objects Filtrér efter smartobjekter + Filter for type layers Filtrér efter tekstlag + Fixed Ratio Fast forhold + Fixed Size Fast størrelse + Follows only edges this close to the pointer ([ and ] change it) Følger kun kanter, der er så tæt på markøren ([ og ] ændrer det) + Hide Properties Skjul egenskaber + Higher values follow only edges that contrast sharply with their surroundings Højere værdier følger kun kanter, der står i skarp kontrast til omgivelserne + How often fastening points are placed by themselves Hvor ofte fastgøringspunkter placeres af sig selv + Hue/Saturation Farvetone/mætning + Intersect with selection ({key}) Skær med markering ({key}) + Kind Type + Linear Gradient Lineær gradient + Link layers Sammenkæd lag + Lock Lås + Lock all Lås alt + Lock image pixels Lås billedpixels + Lock layer Lås lag + Lock position Lås placering + Lock transparent pixels Lås gennemsigtige pixels + New selection Ny markering + No properties Ingen egenskaber + Pressure for Opacity Tryk for opacitet + Pressure for Size Tryk for størrelse + Prevent auto-nesting in and out of Artboards and Frames Forhindr automatisk indlejring i og ud af tavler og rammer + Radial Gradient Radial gradient + Reflected Gradient Reflekteret gradient + Reset to defaults Nulstil til standard + Set additional smoothing options Angiv yderligere udjævningsindstillinger + Show Transform Controls Vis transformeringskontroller + Subtract from selection ({key}) Træk fra markering ({key}) + Swaps height and width Ombytter højde og bredde + Tolerance Tolerance + Add vector mask Tilføj vektormaske + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klik for at zoome ind · {key}-klik for at zoome ud · træk til højre/venstre for at zoome ind/ud + Appearance Settings… Indstillinger for udseende… + Add to favorites Føj til favoritter + Remove from favorites Fjern fra favoritter +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Mærke +cameraData Model Model +cameraData Date Taken Optagelsesdato +cameraData Exposure Eksponering +cameraData F-Stop Blænde +cameraData ISO Speed ISO-hastighed +cameraData Focal Length Brændvidde +cameraData Focal Length (35 mm) Brændvidde (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktivér panel + Change Panel Group Skift panelgruppe + Change Panel Layout Skift panellayout + Close Panel Luk panel + Dock Panel Dok panel + Float Panel Frigør panel + Move Panel Flyt panel + Group with Gruppér med + Panel menu Panelmenu + Resize panel window Ændr størrelse på panelvindue + Resize panels Ændr størrelse på paneler + Use Classic Mode Brug klassisk tilstand + Auto Center Preview Centrér forhåndsvisning automatisk + Next Document Næste dokument + Previous Document Forrige dokument + W x H x Resolution B x H x opløsning + Front Image Forreste billede + Frame Tool Rammeværktøj + Create a new rectangular frame Opret en ny rektangulær ramme + Create a new elliptical frame Opret en ny elliptisk ramme + Include in playback Medtag i afspilning + Perspective Crop Tool Perspektivbeskæringsværktøj + Show Grid Vis gitter + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Træk en ramme, eller klik fire hjørner · træk hjørnerne hen på kanterne af en skæv form · {commit} bekræfter · Esc annullerer + Artboard Tool Tavleværktøj + Make Portrait Gør stående + Make Landscape Gør liggende + Add New Artboard Tilføj ny tavle + Freeform Pen Tool Frihåndspenværktøj + Curvature Pen Tool Krumningspenværktøj + Curve Fit: Kurvetilpasning: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Træk: tegn frihånd · {alt}-klik: lige segment · Slip på startpunktet: luk + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: glat punkt · Dobbeltklik: hjørne · Træk et punkt: flyt · Klik på det første punkt: luk · {key} afslut + Scale: Skalering: + Snap to Origin Fastgør til origo + Link with Layer Sammenkæd med lag + Rasterize Layer Style Rasterisér lagstil + Search settings… Søg i indstillinger… + No matching settings Ingen indstillinger passer +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Prøvetagningspenselværktøj + Sampling Area Overlay Overlejring af prøvetagningsområde + Show Sampling Area Vis prøvetagningsområde + Indicates Angiver + Sampling Area Prøvetagningsområde + Excluded Area Udeladt område + Sampling Area Options Indstillinger for prøvetagningsområde + Rectangular Rektangulær + Fill Settings Udfyldningsindstillinger + Rotation Adaptation Rotationstilpasning + Very High Meget høj + Full Fuld + Mirror Spejl + Output Settings Outputindstillinger + Output To Output til + Duplicate Layer Duplikér lag + Contract Formindsk + + Animation Options Animationsindstillinger + Entire Document Hele dokumentet + Filter Options Filterindstillinger + Layer Bounds Laggrænser + Layers Panel Options Indstillinger for panelet Lag + Lock Layers Lås lag + New Artboard… Ny tavle… + New Group… Ny gruppe… + Prevent Auto-Nesting Forhindr automatisk indlejring + Show Layers Vis lag + Thumbnail Contents Miniatureindhold + Thumbnail Size Miniaturestørrelse + Use Default Masks on Fill Layers Brug standardmasker på udfyldningslag + Expand New Effects Udvid nye effekter + Add “copy” to Copied Layers and Groups Tilføj »kopi« til kopierede lag og grupper + Preview on canvas when reordering layers Forhåndsvis på lærredet, når lagenes rækkefølge ændres + Show layer mask badges Vis mærker for lagmasker + +# Smart Filter editors + Blending Options ({filter}) Blandingsindstillinger ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Skjul lagets effekter og smartfiltre ({key}-klik: alle lag) + Show the layer's effects and Smart Filters ({key}-click: all layers) Vis lagets effekter og smartfiltre ({key}-klik: alle lag) + Wait for the current job to finish. Vent, til det aktuelle job er færdigt. + The document or Smart Filter changed. Reopen its dialog. Dokumentet eller smartfilteret er ændret. Åbn dialogen igen. + Opacity must be between 0% and 100%. Opaciteten skal være mellem 0 % og 100 %. + Dissolve is approximated: it blends without Photoshop's dither. Opløst er tilnærmet: det blander uden Photoshops dither. + Loading fonts… Indlæser skrifttyper… + Response curve Responskurve + Tool Værktøj + Simulate Pressure Simulér tryk + Stroke Path… Lav kontur på sti… + Background Eraser Baggrundsviskelæder + Healing Brush Reparationspensel + Spot Healing Brush Pletreparationspensel + Smudge Udtvær + Sponge Svamp + Amount: Mængde: + Protect: Beskyt: + Protect skin tones Beskyt hudtoner + Edit points to modify the curve Redigér punkter for at ændre kurven + Draw to modify the curve Tegn for at ændre kurven +curves Smooth Udjævn + Smooth the curve values Udjævn kurveværdierne + Drag to draw the curve · {key}-click draws a straight line Træk for at tegne kurven · {key}-klik tegner en lige linje + Grid Size: Gitterstørrelse: + Vanishing Point: click four corners to create a plane Forsvindingspunkt: klik på fire hjørner for at oprette et plan + Vanishing Point is not active Forsvindingspunktet er ikke aktivt + Apply Vanishing Point ({key}) Anvend forsvindingspunkt ({key}) + Fix the red plane first: its corners must form a convex quad Ret det røde plan først: dets hjørner skal danne en konveks firkant + Alt-click a plane to set the clone source Alt-klik på et plan for at angive kloningskilden + Too many clone strokes: apply Vanishing Point first For mange kloningsstrøg: anvend forsvindingspunkt først +# Select and Mask view modes (#1754) + Onion Skin Løgskind + Marching Ants Marcherende myrer + On Black På sort + On White På hvidt + On Layers På lag + Show Edge Vis kant + Show Original Vis original + F cycles the views; hold X to see the image F skifter mellem visningerne; hold X nede for at se billedet +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Kantforfiningspensel (R) + Expand Detection Area Udvid detektionsområde + Restore Original Edge Gendan oprindelig kant + Paint over hair or fur on the image; Alt paints the other mode Mal over hår eller pels på billedet; Alt maler i den anden tilstand + Clear Brush Strokes Ryd penselstrøg + Show rulers Vis linealer + New Crop Preset… Ny beskæringsforudindstilling… + Delete Crop Preset… Slet beskæringsforudindstilling… + Select the crop presets to delete: Vælg de beskæringsforudindstillinger, der skal slettes: + Select the crop presets to delete Vælg de beskæringsforudindstillinger, der skal slettes + Enter a name for the crop preset Angiv et navn til beskæringsforudindstillingen + There is no crop size or ratio to save Der er ingen beskæringsstørrelse eller -forhold at gemme + Presentation Præsentation + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klik for at placere · Alt-klik eller træk uden for lærredet for at fjerne + No document open Intet åbent dokument + {n} of {max} sample points {n} af {max} prøvepunkter + +# History panel menu (#2966) + New Snapshot… Nyt øjebliksbillede… + History Options… Historikindstillinger… +# History panel › History Options… (#2966) + Allow Non-Linear History Tillad ikke-lineær historik + Make Layer Visibility Changes Undoable Gør ændringer af lagsynlighed fortrydelige +# Print dialog (#2697) + Emulsion Down Emulsion nedad + Functions Funktioner + Print Resolution: Udskriftsopløsning: + Use Previous Layer to Create Clipping Mask Brug forrige lag til at oprette udklipningsmaske + Fill with {mode}-neutral color (white) Udfyld med {mode}-neutral farve (hvid) + Fill with {mode}-neutral color (black) Udfyld med {mode}-neutral farve (sort) + Fill with {mode}-neutral color (50% gray) Udfyld med {mode}-neutral farve (50 % grå) + (No neutral color exists for {mode} mode.) (Der findes ingen neutral farve til {mode}-tilstanden.) + Apply mask to layer before removing? Anvend masken på laget, før den fjernes? + The layer or its mask changed. Drag the mask onto the Trash again. Laget eller dets maske er ændret. Træk masken hen på papirkurven igen. + Delete the vector mask? Slet vektormasken? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Eksporterede {n} farveprøve til {file}|Eksporterede {n} farveprøver til {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importerede {n} pensel fra {group}|Importerede {n} pensler fra {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importerede {n} farveprøve fra {file}|Importerede {n} farveprøver fra {file} +@plural Played {n} step|Played {n} steps Afspillede {n} trin|Afspillede {n} trin +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Opdaterede {n} sammenkædet smartobjekt, der er ændret på disken|Opdaterede {n} sammenkædede smartobjekter, der er ændret på disken +@plural Saved for Web: {n} file|Saved for Web: {n} files Gemt til web: {n} fil|Gemt til web: {n} filer +@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} lag, der er registreret af denne komposition, blev slettet. Opdatér kompositionen, eller fjern advarslen.|{n} lag, der er registreret af denne komposition, blev slettet. Opdatér kompositionen, eller fjern advarslen. +@plural {n} plane|{n} planes {n} plan|{n} planer +@plural {n} preset|{n} presets {n} forudindstilling|{n} forudindstillinger +@plural {n} sample point removed|{n} sample points removed {n} prøvepunkt fjernet|{n} prøvepunkter fjernet +@plural {n} slice|{n} slices {n} udsnit|{n} udsnit +@plural {n} step|{n} steps {n} trin|{n} trin +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} yderligere advarsel om penselforudindstillinger)|{warning} (+{n} yderligere advarsler om penselforudindstillinger) +@plural {what} with {n} warning|{what} with {n} warnings {what} med {n} advarsel|{what} med {n} advarsler +# View › Guides › New Guide… › Color + Light Blue Lyseblå + Light Red Lysrød + Medium Blue Mellemblå + Light Gray Lysegrå + Object Finder Objektfinder + No object found there — drag a rectangle around it Intet objekt fundet der — træk et rektangel omkring det + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Det aktive lag har ingen pixels her — slå Prøvetag alle lag til, eller markér et lag med pixels + Remove after each stroke Fjern efter hvert strøg + Apply the removal ({key}) Anvend fjernelsen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Strøg, der skal fjernes: {n} (Enter anvender, Esc annullerer) + Load Contours… Indlæs konturer… + Replace Contours… Erstat konturer… + Save Contours… Gem konturer… + Delete Contour Slet kontur + Loaded contours from {file} Indlæste konturer fra {file} + Saved contours to {file} Gemte konturer i {file} + Show home recent files Vis seneste filer på startsiden + Clear Brush Controls Ryd penselkontroller + Reset All Locked Settings Nulstil alle låste indstillinger + Copy Texture to Other Tools Kopiér tekstur til andre værktøjer + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Indlæs handlinger… + Save Actions… Gem handlinger… + New Action… Ny handling… + Start Recording Start optagelse + Stop Recording Stop optagelse + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Indlæste {n} handling fra {file}|Indlæste {n} handlinger fra {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} trin har ingen PhotoCraft-ækvivalent endnu|{n} trin har ingen PhotoCraft-ækvivalent endnu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Gemte {n} handling i {file}|Gemte {n} handlinger i {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} trin uden Photoshop-ækvivalent udeladt|{n} trin uden Photoshop-ækvivalent udeladt +@plural {n} action file loaded|{n} action files loaded {n} handlingsfil indlæst|{n} handlingsfiler indlæst + {set} from {file} replaced the one from {earlier} Sættet {set} fra {file} erstattede det fra {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fil kunne ikke indlæses: {errors}|{n} filer kunne ikke indlæses: {errors} + + File Naming Filnavngivning + Starting serial Startserienummer +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Indsæt stop… + Save Set… Gem sæt… + Message: Meddelelse: + Allow Continue Tillad fortsættelse + Show dialog during playback Vis dialog under afspilning + Select an action first Vælg en handling først + Action playback stopped Afspilning af handlingen er stoppet + Step {step} is waiting for its dialog Trin {step} venter på sin dialog +@plural {n} action|{n} actions {n} handling|{n} handlinger + Background (Default) Baggrund (standard) + Content-Aware Fill Indholdsbevidst udfyldning +# Batch / Image Processor + Include All Subfolders Medtag alle undermapper + Auto Add/Delete Tilføj/fjern automatisk + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Kryds ikke også Spejlbillede af i Udskriftsindstillinger…: de to spejlvendinger ophæver hinanden. + Print Settings… Udskriftsindstillinger… + Remember to turn off the printer's own colour management in Print Settings… Husk at slå printerens egen farvestyring fra i Udskriftsindstillinger… + Sent to {printer} Sendt til {printer} + set in Print Settings… angivet i Udskriftsindstillinger… + ICO is limited to 256 × 256 pixels ICO er begrænset til 256 × 256 pixels + Calibration Kalibrering + Red Primary Rød primærfarve + Green Primary Grøn primærfarve + Blue Primary Blå primærfarve + Write xmp sidecars Skriv xmp-sidefiler + Set the tone sliders from the image Indstil toneskyderne ud fra billedet + Settings from {name} Indstillinger fra {name} + Settings from {name}; {count} not supported: {names} Indstillinger fra {name}; {count} understøttes ikke: {names} + Could not write {name}: {error} Kunne ikke skrive {name}: {error} + Kept {name}: written by another application Beholdt {name}: skrevet af et andet program + Shadow Tint Skyggenuance +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maler ud fra kildetilstanden i panelet Historik + Sets the source for the history brush Angiver kilden til historikpenslen + +# Dynamic selection context menu and appearance favorites + Select Inverse Markér omvendt + Pro (Light) Pro (lys) + Pro (Light Gray) Pro (lysegrå) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Etiket + Date and Time Dato og klokkeslæt + Scale Units Skalaenheder + Scale Factor Skaleringsfaktor + Area Område + Perimeter Omkreds + Circularity Cirkularitet + Gray Value (Minimum) Gråværdi (minimum) + Gray Value (Maximum) Gråværdi (maksimum) + Gray Value (Mean) Gråværdi (gennemsnit) + Gray Value (Median) Gråværdi (median) + Integrated Density Integreret tæthed + Length Længde + Show Favorite Fonts Vis foretrukne skrifttyper + No favorite fonts yet: click the star beside a font to add it. Ingen foretrukne skrifttyper endnu: klik på stjernen ud for en skrifttype for at tilføje den. + Favorite fonts Foretrukne skrifttyper + Add to Favorites Føj til foretrukne + Remove from Favorites Fjern fra foretrukne + Show All Fonts Vis alle skrifttyper + All Classes Alle klasser + Blackletter Fraktur + Decorative Dekorativ + Font preview: Skrifttypeeksempel: + Handwritten Håndskrevet + Hide Font Classes Skjul skrifttypeklasser + Hide Styles Skjul snit + Monospace Fast bredde + No fonts match these filters. Ingen skrifttyper passer til disse filtre. + No similar fonts: the current font doesn't say what it looks like. Ingen lignende skrifttyper: den aktuelle skrifttype oplyser ikke, hvordan den ser ud. + OpenType font OpenType-skrifttype + Sans Serif Sans serif + Script Skriveskrift + Serif Serif + Show Font Classes Vis skrifttypeklasser + Show Similar Fonts Vis lignende skrifttyper + Show Styles Vis snit + Show Variable Fonts Vis variable skrifttyper + Slab Serif Egyptienne + TrueType font TrueType-skrifttype + Variable font Variabel skrifttype +# Bevel & Emboss shading controls. + Highlight Mode Højlystilstand + Highlight Color Højlysfarve + Highlight Opacity Højlysopacitet + Shadow Mode Skyggetilstand + Shadow Color Skyggefarve + Shadow Opacity Skyggeopacitet +# PhotoCraft release checks. + Check for Updates… Søg efter opdateringer… + Open the release page to choose an installer for your system. Åbn udgivelsessiden for at vælge et installationsprogram til dit system. + Automatically Check for Updates Søg automatisk efter opdateringer +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Brug den indlejrede profil ({profile}) + Convert to the working space ({profile}) Konvertér til arbejdsfarverummet ({profile}) + Remember this choice for future images Husk dette valg til fremtidige billeder + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Slå meddelelsen til igen via Spørg ved uoverensstemmelse under Redigér › Farveindstillinger. + Button Mode Knaptilstand + Button Mode On Knaptilstand slået til + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Gør brugerfladen mørkere + Lighten Interface Gør brugerfladen lysere +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Hård mejsel + Chisel Soft Blød mejsel + Open color-less EXR as ACEScg Åbn farveløs EXR som ACEScg + Do not append "copy" to filename when saving a copy Tilføj ikke »kopi« til filnavnet, når der gemmes en kopi + +# View › Guides › right-click a guide + Edit Selected Guides Redigér markerede hjælpelinjer + Clear Selected Guides Ryd markerede hjælpelinjer + Bicubic Automatic Bikubisk automatisk + Bicubic Smoother Bikubisk glattere + Bicubic Sharper Bikubisk skarpere + Pasting from other apps is unavailable Indsættelse fra andre programmer er ikke tilgængelig + 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 kan ikke få adgang til systemets udklipsholder i denne session. På Wayland skjuler en Flatpak-sandkasse den protokol, som billeder fra andre programmer læses igennem. + To paste from other apps, start PhotoCraft under XWayland: `{command}` For at indsætte fra andre programmer skal du starte PhotoCraft under XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Intet at indsætte: PhotoCraft kan ikke læse systemets udklipsholder her + Delete this set Slet dette sæt + New Set… Nyt sæt… + PhotoCraft Defaults PhotoCraft-standarder + Save a list of these shortcuts as a web page Gem en liste over disse genveje som en webside + Save the changes to this set Gem ændringerne i dette sæt + Save these shortcuts as a .kys file Gem disse genveje som en .kys-fil + Save these shortcuts as a new set Gem disse genveje som et nyt sæt + Set deleted. Sættet er slettet. + Set saved. Sættet er gemt. + Set: Sæt: + Summarize… Opsummér… + There are too many sets: delete one first. Der er for mange sæt: slet ét først. + set name sætnavn + {set} (modified) {set} (ændret) + Random Tilfældig + Show file name in title Vis filnavn i titlen + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 % tærskel + Pattern Dither Mønsterdither + Diffusion Dither Diffusionsdither + Halftone Screen Halvtoneraster + Custom Pattern Brugerdefineret mønster + Frequency: Frekvens: + Lines/Inch Linjer/tomme + Lines/Centimeter Linjer/centimeter + Ellipse Ellipse + Diamond Diamant + Cross Kors + Accept and Go to Conflict Acceptér og gå til konflikten + Changes undone. Ændringerne er fortrudt. + Choose a new shortcut for this command, or leave it without one. Vælg en ny genvej til denne kommando, eller lad den være uden. + Put this command's shortcuts back as they were Gendan denne kommandos genveje, som de var + Take the shortcut from the other command and select it Tag genvejen fra den anden kommando, og vælg den + Undo Changes Fortryd ændringer + {shortcut} is reserved by the system and can't be assigned. {shortcut} er reserveret af systemet og kan ikke tildeles. + Low-Latency Drawing (vsync off, applies at next launch) Tegning med lav latenstid (vsync slået fra, anvendes ved næste start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Fri transformering anvendes stadig; prøv igen, når den er færdig. + The transform couldn't be applied. Transformeringen kunne ikke anvendes. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Dets lag kræver {needed} GPU-hukommelse; budgettet efterlader dem {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Angiv GPU-hukommelse i Præferencer › Ydeevne til det, dit grafikkort kan rumme, højst 3/8 af dets hukommelse. Automatisk følger Hukommelsesforbrug inden for en fjerdedel af denne computers hukommelse og 3/8 af kortets og medregner ikke dokumentet på et kort med kendt hukommelse. Statuslinjen viser GPU-hukommelse fuld, når de ikke kan rummes. + GPU Memory Full GPU-hukommelse fuld + 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. Lagene kræver {needed} GPU-hukommelse og kan rumme {budget}, så redigeringer, der gentegner hele dokumentet, sker på CPU'en. Klik for at angive GPU-hukommelse. + GPU memory budget: {budget} GPU-hukommelsesbudget: {budget} + Graphics memory: {size} Grafikhukommelse: {size} + This document's layers: {needed} of {budget} for layers Dette dokuments lag: {needed} af {budget} til lag + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokuments lag kræver {needed}, men kan rumme {budget}: gentegninger af hele dokumentet bruger CPU'en + GPU Memory (MB, 0 = Automatic) GPU-hukommelse (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Størrelsesgrænse for arbejdsdisk (MB, 0 = ingen grænse) + {path}: the path is not valid Unicode; save under another name. {path}: stien er ikke gyldig Unicode; gem under et andet navn. + Replace Erstat + {name} was changed by another app after it was opened or saved here. {name} blev ændret af et andet program, efter at den blev åbnet eller gemt her. + Replace it with this document, or cancel and use File › Save As to keep both. Erstat den med dette dokument, eller annullér, og brug Filer › Gem som for at beholde begge. + +# 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) (kommer snart) + (none) (ingen) + 32-bit Preview Options Indstillinger for 32-bit-forhåndsvisning + 3D… 3D… + A: A: + AI Integrations AI-integrationer + About {app} Om {app} + Accented Edges Accentuerede kanter + Action playback failed: {error} Afspilning af handlingen mislykkedes: {error} + Actual Pixels Faktiske pixels + Add Mask Tilføj maske + Add Noise Tilføj støj + Add a mask (from the selection; ⌥ inverts) Tilføj en maske (fra markeringen; ⌥ inverterer) + Add space after paragraph Tilføj mellemrum efter afsnit + Add space before paragraph Tilføj mellemrum før afsnit + Add to selection (⇧) Tilføj til markering (⇧) + Added swatch {name} Tilføjede farveprøven {name} + Adds grain to the soft edges of the brush tip. Tilføjer korn til penselspidsens bløde kanter. + Adjust Interval For Loops Justér interval for sløjfer + Adjustment Layers Justeringslag + Adjustment Properties Justeringsegenskaber + Adjustment layers have nothing to transform Justeringslag har intet at transformere + Adobe PDF Presets… Adobe PDF-forudindstillinger… + Adobe-rgb-compat Adobe-rgb-kompat + Adwaita (Dark) Adwaita (mørk) + Adwaita (Light) Adwaita (lys) + After Efter + Airbrush: keeps painting while the pointer rests. Airbrush: bliver ved med at male, mens markøren hviler. + All shortcuts reset to Photoshop defaults. Alle genveje er nulstillet til Photoshop-standarderne. + Altitude Højde + Ambience Ambiance + Ambient Omgivende + Amplitude Max Maks. amplitude + Amplitude Min Min. amplitude + Android 1080p Android 1080p + Angled Strokes Vinklede strøg + Anisotropic Anisotropisk + Any Alle + Apple Watch 45mm Apple Watch 45mm + Apply Data Set Anvend datasæt + Apply Image Anvend billede + Apply Mask Anvend maske + Apply to All Anvend på alle + Apply {option}: {state} Anvend {option}: {state} + Arbitrary Vilkårlig + Arc Lower Nedre bue + Arc Upper Øvre bue + Are you sure you want to quit? Er du sikker på, at du vil afslutte? + Are you sure? Er du sikker? + Around Center Rundt om midten + Arrangement Arrangement + Arrows Pile + Artboard from Group Tavle fra gruppe + Artboard from Layers Tavle fra lag + Artboards Tavler + Artboards to Files Tavler til filer + Artboards to PDF Tavler til PDF + Artistic Kunstnerisk + Ask On Mismatch Spørg ved uoverensstemmelse + Ask On Missing Spørg ved manglende + Ask On Paste Spørg ved indsættelse + Assign Profile Tildel profil + At Ved + Author Title Forfattertitel + Auto Scale Skalér automatisk + Auto-Align Layers Justér lag automatisk + Auto-Blend Layers Bland lag automatisk + Autosave failed: {error} Automatisk gemning mislykkedes: {error} + Back Tilbage + Backend: {backend} ({type}) Backend: {backend} ({type}) + Background Level Baggrundsniveau + Bamboo Bambus + Bas Relief Basrelief + Base Tree Type Grundtrætype + Based On Baseret på + Baseline shift Grundlinjeforskydning + Basic Latin Grundlæggende latin + Batch Batch + Before Før + Bend Bøjning + Berries Bær + Best Bedste + Black Clip Sortklipning + Black Intensity Sortintensitet + Blade Curvature Bladkrumning + Blast Blæst + Bleach Bypass Bleach Bypass + Blend Clipped Layers as Group Bland udklippede lag som gruppe + Blend Interior Effects as Group Bland indre effekter som gruppe + Blend Text Gamma Bland tekstgamma + Blending Mode Blandingstilstand + Bloat Pust op + Blocks Blokke + Blue Yellow Blå-gul + Blues Blå + Border Kant + Border Thickness Kanttykkelse + Box Blur Bokssløring + Bpc Bpc + Branches Color Grenfarve + Branches Height Grenhøjde + Branches Thickness Grentykkelse + Breeze Dark Breeze Dark + Breeze Light Breeze Light + Brick Mursten + Bristle Detail Børstedetaljer + Browse Gennemse + Brush Detail Penseldetaljer + Brush Settings… (F5): every option of this brush Penselindstillinger… (F5): alle indstillinger for denne pensel + Brush Strokes Penselstrøg + Brush Type Penseltype + Burlap Sækkelærred + Calculations Beregninger + Camera Raw Camera Raw + Camera Raw Filter Camera Raw-filter + Cancel transform Annullér transformering + Candle Light Stearinlys + Canvas Size Lærredsstørrelse + Case Sensitive Forskel på store og små bogstaver + Ccw Mod uret + Cell Size Cellestørrelse + Center X Midtpunkt X + Center Y Midtpunkt Y + Chalk Area Kridtområde + Change Find Ændr søgning + Changes Saved Ændringer gemt + Changes have not been saved. Ændringerne er ikke blevet gemt. + Change… Ændr… + Channel1 Kanal1 + Channel2 Kanal2 + Channel3 Kanal3 + Channel4 Kanal4 + Charcoal Area Kulområde + Charcoal Thickness Kultykkelse + Check for Updates Søg efter opdateringer + Checkerboard Skakbræt + Choke Indsnævring + Choose Folder Vælg mappe + Circle Cirkel + Cleanliness Renhed + Clear Amount Klarhedsgrad + Clear Selection Ryd markering + Click sets foreground · right-click sets background Klik sætter forgrund · højreklik sætter baggrund + Click the first point or press ↵ to close · Esc cancels Klik på det første punkt, eller tryk på ↵ for at lukke · Esc annullerer + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Klik for at tilføje et punkt · træk væk, eller ⌘/Ctrl-klik eller Delete for at fjerne + Click to apply, ⌥-click to clear overrides, double-click for Style Options Klik for at anvende, ⌥-klik for at rydde tilsidesættelser, dobbeltklik for stilindstillinger + Click to zoom in · ⌥-click to zoom out Klik for at zoome ind · ⌥-klik for at zoome ud + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klik: hjørne · Træk: glat · Klik på første punkt: luk · ↩ afslut · Esc annullér + Clipping Mask Udklipningsmaske + Clipping Path… Udklipningssti… + Clone Klon + Clone source set at {x}, {y} Kloningskilde angivet ved {x}, {y} + Close Window Luk vindue + Clumped Klumpet + Cmyk Cmyk + Coarse Dots Grove prikker + Coated-cmyk Bestrøget-cmyk + Collapse group (⌥-click: all groups) Fold gruppe sammen (⌥-klik: alle grupper) + Color Decontaminate Fjern farveforurening + Color Halftone Farverastrering + Color Indicates Masked Areas Farve angiver maskerede områder + Color Indicates Selected Areas Farve angiver markerede områder + Color Lookup Tables Farveopslagstabeller + Color Picker Farvevælger + Color Picker (Background Color) Farvevælger (baggrundsfarve) + Color Picker (Custom Canvas Color) Farvevælger (brugerdefineret lærredsfarve) + Color Picker (Foreground Color) Farvevælger (forgrundsfarve) + Color Sampler Tool Farveprøvetagerværktøj + Color Settings Farveindstillinger + Color Swatches Farveprøver + Color Table Farvetabel + Color management: {error} Farvestyring: {error} + Colored Pencil Farvet blyant + Comic Book Tegneserie + Commit Perspective Warp (↩) Anvend perspektivvridning (↩) + Commit Puppet Warp (↩) Anvend marionetvridning (↩) + Commit any current edits (⌘↩) Anvend alle aktuelle redigeringer (⌘↩) + Commit current crop operation (↵) Anvend aktuel beskæring (↵) + Commit transform Anvend transformering + Commit transform (↩) Anvend transformering (↩) + Commit warp (↩) Anvend vridning (↩) + Compare Sammenlign + Conditional Mode Change Betinget tilstandsændring + Confirm Bekræft + Conform Tilpas + Constraints Begrænsninger + Content-Aware Fill needs a selection on a pixel layer Indholdsbevidst udfyldning kræver en markering på et pixellag + Contrasty Kontrastfuld + Convert Konvertér + Convert to Alpha Channel Konvertér til alfakanal + Convert to Linked Konvertér til sammenkædet + Convert to Profile Konvertér til profil + Cool Kølig + Cooling L B B Afkøling LBB + Cooling80 Afkøling 80 + Cooling82 Afkøling 82 + Copy Complete Stroke Kopiér komplet strøg + Copy Fill Kopiér udfyldning + Copyright Url Ophavsrets-URL + Correct C A Korrigér kromatisk aberration + Correct Distortion Korrigér forvrængning + Correct Vignette Korrigér vignettering + Could not rename the action: {error} Kunne ikke omdøbe handlingen: {error} + Could not transform: the layer is empty Kunne ikke transformere: laget er tomt + Could not use Perspective Warp: the layer is empty Kunne ikke bruge perspektivvridning: laget er tomt + Could not use Puppet Warp: no opaque pixels Kunne ikke bruge marionetvridning: ingen uigennemsigtige pixels + Could not use Puppet Warp: the layer is empty Kunne ikke bruge marionetvridning: laget er tomt + Couldn't open {name}: {error} Kunne ikke åbne {name}: {error} + Couldn't save preferences: {error}. Retrying… Kunne ikke gemme præferencer: {error}. Prøver igen… + Crack Brightness Revnelysstyrke + Crack Depth Revnedybde + Crack Spacing Revneafstand + Craquelure Krakelering + Create By Oprettet af + Create Vector Mask Opret vektormaske + Create Warped Text Opret tekst med vridning + Create a new layer (⇧⌘N) Opret et nyt lag (⇧⌘N) + Create new brush from the current settings Opret ny pensel ud fra de aktuelle indstillinger + Create new brush preset from the current settings Opret ny penselforudindstilling ud fra de aktuelle indstillinger + Create… Opret… + Cross Process Krydsfremkaldelse + Crosshatch Krydsskravering + Crosswise På tværs + Crystallize Krystallisér + Currency Valuta + Curve Darks Kurve – mørke + Curve Highlights Kurve – højlys + Curve Lights Kurve – lyse + Curve Shadows Kurve – skygger + Curve Splits Kurve – opdelinger + Custom Branch Color Brugerdefineret grenfarve + Custom Pixel Aspect Ratio Brugerdefineret pixelsideforhold + Custom pattern Brugerdefineret mønster + Cutout Udklip + Cw Med uret + Cyans Cyan + Cyrillic Kyrillisk + Daisies Margueritter + Dark Gray Mørkegrå + Dark Intensity Mørkeintensitet + Dark Rough Mørk og ru + Dark Strokes Mørke strøg + Darken Only Gør kun mørkere + Darkness Mørke + Data Data + Data Set: {name} ({index}/{total}) Datasæt: {name} ({index}/{total}) + Data Sets as Files Datasæt som filer + Date Dato + Day For Night Dag til nat + De-Interlace Fjern interlace + Decrease Brush Hardness Formindsk penselhårdheden + Decrease Brush Size Formindsk penselstørrelsen + Decrease Type Size Formindsk tekststørrelsen + Decrease Type Size by 10 Formindsk tekststørrelsen med 10 + Deep Dyb + Deep Blue Dyb blå + Deep Emerald Dyb smaragd + Deep Red Dyb rød + Deep Yellow Dyb gul + Default Foreground and Background Colors Standard forgrunds- og baggrundsfarver + Default Leaves Standardblade + Default Photoshop Size Photoshop-standardstørrelse + Defaults Standardindstillinger + Define Brush Preset Definér penselforudindstilling + Define Custom Shape Definér brugerdefineret form + Define Pattern Definér mønster + Definition Definition + Defringe Fjern kantfarver + Degrees Grader + Delete Channel Slet kanal + Delete Mask Slet maske + Delete Workspace Slet arbejdsområde + Delete brush Slet pensel + Delete path Slet sti + Delete vector mask Slet vektormaske + Delete… Slet… + Depth Map Dybdekort + Depth Mode Dybdetilstand + Diameter Diameter + Diffuse Diffusér + Diffuse Glow Diffus glød + Direction Retning + Direction Balance Retningsbalance + Disable Deaktivér + Disable Mask Deaktivér maske + Disable Symmetry Path Deaktivér symmetristi + Disabled Deaktiveret + Discard Changes Forkast ændringer + Displace Forskyd + Display Visning + Display profile not used Skærmprofil bruges ikke + Display profiles: {reason} Skærmprofiler: {reason} + Display-p3 Display-p3 + Distance Afstand + Distortion Forvrængning + Distribution Fordeling + Do you want to save you made to “{name}” before quitting? Vil du gemme dine ændringer i »{name}«, før du afslutter? + Document Sizes Dokumentstørrelser + Documentation Dokumentation + Documents Dokumenter + Dont Enlarge Forstør ikke + Dot Prik + Double Line Dobbelt linje + Down Ned + Draft Kladde + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Træk en beskæringsramme · træk indeni for at flytte · kanterne ændrer størrelsen ({ratio}-forhold, {centre} centreret) · Mellemrumstasten flytter, mens du tegner · {commit} anvender · Esc annullerer + Drag: draw freehand · Release on the start point: close Træk: tegn frihånd · Slip på startpunktet: luk + Driver: {driver} Driver: {driver} + Dry Brush Tør pensel + Duplicate Channel Duplikér kanal + Duplicate Layers Duplikér lag + Duplication Duplikering + Dust & Scratches Støv og ridser + Edge Kant + Edge Brightness Kantlysstyrke + Edge Extension Kantudvidelse + Edge Fidelity Kantnøjagtighed + Edge Intensity Kantintensitet + Edge Only Kun kanter + Edge Simplicity Kantforenkling + Edge Thickness Kanttykkelse + Edge Width Kantbredde + Edit Toolbar Redigér værktøjslinje + Edit… Redigér… + Elevation Elevation + Eliminate Eliminér + Emboss Relief + Enable Aktivér + Enable Mask Aktivér maske + End X Slutpunkt X + End Y Slutpunkt Y + Enlarged Forstørret + Erase with Brush or Pencil (hold) Visk ud med pensel eller blyant (hold nede) + Error Fejl + Even Fields Lige felter + Exit Quick Mask Afslut hurtig maske + Expand group (⌥-click: all groups) Udvid gruppe (⌥-klik: alle grupper) + Export As Eksportér som + Export As: {name} Eksportér som: {name} + Export Contents Eksportér indhold + Export Image Eksportér billede + Export Options Eksportindstillinger + Export Preferences Eksportpræferencer + Export Settings Eksportindstillinger + Export to Eksportér til + Export/Import Presets Eksportér/importér forudindstillinger + Exported contents to {path} Eksporterede indholdet til {path} + Exported presets to {path} Eksporterede forudindstillinger til {path} + Exported {path} Eksporterede {path} + Exported {path} ({size}) Eksporterede {path} ({size}) + Exporting… Eksporterer… + ExtraBold Ekstra fed + ExtraLight Ekstra let + Extrude Ekstrudér + Fade Udtoning + Faded Film Falmet film + Failed Mislykkedes + Failed to open file Kunne ikke åbne filen + Failed to save file Kunne ikke gemme filen + Fallback: {reason} Reserveløsning: {reason} + Feather Radius Radius for blød kant + Fiber Length Fiberlængde + Fibers Fibre + Field Blur Feltsløring + File Format Filformat + File Info Filoplysninger + File Name Filnavn + File Size Filstørrelse + File not found Filen blev ikke fundet + Filename Filnavn + Fill Color Udfyldningsfarve + Fill Opacity Udfyldningsopacitet + Fill Path… Udfyld sti… + Fill path with foreground color Udfyld sti med forgrundsfarve + Fill with Background Color Udfyld med baggrundsfarve + Fill with Background Color, Preserve Transparency Udfyld med baggrundsfarve, bevar gennemsigtighed + Fill with Foreground Color Udfyld med forgrundsfarve + Fill with Foreground Color, Preserve Transparency Udfyld med forgrundsfarve, bevar gennemsigtighed + Film Grain Filmkorn + Filter Gallery Filtergalleri + Find Find + Find and Replace Text Find og erstat tekst + Fine Fin + Fine Dots Fine prikker + Finish Afslut + Fit Image Tilpas billede + Fit to Window Tilpas til vindue + Flame Flamme + Flame Bottom Alignment Justering af flammens bund + Flame Lines Flammelinjer + Flame Shape Flammeform + Flame Style Flammestil + Flame Type Flammetype + Flat Flad + Flat Shading Flad skygning + Flower Color Blomsterfarve + Focal Distance Fokusafstand + Focus Fokus + Focus Area Fokusområde + Foreground Level Forgrundsniveau + Forward Fremad + Forward Warp Vrid fremad + Frame Billede + Frame {current} / {total} Billede {current} / {total} + Free Transform Path Fri transformering af sti + Freeze Lasso Fryselasso + Freeze Mask Frysemaske + Fresco Fresko + From Fra + From Left Fra venstre + From Right Fra højre + Frosted Matteret + Full Screen Fuld skærm + Fullscreen Fuldskærm + Fuzziness Uklarhed + GPU backend preference: {backend} GPU-backend-præference: {backend} + GPU device was lost; using the CPU renderer. GPU-enheden gik tabt; bruger CPU-gengiveren. + GPU error; using the CPU renderer. GPU-fejl; bruger CPU-gengiveren. + GPU retry scheduled Nyt GPU-forsøg planlagt + Gamma Gamma + Gaussian Blur Gaussisk sløring + General Blending Generel blanding + General Brushes Generelle pensler + Generators Generatorer + Generic Generisk + Glass Glas + Global Light Globalt lys + Gloss Glans + Glow Amount Glødmængde + Glow Brightness Glødlysstyrke + Glow Size Glødstørrelse + Glowing Edges Glødende kanter + Good God + Grade Balance Graderingsbalance + Grade Blending Graderingsblanding + Grade Global Global gradering + Grade Highlights Gradering af højlys + Grade Midtones Gradering af mellemtoner + Grade Shadows Gradering af skygger + Gradient Options Gradientindstillinger + Grain Amount Kornmængde + Grain Roughness Kornruhed + Grain Size Kornstørrelse + Grain Type Korntype + Graininess Kornethed + Grainy Dots Kornede prikker + Graphics adapter Grafikadapter + Graphics adapter: {adapter} Grafikadapter: {adapter} + Gray-gamma-2.2 Grå-gamma-2.2 + Greek Græsk + Greens Grønne + Grid (⌘') Gitter (⌘') + Grid view Gittervisning + Group from Layers Gruppe fra lag + Groups Grupper + Grout Width Fugebredde + Guide Layout… Hjælpelinjelayout… + HDR Toning HDR-toning + Hand Tool (hold) Håndværktøj (hold nede) + Hearts Hjerter + Heptagon Syvkant + Hexagon Sekskant + Hidden Skjult + Hide Grid Skjul gitter + Hide Others Skjul andre + Hide the layer's effects (⌥-click: all layers) Skjul lagets effekter (⌥-klik: alle lag) + Hide {app} Skjul {app} + High Pass Højpas + Highlight Højlys + Highlight Amount Højlysmængde + Highlight Area Højlysområde + Highlight Radius Højlysradius + Highlight Strength Højlysstyrke + Highlight Tone Højlystone + Highlights from: Højlys fra: + Horizontal Distortion Vandret forvrængning + Horizontal Only Kun vandret + Horizontal scale Vandret skalering + Horizontal type Vandret tekst + Hotspot Hotspot + Hsb HSB + Hsl HSL + Hsl Hue HSL-farvetone + Hsl Lum HSL-luminans + Hsl Sat HSL-mætning + Image Balance Billedbalance + Image Processor Billedprocessor + Image compositor: {processor} Billedsammensætter: {processor} + Import Brushes Importér pensler + Import Brushes… (.abr) Importér pensler… (.abr) + Import Image Importér billede + Import Options Importindstillinger + Import Photoshop brushes (.abr) Importér Photoshop-pensler (.abr) + Import Settings Importér indstillinger + Importing {what} from {file}… Importerer {what} fra {file}… + Importing… Importerer… + In Black Input-sort + In White Input-hvid + Increase Brush Hardness Forøg penselhårdheden + Demosaic Demosaicering + 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. Hvordan kameraets farvefiltermosaik udfyldes. Det anvendes, når filen åbnes; forhåndsvisningen viser den metode, den blev åbnet med. + AHD (adaptive homogeneity) AHD (adaptiv homogenitet) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de reneste kanter og de mindste farvefrynser, men den langsomste. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigeret interpolation: skarp og hurtig, med nogle farvefrynser i fine detaljer. + The fastest and the softest, with colour fringing on fine detail. Den hurtigste og den blødeste, med farvefrynser i fine detaljer. + “{name}” already exists. Replace it? »{name}« findes allerede. Vil du erstatte den? + The name in the save dialog was “{name}”. Navnet i dialogen Gem var »{name}«. + Move Up Flyt op + Move Down Flyt ned + Previous Brush Forrige pensel + Next Brush Næste pensel + First Brush Første pensel + Last Brush Sidste pensel + Preview size Forhåndsvisningsstørrelse + Memory Usage Hukommelsesforbrug + Per-document RAM limit (MB) RAM-grænse pr. dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installeret RAM: {ram} MB; denne grænse: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrænser hvert åbent dokuments pixels plus historik. De ældste fortrydelsestilstande kasseres, når grænsen overskrides. + Applies immediately when saved. Memory is not reserved in advance. Træder i kraft med det samme, når der gemmes. Hukommelsen reserveres ikke på forhånd. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Når GPU-hukommelse er sat til Automatisk, begrænser dette også GPU-lagbudgettet. Budgettet er højst en fjerdedel af den installerede RAM og, når den kendes, tre ottendedele af grafikkortets hukommelse, dog mindst 512 MB. Hvis du hæver denne grænse ud over disse lofter, øges GPU-budgettet ikke. + Command Kommando + Use Brug + In Front Foran + Behind Bagved + +# DDS Options (#1327) + DDS Options DDS-indstillinger + Compression Komprimering + Generate Mipmaps Generér mipmaps + +# Color Table (.act) dialog + There is no color table to save. Der er ingen farvetabel at gemme. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Gemte {n} farve i {file}|Gemte {n} farver i {file} + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Importér stile… + Export Selected Styles… Eksportér markerede stile… + Export Styles… Eksportér stile… + {file} is not a style library (.asl) {file} er ikke et stilbibliotek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importerede {n} stil fra {file}|Importerede {n} stile fra {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Eksporterede {n} stil til {file}|Eksporterede {n} stile til {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bekræft eller annullér først den åbne dialog for handlingstrinnet + Pro Medium Gray Pro mellemgrå + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro lysegrå + Pro Light Pro lys + Resize Windows to Fit Tilpas vinduesstørrelse + EXR EXR + Increase Brush Size Forøg penselstørrelsen + Increase Type Size Forøg tekststørrelsen + Increase Type Size by 10 Forøg tekststørrelsen med 10 + Indent first line Indryk første linje + Indent left margin Indryk venstre margen + Indent right margin Indryk højre margen + Indexed Indekseret + Infinite Uendelig + Information Information + Initial Direction Startretning + Ink Outlines Blækkonturer + Inner Bevel Indre facet + Input Input + Input Levels Inputniveauer + Input Mode Inputtilstand + Install Plug-in Installér plug-in + Intensity Intensitet + Intent Hensigt + Interpret Footage Tolk materiale + Intersect with selection (⇧⌥) Skær med markering (⇧⌥) + Interval Interval + Invalid panel layout; restored the default dock Ugyldigt panellayout; standarddokken er gendannet + Invert Mask Invertér maske + Invert Selection Invertér markering + Iris Blur Irissløring + Ivy Vedbend + Jag Hak + Join the ArtCraft Discord Deltag i ArtCrafts Discord + Join the ArtCraft Discord ({url}) Deltag i ArtCrafts Discord ({url}) + Keeps the current texture when switching presets. Beholder den aktuelle tekstur ved skift af forudindstilling. + Kernel Kerne + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Knibning: metrik, optisk eller en værdi i 1/1000 em (Alt+←/→ ved indsætningspunktet) + Keyboard Tastatur + Lab Lab + Landscape, 10 x 8 Liggende, 10 x 8 + Landscape, 6 x 4 Liggende, 6 x 4 + Landscape, 7 x 5 Liggende, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin udvidet-A + Latin Extended-B Latin udvidet-B + Layer Comps to Files Lagkompositioner til filer + Layer Content Options Indstillinger for lagindhold + Layer Count Antal lag + Layer From Background Lag fra baggrund + Layer Knocks Out Drop Shadow Lag udstanser slagskygge + Layer Mask Hides Effects Lagmaske skjuler effekter + Layer Properties Lagegenskaber + Layer Styles Lagstile + Layer from Background Lag fra baggrund + Layers to Files Lag til filer + Layout Layout + Leading (set to the font size × auto-leading when Auto) Linjeafstand (sættes til skriftstørrelse × automatisk linjeafstand ved Auto) + Leaf Color Bladfarve + Learn More Få mere at vide + Leaves Amount Bladmængde + Leaves Color Bladfarve + Leaves Size Bladstørrelse + Left Diagonal Venstre diagonal + Legacy Ældre + Legal Legal + Lens Blur Objektivsløring + Lens Correction Objektivkorrektion + Lens Flare Objektivblænding + Less Mindre + Letter Letter + Level Based Niveaubaseret + Library Bibliotek + Light Dark Balance Lys/mørk-balance + Light Direction Lysretning + Light Intensity Lysintensitet + Light Rough Lys og ru + Light Type Lystype + Light X Lys-X + Light Y Lys-Y + Light Z Lys-Z + Lighten Grout Lysere fuge + Lighten Only Gør kun lysere + Lighting Belysning + Lighting Effects Lyseffekter + Linear Dodge Lineær lysning + Linear Height Lineær højde + Linear-srgb Lineær-srgb + Link Sammenkæd + Liquify Gør flydende + List Liste + List view Listevisning + Live Preview Live-forhåndsvisning + Load 3D LUT Indlæs 3D LUT + Load Files into Stack Indlæs filer i stak + Load path as a selection Indlæs sti som markering + Loading… Indlæser… + Lock All Lås alle + Lock Image Pixels Lås billedpixels + Lock Position Lås placering + Lock Transparent Pixels Lås gennemsigtige pixels + Locked Låst + Long Lines Lange linjer + Long Strokes Lange strøg + Lorem Ipsum Lorem Ipsum + Lower Nedre + Lum Luminans + Lut LUT + Magentas Magenta + Make Selection from Path Opret markering fra sti + Make Selection… Opret markering… + Make Symmetry Path Opret symmetristi + Make Work Path… Opret arbejdssti… + Make work path from selection Opret arbejdssti ud fra markering + Manual Manuelt + Map Document Kortlæg dokument + Map Layer Kortlæg lag + Map Path Kortlæg sti + Mask Channel Maskekanal + Mask Incomplete Blocks Maskér ufuldstændige blokke + Mask None Ingen maske + Masks Masker + Match Color Afstem farve + Math Symbols Matematiske symboler + Max Offset Maks. forskydning + Max Radius Maks. radius + Measurement Scale Måleskala + Measurement Scale Marker Måleskalamarkør + Medium Dots Mellemstore prikker + Medium Gray Mellemgrå + Medium Lines Mellemstore linjer + Medium Strokes Mellemstore strøg + Merge Channels… Flet kanaler… + Merge Spot Channel Flet spotkanal + Merge to HDR Pro Flet til HDR Pro + Metallic Metallisk + Metrics Metrik + Mezzotint Mezzotint + Middle Midten + Midtone Mellemtone + Monitor Profile Skærmprofil + Monitor profile in use on {display}: {profile} Skærmprofil i brug på {display}: {profile} + Monitor profile in use: {profile} Skærmprofil i brug: {profile} + Monitor profile on {display} (main window): {profile} Skærmprofil på {display} (hovedvindue): {profile} + Monitor profile on {display}: {profile} Skærmprofil på {display}: {profile} + Monitor profile: {profile} Skærmprofil: {profile} + Mono Contrast Monokontrast + Monochromatic Monokrom + More Mere + Mosaic Mosaik + Mosaic Tiles Mosaikfliser + Motion Blur Bevægelsessløring + Mouse Mus + Move Layer Flyt lag + Movie Prime Movie Prime + Multiple Flames Along Path Flere flammer langs sti + Multiple Flames One Direction Flere flammer i én retning + Multiple Flames Path Directions Flere flammer i stiens retninger + Multiple Flames Various Length Flere flammer med forskellig længde + My Brushes Mine pensler + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Indbygget træk-og-slip af filer understøttes endnu ikke på Wayland. Brug Filer › Åbn, eller kør PhotoCraft under XWayland med `WAYLAND_DISPLAY= photocraft`. + Nearest Nærmeste + Neon Glow Neonglød + Neutralize Neutralisér + Neutrals Neutrale + New Brush Preset Ny penselforudindstilling + New Channel… Ny kanal… + New Guide Ny hjælpelinje + New Guide Layout Nyt hjælpelinjelayout + New Spot Channel… Ny spotkanal… + New Video Layer from File Nyt videolag fra fil + New Window Nyt vindue + New Workspace Nyt arbejdsområde + New document Nyt dokument + New document… Nyt dokument… + New document… ⌘N Nyt dokument… ⌘N + Next Næste + No Document Intet dokument + No Image Intet billede + No Layers Ingen lag + No Results Ingen resultater + No Results Found Fandt ingen resultater + No Selection Ingen markering + No custom shape "{name}": pick one in the options bar or Window › Shapes Ingen brugerdefineret form »{name}«: vælg en på indstillingslinjen eller i Vindue › Former + No layers Ingen lag + No recent files Ingen seneste filer + No selection Ingen markering + Noise Color Farvestøj + Noise Color Detail Farvestøjsdetaljer + Noise Luminance Luminansstøj + Noise Luminance Detail Luminansstøjdetaljer + Nothing to Redo Intet at gøre om + Nothing to Undo Intet at fortryde + Nothing to paste: the clipboard holds no image Intet at indsætte: udklipsholderen indeholder ikke noget billede + Number Of Levels Antal niveauer + Numbers Tal + Ocean Ripple Havkrusning + Octagon Ottekant + Odd Fields Ulige felter + Oil Paint Oliemaleri + On Til + One Flame Along Path Én flamme langs sti + Open As Åbn som + Open File Åbn fil + Open Folder Åbn mappe + Open Image Åbn billede + Open Recent is unavailable on the web Åbn seneste er ikke tilgængelig i webversionen + Opened {url} Åbnede {url} + Opening Åbner + Open… ⌘O Åbn… ⌘O + Operation canceled Operationen blev annulleret + Operation cancelled Operationen blev annulleret + Operation completed Operationen er fuldført + Operation failed Operationen mislykkedes + Optical Optisk + Original Ratio Oprindeligt forhold + Out Black Output-sort + Out From Center Ud fra centrum + Out Of Gamut Uden for gamut + Out White Output-hvid + Out of memory Ikke nok hukommelse + Outer Bevel Ydre facet + Output Levels Outputniveauer + Oval Oval + Overlay Edge Overlejrede kanter + Overlay: Rule of Thirds Overlejring: tredjedelsreglen + PSD PSD + Package Pakke + Page Setup… Sideopsætning… + Paint Daubs Malingsklatter + Paints from the document's opening state Maler fra dokumentets tilstand ved åbning + Palette Knife Paletkniv + Panel Panel + Paper Papir + Paper Brightness Papirlysstyrke + Parallel Parallel + Paste Complete Stroke Indsæt komplet strøg + Paste Fill Indsæt udfyldning + Patchwork Patchwork + Path Blur Stisløring + Paths to Illustrator Stier til Illustrator + Pattern Fill… Mønsterudfyldning… + Pattern Type Mønstertype + Pencil Width Blyantbredde + Pentagon Femkant + Percentage Procent + Permission denied Adgang nægtet + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft bliver ved med at prøve igen; indtil det lykkes at gemme, går ændringer i præferencerne tabt, når programmet lukkes. + Photomerge Photomerge + Pick Color From Image Vælg farve fra billede + Picture Frame Billedramme + Pillow Emboss Puderelief + Pinch Klemning + Pins Nåle + Place Embedded Placér indlejret + Place Linked Placér sammenkædet + Place Scale Marker Placér skalamarkør + Plane Plan + Planes Planer + Plastic Wrap Plastfolie + Platform: {os} {arch} Platform: {os} {arch} + Please wait… Vent et øjeblik… + Point Punkt + Point Curve Punktkurve + Point Curve Blue Punktkurve blå + Point Curve Green Punktkurve grøn + Point Curve Red Punktkurve rød + Pointed Spids + Pointillize Pointillér + Polar Coordinates Polære koordinater + Polar To Rectangular Polar til rektangulær + Policy Cmyk CMYK-politik + Policy Gray Gråtonepolitik + Policy Rgb RGB-politik + Pond Ripples Damkrusninger + Portrait, 4 x 6 Stående, 4 x 6 + Portrait, 5 x 7 Stående, 5 x 7 + Postcard Postkort + Poster Plakat + Poster Edges Plakatkanter + Posterization Posterisering + Preferences can't be saved Præferencer kan ikke gemmes + Preferences saved Præferencer gemt + Preferences were reset: {error} Præferencerne blev nulstillet: {error} + Preferences… Præferencer… + Preserve Bevar + Preset Manager Forudindstillingsadministrator + Press keys Tryk på taster + Preview Image Forhåndsvisningsbillede + Previous Forrige + Prime105 Prime105 + Prime35 Prime35 + Printed to {path} Udskrevet til {path} + Pro (Dark) Pro (mørk) + Pro (Medium Gray) Pro (mellemgrå) + Processing… Behandler… + Profile Profil + Proof Setup… Korrekturopsætning… + Prophoto-compat Prophoto-kompat + Pucker Rynk + Push Left Skub til venstre + Pyramids Pyramider + Quick Actions Hurtige handlinger + Quit Afslut + Quit PhotoCraft Afslut PhotoCraft + Quit {app} Afslut {app} + R G B R G B + R g b R g b + Radial Radial + Radial Blur Radial sløring + Radius X Radius X + Radius Y Radius Y + Ran script {name} Kørte scriptet {name} + Randomize Length Tilfældig længde + Randomize Shapes Tilfældige former + Rasterize Fill Content Rasterisér udfyldningsindhold + Rasterize Smart Object Rasterisér smartobjekt + Ratio Forhold + Raw Defaults Raw-standarder + Reading Læser + Ready Klar + Rec2020 Rec2020 + Recent Files Seneste filer + Reconstruct All Rekonstruér alt + Recovered {name} Gendannede {name} + Rectangular Selection Rektangulær markering + Rectangular To Polar Rektangulær til polar + Red Cyan Rød-cyan + Reds Røde + Reduce Color Noise Reducér farvestøj + Reduce Noise Reducér støj + Reflected Reflekteret + Relief Relief + Relink to File Sammenkæd med fil igen + Remove All Fjern alle + Remove Jpeg Artifact Fjern JPEG-artefakt + Rename Brush Omdøb pensel + Rename Channel… Omdøb kanal… + Rename Group Omdøb gruppe + Rename Tool Preset Omdøb værktøjsforudindstilling + Render Video Gengiv video + Rendering… Gengiver… + Reorder Layer Omorganisér lag + Replace Color Erstat farve + Replace Contents Erstat indhold + Replace Footage Erstat materiale + Report a Bug Rapportér en fejl + Report an Issue Rapportér et problem + Reset Alignment Nulstil justering + Reset All Nulstil alle + Reset Preferences Nulstil præferencer + Resolve Missing Fonts Håndtér manglende skrifttyper + Restart Genstart + Restart Required Genstart påkrævet + Resume Genoptag + Retry Prøv igen + Rgb RGB + Ridges Kamme + Right Diagonal Højre diagonal + Ripple Krusning + Ripple Magnitude Krusningsstyrke + Ripple Size Krusningsstørrelse + Rope Reb + Roses Roser + Rough Pastels Ru pasteller + Row Order Rækkefølge + Rulers (⌘R) Linealer (⌘R) + Sample Size Prøvestørrelse + Sampled Colors Prøvetagne farver + Sandstone Sandsten + Sat Mætning + Save As Gem som + Save Changes Gem ændringer + Save File Gem fil + Save Image Gem billede + Save Path Gem sti + Save Pixel Aspect Ratio Gem pixelsideforhold + Save a Copy Gem en kopi + Save before closing? Gem før lukning? + Save for Web (Legacy) Gem til web (ældre) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Gem dit arbejde, og genstart PhotoCraft for at prøve GPU-acceleration igen. CPU-gengivelse er stadig aktiv i denne session. + Saved a copy as {path} Gemte en kopi som {path} + Saved {path}; image assets: {n} in {dir} Gemte {path}; billedaktiver: {n} i {dir} + Saving… Gemmer… + Scale Effects Skalér effekter + Scaling Skalering + Scallops Muslingekanter + Script Events Manager Scripthændelsesadministrator + Scroll menu down Rul ned i menuen + Scroll menu up Rul op i menuen + Search commands Søg i kommandoer + Search commands (⌘K) Søg i kommandoer (⌘K) + Search commands, tools and panels Søg i kommandoer, værktøjer og paneler + Search menus Søg i menuer + Seed Startværdi + Select Custom Color… Vælg brugerdefineret farve… + Select Data Points Markér datapunkter + Select Layer Markér lag + Select and Mask Markér og maskér + Color Aware Farvebevidst + Decontaminate Colors Fjern farveforurening + Edge Detection Kantregistrering + Global Refinements Globale forfininger + High Quality Preview Forhåndsvisning i høj kvalitet + Object Aware Objektbevidst + Object Aware needs a segmentation model, which isn't available yet Objektbevidst kræver en segmenteringsmodel, som endnu ikke er tilgængelig + Real-Time Refinement Forfining i realtid + Refine Edge Brush Tool Kantforfiningspenselværktøj + Refine Hair Forfin hår + Refine Hair needs hair detection, which isn't available yet Forfin hår kræver hårregistrering, som endnu ikke er tilgængelig + Refine Mode Forfiningstilstand + Select and Mask was closed: another layer was selected Markér og maskér blev lukket: et andet lag blev markeret + Select and Mask was closed: its document is no longer the active one Markér og maskér blev lukket: dets dokument er ikke længere det aktive + View Mode Visningstilstand + Selected at launch: {backend} Valgt ved opstart: {backend} + SemiBold Halvfed + Sensitivity Følsomhed + Sent to the printer ({spooler}) Sendt til printeren ({spooler}) + Sepia Sepia + Services Tjenester + Set Colors Indstil farver + Set Measurement Scale Indstil måleskala + Sgray Sgray + Shadow Skygge + Shadow Amount Skyggemængde + Shadow Intensity Skyggeintensitet + Shadow Radius Skyggeradius + Shadow Tone Skyggetone + Shadows up to: Skygger op til: + Shadows/Highlights Skygger/højlys + Shallow Lav + Shape Blur Formsløring + Sharpen Amount Skærpningsmængde + Sharpen Detail Skærpningsdetalje + Sharpen Details Skærpningsdetaljer + Sharpen Masking Skærpningsmaskering + Sharpen Radius Skærpningsradius + Shear Skævvridning + Shine Skær + Short Lines Korte linjer + Short Strokes Korte strøg + Shortcut Genvej + Shortcuts Genveje + Show All Vis alle + Show Extras Options Vis indstillinger for ekstraer + Show Guides Vis hjælpelinjer + Show Guides (⌘;) Vis hjælpelinjer (⌘;) + Show Rulers Vis linealer + Show the layer's effects (⌥-click: all layers) Vis lagets effekter (⌥-klik: alle lag) + Simple Enkel + Simulate Paper Simulér papir + Sine Sinus + Sketch Skitse + Skip Spring over + Slice Options Udsnitsindstillinger + Smart Blur Smart sløring + Smart Sharpen Smart skærpning + Smoothness Glathed + Smudge Stick Udtværingspind + Snap to Grid Fastgør til gitter + Snowflakes Snefnug + Soften Blødgør + Softness Blødhed + Software adapter Softwareadapter + Solarized Dark Solarized Dark + Solid Color Ensfarvet + Solid Front Faces Solide frontflader + Source Channel Kildekanal + Source Invert Invertér kilde + Source Layer Kildelag + Source1 Channel Kilde 1-kanal + Source1 Invert Invertér kilde 1 + Source2 Channel Kilde 2-kanal + Source2 Invert Invertér kilde 2 + Source: set Kilde: angivet + Space Mellemrum + Sparkle Glimt + Spatter Stænk + Speckle Prikvirkning + Speed Hastighed + Spherize Kugleform + Spin Drej + Spin Blur Rotationssløring + Split Channels Opdel kanaler + Spot Plet + Spray Radius Sprøjteradius + Sprayed Strokes Sprøjtede strøg + Spread Udbredelse + Sprinkles Strøsler + Square Size Kvadratstørrelse + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Firkantethed + Stagger Forskudt + Stained Glass Blyindfattet glas + Star Stjerne + Stars Stjerner + Start X Start-X + Start Y Start-Y + Statistics Statistik + Step {step} ({id}) failed: {error} Trin {step} ({id}) mislykkedes: {error} + Stippled Prikket + Stops Stop + Strength Styrke + Stretch Stræk + Stroke Color Strøgfarve + Stroke Detail Strøgdetalje + Stroke Direction Strøgretning + Stroke Length Strøglængde + Stroke Pressure Strøgtryk + Stroke Size Strøgstørrelse + Stroke Width Strøgbredde + Stroke path with brush Lav kontur på sti med pensel + Strokes Strøg + Studio (Dark) Studio (mørk) + Studio (Light) Studio (lys) + Stylization Stilisering + Subtract Shapes at Overlap Træk former fra ved overlap + Subtract from selection (⌥) Træk fra markering (⌥) + Sumi-e Sumi-e + Surface Blur Overfladesløring + Switch Foreground and Background Colors Byt om på forgrunds- og baggrundsfarver + System Info Systemoplysninger + Tablet Tablet + Tabloid Tabloid + Taper Afsmalning + Target Mål + Target Channel 3 Sæt kanal 3 som mål + Target Channel 4 Sæt kanal 4 som mål + Target Channel 5 Sæt kanal 5 som mål + Target Channel 6 Sæt kanal 6 som mål + Target Channel 7 Sæt kanal 7 som mål + Target Channel 8 Sæt kanal 8 som mål + Target Channel 9 Sæt kanal 9 som mål + Target Composite Channel Sæt sammensat kanal som mål + Target X Mål-X + Target Y Mål-Y + Teal Orange Blågrøn-orange + Technology Previews… Teknologieksempler… + Temporary Midlertidig + Text Layer Tekstlag + Texture Coverage Teksturdækning + Texturizer Teksturering + Thaw Mask Optøningsmaske + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lærredet vises som sRGB. Vælg en skærmprofil under Redigér › Farveindstillinger. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lærredet på {display} vises som sRGB. Vælg en skærmprofil under Redigér › Farveindstillinger. + The current gradient (pick one in Window › Gradients) Den aktuelle gradient (vælg en i Vindue › Gradienter) + The document does not have an embedded colour profile. Working space: {working}. Dokumentet har ingen indlejret farveprofil. Arbejdsfarverum: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokumentet har en indlejret farveprofil, der ikke stemmer overens med arbejdsfarverummet.\nIndlejret: {embedded}\nArbejdsfarverum: {working} + The file could not be opened. Filen kunne ikke åbnes. + The file could not be saved. Filen kunne ikke gemmes. + Thickness Tykkelse + This Layer: Dette lag: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjekt skal rasteriseres, før du fortsætter. Dets indhold kan ikke længere redigeres. Vil du rasterisere smartobjektet? + This adjustment ({key}) is kept as imported and can't be edited yet. Denne justering ({key}) bevares som importeret og kan ikke redigeres endnu. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette udfyldningslag skal rasteriseres, før du fortsætter. Dets udfyldningsindhold kan ikke længere redigeres. Vil du rasterisere laget? + This layer must be rasterized before proceeding. Dette lag skal rasteriseres, før du fortsætter. + This session: {reason} Denne session: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Dette formlag skal rasteriseres, før du fortsætter. Det kan ikke længere redigeres som form. Vil du rasterisere formen? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Dette tekstlag skal rasteriseres, før du fortsætter. Dets tekst kan ikke længere redigeres. Vil du rasterisere teksten? + Tile Size Flisestørrelse + Tiles Fliser + Tilt-Shift Tilt-shift + Tint Color Toningsfarve + Tiny Lens Lille objektiv + Title Titel + To Til + To Center Mod centrum + Toggle Layer Mask Overlay Slå lagmaskeoverlejring til/fra + Toggle the Brush Settings panel Slå panelet Penselindstillinger til/fra + Toggle the Brush Settings panel (F5) Slå panelet Penselindstillinger til/fra (F5) + Tool Options Værktøjsindstillinger + Trace Contour Spor kontur + Tracking (1/1000 em) Spatiering (1/1000 em) + Transformation Transformering + Transition Overgang + Transparency Shapes Layer Gennemsigtighed former lag + Trap Trap + Tree Træ + Trim Trim + Turbulent Turbulent + Twirl Hvirvel + Twirl Clockwise Drej med uret + Twirl Counterclockwise Drej mod uret + Type Options Tekstindstillinger + Unaltered Uændret + Undefined Areas Udefinerede områder + Underlying Layer: Underliggende lag: + Underpainting Undermalering + Underwater Under vandet + Ungroup Ophæv gruppering + Uniform Ensartet + Unite Shapes at Overlap Foren former ved overlap + Unlink Fjern sammenkædning + Unlock Lås op + Unlocked Ikke låst + Unsaved Changes Ugemte ændringer + Unsharp Mask Uskarp maskering + Unsupported file format Ikke-understøttet filformat + Unsupported format Ikke-understøttet format + Up Op + Update Opdatér + Updates Opdateringer + Upper Øvre + Use Custom Color Brug brugerdefineret farve + Use File › Open to open an image. Brug Filer › Åbn for at åbne et billede. + Use Selection In Source Brug markering i kilde + Use Selection In Target Brug markering i mål + Uses the foreground and background colors. Bruger forgrunds- og baggrundsfarverne. + Vanishing Point Forsvindingspunkt + Variable Data Sets Variable datasæt + Vector Mask Hides Effects Vektormaske skjuler effekter + Vertical Distortion Lodret forvrængning + Vertical Only Kun lodret + Vertical Type Tool Lodret tekstværktøj + Vertical scale Lodret skalering + Vertical type Lodret tekst + Video Frames to Layers Videobilleder til lag + Vignette Amount Vignetteringsmængde + Vignette Feather Blød kant på vignettering + Vignette Highlights Vignettering af højlys + Vignette Midpoint Vignetteringsmidtpunkt + Vignette Roundness Vignetteringsrundhed + Vignette Style Vignetteringsstil + Vine Color Rankefarve + Vine With Flowers Ranke med blomster + Vine With Leaves Ranke med blade + Violent Voldsom + W: B: + WIA Support WIA-support + Warm Varm + Warming Filter (85) Opvarmningsfilter (85) + Warming L B A Opvarmning LBA + Warming81 Opvarmning 81 + Warming85 Opvarmning 85 + Warmth Varmhed + Warning Advarsel + Warp Text Vrid tekst + Watercolor Vandfarve + Watercolour look: lighter interior, darker rims. Vandfarvelook: lysere indre, mørkere kanter. + Wavelength Max Maks. bølgelængde + Wavelength Min Min. bølgelængde + Waves Bølger + Web Large Web stor + Web Minimum Web minimum + Web Most Common Web mest almindelige + WebP (lossless) WebP (tabsfri) + Wetness Vådhed + White Clip Hvidklipning + White Intensity Hvidintensitet + White Is High Hvidt er højt + Whole Word Hele ord + Wide Blurry Bredt og sløret + Wide Sharp Bredt og skarpt + Wind Vind + Window renderer: {renderer} Vinduesgengiver: {renderer} + Working Arbejder + Working Cmyk Arbejds-CMYK + Working Gray Arbejds-grå + Working Rgb Arbejds-RGB + Working-cmyk Arbejds-cmyk + Workspaces Arbejdsområder + Wrap Ombryd + Yellows Gule + ZigZag Zigzag + Zigzag Zigzag + Zoom In (hold, drag to scrub) Zoom ind (hold nede, træk for at zoome) + Zoom Out (hold) Zoom ud (hold nede) + auto auto + blue blå + brushes pensler + fallback reserve + gradients gradienter + gray grå + green grøn + layer mask lagmaske + manual manuelt + none ingen + off fra + on til + orange orange + previous reading kept; the last re-read failed: {reason} forrige aflæsning bevaret; den seneste genaflæsning mislykkedes: {reason} + red rød + unknown ukendt + unknown type ukendt type + vector mask vektormaske + violet violet + yellow gul + {index} of {total} {index} af {total} + {label} is not available: {reason} {label} er ikke tilgængelig: {reason} + {message} (notes: {n}; {warning}) {message} (bemærkninger: {n}; {warning}) + {shortcut} assigned. {shortcut} tildelt. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} bruges allerede af {commands} og fjernes dér, når du klikker på OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ navigér ↵ kør esc luk + ✓ Color Indicates Masked Areas ✓ Farve angiver maskerede områder + ✓ Color Indicates Selected Areas ✓ Farve angiver markerede områder +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Slet +@id edit.purge.undo Fortryd +@id edit.stroke Kontur… +@id edit.transform.scale Skalér +@id file.close Luk +@id filter.sharpen.sharpen Skærp +@id layer.layerMask.apply Anvend +@id select.all Markér alt +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importerede {n} pensel fra {file}|Importerede {n} pensler fra {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importerede {n} gradient fra {file}|Importerede {n} gradienter fra {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importerede {n} mønster fra {file}|Importerede {n} mønstre fra {file} +cameraRaw Balance Balance +cameraRaw Clarity Klarhed +cameraRaw Color Farve +cameraRaw Color Mixer Farvemikser +cameraRaw Contrast Kontrast +cameraRaw Curve Kurve +cameraRaw Dehaze Fjern dis +cameraRaw Detail Detaljer +cameraRaw Effects Effekter +cameraRaw Exposure Eksponering +cameraRaw Feather Blød kant +cameraRaw Highlights Højlys +cameraRaw Luminance Luminans +cameraRaw Masking Maskering +cameraRaw Midpoint Midtpunkt +cameraRaw Midtones Mellemtoner +cameraRaw Radius Radius +cameraRaw Roundness Rundhed +cameraRaw Saturation Mætning +cameraRaw Shadows Skygger +cameraRaw Size Størrelse +cameraRaw Temperature Temperatur +cameraRaw Texture Tekstur +cameraRaw Tint Nuance +cameraRaw Whites Hvide +colorRange Invert Invertér +document Orientation Orientering +layerLabel Magenta Magenta +layerLabel No Color Ingen farve +layerLabel Red Rød +layers Fill Udfyldning +layers Fill: Udfyldning: +navigator Zoom Zoom +shape Appearance Udseende +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatisk afsnitsretning + Left-to-right paragraph direction Afsnitsretning fra venstre mod højre + Right-to-left paragraph direction Afsnitsretning fra højre mod venstre + Not rendered: this adjustment ({key}) is kept as imported. Ikke gengivet: denne justering ({key}) bevares som importeret. +# Print preview image (#8236) + Print preview image Forhåndsvisningsbillede til udskrift +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Farvetonekube + Brightness Cube Lysstyrkekube + Color Wheel Farvehjul + RGB Sliders RGB-skydere + HSB Sliders HSB-skydere + CMYK Sliders CMYK-skydere + Lab Sliders Lab-skydere + Web Color Sliders Webfarveskydere + Copy Color as HTML Kopiér farve som HTML + Copy Color's Hex Code Kopiér farvens hex-kode + RGB Spectrum RGB-spektrum + CMYK Spectrum CMYK-spektrum + Grayscale Ramp Gråtonerampe + Current Colors Aktuelle farver + Make Ramp Web Safe Gør rampe websikker +# Art History Brush (#213). + Art History Brush Tool Kunstnerisk historikpenselværktøj + Tight Short Stram kort + Tight Medium Stram mellem + Tight Long Stram lang + Loose Medium Løs mellem + Loose Long Løs lang + Dab Dup + Tight Curl Stram krølle + Loose Curl Løs krølle + Stylized dabs from a history state Stiliserede dup fra en historiktilstand + Font size presets Forudindstillinger for skriftstørrelse + Leading presets Forudindstillinger for linjeafstand + Tracking presets Forudindstillinger for spatiering + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-gengivelse + Calculating… Beregner… + Click to refresh histogram with uncached data Klik for at opdatere histogrammet med ikke-cachelagrede data + Convert to Shape… Konvertér til form… + Cooling Filter (80) Afkølingsfilter (80) + Cooling Filter (82) Afkølingsfilter (82) + Cooling Filter (LBB) Afkølingsfilter (LBB) + Count: Antal: + Delete Hidden Layers Slet skjulte lag + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Træk på billedet for at tilføje en begrænsning; Shift for vandret/lodret; højreklik for at slette. + Drawn on the CPU: {reason}. Click for System Info. Tegnet på CPU'en: {reason}. Klik for at se Systemoplysninger. + Exported {n} files Eksporterede {n} filer + Fit Layer on Screen Tilpas lag til skærmen + Flow: Gennemstrømning: + Level: Niveau: + Percentile: Percentil: + Refine Edge… Forfin kant… + Remember Settings Husk indstillinger + Reset Essentials Nulstil Grundlæggende + Rounded Rectangle Tool Afrundet rektangelværktøj + Show Channels in Color Vis kanaler i farve + Show Statistics Vis statistik + Source: Kilde: + Status Bar Statuslinje + Tile Horizontally Side om side vandret + Tile Vertically Side om side lodret + Uncached Refresh Opdatering uden cache + Warming Filter (81) Opvarmningsfilter (81) + Warming Filter (LBA) Opvarmningsfilter (LBA) +@plural {n} document|{n} documents {n} dokument|{n} dokumenter +@plural {n} pixel|{n} pixels {n} pixel|{n} pixels +cameraRaw Before (Y) Før (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) +cameraRaw Camera Raw isn't open Camera Raw er ikke åben +cameraRaw Cancel Annullér +cameraRaw Cancelled opening {name} Annullerede åbning af {name} +cameraRaw Cancelled {label} Annullerede {label} +cameraRaw Color Priority Farveprioritet +cameraRaw Developing {name} Fremkalder {name} +cameraRaw Full-resolution preview unavailable Forhåndsvisning i fuld opløsning er ikke tilgængelig +cameraRaw Grain Korn +cameraRaw Highlight Clipping Warning (O) Advarsel om højlysklipning (O) +cameraRaw Highlight Priority Højlysprioritet +cameraRaw Load Settings… Indlæs indstillinger… +cameraRaw Load settings from a Camera Raw .xmp file Indlæs indstillinger fra en Camera Raw .xmp-fil +cameraRaw Loaded Camera Raw settings from {file} Indlæste Camera Raw-indstillinger fra {file} +cameraRaw Noise Reduction Støjreduktion +cameraRaw OK OK +cameraRaw Open Åbn +cameraRaw Opened {name} Åbnede {name} +cameraRaw Paint Overlay Malingsoverlejring +cameraRaw Save Settings… Gem indstillinger… +cameraRaw Save these settings as a Camera Raw .xmp file Gem disse indstillinger som en Camera Raw .xmp-fil +cameraRaw Shadow Clipping Warning (U) Advarsel om skyggeklipning (U) +cameraRaw Sharpening Skærpning +cameraRaw Show Lab Color Readouts Vis Lab-farveaflæsninger +cameraRaw Show Red at 3 o'clock Vis rødt kl. 3 +cameraRaw Show Selected Region Vis markeret område +cameraRaw Show Skin Tone Indicator Vis indikator for hudtone +cameraRaw Show Vectorscope Vis vektorskop +cameraRaw Tone Histogram Tonehistogram +cameraRaw Vignetting Vignettering +cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet + Current Layer: Aktuelt lag: + Delete effect Slet effekt + Move effect down Flyt effekten ned + Move effect up Flyt effekten op + Path operations Stihandlinger + The path was discarded Stien blev kasseret + The path was kept as the Work Path Stien blev bevaret som arbejdssti + Edit Brush… Rediger pensel… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c1dfc027ed..45fb326bea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -16,20 +16,116 @@ # 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) + +# Guide layout dialog + +# Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Layer Style dialog: style presets and per-effect defaults + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color +# Status messages, system info and tool names +# Shortcut editor commands without a menu item +# Document Info and color profiles +# Color Lookup, Shadows/Highlights, channels and Calculations +# Measurement log +# Lens Correction, Blur Gallery and distortion filters +# Render filters: Flame, Picture Frame, Tree +# Lens, Lighting Effects, depth and mosaic filters +# Find and Replace, image size, Warp and Layer Style blending options + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# Export As metadata and profile options + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Mask Options… Maskenoptionen… + Layer Mask Display Options… Anzeigeoptionen für Ebenenmasken… Channel Color Kanalfarbe Color Indicates: Farbe kennzeichnet: Solidity: Solidität: Masked Areas Maskierte Bereiche Selected Areas Ausgewählte Bereiche Ink Characteristics Farbeigenschaften - -# Guide layout dialog - -# Custom print paper (#3820) Paper Width Papierbreite Paper Height Papierhöhe Columns Spalten @@ -49,7 +145,6 @@ 5 Columns, 3 Rows 5 Spalten, 3 Zeilen 8 Columns 8 Spalten 12 Columns 12 Spalten - File Datei Edit Bearbeiten Image Bild @@ -458,6 +553,7 @@ Intersect Shape Areas Schnittmenge der Formen Exclude Overlapping Shapes Überlappende Formen ausschließen Merge Shape Components Formkomponenten zusammenfügen + Path operations Pfadvorgänge Align Ausrichten Top Edges Obere Kanten Vertical Centers Vertikale Mitten @@ -537,7 +633,6 @@ Color Range… Farbbereich… Focus Area… Schärfebereich… Select and Mask… Auswählen und maskieren… -# Select and Mask dialog labels Smart Radius Smartradius Shift Edge Kante verschieben Decontaminate Dekontaminieren @@ -564,7 +659,6 @@ Blur Weichzeichnen Blur Gallery Weichzeichnen-Galerie Field Blur… Feldweichzeichner… -# Field Blur pins (#3373) No pin selected Kein Pin ausgewählt Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicken Sie ins Bild, um einen Pin hinzuzufügen; ziehen Sie einen Pin, um ihn zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Entf entfernt den ausgewählten Pin. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Ziehen Sie die Griffe am Umriss, um ihn zu skalieren und zu drehen, das Quadrat, um die Ecken abzurunden, und die Punkte innen, um festzulegen, wo die Unschärfe beginnt. @@ -832,6 +926,9 @@ Add Layer Mask Ebenenmaske hinzufügen Add Stop Farbstopp hinzufügen Add a layer style Ebenenstil hinzufügen + Move effect up Effekt nach oben verschieben + Move effect down Effekt nach unten verschieben + Delete effect Effekt löschen Add a mask (from the selection; {key} inverts) Maske hinzufügen (aus der Auswahl; {key} kehrt um) Add an adjustment Anpassung hinzufügen Add disk Festplatte hinzufügen @@ -1060,8 +1157,6 @@ Delete current channel Aktuellen Kanal löschen Delete current state Aktuellen Status löschen Delete effect layer Effektebene löschen - -# Layers-panel trash confirmation Delete layer ‘{name}’? Ebene „{name}“ löschen? Delete {count} layers? {names} {count} Ebenen löschen? {names} Delete layer? Ebene löschen? @@ -1070,7 +1165,6 @@ Missing delete command Löschbefehl fehlt Invalid delete command Ungültiger Löschbefehl Layer deleted, but confirmation preference could not be saved: {error} Die Ebene wurde gelöscht, aber die Bestätigungseinstellung konnte nicht gespeichert werden: {error} - Delete layer Ebene löschen Delete layer comp Ebenenkomposition löschen Delete note Notiz löschen @@ -2176,7 +2270,6 @@ Align with layer An Ebene ausrichten Reset Alignment Ausrichtung zurücksetzen Centre the gradient (offset 0, 0) Verlauf zentrieren (Versatz 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Gradienten-Editor Gradient picker Verlaufsauswahl Gradient stops Verlaufsstufen @@ -2386,8 +2479,6 @@ Don't show again Nicht mehr anzeigen Save failed: {error} Speichern fehlgeschlagen: {error} Save cancelled Speichern abgebrochen - -# Layer Style dialog: style presets and per-effect defaults Style {name} saved Stil {name} gespeichert Make Default Als Standard festlegen Reset to Default Auf Standard zurücksetzen @@ -2396,8 +2487,6 @@ Magnetic Lasso Tool Magnetisches-Lasso-Werkzeug Frequency Frequenz Use tablet pressure to change pen width Tablettdruck zum Ändern der Stiftbreite verwenden - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About Über Contributors Mitwirkende Models Modelle @@ -2428,8 +2517,6 @@ % of all commits % aller Commits Lines +/− Zeilen +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{binAdded} / −{binDeleted} Binärdateien, {first} – {last} - -# Swatches panel Create new swatch Neues Farbfeld erstellen New Swatch… Neues Farbfeld… New Swatch Group… Neue Farbfeldgruppe… @@ -2439,13 +2526,11 @@ Export Swatches for Exchange… Farbfelder für Austausch exportieren… Reset Swatches Farbfelder zurücksetzen Add to Swatches Zu Farbfeldern hinzufügen -# Layer Style > Blending Options (Advanced Blending) Blend If: Farbbereich: + Current Layer: Aktuelle Ebene: Channels: Kanäle: Knockout Aussparung Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Farbwähler (Textfarbe) Color Picker ({name}) Farbwähler ({name}) Color Picker (Fill Color) Farbwähler (Füllfarbe) @@ -2462,8 +2547,6 @@ Show/Hide Panels Bedienfelder ein-/ausblenden Could not open Öffnen nicht möglich {path}: the path is not valid Unicode; rename the file and open it again. {path}: Der Dateipfad enthält ungültige Zeichen. Benenne die Datei um und öffne sie erneut. - -# Fill dialog, layer style contours and Image Size Contents: Inhalt: Custom Pattern: Eigenes Muster: Foreground Color Vordergrundfarbe @@ -2490,7 +2573,6 @@ Rolling Slope - Descending Wellenhang - absteigend Rounded Steps Abgerundete Stufen Sawtooth 1 Sägezahn 1 - Red Eye Tool Rote-Augen-Werkzeug Red Eye Rote Augen Pupil Size Pupillengröße @@ -2498,7 +2580,6 @@ Click a red pupil to neutralize it Klick auf die Pupille, um die Rotfärbung zu entfernen. open source Open Source …and {n} more …und {n} weitere - Eyedroppers: Pipetten: Set Black Point Schwarzpunkt festlegen Set Neutral Gray Point Neutralen Graupunkt festlegen @@ -2507,8 +2588,6 @@ Selection Preview: Auswahlvorschau: Black Matte Schwarzer Hintergrund White Matte Weißer Hintergrund - -# Layer colour labels. layerLabel No Color Keine Farbe Seafoam Meeresschaum Indigo Indigo @@ -2530,8 +2609,6 @@ fontWeight Bold Fett fontWeight ExtraBold Extrafett fontWeight Black Sehr fett (Black) fontWeight Italic Kursiv - -# Pattern Stamp tool Pattern Stamp Tool Musterstempel-Werkzeug Pattern Stamp Musterstempel Impressionist Impressionistisch @@ -2539,13 +2616,9 @@ fontWeight Italic Kursiv Rotate View Tool Ansicht-drehen-Werkzeug Reset View Ansicht zurücksetzen Drag around the centre to rotate the view · Shift constrains to 15° Ansicht drehen: im Bogen ziehen · Umschalt: 15°-Schritte - -# Painting symmetry presets. Dual Axis Duale Achse Diagonal Diagonal Transform Symmetry Symmetrie transformieren - -# Eyedropper Sample Size and Sample (#1649). Point Sample 1 Pixel 3 by 3 Average 3 x 3 Pixel Durchschnitt 5 by 5 Average 5 x 5 Pixel Durchschnitt @@ -2557,15 +2630,12 @@ fontWeight Italic Kursiv All Layers No Adjustments Alle Ebenen ohne Einstellungen Current & Below No Adjustments Aktuelle und darunter ohne Einstellungen Show Sampling Ring Aufnahmering anzeigen - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe Color to Alpha… Farbe zu Alpha… Transparency Threshold Transparenz-Schwellenwert Opacity Threshold Deckkraft-Schwellenwert PhotoCraft — Screen Color Picker PhotoCraft — Bildschirmfarbenauswahl - Rasterize svg groups deeper than SVG-Gruppen ab dieser Tiefe rastern 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 schaltet diese Schwelle aus; SVG-Gruppen, die tiefer als 100 Ebenen verschachtelt sind, werden immer gerastert. Rule of Thirds Drittel-Regel @@ -2579,8 +2649,6 @@ fontWeight Italic Kursiv Cycle Orientation Ausrichtung wechseln Set the overlay options for the Crop tool Überlagerungsoptionen für das Freistellungswerkzeug festlegen 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. Der Alt-Klick ging an die Arbeitsumgebung, die Fenster mit Alt-Ziehen verschiebt. Stelle dort die Taste zum Verschieben von Fenstern auf Super oder raste Alt unter Fenster › Modifikatortasten ein. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Farbwähler (Ebenenstilfarbe) Auto Hide Notifications Benachrichtigungen automatisch ausblenden Notification Duration (seconds) Dauer der Benachrichtigung (Sekunden) @@ -2657,7 +2725,6 @@ fontWeight Italic Kursiv Appearance Settings… Erscheinungsbild-Einstellungen… Add to favorites Zu Favoriten hinzufügen Remove from favorites Aus Favoriten entfernen -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Kameradaten cameraData Make Hersteller cameraData Model Modell @@ -2668,7 +2735,6 @@ cameraData ISO Speed ISO-Empfindlichkeit cameraData Focal Length Brennweite cameraData Focal Length (35 mm) Brennweite (35 mm) cameraData Lens Objektiv -# Shared panel docking Activate Panel Bedienfeld aktivieren Change Panel Group Bedienfeldgruppe ändern Change Panel Layout Bedienfeldlayout ändern @@ -2712,7 +2778,6 @@ cameraData Lens Objektiv Snap to Origin An Ursprung ausrichten Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen -# Edit › Content-Aware Fill workspace Sampling Brush Tool Aufnahmepinsel-Werkzeug Sampling Area Overlay Überlagerung des Aufnahmebereichs Show Sampling Area Aufnahmebereich einblenden @@ -2730,7 +2795,6 @@ cameraData Lens Objektiv Output To Ausgabe in Duplicate Layer Ebene duplizieren Contract Verkleinern - Animation Options Animationsoptionen Entire Document Gesamtes Dokument Filter Options Filteroptionen @@ -2750,8 +2814,6 @@ cameraData Lens Objektiv 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) @@ -2786,7 +2848,6 @@ curves Smooth Glätten Fix the red plane first: its corners must form a convex quad Korrigieren Sie zuerst die rote Ebene: Ihre Ecken müssen ein konvexes Viereck bilden Alt-click a plane to set the clone source Alt-Klick auf eine Ebene legt die Kopierquelle fest Too many clone strokes: apply Vanishing Point first Zu viele Kopierstriche: Wenden Sie zuerst Fluchtpunkt an -# Select and Mask view modes (#1754) Onion Skin Zwiebelschicht Marching Ants Laufende Ameisen On Black Auf Schwarz @@ -2795,7 +2856,6 @@ curves Smooth Glätten Show Edge Kante anzeigen Show Original Original anzeigen F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Kante-verbessern-Pinsel (R) Expand Detection Area Erkennungsbereich erweitern Restore Original Edge Ursprüngliche Kante wiederherstellen @@ -2803,22 +2863,16 @@ curves Smooth Glätten Clear Brush Strokes Pinselstriche löschen Show rulers Lineale anzeigen Presentation Präsentation - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Nicht-lineare Protokolle zulassen Make Layer Visibility Changes Undoable Änderungen der Ebenensichtbarkeit widerrufbar machen -# Print dialog (#2697) Emulsion Down Schichtseite unten Functions Funktionen Print Resolution: Druckauflösung: @@ -2830,7 +2884,6 @@ curves Smooth Glätten Apply mask to layer before removing? Maske vor dem Entfernen auf die Ebene anwenden? The layer or its mask changed. Drag the mask onto the Trash again. Die Ebene oder ihre Maske hat sich geändert. Ziehen Sie die Maske erneut in den Papierkorb. Delete the vector mask? Vektormaske löschen? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} Farbfeld nach {file} exportiert|{n} Farbfelder nach {file} exportiert @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} Pinsel aus {group} importiert|{n} Pinsel aus {group} importiert @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} Farbfeld aus {file} importiert|{n} Farbfelder aus {file} importiert @@ -2845,13 +2898,11 @@ curves Smooth Glätten @plural {n} step|{n} steps {n} Schritt|{n} Schritte @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Pinselvorgaben-Warnung)|{warning} (+{n} weitere Pinselvorgaben-Warnungen) @plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen -# View › Guides › New Guide… › Color Light Blue Hellblau Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau Stop Stopp -# Status messages, system info and tool names Join the ArtCraft Discord ({url}) Dem ArtCraft-Discord beitreten ({url}) Nothing to paste: the clipboard holds no image Nichts einzufügen: Die Zwischenablage enthält kein Bild Graphics adapter: {adapter} Grafikadapter: {adapter} @@ -2874,7 +2925,6 @@ curves Smooth Glätten List view Listenansicht Proof Setup… Proof einrichten… 32-bit Preview Options 32-Bit-Vorschauoptionen -# Shortcut editor commands without a menu item Toggle Layer Mask Overlay Ebenenmasken-Überlagerung umschalten Increase Type Size Schriftgröße erhöhen Decrease Type Size Schriftgröße verringern @@ -2910,7 +2960,6 @@ curves Smooth Glätten Zoom In (hold, drag to scrub) Vergrößern (gedrückt halten, zum Zoomen ziehen) Zoom Out (hold) Verkleinern (gedrückt halten) Erase with Brush or Pencil (hold) Mit Pinsel oder Buntstift radieren (gedrückt halten) -# Document Info and color profiles Author Title Position des Urhebers Copyright Status Urheberrechtsstatus Copyright Info URL URL für Urheberrechtsinfo @@ -2934,7 +2983,6 @@ curves Smooth Glätten Intent Renderpriorität Bpc Schwarzpunktkompensierung Simulate Paper Papierfarbe simulieren -# Color Lookup, Shadows/Highlights, channels and Calculations Warming Filter (85) Wärmefilter (85) Lut LUT Cool Kühl @@ -2976,7 +3024,6 @@ curves Smooth Glätten Channel2 Kanal 2 Channel3 Kanal 3 Channel4 Kanal 4 -# Measurement log Label Beschriftung Date and Time Datum und Uhrzeit Scale Units Maßstabseinheiten @@ -2992,7 +3039,6 @@ curves Smooth Glätten Length Länge W: B: A: Wi: -# Lens Correction, Blur Gallery and distortion filters Pattern Fill… Musterfüllung… Generic Allgemein Correct Distortion Verzerrung korrigieren @@ -3068,7 +3114,6 @@ curves Smooth Glätten Short Strokes Kurze Striche Medium Strokes Mittlere Striche Long Strokes Lange Striche -# Render filters: Flame, Picture Frame, Tree Flame Type Flammentyp One Flame Along Path Eine Flamme entlang des Pfads Multiple Flames Along Path Mehrere Flammen entlang des Pfads @@ -3123,7 +3168,6 @@ curves Smooth Glätten Custom Branch Color Eigene Astfarbe Branches Color Astfarbe Flat Shading Flächige Schattierung -# Lens, Lighting Effects, depth and mosaic filters Lens Objektiv Prime35 Festbrennweite 35 Prime105 Festbrennweite 105 @@ -3181,7 +3225,6 @@ curves Smooth Glätten Row Order Zeilenreihenfolge Preserve Erhalten Squareness Rechteckigkeit -# Find and Replace, image size, Warp and Layer Style blending options Any Beliebig Cmyk CMYK To Nach @@ -3200,7 +3243,6 @@ curves Smooth Glätten Transparency Shapes Layer Transparenz bestimmt die Ebenenform Layer Mask Hides Effects Ebenenmaske blendet Effekte aus Vector Mask Hides Effects Vektormaske blendet Effekte aus - This Layer: Diese Ebene: Underlying Layer: Darunterliegende Ebene: Up Nach oben Checkerboard Schachbrett @@ -3229,8 +3271,6 @@ curves Smooth Glätten Clear Brush Controls Pinselsteuerungen löschen Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Aktionen laden… Save Actions… Aktionen speichern… New Action… Neue Aktion… @@ -3242,10 +3282,11 @@ curves Smooth Glätten @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} Schritt hat noch keine Entsprechung in PhotoCraft|{n} Schritte haben noch keine Entsprechung in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen - +@plural {n} action file loaded|{n} action files loaded {n} Aktionsdatei geladen|{n} Aktionsdateien geladen + {set} from {file} replaced the one from {earlier} Satz {set} aus {file} hat den aus {earlier} ersetzt +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} Datei konnte nicht geladen werden: {errors}|{n} Dateien konnten nicht geladen werden: {errors} File Naming Dateibenennung Starting serial Startnummer -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Stopp einfügen… Save Set… Set speichern… Message: Meldung: @@ -3257,17 +3298,15 @@ curves Smooth Glätten @plural {n} action|{n} actions {n} Aktion|{n} Aktionen Background (Default) Hintergrund (Standard) Content-Aware Fill Inhaltsbasiert füllen -# Batch / Image Processor Include All Subfolders Alle Unterordner einschließen Auto Add/Delete Autom. hinzuf./löschen - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Aktiviere nicht zusätzlich das Spiegelbild in den Druckeinstellungen: Die beiden Spiegelungen heben sich auf. Print Settings… Druckeinstellungen… Remember to turn off the printer's own colour management in Print Settings… Denke daran, die eigene Farbverwaltung des Druckers in den Druckeinstellungen auszuschalten… 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 + Memory usage percent Speichernutzung (%) Show Favorite Fonts Favorisierte Schriften anzeigen No favorite fonts yet: click the star beside a font to add it. Noch keine favorisierten Schriften: Klicken Sie auf den Stern neben einer Schrift, um sie hinzuzufügen. Favorite fonts Favorisierte Schriften @@ -3306,51 +3345,33 @@ curves Smooth Glätten Could not write {name}: {error} {name} konnte nicht geschrieben werden: {error} Kept {name}: written by another application {name} beibehalten: von einer anderen Anwendung geschrieben Shadow Tint Schattentönung -# PhotoCraft release checks. Check for Updates… Nach Updates suchen… - PhotoCraft Updates PhotoCraft-Updates - Checking for updates… Suche nach Updates… Open the release page to choose an installer for your system. Öffnen Sie die Versionsseite und wählen Sie ein Installationspaket für Ihr System. - Open Download Page Downloadseite öffnen - PhotoCraft is up to date. PhotoCraft ist auf dem neuesten Stand. - Could not check for updates. Updates konnten nicht geprüft werden. - Updates have not been checked yet. Es wurde noch nicht nach Updates gesucht. - Check Again Erneut prüfen Automatically Check for Updates Automatisch nach Updates suchen - Installed version: {version} Installierte Version: {version} - PhotoCraft {version} is available. PhotoCraft {version} ist verfügbar. -# 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}) Remember this choice for future images Diese Auswahl für künftige Bilder merken Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Die Abfrage lässt sich mit „Ask On Mismatch“ unter Bearbeiten › Farbeinstellungen wieder einschalten. Button Mode Schaltflächenmodus Button Mode On Schaltflächenmodus an - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Benutzeroberfläche abdunkeln Lighten Interface Benutzeroberfläche aufhellen -# Bevel & Emboss Technique. Technique Technik 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 + Save Full-Size Composite in .pcraft Files Kompositbild in voller Größe in .pcraft-Dateien speichern Color Profile Farbprofil EXIF EXIF - -# 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 @@ -3375,8 +3396,6 @@ 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 @@ -3394,11 +3413,9 @@ curves Smooth Glätten Undo Changes Änderungen rückgängig machen {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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann, höchstens 3/8 ihres Speichers. Automatisch folgt der Speichernutzung, innerhalb eines Viertels des Arbeitsspeichers dieses Computers und 3/8 des Kartenspeichers, und rechnet das Dokument bei einer Karte mit bekanntem Speicher nicht an. Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. GPU Memory Full GPU-Speicher voll @@ -3408,6 +3425,7 @@ curves Smooth Glätten 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) + Scratch Size Limit (MB, 0 = No Limit) Größenlimit für Arbeitsvolume (MB, 0 = kein Limit) {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. @@ -3420,8 +3438,6 @@ curves Smooth Glätten 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}% @@ -3432,18 +3448,22 @@ curves Smooth Glätten In Front Davor Behind Dahinter Blend Mischen - -# DDS Options (#1327) DDS Options DDS-Optionen 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 +smartSvg Edit SVG Smart Object SVG-Smart-Objekt bearbeiten +smartSvg Choose the application used to edit this SVG: Wählen Sie die Anwendung zum Bearbeiten dieses SVG: +smartSvg Other application Andere Anwendung +smartSvg PhotoCraft (internal editor) PhotoCraft (interner Editor) +smartSvg Choose executable… Programm auswählen… +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Speichern Sie die geöffnete SVG-Datei. Jede gespeicherte Änderung wird in PhotoCraft zu einem Rückgängig-Schritt. +smartSvg Updates wait while another document or an editing dialog is active. Aktualisierungen warten, während ein anderes Dokument oder ein Bearbeitungsdialog aktiv ist. +smartSvg Stop syncing this Smart Object Synchronisierung dieses Smart-Objekts beenden +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-Editor geöffnet. Speichern Sie das SVG, um dieses Smart-Objekt zu aktualisieren. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS-Inhalte werden als SVG bearbeitet. Speichern ersetzt die eingebettete Quelle; formatspezifische Eigenschaften können sich ändern. # Styles panel: Import / Export Styles (.asl) Import Styles… Stile importieren… @@ -3452,7 +3472,6 @@ curves Smooth Glätten {file} is not a style library (.asl) {file} ist keine Stilbibliothek (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {n} Stil aus {file} importiert|{n} Stile aus {file} importiert @plural Exported {n} style to {file}|Exported {n} styles to {file} {n} Stil nach {file} exportiert|{n} Stile nach {file} exportiert -# 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 @@ -3461,14 +3480,11 @@ curves Smooth Glätten 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Farbtonwürfel Brightness Cube Helligkeitswürfel Color Wheel Farbkreis @@ -3485,7 +3501,8 @@ curves Smooth Glätten Grayscale Ramp Graustufenbalken Current Colors Aktuelle Farben Make Ramp Web Safe Farbbalken auf Webfarben beschränken -# Art History Brush (#213). + The path was kept as the Work Path Der Pfad wurde als Arbeitspfad beibehalten + The path was discarded Der Pfad wurde verworfen Art History Brush Künstlerischer Protokollpinsel Art History Brush Tool Künstlerischer-Protokollpinsel-Werkzeug Tight Short Eng kurz @@ -3497,8 +3514,6 @@ curves Smooth Glätten Tight Curl Eng gewellt Loose Curl Locker gewellt Stylized dabs from a history state Stilisierte Tupfer aus einem Protokollzustand - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio Seitenverhältnis Category Kategorie Color Table Farbtabelle @@ -3509,3 +3524,30 @@ curves Smooth Glätten Font size presets Schriftgrößen-Vorgaben Leading presets Zeilenabstand-Vorgaben Tracking presets Laufweiten-Vorgaben + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ungültige Zahleneingabe. Es ist eine ganze Zahl zwischen {min}% und {max}% erforderlich. Der vorherige Wert wurde eingesetzt. + Clear Selection Auswahl löschen + Color Aware Farbbezogen + Decontaminate Colors Farben dekontaminieren + Edge Detection Kantenerkennung + Global Refinements Globale Verbesserungen + High Quality Preview Hochwertige Vorschau + Object Aware Objektbezogen + Real-Time Refinement Verbesserung in Echtzeit + Refine Edge Brush Tool Kante-verbessern-Pinsel-Werkzeug + Refine Hair Haare verbessern + Refine Mode Verbesserungsmodus + Remember Settings Einstellungen merken + View Mode Ansichtsmodus + Object Aware needs a segmentation model, which isn't available yet Objektbezogen benötigt ein Segmentierungsmodell, das noch nicht verfügbar ist + Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist + Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv + Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker Farbwähler + Recently Used Colors Zuletzt verwendete Farben + Stop Color Farbe der Farbunterbrechung + Shapes can't use patterns yet Formen können noch keine Muster verwenden + Solid Color Volltonfarbe + Tint Color Tönungsfarbe + Edit Brush… Pinsel bearbeiten… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2b35dbbdc..8f20dd6705 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4,18 +4,106 @@ # 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: Το χρώμα υποδεικνύει: - Solidity: Συμπαγότητα: - Ink Characteristics Χαρακτηριστικά μελανιού # Guide layout dialog # Custom print paper (#3820) + + +# Consolidated Greek localization additions +# Select and Mask dialog labels +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Greek audit completion – menus and layers + +# Final interface terminology refinements + +# Greek full-localization recovery additions + +# Layers-panel trash confirmation + +# Field Blur pins (#3373) + +# Greek tool names, original English in parentheses + +# Labels added on main since the first batch + +# Screen color picker (#1232) + +# Saved New Document presets (#1516) + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker + +# Shared text style Color Picker (#2015) +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# Export As metadata and profile options + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Mask Options… Επιλογές μάσκας… + Layer Mask Display Options… Επιλογές εμφάνισης μάσκας επιπέδου… + Channel Color Χρώμα καναλιού + Color Indicates: Το χρώμα υποδεικνύει: + Solidity: Συμπαγότητα: + Ink Characteristics Χαρακτηριστικά μελανιού Paper Width Πλάτος χαρτιού Paper Height Ύψος χαρτιού Rows Γραμμές @@ -33,7 +121,6 @@ 5 Columns, 3 Rows 5 στήλες, 3 γραμμές 8 Columns 8 στήλες 12 Columns 12 στήλες - File Αρχείο Edit Επεξεργασία Image Εικόνα @@ -212,8 +299,6 @@ Pixelate Εικονοστοιχείωση Render Απόδοση About PhotoCraft Πληροφορίες για το PhotoCraft - -# Consolidated Greek localization additions Content-Aware Fill… Γέμισμα βάσει περιεχομένου… Content-Aware Scale Κλιμάκωση βάσει περιεχομένου Puppet Warp Παραμόρφωση με σημεία ελέγχου @@ -258,7 +343,6 @@ Mask All Objects Απόκρυψη όλων των αντικειμένων με μάσκα Update All Text Layers Ενημέρωση όλων των επιπέδων κειμένου Select and Mask… Επιλογή και μάσκα… -# Select and Mask dialog labels Smart Radius Έξυπνη ακτίνα Shift Edge Μετατόπιση άκρης Decontaminate Απομάκρυνση επιμόλυνσης @@ -1602,7 +1686,6 @@ Centre the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0) Refine Strokes Πινελιές βελτίωσης Edits Επεξεργασίες -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Επεξεργασία διαβάθμισης Gradient picker Επιλογή διαβάθμισης Gradient stops Σημεία διαβάθμισης @@ -1727,8 +1810,6 @@ Rulers (⌘R) Χάρακες (⌘R) Show Guides (⌘;) Εμφάνιση οδηγών (⌘;) Grid (⌘') Πλέγμα (⌘') - -# Greek audit completion – menus and layers Free Transform a Copy Ελεύθερος μετασχηματισμός αντιγράφου Again Επανάληψη Transform Again on a Copy Επανάληψη μετασχηματισμού σε αντίγραφο @@ -1929,6 +2010,7 @@ Intersect Shape Areas Τομή περιοχών σχημάτων Exclude Overlapping Shapes Εξαίρεση επικαλυπτόμενων σχημάτων Merge Shape Components Συγχώνευση συστατικών σχήματος + Path operations Λειτουργίες διαδρομών Top Edges Επάνω ακμές Vertical Centers Κατακόρυφα κέντρα Bottom Edges Κάτω ακμές @@ -1950,8 +2032,6 @@ Glyphs Panel Πλαίσιο γλυφών Anti-Alias Εξομάλυνση ακμών Orientation Προσανατολισμός - -# Final interface terminology refinements PDF Presentation… Παρουσίαση PDF… Picture Package… Πακέτο εικόνων… Web Photo Gallery… Συλλογή φωτογραφιών ιστού… @@ -2018,8 +2098,6 @@ Mix Ανάμειξη 8 bit 8 bit ανά κανάλι 16 bit 16 bit ανά κανάλι - -# Greek full-localization recovery additions (print to the printer) (εκτύπωση στον εκτυπωτή) + Add variable + Προσθήκη μεταβλητής 2-up Horizontal Δύο οριζόντια @@ -2046,6 +2124,9 @@ Add Pins Προσθήκη καρφιτσών Add Stop Προσθήκη σημείου διαβάθμισης Add a layer style Προσθήκη στυλ επιπέδου + Move effect up Μετακίνηση εφέ προς τα πάνω + Move effect down Μετακίνηση εφέ προς τα κάτω + Delete effect Διαγραφή εφέ Add a mask (from the selection; {key} inverts) Προσθήκη μάσκας (από την επιλογή· το {key} αντιστρέφει) Add a mask (from the selection; ⌥ inverts) Προσθήκη μάσκας (από την επιλογή· ⌥ για αντιστροφή) Add an adjustment Προσθήκη προσαρμογής @@ -2327,8 +2408,6 @@ Delete Pins Διαγραφή καρφιτσών Delete Workspace Διαγραφή χώρου εργασίας Delete Workspace… Διαγραφή χώρου εργασίας… - -# Layers-panel trash confirmation Delete layer ‘{name}’? Διαγραφή του επιπέδου «{name}»; Delete {count} layers? {names} Διαγραφή {count} επιπέδων; {names} Delete layer? Διαγραφή επιπέδου; @@ -2337,7 +2416,6 @@ Missing delete command Λείπει η εντολή διαγραφής Invalid delete command Μη έγκυρη εντολή διαγραφής Layer deleted, but confirmation preference could not be saved: {error} Το επίπεδο διαγράφηκε, αλλά δεν ήταν δυνατή η αποθήκευση της προτίμησης επιβεβαίωσης: {error} - Delete layer Διαγραφή επιπέδου Density: Πυκνότητα: Depth Βάθος @@ -2375,7 +2453,7 @@ 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 Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας + Drag to move the selected path Σύρετε για μετακίνηση της επιλεγμένης διαδρομής Dual Brush Διπλό πινέλο Duplicate Path Διπλασιασμός διαδρομής Rename Path… Μετονομασία διαδρομής… @@ -2430,7 +2508,6 @@ Fibers… Ίνες… Field Blur Θόλωση πεδίου Field Blur… Θόλωση πεδίου… -# Field Blur pins (#3373) No pin selected Δεν έχει επιλεγεί καρφίτσα Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Κάντε κλικ στην εικόνα για να προσθέσετε καρφίτσα· σύρετε μια καρφίτσα για να τη μετακινήσετε ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Το Delete αφαιρεί την επιλεγμένη καρφίτσα. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Σύρετε τις λαβές στο περίγραμμα για αλλαγή μεγέθους και περιστροφή, το τετράγωνο για στρογγύλεμα των γωνιών και τις κουκκίδες μέσα για να ορίσετε πού αρχίζει η θόλωση. @@ -3352,8 +3429,6 @@ cameraRaw Tint Χρωματική απόχρωση cameraRaw Vibrance Ζωντάνια χρωμάτων cameraRaw Whites Λευκά cameraRaw Yellow Κίτρινο - -# Greek tool names, original English in parentheses Ruler Tool Χάρακας (Ruler Tool) Count Tool Καταμέτρηση (Count Tool) Background Eraser Tool Γόμα φόντου (Background Eraser Tool) @@ -3431,8 +3506,6 @@ cameraRaw Yellow Κίτρινο Quit {app} Τερματισμός του {app} Services Υπηρεσίες Hide Others Απόκρυψη άλλων - -# Labels added on main since the first batch 100% 100% 105% 105% 110% 110% @@ -3449,6 +3522,8 @@ cameraRaw Yellow Κίτρινο Anamorphic 2:1 (2) Αναμορφικό 2:1 (2) ArtCraft ArtCraft Blend If: Ανάμειξη υπό όρους: + Current Layer: Τρέχον επίπεδο: + Underlying Layer: Υποκείμενο επίπεδο: Channels: Κανάλια: Click a red pupil to neutralize it Κάντε κλικ σε μια κόκκινη κόρη για να την εξουδετερώσετε Cmd Cmd @@ -3589,25 +3664,17 @@ fontWeight Medium Μεσαία fontWeight Regular Κανονικά fontWeight SemiBold Ημιέντονα fontWeight Thin Πολύ λεπτά - -# Screen color picker (#1232) Pick screen color Επιλογή χρώματος οθόνης Pick a screen pixel or press Esc to cancel Επιλέξτε ένα εικονοστοιχείο της οθόνης ή πατήστε Esc για ακύρωση Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση Scroll to zoom Κύλιση για μεγέθυνση Drag to pan · Scroll to zoom · Double-click to fit Σύρετε για μετακίνηση · Κυλήστε για μεγέθυνση · Διπλό κλικ για προσαρμογή - -# Saved New Document presets (#1516) Preset Name Όνομα προεπιλογής No saved presets yet. Δεν υπάρχουν ακόμη αποθηκευμένες προεπιλογές. - -# Painting symmetry presets. Dual Axis Διπλός άξονας Diagonal Διαγώνια Transform Symmetry Μετασχηματισμός συμμετρίας - -# Eyedropper Sample Size and Sample (#1649). Point Sample Σημειακό δείγμα 3 by 3 Average Μέσος όρος 3 × 3 5 by 5 Average Μέσος όρος 5 × 5 @@ -3619,15 +3686,12 @@ fontWeight Thin Πολύ λεπτά All Layers No Adjustments Όλα τα επίπεδα χωρίς προσαρμογές Current & Below No Adjustments Τρέχον και υποκείμενα χωρίς προσαρμογές Show Sampling Ring Εμφάνιση δακτυλίου δειγματοληψίας - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web Color to Alpha… Χρώμα σε άλφα… Transparency Threshold Κατώφλι διαφάνειας Opacity Threshold Κατώφλι αδιαφάνειας PhotoCraft — Screen Color Picker PhotoCraft — Επιλογή χρώματος οθόνης - Rasterize svg groups deeper than Ραστεροποίηση ομάδων SVG πέρα από αυτό το βάθος 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. Το 0 απενεργοποιεί αυτό το όριο· ομάδες SVG με ένθεση βαθύτερη από 100 επίπεδα ραστεροποιούνται πάντα. Rule of Thirds Κανόνας των τρίτων @@ -3640,11 +3704,7 @@ fontWeight Thin Πολύ λεπτά Cycle Orientation Εναλλαγή προσανατολισμού Set the overlay options for the Crop tool Ορισμός επιλογών επικάλυψης για το εργαλείο Περικοπή 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-κλικ πήγε στο περιβάλλον εργασίας, που μετακινεί παράθυρα με Alt-σύρσιμο. Ορίστε το πλήκτρο μετακίνησης παραθύρων του σε Super ή κλειδώστε το Alt στο Παράθυρο › Πλήκτρα τροποποίησης. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Επιλογέας χρώματος (χρώμα στυλ επιπέδου) - -# Shared text style Color Picker (#2015) Color Picker (Text Color) Επιλογέας χρώματος (χρώμα κειμένου) Color Picker ({name}) Επιλογέας χρώματος ({name}) Auto Hide Notifications Αυτόματη απόκρυψη ειδοποιήσεων @@ -3713,7 +3773,6 @@ fontWeight Thin Πολύ λεπτά Appearance Settings… Ρυθμίσεις εμφάνισης… Add to favorites Προσθήκη στα αγαπημένα Remove from favorites Αφαίρεση από τα αγαπημένα -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Κατασκευαστής cameraData Model Μοντέλο cameraData Date Taken Ημερομηνία λήψης @@ -3723,7 +3782,6 @@ cameraData ISO Speed Ευαισθησία ISO cameraData Focal Length Εστιακή απόσταση cameraData Focal Length (35 mm) Εστιακή απόσταση (35 mm) cameraData Lens Φακός -# Shared panel docking Activate Panel Ενεργοποίηση πάνελ Change Panel Group Αλλαγή ομάδας πάνελ Change Panel Layout Αλλαγή διάταξης πάνελ @@ -3768,7 +3826,6 @@ cameraData Lens Φακός Snap to Origin Προσκόλληση στην αρχή Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις -# Edit › Content-Aware Fill workspace Sampling Brush Tool Εργαλείο πινέλου δειγματοληψίας Sampling Area Overlay Επικάλυψη περιοχής δειγματοληψίας Show Sampling Area Εμφάνιση περιοχής δειγματοληψίας @@ -3784,7 +3841,6 @@ cameraData Lens Φακός Mirror Κατοπτρισμός Output Settings Ρυθμίσεις εξόδου Output To Έξοδος σε - Animation Options Επιλογές κίνησης Entire Document Ολόκληρο έγγραφο Filter Options Επιλογές φίλτρου @@ -3801,8 +3857,6 @@ cameraData Lens Φακός 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}-κλικ: όλα τα επίπεδα) @@ -3836,7 +3890,6 @@ curves Smooth Εξομάλυνση Fix the red plane first: its corners must form a convex quad Διορθώστε πρώτα το κόκκινο επίπεδο: οι γωνίες του πρέπει να σχηματίζουν κυρτό τετράπλευρο Alt-click a plane to set the clone source Alt-κλικ σε ένα επίπεδο για να ορίσετε την πηγή κλωνοποίησης Too many clone strokes: apply Vanishing Point first Πάρα πολλές πινελιές κλωνοποίησης: εφαρμόστε πρώτα το Σημείο φυγής -# Select and Mask view modes (#1754) Onion Skin Φλούδα κρεμμυδιού Marching Ants Κινούμενο περίγραμμα On Black Σε μαύρο @@ -3845,7 +3898,6 @@ curves Smooth Εξομάλυνση Show Edge Εμφάνιση άκρης Show Original Εμφάνιση αρχικού F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Πινέλο βελτίωσης ακμής (R) Expand Detection Area Επέκταση περιοχής ανίχνευσης Restore Original Edge Επαναφορά αρχικής ακμής @@ -3853,18 +3905,12 @@ curves Smooth Εξομάλυνση Clear Brush Strokes Απαλοιφή πινελιών Show rulers Εμφάνιση χαράκων Presentation Παρουσίαση - -# 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… Επιλογές ιστορικού… -# History panel › History Options… (#2966) Allow Non-Linear History Να επιτρέπεται μη γραμμικό ιστορικό Make Layer Visibility Changes Undoable Οι αλλαγές ορατότητας επιπέδων να αναιρούνται -# Print dialog (#2697) Emulsion Down Γαλάκτωμα προς τα κάτω Functions Λειτουργίες Print Resolution: Ανάλυση εκτύπωσης: @@ -3876,7 +3922,6 @@ curves Smooth Εξομάλυνση Apply mask to layer before removing? Εφαρμογή της μάσκας στο επίπεδο πριν από την αφαίρεση; The layer or its mask changed. Drag the mask onto the Trash again. Το επίπεδο ή η μάσκα του άλλαξε. Σύρετε ξανά τη μάσκα στον Κάδο. Delete the vector mask? Διαγραφή της διανυσματικής μάσκας; -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Εξήχθη {n} δείγμα χρώματος στο {file}|Εξήχθησαν {n} δείγματα χρωμάτων στο {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} Εισήχθη {n} πινέλο από την ομάδα {group}|Εισήχθησαν {n} πινέλα από την ομάδα {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Εισήχθη {n} δείγμα χρώματος από το {file}|Εισήχθησαν {n} δείγματα χρωμάτων από το {file} @@ -3891,7 +3936,6 @@ curves Smooth Εξομάλυνση @plural {n} step|{n} steps {n} βήμα|{n} βήματα @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογών πινέλων)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων) @plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις -# View › Guides › New Guide… › Color Light Blue Ανοιχτό μπλε Light Red Ανοιχτό κόκκινο Medium Blue Μεσαίο μπλε @@ -3912,8 +3956,6 @@ curves Smooth Εξομάλυνση Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Φόρτωση ενεργειών… Save Actions… Αποθήκευση ενεργειών… New Action… Νέα ενέργεια… @@ -3925,10 +3967,11 @@ curves Smooth Εξομάλυνση @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} βήμα δεν έχει ακόμη αντίστοιχο στο PhotoCraft|{n} βήματα δεν έχουν ακόμη αντίστοιχο στο PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop - +@plural {n} action file loaded|{n} action files loaded Φορτώθηκε {n} αρχείο ενεργειών|Φορτώθηκαν {n} αρχεία ενεργειών + {set} from {file} replaced the one from {earlier} Το σύνολο {set} από το {file} αντικατέστησε εκείνο από το {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Δεν ήταν δυνατή η φόρτωση {n} αρχείου: {errors}|Δεν ήταν δυνατή η φόρτωση {n} αρχείων: {errors} File Naming Ονομασία αρχείων Starting serial Αρχικός αριθμός σειράς -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Εισαγωγή διακοπής… Save Set… Αποθήκευση συνόλου… Message: Μήνυμα: @@ -3939,17 +3982,15 @@ curves Smooth Εξομάλυνση Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες Background (Default) Φόντο (προεπιλογή) -# Batch / Image Processor Include All Subfolders Συμπερίληψη όλων των υποφακέλων Auto Add/Delete Αυτόματη προσθήκη/διαγραφή - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Μην ενεργοποιείτε επίσης τον κατοπτρισμό στις ρυθμίσεις εκτύπωσης: οι δύο αναστροφές αλληλοαναιρούνται. Print Settings… Ρυθμίσεις εκτύπωσης… Remember to turn off the printer's own colour management in Print Settings… Θυμηθείτε να απενεργοποιήσετε τη διαχείριση χρωμάτων του εκτυπωτή στις ρυθμίσεις εκτύπωσης… Sent to {printer} Στάλθηκε στον {printer} set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία + Memory usage percent Χρήση μνήμης (%) Show Favorite Fonts Εμφάνιση αγαπημένων γραμματοσειρών No favorite fonts yet: click the star beside a font to add it. Δεν υπάρχουν ακόμη αγαπημένες γραμματοσειρές: κάντε κλικ στο αστέρι δίπλα σε μια γραμματοσειρά για να την προσθέσετε. Favorite fonts Αγαπημένες γραμματοσειρές @@ -3988,50 +4029,32 @@ curves Smooth Εξομάλυνση Could not write {name}: {error} Αδυναμία εγγραφής του {name}: {error} Kept {name}: written by another application Το {name} διατηρήθηκε: γράφτηκε από άλλη εφαρμογή Shadow Tint Χροιά σκιών -# PhotoCraft release checks. Check for Updates… Έλεγχος ενημερώσεων… - PhotoCraft Updates Ενημερώσεις PhotoCraft - Checking for updates… Έλεγχος για ενημερώσεις… Open the release page to choose an installer for your system. Ανοίξτε τη σελίδα της έκδοσης για να επιλέξετε πρόγραμμα εγκατάστασης για το σύστημά σας. - Open Download Page Άνοιγμα σελίδας λήψης - PhotoCraft is up to date. Το PhotoCraft είναι ενημερωμένο. - Could not check for updates. Δεν ήταν δυνατός ο έλεγχος ενημερώσεων. - Updates have not been checked yet. Δεν έχει γίνει ακόμη έλεγχος ενημερώσεων. - Check Again Νέος έλεγχος Automatically Check for Updates Αυτόματος έλεγχος ενημερώσεων - Installed version: {version} Εγκατεστημένη έκδοση: {version} - PhotoCraft {version} is available. Το PhotoCraft {version} είναι διαθέσιμο. -# 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}) Remember this choice for future images Απομνημόνευση αυτής της επιλογής για μελλοντικές εικόνες Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Ενεργοποιήστε ξανά την ερώτηση με το Ask On Mismatch στο Επεξεργασία › Ρυθμίσεις χρωμάτων. Button Mode Λειτουργία κουμπιών Button Mode On Λειτουργία κουμπιών ενεργή - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Σκουρότερο περιβάλλον Lighten Interface Φωτεινότερο περιβάλλον -# Bevel & Emboss Technique. Technique Τεχνική 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 + Save Full-Size Composite in .pcraft Files Αποθήκευση σύνθετης εικόνας πλήρους μεγέθους στα αρχεία .pcraft EXIF EXIF - -# View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών Bicubic Automatic Δικυβική αυτόματη @@ -4056,8 +4079,6 @@ curves Smooth Εξομάλυνση set name όνομα συνόλου {set} (modified) {set} (τροποποιημένο) Show file name in title Εμφάνιση ονόματος αρχείου στον τίτλο - -# Image › Mode › Bitmap (#5559) 50% Threshold Κατώφλι 50% Pattern Dither Πρόσμιξη μοτίβου Diffusion Dither Πρόσμιξη διάχυσης @@ -4075,10 +4096,8 @@ curves Smooth Εξομάλυνση Undo Changes Αναίρεση αλλαγών {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση) -# Free Transform: a new layer while the box is open (#5859) 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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας, το πολύ στα 3/8 της μνήμης της. Το Αυτόματο ακολουθεί τη Χρήση μνήμης, εντός του ενός τετάρτου της μνήμης αυτού του υπολογιστή και των 3/8 της κάρτας, και δεν χρεώνει το έγγραφο σε κάρτα με γνωστή μνήμη. Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. GPU Memory Full Η μνήμη GPU είναι πλήρης @@ -4088,6 +4107,7 @@ curves Smooth Εξομάλυνση 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 = Αυτόματο) + Scratch Size Limit (MB, 0 = No Limit) Όριο μεγέθους δίσκου προσωρινής αποθήκευσης (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. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. @@ -4104,8 +4124,6 @@ curves Smooth Εξομάλυνση 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 Πρώτο πινέλο @@ -4120,17 +4138,12 @@ curves Smooth Εξομάλυνση Use Χρήση In Front Μπροστά Behind Πίσω - -# DDS Options (#1327) 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Το περιεχόμενο AI/PDF/EPS θα υποστεί επεξεργασία ως SVG. Η αποθήκευση αντικαθιστά την ενσωματωμένη πηγή· οι ιδιότητες του αρχικού τύπου αρχείου μπορεί να αλλάξουν. # Styles panel: Import / Export Styles (.asl) Import Styles… Εισαγωγή στιλ… @@ -4139,7 +4152,6 @@ curves Smooth Εξομάλυνση {file} is not a style library (.asl) Το {file} δεν είναι βιβλιοθήκη στιλ (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Εισήχθη {n} στιλ από το {file}|Εισήχθησαν {n} στιλ από το {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Εξήχθη {n} στιλ στο {file}|Εξήχθησαν {n} στιλ στο {file} -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Επιβεβαιώστε ή ακυρώστε πρώτα το ανοιχτό παράθυρο βήματος της ενέργειας Pro Medium Gray Pro μεσαίου γκρι Solarized Solarized @@ -4148,14 +4160,11 @@ curves Smooth Εξομάλυνση 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Κύβος απόχρωσης Brightness Cube Κύβος φωτεινότητας Color Wheel Χρωματικός τροχός @@ -4171,7 +4180,8 @@ curves Smooth Εξομάλυνση Grayscale Ramp Διαβάθμιση του γκρι Current Colors Τρέχοντα χρώματα Make Ramp Web Safe Μόνο χρώματα ιστού στη διαβάθμιση -# Art History Brush (#213). + The path was kept as the Work Path Η διαδρομή διατηρήθηκε ως διαδρομή εργασίας + The path was discarded Η διαδρομή απορρίφθηκε Art History Brush Tool Πινέλο ιστορικού τέχνης (Art History Brush Tool) Tight Short Σφιχτό κοντό Tight Medium Σφιχτό μεσαίο @@ -4182,9 +4192,31 @@ curves Smooth Εξομάλυνση Tight Curl Σφιχτή μπούκλα Loose Curl Χαλαρή μπούκλα Stylized dabs from a history state Στυλιζαρισμένες πινελιές από μια κατάσταση ιστορικού - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Target Στόχος Font size presets Προεπιλογές μεγέθους γραμματοσειράς Leading presets Προεπιλογές διάστιχου Tracking presets Προεπιλογές διαστήματος χαρακτήρων + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Μη έγκυρη αριθμητική τιμή. Απαιτείται ακέραιος αριθμός μεταξύ {min}% και {max}%. Εισήχθη η προηγούμενη τιμή. + Color Aware Με βάση το χρώμα + Decontaminate Colors Απολύμανση χρωμάτων + Edge Detection Ανίχνευση άκρης + Global Refinements Καθολικές βελτιώσεις + High Quality Preview Προεπισκόπηση υψηλής ποιότητας + Object Aware Με βάση το αντικείμενο + Real-Time Refinement Βελτίωση σε πραγματικό χρόνο + Refine Edge Brush Tool Εργαλείο πινέλου βελτίωσης ακμής + Refine Hair Βελτίωση μαλλιών + Refine Mode Λειτουργία βελτίωσης + Remember Settings Απομνημόνευση ρυθμίσεων + View Mode Λειτουργία προβολής + Object Aware needs a segmentation model, which isn't available yet Η λειτουργία Με βάση το αντικείμενο χρειάζεται μοντέλο τμηματοποίησης, που δεν είναι ακόμη διαθέσιμο + Refine Hair needs hair detection, which isn't available yet Η Βελτίωση μαλλιών χρειάζεται ανίχνευση μαλλιών, που δεν είναι ακόμη διαθέσιμη + Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό + Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο + Select Layer Under Cursor Επιλογή επιπέδου κάτω από τον δρομέα + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Χρώματα που χρησιμοποιήθηκαν πρόσφατα + Stop Color Χρώμα στάσης + Shapes can't use patterns yet Τα σχήματα δεν μπορούν ακόμη να χρησιμοποιούν μοτίβα + Edit Brush… Επεξεργασία πινέλου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b42f3be02c..6b365ce2be 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -11,22 +11,87 @@ # resource, string table or product UI was consulted or copied; add entries the same way. # 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) +# Channel Options and New Spot Channel (#3761) +# Guide layout dialog +# Custom print paper (#3820) +# Select and Mask dialog labels +# Field Blur pins (#3373) +# Dialogs and panels +# Layers-panel trash confirmation +# Tool names, brush and style option tables +# Templates with placeholders +# Preferences (labels are generated from the setting names) +# Added with the rebase onto the dock / brush picker work +# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). +# Gradient Editor window, opened from the options bar's gradient swatch. +# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test. +# Spanish follows the tool wording used above (Herramienta X). +# Command-id override: under Edit > Purge a bare "Deshacer" reads as an action, not the history. +# Rendering mode and recovery +# Camera Raw: contextual controls and window title +# About ▸ Contributors / Models (credits) +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) +# Shared screen eyedropper and text Color Picker (#1128). +# Fill dialog, layer style contours and Image Size +# Layer colour labels. +# Pattern Stamp tool +# Painting symmetry presets. +# Eyedropper Sample Size and Sample (#1649). +# Color Picker warnings +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Add Noise, Reduce Noise and Smart Sharpen dialog controls (Filter > Noise, Filter > Sharpen). +# Edit › Content-Aware Fill workspace +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) +# Color Sampler (added with the i18n coverage repair) +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the +# other catalogs already translate +# Path, channel, layer style, status and System Info labels the Czech catalog added +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# Export As metadata and profile options +# View › Guides › right-click a guide +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice +# DDS Options (#1327) +# Color Table (.act) dialog +# Graphics Adapter preference (#1885) +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). +# Smart Filter row actions (#1265) + 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: Solidity: Solidez: Masked Areas Áreas enmascaradas Selected Areas Áreas seleccionadas Ink Characteristics Características de la tinta - -# Guide layout dialog - -# Custom print paper (#3820) Paper Width Ancho del papel Paper Height Altura del papel Columns Columnas @@ -46,7 +111,6 @@ 5 Columns, 3 Rows 5 columnas, 3 filas 8 Columns 8 columnas 12 Columns 12 columnas - File Archivo Edit Edición Image Imagen @@ -402,8 +466,8 @@ Summation Sumatoria Variance Varianza Video Layers Capas de vídeo - Interpret Footage… Interpretar material de archivo… - Replace Footage… Reemplazar material de archivo… + Interpret Footage… Interpretar material de vídeo… + Replace Footage… Reemplazar material de vídeo… Reload Frame Recargar fotograma Show Altered Video Mostrar vídeo alterado Restore Frame Restaurar fotograma @@ -455,6 +519,7 @@ Intersect Shape Areas Intersecar áreas de formas Exclude Overlapping Shapes Excluir formas superpuestas Merge Shape Components Combinar componentes de formas + Path operations Operaciones de trazado Align Alinear Top Edges Bordes superiores Vertical Centers Centros verticales @@ -534,7 +599,6 @@ Color Range… Gama de colores… Focus Area… Área de enfoque… Select and Mask… Selección y máscara… -# Select and Mask dialog labels Smart Radius Radio inteligente Shift Edge Desplazar borde Decontaminate Descontaminar @@ -561,7 +625,6 @@ Blur Desenfocar Blur Gallery Galería de desenfoque Field Blur… Desenfoque de campo… -# Field Blur pins (#3373) No pin selected Ningún pin seleccionado Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Haga clic en la imagen para añadir un pin; arrastre un pin para moverlo o su anillo para cambiar el desenfoque. Suprimir elimina el pin seleccionado. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Arrastre los manejadores del contorno para cambiar su tamaño y girarlo, el cuadrado para redondear las esquinas y los puntos interiores para fijar dónde empieza el desenfoque. @@ -569,7 +632,7 @@ Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Arrastre los manejadores del contorno para cambiar su tamaño y girarlo; el anillo fija el ángulo de desenfoque. Blur Angle Ángulo de desenfoque Iris Blur… Desenfoque de iris… - Tilt-Shift… Cambio de inclinación… + Tilt-Shift… Desenfoque de inclinación y desplazamiento… Path Blur… Desenfoque de trazado… Spin Blur… Desenfoque de rotación… Blur More Desenfocar más @@ -602,7 +665,7 @@ Crystallize… Cristalizar… Facet Faceta Fragment Fragmento - Mezzotint… Grabado… + Mezzotint… Grabado a media tinta… Mosaic… Mosaico… Pointillize… Puntillista… Render Interpretar @@ -618,7 +681,7 @@ Sharpen Enfocar Sharpen Edges Enfocar bordes Sharpen More Enfocar más - Smart Sharpen… Enfoque suavizado… + Smart Sharpen… Enfoque inteligente… Unsharp Mask… Máscara de enfoque… High Pass… Paso alto… Stylize Estilizar @@ -827,8 +890,6 @@ Edits Ediciones @plural {n} item|{n} items {n} elemento|{n} elementos Auto Automático - -# Dialogs and panels (print to the printer) (imprimir en la impresora) + Add variable + Añadir variable 30 frames @ 30 fps 30 fotogramas @ 30 fps @@ -842,6 +903,9 @@ Add Layer Mask Añadir máscara de capa Add Stop Añadir parada Add a layer style Añadir un estilo de capa + Move effect up Subir efecto + Move effect down Bajar efecto + Delete effect Eliminar efecto Add a mask (from the selection; {key} inverts) Añadir una máscara (de la selección; {key} la invierte) Add an adjustment Añadir un ajuste Add disk Añadir disco @@ -1066,8 +1130,6 @@ Delete current channel Eliminar el canal actual Delete current state Eliminar el estado actual Delete effect layer Eliminar capa de efecto - -# Layers-panel trash confirmation Delete layer ‘{name}’? ¿Eliminar la capa «{name}»? Delete {count} layers? {names} ¿Eliminar {count} capas? {names} Delete layer? ¿Eliminar capa? @@ -1076,7 +1138,6 @@ Missing delete command Falta el comando de eliminación Invalid delete command Comando de eliminación no válido Layer deleted, but confirmation preference could not be saved: {error} La capa se eliminó, pero no se pudo guardar la preferencia de confirmación: {error} - Delete layer Eliminar capa Delete layer comp Eliminar composición de capa Delete note Eliminar nota @@ -1109,7 +1170,6 @@ 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 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. Drop an image or PSD anywhere to open it. Suelte una imagen o un PSD en cualquier lugar para abrirlo. @@ -1631,8 +1691,6 @@ No saved presets yet. Todavía no hay ajustes preestablecidos guardados. Error Error Command Comando - -# Tool names, brush and style option tables Adjustment layers have nothing to transform Las capas de ajuste no tienen nada que transformar Altitude Altitud Android 1080p Android 1080p @@ -1865,8 +1923,6 @@ Yellows Amarillos Zoom Tool Herramienta Zoom {name} Properties Propiedades de {name} - -# Templates with placeholders Revert “{name}” to the last saved version? Your changes will be lost. ¿Desea volver «{name}» a la última versión guardada? Se perderán los cambios. Do you want to save the changes you made to “{name}” before quitting? ¿Desea guardar los cambios realizados en «{name}» antes de salir? Do you want to save the changes you made to “{name}” before closing? ¿Desea guardar los cambios realizados en «{name}» antes de cerrar? @@ -1887,8 +1943,6 @@ @plural {n} Layer|{n} Layers {n} capa|{n} capas @plural {n} layer|{n} layers {n} capa|{n} capas @plural Group · {n} layer|Group · {n} layers Grupo · {n} capa|Grupo · {n} capas - -# Preferences (labels are generated from the setting names) Additional plugins folder Carpeta adicional de plugins Adobe Adobe Adobe rgb Adobe RGB @@ -2088,8 +2142,6 @@ Luminosity Luminosidad @plural {n} sample|{n} samples {n} muestra|{n} muestras {added}, {removed} subtracted {added}, {removed} restados - -# Added with the rebase onto the dock / brush picker work Adjustment Preview Vista previa de ajuste Airbrush: keeps depositing paint while the pointer rests. Aerógrafo: sigue depositando pintura mientras el puntero permanece quieto. Angle: Ángulo: @@ -2121,8 +2173,6 @@ Brush picker Selector de pinceles Erase Borrar Right click with painting tools Clic derecho con herramientas de pintura - -# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). System Info… Información del sistema… System Info Información del sistema More panels Más paneles @@ -2193,7 +2243,6 @@ Align with layer Alinear con la capa Reset Alignment Restablecer la alineación Centre the gradient (offset 0, 0) Centrar el degradado (desplazamiento 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Editor de degradados Gradient picker Selector de degradados Save As Preset Guardar como ajuste preestablecido @@ -2252,8 +2301,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Cuadrícula: -# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test. -# Spanish follows the tool wording used above (Herramienta X). Color Range Gama de colores Adaptive Wide Angle Gran angular adaptable Chalk & Charcoal Tiza y carboncillo @@ -2336,9 +2383,7 @@ New Channel Nuevo canal Save Selection Guardar selección Load Selection Cargar selección -# Command-id override: under Edit > Purge a bare "Deshacer" reads as an action, not the history. @id edit.purge.undo Historial de deshacer -# Rendering mode and recovery Rendering Mode Modo de renderizado Automatic (recommended) Automático (recomendado) CPU / Compatibility CPU / Compatibilidad @@ -2365,7 +2410,6 @@ Tone Histogram Histograma tonal Show Selected Region Mostrar región seleccionada cameraRaw Light Luz -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Filtro de Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) cameraRaw Lights Claros @@ -2389,14 +2433,10 @@ cameraRaw Amount Cantidad Scale or rotate the moved area, then press {key} Escale o gire el área movida y pulse {key} Now drag the selection onto another area Ahora arrastre la selección a otra área Edit Type Editar texto - - New Style… Nuevo estilo… Style {name} saved Estilo {name} guardado - Create new style (from the selected type) Crear estilo nuevo (del texto seleccionado) Create new style from the selected layer Crear estilo nuevo de la capa seleccionada - Make Default Establecer como predeterminado Reset to Default Restablecer valores predeterminados Full-resolution preview unavailable Vista previa a resolución completa no disponible @@ -2424,9 +2464,6 @@ cameraRaw Amount Cantidad Magnetic Lasso Tool Herramienta Lazo magnético Frequency Frecuencia Use tablet pressure to change pen width Usar la presión de la tableta para cambiar la anchura de la pluma - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About Acerca de Contributors Colaboradores Models Modelos @@ -2457,8 +2494,6 @@ cameraRaw Amount Cantidad % of all commits % de todos los commits Lines +/− Líneas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{binAdded} / −{binDeleted} archivos binarios, {first} – {last} - -# Swatches panel Create new swatch Crear una muestra nueva New Swatch… Muestra nueva… New Swatch Group… Grupo de muestras nuevo… @@ -2468,13 +2503,11 @@ cameraRaw Amount Cantidad Export Swatches for Exchange… Exportar muestras para intercambio… Reset Swatches Restablecer muestras Add to Swatches Añadir a muestras -# Layer Style > Blending Options (Advanced Blending) Blend If: Fusionar si: + Current Layer: Capa actual: Channels: Canales: Knockout Extraer Apply At Canvas Bounds Aplicar en los límites del lienzo - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Selector de color (Color del texto) Color Picker ({name}) Selector de color ({name}) Color Picker (Fill Color) Selector de color (Color de relleno) @@ -2491,8 +2524,6 @@ cameraRaw Amount Cantidad Show/Hide Panels Mostrar/ocultar paneles Could not open No se pudo abrir {path}: the path is not valid Unicode; rename the file and open it again. {path}: la ruta no es Unicode válido; cambie el nombre del archivo y vuelva a abrirlo. - -# Fill dialog, layer style contours and Image Size Contents: Contenido: Custom Pattern: Motivo personalizado: Foreground Color Color frontal @@ -2518,7 +2549,6 @@ cameraRaw Amount Cantidad Rolling Slope - Descending Pendiente ondulada - descendente Rounded Steps Pasos redondeados Sawtooth 1 Diente de sierra 1 - Red Eye Tool Herramienta Ojos rojos Red Eye Ojos rojos Pupil Size Tamaño de pupila @@ -2526,7 +2556,6 @@ cameraRaw Amount Cantidad Click a red pupil to neutralize it Haga clic en una pupila roja para corregirla open source código abierto …and {n} more …y {n} más - Eyedroppers: Cuentagotas: Set Black Point Definir punto negro Set Neutral Gray Point Definir punto gris neutro @@ -2535,8 +2564,6 @@ cameraRaw Amount Cantidad Selection Preview: Vista previa de la selección: Black Matte Sobre negro White Matte Sobre blanco - -# Layer colour labels. Seafoam Espuma de mar Indigo Índigo Fuchsia Fucsia @@ -2557,8 +2584,6 @@ fontWeight Bold Negrita fontWeight ExtraBold Extranegra fontWeight Black Ultranegra fontWeight Italic Cursiva - -# Pattern Stamp tool Pattern Stamp Tool Herramienta Tampón de motivo Pattern Stamp Tampón de motivo Impressionist Impresionista @@ -2566,13 +2591,9 @@ fontWeight Italic Cursiva Rotate View Tool Herramienta Rotar vista Reset View Restablecer vista Drag around the centre to rotate the view · Shift constrains to 15° Arrastre alrededor del centro para rotar la vista · Mayús fija a 15° - -# Painting symmetry presets. Dual Axis Doble eje Diagonal Diagonal Transform Symmetry Transformar simetría - -# Eyedropper Sample Size and Sample (#1649). Point Sample Muestra puntual 3 by 3 Average Promedio de 3 × 3 5 by 5 Average Promedio de 5 × 5 @@ -2584,15 +2605,12 @@ fontWeight Italic Cursiva All Layers No Adjustments Todas las capas sin ajustes Current & Below No Adjustments Actual e inferiores sin ajustes Show Sampling Ring Mostrar anillo de muestreo - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano Color to Alpha… Color a alfa… Transparency Threshold Umbral de transparencia Opacity Threshold Umbral de opacidad PhotoCraft — Screen Color Picker PhotoCraft — Selector de color de pantalla - Rasterize svg groups deeper than Rasterizar grupos SVG con mayor profundidad 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 desactiva este umbral; los grupos SVG anidados a más de 100 niveles siempre se rasterizan. Rule of Thirds Regla de los tercios @@ -2606,8 +2624,6 @@ fontWeight Italic Cursiva Cycle Orientation Alternar orientación Set the overlay options for the Crop tool Definir las opciones de superposición de la herramienta Recortar 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. El Alt+clic lo recibió el escritorio, que mueve ventanas con Alt+arrastrar. Cambie su tecla para mover ventanas a Super o bloquee Alt en Ventana › Teclas modificadoras. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Selector de color (color del estilo de capa) Auto Hide Notifications Ocultar notificaciones automáticamente Notification Duration (seconds) Duración de la notificación (segundos) @@ -2684,7 +2700,6 @@ fontWeight Italic Cursiva Appearance Settings… Ajustes de apariencia… Add to favorites Añadir a favoritos Remove from favorites Quitar de favoritos -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Datos de cámara cameraData Make Marca cameraData Model Modelo @@ -2695,7 +2710,6 @@ cameraData ISO Speed Sensibilidad ISO cameraData Focal Length Longitud focal cameraData Focal Length (35 mm) Longitud focal (35 mm) cameraData Lens Objetivo -# Shared panel docking Activate Panel Activar panel Change Panel Group Cambiar grupo de paneles Change Panel Layout Cambiar disposición de paneles @@ -2707,8 +2721,6 @@ cameraData Lens Objetivo Panel menu Menú del panel Resize panel window Cambiar tamaño de la ventana del panel Resize panels Cambiar tamaño de los paneles - -# Add Noise, Reduce Noise and Smart Sharpen dialog controls (Filter > Noise, Filter > Sharpen). Distribution Distribución Uniform Uniforme Gaussian Gaussiana @@ -2751,7 +2763,6 @@ cameraData Lens Objetivo Snap to Origin Ajustar a origen Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan -# Edit › Content-Aware Fill workspace Sampling Brush Tool Herramienta Pincel de muestreo Sampling Area Overlay Superposición del área de muestreo Show Sampling Area Mostrar área de muestreo @@ -2767,7 +2778,6 @@ 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 @@ -2787,8 +2797,6 @@ cameraData Lens Objetivo 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) @@ -2823,7 +2831,6 @@ curves Smooth Suavizar Fix the red plane first: its corners must form a convex quad Corrija primero el plano rojo: sus esquinas deben formar un cuadrilátero convexo Alt-click a plane to set the clone source Haga Alt-clic en un plano para definir el origen de clonación Too many clone strokes: apply Vanishing Point first Demasiados trazos de clonación: aplique primero Punto de fuga -# Select and Mask view modes (#1754) Onion Skin Papel cebolla Marching Ants Hormigas en marcha On Black Sobre negro @@ -2832,7 +2839,6 @@ curves Smooth Suavizar Show Edge Mostrar borde Show Original Mostrar original F cycles the views; hold X to see the image F alterna las vistas; mantén X para ver la imagen -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Pincel para perfeccionar bordes (R) Expand Detection Area Ampliar área de detección Restore Original Edge Restaurar borde original @@ -2840,19 +2846,13 @@ curves Smooth Suavizar Clear Brush Strokes Borrar trazos de pincel Show rulers Mostrar reglas Presentation Presentación - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Permitir historia no lineal Make Layer Visibility Changes Undoable Permitir deshacer los cambios de visibilidad de capas -# Print dialog (#2697) Emulsion Down Emulsión hacia abajo Functions Funciones Print Resolution: Resolución de impresión: @@ -2864,7 +2864,6 @@ curves Smooth Suavizar Apply mask to layer before removing? ¿Aplicar la máscara a la capa antes de eliminarla? The layer or its mask changed. Drag the mask onto the Trash again. La capa o su máscara ha cambiado. Arrastre de nuevo la máscara a la papelera. Delete the vector mask? ¿Eliminar la máscara vectorial? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Se exportó {n} muestra a {file}|Se exportaron {n} muestras a {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} Se importó {n} pincel de {group}|Se importaron {n} pinceles de {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Se importó {n} muestra de {file}|Se importaron {n} muestras de {file} @@ -2879,7 +2878,6 @@ curves Smooth Suavizar @plural {n} step|{n} steps {n} paso|{n} pasos @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel) @plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias -# View › Guides › New Guide… › Color Light Blue Azul claro Light Red Rojo claro Medium Blue Azul medio @@ -2896,8 +2894,6 @@ curves Smooth Suavizar 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 @@ -2909,29 +2905,29 @@ curves Smooth Suavizar 3D 3D Toolbars Barras de herramientas Wand Varita - Healing Corrección + Healing Corrector Pen Pluma Hand Mano Sampler Muestreador Magnetic Lasso Lazo magnético Art History Brush Pincel histórico artístico - Spot Healing Corrección puntual + Spot Healing Corrector puntual Patch Parche Content-Aware Move Mover con detección de contenido - Perspective Crop Recorte con perspectiva - Dodge/Burn Sobreexponer/Subexponer + Perspective Crop Recortar con perspectiva + Dodge/Burn Sobreexponer/subexponer Dynamics Dinámica Tilt Inclinación Defaults restored. Valores predeterminados restaurados. Stop Detener Create new set Crear nuevo conjunto Technology Previews Previsualizaciones de tecnología - Create new layer Crear nueva capa + Create new layer Crear una capa nueva Group · {n} layer Grupo · {n} capa General Blend Fusión general Advanced Blending Fusión avanzada RGB RGB - Spot Channel Canal de tinta plana + Spot Channel Canal de color plano Working Gray Plate Placa gris de trabajo HSL/Grayscale HSL/Escala de grises Tone Curve Curva de tonos @@ -2955,7 +2951,7 @@ curves Smooth Suavizar ProPhoto ProPhoto ACES ACES Industry Standard Estándar del sector - Uncoated CMYK CMYK sin estucar + Uncoated CMYK CMYK para papel no estucado Newspaper Papel de periódico Working Spaces Espacios de trabajo Color Management Policies Normas de gestión de color @@ -3011,7 +3007,7 @@ curves Smooth Suavizar Hanging Punctuation Puntuación colgante Roman Hanging Punctuation Puntuación latina colgante Avoid Widows/Orphans Evitar líneas viudas y huérfanas - Compose Composición + Compose Componer Adobe Single-line Composer Compositor de línea única de Adobe Adobe Every-line Composer Compositor multilínea de Adobe Word Spacing Espacio entre palabras @@ -3078,7 +3074,7 @@ curves Smooth Suavizar Select All Layers Seleccionar todas las capas Keep proportions Mantener proporciones Interpolation Interpolación - Caption Pie de foto + Caption Leyenda Caption Credits Créditos del pie de foto Keywords Palabras clave Copyright Info URL URL de información de copyright @@ -3147,7 +3143,7 @@ curves Smooth Suavizar RGB: RGB: CMYK: CMYK: Gray: Gris: - Spot: Tinta plana: + Spot: Color plano: Advanced Controls Controles avanzados Desaturate Monitor Colors By Desaturar colores del monitor un Blend RGB Colors Using Gamma 1.0 Fusionar colores RGB con gamma 1,0 @@ -3177,7 +3173,7 @@ curves Smooth Suavizar Recent Items Elementos recientes Delete Preset… Eliminar ajuste preestablecido… Preset Name: Nombre del ajuste preestablecido: - Width and Height Anchura y altura + Width and Height Ancho y alto Color Profile Perfil de color Units Unidades Create new count group Crear nuevo grupo de recuento @@ -3193,20 +3189,20 @@ curves Smooth Suavizar Rotate Fixed Rotación fija Select Plane Seleccionar plano Measure Medir - Heal Corrección + Heal Corregir Heal Off Corrección desactivada Heal On Corrección activada Heal Auto Corrección automática - Blend Fusión + Blend Fusionar Brush Size Tamaño del pincel Heal Color Color de corrección Result Resultado Duplicate Duplicar - Fast Rápida + Fast Rápido Accurate Precisa Add to Añadir a Subtract from Restar de - Intersect with Formar intersección con + Intersect with Intersecar con Capture Comp Capturar composición No Layer Comps No hay composiciones de capas Adobe PDF Presets… Ajustes preestablecidos de Adobe PDF… @@ -3231,7 +3227,7 @@ curves Smooth Suavizar General Brushes Pinceles generales Grid (⌘') Cuadrícula (⌘') Intersect with selection (⇧⌥) Formar intersección con la selección (⇧⌥) - New document… ⌘N Nuevo documento… ⌘N + New document… ⌘N Documento nuevo… ⌘N Open… ⌘O Abrir… ⌘O Rulers (⌘R) Reglas (⌘R) Search commands (⌘K) Buscar comandos (⌘K) @@ -3248,8 +3244,8 @@ curves Smooth Suavizar Show the layer's effects (⌥-click: all layers) Mostrar los efectos de la capa (⌥-clic: todas las capas) cameraRaw Blacks Negros cameraRaw Masking Enmascarado -@id edit.stroke Contornear -@id edit.transform.scale Escala +@id edit.stroke Contornear… +@id edit.transform.scale Escalar cameraRaw Luminance Luminancia cameraRaw Color Color cameraRaw Vibrance Intensidad @@ -3269,14 +3265,14 @@ layerLabel No Color Sin color Linear Dodge Sobreexposición lineal Preview Image Previsualizar imagen Save As Guardar como - Remove All Eliminar todo + Remove All Quitar todo Unlock Desbloquear Enable Activar Disable Desactivar On Activado Clear Selection Borrar selección Invert Selection Invertir selección - Select Inverse Seleccionar inverso + Select Inverse Invertir selección Contract Contraer Sample Size Tamaño de muestra Gamma Gamma @@ -3302,7 +3298,7 @@ layerLabel No Color Sin color Hidden Oculto Duplicate Layer Duplicar capa Ungroup Desagrupar - Guide Layout… Composición de guías… + Guide Layout… Diseño de guías… Page Setup… Configurar página… Reset Preferences Restablecer preferencias Documentation Documentación @@ -3312,8 +3308,8 @@ layerLabel No Color Sin color Saving… Guardando… Exporting… Exportando… Please wait… Espere… - No selection Ninguna selección - No layers Ninguna capa + No selection Sin selección + No layers Sin capas No recent files Ningún archivo reciente Unsupported file format Formato de archivo no compatible Failed to open file No se pudo abrir el archivo @@ -3328,7 +3324,7 @@ layerLabel No Color Sin color Rendering… Procesando… Importing… Importando… Ready Listo - Failed Error + Failed Falló Warning Advertencia Information Información Retry Reintentar @@ -3354,18 +3350,18 @@ layerLabel No Color Sin color Edit… Editar… Change… Cambiar… Transformation Transformación - Scaling Escala + Scaling Escalado Canvas Size Tamaño de lienzo Percentage Porcentaje Degrees Grados - Middle Medio + Middle Centro Diameter Diámetro Intensity Intensidad Sensitivity Sensibilidad Softness Suavidad Feather Radius Radio de calado Library Biblioteca - Layer Properties Propiedades de capa + Layer Properties Propiedades de la capa Groups Grupos Masks Máscaras Add Mask Añadir máscara @@ -3513,7 +3509,7 @@ layerLabel No Color Sin color Center Y Centro Y Cell Size Tamaño de celda Generators Generadores - Wavelength Min Longitud de onda mínima + Wavelength Min Longitud de onda mín. Wavelength Max Longitud de onda máxima Amplitude Min Amplitud mínima Amplitude Max Amplitud máxima @@ -3624,8 +3620,8 @@ layerLabel No Color Sin color Bamboo Bambú Bas Relief Bajorrelieve Berries Bayas - Blast Ráfaga - Bleach Bypass Omisión de blanqueado + Blast Explosión + Bleach Bypass Retención de plata Blocks Bloques Brick Ladrillo Brush Strokes Trazos de pincel @@ -3637,20 +3633,20 @@ layerLabel No Color Sin color Colored Pencil Lápiz de color Contrasty Contrastado Cool Frío - Craquelure Cuarteado + Craquelure Craquelado Cross Cruz Cross Process Proceso cruzado Crosshatch Sombreado cruzado - Cutout Recortado + Cutout Recortes Daisies Margaritas Dark Rough Oscuro rugoso Dark Strokes Trazos oscuros Darken Only Solo oscurecer Day For Night Noche americana - Deep Blue Azul intenso - Deep Emerald Esmeralda intenso - Deep Red Rojo intenso - Deep Yellow Amarillo intenso + Deep Blue Azul profundo + Deep Emerald Esmeralda profundo + Deep Red Rojo profundo + Deep Yellow Amarillo profundo Dot Punto Double Line Línea doble Draft Borrador @@ -3661,7 +3657,7 @@ layerLabel No Color Sin color Enlarged Ampliado Even Fields Campos pares Faded Film Película desvanecida - Film Grain Granulado de película + Film Grain Grano de película Fine Fino Fine Dots Puntos finos Flat Plano @@ -3671,7 +3667,7 @@ layerLabel No Color Sin color Frosted Escarchado Generic Genérico Glass Vidrio - Glowing Edges Bordes resplandecientes + Glowing Edges Bordes brillantes Grainy Dots Puntos granulados Hearts Corazones Heptagon Heptágono @@ -3691,7 +3687,7 @@ layerLabel No Color Sin color Medium Lines Líneas medianas Medium Strokes Trazos medianos Mono Contrast Contraste monocromo - Mosaic Tiles Azulejos de mosaico + Mosaic Tiles Teselas de mosaico Multiple Flames Along Path Varias llamas a lo largo del trazado Multiple Flames One Direction Varias llamas en una dirección Multiple Flames Path Directions Varias llamas en las direcciones del trazado @@ -3704,7 +3700,7 @@ layerLabel No Color Sin color Overlay Edge Borde superpuesto Palette Knife Espátula Parallel Paralelo - Patchwork Retales + Patchwork Retazos Pentagon Pentágono Point Punto Pointed Puntiagudo @@ -3753,7 +3749,7 @@ layerLabel No Color Sin color Correct C A Corregir AC Auto Scale Escala automática Red Cyan Rojo/Cian - Blue Yellow Azul/Amarillo + Blue Yellow Azul/amarillo Vignette Amount Cantidad de viñeta Vignette Midpoint Punto medio de la viñeta Constraints Restricciones @@ -3775,17 +3771,17 @@ layerLabel No Color Sin color Grade Global Gradación global Grade Blending Fusión de gradación Grade Balance Equilibrio de gradación - Sine Seno + Sine Sinusoidal Sketch Boceto Snowflakes Copos de nieve Sparkle Destellos Spatter Salpicadura - Speckle Motas + Speckle Moteado Spin Giro Sprinkles Espolvoreado - Squareness Cuadratura + Squareness Forma cuadrada Stagger Escalonado - Stained Glass Vidriera + Stained Glass Vitral Star Estrella Stars Estrellas Stippled Punteado @@ -3793,9 +3789,9 @@ layerLabel No Color Sin color Teal Orange Verde azulado y naranja Tiny Lens Lente diminuta To Center Hacia el centro - Unaltered Sin alterar - Underpainting Pintura base - Underwater Bajo el agua + Unaltered Inalterado + Underpainting Pintura de base + Underwater Subacuático Upper Superior Vertical Only Solo vertical Vine With Flowers Enredadera con flores @@ -3875,7 +3871,7 @@ layerLabel No Color Sin color Highlight Iluminación Image Processor Procesador de imágenes Install Plug-in Instalar plugin - Interpret Footage Interpretar material de archivo + Interpret Footage Interpretar material de vídeo Iris Blur Desenfoque de iris Jag Dentado Label Etiqueta @@ -3893,18 +3889,18 @@ layerLabel No Color Sin color Lum Lum Match Color Igualar color Merge to HDR Pro Combinar en HDR Pro - Mezzotint Grabado + Mezzotint Grabado a media tinta Mosaic Mosaico Motion Blur Desenfoque de movimiento Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Arrastrar y soltar archivos de forma nativa aún no es compatible con Wayland. Use Archivo › Abrir o ejecute PhotoCraft mediante XWayland con `WAYLAND_DISPLAY= photocraft`. Neon Glow Resplandor de neón New Guide Layout Nuevo diseño de guías New Video Layer from File Nueva capa de vídeo desde archivo - Ocean Ripple Ondas marinas + Ocean Ripple Ondulación oceánica Oil Paint Pintura al óleo Open As Abrir como Package Empaquetar - Paint Daubs Pinceladas + Paint Daubs Manchas de pintura Path Blur Desenfoque de trazado Paths to Illustrator Trazados a Illustrator Photomerge Photomerge @@ -3913,11 +3909,11 @@ layerLabel No Color Sin color Place Embedded Colocar incrustado Place Linked Colocar vinculado Place Scale Marker Colocar marcador de escala - Plastic Wrap Plastificado + Plastic Wrap Envoltura de plástico Pointillize Puntillista Polar Coordinates Coordenadas polares Polar To Rectangular Polar a rectangular - Poster Edges Bordes de cartel + Poster Edges Bordes posterizados Preset Manager Gestor de ajustes preestablecidos Profile Perfil Radial Blur Desenfoque radial @@ -3927,35 +3923,35 @@ layerLabel No Color Sin color Render Video Procesar vídeo Replace Color Reemplazar color Replace Contents Reemplazar contenido - Replace Footage Reemplazar material de archivo + Replace Footage Reemplazar material de vídeo Resolve Missing Fonts Resolver fuentes que faltan Ripple Ondulación - Rough Pastels Pasteles rugosos + Rough Pastels Pasteles ásperos Sat Sat Save a Copy Guardar una copia Save for Web (Legacy) Guardar para web (heredado) Scale Effects Escalar efectos Script Events Manager Administrador de eventos de secuencias de comandos - Search menus Buscar en menús - Select and Mask Seleccionar y aplicar máscara + Search menus Buscar en los menús + Select and Mask Selección y máscara Set Measurement Scale Definir escala de medida Shadows/Highlights Sombras/iluminaciones Shape Blur Desenfoque de forma Shear Inclinación Smart Blur Desenfoque inteligente - Smart Sharpen Enfoque suavizado - Smudge Stick Barra de difuminado + Smart Sharpen Enfoque inteligente + Smudge Stick Difumino Solid Color Color sólido Spherize Esferizar Spin Blur Desenfoque de rotación - Spot Tinta plana - Sprayed Strokes Trazos con spray + Spot Foco + Sprayed Strokes Trazos rociados Strokes Trazos Sumi-e Sumi-e Surface Blur Desenfoque de superficie - Texturizer Texturizar + Texturizer Texturizador Tiles Azulejos - Tilt-Shift Cambio de inclinación + Tilt-Shift Desenfoque de inclinación y desplazamiento Trace Contour Trazar contorno Trap Reventado Tree Árbol @@ -3983,24 +3979,23 @@ layerLabel No Color Sin color Hsb HSB Hsl HSL Movie Prime Película Prime - Prime105 Prime 105 - Prime35 Prime 35 + Prime105 Objetivo fijo de 105 mm + Prime35 Objetivo fijo de 35 mm Warming L B A Calentamiento LBA Warming81 Calentamiento 81 Warming85 Calentamiento 85 @id filter.sharpen.sharpen Enfocar Good Buena - Best Óptima - Adjustment Properties Propiedades de ajuste + Best Mejor + Adjustment Properties Propiedades del ajuste Default Foreground and Background Colors Colores frontal y de fondo predeterminados Move Layer Mover capa Rectangular Selection Selección rectangular Reorder Layer Reordenar capa Select Layer Seleccionar capa Set Colors Definir colores - Switch Foreground and Background Colors Intercambiar colores frontal y de fondo + Switch Foreground and Background Colors Intercambiar los colores frontal y de fondo Target Destino -# Path, channel, layer style, status and System Info labels the Czech catalog added Make Work Path… Crear trazado de trabajo… Create Vector Mask Crear máscara vectorial Make Selection… Crear selección… @@ -4041,7 +4036,6 @@ layerLabel No Color Sin color Layer Mask Hides Effects La máscara de capa oculta los efectos Vector Mask Hides Effects La máscara vectorial oculta los efectos General Blending Fusión general - This Layer: Esta capa: Underlying Layer: Capa subyacente: Nothing to paste: the clipboard holds no image Nada que pegar: el portapapeles no contiene ninguna imagen Preferences saved Preferencias guardadas @@ -4156,7 +4150,7 @@ navigator Zoom Zoom manual manual Medium Gray Gris medio New document Documento nuevo - New Selection Selección nueva + New Selection Nueva selección No Document Sin documento Note tool Herramienta Nota off desactivado @@ -4176,8 +4170,6 @@ navigator Zoom Zoom Clear Brush Controls Borrar controles de pincel Reset All Locked Settings Restaurar todos los ajustes bloqueados Copy Texture to Other Tools Copiar textura en otras herramientas - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Cargar acciones… Save Actions… Guardar acciones… New Action… Nueva acción… @@ -4189,9 +4181,10 @@ navigator Zoom Zoom @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} paso aún no tiene equivalente en PhotoCraft|{n} pasos aún no tienen equivalente en PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos - +@plural {n} action file loaded|{n} action files loaded Se cargó {n} archivo de acciones|Se cargaron {n} archivos de acciones + {set} from {file} replaced the one from {earlier} El conjunto {set} de {file} reemplazó al de {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} No se pudo cargar {n} archivo: {errors}|No se pudieron cargar {n} archivos: {errors} Starting serial Número de serie inicial -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insertar detención… Save Set… Guardar conjunto… Message: Mensaje: @@ -4202,17 +4195,15 @@ navigator Zoom Zoom Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo @plural {n} action|{n} actions {n} acción|{n} acciones Background (Default) Fondo (predeterminado) -# Batch / Image Processor Include All Subfolders Incluir todas las subcarpetas Auto Add/Delete Añadir/eliminar automáticamente - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. No actives también Imagen especular en Ajustes de impresión: las dos inversiones se anulan. Print Settings… Ajustes de impresión… Remember to turn off the printer's own colour management in Print Settings… Recuerda desactivar la gestión de color de la impresora en Ajustes de impresión… 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 + Memory usage percent Uso de memoria (%) Show Favorite Fonts Mostrar fuentes favoritas No favorite fonts yet: click the star beside a font to add it. Aún no hay fuentes favoritas: haz clic en la estrella junto a una fuente para añadirla. Favorite fonts Fuentes favoritas @@ -4251,49 +4242,31 @@ navigator Zoom Zoom Could not write {name}: {error} No se pudo escribir {name}: {error} Kept {name}: written by another application Se conservó {name}: lo escribió otra aplicación Shadow Tint Tinte de sombras -# PhotoCraft release checks. Check for Updates… Buscar actualizaciones… - PhotoCraft Updates Actualizaciones de PhotoCraft - Checking for updates… Buscando actualizaciones… Open the release page to choose an installer for your system. Abra la página de la versión para elegir un instalador para su sistema. - Open Download Page Abrir página de descarga - PhotoCraft is up to date. PhotoCraft está actualizado. - Could not check for updates. No se pudieron buscar actualizaciones. - Updates have not been checked yet. Aún no se han buscado actualizaciones. - Check Again Comprobar de nuevo Automatically Check for Updates Buscar actualizaciones automáticamente - Installed version: {version} Versión instalada: {version} - PhotoCraft {version} is available. PhotoCraft {version} está disponible. -# 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}) Remember this choice for future images Recordar esta opción para próximas imágenes Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Vuelve a activar el aviso con Ask On Mismatch en Edición › Ajustes de color. Button Mode Modo de botón Button Mode On Modo de botón activado - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Oscurecer interfaz Lighten Interface Aclarar interfaz -# Bevel & Emboss Technique. Technique Técnica 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 + Save Full-Size Composite in .pcraft Files Guardar la imagen compuesta a tamaño completo en los archivos .pcraft Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas Bicubic Automatic Bicúbica automática @@ -4318,8 +4291,6 @@ 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 @@ -4337,10 +4308,8 @@ navigator Zoom Zoom Undo Changes Deshacer cambios {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine. The transform couldn't be applied. No se pudo aplicar la transformación. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica, como máximo 3/8 de su memoria. Automático sigue Uso de memoria, dentro de una cuarta parte de la memoria de este equipo y 3/8 de la de la tarjeta, y no descuenta el documento en una tarjeta cuya memoria se conoce. Mientras no quepan, la barra de estado muestra Memoria de GPU llena. GPU Memory Full Memoria de GPU llena @@ -4350,6 +4319,7 @@ navigator Zoom Zoom 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) + Scratch Size Limit (MB, 0 = No Limit) Límite de tamaño del disco de memoria virtual (MB, 0 = sin límite) {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. @@ -4366,8 +4336,6 @@ navigator Zoom Zoom 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 @@ -4382,17 +4350,12 @@ navigator Zoom Zoom Use Usar In Front Delante Behind Detrás - -# DDS Options (#1327) 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. El contenido AI/PDF/EPS se editará como SVG. Al guardar se reemplaza el origen incrustado; las funciones específicas del formato pueden cambiar. # Styles panel: Import / Export Styles (.asl) Import Styles… Importar estilos… @@ -4401,7 +4364,6 @@ navigator Zoom Zoom {file} is not a style library (.asl) {file} no es una biblioteca de estilos (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Se importó {n} estilo de {file}|Se importaron {n} estilos de {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Se exportó {n} estilo a {file}|Se exportaron {n} estilos a {file} -# 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 @@ -4410,14 +4372,11 @@ navigator Zoom Zoom 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cubo de tono Brightness Cube Cubo de brillo Color Wheel Rueda de color @@ -4433,7 +4392,8 @@ navigator Zoom Zoom Grayscale Ramp Rampa de escala de grises Current Colors Colores actuales Make Ramp Web Safe Hacer rampa segura para web -# Art History Brush (#213). + The path was kept as the Work Path El trazado se conservó como trazado de trabajo + The path was discarded El trazado se descartó Art History Brush Tool Herramienta Pincel histórico artístico Tight Short Corto apretado Tight Medium Medio apretado @@ -4447,3 +4407,28 @@ navigator Zoom Zoom Font size presets Valores predefinidos de tamaño de fuente Leading presets Valores predefinidos de interlineado Tracking presets Valores predefinidos de tracking + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica no válida. Se requiere un número entero entre {min}% y {max}%. Se ha insertado el valor anterior. + Color Aware Según color + Decontaminate Colors Descontaminar colores + Edge Detection Detección de bordes + Global Refinements Perfeccionamientos globales + High Quality Preview Previsualización de alta calidad + Object Aware Según objeto + Real-Time Refinement Perfeccionamiento en tiempo real + Refine Edge Brush Tool Herramienta Pincel para perfeccionar bordes + Refine Hair Perfeccionar pelo + Refine Mode Modo de perfeccionamiento + Remember Settings Recordar ajustes + View Mode Modo de visualización + Object Aware needs a segmentation model, which isn't available yet Según objeto necesita un modelo de segmentación que aún no está disponible + Refine Hair needs hair detection, which isn't available yet Perfeccionar pelo necesita detección de pelo, que aún no está disponible + Select and Mask was closed: its document is no longer the active one Se cerró Seleccionar y aplicar máscara: su documento ya no es el activo + Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa + Select Layer Under Cursor Seleccionar capa bajo el cursor + Drag to move the selected path Arrastre para mover el trazado seleccionado + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Colores usados recientemente + Stop Color Color de la parada + Shapes can't use patterns yet Las formas aún no pueden usar motivos + Edit Brush… Editar pincel… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv new file mode 100644 index 0000000000..b08a224b27 --- /dev/null +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -0,0 +1,4776 @@ +# Finnish (fi) 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.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. +# +# Combined from the community Finnish catalogs in #5666 and #6961: each row takes the best of their +# translations, with one term per concept across the UI (taso, valinta, maski, liukuväri, +# pehmennys, muunnos, vapaa muunnos, ruudukko, apuviiva, piirtoalusta, kangas, leikkausmaski, +# työtila, sivellin, sekoitustila, säätö, kanava, polku, historia, asiakirja, suodatin, reunaviiva, +# taivutus, edustaväri, tasoasetelma, heittovarjo …). Clean-room: written from the plain meaning of +# the English labels; no proprietary localisation resource was consulted or copied. +# Labels use sentence case, commands are imperatives, and pointer hints say what an action does +# ("Lisää piste napsauttamalla"). Product, format and technology names (PhotoCraft, ArtCraft, +# OpenType, CMYK, Lab …) and key caps keep their spelling; the trailing "…" of dialog commands is +# kept. Numbers follow Finnish style (decimal comma, a space before %). Plural rule: one|other +# (n != 1 -> other); the other form takes the partitive singular ("1 taso", "2 tasoa"). +# Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskin asetukset… + Layer Mask Display Options… Tasomaskin näyttöasetukset… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanavaväri + Color Indicates: Väri ilmaisee: + Solidity: Kiinteys: + Ink Characteristics Painovärin ominaisuudet + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Paperin leveys + Paper Height Paperin korkeus + Rows Rivit + Number Määrä + Margin Marginaali + Top: Yläreuna: + Left: Vasen: + Bottom: Alareuna: + Right: Oikea: + Target: Kohde: + Selected Artboards Valitut piirtoalustat + Center Columns Keskitä sarakkeet + Clear Existing Guides Poista nykyiset apuviivat + Invalid guide layout Virheellinen apuviiva-asettelu + Leave empty for automatic size Jätä tyhjäksi, niin koko määräytyy automaattisesti + 5 Columns, 3 Rows 5 saraketta, 3 riviä + 8 Columns 8 saraketta + 12 Columns 12 saraketta + + File Tiedosto + Edit Muokkaa + Image Kuva + Layer Taso + Type Teksti + Select Valitse + Selected Valittu + Scope Alue + Top Left Vasen yläkulma + Top Right Oikea yläkulma + Bottom Left Vasen alakulma + Bottom Right Oikea alakulma + Filter Suodatin + View Näytä + Window Ikkuna + Help Ohje + +# File + New… Uusi… + New from Clipboard Uusi leikepöydältä + New Uusi + Open… Avaa… + Open Avaa + Open As… Avaa muodossa… + Open EXR Parts… Avaa EXR-osat… + Add beauty precomp from the AOVs Lisää beauty-esikompositio AOV-kanavista + Both Molemmat + Left Vasen + Right Oikea + Open Recent Viimeksi avatut + Clear Recent Files Tyhjennä viimeisimmät tiedostot + Remove from Recent Poista viimeisimmistä + Remove {name} from Recent Poista {name} viimeisimmistä + Close Sulje + Minimize Pienennä + Maximize Suurenna + Restore Palauta + Close All Sulje kaikki + Close Others Sulje muut + Save Tallenna + Save As… Tallenna nimellä… + Save a Copy… Tallenna kopio… + {name} can't keep everything in this document {name} ei voi säilyttää kaikkea tässä asiakirjassa + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Valitse muoto, joka säilyttää kaiken, kuten Photoshop (PSD), tai tallenna kopio. + Save… Tallenna… + Revert Palauta tallennettu versio + Import Tuo + Import Presets Tuo esiasetukset + Export As… Vie nimellä… + Export Vie + Export… Vie… + Quick Export As PNG Pikavienti PNG-muodossa + Quick Export as PNG Pikavienti PNG-muotoon + Place Embedded… Sijoita upotettuna… + Place Linked… Sijoita linkitettynä… + Package… Paketoi… + Save for Web (Legacy)… Tallenna verkkoa varten (vanha)… + Generate Luo + Image Assets Kuvaresurssit + +# Edit + Undo Kumoa + Redo Tee uudelleen + Fade… Häivytä… + Cut Leikkaa + Copy Kopioi + Copy Merged Kopioi yhdistettynä + Paste Liitä + Paste Special Liitä määräten + Paste Into Liitä valinnan sisään + Paste Outside Liitä valinnan ulkopuolelle + Paste in Place Liitä paikalleen + Clear Tyhjennä + Search Haku + Search… Etsi… + Check Spelling… Tarkista oikeinkirjoitus… + Fill… Täytä… + Stroke… Piirrä reunaviiva… + Content-Aware Fill… Sisältötietoinen täyttö… + Delete and Fill Selection Poista ja täytä valinta + Define Brush Preset… Määritä siveltimen esiasetus… + Define Pattern… Määritä kuvio… + Define Custom Shape… Määritä mukautettu muoto… + Purge Tyhjennä muisti + +# Image + Image Size… Kuvan koko… + Image Size Kuvan koko + Canvas Size… Kankaan koko… + Image Rotation Kuvan kierto + Rotate 90° Clockwise Kierrä 90° myötäpäivään + Rotate 90° Counter Clockwise Kierrä 90° vastapäivään + Rotate 180° Kierrä 180° + Flip Canvas Horizontal Peilaa kangas vaakasuunnassa + Flip Canvas Vertical Peilaa kangas pystysuunnassa + Crop Rajaa + Crop and Straighten Photos Rajaa ja suorista kuvat + Trim… Karsi reunat… + Reveal All Paljasta kaikki + Hide Selection Piilota valinta + Auto Tone Automaattisävy + Auto Contrast Automaattikontrasti + Auto Color Automaattiväri + Adjustments Säädöt + 16 Bits/Channel 16 bittiä/kanava + 32 Bits/Channel 32 bittiä/kanava + 8 Bits/Channel 8 bittiä/kanava + 8 bit 8-bittinen + Integer Kokonaisluku + 16 bit 16-bittinen + 32 bit (float) 32-bittinen (liukuluku) + Floating point Liukuluku + Mode Tila + Grayscale Harmaasävy + RGB Color RGB-väri + CMYK Color CMYK-väri + Lab Color Lab-väri + Indexed Color Indeksoitu väri + Multichannel Monikanava + Bitmap… Bittikartta… + Bitmap Bittikartta + Duotone… Kaksisävy… + Duotone Kaksisävy + Trap… Trappaus… + +# Layer + Layer via Copy Taso kopioimalla + Layer via Cut Taso leikkaamalla + Duplicate Layer… Monista taso… + Duplicate… Monista… + Delete Poista + Delete Layer Poista taso + Delete Layers Poista tasot + Rename Layer Nimeä taso uudelleen + Layer From Background… Taso taustasta… + Layer from Background… Taso taustasta… + Background from Layer Tausta tasosta + Background From Layer Tausta tasosta + Group Layers Ryhmittele tasot + Group… Ryhmä… + Ungroup Layers Pura tasojen ryhmittely + Group from Layers… Ryhmä tasoista… + New Group from Layers… Uusi ryhmä tasoista… + Hide Layers Piilota tasot + Arrange Järjestä + Bring to Front Tuo etualalle + Bring Forward Siirrä eteenpäin + Send Backward Siirrä taaksepäin + Send to Back Vie taustalle + Convert to Smart Object Muunna älykkääksi objektiksi + Convert for Smart Filters Muunna älykkäitä suodattimia varten + New Smart Object via Copy Uusi älykäs objekti kopioimalla + Edit Contents Muokkaa sisältöä + Replace Contents… Korvaa sisältö… + Export Contents… Vie sisältö… + Rasterize Rasteroi + Rasterize Type Rasteroi teksti + Rasterize Shape Rasteroi muoto + Rasterize Layer Rasteroi taso + Rasterize Layers Rasteroi tasot + New Adjustment Layer Uusi säätötaso + New Fill Layer Uusi täyttötaso + Layer Mask Tasomaski + Add Layer Mask Lisää tasomaski + Delete Layer Mask Poista tasomaski + Enable Layer Mask Ota tasomaski käyttöön + Disable Layer Mask Ota tasomaski pois käytöstä + Apply Layer Mask Käytä tasomaskia + Add Mask to Selection Lisää maski valintaan + Subtract Mask from Selection Vähennä maski valinnasta + Intersect Mask with Selection Leikkaa maski valinnan kanssa + Link Layer Mask Linkitä tasomaski + Unlink Layer Mask Poista tasomaskin linkitys + Vector Mask Vektorimaski + Add Vector Mask Lisää vektorimaski + Delete Vector Mask Poista vektorimaski + Enable Vector Mask Ota vektorimaski käyttöön + Link Vector Mask Linkitä vektorimaski + Create Clipping Mask Luo leikkausmaski + Release Clipping Mask Vapauta leikkausmaski + New Shape Layer Uusi muototaso + New Type Layer Uusi tekstitaso + New Video Layer from File… Uusi videotaso tiedostosta… + New Blank Video Layer Uusi tyhjä videotaso + Smart Filters Älykkäät suodattimet + Delete Smart Filters Poista älykkäät suodattimet + Enable Smart Filters Ota älykkäät suodattimet käyttöön + Disable Smart Filters Ota älykkäät suodattimet pois käytöstä + Clear Smart Filters Tyhjennä älykkäät suodattimet + Delete Filter Mask Poista suodattimen maski + Disable Filter Mask Ota suodattimen maski pois käytöstä + New Layer Based Slice Uusi tasopohjainen viipale + Layer Style Tasotyyli + Blending Options… Sekoitusasetukset… + Blending Options Sekoitusasetukset + Drop Shadow… Heittovarjo… + Inner Shadow… Sisävarjo… + Outer Glow… Ulkohehku… + Inner Glow… Sisähehku… + Bevel & Emboss… Viiste ja kohokuvio… + Use Global Light Käytä yleistä valoa + Overprint Päällepainatus + Satin… Satiini… + Color Overlay… Väripeitto… + Gradient Overlay… Liukuväripeitto… + Pattern Overlay… Kuviopeitto… + Bevel & Emboss Viiste ja kohokuvio + Inner Shadow Sisävarjo + Inner Glow Sisähehku + Satin Satiini + Color Overlay Väripeitto + Gradient Overlay Liukuväripeitto + Pattern Overlay Kuviopeitto + Outer Glow Ulkohehku + Drop Shadow Heittovarjo + Copy Layer Style Kopioi tasotyyli + Paste Layer Style Liitä tasotyyli + Clear Layer Style Tyhjennä tasotyyli + Layer Content Options… Tason sisällön asetukset… + Scale Effects… Skaalaa tehosteet… + New Layer… Uusi taso… + Delete Layer… Poista taso… + +# Type + Font Fontti + Font Preview Size Fontin esikatselukoko + Orientation Suunta + Horizontal Vaakasuuntainen + Vertical Pystysuuntainen + Rasterize Type Layer Rasteroi tekstitaso + Create Work Path Luo työpolku + Convert to Shape Muunna muodoksi + Convert to Paragraph Text Muunna kappaletekstiksi + Convert to Point Text Muunna pistetekstiksi + Warp Text… Taivuta tekstiä… + Character Panel Merkkipaneeli + Paragraph Panel Kappalepaneeli + Check Spelling Tarkista oikeinkirjoitus + Language Options Kieliasetukset + Find and Replace Text… Etsi ja korvaa teksti… + Add to Dictionary Lisää sanakirjaan + No Spelling Suggestions Ei oikeinkirjoitusehdotuksia + Paste Lorem Ipsum Liitä Lorem ipsum -teksti + Glyphs Panel Glyfipaneeli + Type Styles Tekstityylit + Character Styles Merkkityylit + Paragraph Styles Kappaletyylit + Load Default Type Styles Lataa tekstin oletustyylit + Save Default Type Styles Tallenna tekstin oletustyylit + Type… Teksti… + +# Select + All Kaikki + Deselect Poista valinta + Reselect Valitse uudelleen + Inverse Selection Käänteinen valinta + Inverse Käännä valinta + Color Range… Värialue… + Select and Mask… Valitse ja maskaa… +# Select and Mask dialog labels + Smart Radius Älykäs säde + Shift Edge Reunan siirto + Decontaminate Puhdista värit + Output Tulos + Refine Edge Tarkenna reunaa + Select Subject Valitse kohde + Sky Taivas + Focus Area… Tarkennusalue… + Subject Kohde + Color Range Värialue + Similar Valitse samankaltaiset + Grow Kasvata + Select Similar Colors Valitse samankaltaiset värit + Load Selection… Lataa valinta… + Save Selection… Tallenna valinta… + New Selection Uusi valinta + Select All Valitse kaikki + Deselect All Poista kaikki valinnat + Deselect Layers Poista tasojen valinta + Find Layers Etsi tasoja + Isolate Layers Eristä tasot + Select Linked Layers Valitse linkitetyt tasot + All Layers Kaikki tasot + Current & Below Nykyinen ja alemmat + Current Layer Nykyinen taso + Refine Strokes Tarkenna vetoja + Edits Muokkaukset + +# Filter + Last Filter Viimeisin suodatin + Filter Gallery… Suodatingalleria… + Liquify… Nesteytä… + Camera Raw Filter… Camera Raw -suodatin… + Adaptive Wide Angle… Mukautuva laajakulma… + Lens Correction… Objektiivin korjaus… + Vanishing Point… Pakopiste… + Oil Paint… Öljymaalaus… + Blur Gallery Sumennusgalleria + Field Blur… Kenttäsumennus… +# Field Blur pins (#3373) + No pin selected Nastaa ei ole valittu + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Lisää nasta napsauttamalla kuvaa. Siirrä nastaa vetämällä sitä ja muuta sumennusta vetämällä sen rengasta. Delete poistaa valitun nastan. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Muuta kokoa ja kierrä vetämällä ääriviivan kahvoja, pyöristä kulmia vetämällä neliötä ja aseta sumennuksen alkukohta vetämällä sisäpuolen pisteitä. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Siirrä kaistaa vetämällä nastaa tai muuta sen sumennusta vetämällä rengasta. Levennä terävää kaistaa vetämällä yhtenäisiä viivoja, aseta siirtymä vetämällä katkoviivoja ja kierrä vetämällä pisteitä. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Muuta kokoa ja kierrä vetämällä ääriviivan kahvoja; rengas asettaa sumennuksen kulman. + Blur Angle Sumennuskulma + Iris Blur… Iirissumennus… + Tilt-Shift… Kallistus ja siirto… + Path Blur… Polkusumennus… + Spin Blur… Pyörähdyssumennus… + Auto-Align Layers… Kohdista tasot automaattisesti… + Auto-Blend Layers… Sulauta tasot automaattisesti… + Photomerge… Photomerge… + Content-Aware Scale Sisältötietoinen skaalaus + Adaptive Wide Angle Mukautuva laajakulma + Apply Image… Käytä kuvaa… + Calculations… Laskutoimitukset… + Crystallize… Kiteytä… + Facet Fasetti + Fragment Sirpale + Mosaic… Mosaiikki… + Pointillize… Pointillismi… + Chalk & Charcoal Liitu ja hiili + Charcoal Hiili + Chrome Kromi + Conté Crayon Conté-liitu + Graphic Pen Grafiikkakynä + Halftone Pattern Rasterikuvio + Note Paper Muistiopaperi + Photocopy Valokopio + Plaster Kipsi + Reticulation Verkkoutuminen + Stamp Leimasin + Torn Edges Revityt reunat + Water Paper Akvarellipaperi + Clouds Pilvet + Difference Clouds Erotuspilvet + Fibers… Kuidut… + Lens Flare… Linssiheijastus… + Picture Frame… Kuvakehys… + Tree… Puu… + Flame… Liekit… + Sharpen Edges Terävöitä reunat + Dust & Scratches… Pöly ja naarmut… + Noise Kohina + Add Noise… Lisää kohinaa… + Reduce Noise… Vähennä kohinaa… + Despeckle Poista täplät + Median… Mediaani… + Median Mediaani + Maximum… Maksimi… + Maximum Maksimi + Minimum… Minimi… + Minimum Minimi + Custom… Mukautettu… + High Pass… Ylipäästö… + Emboss… Kohokuvio… + Extrude… Pursota… + Find Edges Etsi reunat + Solarize Solarisoi + Tiles… Laatat… + Trace Contour… Jäljitä ääriviiva… + Wind… Tuuli… + Offset… Siirtymä… + Pinch… Nipistä… + Polar Coordinates… Napakoordinaatit… + Ripple… Väreily… + Shear… Vinouta… + Spherize… Palloita… + Twirl… Pyörre… + Wave… Aalto… + ZigZag… Siksak… + Average Keskiarvo + Blur More Sumenna lisää + Box Blur… Laatikkosumennus… + Gaussian Blur… Gauss-sumennus… + Motion Blur… Liikesumennus… + Radial Blur… Säteittäinen sumennus… + Shape Blur… Muotosumennus… + Smart Blur… Älykäs sumennus… + Surface Blur… Pintasumennus… + Sharpen Terävöitä + Sharpen More Terävöitä lisää + Unsharp Mask… Epäterävä maski… + Smart Sharpen… Älykäs terävöitys… + Diffuse… Hajauta… + Mezzotint… Mezzotinto… + Color Halftone… Värirasteri… + Layer… Taso… + +# View + Proof Setup Koevedoksen asetukset + Proof Colors Koevedosvärit + Pixel Aspect Ratio Pikselin kuvasuhde + Pixel Aspect Ratio Correction Pikselin kuvasuhteen korjaus + Zoom In Lähennä + Zoom Out Loitonna + Fit on Screen Sovita näyttöön + 100% 100 % + 105% 105 % + 110% 110 % + 115% 115 % + 120% 120 % + 200% 200 % + 75% 75 % + 80% 80 % + 85% 85 % + 90% 90 % + 95% 95 % + 125% 125 % + 150% 150 % + 175% 175 % + 250% 250 % + 300% 300 % + Print Size Tulostuskoko + Screen Mode Näyttötila + Standard Screen Mode Tavallinen näyttötila + Full Screen Mode With Menu Bar Koko näytön tila valikkorivillä + Full Screen Mode Koko näytön tila + Extras Apuvälineet + Show Näytä + Rulers Viivaimet + Grid Ruudukko + Guides Apuviivat + Slices Viipaleet + Snap Tartu + Snap To Tartu kohteeseen + Lock Guides Lukitse apuviivat + Clear Guides Poista apuviivat + New Guide… Uusi apuviiva… + Lock Slices Lukitse viipaleet + Clear Slices Poista viipaleet + New Guide Layout… Uusi apuviiva-asettelu… + New Guides From Shape Uudet apuviivat muodosta + Canvas rotation Kankaan kierto + Zoom in Lähennä + Zoom out Loitonna + +# Window + Cascade Limittäin + Tile Rinnakkain + 2-up Horizontal 2 allekkain + 3-up Horizontal 3 allekkain + 2-up Vertical 2 rinnakkain + 3-up Vertical 3 rinnakkain + 3-up Stacked 3, kaksi pinossa + 4-up 4 ruudukossa + 6-up 6 ruudukossa + Consolidate All to Tabs Kokoa kaikki välilehdiksi + Match Zoom Täsmäytä zoomaus + Match Location Täsmäytä sijainti + Match Rotation Täsmäytä kierto + Match All Täsmäytä kaikki + New Window for Document Uusi ikkuna asiakirjalle + Workspace Työtila + Extensions Laajennukset + Panels Paneelit + Actions Toiminnot + Brush Settings Siveltimen asetukset + Brushes Siveltimet + Channels Kanavat + Character Merkki + Clone Source Kloonauslähde + Color Mixer Värisekoitin + Glyphs Glyfit + Gradients Liukuvärit + Histogram Histogrammi + History Historia + Info Tiedot + Layer Comps Tasoasetelmat + Layers Tasot + Measurement Log Mittausloki + Notes Muistiinpanot + Navigator Navigaattori + Paragraph Kappale + Paths Polut + Patterns Kuviot + Properties Ominaisuudet + Styles Tyylit + Swatches Värimallit + Timeline Aikajana + Tool Presets Työkalujen esiasetukset + Tools Työkalut + Libraries Kirjastot + 3D 3D + Toolbars Työkalurivit + Hide All Piilota kaikki + +# Help + About PhotoCraft Tietoja PhotoCraftista + PhotoCraft Website PhotoCraft-verkkosivusto + PhotoCraft on GitHub PhotoCraft GitHubissa + Report an Issue… Ilmoita ongelmasta… + ArtCraft Website ArtCraft-verkkosivusto + Join the ArtCraft Discord… Liity ArtCraftin Discordiin… + System Info… Järjestelmän tiedot… + +# Tools and options bar + Move Siirto + Marquee Valintakehys + Lasso Lasso + Wand Sauva + Eyedropper Pipetti + Brush Sivellin + Block Lohko + Pencil Lyijykynä + Eraser Pyyhekumi + Clone Stamp Kloonausleimasin + Healing Korjaus + Blur Sumennus + Dodge Vaalennus + Burn Tummennus + Gradient Liukuväri + Shape Muoto + Pen Kynä + Hand Käsi + Zoom Zoomaus + Sampler Näytteenotin + Ruler Tool Viivaintyökalu + Count Tool Laskentatyökalu + Note tool Muistiinpanotyökalu + Quick Selection Pikavalinta + Magnetic Lasso Magneettinen lasso + Mixer Brush Sekoitussivellin + Mixer Brush Tool Sekoitussivellintyökalu + Color Replacement Tool Värinkorvaustyökalu + History Brush Historiasivellin + Art History Brush Taidehistoriasivellin + Pattern Stamp Kuvioleimasin + Spot Healing Pistekorjaus + Red Eye Punasilmäisyyden poisto + Patch Paikkaus + Content-Aware Move Sisältötietoinen siirto + Rotate View Kierrä näkymää + Perspective Crop Perspektiivirajaus + Color Replacement Värinkorvaus + Dodge/Burn Vaalenna/tummenna + Foreground Edustaväri + Background Tausta + Set foreground color Aseta edustaväri + Set background color Aseta taustaväri + Swap colours (X) Vaihda värit (X) + Default colours (D) Oletusvärit (D) + Brush Tool Options Sivellintyökalun asetukset + Opacity Peittävyys + Flow Virtaus + Smoothing Tasoitus + Size Koko + Hardness Kovuus + Angle: Kulma: + Roundness: Pyöreys: + Spacing Välistys + Dynamics Dynamiikka + Pressure Paine + Tilt Kallistus + Rotation Kierto + Align Tasaa + Aligned Kohdistettu + +# Panels, dialogs and common widgets + OK OK + Cancel Peruuta + Cancel (Esc) Peruuta (Esc) + Manage submitted jobs in the system print queue Hallitse lähetettyjä töitä järjestelmän tulostusjonossa + Done Valmis + Apply Käytä + Reset Nollaa + Reset Section Nollaa osio + Reset All to Defaults Palauta kaikki oletuksiin + Import Shortcuts… Tuo pikanäppäimet… + Load a keyboard shortcut set saved as .kys Lataa .kys-muodossa tallennettu pikanäppäinsarja + Imported {n} shortcuts from {file}. Click OK to keep them. Tuotiin {n} pikanäppäintä tiedostosta {file}. Säilytä ne napsauttamalla OK. + {n} commands are not in PhotoCraft. {n} komentoa ei ole PhotoCraftissa. + Keyboard shortcuts imported Pikanäppäimet tuotu + 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. Pikanäppäinsarjasi {set} on käytössä: {n} pikanäppäintä poikkeaa oletuksista. Muokkaa › Pikanäppäimet näyttää ne; Palauta kaikki oletuksiin kumoaa tämän. + Import keyboard shortcuts? Tuodaanko pikanäppäimet? + 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 löysi tältä tietokoneelta toisen kuvankäsittelyohjelman pikanäppäinsarjan {set}: {n} sen pikanäppäintä poikkeaa PhotoCraftin pikanäppäimistä. Tuodaanko se? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Muokkaa › Pikanäppäimet › Palauta kaikki oletuksiin kumoaa tuonnin. + Don't Import Älä tuo + The keyboard shortcut set was not imported: {error} Pikanäppäinsarjaa ei tuotu: {error} + Defaults restored. Oletukset palautettu. + Restore Defaults Palauta oletukset + Restore original size Palauta alkuperäinen koko + Use Default Käytä oletusta + Dismiss Sulje + Preview Esikatselu + Options Asetukset + Settings… Asetukset… + Rename… Nimeä uudelleen… + Load… Lataa… + Define… Määritä… + Custom Mukautettu + Default Oletus + None Ei mitään + Add Lisää + Remove Poista + Remove Background Poista tausta + Rename Nimeä uudelleen + Ignore Ohita + Ignore All Ohita kaikki + Change Muuta + Change All Muuta kaikki + Next frame Seuraava ruutu + Previous frame Edellinen ruutu + Play Toista + Pause Tauko + Stop Pysäytä + Begin recording Aloita nauhoitus + Stop playing/recording Lopeta toisto/nauhoitus + Record ● a sequence of edits, then play ▶ it on any document. Nauhoita ● muokkaussarja ja toista ▶ se missä tahansa asiakirjassa. + Create new action Luo uusi toiminto + Play selection Toista valittu + Create new set Luo uusi sarja + Export selected measurements (CSV) Vie valitut mittaukset (CSV) + Record Measurements Tallenna mittaukset + Delete selected measurements Poista valitut mittaukset + Select Data Points… Valitse datapisteet… + Set Measurement Scale… Aseta mittakaava… + Use Measurement Scale Käytä mittakaavaa + +# Preferences dialog + Preferences Asetukset + General… Yleiset… + Interface… Käyttöliittymä… + File Handling… Tiedostojen käsittely… + Performance… Suorituskyky… + Cursors… Osoittimet… + Transparency & Gamut… Läpinäkyvyys ja väriskaala… + Units & Rulers… Yksiköt ja viivaimet… + Guides, Grid & Slices… Apuviivat, ruudukko ja viipaleet… + Plug-ins… Laajennukset… + Technology Previews Teknologian esikatselut + Scratch Disks… Työlevyt… + Export Preferences… Vientiasetukset… + Keyboard Shortcuts… Pikanäppäimet… + Menus… Valikot… + Export/Import Presets… Vie/tuo esiasetuksia… + Additional plugins folder Lisälaajennusten kansio + Install Plug-in… Asenna laajennus… + Export Presets Vie esiasetukset + Import brushes (.abr) Tuo siveltimiä (.abr) + Migrate Presets Siirrä esiasetukset + Thumbnail size (smallest: list) Pikkukuvan koko (pienin: luettelo) + Reset GPU Backend Nollaa GPU-taustajärjestelmä + Applies at next launch. Astuu voimaan seuraavalla käynnistyksellä. + +# Document and canvas status bar + Untitled Nimetön + Untitled-1 Nimetön-1 + Doc: {flat}/{layered} Asiak.: {flat}/{layered} + Scratch: {size} Työlevy: {size} + Efficiency: 100% Tehokkuus: 100 % + Dimensions: Mitat: + Width Leveys + Slant Kaltevuus + Height Korkeus + Width: Leveys: + Height: Korkeus: + Resolution Resoluutio + Resolution: Resoluutio: + Resolution: {dpi} pixels/inch Resoluutio: {dpi} pikseliä/tuuma + {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) + Pixels: Pikselit: + Pixel Length: Pikselipituus: + Logical Length: Looginen pituus: + Logical Units: Loogiset yksiköt: + Length: Pituus: + Interpolation: Interpolointi: + Resample Uudelleennäytteistä + Constrain proportions Säilytä mittasuhteet + Link width and height Linkitä leveys ja korkeus + Maintain aspect ratio Säilytä kuvasuhde + Current Size Nykyinen koko + New Size Uusi koko + Fit Sovita + Percent Prosentti + Pixels/Inch Pikseliä/tuuma + Pixels/Centimeter Pikseliä/senttimetri + Relative Suhteellinen + Canvas extension color: Kankaan laajennusväri: + Anchor: Ankkuri: + Both Axes Molemmat akselit + Horizontally Vaakasuunnassa + Vertically Pystysuunnassa + +# Layers panel + Layers to Files… Tasot tiedostoiksi… + Duplicate Layers… Monista tasot… + Group name Ryhmän nimi + New Group Uusi ryhmä + Layer Comp Tasoasetelma + Create new layer Luo uusi taso + Create new group Luo uusi ryhmä + Create new channel Luo uusi kanava + Create new path Luo uusi polku + Toggle visibility Vaihda näkyvyyttä + Clipped Leikattu + Group · {n} layer Ryhmä · {n} taso + Lock: keep these settings when picking another preset Lukitse: säilytä nämä asetukset, kun valitaan toinen esiasetus + Unlock: picking a preset replaces these settings Avaa lukitus: esiasetuksen valitseminen korvaa nämä asetukset + Show the layer's effects ({key}-click: all layers) Näytä tason tehosteet ({key}-napsautus: kaikki tasot) + Hide the layer's effects ({key}-click: all layers) Piilota tason tehosteet ({key}-napsautus: kaikki tasot) + Expand group ({key}-click: all groups) Laajenna ryhmä ({key}-napsautus: kaikki ryhmät) + Collapse group ({key}-click: all groups) Tiivistä ryhmä ({key}-napsautus: kaikki ryhmät) + Effects Tehosteet + Blending Sekoitus + Blend If Sekoita, jos + General Blend Yleinen sekoitus + Advanced Blending Sekoituksen lisäasetukset + Opacity: Peittävyys: + Fill: Täyttö: + Lock: Lukitse: + Smart Filter Älykäs suodatin + Fill Content Täyttösisältö + Adjustment Preview Säädön esikatselu + +# Channels panel + Channel: Kanava: + Channel Mixer… Kanavasekoitin… + Output Channel: Lähtökanava: + RGB RGB + Red Punainen + Green Vihreä + Blue Sininen + Alpha Alfa + Load channel as selection Lataa kanava valintana + Save selection as channel Tallenna valinta kanavana + Delete current channel Poista nykyinen kanava + Spot Channel Spottivärikanava + Mask All Maskaa kaikki + Mask All Objects Maskaa kaikki objektit + +# Color picker and swatches + Color Väri + Color: Väri: + Colors Värit + HSB/HSL HSB/HSL + No Color Ei väriä + Set shape fill type Aseta muodon täyttötyyppi + Set shape stroke type Aseta muodon reunaviivatyyppi + Set the text color Aseta tekstin väri + Create warped text Luo taivutettu teksti + Only Web Colors Vain verkkovärit + Overlay Color Peiteväri + Overlay Opacity Peitteen peittävyys + Glow Color Hehkun väri + Web Snap Tarttuminen verkkoväreihin + +# Filters and adjustments dialogs + Brightness Kirkkaus + Contrast Kontrasti + Brightness/Contrast… Kirkkaus/kontrasti… + Levels… Sävytasot… + Curves… Käyrät… + Curve Käyrä + Exposure… Valotus… + Exposure Valotus + Vibrance… Eloisuus… + Vibrance Eloisuus + Hue/Saturation… Sävy/värikylläisyys… + Hue Sävy + Saturation Värikylläisyys + Lightness Vaaleus + Color Balance… Väritasapaino… + Black & White… Mustavalkoinen… + Photo Filter… Kuvasuodatin… + Color Lookup… Värihakutaulukko… + Color Lookup Tables… Värihakutaulukot… + 3D LUT File 3D LUT -tiedosto + Load 3D LUT… Lataa 3D LUT… + Invert… Invertoi… + Invert Invertoi + Invert has no settings. Invertoinnilla ei ole asetuksia. + Threshold… Kynnysarvo… + Threshold Level Kynnystaso + Posterize… Posterisoi… + Gradient Map… Liukuvärikartta… + Selective Color… Valikoiva väri… + Match Color… Täsmäytä värit… + Replace Color… Korvaa väri… + Equalize Tasaa + HDR Toning… HDR-sävytys… + Shadows/Highlights… Varjot/korostukset… + Amount Määrä + Radius Säde + Radius: Säde: + Tolerance: Toleranssi: + Density Tiheys + Density: Tiheys: + Feather Pehmennä reunat + Feather… Pehmennä reunoja… + Anti-alias Antialiasointi + Anti-Alias Antialiasointi + Contiguous Yhtenäinen + Sample All Layers Ota näyte kaikilta tasoilta + Protect Foreground Color Suojaa edustaväri + Matte Mattaväri + Matting Reunojen puhdistus + Color Decontaminate… Puhdista värit… + Remove Black Matte Poista musta reunus + Remove White Matte Poista valkoinen reunus + Defringe… Poista reunaväristymät… + Source Lähde + Working CMYK Työ-CMYK + Working Cyan Plate Työ-syaanilevy + Working Magenta Plate Työ-magentalevy + Working Yellow Plate Työ-keltalevy + Working Black Plate Työ-mustalevy + Working Gray Plate Harmaa työlevy + +# Camera Raw and lens dialogs + Basic Perusasetukset + Detail Yksityiskohdat + HSL/Grayscale HSL/harmaasävy + Tone Curve Sävykäyrä + Split Toning Jaettu sävytys + Lens Corrections Objektiivikorjaukset + Camera Calibration Kameran kalibrointi + Presets Esiasetukset + Snapshots Tilannevedokset + Temperature Lämpötila + Tint Sävytys + Exposure: Valotus: + Recovery Palautus + Fill Light Täytevalo + Blacks Mustat + Clarity Selkeys + Convert to Grayscale Muunna harmaasävyiseksi + Auto Auto + Auto collapse icon panels Tiivistä kuvakepaneelit automaattisesti + Auto show hidden panels Näytä piilotetut paneelit automaattisesti + Auto show home screen Näytä aloitusnäyttö automaattisesti + Sharpness Terävyys + Noise Reduction Kohinan vaimennus + Color Noise Reduction Värikohinan vähennys + Luminance Detail Luminanssin yksityiskohdat + Color Detail Värin yksityiskohdat + Luminance Smoothness Luminanssin tasaisuus + Vignetting Vinjetointi + White Balance: As Shot Valkotasapaino: kuvauksen mukainen + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw ei ole auki + Dehaze Usvanpoisto + Texture Tekstuuri + Focal Length Polttoväli + Make Valmistaja + Model Malli + Lens Objektiivi + F F + ISO ISO + Focal Length35mm Polttoväli 35 mm + Aspect Ratio Kuvasuhde + Time Aika + Artist Taiteilija + Copyright Tekijänoikeus + Enter description… Kirjoita kuvaus… + Adobe RGB (1998) compatible Adobe RGB (1998) -yhteensopiva + Gray Gamma 2.2 Harmaa gamma 2,2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Alan standardi + Web Verkko + Internet Standard RGB Internetin standardi-RGB + Legacy Macintosh RGB Vanha Macintosh RGB + Monitor RGB Näytön RGB + Windows LCD Windows LCD + Coated CMYK CMYK, päällystetty paperi + Uncoated CMYK Päällystämätön CMYK + Newspaper Sanomalehti + Working Spaces Työväriavaruudet + Color Management Policies Värinhallintakäytännöt + Assign working space Määritä työavaruus + Leave as is (don't color manage) Jätä ennalleen (ei värinhallintaa) + Discard the embedded profile (don't color manage) Hylkää upotettu profiili (ei värinhallintaa) + Embed Color Profile Upota väriprofiili + Assign Profile… Määritä profiili… + Convert to Profile… Muunna profiiliksi… + PhotoCraft Manages Colors PhotoCraft hallitsee värejä + Printer Manages Colors Tulostin hallitsee värejä + (print to the printer) (tulosta tulostimelle) + Absolute Colorimetric Absoluuttinen kolorimetrinen + Relative Colorimetric Suhteellinen kolorimetrinen + Perceptual Havaintoon perustuva + Black Point Compensation Mustan pisteen kompensointi + Use Legacy Käytä vanhaa menetelmää + Dither Ditteröinti + No Dither Ei ditteröintiä + Diffusion Diffuusio + Pattern Kuvio + Fuzziness: Sumeus: + Range: Alue: + Select: Valitse: + Detect Faces Tunnista kasvot + Localize Color Clusters Paikallista väriryhmät + Sample: Näyte: + Add to Sample Lisää näytteeseen + Subtract from Sample Vähennä näytteestä + Selections Valinnat + Duplicated selection name Valinnan nimi on jo käytössä + Operation Toiminto + New channel only Vain uusi kanava + Masked Areas Maskatut alueet + Selected Areas Valitut alueet + Spot Color Spottiväri + Color indicates Väri osoittaa + +# Adjustments panel + Curves Käyrät + Levels Sävytasot + Color Balance Väritasapaino + Black & White Mustavalkoinen + Photo Filter Kuvasuodatin + Color Lookup Värihakutaulukko + Threshold Kynnysarvo + Posterize Posterisoi + Brightness/Contrast Kirkkaus/kontrasti + Channel Mixer Kanavasekoitin + Gradient Map Liukuvärikartta + Selective Color Valikoiva väri + +# History panel + History Log… Historialoki… + Create Snapshot Luo tilannevedos + Delete Snapshot Poista tilannevedos + New Snapshot Uusi tilannevedos + Snapshot 1 Tilannevedos 1 + Step Forward Askel eteenpäin + Step Backward Askel taaksepäin + Histories Historiat + Nonlinear History Epälineaarinen historia + Set New Snapshot as Default Aseta uusi tilannevedos oletukseksi + Automatically Create First Snapshot Luo ensimmäinen tilannevedos automaattisesti + Automatically Create New Snapshot When Saving Luo uusi tilannevedos automaattisesti tallennettaessa + Show New Snapshot Dialog by Default Näytä uuden tilannevedoksen valintaikkuna oletuksena + Clear History Tyhjennä historia + +# Navigator panel + Panel Options… Paneelin asetukset… + View menu Näytä-valikko + Keep view in sync Pidä näkymä synkronoituna + {zoom}% {zoom} % + +# Brush Settings panel + Brush name Siveltimen nimi + Brush Preview Siveltimen esikatselu + Brush Tip Shape Siveltimen kärjen muoto + Shape Dynamics Muodon dynamiikka + Scattering Sironta + Dual Brush Kaksoissivellin + Color Dynamics Väridynamiikka + Transfer Siirto + Brush Pose Siveltimen asento + Mouse Controls Hiiren ohjaimet + Wet Edges Märät reunat + Build-up Kertyminen + Protect Texture Suojaa tekstuuri + Blend Mode Sekoitustila + Size Jitter Koon vaihtelu + Minimum Diameter Vähimmäishalkaisija + Tilt Scale Kallistuksen skaalaus + Angle Jitter Kulman vaihtelu + Roundness Jitter Pyöreyden vaihtelu + Minimum Roundness Vähimmäispyöreys + Flip X Jitter X-peilauksen vaihtelu + Flip Y Jitter Y-peilauksen vaihtelu + Brush Projection Siveltimen projektio + Control: Ohjaus: + Off: the speed of the pointer sets the spacing Pois päältä: osoittimen nopeus määrittää välistyksen + Fade steps Häivytysvaiheet + Pen Pressure Kynän paine + Pen Tilt Kynän kallistus + Stylus Wheel Piirtokynän rulla + Initial Direction of Stroke Vedon alkusuunta + Direction of Stroke Vedon suunta + Opacity Jitter Peittävyyden vaihtelu + Flow Jitter Virtauksen vaihtelu + Humidity Kosteus + Load Lataa + Mix Sekoitus + Auto Clean Brush Puhdista sivellin automaattisesti + Wet Märkä + Current Brush Load Siveltimessä oleva maali + Load Brush Lisää maalia siveltimeen + Clean Brush Puhdista sivellin + Load the brush after each stroke Lisää maalia siveltimeen jokaisen vedon jälkeen + Clean the brush after each stroke Puhdista sivellin jokaisen vedon jälkeen + Rate Nopeus + Airbrush: keeps depositing paint while the pointer rests. Ilmasivellin: lisää maalia, kun osoitin on paikallaan. + Pulled String Mode Vedettävän narun tila + Catch-up on Stroke End Ota osoitin kiinni vedon lopussa + Adjust for Zoom Säädä zoomauksen mukaan + Apply Per Tip Käytä kärkikohtaisesti + Hardness: Kovuus: + Spacing: Välistys: + Size: Koko: + Flip X Peilaa X + Flip Y Peilaa Y + Brush Tip Siveltimen kärki + Diameter: Halkaisija: + New Layer from Brush Uusi taso siveltimestä + Rename Brush… Nimeä sivellin uudelleen… + Delete Brush Poista sivellin + Import Brushes… Tuo siveltimiä… + Import Brushes… (.abr or image) Tuo siveltimiä… (.abr tai kuva) + Import Brush from Image… Tuo sivellin kuvasta… + Gradient Preset Options Liukuvärin esiasetusten asetukset + Import .GRD Tuo .GRD + Choose a gradient file (.grd). Valitse liukuväritiedosto (.grd). + Brush Name Siveltimen nimi + Brush Stroke Siveltimenveto + New Brush Preset… Uusi siveltimen esiasetus… + Capture Brush Size in Preset Sisällytä siveltimen koko esiasetukseen + Include Tool Settings Sisällytä työkalun asetukset + Include Color Sisällytä väri + Enter a name for the brush preset Syötä nimi siveltimen esiasetukselle + A brush preset with this name already exists Tällä nimellä on jo olemassa siveltimen esiasetus + Brush Preset Options Siveltimen esiasetuksen asetukset + New Brush Uusi sivellin + Create new brush from these settings Luo uusi sivellin näistä asetuksista + Update the current brush with these settings Päivitä nykyinen sivellin näillä asetuksilla + Reset the brush to the defaults Palauta sivellin oletuksiin + No brush presets Ei siveltimen esiasetuksia + Override Pressure Pakota paine + Override Tilt Pakota kallistus + Override Tilt X Pakota kallistus X + Override Tilt Y Pakota kallistus Y + Override Rotation Pakota kierto + Apply the pen's tilt and rotation to the tip shape Käytä kynän kallistusta ja kiertoa kärjen muotoon + +# Tool presets + Preset Type Esiasetustyyppi + Preset Manager… Esiasetusten hallinta… + Rename Tool Preset… Nimeä työkalun esiasetus uudelleen… + Delete Tool Preset Poista työkalun esiasetus + New Tool Preset Uusi työkalun esiasetus + Create new tool preset Luo uusi työkalun esiasetus + Current Tool Only Vain nykyinen työkalu + No tool presets. Ei työkalujen esiasetuksia. + No presets for the current tool. Nykyiselle työkalulle ei ole esiasetuksia. + No presets of this type. Tämän tyyppisiä esiasetuksia ei ole. + Preset: Esiasetus: + Preset Esiasetus + Presets: Esiasetukset: + +# Paragraph and character panels + Font Size: Fonttikoko: + Font size Fonttikoko + Type: Tyyppi: + Style Tyyli + Style: Normal Tyyli: normaali + Tracking Kirjainväli + Kerning Parivälistys + Leading Riviväli + Auto-Leading Automaattinen riviväli + Scale Horizontally Skaalaa vaakasuunnassa + Scale Vertically Skaalaa pystysuunnassa + Baseline Shift Perusviivan siirto + Language Kieli + Anti-aliasing method Antialiasointimenetelmä + Sharp Terävä + Crisp Selkeä + Strong Voimakas + Smooth Pehmeä + Mac LCD Mac LCD + Mac Sharp Mac terävä + Mac Crisp Mac selkeä + Mac Strong Mac vahva + Mac Smooth Mac pehmeä + Faux Bold Keinolihavointi + Faux Italic Keinokursivointi + All Caps Isot kirjaimet + Small Caps Kapiteelit + Superscript Yläindeksi + Subscript Alaindeksi + Underline Alleviivaus + Strikethrough Yliviivaus + Oldstyle Tekstinumerot + Ligature Ligatuuri + Alternative Vaihtoehtoinen + Hyphenate Tavuta + Roman Normaali + Italic Kursivoitu + Bold Lihavoitu + Bold Italic Lihavoitu kursivoitu + Light Vaalea + Medium Keskikokoinen + Black Musta + Thin Ohut + Ultra Light Ultrakevyt + Extra Light Erittäin kevyt + Semi Bold Puolilihava + Extra Bold Erittäin lihava + Ultra Bold Ultralihava + Condensed Tiivistetty + Left align text Tasaa teksti vasemmalle + Center text Keskitä teksti + Right align text Tasaa teksti oikealle + Justify last left Tasaa molemmat reunat, viimeinen rivi vasemmalle + Justify last centered Tasaa molemmat reunat, viimeinen rivi keskitetty + Justify last right Tasaa molemmat reunat, viimeinen rivi oikealle + Justify all Tasaa kaikki rivit + Indent Left Vasen sisennys + Indent Right Oikea sisennys + First Line Indent Ensimmäisen rivin sisennys + Space Before Väli ennen + Space After Väli jälkeen + Hanging Punctuation Riippuva välimerkitys + Roman Hanging Punctuation Latinalainen riippuva välimerkitys + Avoid Widows/Orphans Vältä leski- ja orporivejä + Compose Sommittele + Adobe Single-line Composer Adobe yksirivinen asettelija + Adobe Every-line Composer Adobe kaikkien rivien asettelija + Justification Tasaus + Word Spacing Sanaväli + Letter Spacing Merkkiväli + Glyph Scaling Glyfien skaalaus + Auto-leading % Automaattinen riviväli % + Single Word Justification Yksittäisen sanan tasaus + Alignment Tasaus + Indent First Ensimmäisen rivin sisennys + Center Keskitä + Justify Tasaa + +# Glyphs panel + Glyph Glyfi + Select a type layer or click into text to insert glyphs Valitse tekstitaso tai napsauta tekstiä lisätäksesi glyfejä + Entire Font Koko fontti + Alternates for Current Selection Nykyisen valinnan vaihtoehtomerkit + Recently Used Viimeksi käytetyt + Digits Numerot + Symbols Symbolit + Punctuation Välimerkit + OpenType OpenType + Standard Ligatures Vakioligatuurit + Discretionary Ligatures Valinnaiset ligatuurit + Contextual Alternates Kontekstuaaliset vaihtoehdot + Swash Kiemura + Titling Alternates Otsikkovaihtoehdot + Ornaments Koristeet + Fractions Murtoluvut + Ordinals Järjestysluvut + East Asian Features Itä-Aasian ominaisuudet + Middle Eastern Features Lähi-idän ominaisuudet + +# Paths panel + Path Polku + Path: Polku: + Work Path Työpolku + Path to Illustrator… Polku Illustratoriin… + Make Work Path Luo työpolku + Make Selection Luo valinta + Fill Path Täytä polku + Stroke Path Piirrä polun reunaviiva + Clipping Path Leikkauspolku + New Path Uusi polku + Duplicate Path Monista polku + Rename Path… Nimeä polku uudelleen… + Delete Path Poista polku + Current Path Nykyinen polku + Vector Mask from Current Path Vektorimaski nykyisestä polusta + Create new shape from the current path Luo uusi muoto nykyisestä polusta + Drag to move the active shape's path or the Work Path Siirrä aktiivisen muodon polkua tai työpolkua vetämällä + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Napsautus: valitse ankkuri · Veto: siirrä ankkuria, kahvaa tai segmenttiä · {shift}-napsautus: lisää · {alt}-napsautus: koko alipolku + Draw with the Pen tool (P) or make a work path from a selection. Piirrä kynätyökalulla (P) tai luo työpolku valinnasta. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Lähde: ei asetettu ({key}-napsautus kloonausleimasimella) + Clone source {n} ({key}-click with Clone Stamp to set) Kloonauslähde {n} (aseta {key}-napsautuksella kloonausleimasimella) + {key}-click to set the source Aseta lähde {key}-napsautuksella + Option-click to define a source point to clone from Määritä kloonauksen lähdepiste Option-napsautuksella + Show Overlay Näytä peite + Auto-Hide Piilota automaattisesti + W L + H K + Angle Kulma + Scale Skaalaus + X X + Y Y + Offset Siirtymä + Reset Transform Nollaa muunnos + Rotate Kierrä + Set horizontal skew Aseta vaakasuuntainen vinous + Set vertical skew Aseta pystysuuntainen vinous + Flip Horizontal Peilaa vaakasuunnassa + Flip Vertical Peilaa pystysuunnassa + Frame Sync Ruutujen synkronointi + Quick Sync Pikasynkronointi + Enabled Käytössä + +# Timeline panel + Create Video Timeline Luo videon aikajana + Open a document to use the timeline. Avaa asiakirja käyttääksesi aikajanaa. + Video Layers Videotasot + Video Video + Audio Ääni + Frame Duration Ruudun kesto + 30 frames @ 30 fps 30 ruutua @ 30 fps + Loop Silmukka + Render Video… Renderöi video… + Split at Playhead Jaa toistokohdassa + Delete Timeline Poista aikajana + Reload Frame Lataa ruutu uudelleen + Restore Frame Palauta ruutu + Insert Blank Frame Lisää tyhjä ruutu + Duplicate Frame Monista ruutu + Delete Frame Poista ruutu + Restore All Frames Palauta kaikki ruudut + Flatten Frames Into Clips Yhdistä ruudut leikkeiksi + Flatten Frames Into Layers Yhdistä ruudut tasoiksi + Convert Frames > Video Layers Muunna ruudut > videotasot + New Video Layer Uusi videotaso + New Video Group Uusi videoryhmä + Interpret Footage… Tulkitse kuvamateriaali… + Replace Footage… Korvaa kuvamateriaali… + Show Altered Video Näytä muokattu video + Limit Playback Rajoita toistoa + Enable Timeline Quick Config Ota aikajanan pika-asetukset käyttöön + Set Start Timecode… Aseta aloitusaikakoodi… + Timecode Aikakoodi + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Nykyinen aika + Out Ulos + In Sisään + Duration Kesto + Repeat Toista reunapikselit + Play Once Toista kerran + Video Cache Videovälimuisti + +# Info panel + Info Options… Tietopaneelin asetukset… + First Color Readout Ensimmäinen värilukema + Second Color Readout Toinen värilukema + Actual Color Todellinen väri + Grayscale Slider Harmaasävyn liukusäädin + Web Color Web-väri + HSB Color HSB-väri + Total Ink Musteen kokonaismäärä + Opacity Mask Peittävyysmaski + Ruler Viivain + Current Tool Nykyinen työkalu + Document Size Asiakirjan koko + Document Profile Asiakirjan profiili + Document Dimensions Asiakirjan mitat + Scratch Sizes Työlevyn koot + Efficiency Tehokkuus + Timing Ajoitus + Status Information Tilatiedot + Saved Multichannel Tallennettu monikanavaisena + Mouse Coordinates Hiiren koordinaatit + Ruler Units Viivaimen yksiköt + Hide Piilota + +# Layer Comps panel + Layer Comps to Files… Tasoasetelmat tiedostoiksi… + Apply next selected layer comp Käytä seuraavaa valittua tasoasetelmaa + Apply previous selected layer comp Käytä edellistä valittua tasoasetelmaa + Update layer comp (visibility, position and appearance) Päivitä tasoasetelma (näkyvyys, sijainti ja ulkoasu) + New Layer Comp Uusi tasoasetelma + Duplicate Layer Comp Monista tasoasetelma + Delete layer comp Poista tasoasetelma + Rename Layer Comp Nimeä tasoasetelma uudelleen + Create new layer comp Luo uusi tasoasetelma + Capture the visibility, position and style of every layer with + below, then switch between versions. Kaappaa jokaisen tason näkyvyys, sijainti ja tyyli alla olevalla +-painikkeella ja vaihda sitten versioiden välillä. + Apply Layer Comp Käytä tasoasetelmaa + Update Layer Comp Päivitä tasoasetelma + Restore last viewed comp Palauta viimeksi katsottu asetelma + Last Document State Asiakirjan viimeisin tila + Visibility Näkyvyys + Position Sijainti + Appearance (Layer Style) Ulkoasu (tasotyyli) + +# Analysis and variables + Analysis Analyysi + Variables Muuttujat + Variable Data Sets… Muuttuvat tietojoukot… + Data Sets… Tietojoukot… + Data Sets Tietojoukot + Data Sets as Files… Tietojoukot tiedostoiksi… + Apply Data Set… Käytä tietojoukkoa… + Define Määritä + + Add variable + Lisää muuttuja + Remove variable Poista muuttuja + Define at least one variable first. Määritä ensin vähintään yksi muuttuja. + Open a document to define variables. Avaa asiakirja määrittääksesi muuttujia. + Variables bind a layer's visibility, text or pixels to a named data slot. Muuttujat sitovat tason näkyvyyden, tekstin tai pikselit nimettyyn tietopaikkaan. + Pixel Replacement Pikseleiden korvaus + Text Replacement Tekstin korvaus + Name Nimi + Method Menetelmä + Select All Layers Valitse kaikki tasot + Fill Täyttö + Keep proportions Säilytä mittasuhteet + Interpolation Interpolointi + Author: Tekijä: + Author Tekijä + Name: Nimi: + Caption Kuvateksti + Caption Credits Kuvatekstin tekijät + Description Kuvaus + Keywords Avainsanat + Copyright Info URL Tekijänoikeustietojen URL + Copyright Status Tekijänoikeuden tila + Copyright Notice Tekijänoikeusilmoitus + Document Title Asiakirjan otsikko + Copyrighted Tekijänoikeussuojattu + Public Domain Tekijänoikeudeton + Unknown Tuntematon + Copyright and Contact Info Tekijänoikeus- ja yhteystiedot + Credit Tekijätieto + Transmission Reference Lähetysviite + Urgency Kiireellisyys + High Korkea + Normal Normaali + Lossless Häviötön + Low Matala + Category Luokka + Supplemental Categories Lisäluokat + Object Name / Title Objektin nimi / otsikko + Date Created Luontipäivä + City Kaupunki + Location Sijainti + Province / State Maakunta / osavaltio + Country Maa + Byline Tekijä + Byline Title Tekijän titteli + Phone Puhelin + Email Sähköposti + URLs URL-osoitteet + Last Modified Viimeksi muokattu + EXIF EXIF + GPS GPS + Camera Data Kameratiedot + Camera Model Kameran malli + Camera Make Kameran valmistaja + EXIF Version EXIF-versio + Exposure Program Valotusohjelma + Aperture Aukko + Shutter Speed Suljinaika + Metering Mode Mittaustapa + Light Source Valonlähde + Flash Salama + Lens Info Objektiivin tiedot + ISO Speed ISO-herkkyys + Subject Distance Kohteen etäisyys + DateTime Original Alkuperäinen päivämäärä ja aika + Create Date Luontipäivä + Modify Date Muokkauspäivä + Software Ohjelmisto + Raw Data Raakadata + Version {version} Versio {version} + Advanced Lisäasetukset + Import… Tuo… + +# Automate + Automate Automaatio + Batch… Eräajo… + Create Droplet… Luo pikkusovellus… + Create Droplet Luo pikkusovellus + PDF Presentation… PDF-esitys… + Create Multiple Picture Packages Luo useita kuvapaketteja + Contact Sheet II Vedosarkki II + Contact Sheet II… Vedosarkki II… + Conditional Mode Change… Ehdollinen tilanvaihto… + Fit Image… Sovita kuva… + Picture Package… Kuvapaketti… + Web Photo Gallery… Verkkokuvagalleria… + Merge to HDR Pro… Yhdistä HDR Pro -kuvaksi… + Load Files into Stack… Lataa tiedostot pinoon… + Scripts Komentosarjat + Script Events Manager… Komentosarjatapahtumien hallinta… + Image Processor… Kuvaprosessori… + Destination Kohde + Choose… Valitse… + Select Folder… Valitse kansio… + Override Action "Save" Commands Ohita toimintojen ”Tallenna”-komennot + Compatibility Yhteensopivuus + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Sisällytä ICC-profiili + Error File Virhetiedosto + Stop For Errors Pysäytä virheisiin + Log Errors To File Kirjaa virheet tiedostoon + Errors Virheet + +# Color Settings dialog + Color Settings… Väriasetukset… + Settings Asetukset + Settings: Asetukset: + Working Space Työväriavaruus + RGB: RGB: + CMYK: CMYK: + Gray: Harmaa: + Spot: Spottiväri: + Advanced Controls Lisäasetukset + Desaturate Monitor Colors By Vähennä näytön värien kylläisyyttä + Blend RGB Colors Using Gamma 1.0 Sekoita RGB-värit gammalla 1,0 + Profile Mismatches: Ask When Opening Profiilien ristiriidat: kysy avattaessa + Ask When Pasting Kysy liitettäessä + Missing Profiles: Ask When Opening Puuttuvat profiilit: kysy avattaessa + Embed profiles Upota profiilit + Ask Before Saving Saved Embedded Kysy ennen upotetun tallentamista + Coerce to sRGB Pakota sRGB:ksi + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + +# Themes + Theme Teema + Switch theme Vaihda teemaa + Next Theme Seuraava teema + Classic Theme Klassinen teema + Solarized dark Tumma Solarized + Solarized Dark Theme Tumma Solarized -teema + Breeze dark Tumma Breeze + Breeze Dark Theme Tumma Breeze -teema + Breeze light Vaalea Breeze + Breeze Light Theme Vaalea Breeze -teema + Soft dark Pehmeä tumma + Soft light Pehmeä vaalea + Soft Dark Theme Pehmeä tumma teema + Soft Light Theme Pehmeä vaalea teema + Studio Theme Studio-teema + Studio Light Theme Vaalea Studio-teema + Adwaita Light Theme Vaalea Adwaita -teema + Adwaita Dark Theme Tumma Adwaita -teema + Pro Theme Pro-teema + Pro Medium Gray Theme Keskiharmaa Pro-teema + Pro Light Gray Theme Vaaleanharmaa Pro-teema + Pro Light Theme Vaalea Pro-teema + +# Workspaces + Workspace… Työtila… + Essentials Perusteet + New Workspace… Uusi työtila… + Delete Workspace… Poista työtila… + Reset Workspace Nollaa työtila + Reset Tool Nollaa työkalu + Reset All Tools Nollaa kaikki työkalut + Reset all tools to their default settings? Nollataanko kaikki työkalut oletusasetuksiin? + Lock Workspace Lukitse työtila + Photography Valokuvaus + Graphic and web Grafiikka ja verkko + Painting Maalaus + Motion Liike + Minimalistic Minimalistinen + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Paneelien sijainnit tallennetaan työtilaan. Pikanäppäimet, valikot ja työkalurivi ovat valinnaisia. + Keyboard Shortcuts Pikanäppäimet + Keyboard Shortcuts and Menus Pikanäppäimet ja valikot + Menus Valikot + More panels Muut paneelit + More documents Muut asiakirjat + Collapse All Groups Tiivistä kaikki ryhmät + Expand All Groups Laajenna kaikki ryhmät + Collapse Tiivistä + Expand Laajenna + Collapse Panel Group Tiivistä paneeliryhmä + Expand Panel Group Laajenna paneeliryhmä + Close Tab Group Sulje välilehtiryhmä + +# New Document dialog + PRESET DETAILS ESIASETUKSEN TIEDOT + BLANK DOCUMENT PRESETS ({n}) TYHJÄN ASIAKIRJAN ESIASETUKSET ({n}) + Create a new document or open an existing file. Luo uusi asiakirja tai avaa olemassa oleva tiedosto. + Drop an image or PSD anywhere to open it. Avaa kuva tai PSD pudottamalla se mihin tahansa. + Use File › Open, or paste a copied image with {paste}. Käytä komentoa Tiedosto › Avaa tai liitä kopioitu kuva komennolla {paste}. + Native file drag-and-drop is unavailable Tiedostojen vetäminen ja pudottaminen ei ole käytettävissä + 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}. Tiedostojen vetämistä ja pudottamista ei vielä tueta Waylandissa. Käytä komentoa Tiedosto › Avaa tai kopioi kuva tiedostonhallinnassa ja liitä se komennolla {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Jos haluat pudottaa tiedostoja, käynnistä PhotoCraft XWaylandissa: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Tai aseta Asetukset › Suorituskyky › Linux-näyttöpalvelin arvoksi X11: PhotoCraft käynnistyy tällöin aina XWaylandissa. + Edit › Paste Muokkaa › Liitä + Photo Valokuva + Print Tulosta + Art & Illustration Taide ja kuvitus + Mobile Mobiili + Film & Video Elokuva ja video + Default PhotoCraft Size PhotoCraftin oletuskoko + Clipboard Leikepöytä + Photo (2 × 3 in) Valokuva (2 × 3 in) + Photo (4 × 6 in) Valokuva (4 × 6 in) + Photo (5 × 7 in) Valokuva (5 × 7 in) + Photo (8 × 10 in) Valokuva (8 × 10 in) + Landscape Vaaka + Portrait Pysty + Recent Items Viimeisimmät kohteet + Save Preset… Tallenna esiasetus… + Delete Preset… Poista esiasetus… + Preset Name: Esiasetuksen nimi: + Width and Height Leveys ja korkeus + Background Contents Taustan sisältö + Custom background color Mukautettu taustaväri + White Valkoinen + Background Color Taustaväri + Transparent Läpinäkyvä + Color Mode Väritila + Depth Syvyys + Color Profile Väriprofiili + Create Luo + Document Asiakirja + Units Yksiköt + Pixels Pikselit + Inches Tuumat + Centimeters Senttimetrit + Millimeters Millimetrit + Points Pisteet + Picas Picat + Columns Sarakkeet + +# Placeholders, tooltips, status and keyboard hints + {name} options {name}: asetukset + Align {what} Tasaa {what} + Untagged {mode} Profiiliton {mode} + Invalid {mode} profile Virheellinen {mode}-profiili + Unnamed {mode} profile Nimetön {mode}-profiili + Constraints ({n}) Rajoitteet ({n}) + Count: {n} Määrä: {n} + Click to add a point · drag off or {key}-click or Delete to remove Lisää piste napsauttamalla · poista vetämällä pois, {key}-napsautuksella tai Deletellä + Click to apply, {key}-click to clear overrides, double-click for Style Options Käytä napsauttamalla, tyhjennä ohitukset {key}-napsautuksella, tyyliasetukset kaksoisnapsautuksella + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Napsauta: kulma · Vedä: pehmeä · Napsauta ensimmäistä pistettä: sulje · {key} lopeta · Esc peruuta + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Napsauta: kulma · Vedä: pehmeä · {alt}-vedä: katkaise kahva · Napsauta tai vedä ensimmäistä pistettä: sulje · {key} lopeta · Esc peruuta + Commit Perspective Warp ({key}) Vahvista perspektiivin taivutus ({key}) + Commit Puppet Warp ({key}) Vahvista nukketaivutus ({key}) + Commit any current edits ({key}) Vahvista nykyiset muokkaukset ({key}) + Commit transform ({key}) Vahvista muunnos ({key}) + Commit warp ({key}) Vahvista taivutus ({key}) + Cancel any current edits (Esc) Peruuta nykyiset muokkaukset (Esc) + Cancel transform (Esc) Peruuta muunnos (Esc) + Cancel warp (Esc) Peruuta taivutus (Esc) + Do you want to save the changes you made to “{name}” before closing? Tallennetaanko kohteeseen ”{name}” tehdyt muutokset ennen sulkemista? + Revert “{name}” to the last saved version? Your changes will be lost. Palautetaanko ”{name}” viimeksi tallennettuun versioon? Muutokset menetetään. + {key} to subtract {key}: vähennä + ↑↓ navigate {key} run esc close ↑↓ siirry {key} suorita esc sulje + Click to restore the current colour Palauta nykyinen väri napsauttamalla + Click with the Note tool to add a note. Lisää muistiinpano napsauttamalla Muistiinpano-työkalulla. + Drag a rectangle around the object Vedä suorakulmio kohteen ympärille + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Lisää rajoite vetämällä kuvassa; Shift vaaka-/pystysuunnalle; muuta tai poista rajoite napsauttamalla sitä hiiren kakkospainikkeella. + Drag the arrow to set the angle, the dots to set the roundness Vedä nuolta asettaaksesi kulman, pisteitä asettaaksesi pyöreyden + Create new pattern from the selection Luo uusi kuvio valinnasta + Create new gradient Luo uusi liukuväri + Create new count group Luo uusi laskentaryhmä + Search commands, tools and panels… Etsi komentoja, työkaluja ja paneeleja… + Search Brushes Etsi siveltimiä + Search fonts Etsi fontteja + No matching commands Ei vastaavia komentoja + No document Ei asiakirjaa + Pixel Layer Pikselitaso + Type Layer Tekstitaso + Shape Layer Muototaso + Group Layer Ryhmätaso + Fill Layer Täyttötaso + Smart Object Layer Älykäs objekti -taso + Adjustment Layer Säätötaso + {name} Properties {name}: ominaisuudet + Move Tool Siirtotyökalu + Could not use the move tool because the layer is locked. Siirtotyökalua ei voitu käyttää, koska taso on lukittu. + Could not complete the Free Transform command because the layer is locked. Vapaa muunnos -komentoa ei voitu suorittaa, koska taso on lukittu. + Rectangular Marquee Tool Suorakulmainen valintakehys + Elliptical Marquee Tool Elliptinen valintakehys + Single Row Marquee Tool Yhden rivin valintakehys + Single Column Marquee Tool Yhden sarakkeen valintakehys + Brush Tool Sivellintyökalu + Pencil Tool Lyijykynätyökalu + Eraser Tool Pyyhekumityökalu + Background Eraser Tool Taustapyyhekumi + Magic Eraser Tool Taikapyyhekumi + Eyedropper Tool Pipettityökalu + Note Tool Muistiinpanotyökalu + Lasso Tool Lassotyökalu + Polygonal Lasso Tool Monikulmiolasso + Magic Wand Tool Taikasauva + Crop Tool Rajaustyökalu + Slice Tool Viipaletyökalu + Slice Select Tool Viipaleen valintatyökalu + Gradient Tool Liukuvärityökalu + Paint Bucket Tool Maaliämpäri + Horizontal Type Tool Vaakasuuntainen tekstityökalu + Horizontal Type Mask Tool Vaakasuuntainen tekstimaskityökalu + Vertical Type Mask Tool Pystysuuntainen tekstimaskityökalu + Hand Tool Käsityökalu + Zoom Tool Zoomaustyökalu + Spot Healing Brush Tool Pistekorjaussivellin + Healing Brush Tool Korjaussivellin + Clone Stamp Tool Kloonausleimasin + History Brush Tool Historiasivellin + Blur Tool Sumennustyökalu + Sharpen Tool Terävöitystyökalu + Smudge Tool Tahraustyökalu + Dodge Tool Vaalennustyökalu + Burn Tool Tummennustyökalu + Sponge Tool Sienityökalu + Quick Selection Tool Pikavalintatyökalu + Object Selection Tool Objektinvalintatyökalu + Pen Tool Kynätyökalu + Convert Point Tool Muunna piste -työkalu + Path Selection Tool Polun valintatyökalu + Direct Selection Tool Suoravalintatyökalu + Add Anchor Point Tool Lisää ankkuripiste -työkalu + Delete Anchor Point Tool Poista ankkuripiste -työkalu + Click a segment to add an anchor point · {alt}-click an anchor to delete it Lisää ankkuripiste napsauttamalla segmenttiä · poista ankkuri {alt}-napsautuksella + Click an anchor point to delete it · {alt}-click a segment to add one Poista ankkuripiste napsauttamalla sitä · lisää ankkuri segmenttiin {alt}-napsautuksella + Rectangle Tool Suorakulmiotyökalu + Ellipse Tool Ellipsityökalu + Triangle Tool Kolmiotyökalu + Polygon Tool Monikulmiotyökalu + Line Tool Viivatyökalu + Custom Shape Tool Mukautetun muodon työkalu + Pixel Pikseli + Group Ryhmä + Adjustment Säätö + Smart Object Älykäs objekti + Drag to move the active layer Siirrä aktiivista tasoa vetämällä + Drag to pan · hold Space with any tool Panoroi vetämällä · tai pidä Välilyöntiä painettuna millä tahansa työkalulla + Drag to draw a gradient Piirrä liukuväri vetämällä + Click to fill similar colours Täytä samankaltaiset värit napsauttamalla + Click to add text Lisää teksti napsauttamalla + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Vedä (lasso) tai napsauta pisteitä (monikulmio) · {add} lisää · {sub} vähennä + 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 Vedä rajauslaatikko · siirrä vetämällä sisältä · reunat muuttavat kokoa ({ratio} suhde, {centre} keskeltä) · Välilyönti siirtää piirrettäessä · nuolet siirtävät · X vaihtaa suunnan · {commit} vahvistaa · Esc peruuttaa + {mode} Color · {bits} bit {mode}-väri · {bits}-bittinen + Drag to move the layer Siirrä tasoa vetämällä + Fit Screen Sovita näyttöön + Fill Screen Täytä näyttö + Scrubby Zoom Vetozoomaus + Auto Erase Automaattinen pyyhintä + Color Adaptation Värin mukautus + Preserve Transparency Säilytä läpinäkyvyys + Reverse Käännä järjestys + Align with layer Tasaa tason kanssa + Join us on Discord Liity Discordiin + PhotoCraft website PhotoCraft-verkkosivusto + ArtCraft ArtCraft + GitHub GitHub + No character style Ei merkkityyliä + No author Ei tekijää + Not in Dictionary: Ei sanastossa: + Suggestions: Ehdotukset: + Spell check complete. Oikeinkirjoituksen tarkistus valmis. + Check All Layers Tarkista kaikki tasot + Select a type layer to edit its character and paragraph settings. Valitse tekstitaso muokataksesi sen merkki- ja kappaleasetuksia. + Select text to apply a character style Valitse teksti, johon merkkityyli käytetään + Redefine style by current selection Määritä tyyli uudelleen nykyisen valinnan mukaan + Duplicate style Monista tyyli + Delete style Poista tyyli + Style Options Tyyliasetukset + Style Options… Tyyliasetukset… + Close Style Options Sulje tyyliasetukset + Appearance Ulkoasu + Text Position: Tekstin sijainti: + Text color Tekstin väri + Uses the foreground and background colours. Käyttää edusta- ja taustaväriä. + Answer the unsaved-changes prompt first Vastaa ensin tallentamattomien muutosten kyselyyn + Unsaved changes Tallentamattomat muutokset + Don't Save Älä tallenna + No Ei + Yes Kyllä + These settings aren't available in PhotoCraft yet. Nämä asetukset eivät ole vielä käytettävissä PhotoCraftissa. + Shortcut removed. Pikanäppäin poistettu. + All shortcuts reset to PhotoCraft defaults. Kaikki pikanäppäimet palautettu PhotoCraftin oletusasetuksiin. + Add Shortcut Lisää pikanäppäin + Delete Shortcut Poista pikanäppäin + Press keys… Paina näppäimiä… + shortcut pikanäppäin + command or shortcut komento tai pikanäppäin + current nykyinen + image file path kuvatiedoston polku + modified muokattu + name nimi + new uusi + Go to Layer Siirry tasoon + Open a document to select a colour range. Avaa asiakirja valitaksesi värialueen. + Saved Tallennettu + Reveal in Finder Näytä Finderissa + Show in Explorer Näytä Resurssienhallinnassa + Show in Folder Näytä kansiossa + Before (Y) Ennen (Y) + Save Settings… Tallenna asetukset… + Load Settings… Lataa asetukset… + Save these settings as a Camera Raw .xmp file Tallenna nämä asetukset Camera Raw .xmp -tiedostona + Load settings from a Camera Raw .xmp file Lataa asetukset Camera Raw .xmp -tiedostosta + Loaded Camera Raw settings from {file} Camera Raw -asetukset ladattu tiedostosta {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw -asetukset ladattu tiedostosta {file} ({n} tukematonta) + After (X) Jälkeen (X) + Original Alkuperäinen + Current Nykyinen + {added}, {removed} subtracted {added}, {removed} vähennetty + Filter: Suodatin: + +# Transform / warp overlays + Free Transform Vapaa muunnos + Free Transform a Copy Vapaa muunnos kopiolle + Transform Muunna + Transform Selection Muunna valinta + Switch between free transform and warp modes Vaihda vapaan muunnoksen ja taivutustilan välillä + Perspective Perspektiivi + Skew Vinouta + Distort Vääristys + Rotate: Kierto: + Flip Peilaa + Warp: Taivutus: + Warp Taivutus + Bend: Kaarevuus: + Reference point location Viitepisteen sijainti + Toggle reference point Näytä/piilota viitepiste + Free Vapaa + Arc Kaari + Arch Holvikaari + Bulge Pullistuma + Shell Lower Alakuori + Shell Upper Yläkuori + Flag Lippu + Fish Kala + Wave Aalto + Rise Nousu + Fisheye Kalansilmä + Inflate Paisutus + Twist Kierre + Squeeze Puristus + Corner radius Kulman säde + Split Warp Crosswise Jaa taivutus poikittain + Split Warp Horizontally Jaa taivutus vaakasuunnassa + Split Warp Vertically Jaa taivutus pystysuunnassa + Remove Warp Split Poista taivutuksen jako + Puppet Warp Nukketaivutus + Puppet Warp is not active Nukketaivutus ei ole aktiivinen + Perspective Warp Perspektiivin taivutus + Perspective Warp is not active Perspektiivin taivutus ei ole aktiivinen + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektiivin taivutus: piirrä perspektiivitasot kuvan perspektiivin mukaan ja vaihda sitten Taivutus-tilaan + Remove Planes Poista perspektiivitasot + Edit Pins Muokkaa nastoja + Add Pins Lisää nastoja + Delete Pins Poista nastat + Remove All Pins Poista kaikki nastat + Remove Tool Poistotyökalu + Pin Depth: Nastan syvyys: + Bring the selected pin forward Tuo valittu nasta eteen + Send the selected pin backward Siirrä valittua nastaa taaksepäin + Rotate Auto Kierrä automaattisesti + Rotate Fixed Kiinteä kierto + Adaptive Wide Angle isn't open Mukautuva laajakulma ei ole auki + Liquify is not open Nesteytys ei ole auki + +# Vanishing Point + Create Plane Luo perspektiivitaso + Edit Plane Muokkaa perspektiivitasoa + Select Plane Valitse perspektiivitaso + Measure Mittaa + Heal Korjaa + Off Pois + Symmetry Off Symmetria pois päältä + Heal Off Korjaus: pois + Heal On Korjaus: päällä + Heal Auto Korjaus: automaattinen + Blend Sekoitus + Brush Size Siveltimen koko + Heal Color Korjaus: väri + +# Misc dialog strings + Mask Maski + Channel Kanava + Rectangle Suorakulmio + Subtract Vähennys + Multiply Kertominen + Divide Jakaminen + Result Tulos + New Document Uusi asiakirja + New Document With Mask Uusi asiakirja maskilla + New Layer With Mask Uusi taso maskilla + New Layer Uusi taso + Preset Name Esiasetuksen nimi + No saved presets yet. Tallennettuja esiasetuksia ei vielä ole. + New Channel Uusi kanava + Selection Valinta + Save Selection Tallenna valinta + Load Selection Lataa valinta + Duplicate Monista + Fast Nopea + Accurate Tarkka + Add to Lisää kohteeseen + Subtract from Vähennä + Intersect with Leikkaa + +# Blend modes + Pass Through Läpäisy + Dissolve Liuota + Darken Tummenna + Color Burn Väritummennus + Linear Burn Lineaarinen tummennus + Darker Color Tummempi väri + Lighten Vaalenna + Screen Rasteri + Color Dodge Värivaalennus + Linear Dodge (Add) Lineaarinen vaalennus (lisää) + Lighter Color Vaaleampi väri + Overlay Peitto + Soft Light Pehmeä valo + Hard Light Kova valo + Vivid Light Elävä valo + Linear Light Lineaarinen valo + Pin Light Pistevalo + Hard Mix Kova sekoitus + Difference Erotus + Exclusion Poissulku + Luminosity Luminanssi + +# Saved views and other bits + Capture Comp Tallenna asetelma + No Layer Comps Ei tasoasetelmia + +# Plurals +@plural {n} item|{n} items {n} kohde|{n} kohdetta +@plural {n} Layer|{n} Layers {n} taso|{n} tasoa +@plural {n} layer|{n} layers {n} taso|{n} tasoa +@plural Group · {n} layer|Group · {n} layers Ryhmä · {n} taso|Ryhmä · {n} tasoa +@plural {n} sample|{n} samples {n} näyte|{n} näytettä + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… 32-bittisen esikatselun asetukset… + 90° Clockwise 90° myötäpäivään + 90° Counter Clockwise 90° vastapäivään + AI Integrations… Tekoälyintegraatiot… + Again Uudelleen + Transform Again on a Copy Muunna uudelleen kopiolle + Anamorphic 2:1 (2) Anamorfinen 2:1 (2) + Arbitrary… Vapaa kulma… + Artboard Guides Piirtoalustan apuviivat + Artboard from Group… Piirtoalusta ryhmästä… + Artboard from Layers… Piirtoalusta tasoista… + Artboards to Files… Piirtoalustat tiedostoiksi… + Artboards to PDF… Piirtoalustat PDF-tiedostoksi… + Artboard… Piirtoalusta… + Border… Reunus… + Bottom Edges Alareunat + Browse… Selaa… + Source Files Lähdetiedostot + Use: Käytä: + Files Tiedostot + Folder Kansio + Add Open Files Lisää avoimet tiedostot + Sort By Name Järjestä nimen mukaan + Attempt to Automatically Align Source Images Yritä kohdistaa lähdekuvat automaattisesti + Create Smart Object after Loading Layers Luo älykäs objekti tasojen lataamisen jälkeen + Canvas Guides Kankaan apuviivat + Character Styles Panel Merkkityylipaneeli + Clear Canvas Guides Poista kankaan apuviivat + Clear Selected Artboard Guides Poista valitun piirtoalustan apuviivat + Color Blindness — Deuteranopia-type Värisokeus — deuteranopia + Color Blindness — Protanopia-type Värisokeus — protanopia + Color Table… Väritaulukko… + Combine Shapes Yhdistä muodot + Contract… Supista… + Convert to Embedded Muunna upotetuksi + Convert to Layers Muunna tasoiksi + Convert to Linked… Muunna linkitetyksi… + Count Laskuri + Create Layer Luo taso + Custom Pixel Aspect Ratio… Mukautettu pikselin kuvasuhde… + Cycle Screen Mode Vaihda näyttötilaa + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC -laajakuva (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL -laajakuva (1,46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + De-Interlace… Lomituksen poisto… + Default Features Oletusominaisuudet + Delete All Empty Layers Poista kaikki tyhjät tasot + Desaturate Poista värikylläisyys + Displace… Syrjäytä… + Distribute Jaa tasaisesti + Document Bounds Asiakirjan rajat + Edit in Quick Mask Mode Muokkaa pikamaskitilassa + Enhanced Controls… Laajennetut säädöt… + Entropy Entropia + Exclude Overlapping Shapes Poissulje päällekkäiset muodot + Exit Lopeta + Expand… Laajenna… + Extra Large Erittäin suuri + File Info… Tiedoston tiedot… + Fit Artboard on Screen Sovita piirtoalusta näyttöön + Fit Layer(s) on Screen Sovita taso(t) näyttöön + Flatten All Layer Effects Litistä kaikki tasotehosteet + Flatten All Masks Litistä kaikki maskit + Flatten Image Litistä kuva + Float All in Windows Kelluta kaikki ikkunoissa + Float in Window Kelluta ikkunassa + Frame from Layers Kehys tasoista + From Transparency Läpinäkyvyydestä + Gamut Warning Väriskaalavaroitus + Global Light… Yleinen valo… + Gradient… Liukuväri… + Graphic and Web Grafiikka ja verkko + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Hidden Layers Piilotetut tasot + Hide All Effects Piilota kaikki tehosteet + Horizontal Centers Vaakakeskikohdat + Huge Valtava + Indexed Color… Indeksoitu väri… + Integrations… Integraatiot… + Intersect Shape Areas Leikkaa muotoalueet + Kurtosis Huipukkuus + Large Suuri + Layer Edges Tasojen reunat + Left Edges Vasemmat reunat + Lens Blur… Linssisumennus… + Lighting Effects… Valaistustehosteet… + Link Layers Linkitä tasot + Lock Layers… Lukitse tasot… + Mean Keskiarvo + Merge Down Yhdistä alaspäin + Merge Group Yhdistä ryhmä + Merge Layers Yhdistä tasot + Merge Shape Components Yhdistä muodon osat + Merge Visible Yhdistä näkyvät + Stamp Visible Leimaa näkyvät + Select Layer Above Valitse yläpuolinen taso + Select Layer Below Valitse alapuolinen taso + Select Top Layer Valitse ylin taso + Select Bottom Layer Valitse alin taso + Add Layer Above to Selection Lisää yläpuolinen taso valintaan + Add Layer Below to Selection Lisää alapuolinen taso valintaan + Stamp Down Leimaa alaspäin + Mesh Verkko + Middle Eastern & South Asian Composer Lähi-idän ja Etelä-Aasian ladonta + Modifier Keys Muokkausnäppäimet + Modify Muokkaa + NTSC Colors NTSC-värit + Notes… Muistiinpanot… + Other Muut + Other… Muu… + Paragraph Styles Panel Kappaletyylipaneeli + Paths to Illustrator… Polut Illustratoriin… + Pattern Preview Kuvion esikatselu + Pattern… Kuvio… + Pixel Art Pikselitaide + Pixel Art Preview Pikselitaiteen esikatselu + Pixel Grid Pikseliruudukko + Pixelate Pikselöinti + Place Scale Marker… Sijoita mittakaavamerkki… + Plug-ins Laajennukset + Print One Copy Tulosta yksi kopio + Print… Tulosta… + Range Alue + Raw Defaults… Raw-oletusarvot… + Relink to File… Linkitä uudelleen tiedostoon… + Relight… Uudelleenvalaistus… + Render Renderöi + Replace All Missing Fonts Korvaa kaikki puuttuvat fontit + Resolve Missing Fonts… Korjaa puuttuvat fontit… + Reveal Selection Paljasta valinta + Right Edges Oikeat reunat + Selection Edges Valinnan reunat + Shapes Muodot + Show Extras Options… Näytä apuvälineiden asetukset… + Skewness Vinous + Small Pieni + Smart Guides Älykkäät apuviivat + Smart Objects Älykkäät objektit + Smooth… Tasoita… + Solid Color… Yhtenäinen väri… + Square Neliö + Stack Mode Pinotila + Choose Stack Mode: Valitse pinotila: + Standard Deviation Keskihajonta + Statistics… Tilastot… + Stylistic Alternates Tyylivaihtoehdot + Stylize Stilisoi + Subtract Front Shape Vähennä edessä oleva muoto + Summation Summa + Target Path Kohdepolku + Tile All Horizontally Kaikki allekkain + Tile All Vertically Kaikki rinnakkain + Toggle Last State Vaihda viimeisin tila + Toolbar… Työkalurivi… + Tools… Työkalut… + Top Edges Yläreunat + Unite Shapes Yhdistä muodot + Update All Modified Content Päivitä kaikki muokattu sisältö + Update All Text Layers Päivitä kaikki tekstitasot + Update Modified Content Päivitä muokattu sisältö + Variance Varianssi + Vertical Centers Pystykeskikohdat + Video Frames to Layers… Videoruudut tasoiksi… + WIA Support… WIA-tuki… + Working CMY Plate Työ-CMY-levy + +# tl! literals (third pass) + Absolute Absoluuttinen + Action Toiminto + Adaptive Mukautuva + Add Stop Lisää liukuvärin piste + Add disk Lisää levy + Align and Distribute Tasaa ja jaa tasaisesti + All Slices Kaikki viipaleet + All User Slices Kaikki käyttäjän viipaleet + Alt Alt + Artboard Piirtoalusta + Artboard Background Color Piirtoalustan taustaväri + Auto Hide Piilota automaattisesti + Automatically level near-horizontal line segments Oikaise lähes vaakasuorat janat automaattisesti + Automatically straighten and level Suorista ja oikaise automaattisesti + Automatically straighten near-vertical line segments Suorista lähes pystysuorat janat automaattisesti + Balance Tasapaino + Baseline Perusviiva + Bicubic Bikubinen + Bicubic (smooth gradients) Bikubinen (tasaiset liukuvärit) + Automatic Automaattinen + Bicubic Smoother (enlargement) Bikubinen pehmeämpi (suurennus) + Bicubic Sharper (reduction) Bikubinen terävämpi (pienennys) + Bilinear Bilineaarinen + Black, White Musta, valkoinen + Blue, Red, Yellow Sininen, punainen, keltainen + Bottom Alareuna + Brightness Jitter Kirkkauden vaihtelu + Brush Reconstruct Options Siveltimen rekonstruointiasetukset + Cache Level: Välimuistitaso: + Canvas Kangas + Capture Kaappaa + Center Crop Marks Keskikohdan rajausmerkit + Centre the gradient (offset 0, 0) Keskitä liukuväri (siirtymä 0, 0) + Change Screen Mode (F) Vaihda näyttötilaa (F) + Change To: Muuta muotoon: + Clear All Tyhjennä kaikki + Clear Override Tyhjennä ohitus + Clear Samples Tyhjennä näytteet + Cmd Cmd + Color Grading Värimäärittely + Color Management Värinhallinta + Color Priority Väriprioriteetti + Color Range preview Värialueen esikatselu + Colorize Väritä + Constant Vakio + Content-Aware Sisältötietoinen + Convert to sRGB Muunna sRGB:ksi + Copies Kopiot + Copper Kupari + Corner Crop Marks Kulmarajausmerkit + Correction Korjaus + Count Jitter Määrän vaihtelu + Create Texture Luo tekstuuri + Create a new count group Luo uusi laskentaryhmä + Crop Factor Rajauskerroin + Ctrl Ctrl + Custom Shapes Mukautetut muodot + Cyan Syaani + Cyan · Red Syaani · punainen + Darks Tummat sävyt + Default printer Oletustulostin + Default restored. Oletus palautettu. + Delete Group Poista ryhmä + Delete count group Poista laskentaryhmä + Delete effect layer Poista tehostetaso + Delete note Poista muistiinpano + Delete tool preset Poista työkalun esiasetus + Depth Jitter Syvyyden vaihtelu + Discontiguous Epäyhtenäinen + Discord Discord + Display P3 Display P3 + Display Text Näytä teksti + Divide… Jaa… + Do you want to save the changes you made to “{name}” before quitting? Tallennetaanko kohteeseen ”{name}” tehdyt muutokset ennen lopettamista? + Edit Toolbar… Muokkaa työkaluriviä… + Edit in Quick Mask Mode (Q) Muokkaa pikamaskitilassa (Q) + Edit in Standard Mode (Q) Muokkaa vakiotilassa (Q) + Embedded Profile Mismatch Upotettu profiili ei täsmää + Enhance Edge Paranna reunaa + Exact Täsmällinen + Expansion: Laajennus: + Exposure and Gamma Valotus ja gamma + Factor: Kerroin: + Fewer Points Vähemmän pisteitä + File Settings Tiedostoasetukset + Finger Painting Sormimaalaus + Fit in View Sovita näkymään + Fixed Kiinteä + Foreground to Background Edustaväristä taustaväriin + Foreground/Background Jitter Edusta-/taustavärin vaihtelu + Format Muoto + Frame from Layers… Kehys tasoista… + Full Spherical Täysin pallomainen + Gamma Correction Gammakorjaus + Gamma: Gamma: + Global Yleinen + Grain Rakeisuus + Graphics Grafiikka + Gray Harmaa + Heart Sydän + Hide Auto Slices Piilota automaattiviipaleet + Highlight Compression Korostusten tiivistys + Highlight Priority Korostusten prioriteetti + Highlights Korostukset + Home Koti + Hue Jitter Sävyn vaihtelu + Image Size: Kuvan koko: + Imported Tuotu + Input: Syöte: + Interlaced Lomitettu + Invert All Invertoi kaikki + Label Size Nimikkeen koko + Labels Nimikkeet + Lanczos (sharp) Lanczos (terävä) + Level Vaaita + Lights Vaaleat sävyt + Limits: Rajat: + Link the mask to the layer Linkitä maski tasoon + Localized Color Clusters Paikalliset väriryhmät + Luminance Luminanssi + Magenta Magenta + Magenta · Green Magenta · vihreä + Marker Size Merkinnän koko + Mask Options Maskin asetukset + Masking Maskaus + Master Kaikki värit + Mean: Keskiarvo: + Median: Mediaani: + Mesh Size Verkon koko + Metadata Metatiedot + Midpoint Keskikohta + Midtones Keskisävyt + Midtones from: Keskisävyt alkaen: + Midtones to: Keskisävyt päättyen: + Minimum Depth Vähimmäissyvyys + Mix Jitter Sekoituksen vaihtelu + Mode: Tila: + Monochrome Yksivärinen + More Points Lisää pisteitä + Move Group Down Siirrä ryhmää alaspäin + Move Group Up Siirrä ryhmää ylöspäin + Move effect layer down Siirrä tehostetasoa alaspäin + Move effect layer up Siirrä tehostetasoa ylöspäin + Move to Siirrä ryhmään + Nearest Neighbor Lähin naapuri + Nearest Neighbor (hard edges) Lähin naapuri (terävät reunat) + New effect layer Uusi tehostetaso + Next note Seuraava muistiinpano + No Color Management Ei värinhallintaa + Optimized Optimoitu + Output Levels: Lähtötasot: + Output: Lähtö: + Paint Overlay Maalipeite + Pattern Size Kuvion koko + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — avoimen lähdekoodin natiivi kuvankäsittelyohjelma, joka on kirjoitettu Rustilla. + Position and Size Sijainti ja koko + Preserve Details Säilytä yksityiskohdat + Preserve Luminosity Säilytä luminanssi + Previous note Edellinen muistiinpano + Printer Tulostin + Printer Setup Tulostimen asetukset + Printing Marks Tulostusmerkit + Progressive Progressiivinen + Promote Ylennä + Protect Detail Suojaa yksityiskohdat + Protect Tones Suojaa sävyt + Proximity Match Lähialueen vastaavuus + Purity Puhtaus + Quality Laatu + Recent Viimeisimmät + Reconstruct Rekonstruoi + Refine Tarkennus + Registration Marks Kohdistusmerkit + Regular Normaali + Remove stop Poista liukuvärin piste + Rename Group… Nimeä ryhmä uudelleen… + Rename Layer… Nimeä taso uudelleen… + Restore All Palauta kaikki + Restrictive (Web) Rajoitettu (verkko) + Rigid Jäykkä + Roughness Karheus + Roundness Pyöreys + Rulers & Grids Viivaimet ja ruudukot + Sample: foreground colour Näyte: edustaväri + Sampling: Background Swatch Näytteistys: taustan värimalli + Sampling: Continuous Näytteistys: jatkuva + Sampling: Once Näytteistys: kerran + Saturate Kyllästä + Saturation Jitter Värikylläisyyden vaihtelu + Save HTML and Images Tallenna HTML ja kuvat + Save as PDF Tallenna PDF-muodossa + Scale to Fit Media Sovita paperiin + Scatter Sironta + Scratch disks Työlevyt + Selective Valikoiva + Shadows Varjot + Shape: Muoto: + Sharpening Terävöitys + Shift Shift + Show All Menu Items Näytä kaikki valikkokohdat + Show Backdrop Näytä tausta + Show Mask Näytä maski + Show Mesh Näytä verkko + Show or hide the Notes panel Näytä tai piilota Muistiinpanot-paneeli + Show the layer mask as an overlay Näytä tasomaski peitteenä + Show: Näytä: + Sides Sivut + Sides: Sivut: + Slice Options… Viipaleen asetukset… + Slices From Guides Viipaleet apuviivoista + Slices are locked (View › Lock Slices) Viipaleet on lukittu (Näytä › Lukitse viipaleet) + Split: Jako: + Star ratio Tähtisuhde + Std Dev: Keskihajonta: + Straighten Suorista + Straighten Layer Suorista taso + Strength: Voimakkuus: + Stroke Reunaviiva + Stroke Catch-up Osoittimen kiinniotto + Stroke: Reunaviiva: + Location: Sijainti: + Inside Sisäpuolella + Outside Ulkopuolella + Tetrahedral Tetraedrinen + Texture Each Tip Tekstuuri jokaiselle kärjelle +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Liukuvärieditori + Gradient picker Liukuvärin valitsin + Save As Preset Tallenna esiasetuksena + The gradient the Gradient tool paints with Liukuväri, jolla liukuvärityökalu maalaa + Adds the current gradient to the Gradients panel Lisää nykyisen liukuvärin Liukuvärit-paneeliin + Gradient stops Liukuvärin pisteet + Click to edit the gradient Muokkaa liukuväriä napsauttamalla + Tilt X Kallistus X + Tilt Y Kallistus Y + Toggle count group visibility Vaihda laskentaryhmän näkyvyys + Toggle text orientation Vaihda tekstin suuntausta + Tone: Sävyalue: + Toolbar Työkalurivi + Top Yläreuna + Total: Yhteensä: + Transparency Läpinäkyvyys + Trilinear Trilineaarinen + Unlink the mask from the layer Poista maskin linkitys tasosta + View Options Näkymäasetukset + Violet, Orange Violetti, oranssi + Visible Näkyvissä + Weight Paino + Weight: Paino: + Wetness Jitter Märkyyden vaihtelu + Whites Valkoiset sävyt + Yellow Keltainen + Yellow · Blue Keltainen · sininen + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Salli agentin ohjaus + Allow scripts to connect Salli komentosarjojen yhdistää + Always Aina + Always create smart objects when placing Luo aina älykkäitä objekteja sijoitettaessa + Animated zoom Animoitu zoomaus + Apply auto tone Käytä automaattisävyä + Ask Kysy + Ask before saving layered tiff Kysy ennen tasoja sisältävän TIFF-tiedoston tallentamista + Autosave Automaattinen tallennus + Autosave minutes Automaattisen tallennuksen väli (min) + Beep when done Äänimerkki, kun valmis + Bicubic automatic Bikubinen automaattinen + Bicubic sharper Bikubinen terävämpi + Bicubic smoother Bikubinen pehmeämpi + Bit depth Bittisyvyys + Brush picker Siveltimen valitsin + Brush preview color Siveltimen esikatseluväri + CPU (no GPU acceleration) CPU (ei GPU-kiihdytystä) + Cache levels Välimuistitasot + Cache tile size Välimuistin laatan koko + Camera Raw Defaults Camera Raw -oletukset + Canvas border Kankaan reunus + Show canvas dots Näytä kankaan pisteet + Clip thumbnails to layer bounds Rajaa pikkukuvat tason rajoihin + Clip Thumbnails to Layer Bounds Rajaa pikkukuvat tason rajoihin + Canvas color Kankaan väri + Canvas custom color Kankaan mukautettu väri + Classic Klassinen + Color picker Värinvalitsin + Color space Väriavaruus + Column width Sarakkeen leveys + Concise Tiivis + Control port Ohjausportti + Convert to srgb Muunna sRGB-muotoon + Cursors Osoittimet + Custom dark Mukautettu tumma + Custom light Mukautettu vaalea + Dark Tumma + Dark gray Tummanharmaa + Dashed lines Katkoviivat + Detailed Yksityiskohtainen + DirectX 12 DirectX 12 + Disks Levyt + Display p3 Display P3 + Dots Pisteet + Double click layer mask launches select and mask Tasomaskin kaksoisnapsautus avaa Valitse ja maskaa -tilan + Drop shadow Heittovarjo + Dynamic color sliders Dynaamiset värisäätimet + East asian Itä-Aasian + Effect cache mb Tehostevälimuisti (Mt) + Enable flick panning Ota pyyhkäisypanorointi käyttöön + Enable floating document window docking Ota kelluvien asiakirjaikkunoiden telakointi käyttöön + Enable narrow options bar Ota kapea asetuspalkki käyttöön + Enhanced Controls Laajennetut säädöt + Erase Pyyhi + Export clipboard Vie leikepöytä + Extra large Erittäin suuri + File Handling Tiedostojen käsittely + File path Tiedostopolku + Fill new type layers with placeholder Täytä uudet tekstitasot paikkamerkkitekstillä + Font preview Fontin esikatselu + Full size tip Täysikokoinen kärki + GPU backend GPU-taustajärjestelmä + Linux display server Linux-näyttöpalvelin + X11 X11 + Gamut warning color Väriskaalavaroituksen väri + Gamut warning opacity Väriskaalavaroituksen peittävyys + General Yleiset + Enable Generator Ota Generator käyttöön + Gif GIF + Glossy Kiiltävä + Grid color Ruudukon väri + Grid colors Ruudukon värit + Grid size Ruudukon koko + Grid style Ruudukon tyyli + Grid unit Ruudukon yksikkö + Gridline every Ruudukkoviiva joka + Guide color Apuviivan väri + Guide style Apuviivan tyyli + Guides, Grid & Slices Apuviivat, ruudukko ja viipaleet + Gutter Palstaväli + History Log Historialoki + History states Historiatilat + Ignore exif profile tag Ohita EXIF-profiilitunniste + Image interpolation Kuvan interpolointi + Image previews Kuvan esikatselut + Integrations Integraatiot + Interface Käyttöliittymä + Jpeg quality JPEG-laatu + Jpg JPG + Large tabs Suuret välilehdet + Legacy compositing Vanha koostaminen + Light gray Vaaleanharmaa + Line Viiva + Lines Viivat + Lowercase extension Tiedostopääte pienillä kirjaimilla + Maximize psd compatibility Maksimoi PSD-yhteensopivuus + Medium gray Keskiharmaa + Memory usage mb Muistin käyttö (Mt) + Low resolution previews Matalaresoluutioiset esikatselut + Metal Metal + Missing glyph protection Puuttuvan glyfin suojaus + Modern Moderni + Nearest neighbor Lähin naapuri + Never Ei koskaan + Normal tip Normaali kärki + Open as smart object Avaa älykkäänä objektina + Open in camera raw Avaa Camera Raw'ssa + Open documents as tabs Avaa asiakirjat välilehtinä + OpenGL OpenGL + Orange Oranssi + Overscroll Ylivieritys + Performance Suorituskyky + Png PNG + Point size Pistekoko + PostScript (72 points/inch) PostScript (72 pistettä/tuuma) + Precise Täsmällinen + Preserve details Säilytä yksityiskohdat + Print resolution Tulostusresoluutio + Pro Pro + Pro light Pro vaalea + Pro light gray Pro vaaleanharmaa + Pro medium Pro keskiharmaa + Pro photo ProPhoto + Purple Purppura + Quick export format Pikaviennin muoto + Quick export location Pikaviennin sijainti + Recent file count Viimeisimpien tiedostojen määrä + LUT folder LUT-kansio + Recent files Viimeisimmät tiedostot + Recent fonts Viimeisimmät fontit + Recover on launch Palauta käynnistettäessä + Remember workspace changes Muista työtilan muutokset + Resize image during place Muuta kuvan kokoa sijoitettaessa + Right click with painting tools Hiiren oikea painike maalaustyökaluissa + Rotate view with trackpad Kierrä näkymää kosketuslevyllä + Same folder Sama kansio + Save in background Tallenna taustalla + Scratch Disks Työlevyt + Screen resolution Näytön resoluutio + Scrubby slider acceleration Raahattavan liukusäätimen kiihdytys + Sessions only Vain istunnot + Sharpen for Terävöityksen kohde + Show channels in color Näytä kanavat värillisinä + Show crosshair in brush tip Näytä hiusristikko siveltimen kärjessä + Show extension panels Näytä laajennuspaneelit + Show font names in english Näytä fonttien nimet englanniksi + Show menu colors Näytä valikon värit + Show only crosshair while painting Näytä vain hiusristikko maalattaessa + Show slice numbers Näytä viipaleiden numerot + Show tooltips Näytä työkaluvihjeet + Enable animations Ota animaatiot käyttöön + Show highlight on rollover Korosta kohde osoittimen alla + Show bounding box when dragging layer Näytä rajauslaatikko tasoa vedettäessä + System title bar Järjestelmän otsikkorivi + Show transformation values Näytä muunnosarvot + Slice color Viipaleen väri + Smart guide color Älykkään apuviivan väri + Smart quotes Älykkäät lainausmerkit + Snap vector tools and transforms to pixel grid Vektorityökalut ja muunnokset tarttuvat pikseliruudukkoon + Srgb sRGB + Standard Vakio + Studio Studio + Studio light Studio vaalea + Adwaita Adwaita + Adwaita dark Adwaita tumma + Subdivisions Alijaot + System Järjestelmä + Text engine Tekstimoottori + Text file Tekstitiedosto + Tiny Erittäin pieni + Touch gestures Kosketuseleet + Traditional (72.27 points/inch) Perinteinen (72,27 pistettä/tuuma) + Transparency & Gamut Läpinäkyvyys ja väriskaala + Type units Tekstiyksiköt + UI font size Käyttöliittymän fonttikoko + UI scale Käyttöliittymän skaalaus + Units & Rulers Yksiköt ja viivaimet + Use additional plugins folder Käytä lisälaajennusten kansiota + Use esc to commit Vahvista Esc-näppäimellä + Use gpu Käytä GPU:ta + Use legacy free transform Käytä vanhaa vapaata muunnosta + Use shift key for tool switch Käytä Shift-näppäintä työkalun vaihtoon + Use tablet pressure Käytä piirtopöydän painetta + Vary round brush hardness on hud Säädä pyöreän siveltimen kovuutta HUD-säätimellä + Vulkan Vulkan + Webp WebP + World ready Kansainvälinen + Zoom clicked point to center Keskitä napsautettu piste zoomattaessa + Zoom resizes windows Zoomaus muuttaa ikkunoiden kokoa + Zoom with scroll wheel Zoomaa hiiren rullalla + Zoom with trackpad pinch Zoomaa kosketuslevyn nipistyksellä + Alt-click to define a source point to clone from Määritä kloonauksen lähdepiste Alt-napsautuksella + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Ei polkua, josta luoda valinta: piirrä polku kynätyökalulla tai valitse polku Polut-paneelissa. + Cancel opening Peruuta avaaminen + Cancelled opening {name} Tiedoston {name} avaaminen peruutettu + Damaged Photoshop document Vioittunut Photoshop-asiakirja + The document “{name}” may be damaged. Asiakirja ”{name}” saattaa olla vioittunut. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Joitakin tasoja, pikseleitä tai maskeja ei voitu lukea. Haluatko jatkaa avaamista? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Jos jatkat, alkuperäisen päälle tallentaminen voi hävittää lukukelvottomat tiedot pysyvästi. Säilytä alkuperäinen tallentamalla kopio. + Continue Jatka + The document “{name}” does not have an embedded {mode} profile. Asiakirjassa ”{name}” ei ole upotettua {mode}-profiilia. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Asiakirjassa ”{name}” on upotettu väriprofiili, joka ei vastaa nykyistä {mode}-työavaruutta. + Working: {working} Työavaruus: {working} + Developing {name} Kehitetään tiedostoa {name} + Cancelled {label} Peruutettu: {label} + Esc to cancel Peruuta painamalla Esc + Opened {name} Avattu {name} + Opening {name} Avataan tiedostoa {name} + Saved {name} Tallennettu {name} + Saving {name} Tallennetaan tiedostoa {name} + Saving {percent} Tallennetaan {percent} + A save to {name} is still running; try again when it finishes Tallennus tiedostoon {name} on vielä käynnissä; yritä uudelleen sen päätyttyä + {label} is writing the file and can no longer be cancelled {label}: tiedostoa kirjoitetaan, eikä toimintoa voi enää peruuttaa + Opening {name}… Avataan tiedostoa {name}… + Opening… Avataan… + Reading… Luetaan… + Working… Käsitellään… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Ruudukko: +# Rendering mode and recovery + Rendering Mode Renderöintitila + Automatic (recommended) Automaattinen (suositeltu) + CPU / Compatibility CPU / yhteensopivuus + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automaattinen käyttää GPU-kiihdytystä, kun se on käytettävissä, ja siirtyy virhetilanteissa CPU-renderöintiin. + Switched to CPU rendering Siirryttiin CPU-renderöintiin + GPU acceleration could not continue. Your documents are unchanged. GPU-kiihdytystä ei voitu jatkaa. Asiakirjasi ovat ennallaan. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft käyttää CPU-kuvankoostinta. Ikkuna voi silti käyttää näytönohjainta. + Retry GPU requires a restart. Save your work first. GPU:n uudelleenyritys vaatii uudelleenkäynnistyksen. Tallenna työsi ensin. + Retry GPU tries the graphics processor again now. Yritä GPU:ta uudelleen -toiminto kokeilee grafiikkasuoritinta heti uudelleen. + GPU acceleration resumed. GPU-kiihdytys on taas käytössä. + Keep Using CPU Jatka CPU:n käyttöä + Retry GPU Yritä GPU:ta uudelleen + GPU Backend GPU-taustajärjestelmä + Details Tiedot + GPU GPU + Rendering mode Renderöintitila + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Jäädytyslasso: jäädytä alue vetämällä, sulata Alt-vedolla (L) + Shadow Clipping Warning (U) Varjojen leikkautumisvaroitus (U) + Highlight Clipping Warning (O) Korostusten leikkautumisvaroitus (O) + Show Lab Color Readouts Näytä Lab-väriarvot + Show Vectorscope Näytä vektoriskooppi + Show Red at 3 o'clock Näytä punainen kello kolmen kohdalla + Show Skin Tone Indicator Näytä ihonsävyn osoitin + Tone Histogram Sävyhistogrammi + Show Selected Region Näytä valittu alue +cameraRaw Light Valo +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw -suodatin ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Turkoosi +cameraRaw Amount Määrä +cameraRaw Blacks Mustat +cameraRaw Vibrance Eloisuus +cameraRaw Color Grading Värimäärittely +cameraRaw Lights Vaaleat sävyt +cameraRaw Darks Tummat sävyt +cameraRaw Global Yleinen +cameraRaw Red Punainen +cameraRaw Orange Oranssi +cameraRaw Yellow Keltainen +cameraRaw Green Vihreä +cameraRaw Aqua Turkoosi +cameraRaw Blue Sininen +cameraRaw Purple Purppura +cameraRaw Magenta Magenta +cameraRaw Hue Sävy +cameraRaw Blending Sekoitus +cameraRaw Luminance Detail Luminanssin yksityiskohdat +cameraRaw Color Detail Värin yksityiskohdat +cameraRaw Roughness Karheus + Patch Tool Paikkaustyökalu + Patch: Paikkaus: + Lasso around an area, then drag the selection Rajaa alue lassolla ja vedä sitten valintaa + Transform On Drop Muunna pudotettaessa + Scale or rotate the moved area, then press {key} Skaalaa tai kierrä siirrettyä aluetta ja paina sitten {key} + Now drag the selection onto another area Vedä valinta nyt toiselle alueelle + Edit Type Muokkaa tekstiä + + New Style… Uusi tyyli… + Style {name} saved Tyyli {name} tallennettu + + Create new style (from the selected type) Luo uusi tyyli (valitusta tekstistä) + Create new style from the selected layer Luo uusi tyyli valitusta tasosta + + Make Default Aseta oletukseksi + Reset to Default Palauta oletukseen + Full-resolution preview unavailable Täyden resoluution esikatselu ei ole käytettävissä + No patterns Ei kuvioita + Enable {name} Ota {name} käyttöön + Hold Alt while drawing for straight segments Pidä Alt-näppäintä painettuna piirtäessäsi suoria segmenttejä varten + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Vedä tai napsauta monikulmion pisteitä · pidä Alt painettuna lassoa käytettäessä suorille segmenteille · {add} lisää · {sub} ennen piirtämistä vähentää + Extend Laajennus + Structure: Rakenne: + Content-Aware Move Tool Sisältötietoinen siirtotyökalu + TIFF Options TIFF-asetukset + Targa Options Targa-asetukset + JPEG Options JPEG-asetukset + Image Options Kuva-asetukset + Format Options Muotoasetukset + Baseline ("Standard") Perusmuotoinen (”vakio”) + Baseline Optimized Perusmuotoinen, optimoitu + 24 bits/pixel 24 bittiä/pikseli + 32 bits/pixel 32 bittiä/pikseli + Including layers will increase file size. Tasojen sisällyttäminen kasvattaa tiedostokokoa. + Discard Layers and Save a Copy Hylkää tasot ja tallenna kopio + Don't show again Älä näytä uudelleen + Save failed: {error} Tallennus epäonnistui: {error} + Save cancelled Tallennus peruutettu + Couldn't place {name}: {error} Kohteen {name} sijoittaminen epäonnistui: {error} + Magnetic Lasso Tool Magneettinen lassotyökalu + Frequency Taajuus + Use tablet pressure to change pen width Muuta kynän leveyttä piirtopöydän paineella + +# About ▸ Contributors / Models (credits) + About Tietoja + Contributors Tekijät + Models Mallit + Sort Järjestä + Username Käyttäjätunnus + Display name Näyttönimi + Real name Oikea nimi + Reverse the order Käänteinen järjestys + Grab bag Sekalaiset + Table Taulukko + Name (A–Z) Nimi (A–Z) + Merged PRs Yhdistetyt PR:t + Commits Kommitit + Lines added Lisätyt rivit + Lines deleted Poistetut rivit + Line delta Rivien muutos + Binary assets added Lisätyt binääriresurssit + Binary assets removed Poistetut binääriresurssit + First commit Ensimmäinen kommitti + First Ensimmäinen + Last commit Viimeisin kommitti + Last Viimeisin + Contributors: {contributors} · Commits: {commits} Tekijät: {contributors} · Kommitit: {commits} + No contributor data was built into this copy. Tähän kopioon ei ole koottu tekijätietoja. + No model credits were built into this copy. Tähän kopioon ei ole koottu mallien tekijätietoja. + Company Yritys + Version Versio + % of all commits % kaikista kommiteista + Lines +/− Rivit +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR:ää, {commits} kommittia, +{added} / −{deleted} riviä (Δ {delta}), +{binAdded} / −{binDeleted} binääritiedostoa, {first} – {last} + +# Swatches panel + Create new swatch Luo uusi värimalli + New Swatch… Uusi värimalli… + New Swatch Group… Uusi värimalliryhmä… + Import Swatches… Tuo värimalleja… + Replace Swatches… Korvaa värimallit… + Export Swatches… Vie värimallit… + Export Swatches for Exchange… Vie värimallit vaihtoa varten… + Reset Swatches Palauta värimallit + Add to Swatches Lisää värimalleihin +# Layer Style > Blending Options (Advanced Blending) + Blend If: Sekoita jos: + Channels: Kanavat: + Knockout Läpileikkaus + Apply At Canvas Bounds Käytä myös kankaan reunoilla + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Värinvalitsin (tekstin väri) + Color Picker ({name}) Värinvalitsin ({name}) + Color Picker (Fill Color) Värinvalitsin (täyttöväri) + Fill color Täyttöväri + Color Picker (Stroke Color) Värinvalitsin (reunaviivan väri) + Stroke color Reunaviivan väri + Pick screen color Poimi näytön väri + Sample a pixel anywhere on the screen Poimi pikseli mistä tahansa näytöltä + Click to pick · Esc to cancel Poimi napsauttamalla · Esc peruuttaa + Pick a screen pixel or press Esc to cancel Poimi näytön pikseli tai peruuta painamalla Esc + Scroll to zoom Zoomaa vierittämällä + Drag to pan · Scroll to zoom · Double-click to fit Panoroi vetämällä · zoomaa vierittämällä · sovita kaksoisnapsauttamalla + Show/Hide All Panels Näytä/piilota kaikki paneelit + Show/Hide Panels Näytä/piilota paneelit + Could not open Avaaminen epäonnistui + {path}: the path is not valid Unicode; rename the file and open it again. {path}: polku ei ole kelvollista Unicodea; nimeä tiedosto uudelleen ja avaa se sitten. + +# Fill dialog, layer style contours and Image Size + Contents: Sisältö: + Custom Pattern: Mukautettu kuvio: + Foreground Color Edustaväri + Color… Väri… + 50% Gray 50 % harmaa + {size} (was {old}) {size} (aiemmin {old}) + Cone Kartio + Cone (Inverted) Kartio (käänteinen) + Domed Kupera + Domed (Inverted) Kupera (käänteinen) + Diagonal (Descending) Diagonaalinen (laskeva) + Contour Editor Kontuurimuokkain + Anti-aliased Antialiasoitu + Corner Kulma + Gloss Contour Kiiltokontuuri + Contour Kontuuri + Cone - Inverted Kartio - käänteinen + Cove - Deep Kolo - syvä + Cove - Shallow Kolo - matala + Gaussian Gauss + Half Round Puolipyöreä + Ring Rengas + Ring - Double Rengas - kaksinkertainen + Rolling Slope - Descending Aaltoileva rinne - laskeva + Rounded Steps Pyöristetyt portaat + Sawtooth 1 Sahalaita 1 + Linear Lineaarinen + + Red Eye Tool Punasilmätyökalu + Pupil Size Pupillin koko + Darken Amount Tummennusmäärä + Click a red pupil to neutralize it Neutraloi punainen pupilli napsauttamalla sitä + open source avoin lähdekoodi + …and {n} more …ja {n} muuta + + Eyedroppers: Pipetit: + Set Black Point Aseta mustapiste + Set Neutral Gray Point Aseta neutraali harmaapiste + Set White Point Aseta valkopiste + Unavailable in this color mode Ei käytettävissä tässä väritilassa + Selection Preview: Valinnan esikatselu: + Black Matte Musta pohja + White Matte Valkoinen pohja + Quick Mask Pikamaski + +# Layer colour labels. + Seafoam Merenvaahto + Indigo Indigo + Fuchsia Fuksia + Violet Violetti + Large document: redrawing on the CPU Suuri asiakirja: uudelleenpiirto suorittimella + Redrawing Piirretään uudelleen + This redraw took {seconds} s on the CPU. Tämä uudelleenpiirto kesti {seconds} s suorittimella. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Koko asiakirjaa muuttavat muokkaukset, kuten tason sekoitustila, peittävyys tai näkyvyys, piirtävät sen kokonaan uudelleen. + The GPU compositor wasn't used: {reason} GPU-koostinta ei käytetty: {reason} +fontWeight Thin Ohut +fontWeight ExtraLight Erittäin kevyt +fontWeight Light Kevyt +fontWeight Regular Normaali +fontWeight Medium Keskivahva +fontWeight SemiBold Puolilihavoitu +fontWeight Bold Lihavoitu +fontWeight ExtraBold Erittäin lihavoitu +fontWeight Black Raskas +fontWeight Italic Kursivoitu + +# Pattern Stamp tool + Pattern Stamp Tool Kuvioleimasin + Impressionist Impressionistinen + Rotate View Tool Näkymän kiertotyökalu + Reset View Palauta näkymä + Drag around the centre to rotate the view · Shift constrains to 15° Kierrä näkymää vetämällä keskipisteen ympäri · Shift rajoittaa 15°:n välein + +# Painting symmetry presets. + Dual Axis Kaksi akselia + Diagonal Diagonaalinen + Transform Symmetry Muunnossymmetria + Set painting symmetry options Aseta maalauksen symmetria-asetukset + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Pistenäyte + 3 by 3 Average 3 × 3 -keskiarvo + 5 by 5 Average 5 × 5 -keskiarvo + 11 by 11 Average 11 × 11 -keskiarvo + 31 by 31 Average 31 × 31 -keskiarvo + 51 by 51 Average 51 × 51 -keskiarvo + 101 by 101 Average 101 × 101 -keskiarvo + Sample Size: Näytteen koko: + All Layers No Adjustments Kaikki tasot ilman säätöjä + Current & Below No Adjustments Nykyinen ja alemmat ilman säätöjä + Show Sampling Ring Näytä näytteenottorengas + Click to sample the foreground colour · {key}-click for background Ota edustavärin näyte napsauttamalla · {key}-napsautus taustavärille + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Tulostusvärialueen ulkopuolella: napsauta valitaksesi lähimmän tulostettavan värin + Not a web colour: click for the nearest web colour Ei verkkoväri: napsauta valitaksesi lähimmän verkkovärin + Color to Alpha… Väri alfaksi… + Transparency Threshold Läpinäkyvyyskynnys + Opacity Threshold Peittävyyskynnys + PhotoCraft — Screen Color Picker PhotoCraft — näytön värinvalitsin + + Rasterize svg groups deeper than Rasteroi SVG-ryhmät, joiden syvyys on yli + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 poistaa tämän kynnyksen käytöstä; yli 100 tason syvyyteen sisäkkäiset SVG-ryhmät rasteroidaan aina. + Rule of Thirds Kolmanneksen sääntö + Triangle Kolmio + Golden Ratio Kultainen leikkaus + Golden Spiral Kultainen spiraali + Auto Show Overlay Näytä peite automaattisesti + Always Show Overlay Näytä peite aina + Never Show Overlay Älä näytä peitettä koskaan + Cycle Overlay Vaihda peitettä + Cycle Orientation Vaihda suuntaa + Set the overlay options for the Crop tool Aseta rajaustyökalun peitteen asetukset + 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-napsautus meni työpöydälle, joka siirtää ikkunoita Alt-vedolla. Aseta sen ikkunansiirtonäppäimeksi Super tai lukitse Alt kohdassa Ikkuna › Muokkausnäppäimet. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Värinvalitsin (tasotyylin väri) + Auto Hide Notifications Piilota ilmoitukset automaattisesti + Notification Duration (seconds) Ilmoituksen kesto (sekuntia) + Bundle Arrow-Key Nudges Yhdistä nuolinäppäinsiirrot + Nudge Bundle Pause (ms) Siirtojen yhdistämisen tauko (ms) + Appearance Mode Ulkoasutila + Dark Theme Tumma teema + Light Theme Vaalea teema + Pro Medium Pro (keskiharmaa) + Studio Light Studio vaalea + Next Appearance Mode Seuraava ulkoasutila + Sync with system Synkronoi järjestelmän kanssa + Active Aktiivinen + Color Picker (Solid Color) Värinvalitsin (yhtenäinen väri) + Straighten the image by drawing a line on it Suorista kuva piirtämällä siihen viiva + Set additional Crop options Aseta lisärajausasetuksia + Show Cropped Area Näytä pois rajattava alue + Enable Crop Shield Ota rajaussuoja käyttöön + Match Canvas Sama kuin kangas + Auto Adjust Opacity Säädä peittävyyttä automaattisesti + Crop Shield color Rajaussuojan väri + Color Picker (Crop Shield Color) Värinvalitsin (rajaussuojan väri) + From Center Keskeltä + Link radii Linkitä säteet + Star Ratio: Tähden suhde: + Create Rectangle Luo suorakulmio + Create Ellipse Luo ellipsi + Create Triangle Luo kolmio + Create Polygon Luo monikulmio + Pressure curve Painekäyrä + Click to add a point, right-click to remove Lisää piste napsauttamalla, poista napsauttamalla hiiren kakkospainikkeella + Soft Pehmeä + Hard Kova + Some brush presets could not be loaded Joitakin siveltimen esiasetuksia ei voitu ladata + Retry saving presets Yritä esiasetusten tallennusta uudelleen + Brush preset changes are not saved: {error} Siveltimen esiasetusten muutoksia ei ole tallennettu: {error} + Keep this tab open to retain your brushes. Pidä tämä välilehti auki, jotta siveltimet säilyvät. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Selaimen tallennustila ei ole käytettävissä; siveltimet säilyvät vain tämän istunnon ajan. Pidä tämä välilehti auki, jotta ne säilyvät. + Saving brush presets… Keep this tab open until saving finishes. Tallennetaan siveltimen esiasetuksia… Pidä tämä välilehti auki, kunnes tallennus päättyy. + Reflect Heijastus + Glow Hehku + Negation Negaatio + XOR XOR + Color Burn (Paint.NET) Väritummennus (Paint.NET) + Color Dodge (Paint.NET) Värivaalennus (Paint.NET) + Stroke Options Reunaviivan asetukset + Solid Yhtenäinen + Dashed Katkoviiva + Dotted Pisteviiva + Caps Päätystyylit + Butt / Flat Tylppä / suora + Corners Kulmat + Miter Jiiri + Round Pyöreä + Bevel Viiste + Miter limit Jiiriraja + Dashed line Katkoviiva + Dash Viiva + Gap Väli + Dash / gap lengths × stroke width Viivan / välin pituudet × reunaviivan leveys + Add pair Lisää pari + Remove pair Poista pari + Dash offset Katkoviivan siirtymä + Open paths use centered strokes Avoimet polut käyttävät keskitettyjä reunaviivoja + Use finite lengths and a positive pattern total Käytä äärellisiä pituuksia ja positiivista kuvion kokonaispituutta + No subject found — try Object Selection or the Lasso Kohdetta ei löytynyt – kokeile objektin valintaa tai lassoa + Quick Selection found nothing there Pikavalinta ei löytänyt sieltä mitään + Object Selection found nothing there — try a tighter rectangle Objektin valinta ei löytänyt sieltä mitään – kokeile tiukempaa suorakulmiota + No subject found — try a rectangle around the object Kohdetta ei löytynyt – kokeile suorakulmiota objektin ympärille + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Poistetaanko taso ’{name}’? + Delete {count} layers? {names} Poistetaanko {count} tasoa? {names} + Delete layer? Poistetaanko taso? + The document is closed Asiakirja on suljettu + The document or selected layers changed. Please request deletion again. Asiakirja tai valitut tasot muuttuivat. Pyydä poistoa uudelleen. + Missing delete command Poistokomento puuttuu + Invalid delete command Virheellinen poistokomento + Layer deleted, but confirmation preference could not be saved: {error} Taso poistettiin, mutta vahvistusasetusta ei voitu tallentaa: {error} + Add a layer style Lisää tasotyyli + Add a mask (from the selection; {key} inverts) Lisää maski (valinnasta; {key} kääntää) + Add an adjustment Lisää säätö + Add to selection ({key}) Lisää valintaan ({key}) + Adjustment visible Säätö näkyvissä + Align and distribute Tasaa ja jaa tasaisesti + Align horizontal centers Tasaa vaakakeskikohdat + Align left edges Tasaa vasemmat reunat + Align right edges Tasaa oikeat reunat + Always use pressure for opacity Ohjaa peittävyyttä aina paineella + Always use pressure for size Ohjaa kokoa aina paineella + Angle Gradient Kulmaliukuväri + Auto-Select: Automaattivalinta: + Brush Preset picker Siveltimen esiasetusten valitsin + Cancel current crop operation (Esc) Peruuta nykyinen rajaus (Esc) + Click the first point or press {key} to close · Esc cancels Sulje napsauttamalla ensimmäistä pistettä tai painamalla {key} · Esc peruuttaa + Click the first point or press {key} to close · {del} removes a point · Esc cancels Napsauta ensimmäistä pistettä tai sulje painamalla {key} · {del} poistaa pisteen · Esc peruuttaa + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Napsauta ja liiku reunaa pitkin · kiinnitä piste napsauttamalla · {alt}-napsautus: suora segmentti · sulje kaksoisnapsauttamalla + Clip to layer below Leikkaa alla olevaan tasoon + Color & Swatches Väri ja värimallit + Commit current crop operation ({key}) Vahvista nykyinen rajaus ({key}) + Create a new group Luo uusi ryhmä + Create a new layer Luo uusi taso + Create new document from current state Luo uusi asiakirja nykyisestä tilasta + Create new fill or adjustment layer Luo uusi täyttö- tai säätötaso + Create new snapshot Luo uusi tilannevedos + From: Lähde: + Full Document Koko asiakirja + Merged Layers Yhdistetyt tasot + Delete Cropped Pixels Poista rajatut pikselit + Delete current state Poista nykyinen tila + Delete layer Poista taso + Diamond Gradient Timanttiliukuväri + Doc: {w} × {h} px Asiak.: {w} × {h} px + Enable airbrush-style build-up effects Ota käyttöön airbrush-tyyliset kertymätehosteet + Filter for adjustment layers Suodata säätötasot + Filter for pixel layers Suodata pikselitasot + Filter for shape layers Suodata muototasot + Filter for smart objects Suodata älykkäät objektit + Filter for type layers Suodata tekstitasot + Fixed Ratio Kiinteä kuvasuhde + Fixed Size Kiinteä koko + Follows only edges this close to the pointer ([ and ] change it) Seuraa vain näin lähellä osoitinta olevia reunoja ([ ja ] muuttavat) + Hide Properties Piilota ominaisuudet + Higher values follow only edges that contrast sharply with their surroundings Korkeammat arvot seuraavat vain reunoja, joiden kontrasti ympäristöön on jyrkkä + How often fastening points are placed by themselves Kuinka usein kiinnityspisteitä lisätään automaattisesti + Hue/Saturation Sävy/värikylläisyys + Intersect with selection ({key}) Leikkaa valinnan kanssa ({key}) + Kind Tyyppi + Linear Gradient Lineaarinen liukuväri + Link layers Linkitä tasot + Lock Lukitse + Lock all Lukitse kaikki + Lock image pixels Lukitse kuvan pikselit + Lock layer Lukitse taso + Lock position Lukitse sijainti + Lock transparent pixels Lukitse läpinäkyvät pikselit + New selection Uusi valinta + No properties Ei ominaisuuksia + Pressure for Opacity Paine peittävyydelle + Pressure for Size Paine koolle + Prevent auto-nesting in and out of Artboards and Frames Estä automaattinen sisäkkäistys piirtoalustoihin ja kehyksiin ja niistä pois + Radial Gradient Säteittäinen liukuväri + Reflected Gradient Heijastettu liukuväri + Reset to defaults Palauta oletukset + Set additional smoothing options Aseta tasoituksen lisäasetukset + Show Transform Controls Näytä muunnoskahvat + Subtract from selection ({key}) Vähennä valinnasta ({key}) + Swaps height and width Vaihtaa korkeuden ja leveyden + Tolerance Toleranssi + Add vector mask Lisää vektorimaski + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Lähennä napsauttamalla · loitonna {key}-napsautuksella · lähennä/loitonna vetämällä oikealle/vasemmalle + Appearance Settings… Ulkoasuasetukset… + Add to favorites Lisää suosikkeihin + Remove from favorites Poista suosikeista +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Valmistaja +cameraData Model Malli +cameraData Date Taken Kuvauspäivä +cameraData Exposure Valotusaika +cameraData F-Stop Aukko +cameraData ISO Speed ISO-herkkyys +cameraData Focal Length Polttoväli +cameraData Focal Length (35 mm) Polttoväli (35 mm) +cameraData Lens Objektiivi +# Shared panel docking + Activate Panel Aktivoi paneeli + Change Panel Group Vaihda paneeliryhmää + Change Panel Layout Vaihda paneelin asettelua + Close Panel Sulje paneeli + Dock Panel Telakoi paneeli + Float Panel Irrota paneeli + Move Panel Siirrä paneelia + Group with Ryhmitä paneelin kanssa + Panel menu Paneelivalikko + Resize panel window Muuta paneeli-ikkunan kokoa + Resize panels Muuta paneelien kokoa + Use Classic Mode Käytä klassista tilaa + Auto Center Preview Keskitä esikatselu automaattisesti + Next Document Seuraava asiakirja + Previous Document Edellinen asiakirja + W x H x Resolution L × K × resoluutio + Front Image Etummainen kuva + Frame Tool Kehystyökalu + Create a new rectangular frame Luo uusi suorakulmainen kehys + Create a new elliptical frame Luo uusi elliptinen kehys + Include in playback Sisällytä toistoon + Perspective Crop Tool Perspektiivirajaustyökalu + Show Grid Näytä ruudukko + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Vedä laatikko tai napsauta neljää kulmaa · vedä kulmat vinoutuneen muodon reunoille · {commit} vahvistaa · Esc peruuttaa + Artboard Tool Piirtoalustatyökalu + Make Portrait Muuta pystysuuntaiseksi + Make Landscape Muuta vaakasuuntaiseksi + Add New Artboard Lisää uusi piirtoalusta + Freeform Pen Tool Vapaamuotoinen kynätyökalu + Curvature Pen Tool Kaarevuuskynätyökalu + Curve Fit: Käyrän sovitus: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Vedä: piirrä vapaalla kädellä · {alt}-napsautus: suora segmentti · Vapauta aloituspisteessä: sulje + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Napsauta: pehmeä piste · Kaksoisnapsauta: kulma · Vedä pistettä: siirrä · Napsauta ensimmäistä pistettä: sulje · {key} lopeta + Scale: Skaalaus: + Snap to Origin Tartu origoon + Link with Layer Linkitä tasoon + Rasterize Layer Style Rasteroi tasotyyli + Search settings… Hae asetuksia… + No matching settings Ei vastaavia asetuksia +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Näytteistyssivellin + Sampling Area Overlay Näytteistysalueen peite + Show Sampling Area Näytä näytteistysalue + Indicates Ilmaisee + Sampling Area Näytteistysalue + Excluded Area Poissuljettu alue + Sampling Area Options Näytteistysalueen asetukset + Rectangular Suorakulmainen + Fill Settings Täyttöasetukset + Rotation Adaptation Kierron mukautus + Very High Erittäin korkea + Full Täysi + Mirror Peilaus + Output Settings Tulosasetukset + Output To Tuloksen kohde + Duplicate Layer Monista taso + Contract Supista + + Animation Options Animaatioasetukset + Entire Document Koko asiakirja + Filter Options Suodatusasetukset + Layer Bounds Tason rajat + Layers Panel Options Tasot-paneelin asetukset + Lock Layers Lukitse tasot + New Artboard… Uusi piirtoalusta… + New Group… Uusi ryhmä… + Prevent Auto-Nesting Estä automaattinen sisäkkäistys + Show Layers Näytä tasot + Thumbnail Contents Pikkukuvan sisältö + Thumbnail Size Pikkukuvan koko + Use Default Masks on Fill Layers Käytä oletusmaskeja täyttötasoissa + Expand New Effects Laajenna uudet tehosteet + Add “copy” to Copied Layers and Groups Lisää ”kopio” kopioituihin tasoihin ja ryhmiin + Preview on canvas when reordering layers Esikatsele kankaalla tasojen järjestystä muutettaessa + Show layer mask badges Näytä tasomaskien merkit + +# Smart Filter editors + Blending Options ({filter}) Sekoitusasetukset ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Piilota tason tehosteet ja älykkäät suodattimet ({key}-napsautus: kaikki tasot) + Show the layer's effects and Smart Filters ({key}-click: all layers) Näytä tason tehosteet ja älykkäät suodattimet ({key}-napsautus: kaikki tasot) + Wait for the current job to finish. Odota, että nykyinen työ valmistuu. + The document or Smart Filter changed. Reopen its dialog. Asiakirja tai älykäs suodatin muuttui. Avaa sen valintaikkuna uudelleen. + Opacity must be between 0% and 100%. Peittävyyden on oltava välillä 0–100 %. + Dissolve is approximated: it blends without Photoshop's dither. Liuota on likimääräinen: se sekoittaa ilman Photoshopin ditteröintiä. + Loading fonts… Ladataan fontteja… + Response curve Vastekäyrä + Tool Työkalu + Simulate Pressure Simuloi painetta + Stroke Path… Piirrä polun reunaviiva… + Background Eraser Taustapyyhekumi + Healing Brush Korjaussivellin + Spot Healing Brush Pistekorjaussivellin + Smudge Tahraus + Sponge Sieni + Amount: Määrä: + Protect: Suojaa: + Protect skin tones Suojaa ihonsävyt + Edit points to modify the curve Muokkaa käyrää muokkaamalla pisteitä + Draw to modify the curve Muokkaa käyrää piirtämällä +curves Smooth Tasoita + Smooth the curve values Tasoita käyrän arvot + Drag to draw the curve · {key}-click draws a straight line Piirrä käyrä vetämällä · {key}-napsautus piirtää suoran viivan + Grid Size: Ruudukon koko: + Vanishing Point: click four corners to create a plane Pakopiste: napsauta neljää kulmaa luodaksesi perspektiivitason + Vanishing Point is not active Pakopiste ei ole aktiivinen + Apply Vanishing Point ({key}) Käytä pakopistettä ({key}) + Fix the red plane first: its corners must form a convex quad Korjaa ensin punainen perspektiivitaso: sen kulmien täytyy muodostaa kupera nelikulmio + Alt-click a plane to set the clone source Alt-napsauta perspektiivitasoa asettaaksesi kloonauslähteen + Too many clone strokes: apply Vanishing Point first Liikaa kloonausvetoja: käytä ensin Pakopiste-suodatinta +# Select and Mask view modes (#1754) + Onion Skin Sipulinkuori + Marching Ants Marssivat muurahaiset + On Black Mustalla + On White Valkoisella + On Layers Tasoilla + Show Edge Näytä reuna + Show Original Näytä alkuperäinen + F cycles the views; hold X to see the image F vaihtaa näkymiä; pidä X painettuna nähdäksesi kuvan +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Reunan tarkennussivellin (R) + Expand Detection Area Laajenna tunnistusaluetta + Restore Original Edge Palauta alkuperäinen reuna + Paint over hair or fur on the image; Alt paints the other mode Maalaa kuvan hiusten tai turkin päälle; Alt maalaa toisessa tilassa + Clear Brush Strokes Tyhjennä siveltimenvedot + Show rulers Näytä viivaimet + New Crop Preset… Uusi rajausesiasetus… + Delete Crop Preset… Poista rajausesiasetus… + Select the crop presets to delete: Valitse poistettavat rajausesiasetukset: + Select the crop presets to delete Valitse poistettavat rajausesiasetukset + Enter a name for the crop preset Anna rajausesiasetukselle nimi + There is no crop size or ratio to save Tallennettavaa rajauskokoa tai -suhdetta ei ole + Presentation Esitystapa + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Sijoita napsauttamalla · poista Alt-napsautuksella tai vetämällä kankaan ulkopuolelle + No document open Ei avointa asiakirjaa + {n} of {max} sample points {n}/{max} näytepistettä + +# History panel menu (#2966) + New Snapshot… Uusi tilannevedos… + History Options… Historian asetukset… +# History panel › History Options… (#2966) + Allow Non-Linear History Salli epälineaarinen historia + Make Layer Visibility Changes Undoable Tee tasojen näkyvyysmuutoksista kumottavia +# Print dialog (#2697) + Emulsion Down Emulsio alaspäin + Functions Funktiot + Print Resolution: Tulostusresoluutio: + Use Previous Layer to Create Clipping Mask Luo leikkausmaski edellisen tason avulla + Fill with {mode}-neutral color (white) Täytä {mode}-neutraalilla värillä (valkoinen) + Fill with {mode}-neutral color (black) Täytä {mode}-neutraalilla värillä (musta) + Fill with {mode}-neutral color (50% gray) Täytä {mode}-neutraalilla värillä (50 % harmaa) + (No neutral color exists for {mode} mode.) (Tilalle {mode} ei ole neutraalia väriä.) + Apply mask to layer before removing? Käytetäänkö maskia tasoon ennen poistamista? + The layer or its mask changed. Drag the mask onto the Trash again. Taso tai sen maski muuttui. Vedä maski uudelleen roskakoriin. + Delete the vector mask? Poistetaanko vektorimaski? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Viety {n} värimalli tiedostoon {file}|Viety {n} värimallia tiedostoon {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Tuotu {n} sivellin ryhmästä {group}|Tuotu {n} sivellintä ryhmästä {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Tuotu {n} värimalli tiedostosta {file}|Tuotu {n} värimallia tiedostosta {file} +@plural Played {n} step|Played {n} steps Suoritettu {n} vaihe|Suoritettu {n} vaihetta +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Päivitetty {n} levyllä muuttunut linkitetty älykäs objekti|Päivitetty {n} levyllä muuttunutta linkitettyä älykästä objektia +@plural Saved for Web: {n} file|Saved for Web: {n} files Tallennettu verkkoa varten: {n} tiedosto|Tallennettu verkkoa varten: {n} tiedostoa +@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} tämän asetelman tallentama taso poistettiin. Päivitä asetelma tai tyhjennä varoitus.|{n} tämän asetelman tallentamaa tasoa poistettiin. Päivitä asetelma tai tyhjennä varoitus. +@plural {n} plane|{n} planes {n} perspektiivitaso|{n} perspektiivitasoa +@plural {n} preset|{n} presets {n} esiasetus|{n} esiasetusta +@plural {n} sample point removed|{n} sample points removed {n} näytepiste poistettu|{n} näytepistettä poistettu +@plural {n} slice|{n} slices {n} viipale|{n} viipaletta +@plural {n} step|{n} steps {n} vaihe|{n} vaihetta +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} muu siveltimen esiasetuksen varoitus)|{warning} (+{n} muuta siveltimen esiasetuksen varoitusta) +@plural {what} with {n} warning|{what} with {n} warnings {what} ({n} varoitus)|{what} ({n} varoitusta) +# View › Guides › New Guide… › Color + Light Blue Vaaleansininen + Light Red Vaalea punainen + Medium Blue Keskisininen + Light Gray Vaaleanharmaa + Object Finder Objektien etsijä + No object found there — drag a rectangle around it Kohdasta ei löytynyt objektia — vedä suorakulmio sen ympärille + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktiivisella tasolla ei ole tässä pikseleitä — ota käyttöön ”Ota näyte kaikilta tasoilta” tai valitse taso, jossa on pikseleitä + Remove after each stroke Poista jokaisen vedon jälkeen + Apply the removal ({key}) Käytä poistoa ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Poistettavat vedot: {n} (Enter vahvistaa, Esc peruuttaa) + Load Contours… Lataa kontuureja… + Replace Contours… Korvaa kontuurit… + Save Contours… Tallenna kontuurit… + Delete Contour Poista kontuuri + Loaded contours from {file} Kontuurit ladattu tiedostosta {file} + Saved contours to {file} Kontuurit tallennettu tiedostoon {file} + Show home recent files Näytä viimeisimmät tiedostot aloitusnäkymässä + Clear Brush Controls Nollaa siveltimen asetukset + Reset All Locked Settings Nollaa kaikki lukitut asetukset + Copy Texture to Other Tools Kopioi tekstuuri muihin työkaluihin + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Lataa toiminnot… + Save Actions… Tallenna toiminnot… + New Action… Uusi toiminto… + Start Recording Aloita nauhoitus + Stop Recording Lopeta nauhoitus + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Ladattu {n} toiminto tiedostosta {file}|Ladattu {n} toimintoa tiedostosta {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} vaiheella ei ole vielä vastaavuutta PhotoCraftissa|{n} vaiheella ei ole vielä vastaavuutta PhotoCraftissa +@plural Saved {n} action to {file}|Saved {n} actions to {file} Tallennettu {n} toiminto tiedostoon {file}|Tallennettu {n} toimintoa tiedostoon {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} vaihe jätettiin pois, koska sillä ei ole Photoshop-vastinetta|{n} vaihetta jätettiin pois, koska niillä ei ole Photoshop-vastinetta +@plural {n} action file loaded|{n} action files loaded Ladattu {n} toimintotiedosto|Ladattu {n} toimintotiedostoa + {set} from {file} replaced the one from {earlier} Sarja {set} tiedostosta {file} korvasi sarjan tiedostosta {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} tiedoston lataaminen epäonnistui: {errors}|{n} tiedoston lataaminen epäonnistui: {errors} + + File Naming Tiedostojen nimeäminen + Starting serial Aloitusnumero +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Lisää pysäytys… + Save Set… Tallenna sarja… + Message: Viesti: + Allow Continue Salli jatkaminen + Show dialog during playback Näytä valintaikkuna toiston aikana + Select an action first Valitse ensin toiminto + Action playback stopped Toiminnon toisto pysäytetty + Step {step} is waiting for its dialog Vaihe {step} odottaa valintaikkunaansa +@plural {n} action|{n} actions {n} toiminto|{n} toimintoa + Background (Default) Tausta (oletus) + Content-Aware Fill Sisältötietoinen täyttö +# Batch / Image Processor + Include All Subfolders Sisällytä kaikki alikansiot + Auto Add/Delete Automaattinen lisäys/poisto + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Älä valitse myös Peilikuva-asetusta kohdassa Tulostusasetukset…: kaksi peilausta kumoaa toisensa. + Print Settings… Tulostusasetukset… + Remember to turn off the printer's own colour management in Print Settings… Muista poistaa tulostimen oma värinhallinta käytöstä kohdassa Tulostusasetukset… + Sent to {printer} Lähetetty tulostimelle {printer} + set in Print Settings… asetetaan kohdassa Tulostusasetukset… + ICO is limited to 256 × 256 pixels ICO-tiedosto on rajoitettu kokoon 256 × 256 pikseliä + Calibration Kalibrointi + Red Primary Punainen perusväri + Green Primary Vihreä perusväri + Blue Primary Sininen perusväri + Write xmp sidecars Kirjoita XMP-sivutiedostot + Set the tone sliders from the image Aseta sävyliukusäätimet kuvan perusteella + Settings from {name} Asetukset kohteesta {name} + Settings from {name}; {count} not supported: {names} Asetukset kohteesta {name}; {count} ei tuettu: {names} + Could not write {name}: {error} Kohdetta {name} ei voitu kirjoittaa: {error} + Kept {name}: written by another application {name} säilytettiin: toisen sovelluksen kirjoittama + Shadow Tint Varjojen sävy +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maalaa Historia-paneelin lähdetilasta + Sets the source for the history brush Asettaa historiasiveltimen lähteen + +# Dynamic selection context menu and appearance favorites + Select Inverse Käännä valinta + Pro (Light) Pro (vaalea) + Pro (Light Gray) Pro (vaaleanharmaa) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Tunniste + Date and Time Päivämäärä ja aika + Scale Units Mittakaavayksiköt + Scale Factor Mittakaavakerroin + Area Pinta-ala + Perimeter Ympärysmitta + Circularity Pyöreys + Gray Value (Minimum) Harmaa-arvo (minimi) + Gray Value (Maximum) Harmaa-arvo (maksimi) + Gray Value (Mean) Harmaa-arvo (keskiarvo) + Gray Value (Median) Harmaa-arvo (mediaani) + Integrated Density Integroitu tiheys + Length Pituus + Show Favorite Fonts Näytä suosikkifontit + No favorite fonts yet: click the star beside a font to add it. Ei vielä suosikkifontteja: lisää fontti napsauttamalla sen vieressä olevaa tähteä. + Favorite fonts Suosikkifontit + Add to Favorites Lisää suosikkeihin + Remove from Favorites Poista suosikeista + Show All Fonts Näytä kaikki fontit + All Classes Kaikki luokat + Blackletter Fraktuura + Decorative Koriste + Font preview: Fontin esikatselu: + Handwritten Käsinkirjoitettu + Hide Font Classes Piilota fonttiluokat + Hide Styles Piilota tyylit + Monospace Tasalevyinen + No fonts match these filters. Mikään fontti ei vastaa näitä suodattimia. + No similar fonts: the current font doesn't say what it looks like. Ei samankaltaisia fontteja: nykyinen fontti ei kerro, miltä se näyttää. + OpenType font OpenType-fontti + Sans Serif Pääteviivaton + Script Kaunokirjoitus + Serif Pääteviivallinen + Show Font Classes Näytä fonttiluokat + Show Similar Fonts Näytä samankaltaiset fontit + Show Styles Näytä tyylit + Show Variable Fonts Näytä muuttuvat fontit + Slab Serif Laattapäätteinen + TrueType font TrueType-fontti + Variable font Muuttuva fontti +# Bevel & Emboss shading controls. + Highlight Mode Korostuksen sekoitustila + Highlight Color Korostuksen väri + Highlight Opacity Korostuksen peittävyys + Shadow Mode Varjon sekoitustila + Shadow Color Varjon väri + Shadow Opacity Varjon peittävyys +# PhotoCraft release checks. + Check for Updates… Tarkista päivitykset… + Open the release page to choose an installer for your system. Avaa julkaisusivu ja valitse järjestelmällesi sopiva asennusohjelma. + Automatically Check for Updates Tarkista päivitykset automaattisesti +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Käytä upotettua profiilia ({profile}) + Convert to the working space ({profile}) Muunna työavaruuteen ({profile}) + Remember this choice for future images Muista tämä valinta tuleville kuville + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Ota kysely uudelleen käyttöön kohdasta Kysy täsmäämättömyydestä valikossa Muokkaa › Väriasetukset. + Button Mode Painiketila + Button Mode On Painiketila käytössä + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Tummenna käyttöliittymää + Lighten Interface Vaalenna käyttöliittymää +# Bevel & Emboss Technique. + Technique Tekniikka + Chisel Hard Kova talttaus + Chisel Soft Pehmeä talttaus + Open color-less EXR as ACEScg Avaa EXR ilman väriavaruustietoa ACEScg-muotoisena + Do not append "copy" to filename when saving a copy Älä lisää tiedostonimeen ”kopio”, kun tallennat kopion + +# View › Guides › right-click a guide + Edit Selected Guides Muokkaa valittuja apuviivoja + Clear Selected Guides Poista valitut apuviivat + Bicubic Automatic Bikubinen automaattinen + Bicubic Smoother Bikubinen pehmeämpi + Bicubic Sharper Bikubinen terävämpi + Pasting from other apps is unavailable Liittäminen muista sovelluksista ei ole käytettävissä + 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 ei pääse järjestelmän leikepöydälle tässä istunnossa. Waylandissa Flatpak-hiekkalaatikko piilottaa protokollan, jonka kautta muiden sovellusten kuvat luetaan. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Jos haluat liittää muista sovelluksista, käynnistä PhotoCraft XWaylandin alla: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Ei liitettävää: PhotoCraft ei pysty lukemaan järjestelmän leikepöytää täällä + Delete this set Poista tämä sarja + New Set… Uusi sarja… + PhotoCraft Defaults PhotoCraft-oletukset + Save a list of these shortcuts as a web page Tallenna luettelo näistä pikanäppäimistä verkkosivuna + Save the changes to this set Tallenna muutokset tähän sarjaan + Save these shortcuts as a .kys file Tallenna nämä pikanäppäimet .kys-tiedostona + Save these shortcuts as a new set Tallenna nämä pikanäppäimet uutena sarjana + Set deleted. Sarja poistettu. + Set saved. Sarja tallennettu. + Set: Sarja: + Summarize… Yhteenveto… + There are too many sets: delete one first. Sarjoja on liikaa: poista ensin yksi. + set name sarjan nimi + {set} (modified) {set} (muokattu) + Random Satunnainen + Show file name in title Näytä tiedostonimi otsikkorivillä + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 %:n kynnysarvo + Pattern Dither Kuvioditteröinti + Diffusion Dither Diffuusioditteröinti + Halftone Screen Rasteri + Custom Pattern Mukautettu kuvio + Frequency: Taajuus: + Lines/Inch Viivaa/tuuma + Lines/Centimeter Viivaa/senttimetri + Ellipse Ellipsi + Diamond Timantti + Cross Risti + Accept and Go to Conflict Hyväksy ja siirry ristiriitaan + Changes undone. Muutokset kumottu. + Choose a new shortcut for this command, or leave it without one. Valitse tälle komennolle uusi pikanäppäin tai jätä se ilman. + Put this command's shortcuts back as they were Palauta tämän komennon pikanäppäimet entiselleen + Take the shortcut from the other command and select it Ota pikanäppäin toiselta komennolta ja valitse se + Undo Changes Kumoa muutokset + {shortcut} is reserved by the system and can't be assigned. {shortcut} on järjestelmän varaama eikä sitä voi määrittää. + Low-Latency Drawing (vsync off, applies at next launch) Matalan viiveen piirto (vsync pois päältä, astuu voimaan seuraavalla käynnistyksellä) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Vapaata muunnosta toteutetaan vielä; yritä uudelleen, kun se on valmis. + The transform couldn't be applied. Muunnosta ei voitu toteuttaa. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sen tasot tarvitsevat {needed} GPU-muistia; budjetti jättää niille {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Aseta GPU-muisti kohdassa Asetukset › Suorituskyky arvoon, joka mahtuu näytönohjaimesi muistiin, enintään 3/8 sen muistista. Automaattinen seuraa muistin käyttöä, enintään neljäsosa tämän tietokoneen muistista ja 3/8 kortin muistista, eikä laske asiakirjaa sellaisen kortin kuormaksi, jonka muisti tunnetaan. Tilarivillä näkyy GPU-muisti täynnä, kun tasot eivät mahdu. + GPU Memory Full GPU-muisti täynnä + 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. Tasot tarvitsevat {needed} GPU-muistia ja saavat käyttää {budget}, joten koko asiakirjan uudelleenpiirtävät muokkaukset tehdään suorittimella. Aseta GPU-muisti napsauttamalla. + GPU memory budget: {budget} GPU-muistibudjetti: {budget} + Graphics memory: {size} Grafiikkamuisti: {size} + This document's layers: {needed} of {budget} for layers Tämän asiakirjan tasot: {needed} / {budget} tasoille + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Tämän asiakirjan tasot tarvitsevat {needed}, mutta saavat käyttää {budget}: koko asiakirjan uudelleenpiirrot käyttävät suoritinta + GPU Memory (MB, 0 = Automatic) GPU-muisti (Mt, 0 = automaattinen) + Scratch Size Limit (MB, 0 = No Limit) Työlevyn kokoraja (Mt, 0 = ei rajaa) + {path}: the path is not valid Unicode; save under another name. {path}: polku ei ole kelvollista Unicodea; tallenna toisella nimellä. + Replace Korvaa + {name} was changed by another app after it was opened or saved here. Toinen ohjelma on muuttanut tiedostoa {name} sen jälkeen, kun se avattiin tai tallennettiin täällä. + Replace it with this document, or cancel and use File › Save As to keep both. Korvaa se tällä asiakirjalla, tai peruuta ja käytä komentoa Tiedosto › Tallenna nimellä, jos haluat säilyttää molemmat. + +# 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) (tulossa pian) + (none) (ei mitään) + 32-bit Preview Options 32-bittisen esikatselun asetukset + 3D… 3D… + A: K: + AI Integrations Tekoälyintegraatiot + About {app} Tietoja: {app} + Accented Edges Korostetut reunat + Action playback failed: {error} Toiminnon toisto epäonnistui: {error} + Actual Pixels Todelliset pikselit + Add Mask Lisää maski + Add Noise Lisää kohinaa + Add a mask (from the selection; ⌥ inverts) Lisää maski (valinnasta; ⌥ kääntää) + Add space after paragraph Lisää väli kappaleen jälkeen + Add space before paragraph Lisää väli ennen kappaletta + Add to selection (⇧) Lisää valintaan (⇧) + Added swatch {name} Värimalli {name} lisätty + Adds grain to the soft edges of the brush tip. Lisää rakeisuutta siveltimen kärjen pehmeisiin reunoihin. + Adjust Interval For Loops Säädä silmukoiden väliä + Adjustment Layers Säätötasot + Adjustment Properties Säädön ominaisuudet + Adjustment layers have nothing to transform Säätötasoilla ei ole mitään muunnettavaa + Adobe PDF Presets… Adobe PDF -esiasetukset… + Adobe-rgb-compat Adobe RGB -yhteensopiva + Adwaita (Dark) Adwaita (tumma) + Adwaita (Light) Adwaita (vaalea) + After Jälkeen + Airbrush: keeps painting while the pointer rests. Ilmasivellin: maalaa edelleen, kun osoitin on paikallaan. + All shortcuts reset to Photoshop defaults. Kaikki pikanäppäimet palautettu Photoshopin oletuksiin. + Altitude Korkeus + Ambience Ympäristövalo + Ambient Ympäristö + Amplitude Max Amplitudi max + Amplitude Min Amplitudi min + Android 1080p Android 1080p + Angled Strokes Kulmikkaat vedot + Anisotropic Anisotrooppinen + Any Mikä tahansa + Apple Watch 45mm Apple Watch 45mm + Apply Data Set Käytä tietojoukkoa + Apply Image Käytä kuvaa + Apply Mask Käytä maskia + Apply to All Käytä kaikkiin + Apply {option}: {state} Käytä {option}: {state} + Arbitrary Mielivaltainen + Arc Lower Alakaari + Arc Upper Yläkaari + Are you sure you want to quit? Haluatko varmasti lopettaa? + Are you sure? Oletko varma? + Around Center Keskipisteen ympäri + Arrangement Järjestely + Arrows Nuolet + Artboard from Group Piirtoalusta ryhmästä + Artboard from Layers Piirtoalusta tasoista + Artboards Piirtoalustat + Artboards to Files Piirtoalustat tiedostoiksi + Artboards to PDF Piirtoalustat PDF:ksi + Artistic Taiteellinen + Ask On Mismatch Kysy ristiriidassa + Ask On Missing Kysy puuttuessa + Ask On Paste Kysy liitettäessä + Assign Profile Määritä profiili + At Kohdassa + Author Title Tekijän nimike + Auto Scale Automaattinen skaalaus + Auto-Align Layers Kohdista tasot automaattisesti + Auto-Blend Layers Sulauta tasot automaattisesti + Autosave failed: {error} Automaattinen tallennus epäonnistui: {error} + Back Takaisin + Backend: {backend} ({type}) Taustajärjestelmä: {backend} ({type}) + Background Level Taustavärin taso + Bamboo Bambu + Bas Relief Matala reliefi + Base Tree Type Puun perustyyppi + Based On Perustuu + Baseline shift Perusviivan siirtymä + Basic Latin Latinalainen perusmerkistö + Batch Eräajo + Before Ennen + Bend Taivutus + Berries Marjat + Best Paras + Black Clip Mustan leikkaus + Black Intensity Mustan voimakkuus + Blade Curvature Lamellien kaarevuus + Blast Puhallus + Bleach Bypass Valkaisun ohitus + Blend Clipped Layers as Group Sekoita leikkausmaskin tasot ryhmänä + Blend Interior Effects as Group Sekoita sisäiset tehosteet ryhmänä + Blend Text Gamma Tekstin sekoitusgamma + Blending Mode Sekoitustila + Bloat Pullista + Blocks Lohkot + Blue Yellow Sini/keltainen + Blues Siniset + Border Reunus + Border Thickness Reunuksen paksuus + Box Blur Laatikkosumennus + Bpc Mustan pisteen kompensointi + Branches Color Oksien väri + Branches Height Oksien korkeus + Branches Thickness Oksien paksuus + Breeze Dark Breeze (tumma) + Breeze Light Breeze (vaalea) + Brick Tiili + Bristle Detail Harjaksen yksityiskohta + Browse Selaa + Brush Detail Siveltimen yksityiskohdat + Brush Settings… (F5): every option of this brush Siveltimen asetukset… (F5): tämän siveltimen kaikki asetukset + Brush Strokes Siveltimenvedot + Brush Type Siveltimen tyyppi + Burlap Juuttikangas + Calculations Laskutoimitukset + Camera Raw Camera Raw + Camera Raw Filter Camera Raw -suodatin + Cancel transform Peruuta muunnos + Candle Light Kynttilänvalo + Canvas Size Kankaan koko + Case Sensitive Huomioi kirjainkoko + Ccw Vastapäivään + Cell Size Solukoko + Center X Keskipiste X + Center Y Keskipiste Y + Chalk Area Liidun alue + Change Find Etsi ja muuta + Changes Saved Muutokset tallennettu + Changes have not been saved. Muutoksia ei ole tallennettu. + Change… Muuta… + Channel1 Kanava 1 + Channel2 Kanava 2 + Channel3 Kanava 3 + Channel4 Kanava 4 + Charcoal Area Hiilen alue + Charcoal Thickness Hiilen paksuus + Check for Updates Tarkista päivitykset + Checkerboard Shakkiruudukko + Choke Kuristus + Choose Folder Valitse kansio + Circle Ympyrä + Cleanliness Puhtaus + Clear Amount Tyhjennyksen määrä + Clear Selection Tyhjennä valinta + Click sets foreground · right-click sets background Napsautus asettaa edustavärin · napsautus hiiren kakkospainikkeella asettaa taustavärin + Click the first point or press ↵ to close · Esc cancels Sulje napsauttamalla ensimmäistä pistettä tai painamalla ↵ · Esc peruuttaa + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Lisää piste napsauttamalla · poista vetämällä pois, ⌘/Ctrl-napsautuksella tai Delete-näppäimellä + Click to apply, ⌥-click to clear overrides, double-click for Style Options Käytä napsauttamalla, tyhjennä ohitukset ⌥-napsautuksella, tyyliasetukset kaksoisnapsautuksella + Click to zoom in · ⌥-click to zoom out Lähennä napsauttamalla · loitonna ⌥-napsautuksella + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Napsautus: kulma · Veto: pyöristetty · Ensimmäisen pisteen napsautus: sulje · ↩ valmis · Esc peruuta + Clipping Mask Leikkausmaski + Clipping Path… Leikkauspolku… + Clone Kloonaa + Clone source set at {x}, {y} Kloonauslähde asetettu kohtaan {x}, {y} + Close Window Sulje ikkuna + Clumped Kasautunut + Cmyk CMYK + Coarse Dots Karkeat pisteet + Coated-cmyk Päällystetty CMYK + Collapse group (⌥-click: all groups) Tiivistä ryhmä (⌥-napsautus: kaikki ryhmät) + Color Decontaminate Värien puhdistus + Color Halftone Värirasteri + Color Indicates Masked Areas Väri osoittaa maskatut alueet + Color Indicates Selected Areas Väri osoittaa valitut alueet + Color Lookup Tables Värihakutaulukot + Color Picker Värinvalitsin + Color Picker (Background Color) Värinvalitsin (taustaväri) + Color Picker (Custom Canvas Color) Värinvalitsin (mukautettu kankaan väri) + Color Picker (Foreground Color) Värinvalitsin (edustaväri) + Color Sampler Tool Värinäytetyökalu + Color Settings Väriasetukset + Color Swatches Värimallit + Color Table Väritaulukko + Color management: {error} Värinhallinta: {error} + Colored Pencil Värikynä + Comic Book Sarjakuva + Commit Perspective Warp (↩) Vahvista perspektiivin taivutus (↩) + Commit Puppet Warp (↩) Vahvista nukketaivutus (↩) + Commit any current edits (⌘↩) Vahvista nykyiset muokkaukset (⌘↩) + Commit current crop operation (↵) Vahvista nykyinen rajaus (↵) + Commit transform Vahvista muunnos + Commit transform (↩) Vahvista muunnos (↩) + Commit warp (↩) Vahvista taivutus (↩) + Compare Vertaa + Conditional Mode Change Ehdollinen tilan muutos + Confirm Vahvista + Conform Mukauta + Constraints Rajoitteet + Content-Aware Fill needs a selection on a pixel layer Sisältötietoinen täyttö tarvitsee valinnan pikselitasolla + Contrasty Kontrastikas + Convert Muunna + Convert to Alpha Channel Muunna alfakanavaksi + Convert to Linked Muunna linkitetyksi + Convert to Profile Muunna profiiliin + Cool Viileä + Cooling L B B Viilentävä LBB + Cooling80 Viilentävä 80 + Cooling82 Viilentävä 82 + Copy Complete Stroke Kopioi koko reunaviiva + Copy Fill Kopioi täyttö + Copyright Url Tekijänoikeuden URL + Correct C A Korjaa väripoikkeama + Correct Distortion Korjaa vääristymä + Correct Vignette Korjaa vinjetointi + Could not rename the action: {error} Toimintoa ei voitu nimetä uudelleen: {error} + Could not transform: the layer is empty Muuntaminen epäonnistui: taso on tyhjä + Could not use Perspective Warp: the layer is empty Perspektiivin taivutusta ei voitu käyttää: taso on tyhjä + Could not use Puppet Warp: no opaque pixels Nukketaivutusta ei voitu käyttää: ei läpinäkymättömiä pikseleitä + Could not use Puppet Warp: the layer is empty Nukketaivutusta ei voitu käyttää: taso on tyhjä + Couldn't open {name}: {error} Kohdetta {name} ei voitu avata: {error} + Couldn't save preferences: {error}. Retrying… Asetuksia ei voitu tallentaa: {error}. Yritetään uudelleen… + Crack Brightness Halkeamien kirkkaus + Crack Depth Halkeamien syvyys + Crack Spacing Halkeamien väli + Craquelure Krakelyyri + Create By Luontitapa + Create Vector Mask Luo vektorimaski + Create Warped Text Luo taivutettu teksti + Create a new layer (⇧⌘N) Luo uusi taso (⇧⌘N) + Create new brush from the current settings Luo uusi sivellin nykyisistä asetuksista + Create new brush preset from the current settings Luo uusi siveltimen esiasetus nykyisistä asetuksista + Create… Luo… + Cross Process Ristiinkehitys + Crosshatch Ristiviivoitus + Crosswise Poikittain + Crystallize Kiteytä + Currency Valuutta + Curve Darks Käyrä: tummat + Curve Highlights Käyrä: korostukset + Curve Lights Käyrä: vaaleat + Curve Shadows Käyrä: varjot + Curve Splits Käyrä: jakopisteet + Custom Branch Color Mukautettu oksien väri + Custom Pixel Aspect Ratio Mukautettu pikselien kuvasuhde + Custom pattern Mukautettu kuvio + Cutout Leikekuva + Cw Myötäpäivään + Cyans Syaanit + Cyrillic Kyrilliset + Daisies Päivänkakkarat + Dark Gray Tummanharmaa + Dark Intensity Tummien voimakkuus + Dark Rough Tumma karhea + Dark Strokes Tummat vedot + Darken Only Vain tummennus + Darkness Tummuus + Data Tiedot + Data Set: {name} ({index}/{total}) Tietojoukko: {name} ({index}/{total}) + Data Sets as Files Tietojoukot tiedostoiksi + Date Päivämäärä + Day For Night Päivä yöksi + De-Interlace Lomituksen poisto + Decrease Brush Hardness Vähennä siveltimen kovuutta + Decrease Brush Size Pienennä siveltimen kokoa + Decrease Type Size Pienennä tekstin kokoa + Decrease Type Size by 10 Pienennä tekstin kokoa 10:llä + Deep Syvä + Deep Blue Syvä sininen + Deep Emerald Syvä smaragdi + Deep Red Syvä punainen + Deep Yellow Syvä keltainen + Default Foreground and Background Colors Oletusarvoiset edusta- ja taustavärit + Default Leaves Oletuslehdet + Default Photoshop Size Photoshopin oletuskoko + Defaults Oletukset + Define Brush Preset Määritä siveltimen esiasetus + Define Custom Shape Määritä mukautettu muoto + Define Pattern Määritä kuvio + Definition Määritelmä + Defringe Poista reunaväristymät + Degrees Astetta + Delete Channel Poista kanava + Delete Mask Poista maski + Delete Workspace Poista työtila + Delete brush Poista sivellin + Delete path Poista polku + Delete vector mask Poista vektorimaski + Delete… Poista… + Depth Map Syvyyskartta + Depth Mode Syvyystila + Diameter Halkaisija + Diffuse Hajauta + Diffuse Glow Hajautettu hehku + Direction Suunta + Direction Balance Suuntatasapaino + Disable Poista käytöstä + Disable Mask Poista maski käytöstä + Disable Symmetry Path Poista symmetriapolku käytöstä + Disabled Ei käytössä + Discard Changes Hylkää muutokset + Displace Syrjäytä + Display Näyttö + Display profile not used Näyttöprofiilia ei käytetä + Display profiles: {reason} Näyttöprofiilit: {reason} + Display-p3 Display P3 + Distance Etäisyys + Distortion Vääristymä + Distribution Jakauma + Do you want to save you made to “{name}” before quitting? Haluatko tallentaa tiedostoon ”{name}” tekemäsi muutokset ennen lopettamista? + Document Sizes Asiakirjan koot + Documentation Dokumentaatio + Documents Asiakirjat + Dont Enlarge Älä suurenna + Dot Piste + Double Line Kaksoisviiva + Down Alas + Draft Luonnos + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Vedä rajausruutu · siirrä vetämällä sisältä · reunat muuttavat kokoa (suhde {ratio}, keskitys {centre}) · Välilyönti siirtää piirrettäessä · {commit} vahvistaa · Esc peruuttaa + Drag: draw freehand · Release on the start point: close Veto: piirrä vapaalla kädellä · Vapauta aloituspisteessä: sulje + Driver: {driver} Ajuri: {driver} + Dry Brush Kuiva sivellin + Duplicate Channel Monista kanava + Duplicate Layers Monista tasot + Duplication Monistus + Dust & Scratches Pöly ja naarmut + Edge Reuna + Edge Brightness Reunan kirkkaus + Edge Extension Reunan laajennus + Edge Fidelity Reunan tarkkuus + Edge Intensity Reunan voimakkuus + Edge Only Vain reuna + Edge Simplicity Reunan yksinkertaisuus + Edge Thickness Reunan paksuus + Edge Width Reunan leveys + Edit Toolbar Muokkaa työkaluriviä + Edit… Muokkaa… + Elevation Korkeuskulma + Eliminate Poista + Emboss Kohokuvio + Enable Ota käyttöön + Enable Mask Ota maski käyttöön + End X Loppupiste X + End Y Loppupiste Y + Enlarged Suurennettu + Erase with Brush or Pencil (hold) Pyyhi siveltimellä tai lyijykynällä (pidä painettuna) + Error Virhe + Even Fields Parilliset kentät + Exit Quick Mask Poistu pikamaskista + Expand group (⌥-click: all groups) Laajenna ryhmä (⌥-napsautus: kaikki ryhmät) + Export As Vie nimellä + Export As: {name} Vie nimellä: {name} + Export Contents Vie sisältö + Export Image Vie kuva + Export Options Vientiasetukset + Export Preferences Viennin oletusasetukset + Export Settings Vientiasetukset + Export to Vie kohteeseen + Export/Import Presets Vie/tuo esiasetukset + Exported contents to {path} Sisältö viety kohteeseen {path} + Exported presets to {path} Esiasetukset viety kohteeseen {path} + Exported {path} Viety {path} + Exported {path} ({size}) Viety {path} ({size}) + Exporting… Viedään… + ExtraBold Erittäin lihavoitu + ExtraLight Erittäin kevyt + Extrude Pursota + Fade Häivytys + Faded Film Haalistunut filmi + Failed Epäonnistui + Failed to open file Tiedoston avaaminen epäonnistui + Failed to save file Tiedoston tallentaminen epäonnistui + Fallback: {reason} Varajärjestelmä: {reason} + Feather Radius Pehmennyssäde + Fiber Length Kuidun pituus + Fibers Kuidut + Field Blur Kenttäsumennus + File Format Tiedostomuoto + File Info Tiedoston tiedot + File Name Tiedostonimi + File Size Tiedostokoko + File not found Tiedostoa ei löytynyt + Filename Tiedostonimi + Fill Color Täyttöväri + Fill Opacity Täytön peittävyys + Fill Path… Täytä polku… + Fill path with foreground color Täytä polku edustavärillä + Fill with Background Color Täytä taustavärillä + Fill with Background Color, Preserve Transparency Täytä taustavärillä, säilytä läpinäkyvyys + Fill with Foreground Color Täytä edustavärillä + Fill with Foreground Color, Preserve Transparency Täytä edustavärillä, säilytä läpinäkyvyys + Film Grain Filmin rae + Filter Gallery Suodatingalleria + Find Etsi + Find and Replace Text Etsi ja korvaa tekstiä + Fine Hieno + Fine Dots Hienot pisteet + Finish Viimeistely + Fit Image Sovita kuva + Fit to Window Sovita ikkunaan + Flame Liekki + Flame Bottom Alignment Liekin alareunan tasaus + Flame Lines Liekin viivat + Flame Shape Liekin muoto + Flame Style Liekin tyyli + Flame Type Liekkityyppi + Flat Litteä + Flat Shading Tasainen varjostus + Flower Color Kukan väri + Focal Distance Tarkennusetäisyys + Focus Tarkennus + Focus Area Tarkennusalue + Foreground Level Edustavärin taso + Forward Eteenpäin + Forward Warp Eteenpäin taivutus + Frame Kehys + Frame {current} / {total} Ruutu {current} / {total} + Free Transform Path Polun vapaa muunnos + Freeze Lasso Jäädytyslasso + Freeze Mask Jäädytysmaski + Fresco Fresko + From Alkuväri + From Left Vasemmalta + From Right Oikealta + Frosted Huurrettu + Full Screen Koko näyttö + Fullscreen Koko näyttö + Fuzziness Sumeus + GPU backend preference: {backend} GPU-taustajärjestelmän valinta: {backend} + GPU device was lost; using the CPU renderer. GPU-laite menetettiin; käytetään CPU-renderöijää. + GPU error; using the CPU renderer. GPU-virhe; käytetään CPU-renderöijää. + GPU retry scheduled GPU:n uusi yritys ajastettu + Gamma Gamma + Gaussian Blur Gauss-sumennus + General Blending Yleinen sekoitus + General Brushes Yleiset siveltimet + Generators Generaattorit + Generic Yleinen + Glass Lasi + Global Light Yleinen valo + Gloss Kiilto + Glow Amount Hehkun määrä + Glow Brightness Hehkun kirkkaus + Glow Size Hehkun koko + Glowing Edges Hehkuvat reunat + Good Hyvä + Grade Balance Sävytyksen tasapaino + Grade Blending Sävytyksen sekoitus + Grade Global Sävytys: yleinen + Grade Highlights Sävytys: korostukset + Grade Midtones Sävytys: keskisävyt + Grade Shadows Sävytys: varjot + Gradient Options Liukuvärin asetukset + Grain Amount Rakeen määrä + Grain Roughness Rakeen karheus + Grain Size Rakeen koko + Grain Type Rakeen tyyppi + Graininess Rakeisuus + Grainy Dots Rakeiset pisteet + Graphics adapter Näytönohjain + Graphics adapter: {adapter} Näytönohjain: {adapter} + Gray-gamma-2.2 Harmaa (gamma 2,2) + Greek Kreikka + Greens Vihreät + Grid (⌘') Ruudukko (⌘') + Grid view Ruudukkonäkymä + Group from Layers Ryhmä tasoista + Groups Ryhmät + Grout Width Saumalaastin leveys + Guide Layout… Apuviiva-asettelu… + HDR Toning HDR-sävytys + Hand Tool (hold) Käsityökalu (pidä painettuna) + Hearts Sydämet + Heptagon Seitsemänkulmio + Hexagon Kuusikulmio + Hidden Piilotettu + Hide Grid Piilota ruudukko + Hide Others Piilota muut + Hide the layer's effects (⌥-click: all layers) Piilota tason tehosteet (⌥-napsautus: kaikki tasot) + Hide {app} Piilota {app} + High Pass Ylipäästö + Highlight Korostus + Highlight Amount Korostusten määrä + Highlight Area Korostuksen alue + Highlight Radius Korostusten säde + Highlight Strength Korostuksen voimakkuus + Highlight Tone Korostusten sävyalue + Highlights from: Korostukset alkaen: + Horizontal Distortion Vaakasuuntainen vääristymä + Horizontal Only Vain vaakasuunnassa + Horizontal scale Vaakasuuntainen skaalaus + Horizontal type Vaakasuuntainen teksti + Hotspot Kuuma piste + Hsb HSB + Hsl HSL + Hsl Hue HSL-sävy + Hsl Lum HSL-luminanssi + Hsl Sat HSL-kylläisyys + Image Balance Kuvan tasapaino + Image Processor Kuvaprosessori + Image compositor: {processor} Kuvan koostin: {processor} + Import Brushes Tuo siveltimiä + Import Brushes… (.abr) Tuo siveltimiä… (.abr) + Import Image Tuo kuva + Import Options Tuontiasetukset + Import Photoshop brushes (.abr) Tuo Photoshop-siveltimiä (.abr) + Import Settings Tuo asetukset + Importing {what} from {file}… Tuodaan {what} tiedostosta {file}… + Importing… Tuodaan… + In Black Tulon musta + In White Tulon valkoinen + Increase Brush Hardness Lisää siveltimen kovuutta + Demosaic Mosaiikin purku + 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. Miten kameran värisuodatinmosaiikki täydennetään. Menetelmä otetaan käyttöön tiedostoa avattaessa; esikatselu näyttää menetelmän, jolla tiedosto avattiin. + AHD (adaptive homogeneity) AHD (adaptiivinen homogeenisuus) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: siisteimmät reunat ja vähiten värireunuksia, hitain. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradienttikorjattu interpolointi: terävä ja nopea, hienoissa yksityiskohdissa jonkin verran värireunuksia. + The fastest and the softest, with colour fringing on fine detail. Nopein ja pehmein, hienoissa yksityiskohdissa värireunuksia. + “{name}” already exists. Replace it? ”{name}” on jo olemassa. Korvataanko se? + The name in the save dialog was “{name}”. Tallennusikkunassa nimi oli ”{name}”. + Move Up Siirrä ylös + Move Down Siirrä alas + Previous Brush Edellinen sivellin + Next Brush Seuraava sivellin + First Brush Ensimmäinen sivellin + Last Brush Viimeinen sivellin + Preview size Esikatselun koko + Memory Usage Muistin käyttö + Per-document RAM limit (MB) Asiakirjakohtainen RAM-raja (Mt) + Installed RAM: {ram} MB; this limit: {percent}% Asennettu RAM: {ram} Mt; tämä raja: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Rajoittaa kunkin avoimen asiakirjan pikseleitä ja historiaa. Vanhimmat kumoamistilat hylätään, kun raja ylittyy. + Applies immediately when saved. Memory is not reserved in advance. Tulee voimaan heti, kun asetukset tallennetaan. Muistia ei varata etukäteen. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Kun GPU-muisti on asetettu automaattiseksi, tämä rajoittaa myös GPU:n tasobudjettia. Budjetin yläraja on neljäsosa asennetusta RAM-muistista ja, jos tiedossa, kolme kahdeksasosaa näytönohjaimen muistista, vähintään kuitenkin 512 Mt. Tämän rajan nostaminen näiden ylärajojen yli ei kasvata GPU-budjettia. + Command Komento + Use Käytä + In Front Edessä + Behind Takana + +# DDS Options (#1327) + DDS Options DDS-asetukset + Compression Pakkaus + Generate Mipmaps Luo mipmap-tasot + +# Color Table (.act) dialog + There is no color table to save. Tallennettavaa väritaulukkoa ei ole. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Tallennettu {n} väri tiedostoon {file}|Tallennettu {n} väriä tiedostoon {file} + +# Graphics Adapter preference (#1885) + GPU adapter Näytönohjain + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Tuo tyylejä… + Export Selected Styles… Vie valitut tyylit… + Export Styles… Vie tyylit… + {file} is not a style library (.asl) {file} ei ole tyylikirjasto (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Tuotu {n} tyyli tiedostosta {file}|Tuotu {n} tyyliä tiedostosta {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Viety {n} tyyli tiedostoon {file}|Viety {n} tyyliä tiedostoon {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Vahvista tai peruuta ensin avoinna oleva toimintovaiheen valintaikkuna + Pro Medium Gray Pro keskiharmaa + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro vaaleanharmaa + Pro Light Pro vaalea + Resize Windows to Fit Muuta ikkunoiden koko sopivaksi + EXR EXR + Increase Brush Size Suurenna siveltimen kokoa + Increase Type Size Suurenna tekstin kokoa + Increase Type Size by 10 Suurenna tekstin kokoa 10:llä + Indent first line Sisennä ensimmäinen rivi + Indent left margin Vasen sisennys + Indent right margin Oikea sisennys + Indexed Indeksoitu + Infinite Ääretön + Information Tiedot + Initial Direction Alkusuunta + Ink Outlines Musteääriviivat + Inner Bevel Sisäinen viiste + Input Tulo + Input Levels Tulotasot + Input Mode Syöttötila + Install Plug-in Asenna laajennus + Intensity Voimakkuus + Intent Muuntotapa + Interpret Footage Tulkitse kuvamateriaali + Intersect with selection (⇧⌥) Leikkaa valinnan kanssa (⇧⌥) + Interval Väli + Invalid panel layout; restored the default dock Virheellinen paneeliasettelu; oletustelakka palautettu + Invert Mask Käännä maski + Invert Selection Käännä valinta + Iris Blur Iiris-sumennus + Ivy Muratti + Jag Rosoisuus + Join the ArtCraft Discord Liity ArtCraftin Discordiin + Join the ArtCraft Discord ({url}) Liity ArtCraftin Discordiin ({url}) + Keeps the current texture when switching presets. Säilyttää nykyisen tekstuurin esiasetusta vaihdettaessa. + Kernel Ydin + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Parivälistys: metriikka, optinen tai arvo 1/1000 em (Alt+←/→ kohdistimessa) + Keyboard Näppäimistö + Lab Lab + Landscape, 10 x 8 Vaaka, 10 x 8 + Landscape, 6 x 4 Vaaka, 6 x 4 + Landscape, 7 x 5 Vaaka, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin, laajennettu A + Latin Extended-B Latin, laajennettu B + Layer Comps to Files Tasoasetelmat tiedostoiksi + Layer Content Options Tason sisällön asetukset + Layer Count Tasojen määrä + Layer From Background Taso taustasta + Layer Knocks Out Drop Shadow Taso peittää heittovarjon + Layer Mask Hides Effects Tasomaski piilottaa tehosteet + Layer Properties Tason ominaisuudet + Layer Styles Tasotyylit + Layer from Background Taso taustasta + Layers to Files Tasot tiedostoiksi + Layout Asettelu + Leading (set to the font size × auto-leading when Auto) Riviväli (Automaattinen-tilassa fonttikoko × automaattinen riviväli) + Leaf Color Lehden väri + Learn More Lisätietoja + Leaves Amount Lehtien määrä + Leaves Color Lehtien väri + Leaves Size Lehtien koko + Left Diagonal Vasen vinottainen + Legacy Vanha + Legal Legal + Lens Blur Linssisumennus + Lens Correction Objektiivin korjaus + Lens Flare Objektiivin heijastus + Less Vähemmän + Letter Letter + Level Based Kirkkauden mukaan + Library Kirjasto + Light Dark Balance Vaalea-tumma-tasapaino + Light Direction Valon suunta + Light Intensity Valon voimakkuus + Light Rough Vaalea karhea + Light Type Valotyyppi + Light X Valo X + Light Y Valo Y + Light Z Valo Z + Lighten Grout Vaalenna saumalaasti + Lighten Only Vain vaalennus + Lighting Valaistus + Lighting Effects Valaistustehosteet + Linear Dodge Lineaarinen vaalennus + Linear Height Lineaarinen korkeus + Linear-srgb Lineaarinen sRGB + Link Linkitä + Liquify Nesteytä + List Luettelo + List view Luettelonäkymä + Live Preview Reaaliaikainen esikatselu + Load 3D LUT Lataa 3D-LUT + Load Files into Stack Lataa tiedostot pinoon + Load path as a selection Lataa polku valintana + Loading… Ladataan… + Lock All Lukitse kaikki + Lock Image Pixels Lukitse kuvapikselit + Lock Position Lukitse sijainti + Lock Transparent Pixels Lukitse läpinäkyvät pikselit + Locked Lukittu + Long Lines Pitkät viivat + Long Strokes Pitkät vedot + Lorem Ipsum Lorem Ipsum + Lower Alempi + Lum Luminanssi + Lut LUT + Magentas Magentat + Make Selection from Path Luo valinta polusta + Make Selection… Luo valinta… + Make Symmetry Path Luo symmetriapolku + Make Work Path… Luo työpolku… + Make work path from selection Luo työpolku valinnasta + Manual Manuaalinen + Map Document Kartta-asiakirja + Map Layer Kartoita taso + Map Path Kartan polku + Mask Channel Maskikanava + Mask Incomplete Blocks Maskaa vajaat lohkot + Mask None Ei maskia + Masks Maskit + Match Color Täsmäytä värit + Math Symbols Matemaattiset symbolit + Max Offset Suurin siirtymä + Max Radius Suurin säde + Measurement Scale Mittakaava + Measurement Scale Marker Mittakaavan merkki + Medium Dots Keskikokoiset pisteet + Medium Gray Keskiharmaa + Medium Lines Keskipitkät viivat + Medium Strokes Keskipitkät vedot + Merge Channels… Yhdistä kanavat… + Merge Spot Channel Yhdistä spottivärikanava + Merge to HDR Pro Yhdistä HDR Pro -kuvaksi + Metallic Metallinen + Metrics Metriikka + Mezzotint Mezzotinto + Middle Keskellä + Midtone Keskisävy + Monitor Profile Näyttöprofiili + Monitor profile in use on {display}: {profile} Käytössä oleva näyttöprofiili näytöllä {display}: {profile} + Monitor profile in use: {profile} Käytössä oleva näyttöprofiili: {profile} + Monitor profile on {display} (main window): {profile} Näyttöprofiili näytöllä {display} (pääikkuna): {profile} + Monitor profile on {display}: {profile} Näyttöprofiili näytöllä {display}: {profile} + Monitor profile: {profile} Näytön profiili: {profile} + Mono Contrast Monokromikontrasti + Monochromatic Yksivärinen + More Lisää + Mosaic Mosaiikki + Mosaic Tiles Mosaiikkilaatat + Motion Blur Liikesumennus + Mouse Hiiri + Move Layer Siirrä tasoa + Movie Prime Elokuvaobjektiivi + Multiple Flames Along Path Useita liekkejä polkua pitkin + Multiple Flames One Direction Useita liekkejä yhteen suuntaan + Multiple Flames Path Directions Useita liekkejä polun suuntiin + Multiple Flames Various Length Useita eripituisia liekkejä + My Brushes Omat siveltimet + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Tiedostojen natiivia vedä ja pudota -toimintoa ei vielä tueta Waylandissa. Käytä komentoa Tiedosto › Avaa tai suorita PhotoCraft XWaylandissa komennolla `WAYLAND_DISPLAY= photocraft`. + Nearest Lähin + Neon Glow Neonhehku + Neutralize Neutraloi + Neutrals Neutraalit + New Brush Preset Uusi siveltimen esiasetus + New Channel… Uusi kanava… + New Guide Uusi apuviiva + New Guide Layout Uusi apuviiva-asettelu + New Spot Channel… Uusi spottivärikanava… + New Video Layer from File Uusi videotaso tiedostosta + New Window Uusi ikkuna + New Workspace Uusi työtila + New document Uusi asiakirja + New document… Uusi asiakirja… + New document… ⌘N Uusi asiakirja… ⌘N + Next Seuraava + No Document Ei asiakirjaa + No Image Ei kuvaa + No Layers Ei tasoja + No Results Ei tuloksia + No Results Found Tuloksia ei löytynyt + No Selection Ei valintaa + No custom shape "{name}": pick one in the options bar or Window › Shapes Mukautettua muotoa ”{name}” ei ole: valitse muoto asetuspalkista tai kohdasta Ikkuna › Muodot + No layers Ei tasoja + No recent files Ei viimeisimpiä tiedostoja + No selection Ei valintaa + Noise Color Kohinan väri + Noise Color Detail Kohinan värin yksityiskohdat + Noise Luminance Kohinan luminanssi + Noise Luminance Detail Kohinan luminanssin yksityiskohdat + Nothing to Redo Ei uudelleen tehtävää + Nothing to Undo Ei kumottavaa + Nothing to paste: the clipboard holds no image Ei liitettävää: leikepöydällä ei ole kuvaa + Number Of Levels Sävytasojen määrä + Numbers Numerot + Ocean Ripple Meren väreily + Octagon Kahdeksankulmio + Odd Fields Parittomat kentät + Oil Paint Öljymaalaus + On Päällä + One Flame Along Path Yksi liekki polkua pitkin + Open As Avaa muodossa + Open File Avaa tiedosto + Open Folder Avaa kansio + Open Image Avaa kuva + Open Recent is unavailable on the web Avaa viimeisimmät ei ole käytettävissä verkossa + Opened {url} Avattu {url} + Opening Avaus + Open… ⌘O Avaa… ⌘O + Operation canceled Toiminto peruttu + Operation cancelled Toiminto peruttu + Operation completed Toiminto valmis + Operation failed Toiminto epäonnistui + Optical Optinen + Original Ratio Alkuperäinen kuvasuhde + Out Black Lähdön musta + Out From Center Keskipisteestä ulospäin + Out Of Gamut Väriskaalan ulkopuolella + Out White Lähdön valkoinen + Out of memory Muisti loppui + Outer Bevel Ulkoinen viiste + Output Levels Lähtötasot + Oval Soikio + Overlay Edge Peitereuna + Overlay: Rule of Thirds Peite: kolmanneksen sääntö + PSD PSD + Package Paketti + Page Setup… Sivun asetukset… + Paint Daubs Maalitäplät + Paints from the document's opening state Maalaa asiakirjan avaustilasta + Palette Knife Palettiveitsi + Panel Paneeli + Paper Paperi + Paper Brightness Paperin kirkkaus + Parallel Rinnakkainen + Paste Complete Stroke Liitä koko reunaviiva + Paste Fill Liitä täyttö + Patchwork Tilkkutyö + Path Blur Polkusumennus + Paths to Illustrator Polut Illustratoriin + Pattern Fill… Kuviotäyttö… + Pattern Type Kuvion tyyppi + Pencil Width Lyijykynän leveys + Pentagon Viisikulmio + Percentage Prosenttiosuus + Permission denied Käyttöoikeus evätty + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft yrittää uudelleen; ennen onnistunutta tallennusta tehdyt asetusmuutokset menetetään, kun sovellus suljetaan. + Photomerge Photomerge + Pick Color From Image Poimi väri kuvasta + Picture Frame Kuvakehys + Pillow Emboss Tyynykohokuvio + Pinch Purista + Pins Nastat + Place Embedded Sijoita upotettuna + Place Linked Sijoita linkitettynä + Place Scale Marker Sijoita mittakaavamerkki + Plane Perspektiivitaso + Planes Perspektiivitasot + Plastic Wrap Muovikalvo + Platform: {os} {arch} Alusta: {os} {arch} + Please wait… Odota… + Point Piste + Point Curve Pistekäyrä + Point Curve Blue Pistekäyrä sininen + Point Curve Green Pistekäyrä vihreä + Point Curve Red Pistekäyrä punainen + Pointed Terävä + Pointillize Pointillismi + Polar Coordinates Napakoordinaatit + Polar To Rectangular Napakoordinaateista suorakulmaisiin + Policy Cmyk CMYK-käytäntö + Policy Gray Harmaasävykäytäntö + Policy Rgb RGB-käytäntö + Pond Ripples Lammen väreet + Portrait, 4 x 6 Pysty, 4 x 6 + Portrait, 5 x 7 Pysty, 5 x 7 + Postcard Postikortti + Poster Juliste + Poster Edges Julistereunat + Posterization Posterisointi + Preferences can't be saved Asetuksia ei voi tallentaa + Preferences saved Asetukset tallennettu + Preferences were reset: {error} Asetukset palautettiin oletuksiin: {error} + Preferences… Asetukset… + Preserve Säilytä + Preset Manager Esiasetusten hallinta + Press keys Paina näppäimiä + Preview Image Esikatselukuva + Previous Edellinen + Prime105 105 mm kiinteä + Prime35 35 mm kiinteä + Printed to {path} Tulostettu kohteeseen {path} + Pro (Dark) Pro (tumma) + Pro (Medium Gray) Pro (keskiharmaa) + Processing… Käsitellään… + Profile Profiili + Proof Setup… Koevedoksen asetukset… + Prophoto-compat ProPhoto-yhteensopiva + Pucker Kutista + Push Left Työnnä vasemmalle + Pyramids Pyramidit + Quick Actions Pikatoiminnot + Quit Lopeta + Quit PhotoCraft Lopeta PhotoCraft + Quit {app} Lopeta {app} + R G B R G B + R g b R g b + Radial Säteittäinen + Radial Blur Säteittäinen sumennus + Radius X Säde X + Radius Y Säde Y + Ran script {name} Komentosarja {name} suoritettu + Randomize Length Satunnaista pituus + Randomize Shapes Satunnaista muodot + Rasterize Fill Content Rasteroi täyttösisältö + Rasterize Smart Object Rasteroi älykäs objekti + Ratio Kuvasuhde + Raw Defaults Raw-oletukset + Reading Luetaan + Ready Valmis + Rec2020 Rec2020 + Recent Files Viimeisimmät tiedostot + Reconstruct All Rekonstruoi kaikki + Recovered {name} Palautettu {name} + Rectangular Selection Suorakulmainen valinta + Rectangular To Polar Suorakulmaisesta napakoordinaatteihin + Red Cyan Puna/syaani + Reds Punaiset + Reduce Color Noise Vähennä värikohinaa + Reduce Noise Vähennä kohinaa + Reflected Heijastettu + Relief Reliefi + Relink to File Linkitä uudelleen tiedostoon + Remove All Poista kaikki + Remove Jpeg Artifact Poista JPEG-artefaktit + Rename Brush Nimeä sivellin uudelleen + Rename Channel… Nimeä kanava uudelleen… + Rename Group Nimeä ryhmä uudelleen + Rename Tool Preset Nimeä työkalun esiasetus uudelleen + Render Video Renderöi video + Rendering… Renderöidään… + Reorder Layer Järjestä taso uudelleen + Replace Color Korvaa väri + Replace Contents Korvaa sisältö + Replace Footage Korvaa kuvamateriaali + Report a Bug Ilmoita virheestä + Report an Issue Ilmoita ongelmasta + Reset Alignment Palauta tasaus + Reset All Palauta kaikki + Reset Preferences Palauta oletusasetukset + Resolve Missing Fonts Korjaa puuttuvat fontit + Restart Käynnistä uudelleen + Restart Required Uudelleenkäynnistys tarvitaan + Resume Jatka + Retry Yritä uudelleen + Rgb RGB + Ridges Harjanteet + Right Diagonal Oikea vinottainen + Ripple Väreily + Ripple Magnitude Väreilyn voimakkuus + Ripple Size Väreilyn koko + Rope Köysi + Roses Ruusut + Rough Pastels Karkeat pastellit + Row Order Rivijärjestys + Rulers (⌘R) Viivaimet (⌘R) + Sample Size Näytteen koko + Sampled Colors Poimitut värit + Sandstone Hiekkakivi + Sat Kyll. + Save As Tallenna nimellä + Save Changes Tallenna muutokset + Save File Tallenna tiedosto + Save Image Tallenna kuva + Save Path Tallenna polku + Save Pixel Aspect Ratio Tallenna pikselin kuvasuhde + Save a Copy Tallenna kopio + Save before closing? Tallennetaanko ennen sulkemista? + Save for Web (Legacy) Tallenna verkkoa varten (vanha) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Tallenna työsi ja käynnistä PhotoCraft uudelleen, niin GPU-kiihdytystä yritetään uudelleen. CPU-renderöinti pysyy käytössä tämän istunnon ajan. + Saved a copy as {path} Kopio tallennettu nimellä {path} + Saved {path}; image assets: {n} in {dir} Tallennettu {path}; kuvaresurssit: {n} kansiossa {dir} + Saving… Tallennetaan… + Scale Effects Skaalaa tehosteet + Scaling Skaalaus + Scallops Simpukkareunus + Script Events Manager Komentosarjatapahtumien hallinta + Scroll menu down Vieritä valikkoa alas + Scroll menu up Vieritä valikkoa ylös + Search commands Etsi komentoja + Search commands (⌘K) Hae komentoja (⌘K) + Search commands, tools and panels Hae komentoja, työkaluja ja paneeleja + Search menus Hae valikoista + Seed Siemen + Select Custom Color… Valitse mukautettu väri… + Select Data Points Valitse datapisteet + Select Layer Valitse taso + Select and Mask Valitse ja maskaa + Color Aware Väritietoinen + Decontaminate Colors Puhdista värit + Edge Detection Reunantunnistus + Global Refinements Yleiset tarkennukset + High Quality Preview Laadukas esikatselu + Object Aware Objektitietoinen + Object Aware needs a segmentation model, which isn't available yet Objektitietoinen tila tarvitsee segmentointimallin, jota ei vielä ole saatavilla + Real-Time Refinement Reaaliaikainen tarkennus + Refine Edge Brush Tool Reunan tarkennussivellintyökalu + Refine Hair Tarkenna hiuksia + Refine Hair needs hair detection, which isn't available yet Tarkenna hiuksia tarvitsee hiusten tunnistuksen, jota ei vielä ole saatavilla + Refine Mode Tarkennustila + Select and Mask was closed: another layer was selected Valitse ja maskaa suljettiin: toinen taso valittiin + Select and Mask was closed: its document is no longer the active one Valitse ja maskaa suljettiin: sen asiakirja ei ole enää aktiivinen + View Mode Näkymätila + Selected at launch: {backend} Valittu käynnistettäessä: {backend} + SemiBold Puolilihavoitu + Sensitivity Herkkyys + Sent to the printer ({spooler}) Lähetetty tulostimelle ({spooler}) + Sepia Seepia + Services Palvelut + Set Colors Aseta värit + Set Measurement Scale Aseta mittakaava + Sgray sGray + Shadow Varjo + Shadow Amount Varjojen määrä + Shadow Intensity Varjon voimakkuus + Shadow Radius Varjojen säde + Shadow Tone Varjojen sävyalue + Shadows up to: Varjot enintään: + Shadows/Highlights Varjot/korostukset + Shallow Matala + Shape Blur Muotosumennus + Sharpen Amount Terävöityksen määrä + Sharpen Detail Terävöityksen yksityiskohta + Sharpen Details Terävöitä yksityiskohtia + Sharpen Masking Terävöityksen maskaus + Sharpen Radius Terävöityksen säde + Shear Vinouta + Shine Loiste + Short Lines Lyhyet viivat + Short Strokes Lyhyet vedot + Shortcut Pikanäppäin + Shortcuts Pikanäppäimet + Show All Näytä kaikki + Show Extras Options Näytä apuvälineiden asetukset + Show Guides Näytä apuviivat + Show Guides (⌘;) Näytä apuviivat (⌘;) + Show Rulers Näytä viivaimet + Show the layer's effects (⌥-click: all layers) Näytä tason tehosteet (⌥-napsautus: kaikki tasot) + Simple Yksinkertainen + Simulate Paper Simuloi paperia + Sine Siniaalto + Sketch Luonnos + Skip Ohita + Slice Options Viipaleen asetukset + Smart Blur Älykäs sumennus + Smart Sharpen Älykäs terävöitys + Smoothness Pehmeys + Smudge Stick Hankaustikku + Snap to Grid Tartu ruudukkoon + Snowflakes Lumihiutaleet + Soften Pehmennys + Softness Pehmeys + Software adapter Ohjelmistosovitin + Solarized Dark Solarized (tumma) + Solid Color Yhtenäinen väri + Solid Front Faces Umpinaiset etupinnat + Source Channel Lähdekanava + Source Invert Käänteinen lähde + Source Layer Lähdetaso + Source1 Channel Lähteen 1 kanava + Source1 Invert Käänteinen lähde 1 + Source2 Channel Lähteen 2 kanava + Source2 Invert Käänteinen lähde 2 + Source: set Lähde: asetettu + Space Välilyönti + Sparkle Kipinöinti + Spatter Roiske + Speckle Pilkutus + Speed Nopeus + Spherize Palloita + Spin Kierto + Spin Blur Pyörityssumennus + Split Channels Erota kanavat + Spot Pistemäinen + Spray Radius Ruiskutuksen säde + Sprayed Strokes Ruiskutetut vedot + Spread Levitys + Sprinkles Strösselit + Square Size Ruudun koko + Square, 12 x 12 Neliö, 12 x 12 + Square, 5 x 5 Neliö, 5 x 5 + Squareness Neliömäisyys + Stagger Porrastus + Stained Glass Lasimaalaus + Star Tähti + Stars Tähdet + Start X Alku X + Start Y Alku Y + Statistics Tilastot + Step {step} ({id}) failed: {error} Vaihe {step} ({id}) epäonnistui: {error} + Stippled Pisteytetty + Stops Pysäytyspisteet + Strength Voimakkuus + Stretch Venytä + Stroke Color Reunaviivan väri + Stroke Detail Vedon yksityiskohdat + Stroke Direction Vedon suunta + Stroke Length Vedon pituus + Stroke Pressure Vedon paine + Stroke Size Vedon koko + Stroke Width Vedon leveys + Stroke path with brush Piirrä polun reunaviiva siveltimellä + Strokes Vedot + Studio (Dark) Studio (tumma) + Studio (Light) Studio (vaalea) + Stylization Stilisointi + Subtract Shapes at Overlap Vähennä muodot päällekkäisyydessä + Subtract from selection (⌥) Vähennä valinnasta (⌥) + Sumi-e Sumi-e + Surface Blur Pintasumennus + Switch Foreground and Background Colors Vaihda edusta- ja taustaväri keskenään + System Info Järjestelmän tiedot + Tablet Piirtopöytä + Tabloid Tabloid + Taper Kaventuminen + Target Kohde + Target Channel 3 Valitse kohteeksi kanava 3 + Target Channel 4 Valitse kohteeksi kanava 4 + Target Channel 5 Valitse kohteeksi kanava 5 + Target Channel 6 Valitse kohteeksi kanava 6 + Target Channel 7 Valitse kohteeksi kanava 7 + Target Channel 8 Valitse kohteeksi kanava 8 + Target Channel 9 Valitse kohteeksi kanava 9 + Target Composite Channel Valitse kohteeksi yhdistelmäkanava + Target X Kohde X + Target Y Kohde Y + Teal Orange Sinivihreä ja oranssi + Technology Previews… Teknologian esikatselut… + Temporary Väliaikainen + Text Layer Tekstitaso + Texture Coverage Tekstuurin peitto + Texturizer Tekstuurisoija + Thaw Mask Sulatusmaski + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Kangas näytetään sRGB-muodossa. Valitse näyttöprofiili kohdassa Muokkaa › Väriasetukset. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Näytön {display} kangas näytetään sRGB-muodossa. Valitse näyttöprofiili kohdassa Muokkaa › Väriasetukset. + The current gradient (pick one in Window › Gradients) Nykyinen liukuväri (valitse kohdassa Ikkuna › Liukuvärit) + The document does not have an embedded colour profile. Working space: {working}. Asiakirjassa ei ole upotettua väriprofiilia. Työväriavaruus: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Asiakirjan upotettu väriprofiili ei vastaa työväriavaruutta.\nUpotettu: {embedded}\nTyöväriavaruus: {working} + The file could not be opened. Tiedostoa ei voitu avata. + The file could not be saved. Tiedostoa ei voitu tallentaa. + Thickness Paksuus + This Layer: Tämä taso: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Tämä älykäs objekti on rasteroitava ennen jatkamista. Sen sisältöä ei voi enää muokata. Rasteroidaanko älykäs objekti? + This adjustment ({key}) is kept as imported and can't be edited yet. Tämä säätö ({key}) säilytetään tuotuna, eikä sitä voi vielä muokata. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Tämä täyttötaso on rasteroitava ennen jatkamista. Sen täyttösisältöä ei voi enää muokata. Rasteroidaanko taso? + This layer must be rasterized before proceeding. Tämä taso on rasteroitava ennen jatkamista. + This session: {reason} Tämä istunto: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Tämä muototaso on rasteroitava ennen jatkamista. Sitä ei voi enää muokata muotona. Rasteroidaanko muoto? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Tämä tekstitaso on rasteroitava ennen jatkamista. Sen tekstiä ei voi enää muokata. Rasteroidaanko teksti? + Tile Size Laatan koko + Tiles Laatat + Tilt-Shift Kallistus ja siirto + Tint Color Sävytysväri + Tiny Lens Pieni linssi + Title Otsikko + To Loppuväri + To Center Keskipistettä kohti + Toggle Layer Mask Overlay Näytä/piilota tasomaskin peite + Toggle the Brush Settings panel Näytä tai piilota Siveltimen asetukset -paneeli + Toggle the Brush Settings panel (F5) Näytä tai piilota Siveltimen asetukset -paneeli (F5) + Tool Options Työkalun asetukset + Trace Contour Jäljitä ääriviiva + Tracking (1/1000 em) Kirjainväli (1/1000 em) + Transformation Muunnos + Transition Siirtymä + Transparency Shapes Layer Läpinäkyvyys muotoilee tason + Trap Trappaus + Tree Puu + Trim Karsi reunat + Turbulent Pyörteinen + Twirl Pyörre + Twirl Clockwise Pyöritä myötäpäivään + Twirl Counterclockwise Pyöritä vastapäivään + Type Options Tekstiasetukset + Unaltered Muuttamaton + Undefined Areas Määrittelemättömät alueet + Underlying Layer: Alla oleva taso: + Underpainting Pohjamaalaus + Underwater Vedenalainen + Ungroup Pura ryhmitys + Uniform Tasainen + Unite Shapes at Overlap Yhdistä muodot päällekkäisyydessä + Unlink Poista linkitys + Unlock Avaa lukitus + Unlocked Lukitsematon + Unsaved Changes Tallentamattomat muutokset + Unsharp Mask Epäterävä maski + Unsupported file format Tiedostomuotoa ei tueta + Unsupported format Muotoa ei tueta + Up Ylös + Update Päivitä + Updates Päivitykset + Upper Ylempi + Use Custom Color Käytä mukautettua väriä + Use File › Open to open an image. Avaa kuva komennolla Tiedosto › Avaa. + Use Selection In Source Käytä valintaa lähteessä + Use Selection In Target Käytä valintaa kohteessa + Uses the foreground and background colors. Käyttää edusta- ja taustaväriä. + Vanishing Point Pakopiste + Variable Data Sets Muuttuvat tietojoukot + Vector Mask Hides Effects Vektorimaski piilottaa tehosteet + Vertical Distortion Pystysuuntainen vääristymä + Vertical Only Vain pystysuunnassa + Vertical Type Tool Pystytekstityökalu + Vertical scale Pystysuuntainen skaalaus + Vertical type Pystysuuntainen teksti + Video Frames to Layers Videoruudut tasoiksi + Vignette Amount Vinjetoinnin määrä + Vignette Feather Vinjetin reunojen pehmennys + Vignette Highlights Vinjetin korostukset + Vignette Midpoint Vinjetoinnin keskikohta + Vignette Roundness Vinjetin pyöreys + Vignette Style Vinjetin tyyli + Vine Color Köynnöksen väri + Vine With Flowers Kukkiva köynnös + Vine With Leaves Lehtevä köynnös + Violent Raju + W: L: + WIA Support WIA-tuki + Warm Lämmin + Warming Filter (85) Lämmityssuodatin (85) + Warming L B A Lämmittävä LBA + Warming81 Lämmittävä 81 + Warming85 Lämmittävä 85 + Warmth Lämpimyys + Warning Varoitus + Warp Text Taivuta tekstiä + Watercolor Vesiväri + Watercolour look: lighter interior, darker rims. Vesiväriefekti: vaaleampi sisus, tummemmat reunat. + Wavelength Max Aallonpituus max + Wavelength Min Aallonpituus min + Waves Aallot + Web Large Verkko, suuri + Web Minimum Verkko, pienin + Web Most Common Verkko, yleisin + WebP (lossless) WebP (häviötön) + Wetness Märkyys + White Clip Valkoisen leikkaus + White Intensity Valkoisen voimakkuus + White Is High Valkoinen on korkea + Whole Word Vain kokonaiset sanat + Wide Blurry Leveä sumea + Wide Sharp Leveä terävä + Wind Tuuli + Window renderer: {renderer} Ikkunan renderöijä: {renderer} + Working Työavaruus + Working Cmyk Työ-CMYK + Working Gray Työ-harmaa + Working Rgb Työ-RGB + Working-cmyk Työ-CMYK + Workspaces Työtilat + Wrap Kierrä ympäri + Yellows Keltaiset + ZigZag Siksak + Zigzag Siksak + Zoom In (hold, drag to scrub) Lähennä (pidä painettuna, vedä zoomataksesi) + Zoom Out (hold) Loitonna (pidä painettuna) + auto automaattinen + blue sininen + brushes siveltimet + fallback varajärjestelmä + gradients liukuvärit + gray harmaa + green vihreä + layer mask tasomaski + manual manuaalinen + none ei mitään + off pois + on päällä + orange oranssi + previous reading kept; the last re-read failed: {reason} edellinen lukema säilytetty; viimeisin uudelleenluku epäonnistui: {reason} + red punainen + unknown tuntematon + unknown type tuntematon tyyppi + vector mask vektorimaski + violet violetti + yellow keltainen + {index} of {total} {index} / {total} + {label} is not available: {reason} {label} ei ole käytettävissä: {reason} + {message} (notes: {n}; {warning}) {message} (huomautuksia: {n}; {warning}) + {shortcut} assigned. {shortcut} määritetty. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} on jo käytössä komennolla {commands}, ja se poistetaan siltä, kun napsautat OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ siirry ↵ suorita esc sulje + ✓ Color Indicates Masked Areas ✓ Väri osoittaa maskatut alueet + ✓ Color Indicates Selected Areas ✓ Väri osoittaa valitut alueet +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Tyhjennä +@id edit.purge.undo Kumoa +@id edit.stroke Piirrä reunaviiva… +@id edit.transform.scale Skaalaa +@id file.close Sulje +@id filter.sharpen.sharpen Terävöitä +@id layer.layerMask.apply Käytä tasomaskia +@id select.all Valitse kaikki +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Tuotiin {n} sivellin tiedostosta {file}|Tuotiin {n} sivellintä tiedostosta {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Tuotiin {n} liukuväri tiedostosta {file}|Tuotiin {n} liukuväriä tiedostosta {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Tuotiin {n} kuvio tiedostosta {file}|Tuotiin {n} kuviota tiedostosta {file} +cameraRaw Balance Tasapaino +cameraRaw Clarity Selkeys +cameraRaw Color Väri +cameraRaw Color Mixer Värisekoitin +cameraRaw Contrast Kontrasti +cameraRaw Curve Käyrä +cameraRaw Dehaze Usvanpoisto +cameraRaw Detail Yksityiskohdat +cameraRaw Effects Tehosteet +cameraRaw Exposure Valotus +cameraRaw Feather Pehmennys +cameraRaw Highlights Korostukset +cameraRaw Luminance Luminanssi +cameraRaw Masking Maskaus +cameraRaw Midpoint Keskipiste +cameraRaw Midtones Keskisävyt +cameraRaw Radius Säde +cameraRaw Roundness Pyöreys +cameraRaw Saturation Värikylläisyys +cameraRaw Shadows Varjot +cameraRaw Size Koko +cameraRaw Temperature Lämpötila +cameraRaw Texture Tekstuuri +cameraRaw Tint Värisävy +cameraRaw Whites Valkoiset +colorRange Invert Käänteinen +document Orientation Suunta +layerLabel Magenta Magenta +layerLabel No Color Ei väriä +layerLabel Red Punainen +layers Fill Täyttö +layers Fill: Täyttö: +navigator Zoom Zoomaus +shape Appearance Ulkoasu +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automaattinen kappaleen suunta + Left-to-right paragraph direction Kappaleen suunta vasemmalta oikealle + Right-to-left paragraph direction Kappaleen suunta oikealta vasemmalle + Not rendered: this adjustment ({key}) is kept as imported. Ei renderöidä: tämä säätö ({key}) säilytetään tuodussa muodossa. +# Print preview image (#8236) + Print preview image Tulostuksen esikatselukuva +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Sävykuutio + Brightness Cube Kirkkauskuutio + Color Wheel Väriympyrä + RGB Sliders RGB-liukusäätimet + HSB Sliders HSB-liukusäätimet + CMYK Sliders CMYK-liukusäätimet + Lab Sliders Lab-liukusäätimet + Web Color Sliders Verkkovärien liukusäätimet + Copy Color as HTML Kopioi väri HTML-muodossa + Copy Color's Hex Code Kopioi värin heksakoodi + RGB Spectrum RGB-spektri + CMYK Spectrum CMYK-spektri + Grayscale Ramp Harmaasävyliukuma + Current Colors Nykyiset värit + Make Ramp Web Safe Rajoita liukuma verkkoturvallisiin väreihin +# Art History Brush (#213). + Art History Brush Tool Taidehistoriasivellintyökalu + Tight Short Tiivis lyhyt + Tight Medium Tiivis keskipitkä + Tight Long Tiivis pitkä + Loose Medium Väljä keskipitkä + Loose Long Väljä pitkä + Dab Täplä + Tight Curl Tiivis kiehkura + Loose Curl Väljä kiehkura + Stylized dabs from a history state Tyyliteltyjä täpliä historiatilasta + Font size presets Fonttikoon esiasetukset + Leading presets Rivivälin esiasetukset + Tracking presets Kirjainvälin esiasetukset + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering Suoritinpiirto + Calculating… Lasketaan… + Click to refresh histogram with uncached data Päivitä histogrammi ilman välimuistia napsauttamalla + Convert to Shape… Muunna muodoksi… + Cooling Filter (80) Viilennyssuodatin (80) + Cooling Filter (82) Viilennyssuodatin (82) + Cooling Filter (LBB) Viilennyssuodatin (LBB) + Count: Määrä: + Delete Hidden Layers Poista piilotetut tasot + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Lisää rajoite vetämällä kuvassa; Shift vaaka-/pystysuunnalle; poista napsauttamalla hiiren kakkospainikkeella. + Drawn on the CPU: {reason}. Click for System Info. Piirretty suorittimella: {reason}. Avaa järjestelmän tiedot napsauttamalla. + Exported {n} files Vietiin {n} tiedostoa + Fit Layer on Screen Sovita taso näyttöön + Flow: Virtaus: + Level: Sävytaso: + Percentile: Prosenttipiste: + Refine Edge… Tarkenna reunaa… + Remember Settings Muista asetukset + Reset Essentials Nollaa Perusteet + Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu + Show Channels in Color Näytä kanavat värillisinä + Show Statistics Näytä tilastot + Source: Lähde: + Status Bar Tilarivi + Tile Horizontally Allekkain + Tile Vertically Rinnakkain + Uncached Refresh Päivitä ilman välimuistia + Warming Filter (81) Lämmityssuodatin (81) + Warming Filter (LBA) Lämmityssuodatin (LBA) +@plural {n} document|{n} documents {n} asiakirja|{n} asiakirjaa +@plural {n} pixel|{n} pixels {n} pikseli|{n} pikseliä +cameraRaw Before (Y) Ennen (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw -suodatin ({layer}) +cameraRaw Camera Raw isn't open Camera Raw ei ole auki +cameraRaw Cancel Peruuta +cameraRaw Cancelled opening {name} Tiedoston {name} avaaminen peruutettu +cameraRaw Cancelled {label} Peruutettu: {label} +cameraRaw Color Priority Väriprioriteetti +cameraRaw Developing {name} Kehitetään tiedostoa {name} +cameraRaw Full-resolution preview unavailable Täyden resoluution esikatselu ei ole käytettävissä +cameraRaw Grain Rakeisuus +cameraRaw Highlight Clipping Warning (O) Korostusten leikkautumisvaroitus (O) +cameraRaw Highlight Priority Korostusten prioriteetti +cameraRaw Load Settings… Lataa asetukset… +cameraRaw Load settings from a Camera Raw .xmp file Lataa asetukset Camera Raw .xmp -tiedostosta +cameraRaw Loaded Camera Raw settings from {file} Camera Raw -asetukset ladattu tiedostosta {file} +cameraRaw Noise Reduction Kohinan vaimennus +cameraRaw OK OK +cameraRaw Open Avaa +cameraRaw Opened {name} Avattu {name} +cameraRaw Paint Overlay Maalipeite +cameraRaw Save Settings… Tallenna asetukset… +cameraRaw Save these settings as a Camera Raw .xmp file Tallenna nämä asetukset Camera Raw .xmp -tiedostona +cameraRaw Shadow Clipping Warning (U) Varjojen leikkautumisvaroitus (U) +cameraRaw Sharpening Terävöitys +cameraRaw Show Lab Color Readouts Näytä Lab-väriarvot +cameraRaw Show Red at 3 o'clock Näytä punainen kello kolmen kohdalla +cameraRaw Show Selected Region Näytä valittu alue +cameraRaw Show Skin Tone Indicator Näytä ihonsävyn osoitin +cameraRaw Show Vectorscope Näytä vektoriskooppi +cameraRaw Tone Histogram Sävyhistogrammi +cameraRaw Vignetting Vinjetointi +cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen + Current Layer: Nykyinen taso: + Delete effect Poista tehoste + Move effect down Siirrä tehostetta alas + Move effect up Siirrä tehostetta ylös + Path operations Polkutoiminnot + The path was discarded Polku hylättiin + The path was kept as the Work Path Polku säilytettiin työpolkuna + Edit Brush… Muokkaa sivellintä… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3bad30d474..89bdec45d4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -20,18 +20,112 @@ # 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 : - Solidity: Solidité : - Ink Characteristics Caractéristiques de l’encre # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). + +# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) + +# Camera Raw scopes and the Patch tool + +# Warp grid presets (#388) + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + Mask Options… Options de masque… + Layer Mask Display Options… Options d'affichage du masque de calque… + Channel Color Couleur de la couche + Color Indicates: La couleur indique : + Solidity: Solidité : + Ink Characteristics Caractéristiques de l’encre Paper Width Largeur du papier Paper Height Hauteur du papier Rows Lignes @@ -50,7 +144,6 @@ 5 Columns, 3 Rows 5 colonnes, 3 lignes 8 Columns 8 colonnes 12 Columns 12 colonnes - File Fichier Edit Édition Image Image @@ -442,6 +535,7 @@ Intersect Shape Areas Intersection des zones de formes Exclude Overlapping Shapes Exclure les formes superposées Merge Shape Components Fusionner les composants de forme + Path operations Opérations sur les tracés Align Aligner Top Edges Bords supérieurs Vertical Centers Centres verticaux @@ -521,7 +615,6 @@ Color Range… Plage de couleurs… Focus Area… Zone de mise au point… Select and Mask… Sélectionner et masquer… -# Select and Mask dialog labels Smart Radius Rayon dynamique Shift Edge Décaler le contour Decontaminate Décontaminer @@ -548,7 +641,6 @@ Blur Flou Blur Gallery Galerie de flous Field Blur… Flou de champ… -# Field Blur pins (#3373) No pin selected Aucune épingle sélectionnée Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Faites glisser les poignées du contour pour le redimensionner et le faire pivoter, le carré pour arrondir ses angles et les points intérieurs pour définir où commence le flou. @@ -827,6 +919,9 @@ Add Layer Mask Ajouter un masque de calque Add Stop Ajouter un point Add a layer style Ajouter un style de calque + Move effect up Monter l'effet + Move effect down Descendre l'effet + Delete effect Supprimer l'effet Add a mask (from the selection; {key} inverts) Ajouter un masque (d'après la sélection ; {key} l'inverse) Add an adjustment Ajouter un ajustement Add disk Ajouter un disque @@ -1051,8 +1146,6 @@ Delete current channel Supprimer le canal actif Delete current state Supprimer l'état actuel Delete effect layer Supprimer le calque d'effet - -# Layers-panel trash confirmation Delete layer ‘{name}’? Supprimer le calque « {name} » ? Delete {count} layers? {names} Supprimer {count} calques ? {names} Delete layer? Supprimer le calque ? @@ -1061,7 +1154,6 @@ Missing delete command Commande de suppression manquante Invalid delete command Commande de suppression non valide Layer deleted, but confirmation preference could not be saved: {error} Le calque a été supprimé, mais la préférence de confirmation n’a pas pu être enregistrée : {error} - Delete layer Supprimer le calque Delete layer comp Supprimer la composition de calques Delete note Supprimer la note @@ -2152,7 +2244,6 @@ Align with layer Aligner sur le calque Reset Alignment Réinitialiser l'alignement Centre the gradient (offset 0, 0) Centrer le dégradé (décalage 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Éditeur de dégradé Gradient picker Sélecteur de dégradé Save As Preset Enregistrer comme préreglage @@ -2662,8 +2753,6 @@ Expand group (⌥-click: all groups) Développer le groupe (⌥-clic : tous les groupes) Hide the layer's effects (⌥-click: all layers) Masquer les effets du calque (⌥-clic : tous les calques) Show the layer's effects (⌥-click: all layers) Afficher les effets du calque (⌥-clic : tous les calques) - -# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). New from Clipboard Créer depuis le presse-papiers Free Transform a Copy Transformer librement une copie Transform Again on a Copy Répéter la transformation sur une copie @@ -2673,8 +2762,6 @@ Select Bottom Layer Sélectionner le dernier calque Add Layer Above to Selection Ajouter le calque au-dessus à la sélection Add Layer Below to Selection Ajouter le calque au-dessous à la sélection - -# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) 8 bit 8 bits Integer Entier 16 bit 16 bits @@ -2710,8 +2797,6 @@ GPU Backend Moteur GPU Details Détails Rendering mode Mode de rendu - -# Camera Raw scopes and the Patch tool Shadow Clipping Warning (U) Avertissement d'écrêtage des tons foncés (U) Highlight Clipping Warning (O) Avertissement d'écrêtage des tons clairs (O) Show Lab Color Readouts Afficher les valeurs de couleur Lab @@ -2736,21 +2821,15 @@ cameraRaw Blending Fusion Transform On Drop Transformer au dépôt Scale or rotate the moved area, then press {key} Mettez à l'échelle ou faites pivoter la zone déplacée, puis appuyez sur {key} Now drag the selection onto another area Faites maintenant glisser la sélection vers une autre zone - -# Warp grid presets (#388) 3 x 3 3 × 3 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Grille : Edit Type Modifier le texte - - New Style… Nouveau style… Style {name} saved Style {name} enregistré - Create new style (from the selected type) Créer un nouveau style (à partir du texte sélectionné) Create new style from the selected layer Créer un nouveau style à partir du calque sélectionné - Make Default Définir par défaut Reset to Default Rétablir par défaut Full-resolution preview unavailable Aperçu en pleine résolution indisponible @@ -2779,9 +2858,6 @@ cameraRaw Blending Fusion Magnetic Lasso Tool Outil Lasso magnétique Frequency Fréquence Use tablet pressure to change pen width Utiliser la pression de la tablette pour modifier la largeur de la plume - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About À propos Contributors Contributeurs Models Modèles @@ -2812,8 +2888,6 @@ cameraRaw Blending Fusion % of all commits % de tous les commits Lines +/− Lignes +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{binAdded} / −{binDeleted} fichiers binaires, {first} – {last} - -# Swatches panel Create new swatch Créer une nouvelle nuance New Swatch… Nouvelle nuance… New Swatch Group… Nouveau groupe de nuances… @@ -2823,13 +2897,12 @@ cameraRaw Blending Fusion Export Swatches for Exchange… Exporter les nuances pour l'échange… Reset Swatches Réinitialiser les nuances Add to Swatches Ajouter au nuancier -# Layer Style > Blending Options (Advanced Blending) Blend If: Fusion si : + Current Layer: Calque actif : + Underlying Layer: Calque sous-jacent : Channels: Couches : Knockout Masquage Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Sélecteur de couleurs (Couleur du texte) Color Picker ({name}) Sélecteur de couleurs ({name}) Color Picker (Fill Color) Sélecteur de couleurs (Couleur de remplissage) @@ -2846,8 +2919,6 @@ cameraRaw Blending Fusion Show/Hide Panels Afficher/masquer les panneaux Could not open Impossible d’ouvrir {path}: the path is not valid Unicode; rename the file and open it again. {path} : le chemin n’est pas un Unicode valide ; renommez le fichier puis rouvrez-le. - -# Fill dialog, layer style contours and Image Size Contents: Contenu : Custom Pattern: Motif personnalisé : Foreground Color Couleur de premier plan @@ -2874,14 +2945,12 @@ cameraRaw Blending Fusion Rolling Slope - Descending Pente ondulée - descendante Rounded Steps Paliers arrondis Sawtooth 1 Dents de scie 1 - Red Eye Tool Outil Yeux rouges Pupil Size Taille de la pupille Darken Amount Quantité d'assombrissement Click a red pupil to neutralize it Cliquez sur une pupille rouge pour la corriger open source code source ouvert …and {n} more …et {n} de plus - Eyedroppers: Pipettes : Set Black Point Définir le point noir Set Neutral Gray Point Définir le point gris neutre @@ -2890,8 +2959,6 @@ cameraRaw Blending Fusion Selection Preview: Aperçu de la sélection : Black Matte Fond noir White Matte Fond blanc - -# Layer colour labels. Seafoam Écume de mer Indigo Indigo Fuchsia Fuchsia @@ -2911,20 +2978,14 @@ fontWeight Bold Gras fontWeight ExtraBold Extra-gras fontWeight Black Noir fontWeight Italic Italique - -# Pattern Stamp tool Pattern Stamp Tool Outil Tampon de motif Impressionist Impressionniste Rotate View Tool Outil Rotation de la vue Reset View Réinitialiser la vue Drag around the centre to rotate the view · Shift constrains to 15° Faites glisser autour du centre pour tourner la vue · Maj contraint à 15° - -# Painting symmetry presets. Dual Axis Deux axes Diagonal Diagonale Transform Symmetry Transformer la symétrie - -# Eyedropper Sample Size and Sample (#1649). Point Sample Échantillon ponctuel 3 by 3 Average Moyenne 3 × 3 5 by 5 Average Moyenne 5 × 5 @@ -2936,15 +2997,12 @@ fontWeight Italic Italique All Layers No Adjustments Tous les calques sans ajustements Current & Below No Adjustments Actif et inférieurs sans ajustements Show Sampling Ring Afficher l'anneau d'échantillonnage - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche Color to Alpha… Couleur vers alpha… Transparency Threshold Seuil de transparence Opacity Threshold Seuil d'opacité PhotoCraft — Screen Color Picker PhotoCraft — Sélecteur de couleur d'écran - Rasterize svg groups deeper than Pixelliser les groupes SVG au-delà de cette profondeur 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 désactive ce seuil ; les groupes SVG imbriqués sur plus de 100 niveaux sont toujours pixellisés. Rule of Thirds Règle des tiers @@ -2958,8 +3016,6 @@ fontWeight Italic Italique Cycle Orientation Changer d’orientation Set the overlay options for the Crop tool Définir les options de superposition de l’outil Recadrage 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. L’Alt-clic a été intercepté par le bureau, qui déplace les fenêtres avec Alt-glisser. Réglez sa touche de déplacement des fenêtres sur Super, ou verrouillez Alt dans Fenêtre › Touches de modification. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Sélecteur de couleurs (couleur du style de calque) Auto Hide Notifications Masquer automatiquement les notifications Notification Duration (seconds) Durée de la notification (secondes) @@ -3035,7 +3091,6 @@ fontWeight Italic Italique Appearance Settings… Réglages d’apparence… Add to favorites Ajouter aux favoris Remove from favorites Retirer des favoris -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Marque cameraData Model Modèle cameraData Date Taken Date de prise de vue @@ -3045,7 +3100,6 @@ cameraData ISO Speed Sensibilité ISO cameraData Focal Length Focale cameraData Focal Length (35 mm) Focale (35 mm) cameraData Lens Objectif -# Shared panel docking Activate Panel Activer le panneau Change Panel Group Modifier le groupe de panneaux Change Panel Layout Modifier la disposition des panneaux @@ -3089,7 +3143,6 @@ cameraData Lens Objectif Snap to Origin Aligner sur l'origine Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant -# Edit › Content-Aware Fill workspace Sampling Brush Tool Outil Pinceau d’échantillonnage Sampling Area Overlay Incrustation de la zone d’échantillonnage Show Sampling Area Afficher la zone d’échantillonnage @@ -3107,7 +3160,6 @@ 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 @@ -3125,8 +3177,6 @@ cameraData Lens Objectif 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) @@ -3159,7 +3209,6 @@ curves Smooth Lisser Fix the red plane first: its corners must form a convex quad Corrigez d’abord le plan rouge : ses coins doivent former un quadrilatère convexe Alt-click a plane to set the clone source Alt-clic sur un plan pour définir la source de duplication Too many clone strokes: apply Vanishing Point first Trop de traits de duplication : appliquez d’abord Point de fuite -# Select and Mask view modes (#1754) Onion Skin Pelure d'oignon Marching Ants Fourmis en marche On Black Sur fond noir @@ -3168,7 +3217,6 @@ curves Smooth Lisser Show Edge Afficher le bord Show Original Afficher l'original F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Pinceau d'amélioration du contour (R) Expand Detection Area Étendre la zone de détection Restore Original Edge Restaurer le contour d'origine @@ -3176,19 +3224,13 @@ curves Smooth Lisser Clear Brush Strokes Effacer les traits de pinceau Show rulers Afficher les règles Presentation Présentation - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Autoriser l'historique non linéaire Make Layer Visibility Changes Undoable Permettre d'annuler les changements de visibilité des calques -# Print dialog (#2697) Emulsion Down Émulsion verso Functions Fonctions Print Resolution: Résolution d’impression : @@ -3200,7 +3242,6 @@ curves Smooth Lisser Apply mask to layer before removing? Appliquer le masque au calque avant de le supprimer ? The layer or its mask changed. Drag the mask onto the Trash again. Le calque ou son masque a changé. Faites de nouveau glisser le masque sur la corbeille. Delete the vector mask? Supprimer le masque vectoriel ? -# View › Guides › New Guide… › Color Light Blue Bleu clair Light Red Rouge clair Medium Blue Bleu moyen @@ -3221,8 +3262,6 @@ curves Smooth Lisser Clear Brush Controls Effacer les commandes de pinceau Reset All Locked Settings Réinitialiser tous les paramètres verrouillés Copy Texture to Other Tools Copier la texture dans d'autres outils - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Charger des actions… Save Actions… Enregistrer les actions… New Action… Nouvelle action… @@ -3234,10 +3273,11 @@ curves Smooth Lisser @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} étape n’a pas encore d’équivalent dans PhotoCraft|{n} étapes n’ont pas encore d’équivalent dans PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises - +@plural {n} action file loaded|{n} action files loaded {n} fichier d'actions chargé|{n} fichiers d'actions chargés + {set} from {file} replaced the one from {earlier} Le jeu {set} de {file} a remplacé celui de {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fichier n'a pas pu être chargé : {errors}|{n} fichiers n'ont pas pu être chargés : {errors} File Naming Dénomination des fichiers Starting serial Numéro de série initial -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Insérer un arrêt… Save Set… Enregistrer l’ensemble… Message: Message : @@ -3249,17 +3289,15 @@ curves Smooth Lisser @plural {n} action|{n} actions {n} action|{n} actions Background (Default) Arrière-plan (par défaut) Content-Aware Fill Remplissage d'après le contenu -# Batch / Image Processor Include All Subfolders Inclure tous les sous-dossiers Auto Add/Delete Ajout/suppr. auto - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Ne cochez pas aussi Image miroir dans les paramètres d’impression : les deux inversions s’annulent. Print Settings… Paramètres d’impression… Remember to turn off the printer's own colour management in Print Settings… Pensez à désactiver la gestion des couleurs de l’imprimante dans les paramètres d’impression… 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 + Memory usage percent Utilisation de la mémoire (%) Show Favorite Fonts Afficher les polices favorites No favorite fonts yet: click the star beside a font to add it. Aucune police favorite pour l’instant : cliquez sur l’étoile à côté d’une police pour l’ajouter. Favorite fonts Polices favorites @@ -3298,48 +3336,31 @@ curves Smooth Lisser Could not write {name}: {error} Impossible d’écrire {name} : {error} Kept {name}: written by another application {name} conservé : écrit par une autre application Shadow Tint Teinte des ombres -# PhotoCraft release checks. Check for Updates… Rechercher des mises à jour… - PhotoCraft Updates Mises à jour de PhotoCraft - Checking for updates… Recherche de mises à jour… Open the release page to choose an installer for your system. Ouvrez la page de la version pour choisir un programme d’installation pour votre système. - Open Download Page Ouvrir la page de téléchargement - PhotoCraft is up to date. PhotoCraft est à jour. - Could not check for updates. Impossible de rechercher des mises à jour. - Updates have not been checked yet. Les mises à jour n’ont pas encore été vérifiées. - Check Again Vérifier à nouveau Automatically Check for Updates Rechercher automatiquement des mises à jour - Installed version: {version} Version installée : {version} - PhotoCraft {version} is available. PhotoCraft {version} est disponible. -# 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}) Remember this choice for future images Mémoriser ce choix pour les prochaines images Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Réactivez la question avec Ask On Mismatch dans Édition › Paramètres de couleur. Button Mode Mode bouton Button Mode On Mode bouton activé - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Assombrir l'interface Lighten Interface Éclaircir l'interface -# Bevel & Emboss Technique. Technique Technique 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 + Save Full-Size Composite in .pcraft Files Enregistrer l’image composite en taille réelle dans les fichiers .pcraft Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés Bicubic Automatic Bicubique automatique @@ -3365,8 +3386,6 @@ 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 @@ -3385,10 +3404,8 @@ curves Smooth Lisser Undo Changes Annuler les modifications {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée. The transform couldn't be applied. Impossible d'appliquer la transformation. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir, au plus 3/8 de sa mémoire. Automatique suit Utilisation de la mémoire, dans la limite du quart de la mémoire de cet ordinateur et des 3/8 de celle de la carte, et ne compte pas le document sur une carte dont la mémoire est connue. Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. GPU Memory Full Mémoire GPU pleine @@ -3398,6 +3415,7 @@ curves Smooth Lisser 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) + Scratch Size Limit (MB, 0 = No Limit) Limite de taille du disque de travail (Mo, 0 = aucune limite) {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. @@ -3415,8 +3433,6 @@ curves Smooth Lisser 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 @@ -3431,18 +3447,13 @@ curves Smooth Lisser Use Utiliser In Front Devant Behind Derrière - -# DDS Options (#1327) DDS Options Options DDS 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Le contenu AI/PDF/EPS sera modifié au format SVG. L’enregistrement remplace la source incorporée ; les propriétés propres au format peuvent changer. # Styles panel: Import / Export Styles (.asl) Import Styles… Importer des styles… @@ -3451,7 +3462,6 @@ curves Smooth Lisser {file} is not a style library (.asl) {file} n’est pas une bibliothèque de styles (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {n} style importé depuis {file}|{n} styles importés depuis {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} {n} style exporté vers {file}|{n} styles exportés vers {file} -# 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 @@ -3836,7 +3846,6 @@ curves Smooth Lisser Hearts Cœurs Heptagon Heptagone Hexagon Hexagone - Hidden Masqué Hide Grid Masquer la grille Hide Others Masquer les autres Hide {app} Masquer {app} @@ -4328,7 +4337,6 @@ curves Smooth Lisser 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 @@ -4490,14 +4498,11 @@ 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cube de teinte Brightness Cube Cube de luminosité Color Wheel Roue chromatique @@ -4513,7 +4518,8 @@ shape Appearance Apparence Grayscale Ramp Barre de niveaux de gris Current Colors Couleurs actuelles Make Ramp Web Safe Limiter la barre aux couleurs Web -# Art History Brush (#213). + The path was kept as the Work Path Le tracé a été conservé comme tracé de travail + The path was discarded Le tracé a été supprimé Art History Brush Tool Outil Pinceau d'historique artistique Tight Short Serré court Tight Medium Serré moyen @@ -4527,3 +4533,26 @@ shape Appearance Apparence Font size presets Tailles de police prédéfinies Leading presets Interlignages prédéfinis Tracking presets Approches prédéfinies + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrée numérique non valide. Un entier compris entre {min}% et {max}% est requis. La valeur précédente a été insérée. + Color Aware Selon la couleur + Decontaminate Colors Décontaminer les couleurs + Edge Detection Détection des contours + Global Refinements Améliorations globales + High Quality Preview Aperçu haute qualité + Object Aware Selon l'objet + Real-Time Refinement Amélioration en temps réel + Refine Edge Brush Tool Outil Pinceau d'amélioration du contour + Refine Hair Améliorer les cheveux + Refine Mode Mode d'amélioration + Remember Settings Mémoriser les paramètres + View Mode Mode d'affichage + Object Aware needs a segmentation model, which isn't available yet Selon l'objet nécessite un modèle de segmentation, pas encore disponible + Refine Hair needs hair detection, which isn't available yet Améliorer les cheveux nécessite une détection des cheveux, pas encore disponible + Select and Mask was closed: its document is no longer the active one Sélectionner et masquer a été fermé : son document n’est plus le document actif + Select and Mask was closed: another layer was selected Sélectionner et masquer a été fermé : un autre calque a été sélectionné + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Couleurs récemment utilisées + Stop Color Couleur du point + Shapes can't use patterns yet Les formes ne peuvent pas encore utiliser de motifs + Edit Brush… Modifier le pinceau… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3d6ddab7cb..0acf7e4bf1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -17,6 +17,37 @@ # (`plural_one_other` in i18n/mod.rs). # Top-level Menus + +# File Menu + +# Edit Menu + +# Image Menu + +# Layer Menu + +# Select Menu + +# View Menu + +# Window Menu & Panels + +# Tools (Toolbox) + +# Common Dialogs and Buttons + +# Plurals + +# Tool and Scope strings + +# Camera Raw + +# Coverage-test keys (English until translated) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) File फ़ाइल Edit एडिट Image इमेज @@ -28,8 +59,6 @@ View व्यू Window विंडो Help सहायता - -# File Menu New… नया… Open… खोलें… Open Recent हाल ही में खोले गए @@ -48,8 +77,6 @@ Print… प्रिंट करें… Print One Copy एक प्रति प्रिंट करें Exit बाहर निकलें - -# Edit Menu Undo पूर्ववत करें Redo फिर से करें Cut काटें @@ -79,8 +106,6 @@ Workspace… कार्यस्थान… Tools… टूल्स… Performance… परफॉरमेंस… - -# Image Menu Mode मोड RGB Color RGB रंग CMYK Color CMYK रंग @@ -113,8 +138,6 @@ Trim… ट्रिम करें… Reveal All सभी दिखाएँ Duplicate… डुप्लीकेट… - -# Layer Menu New नया Layer… लेयर… Layer from Background बैकग्राउंड से लेयर @@ -141,8 +164,6 @@ Merge Down नीचे मर्ज करें Merge Visible दृश्यमान मर्ज करें Flatten Image इमेज फ़्लैट करें - -# Select Menu All सभी Deselect अचयनित करें Reselect पुनः चयन करें @@ -163,8 +184,6 @@ Transform Selection चयन रूपांतरित करें Load Selection… चयन लोड करें… Save Selection… चयन सहेजें… - -# View Menu Proof Setup प्रूफ़ सेटअप Proof Colors प्रूफ़ रंग Zoom In ज़ूम इन @@ -187,8 +206,6 @@ New Guide… नई गाइड… Lock Slices स्लाइस लॉक करें Clear Slices स्लाइस हटाएं - -# Window Menu & Panels Arrange व्यवस्थित करें Tile Horizontally क्षैतिज टाइल Tile Vertically लंबवत टाइल @@ -216,8 +233,6 @@ Tools टूल्स Options विकल्प Status Bar स्टेटस बार - -# Tools (Toolbox) Move Tool मूव टूल Artboard Tool आर्टबोर्ड टूल Rectangular Marquee Tool रेक्टेंगुलर मार्की टूल @@ -286,8 +301,6 @@ Hand Tool हैंड टूल Rotate View Tool रोटेट व्यू टूल Zoom Tool ज़ूम टूल - -# Common Dialogs and Buttons OK ठीक है Cancel रद्द करें Reset रीसेट करें @@ -316,14 +329,10 @@ 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 मिक्सर ब्रश @@ -337,8 +346,6 @@ Aligned संरेखित Fit in View दृश्य में फ़िट करें Camera Raw Camera Raw - -# Camera Raw cameraRaw Amount मात्रा Amount मात्रा cameraRaw Aqua एक्वा @@ -493,8 +500,6 @@ 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… @@ -539,7 +544,8 @@ cameraRaw Yellow पीला Missing delete command हटाने का कमांड नहीं मिला Invalid delete command हटाने का कमांड अमान्य है Layer deleted, but confirmation preference could not be saved: {error} लेयर हटा दी गई, लेकिन पुष्टि की प्राथमिकता सहेजी नहीं जा सकी: {error} -# Art History Brush (#213). + The path was kept as the Work Path पथ को कार्य पथ के रूप में रखा गया + The path was discarded पथ हटा दिया गया Art History Brush आर्ट हिस्ट्री ब्रश Style शैली Area क्षेत्र @@ -552,8 +558,6 @@ cameraRaw Yellow पीला Tight Curl सघन घुमाव Loose Curl ढीला घुमाव Stylized dabs from a history state इतिहास स्थिति से शैलीबद्ध थपके - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio पहलू अनुपात Category श्रेणी Color Table रंग तालिका @@ -563,3 +567,33 @@ cameraRaw Yellow पीला Target लक्ष्य Units इकाइयाँ Demosaic डिमोज़ेक + Clear Selection चयन साफ़ करें + Color Aware रंग-आधारित + Decontaminate Colors रंग शुद्ध करें + Edge Detection किनारा पहचान + Global Refinements वैश्विक परिशोधन + High Quality Preview उच्च गुणवत्ता पूर्वावलोकन + Object Aware ऑब्जेक्ट-आधारित + Real-Time Refinement रियल-टाइम परिशोधन + Refine Edge Brush Tool किनारा परिशोधन ब्रश टूल + Refine Hair बाल परिशोधित करें + Refine Mode परिशोधन मोड + Remember Settings सेटिंग्स याद रखें + View Mode दृश्य मोड + Object Aware needs a segmentation model, which isn't available yet ऑब्जेक्ट-आधारित के लिए सेगमेंटेशन मॉडल चाहिए, जो अभी उपलब्ध नहीं है + Refine Hair needs hair detection, which isn't available yet बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है + Select and Mask was closed: its document is no longer the active one चयन और मास्क बंद कर दिया गया: उसका दस्तावेज़ अब सक्रिय नहीं है + Select and Mask was closed: another layer was selected चयन और मास्क बंद कर दिया गया: दूसरी लेयर चुनी गई +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. अमान्य संख्यात्मक प्रविष्टि। {min}% और {max}% के बीच एक पूर्णांक आवश्यक है। पिछला मान डाला गया। + Edit Brush… ब्रश संपादित करें… + +# Actions panel: loading several action files at once + {message} ({note}) {message} ({note}) +@plural {n} action|{n} actions {n} एक्शन|{n} एक्शन +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} चरण का अभी PhotoCraft में कोई समकक्ष नहीं है|{n} चरणों का अभी PhotoCraft में कोई समकक्ष नहीं है +@plural {n} action file loaded|{n} action files loaded {n} एक्शन फ़ाइल लोड हुई|{n} एक्शन फ़ाइलें लोड हुईं + {set} from {file} replaced the one from {earlier} {file} के सेट {set} ने {earlier} वाले सेट की जगह ले ली +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} फ़ाइल लोड नहीं हो सकी: {errors}|{n} फ़ाइलें लोड नहीं हो सकीं: {errors} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d632cb52f1..c5a61baed8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -18,9 +18,73 @@ # Plural messages and command id overrides # Custom paper dimensions in Print Settings. + + +# Menus: File, Edit, Image + +# Menus: Layer, Select + +# Menus: Type, Filter, View, Window; UI and engine commands + +# Tools, brushes and the options bar + +# Layers, masks, layer styles and blend modes + +# Panels + +# Adjustments and filters + +# Camera Raw and camera data + +# Dialogs, preferences and file formats + +# Status bar, messages and other labels + +# Labels kept for parity with the other catalogs +# Edit › Content-Aware Fill workspace + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop + +# Native Windows printing qualification + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + +# Channel Options and New Spot Channel (#3761) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) + +# Layers panel options and Smart Filter editors + +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). +# Smart Filter row actions. + Paper Width Papír szélessége Paper Height Papír magassága - @id edit.purge.undo Visszavonási adatok @id edit.stroke Körvonalazás… @id edit.transform.scale Méretezés @@ -53,8 +117,6 @@ @id file.close Bezárás @id layer.layerMask.apply Alkalmazás @id edit.clear Kiürítés - -# Menus: File, Edit, Image 16 Bits/Channel 16 bit/csatorna 180° 180° 32 Bits/Channel 32 bit/csatorna @@ -259,8 +321,6 @@ Warp Hajlítás WIA Support… WIA-támogatás… Workspace… Munkaterület… - -# Menus: Layer, Select Align Igazítás Apply Alkalmaz Arrange Elrendezés @@ -357,6 +417,7 @@ Median Medián Merge Layers Rétegek egyesítése Merge Shape Components Alakzatösszetevők egyesítése + Path operations Útvonalműveletek Merge Visible Láthatók egyesítése Modify Módosítás New Adjustment Layer Új korrekciós réteg @@ -419,8 +480,6 @@ Vertical Centers Függőleges középvonalak Video Videó Video Layers Videorétegek - -# Menus: Type, Filter, View, Window; UI and engine commands (coming soon) (hamarosan) 100% 100% 105% 105% @@ -869,8 +928,6 @@ ZigZag… Cikcakk… Zoom In Nagyítás Zoom Out Kicsinyítés - -# Tools, brushes and the options bar 101 by 101 Average 101 x 101 átlag 11 by 11 Average 11 x 11 átlag 3 by 3 Average 3 x 3 átlag @@ -1443,8 +1500,6 @@ fontWeight Medium Közepes fontWeight Regular Normál fontWeight SemiBold Középkövér fontWeight Thin Hajszálvékony - -# Layers, masks, layer styles and blend modes 30 fps 30 fps Add Layer Above to Selection Felette lévő réteg hozzáadása a kijelöléshez Add Layer Below to Selection Alatta lévő réteg hozzáadása a kijelöléshez @@ -1691,7 +1746,7 @@ fontWeight Thin Hajszálvékony Subtract Kivonás The document is closed A dokumentum be van zárva The document or selected layers changed. Please request deletion again. A dokumentum vagy a kijelölt rétegek megváltoztak. Kérd újra a törlést. - This Layer: Ez a réteg: + Current Layer: Jelenlegi réteg: To Végszín Toggle visibility Láthatóság váltása Transparency Shapes Layer Az átlátszóság alakítja a réteget @@ -1718,8 +1773,6 @@ layerLabel Magenta Bíbor Fill with {mode}-neutral color (black) Kitöltés {mode} módhoz semleges színnel (fekete) Fill with {mode}-neutral color (50% gray) Kitöltés {mode} módhoz semleges színnel (50%-os szürke) (No neutral color exists for {mode} mode.) ({mode} módhoz nincs semleges szín.) - -# Panels (none) (nincs) + Add variable + Változó hozzáadása 30 frames @ 30 fps 30 képkocka, 30 fps @@ -1728,6 +1781,9 @@ layerLabel Magenta Bíbor 80% 80% A save to {name} is still running; try again when it finishes Még tart egy mentés ide: {name}; próbáld újra, ha befejeződött Add a layer style Rétegstílus hozzáadása + Move effect up Effektus feljebb mozgatása + Move effect down Effektus lejjebb mozgatása + Delete effect Effektus törlése Add a mask (from the selection; {key} inverts) Maszk hozzáadása (a kijelölésből; {key}: invertálás) Add an adjustment Korrekció hozzáadása Add to selection ({key}) Hozzáadás a kijelöléshez ({key}) @@ -1946,8 +2002,6 @@ layerLabel Magenta Bíbor {name} Properties Tulajdonságok: {name} ↑↓ navigate {key} run esc close ↑↓ navigálás {key} futtatás esc bezárás navigator Zoom Nagyítás mértéke - -# Adjustments and filters 3D LUT File 3D LUT-fájl 90% 90% Absolute Abszolút @@ -2497,8 +2551,6 @@ navigator Zoom Nagyítás mértéke {added}, {removed} subtracted {added}, {removed} kivonva {index} of {total} {index} / {total} {n} of {max} sample points {n} / {max} mintavételi pont - -# Camera Raw and camera data After Utána Aqua Türkiz Balance Egyensúly @@ -2625,8 +2677,6 @@ cameraRaw Tint Színezet cameraRaw Vibrance Élénkség cameraRaw Whites Fehérek cameraRaw Yellow Sárga - -# Dialogs, preferences and file formats % of all commits Commitok aránya (%) (print to the printer) (nyomtatás a nyomtatón) 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. A 0 kikapcsolja ezt a küszöböt; a 100 szintnél mélyebben egymásba ágyazott SVG-csoportok mindig raszterizálódnak. @@ -3056,8 +3106,6 @@ cameraRaw Yellow Sárga {n} commands are not in PhotoCraft. {n} parancs nem érhető el a PhotoCraftban. {shortcut} assigned. Billentyűparancs hozzárendelve: {shortcut} {shortcut} is already in use by {commands} and will be removed from it when you click OK. A billentyűparancs már foglalt: {shortcut}. Jelenlegi parancs: {commands}. Az OK gombra kattintva onnan törlődik. - -# Status bar, messages and other labels Action playback failed: {error} A műveletsor lejátszása sikertelen: {error} Adaptive Wide Angle Adaptív nagy látószög Add Anchor Point Tool Horgonypont hozzáadása @@ -3689,8 +3737,6 @@ cameraRaw Yellow Sárga {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) …and {n} more …és még {n} - -# Labels kept for parity with the other catalogs 10 fps 10 fps 15 fps 15 fps 24 fps 24 fps @@ -4066,7 +4112,6 @@ cameraRaw Yellow Sárga 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 Full Teljes @@ -4088,8 +4133,6 @@ cameraRaw Yellow Sárga Clear Brush Controls Ecsetvezérlők törlése Reset All Locked Settings Összes zárolt beállítás visszaállítása Copy Texture to Other Tools Textúra másolása más eszközökre - -# 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… @@ -4101,9 +4144,10 @@ cameraRaw Yellow Sárga @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} lépésnek még nincs megfelelője a PhotoCraftban @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva - +@plural {n} action file loaded|{n} action files loaded {n} műveletfájl betöltve + {set} from {file} replaced the one from {earlier} A(z) {file} {set} készlete lecserélte a(z) {earlier} készletét +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fájlt nem sikerült betölteni: {errors} Starting serial Kezdő sorszám -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Leállítás beszúrása… Save Set… Készlet mentése… Message: Üzenet: @@ -4127,20 +4171,14 @@ cameraRaw Yellow Sárga Loaded Camera Raw settings from {file} Camera Raw-beállítások betöltve innen: {file} Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-beállítások betöltve innen: {file} ({n} nem támogatott) Auto Add/Delete Automatikus hozzáadás/törlés - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. A nyomtatási beállításokban ne jelöld be a tükrözést is: a két tükrözés kioltja egymást. Print Settings… Nyomtatási beállítások… Remember to turn off the printer's own colour management in Print Settings… Ne felejtsd el kikapcsolni a nyomtató saját színkezelését a nyomtatási beállításokban… Sent to {printer} Elküldve ide: {printer} 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 @@ -4157,6 +4195,7 @@ cameraRaw Yellow Sárga Kept {name}: written by another application {name} megtartva: egy másik alkalmazás írta Shadow Tint Árnyék színezet Redrawing Újrarajzolás + Memory usage percent Memóriahasználat százaléka Show Favorite Fonts Kedvenc betűtípusok megjelenítése No favorite fonts yet: click the star beside a font to add it. Még nincsenek kedvenc betűtípusok: kattintson a betűtípus melletti csillagra a hozzáadáshoz. Favorite fonts Kedvenc betűtípusok @@ -4184,7 +4223,6 @@ cameraRaw Yellow Sárga Slab Serif Talpas (szögletes) TrueType font TrueType betűtípus Variable font Változó betűtípus -# 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 @@ -4192,36 +4230,22 @@ cameraRaw Yellow Sárga Shadow Color Árnyék színe Shadow Opacity Árnyék fedettsége Check for Updates… Frissítések keresése… - PhotoCraft Updates PhotoCraft-frissítések - Checking for updates… Frissítések keresése… Open the release page to choose an installer for your system. Nyissa meg a kiadás oldalát, és válassza ki a rendszerének megfelelő telepítőt. - Open Download Page Letöltési oldal megnyitása - PhotoCraft is up to date. A PhotoCraft naprakész. - Could not check for updates. Nem sikerült ellenőrizni a frissítéseket. - Updates have not been checked yet. A frissítések még nem lettek ellenőrizve. - Check Again Újraellenőrzés Automatically Check for Updates Frissítések automatikus keresése - Installed version: {version} Telepített verzió: {version} - PhotoCraft {version} is available. Elérhető a PhotoCraft {version}. -# 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}) Remember this choice for future images A választás megjegyzése a későbbi képekhez Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdést a Szerkesztés › Színbeállítások „Profileltérés: rákérdezés megnyitáskor” beállításával kapcsolhatod vissza. Button Mode Gomb mód Button Mode On Gomb mód be - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Felület sötétítése Lighten Interface Felület világosítása -# Bevel & Emboss Technique. Technique Technika 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 + Save Full-Size Composite in .pcraft Files Teljes méretű kompozit kép mentése a .pcraft fájlokba Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése Bicubic Automatic Automatikus bikubikus @@ -4246,8 +4270,6 @@ 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 @@ -4257,8 +4279,6 @@ cameraRaw Yellow Sárga 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 @@ -4275,7 +4295,6 @@ cameraRaw Yellow Sárga 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. @@ -4291,10 +4310,8 @@ cameraRaw Yellow Sárga Undo Changes Változások visszavonása {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött. The transform couldn't be applied. Az átalakítás nem alkalmazható. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír, legfeljebb a memóriája 3/8-ára. Az Automatikus a memóriahasználatot követi, a számítógép memóriájának negyedén és a kártya 3/8-án belül, és ismert memóriájú kártyánál nem számítja bele a dokumentumot. Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. GPU Memory Full GPU-memória megtelt @@ -4304,6 +4321,7 @@ cameraRaw Yellow Sárga 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) + Scratch Size Limit (MB, 0 = No Limit) Lapozólemez méretkorlátja (MB, 0 = nincs korlát) {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. @@ -4320,8 +4338,6 @@ cameraRaw Yellow Sárga 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 @@ -4336,24 +4352,27 @@ cameraRaw Yellow Sárga Use Használat In Front Elöl Behind Mögött - -# DDS Options (#1327) 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 +smartSvg Choose executable… Futtatható fájl kiválasztása… +smartSvg Choose the application used to edit this SVG: Válassza ki az SVG szerkesztésére használt alkalmazást: +smartSvg Edit SVG Smart Object SVG intelligens objektum szerkesztése +smartSvg Other application Másik alkalmazás +smartSvg PhotoCraft (internal editor) PhotoCraft (belső szerkesztő) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. Az SVG-szerkesztő megnyílt. Mentse az SVG-t az intelligens objektum frissítéséhez. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Mentse a megnyitott SVG-fájlba. Minden mentett módosítás külön visszavonási lépés lesz a PhotoCraftban. +smartSvg Stop syncing this Smart Object Az intelligens objektum szinkronizálásának leállítása +smartSvg Updates wait while another document or an editing dialog is active. A frissítések várakoznak, amíg másik dokumentum vagy szerkesztési párbeszédablak aktív. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Az AI/PDF/EPS tartalom SVG-ként szerkeszthető. A mentés lecseréli a beágyazott forrást; a formátumspecifikus tulajdonságok megváltozhatnak. # Styles panel: Import / Export Styles (.asl) Import Styles… Stílusok importálása… Export Selected Styles… Kijelölt stílusok exportálása… Export Styles… Stílusok exportálása… {file} is not a style library (.asl) {file} nem stíluskönyvtár (.asl) -# 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 @@ -4362,14 +4381,11 @@ cameraRaw Yellow Sárga 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Színárnyalat-kocka Brightness Cube Fényerő-kocka Color Wheel Színkör @@ -4385,7 +4401,8 @@ cameraRaw Yellow Sárga Grayscale Ramp Szürkeárnyalatos sáv Current Colors Aktuális színek Make Ramp Web Safe Sáv korlátozása webes színekre -# Art History Brush (#213). + The path was kept as the Work Path Az útvonal munkaútvonalként megmaradt + The path was discarded Az útvonal el lett vetve Art History Brush Tool Művészi előzményecset eszköz Tight Short Szoros, rövid Tight Medium Szoros, közepes @@ -4399,3 +4416,27 @@ cameraRaw Yellow Sárga Font size presets Betűméret-előbeállítások Leading presets Sorköz-előbeállítások Tracking presets Betűköz-előbeállítások + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Érvénytelen számbevitel. {min}% és {max}% közötti egész szám szükséges. Az előző érték lett beillesztve. + Color Aware Színalapú + Decontaminate Colors Színek fertőtlenítése + Edge Detection Élészlelés + Global Refinements Globális finomítások + High Quality Preview Kiváló minőségű előnézet + Object Aware Objektumalapú + Real-Time Refinement Valós idejű finomítás + Refine Edge Brush Tool Élfinomító ecset eszköz + Refine Hair Haj finomítása + Refine Mode Finomítási mód + Remember Settings Beállítások megjegyzése + View Mode Nézetmód + Object Aware needs a segmentation model, which isn't available yet Az Objektumalapú módhoz szegmentálási modell kell, amely még nem érhető el + Refine Hair needs hair detection, which isn't available yet A Haj finomításához hajfelismerés kell, amely még nem érhető el + Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív + Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve + Drag to move the selected path Húzza a kijelölt útvonal mozgatásához + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Legutóbb használt színek + Stop Color Színpont színe + Shapes can't use patterns yet Az alakzatok még nem használhatnak mintákat + Edit Brush… Ecset szerkesztése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 771e04e80a..e5058af2c9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -13,18 +13,112 @@ # 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: - Solidity: Soliditas: - Ink Characteristics Karakteristik Tinta # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) +# Counted status and panel messages + + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Labels added on main after this catalog (depth labels, Wayland drop, profiles, Camera Raw, Patch tool, rendering mode) + +# Warp grid presets (#388) + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Mask Options… Opsi Mask… + Layer Mask Display Options… Opsi Tampilan Mask Layer… + Channel Color Warna Saluran + Color Indicates: Warna Menunjukkan: + Solidity: Soliditas: + Ink Characteristics Karakteristik Tinta Paper Width Lebar kertas Paper Height Tinggi kertas Rows Baris @@ -43,7 +137,6 @@ 5 Columns, 3 Rows 5 kolom, 3 baris 8 Columns 8 kolom 12 Columns 12 kolom - File Berkas Edit Edit Image Gambar @@ -435,6 +528,7 @@ Intersect Shape Areas Irisan Area Bentuk Exclude Overlapping Shapes Kecualikan Bentuk yang Tumpang Tindih Merge Shape Components Gabungkan Komponen Bentuk + Path operations Operasi path Align Rata Top Edges Tepi Atas Vertical Centers Tengah Vertikal @@ -514,7 +608,6 @@ Color Range… Rentang Warna… Focus Area… Area Fokus… Select and Mask… Seleksi dan Mask… -# Select and Mask dialog labels Smart Radius Radius Cerdas Shift Edge Geser Tepi Decontaminate Dekontaminasi @@ -541,7 +634,6 @@ Blur Blur Blur Gallery Galeri Blur Field Blur… Field Blur… -# Field Blur pins (#3373) No pin selected Tidak ada pin yang dipilih Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik gambar untuk menambah pin; seret pin untuk memindahkannya, atau cincinnya untuk mengubah blur. Delete menghapus pin yang dipilih. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Seret pegangan pada garis luar untuk mengubah ukuran dan memutarnya, kotak untuk membulatkan sudutnya, dan titik di dalam untuk menentukan di mana blur dimulai. @@ -805,7 +897,6 @@ Statistics… Statistik… Refine Strokes Goresan penyempurnaan Edits Suntingan -# 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} @@ -820,7 +911,6 @@ @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) @@ -836,6 +926,9 @@ Add Layer Mask Tambah Mask Layer Add Stop Tambah Stop Add a layer style Tambah gaya layer + Move effect up Pindahkan efek ke atas + Move effect down Pindahkan efek ke bawah + Delete effect Hapus efek Add a mask (from the selection; {key} inverts) Tambah mask (dari seleksi; {key} membalik) Add an adjustment Tambah penyesuaian Add disk Tambah disk @@ -1061,8 +1154,6 @@ Delete current channel Hapus channel saat ini Delete current state Hapus kondisi saat ini Delete effect layer Hapus layer efek - -# Layers-panel trash confirmation Delete layer ‘{name}’? Hapus layer ‘{name}’? Delete {count} layers? {names} Hapus {count} layer? {names} Delete layer? Hapus layer? @@ -1071,7 +1162,6 @@ Missing delete command Perintah hapus tidak tersedia Invalid delete command Perintah hapus tidak valid Layer deleted, but confirmation preference could not be saved: {error} Layer dihapus, tetapi preferensi konfirmasi tidak dapat disimpan: {error} - Delete layer Hapus layer Delete layer comp Hapus layer comp Delete note Hapus catatan @@ -2162,7 +2252,6 @@ Align with layer Sejajarkan dengan layer Reset Alignment Reset Penyelarasan Centre the gradient (offset 0, 0) Tengahkan gradien (offset 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Editor Gradien Gradient picker Pemilih gradien Save As Preset Simpan sebagai Preset @@ -2678,8 +2767,6 @@ Select Bottom Layer Pilih Layer Terbawah Add Layer Above to Selection Tambah Layer di Atas ke Seleksi Add Layer Below to Selection Tambah Layer di Bawah ke Seleksi - -# Labels added on main after this catalog (depth labels, Wayland drop, profiles, Camera Raw, Patch tool, rendering mode) 8 bit 8 bit (bilangan bulat) Integer Bilangan bulat 16 bit 16 bit (bilangan bulat) @@ -2739,21 +2826,15 @@ cameraRaw Blending Pembauran GPU Backend Backend GPU Details Detail Rendering mode Mode rendering - -# Warp grid presets (#388) 3 x 3 3 × 3 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Kisi-kisi: Edit Type Edit Teks - - New Style… Gaya Baru… Style {name} saved Gaya {name} disimpan - Create new style (from the selected type) Buat gaya baru (dari teks terpilih) Create new style from the selected layer Buat gaya baru dari lapisan terpilih - Make Default Jadikan Default Reset to Default Atur Ulang ke Default Full-resolution preview unavailable Pratinjau resolusi penuh tidak tersedia @@ -2782,9 +2863,6 @@ cameraRaw Blending Pembauran Magnetic Lasso Tool Alat Magnetic Lasso Frequency Frekuensi Use tablet pressure to change pen width Gunakan tekanan tablet untuk mengubah lebar pena - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About Tentang Contributors Kontributor Models Model @@ -2815,8 +2893,6 @@ cameraRaw Blending Pembauran % of all commits % dari semua commit Lines +/− Baris +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{binAdded} / −{binDeleted} berkas biner, {first} – {last} - -# Swatches panel Create new swatch Buat swatch baru New Swatch… Swatch Baru… New Swatch Group… Grup Swatch Baru… @@ -2826,13 +2902,12 @@ cameraRaw Blending Pembauran Export Swatches for Exchange… Ekspor Swatch untuk Pertukaran… Reset Swatches Atur Ulang Swatch Add to Swatches Tambahkan ke Swatch -# Layer Style > Blending Options (Advanced Blending) Blend If: Blend If: + Current Layer: Layer Saat Ini: + Underlying Layer: Layer di Bawah: Channels: Channel: Knockout Knockout Apply At Canvas Bounds Terapkan efek di batas kanvas - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Pemilih Warna (Warna Teks) Color Picker ({name}) Pemilih Warna ({name}) Color Picker (Fill Color) Pemilih Warna (Warna Isian) @@ -2849,8 +2924,6 @@ cameraRaw Blending Pembauran Show/Hide Panels Tampilkan/Sembunyikan Panel Could not open Tidak dapat membuka {path}: the path is not valid Unicode; rename the file and open it again. {path}: jalur bukan Unicode yang valid; ganti nama file lalu buka lagi. - -# Fill dialog, layer style contours and Image Size Contents: Isi: Custom Pattern: Pola Kustom: Foreground Color Warna Foreground @@ -2877,14 +2950,12 @@ cameraRaw Blending Pembauran Rolling Slope - Descending Lereng Bergelombang - Menurun Rounded Steps Tangga Bulat Sawtooth 1 Gigi Gergaji 1 - Red Eye Tool Alat Mata Merah Pupil Size Ukuran pupil Darken Amount Jumlah penggelapan Click a red pupil to neutralize it Klik pupil merah untuk memperbaikinya open source sumber terbuka …and {n} more …dan {n} lagi - Eyedroppers: Eyedropper: Set Black Point Atur Titik Hitam Set Neutral Gray Point Atur Titik Abu-abu Netral @@ -2893,8 +2964,6 @@ cameraRaw Blending Pembauran Selection Preview: Pratinjau Seleksi: Black Matte Latar Hitam White Matte Latar Putih - -# Layer colour labels. Seafoam Busa Laut Indigo Nila Fuchsia Fuksia @@ -2914,20 +2983,14 @@ fontWeight Bold Tebal fontWeight ExtraBold Ekstra Tebal fontWeight Black Sangat Tebal fontWeight Italic Miring - -# Pattern Stamp tool Pattern Stamp Tool Alat Stempel Pola Impressionist Impresionis Rotate View Tool Alat Putar Tampilan Reset View Atur Ulang Tampilan Drag around the centre to rotate the view · Shift constrains to 15° Seret di sekitar pusat untuk memutar tampilan · Shift membatasi ke 15° - -# Painting symmetry presets. Dual Axis Sumbu Ganda Diagonal Diagonal Transform Symmetry Transformasi Simetri - -# Eyedropper Sample Size and Sample (#1649). Point Sample Sampel Titik 3 by 3 Average Rata-rata 3 × 3 5 by 5 Average Rata-rata 5 × 5 @@ -2939,15 +3002,12 @@ fontWeight Italic Miring All Layers No Adjustments Semua Layer Tanpa Penyesuaian Current & Below No Adjustments Saat Ini & Di Bawahnya Tanpa Penyesuaian Show Sampling Ring Tampilkan Cincin Sampel - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat Color to Alpha… Warna ke Alpha… Transparency Threshold Ambang transparansi Opacity Threshold Ambang opasitas PhotoCraft — Screen Color Picker PhotoCraft — Pemilih Warna Layar - Rasterize svg groups deeper than Rasterisasi grup SVG yang lebih dalam dari ini 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 menonaktifkan ambang ini; grup SVG yang bersarang lebih dari 100 tingkat selalu dirasterisasi. Rule of Thirds Aturan Sepertiga @@ -2961,8 +3021,6 @@ fontWeight Italic Miring Cycle Orientation Ganti Orientasi Set the overlay options for the Crop tool Atur opsi overlay untuk alat Crop 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-klik diambil oleh desktop, yang memindahkan jendela dengan Alt-seret. Atur tombol pemindah jendelanya ke Super, atau kunci Alt di Jendela › Tombol Pengubah. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Pemilih Warna (Warna Gaya Lapisan) Auto Hide Notifications Sembunyikan notifikasi secara otomatis Notification Duration (seconds) Durasi notifikasi (detik) @@ -3038,7 +3096,6 @@ fontWeight Italic Miring Appearance Settings… Pengaturan Tampilan… Add to favorites Tambahkan ke favorit Remove from favorites Hapus dari favorit -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Merek cameraData Model Model cameraData Date Taken Tanggal Diambil @@ -3048,7 +3105,6 @@ cameraData ISO Speed Sensitivitas ISO cameraData Focal Length Panjang Fokal cameraData Focal Length (35 mm) Panjang Fokal (35 mm) cameraData Lens Lensa -# Shared panel docking Activate Panel Aktifkan Panel Change Panel Group Ubah Grup Panel Change Panel Layout Ubah Tata Letak Panel @@ -3092,7 +3148,6 @@ cameraData Lens Lensa Snap to Origin Jepret ke Titik Asal Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok -# Edit › Content-Aware Fill workspace Sampling Brush Tool Alat Kuas Pengambilan Sampel Sampling Area Overlay Hamparan Area Pengambilan Sampel Show Sampling Area Tampilkan Area Pengambilan Sampel @@ -3110,7 +3165,6 @@ 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 @@ -3128,8 +3182,6 @@ cameraData Lens Lensa 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) @@ -3162,7 +3214,6 @@ curves Smooth Haluskan Fix the red plane first: its corners must form a convex quad Perbaiki bidang merah dulu: sudut-sudutnya harus membentuk segi empat cembung Alt-click a plane to set the clone source Alt-klik sebuah bidang untuk menetapkan sumber kloning Too many clone strokes: apply Vanishing Point first Terlalu banyak goresan kloning: terapkan Vanishing Point terlebih dahulu -# Select and Mask view modes (#1754) Onion Skin Kulit Bawang Marching Ants Semut Berbaris On Black Di Atas Hitam @@ -3171,7 +3222,6 @@ curves Smooth Haluskan Show Edge Tampilkan Tepi Show Original Tampilkan Asli F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Kuas Perhalus Tepi (R) Expand Detection Area Perluas Area Deteksi Restore Original Edge Pulihkan Tepi Asli @@ -3179,19 +3229,13 @@ curves Smooth Haluskan Clear Brush Strokes Hapus Goresan Kuas Show rulers Tampilkan Penggaris Presentation Presentasi - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Izinkan Riwayat Non-Linier Make Layer Visibility Changes Undoable Perubahan Visibilitas Layer Dapat Diurungkan -# Print dialog (#2697) Emulsion Down Emulsi di Bawah Functions Fungsi Print Resolution: Resolusi Cetak: @@ -3203,7 +3247,6 @@ curves Smooth Haluskan Apply mask to layer before removing? Terapkan masker ke layer sebelum dihapus? The layer or its mask changed. Drag the mask onto the Trash again. Layer atau maskernya berubah. Seret masker ke Tempat Sampah lagi. Delete the vector mask? Hapus masker vektor? -# View › Guides › New Guide… › Color Light Blue Biru Muda Light Red Merah Terang Medium Blue Biru Sedang @@ -3224,8 +3267,6 @@ curves Smooth Haluskan Clear Brush Controls Hapus Kontrol Kuas Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci Copy Texture to Other Tools Salin Tekstur ke Alat Lain - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Muat Tindakan… Save Actions… Simpan Tindakan… New Action… Tindakan Baru… @@ -3237,10 +3278,11 @@ curves Smooth Haluskan @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} langkah belum memiliki padanan di PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati - +@plural {n} action file loaded|{n} action files loaded {n} file tindakan dimuat + {set} from {file} replaced the one from {earlier} Set {set} dari {file} menggantikan set dari {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} file tidak dapat dimuat: {errors} File Naming Penamaan Berkas Starting serial Nomor urut awal -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Sisipkan Berhenti… Save Set… Simpan Set… Message: Pesan: @@ -3252,17 +3294,15 @@ curves Smooth Haluskan @plural {n} action|{n} actions {n} tindakan Background (Default) Background (Default) Content-Aware Fill Isi Content-Aware -# Batch / Image Processor Include All Subfolders Sertakan semua subfolder Auto Add/Delete Tambah/Hapus Otomatis - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Jangan aktifkan juga Gambar Cermin di Pengaturan Cetak: kedua pembalikan saling meniadakan. Print Settings… Pengaturan Cetak… Remember to turn off the printer's own colour management in Print Settings… Ingat untuk menonaktifkan pengelolaan warna milik printer di Pengaturan Cetak… 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 + Memory usage percent Pemakaian memori (%) Show Favorite Fonts Tampilkan Font Favorit No favorite fonts yet: click the star beside a font to add it. Belum ada font favorit: klik bintang di samping font untuk menambahkannya. Favorite fonts Font favorit @@ -3301,48 +3341,31 @@ curves Smooth Haluskan Could not write {name}: {error} Tidak dapat menulis {name}: {error} Kept {name}: written by another application {name} dipertahankan: ditulis oleh aplikasi lain Shadow Tint Rona bayangan -# PhotoCraft release checks. Check for Updates… Periksa Pembaruan… - PhotoCraft Updates Pembaruan PhotoCraft - Checking for updates… Memeriksa pembaruan… Open the release page to choose an installer for your system. Buka halaman rilis untuk memilih penginstal bagi sistem Anda. - Open Download Page Buka Halaman Unduhan - PhotoCraft is up to date. PhotoCraft sudah versi terbaru. - Could not check for updates. Tidak dapat memeriksa pembaruan. - Updates have not been checked yet. Pembaruan belum diperiksa. - Check Again Periksa Lagi Automatically Check for Updates Periksa Pembaruan Secara Otomatis - Installed version: {version} Versi terpasang: {version} - PhotoCraft {version} is available. PhotoCraft {version} tersedia. -# 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}) Remember this choice for future images Ingat pilihan ini untuk gambar berikutnya Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Aktifkan lagi pertanyaan ini lewat Ask On Mismatch di Edit › Pengaturan Warna. Button Mode Mode tombol Button Mode On Mode tombol aktif - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Gelapkan Antarmuka Lighten Interface Cerahkan Antarmuka -# Bevel & Emboss Technique. Technique Teknik 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 + Save Full-Size Composite in .pcraft Files Simpan gambar komposit ukuran penuh di file .pcraft Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih Bicubic Automatic Bicubic otomatis @@ -3368,8 +3391,6 @@ 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 @@ -3388,10 +3409,8 @@ curves Smooth Haluskan Undo Changes Batalkan Perubahan {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai. The transform couldn't be applied. Transformasi tidak dapat diterapkan. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Layer-nya membutuhkan {needed} memori GPU; anggaran menyisakan {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda, paling banyak 3/8 memorinya. Otomatis mengikuti Pemakaian memori, dalam seperempat memori komputer ini dan 3/8 memori kartu, dan tidak membebankan dokumen pada kartu yang memorinya diketahui. Selama belum muat, bilah status menampilkan Memori GPU Penuh. GPU Memory Full Memori GPU Penuh @@ -3401,6 +3420,7 @@ curves Smooth Haluskan 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) + Scratch Size Limit (MB, 0 = No Limit) Batas ukuran disk scratch (MB, 0 = Tanpa batas) {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. @@ -3418,8 +3438,6 @@ curves Smooth Haluskan 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 @@ -3434,18 +3452,13 @@ curves Smooth Haluskan Use Gunakan In Front Di Depan Behind Di Belakang - -# DDS Options (#1327) DDS Options Opsi DDS 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Konten AI/PDF/EPS akan diedit sebagai SVG. Menyimpan akan mengganti sumber tersemat; fitur khusus format mungkin berubah. # Styles panel: Import / Export Styles (.asl) Import Styles… Impor Gaya… @@ -3454,7 +3467,6 @@ curves Smooth Haluskan {file} is not a style library (.asl) {file} bukan pustaka gaya (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {n} gaya diimpor dari {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} {n} gaya diekspor ke {file} -# 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 @@ -3463,14 +3475,11 @@ curves Smooth Haluskan 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Kubus Hue Brightness Cube Kubus Kecerahan Color Wheel Roda Warna @@ -3486,7 +3495,8 @@ curves Smooth Haluskan Grayscale Ramp Gradasi Grayscale Current Colors Warna Saat Ini Make Ramp Web Safe Jadikan Gradasi Aman untuk Web -# Art History Brush (#213). + The path was kept as the Work Path Path dipertahankan sebagai Work Path + The path was discarded Path dibuang Art History Brush Tool Alat Kuas riwayat artistik Tight Short Rapet pendek Tight Medium Rapet sedang @@ -3498,8 +3508,6 @@ curves Smooth Haluskan Loose Curl Ikal longgar Area Area Stylized dabs from a history state Cap bergaya dari sebuah keadaan riwayat - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Color Table Tabel Warna Gamma Gamma Profile Profil @@ -3507,3 +3515,31 @@ curves Smooth Haluskan Font size presets Preset ukuran font Leading presets Preset leading Tracking presets Preset tracking + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entri numerik tidak valid. Diperlukan bilangan bulat antara {min}% dan {max}%. Nilai sebelumnya telah disisipkan. + Clear Selection Hapus Seleksi + Color Aware Berbasis Warna + Decontaminate Colors Dekontaminasi Warna + Edge Detection Deteksi Tepi + Global Refinements Penyempurnaan Global + High Quality Preview Pratinjau Kualitas Tinggi + Object Aware Berbasis Objek + Real-Time Refinement Penyempurnaan Waktu Nyata + Refine Edge Brush Tool Alat Kuas Perhalus Tepi + Refine Hair Perhalus Rambut + Refine Mode Mode Penyempurnaan + Remember Settings Ingat Pengaturan + View Mode Mode Tampilan + Object Aware needs a segmentation model, which isn't available yet Berbasis Objek memerlukan model segmentasi, yang belum tersedia + Refine Hair needs hair detection, which isn't available yet Perhalus Rambut memerlukan deteksi rambut, yang belum tersedia + Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif + Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih + Select Layer Under Cursor Pilih Lapisan di Bawah Kursor + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker Pemilih Warna + Recently Used Colors Warna yang Baru Digunakan + Stop Color Warna Titik + Shapes can't use patterns yet Bentuk belum dapat memakai pola + Solid Color Warna Solid + Tint Color Warna Tint + Edit Brush… Edit Kuas… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b1598ac237..367c7267f5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -13,18 +13,132 @@ # 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: - Solidity: Solidità: - Ink Characteristics Caratteristiche inchiostro # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Dialogs and panels + +# Layers-panel trash confirmation + + +# Tool names, brush and style option tables + +# Templates with placeholders + +# Preferences (labels are generated from the setting names) + +# Added with the rebase onto the dock / brush picker work + +# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test. +# Italian follows the tool wording used above (Strumento X). + +# Command-id override: under Edit > Purge a bare Annulla reads as an action, not the history. +# Rendering mode and recovery + + + + + +# Camera Raw: contextual controls and window title + +# Strings added on main after this catalog was written (2026-10). + + +# About ▸ Contributors / Models (credits) +# Layer Style > Blending Options (Advanced Blending) + +# Swatches panel + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the +# other catalogs already translate + +# Path, channel, layer style, status and System Info labels the Czech catalog added + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added +# Shortcut commands, find and replace, profile prompts and metadata labels the German, Hungarian and Serbian catalogs added +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + Mask Options… Opzioni maschera… + Layer Mask Display Options… Opzioni di visualizzazione maschera di livello… + Channel Color Colore canale + Color Indicates: Il colore indica: + Solidity: Solidità: + Ink Characteristics Caratteristiche inchiostro Paper Width Larghezza della carta Paper Height Altezza della carta Columns Colonne @@ -44,7 +158,6 @@ 5 Columns, 3 Rows 5 colonne, 3 righe 8 Columns 8 colonne 12 Columns 12 colonne - File File Edit Modifica Image Immagine @@ -451,6 +564,7 @@ Intersect Shape Areas Interseca aree forme Exclude Overlapping Shapes Escludi forme sovrapposte Merge Shape Components Unisci componenti forma + Path operations Operazioni sui tracciati Align Allinea Top Edges Bordi superiori Vertical Centers Centri verticali @@ -530,7 +644,6 @@ Color Range… Intervallo colori… Focus Area… Area di messa a fuoco… Select and Mask… Seleziona e maschera… -# Select and Mask dialog labels Smart Radius Raggio avanzato Shift Edge Sposta bordo Decontaminate Decontamina @@ -557,7 +670,6 @@ Blur Sfocatura Blur Gallery Galleria sfocatura Field Blur… Sfocatura campo… -# Field Blur pins (#3373) No pin selected Nessuna puntina selezionata Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Fai clic sull’immagine per aggiungere una puntina; trascina una puntina per spostarla o il suo anello per cambiare la sfocatura. Canc rimuove la puntina selezionata. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Trascina le maniglie sul contorno per ridimensionarlo e ruotarlo, il quadrato per arrotondarne gli angoli e i punti interni per stabilire dove inizia la sfocatura. @@ -821,8 +933,6 @@ Edits Modifiche @plural {n} item|{n} items {n} elemento|{n} elementi Auto Automatico - -# Dialogs and panels (print to the printer) (stampa sulla stampante) + Add variable + Aggiungi variabile 30 frames @ 30 fps 30 fotogrammi @ 30 fps @@ -836,6 +946,9 @@ Add Layer Mask Aggiungi maschera di livello Add Stop Aggiungi punto Add a layer style Aggiungi uno stile livello + Move effect up Sposta effetto in alto + Move effect down Sposta effetto in basso + Delete effect Elimina effetto Add a mask (from the selection; {key} inverts) Aggiungi una maschera (dalla selezione; {key} inverte) Add an adjustment Aggiungi una regolazione Add disk Aggiungi disco @@ -1063,8 +1176,6 @@ Delete current channel Elimina canale corrente Delete current state Elimina stato corrente Delete effect layer Elimina livello effetto - -# Layers-panel trash confirmation Delete layer ‘{name}’? Eliminare il livello «{name}»? Delete {count} layers? {names} Eliminare {count} livelli? {names} Delete layer? Eliminare il livello? @@ -1073,7 +1184,6 @@ Missing delete command Comando di eliminazione mancante Invalid delete command Comando di eliminazione non valido Layer deleted, but confirmation preference could not be saved: {error} Il livello è stato eliminato, ma non è stato possibile salvare la preferenza di conferma: {error} - Delete layer Elimina livello Delete layer comp Elimina composizione livelli Delete note Elimina nota @@ -1105,6 +1215,7 @@ 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 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 + Drag to move the selected path Trascina per spostare il tracciato selezionato 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. Drop an image or PSD anywhere to open it. Trascina un'immagine o PSD ovunque per aprirlo. @@ -1626,8 +1737,6 @@ No saved presets yet. Nessun predefinito salvato. Error Errore Command Comando - -# Tool names, brush and style option tables Adjustment layers have nothing to transform I livelli di regolazione non hanno nulla da trasformare Altitude Altitudine Android 1080p Android 1080p @@ -1859,8 +1968,6 @@ Yellows Gialli Zoom Tool Strumento Zoom {name} Properties Proprietà {name} - -# Templates with placeholders Revert “{name}” to the last saved version? Your changes will be lost. Ripristinare “{name}” all'ultima versione salvata? Le modifiche andranno perse. Do you want to save the changes you made to “{name}” before quitting? Vuoi salvare le modifiche apportate a “{name}” prima di uscire? Do you want to save the changes you made to “{name}” before closing? Vuoi salvare le modifiche apportate a “{name}” prima di chiudere? @@ -1881,8 +1988,6 @@ @plural {n} Layer|{n} Layers {n} livello|{n} livelli @plural {n} layer|{n} layers {n} livello|{n} livelli @plural Group · {n} layer|Group · {n} layers Gruppo · {n} livello|Gruppo · {n} livelli - -# Preferences (labels are generated from the setting names) Additional plugins folder Cartella plugin aggiuntiva Adobe Adobe Adobe rgb Adobe RGB @@ -2082,8 +2187,6 @@ Luminosity Luminosità @plural {n} sample|{n} samples {n} campione|{n} campioni {added}, {removed} subtracted {added}, {removed} sottratti - -# Added with the rebase onto the dock / brush picker work Adjustment Preview Anteprima regolazione Airbrush: keeps depositing paint while the pointer rests. Aerografo: continua a depositare vernice mentre il puntatore è fermo. Angle: Angolo: @@ -2116,8 +2219,6 @@ Brush picker Selettore pennelli Erase Cancella Right click with painting tools Clic destro con strumenti pittura - -# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). System Info… Info sistema… System Info Info sistema More panels Altri pannelli @@ -2188,7 +2289,6 @@ Align with layer Allinea con livello Reset Alignment Reimposta allineamento Centre the gradient (offset 0, 0) Centra sfumatura (offset 0,0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Editor sfumatura Gradient picker Selettore sfumatura Gradient stops Punti della sfumatura @@ -2248,9 +2348,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Griglia: - -# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test. -# Italian follows the tool wording used above (Strumento X). Color Range Intervallo colori Adaptive Wide Angle Grandangolo adattivo Chalk & Charcoal Gessetto e carboncino @@ -2334,10 +2431,7 @@ New Channel Nuovo canale Save Selection Salva selezione Load Selection Carica selezione - -# Command-id override: under Edit > Purge a bare Annulla reads as an action, not the history. @id edit.purge.undo Cronologia annullamenti -# Rendering mode and recovery Rendering Mode Modalità rendering Automatic (recommended) Automatico (consigliato) CPU / Compatibility CPU / Compatibilità @@ -2353,11 +2447,8 @@ Advanced Avanzato GPU Backend Backend GPU Details Dettagli - Rendering mode Modalità rendering Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Lazo congela: trascina per congelare un'area, Alt-trascina per scongelare (L) - - Shadow Clipping Warning (U) Avviso clipping ombre (U) Highlight Clipping Warning (O) Avviso clipping luci (O) Show Lab Color Readouts Mostra letture colore Lab @@ -2366,10 +2457,7 @@ Show Skin Tone Indicator Mostra indicatore incarnato Tone Histogram Istogramma tonale Show Selected Region Mostra regione selezionata - cameraRaw Light Luce - -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Filtro Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) cameraRaw Lights Toni chiari @@ -2393,8 +2481,6 @@ cameraRaw Amount Quantità Scale or rotate the moved area, then press {key} Ridimensiona o ruota l'area spostata, poi premi {key} Now drag the selection onto another area Ora trascina la selezione su un'altra area Edit Type Modifica testo - -# Strings added on main after this catalog was written (2026-10). Relight… Riillumina… No patterns Nessun pattern Enable {name} Attiva {name} @@ -2424,9 +2510,6 @@ cameraRaw Amount Quantità Magnetic Lasso Tool Strumento Lazo magnetico Frequency Frequenza Use tablet pressure to change pen width Usa pressione tavoletta per cambiare la larghezza della penna - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About Informazioni Contributors Collaboratori Models Modelli @@ -2457,12 +2540,9 @@ cameraRaw Amount Quantità % of all commits % di tutti i commit Lines +/− Righe +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{binAdded} / −{binDeleted} risorse binarie, {first} – {last} -# Layer Style > Blending Options (Advanced Blending) Blend If: Fondi se: Channels: Canali: Knockout Foratura - -# Swatches panel Create new swatch Crea nuovo campione New Swatch… Nuovo campione… New Swatch Group… Nuovo gruppo di campioni… @@ -2474,8 +2554,6 @@ cameraRaw Amount Quantità Add to Swatches Aggiungi ai campioni Convert to Layers Converti in livelli Apply At Canvas Bounds Applica ai limiti del quadro - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Selettore colore (Colore del testo) Color Picker ({name}) Selettore colore ({name}) Color Picker (Fill Color) Selettore colore (Colore di riempimento) @@ -2492,8 +2570,6 @@ cameraRaw Amount Quantità Show/Hide Panels Mostra/nascondi pannelli Could not open Impossibile aprire {path}: the path is not valid Unicode; rename the file and open it again. {path}: il percorso non è Unicode valido; rinomina il file e riaprilo. - -# Fill dialog, layer style contours and Image Size Contents: Contenuto: Custom Pattern: Pattern personalizzato: Foreground Color Colore primo piano @@ -2527,7 +2603,6 @@ cameraRaw Amount Quantità Click a red pupil to neutralize it Fai clic su una pupilla rossa per correggerla open source open source …and {n} more …e altri {n} - Eyedroppers: Contagocce: Set Black Point Imposta punto nero Set Neutral Gray Point Imposta il punto grigio neutro @@ -2536,8 +2611,6 @@ cameraRaw Amount Quantità Selection Preview: Anteprima selezione: Black Matte Sfondo nero White Matte Sfondo bianco - -# Layer colour labels. Seafoam Schiuma marina Indigo Indaco Fuchsia Fucsia @@ -2557,8 +2630,6 @@ fontWeight Bold Grassetto fontWeight ExtraBold Extragrassetto fontWeight Black Nero fontWeight Italic Corsivo - -# Pattern Stamp tool Pattern Stamp Tool Strumento Timbro pattern Pattern Stamp Timbro pattern Impressionist Impressionista @@ -2566,13 +2637,9 @@ fontWeight Italic Corsivo Rotate View Tool Strumento Ruota vista Reset View Reimposta vista Drag around the centre to rotate the view · Shift constrains to 15° Trascina intorno al centro per ruotare la vista · Maiusc vincola a 15° - -# Painting symmetry presets. Dual Axis Doppio asse Diagonal Diagonale Transform Symmetry Trasforma simmetria - -# Eyedropper Sample Size and Sample (#1649). Point Sample Campione puntiforme 3 by 3 Average Media 3 × 3 5 by 5 Average Media 5 × 5 @@ -2584,15 +2651,12 @@ fontWeight Italic Corsivo All Layers No Adjustments Tutti i livelli senza regolazioni Current & Below No Adjustments Corrente e inferiori senza regolazioni Show Sampling Ring Mostra anello di campionamento - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino Color to Alpha… Colore ad alfa… Transparency Threshold Soglia di trasparenza Opacity Threshold Soglia di opacità PhotoCraft — Screen Color Picker PhotoCraft — Selettore colore schermo - Rasterize svg groups deeper than Rasterizza i gruppi SVG oltre questa profondità 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 disattiva questa soglia; i gruppi SVG annidati oltre 100 livelli vengono sempre rasterizzati. Rule of Thirds Regola dei terzi @@ -2606,8 +2670,6 @@ fontWeight Italic Corsivo Cycle Orientation Cambia orientamento Set the overlay options for the Crop tool Imposta le opzioni di sovrapposizione dello strumento Taglierina 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. L’Alt-clic è andato al desktop, che sposta le finestre con Alt-trascina. Imposta il suo tasto per spostare le finestre su Super, oppure blocca Alt in Finestra › Tasti modificatori. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Selettore colore (colore dello stile livello) Auto Hide Notifications Nascondi automaticamente le notifiche Notification Duration (seconds) Durata della notifica (secondi) @@ -2684,7 +2746,6 @@ fontWeight Italic Corsivo Appearance Settings… Impostazioni aspetto… Add to favorites Aggiungi ai preferiti Remove from favorites Rimuovi dai preferiti -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data Dati fotocamera cameraData Make Marca cameraData Model Modello @@ -2695,7 +2756,6 @@ cameraData ISO Speed Sensibilità ISO cameraData Focal Length Lunghezza focale cameraData Focal Length (35 mm) Lunghezza focale (35 mm) cameraData Lens Obiettivo -# Shared panel docking Activate Panel Attiva pannello Change Panel Group Cambia gruppo di pannelli Change Panel Layout Cambia disposizione dei pannelli @@ -2739,7 +2799,6 @@ cameraData Lens Obiettivo Snap to Origin Aggancia all'origine Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente -# Edit › Content-Aware Fill workspace Sampling Brush Tool Strumento Pennello di campionamento Sampling Area Overlay Sovrapposizione area di campionamento Show Sampling Area Mostra area di campionamento @@ -2755,7 +2814,6 @@ 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 @@ -2772,8 +2830,6 @@ cameraData Lens Obiettivo 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) @@ -2808,7 +2864,6 @@ curves Smooth Leviga Fix the red plane first: its corners must form a convex quad Correggi prima il piano rosso: i suoi angoli devono formare un quadrilatero convesso Alt-click a plane to set the clone source Alt-clic su un piano per impostare l’origine della clonazione Too many clone strokes: apply Vanishing Point first Troppi tratti di clonazione: applica prima Punto di fuga -# Select and Mask view modes (#1754) Onion Skin Velina Marching Ants Formiche in marcia On Black Su nero @@ -2817,7 +2872,6 @@ curves Smooth Leviga Show Edge Mostra bordo Show Original Mostra originale F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Pennello per migliorare il bordo (R) Expand Detection Area Espandi area di rilevamento Restore Original Edge Ripristina bordo originale @@ -2825,19 +2879,13 @@ curves Smooth Leviga Clear Brush Strokes Cancella tracce del pennello Show rulers Mostra righelli Presentation Presentazione - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Consenti cronologia non lineare Make Layer Visibility Changes Undoable Rendi annullabili le modifiche di visibilità dei livelli -# Print dialog (#2697) Emulsion Down Emulsione sotto Functions Funzioni Print Resolution: Risoluzione di stampa: @@ -2849,7 +2897,6 @@ curves Smooth Leviga Apply mask to layer before removing? Applicare la maschera al livello prima di rimuoverla? The layer or its mask changed. Drag the mask onto the Trash again. Il livello o la sua maschera è cambiato. Trascina di nuovo la maschera nel cestino. Delete the vector mask? Eliminare la maschera vettoriale? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} campione esportato in {file}|{n} campioni esportati in {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pennello importato da {group}|{n} pennelli importati da {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} campione importato da {file}|{n} campioni importati da {file} @@ -2864,14 +2911,10 @@ curves Smooth Leviga @plural {n} step|{n} steps {n} passaggio|{n} passaggi @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso sui predefiniti pennello)|{warning} (+{n} altri avvisi sui predefiniti pennello) @plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi -# View › Guides › New Guide… › Color Light Blue Blu chiaro Light Red Rosso chiaro Medium Blue Blu medio Light Gray Grigio chiaro - -# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the -# other catalogs already translate No No Yes Sì @id select.all Seleziona tutto @@ -4013,8 +4056,6 @@ layerLabel No Color Nessun colore Set Colors Imposta colori Switch Foreground and Background Colors Scambia colori di primo piano e sfondo Target Destinazione - -# Path, channel, layer style, status and System Info labels the Czech catalog added Make Work Path… Crea tracciato di lavoro… Create Vector Mask Crea maschera vettoriale Make Selection… Crea selezione… @@ -4057,7 +4098,7 @@ layerLabel No Color Nessun colore Layer Mask Hides Effects La maschera di livello nasconde gli effetti Vector Mask Hides Effects La maschera vettoriale nasconde gli effetti General Blending Fusione generale - This Layer: Questo livello: + Current Layer: Livello corrente: Underlying Layer: Livello sottostante: Nothing to paste: the clipboard holds no image Niente da incollare: gli appunti non contengono un'immagine Preferences saved Preferenze salvate @@ -4178,8 +4219,6 @@ navigator Zoom Zoom Clear Brush Controls Cancella controlli pennello Reset All Locked Settings Ripristina tutte le impostazioni bloccate Copy Texture to Other Tools Copia texture su altri strumenti - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carica azioni… Save Actions… Salva azioni… New Action… Nuova azione… @@ -4191,10 +4230,11 @@ navigator Zoom Zoom @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} passaggio non ha ancora un equivalente in PhotoCraft|{n} passaggi non hanno ancora un equivalente in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop - +@plural {n} action file loaded|{n} action files loaded Caricato {n} file di azioni|Caricati {n} file di azioni + {set} from {file} replaced the one from {earlier} Il set {set} di {file} ha sostituito quello di {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Impossibile caricare {n} file: {errors}|Impossibile caricare {n} file: {errors} File Naming Denominazione file Starting serial Numero di serie iniziale -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Inserisci interruzione… Save Set… Salva set… Message: Messaggio: @@ -4205,17 +4245,15 @@ navigator Zoom Zoom Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo @plural {n} action|{n} actions {n} azione|{n} azioni Background (Default) Sfondo (predefinito) -# Batch / Image Processor Include All Subfolders Includi tutte le sottocartelle Auto Add/Delete Aggiungi/elimina automaticamente - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Non attivare anche Immagine speculare nelle impostazioni di stampa: le due inversioni si annullano. Print Settings… Impostazioni di stampa… Remember to turn off the printer's own colour management in Print Settings… Ricorda di disattivare la gestione del colore della stampante nelle impostazioni di stampa… 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 + Memory usage percent Uso memoria (%) Show Favorite Fonts Mostra font preferiti No favorite fonts yet: click the star beside a font to add it. Ancora nessun font preferito: fai clic sulla stella accanto a un font per aggiungerlo. Favorite fonts Font preferiti @@ -4254,47 +4292,31 @@ navigator Zoom Zoom Could not write {name}: {error} Impossibile scrivere {name}: {error} Kept {name}: written by another application {name} mantenuto: scritto da un’altra applicazione Shadow Tint Tinta ombre -# PhotoCraft release checks. Check for Updates… Cerca aggiornamenti… - PhotoCraft Updates Aggiornamenti di PhotoCraft - Checking for updates… Ricerca aggiornamenti… Open the release page to choose an installer for your system. Apri la pagina della versione per scegliere un programma di installazione per il tuo sistema. - Open Download Page Apri pagina di download - PhotoCraft is up to date. PhotoCraft è aggiornato. - Could not check for updates. Impossibile cercare aggiornamenti. - Updates have not been checked yet. Gli aggiornamenti non sono ancora stati verificati. - Check Again Controlla di nuovo Automatically Check for Updates Cerca automaticamente aggiornamenti - Installed version: {version} Versione installata: {version} - PhotoCraft {version} is available. PhotoCraft {version} è disponibile. -# 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}) Remember this choice for future images Ricorda questa scelta per le prossime immagini Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Riattiva la richiesta con Ask On Mismatch in Modifica › Impostazioni colore. Button Mode Modalità pulsante Button Mode On Modalità pulsante attiva -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Scurisci interfaccia Lighten Interface Schiarisci interfaccia -# Bevel & Emboss Technique. Technique Tecnica 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 + Save Full-Size Composite in .pcraft Files Salva l'immagine composita a dimensione piena nei file .pcraft Adobe-rgb-compat Adobe RGB (compatibile) All shortcuts reset to Photoshop defaults. Tutte le scorciatoie ripristinate ai predefiniti di Photoshop. Arc Lower Arco inferiore @@ -4369,7 +4391,6 @@ navigator Zoom Zoom red rosso violet viola yellow giallo -# Shortcut commands, find and replace, profile prompts and metadata labels the German, Hungarian and Serbian catalogs added Export As: {name} Esporta come: {name} Color Sampler Tool Strumento Campionatore colore Proof Setup… Configurazione prova… @@ -4457,7 +4478,6 @@ layerLabel Red Rosso layers Fill Riempimento layers Fill: Riempimento: 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 @@ -4482,8 +4502,6 @@ 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 @@ -4501,10 +4519,8 @@ shape Appearance Aspetto Undo Changes Annulla modifiche {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata. The transform couldn't be applied. Impossibile applicare la trasformazione. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. I livelli richiedono {needed} di memoria GPU; il budget ne lascia {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica, al massimo 3/8 della sua memoria. Automatico segue Uso memoria, entro un quarto della memoria di questo computer e 3/8 di quella della scheda, e non conta il documento su una scheda la cui memoria è nota. Finché non ci stanno, la barra di stato mostra Memoria GPU piena. GPU Memory Full Memoria GPU piena @@ -4514,6 +4530,7 @@ shape Appearance Aspetto 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) + Scratch Size Limit (MB, 0 = No Limit) Limite dimensioni disco di memoria virtuale (MB, 0 = nessun limite) {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. @@ -4526,8 +4543,6 @@ shape Appearance Aspetto 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}% @@ -4537,17 +4552,12 @@ shape Appearance Aspetto Use Usa In Front Davanti Behind Dietro - -# DDS Options (#1327) 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Il contenuto AI/PDF/EPS verrà modificato come SVG. Il salvataggio sostituisce la sorgente incorporata; le caratteristiche specifiche del formato potrebbero cambiare. # Styles panel: Import / Export Styles (.asl) Import Styles… Importa stili… @@ -4556,7 +4566,6 @@ shape Appearance Aspetto {file} is not a style library (.asl) {file} non è una libreria di stili (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {n} stile importato da {file}|{n} stili importati da {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} {n} stile esportato in {file}|{n} stili esportati in {file} -# 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 @@ -4565,14 +4574,11 @@ shape Appearance Aspetto 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cubo tonalità Brightness Cube Cubo luminosità Color Wheel Ruota dei colori @@ -4588,7 +4594,8 @@ shape Appearance Aspetto Grayscale Ramp Barra scala di grigio Current Colors Colori correnti Make Ramp Web Safe Limita la barra ai colori web -# Art History Brush (#213). + The path was kept as the Work Path Il tracciato è stato conservato come tracciato di lavoro + The path was discarded Il tracciato è stato scartato Art History Brush Tool Strumento Pennello storia artistica Tight Short Corto stretto Tight Medium Medio stretto @@ -4602,3 +4609,26 @@ shape Appearance Aspetto Font size presets Predefiniti dimensione font Leading presets Predefiniti interlinea Tracking presets Predefiniti spaziatura + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Valore numerico non valido. È richiesto un numero intero compreso tra {min}% e {max}%. È stato inserito il valore precedente. + Color Aware In base al colore + Decontaminate Colors Decontamina colori + Edge Detection Rilevamento bordi + Global Refinements Perfezionamenti globali + High Quality Preview Anteprima di alta qualità + Object Aware In base all'oggetto + Real-Time Refinement Perfezionamento in tempo reale + Refine Edge Brush Tool Strumento Pennello per migliorare il bordo + Refine Hair Migliora capelli + Refine Mode Metodo di perfezionamento + Remember Settings Ricorda impostazioni + View Mode Modalità di visualizzazione + Object Aware needs a segmentation model, which isn't available yet In base all'oggetto richiede un modello di segmentazione, non ancora disponibile + Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile + Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo + Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Colori usati di recente + Stop Color Colore del punto + Shapes can't use patterns yet Le forme non possono ancora usare pattern + Edit Brush… Modifica pennello… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 435813b0c7..2eee5145a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -16,18 +16,140 @@ # 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: 着色表示: - Solidity: 濃度: - Ink Characteristics インキの特性 # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Dialogs and panels + +# Layers-panel trash confirmation + + +# Tool names, brush and style option tables + +# Templates with placeholders + +# Preferences (labels are generated from the setting names) + +# Added with the rebase onto the dock / brush picker work + +# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). +# Gradient Editor window, opened from the options bar's gradient swatch. +# Rendering mode and recovery +# Camera Raw: contextual controls and window title + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + +# Generated labels: filter/gallery parameter names, option choices and gallery names (camelCase keys shown as words) (added 2026-10, checked against the code) + +# Command, tool and menu names found in the code but not yet in this catalog (added 2026-10, checked against the code) + +# Remaining UI strings: hints, preferences, metadata fields, colour spaces, snapshots (added 2026-10, checked against the code) + +# Command id overrides + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Liquify tool names (shown via tl!(tool.label()) in the UI, so they are not found by the static scan) + +# Panel menu items and section labels passed to tl!() as variables (channels panel, paths panel, layer style) + +# Character panel tooltip (shown once the tooltip helpers in type_tool.rs pass their text through tl!) + +# Glyphs panel "Show" categories (text/src/glyphs.rs; the dropdown passes them through tl!) + +# Dialog field labels generated from parameter names (Warp Text, Trim, Calculations, Apply Image; not seen by the tl! scanner) + +# Canvas background menu, Blend If labels and Advanced Blending options + +# Native Windows printing qualification +# Restored rows (custom print paper #3820, brush presets, Vanishing Point help, brush panel menu) +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). +# Smart Filter row actions (#1265) + + Mask Options… マスクオプション… + Layer Mask Display Options… レイヤーマスクの表示オプション… + Channel Color チャンネルカラー + Color Indicates: 着色表示: + Solidity: 濃度: + Ink Characteristics インキの特性 Paper Width 用紙の幅 Paper Height 用紙の高さ Rows 行 @@ -45,7 +167,6 @@ 5 Columns, 3 Rows 5列、3行 8 Columns 8列 12 Columns 12列 - File ファイル Edit 編集 Image 画像 @@ -453,6 +574,7 @@ Intersect Shape Areas シェイプの重なりを抽出 Exclude Overlapping Shapes シェイプの重なりを除外 Merge Shape Components シェイプの構成要素を結合 + Path operations パスの操作 Align 整列 Top Edges 上端 Vertical Centers 垂直方向の中央 @@ -532,7 +654,6 @@ Color Range… 色域指定… Focus Area… 焦点領域… Select and Mask… 選択とマスク… -# Select and Mask dialog labels Smart Radius スマート半径 Shift Edge エッジをシフト Decontaminate 混入を除去 @@ -559,7 +680,6 @@ Blur ぼかし Blur Gallery ぼかしギャラリー Field Blur… フィールドぼかし… -# Field Blur pins (#3373) No pin selected ピンが選択されていません Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 画像をクリックしてピンを追加します。ピンをドラッグして移動し、リングをドラッグしてぼかしを変更します。Delete キーで選択したピンを削除します。 Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 輪郭上のハンドルをドラッグしてサイズ変更と回転、四角形で角の丸み、内側の点でぼかしの開始位置を設定します。 @@ -824,8 +944,6 @@ Edits 編集 @plural {n} item|{n} items {n} 件 Auto 自動 - -# Dialogs and panels (print to the printer) (プリンターへ出力) + Add variable + 変数を追加 30 frames @ 30 fps 30 フレーム @ 30 fps @@ -839,6 +957,9 @@ Add Layer Mask レイヤーマスクを追加 Add Stop 分岐点を追加 Add a layer style レイヤースタイルを追加 + Move effect up 効果を上へ移動 + Move effect down 効果を下へ移動 + Delete effect 効果を削除 Add a mask (from the selection; {key} inverts) マスクを追加(選択範囲から。{key} で反転) Add an adjustment 色調補正を追加 Add disk ディスクを追加 @@ -1064,8 +1185,6 @@ Delete current channel 現在のチャンネルを削除 Delete current state 現在の状態を削除 Delete effect layer 効果レイヤーを削除 - -# Layers-panel trash confirmation Delete layer ‘{name}’? レイヤー「{name}」を削除しますか? Delete {count} layers? {names} {count} 個のレイヤーを削除しますか? {names} Delete layer? レイヤーを削除しますか? @@ -1074,7 +1193,6 @@ Missing delete command 削除コマンドがありません Invalid delete command 無効な削除コマンドです Layer deleted, but confirmation preference could not be saved: {error} レイヤーは削除されましたが、確認設定を保存できませんでした: {error} - Delete layer レイヤーを削除 Delete layer comp レイヤーカンプを削除 Delete note 注釈を削除 @@ -1107,7 +1225,6 @@ Dots ドット Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ 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)で描くか、選択範囲から作業用パスを作成します。 Drop an image or PSD anywhere to open it. 画像や PSD をここにドロップすると開きます。 @@ -1627,8 +1744,6 @@ No saved presets yet. 保存済みのプリセットはありません。 Error エラー Command コマンド - -# Tool names, brush and style option tables Adjustment layers have nothing to transform 色調補正レイヤーには変形するものがありません Altitude 高度 Android 1080p Android 1080p @@ -1861,8 +1976,6 @@ Yellows イエロー系 Zoom Tool ズームツール {name} Properties {name} のプロパティ - -# Templates with placeholders Revert “{name}” to the last saved version? Your changes will be lost. 「{name}」を最後に保存した状態に戻しますか? 変更は失われます。 Do you want to save the changes you made to “{name}” before quitting? 終了する前に、「{name}」への変更を保存しますか? Do you want to save the changes you made to “{name}” before closing? 閉じる前に、「{name}」への変更を保存しますか? @@ -1883,8 +1996,6 @@ @plural {n} Layer|{n} Layers {n} レイヤー @plural {n} layer|{n} layers {n} レイヤー @plural Group · {n} layer|Group · {n} layers グループ · {n} レイヤー - -# Preferences (labels are generated from the setting names) Additional plugins folder 追加のプラグインフォルダー Adobe Adobe Adobe rgb Adobe RGB @@ -2084,8 +2195,6 @@ Luminosity 輝度 @plural {n} sample|{n} samples サンプル {n} 個 {added}, {removed} subtracted {added}、除外 {removed} 個 - -# Added with the rebase onto the dock / brush picker work Adjustment Preview 色調補正のプレビュー Airbrush: keeps depositing paint while the pointer rests. エアブラシ: ポインターを止めている間も塗料を乗せ続けます。 Angle: 角度: @@ -2118,8 +2227,6 @@ Brush picker ブラシピッカー Erase 消去 Right click with painting tools 描画ツールで右クリック - -# Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). System Info… システム情報… System Info システム情報 More panels その他のパネル @@ -2188,7 +2295,6 @@ Align with layer レイヤーに揃える Reset Alignment 位置をリセット Centre the gradient (offset 0, 0) グラデーションを中央に配置(オフセット 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor グラデーションエディター Gradient picker グラデーションピッカー Save As Preset プリセットとして保存 @@ -2247,7 +2353,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: グリッド: -# Rendering mode and recovery Rendering Mode レンダリングモード Automatic (recommended) 自動(推奨) CPU / Compatibility CPU / 互換性 @@ -2274,7 +2379,6 @@ Tone Histogram 階調ヒストグラム Show Selected Region 選択範囲を表示 cameraRaw Light ライト -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Camera Raw フィルター ({layer}) Camera Raw ({name}) Camera Raw ({name}) cameraRaw Lights 明部 @@ -2289,14 +2393,10 @@ cameraRaw Color カラー Scale or rotate the moved area, then press {key} 移動した範囲を拡大・縮小または回転し、{key} を押します Now drag the selection onto another area 選択範囲を別の場所へドラッグします Edit Type 文字を編集 - - New Style… 新規スタイル… Style {name} saved スタイル {name} を保存しました - Create new style (from the selected type) 新規スタイルを作成(選択したテキストから) Create new style from the selected layer 選択したレイヤーから新規スタイルを作成 - Make Default 既定に設定 Reset to Default 既定値に戻す Vertical Type Tool 縦書き文字ツール @@ -2326,9 +2426,6 @@ cameraRaw Color カラー Magnetic Lasso Tool マグネット選択ツール Frequency 頻度 Use tablet pressure to change pen width タブレットの筆圧を使用してペンの幅を変更 - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About バージョン情報 Contributors コントリビューター Models モデル @@ -2360,8 +2457,6 @@ cameraRaw Color カラー % of all commits 全コミットに占める割合 (%) Lines +/− 行数 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs} 件、コミット {commits} 件、行 +{added} / −{deleted} (Δ {delta})、バイナリ +{binAdded} / −{binDeleted}、{first} – {last} - -# Swatches panel Create new swatch 新規スウォッチを作成 New Swatch… 新規スウォッチ… New Swatch Group… 新規スウォッチグループ… @@ -2371,13 +2466,10 @@ cameraRaw Color カラー Export Swatches for Exchange… 交換用にスウォッチを書き出し… Reset Swatches スウォッチを初期化 Add to Swatches スウォッチに追加 -# Layer Style > Blending Options (Advanced Blending) Blend If: 条件付き合成: Channels: チャンネル: Knockout ノックアウト Apply At Canvas Bounds カンバスの境界に効果を適用 - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) カラーピッカー (文字色) Color Picker ({name}) カラーピッカー({name}) Color Picker (Fill Color) カラーピッカー (塗りつぶしカラー) @@ -2393,8 +2485,6 @@ cameraRaw Color カラー Show/Hide Panels パネルを表示/非表示 Could not open 開けませんでした {path}: the path is not valid Unicode; rename the file and open it again. {path}: パスが有効な Unicode ではありません。ファイル名を変更してから開き直してください。 - -# Fill dialog, layer style contours and Image Size Contents: 内容: Custom Pattern: カスタムパターン: Foreground Color 描画色 @@ -2420,7 +2510,6 @@ cameraRaw Color カラー Rolling Slope - Descending ローリングスロープ - 下降 Rounded Steps 丸い階段 Sawtooth 1 のこぎり波 1 - Red Eye Tool 赤目ツール Red Eye 赤目 Pupil Size 瞳のサイズ @@ -2428,7 +2517,6 @@ cameraRaw Color カラー Click a red pupil to neutralize it 赤い瞳をクリックして補正します open source オープンソース …and {n} more …ほか {n} 件 - Eyedroppers: スポイト: Set Black Point 黒点を設定 Set Neutral Gray Point 中間調のグレーポイントを設定 @@ -2437,8 +2525,6 @@ cameraRaw Color カラー Selection Preview: 選択範囲のプレビュー: Black Matte 黒の背景 White Matte 白の背景 - -# Layer colour labels. Seafoam シーフォーム Indigo インディゴ Fuchsia フクシア @@ -2459,8 +2545,6 @@ fontWeight Bold 太字 fontWeight ExtraBold 特太 fontWeight Black 極太 fontWeight Italic 斜体 - -# Pattern Stamp tool Pattern Stamp Tool パターンスタンプツール Pattern Stamp パターンスタンプ Impressionist 印象派 @@ -2468,13 +2552,9 @@ fontWeight Italic 斜体 Rotate View Tool 表示回転ツール Reset View 表示をリセット Drag around the centre to rotate the view · Shift constrains to 15° 中心の周りをドラッグして表示を回転 · Shift で 15° 単位 - -# Painting symmetry presets. Dual Axis 二軸 Diagonal 斜め Transform Symmetry 対称軸を変形 - -# Eyedropper Sample Size and Sample (#1649). Point Sample ポイントサンプル 3 by 3 Average 3 × 3 平均 5 by 5 Average 5 × 5 平均 @@ -2486,12 +2566,8 @@ fontWeight Italic 斜体 All Layers No Adjustments すべてのレイヤー (色調補正なし) Current & Below No Adjustments 現在のレイヤーとその下 (色調補正なし) Show Sampling Ring サンプリングリングを表示 - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour 印刷の色域外: クリックすると最も近い印刷可能な色になります Not a web colour: click for the nearest web colour Web カラーではありません: クリックすると最も近い Web カラーになります - -# Generated labels: filter/gallery parameter names, option choices and gallery names (camelCase keys shown as words) (added 2026-10, checked against the code) Accented Edges 輪郭の強調 Adjust Interval For Loops ループ用に間隔を調整 Adjustment Properties 色調補正のプロパティ @@ -2855,8 +2931,6 @@ fontWeight Italic 斜体 Wind 風 Wrap 折り返し Zigzag ジグザグ - -# Command, tool and menu names found in the code but not yet in this catalog (added 2026-10, checked against the code) 32-bit Preview Options 32 ビットプレビューオプション Adaptive Wide Angle 広角補正 Add Noise ノイズを加える @@ -3230,8 +3304,6 @@ fontWeight Italic 斜体 Working 作業用 sRGB sRGB {zoom}% {zoom}% - -# Remaining UI strings: hints, preferences, metadata fields, colour spaces, snapshots (added 2026-10, checked against the code) 10 fps 10 fps 15 fps 15 fps 24 fps 24 fps @@ -3534,14 +3606,11 @@ fontWeight Italic 斜体 Working Spaces 作業用スペース {mode} Color · {bits} bit {mode} カラー · {bits} ビット ↑↓ navigate ↵ run esc close ↑↓ 移動 ↵ 実行 esc 閉じる - -# Command id overrides @id select.all すべてを選択 Color to Alpha… カラーを透明に… Transparency Threshold 透明のしきい値 Opacity Threshold 不透明度のしきい値 PhotoCraft — Screen Color Picker PhotoCraft — スクリーンカラーピッカー - Rasterize svg groups deeper than この深さを超える SVG グループをラスタライズ 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 でこのしきい値は無効になります。100 階層を超えて入れ子になった SVG グループは常にラスタライズされます。 Rule of Thirds 三分割法 @@ -3554,8 +3623,6 @@ fontWeight Italic 斜体 Cycle Orientation 方向を切り替え Set the overlay options for the Crop tool 切り抜きツールのオーバーレイオプションを設定 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+クリックはデスクトップに取られました(Alt+ドラッグでウィンドウを移動する設定です)。デスクトップのウィンドウ移動キーを Super に変更するか、ウィンドウ › 修飾キーで Alt を固定してください。 - -# Shared Layer Style color picker Color Picker (Layer Style Color) カラーピッカー (レイヤースタイルの色) Auto Hide Notifications 通知を自動的に隠す Notification Duration (seconds) 通知の表示時間 (秒) @@ -3630,7 +3697,6 @@ fontWeight Italic 斜体 Appearance Settings… 外観設定… Add to favorites お気に入りに追加 Remove from favorites お気に入りから削除 -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make メーカー cameraData Model モデル cameraData Date Taken 撮影日時 @@ -3640,7 +3706,6 @@ cameraData ISO Speed ISO 感度 cameraData Focal Length 焦点距離 cameraData Focal Length (35 mm) 焦点距離 (35 mm 換算) cameraData Lens レンズ -# Shared panel docking Activate Panel パネルをアクティブにする Change Panel Group パネルグループを変更 Change Panel Layout パネルレイアウトを変更 @@ -3684,7 +3749,6 @@ cameraData Lens レンズ Snap to Origin 原点にスナップ Search settings… 設定を検索… No matching settings 一致する設定はありません -# Edit › Content-Aware Fill workspace Sampling Brush Tool サンプリングブラシツール Sampling Area Overlay サンプリング領域のオーバーレイ Show Sampling Area サンプリング領域を表示 @@ -3700,7 +3764,6 @@ cameraData Lens レンズ Mirror ミラー Output Settings 出力設定 Output To 出力先 - Animation Options アニメーションオプション Entire Document ドキュメント全体 Filter Options フィルターオプション @@ -3717,8 +3780,6 @@ cameraData Lens レンズ 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}+クリック: すべてのレイヤー) @@ -3749,7 +3810,6 @@ curves Smooth スムーズ Alt-click a plane to set the clone source Alt キーを押しながら平面をクリックしてコピー元を設定 Too many clone strokes: apply Vanishing Point first コピースタンプのストロークが多すぎます:先に消点を適用してください Fix the red plane first: its corners must form a convex quad 先に赤い面を修正してください: 角が凸四角形になる必要があります -# Select and Mask view modes (#1754) Onion Skin オニオンスキン Marching Ants 点線の境界 On Black 黒地 @@ -3758,7 +3818,6 @@ curves Smooth スムーズ Show Edge エッジを表示 Show Original 元の状態を表示 F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示 -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) 境界線調整ブラシ (R) Expand Detection Area 検出領域を拡張 Restore Original Edge 元のエッジを復元 @@ -3766,19 +3825,13 @@ curves Smooth スムーズ Clear Brush Strokes ブラシストロークを消去 Show rulers 定規を表示 Presentation プレゼンテーション - -# Color Sampler (added with the i18n coverage repair) 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… ヒストリーオプション… -# History panel › History Options… (#2966) Allow Non-Linear History 非線形のヒストリーを許可 Make Layer Visibility Changes Undoable レイヤーの表示/非表示の変更を取り消し可能にする -# Print dialog (#2697) Emulsion Down 乳剤面を下 Functions 機能 Print Resolution: プリント解像度: @@ -3790,7 +3843,6 @@ curves Smooth スムーズ Apply mask to layer before removing? 削除する前にマスクをレイヤーに適用しますか? The layer or its mask changed. Drag the mask onto the Trash again. レイヤーまたはそのマスクが変更されました。もう一度マスクをゴミ箱にドラッグしてください。 Delete the vector mask? ベクトルマスクを削除しますか? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} スウォッチ {n} 個を {file} に書き出しました @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} からブラシ {n} 個を読み込みました @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} からスウォッチ {n} 個を読み込みました @@ -3805,7 +3857,6 @@ curves Smooth スムーズ @plural {n} step|{n} steps {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} 件) -# View › Guides › New Guide… › Color Light Blue ライトブルー Light Red ライトレッド Medium Blue ミディアムブルー @@ -3823,8 +3874,6 @@ curves Smooth スムーズ 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… 新規アクション… @@ -3836,10 +3885,11 @@ curves Smooth スムーズ @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個のステップにはまだ PhotoCraft の対応機能がありません @plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました - +@plural {n} action file loaded|{n} action files loaded アクションファイル {n} 個を読み込みました + {set} from {file} replaced the one from {earlier} {file} のセット {set} が {earlier} のセットを置き換えました +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} 個のファイルを読み込めませんでした: {errors} File Naming ファイル名の付け方 Starting serial 開始番号 -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… ストップを挿入… Save Set… セットを保存… Message: メッセージ: @@ -3850,10 +3900,7 @@ curves Smooth スムーズ Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 Background (Default) 背景(既定) -# Batch / Image Processor Include All Subfolders すべてのサブフォルダーを含める - -# Liquify tool names (shown via tl!(tool.label()) in the UI, so they are not found by the static scan) Forward Warp 前方ワープ Twirl Clockwise 時計回りに渦巻き Twirl Counterclockwise 反時計回りに渦巻き @@ -3862,8 +3909,6 @@ curves Smooth スムーズ Push Left ピクセル移動 Freeze Mask ブラシでマスクを描画 Thaw Mask ブラシでマスクを消去 - -# Panel menu items and section labels passed to tl!() as variables (channels panel, paths panel, layer style) Convert to Alpha Channel アルファチャンネルに変換 Delete Channel チャンネルを削除 Duplicate Channel チャンネルを複製 @@ -3876,11 +3921,7 @@ curves Smooth スムーズ Split Channels チャンネルを分割 Save Path パスを保存 General Blending 通常の描画 - -# Character panel tooltip (shown once the tooltip helpers in type_tool.rs pass their text through tl!) Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) カーニング: メトリクス、オプティカル、または 1/1000 em 単位の値 (キャレット位置で Alt+←/→) - -# Glyphs panel "Show" categories (text/src/glyphs.rs; the dropdown passes them through tl!) Basic Latin 基本ラテン文字 Latin 1 ラテン 1 Latin Extended-A ラテン拡張 A @@ -3891,8 +3932,6 @@ curves Smooth スムーズ Currency 通貨記号 Math Symbols 数学記号 Arrows 矢印 - -# Dialog field labels generated from parameter names (Warp Text, Trim, Calculations, Apply Image; not seen by the tl! scanner) Bend 曲げ Horizontal Distortion 水平方向のゆがみ Vertical Distortion 垂直方向のゆがみ @@ -3905,12 +3944,10 @@ curves Smooth スムーズ Source2 Channel ソース 2 のチャンネル Source2 Invert ソース 2 を反転 R g b RGB - -# Canvas background menu, Blend If labels and Advanced Blending options Dark Gray ダークグレー Medium Gray ミディアムグレー Select Custom Color… カスタムカラーを選択… - This Layer: 現在のレイヤー: + Current Layer: 現在のレイヤー: Underlying Layer: 下のレイヤー: Blend Interior Effects as Group 内部効果をまとめて描画 Blend Clipped Layers as Group クリップされたレイヤーをまとめて描画 @@ -3918,19 +3955,17 @@ curves Smooth スムーズ Layer Mask Hides Effects レイヤーマスクで効果を隠す Vector Mask Hides Effects ベクトルマスクで効果を隠す Auto Add/Delete 自動追加・削除 - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 印刷設定の「鏡像」も有効にしないでください。2回反転すると元に戻ります。 Print Settings… 印刷設定… Remember to turn off the printer's own colour management in Print Settings… 印刷設定でプリンター独自のカラーマネジメントを無効にしてください… Sent to {printer} {printer} に送信しました set in Print Settings… 印刷設定で指定… -# Restored rows (custom print paper #3820, brush presets, Vanishing Point help, brush panel menu) Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 Clear Brush Controls ブラシのコントロールを消去 Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです + Memory usage percent メモリ使用率 (%) Show Favorite Fonts お気に入りのフォントを表示 No favorite fonts yet: click the star beside a font to add it. お気に入りのフォントはまだありません。フォントの横の星をクリックして追加してください。 Favorite fonts お気に入りのフォント @@ -3969,46 +4004,31 @@ curves Smooth スムーズ Could not write {name}: {error} {name} を書き込めません: {error} Kept {name}: written by another application {name} はそのままにしました: 別のアプリケーションが書き込んだファイルです Shadow Tint シャドウの色かぶり補正 -# PhotoCraft release checks. Check for Updates… アップデートを確認… - PhotoCraft Updates PhotoCraft のアップデート - Checking for updates… アップデートを確認中… Open the release page to choose an installer for your system. リリースページでお使いのシステム用のインストーラーを選択してください。 - Open Download Page ダウンロードページを開く - PhotoCraft is up to date. PhotoCraft は最新です。 - Could not check for updates. アップデートを確認できませんでした。 - Updates have not been checked yet. まだアップデートを確認していません。 - Check Again 再確認 Automatically Check for Updates アップデートを自動的に確認 - Installed version: {version} インストール済みのバージョン: {version} - PhotoCraft {version} is available. PhotoCraft {version} が利用可能です。 -# 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}) Remember this choice for future images 今後の画像でもこの選択を使用 Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 確認を再び表示するには、編集 › カラー設定 の Ask On Mismatch をオンにします。 Button Mode ボタンモード Button Mode On ボタンモード オン -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface インターフェイスを暗くする Lighten Interface インターフェイスを明るくする -# Bevel & Emboss Technique. Technique テクニック 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 + Save Full-Size Composite in .pcraft Files .pcraftファイルにフルサイズの合成画像を保存 Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 Bicubic Automatic バイキュービック法 (自動) @@ -4033,8 +4053,6 @@ curves Smooth スムーズ set name セット名 {set} (modified) {set}(変更あり) Show file name in title タイトルにファイル名を表示 - -# Image › Mode › Bitmap (#5559) 50% Threshold 50% しきい値 Pattern Dither パターンディザ Diffusion Dither 拡散ディザ @@ -4052,10 +4070,8 @@ curves Smooth スムーズ Undo Changes 変更を取り消し {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) 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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. レイヤーには {needed} の GPU メモリが必要ですが、予算で使えるのは {budget} です。 Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量(最大でメモリの 3/8)に設定してください。自動はメモリ使用量に従い、このコンピューターのメモリの4分の1とカードの 3/8 の範囲内で、メモリ量がわかるカードではドキュメント分を差し引きません。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 GPU Memory Full GPU メモリ不足 @@ -4065,6 +4081,7 @@ curves Smooth スムーズ 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 = 自動) + Scratch Size Limit (MB, 0 = No Limit) 仮想記憶ディスクのサイズ上限(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} は、ここで開くか保存した後に別のアプリで変更されました。 @@ -4082,8 +4099,6 @@ curves Smooth スムーズ 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 最初のブラシ @@ -4098,18 +4113,13 @@ curves Smooth スムーズ Use 使用 In Front 前面 Behind 背面 - -# DDS Options (#1327) DDS Options DDS オプション 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 アダプター +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS の内容を SVG として編集します。保存すると埋め込みソースが置き換わります。形式固有の機能が変わる場合があります。 # Styles panel: Import / Export Styles (.asl) Import Styles… スタイルを読み込み… @@ -4118,7 +4128,6 @@ curves Smooth スムーズ {file} is not a style library (.asl) {file} はスタイルライブラリ (.asl) ではありません @plural Imported {n} style from {file}|Imported {n} styles from {file} {file} からスタイル {n} 個を読み込みました @plural Exported {n} style to {file}|Exported {n} styles to {file} {file} にスタイル {n} 個を書き出しました -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 先に開いているアクションのステップのダイアログを確定またはキャンセルしてください Pro Medium Gray プロ(中間グレー) Solarized Solarized @@ -4127,14 +4136,11 @@ curves Smooth スムーズ 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 色相キューブ Brightness Cube 明るさキューブ Color Wheel カラーホイール @@ -4150,7 +4156,8 @@ curves Smooth スムーズ Grayscale Ramp グレースケールランプ Current Colors 現在のカラー Make Ramp Web Safe ランプを Web セーフにする -# Art History Brush (#213). + The path was kept as the Work Path パスは作業用パスとして保持されました + The path was discarded パスは破棄されました Art History Brush Tool アートヒストリーブラシツール Tight Short タイト・ショート Tight Medium タイト・ミディアム @@ -4164,3 +4171,29 @@ curves Smooth スムーズ Font size presets フォントサイズのプリセット Leading presets 行送りのプリセット Tracking presets トラッキングのプリセット + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 無効な数値です。{min}% から {max}% までの整数が必要です。以前の値が挿入されました。 + Clear Selection 選択範囲をクリア + Color Aware カラー認識 + Decontaminate Colors 不要なカラーの除去 + Edge Detection エッジの検出 + Global Refinements グローバル調整 + High Quality Preview 高品質プレビュー + Object Aware オブジェクト認識 + Real-Time Refinement リアルタイム調整 + Refine Edge Brush Tool 境界線調整ブラシツール + Refine Hair 髪の毛を調整 + Refine Mode 調整モード + Remember Settings 設定を保存 + View Mode 表示モード + Object Aware needs a segmentation model, which isn't available yet オブジェクト認識にはセグメンテーションモデルが必要ですが、まだ利用できません + Refine Hair needs hair detection, which isn't available yet 髪の毛を調整には髪の毛の検出が必要ですが、まだ利用できません + Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました + Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました + Drag to move the selected path ドラッグして選択したパスを移動 + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker カラーピッカー + Recently Used Colors 最近使用したカラー + Stop Color 分岐点のカラー + Shapes can't use patterns yet シェイプではまだパターンを使用できません + Edit Brush… ブラシを編集… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ab8cb165..43f7dea83e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8,10 +8,115 @@ # 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) + +# Guide layout dialog + +# Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) +# Counted status and panel messages + + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Labels added on main before the Korean/live-switching split. + +# Warp grid presets merged on main in #388. + +# Type layer thumbnails (#598) + +# UI scale steps (#611) + + + + +# Additional generated filter labels and Photoshop-aligned UI terminology. + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# Export As metadata and profile options + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + + Mask Options… 마스크 옵션… + Layer Mask Display Options… 레이어 마스크 표시 옵션… Channel Color 채널 색상 Color Indicates: 색상 표시 내용: Solidity: 견고도: @@ -19,10 +124,6 @@ Selected Areas 선택 영역 Spot Color 별색 Ink Characteristics 잉크 특성 - -# Guide layout dialog - -# Custom print paper (#3820) Paper Width 용지 너비 Paper Height 용지 높이 Columns 열 @@ -41,7 +142,6 @@ 5 Columns, 3 Rows 5열, 3행 8 Columns 8열 12 Columns 12열 - File 파일 Edit 편집 Image 이미지 @@ -439,6 +539,7 @@ Intersect Shape Areas 모양 영역 교차 Exclude Overlapping Shapes 겹치는 모양 제외 Merge Shape Components 모양 구성 요소 병합 + Path operations 패스 작업 Align 정렬 Top Edges 위쪽 가장자리 Vertical Centers 세로 중앙 @@ -518,7 +619,6 @@ Color Range… 색상 범위… Focus Area… 초점 영역… Select and Mask… 선택 및 마스크… -# Select and Mask dialog labels Smart Radius 고급 반경 Shift Edge 가장자리 이동 Decontaminate 오염 제거 @@ -545,7 +645,6 @@ Blur 흐림 효과 Blur Gallery 흐림 효과 갤러리 Field Blur… 필드 흐림 효과… -# Field Blur pins (#3373) No pin selected 선택한 핀 없음 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 이미지를 클릭하여 핀을 추가합니다. 핀을 드래그하여 이동하고, 링을 드래그하여 흐림 정도를 바꿉니다. Delete 키로 선택한 핀을 삭제합니다. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 윤곽선의 핸들을 드래그하여 크기를 바꾸고 회전하고, 사각형으로 모서리를 둥글게 하며, 안쪽 점으로 흐림이 시작되는 위치를 설정합니다. @@ -799,7 +898,6 @@ Statistics… 통계… Refine Strokes 다듬기 획 Edits 편집 -# 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}개를 가져왔습니다 @@ -814,7 +912,6 @@ @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) (프린터로 인쇄) @@ -830,6 +927,9 @@ Add Layer Mask 레이어 마스크 추가 Add Stop 색상 정지점 추가 Add a layer style 레이어 스타일 추가 + Move effect up 효과 위로 이동 + Move effect down 효과 아래로 이동 + Delete effect 효과 삭제 Add a mask (from the selection; {key} inverts) 마스크 추가 (선택 영역에서 만들기; {key} 키로 반전) Add an adjustment 조정 추가 Add disk 디스크 추가 @@ -1062,8 +1162,6 @@ Delete current channel 현재 채널 삭제 Delete current state 현재 상태 삭제 Delete effect layer 효과 레이어 삭제 - -# Layers-panel trash confirmation Delete layer ‘{name}’? 레이어 ‘{name}’을(를) 삭제하시겠습니까? Delete {count} layers? {names} 레이어 {count}개를 삭제하시겠습니까? {names} Delete layer? 레이어를 삭제하시겠습니까? @@ -1072,7 +1170,6 @@ Missing delete command 삭제 명령이 없습니다 Invalid delete command 잘못된 삭제 명령입니다 Layer deleted, but confirmation preference could not be saved: {error} 레이어가 삭제되었지만 확인 설정을 저장할 수 없습니다: {error} - Delete layer 레이어 삭제 Delete layer comp 레이어 구성 요소 삭제 Delete note 메모 삭제 @@ -1105,7 +1202,6 @@ Dots 점 Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요 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)로 그리거나 선택 영역에서 작업 패스를 만드세요. Drop an image or PSD anywhere to open it. 이미지나 PSD를 아무 곳에나 놓아 여세요. @@ -2171,7 +2267,6 @@ Centre the gradient (offset 0, 0) 그레이디언트를 중앙에 배치(오프셋 0, 0) The current gradient (pick one in Window › Gradients) 현재 그레이디언트(창 › 그레이디언트에서 선택) Select a type layer to edit its character and paragraph settings. 문자 및 단락 설정을 편집하려면 문자 레이어를 선택하세요. -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor 그레이디언트 편집기 Gradient picker 그레이디언트 선택기 Save As Preset 사전 설정으로 저장 @@ -2225,8 +2320,6 @@ Opening… 여는 중… Reading… 읽는 중… Working… 작업 중… - -# Labels added on main before the Korean/live-switching split. 8 bit 8비트 Integer 정수 16 bit 16비트 @@ -2286,22 +2379,16 @@ cameraRaw Color 색상 Transform On Drop 놓을 때 변형 Scale or rotate the moved area, then press {key} 이동한 영역의 크기를 조절하거나 회전한 다음 {key} 키를 누르세요 Now drag the selection onto another area 이제 선택 영역을 다른 영역으로 드래그하세요 - -# Warp grid presets merged on main in #388. 3 x 3 3 × 3 4 x 4 4 × 4 5 x 5 5 × 5 Grid: 격자: - -# Type layer thumbnails (#598) Edit Type 문자 편집 Eyedroppers: 스포이드: Set Black Point 검은점 설정 Set Neutral Gray Point 중성 회색점 설정 Set White Point 흰점 설정 Unavailable in this color mode 이 색상 모드에서는 사용할 수 없음 - -# UI scale steps (#611) 75% 75% 80% 80% 85% 85% @@ -2313,15 +2400,10 @@ cameraRaw Color 색상 250% 250% 300% 300% Full-resolution preview unavailable 전체 해상도 미리보기를 사용할 수 없습니다 - - New Style… 새 스타일… Style {name} saved {name} 스타일을 저장했습니다 - Make Default 기본값으로 만들기 Reset to Default 기본값으로 되돌리기 - -# Additional generated filter labels and Photoshop-aligned UI terminology. @id select.all 모두 선택 No patterns 패턴 없음 Enable {name} {name} 사용 @@ -2965,9 +3047,6 @@ cameraRaw Color 색상 Magnetic Lasso Tool 자석 올가미 도구 Frequency 빈도 Use tablet pressure to change pen width 태블릿 압력을 사용하여 펜 폭 변경 - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About 정보 Contributors 기여자 Models 모델 @@ -2998,8 +3077,6 @@ cameraRaw Color 색상 % of all commits 전체 커밋 대비 % Lines +/− 줄 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs}개, 커밋 {commits}개, 줄 +{added} / −{deleted} (Δ {delta}), 바이너리 +{binAdded} / −{binDeleted}, {first} – {last} - -# Swatches panel Create new swatch 새 색상 견본 만들기 New Swatch… 새 색상 견본… New Swatch Group… 새 색상 견본 그룹… @@ -3009,13 +3086,12 @@ cameraRaw Color 색상 Export Swatches for Exchange… 교환용 색상 견본 내보내기… Reset Swatches 색상 견본 재설정 Add to Swatches 색상 견본에 추가 -# Layer Style > Blending Options (Advanced Blending) Blend If: 혼합 조건: + Current Layer: 현재 레이어: + Underlying Layer: 아래 레이어: Channels: 채널: Knockout 녹아웃 Apply At Canvas Bounds 캔버스 경계에 효과 적용 - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) 색상 선택기 (텍스트 색상) Color Picker ({name}) 색상 선택기 ({name}) Color Picker (Fill Color) 색상 선택기 (채우기 색상) @@ -3034,8 +3110,6 @@ cameraRaw Color 색상 {path}: the path is not valid Unicode; rename the file and open it again. {path}: 경로가 올바른 유니코드가 아닙니다. 파일 이름을 바꾼 뒤 다시 여세요. Good 좋음 Best 최적 - -# Fill dialog, layer style contours and Image Size Contents: 내용: Custom Pattern: 사용자 정의 패턴: Foreground Color 전경색 @@ -3060,7 +3134,6 @@ cameraRaw Color 색상 Rolling Slope - Descending 구르는 경사 - 내림차순 Rounded Steps 둥근 계단 Sawtooth 1 톱니 1 - Red Eye Tool 적목 현상 도구 Red Eye 적목 현상 Pupil Size 동공 크기 @@ -3071,8 +3144,6 @@ cameraRaw Color 색상 Selection Preview: 선택 영역 미리 보기: Black Matte 검정 배경 White Matte 흰색 배경 - -# Layer colour labels. Seafoam 바다 거품색 Indigo 남색 Fuchsia 자홍색 @@ -3091,8 +3162,6 @@ fontWeight Bold 굵게 fontWeight ExtraBold 더 굵게 fontWeight Black 아주 굵게 fontWeight Italic 기울임 - -# Pattern Stamp tool Pattern Stamp Tool 패턴 스탬프 도구 Pattern Stamp 패턴 스탬프 Impressionist 인상파 @@ -3100,13 +3169,9 @@ fontWeight Italic 기울임 Rotate View Tool 보기 회전 도구 Reset View 보기 재설정 Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기 회전 · Shift로 15° 단위 제한 - -# Painting symmetry presets. Dual Axis 이중 축 Diagonal 대각선 Transform Symmetry 대칭 변형 - -# Eyedropper Sample Size and Sample (#1649). Point Sample 포인트 샘플 3 by 3 Average 3 × 3 평균 5 by 5 Average 5 × 5 평균 @@ -3118,15 +3183,12 @@ fontWeight Italic 기울임 All Layers No Adjustments 모든 레이어(조정 제외) Current & Below No Adjustments 현재 및 이하(조정 제외) Show Sampling Ring 샘플링 링 표시 - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 Color to Alpha… 색상을 알파로… Transparency Threshold 투명도 한계값 Opacity Threshold 불투명도 한계값 PhotoCraft — Screen Color Picker PhotoCraft — 화면 색상 선택기 - Rasterize svg groups deeper than 이 깊이보다 깊은 SVG 그룹 래스터화 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0이면 이 한계값을 끕니다. 100단계보다 깊이 중첩된 SVG 그룹은 항상 래스터화됩니다. Rule of Thirds 삼등분 법칙 @@ -3139,8 +3201,6 @@ fontWeight Italic 기울임 Cycle Orientation 방향 순환 Set the overlay options for the Crop tool 자르기 도구의 오버레이 옵션 설정 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 클릭이 데스크톱으로 전달되었습니다. 데스크톱이 Alt 드래그로 창을 이동합니다. 창 이동 키를 Super로 바꾸거나 창 › 보조 키에서 Alt를 고정하세요. - -# Shared Layer Style color picker Color Picker (Layer Style Color) 색상 피커 (레이어 스타일 색상) Auto Hide Notifications 알림 자동 숨기기 Notification Duration (seconds) 알림 지속 시간(초) @@ -3220,7 +3280,6 @@ fontWeight Italic 기울임 Appearance Settings… 화면 설정… Add to favorites 즐겨찾기에 추가 Remove from favorites 즐겨찾기에서 제거 -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data 카메라 데이터 cameraData Make 제조사 cameraData Model 모델 @@ -3231,7 +3290,6 @@ cameraData ISO Speed ISO 감도 cameraData Focal Length 초점 거리 cameraData Focal Length (35 mm) 초점 거리(35mm 환산) cameraData Lens 렌즈 -# Shared panel docking Activate Panel 패널 활성화 Change Panel Group 패널 그룹 변경 Change Panel Layout 패널 레이아웃 변경 @@ -3277,7 +3335,6 @@ cameraData Lens 렌즈 Snap to Origin 원점에 스냅 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 -# Edit › Content-Aware Fill workspace Sampling Brush Tool 샘플링 브러시 도구 Sampling Area Overlay 샘플링 영역 오버레이 Show Sampling Area 샘플링 영역 표시 @@ -3293,7 +3350,6 @@ cameraData Lens 렌즈 Mirror 미러 Output Settings 출력 설정 Output To 출력 위치 - Animation Options 애니메이션 옵션 Entire Document 전체 문서 Filter Options 필터 옵션 @@ -3313,8 +3369,6 @@ cameraData Lens 렌즈 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}+클릭: 모든 레이어) @@ -3351,7 +3405,6 @@ curves Smooth 매끄럽게 Fix the red plane first: its corners must form a convex quad 먼저 빨간색 평면을 수정하십시오: 모서리가 볼록 사각형을 이루어야 합니다 Alt-click a plane to set the clone source Alt 키를 누른 채 평면을 클릭하여 복제 소스를 설정하십시오 Too many clone strokes: apply Vanishing Point first 복제 획이 너무 많습니다. 먼저 소실점을 적용하십시오 -# Select and Mask view modes (#1754) Onion Skin 어니언 스킨 Marching Ants 점선 윤곽 On Black 검정 배경 @@ -3360,7 +3413,6 @@ curves Smooth 매끄럽게 Show Edge 가장자리 표시 Show Original 원본 표시 F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시 -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) 가장자리 다듬기 브러시 (R) Expand Detection Area 감지 영역 확장 Restore Original Edge 원래 가장자리 복원 @@ -3368,22 +3420,16 @@ curves Smooth 매끄럽게 Clear Brush Strokes 브러시 획 지우기 Show rulers 눈금자 표시 Presentation 프레젠테이션 - -# Color Sampler (added with the i18n coverage repair) 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… 작업 내역 옵션… -# History panel › History Options… (#2966) Allow Non-Linear History 비선형 작업 내역 허용 Make Layer Visibility Changes Undoable 레이어 표시 여부 변경을 실행 취소할 수 있게 하기 -# Print dialog (#2697) Emulsion Down 감광면 아래로 Functions 기능 Print Resolution: 인쇄 해상도: @@ -3395,7 +3441,6 @@ curves Smooth 매끄럽게 Apply mask to layer before removing? 제거하기 전에 마스크를 레이어에 적용하시겠습니까? The layer or its mask changed. Drag the mask onto the Trash again. 레이어 또는 마스크가 변경되었습니다. 마스크를 휴지통으로 다시 드래그하세요. Delete the vector mask? 벡터 마스크를 삭제하시겠습니까? -# View › Guides › New Guide… › Color Light Blue 연한 파랑 Light Red 연한 빨강 Medium Blue 중간 파랑 @@ -3417,8 +3462,6 @@ curves Smooth 매끄럽게 Clear Brush Controls 브러시 제어 지우기 Reset All Locked Settings 모든 잠긴 설정 재설정 Copy Texture to Other Tools 다른 도구로 텍스처 복사 - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 액션 불러오기… Save Actions… 액션 저장… New Action… 새 액션… @@ -3430,10 +3473,11 @@ curves Smooth 매끄럽게 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n}개 단계에는 아직 PhotoCraft에 해당하는 기능이 없습니다 @plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 - +@plural {n} action file loaded|{n} action files loaded 액션 파일 {n}개를 불러왔습니다 + {set} from {file} replaced the one from {earlier} {file}의 세트 {set}이(가) {earlier}의 세트를 대체했습니다 +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} 파일 {n}개를 불러오지 못했습니다: {errors} File Naming 파일 이름 지정 Starting serial 시작 일련번호 -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 정지 삽입… Save Set… 세트 저장… Message: 메시지: @@ -3444,17 +3488,15 @@ curves Smooth 매끄럽게 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 Background (Default) 배경(기본값) -# Batch / Image Processor Include All Subfolders 모든 하위 폴더 포함 Auto Add/Delete 자동 추가/삭제 - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 인쇄 설정에서도 이미지 반전을 켜지 마세요. 두 번 반전하면 원래대로 돌아갑니다. Print Settings… 인쇄 설정… Remember to turn off the printer's own colour management in Print Settings… 인쇄 설정에서 프린터 자체의 색상 관리를 끄세요… Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + Memory usage percent 메모리 사용 비율(%) Show Favorite Fonts 즐겨찾는 글꼴 표시 No favorite fonts yet: click the star beside a font to add it. 아직 즐겨찾는 글꼴이 없습니다. 글꼴 옆의 별을 클릭하여 추가하세요. Favorite fonts 즐겨찾는 글꼴 @@ -3493,51 +3535,33 @@ curves Smooth 매끄럽게 Could not write {name}: {error} {name}을(를) 쓸 수 없음: {error} Kept {name}: written by another application {name}을(를) 유지함: 다른 응용 프로그램이 작성한 파일 Shadow Tint 그림자 색조 -# PhotoCraft release checks. Check for Updates… 업데이트 확인… - PhotoCraft Updates PhotoCraft 업데이트 - Checking for updates… 업데이트 확인 중… Open the release page to choose an installer for your system. 릴리스 페이지에서 시스템에 맞는 설치 프로그램을 선택하세요. - Open Download Page 다운로드 페이지 열기 - PhotoCraft is up to date. PhotoCraft가 최신 버전입니다. - Could not check for updates. 업데이트를 확인할 수 없습니다. - Updates have not been checked yet. 아직 업데이트를 확인하지 않았습니다. - Check Again 다시 확인 Automatically Check for Updates 자동으로 업데이트 확인 - Installed version: {version} 설치된 버전: {version} - PhotoCraft {version} is available. PhotoCraft {version}을 사용할 수 있습니다. -# 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}) Remember this choice for future images 이후 이미지에도 이 선택 기억 Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 편집 › 색상 설정의 Ask On Mismatch로 이 확인을 다시 켤 수 있습니다. Button Mode 단추 모드 Button Mode On 단추 모드 켜짐 - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface 인터페이스 어둡게 Lighten Interface 인터페이스 밝게 -# Bevel & Emboss Technique. Technique 기법 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 + Save Full-Size Composite in .pcraft Files .pcraft 파일에 전체 크기 합성 이미지 저장 Color Profile 색상 프로필 EXIF EXIF - -# View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 Make Selection 선택 영역 만들기 @@ -3563,8 +3587,6 @@ curves Smooth 매끄럽게 set name 세트 이름 {set} (modified) {set}(수정됨) Show file name in title 제목에 파일 이름 표시 - -# Image › Mode › Bitmap (#5559) 50% Threshold 50% 한계값 Pattern Dither 패턴 디더 Diffusion Dither 확산 디더 @@ -3582,11 +3604,9 @@ curves Smooth 매끄럽게 Undo Changes 변경 사항 취소 {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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(최대 카드 메모리의 3/8). 자동은 메모리 사용량을 따르며, 이 컴퓨터 메모리의 4분의 1과 카드 메모리의 3/8 이내이고, 메모리를 알 수 있는 카드에서는 문서를 차감하지 않습니다. 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. GPU Memory Full GPU 메모리 부족 @@ -3596,6 +3616,7 @@ curves Smooth 매끄럽게 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 = 자동) + Scratch Size Limit (MB, 0 = No Limit) 임시 디스크 크기 제한(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}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. @@ -3613,8 +3634,6 @@ curves Smooth 매끄럽게 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 첫 번째 브러시 @@ -3630,18 +3649,13 @@ curves Smooth 매끄럽게 In Front 앞 Behind 뒤 Blend 혼합 - -# DDS Options (#1327) DDS Options DDS 옵션 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 어댑터 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS 내용을 SVG로 편집합니다. 저장하면 포함된 원본이 교체되며 형식별 기능이 변경될 수 있습니다. # Styles panel: Import / Export Styles (.asl) Import Styles… 스타일 가져오기… @@ -3650,7 +3664,6 @@ curves Smooth 매끄럽게 {file} is not a style library (.asl) {file}은(는) 스타일 라이브러리(.asl)가 아닙니다 @plural Imported {n} style from {file}|Imported {n} styles from {file} {file}에서 스타일 {n}개를 가져왔습니다 @plural Exported {n} style to {file}|Exported {n} styles to {file} 스타일 {n}개를 {file}(으)로 내보냈습니다 -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 먼저 열려 있는 액션 단계 대화 상자를 확인하거나 취소하세요 Pro Medium Gray 프로 중간 회색 Solarized Solarized @@ -3659,14 +3672,11 @@ curves Smooth 매끄럽게 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 색조 큐브 Brightness Cube 밝기 큐브 Color Wheel 색상환 @@ -3683,7 +3693,8 @@ curves Smooth 매끄럽게 Grayscale Ramp 회색 음영 램프 Current Colors 현재 색상 Make Ramp Web Safe 램프를 웹 안전 색상으로 만들기 -# Art History Brush (#213). + The path was kept as the Work Path 패스가 작업 패스로 유지되었습니다 + The path was discarded 패스가 삭제되었습니다 Art History Brush 아트 히스토리 브러시 Art History Brush Tool 아트 히스토리 브러시 도구 Tight Short 타이트 숏 @@ -3695,8 +3706,6 @@ curves Smooth 매끄럽게 Tight Curl 타이트 컬 Loose Curl 루즈 컬 Stylized dabs from a history state 작업 내역 상태에서 양식화된 찍기 - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio 종횡비 Category 카테고리 Target 대상 @@ -3704,3 +3713,30 @@ curves Smooth 매끄럽게 Font size presets 글꼴 크기 사전 설정 Leading presets 행간 사전 설정 Tracking presets 자간 사전 설정 + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 잘못된 숫자 입력입니다. {min}%에서 {max}% 사이의 정수가 필요합니다. 이전 값이 입력되었습니다. + Clear Selection 선택 영역 지우기 + Color Aware 색상 인식 + Decontaminate Colors 색상 정화 + Edge Detection 가장자리 감지 + Global Refinements 전역 다듬기 + High Quality Preview 고품질 미리 보기 + Object Aware 개체 인식 + Real-Time Refinement 실시간 다듬기 + Refine Edge Brush Tool 가장자리 다듬기 브러시 도구 + Refine Hair 머리카락 다듬기 + Refine Mode 다듬기 모드 + Remember Settings 설정 기억 + View Mode 보기 모드 + Object Aware needs a segmentation model, which isn't available yet 개체 인식에는 분할 모델이 필요하지만 아직 사용할 수 없습니다 + Refine Hair needs hair detection, which isn't available yet 머리카락 다듬기에는 머리카락 감지가 필요하지만 아직 사용할 수 없습니다 + Select and Mask was closed: its document is no longer the active one 선택 및 마스크가 닫혔습니다: 해당 문서가 더 이상 활성 문서가 아닙니다 + Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다 + Select Layer Under Cursor 커서 아래 레이어 선택 + Drag to move the selected path 드래그하여 선택한 패스 이동 + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker 색상 선택기 + Recently Used Colors 최근 사용한 색상 + Stop Color 정지점 색상 + Shapes can't use patterns yet 모양에는 아직 패턴을 사용할 수 없습니다 + Edit Brush… 브러시 편집… diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 10332ced7c..f5e18da1fd 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -230,7 +230,19 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, P #[test] fn first_launch_renders_the_system_language_without_overwriting_auto() { - for (tag, code) in [("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr")] { + for (tag, code) in [ + ("en-US", "en"), + ("zh-CN", "zh-hans"), + ("ja-JP", "ja"), + ("ko-KR", "ko"), + ("ru-RU", "ru"), + ("uk-UA", "uk"), + ("fr-CA", "fr"), + ("sv-SE", "sv"), + ("fi-FI", "fi"), + ("da-DK", "da"), + ("nb-NO", "nb"), + ] { system::with_system_tags(&[tag], || { with_language(Lang::EN, || { let h = harness(); @@ -244,7 +256,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() { #[test] fn missing_or_unsupported_system_languages_render_english() { - for tags in [vec![], vec!["sv-SE", "ar-SA"]] { + for tags in [vec![], vec!["sw-KE", "is-IS"]] { system::with_system_tags(&tags, || { with_language(Lang::EN, || { let h = harness(); @@ -449,3 +461,44 @@ fn czech_shortcut_conflicts_keep_warning_state_and_shortcut_codes() { assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O")); }); } + +/// The catalog stays in logical order: egui puts a right-to-left label into display order itself, +/// so the first word of an Arabic label lands to the right of its last, and a Latin brand name at +/// the end of the sentence lands at the left. +#[test] +fn arabic_labels_are_laid_out_right_to_left() { + with_language(Lang::EN, || { + let mut h = harness(); + h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "ar"})).unwrap(); + h.run_steps(4); + let x_of = |text: &str, c: char| { + let galley = h.ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), egui::FontId::proportional(13.0), egui::Color32::WHITE)); + galley.rows.iter().flat_map(|r| r.glyphs.iter()).find(|g| g.chr == c).map(|g| g.pos.x).expect("glyph") + }; + let label = tr(lang("ar"), "No properties"); + assert_eq!(label, "لا توجد خصائص"); + assert!(x_of(label, 'ل') > x_of(label, 'ص'), "{label}: first word at the right"); + let join = tr(lang("ar"), "Join us on Discord"); + assert!(x_of(join, 'D') < x_of(join, 'ن'), "{join}: Discord at the left"); + }); +} + +/// #5159 on egui 0.37: user names are handed to egui as typed, and egui orders the words. An +/// Arabic layer name reads from the right; the title line keeps "— PhotoCraft" at the right of a +/// right-to-left document name. +#[test] +fn arabic_names_are_laid_out_right_to_left() { + with_language(Lang::EN, || { + let h = harness(); + let font = egui::FontId::proportional(13.0); + let x_in = |galley: &egui::Galley, c: char| galley.rows.iter().flat_map(|r| r.glyphs.iter()).find(|g| g.chr == c).map(|g| g.pos.x).expect("glyph"); + let name = "لاشي مكتوب هنا"; + let galley = h.ctx.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), egui::Color32::WHITE)); + assert!(x_in(&galley, 'ل') > x_in(&galley, 'ه'), "{name}: first word at the right"); + let title = "لاشي مكتوب.png — PhotoCraft"; + let job = crate::panels::title_bar::title_job(title, font, egui::Color32::WHITE); + let galley = h.ctx.fonts_mut(|f| f.layout_job(job)); + assert!(x_in(&galley, 'ل') > x_in(&galley, 'م'), "{title}: the name's first word right of its second"); + assert!(x_in(&galley, 'P') > x_in(&galley, 'ل'), "{title}: PhotoCraft stays at the right"); + }); +} diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv new file mode 100644 index 0000000000..d7232004b6 --- /dev/null +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -0,0 +1,4775 @@ +# Norwegian Bokmål (nb) 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.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. +# +# Combined from the community Norwegian Bokmål catalogs in #8072 and #9025: each row takes the best +# of their translations, with one term per concept across the UI (lag, utvalg, maske, gradient, +# myk kant, transformer, rutenett, hjelpelinje, tegneflate, lerret, klippemaske, arbeidsområde, +# pensel, justering, kanal, bane, historikk, alternativer, forvalg, strek, sløring, hurtigtaster …). +# Clean-room: written from the plain meaning of the English labels; no proprietary localisation +# resource was consulted or copied. Labels use sentence case, commands are imperatives and the +# user is addressed as "du". Product, format and technology names (PhotoCraft, ArtCraft, OpenType, +# CMYK, Lab …) and key caps keep their spelling; the trailing "…" of dialog commands is kept. +# Decimal commas and a space before % follow Norwegian typography. Plural rule: one|other +# (n != 1 -> other); nouns like "lag" (layer) have the same form in singular and plural. +# Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskealternativer… + Layer Mask Display Options… Visningsalternativer for lagmaske… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfarge + Color Indicates: Farge angir: + Solidity: Tetthet: + Ink Characteristics Trykkfargeegenskaper + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Papirbredde + Paper Height Papirhøyde + Rows Rader + Number Antall + Margin Marg + Top: Topp: + Left: Venstre: + Bottom: Bunn: + Right: Høyre: + Target: Mål: + Selected Artboards Valgte tegneflater + Center Columns Sentrer kolonner + Clear Existing Guides Fjern eksisterende hjelpelinjer + Invalid guide layout Ugyldig hjelpelinjeoppsett + Leave empty for automatic size La stå tomt for automatisk størrelse + 5 Columns, 3 Rows 5 kolonner, 3 rader + 8 Columns 8 kolonner + 12 Columns 12 kolonner + + File Fil + Edit Rediger + Image Bilde + Layer Lag + Type Tekst + Select Velg + Selected Valgt + Scope Omfang + Top Left Øverst til venstre + Top Right Øverst til høyre + Bottom Left Nederst til venstre + Bottom Right Nederst til høyre + Filter Filter + View Vis + Window Vindu + Help Hjelp + +# File + New… Ny… + New from Clipboard Ny fra utklippstavlen + New Ny + Open… Åpne… + Open Åpne + Open As… Åpne som… + Open EXR Parts… Åpne EXR-deler… + Add beauty precomp from the AOVs Legg til beauty-forkomposisjon fra AOV-ene + Both Begge + Left Venstre + Right Høyre + Open Recent Åpne nylig brukte + Clear Recent Files Tøm nylig brukte filer + Remove from Recent Fjern fra nylig brukte + Remove {name} from Recent Fjern {name} fra nylig brukte + Close Lukk + Minimize Minimer + Maximize Maksimer + Restore Gjenopprett + Close All Lukk alle + Close Others Lukk andre + Save Lagre + Save As… Lagre som… + Save a Copy… Lagre en kopi… + {name} can't keep everything in this document {name} kan ikke bevare alt i dette dokumentet + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Velg et format som bevarer det, for eksempel Photoshop (PSD), eller lagre en kopi. + Save… Lagre… + Revert Tilbakestill til lagret + Import Importer + Import Presets Importer forvalg + Export As… Eksporter som… + Export Eksporter + Export… Eksporter… + Quick Export As PNG Hurtigeksport som PNG + Quick Export as PNG Hurtigeksport som PNG + Place Embedded… Plasser innebygd… + Place Linked… Plasser koblet… + Package… Pakk… + Save for Web (Legacy)… Lagre for web (eldre)… + Generate Generer + Image Assets Bilderessurser + +# Edit + Undo Angre + Redo Gjør om + Fade… Ton ned… + Cut Klipp ut + Copy Kopier + Copy Merged Kopier sammenslått + Paste Lim inn + Paste Special Lim inn spesielt + Paste Into Lim inn i + Paste Outside Lim inn utenfor + Paste in Place Lim inn på samme sted + Clear Fjern + Search Søk + Search… Søk… + Check Spelling… Stavekontroll… + Fill… Fyll… + Stroke… Strek… + Content-Aware Fill… Innholdsbasert fyll… + Delete and Fill Selection Slett og fyll utvalg + Define Brush Preset… Definer penselforvalg… + Define Pattern… Definer mønster… + Define Custom Shape… Definer egendefinert form… + Purge Tøm + +# Image + Image Size… Bildestørrelse… + Image Size Bildestørrelse + Canvas Size… Lerretstørrelse… + Image Rotation Bilderotasjon + Rotate 90° Clockwise Roter 90° med klokken + Rotate 90° Counter Clockwise Roter 90° mot klokken + Rotate 180° Roter 180° + Flip Canvas Horizontal Speilvend lerretet vannrett + Flip Canvas Vertical Speilvend lerretet loddrett + Crop Beskjær + Crop and Straighten Photos Beskjær og rett opp bilder + Trim… Fjern kanter… + Reveal All Vis alt + Hide Selection Skjul utvalg + Auto Tone Autotone + Auto Contrast Autokontrast + Auto Color Autofarge + Adjustments Justeringer + 16 Bits/Channel 16 biter/kanal + 32 Bits/Channel 32 biter/kanal + 8 Bits/Channel 8 biter/kanal + 8 bit 8 biter + Integer Heltall + 16 bit 16 biter + 32 bit (float) 32 biter (flyttall) + Floating point Flyttall + Mode Modus + Grayscale Gråtoner + RGB Color RGB-farge + CMYK Color CMYK-farge + Lab Color Lab-farge + Indexed Color Indeksert farge + Multichannel Flerkanal + Bitmap… Punktgrafikk… + Bitmap Punktgrafikk + Duotone… Duotone… + Duotone Duotone + Trap… Overlapping… + +# Layer + Layer via Copy Lag via kopiering + Layer via Cut Lag via utklipp + Duplicate Layer… Dupliser lag… + Duplicate… Dupliser… + Delete Slett + Delete Layer Slett lag + Delete Layers Slett lag + Rename Layer Gi laget nytt navn + Layer From Background… Lag fra bakgrunn… + Layer from Background… Lag fra bakgrunn… + Background from Layer Bakgrunn fra lag + Background From Layer Bakgrunn fra lag + Group Layers Grupper lag + Group… Gruppe… + Ungroup Layers Opphev gruppering av lag + Group from Layers… Gruppe fra lag… + New Group from Layers… Ny gruppe fra lag… + Hide Layers Skjul lag + Arrange Ordne + Bring to Front Flytt fremst + Bring Forward Flytt fremover + Send Backward Flytt bakover + Send to Back Flytt bakerst + Convert to Smart Object Konverter til smartobjekt + Convert for Smart Filters Konverter for smartfiltre + New Smart Object via Copy Nytt smartobjekt via kopiering + Edit Contents Rediger innhold + Replace Contents… Erstatt innhold… + Export Contents… Eksporter innhold… + Rasterize Rasteriser + Rasterize Type Rasteriser tekst + Rasterize Shape Rasteriser form + Rasterize Layer Rasteriser lag + Rasterize Layers Rasteriser lag + New Adjustment Layer Nytt justeringslag + New Fill Layer Nytt fyllag + Layer Mask Lagmaske + Add Layer Mask Legg til lagmaske + Delete Layer Mask Slett lagmaske + Enable Layer Mask Aktiver lagmaske + Disable Layer Mask Deaktiver lagmaske + Apply Layer Mask Bruk lagmaske + Add Mask to Selection Legg maske til utvalget + Subtract Mask from Selection Trekk maske fra utvalget + Intersect Mask with Selection Snitt av maske og utvalg + Link Layer Mask Koble lagmaske + Unlink Layer Mask Koble fra lagmaske + Vector Mask Vektormaske + Add Vector Mask Legg til vektormaske + Delete Vector Mask Slett vektormaske + Enable Vector Mask Aktiver vektormaske + Link Vector Mask Koble vektormaske + Create Clipping Mask Opprett klippemaske + Release Clipping Mask Frigjør klippemaske + New Shape Layer Nytt formlag + New Type Layer Nytt tekstlag + New Video Layer from File… Nytt videolag fra fil… + New Blank Video Layer Nytt tomt videolag + Smart Filters Smartfiltre + Delete Smart Filters Slett smartfiltre + Enable Smart Filters Aktiver smartfiltre + Disable Smart Filters Deaktiver smartfiltre + Clear Smart Filters Fjern smartfiltre + Delete Filter Mask Slett filtermaske + Disable Filter Mask Deaktiver filtermaske + New Layer Based Slice Nytt lagbasert segment + Layer Style Lagstil + Blending Options… Blandingsalternativer… + Blending Options Blandingsalternativer + Drop Shadow… Slagskygge… + Inner Shadow… Indre skygge… + Outer Glow… Ytre glød… + Inner Glow… Indre glød… + Bevel & Emboss… Skråkant og relieff… + Use Global Light Bruk globalt lys + Overprint Overtrykk + Satin… Sateng… + Color Overlay… Fargeoverlegg… + Gradient Overlay… Gradientoverlegg… + Pattern Overlay… Mønsteroverlegg… + Bevel & Emboss Skråkant og relieff + Inner Shadow Indre skygge + Inner Glow Indre glød + Satin Sateng + Color Overlay Fargeoverlegg + Gradient Overlay Gradientoverlegg + Pattern Overlay Mønsteroverlegg + Outer Glow Ytre glød + Drop Shadow Slagskygge + Copy Layer Style Kopier lagstil + Paste Layer Style Lim inn lagstil + Clear Layer Style Fjern lagstil + Layer Content Options… Alternativer for laginnhold… + Scale Effects… Skaler effekter… + New Layer… Nytt lag… + Delete Layer… Slett lag… + +# Type + Font Skrift + Font Preview Size Forhåndsvisningsstørrelse for skrift + Orientation Retning + Horizontal Vannrett + Vertical Loddrett + Rasterize Type Layer Rasteriser tekstlag + Create Work Path Opprett arbeidsbane + Convert to Shape Konverter til form + Convert to Paragraph Text Konverter til avsnittstekst + Convert to Point Text Konverter til punkttekst + Warp Text… Fordrei tekst… + Character Panel Tegnpanel + Paragraph Panel Avsnittspanel + Check Spelling Stavekontroll + Language Options Språkalternativer + Find and Replace Text… Søk og erstatt tekst… + Add to Dictionary Legg til i ordlisten + No Spelling Suggestions Ingen staveforslag + Paste Lorem Ipsum Lim inn lorem ipsum + Glyphs Panel Glyfpanel + Type Styles Tekststiler + Character Styles Tegnstiler + Paragraph Styles Avsnittsstiler + Load Default Type Styles Last inn standard tekststiler + Save Default Type Styles Lagre standard tekststiler + Type… Tekst… + +# Select + All Alle + Deselect Opphev utvalg + Reselect Velg på nytt + Inverse Selection Inverter utvalget + Inverse Inverter + Color Range… Fargeområde… + Select and Mask… Velg og masker… +# Select and Mask dialog labels + Smart Radius Smart radius + Shift Edge Forskyv kant + Decontaminate Fjern fargesmitte + Output Utdata + Refine Edge Forbedre kant + Select Subject Velg motiv + Sky Himmel + Focus Area… Fokusområde… + Subject Motiv + Color Range Fargeområde + Similar Lignende + Grow Voks + Select Similar Colors Velg like farger + Load Selection… Last inn utvalg… + Save Selection… Lagre utvalg… + New Selection Nytt utvalg + Select All Velg alt + Deselect All Opphev alle valg + Deselect Layers Opphev lagvalg + Find Layers Finn lag + Isolate Layers Isoler lag + Select Linked Layers Velg koblede lag + All Layers Alle lag + Current & Below Gjeldende og under + Current Layer Gjeldende lag + Refine Strokes Forbedre strøk + Edits Redigeringer + +# Filter + Last Filter Siste filter + Filter Gallery… Filtergalleri… + Liquify… Gjør flytende… + Camera Raw Filter… Camera Raw-filter… + Adaptive Wide Angle… Adaptiv vidvinkel… + Lens Correction… Objektivkorreksjon… + Vanishing Point… Fluktpunkt… + Oil Paint… Oljemaling… + Blur Gallery Sløringsgalleri + Field Blur… Feltsløring… +# Field Blur pins (#3373) + No pin selected Ingen nål valgt + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klikk i bildet for å legge til en nål. Dra en nål for å flytte den, eller ringen for å endre sløringen. Delete fjerner den valgte nålen. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Dra i håndtakene på omrisset for å endre størrelse og rotere, i firkanten for å runde av hjørnene, og i prikkene innenfor for å angi hvor sløringen starter. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Dra nålen for å flytte båndet, eller ringen for å endre sløringen. Dra i de heltrukne linjene for å gjøre det skarpe båndet bredere, i de stiplede linjene for å angi overgangen, og i prikkene for å rotere det. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Dra i håndtakene på omrisset for å endre størrelse og rotere. Ringen angir sløringsvinkelen. + Blur Angle Sløringsvinkel + Iris Blur… Irissløring… + Tilt-Shift… Tilt-shift… + Path Blur… Banesløring… + Spin Blur… Rotasjonssløring… + Auto-Align Layers… Juster lag automatisk… + Auto-Blend Layers… Bland lag automatisk… + Photomerge… Photomerge… + Content-Aware Scale Innholdsbasert skalering + Adaptive Wide Angle Adaptiv vidvinkel + Apply Image… Bruk bilde… + Calculations… Beregninger… + Crystallize… Krystalliser… + Facet Fasett + Fragment Fragmenter + Mosaic… Mosaikk… + Pointillize… Pointillisme… + Chalk & Charcoal Kritt og trekull + Charcoal Trekull + Chrome Krom + Conté Crayon Conté-kritt + Graphic Pen Grafisk penn + Halftone Pattern Halvtonemønster + Note Paper Notatpapir + Photocopy Fotokopi + Plaster Gips + Reticulation Retikulering + Stamp Stempel + Torn Edges Avrevne kanter + Water Paper Vannpapir + Clouds Skyer + Difference Clouds Differanseskyer + Fibers… Fibre… + Lens Flare… Objektivrefleks… + Picture Frame… Bilderamme… + Tree… Tre… + Flame… Flamme… + Sharpen Edges Skjerp kanter + Dust & Scratches… Støv og riper… + Noise Støy + Add Noise… Legg til støy… + Reduce Noise… Reduser støy… + Despeckle Fjern flekker + Median… Median… + Median Median + Maximum… Maksimum… + Maximum Maksimum + Minimum… Minimum… + Minimum Minimum + Custom… Egendefinert… + High Pass… Høypass… + Emboss… Relieff… + Extrude… Ekstruder… + Find Edges Finn kanter + Solarize Solariser + Tiles… Fliser… + Trace Contour… Tegn kontur… + Wind… Vind… + Offset… Forskyvning… + Pinch… Klyp… + Polar Coordinates… Polarkoordinater… + Ripple… Krusning… + Shear… Skjær… + Spherize… Kuleform… + Twirl… Virvel… + Wave… Bølge… + ZigZag… Sikksakk… + Average Gjennomsnitt + Blur More Slør mer + Box Blur… Bokssløring… + Gaussian Blur… Gaussisk sløring… + Motion Blur… Bevegelsessløring… + Radial Blur… Radial sløring… + Shape Blur… Formsløring… + Smart Blur… Smart sløring… + Surface Blur… Overflatesløring… + Sharpen Skjerp + Sharpen More Skjerp mer + Unsharp Mask… Uskarp maske… + Smart Sharpen… Smart skjerping… + Diffuse… Diffuser… + Mezzotint… Mezzotint… + Color Halftone… Fargeraster… + Layer… Lag… + +# View + Proof Setup Korrekturoppsett + Proof Colors Korrekturfarger + Pixel Aspect Ratio Pikselforhold + Pixel Aspect Ratio Correction Korrigering av pikselforhold + Zoom In Zoom inn + Zoom Out Zoom ut + Fit on Screen Tilpass til skjermen + 100% 100 % + 105% 105 % + 110% 110 % + 115% 115 % + 120% 120 % + 200% 200 % + 75% 75 % + 80% 80 % + 85% 85 % + 90% 90 % + 95% 95 % + 125% 125 % + 150% 150 % + 175% 175 % + 250% 250 % + 300% 300 % + Print Size Utskriftsstørrelse + Screen Mode Skjermmodus + Standard Screen Mode Standard skjermmodus + Full Screen Mode With Menu Bar Fullskjermmodus med menylinje + Full Screen Mode Fullskjermmodus + Extras Ekstra + Show Vis + Rulers Linjaler + Grid Rutenett + Guides Hjelpelinjer + Slices Segmenter + Snap Fest + Snap To Fest til + Lock Guides Lås hjelpelinjer + Clear Guides Fjern hjelpelinjer + New Guide… Ny hjelpelinje… + Lock Slices Lås segmenter + Clear Slices Fjern segmenter + New Guide Layout… Nytt hjelpelinjeoppsett… + New Guides From Shape Nye hjelpelinjer fra form + Canvas rotation Lerretrotasjon + Zoom in Zoom inn + Zoom out Zoom ut + +# Window + Cascade Overlappende + Tile Side om side + 2-up Horizontal 2 vannrett + 3-up Horizontal 3 vannrett + 2-up Vertical 2 loddrett + 3-up Vertical 3 loddrett + 3-up Stacked 3 stablet + 4-up 4 vinduer + 6-up 6 vinduer + Consolidate All to Tabs Samle alle i faner + Match Zoom Samkjør zoom + Match Location Samkjør plassering + Match Rotation Samkjør rotasjon + Match All Samkjør alle + New Window for Document Nytt vindu for dokument + Workspace Arbeidsområde + Extensions Utvidelser + Panels Paneler + Actions Handlinger + Brush Settings Penselinnstillinger + Brushes Pensler + Channels Kanaler + Character Tegn + Clone Source Klonekilde + Color Mixer Fargemikser + Glyphs Glyfer + Gradients Gradienter + Histogram Histogram + History Historikk + Info Info + Layer Comps Lagkomposisjoner + Layers Lag + Measurement Log Målelogg + Notes Notater + Navigator Navigator + Paragraph Avsnitt + Paths Baner + Patterns Mønstre + Properties Egenskaper + Styles Stiler + Swatches Fargeprøver + Timeline Tidslinje + Tool Presets Verktøyforvalg + Tools Verktøy + Libraries Biblioteker + 3D 3D + Toolbars Verktøylinjer + Hide All Skjul alt + +# Help + About PhotoCraft Om PhotoCraft + PhotoCraft Website PhotoCrafts nettsted + PhotoCraft on GitHub PhotoCraft på GitHub + Report an Issue… Rapporter et problem… + ArtCraft Website ArtCraft-nettstedet + Join the ArtCraft Discord… Bli med i ArtCraft-Discord… + System Info… Systeminformasjon… + +# Tools and options bar + Move Flytt + Marquee Markeringsramme + Lasso Lasso + Wand Tryllestav + Eyedropper Pipette + Brush Pensel + Block Blokk + Pencil Blyant + Eraser Viskelær + Clone Stamp Klonestempel + Healing Reparasjon + Blur Sløring + Dodge Avskjerming + Burn Etterbelysning + Gradient Gradient + Shape Form + Pen Penn + Hand Hånd + Zoom Zoom + Sampler Fargemåler + Ruler Tool Linjalverktøy + Count Tool Telleverktøy + Note tool Notatverktøy + Quick Selection Hurtigvalg + Magnetic Lasso Magnetisk lasso + Mixer Brush Mikserpensel + Mixer Brush Tool Mikserpenselverktøy + Color Replacement Tool Fargeerstatningsverktøy + History Brush Historikkpensel + Art History Brush Kunsthistorikkpensel + Pattern Stamp Mønsterstempel + Spot Healing Punktreparasjon + Red Eye Røde øyne + Patch Lapp + Content-Aware Move Innholdsbasert flytting + Rotate View Roter visning + Perspective Crop Perspektivbeskjæring + Color Replacement Fargeerstatning + Dodge/Burn Avskjerming/etterbelysning + Foreground Forgrunn + Background Bakgrunn + Set foreground color Angi forgrunnsfarge + Set background color Angi bakgrunnsfarge + Swap colours (X) Bytt farger (X) + Default colours (D) Standardfarger (D) + Brush Tool Options Alternativer for penselverktøy + Opacity Dekkevne + Flow Flyt + Smoothing Utjevning + Size Størrelse + Hardness Hardhet + Angle: Vinkel: + Roundness: Rundhet: + Spacing Avstand + Dynamics Dynamikk + Pressure Trykk + Tilt Helling + Rotation Rotasjon + Align Juster + Aligned Justert + +# Panels, dialogs and common widgets + OK OK + Cancel Avbryt + Cancel (Esc) Avbryt (Esc) + Manage submitted jobs in the system print queue Behandle innsendte jobber i systemets utskriftskø + Done Ferdig + Apply Bruk + Reset Tilbakestill + Reset Section Tilbakestill seksjon + Reset All to Defaults Tilbakestill alle til standard + Import Shortcuts… Importer hurtigtaster… + Load a keyboard shortcut set saved as .kys Last inn et hurtigtastsett lagret som .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Importerte {n} hurtigtaster fra {file}. Klikk OK for å beholde dem. + {n} commands are not in PhotoCraft. {n} kommandoer finnes ikke i PhotoCraft. + Keyboard shortcuts imported Hurtigtaster importert + 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. Hurtigtastsettet ditt {set} er i bruk her: {n} hurtigtaster avviker fra standard. Rediger › Hurtigtaster viser dem; Tilbakestill alle til standard angrer dette. + Import keyboard shortcuts? Importere hurtigtaster? + 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 fant hurtigtastsettet {set} fra et annet bilderedigeringsprogram på denne datamaskinen: {n} av hurtigtastene avviker fra PhotoCrafts. Importere det? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Rediger › Hurtigtaster › Tilbakestill alle til standard angrer en import. + Don't Import Ikke importer + The keyboard shortcut set was not imported: {error} Hurtigtastsettet ble ikke importert: {error} + Defaults restored. Standarder gjenopprettet. + Restore Defaults Gjenopprett standarder + Restore original size Gjenopprett opprinnelig størrelse + Use Default Bruk standard + Dismiss Lukk + Preview Forhåndsvisning + Options Alternativer + Settings… Innstillinger… + Rename… Gi nytt navn… + Load… Last inn… + Define… Definer… + Custom Egendefinert + Default Standard + None Ingen + Add Legg til + Remove Fjern + Remove Background Fjern bakgrunn + Rename Gi nytt navn + Ignore Ignorer + Ignore All Ignorer alle + Change Endre + Change All Endre alle + Next frame Neste ramme + Previous frame Forrige ramme + Play Spill av + Pause Pause + Stop Stopp + Begin recording Start opptak + Stop playing/recording Stopp avspilling/opptak + Record ● a sequence of edits, then play ▶ it on any document. Ta opp ● en rekke redigeringer, og spill dem av ▶ på et hvilket som helst dokument. + Create new action Opprett ny handling + Play selection Spill av utvalget + Create new set Opprett nytt sett + Export selected measurements (CSV) Eksporter valgte målinger (CSV) + Record Measurements Registrer målinger + Delete selected measurements Slett valgte målinger + Select Data Points… Velg datapunkter… + Set Measurement Scale… Angi måleskala… + Use Measurement Scale Bruk måleskala + +# Preferences dialog + Preferences Innstillinger + General… Generelt… + Interface… Grensesnitt… + File Handling… Filhåndtering… + Performance… Ytelse… + Cursors… Markører… + Transparency & Gamut… Gjennomsiktighet og fargeomfang… + Units & Rulers… Enheter og linjaler… + Guides, Grid & Slices… Hjelpelinjer, rutenett og segmenter… + Plug-ins… Programtillegg… + Technology Previews Teknologiforhåndsvisninger + Scratch Disks… Arbeidsdisker… + Export Preferences… Eksportinnstillinger… + Keyboard Shortcuts… Hurtigtaster… + Menus… Menyer… + Export/Import Presets… Eksporter/importer forvalg… + Additional plugins folder Ekstra mappe for programtillegg + Install Plug-in… Installer programtillegg… + Export Presets Eksporter forvalg + Import brushes (.abr) Importer pensler (.abr) + Migrate Presets Overfør forvalg + Thumbnail size (smallest: list) Miniatyrstørrelse (minste: liste) + Reset GPU Backend Tilbakestill GPU-motor + Applies at next launch. Trer i kraft ved neste oppstart. + +# Document and canvas status bar + Untitled Uten navn + Untitled-1 Uten navn-1 + Doc: {flat}/{layered} Dok: {flat}/{layered} + Scratch: {size} Arbeidsdisk: {size} + Efficiency: 100% Effektivitet: 100 % + Dimensions: Dimensjoner: + Width Bredde + Slant Skråstilling + Height Høyde + Width: Bredde: + Height: Høyde: + Resolution Oppløsning + Resolution: Oppløsning: + Resolution: {dpi} pixels/inch Oppløsning: {dpi} piksler/tomme + {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) + Pixels: Piksler: + Pixel Length: Piksellengde: + Logical Length: Logisk lengde: + Logical Units: Logiske enheter: + Length: Lengde: + Interpolation: Interpolering: + Resample Resampling + Constrain proportions Behold proporsjoner + Link width and height Koble bredde og høyde + Maintain aspect ratio Behold størrelsesforhold + Current Size Gjeldende størrelse + New Size Ny størrelse + Fit Tilpass + Percent Prosent + Pixels/Inch Piksler/tomme + Pixels/Centimeter Piksler/centimeter + Relative Relativ + Canvas extension color: Farge for lerretutvidelse: + Anchor: Anker: + Both Axes Begge akser + Horizontally Vannrett + Vertically Loddrett + +# Layers panel + Layers to Files… Lag til filer… + Duplicate Layers… Dupliser lag… + Group name Gruppenavn + New Group Ny gruppe + Layer Comp Lagkomposisjon + Create new layer Opprett nytt lag + Create new group Opprett ny gruppe + Create new channel Opprett ny kanal + Create new path Opprett ny bane + Toggle visibility Veksle synlighet + Clipped Klippet + Group · {n} layer Gruppe · {n} lag + Lock: keep these settings when picking another preset Lås: behold disse innstillingene når et annet forvalg velges + Unlock: picking a preset replaces these settings Lås opp: valg av et forvalg erstatter disse innstillingene + Show the layer's effects ({key}-click: all layers) Vis lagets effekter ({key}-klikk: alle lag) + Hide the layer's effects ({key}-click: all layers) Skjul lagets effekter ({key}-klikk: alle lag) + Expand group ({key}-click: all groups) Fold ut gruppe ({key}-klikk: alle grupper) + Collapse group ({key}-click: all groups) Fold sammen gruppe ({key}-klikk: alle grupper) + Effects Effekter + Blending Blanding + Blend If Bland hvis + General Blend Generell blanding + Advanced Blending Avansert blanding + Opacity: Dekkevne: + Fill: Fyll: + Lock: Lås: + Smart Filter Smartfilter + Fill Content Fyllinnhold + Adjustment Preview Forhåndsvisning av justering + +# Channels panel + Channel: Kanal: + Channel Mixer… Kanalmikser… + Output Channel: Utdatakanal: + RGB RGB + Red Rød + Green Grønn + Blue Blå + Alpha Alfa + Load channel as selection Last inn kanal som utvalg + Save selection as channel Lagre utvalg som kanal + Delete current channel Slett gjeldende kanal + Spot Channel Spotkanal + Mask All Masker alt + Mask All Objects Masker alle objekter + +# Color picker and swatches + Color Farge + Color: Farge: + Colors Farger + HSB/HSL HSB/HSL + No Color Ingen farge + Set shape fill type Angi fylltype for form + Set shape stroke type Angi strektype for form + Set the text color Angi tekstfargen + Create warped text Opprett fordreid tekst + Only Web Colors Bare webfarger + Overlay Color Overleggsfarge + Overlay Opacity Overleggsdekkevne + Glow Color Glødfarge + Web Snap Webfesting + +# Filters and adjustments dialogs + Brightness Lysstyrke + Contrast Kontrast + Brightness/Contrast… Lysstyrke/kontrast… + Levels… Nivåer… + Curves… Kurver… + Curve Kurve + Exposure… Eksponering… + Exposure Eksponering + Vibrance… Vibrans… + Vibrance Vibrans + Hue/Saturation… Fargetone/metning… + Hue Fargetone + Saturation Metning + Lightness Lyshet + Color Balance… Fargebalanse… + Black & White… Svart-hvitt… + Photo Filter… Fotofilter… + Color Lookup… Fargeoppslag… + Color Lookup Tables… Fargeoppslagstabeller… + 3D LUT File 3D LUT-fil + Load 3D LUT… Last inn 3D LUT… + Invert… Inverter… + Invert Inverter + Invert has no settings. Inverter har ingen innstillinger. + Threshold… Terskel… + Threshold Level Terskelnivå + Posterize… Posteriser… + Gradient Map… Gradienttilordning… + Selective Color… Selektiv farge… + Match Color… Tilpass farge… + Replace Color… Erstatt farge… + Equalize Utjevn + HDR Toning… HDR-toning… + Shadows/Highlights… Skygger/høylys… + Amount Mengde + Radius Radius + Radius: Radius: + Tolerance: Toleranse: + Density Tetthet + Density: Tetthet: + Feather Myk kant + Feather… Myk kant… + Anti-alias Kantutjevning + Anti-Alias Kantutjevning + Contiguous Sammenhengende + Sample All Layers Ta prøve fra alle lag + Protect Foreground Color Beskytt forgrunnsfarge + Matte Kantfarge + Matting Kantbehandling + Color Decontaminate… Fjern fargesmitte… + Remove Black Matte Fjern svart kant + Remove White Matte Fjern hvit kant + Defringe… Fjern frynser… + Source Kilde + Working CMYK Arbeids-CMYK + Working Cyan Plate Arbeidsplate for cyan + Working Magenta Plate Arbeidsplate for magenta + Working Yellow Plate Arbeidsplate for gul + Working Black Plate Arbeidsplate for svart + Working Gray Plate Arbeidsplate for grå + +# Camera Raw and lens dialogs + Basic Grunnleggende + Detail Detalj + HSL/Grayscale HSL/gråtoner + Tone Curve Tonekurve + Split Toning Delt toning + Lens Corrections Objektivkorreksjoner + Camera Calibration Kamerakalibrering + Presets Forvalg + Snapshots Øyeblikksbilder + Temperature Temperatur + Tint Fargetone + Exposure: Eksponering: + Recovery Gjenoppretting + Fill Light Utfyllingslys + Blacks Svarttoner + Clarity Klarhet + Convert to Grayscale Konverter til gråtoner + Auto Auto + Auto collapse icon panels Skjul ikonpaneler automatisk + Auto show hidden panels Vis skjulte paneler automatisk + Auto show home screen Vis startskjermen automatisk + Sharpness Skarphet + Noise Reduction Støyreduksjon + Color Noise Reduction Fargestøyreduksjon + Luminance Detail Luminansdetalj + Color Detail Fargedetalj + Luminance Smoothness Luminansjevnhet + Vignetting Vignettering + White Balance: As Shot Hvitbalanse: som fotografert + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw er ikke åpen + Dehaze Fjern dis + Texture Tekstur + Focal Length Brennvidde + Make Produsent + Model Modell + Lens Objektiv + F F + ISO ISO + Focal Length35mm Brennvidde 35 mm + Aspect Ratio Sideforhold + Time Tid + Artist Kunstner + Copyright Opphavsrett + Enter description… Angi beskrivelse… + Adobe RGB (1998) compatible Adobe RGB (1998)-kompatibel + Gray Gamma 2.2 Grå gamma 2,2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Bransjestandard + Web Web + Internet Standard RGB Internettstandard RGB + Legacy Macintosh RGB Eldre Macintosh RGB + Monitor RGB Skjerm-RGB + Windows LCD Windows LCD + Coated CMYK Bestrøket CMYK + Uncoated CMYK Ubestrøket CMYK + Newspaper Avis + Working Spaces Arbeidsfargerom + Color Management Policies Retningslinjer for fargestyring + Assign working space Tilordne arbeidsfargerom + Leave as is (don't color manage) La være som det er (ikke bruk fargestyring) + Discard the embedded profile (don't color manage) Forkast den innebygde profilen (ikke bruk fargestyring) + Embed Color Profile Bygg inn fargeprofil + Assign Profile… Tilordne profil… + Convert to Profile… Konverter til profil… + PhotoCraft Manages Colors PhotoCraft styrer farger + Printer Manages Colors Skriveren styrer farger + (print to the printer) (skriv ut på skriveren) + Absolute Colorimetric Absolutt kolorimetrisk + Relative Colorimetric Relativ kolorimetrisk + Perceptual Perseptuell + Black Point Compensation Svartpunktkompensasjon + Use Legacy Bruk eldre + Dither Dithering + No Dither Ingen dithering + Diffusion Diffusjon + Pattern Mønster + Fuzziness: Uklarhet: + Range: Område: + Select: Velg: + Detect Faces Gjenkjenn ansikter + Localize Color Clusters Lokaliser fargeklynger + Sample: Prøve: + Add to Sample Legg til i prøve + Subtract from Sample Trekk fra prøve + Selections Utvalg + Duplicated selection name Duplisert utvalgsnavn + Operation Operasjon + New channel only Bare ny kanal + Masked Areas Maskerte områder + Selected Areas Valgte områder + Spot Color Spotfarge + Color indicates Farge angir + +# Adjustments panel + Curves Kurver + Levels Nivåer + Color Balance Fargebalanse + Black & White Svart-hvitt + Photo Filter Fotofilter + Color Lookup Fargeoppslag + Threshold Terskel + Posterize Posteriser + Brightness/Contrast Lysstyrke/kontrast + Channel Mixer Kanalmikser + Gradient Map Gradienttilordning + Selective Color Selektiv farge + +# History panel + History Log… Historikklogg… + Create Snapshot Opprett øyeblikksbilde + Delete Snapshot Slett øyeblikksbilde + New Snapshot Nytt øyeblikksbilde + Snapshot 1 Øyeblikksbilde 1 + Step Forward Trinn fremover + Step Backward Trinn bakover + Histories Historikker + Nonlinear History Ikke-lineær historikk + Set New Snapshot as Default Angi nytt øyeblikksbilde som standard + Automatically Create First Snapshot Opprett første øyeblikksbilde automatisk + Automatically Create New Snapshot When Saving Opprett nytt øyeblikksbilde automatisk ved lagring + Show New Snapshot Dialog by Default Vis dialogen for nytt øyeblikksbilde som standard + Clear History Tøm historikk + +# Navigator panel + Panel Options… Panelalternativer… + View menu Vis-menyen + Keep view in sync Hold visningen synkronisert + {zoom}% {zoom} % + +# Brush Settings panel + Brush name Penselnavn + Brush Preview Penselforhåndsvisning + Brush Tip Shape Penselspissform + Shape Dynamics Formdynamikk + Scattering Spredning + Dual Brush Dobbel pensel + Color Dynamics Fargedynamikk + Transfer Overføring + Brush Pose Penselstilling + Mouse Controls Musekontroller + Wet Edges Våte kanter + Build-up Oppbygging + Protect Texture Beskytt tekstur + Blend Mode Blandingsmodus + Size Jitter Størrelsesvariasjon + Minimum Diameter Minste diameter + Tilt Scale Hellingsskala + Angle Jitter Vinkelvariasjon + Roundness Jitter Rundhetsvariasjon + Minimum Roundness Minste rundhet + Flip X Jitter Speilvend X-variasjon + Flip Y Jitter Speilvend Y-variasjon + Brush Projection Penselprojeksjon + Control: Kontroll: + Off: the speed of the pointer sets the spacing Av: pekerens hastighet bestemmer avstanden + Fade steps Nedtoningstrinn + Pen Pressure Penntrykk + Pen Tilt Pennhelling + Stylus Wheel Pennhjul + Initial Direction of Stroke Strøkets startretning + Direction of Stroke Strøkets retning + Opacity Jitter Dekkevnevariasjon + Flow Jitter Flytvariasjon + Humidity Fuktighet + Load Last inn + Mix Bland + Auto Clean Brush Rens pensel automatisk + Wet Våt + Current Brush Load Gjeldende penselfylling + Load Brush Fyll pensel + Clean Brush Rens pensel + Load the brush after each stroke Fyll penselen etter hvert strøk + Clean the brush after each stroke Rens penselen etter hvert strøk + Rate Hastighet + Airbrush: keeps depositing paint while the pointer rests. Airbrush: fortsetter å legge på maling mens pekeren står i ro. + Pulled String Mode Strukket snor-modus + Catch-up on Stroke End Fullfør ved strøkslutt + Adjust for Zoom Juster for zoom + Apply Per Tip Bruk per spiss + Hardness: Hardhet: + Spacing: Avstand: + Size: Størrelse: + Flip X Speilvend X + Flip Y Speilvend Y + Brush Tip Penselspiss + Diameter: Diameter: + New Layer from Brush Nytt lag fra pensel + Rename Brush… Gi penselen nytt navn… + Delete Brush Slett pensel + Import Brushes… Importer pensler… + Import Brushes… (.abr or image) Importer pensler… (.abr eller bilde) + Import Brush from Image… Importer pensel fra bilde… + Gradient Preset Options Alternativer for gradientforvalg + Import .GRD Importer .GRD + Choose a gradient file (.grd). Velg en gradientfil (.grd). + Brush Name Penselnavn + Brush Stroke Penselstrøk + New Brush Preset… Nytt penselforvalg… + Capture Brush Size in Preset Ta med penselstørrelse i forvalget + Include Tool Settings Ta med verktøyinnstillinger + Include Color Ta med farge + Enter a name for the brush preset Skriv inn et navn på penselforvalget + A brush preset with this name already exists Et penselforvalg med dette navnet finnes allerede + Brush Preset Options Alternativer for penselforvalg + New Brush Ny pensel + Create new brush from these settings Opprett ny pensel fra disse innstillingene + Update the current brush with these settings Oppdater gjeldende pensel med disse innstillingene + Reset the brush to the defaults Tilbakestill penselen til standard + No brush presets Ingen penselforvalg + Override Pressure Overstyr trykk + Override Tilt Overstyr helling + Override Tilt X Overstyr helling X + Override Tilt Y Overstyr helling Y + Override Rotation Overstyr rotasjon + Apply the pen's tilt and rotation to the tip shape Bruk pennens helling og rotasjon på spissformen + +# Tool presets + Preset Type Forvalgstype + Preset Manager… Forvalgsbehandling… + Rename Tool Preset… Gi verktøyforvalget nytt navn… + Delete Tool Preset Slett verktøyforvalg + New Tool Preset Nytt verktøyforvalg + Create new tool preset Opprett nytt verktøyforvalg + Current Tool Only Bare gjeldende verktøy + No tool presets. Ingen verktøyforvalg. + No presets for the current tool. Ingen forvalg for gjeldende verktøy. + No presets of this type. Ingen forvalg av denne typen. + Preset: Forvalg: + Preset Forvalg + Presets: Forvalg: + +# Paragraph and character panels + Font Size: Skriftstørrelse: + Font size Skriftstørrelse + Type: Type: + Style Stil + Style: Normal Stil: Normal + Tracking Tegnavstand + Kerning Kniping + Leading Linjeavstand + Auto-Leading Automatisk linjeavstand + Scale Horizontally Skaler vannrett + Scale Vertically Skaler loddrett + Baseline Shift Grunnlinjeforskyvning + Language Språk + Anti-aliasing method Kantutjevningsmetode + Sharp Skarp + Crisp Klar + Strong Sterk + Smooth Myk + Mac LCD Mac LCD + Mac Sharp Mac skarp + Mac Crisp Mac klar + Mac Strong Mac sterk + Mac Smooth Mac myk + Faux Bold Falsk fet + Faux Italic Falsk kursiv + All Caps Store bokstaver + Small Caps Kapitéler + Superscript Hevetekst + Subscript Senketekst + Underline Understreking + Strikethrough Gjennomstreking + Oldstyle Gammeldagse tall + Ligature Ligatur + Alternative Alternativ + Hyphenate Orddeling + Roman Antikva + Italic Kursiv + Bold Fet + Bold Italic Fet kursiv + Light Lys + Medium Middels + Black Svart + Thin Tynn + Ultra Light Ultra lett + Extra Light Ekstra lett + Semi Bold Halvfet + Extra Bold Ekstra fet + Ultra Bold Ultra fet + Condensed Sammentrengt + Left align text Venstrejuster tekst + Center text Sentrer tekst + Right align text Høyrejuster tekst + Justify last left Juster, siste linje til venstre + Justify last centered Juster, siste linje sentrert + Justify last right Juster, siste linje til høyre + Justify all Juster alle linjer + Indent Left Innrykk venstre + Indent Right Innrykk høyre + First Line Indent Innrykk første linje + Space Before Avstand før + Space After Avstand etter + Hanging Punctuation Hengende tegnsetting + Roman Hanging Punctuation Antikva hengende tegnsetting + Avoid Widows/Orphans Unngå enker/horunger + Compose Komponer + Adobe Single-line Composer Adobe setter for én linje + Adobe Every-line Composer Adobe setter for hver linje + Justification Justering + Word Spacing Ordmellomrom + Letter Spacing Bokstavavstand + Glyph Scaling Glyfskalering + Auto-leading % Automatisk linjeavstand % + Single Word Justification Justering av enkeltord + Alignment Justering + Indent First Innrykk første linje + Center Sentrer + Justify Juster + +# Glyphs panel + Glyph Glyf + Select a type layer or click into text to insert glyphs Velg et tekstlag eller klikk i teksten for å sette inn glyfer + Entire Font Hele skriften + Alternates for Current Selection Alternativer for gjeldende utvalg + Recently Used Nylig brukt + Digits Sifre + Symbols Symboler + Punctuation Tegnsetting + OpenType OpenType + Standard Ligatures Standardligaturer + Discretionary Ligatures Valgfrie ligaturer + Contextual Alternates Kontekstuelle alternativer + Swash Svungne tegn + Titling Alternates Tittelalternativer + Ornaments Ornamenter + Fractions Brøker + Ordinals Ordenstall + East Asian Features Østasiatiske funksjoner + Middle Eastern Features Midtøstlige funksjoner + +# Paths panel + Path Bane + Path: Bane: + Work Path Arbeidsbane + Path to Illustrator… Bane til Illustrator… + Make Work Path Opprett arbeidsbane + Make Selection Opprett utvalg + Fill Path Fyll bane + Stroke Path Strek opp bane + Clipping Path Klippebane + New Path Ny bane + Duplicate Path Dupliser bane + Rename Path… Gi banen nytt navn… + Delete Path Slett bane + Current Path Gjeldende bane + Vector Mask from Current Path Vektormaske fra gjeldende bane + Create new shape from the current path Opprett ny form fra gjeldende bane + Drag to move the active shape's path or the Work Path Dra for å flytte banen til den aktive formen eller arbeidsbanen + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klikk: velg ankerpunkt · Dra: flytt ankerpunkt, håndtak eller segment · {shift}-klikk: legg til · {alt}-klikk: hele delbanen + Draw with the Pen tool (P) or make a work path from a selection. Tegn med pennverktøyet (P) eller lag en arbeidsbane fra et utvalg. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Kilde: ikke angitt ({key}-klikk med klonestempelet) + Clone source {n} ({key}-click with Clone Stamp to set) Klonekilde {n} ({key}-klikk med klonestempelet for å angi) + {key}-click to set the source {key}-klikk for å angi kilden + Option-click to define a source point to clone from Option-klikk for å angi et kildepunkt å klone fra + Show Overlay Vis overlegg + Auto-Hide Skjul automatisk + W B + H H + Angle Vinkel + Scale Skaler + X X + Y Y + Offset Forskyvning + Reset Transform Tilbakestill transformering + Rotate Roter + Set horizontal skew Angi vannrett skråstilling + Set vertical skew Angi loddrett skråstilling + Flip Horizontal Speilvend vannrett + Flip Vertical Speilvend loddrett + Frame Sync Rammesynkronisering + Quick Sync Hurtigsynkronisering + Enabled Aktivert + +# Timeline panel + Create Video Timeline Opprett videotidslinje + Open a document to use the timeline. Åpne et dokument for å bruke tidslinjen. + Video Layers Videolag + Video Video + Audio Lyd + Frame Duration Rammevarighet + 30 frames @ 30 fps 30 bilder @ 30 fps + Loop Løkke + Render Video… Gjengi video… + Split at Playhead Del ved avspillingshodet + Delete Timeline Slett tidslinje + Reload Frame Last inn ramme på nytt + Restore Frame Gjenopprett ramme + Insert Blank Frame Sett inn tom ramme + Duplicate Frame Dupliser ramme + Delete Frame Slett ramme + Restore All Frames Gjenopprett alle rammer + Flatten Frames Into Clips Slå sammen rammer til klipp + Flatten Frames Into Layers Slå sammen rammer til lag + Convert Frames > Video Layers Konverter rammer > videolag + New Video Layer Nytt videolag + New Video Group Ny videogruppe + Interpret Footage… Tolk opptak… + Replace Footage… Erstatt opptak… + Show Altered Video Vis endret video + Limit Playback Begrens avspilling + Enable Timeline Quick Config Aktiver hurtigoppsett for tidslinje + Set Start Timecode… Angi starttidskode… + Timecode Tidskode + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Gjeldende tid + Out Ut + In Inn + Duration Varighet + Repeat Gjenta + Play Once Spill av én gang + Video Cache Videobuffer + +# Info panel + Info Options… Infoalternativer… + First Color Readout Første fargeavlesning + Second Color Readout Andre fargeavlesning + Actual Color Faktisk farge + Grayscale Slider Gråtoneglidebryter + Web Color Webfarge + HSB Color HSB-farge + Total Ink Total fargemengde + Opacity Mask Dekkevnemaske + Ruler Linjal + Current Tool Gjeldende verktøy + Document Size Dokumentstørrelse + Document Profile Dokumentprofil + Document Dimensions Dokumentdimensjoner + Scratch Sizes Arbeidsdiskstørrelser + Efficiency Effektivitet + Timing Tidtaking + Status Information Statusinformasjon + Saved Multichannel Lagret flerkanal + Mouse Coordinates Musekoordinater + Ruler Units Linjalenheter + Hide Skjul + +# Layer Comps panel + Layer Comps to Files… Lagkomposisjoner til filer… + Apply next selected layer comp Bruk neste valgte lagkomposisjon + Apply previous selected layer comp Bruk forrige valgte lagkomposisjon + Update layer comp (visibility, position and appearance) Oppdater lagkomposisjon (synlighet, plassering og utseende) + New Layer Comp Ny lagkomposisjon + Duplicate Layer Comp Dupliser lagkomposisjon + Delete layer comp Slett lagkomposisjon + Rename Layer Comp Gi lagkomposisjonen nytt navn + Create new layer comp Opprett ny lagkomposisjon + Capture the visibility, position and style of every layer with + below, then switch between versions. Registrer synlighet, plassering og stil for hvert lag med + nedenfor, og bytt deretter mellom versjoner. + Apply Layer Comp Bruk lagkomposisjon + Update Layer Comp Oppdater lagkomposisjon + Restore last viewed comp Gjenopprett sist viste komposisjon + Last Document State Siste dokumenttilstand + Visibility Synlighet + Position Posisjon + Appearance (Layer Style) Utseende (lagstil) + +# Analysis and variables + Analysis Analyse + Variables Variabler + Variable Data Sets… Variable datasett… + Data Sets… Datasett… + Data Sets Datasett + Data Sets as Files… Datasett som filer… + Apply Data Set… Bruk datasett… + Define Definer + + Add variable + Legg til variabel + Remove variable Fjern variabel + Define at least one variable first. Definer minst én variabel først. + Open a document to define variables. Åpne et dokument for å definere variabler. + Variables bind a layer's visibility, text or pixels to a named data slot. Variabler knytter et lags synlighet, tekst eller piksler til et navngitt datafelt. + Pixel Replacement Pikselerstatning + Text Replacement Teksterstatning + Name Navn + Method Metode + Select All Layers Velg alle lag + Fill Fyll + Keep proportions Behold proporsjoner + Interpolation Interpolering + Author: Forfatter: + Author Forfatter + Name: Navn: + Caption Bildetekst + Caption Credits Bildetekstkreditering + Description Beskrivelse + Keywords Nøkkelord + Copyright Info URL URL for opphavsrettsinformasjon + Copyright Status Opphavsrettsstatus + Copyright Notice Opphavsrettsmerknad + Document Title Dokumenttittel + Copyrighted Opphavsrettsbeskyttet + Public Domain Allemannseie + Unknown Ukjent + Copyright and Contact Info Opphavsrett og kontaktinformasjon + Credit Kreditering + Transmission Reference Overføringsreferanse + Urgency Hastegrad + High Høy + Normal Normal + Lossless Tapsfri + Low Lav + Category Kategori + Supplemental Categories Supplerende kategorier + Object Name / Title Objektnavn / tittel + Date Created Opprettet + City By + Location Plassering + Province / State Provins / delstat + Country Land + Byline Byline + Byline Title Byline-tittel + Phone Telefon + Email E-post + URLs URL-er + Last Modified Sist endret + EXIF EXIF + GPS GPS + Camera Data Kameradata + Camera Model Kameramodell + Camera Make Kameramerke + EXIF Version EXIF-versjon + Exposure Program Eksponeringsprogram + Aperture Blenderåpning + Shutter Speed Lukkerhastighet + Metering Mode Målemodus + Light Source Lyskilde + Flash Blits + Lens Info Objektivinfo + ISO Speed ISO-hastighet + Subject Distance Motivavstand + DateTime Original Opprinnelig dato og tid + Create Date Opprettelsesdato + Modify Date Endringsdato + Software Programvare + Raw Data Raw-data + Version {version} Versjon {version} + Advanced Avansert + Import… Importer… + +# Automate + Automate Automatiser + Batch… Satsvis… + Create Droplet… Opprett droplet… + Create Droplet Opprett droplet + PDF Presentation… PDF-presentasjon… + Create Multiple Picture Packages Opprett flere bildepakker + Contact Sheet II Kontaktkopi II + Contact Sheet II… Kontaktkopi II… + Conditional Mode Change… Betinget modusendring… + Fit Image… Tilpass bilde… + Picture Package… Bildepakke… + Web Photo Gallery… Webfotogalleri… + Merge to HDR Pro… Slå sammen til HDR Pro… + Load Files into Stack… Last inn filer i stabel… + Scripts Skript + Script Events Manager… Behandling av skripthendelser… + Image Processor… Bildebehandler… + Destination Mål + Choose… Velg… + Select Folder… Velg mappe… + Override Action "Save" Commands Overstyr «Lagre»-kommandoer i handlinger + Compatibility Kompatibilitet + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Ta med ICC-profil + Error File Feilfil + Stop For Errors Stopp ved feil + Log Errors To File Logg feil til fil + Errors Feil + +# Color Settings dialog + Color Settings… Fargeinnstillinger… + Settings Innstillinger + Settings: Innstillinger: + Working Space Arbeidsfargerom + RGB: RGB: + CMYK: CMYK: + Gray: Grå: + Spot: Spotfarge: + Advanced Controls Avanserte kontroller + Desaturate Monitor Colors By Reduser metningen i skjermfarger med + Blend RGB Colors Using Gamma 1.0 Bland RGB-farger med gamma 1,0 + Profile Mismatches: Ask When Opening Profilavvik: spør ved åpning + Ask When Pasting Spør ved innliming + Missing Profiles: Ask When Opening Manglende profiler: spør ved åpning + Embed profiles Bygg inn profiler + Ask Before Saving Saved Embedded Spør før lagring av lagret innebygd + Coerce to sRGB Tving til sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + +# Themes + Theme Tema + Switch theme Bytt tema + Next Theme Neste tema + Classic Theme Klassisk tema + Solarized dark Solarized mørk + Solarized Dark Theme Solarized mørkt tema + Breeze dark Breeze mørk + Breeze Dark Theme Breeze mørkt tema + Breeze light Breeze lys + Breeze Light Theme Breeze lyst tema + Soft dark Myk mørk + Soft light Mykt lys + Soft Dark Theme Mykt mørkt tema + Soft Light Theme Mykt lyst tema + Studio Theme Studio-tema + Studio Light Theme Studio lyst tema + Adwaita Light Theme Adwaita lyst tema + Adwaita Dark Theme Adwaita mørkt tema + Pro Theme Pro-tema + Pro Medium Gray Theme Pro mellomgrått tema + Pro Light Gray Theme Pro lysegrått tema + Pro Light Theme Pro lyst tema + +# Workspaces + Workspace… Arbeidsområde… + Essentials Grunnleggende + New Workspace… Nytt arbeidsområde… + Delete Workspace… Slett arbeidsområde… + Reset Workspace Tilbakestill arbeidsområde + Reset Tool Tilbakestill verktøy + Reset All Tools Tilbakestill alle verktøy + Reset all tools to their default settings? Tilbakestille alle verktøy til standardinnstillingene? + Lock Workspace Lås arbeidsområde + Photography Fotografi + Graphic and web Grafikk og web + Painting Maling + Motion Bevegelse + Minimalistic Minimalistisk + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panelplasseringer lagres i arbeidsområdet. Hurtigtaster, menyer og verktøylinje er valgfrie. + Keyboard Shortcuts Hurtigtaster + Keyboard Shortcuts and Menus Hurtigtaster og menyer + Menus Menyer + More panels Flere paneler + More documents Flere dokumenter + Collapse All Groups Fold sammen alle grupper + Expand All Groups Fold ut alle grupper + Collapse Fold sammen + Expand Fold ut + Collapse Panel Group Fold sammen panelgruppe + Expand Panel Group Fold ut panelgruppe + Close Tab Group Lukk fanegruppe + +# New Document dialog + PRESET DETAILS FORVALGSDETALJER + BLANK DOCUMENT PRESETS ({n}) FORVALG FOR TOMT DOKUMENT ({n}) + Create a new document or open an existing file. Opprett et nytt dokument eller åpne en eksisterende fil. + Drop an image or PSD anywhere to open it. Slipp et bilde eller en PSD hvor som helst for å åpne det. + Use File › Open, or paste a copied image with {paste}. Bruk Fil › Åpne, eller lim inn et kopiert bilde med {paste}. + Native file drag-and-drop is unavailable Innebygd dra-og-slipp av filer er ikke tilgjengelig + 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}. Innebygd dra-og-slipp av filer støttes ikke på Wayland ennå. Bruk Fil › Åpne, eller kopier bildet i filbehandleren og lim det inn med {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` For å slippe filer, start PhotoCraft under XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Eller sett Innstillinger › Ytelse › Linux-skjermtjener til X11: da starter PhotoCraft alltid under XWayland. + Edit › Paste Rediger › Lim inn + Photo Foto + Print Skriv ut + Art & Illustration Kunst og illustrasjon + Mobile Mobil + Film & Video Film og video + Default PhotoCraft Size Standard PhotoCraft-størrelse + Clipboard Utklippstavle + Photo (2 × 3 in) Foto (2 × 3 tommer) + Photo (4 × 6 in) Foto (4 × 6 tommer) + Photo (5 × 7 in) Foto (5 × 7 tommer) + Photo (8 × 10 in) Foto (8 × 10 tommer) + Landscape Liggende + Portrait Stående + Recent Items Nylige elementer + Save Preset… Lagre forvalg… + Delete Preset… Slett forvalg… + Preset Name: Navn på forvalg: + Width and Height Bredde og høyde + Background Contents Bakgrunnsinnhold + Custom background color Egendefinert bakgrunnsfarge + White Hvit + Background Color Bakgrunnsfarge + Transparent Gjennomsiktig + Color Mode Fargemodus + Depth Dybde + Color Profile Fargeprofil + Create Opprett + Document Dokument + Units Enheter + Pixels Piksler + Inches Tommer + Centimeters Centimeter + Millimeters Millimeter + Points Punkter + Picas Pica + Columns Kolonner + +# Placeholders, tooltips, status and keyboard hints + {name} options Alternativer for {name} + Align {what} Juster {what} + Untagged {mode} Umerket {mode} + Invalid {mode} profile Ugyldig {mode}-profil + Unnamed {mode} profile Navnløs {mode}-profil + Constraints ({n}) Begrensninger ({n}) + Count: {n} Antall: {n} + Click to add a point · drag off or {key}-click or Delete to remove Klikk for å legge til et punkt · dra bort, {key}-klikk eller Delete for å fjerne + Click to apply, {key}-click to clear overrides, double-click for Style Options Klikk for å bruke, {key}-klikk for å fjerne overstyringer, dobbeltklikk for stilalternativer + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klikk: hjørne · Dra: myk · Klikk på første punkt: lukk · {key} fullfør · Esc avbryt + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klikk: hjørne · Dra: myk · {alt}-dra: bryt håndtak · Klikk eller dra på første punkt: lukk · {key} fullfør · Esc avbryt + Commit Perspective Warp ({key}) Bekreft perspektivfordreining ({key}) + Commit Puppet Warp ({key}) Bekreft marionettfordreining ({key}) + Commit any current edits ({key}) Bekreft pågående redigering ({key}) + Commit transform ({key}) Bekreft transformering ({key}) + Commit warp ({key}) Bekreft fordreining ({key}) + Cancel any current edits (Esc) Avbryt pågående redigering (Esc) + Cancel transform (Esc) Avbryt transformering (Esc) + Cancel warp (Esc) Avbryt fordreining (Esc) + Do you want to save the changes you made to “{name}” before closing? Vil du lagre endringene du gjorde i «{name}» før du lukker? + Revert “{name}” to the last saved version? Your changes will be lost. Tilbakestille «{name}» til sist lagrede versjon? Endringene dine går tapt. + {key} to subtract {key} for å trekke fra + ↑↓ navigate {key} run esc close ↑↓ naviger {key} kjør esc lukk + Click to restore the current colour Klikk for å gjenopprette gjeldende farge + Click with the Note tool to add a note. Klikk med notatverktøyet for å legge til et notat. + Drag a rectangle around the object Dra et rektangel rundt objektet + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Dra på bildet for å legge til en begrensning. Shift for vannrett/loddrett. Høyreklikk på en begrensning for å endre eller slette den. + Drag the arrow to set the angle, the dots to set the roundness Dra pilen for å angi vinkelen, prikkene for å angi rundheten + Create new pattern from the selection Opprett nytt mønster fra utvalget + Create new gradient Opprett ny gradient + Create new count group Opprett ny tellegruppe + Search commands, tools and panels… Søk etter kommandoer, verktøy og paneler… + Search Brushes Søk i pensler + Search fonts Søk etter skrifter + No matching commands Ingen samsvarende kommandoer + No document Ikke noe dokument + Pixel Layer Piksellag + Type Layer Tekstlag + Shape Layer Formlag + Group Layer Gruppelag + Fill Layer Fyllag + Smart Object Layer Smartobjektlag + Adjustment Layer Justeringslag + {name} Properties Egenskaper for {name} + Move Tool Flytteverktøy + Could not use the move tool because the layer is locked. Kunne ikke bruke flytteverktøyet fordi laget er låst. + Could not complete the Free Transform command because the layer is locked. Kunne ikke fullføre Fri transformering fordi laget er låst. + Rectangular Marquee Tool Rektangulær markeringsramme + Elliptical Marquee Tool Elliptisk markeringsramme + Single Row Marquee Tool Markeringsramme for enkeltrad + Single Column Marquee Tool Markeringsramme for enkeltkolonne + Brush Tool Penselverktøy + Pencil Tool Blyantverktøy + Eraser Tool Viskelærverktøy + Background Eraser Tool Bakgrunnsviskelær + Magic Eraser Tool Magisk viskelær + Eyedropper Tool Pipetteverktøy + Note Tool Notatverktøy + Lasso Tool Lassoverktøy + Polygonal Lasso Tool Polygonlasso + Magic Wand Tool Tryllestav + Crop Tool Beskjæringsverktøy + Slice Tool Segmentverktøy + Slice Select Tool Segmentvalgverktøy + Gradient Tool Gradientverktøy + Paint Bucket Tool Malingsspann + Horizontal Type Tool Vannrett tekstverktøy + Horizontal Type Mask Tool Vannrett tekstmaskeverktøy + Vertical Type Mask Tool Loddrett tekstmaskeverktøy + Hand Tool Håndverktøy + Zoom Tool Zoomverktøy + Spot Healing Brush Tool Punktreparasjonspensel + Healing Brush Tool Reparasjonspensel + Clone Stamp Tool Klonestempel + History Brush Tool Historikkpensel + Blur Tool Sløringsverktøy + Sharpen Tool Skjerpeverktøy + Smudge Tool Utgnidningsverktøy + Dodge Tool Avskjermingsverktøy + Burn Tool Etterbelysningsverktøy + Sponge Tool Svampverktøy + Quick Selection Tool Hurtigvalgverktøy + Object Selection Tool Objektvalgverktøy + Pen Tool Pennverktøy + Convert Point Tool Verktøy for å konvertere punkt + Path Selection Tool Banevalgverktøy + Direct Selection Tool Direktevalgverktøy + Add Anchor Point Tool Verktøy for å legge til ankerpunkt + Delete Anchor Point Tool Verktøy for å slette ankerpunkt + Click a segment to add an anchor point · {alt}-click an anchor to delete it Klikk på et segment for å legge til et ankerpunkt · {alt}-klikk på et ankerpunkt for å slette det + Click an anchor point to delete it · {alt}-click a segment to add one Klikk på et ankerpunkt for å slette det · {alt}-klikk på et segment for å legge til ett + Rectangle Tool Rektangelverktøy + Ellipse Tool Ellipseverktøy + Triangle Tool Trekantverktøy + Polygon Tool Polygonverktøy + Line Tool Linjeverktøy + Custom Shape Tool Verktøy for egendefinert form + Pixel Piksel + Group Gruppe + Adjustment Justering + Smart Object Smartobjekt + Drag to move the active layer Dra for å flytte det aktive laget + Drag to pan · hold Space with any tool Dra for å panorere · hold mellomrom med et hvilket som helst verktøy + Drag to draw a gradient Dra for å tegne en gradient + Click to fill similar colours Klikk for å fylle lignende farger + Click to add text Klikk for å legge til tekst + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Dra (lasso) eller klikk punkter (polygon) · {add} legg til · {sub} trekk fra + 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 Dra en beskjæringsramme · dra innenfor for å flytte · kantene endrer størrelse ({ratio} forhold, {centre} midtpunkt) · mellomrom flytter under tegning · piltaster forskyver · X bytter retning · {commit} bekrefter · Esc avbryter + {mode} Color · {bits} bit {mode}-farge · {bits} biter + Drag to move the layer Dra for å flytte laget + Fit Screen Tilpass til skjerm + Fill Screen Fyll skjermen + Scrubby Zoom Skrubbezoom + Auto Erase Automatisk visking + Color Adaptation Fargetilpasning + Preserve Transparency Bevar gjennomsiktighet + Reverse Snu rekkefølgen + Align with layer Juster etter lag + Join us on Discord Bli med oss på Discord + PhotoCraft website PhotoCrafts nettsted + ArtCraft ArtCraft + GitHub GitHub + No character style Ingen tegnstil + No author Ingen forfatter + Not in Dictionary: Ikke i ordlisten: + Suggestions: Forslag: + Spell check complete. Stavekontrollen er fullført. + Check All Layers Kontroller alle lag + Select a type layer to edit its character and paragraph settings. Velg et tekstlag for å redigere tegn- og avsnittsinnstillingene. + Select text to apply a character style Velg tekst for å bruke en tegnstil + Redefine style by current selection Omdefiner stilen etter gjeldende utvalg + Duplicate style Dupliser stil + Delete style Slett stil + Style Options Stilalternativer + Style Options… Stilalternativer… + Close Style Options Lukk stilalternativer + Appearance Utseende + Text Position: Tekstplassering: + Text color Tekstfarge + Uses the foreground and background colours. Bruker forgrunns- og bakgrunnsfargene. + Answer the unsaved-changes prompt first Svar først på spørsmålet om ulagrede endringer + Unsaved changes Ulagrede endringer + Don't Save Ikke lagre + No Nei + Yes Ja + These settings aren't available in PhotoCraft yet. Disse innstillingene er ikke tilgjengelige i PhotoCraft ennå. + Shortcut removed. Hurtigtasten er fjernet. + All shortcuts reset to PhotoCraft defaults. Alle hurtigtaster er tilbakestilt til PhotoCraft-standard. + Add Shortcut Legg til hurtigtast + Delete Shortcut Slett hurtigtast + Press keys… Trykk på taster… + shortcut hurtigtast + command or shortcut kommando eller hurtigtast + current gjeldende + image file path bildefilbane + modified endret + name navn + new ny + Go to Layer Gå til lag + Open a document to select a colour range. Åpne et dokument for å velge et fargeområde. + Saved Lagret + Reveal in Finder Vis i Finder + Show in Explorer Vis i Utforsker + Show in Folder Vis i mappe + Before (Y) Før (Y) + Save Settings… Lagre innstillinger… + Load Settings… Last inn innstillinger… + Save these settings as a Camera Raw .xmp file Lagre disse innstillingene som en Camera Raw .xmp-fil + Load settings from a Camera Raw .xmp file Last inn innstillinger fra en Camera Raw .xmp-fil + Loaded Camera Raw settings from {file} Lastet inn Camera Raw-innstillinger fra {file} + Loaded Camera Raw settings from {file} ({n} not supported) Lastet inn Camera Raw-innstillinger fra {file} ({n} støttes ikke) + After (X) Etter (X) + Original Original + Current Gjeldende + {added}, {removed} subtracted {added}, {removed} trukket fra + Filter: Filter: + +# Transform / warp overlays + Free Transform Fri transformering + Free Transform a Copy Fri transformering av en kopi + Transform Transformer + Transform Selection Transformer utvalg + Switch between free transform and warp modes Bytt mellom fri transformering og fordreining + Perspective Perspektiv + Skew Skråstill + Distort Forvreng + Rotate: Roter: + Flip Speilvend + Warp: Fordreining: + Warp Fordrei + Bend: Bøy: + Reference point location Plassering av referansepunkt + Toggle reference point Slå av/på referansepunkt + Free Fri + Arc Bue + Arch Hvelving + Bulge Utbuling + Shell Lower Nedre skall + Shell Upper Øvre skall + Flag Flagg + Fish Fisk + Wave Bølge + Rise Stigning + Fisheye Fiskeøye + Inflate Blås opp + Twist Vri + Squeeze Klem + Corner radius Hjørneradius + Split Warp Crosswise Del fordreining på kryss + Split Warp Horizontally Del fordreining vannrett + Split Warp Vertically Del fordreining loddrett + Remove Warp Split Fjern fordreiningsdeling + Puppet Warp Marionettfordreining + Puppet Warp is not active Marionettfordreining er ikke aktiv + Perspective Warp Perspektivfordreining + Perspective Warp is not active Perspektivfordreining er ikke aktiv + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivfordreining: tegn plan langs bildets perspektiv, og bytt deretter til Fordrei + Remove Planes Fjern plan + Edit Pins Rediger nåler + Add Pins Legg til nåler + Delete Pins Slett nåler + Remove All Pins Fjern alle nåler + Remove Tool Fjerneverktøy + Pin Depth: Nåldybde: + Bring the selected pin forward Flytt den valgte nålen fremover + Send the selected pin backward Flytt den valgte nålen bakover + Rotate Auto Roter automatisk + Rotate Fixed Roter fast + Adaptive Wide Angle isn't open Adaptiv vidvinkel er ikke åpen + Liquify is not open Gjør flytende er ikke åpen + +# Vanishing Point + Create Plane Opprett plan + Edit Plane Rediger plan + Select Plane Velg plan + Measure Mål + Heal Reparer + Off Av + Symmetry Off Symmetri av + Heal Off Reparering av + Heal On Reparering på + Heal Auto Reparer automatisk + Blend Bland + Brush Size Penselstørrelse + Heal Color Reparer farge + +# Misc dialog strings + Mask Maske + Channel Kanal + Rectangle Rektangel + Subtract Trekk fra + Multiply Multipliser + Divide Del + Result Resultat + New Document Nytt dokument + New Document With Mask Nytt dokument med maske + New Layer With Mask Nytt lag med maske + New Layer Nytt lag + Preset Name Forvalgsnavn + No saved presets yet. Ingen lagrede forvalg ennå. + New Channel Ny kanal + Selection Utvalg + Save Selection Lagre utvalg + Load Selection Last inn utvalg + Duplicate Dupliser + Fast Rask + Accurate Nøyaktig + Add to Legg til i + Subtract from Trekk fra + Intersect with Skjær med + +# Blend modes + Pass Through Gjennomgående + Dissolve Oppløs + Darken Mørkere + Color Burn Fargebrenning + Linear Burn Lineær brenning + Darker Color Mørkere farge + Lighten Lysere + Screen Skjerm + Color Dodge Fargeavskjerming + Linear Dodge (Add) Lineær avskjerming (legg til) + Lighter Color Lysere farge + Overlay Overlegg + Soft Light Mykt lys + Hard Light Hardt lys + Vivid Light Skarpt lys + Linear Light Lineært lys + Pin Light Punktlys + Hard Mix Hard blanding + Difference Differanse + Exclusion Utelukkelse + Luminosity Luminositet + +# Saved views and other bits + Capture Comp Ta opp komposisjon + No Layer Comps Ingen lagkomposisjoner + +# Plurals +@plural {n} item|{n} items {n} element|{n} elementer +@plural {n} Layer|{n} Layers {n} lag|{n} lag +@plural {n} layer|{n} layers {n} lag|{n} lag +@plural Group · {n} layer|Group · {n} layers Gruppe · {n} lag|Gruppe · {n} lag +@plural {n} sample|{n} samples {n} prøve|{n} prøver + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… Alternativer for 32-biters forhåndsvisning… + 90° Clockwise 90° med klokken + 90° Counter Clockwise 90° mot klokken + AI Integrations… KI-integrasjoner… + Again Igjen + Transform Again on a Copy Transformer igjen på en kopi + Anamorphic 2:1 (2) Anamorf 2:1 (2) + Arbitrary… Vilkårlig… + Artboard Guides Hjelpelinjer for tegneflate + Artboard from Group… Tegneflate fra gruppe… + Artboard from Layers… Tegneflate fra lag… + Artboards to Files… Tegneflater til filer… + Artboards to PDF… Tegneflater til PDF… + Artboard… Tegneflate… + Border… Kant… + Bottom Edges Nedre kanter + Browse… Bla gjennom… + Source Files Kildefiler + Use: Bruk: + Files Filer + Folder Mappe + Add Open Files Legg til åpne filer + Sort By Name Sorter etter navn + Attempt to Automatically Align Source Images Prøv å justere kildebildene automatisk + Create Smart Object after Loading Layers Opprett smartobjekt etter innlasting av lag + Canvas Guides Lerrethjelpelinjer + Character Styles Panel Tegnstilpanel + Clear Canvas Guides Fjern lerrethjelpelinjer + Clear Selected Artboard Guides Fjern hjelpelinjer på valgt tegneflate + Color Blindness — Deuteranopia-type Fargeblindhet: deuteranopi-type + Color Blindness — Protanopia-type Fargeblindhet: protanopi-type + Color Table… Fargetabell… + Combine Shapes Kombiner former + Contract… Trekk sammen… + Convert to Embedded Konverter til innebygd + Convert to Layers Konverter til lag + Convert to Linked… Konverter til koblet… + Count Telling + Create Layer Opprett lag + Custom Pixel Aspect Ratio… Egendefinert pikselforhold… + Cycle Screen Mode Bytt skjermmodus + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC bredformat (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL bredformat (1,46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + De-Interlace… Fjern linjesprang… + Default Features Standardfunksjoner + Delete All Empty Layers Slett alle tomme lag + Desaturate Fjern metning + Displace… Forskyv… + Distribute Fordel + Document Bounds Dokumentgrenser + Edit in Quick Mask Mode Rediger i hurtigmaskemodus + Enhanced Controls… Utvidede kontroller… + Entropy Entropi + Exclude Overlapping Shapes Utelat overlappende former + Exit Avslutt + Expand… Utvid… + Extra Large Ekstra stor + File Info… Filinformasjon… + Fit Artboard on Screen Tilpass tegneflate til skjermen + Fit Layer(s) on Screen Tilpass lag til skjermen + Flatten All Layer Effects Slå sammen alle lageffekter + Flatten All Masks Slå sammen alle masker + Flatten Image Slå sammen bildet + Float All in Windows Alle flytende i vinduer + Float in Window Flytende i vindu + Frame from Layers Ramme fra lag + From Transparency Fra gjennomsiktighet + Gamut Warning Fargeomfangsadvarsel + Global Light… Globalt lys… + Gradient… Gradient… + Graphic and Web Grafikk og web + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Hidden Layers Skjulte lag + Hide All Effects Skjul alle effekter + Horizontal Centers Vannrette sentre + Huge Enorm + Indexed Color… Indeksert farge… + Integrations… Integrasjoner… + Intersect Shape Areas Snitt av formområder + Kurtosis Kurtose + Large Stor + Layer Edges Lagkanter + Left Edges Venstre kanter + Lens Blur… Objektivsløring… + Lighting Effects… Lyseffekter… + Link Layers Koble lag + Lock Layers… Lås lag… + Mean Gjennomsnitt + Merge Down Slå sammen nedover + Merge Group Slå sammen gruppe + Merge Layers Slå sammen lag + Merge Shape Components Slå sammen formkomponenter + Merge Visible Slå sammen synlige + Stamp Visible Stemple synlige + Select Layer Above Velg laget over + Select Layer Below Velg laget under + Select Top Layer Velg øverste lag + Select Bottom Layer Velg nederste lag + Add Layer Above to Selection Legg laget over til utvalget + Add Layer Below to Selection Legg laget under til utvalget + Stamp Down Stemple nedover + Mesh Nett + Middle Eastern & South Asian Composer Midtøstlig og sørasiatisk tekstsetter + Modifier Keys Modifikasjonstaster + Modify Endre + NTSC Colors NTSC-farger + Notes… Notater… + Other Annet + Other… Annet… + Paragraph Styles Panel Avsnittsstilpanel + Paths to Illustrator… Baner til Illustrator… + Pattern Preview Mønsterforhåndsvisning + Pattern… Mønster… + Pixel Art Pikselkunst + Pixel Art Preview Forhåndsvisning av pikselkunst + Pixel Grid Pikselrutenett + Pixelate Pikseler + Place Scale Marker… Plasser skalamarkør… + Plug-ins Programtillegg + Print One Copy Skriv ut ett eksemplar + Print… Skriv ut… + Range Område + Raw Defaults… Raw-standarder… + Relink to File… Koble til fil på nytt… + Relight… Nytt lys… + Render Gjengi + Replace All Missing Fonts Erstatt alle manglende skrifter + Resolve Missing Fonts… Løs manglende skrifter… + Reveal Selection Vis utvalg + Right Edges Høyre kanter + Selection Edges Utvalgskanter + Shapes Former + Show Extras Options… Alternativer for Vis ekstra… + Skewness Skjevhet + Small Liten + Smart Guides Smarte hjelpelinjer + Smart Objects Smartobjekter + Smooth… Jevn ut… + Solid Color… Ensfarget… + Square Kvadratisk + Stack Mode Stabelmodus + Choose Stack Mode: Velg stabelmodus: + Standard Deviation Standardavvik + Statistics… Statistikk… + Stylistic Alternates Stilistiske alternativer + Stylize Stiliser + Subtract Front Shape Trekk fra fremste form + Summation Summering + Target Path Målbane + Tile All Horizontally Alle side om side vannrett + Tile All Vertically Alle side om side loddrett + Toggle Last State Veksle siste tilstand + Toolbar… Verktøylinje… + Tools… Verktøy… + Top Edges Øvre kanter + Unite Shapes Foren former + Update All Modified Content Oppdater alt endret innhold + Update All Text Layers Oppdater alle tekstlag + Update Modified Content Oppdater endret innhold + Variance Varians + Vertical Centers Loddrette sentre + Video Frames to Layers… Videorammer til lag… + WIA Support… WIA-støtte… + Working CMY Plate Arbeidsplate for CMY + +# tl! literals (third pass) + Absolute Absolutt + Action Handling + Adaptive Adaptiv + Add Stop Legg til stopp + Add disk Legg til disk + Align and Distribute Juster og fordel + All Slices Alle segmenter + All User Slices Alle brukersegmenter + Alt Alt + Artboard Tegneflate + Artboard Background Color Bakgrunnsfarge for tegneflate + Auto Hide Skjul automatisk + Automatically level near-horizontal line segments Rett opp nesten vannrette linjestykker automatisk + Automatically straighten and level Rett opp og nivellér automatisk + Automatically straighten near-vertical line segments Rett opp nesten loddrette linjestykker automatisk + Balance Balanse + Baseline Grunnlinje + Bicubic Bikubisk + Bicubic (smooth gradients) Bikubisk (jevne overganger) + Automatic Automatisk + Bicubic Smoother (enlargement) Bikubisk jevnere (forstørrelse) + Bicubic Sharper (reduction) Bikubisk skarpere (forminskning) + Bilinear Bilineær + Black, White Svart, hvit + Blue, Red, Yellow Blå, rød, gul + Bottom Bunn + Brightness Jitter Lysstyrkevariasjon + Brush Reconstruct Options Alternativer for penselrekonstruksjon + Cache Level: Buffernivå: + Canvas Lerret + Capture Opptak + Center Crop Marks Sentrerte beskjæringsmerker + Centre the gradient (offset 0, 0) Sentrer gradienten (forskyvning 0, 0) + Change Screen Mode (F) Bytt skjermmodus (F) + Change To: Endre til: + Clear All Fjern alle + Clear Override Fjern overstyring + Clear Samples Fjern prøver + Cmd Cmd + Color Grading Fargegradering + Color Management Fargestyring + Color Priority Fargeprioritet + Color Range preview Forhåndsvisning av fargeområde + Colorize Fargelegg + Constant Konstant + Content-Aware Innholdsbasert + Convert to sRGB Konverter til sRGB + Copies Eksemplarer + Copper Kobber + Corner Crop Marks Beskjæringsmerker i hjørnene + Correction Korreksjon + Count Jitter Antallsvariasjon + Create Texture Opprett tekstur + Create a new count group Opprett en ny tellegruppe + Crop Factor Beskjæringsfaktor + Ctrl Ctrl + Custom Shapes Egendefinerte former + Cyan Cyan + Cyan · Red Cyan · Rød + Darks Mørke toner + Default printer Standardskriver + Default restored. Standard gjenopprettet. + Delete Group Slett gruppe + Delete count group Slett tellegruppe + Delete effect layer Slett effektlag + Delete note Slett notat + Delete tool preset Slett verktøyforvalg + Depth Jitter Dybdevariasjon + Discontiguous Ikke sammenhengende + Discord Discord + Display P3 Display P3 + Display Text Vis tekst + Divide… Del… + Do you want to save the changes you made to “{name}” before quitting? Vil du lagre endringene du gjorde i «{name}» før du avslutter? + Edit Toolbar… Rediger verktøylinje… + Edit in Quick Mask Mode (Q) Rediger i hurtigmaskemodus (Q) + Edit in Standard Mode (Q) Rediger i standardmodus (Q) + Embedded Profile Mismatch Innebygd profil samsvarer ikke + Enhance Edge Forsterk kant + Exact Nøyaktig + Expansion: Utvidelse: + Exposure and Gamma Eksponering og gamma + Factor: Faktor: + Fewer Points Færre punkter + File Settings Filinnstillinger + Finger Painting Fingermaling + Fit in View Tilpass til visning + Fixed Fast + Foreground to Background Forgrunn til bakgrunn + Foreground/Background Jitter Forgrunn/bakgrunn-variasjon + Format Format + Frame from Layers… Ramme fra lag… + Full Spherical Fullt sfærisk + Gamma Correction Gammakorreksjon + Gamma: Gamma: + Global Global + Grain Korn + Graphics Grafikk + Gray Grå + Heart Hjerte + Hide Auto Slices Skjul automatiske segmenter + Highlight Compression Høylyskomprimering + Highlight Priority Høylysprioritet + Highlights Høylys + Home Hjem + Hue Jitter Fargetonevariasjon + Image Size: Bildestørrelse: + Imported Importert + Input: Inndata: + Interlaced Linjesprang + Invert All Inverter alle + Label Size Etikettstørrelse + Labels Etiketter + Lanczos (sharp) Lanczos (skarp) + Level Nivå + Lights Lyse toner + Limits: Grenser: + Link the mask to the layer Koble masken til laget + Localized Color Clusters Lokaliserte fargeklynger + Luminance Luminans + Magenta Magenta + Magenta · Green Magenta · Grønn + Marker Size Markørstørrelse + Mask Options Maskealternativer + Masking Maskering + Master Hoved + Mean: Gjennomsnitt: + Median: Median: + Mesh Size Nettstørrelse + Metadata Metadata + Midpoint Midtpunkt + Midtones Mellomtoner + Midtones from: Mellomtoner fra: + Midtones to: Mellomtoner til: + Minimum Depth Minste dybde + Mix Jitter Blandingsvariasjon + Mode: Modus: + Monochrome Monokrom + More Points Flere punkter + Move Group Down Flytt gruppen ned + Move Group Up Flytt gruppen opp + Move effect layer down Flytt effektlaget ned + Move effect layer up Flytt effektlaget opp + Move to Flytt til + Nearest Neighbor Nærmeste nabo + Nearest Neighbor (hard edges) Nærmeste nabo (harde kanter) + New effect layer Nytt effektlag + Next note Neste notat + No Color Management Ingen fargestyring + Optimized Optimalisert + Output Levels: Utdatanivåer: + Output: Utdata: + Paint Overlay Malingsoverlegg + Pattern Size Mønsterstørrelse + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft: et bilderedigeringsprogram med åpen kildekode, skrevet i Rust. + Position and Size Posisjon og størrelse + Preserve Details Bevar detaljer + Preserve Luminosity Bevar luminositet + Previous note Forrige notat + Printer Skriver + Printer Setup Skriveroppsett + Printing Marks Utskriftsmerker + Progressive Progressiv + Promote Forfrem + Protect Detail Beskytt detaljer + Protect Tones Beskytt toner + Proximity Match Nærhetstilpasning + Purity Renhet + Quality Kvalitet + Recent Nylig + Reconstruct Rekonstruer + Refine Forbedre + Registration Marks Passmerker + Regular Normal + Remove stop Fjern stopp + Rename Group… Gi gruppen nytt navn… + Rename Layer… Gi laget nytt navn… + Restore All Gjenopprett alle + Restrictive (Web) Begrenset (web) + Rigid Stiv + Roughness Ruhet + Roundness Rundhet + Rulers & Grids Linjaler og rutenett + Sample: foreground colour Prøve: forgrunnsfarge + Sampling: Background Swatch Prøvetaking: bakgrunnsfargeprøve + Sampling: Continuous Prøvetaking: kontinuerlig + Sampling: Once Prøvetaking: én gang + Saturate Øk metning + Saturation Jitter Metningsvariasjon + Save HTML and Images Lagre HTML og bilder + Save as PDF Lagre som PDF + Scale to Fit Media Skaler for å tilpasse til mediet + Scatter Spredning + Scratch disks Arbeidsdisker + Selective Selektiv + Shadows Skygger + Shape: Form: + Sharpening Skjerping + Shift Shift + Show All Menu Items Vis alle menyelementer + Show Backdrop Vis bakgrunn + Show Mask Vis maske + Show Mesh Vis nett + Show or hide the Notes panel Vis eller skjul notatpanelet + Show the layer mask as an overlay Vis lagmasken som et overlegg + Show: Vis: + Sides Sider + Sides: Sider: + Slice Options… Segmentalternativer… + Slices From Guides Segmenter fra hjelpelinjer + Slices are locked (View › Lock Slices) Segmentene er låst (Vis › Lås segmenter) + Split: Deling: + Star ratio Stjerneforhold + Std Dev: Std.avvik: + Straighten Rett opp + Straighten Layer Rett opp lag + Strength: Styrke: + Stroke Strek + Stroke Catch-up Strøkfullføring + Stroke: Strek: + Location: Plassering: + Inside Innenfor + Outside Utenfor + Tetrahedral Tetraedrisk + Texture Each Tip Tekstur per spiss +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradientredigering + Gradient picker Gradientvelger + Save As Preset Lagre som forvalg + The gradient the Gradient tool paints with Gradienten som gradientverktøyet maler med + Adds the current gradient to the Gradients panel Legger gjeldende gradient til i gradientpanelet + Gradient stops Gradientstopp + Click to edit the gradient Klikk for å redigere gradienten + Tilt X Helling X + Tilt Y Helling Y + Toggle count group visibility Veksle synlighet for tellegruppe + Toggle text orientation Veksle tekstretning + Tone: Tone: + Toolbar Verktøylinje + Top Topp + Total: Totalt: + Transparency Gjennomsiktighet + Trilinear Trilineær + Unlink the mask from the layer Koble masken fra laget + View Options Visningsalternativer + Violet, Orange Fiolett, oransje + Visible Synlig + Weight Tykkelse + Weight: Tykkelse: + Wetness Jitter Våthetsvariasjon + Whites Hvittoner + Yellow Gul + Yellow · Blue Gul · Blå + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Tillat agentstyring + Allow scripts to connect Tillat at skript kobler til + Always Alltid + Always create smart objects when placing Opprett alltid smartobjekter ved plassering + Animated zoom Animert zoom + Apply auto tone Bruk autotone + Ask Spør + Ask before saving layered tiff Spør før lagring av TIFF med lag + Autosave Autolagring + Autosave minutes Minutter mellom autolagring + Beep when done Pip når ferdig + Bicubic automatic Bikubisk automatisk + Bicubic sharper Bikubisk skarpere + Bicubic smoother Bikubisk jevnere + Bit depth Bitdybde + Brush picker Penselvelger + Brush preview color Farge for penselforhåndsvisning + CPU (no GPU acceleration) CPU (ingen GPU-akselerasjon) + Cache levels Buffernivåer + Cache tile size Bufferflisstørrelse + Camera Raw Defaults Camera Raw-standarder + Canvas border Lerretkant + Show canvas dots Vis lerretprikker + Clip thumbnails to layer bounds Klipp miniatyrer til laggrensene + Clip Thumbnails to Layer Bounds Klipp miniatyrer til laggrensene + Canvas color Lerretfarge + Canvas custom color Egendefinert lerretfarge + Classic Klassisk + Color picker Fargevelger + Color space Fargerom + Column width Kolonnebredde + Concise Kortfattet + Control port Kontrollport + Convert to srgb Konverter til sRGB + Cursors Markører + Custom dark Egendefinert mørk + Custom light Egendefinert lys + Dark Mørk + Dark gray Mørkegrå + Dashed lines Stiplede linjer + Detailed Detaljert + DirectX 12 DirectX 12 + Disks Disker + Display p3 Display P3 + Dots Prikker + Double click layer mask launches select and mask Dobbeltklikk på lagmaske åpner Velg og masker + Drop shadow Slagskygge + Dynamic color sliders Dynamiske fargeglidebrytere + East asian Østasiatisk + Effect cache mb Effektbuffer (MB) + Enable flick panning Aktiver kastpanorering + Enable floating document window docking Aktiver dokking av flytende dokumentvinduer + Enable narrow options bar Aktiver smal alternativlinje + Enhanced Controls Utvidede kontroller + Erase Visk ut + Export clipboard Eksporter utklippstavle + Extra large Ekstra stor + File Handling Filhåndtering + File path Filbane + Fill new type layers with placeholder Fyll nye tekstlag med plassholdertekst + Font preview Skriftforhåndsvisning + Full size tip Spiss i full størrelse + GPU backend GPU-motor + Linux display server Linux-skjermtjener + X11 X11 + Gamut warning color Farge for fargeomfangsadvarsel + Gamut warning opacity Dekkevne for fargeomfangsadvarsel + General Generelt + Enable Generator Aktiver Generator + Gif GIF + Glossy Blank + Grid color Rutenettfarge + Grid colors Rutenettfarger + Grid size Rutenettstørrelse + Grid style Rutenettstil + Grid unit Rutenettenhet + Gridline every Rutenettlinje for hver + Guide color Hjelpelinjefarge + Guide style Hjelpelinjestil + Guides, Grid & Slices Hjelpelinjer, rutenett og segmenter + Gutter Spaltemellomrom + History Log Historikklogg + History states Historikktilstander + Ignore exif profile tag Ignorer EXIF-profilmerke + Image interpolation Bildeinterpolering + Image previews Bildeforhåndsvisninger + Integrations Integrasjoner + Interface Grensesnitt + Jpeg quality JPEG-kvalitet + Jpg JPG + Large tabs Store faner + Legacy compositing Eldre komposisjon + Light gray Lysegrå + Line Linje + Lines Linjer + Lowercase extension Filetternavn med små bokstaver + Maximize psd compatibility Maksimer PSD-kompatibilitet + Medium gray Mellomgrå + Memory usage mb Minnebruk (MB) + Low resolution previews Forhåndsvisninger i lav oppløsning + Metal Metal + Missing glyph protection Beskyttelse mot manglende glyfer + Modern Moderne + Nearest neighbor Nærmeste nabo + Never Aldri + Normal tip Normal spiss + Open as smart object Åpne som smartobjekt + Open in camera raw Åpne i Camera Raw + Open documents as tabs Åpne dokumenter som faner + OpenGL OpenGL + Orange Oransje + Overscroll Overrulling + Performance Ytelse + Png PNG + Point size Punktstørrelse + PostScript (72 points/inch) PostScript (72 punkter/tomme) + Precise Presis + Preserve details Bevar detaljer + Print resolution Utskriftsoppløsning + Pro Pro + Pro light Pro lys + Pro light gray Pro lysegrå + Pro medium Pro mellom + Pro photo ProPhoto + Purple Lilla + Quick export format Format for hurtigeksport + Quick export location Plassering for hurtigeksport + Recent file count Antall nylig brukte filer + LUT folder LUT-mappe + Recent files Nylig brukte filer + Recent fonts Nylig brukte skrifter + Recover on launch Gjenopprett ved oppstart + Remember workspace changes Husk endringer i arbeidsområdet + Resize image during place Endre bildestørrelse ved plassering + Right click with painting tools Høyreklikk med maleverktøy + Rotate view with trackpad Roter visningen med styreflaten + Same folder Samme mappe + Save in background Lagre i bakgrunnen + Scratch Disks Arbeidsdisker + Screen resolution Skjermoppløsning + Scrubby slider acceleration Akselerasjon for skrubbeglidebryter + Sessions only Bare økter + Sharpen for Skjerp for + Show channels in color Vis kanaler i farger + Show crosshair in brush tip Vis trådkors i penselspissen + Show extension panels Vis utvidelsespaneler + Show font names in english Vis skriftnavn på engelsk + Show menu colors Vis menyfarger + Show only crosshair while painting Vis bare trådkors under maling + Show slice numbers Vis segmentnumre + Show tooltips Vis verktøytips + Enable animations Aktiver animasjoner + Show highlight on rollover Vis utheving ved pekerberøring + Show bounding box when dragging layer Vis avgrensningsboks når lag dras + System title bar Systemets tittellinje + Show transformation values Vis transformeringsverdier + Slice color Segmentfarge + Smart guide color Farge for smarte hjelpelinjer + Smart quotes Typografiske anførselstegn + Snap vector tools and transforms to pixel grid Fest vektorverktøy og transformeringer til pikselrutenettet + Srgb sRGB + Standard Standard + Studio Studio + Studio light Studio lys + Adwaita Adwaita + Adwaita dark Adwaita mørk + Subdivisions Underinndelinger + System System + Text engine Tekstmotor + Text file Tekstfil + Tiny Bitteliten + Touch gestures Berøringsbevegelser + Traditional (72.27 points/inch) Tradisjonell (72,27 punkter/tomme) + Transparency & Gamut Gjennomsiktighet og fargeomfang + Type units Tekstenheter + UI font size Skriftstørrelse i grensesnittet + UI scale Grensesnittskalering + Units & Rulers Enheter og linjaler + Use additional plugins folder Bruk ekstra mappe for programtillegg + Use esc to commit Bruk Esc for å bekrefte + Use gpu Bruk GPU + Use legacy free transform Bruk eldre fri transformering + Use shift key for tool switch Bruk Shift-tasten for verktøybytte + Use tablet pressure Bruk trykk fra tegnebrett + Vary round brush hardness on hud Varier hardheten til runde pensler i HUD + Vulkan Vulkan + Webp WebP + World ready Verdensklar + Zoom clicked point to center Zoom klikket punkt til midten + Zoom resizes windows Zoom endrer vindusstørrelse + Zoom with scroll wheel Zoom med rullehjulet + Zoom with trackpad pinch Zoom med knip på styreflaten + Alt-click to define a source point to clone from Alt-klikk for å angi et kildepunkt å klone fra + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Ingen bane å lage et utvalg fra: tegn en med pennverktøyet eller velg en i banepanelet. + Cancel opening Avbryt åpning + Cancelled opening {name} Avbrøt åpning av {name} + Damaged Photoshop document Skadet Photoshop-dokument + The document “{name}” may be damaged. Dokumentet «{name}» kan være skadet. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Noen lag, piksler eller masker kunne ikke leses. Vil du fortsette å åpne det? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Hvis du fortsetter, kan lagring over originalen forkaste uleselige data for godt. Lagre en kopi for å beholde originalen. + Continue Fortsett + The document “{name}” does not have an embedded {mode} profile. Dokumentet «{name}» har ingen innebygd {mode}-profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumentet «{name}» har en innebygd fargeprofil som ikke samsvarer med gjeldende {mode}-arbeidsfargerom. + Working: {working} Arbeidsfargerom: {working} + Developing {name} Fremkaller {name} + Cancelled {label} Avbrøt {label} + Esc to cancel Esc for å avbryte + Opened {name} Åpnet {name} + Opening {name} Åpner {name} + Saved {name} Lagret {name} + Saving {name} Lagrer {name} + Saving {percent} Lagrer {percent} + A save to {name} is still running; try again when it finishes En lagring til {name} pågår fortsatt. Prøv igjen når den er ferdig + {label} is writing the file and can no longer be cancelled {label} skriver filen og kan ikke lenger avbrytes + Opening {name}… Åpner {name}… + Opening… Åpner… + Reading… Leser… + Working… Arbeider… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Rutenett: +# Rendering mode and recovery + Rendering Mode Gjengivelsesmodus + Automatic (recommended) Automatisk (anbefalt) + CPU / Compatibility CPU / kompatibilitet + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatisk bruker GPU-akselerasjon når det er tilgjengelig, og faller tilbake til CPU-gjengivelse ved feil. + Switched to CPU rendering Byttet til CPU-gjengivelse + GPU acceleration could not continue. Your documents are unchanged. GPU-akselerasjonen kunne ikke fortsette. Dokumentene dine er uendret. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft bruker CPU-bildekomposisjon. Vinduet kan fortsatt bruke grafikkortet ditt. + Retry GPU requires a restart. Save your work first. Prøv GPU på nytt krever omstart. Lagre arbeidet ditt først. + Retry GPU tries the graphics processor again now. Prøv GPU på nytt prøver grafikkprosessoren igjen nå. + GPU acceleration resumed. GPU-akselerasjon gjenopptatt. + Keep Using CPU Fortsett med CPU + Retry GPU Prøv GPU på nytt + GPU Backend GPU-motor + Details Detaljer + GPU GPU + Rendering mode Gjengivelsesmodus + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Fryselasso: dra for å fryse et område, Alt-dra for å tine det (L) + Shadow Clipping Warning (U) Advarsel for skyggeklipping (U) + Highlight Clipping Warning (O) Advarsel for høylysklipping (O) + Show Lab Color Readouts Vis Lab-fargeverdier + Show Vectorscope Vis vektorskop + Show Red at 3 o'clock Vis rød kl. 3 + Show Skin Tone Indicator Vis hudtoneindikator + Tone Histogram Tonehistogram + Show Selected Region Vis valgt område +cameraRaw Light Lys +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Akvamarin +cameraRaw Amount Mengde +cameraRaw Blacks Svarttoner +cameraRaw Vibrance Vibrans +cameraRaw Color Grading Fargegradering +cameraRaw Lights Lyse toner +cameraRaw Darks Mørke toner +cameraRaw Global Global +cameraRaw Red Rød +cameraRaw Orange Oransje +cameraRaw Yellow Gul +cameraRaw Green Grønn +cameraRaw Aqua Akvamarin +cameraRaw Blue Blå +cameraRaw Purple Lilla +cameraRaw Magenta Magenta +cameraRaw Hue Fargetone +cameraRaw Blending Blanding +cameraRaw Luminance Detail Luminansdetalj +cameraRaw Color Detail Fargedetalj +cameraRaw Roughness Ruhet + Patch Tool Lappeverktøy + Patch: Lapp: + Lasso around an area, then drag the selection Lasso rundt et område, og dra deretter utvalget + Transform On Drop Transformer ved slipp + Scale or rotate the moved area, then press {key} Skaler eller roter det flyttede området, og trykk deretter {key} + Now drag the selection onto another area Dra nå utvalget til et annet område + Edit Type Rediger tekst + + New Style… Ny stil… + Style {name} saved Stilen {name} er lagret + + Create new style (from the selected type) Opprett ny stil (fra den valgte teksten) + Create new style from the selected layer Opprett ny stil fra det valgte laget + + Make Default Gjør til standard + Reset to Default Tilbakestill til standard + Full-resolution preview unavailable Forhåndsvisning i full oppløsning er utilgjengelig + No patterns Ingen mønstre + Enable {name} Aktiver {name} + Hold Alt while drawing for straight segments Hold Alt under tegning for rette segmenter + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Dra eller klikk polygonpunkter · hold Alt under lasso for rette segmenter · {add} legger til · {sub} før tegning trekker fra + Extend Utvid + Structure: Struktur: + Content-Aware Move Tool Innholdsbasert flytteverktøy + TIFF Options TIFF-alternativer + Targa Options Targa-alternativer + JPEG Options JPEG-alternativer + Image Options Bildealternativer + Format Options Formatalternativer + Baseline ("Standard") Baseline («standard») + Baseline Optimized Baseline optimalisert + 24 bits/pixel 24 biter/piksel + 32 bits/pixel 32 biter/piksel + Including layers will increase file size. Å ta med lag øker filstørrelsen. + Discard Layers and Save a Copy Forkast lag og lagre en kopi + Don't show again Ikke vis igjen + Save failed: {error} Lagring mislyktes: {error} + Save cancelled Lagring avbrutt + Couldn't place {name}: {error} Kunne ikke plassere {name}: {error} + Magnetic Lasso Tool Magnetlasso + Frequency Frekvens + Use tablet pressure to change pen width Bruk trykk fra tegnebrett til å endre pennbredden + +# About ▸ Contributors / Models (credits) + About Om + Contributors Bidragsytere + Models Modeller + Sort Sorter + Username Brukernavn + Display name Visningsnavn + Real name Ekte navn + Reverse the order Snu rekkefølgen + Grab bag Diverse + Table Tabell + Name (A–Z) Navn (A–Å) + Merged PRs Sammenslåtte PR-er + Commits Commits + Lines added Linjer lagt til + Lines deleted Linjer slettet + Line delta Linjedifferanse + Binary assets added Binærfiler lagt til + Binary assets removed Binærfiler fjernet + First commit Første commit + First Første + Last commit Siste commit + Last Siste + Contributors: {contributors} · Commits: {commits} Bidragsytere: {contributors} · Commits: {commits} + No contributor data was built into this copy. Ingen bidragsyterdata ble bygget inn i denne kopien. + No model credits were built into this copy. Ingen modellkrediteringer ble bygget inn i denne kopien. + Company Selskap + Version Versjon + % of all commits % av alle commits + Lines +/− Linjer +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR-er, {commits} commits, +{added} / −{deleted} linjer (Δ {delta}), +{binAdded} / −{binDeleted} binærfiler, {first} – {last} + +# Swatches panel + Create new swatch Opprett ny fargeprøve + New Swatch… Ny fargeprøve… + New Swatch Group… Ny fargeprøvegruppe… + Import Swatches… Importer fargeprøver… + Replace Swatches… Erstatt fargeprøver… + Export Swatches… Eksporter fargeprøver… + Export Swatches for Exchange… Eksporter fargeprøver for utveksling… + Reset Swatches Tilbakestill fargeprøver + Add to Swatches Legg til i fargeprøver +# Layer Style > Blending Options (Advanced Blending) + Blend If: Bland hvis: + Channels: Kanaler: + Knockout Utsparing + Apply At Canvas Bounds Bruk ved lerretgrensene + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Fargevelger (tekstfarge) + Color Picker ({name}) Fargevelger ({name}) + Color Picker (Fill Color) Fargevelger (fyllfarge) + Fill color Fyllfarge + Color Picker (Stroke Color) Fargevelger (strekfarge) + Stroke color Strekfarge + Pick screen color Hent skjermfarge + Sample a pixel anywhere on the screen Hent en piksel hvor som helst på skjermen + Click to pick · Esc to cancel Klikk for å hente · Esc for å avbryte + Pick a screen pixel or press Esc to cancel Velg en piksel på skjermen eller trykk Esc for å avbryte + Scroll to zoom Rull for å zoome + Drag to pan · Scroll to zoom · Double-click to fit Dra for å panorere · Rull for å zoome · Dobbeltklikk for å tilpasse + Show/Hide All Panels Vis/skjul alle paneler + Show/Hide Panels Vis/skjul paneler + Could not open Kunne ikke åpne + {path}: the path is not valid Unicode; rename the file and open it again. {path}: banen er ikke gyldig Unicode. Gi filen nytt navn og åpne den igjen. + +# Fill dialog, layer style contours and Image Size + Contents: Innhold: + Custom Pattern: Egendefinert mønster: + Foreground Color Forgrunnsfarge + Color… Farge… + 50% Gray 50 % grå + {size} (was {old}) {size} (var {old}) + Cone Kjegle + Cone (Inverted) Kjegle (invertert) + Domed Kuppel + Domed (Inverted) Kuppel (invertert) + Diagonal (Descending) Diagonal (synkende) + Contour Editor Konturredigering + Anti-aliased Kantutjevnet + Corner Hjørne + Gloss Contour Glanskontur + Contour Kontur + Cone - Inverted Kjegle - invertert + Cove - Deep Hulkil - dyp + Cove - Shallow Hulkil - grunn + Gaussian Gaussisk + Half Round Halvrund + Ring Ring + Ring - Double Ring - dobbel + Rolling Slope - Descending Rullende skråning - synkende + Rounded Steps Avrundede trinn + Sawtooth 1 Sagtann 1 + Linear Lineær + + Red Eye Tool Røde øyneverktøy + Pupil Size Pupillstørrelse + Darken Amount Mørkleggingsmengde + Click a red pupil to neutralize it Klikk på en rød pupill for å nøytralisere den + open source åpen kildekode + …and {n} more …og {n} til + + Eyedroppers: Pipetter: + Set Black Point Angi svartpunkt + Set Neutral Gray Point Angi nøytralt gråpunkt + Set White Point Angi hvitpunkt + Unavailable in this color mode Utilgjengelig i denne fargemodusen + Selection Preview: Forhåndsvisning av utvalg: + Black Matte Svart kant + White Matte Hvit kant + Quick Mask Hurtigmaske + +# Layer colour labels. + Seafoam Sjøgrønn + Indigo Indigo + Fuchsia Fuksia + Violet Fiolett + Large document: redrawing on the CPU Stort dokument: tegner på nytt med CPU + Redrawing Tegner på nytt + This redraw took {seconds} s on the CPU. Denne gjentegningen tok {seconds} s på CPU-en. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Redigeringer som endrer hele dokumentet, for eksempel et lags blandingsmodus, dekkevne eller synlighet, tegner alt på nytt. + The GPU compositor wasn't used: {reason} GPU-komposisjon ble ikke brukt: {reason} +fontWeight Thin Tynn +fontWeight ExtraLight Ekstra lett +fontWeight Light Lett +fontWeight Regular Normal +fontWeight Medium Middels +fontWeight SemiBold Halvfet +fontWeight Bold Fet +fontWeight ExtraBold Ekstra fet +fontWeight Black Svært fet (Black) +fontWeight Italic Kursiv + +# Pattern Stamp tool + Pattern Stamp Tool Mønsterstempel + Impressionist Impresjonistisk + Rotate View Tool Roter visningverktøy + Reset View Tilbakestill visning + Drag around the centre to rotate the view · Shift constrains to 15° Dra rundt midtpunktet for å rotere visningen · Shift begrenser til 15° + +# Painting symmetry presets. + Dual Axis Dobbel akse + Diagonal Diagonal + Transform Symmetry Transformer symmetri + Set painting symmetry options Angi symmetrialternativer for maling + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprøve + 3 by 3 Average 3 x 3 gjennomsnitt + 5 by 5 Average 5 x 5 gjennomsnitt + 11 by 11 Average 11 x 11 gjennomsnitt + 31 by 31 Average 31 x 31 gjennomsnitt + 51 by 51 Average 51 x 51 gjennomsnitt + 101 by 101 Average 101 x 101 gjennomsnitt + Sample Size: Prøvestørrelse: + All Layers No Adjustments Alle lag uten justeringer + Current & Below No Adjustments Gjeldende og under uten justeringer + Show Sampling Ring Vis prøvetakingsring + Click to sample the foreground colour · {key}-click for background Klikk for å hente forgrunnsfargen · {key}-klikk for bakgrunn + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Utenfor fargeomfanget for utskrift: klikk for nærmeste utskrivbare farge + Not a web colour: click for the nearest web colour Ikke en webfarge: klikk for nærmeste webfarge + Color to Alpha… Farge til alfa… + Transparency Threshold Gjennomsiktighetsterskel + Opacity Threshold Dekkevneterskel + PhotoCraft — Screen Color Picker PhotoCraft: skjermfargevelger + + Rasterize svg groups deeper than Rasteriser SVG-grupper dypere enn + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 slår av denne terskelen. SVG-grupper nestet dypere enn 100 nivåer rasteriseres alltid. + Rule of Thirds Tredjedelsregelen + Triangle Trekant + Golden Ratio Det gylne snitt + Golden Spiral Den gylne spiral + Auto Show Overlay Vis overlegg automatisk + Always Show Overlay Vis alltid overlegg + Never Show Overlay Vis aldri overlegg + Cycle Overlay Bytt overlegg + Cycle Orientation Bytt retning + Set the overlay options for the Crop tool Angi overleggsalternativer for beskjæringsverktøyet + 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-klikk gikk til skrivebordet, som flytter vinduer med Alt-dra. Sett vindusflyttetasten til Super, eller lås Alt i Vindu › Modifikasjonstaster. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Fargevelger (lagstilfarge) + Auto Hide Notifications Skjul varsler automatisk + Notification Duration (seconds) Varselvarighet (sekunder) + Bundle Arrow-Key Nudges Samle piltastforskyvninger + Nudge Bundle Pause (ms) Pause for samling av forskyvninger (ms) + Appearance Mode Utseendemodus + Dark Theme Mørkt tema + Light Theme Lyst tema + Pro Medium Pro mellom + Studio Light Studio lys + Next Appearance Mode Neste utseendemodus + Sync with system Synkroniser med systemet + Active Aktiv + Color Picker (Solid Color) Fargevelger (ensfarget) + Straighten the image by drawing a line on it Rett opp bildet ved å tegne en linje på det + Set additional Crop options Angi flere beskjæringsalternativer + Show Cropped Area Vis beskåret område + Enable Crop Shield Aktiver beskjæringsskjold + Match Canvas Tilpass lerretet + Auto Adjust Opacity Juster dekkevne automatisk + Crop Shield color Farge for beskjæringsskjold + Color Picker (Crop Shield Color) Fargevelger (farge for beskjæringsskjold) + From Center Fra midten + Link radii Koble radier + Star Ratio: Stjerneforhold: + Create Rectangle Opprett rektangel + Create Ellipse Opprett ellipse + Create Triangle Opprett trekant + Create Polygon Opprett polygon + Pressure curve Trykkurve + Click to add a point, right-click to remove Klikk for å legge til et punkt, høyreklikk for å fjerne + Soft Myk + Hard Hard + Some brush presets could not be loaded Noen penselforvalg kunne ikke lastes inn + Retry saving presets Prøv å lagre forvalg på nytt + Brush preset changes are not saved: {error} Endringer i penselforvalg lagres ikke: {error} + Keep this tab open to retain your brushes. Hold denne fanen åpen for å beholde penslene dine. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Nettleserlagring er utilgjengelig. Penslene gjelder bare for denne økten. Hold denne fanen åpen for å beholde dem. + Saving brush presets… Keep this tab open until saving finishes. Lagrer penselforvalg… Hold denne fanen åpen til lagringen er ferdig. + Reflect Reflekter + Glow Glød + Negation Negasjon + XOR XOR + Color Burn (Paint.NET) Fargebrenning (Paint.NET) + Color Dodge (Paint.NET) Fargeavskjerming (Paint.NET) + Stroke Options Strekalternativer + Solid Heltrukket + Dashed Stiplet + Dotted Prikket + Caps Ender + Butt / Flat Butt / flat + Corners Hjørner + Miter Gjæring + Round Rund + Bevel Skrå + Miter limit Gjæringsgrense + Dashed line Stiplet linje + Dash Strek + Gap Mellomrom + Dash / gap lengths × stroke width Strek-/mellomromslengder × strekbredde + Add pair Legg til par + Remove pair Fjern par + Dash offset Strekforskyvning + Open paths use centered strokes Åpne baner bruker sentrerte streker + Use finite lengths and a positive pattern total Bruk endelige lengder og en positiv mønstersum + No subject found — try Object Selection or the Lasso Fant ikke noe motiv. Prøv objektvalg eller lassoen + Quick Selection found nothing there Hurtigvalg fant ingenting der + Object Selection found nothing there — try a tighter rectangle Objektvalg fant ingenting der. Prøv et strammere rektangel + No subject found — try a rectangle around the object Fant ikke noe motiv. Prøv et rektangel rundt objektet + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Slette laget «{name}»? + Delete {count} layers? {names} Slette {count} lag? {names} + Delete layer? Slette laget? + The document is closed Dokumentet er lukket + The document or selected layers changed. Please request deletion again. Dokumentet eller de valgte lagene er endret. Be om sletting på nytt. + Missing delete command Slettekommando mangler + Invalid delete command Ugyldig slettekommando + Layer deleted, but confirmation preference could not be saved: {error} Laget ble slettet, men bekreftelsesinnstillingen kunne ikke lagres: {error} + Add a layer style Legg til en lagstil + Add a mask (from the selection; {key} inverts) Legg til en maske (fra utvalget; {key} inverterer) + Add an adjustment Legg til en justering + Add to selection ({key}) Legg til i utvalget ({key}) + Adjustment visible Justering synlig + Align and distribute Juster og fordel + Align horizontal centers Juster vannrette sentre + Align left edges Juster venstre kanter + Align right edges Juster høyre kanter + Always use pressure for opacity Bruk alltid trykk for dekkevne + Always use pressure for size Bruk alltid trykk for størrelse + Angle Gradient Vinkelgradient + Auto-Select: Velg automatisk: + Brush Preset picker Penselforvalgsvelger + Cancel current crop operation (Esc) Avbryt pågående beskjæring (Esc) + Click the first point or press {key} to close · Esc cancels Klikk på det første punktet eller trykk {key} for å lukke · Esc avbryter + Click the first point or press {key} to close · {del} removes a point · Esc cancels Klikk på det første punktet eller trykk {key} for å lukke · {del} fjerner et punkt · Esc avbryter + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klikk, og beveg deretter langs en kant · klikk for å feste et punkt · {alt}-klikk for et rett segment · dobbeltklikk for å lukke + Clip to layer below Klipp til laget under + Color & Swatches Farge og fargeprøver + Commit current crop operation ({key}) Bekreft pågående beskjæring ({key}) + Create a new group Opprett en ny gruppe + Create a new layer Opprett et nytt lag + Create new document from current state Opprett nytt dokument fra gjeldende tilstand + Create new fill or adjustment layer Opprett nytt fyll- eller justeringslag + Create new snapshot Opprett nytt øyeblikksbilde + From: Fra: + Full Document Hele dokumentet + Merged Layers Sammenslåtte lag + Delete Cropped Pixels Slett beskårne piksler + Delete current state Slett gjeldende tilstand + Delete layer Slett lag + Diamond Gradient Diamantgradient + Doc: {w} × {h} px Dok: {w} × {h} px + Enable airbrush-style build-up effects Aktiver oppbyggingseffekter som med airbrush + Filter for adjustment layers Filtrer etter justeringslag + Filter for pixel layers Filtrer etter piksellag + Filter for shape layers Filtrer etter formlag + Filter for smart objects Filtrer etter smartobjekter + Filter for type layers Filtrer etter tekstlag + Fixed Ratio Fast forhold + Fixed Size Fast størrelse + Follows only edges this close to the pointer ([ and ] change it) Følger bare kanter så nær pekeren ([ og ] endrer det) + Hide Properties Skjul egenskaper + Higher values follow only edges that contrast sharply with their surroundings Høyere verdier følger bare kanter som kontrasterer skarpt med omgivelsene + How often fastening points are placed by themselves Hvor ofte festepunkter plasseres av seg selv + Hue/Saturation Fargetone/metning + Intersect with selection ({key}) Snitt med utvalget ({key}) + Kind Type + Linear Gradient Lineær gradient + Link layers Koble lag + Lock Lås + Lock all Lås alt + Lock image pixels Lås bildepiksler + Lock layer Lås lag + Lock position Lås posisjon + Lock transparent pixels Lås gjennomsiktige piksler + New selection Nytt utvalg + No properties Ingen egenskaper + Pressure for Opacity Trykk for dekkevne + Pressure for Size Trykk for størrelse + Prevent auto-nesting in and out of Artboards and Frames Hindre automatisk nesting inn i og ut av tegneflater og rammer + Radial Gradient Radial gradient + Reflected Gradient Speilet gradient + Reset to defaults Tilbakestill til standard + Set additional smoothing options Angi flere utjevningsalternativer + Show Transform Controls Vis transformeringskontroller + Subtract from selection ({key}) Trekk fra utvalget ({key}) + Swaps height and width Bytter om høyde og bredde + Tolerance Toleranse + Add vector mask Legg til vektormaske + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klikk for å zoome inn · {key}-klikk for å zoome ut · dra til høyre/venstre for å zoome inn/ut + Appearance Settings… Utseendeinnstillinger… + Add to favorites Legg til i favoritter + Remove from favorites Fjern fra favoritter +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Produsent +cameraData Model Modell +cameraData Date Taken Opptaksdato +cameraData Exposure Eksponering +cameraData F-Stop Blenderåpning +cameraData ISO Speed ISO-følsomhet +cameraData Focal Length Brennvidde +cameraData Focal Length (35 mm) Brennvidde (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktiver panel + Change Panel Group Bytt panelgruppe + Change Panel Layout Endre paneloppsett + Close Panel Lukk panel + Dock Panel Dokk panel + Float Panel Flytende panel + Move Panel Flytt panel + Group with Grupper med + Panel menu Panelmeny + Resize panel window Endre størrelse på panelvindu + Resize panels Endre størrelse på paneler + Use Classic Mode Bruk klassisk modus + Auto Center Preview Sentrer forhåndsvisning automatisk + Next Document Neste dokument + Previous Document Forrige dokument + W x H x Resolution B x H x oppløsning + Front Image Fremste bilde + Frame Tool Rammeverktøy + Create a new rectangular frame Opprett en ny rektangulær ramme + Create a new elliptical frame Opprett en ny elliptisk ramme + Include in playback Ta med i avspilling + Perspective Crop Tool Perspektivbeskjæringsverktøy + Show Grid Vis rutenett + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Dra en boks eller klikk fire hjørner · dra hjørnene til kantene på en skjev form · {commit} bekrefter · Esc avbryter + Artboard Tool Tegneflateverktøy + Make Portrait Gjør stående + Make Landscape Gjør liggende + Add New Artboard Legg til ny tegneflate + Freeform Pen Tool Frihåndspenn + Curvature Pen Tool Kurvaturpenn + Curve Fit: Kurvetilpasning: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Dra: tegn på frihånd · {alt}-klikk: rett segment · Slipp på startpunktet: lukk + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klikk: mykt punkt · Dobbeltklikk: hjørne · Dra et punkt: flytt · Klikk på første punkt: lukk · {key} fullfør + Scale: Skala: + Snap to Origin Fest til origo + Link with Layer Koble til lag + Rasterize Layer Style Rasteriser lagstil + Search settings… Søk i innstillinger… + No matching settings Ingen samsvarende innstillinger +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Prøvetakingspensel + Sampling Area Overlay Overlegg for prøvetakingsområde + Show Sampling Area Vis prøvetakingsområde + Indicates Angir + Sampling Area Prøvetakingsområde + Excluded Area Utelatt område + Sampling Area Options Alternativer for prøvetakingsområde + Rectangular Rektangulært + Fill Settings Fyllinnstillinger + Rotation Adaptation Rotasjonstilpasning + Very High Svært høy + Full Full + Mirror Speil + Output Settings Utdatainnstillinger + Output To Utdata til + Duplicate Layer Dupliser lag + Contract Trekk sammen + + Animation Options Animasjonsalternativer + Entire Document Hele dokumentet + Filter Options Filteralternativer + Layer Bounds Laggrenser + Layers Panel Options Alternativer for lagpanelet + Lock Layers Lås lag + New Artboard… Ny tegneflate… + New Group… Ny gruppe… + Prevent Auto-Nesting Hindre automatisk nesting + Show Layers Vis lag + Thumbnail Contents Miniatyrinnhold + Thumbnail Size Miniatyrstørrelse + Use Default Masks on Fill Layers Bruk standardmasker på fyllag + Expand New Effects Utvid nye effekter + Add “copy” to Copied Layers and Groups Legg til «kopi» på kopierte lag og grupper + Preview on canvas when reordering layers Forhåndsvis på lerretet når lag omorganiseres + Show layer mask badges Vis lagmaskemerker + +# Smart Filter editors + Blending Options ({filter}) Blandingsalternativer ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Skjul lagets effekter og smartfiltre ({key}-klikk: alle lag) + Show the layer's effects and Smart Filters ({key}-click: all layers) Vis lagets effekter og smartfiltre ({key}-klikk: alle lag) + Wait for the current job to finish. Vent til den gjeldende jobben er ferdig. + The document or Smart Filter changed. Reopen its dialog. Dokumentet eller smartfilteret er endret. Åpne dialogen på nytt. + Opacity must be between 0% and 100%. Dekkevnen må være mellom 0 % og 100 %. + Dissolve is approximated: it blends without Photoshop's dither. Oppløs er tilnærmet: den blander uten Photoshops dithering. + Loading fonts… Laster inn skrifter… + Response curve Responskurve + Tool Verktøy + Simulate Pressure Simuler trykk + Stroke Path… Strek opp bane… + Background Eraser Bakgrunnsviskelær + Healing Brush Reparasjonspensel + Spot Healing Brush Punktreparasjonspensel + Smudge Gni ut + Sponge Svamp + Amount: Mengde: + Protect: Beskytt: + Protect skin tones Beskytt hudtoner + Edit points to modify the curve Rediger punkter for å endre kurven + Draw to modify the curve Tegn for å endre kurven +curves Smooth Jevn ut + Smooth the curve values Jevn ut kurveverdiene + Drag to draw the curve · {key}-click draws a straight line Dra for å tegne kurven · {key}-klikk tegner en rett linje + Grid Size: Rutenettstørrelse: + Vanishing Point: click four corners to create a plane Fluktpunkt: klikk fire hjørner for å opprette et plan + Vanishing Point is not active Fluktpunkt er ikke aktivt + Apply Vanishing Point ({key}) Bruk fluktpunkt ({key}) + Fix the red plane first: its corners must form a convex quad Rett opp det røde planet først: hjørnene må danne en konveks firkant + Alt-click a plane to set the clone source Alt-klikk på et plan for å angi klonekilden + Too many clone strokes: apply Vanishing Point first For mange klonestrøk: bruk fluktpunkt først +# Select and Mask view modes (#1754) + Onion Skin Løkskall + Marching Ants Marsjerende maur + On Black På svart + On White På hvitt + On Layers På lag + Show Edge Vis kant + Show Original Vis original + F cycles the views; hold X to see the image F bytter visning, hold X for å se bildet +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Kantforbedringspensel (R) + Expand Detection Area Utvid gjenkjenningsområdet + Restore Original Edge Gjenopprett opprinnelig kant + Paint over hair or fur on the image; Alt paints the other mode Mal over hår eller pels i bildet. Alt maler i den andre modusen + Clear Brush Strokes Fjern penselstrøk + Show rulers Vis linjaler + New Crop Preset… Nytt beskjæringsforvalg… + Delete Crop Preset… Slett beskjæringsforvalg… + Select the crop presets to delete: Velg beskjæringsforvalgene som skal slettes: + Select the crop presets to delete Velg beskjæringsforvalgene som skal slettes + Enter a name for the crop preset Skriv inn et navn på beskjæringsforvalget + There is no crop size or ratio to save Det finnes ingen beskjæringsstørrelse eller -forhold å lagre + Presentation Presentasjon + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klikk for å plassere · Alt-klikk eller dra utenfor lerretet for å fjerne + No document open Ingen dokumenter er åpne + {n} of {max} sample points {n} av {max} prøvepunkter + +# History panel menu (#2966) + New Snapshot… Nytt øyeblikksbilde… + History Options… Historikkalternativer… +# History panel › History Options… (#2966) + Allow Non-Linear History Tillat ikke-lineær historikk + Make Layer Visibility Changes Undoable Gjør endringer i lagsynlighet angrebare +# Print dialog (#2697) + Emulsion Down Emulsjon ned + Functions Funksjoner + Print Resolution: Utskriftsoppløsning: + Use Previous Layer to Create Clipping Mask Bruk forrige lag til å opprette klippemaske + Fill with {mode}-neutral color (white) Fyll med {mode}-nøytral farge (hvit) + Fill with {mode}-neutral color (black) Fyll med {mode}-nøytral farge (svart) + Fill with {mode}-neutral color (50% gray) Fyll med {mode}-nøytral farge (50 % grå) + (No neutral color exists for {mode} mode.) (Det finnes ingen nøytral farge for {mode}-modus.) + Apply mask to layer before removing? Bruke masken på laget før den fjernes? + The layer or its mask changed. Drag the mask onto the Trash again. Laget eller masken er endret. Dra masken til papirkurven på nytt. + Delete the vector mask? Slette vektormasken? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Eksporterte {n} fargeprøve til {file}|Eksporterte {n} fargeprøver til {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importerte {n} pensel fra {group}|Importerte {n} pensler fra {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importerte {n} fargeprøve fra {file}|Importerte {n} fargeprøver fra {file} +@plural Played {n} step|Played {n} steps Spilte av {n} trinn|Spilte av {n} trinn +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Oppdaterte {n} koblet smartobjekt som var endret på disk|Oppdaterte {n} koblede smartobjekter som var endret på disk +@plural Saved for Web: {n} file|Saved for Web: {n} files Lagret for web: {n} fil|Lagret for web: {n} filer +@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} lag registrert av denne komposisjonen er slettet. Oppdater komposisjonen, eller fjern advarselen.|{n} lag registrert av denne komposisjonen er slettet. Oppdater komposisjonen, eller fjern advarselen. +@plural {n} plane|{n} planes {n} plan|{n} plan +@plural {n} preset|{n} presets {n} forvalg|{n} forvalg +@plural {n} sample point removed|{n} sample points removed {n} prøvepunkt fjernet|{n} prøvepunkter fjernet +@plural {n} slice|{n} slices {n} segment|{n} segmenter +@plural {n} step|{n} steps {n} trinn|{n} trinn +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advarsel til om penselforvalg)|{warning} (+{n} advarsler til om penselforvalg) +@plural {what} with {n} warning|{what} with {n} warnings {what} med {n} advarsel|{what} med {n} advarsler +# View › Guides › New Guide… › Color + Light Blue Lyseblå + Light Red Lyserød + Medium Blue Mellomblå + Light Gray Lysegrå + Object Finder Objektfinner + No object found there — drag a rectangle around it Fant ikke noe objekt der. Dra et rektangel rundt det + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Det aktive laget har ingen piksler her. Slå på Ta prøve fra alle lag, eller velg et lag med piksler + Remove after each stroke Fjern etter hvert strøk + Apply the removal ({key}) Bruk fjerningen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Strøk som skal fjernes: {n} (Enter bruker, Esc avbryter) + Load Contours… Last inn konturer… + Replace Contours… Erstatt konturer… + Save Contours… Lagre konturer… + Delete Contour Slett kontur + Loaded contours from {file} Lastet inn konturer fra {file} + Saved contours to {file} Lagret konturer til {file} + Show home recent files Vis nylig brukte filer på startskjermen + Clear Brush Controls Fjern penselkontroller + Reset All Locked Settings Tilbakestill alle låste innstillinger + Copy Texture to Other Tools Kopier tekstur til andre verktøy + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Last inn handlinger… + Save Actions… Lagre handlinger… + New Action… Ny handling… + Start Recording Start opptak + Stop Recording Stopp opptak + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Lastet inn {n} handling fra {file}|Lastet inn {n} handlinger fra {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} trinn har ingen PhotoCraft-ekvivalent ennå|{n} trinn har ingen PhotoCraft-ekvivalent ennå +@plural Saved {n} action to {file}|Saved {n} actions to {file} Lagret {n} handling til {file}|Lagret {n} handlinger til {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} trinn uten Photoshop-ekvivalent ble utelatt|{n} trinn uten Photoshop-ekvivalent ble utelatt +@plural {n} action file loaded|{n} action files loaded {n} handlingsfil lastet inn|{n} handlingsfiler lastet inn + {set} from {file} replaced the one from {earlier} Settet {set} fra {file} erstattet det fra {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fil kunne ikke lastes inn: {errors}|{n} filer kunne ikke lastes inn: {errors} + + File Naming Filnavngiving + Starting serial Startserienummer +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Sett inn stopp… + Save Set… Lagre sett… + Message: Melding: + Allow Continue Tillat Fortsett + Show dialog during playback Vis dialog under avspilling + Select an action first Velg en handling først + Action playback stopped Avspilling av handling stoppet + Step {step} is waiting for its dialog Trinn {step} venter på dialogen sin +@plural {n} action|{n} actions {n} handling|{n} handlinger + Background (Default) Bakgrunn (standard) + Content-Aware Fill Innholdsbasert fyll +# Batch / Image Processor + Include All Subfolders Ta med alle undermapper + Auto Add/Delete Legg til/slett automatisk + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Ikke kryss også av for Speilvend bilde i Utskriftsinnstillinger…: de to vendingene opphever hverandre. + Print Settings… Utskriftsinnstillinger… + Remember to turn off the printer's own colour management in Print Settings… Husk å slå av skriverens egen fargestyring i Utskriftsinnstillinger… + Sent to {printer} Sendt til {printer} + set in Print Settings… angitt i Utskriftsinnstillinger… + ICO is limited to 256 × 256 pixels ICO er begrenset til 256 × 256 piksler + Calibration Kalibrering + Red Primary Primærfarge rød + Green Primary Primærfarge grønn + Blue Primary Primærfarge blå + Write xmp sidecars Skriv XMP-sidefiler + Set the tone sliders from the image Sett toneglidebryterne fra bildet + Settings from {name} Innstillinger fra {name} + Settings from {name}; {count} not supported: {names} Innstillinger fra {name}; {count} støttes ikke: {names} + Could not write {name}: {error} Kunne ikke skrive {name}: {error} + Kept {name}: written by another application Beholdt {name}: skrevet av et annet program + Shadow Tint Skyggetone +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maler fra kildetilstanden i historikkpanelet + Sets the source for the history brush Angir kilden for historikkpenselen + +# Dynamic selection context menu and appearance favorites + Select Inverse Inverter utvalg + Pro (Light) Pro (lys) + Pro (Light Gray) Pro (lysegrå) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Etikett + Date and Time Dato og klokkeslett + Scale Units Skalaenheter + Scale Factor Skaleringsfaktor + Area Område + Perimeter Omkrets + Circularity Sirkularitet + Gray Value (Minimum) Gråverdi (minimum) + Gray Value (Maximum) Gråverdi (maksimum) + Gray Value (Mean) Gråverdi (gjennomsnitt) + Gray Value (Median) Gråverdi (median) + Integrated Density Integrert tetthet + Length Lengde + Show Favorite Fonts Vis favorittskrifter + No favorite fonts yet: click the star beside a font to add it. Ingen favorittskrifter ennå: klikk stjernen ved siden av en skrift for å legge den til. + Favorite fonts Favorittskrifter + Add to Favorites Legg til i favoritter + Remove from Favorites Fjern fra favoritter + Show All Fonts Vis alle skrifter + All Classes Alle klasser + Blackletter Frakturskrift + Decorative Dekorativ + Font preview: Forhåndsvisning av skrift: + Handwritten Håndskrift + Hide Font Classes Skjul skriftklasser + Hide Styles Skjul snitt + Monospace Fast bredde + No fonts match these filters. Ingen skrifter samsvarer med disse filtrene. + No similar fonts: the current font doesn't say what it looks like. Ingen lignende skrifter: gjeldende skrift oppgir ikke hvordan den ser ut. + OpenType font OpenType-skrift + Sans Serif Sans seriff + Script Skriveskrift + Serif Seriff + Show Font Classes Vis skriftklasser + Show Similar Fonts Vis lignende skrifter + Show Styles Vis snitt + Show Variable Fonts Vis variable skrifter + Slab Serif Blokkseriff + TrueType font TrueType-skrift + Variable font Variabel skrift +# Bevel & Emboss shading controls. + Highlight Mode Høylysmodus + Highlight Color Høylysfarge + Highlight Opacity Høylysdekkevne + Shadow Mode Skyggemodus + Shadow Color Skyggefarge + Shadow Opacity Skyggedekkevne +# PhotoCraft release checks. + Check for Updates… Se etter oppdateringer… + Open the release page to choose an installer for your system. Åpne utgivelsessiden for å velge et installasjonsprogram for systemet ditt. + Automatically Check for Updates Se etter oppdateringer automatisk +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Bruk den innebygde profilen ({profile}) + Convert to the working space ({profile}) Konverter til arbeidsfargerommet ({profile}) + Remember this choice for future images Husk dette valget for fremtidige bilder + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Slå på spørsmålet igjen med Spør ved avvik i Rediger › Fargeinnstillinger. + Button Mode Knappemodus + Button Mode On Knappemodus på + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Gjør grensesnittet mørkere + Lighten Interface Gjør grensesnittet lysere +# Bevel & Emboss Technique. + Technique Teknikk + Chisel Hard Meisel hard + Chisel Soft Meisel myk + Open color-less EXR as ACEScg Åpne fargeløs EXR som ACEScg + Do not append "copy" to filename when saving a copy Ikke legg til «copy» i filnavnet når du lagrer en kopi + +# View › Guides › right-click a guide + Edit Selected Guides Rediger valgte hjelpelinjer + Clear Selected Guides Fjern valgte hjelpelinjer + Bicubic Automatic Bikubisk automatisk + Bicubic Smoother Bikubisk jevnere + Bicubic Sharper Bikubisk skarpere + Pasting from other apps is unavailable Innliming fra andre programmer er utilgjengelig + 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 får ikke tilgang til systemets utklippstavle i denne økten. På Wayland skjuler en Flatpak-sandkasse protokollen som bilder fra andre programmer leses gjennom. + To paste from other apps, start PhotoCraft under XWayland: `{command}` For å lime inn fra andre programmer, start PhotoCraft under XWayland: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Ingenting å lime inn: PhotoCraft kan ikke lese systemets utklippstavle her + Delete this set Slett dette settet + New Set… Nytt sett… + PhotoCraft Defaults PhotoCraft-standarder + Save a list of these shortcuts as a web page Lagre en liste over disse hurtigtastene som en nettside + Save the changes to this set Lagre endringene i dette settet + Save these shortcuts as a .kys file Lagre disse hurtigtastene som en .kys-fil + Save these shortcuts as a new set Lagre disse hurtigtastene som et nytt sett + Set deleted. Settet er slettet. + Set saved. Settet er lagret. + Set: Sett: + Summarize… Oppsummer… + There are too many sets: delete one first. Det er for mange sett: slett ett først. + set name settnavn + {set} (modified) {set} (endret) + Random Tilfeldig + Show file name in title Vis filnavn i tittelen + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 % terskel + Pattern Dither Mønsterdithering + Diffusion Dither Diffusjonsdithering + Halftone Screen Halvtoneraster + Custom Pattern Egendefinert mønster + Frequency: Frekvens: + Lines/Inch Linjer/tomme + Lines/Centimeter Linjer/centimeter + Ellipse Ellipse + Diamond Diamant + Cross Kryss + Accept and Go to Conflict Godta og gå til konflikt + Changes undone. Endringene er angret. + Choose a new shortcut for this command, or leave it without one. Velg en ny hurtigtast for denne kommandoen, eller la den være uten. + Put this command's shortcuts back as they were Sett denne kommandoens hurtigtaster tilbake som de var + Take the shortcut from the other command and select it Ta hurtigtasten fra den andre kommandoen og velg den + Undo Changes Angre endringer + {shortcut} is reserved by the system and can't be assigned. {shortcut} er reservert av systemet og kan ikke tilordnes. + Low-Latency Drawing (vsync off, applies at next launch) Tegning med lav forsinkelse (vsync av, trer i kraft ved neste oppstart) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Fri transformering brukes fortsatt. Prøv igjen når den er ferdig. + The transform couldn't be applied. Transformeringen kunne ikke brukes. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Lagene trenger {needed} GPU-minne. Budsjettet gir dem {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Sett GPU-minne i Innstillinger › Ytelse til det grafikkortet kan holde, høyst 3/8 av minnet. Automatisk følger Minnebruk, innenfor en fjerdedel av datamaskinens minne og 3/8 av kortets, og regner ikke dokumentet mot et kort med kjent minne. Statuslinjen viser GPU-minne fullt så lenge de ikke får plass. + GPU Memory Full GPU-minne fullt + 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. Lagene trenger {needed} GPU-minne og kan holde {budget}, så redigeringer som tegner hele dokumentet på nytt gjør det på CPU-en. Klikk for å angi GPU-minne. + GPU memory budget: {budget} GPU-minnebudsjett: {budget} + Graphics memory: {size} Grafikkminne: {size} + This document's layers: {needed} of {budget} for layers Dette dokumentets lag: {needed} av {budget} for lag + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokumentets lag trenger {needed}, men kan holde {budget}: gjentegning av hele dokumentet bruker CPU-en + GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Størrelsesgrense for arbeidsdisk (MB, 0 = ingen grense) + {path}: the path is not valid Unicode; save under another name. {path}: banen er ikke gyldig Unicode. Lagre under et annet navn. + Replace Erstatt + {name} was changed by another app after it was opened or saved here. {name} ble endret av et annet program etter at den ble åpnet eller lagret her. + Replace it with this document, or cancel and use File › Save As to keep both. Erstatt den med dette dokumentet, eller avbryt og bruk Fil › Lagre som for å beholde begge. + +# 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) (kommer snart) + (none) (ingen) + 32-bit Preview Options Alternativer for 32-biters forhåndsvisning + 3D… 3D… + A: A: + AI Integrations KI-integrasjoner + About {app} Om {app} + Accented Edges Aksentuerte kanter + Action playback failed: {error} Avspilling av handling mislyktes: {error} + Actual Pixels Faktiske piksler + Add Mask Legg til maske + Add Noise Legg til støy + Add a mask (from the selection; ⌥ inverts) Legg til en maske (fra utvalget; ⌥ inverterer) + Add space after paragraph Legg til mellomrom etter avsnitt + Add space before paragraph Legg til mellomrom før avsnitt + Add to selection (⇧) Legg til i utvalget (⇧) + Added swatch {name} La til fargeprøven {name} + Adds grain to the soft edges of the brush tip. Legger til korn i de myke kantene på penselspissen. + Adjust Interval For Loops Juster intervall for løkker + Adjustment Layers Justeringslag + Adjustment Properties Egenskaper for justering + Adjustment layers have nothing to transform Justeringslag har ingenting å transformere + Adobe PDF Presets… Adobe PDF-forvalg… + Adobe-rgb-compat Adobe RGB-kompatibel + Adwaita (Dark) Adwaita (mørk) + Adwaita (Light) Adwaita (lys) + After Etter + Airbrush: keeps painting while the pointer rests. Airbrush: fortsetter å male mens pekeren hviler. + All shortcuts reset to Photoshop defaults. Alle hurtigtaster tilbakestilt til Photoshop-standard. + Altitude Høyde + Ambience Stemning + Ambient Omgivelseslys + Amplitude Max Maks amplitude + Amplitude Min Min amplitude + Android 1080p Android 1080p + Angled Strokes Vinklede strøk + Anisotropic Anisotropisk + Any Alle + Apple Watch 45mm Apple Watch 45 mm + Apply Data Set Bruk datasett + Apply Image Bruk bilde + Apply Mask Bruk maske + Apply to All Bruk på alle + Apply {option}: {state} Bruk {option}: {state} + Arbitrary Vilkårlig + Arc Lower Nedre bue + Arc Upper Øvre bue + Are you sure you want to quit? Er du sikker på at du vil avslutte? + Are you sure? Er du sikker? + Around Center Rundt midten + Arrangement Ordning + Arrows Piler + Artboard from Group Tegneflate fra gruppe + Artboard from Layers Tegneflate fra lag + Artboards Tegneflater + Artboards to Files Tegneflater til filer + Artboards to PDF Tegneflater til PDF + Artistic Kunstnerisk + Ask On Mismatch Spør ved avvik + Ask On Missing Spør ved manglende + Ask On Paste Spør ved innliming + Assign Profile Tilordne profil + At Ved + Author Title Forfattertittel + Auto Scale Automatisk skalering + Auto-Align Layers Juster lag automatisk + Auto-Blend Layers Bland lag automatisk + Autosave failed: {error} Autolagring mislyktes: {error} + Back Tilbake + Backend: {backend} ({type}) Motor: {backend} ({type}) + Background Level Bakgrunnsnivå + Bamboo Bambus + Bas Relief Basrelieff + Base Tree Type Grunntretype + Based On Basert på + Baseline shift Grunnlinjeforskyvning + Basic Latin Grunnleggende latin + Batch Satsvis + Before Før + Bend Bøy + Berries Bær + Best Beste + Black Clip Svartklipping + Black Intensity Svartintensitet + Blade Curvature Bladkrumning + Blast Eksplosjon + Bleach Bypass Blekeforbikobling + Blend Clipped Layers as Group Bland klippede lag som gruppe + Blend Interior Effects as Group Bland indre effekter som gruppe + Blend Text Gamma Bland tekstgamma + Blending Mode Blandingsmodus + Bloat Blås opp + Blocks Blokker + Blue Yellow Blå-gul + Blues Blåtoner + Border Kant + Border Thickness Kanttykkelse + Box Blur Bokssløring + Bpc BPC + Branches Color Greinfarge + Branches Height Greinhøyde + Branches Thickness Greintykkelse + Breeze Dark Breeze mørk + Breeze Light Breeze lys + Brick Murstein + Bristle Detail Bustdetalj + Browse Bla gjennom + Brush Detail Penseldetalj + Brush Settings… (F5): every option of this brush Penselinnstillinger… (F5): alle alternativer for denne penselen + Brush Strokes Penselstrøk + Brush Type Penseltype + Burlap Jutevev + Calculations Beregninger + Camera Raw Camera Raw + Camera Raw Filter Camera Raw-filter + Cancel transform Avbryt transformering + Candle Light Levende lys + Canvas Size Lerretstørrelse + Case Sensitive Skill mellom store og små bokstaver + Ccw Mot klokken + Cell Size Cellestørrelse + Center X Midtpunkt X + Center Y Midtpunkt Y + Chalk Area Krittområde + Change Find Erstatt og søk + Changes Saved Endringer lagret + Changes have not been saved. Endringene er ikke lagret. + Change… Endre… + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Charcoal Area Trekullområde + Charcoal Thickness Trekulltykkelse + Check for Updates Se etter oppdateringer + Checkerboard Sjakkbrett + Choke Innsnevring + Choose Folder Velg mappe + Circle Sirkel + Cleanliness Renhet + Clear Amount Tømmemengde + Clear Selection Fjern utvalg + Click sets foreground · right-click sets background Klikk angir forgrunn · høyreklikk angir bakgrunn + Click the first point or press ↵ to close · Esc cancels Klikk på det første punktet eller trykk ↵ for å lukke · Esc avbryter + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Klikk for å legge til et punkt · dra bort, ⌘/Ctrl-klikk eller Delete for å fjerne + Click to apply, ⌥-click to clear overrides, double-click for Style Options Klikk for å bruke, ⌥-klikk for å fjerne overstyringer, dobbeltklikk for stilalternativer + Click to zoom in · ⌥-click to zoom out Klikk for å zoome inn · ⌥-klikk for å zoome ut + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klikk: hjørne · Dra: myk · Klikk på første punkt: lukk · ↩ fullfør · Esc avbryt + Clipping Mask Klippemaske + Clipping Path… Klippebane… + Clone Klon + Clone source set at {x}, {y} Klonekilde angitt ved {x}, {y} + Close Window Lukk vindu + Clumped Klumpet + Cmyk CMYK + Coarse Dots Grove prikker + Coated-cmyk Bestrøket CMYK + Collapse group (⌥-click: all groups) Fold sammen gruppe (⌥-klikk: alle grupper) + Color Decontaminate Fjern fargesmitte + Color Halftone Fargeraster + Color Indicates Masked Areas Farge angir maskerte områder + Color Indicates Selected Areas Farge angir valgte områder + Color Lookup Tables Fargeoppslagstabeller + Color Picker Fargevelger + Color Picker (Background Color) Fargevelger (bakgrunnsfarge) + Color Picker (Custom Canvas Color) Fargevelger (egendefinert lerretfarge) + Color Picker (Foreground Color) Fargevelger (forgrunnsfarge) + Color Sampler Tool Fargemålerverktøy + Color Settings Fargeinnstillinger + Color Swatches Fargeprøver + Color Table Fargetabell + Color management: {error} Fargestyring: {error} + Colored Pencil Fargeblyant + Comic Book Tegneserie + Commit Perspective Warp (↩) Bekreft perspektivfordreining (↩) + Commit Puppet Warp (↩) Bekreft marionettfordreining (↩) + Commit any current edits (⌘↩) Bekreft pågående redigering (⌘↩) + Commit current crop operation (↵) Bekreft pågående beskjæring (↵) + Commit transform Bekreft transformering + Commit transform (↩) Bekreft transformering (↩) + Commit warp (↩) Bekreft fordreining (↩) + Compare Sammenlign + Conditional Mode Change Betinget modusendring + Confirm Bekreft + Conform Tilpass + Constraints Begrensninger + Content-Aware Fill needs a selection on a pixel layer Innholdsbasert fyll krever et utvalg på et piksellag + Contrasty Kontrastrik + Convert Konverter + Convert to Alpha Channel Konverter til alfakanal + Convert to Linked Konverter til koblet + Convert to Profile Konverter til profil + Cool Kjølig + Cooling L B B Kjølefilter (LBB) + Cooling80 Kjølefilter (80) + Cooling82 Kjølefilter (82) + Copy Complete Stroke Kopier hele strøket + Copy Fill Kopier fyll + Copyright Url Opphavsretts-URL + Correct C A Korriger kromatisk aberrasjon + Correct Distortion Korriger forvrengning + Correct Vignette Korriger vignettering + Could not rename the action: {error} Kunne ikke gi handlingen nytt navn: {error} + Could not transform: the layer is empty Kunne ikke transformere: laget er tomt + Could not use Perspective Warp: the layer is empty Kunne ikke bruke perspektivfordreining: laget er tomt + Could not use Puppet Warp: no opaque pixels Kunne ikke bruke marionettfordreining: ingen ugjennomsiktige piksler + Could not use Puppet Warp: the layer is empty Kunne ikke bruke marionettfordreining: laget er tomt + Couldn't open {name}: {error} Kunne ikke åpne {name}: {error} + Couldn't save preferences: {error}. Retrying… Kunne ikke lagre innstillingene: {error}. Prøver igjen… + Crack Brightness Sprekklysstyrke + Crack Depth Sprekkdybde + Crack Spacing Sprekkavstand + Craquelure Krakelering + Create By Opprett ved + Create Vector Mask Opprett vektormaske + Create Warped Text Opprett fordreid tekst + Create a new layer (⇧⌘N) Opprett et nytt lag (⇧⌘N) + Create new brush from the current settings Opprett ny pensel fra gjeldende innstillinger + Create new brush preset from the current settings Opprett nytt penselforvalg fra gjeldende innstillinger + Create… Opprett… + Cross Process Kryssfremkalling + Crosshatch Krysskravering + Crosswise På tvers + Crystallize Krystalliser + Currency Valuta + Curve Darks Kurve: mørke toner + Curve Highlights Kurve: høylys + Curve Lights Kurve: lyse toner + Curve Shadows Kurve: skygger + Curve Splits Kurvedelinger + Custom Branch Color Egendefinert greinfarge + Custom Pixel Aspect Ratio Egendefinert pikselforhold + Custom pattern Egendefinert mønster + Cutout Utskjæring + Cw Med klokken + Cyans Cyantoner + Cyrillic Kyrillisk + Daisies Tusenfryd + Dark Gray Mørkegrå + Dark Intensity Mørk intensitet + Dark Rough Mørk ru + Dark Strokes Mørke strøk + Darken Only Bare mørkere + Darkness Mørke + Data Data + Data Set: {name} ({index}/{total}) Datasett: {name} ({index}/{total}) + Data Sets as Files Datasett som filer + Date Dato + Day For Night Dag til natt + De-Interlace Fjern linjesprang + Decrease Brush Hardness Reduser penselhardhet + Decrease Brush Size Reduser penselstørrelse + Decrease Type Size Reduser tekststørrelse + Decrease Type Size by 10 Reduser tekststørrelse med 10 + Deep Dyp + Deep Blue Dyp blå + Deep Emerald Dyp smaragd + Deep Red Dyp rød + Deep Yellow Dyp gul + Default Foreground and Background Colors Standard forgrunns- og bakgrunnsfarge + Default Leaves Standardblader + Default Photoshop Size Standard Photoshop-størrelse + Defaults Standarder + Define Brush Preset Definer penselforvalg + Define Custom Shape Definer egendefinert form + Define Pattern Definer mønster + Definition Definisjon + Defringe Fjern frynser + Degrees Grader + Delete Channel Slett kanal + Delete Mask Slett maske + Delete Workspace Slett arbeidsområde + Delete brush Slett pensel + Delete path Slett bane + Delete vector mask Slett vektormaske + Delete… Slett… + Depth Map Dybdekart + Depth Mode Dybdemodus + Diameter Diameter + Diffuse Diffuser + Diffuse Glow Diffus glød + Direction Retning + Direction Balance Retningsbalanse + Disable Deaktiver + Disable Mask Deaktiver maske + Disable Symmetry Path Deaktiver symmetribane + Disabled Deaktivert + Discard Changes Forkast endringer + Displace Forskyv + Display Skjerm + Display profile not used Skjermprofil ikke brukt + Display profiles: {reason} Skjermprofiler: {reason} + Display-p3 Display-P3 + Distance Avstand + Distortion Forvrengning + Distribution Fordeling + Do you want to save you made to “{name}” before quitting? Vil du lagre endringene du gjorde i «{name}» før du avslutter? + Document Sizes Dokumentstørrelser + Documentation Dokumentasjon + Documents Dokumenter + Dont Enlarge Ikke forstørr + Dot Prikk + Double Line Dobbel linje + Down Ned + Draft Utkast + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Dra en beskjæringsramme · dra innenfor for å flytte · kantene endrer størrelse ({ratio} forhold, {centre} midtpunkt) · mellomrom flytter under tegning · {commit} bekrefter · Esc avbryter + Drag: draw freehand · Release on the start point: close Dra: tegn på frihånd · Slipp på startpunktet: lukk + Driver: {driver} Driver: {driver} + Dry Brush Tørrpensel + Duplicate Channel Dupliser kanal + Duplicate Layers Dupliser lag + Duplication Duplisering + Dust & Scratches Støv og riper + Edge Kant + Edge Brightness Kantlysstyrke + Edge Extension Kantutvidelse + Edge Fidelity Kantpresisjon + Edge Intensity Kantintensitet + Edge Only Bare kant + Edge Simplicity Kantenkelhet + Edge Thickness Kanttykkelse + Edge Width Kantbredde + Edit Toolbar Rediger verktøylinje + Edit… Rediger… + Elevation Høyde + Eliminate Eliminer + Emboss Relieff + Enable Aktiver + Enable Mask Aktiver maske + End X Slutt X + End Y Slutt Y + Enlarged Forstørret + Erase with Brush or Pencil (hold) Visk ut med pensel eller blyant (hold) + Error Feil + Even Fields Like felt + Exit Quick Mask Avslutt hurtigmaske + Expand group (⌥-click: all groups) Fold ut gruppe (⌥-klikk: alle grupper) + Export As Eksporter som + Export As: {name} Eksporter som: {name} + Export Contents Eksporter innhold + Export Image Eksporter bilde + Export Options Eksportalternativer + Export Preferences Eksportinnstillinger + Export Settings Eksportinnstillinger + Export to Eksporter til + Export/Import Presets Eksporter/importer forvalg + Exported contents to {path} Eksporterte innhold til {path} + Exported presets to {path} Eksporterte forvalg til {path} + Exported {path} Eksporterte {path} + Exported {path} ({size}) Eksporterte {path} ({size}) + Exporting… Eksporterer… + ExtraBold Ekstra fet + ExtraLight Ekstra lett + Extrude Ekstruder + Fade Ton ned + Faded Film Falmet film + Failed Mislyktes + Failed to open file Kunne ikke åpne filen + Failed to save file Kunne ikke lagre filen + Fallback: {reason} Reserve: {reason} + Feather Radius Radius for myk kant + Fiber Length Fiberlengde + Fibers Fibrer + Field Blur Feltsløring + File Format Filformat + File Info Filinformasjon + File Name Filnavn + File Size Filstørrelse + File not found Filen ble ikke funnet + Filename Filnavn + Fill Color Fyllfarge + Fill Opacity Fylldekkevne + Fill Path… Fyll bane… + Fill path with foreground color Fyll bane med forgrunnsfarge + Fill with Background Color Fyll med bakgrunnsfarge + Fill with Background Color, Preserve Transparency Fyll med bakgrunnsfarge, behold gjennomsiktighet + Fill with Foreground Color Fyll med forgrunnsfarge + Fill with Foreground Color, Preserve Transparency Fyll med forgrunnsfarge, behold gjennomsiktighet + Film Grain Filmkorn + Filter Gallery Filtergalleri + Find Søk + Find and Replace Text Søk og erstatt tekst + Fine Fin + Fine Dots Fine prikker + Finish Fullfør + Fit Image Tilpass bilde + Fit to Window Tilpass til vinduet + Flame Flamme + Flame Bottom Alignment Justering av flammebunn + Flame Lines Flammelinjer + Flame Shape Flammeform + Flame Style Flammestil + Flame Type Flammetype + Flat Flat + Flat Shading Flat skyggelegging + Flower Color Blomsterfarge + Focal Distance Fokusavstand + Focus Fokus + Focus Area Fokusområde + Foreground Level Forgrunnsnivå + Forward Fremover + Forward Warp Fordrei fremover + Frame Ramme + Frame {current} / {total} Ramme {current} / {total} + Free Transform Path Fri transformering av bane + Freeze Lasso Fryselasso + Freeze Mask Frysemaske + Fresco Fresko + From Fra + From Left Fra venstre + From Right Fra høyre + Frosted Frostet + Full Screen Fullskjerm + Fullscreen Fullskjerm + Fuzziness Uklarhet + GPU backend preference: {backend} Innstilling for GPU-motor: {backend} + GPU device was lost; using the CPU renderer. GPU-enheten gikk tapt; bruker CPU-gjengiveren. + GPU error; using the CPU renderer. GPU-feil; bruker CPU-gjengiveren. + GPU retry scheduled Nytt GPU-forsøk er planlagt + Gamma Gamma + Gaussian Blur Gaussisk sløring + General Blending Generell blanding + General Brushes Generelle pensler + Generators Generatorer + Generic Generisk + Glass Glass + Global Light Globalt lys + Gloss Glans + Glow Amount Glødmengde + Glow Brightness Glødlysstyrke + Glow Size Glødstørrelse + Glowing Edges Glødende kanter + Good God + Grade Balance Graderingsbalanse + Grade Blending Graderingsblanding + Grade Global Gradering global + Grade Highlights Gradering høylys + Grade Midtones Gradering mellomtoner + Grade Shadows Gradering skygger + Gradient Options Gradientalternativer + Grain Amount Kornmengde + Grain Roughness Kornruhet + Grain Size Kornstørrelse + Grain Type Korntype + Graininess Kornhet + Grainy Dots Kornete prikker + Graphics adapter Grafikkadapter + Graphics adapter: {adapter} Grafikkadapter: {adapter} + Gray-gamma-2.2 Grå gamma 2,2 + Greek Gresk + Greens Grønntoner + Grid (⌘') Rutenett (⌘') + Grid view Rutenettvisning + Group from Layers Gruppe fra lag + Groups Grupper + Grout Width Fugebredde + Guide Layout… Hjelpelinjeoppsett… + HDR Toning HDR-toning + Hand Tool (hold) Håndverktøy (hold) + Hearts Hjerter + Heptagon Sjukant + Hexagon Sekskant + Hidden Skjult + Hide Grid Skjul rutenett + Hide Others Skjul andre + Hide the layer's effects (⌥-click: all layers) Skjul lagets effekter (⌥-klikk: alle lag) + Hide {app} Skjul {app} + High Pass Høypass + Highlight Høylys + Highlight Amount Høylysmengde + Highlight Area Høylysområde + Highlight Radius Høylysradius + Highlight Strength Høylysstyrke + Highlight Tone Høylystone + Highlights from: Høylys fra: + Horizontal Distortion Vannrett forvrengning + Horizontal Only Bare vannrett + Horizontal scale Vannrett skalering + Horizontal type Vannrett tekst + Hotspot Aktivt område + Hsb HSB + Hsl HSL + Hsl Hue HSL-fargetone + Hsl Lum HSL-luminans + Hsl Sat HSL-metning + Image Balance Bildebalanse + Image Processor Bildebehandler + Image compositor: {processor} Bildekompositor: {processor} + Import Brushes Importer pensler + Import Brushes… (.abr) Importer pensler… (.abr) + Import Image Importer bilde + Import Options Importalternativer + Import Photoshop brushes (.abr) Importer Photoshop-pensler (.abr) + Import Settings Importer innstillinger + Importing {what} from {file}… Importerer {what} fra {file}… + Importing… Importerer… + In Black I svart + In White I hvitt + Increase Brush Hardness Øk penselhardhet + Demosaic Demosaikk + 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. Hvordan kameraets fargefiltermosaikk fylles ut. Det brukes når filen åpnes. Forhåndsvisningen viser metoden den ble åpnet med. + AHD (adaptive homogeneity) AHD (adaptiv homogenitet) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de reneste kantene og minst fargefrynsing, den tregeste. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigert interpolering: skarp og rask, med noe fargefrynsing på fine detaljer. + The fastest and the softest, with colour fringing on fine detail. Den raskeste og mykeste, med fargefrynsing på fine detaljer. + “{name}” already exists. Replace it? «{name}» finnes allerede. Erstatte den? + The name in the save dialog was “{name}”. Navnet i lagringsdialogen var «{name}». + Move Up Flytt opp + Move Down Flytt ned + Previous Brush Forrige pensel + Next Brush Neste pensel + First Brush Første pensel + Last Brush Siste pensel + Preview size Forhåndsvisningsstørrelse + Memory Usage Minnebruk + Per-document RAM limit (MB) RAM-grense per dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installert RAM: {ram} MB. Denne grensen: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begrenser hvert åpne dokuments piksler pluss historikk. De eldste angretilstandene forkastes når grensen overskrides. + Applies immediately when saved. Memory is not reserved in advance. Trer i kraft umiddelbart når den lagres. Minne reserveres ikke på forhånd. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. Med GPU-minne satt til Automatisk begrenser dette også GPU-lagbudsjettet. Det budsjettet er begrenset til en fjerdedel av installert RAM og, når det er kjent, tre åttedeler av grafikkortets minne, med et minimum på 512 MB. Å heve denne grensen over de takene øker ikke GPU-budsjettet. + Command Kommando + Use Bruk + In Front Foran + Behind Bak + +# DDS Options (#1327) + DDS Options DDS-alternativer + Compression Komprimering + Generate Mipmaps Generer mipmaps + +# Color Table (.act) dialog + There is no color table to save. Det er ingen fargetabell å lagre. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Lagret {n} farge til {file}|Lagret {n} farger til {file} + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Importer stiler… + Export Selected Styles… Eksporter valgte stiler… + Export Styles… Eksporter stiler… + {file} is not a style library (.asl) {file} er ikke et stilbibliotek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importerte {n} stil fra {file}|Importerte {n} stiler fra {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Eksporterte {n} stil til {file}|Eksporterte {n} stiler til {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bekreft eller avbryt den åpne dialogen for handlingstrinnet først + Pro Medium Gray Pro mellomgrå + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro lysegrå + Pro Light Pro lys + Resize Windows to Fit Tilpass vindusstørrelse + EXR EXR + Increase Brush Size Øk penselstørrelse + Increase Type Size Øk tekststørrelse + Increase Type Size by 10 Øk tekststørrelse med 10 + Indent first line Innrykk første linje + Indent left margin Innrykk venstremarg + Indent right margin Innrykk høyremarg + Indexed Indeksert + Infinite Uendelig + Information Informasjon + Initial Direction Startretning + Ink Outlines Blekkonturer + Inner Bevel Indre skråkant + Input Inndata + Input Levels Inndatanivåer + Input Mode Inndatamodus + Install Plug-in Installer programtillegg + Intensity Intensitet + Intent Gjengivelsesmetode + Interpret Footage Tolk opptak + Intersect with selection (⇧⌥) Snitt med utvalget (⇧⌥) + Interval Intervall + Invalid panel layout; restored the default dock Ugyldig paneloppsett; gjenopprettet standarddokken + Invert Mask Inverter maske + Invert Selection Inverter utvalg + Iris Blur Irissløring + Ivy Eføy + Jag Takking + Join the ArtCraft Discord Bli med i ArtCraft-Discord + Join the ArtCraft Discord ({url}) Bli med i ArtCraft-Discord ({url}) + Keeps the current texture when switching presets. Beholder gjeldende tekstur når du bytter forvalg. + Kernel Kjerne + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kniping: metrisk, optisk eller en verdi i 1/1000 em (Alt+←/→ ved innsettingspunktet) + Keyboard Tastatur + Lab Lab + Landscape, 10 x 8 Liggende, 10 x 8 + Landscape, 6 x 4 Liggende, 6 x 4 + Landscape, 7 x 5 Liggende, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin utvidet-A + Latin Extended-B Latin utvidet-B + Layer Comps to Files Lagkomposisjoner til filer + Layer Content Options Alternativer for laginnhold + Layer Count Antall lag + Layer From Background Lag fra bakgrunn + Layer Knocks Out Drop Shadow Laget slår ut slagskyggen + Layer Mask Hides Effects Lagmaske skjuler effekter + Layer Properties Lagegenskaper + Layer Styles Lagstiler + Layer from Background Lag fra bakgrunn + Layers to Files Lag til filer + Layout Oppsett + Leading (set to the font size × auto-leading when Auto) Linjeavstand (satt til skriftstørrelse × automatisk linjeavstand når Auto) + Leaf Color Bladfarge + Learn More Les mer + Leaves Amount Bladmengde + Leaves Color Bladfarge + Leaves Size Bladstørrelse + Left Diagonal Venstre diagonal + Legacy Eldre + Legal Legal + Lens Blur Objektivsløring + Lens Correction Objektivkorreksjon + Lens Flare Objektivrefleks + Less Mindre + Letter Bokstav + Level Based Nivåbasert + Library Bibliotek + Light Dark Balance Lys/mørk-balanse + Light Direction Lysretning + Light Intensity Lysintensitet + Light Rough Lys ru + Light Type Lystype + Light X Lys X + Light Y Lys Y + Light Z Lys Z + Lighten Grout Lysne fuge + Lighten Only Bare lysere + Lighting Belysning + Lighting Effects Lyseffekter + Linear Dodge Lineær avskjerming + Linear Height Lineær høyde + Linear-srgb Lineær sRGB + Link Koble + Liquify Gjør flytende + List Liste + List view Listevisning + Live Preview Direkte forhåndsvisning + Load 3D LUT Last inn 3D LUT + Load Files into Stack Last inn filer i stabel + Load path as a selection Last inn bane som utvalg + Loading… Laster… + Lock All Lås alt + Lock Image Pixels Lås bildepiksler + Lock Position Lås posisjon + Lock Transparent Pixels Lås gjennomsiktige piksler + Locked Låst + Long Lines Lange linjer + Long Strokes Lange strøk + Lorem Ipsum Lorem Ipsum + Lower Senk + Lum Lum + Lut LUT + Magentas Magentatoner + Make Selection from Path Opprett utvalg fra bane + Make Selection… Opprett utvalg… + Make Symmetry Path Opprett symmetribane + Make Work Path… Opprett arbeidsbane… + Make work path from selection Opprett arbeidsbane fra utvalg + Manual Manuell + Map Document Tilordne dokument + Map Layer Tilordne lag + Map Path Tilordne bane + Mask Channel Maskekanal + Mask Incomplete Blocks Masker ufullstendige blokker + Mask None Ingen maske + Masks Masker + Match Color Tilpass farge + Math Symbols Matematiske symboler + Max Offset Maks forskyvning + Max Radius Maks radius + Measurement Scale Måleskala + Measurement Scale Marker Måleskalamarkør + Medium Dots Middels prikker + Medium Gray Mellomgrå + Medium Lines Middels linjer + Medium Strokes Middels strøk + Merge Channels… Slå sammen kanaler… + Merge Spot Channel Slå sammen spotkanal + Merge to HDR Pro Slå sammen til HDR Pro + Metallic Metallisk + Metrics Metrisk + Mezzotint Mezzotint + Middle Midten + Midtone Mellomtone + Monitor Profile Skjermprofil + Monitor profile in use on {display}: {profile} Skjermprofil i bruk på {display}: {profile} + Monitor profile in use: {profile} Skjermprofil i bruk: {profile} + Monitor profile on {display} (main window): {profile} Skjermprofil på {display} (hovedvindu): {profile} + Monitor profile on {display}: {profile} Skjermprofil på {display}: {profile} + Monitor profile: {profile} Skjermprofil: {profile} + Mono Contrast Monokontrast + Monochromatic Monokromatisk + More Mer + Mosaic Mosaikk + Mosaic Tiles Mosaikkfliser + Motion Blur Bevegelsessløring + Mouse Mus + Move Layer Flytt lag + Movie Prime Film-prime + Multiple Flames Along Path Flere flammer langs banen + Multiple Flames One Direction Flere flammer i én retning + Multiple Flames Path Directions Flere flammer etter banens retning + Multiple Flames Various Length Flere flammer med ulik lengde + My Brushes Mine pensler + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Innebygd dra-og-slipp av filer støttes ikke på Wayland ennå. Bruk Fil › Åpne, eller kjør PhotoCraft under XWayland med `WAYLAND_DISPLAY= photocraft`. + Nearest Nærmeste + Neon Glow Neonglød + Neutralize Nøytraliser + Neutrals Nøytrale + New Brush Preset Nytt penselforvalg + New Channel… Ny kanal… + New Guide Ny hjelpelinje + New Guide Layout Nytt hjelpelinjeoppsett + New Spot Channel… Ny spotkanal… + New Video Layer from File Nytt videolag fra fil + New Window Nytt vindu + New Workspace Nytt arbeidsområde + New document Nytt dokument + New document… Nytt dokument… + New document… ⌘N Nytt dokument… ⌘N + Next Neste + No Document Ikke noe dokument + No Image Ikke noe bilde + No Layers Ingen lag + No Results Ingen resultater + No Results Found Fant ingen resultater + No Selection Ikke noe utvalg + No custom shape "{name}": pick one in the options bar or Window › Shapes Ingen egendefinert form «{name}»: velg en i alternativlinjen eller Vindu › Former + No layers Ingen lag + No recent files Ingen nylig brukte filer + No selection Ikke noe utvalg + Noise Color Støyfarge + Noise Color Detail Støyfargedetalj + Noise Luminance Støyluminans + Noise Luminance Detail Støyluminansdetalj + Nothing to Redo Ingenting å gjøre om + Nothing to Undo Ingenting å angre + Nothing to paste: the clipboard holds no image Ingenting å lime inn: utklippstavlen inneholder ikke noe bilde + Number Of Levels Antall nivåer + Numbers Tall + Ocean Ripple Havbølger + Octagon Åttekant + Odd Fields Oddetallsfelt + Oil Paint Oljemaling + On På + One Flame Along Path Én flamme langs banen + Open As Åpne som + Open File Åpne fil + Open Folder Åpne mappe + Open Image Åpne bilde + Open Recent is unavailable on the web Åpne nylig brukte er ikke tilgjengelig på nettet + Opened {url} Åpnet {url} + Opening Åpner + Open… ⌘O Åpne… ⌘O + Operation canceled Operasjonen ble avbrutt + Operation cancelled Operasjonen ble avbrutt + Operation completed Operasjonen fullført + Operation failed Operasjonen mislyktes + Optical Optisk + Original Ratio Opprinnelig forhold + Out Black Ut svart + Out From Center Ut fra midten + Out Of Gamut Utenfor fargeomfanget + Out White Ut hvit + Out of memory Ikke nok minne + Outer Bevel Ytre skråkant + Output Levels Utdatanivåer + Oval Oval + Overlay Edge Overleggskant + Overlay: Rule of Thirds Overlegg: tredjedelsregelen + PSD PSD + Package Pakke + Page Setup… Sideoppsett… + Paint Daubs Maleklatter + Paints from the document's opening state Maler fra dokumentets åpningstilstand + Palette Knife Palettkniv + Panel Panel + Paper Papir + Paper Brightness Papirlysstyrke + Parallel Parallell + Paste Complete Stroke Lim inn hele strøket + Paste Fill Lim inn fyll + Patchwork Lappeteppe + Path Blur Banesløring + Paths to Illustrator Baner til Illustrator + Pattern Fill… Mønsterfyll… + Pattern Type Mønstertype + Pencil Width Blyantbredde + Pentagon Femkant + Percentage Prosentdel + Permission denied Tilgang nektet + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft prøver på nytt; til en lagring lykkes, går innstillingsendringer tapt når det lukkes. + Photomerge Photomerge + Pick Color From Image Velg farge fra bilde + Picture Frame Bilderamme + Pillow Emboss Puterelieff + Pinch Klyp + Pins Nåler + Place Embedded Plasser innebygd + Place Linked Plasser koblet + Place Scale Marker Plasser skalamarkør + Plane Plan + Planes Plan + Plastic Wrap Plastfolie + Platform: {os} {arch} Plattform: {os} {arch} + Please wait… Vent litt… + Point Punkt + Point Curve Punktkurve + Point Curve Blue Punktkurve blå + Point Curve Green Punktkurve grønn + Point Curve Red Punktkurve rød + Pointed Spiss + Pointillize Pointillisme + Polar Coordinates Polarkoordinater + Polar To Rectangular Polar til rektangulær + Policy Cmyk Retningslinje for CMYK + Policy Gray Retningslinje for grå + Policy Rgb Retningslinje for RGB + Pond Ripples Damkrusninger + Portrait, 4 x 6 Stående, 4 x 6 + Portrait, 5 x 7 Stående, 5 x 7 + Postcard Postkort + Poster Plakat + Poster Edges Plakatkanter + Posterization Posterisering + Preferences can't be saved Innstillingene kan ikke lagres + Preferences saved Innstillinger lagret + Preferences were reset: {error} Innstillingene ble tilbakestilt: {error} + Preferences… Innstillinger… + Preserve Behold + Preset Manager Forvalgsbehandling + Press keys Trykk på taster + Preview Image Forhåndsvis bilde + Previous Forrige + Prime105 Prime 105 + Prime35 Prime 35 + Printed to {path} Skrev ut til {path} + Pro (Dark) Pro (mørk) + Pro (Medium Gray) Pro (mellomgrå) + Processing… Behandler… + Profile Profil + Proof Setup… Korrekturoppsett… + Prophoto-compat ProPhoto-kompatibel + Pucker Rynk + Push Left Skyv venstre + Pyramids Pyramider + Quick Actions Hurtighandlinger + Quit Avslutt + Quit PhotoCraft Avslutt PhotoCraft + Quit {app} Avslutt {app} + R G B R G B + R g b R G B + Radial Radial + Radial Blur Radial sløring + Radius X Radius X + Radius Y Radius Y + Ran script {name} Kjørte skriptet {name} + Randomize Length Tilfeldig lengde + Randomize Shapes Tilfeldige former + Rasterize Fill Content Rasteriser fyllinnhold + Rasterize Smart Object Rasteriser smartobjekt + Ratio Forhold + Raw Defaults Raw-standarder + Reading Leser + Ready Klar + Rec2020 Rec. 2020 + Recent Files Nylige filer + Reconstruct All Rekonstruer alle + Recovered {name} Gjenopprettet {name} + Rectangular Selection Rektangulært utvalg + Rectangular To Polar Rektangulær til polar + Red Cyan Rød-cyan + Reds Rødtoner + Reduce Color Noise Reduser fargestøy + Reduce Noise Reduser støy + Reflected Reflektert + Relief Relieff + Relink to File Koble til fil på nytt + Remove All Fjern alle + Remove Jpeg Artifact Fjern JPEG-artefakter + Rename Brush Gi penselen nytt navn + Rename Channel… Gi kanalen nytt navn… + Rename Group Gi gruppen nytt navn + Rename Tool Preset Gi verktøyforvalget nytt navn + Render Video Gjengi video + Rendering… Gjengir… + Reorder Layer Omordne lag + Replace Color Erstatt farge + Replace Contents Erstatt innhold + Replace Footage Erstatt opptak + Report a Bug Rapporter en feil + Report an Issue Rapporter et problem + Reset Alignment Tilbakestill justering + Reset All Tilbakestill alle + Reset Preferences Tilbakestill innstillinger + Resolve Missing Fonts Løs problemer med manglende skrifter + Restart Start på nytt + Restart Required Omstart kreves + Resume Fortsett + Retry Prøv igjen + Rgb RGB + Ridges Rygger + Right Diagonal Høyre diagonal + Ripple Krusning + Ripple Magnitude Krusningsstyrke + Ripple Size Krusningsstørrelse + Rope Tau + Roses Roser + Rough Pastels Grove pasteller + Row Order Radrekkefølge + Rulers (⌘R) Linjaler (⌘R) + Sample Size Prøvestørrelse + Sampled Colors Prøvefarger + Sandstone Sandstein + Sat Met + Save As Lagre som + Save Changes Lagre endringer + Save File Lagre fil + Save Image Lagre bilde + Save Path Lagre bane + Save Pixel Aspect Ratio Lagre pikselforhold + Save a Copy Lagre en kopi + Save before closing? Lagre før lukking? + Save for Web (Legacy) Lagre for web (eldre) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Lagre arbeidet og start PhotoCraft på nytt for å prøve GPU-akselerasjon igjen. CPU-gjengivelse er fortsatt aktiv i denne økten. + Saved a copy as {path} Lagret en kopi som {path} + Saved {path}; image assets: {n} in {dir} Lagret {path}; bilderessurser: {n} i {dir} + Saving… Lagrer… + Scale Effects Skaler effekter + Scaling Skalering + Scallops Kamskjell + Script Events Manager Behandling av skripthendelser + Scroll menu down Rull menyen ned + Scroll menu up Rull menyen opp + Search commands Søk etter kommandoer + Search commands (⌘K) Søk etter kommandoer (⌘K) + Search commands, tools and panels Søk etter kommandoer, verktøy og paneler + Search menus Søk i menyer + Seed Frø + Select Custom Color… Velg egendefinert farge… + Select Data Points Velg datapunkter + Select Layer Velg lag + Select and Mask Velg og masker + Color Aware Fargebevisst + Decontaminate Colors Fjern fargesmitte + Edge Detection Kantgjenkjenning + Global Refinements Globale forbedringer + High Quality Preview Forhåndsvisning i høy kvalitet + Object Aware Objektbevisst + Object Aware needs a segmentation model, which isn't available yet Objektbevisst krever en segmenteringsmodell, som ikke er tilgjengelig ennå + Real-Time Refinement Forbedring i sanntid + Refine Edge Brush Tool Kantforbedringspensel + Refine Hair Forbedre hår + Refine Hair needs hair detection, which isn't available yet Forbedre hår krever hårgjenkjenning, som ikke er tilgjengelig ennå + Refine Mode Forbedringsmodus + Select and Mask was closed: another layer was selected Velg og masker ble lukket: et annet lag ble valgt + Select and Mask was closed: its document is no longer the active one Velg og masker ble lukket: dokumentet er ikke lenger det aktive + View Mode Visningsmodus + Selected at launch: {backend} Valgt ved oppstart: {backend} + SemiBold Halvfet + Sensitivity Følsomhet + Sent to the printer ({spooler}) Sendt til skriveren ({spooler}) + Sepia Sepia + Services Tjenester + Set Colors Angi farger + Set Measurement Scale Angi måleskala + Sgray sGray + Shadow Skygge + Shadow Amount Skyggemengde + Shadow Intensity Skyggeintensitet + Shadow Radius Skyggeradius + Shadow Tone Skyggetone + Shadows up to: Skygger opptil: + Shadows/Highlights Skygger/høylys + Shallow Grunn + Shape Blur Formsløring + Sharpen Amount Skjerpingsmengde + Sharpen Detail Skjerpingsdetalj + Sharpen Details Skjerpingsdetaljer + Sharpen Masking Skjerpingsmaskering + Sharpen Radius Skjerpingsradius + Shear Skjær + Shine Glans + Short Lines Korte linjer + Short Strokes Korte strøk + Shortcut Hurtigtast + Shortcuts Hurtigtaster + Show All Vis alle + Show Extras Options Alternativer for Vis ekstra + Show Guides Vis hjelpelinjer + Show Guides (⌘;) Vis hjelpelinjer (⌘;) + Show Rulers Vis linjaler + Show the layer's effects (⌥-click: all layers) Vis lagets effekter (⌥-klikk: alle lag) + Simple Enkel + Simulate Paper Simuler papir + Sine Sinus + Sketch Skisse + Skip Hopp over + Slice Options Segmentalternativer + Smart Blur Smart sløring + Smart Sharpen Smart skjerping + Smoothness Jevnhet + Smudge Stick Utgnidningsstift + Snap to Grid Fest til rutenett + Snowflakes Snøfnugg + Soften Mykne + Softness Mykhet + Software adapter Programvareadapter + Solarized Dark Solarized mørk + Solid Color Ensfarget + Solid Front Faces Heltrukne frontflater + Source Channel Kildekanal + Source Invert Inverter kilde + Source Layer Kildelag + Source1 Channel Kilde 1-kanal + Source1 Invert Inverter kilde 1 + Source2 Channel Kilde 2-kanal + Source2 Invert Inverter kilde 2 + Source: set Kilde: angitt + Space Mellomrom + Sparkle Funkling + Spatter Stenk + Speckle Flekker + Speed Hastighet + Spherize Kuleform + Spin Spinn + Spin Blur Rotasjonssløring + Split Channels Del kanaler + Spot Spotfarge + Spray Radius Sprayradius + Sprayed Strokes Spraystrøk + Spread Spredning + Sprinkles Dryss + Square Size Kvadratstørrelse + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Firkanthet + Stagger Forskyvning + Stained Glass Farget glass + Star Stjerne + Stars Stjerner + Start X Start X + Start Y Start Y + Statistics Statistikk + Step {step} ({id}) failed: {error} Trinn {step} ({id}) mislyktes: {error} + Stippled Prikket + Stops Stopp + Strength Styrke + Stretch Strekk + Stroke Color Strekfarge + Stroke Detail Strøkdetalj + Stroke Direction Strøkretning + Stroke Length Strøklengde + Stroke Pressure Strøktrykk + Stroke Size Strekstørrelse + Stroke Width Strekbredde + Stroke path with brush Strek opp banen med pensel + Strokes Strøk + Studio (Dark) Studio (mørk) + Studio (Light) Studio (lys) + Stylization Stilisering + Subtract Shapes at Overlap Trekk fra former ved overlapp + Subtract from selection (⌥) Trekk fra utvalget (⌥) + Sumi-e Sumi-e + Surface Blur Overflatesløring + Switch Foreground and Background Colors Bytt forgrunns- og bakgrunnsfarge + System Info Systeminformasjon + Tablet Tegnebrett + Tabloid Tabloid + Taper Avsmalning + Target Mål + Target Channel 3 Målkanal 3 + Target Channel 4 Målkanal 4 + Target Channel 5 Målkanal 5 + Target Channel 6 Målkanal 6 + Target Channel 7 Målkanal 7 + Target Channel 8 Målkanal 8 + Target Channel 9 Målkanal 9 + Target Composite Channel Målkomposittkanal + Target X Mål X + Target Y Mål Y + Teal Orange Blågrønn/oransje + Technology Previews… Teknologiforhåndsvisninger… + Temporary Midlertidig + Text Layer Tekstlag + Texture Coverage Teksturdekning + Texturizer Teksturering + Thaw Mask Tin maske + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lerretet vises som sRGB. Velg en skjermprofil i Rediger › Fargeinnstillinger. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Lerretet på {display} vises som sRGB. Velg en skjermprofil i Rediger › Fargeinnstillinger. + The current gradient (pick one in Window › Gradients) Gjeldende gradient (velg en i Vindu › Gradienter) + The document does not have an embedded colour profile. Working space: {working}. Dokumentet har ingen innebygd fargeprofil. Arbeidsfargerom: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokumentet har en innebygd fargeprofil som ikke samsvarer med arbeidsfargerommet.\nInnebygd: {embedded}\nArbeidsfargerom: {working} + The file could not be opened. Filen kunne ikke åpnes. + The file could not be saved. Filen kunne ikke lagres. + Thickness Tykkelse + This Layer: Dette laget: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Dette smartobjektet må rasteriseres før du fortsetter. Innholdet kan da ikke lenger redigeres. Rasterisere smartobjektet? + This adjustment ({key}) is kept as imported and can't be edited yet. Denne justeringen ({key}) beholdes som importert og kan ikke redigeres ennå. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Dette fyllaget må rasteriseres før du fortsetter. Fyllinnholdet kan da ikke lenger redigeres. Rasterisere laget? + This layer must be rasterized before proceeding. Dette laget må rasteriseres før du fortsetter. + This session: {reason} Denne økten: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Dette formlaget må rasteriseres før du fortsetter. Det kan da ikke lenger redigeres som form. Rasterisere formen? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Dette tekstlaget må rasteriseres før du fortsetter. Teksten kan da ikke lenger redigeres. Rasterisere teksten? + Tile Size Flisstørrelse + Tiles Fliser + Tilt-Shift Tilt-shift + Tint Color Tonefarge + Tiny Lens Bitteliten linse + Title Tittel + To Til + To Center Til midten + Toggle Layer Mask Overlay Slå overlegg for lagmaske av/på + Toggle the Brush Settings panel Vis/skjul panelet Penselinnstillinger + Toggle the Brush Settings panel (F5) Vis/skjul panelet Penselinnstillinger (F5) + Tool Options Verktøyalternativer + Trace Contour Tegn kontur + Tracking (1/1000 em) Tegnavstand (1/1000 em) + Transformation Transformering + Transition Overgang + Transparency Shapes Layer Gjennomsiktighet former laget + Trap Overlapping + Tree Tre + Trim Fjern kanter + Turbulent Turbulent + Twirl Virvel + Twirl Clockwise Virvel med klokken + Twirl Counterclockwise Virvel mot klokken + Type Options Tekstalternativer + Unaltered Uendret + Undefined Areas Udefinerte områder + Underlying Layer: Underliggende lag: + Underpainting Undermaling + Underwater Under vann + Ungroup Opphev gruppering + Uniform Ensartet + Unite Shapes at Overlap Slå sammen former ved overlapp + Unlink Koble fra + Unlock Lås opp + Unlocked Låst opp + Unsaved Changes Ulagrede endringer + Unsharp Mask Uskarp maske + Unsupported file format Filformatet støttes ikke + Unsupported format Formatet støttes ikke + Up Opp + Update Oppdater + Updates Oppdateringer + Upper Øvre + Use Custom Color Bruk egendefinert farge + Use File › Open to open an image. Bruk Fil › Åpne for å åpne et bilde. + Use Selection In Source Bruk utvalg i kilde + Use Selection In Target Bruk utvalg i mål + Uses the foreground and background colors. Bruker forgrunns- og bakgrunnsfargene. + Vanishing Point Fluktpunkt + Variable Data Sets Variable datasett + Vector Mask Hides Effects Vektormaske skjuler effekter + Vertical Distortion Loddrett forvrengning + Vertical Only Bare loddrett + Vertical Type Tool Loddrett tekstverktøy + Vertical scale Loddrett skalering + Vertical type Loddrett tekst + Video Frames to Layers Videorammer til lag + Vignette Amount Vignetteringsmengde + Vignette Feather Vignettering: myk kant + Vignette Highlights Vignettering: høylys + Vignette Midpoint Vignettering: midtpunkt + Vignette Roundness Vignettering: rundhet + Vignette Style Vignetteringsstil + Vine Color Rankefarge + Vine With Flowers Ranke med blomster + Vine With Leaves Ranke med blader + Violent Voldsom + W: B: + WIA Support WIA-støtte + Warm Varm + Warming Filter (85) Oppvarmingsfilter (85) + Warming L B A Oppvarmingsfilter (LBA) + Warming81 Oppvarmingsfilter (81) + Warming85 Oppvarmingsfilter (85) + Warmth Varme + Warning Advarsel + Warp Text Fordrei tekst + Watercolor Akvarell + Watercolour look: lighter interior, darker rims. Akvarellutseende: lysere indre, mørkere kanter. + Wavelength Max Maks bølgelengde + Wavelength Min Min bølgelengde + Waves Bølger + Web Large Web stor + Web Minimum Web minimum + Web Most Common Web vanligst + WebP (lossless) WebP (tapsfri) + Wetness Våthet + White Clip Hvitklipping + White Intensity Hvitintensitet + White Is High Hvit er høyt + Whole Word Hele ordet + Wide Blurry Bred uskarp + Wide Sharp Bred skarp + Wind Vind + Window renderer: {renderer} Vindusgjengiver: {renderer} + Working Arbeidsfargerom + Working Cmyk Arbeids-CMYK + Working Gray Arbeidsgrå + Working Rgb Arbeids-RGB + Working-cmyk Arbeids-CMYK + Workspaces Arbeidsområder + Wrap Linjeskift + Yellows Gultoner + ZigZag Sikksakk + Zigzag Sikksakk + Zoom In (hold, drag to scrub) Zoom inn (hold, dra for å skrubbe) + Zoom Out (hold) Zoom ut (hold) + auto auto + blue blå + brushes pensler + fallback reserve + gradients gradienter + gray grå + green grønn + layer mask lagmaske + manual manuell + none ingen + off av + on på + orange oransje + previous reading kept; the last re-read failed: {reason} forrige lesing beholdt; siste nye lesing mislyktes: {reason} + red rød + unknown ukjent + unknown type ukjent type + vector mask vektormaske + violet fiolett + yellow gul + {index} of {total} {index} av {total} + {label} is not available: {reason} {label} er ikke tilgjengelig: {reason} + {message} (notes: {n}; {warning}) {message} (merknader: {n}; {warning}) + {shortcut} assigned. {shortcut} er tilordnet. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} er allerede i bruk av {commands} og fjernes derfra når du klikker OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ naviger ↵ kjør esc lukk + ✓ Color Indicates Masked Areas ✓ Farge angir maskerte områder + ✓ Color Indicates Selected Areas ✓ Farge angir valgte områder +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Fjern +@id edit.purge.undo Angre +@id edit.stroke Strek… +@id edit.transform.scale Skaler +@id file.close Lukk +@id filter.sharpen.sharpen Skjerp +@id layer.layerMask.apply Bruk +@id select.all Velg alt +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importerte {n} pensel fra {file}|Importerte {n} pensler fra {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importerte {n} gradient fra {file}|Importerte {n} gradienter fra {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importerte {n} mønster fra {file}|Importerte {n} mønstre fra {file} +cameraRaw Balance Balanse +cameraRaw Clarity Klarhet +cameraRaw Color Farge +cameraRaw Color Mixer Fargemikser +cameraRaw Contrast Kontrast +cameraRaw Curve Kurve +cameraRaw Dehaze Fjern dis +cameraRaw Detail Detalj +cameraRaw Effects Effekter +cameraRaw Exposure Eksponering +cameraRaw Feather Myk kant +cameraRaw Highlights Høylys +cameraRaw Luminance Luminans +cameraRaw Masking Maskering +cameraRaw Midpoint Midtpunkt +cameraRaw Midtones Mellomtoner +cameraRaw Radius Radius +cameraRaw Roundness Rundhet +cameraRaw Saturation Metning +cameraRaw Shadows Skygger +cameraRaw Size Størrelse +cameraRaw Temperature Temperatur +cameraRaw Texture Tekstur +cameraRaw Tint Fargestikk +cameraRaw Whites Hvittoner +colorRange Invert Inverter +document Orientation Retning +layerLabel Magenta Magenta +layerLabel No Color Ingen farge +layerLabel Red Rød +layers Fill Fyll +layers Fill: Fyll: +navigator Zoom Zoom +shape Appearance Utseende +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatisk avsnittsretning + Left-to-right paragraph direction Avsnittsretning fra venstre til høyre + Right-to-left paragraph direction Avsnittsretning fra høyre til venstre + Not rendered: this adjustment ({key}) is kept as imported. Ikke gjengitt: denne justeringen ({key}) beholdes slik den ble importert. +# Print preview image (#8236) + Print preview image Forhåndsvisningsbilde for utskrift +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Fargetonekube + Brightness Cube Lysstyrkekube + Color Wheel Fargehjul + RGB Sliders RGB-glidebrytere + HSB Sliders HSB-glidebrytere + CMYK Sliders CMYK-glidebrytere + Lab Sliders Lab-glidebrytere + Web Color Sliders Webfarge-glidebrytere + Copy Color as HTML Kopier farge som HTML + Copy Color's Hex Code Kopier fargens heksadesimale kode + RGB Spectrum RGB-spektrum + CMYK Spectrum CMYK-spektrum + Grayscale Ramp Gråtonerampe + Current Colors Gjeldende farger + Make Ramp Web Safe Gjør rampen webtrygg +# Art History Brush (#213). + Art History Brush Tool Kunsthistorikkpensel + Tight Short Stram kort + Tight Medium Stram middels + Tight Long Stram lang + Loose Medium Løs middels + Loose Long Løs lang + Dab Klatt + Tight Curl Stram krøll + Loose Curl Løs krøll + Stylized dabs from a history state Stiliserte klatter fra en historikktilstand + Font size presets Forvalg for skriftstørrelse + Leading presets Forvalg for linjeavstand + Tracking presets Forvalg for tegnavstand + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-gjengivelse + Calculating… Beregner… + Click to refresh histogram with uncached data Klikk for å oppdatere histogrammet med ubufrede data + Convert to Shape… Konverter til form… + Cooling Filter (80) Kjølefilter (80) + Cooling Filter (82) Kjølefilter (82) + Cooling Filter (LBB) Kjølefilter (LBB) + Count: Antall: + Delete Hidden Layers Slett skjulte lag + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Dra i bildet for å legge til en begrensning. Shift for vannrett/loddrett, høyreklikk for å slette. + Drawn on the CPU: {reason}. Click for System Info. Tegnet på CPU-en: {reason}. Klikk for systeminformasjon. + Exported {n} files Eksporterte {n} filer + Fit Layer on Screen Tilpass lag til skjermen + Flow: Flyt: + Level: Nivå: + Percentile: Persentil: + Refine Edge… Forbedre kant… + Remember Settings Husk innstillinger + Reset Essentials Tilbakestill grunnleggende + Rounded Rectangle Tool Verktøy for avrundet rektangel + Show Channels in Color Vis kanaler i farger + Show Statistics Vis statistikk + Source: Kilde: + Status Bar Statuslinje + Tile Horizontally Side om side vannrett + Tile Vertically Side om side loddrett + Uncached Refresh Oppdater uten buffer + Warming Filter (81) Oppvarmingsfilter (81) + Warming Filter (LBA) Oppvarmingsfilter (LBA) +@plural {n} document|{n} documents {n} dokument|{n} dokumenter +@plural {n} pixel|{n} pixels {n} piksel|{n} piksler +cameraRaw Before (Y) Før (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) +cameraRaw Camera Raw isn't open Camera Raw er ikke åpen +cameraRaw Cancel Avbryt +cameraRaw Cancelled opening {name} Avbrøt åpning av {name} +cameraRaw Cancelled {label} Avbrøt {label} +cameraRaw Color Priority Fargeprioritet +cameraRaw Developing {name} Fremkaller {name} +cameraRaw Full-resolution preview unavailable Forhåndsvisning i full oppløsning er ikke tilgjengelig +cameraRaw Grain Korn +cameraRaw Highlight Clipping Warning (O) Advarsel for høylysklipping (O) +cameraRaw Highlight Priority Høylysprioritet +cameraRaw Load Settings… Last inn innstillinger… +cameraRaw Load settings from a Camera Raw .xmp file Last inn innstillinger fra en Camera Raw .xmp-fil +cameraRaw Loaded Camera Raw settings from {file} Lastet inn Camera Raw-innstillinger fra {file} +cameraRaw Noise Reduction Støyreduksjon +cameraRaw OK OK +cameraRaw Open Åpne +cameraRaw Opened {name} Åpnet {name} +cameraRaw Paint Overlay Malingsoverlegg +cameraRaw Save Settings… Lagre innstillinger… +cameraRaw Save these settings as a Camera Raw .xmp file Lagre disse innstillingene som en Camera Raw .xmp-fil +cameraRaw Shadow Clipping Warning (U) Advarsel for skyggeklipping (U) +cameraRaw Sharpening Skjerping +cameraRaw Show Lab Color Readouts Vis Lab-fargeverdier +cameraRaw Show Red at 3 o'clock Vis rød kl. 3 +cameraRaw Show Selected Region Vis valgt område +cameraRaw Show Skin Tone Indicator Vis hudtoneindikator +cameraRaw Show Vectorscope Vis vektorskop +cameraRaw Tone Histogram Tonehistogram +cameraRaw Vignetting Vignettering +cameraRaw White Balance: As Shot Hvitbalanse: som fotografert + Current Layer: Gjeldende lag: + Delete effect Slett effekt + Move effect down Flytt effekten ned + Move effect up Flytt effekten opp + Path operations Baneoperasjoner + The path was discarded Banen ble forkastet + The path was kept as the Work Path Banen ble beholdt som arbeidsbane + Edit Brush… Rediger pensel… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d03260ecaf..7ad2900a28 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -9,18 +9,81 @@ # 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: - Solidity: Soliditeit: - Ink Characteristics Inktkenmerken # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Painting symmetry presets. + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + Mask Options… Maskeropties… + Layer Mask Display Options… Weergaveopties voor laagmasker… + Channel Color Kanaalkleur + Color Indicates: Kleur geeft aan: + Solidity: Soliditeit: + Ink Characteristics Inktkenmerken Paper Width Papierbreedte Paper Height Papierhoogte Rows Rijen @@ -38,7 +101,6 @@ 5 Columns, 3 Rows 5 kolommen, 3 rijen 8 Columns 8 kolommen 12 Columns 12 kolommen - File Bestand Edit Bewerken Image Afbeelding @@ -444,6 +506,7 @@ Intersect Shape Areas Overlappende vormgebieden Exclude Overlapping Shapes Overlappende vormen uitsluiten Merge Shape Components Vormcomponenten samenvoegen + Path operations Padbewerkingen Align Uitlijnen Top Edges Bovenranden Vertical Centers Verticale middens @@ -523,7 +586,6 @@ Color Range… Kleurbereik… Focus Area… Scherptegebied… Select and Mask… Selecteren en maskeren… -# Select and Mask dialog labels Smart Radius Slimme straal Shift Edge Rand verschuiven Decontaminate Verontreiniging verwijderen @@ -549,7 +611,6 @@ Blur Vervagen Blur Gallery Vervagingsgalerie Field Blur… Veldvervaging… -# Field Blur pins (#3373) No pin selected Geen speld geselecteerd Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik in de afbeelding om een speld toe te voegen; sleep een speld om hem te verplaatsen, of zijn ring om de vervaging te wijzigen. Delete verwijdert de geselecteerde speld. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Sleep de grepen op de omtrek om hem te schalen en te draaien, het vierkantje om de hoeken af te ronden en de punten binnenin om in te stellen waar de vervaging begint. @@ -819,6 +880,9 @@ Add Layer Mask Laagmasker toevoegen Add Stop Kleurstop toevoegen Add a layer style Een laagstijl toevoegen + Move effect up Effect omhoog verplaatsen + Move effect down Effect omlaag verplaatsen + Delete effect Effect verwijderen Add a mask (from the selection; {key} inverts) Een masker toevoegen (vanuit de selectie; {key} keert om) Add an adjustment Een aanpassing toevoegen Add disk Schijf toevoegen @@ -1045,8 +1109,6 @@ Delete current channel Huidig kanaal verwijderen Delete current state Huidige staat verwijderen Delete effect layer Effectlaag verwijderen - -# Layers-panel trash confirmation Delete layer ‘{name}’? Laag ‘{name}’ verwijderen? Delete {count} layers? {names} {count} lagen verwijderen? {names} Delete layer? Laag verwijderen? @@ -1055,7 +1117,6 @@ Missing delete command Verwijderopdracht ontbreekt Invalid delete command Ongeldige verwijderopdracht Layer deleted, but confirmation preference could not be saved: {error} De laag is verwijderd, maar de bevestigingsvoorkeur kon niet worden opgeslagen: {error} - Delete layer Laag verwijderen Delete layer comp Laagcompositie verwijderen Delete note Notitie verwijderen @@ -2153,7 +2214,6 @@ Align with layer Uitlijnen met laag Reset Alignment Uitlijning herstellen Centre the gradient (offset 0, 0) Het verloop centreren (verschuiving 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Verloopeditor Gradient picker Verloopkiezer Gradient stops Verloopstops @@ -2408,6 +2468,8 @@ Reset Swatches Stalen herstellen Add to Swatches Aan stalen toevoegen Blend If: Overvloeien als: + Current Layer: Huidige laag: + Underlying Layer: Onderliggende laag: Channels: Kanalen: Knockout Uitsparing Apply At Canvas Bounds Toepassen op canvasgrenzen @@ -3692,12 +3754,9 @@ cameraRaw Whites Witten Point Sample Puntmonster Sample Size: Monstergrootte: Show Sampling Ring Monsterring tonen - -# Painting symmetry presets. Dual Axis Dubbele as Diagonal Diagonaal Transform Symmetry Symmetrie transformeren - Rasterize svg groups deeper than SVG-groepen rasteriseren dieper dan deze grens 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 schakelt deze drempel uit; SVG-groepen die dieper dan 100 niveaus zijn genest, worden altijd gerasterd. Rule of Thirds Regel van derden @@ -3710,8 +3769,6 @@ cameraRaw Whites Witten Cycle Orientation Oriëntatie wisselen Set the overlay options for the Crop tool Overlay-opties voor het gereedschap Uitsnijden instellen 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. De Alt-klik ging naar het bureaublad, dat vensters verplaatst met Alt-slepen. Stel daar de toets voor het verplaatsen van vensters in op Super, of vergrendel Alt via Venster › Wijzigingstoetsen. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Kleurkiezer (kleur van laagstijl) Auto Hide Notifications Meldingen automatisch verbergen Notification Duration (seconds) Duur van melding (seconden) @@ -3779,7 +3836,6 @@ cameraRaw Whites Witten Appearance Settings… Weergave-instellingen… Add to favorites Toevoegen aan favorieten Remove from favorites Verwijderen uit favorieten -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Merk cameraData Model Model cameraData Date Taken Opnamedatum @@ -3789,7 +3845,6 @@ cameraData ISO Speed ISO-waarde cameraData Focal Length Brandpuntsafstand cameraData Focal Length (35 mm) Brandpuntsafstand (35 mm) cameraData Lens Lens -# Shared panel docking Activate Panel Paneel activeren Change Panel Group Paneelgroep wijzigen Change Panel Layout Paneelindeling wijzigen @@ -3827,7 +3882,6 @@ cameraData Lens Lens Search settings… Instellingen zoeken… No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer -# Edit › Content-Aware Fill workspace Sampling Brush Tool Gereedschap Monsterpenseel Sampling Area Overlay Overlay van monstergebied Show Sampling Area Monstergebied tonen @@ -3843,7 +3897,6 @@ cameraData Lens Lens Mirror Spiegelen Output Settings Uitvoerinstellingen Output To Uitvoer naar - Animation Options Animatieopties Entire Document Hele document Filter Options Filteropties @@ -3860,8 +3913,6 @@ cameraData Lens Lens 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) @@ -3892,7 +3943,6 @@ curves Smooth Vloeiend maken Fix the red plane first: its corners must form a convex quad Herstel eerst het rode vlak: de hoeken moeten een convexe vierhoek vormen Alt-click a plane to set the clone source Alt-klik op een vlak om de kloonbron in te stellen Too many clone strokes: apply Vanishing Point first Te veel kloonstreken: pas eerst Verdwijnpunt toe -# Select and Mask view modes (#1754) Onion Skin Uienschil Marching Ants Mierenmars On Black Op zwart @@ -3901,7 +3951,6 @@ curves Smooth Vloeiend maken Show Edge Rand tonen Show Original Origineel tonen F cycles the views; hold X to see the image F wisselt de weergaven; houd X ingedrukt om de afbeelding te zien -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Penseel Rand verfijnen (R) Expand Detection Area Detectiegebied uitbreiden Restore Original Edge Oorspronkelijke rand herstellen @@ -3915,18 +3964,12 @@ curves Smooth Vloeiend maken Scale: Schaal: Snap to Origin Uitlijnen op oorsprong Presentation Presentatie - -# 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… -# History panel › History Options… (#2966) Allow Non-Linear History Niet-lineaire geschiedenis toestaan Make Layer Visibility Changes Undoable Wijzigingen in laagzichtbaarheid ongedaan kunnen maken -# Print dialog (#2697) Emulsion Down Emulsie omlaag Functions Functies Print Resolution: Afdrukresolutie: @@ -3938,7 +3981,6 @@ curves Smooth Vloeiend maken Apply mask to layer before removing? Masker toepassen op laag voordat het wordt verwijderd? The layer or its mask changed. Drag the mask onto the Trash again. De laag of het masker is gewijzigd. Sleep het masker opnieuw naar de prullenbak. Delete the vector mask? Het vectormasker verwijderen? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} staal geëxporteerd naar {file}|{n} stalen geëxporteerd naar {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} penseel geïmporteerd uit {group}|{n} penselen geïmporteerd uit {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} staal geïmporteerd uit {file}|{n} stalen geïmporteerd uit {file} @@ -3953,7 +3995,6 @@ curves Smooth Vloeiend maken @plural {n} step|{n} steps {n} stap|{n} stappen @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} andere waarschuwing over penseelvoorinstellingen)|{warning} (+{n} andere waarschuwingen over penseelvoorinstellingen) @plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen -# View › Guides › New Guide… › Color Light Blue Lichtblauw Light Red Lichtrood Medium Blue Middelblauw @@ -3974,8 +4015,6 @@ curves Smooth Vloeiend maken Clear Brush Controls Penseelbesturing wissen Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Handelingen laden… Save Actions… Handelingen opslaan… New Action… Nieuwe handeling… @@ -3987,10 +4026,11 @@ curves Smooth Vloeiend maken @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} stap heeft nog geen equivalent in PhotoCraft|{n} stappen hebben nog geen equivalent in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten - +@plural {n} action file loaded|{n} action files loaded {n} handelingenbestand geladen|{n} handelingenbestanden geladen + {set} from {file} replaced the one from {earlier} Set {set} uit {file} heeft die uit {earlier} vervangen +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} bestand kon niet worden geladen: {errors}|{n} bestanden konden niet worden geladen: {errors} File Naming Bestandsnamen Starting serial Beginnummer -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Stop invoegen… Save Set… Set opslaan… Message: Bericht: @@ -4001,17 +4041,15 @@ curves Smooth Vloeiend maken Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster @plural {n} action|{n} actions {n} handeling|{n} handelingen Background (Default) Achtergrond (standaard) -# Batch / Image Processor Include All Subfolders Alle submappen opnemen Auto Add/Delete Automatisch toevoegen/verwijderen - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Schakel niet ook Spiegelbeeld in bij de afdrukinstellingen: de twee spiegelingen heffen elkaar op. Print Settings… Afdrukinstellingen… Remember to turn off the printer's own colour management in Print Settings… Vergeet niet het eigen kleurbeheer van de printer uit te schakelen in de afdrukinstellingen… 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 + Memory usage percent Geheugengebruik (%) Show Favorite Fonts Favoriete lettertypen tonen No favorite fonts yet: click the star beside a font to add it. Nog geen favoriete lettertypen: klik op de ster naast een lettertype om het toe te voegen. Favorite fonts Favoriete lettertypen @@ -4050,48 +4088,31 @@ curves Smooth Vloeiend maken Could not write {name}: {error} Kan {name} niet schrijven: {error} Kept {name}: written by another application {name} behouden: geschreven door een andere toepassing Shadow Tint Schaduwtint -# PhotoCraft release checks. Check for Updates… Controleren op updates… - PhotoCraft Updates PhotoCraft-updates - Checking for updates… Controleren op updates… Open the release page to choose an installer for your system. Open de releasepagina om een installatieprogramma voor uw systeem te kiezen. - Open Download Page Downloadpagina openen - PhotoCraft is up to date. PhotoCraft is bijgewerkt. - Could not check for updates. Kan niet controleren op updates. - Updates have not been checked yet. Er is nog niet op updates gecontroleerd. - Check Again Opnieuw controleren Automatically Check for Updates Automatisch op updates controleren - Installed version: {version} Geïnstalleerde versie: {version} - PhotoCraft {version} is available. PhotoCraft {version} is beschikbaar. -# 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}) Remember this choice for future images Deze keuze onthouden voor volgende afbeeldingen Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Zet de vraag weer aan met Ask On Mismatch in Bewerken › Kleurinstellingen. Button Mode Knopmodus Button Mode On Knopmodus aan - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Interface donkerder maken Lighten Interface Interface lichter maken -# Bevel & Emboss Technique. Technique Techniek 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 + Save Full-Size Composite in .pcraft Files Samengestelde afbeelding op volle grootte opslaan in .pcraft-bestanden Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen Bicubic Automatic Bicubisch automatisch @@ -4116,8 +4137,6 @@ 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 @@ -4135,10 +4154,8 @@ curves Smooth Vloeiend maken Undo Changes Wijzigingen ongedaan maken {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is. The transform couldn't be applied. De transformatie kon niet worden toegepast. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. De lagen hebben {needed} GPU-geheugen nodig; het budget laat ze {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan, hooguit 3/8 van haar geheugen. Automatisch volgt Geheugengebruik, binnen een kwart van het geheugen van deze computer en 3/8 van dat van de kaart, en rekent het document niet af van een kaart waarvan het geheugen bekend is. Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. GPU Memory Full GPU-geheugen vol @@ -4148,6 +4165,7 @@ curves Smooth Vloeiend maken 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) + Scratch Size Limit (MB, 0 = No Limit) Groottelimiet voor werkschijf (MB, 0 = geen limiet) {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. @@ -4164,8 +4182,6 @@ curves Smooth Vloeiend maken 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 @@ -4180,17 +4196,12 @@ curves Smooth Vloeiend maken Use Gebruiken In Front Voor Behind Achter - -# DDS Options (#1327) 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS-inhoud wordt als SVG bewerkt. Opslaan vervangt de ingesloten bron; formaatspecifieke eigenschappen kunnen veranderen. # Styles panel: Import / Export Styles (.asl) Import Styles… Stijlen importeren… @@ -4199,7 +4210,6 @@ curves Smooth Vloeiend maken {file} is not a style library (.asl) {file} is geen stijlbibliotheek (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {n} stijl geïmporteerd uit {file}|{n} stijlen geïmporteerd uit {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} {n} stijl geëxporteerd naar {file}|{n} stijlen geëxporteerd naar {file} -# 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 @@ -4208,14 +4218,11 @@ curves Smooth Vloeiend maken 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Kleurtoonkubus Brightness Cube Helderheidskubus Color Wheel Kleurenwiel @@ -4231,7 +4238,8 @@ curves Smooth Vloeiend maken Grayscale Ramp Grijswaardenbalk Current Colors Huidige kleuren Make Ramp Web Safe Balk beperken tot webkleuren -# Art History Brush (#213). + The path was kept as the Work Path Het pad is behouden als werkpad + The path was discarded Het pad is weggegooid Art History Brush Tool Artistiek historiepenseel Tight Short Strak kort Tight Medium Strak middel @@ -4245,3 +4253,27 @@ curves Smooth Vloeiend maken Font size presets Voorinstellingen lettergrootte Leading presets Voorinstellingen interlinie Tracking presets Voorinstellingen spatiëring + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Ongeldige numerieke invoer. Er is een geheel getal tussen {min}% en {max}% vereist. De vorige waarde is ingevoegd. + Color Aware Kleurbewust + Decontaminate Colors Kleuren zuiveren + Edge Detection Randdetectie + Global Refinements Globale verfijningen + High Quality Preview Voorvertoning in hoge kwaliteit + Object Aware Objectbewust + Real-Time Refinement Verfijning in realtime + Refine Edge Brush Tool Gereedschap Penseel Rand verfijnen + Refine Hair Haar verfijnen + Refine Mode Verfijningsmodus + Remember Settings Instellingen onthouden + View Mode Weergavemodus + Object Aware needs a segmentation model, which isn't available yet Objectbewust vereist een segmentatiemodel dat nog niet beschikbaar is + Refine Hair needs hair detection, which isn't available yet Haar verfijnen vereist haardetectie die nog niet beschikbaar is + Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document + Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd + Select Layer Under Cursor Laag onder cursor selecteren + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Recent gebruikte kleuren + Stop Color Kleur van het punt + Shapes can't use patterns yet Vormen kunnen nog geen patronen gebruiken + Edit Brush… Penseel bewerken… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ba41a4e34d..fe13d8f177 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -19,18 +19,117 @@ # 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: - Solidity: Gęstość: - Ink Characteristics Właściwości farby # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) +# Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest) + + +# Dialogs and panels + +# Layers-panel trash confirmation + + +# Tool names, brush and style option tables + +# Templates with placeholders + +# Preferences (labels are generated from the setting names) + +# Added with the dock / brush picker work + +# Strings added to the UI later +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Tool, filter, warp-style, path, layer-comp and metadata names + +# Command-id overrides + +# Rendering mode and recovery + +# About ▸ Contributors / Models (credits) + +# Text Color Picker and screen sampling + +# Swatches panel + +# Fill dialog, layer style contours and Image Size + +# Layer colour labels. +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Mask Options… Opcje maski… + Layer Mask Display Options… Opcje wyświetlania maski warstwy… + Channel Color Kolor kanału + Color Indicates: Kolor oznacza: + Solidity: Gęstość: + Ink Characteristics Właściwości farby Paper Width Szerokość papieru Paper Height Wysokość papieru Rows Wiersze @@ -48,7 +147,6 @@ 5 Columns, 3 Rows 5 kolumn, 3 wiersze 8 Columns 8 kolumn 12 Columns 12 kolumn - File Plik Edit Edycja Image Obraz @@ -451,6 +549,7 @@ Intersect Shape Areas Część wspólna obszarów kształtów Exclude Overlapping Shapes Wyklucz nakładające się kształty Merge Shape Components Scal składniki kształtu + Path operations Operacje na ścieżkach Align Wyrównaj Top Edges Górne krawędzie Vertical Centers Środki w pionie @@ -530,7 +629,6 @@ Color Range… Zakres kolorów… Focus Area… Obszar ostrości… Select and Mask… Zaznacz i maskuj… -# Select and Mask dialog labels Smart Radius Inteligentny promień Shift Edge Przesuń krawędź Decontaminate Dekontaminacja @@ -558,7 +656,6 @@ Blur Rozmycie Blur Gallery Galeria rozmyć Field Blur… Rozmycie pola… -# Field Blur pins (#3373) No pin selected Nie zaznaczono pinezki Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknij obraz, aby dodać pinezkę; przeciągnij pinezkę, aby ją przesunąć, lub jej pierścień, aby zmienić rozmycie. Delete usuwa zaznaczoną pinezkę. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Przeciągnij uchwyty na obrysie, aby zmienić rozmiar i obrócić kształt, kwadrat, aby zaokrąglić narożniki, a punkty wewnątrz, aby ustawić, gdzie zaczyna się rozmycie. @@ -821,7 +918,6 @@ Statistics… Statystyki… Refine Strokes Pociągnięcia udoskonalania Edits Edycje -# 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} @@ -836,11 +932,8 @@ @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 - -# Dialogs and panels (print to the printer) (drukuj na drukarce) + Add variable + Dodaj zmienną 30 frames @ 30 fps 30 klatek @ 30 kl./s @@ -854,6 +947,9 @@ Add Layer Mask Dodaj maskę warstwy Add Stop Dodaj punkt Add a layer style Dodaj styl warstwy + Move effect up Przesuń efekt w górę + Move effect down Przesuń efekt w dół + Delete effect Usuń efekt Add a mask (from the selection; {key} inverts) Dodaj maskę (z zaznaczenia; {key} odwraca) Add an adjustment Dodaj korektę Add disk Dodaj dysk @@ -1080,8 +1176,6 @@ Delete current channel Usuń bieżący kanał Delete current state Usuń bieżący stan Delete effect layer Usuń warstwę efektu - -# Layers-panel trash confirmation Delete layer ‘{name}’? Usunąć warstwę „{name}”? Delete {count} layers? {names} Usunąć warstwy ({count})? {names} Delete layer? Usunąć warstwę? @@ -1090,7 +1184,6 @@ Missing delete command Brak polecenia usuwania Invalid delete command Nieprawidłowe polecenie usuwania Layer deleted, but confirmation preference could not be saved: {error} Warstwa została usunięta, ale nie udało się zapisać ustawienia potwierdzenia: {error} - Delete layer Usuń warstwę Delete layer comp Usuń kompozycję warstw Delete note Usuń notatkę @@ -1637,8 +1730,6 @@ No saved presets yet. Nie ma jeszcze zapisanych ustawień wstępnych. Error Błąd Command Polecenie - -# Tool names, brush and style option tables Adjustment layers have nothing to transform Warstwy korekcyjne nie mają czego przekształcać Altitude Wysokość kątowa Android 1080p Android 1080p @@ -1871,8 +1962,6 @@ Yellows Żółte Zoom Tool Narzędzie Lupa {name} Properties Właściwości: {name} - -# Templates with placeholders Revert “{name}” to the last saved version? Your changes will be lost. Przywrócić „{name}” do ostatnio zapisanej wersji? Zmiany zostaną utracone. Do you want to save the changes you made to “{name}” before quitting? Czy zapisać zmiany wprowadzone w „{name}” przed zakończeniem? Do you want to save the changes you made to “{name}” before closing? Czy zapisać zmiany wprowadzone w „{name}” przed zamknięciem? @@ -1893,8 +1982,6 @@ @plural {n} Layer|{n} Layers {n} warstwa|{n} warstwy|{n} warstw @plural {n} layer|{n} layers {n} warstwa|{n} warstwy|{n} warstw @plural Group · {n} layer|Group · {n} layers Grupa · {n} warstwa|Grupa · {n} warstwy|Grupa · {n} warstw - -# Preferences (labels are generated from the setting names) Additional plugins folder Dodatkowy folder wtyczek Adobe Adobe Adobe rgb Adobe RGB @@ -2092,8 +2179,6 @@ Luminosity Luminancja @plural {n} sample|{n} samples {n} próbka|{n} próbki|{n} próbek {added}, {removed} subtracted {added}, odjęto {removed} - -# Added with the dock / brush picker work Adjustment Preview Podgląd korekty Airbrush: keeps depositing paint while the pointer rests. Aerograf: nakłada farbę nadal, gdy wskaźnik się nie porusza. Angle: Kąt: @@ -2125,8 +2210,6 @@ Brush picker Wybór pędzla Erase Wymaż Right click with painting tools Prawy przycisk z narzędziami malarskimi - -# Strings added to the UI later System Info… Informacje o systemie… System Info Informacje o systemie More panels Więcej paneli @@ -2188,7 +2271,6 @@ Align with layer Wyrównaj do warstwy Reset Alignment Resetuj wyrównanie Centre the gradient (offset 0, 0) Wyśrodkuj gradient (przesunięcie 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Edytor gradientu Gradient picker Wybór gradientu Gradient stops Punkty gradientu @@ -2248,8 +2330,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Siatka: - -# Tool, filter, warp-style, path, layer-comp and metadata names Delete Smart Filters Usuń filtry inteligentne Enable Smart Filters Włącz filtry inteligentne New Layer… Nowa warstwa… @@ -2703,15 +2783,11 @@ Expand group (⌥-click: all groups) Rozwiń grupę (kliknięcie z ⌥: wszystkie grupy) Hide the layer's effects (⌥-click: all layers) Ukryj efekty warstwy (kliknięcie z ⌥: wszystkie warstwy) Show the layer's effects (⌥-click: all layers) Pokaż efekty warstwy (kliknięcie z ⌥: wszystkie warstwy) - -# Command-id overrides @id select.all Zaznacz wszystko @id edit.purge.undo Historia cofania @id edit.stroke Obrys… @id edit.transform.scale Skaluj @id filter.sharpen.sharpen Wyostrz - -# Rendering mode and recovery Rendering Mode Tryb renderowania Automatic (recommended) Automatyczny (zalecany) CPU / Compatibility CPU / zgodność @@ -3396,8 +3472,6 @@ cameraRaw Roughness Chropowatość Wrap Owinięcie Zigzag Zygzak Wetness Wilgotność - -# About ▸ Contributors / Models (credits) About O programie Contributors Współtwórcy Models Modele @@ -3428,8 +3502,6 @@ cameraRaw Roughness Chropowatość % of all commits % wszystkich commitów Lines +/− Wiersze +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, wiersze +{added} / −{deleted} (Δ {delta}), pliki binarne +{binAdded} / −{binDeleted}, {first} – {last} - -# Text Color Picker and screen sampling Color Picker (Text Color) Próbnik kolorów (Kolor tekstu) Color Picker ({name}) Próbnik kolorów ({name}) Color Picker (Fill Color) Próbnik kolorów (Kolor wypełnienia) @@ -3456,8 +3528,6 @@ cameraRaw Roughness Chropowatość Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Albo ustaw Preferencje › Wydajność › Serwer wyświetlania Linuksa na X11: PhotoCraft będzie wtedy zawsze uruchamiany pod XWayland. Edit › Paste Edycja › Wklej Use File › Open, or paste a copied image with {paste}. Użyj Plik › Otwórz albo wklej skopiowany obraz za pomocą {paste}. - -# Swatches panel Create new swatch Utwórz nową próbkę New Swatch… Nowa próbka… New Swatch Group… Nowa grupa próbek… @@ -3468,6 +3538,8 @@ cameraRaw Roughness Chropowatość Reset Swatches Resetuj próbki Add to Swatches Dodaj do próbek Blend If: Mieszaj, jeśli: + Current Layer: Bieżąca warstwa: + Underlying Layer: Warstwa pod spodem: Channels: Kanały: Knockout Wycięcie New Brush Preset… Nowe ustawienie wstępne pędzla… @@ -3477,8 +3549,6 @@ cameraRaw Roughness Chropowatość Enter a name for the brush preset Wpisz nazwę ustawienia wstępnego pędzla A brush preset with this name already exists Ustawienie wstępne pędzla o tej nazwie już istnieje Brush Preset Options Opcje ustawień wstępnych pędzla - -# Fill dialog, layer style contours and Image Size Contents: Zawartość: Custom Pattern: Własny wzór: Foreground Color Kolor pierwszego planu @@ -3516,8 +3586,6 @@ cameraRaw Roughness Chropowatość Selection Preview: Podgląd zaznaczenia: Black Matte Czarne tło White Matte Białe tło - -# Layer colour labels. Seafoam Morski Indigo Indygo Fuchsia Fuksja @@ -3526,19 +3594,14 @@ cameraRaw Roughness Chropowatość This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości. The GPU compositor wasn't used: {reason} Kompozytor GPU nie został użyty: {reason} -# Pattern Stamp tool Pattern Stamp Tool Narzędzie Pieczątka wzoru Impressionist Impresjonistyczny Rotate View Tool Narzędzie Obróć widok Reset View Resetuj widok Drag around the centre to rotate the view · Shift constrains to 15° Przeciągnij wokół środka, aby obrócić widok · Shift ogranicza do 15° - -# Painting symmetry presets. Dual Axis Podwójna oś Diagonal Ukośna Transform Symmetry Przekształć symetrię - -# Eyedropper Sample Size and Sample (#1649). Point Sample Próbka punktowa 3 by 3 Average Uśrednianie 3 × 3 5 by 5 Average Uśrednianie 5 × 5 @@ -3550,15 +3613,12 @@ cameraRaw Roughness Chropowatość All Layers No Adjustments Wszystkie warstwy bez korekt Current & Below No Adjustments Bieżąca i poniżej bez korekt Show Sampling Ring Pokaż pierścień próbkowania - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy Color to Alpha… Kolor na alfę… Transparency Threshold Próg przezroczystości Opacity Threshold Próg krycia PhotoCraft — Screen Color Picker PhotoCraft — Próbnik kolorów ekranu - Rasterize svg groups deeper than Rasteryzuj grupy SVG głębsze niż ten poziom 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 wyłącza ten próg; grupy SVG zagnieżdżone głębiej niż 100 poziomów są zawsze rasteryzowane. Rule of Thirds Zasada trójpodziału @@ -3571,8 +3631,6 @@ cameraRaw Roughness Chropowatość Cycle Orientation Przełącz orientację Set the overlay options for the Crop tool Ustaw opcje nakładki narzędzia Kadrowanie 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+kliknięcie przejęło środowisko pulpitu, które przesuwa okna przez Alt+przeciąganie. Ustaw w nim klawisz przesuwania okien na Super lub zablokuj Alt w Okno › Klawisze modyfikujące. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Próbnik kolorów (kolor stylu warstwy) Auto Hide Notifications Automatycznie ukrywaj powiadomienia Notification Duration (seconds) Czas trwania powiadomienia (sekundy) @@ -3648,7 +3706,6 @@ cameraRaw Roughness Chropowatość Appearance Settings… Ustawienia wyglądu… Add to favorites Dodaj do ulubionych Remove from favorites Usuń z ulubionych -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Producent cameraData Model Model cameraData Date Taken Data wykonania @@ -3658,7 +3715,6 @@ cameraData ISO Speed Czułość ISO cameraData Focal Length Ogniskowa cameraData Focal Length (35 mm) Ogniskowa (35 mm) cameraData Lens Obiektyw -# Shared panel docking Activate Panel Aktywuj panel Change Panel Group Zmień grupę paneli Change Panel Layout Zmień układ paneli @@ -3702,7 +3758,6 @@ cameraData Lens Obiektyw Snap to Origin Przyciągnij do początku Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień -# Edit › Content-Aware Fill workspace Sampling Brush Tool Narzędzie Pędzel próbkowania Sampling Area Overlay Nakładka obszaru próbkowania Show Sampling Area Pokaż obszar próbkowania @@ -3718,7 +3773,6 @@ 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 @@ -3735,8 +3789,6 @@ cameraData Lens Obiektyw 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) @@ -3767,7 +3819,6 @@ curves Smooth Wygładź Fix the red plane first: its corners must form a convex quad Najpierw popraw czerwoną płaszczyznę: jej narożniki muszą tworzyć czworokąt wypukły Alt-click a plane to set the clone source Kliknij płaszczyznę z Alt, aby ustawić źródło klonowania Too many clone strokes: apply Vanishing Point first Zbyt wiele pociągnięć klonowania: najpierw zastosuj Punkt zbieżności -# Select and Mask view modes (#1754) Onion Skin Skórka cebuli Marching Ants Maszerujące mrówki On Black Na czarnym @@ -3776,7 +3827,6 @@ curves Smooth Wygładź Show Edge Pokaż krawędź Show Original Pokaż oryginał F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Pędzel dopracowania krawędzi (R) Expand Detection Area Rozszerz obszar wykrywania Restore Original Edge Przywróć oryginalną krawędź @@ -3784,19 +3834,13 @@ curves Smooth Wygładź Clear Brush Strokes Wyczyść pociągnięcia pędzla Show rulers Pokaż linijki Presentation Prezentacja - -# Color Sampler (added with the i18n coverage repair) 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… -# History panel › History Options… (#2966) Allow Non-Linear History Zezwalaj na nieliniową historię Make Layer Visibility Changes Undoable Pozwalaj cofać zmiany widoczności warstw -# Print dialog (#2697) Emulsion Down Emulsja w dół Functions Funkcje Print Resolution: Rozdzielczość druku: @@ -3808,7 +3852,6 @@ curves Smooth Wygładź Apply mask to layer before removing? Zastosować maskę do warstwy przed usunięciem? The layer or its mask changed. Drag the mask onto the Trash again. Warstwa lub jej maska uległa zmianie. Przeciągnij maskę ponownie do kosza. Delete the vector mask? Usunąć maskę wektorową? -# View › Guides › New Guide… › Color Light Blue Jasnoniebieski Light Red Jasnoczerwony Medium Blue Średnioniebieski @@ -3829,8 +3872,6 @@ curves Smooth Wygładź Clear Brush Controls Wyczyść elementy sterujące pędzlem Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Wczytaj operacje… Save Actions… Zapisz operacje… New Action… Nowa operacja… @@ -3842,10 +3883,11 @@ curves Smooth Wygładź @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok nie ma jeszcze odpowiednika w PhotoCraft|{n} kroki nie mają jeszcze odpowiednika w PhotoCraft|{n} kroków nie ma jeszcze odpowiednika w PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie - +@plural {n} action file loaded|{n} action files loaded Wczytano {n} plik operacji|Wczytano {n} pliki operacji|Wczytano {n} plików operacji + {set} from {file} replaced the one from {earlier} Zestaw {set} z {file} zastąpił zestaw z {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Nie udało się wczytać {n} pliku: {errors}|Nie udało się wczytać {n} plików: {errors}|Nie udało się wczytać {n} plików: {errors} File Naming Nazewnictwo plików Starting serial Początkowy numer seryjny -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Wstaw zatrzymanie… Save Set… Zapisz zestaw… Message: Komunikat: @@ -3856,17 +3898,15 @@ curves Smooth Wygładź Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji Background (Default) Tło (domyślne) -# Batch / Image Processor Include All Subfolders Uwzględnij wszystkie podfoldery Auto Add/Delete Automatyczne dodawanie/usuwanie - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nie włączaj też odbicia lustrzanego w ustawieniach druku: oba odbicia wzajemnie się znoszą. Print Settings… Ustawienia druku… Remember to turn off the printer's own colour management in Print Settings… Pamiętaj o wyłączeniu zarządzania kolorami drukarki w ustawieniach druku… 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 + Memory usage percent Użycie pamięci (%) Show Favorite Fonts Pokaż ulubione czcionki No favorite fonts yet: click the star beside a font to add it. Brak ulubionych czcionek: kliknij gwiazdkę obok czcionki, aby ją dodać. Favorite fonts Ulubione czcionki @@ -3905,48 +3945,31 @@ curves Smooth Wygładź Could not write {name}: {error} Nie można zapisać {name}: {error} Kept {name}: written by another application Zachowano {name}: zapisany przez inną aplikację Shadow Tint Tinta cieni -# PhotoCraft release checks. Check for Updates… Sprawdź aktualizacje… - PhotoCraft Updates Aktualizacje PhotoCraft - Checking for updates… Sprawdzanie aktualizacji… Open the release page to choose an installer for your system. Otwórz stronę wydania, aby wybrać instalator dla swojego systemu. - Open Download Page Otwórz stronę pobierania - PhotoCraft is up to date. PhotoCraft jest aktualny. - Could not check for updates. Nie udało się sprawdzić aktualizacji. - Updates have not been checked yet. Aktualizacje nie zostały jeszcze sprawdzone. - Check Again Sprawdź ponownie Automatically Check for Updates Automatycznie sprawdzaj aktualizacje - Installed version: {version} Zainstalowana wersja: {version} - PhotoCraft {version} is available. PhotoCraft {version} jest dostępny. -# 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}) Remember this choice for future images Zapamiętaj ten wybór dla kolejnych obrazów Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Pytanie można włączyć ponownie opcją Ask On Mismatch w Edycja › Ustawienia kolorów. Button Mode Tryb przycisku Button Mode On Tryb przycisku włączony - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Przyciemnij interfejs Lighten Interface Rozjaśnij interfejs -# Bevel & Emboss Technique. Technique Technika 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 + Save Full-Size Composite in .pcraft Files Zapisuj obraz złożony w pełnym rozmiarze w plikach .pcraft Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice Bicubic Automatic Dwusześcienna automatyczna @@ -3971,8 +3994,6 @@ 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 @@ -3990,10 +4011,8 @@ curves Smooth Wygładź Undo Changes Cofnij zmiany {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy. The transform couldn't be applied. Nie można zastosować przekształcenia. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Warstwy potrzebują {needed} pamięci GPU; budżet zostawia im {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna, najwyżej 3/8 jej pamięci. Automatycznie podąża za Użyciem pamięci, w granicach jednej czwartej pamięci tego komputera i 3/8 pamięci karty, i nie wlicza dokumentu przy karcie o znanej pamięci. Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. GPU Memory Full Pamięć GPU pełna @@ -4003,6 +4022,7 @@ curves Smooth Wygładź 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) + Scratch Size Limit (MB, 0 = No Limit) Limit rozmiaru dysku roboczego (MB, 0 = bez limitu) {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. @@ -4020,8 +4040,6 @@ curves Smooth Wygładź 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 @@ -4036,18 +4054,13 @@ curves Smooth Wygładź Use Użyj In Front Z przodu Behind Z tyłu - -# DDS Options (#1327) DDS Options Opcje DDS 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Zawartość AI/PDF/EPS będzie edytowana jako SVG. Zapisanie zastąpi osadzone źródło; właściwości specyficzne dla formatu mogą się zmienić. # Styles panel: Import / Export Styles (.asl) Import Styles… Importuj style… @@ -4056,7 +4069,6 @@ curves Smooth Wygładź {file} is not a style library (.asl) {file} nie jest biblioteką stylów (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Zaimportowano {n} styl z {file}|Zaimportowano {n} style z {file}|Zaimportowano {n} stylów z {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Wyeksportowano {n} styl do {file}|Wyeksportowano {n} style do {file}|Wyeksportowano {n} stylów do {file} -# 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 @@ -4065,14 +4077,11 @@ curves Smooth Wygładź 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Sześcian odcienia Brightness Cube Sześcian jasności Color Wheel Koło kolorów @@ -4088,7 +4097,8 @@ curves Smooth Wygładź Grayscale Ramp Pasek skali szarości Current Colors Bieżące kolory Make Ramp Web Safe Ogranicz pasek do kolorów internetowych -# Art History Brush (#213). + The path was kept as the Work Path Ścieżka została zachowana jako ścieżka robocza + The path was discarded Ścieżka została odrzucona Art History Brush Tool Narzędzie Pędzel historii artystycznej Tight Short Ścisły krótki Tight Medium Ścisły średni @@ -4099,9 +4109,33 @@ curves Smooth Wygładź Tight Curl Ścisły lok Loose Curl Luźny lok Stylized dabs from a history state Stylizowane plamy ze stanu historii - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Target Cel Font size presets Ustawienia wstępne rozmiaru czcionki Leading presets Ustawienia wstępne interlinii Tracking presets Ustawienia wstępne światła + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Nieprawidłowa wartość liczbowa. Wymagana jest liczba całkowita od {min}% do {max}%. Wstawiono poprzednią wartość. + Clear Selection Wyczyść zaznaczenie + Color Aware Według kolorów + Decontaminate Colors Odkażanie kolorów + Edge Detection Wykrywanie krawędzi + Global Refinements Dopracowanie globalne + High Quality Preview Podgląd wysokiej jakości + Object Aware Według obiektów + Real-Time Refinement Dopracowanie w czasie rzeczywistym + Refine Edge Brush Tool Narzędzie Pędzel dopracowania krawędzi + Refine Hair Dopracuj włosy + Refine Mode Tryb dopracowania + Remember Settings Zapamiętaj ustawienia + View Mode Tryb widoku + Object Aware needs a segmentation model, which isn't available yet Tryb Według obiektów wymaga modelu segmentacji, który nie jest jeszcze dostępny + Refine Hair needs hair detection, which isn't available yet Dopracowanie włosów wymaga wykrywania włosów, które nie jest jeszcze dostępne + Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny + Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę + Select Layer Under Cursor Zaznacz warstwę pod kursorem + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker Próbnik kolorów + Recently Used Colors Ostatnio używane kolory + Stop Color Kolor punktu + Shapes can't use patterns yet Kształty nie mogą jeszcze używać wzorków + Edit Brush… Edytuj pędzel… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f3335cd9c6..f12a596d1a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -16,18 +16,113 @@ # 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: - Solidity: Solidez: - Ink Characteristics Características da tinta # Guide layout dialog # Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Layer Style dialog: style presets and per-effect defaults + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Channels panel menu: channel commands, spot channels, Quick Mask, Color Indicates +# Layer Style › Blending Options: advanced blending and Blend If +# Canvas right-click menus: pasteboard color, paths and shapes +# Color Picker titles +# Rasterize prompts +# GPU and display status (status bar, System Info) +# Status messages +# Panels and small controls +# Liquify tools (tooltips) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). +# Smart Filter row actions (#1265) + + Mask Options… Opções de máscara… + Layer Mask Display Options… Opções de exibição de máscara de camada… + Channel Color Cor do canal + Color Indicates: A cor indica: + Solidity: Solidez: + Ink Characteristics Características da tinta Paper Width Largura do papel Paper Height Altura do papel Rows Linhas @@ -46,7 +141,6 @@ 5 Columns, 3 Rows 5 colunas, 3 linhas 8 Columns 8 colunas 12 Columns 12 colunas - File Arquivo Edit Editar Image Imagem @@ -437,6 +531,7 @@ Intersect Shape Areas Fazer interseção das áreas de formas Exclude Overlapping Shapes Excluir formas sobrepostas Merge Shape Components Mesclar componentes de formas + Path operations Operações de demarcador Align Alinhar Top Edges Bordas superiores Vertical Centers Centros verticais @@ -516,7 +611,6 @@ Color Range… Intervalo de cores… Focus Area… Área de foco… Select and Mask… Selecionar e mascarar… -# Select and Mask dialog labels Smart Radius Raio inteligente Shift Edge Deslocar borda Decontaminate Descontaminar @@ -542,7 +636,6 @@ Blur Desfoque Blur Gallery Galeria de desfoque Field Blur… Desfoque de campo… -# Field Blur pins (#3373) No pin selected Nenhum alfinete selecionado Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Clique na imagem para adicionar um alfinete; arraste um alfinete para movê-lo ou o anel dele para alterar o desfoque. Delete remove o alfinete selecionado. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Arraste as alças do contorno para redimensionar e girar, o quadrado para arredondar os cantos e os pontos internos para definir onde o desfoque começa. @@ -820,6 +913,9 @@ Add Layer Mask Adicionar máscara de camada Add Stop Adicionar parada Add a layer style Adicionar um estilo de camada + Move effect up Mover efeito para cima + Move effect down Mover efeito para baixo + Delete effect Excluir efeito Add a mask (from the selection; {key} inverts) Adicionar uma máscara (a partir da seleção; {key} inverte) Add an adjustment Adicionar um ajuste Add disk Adicionar disco @@ -1046,8 +1142,6 @@ Delete current channel Excluir o canal atual Delete current state Excluir o estado atual Delete effect layer Excluir camada de efeito - -# Layers-panel trash confirmation Delete layer ‘{name}’? Excluir a camada “{name}”? Delete {count} layers? {names} Excluir {count} camadas? {names} Delete layer? Excluir camada? @@ -1056,7 +1150,6 @@ Missing delete command Comando de exclusão ausente Invalid delete command Comando de exclusão inválido Layer deleted, but confirmation preference could not be saved: {error} A camada foi excluída, mas não foi possível salvar a preferência de confirmação: {error} - Delete layer Excluir camada Delete layer comp Excluir composição de camadas Delete note Excluir anotação @@ -1089,7 +1182,6 @@ 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 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. Drop an image or PSD anywhere to open it. Solte uma imagem ou um PSD em qualquer lugar para abri-lo. @@ -2146,7 +2238,6 @@ Align with layer Alinhar com a camada Reset Alignment Redefinir alinhamento Centre the gradient (offset 0, 0) Centralizar o degradê (deslocamento 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Editor de degradê Gradient picker Seletor de degradê Gradient stops Paradas do degradê @@ -2772,8 +2863,6 @@ cameraRaw Whites Brancos Don't show again Não mostrar novamente Save failed: {error} Falha ao salvar: {error} Save cancelled Salvamento cancelado - -# Layer Style dialog: style presets and per-effect defaults Style {name} saved Estilo {name} salvo Make Default Definir como padrão Reset to Default Restaurar padrão @@ -2782,8 +2871,6 @@ cameraRaw Whites Brancos Magnetic Lasso Tool Ferramenta Laço magnético Frequency Frequência Use tablet pressure to change pen width Usar a pressão da mesa digitalizadora para alterar a largura da caneta - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About Sobre Contributors Colaboradores Models Modelos @@ -2814,8 +2901,6 @@ cameraRaw Whites Brancos % of all commits % de todos os commits Lines +/− Linhas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{binAdded} / −{binDeleted} arquivos binários, {first} – {last} - -# Swatches panel Create new swatch Criar nova amostra New Swatch… Nova amostra… New Swatch Group… Novo grupo de amostras… @@ -2825,13 +2910,12 @@ cameraRaw Whites Brancos Export Swatches for Exchange… Exportar amostras para troca… Reset Swatches Redefinir amostras Add to Swatches Adicionar às amostras -# Layer Style > Blending Options (Advanced Blending) Blend If: Mesclar se: + Current Layer: Camada atual: + Underlying Layer: Camada subjacente: Channels: Canais: Knockout Vazado Apply At Canvas Bounds Aplicar efeito nos limites da tela - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Seletor de cores (Cor do texto) Color Picker ({name}) Seletor de cores ({name}) Color Picker (Fill Color) Seletor de cores (Cor de preenchimento) @@ -2848,8 +2932,6 @@ cameraRaw Whites Brancos Show/Hide Panels Mostrar/ocultar painéis Could not open Não foi possível abrir {path}: the path is not valid Unicode; rename the file and open it again. {path}: o caminho não é Unicode válido; renomeie o arquivo e abra-o novamente. - -# Fill dialog, layer style contours and Image Size Contents: Conteúdo: Custom Pattern: Padrão personalizado: Foreground Color Cor de primeiro plano @@ -2876,14 +2958,12 @@ cameraRaw Whites Brancos Rolling Slope - Descending Declive ondulado - descendente Rounded Steps Degraus arredondados Sawtooth 1 Dente de serra 1 - Red Eye Tool Ferramenta Olhos vermelhos Pupil Size Tamanho da pupila Darken Amount Quantidade de escurecimento Click a red pupil to neutralize it Clique em uma pupila vermelha para corrigi-la open source código aberto …and {n} more …e mais {n} - Eyedroppers: Conta-gotas: Set Black Point Definir ponto preto Set Neutral Gray Point Definir ponto cinza neutro @@ -2892,8 +2972,6 @@ cameraRaw Whites Brancos Selection Preview: Visualização da seleção: Black Matte Fundo preto White Matte Fundo branco - -# Layer colour labels. Seafoam Espuma do mar Indigo Índigo Fuchsia Fúcsia @@ -2913,20 +2991,14 @@ fontWeight Bold Negrito fontWeight ExtraBold Extranegrito fontWeight Black Ultranegrito fontWeight Italic Itálico - -# Pattern Stamp tool Pattern Stamp Tool Ferramenta Carimbo de padrão Impressionist Impressionista Rotate View Tool Ferramenta Girar visualização Reset View Redefinir visualização Drag around the centre to rotate the view · Shift constrains to 15° Arraste ao redor do centro para girar a visualização · Shift restringe a 15° - -# Painting symmetry presets. Dual Axis Eixo duplo Diagonal Diagonal Transform Symmetry Transformar simetria - -# Eyedropper Sample Size and Sample (#1649). Point Sample Amostra pontual 3 by 3 Average Média de 3 × 3 5 by 5 Average Média de 5 × 5 @@ -2938,15 +3010,12 @@ fontWeight Italic Itálico All Layers No Adjustments Todas as camadas sem ajustes Current & Below No Adjustments Atual e abaixo sem ajustes Show Sampling Ring Mostrar anel de amostragem - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima Color to Alpha… Cor para alfa… Transparency Threshold Limiar de transparência Opacity Threshold Limiar de opacidade PhotoCraft — Screen Color Picker PhotoCraft — Seletor de cor da tela - Rasterize svg groups deeper than Rasterizar grupos SVG além desta profundidade 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 desativa este limite; grupos SVG aninhados em mais de 100 níveis são sempre rasterizados. Rule of Thirds Regra dos terços @@ -2960,8 +3029,6 @@ fontWeight Italic Itálico Cycle Orientation Alternar orientação Set the overlay options for the Crop tool Definir as opções de sobreposição da ferramenta Corte demarcado 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. O Alt-clique foi para o desktop, que move janelas com Alt-arrastar. Mude a tecla de mover janelas dele para Super ou trave o Alt em Janela › Teclas modificadoras. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Seletor de cores (cor do estilo de camada) Auto Hide Notifications Ocultar notificações automaticamente Notification Duration (seconds) Duração da notificação (segundos) @@ -3037,7 +3104,6 @@ fontWeight Italic Itálico Appearance Settings… Configurações de aparência… Add to favorites Adicionar aos favoritos Remove from favorites Remover dos favoritos -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Fabricante cameraData Model Modelo cameraData Date Taken Data de captura @@ -3047,7 +3113,6 @@ cameraData ISO Speed Sensibilidade ISO cameraData Focal Length Distância focal cameraData Focal Length (35 mm) Distância focal (35 mm) cameraData Lens Lente -# Shared panel docking Activate Panel Ativar painel Change Panel Group Alterar grupo de painéis Change Panel Layout Alterar disposição dos painéis @@ -3091,7 +3156,6 @@ cameraData Lens Lente Snap to Origin Ajustar à origem Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada -# Edit › Content-Aware Fill workspace Sampling Brush Tool Ferramenta Pincel de amostragem Sampling Area Overlay Sobreposição da área de amostragem Show Sampling Area Mostrar área de amostragem @@ -3110,7 +3174,6 @@ 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 @@ -3127,8 +3190,6 @@ cameraData Lens Lente 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) @@ -3161,7 +3222,6 @@ curves Smooth Suavizar Fix the red plane first: its corners must form a convex quad Corrija primeiro o plano vermelho: seus cantos devem formar um quadrilátero convexo Alt-click a plane to set the clone source Clique com Alt em um plano para definir a origem da clonagem Too many clone strokes: apply Vanishing Point first Traços de clonagem demais: aplique Ponto de fuga primeiro -# Select and Mask view modes (#1754) Onion Skin Papel vegetal Marching Ants Formigas marchando On Black Sobre preto @@ -3170,7 +3230,6 @@ curves Smooth Suavizar Show Edge Mostrar borda Show Original Mostrar original F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Pincel de refinamento de borda (R) Expand Detection Area Expandir área de detecção Restore Original Edge Restaurar borda original @@ -3178,8 +3237,6 @@ curves Smooth Suavizar Clear Brush Strokes Limpar traços do pincel Show rulers Mostrar réguas Presentation Apresentação - -# Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover No document open Nenhum documento aberto {n} of {max} sample points {n} de {max} pontos de amostragem @@ -3949,14 +4006,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… -# History panel › History Options… (#2966) Allow Non-Linear History Permitir histórico não linear Make Layer Visibility Changes Undoable Permitir desfazer alterações de visibilidade das camadas -# Print dialog (#2697) Emulsion Down Emulsão para baixo Functions Funções Print Resolution: Resolução de impressão: @@ -3968,7 +4021,6 @@ layerLabel No Color Sem cor Apply mask to layer before removing? Aplicar a máscara à camada antes de removê-la? The layer or its mask changed. Drag the mask onto the Trash again. A camada ou a máscara mudou. Arraste a máscara para a Lixeira novamente. Delete the vector mask? Excluir a máscara vetorial? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} amostra exportada para {file}|{n} amostras exportadas para {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pincel importado de {group}|{n} pincéis importados de {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} amostra importada de {file}|{n} amostras importadas de {file} @@ -3983,7 +4035,6 @@ layerLabel No Color Sem cor @plural {n} step|{n} steps {n} etapa|{n} etapas @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} outro aviso de predefinição de pincel)|{warning} (+{n} outros avisos de predefinição de pincel) @plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos -# View › Guides › New Guide… › Color Light Blue Azul-claro Light Red Vermelho-claro Medium Blue Azul médio @@ -4004,8 +4055,6 @@ layerLabel No Color Sem cor Clear Brush Controls Limpar controles de pincel Reset All Locked Settings Redefinir todas as configurações bloqueadas Copy Texture to Other Tools Copiar textura para outras ferramentas - -# 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… @@ -4017,10 +4066,11 @@ layerLabel No Color Sem cor @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} etapa ainda não tem equivalente no PhotoCraft|{n} etapas ainda não têm equivalente no PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas - +@plural {n} action file loaded|{n} action files loaded {n} arquivo de ações carregado|{n} arquivos de ações carregados + {set} from {file} replaced the one from {earlier} O conjunto {set} de {file} substituiu o de {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Não foi possível carregar {n} arquivo: {errors}|Não foi possível carregar {n} arquivos: {errors} File Naming Nomenclatura de arquivos Starting serial Número de série inicial -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Inserir parada… Save Set… Salvar conjunto… Message: Mensagem: @@ -4031,17 +4081,15 @@ layerLabel No Color Sem cor Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo @plural {n} action|{n} actions {n} ação|{n} ações Background (Default) Plano de fundo (padrão) -# Batch / Image Processor Include All Subfolders Incluir todas as subpastas Auto Add/Delete Adicionar/excluir automaticamente - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Não ative também a imagem espelhada nas configurações de impressão: as duas inversões se anulam. Print Settings… Configurações de impressão… Remember to turn off the printer's own colour management in Print Settings… Lembre-se de desativar o gerenciamento de cores da própria impressora nas configurações de impressão… 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 + Memory usage percent Uso de memória (%) Show Favorite Fonts Mostrar fontes favoritas No favorite fonts yet: click the star beside a font to add it. Ainda não há fontes favoritas: clique na estrela ao lado de uma fonte para adicioná-la. Favorite fonts Fontes favoritas @@ -4080,48 +4128,31 @@ layerLabel No Color Sem cor Could not write {name}: {error} Não foi possível gravar {name}: {error} Kept {name}: written by another application {name} mantido: gravado por outro aplicativo Shadow Tint Matiz das sombras -# PhotoCraft release checks. Check for Updates… Verificar atualizações… - PhotoCraft Updates Atualizações do PhotoCraft - Checking for updates… Verificando atualizações… Open the release page to choose an installer for your system. Abra a página da versão para escolher um instalador para o seu sistema. - Open Download Page Abrir página de download - PhotoCraft is up to date. O PhotoCraft está atualizado. - Could not check for updates. Não foi possível verificar atualizações. - Updates have not been checked yet. As atualizações ainda não foram verificadas. - Check Again Verificar novamente Automatically Check for Updates Verificar atualizações automaticamente - Installed version: {version} Versão instalada: {version} - PhotoCraft {version} is available. PhotoCraft {version} está disponível. -# 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}) Remember this choice for future images Lembrar esta escolha para as próximas imagens Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Reative a pergunta com Ask On Mismatch em Editar › Configurações de cores. Button Mode Modo de botão Button Mode On Modo de botão ativado - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Escurecer interface Lighten Interface Clarear interface -# Bevel & Emboss Technique. Technique Técnica 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 + Save Full-Size Composite in .pcraft Files Salvar a imagem composta em tamanho real nos arquivos .pcraft Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas Bicubic Automatic Bicúbica automática @@ -4146,8 +4177,6 @@ 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 @@ -4165,10 +4194,8 @@ layerLabel No Color Sem cor Undo Changes Desfazer alterações {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar. The transform couldn't be applied. Não foi possível aplicar a transformação. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. As camadas precisam de {needed} de memória da GPU; o orçamento lhes deixa {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta, no máximo 3/8 da memória dela. Automático segue Uso de memória, dentro de um quarto da memória deste computador e 3/8 da memória da placa, e não desconta o documento de uma placa cuja memória é conhecida. Enquanto não couberem, a barra de status mostra Memória da GPU cheia. GPU Memory Full Memória da GPU cheia @@ -4178,6 +4205,7 @@ layerLabel No Color Sem cor 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) + Scratch Size Limit (MB, 0 = No Limit) Limite de tamanho do disco de rascunho (MB, 0 = sem limite) {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. @@ -4194,8 +4222,6 @@ layerLabel No Color Sem cor 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 @@ -4210,17 +4236,12 @@ layerLabel No Color Sem cor Use Usar In Front Na frente Behind Atrás - -# DDS Options (#1327) 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. O conteúdo AI/PDF/EPS será editado como SVG. Salvar substitui a origem incorporada; recursos específicos do formato podem mudar. # Styles panel: Import / Export Styles (.asl) Import Styles… Importar estilos… @@ -4229,9 +4250,7 @@ layerLabel No Color Sem cor {file} is not a style library (.asl) {file} não é uma biblioteca de estilos (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {n} estilo importado de {file}|{n} estilos importados de {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} {n} estilo exportado para {file}|{n} estilos exportados para {file} -# 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 @@ -4244,7 +4263,6 @@ layerLabel No Color Sem cor 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 @@ -4254,8 +4272,6 @@ layerLabel No Color Sem cor 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… @@ -4274,17 +4290,14 @@ layerLabel No Color Sem cor 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} @@ -4312,7 +4325,6 @@ layerLabel No Color Sem cor 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 @@ -4349,7 +4361,6 @@ layerLabel No Color Sem cor 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 @@ -4362,7 +4373,6 @@ layerLabel No Color Sem cor 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 @@ -4378,14 +4388,11 @@ layerLabel No Color Sem cor 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Cubo de matiz Brightness Cube Cubo de brilho Color Wheel Roda de cores @@ -4401,7 +4408,8 @@ layerLabel No Color Sem cor Grayscale Ramp Rampa de tons de cinza Current Colors Cores atuais Make Ramp Web Safe Tornar rampa segura para a Web -# Art History Brush (#213). + The path was kept as the Work Path O demarcador foi mantido como demarcador de trabalho + The path was discarded O demarcador foi descartado Art History Brush Tool Ferramenta Pincel do histórico da arte Tight Short Curto cerrado Tight Medium Médio cerrado @@ -4415,3 +4423,27 @@ layerLabel No Color Sem cor Font size presets Predefinições de tamanho da fonte Leading presets Predefinições de entrelinha Tracking presets Predefinições de tracking + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Entrada numérica inválida. É necessário um número inteiro entre {min}% e {max}%. O valor anterior foi inserido. + Color Aware Com reconhecimento de cor + Decontaminate Colors Descontaminar cores + Edge Detection Detecção de borda + Global Refinements Refinamentos globais + High Quality Preview Visualização de alta qualidade + Object Aware Com reconhecimento de objeto + Real-Time Refinement Refinamento em tempo real + Refine Edge Brush Tool Ferramenta Pincel de refinamento de borda + Refine Hair Refinar cabelo + Refine Mode Modo de refinamento + Remember Settings Lembrar configurações + View Mode Modo de exibição + Object Aware needs a segmentation model, which isn't available yet Com reconhecimento de objeto requer um modelo de segmentação, ainda não disponível + Refine Hair needs hair detection, which isn't available yet Refinar cabelo requer detecção de cabelo, ainda não disponível + Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo + Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada + Drag to move the selected path Arraste para mover o demarcador selecionado + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Cores usadas recentemente + Stop Color Cor da parada + Shapes can't use patterns yet Formas ainda não podem usar padrões + Edit Brush… Editar pincel… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b74c398312..60f3929e5f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -18,20 +18,213 @@ # 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: Цветом обозначены: - Solidity: Плотность: - Ink Characteristics Характеристики краски # Menus # Guide layout dialog # Custom print paper (#3820) + + +# File + +# Edit + +# Image + +# Layer + +# Type + +# Select +# Select and Mask dialog labels + +# Filter +# Field Blur pins (#3373) + +# View + +# Window + +# Help + +# Tools and options bar + +# Panels, dialogs and common widgets + +# Preferences dialog + +# Document and canvas status bar + +# Layers panel + +# Channels panel + +# Color picker and swatches + +# Filters and adjustments dialogs + +# Camera Raw and lens dialogs + +# Adjustments panel + +# History panel + +# Navigator panel + +# Brush Settings panel + +# Tool presets + +# Paragraph and character panels + +# Glyphs panel + +# Paths panel + +# Clone Source panel + +# Timeline panel + +# Info panel + +# Layer Comps panel + +# Analysis and variables + +# Automate + +# Color Settings dialog + +# Themes + +# Workspaces + +# New Document dialog + +# Placeholders, tooltips, status and keyboard hints + +# Transform / warp overlays + +# Vanishing Point + +# Misc dialog strings + +# Blend modes + +# Saved views and other bits + +# Plurals + +# Remaining menu strings (second pass) + +# tl! literals (third pass) +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Preference dialog: generated setting labels and dropdown choices +# Rendering mode and recovery +# Camera Raw: contextual controls and window title + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker + +# Layers-panel trash confirmation +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# History panel › History Brush source (#2966) + +# Dynamic selection context menu and appearance favorites + +# Measurement Log column headers (analysis_cmds::COLUMNS) +# Bevel & Emboss shading controls. +# PhotoCraft release checks. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + + +# 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. + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Dynamic command-id, plural, and contextual labels (fourth pass). +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + Mask Options… Параметры маски… + Layer Mask Display Options… Параметры отображения маски слоя… + Channel Color Цвет канала + Color Indicates: Цветом обозначены: + Solidity: Плотность: + Ink Characteristics Характеристики краски Paper Width Ширина бумаги Paper Height Высота бумаги Rows Строки @@ -50,7 +243,6 @@ 5 Columns, 3 Rows 5 столбцов, 3 строки 8 Columns 8 столбцов 12 Columns 12 столбцов - File Файл Edit Редактирование Image Изображение @@ -67,8 +259,6 @@ View Просмотр Window Окно Help Справка - -# File New… Создать… New from Clipboard Создать из буфера обмена New Создать @@ -110,8 +300,6 @@ Save for Web (Legacy)… Сохранить для Web (устаревшее)… Generate Генерировать Image Assets Ресурсы изображений - -# Edit Undo Отменить Redo Повторить Fade… Ослабить… @@ -135,8 +323,6 @@ Define Pattern… Определить паттерн… Define Custom Shape… Определить свою фигуру… Purge Очистить - -# Image Image Size… Размер изображения… Image Size Размер изображения Canvas Size… Размер холста… @@ -175,8 +361,6 @@ Duotone… Дуотон… Duotone Дуотон Trap… Приводка… - -# Layer Layer via Copy Скопировать на новый слой Layer via Cut Вырезать на новый слой Duplicate Layer… Дублировать слой… @@ -273,8 +457,6 @@ Scale Effects… Масштабировать эффекты… New Layer… Новый слой… Delete Layer… Удалить слой… - -# Type Font Шрифт Font Preview Size Размер предпросмотра шрифтов Orientation Ориентация @@ -301,8 +483,6 @@ Load Default Type Styles Загрузить стили текста по умолчанию Save Default Type Styles Сохранить стили текста по умолчанию Type… Текст… - -# Select All Всё Deselect Отменить выделение Reselect Повторить выделение @@ -310,7 +490,6 @@ Inverse Инверсия Color Range… Цветовой диапазон… Select and Mask… Выделение и маска… -# Select and Mask dialog labels Smart Radius Умный радиус Shift Edge Сместить край Decontaminate Очистить @@ -338,8 +517,6 @@ Current Layer Текущий слой Refine Strokes Штрихи уточнения Edits Правки - -# Filter Last Filter Последний фильтр Filter Gallery… Галерея фильтров… Liquify… Пластичность… @@ -350,7 +527,6 @@ Oil Paint… Масляная живопись… Blur Gallery Галерея размытия Field Blur… Размытие по полю… -# Field Blur pins (#3373) No pin selected Булавка не выбрана Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Щелкните изображение, чтобы добавить булавку; перетащите булавку, чтобы переместить ее, или ее кольцо, чтобы изменить размытие. Delete удаляет выбранную булавку. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Перетаскивайте маркеры на контуре, чтобы изменить размер и повернуть его, квадрат — чтобы скруглить углы, а точки внутри — чтобы задать, где начинается размытие. @@ -440,8 +616,6 @@ Mezzotint… Меццотинто… Color Halftone… Цветные полутона… Layer… Слой… - -# View Proof Setup Настройка цветопробы Proof Colors Цветопроба Pixel Aspect Ratio Пиксельное соотношение сторон @@ -488,8 +662,6 @@ Canvas rotation Вращение холста Zoom in Увеличить Zoom out Уменьшить - -# Window Cascade Каскадом Tile Мозаикой 2-up Horizontal Два по горизонтали @@ -538,8 +710,6 @@ 3D 3D Toolbars Панели инструментов Hide All Скрыть все - -# Help About PhotoCraft О программе PhotoCraft PhotoCraft Website Сайт PhotoCraft PhotoCraft on GitHub PhotoCraft на GitHub @@ -547,8 +717,6 @@ ArtCraft Website Сайт ArtCraft Join the ArtCraft Discord… Discord сообщества ArtCraft… System Info… Информация о системе… - -# Tools and options bar Move Перемещение Marquee Область Lasso Лассо @@ -609,8 +777,6 @@ Rotation Поворот Align Выравнивание Aligned Выровнено - -# Panels, dialogs and common widgets OK OK Cancel Отмена Cancel (Esc) Отмена (Esc) @@ -670,8 +836,6 @@ Select Data Points… Выбрать точки данных… Set Measurement Scale… Задать масштаб измерений… Use Measurement Scale Использовать масштаб измерений - -# Preferences dialog Preferences Настройки General… Общие… Interface… Интерфейс… @@ -696,8 +860,6 @@ Thumbnail size (smallest: list) Размер миниатюр (минимальный: список) Reset GPU Backend Сбросить GPU-бэкенд Applies at next launch. Применится при следующем запуске. - -# Document and canvas status bar Untitled Без имени Untitled-1 Без имени-1 Doc: {flat}/{layered} Док: {flat}/{layered} @@ -736,8 +898,6 @@ Both Axes По обеим осям Horizontally По горизонтали Vertically По вертикали - -# Layers panel Layers to Files… Слои в файлы… Duplicate Layers… Дублировать слои… Group name Имя группы @@ -767,8 +927,6 @@ Smart Filter Смарт-фильтр Fill Content Содержимое заливки Adjustment Preview Предпросмотр коррекции - -# Channels panel Channel: Канал: Channel Mixer… Микширование каналов… Output Channel: Выходной канал: @@ -783,8 +941,6 @@ Spot Channel Цветовой канал Mask All Маскировать всё Mask All Objects Маскировать все объекты - -# Color picker and swatches Color Цвет Color: Цвет: Colors Цвета @@ -799,8 +955,6 @@ Overlay Opacity Непрозрачность наложения Glow Color Цвет свечения Web Snap Привязка к web-палитре - -# Filters and adjustments dialogs Brightness Яркость Contrast Контраст Brightness/Contrast… Яркость/Контраст… @@ -861,8 +1015,6 @@ Working Yellow Plate Рабочая жёлтая форма Working Black Plate Рабочая чёрная форма Working Gray Plate Рабочая серая форма - -# Camera Raw and lens dialogs Basic Основные Detail Детализация HSL/Grayscale HSL/Градации серого @@ -959,8 +1111,6 @@ Selected Areas Выделенные области Spot Color Цветовой (spot) канал Color indicates Цвет обозначает - -# Adjustments panel Curves Кривые Levels Уровни Color Balance Цветовой баланс @@ -973,8 +1123,6 @@ Channel Mixer Микширование каналов Gradient Map Градиентная карта Selective Color Выборочная коррекция цвета - -# History panel History Log… Журнал истории… Create Snapshot Создать снимок Delete Snapshot Удалить снимок @@ -989,14 +1137,10 @@ Automatically Create New Snapshot When Saving Автоматически создавать снимок при сохранении Show New Snapshot Dialog by Default По умолчанию показывать диалог нового снимка Clear History Очистить историю - -# Navigator panel Panel Options… Параметры панели… View menu Меню «Просмотр» Keep view in sync Держать обзор синхронным {zoom}% {zoom}% - -# Brush Settings panel Brush name Имя кисти Brush Preview Предпросмотр кисти Brush Tip Shape Форма отпечатка @@ -1082,8 +1226,6 @@ Override Tilt Y Переопределить наклон Y Override Rotation Переопределить поворот Apply the pen's tilt and rotation to the tip shape Применять наклон и поворот пера к форме отпечатка - -# Tool presets Preset Type Тип набора Preset Manager… Управление наборами… Rename Tool Preset… Переименовать набор… @@ -1097,8 +1239,6 @@ Preset: Набор: Preset Набор Presets: Наборы: - -# Paragraph and character panels Font Size: Кегль: Font size Кегль Type: Текст: @@ -1176,8 +1316,6 @@ Indent First Отступ первой строки Center По центру Justify По ширине - -# Glyphs panel Glyph Глиф Select a type layer or click into text to insert glyphs Выберите текстовый слой или щёлкните в текст, чтобы вставить глифы Entire Font Весь шрифт @@ -1197,8 +1335,6 @@ Ordinals Порядковые числительные East Asian Features Восточноазиатские возможности Middle Eastern Features Ближневосточные возможности - -# Paths panel Path Контур Path: Контур: Work Path Рабочий контур @@ -1215,11 +1351,9 @@ Current Path Текущий контур Vector Mask from Current Path Векторная маска из текущего контура Create new shape from the current path Создать новую фигуру из текущего контура - Drag to move the active shape's path or the Work Path Перетащите, чтобы переместить контур активной фигуры или рабочий контур + Drag to move the selected 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) или создайте рабочий контур из выделения. - -# Clone Source panel Source: not set ({key}-click with Clone Stamp) Источник: не задан ({key}-клик штампом) Clone source {n} ({key}-click with Clone Stamp to set) Источник клонирования {n} ({key}-клик штампом, чтобы задать) {key}-click to set the source {key}-клик, чтобы задать источник @@ -1242,8 +1376,6 @@ Frame Sync Синхронизация кадров Quick Sync Быстрая синхронизация Enabled Включено - -# Timeline panel Create Video Timeline Создать видео-шкалу Open a document to use the timeline. Откройте документ, чтобы работать со шкалой времени. Video Layers Видеослои @@ -1285,8 +1417,6 @@ Repeat Повтор Play Once Однократно Video Cache Видеокэш - -# Info panel Info Options… Параметры информации… First Color Readout Первая цветовая проба Second Color Readout Вторая цветовая проба @@ -1309,8 +1439,6 @@ Mouse Coordinates Координаты курсора Ruler Units Единицы линеек Hide Скрыть - -# Layer Comps panel Layer Comps to Files… Варианты слоя в файлы… Apply next selected layer comp Применить следующий выбранный вариант Apply previous selected layer comp Применить предыдущий выбранный вариант @@ -1328,8 +1456,6 @@ Visibility Видимость Position Положение Appearance (Layer Style) Внешний вид (стиль слоя) - -# Analysis and variables Analysis Анализ Variables Переменные Variable Data Sets… Наборы данных переменных… @@ -1410,8 +1536,6 @@ Version {version} Версия {version} Advanced Дополнительно Import… Импортировать… - -# Automate Automate Автоматизация Batch… Пакетная обработка… Create Droplet… Создать модуль-дроплет… @@ -1442,8 +1566,6 @@ Stop For Errors Останавливаться при ошибках Log Errors To File Записывать ошибки в файл Errors Ошибки - -# Color Settings dialog Color Settings… Настройки цвета… Settings Наборы Settings: Наборы: @@ -1472,8 +1594,6 @@ SMPTE C SMPTE C HDTV (Rec. 709) HDTV (Rec. 709) CIE RGB CIE RGB - -# Themes Theme Тема Switch theme Сменить тему Next Theme Следующая тема @@ -1496,8 +1616,6 @@ Pro Medium Gray Theme Средне-серая тема Pro Pro Light Gray Theme Светло-серая тема Pro Pro Light Theme Светлая тема Pro - -# Workspaces Workspace… Рабочая среда… Essentials Основные New Workspace… Новая рабочая среда… @@ -1525,8 +1643,6 @@ Collapse Panel Group Свернуть группу панелей Expand Panel Group Развернуть группу панелей Close Tab Group Закрыть группу вкладок - -# New Document dialog PRESET DETAILS ПАРАМЕТРЫ НАБОРА BLANK DOCUMENT PRESETS ({n}) НАБОРЫ ПУСТЫХ ДОКУМЕНТОВ ({n}) Create a new document or open an existing file. Создайте новый документ или откройте существующий файл. @@ -1573,8 +1689,6 @@ Points Пункты Picas Пики Columns Столбцы - -# Placeholders, tooltips, status and keyboard hints {name} options Параметры: {name} Align {what} Выровнять: {what} Untagged {mode} Без профиля ({mode}) @@ -1751,8 +1865,6 @@ Current Текущий {added}, {removed} subtracted {added}, вычтено {removed} Filter: Фильтр: - -# Transform / warp overlays Free Transform Свободное трансформирование Free Transform a Copy Свободное трансформирование копии Transform Трансформирование @@ -1805,8 +1917,6 @@ Rotate Fixed Фиксированный поворот Adaptive Wide Angle isn't open Адаптивный широкий угол не открыт Liquify is not open Пластичность не открыта - -# Vanishing Point Create Plane Создать плоскость Edit Plane Править плоскость Select Plane Выбрать плоскость @@ -1820,8 +1930,6 @@ Blend Смешивание Brush Size Размер кисти Heal Color Цвет восстановления - -# Misc dialog strings Mask Маска Channel Канал Rectangle Прямоугольник @@ -1845,8 +1953,6 @@ Add to Добавить к Subtract from Вычесть из Intersect with Пересечь с - -# Blend modes Pass Through Пропустить Dissolve Растворение Darken Замена тёмным @@ -1868,19 +1974,13 @@ Difference Разница Exclusion Исключение Luminosity Яркость - -# Saved views and other bits Capture Comp Зафиксировать вариант No Layer Comps Нет вариантов слоя - -# Plurals @plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов @plural {n} Layer|{n} Layers {n} слой|{n} слоя|{n} слоёв @plural {n} layer|{n} layers {n} слой|{n} слоя|{n} слоёв @plural Group · {n} layer|Group · {n} layers Группа · {n} слой|Группа · {n} слоя|Группа · {n} слоёв @plural {n} sample|{n} samples {n} образец|{n} образца|{n} образцов - -# Remaining menu strings (second pass) 180° 180° 32-bit Preview Options… Параметры предпросмотра 32 бит… 90° Clockwise 90° по часовой стрелке @@ -1977,6 +2077,7 @@ Merge Group Объединить группу Merge Layers Объединить слои Merge Shape Components Объединить компоненты фигуры + Path operations Операции с контурами Merge Visible Свести видимые Stamp Visible Штамп видимых Select Layer Above Выбрать слой выше @@ -2049,8 +2150,6 @@ Video Frames to Layers… Видеокадры в слои… WIA Support… Поддержка WIA… Working CMY Plate Рабочая голубая форма - -# tl! literals (third pass) Absolute Абсолютный Action Операция Adaptive Адаптивный @@ -2277,7 +2376,6 @@ Outside Снаружи Tetrahedral Тетраэдрическая Texture Each Tip Текстура на каждый отпечаток -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Редактор градиентов Gradient picker Выбор градиента Save As Preset Сохранить как набор @@ -2305,8 +2403,6 @@ Whites Белые Yellow Жёлтый Yellow · Blue Жёлтый · Синий - -# Preference dialog: generated setting labels and dropdown choices Adobe Adobe Adobe rgb Adobe RGB Allow agent control Разрешить управление агентами @@ -2531,7 +2627,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: Сетка: -# Rendering mode and recovery Rendering Mode Режим отрисовки Automatic (recommended) Автоматически (рекомендуется) CPU / Compatibility CPU / Совместимость @@ -2558,7 +2653,6 @@ Tone Histogram Тональная гистограмма Show Selected Region Показать выделенную область cameraRaw Light Свет -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Фильтр Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) Aqua Голубой @@ -2589,14 +2683,10 @@ cameraRaw Roughness Неровность Scale or rotate the moved area, then press {key} Масштабируйте или поверните перемещённую область, затем нажмите {key} Now drag the selection onto another area Теперь перетащите выделение на другую область Edit Type Редактировать текст - - New Style… Новый стиль… Style {name} saved Стиль {name} сохранён - Create new style (from the selected type) Создать стиль (из выделенного текста) Create new style from the selected layer Создать стиль из выделенного слоя - Make Default Сделать по умолчанию Reset to Default Сбросить к значению по умолчанию Full-resolution preview unavailable Полноразмерный предпросмотр недоступен @@ -2625,9 +2715,6 @@ cameraRaw Roughness Неровность Magnetic Lasso Tool Магнитное лассо Frequency Частота Use tablet pressure to change pen width Использовать нажим планшета для изменения ширины пера - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About О программе Contributors Участники Models Модели @@ -2658,8 +2745,6 @@ cameraRaw Roughness Неровность % of all commits % от всех коммитов Lines +/− Строки +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, коммиты: {commits}, строки +{added} / −{deleted} (Δ {delta}), двоичные файлы +{binAdded} / −{binDeleted}, {first} – {last} - -# Swatches panel Create new swatch Создать новый образец New Swatch… Новый образец… New Swatch Group… Новая группа образцов… @@ -2669,13 +2754,12 @@ cameraRaw Roughness Неровность Export Swatches for Exchange… Экспортировать образцы для обмена… Reset Swatches Восстановить образцы Add to Swatches Добавить в образцы -# Layer Style > Blending Options (Advanced Blending) Blend If: Изображение если: + Current Layer: Текущий слой: + Underlying Layer: Нижележащий слой: Channels: Каналы: Knockout Просвечивание Apply At Canvas Bounds Применить эффект на границах холста - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Палитра цветов (Цвет текста) Color Picker ({name}) Палитра цветов ({name}) Color Picker (Fill Color) Палитра цветов (Цвет заливки) @@ -2692,8 +2776,6 @@ cameraRaw Roughness Неровность Show/Hide Panels Показать/скрыть панели Could not open Не удалось открыть {path}: the path is not valid Unicode; rename the file and open it again. {path}: путь не является допустимым Unicode; переименуйте файл и откройте его снова. - -# Fill dialog, layer style contours and Image Size Contents: Содержимое: Custom Pattern: Свой узор: Foreground Color Основной цвет @@ -2721,14 +2803,12 @@ cameraRaw Roughness Неровность Rounded Steps Скруглённые ступени Sawtooth 1 Пила 1 Linear Линейный - Red Eye Tool Инструмент «Красные глаза» Pupil Size Размер зрачка Darken Amount Степень затемнения Click a red pupil to neutralize it Щёлкните по красному зрачку, чтобы убрать блик open source открытый исходный код …and {n} more …и ещё {n} - Eyedroppers: Пипетки: Set Black Point Задать точку чёрного Set Neutral Gray Point Задать нейтральную серую точку @@ -2738,8 +2818,6 @@ cameraRaw Roughness Неровность Black Matte На чёрном фоне White Matte На белом фоне Quick Mask Быстрая маска - -# Layer colour labels. Seafoam Морская пена Indigo Индиго Fuchsia Фуксия @@ -2759,21 +2837,15 @@ fontWeight Bold Жирный fontWeight ExtraBold Сверхжирный fontWeight Black Сверхжирный (Black) fontWeight Italic Курсив - -# Pattern Stamp tool Pattern Stamp Tool Инструмент узорный штамп Impressionist Импрессионист Rotate View Tool Инструмент поворота вида Reset View Сбросить вид Drag around the centre to rotate the view · Shift constrains to 15° Перетащите вокруг центра, чтобы повернуть вид · Shift фиксирует шаг 15° - -# Painting symmetry presets. Dual Axis Две оси Diagonal Диагональная Transform Symmetry Трансформировать симметрию Set painting symmetry options Настроить параметры симметричного рисования - -# Eyedropper Sample Size and Sample (#1649). Point Sample Точечная выборка 3 by 3 Average Среднее 3 × 3 5 by 5 Average Среднее 5 × 5 @@ -2786,15 +2858,12 @@ fontWeight Italic Курсив Current & Below No Adjustments Текущий и ниже без коррекции Show Sampling Ring Показать кольцо выборки Click to sample the foreground colour · {key}-click for background Клик — взять основной цвет · {key}-клик — фоновый цвет - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет Color to Alpha… Цвет в альфа-канал… Transparency Threshold Порог прозрачности Opacity Threshold Порог непрозрачности PhotoCraft — Screen Color Picker PhotoCraft — Пипетка цвета экрана - Rasterize svg groups deeper than Растеризовать группы SVG глубже этого уровня 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 отключает этот порог; группы SVG с вложенностью более 100 уровней всегда растрируются. Rule of Thirds Правило третей @@ -2808,8 +2877,6 @@ fontWeight Italic Курсив Cycle Orientation Сменить ориентацию Set the overlay options for the Crop tool Задать параметры перекрытия для инструмента «Рамка» 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+щелчок перехватило окружение рабочего стола: оно перемещает окна через Alt+перетаскивание. Назначьте для перемещения окон клавишу Super или зафиксируйте Alt в меню Окно › Клавиши-модификаторы. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Палитра цветов (цвет стиля слоя) Auto Hide Notifications Автоскрытие уведомлений Notification Duration (seconds) Длительность уведомления (секунды) @@ -2879,8 +2946,6 @@ fontWeight Italic Курсив Quick Selection found nothing there Быстрое выделение ничего здесь не нашло Object Selection found nothing there — try a tighter rectangle Выделение объектов ничего здесь не нашло — попробуйте более узкий прямоугольник No subject found — try a rectangle around the object Объект не найден — попробуйте прямоугольник вокруг объекта - -# Layers-panel trash confirmation Delete layer ‘{name}’? Удалить слой «{name}»? Delete {count} layers? {names} Удалить слои ({count})? {names} Delete layer? Удалить слой? @@ -2890,6 +2955,9 @@ fontWeight Italic Курсив Invalid delete command Недопустимая команда удаления Layer deleted, but confirmation preference could not be saved: {error} Слой удалён, но не удалось сохранить настройку подтверждения: {error} Add a layer style Добавить стиль слоя + Move effect up Переместить эффект вверх + Move effect down Переместить эффект вниз + Delete effect Удалить эффект Add a mask (from the selection; {key} inverts) Добавить маску (из выделения; {key} инвертирует) Add an adjustment Добавить коррекцию Add to selection ({key}) Добавить к выделению ({key}) @@ -2964,7 +3032,6 @@ fontWeight Italic Курсив Appearance Settings… Настройки оформления… Add to favorites Добавить в избранное Remove from favorites Удалить из избранного -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Производитель cameraData Model Модель cameraData Date Taken Дата съёмки @@ -2974,7 +3041,6 @@ cameraData ISO Speed Светочувствительность ISO cameraData Focal Length Фокусное расстояние cameraData Focal Length (35 mm) Фокусное расстояние (35 мм) cameraData Lens Объектив -# Shared panel docking Activate Panel Активировать панель Change Panel Group Изменить группу панелей Change Panel Layout Изменить расположение панелей @@ -3014,7 +3080,6 @@ cameraData Lens Объектив Rasterize Layer Style Растрировать стиль слоя Search settings… Поиск настроек… No matching settings Нет подходящих настроек -# Edit › Content-Aware Fill workspace Sampling Brush Tool Кисть области выборки Sampling Area Overlay Наложение области выборки Show Sampling Area Показать область выборки @@ -3032,7 +3097,6 @@ cameraData Lens Объектив Output To Вывод в Duplicate Layer Дубликат слоя Contract Сжать - Animation Options Параметры анимации Entire Document Весь документ Filter Options Параметры фильтра @@ -3050,8 +3114,6 @@ cameraData Lens Объектив 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}-клик: все слои) @@ -3084,7 +3146,6 @@ curves Smooth Сгладить Fix the red plane first: its corners must form a convex quad Сначала исправьте красную плоскость: ее углы должны образовывать выпуклый четырехугольник Alt-click a plane to set the clone source Щёлкните плоскость с Alt, чтобы задать источник клонирования Too many clone strokes: apply Vanishing Point first Слишком много мазков клонирования: сначала примените «Точку схода» -# Select and Mask view modes (#1754) Onion Skin Калька Marching Ants Бегущие муравьи On Black На черном @@ -3093,7 +3154,6 @@ curves Smooth Сгладить Show Edge Показать край Show Original Показать исходное F cycles the views; hold X to see the image F переключает виды; удерживайте X, чтобы увидеть изображение -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Кисть уточнения края (R) Expand Detection Area Расширить область обнаружения Restore Original Edge Восстановить исходный край @@ -3107,19 +3167,13 @@ curves Smooth Сгладить Enter a name for the crop preset Введите имя набора параметров кадрирования There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения Presentation Представление - -# Color Sampler (added with the i18n coverage repair) 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… Параметры истории… -# History panel › History Options… (#2966) Allow Non-Linear History Разрешить нелинейную историю Make Layer Visibility Changes Undoable Разрешить отмену изменений видимости слоёв -# Print dialog (#2697) Emulsion Down Эмульсией вниз Functions Функции Print Resolution: Разрешение печати: @@ -3131,7 +3185,6 @@ curves Smooth Сгладить Apply mask to layer before removing? Применить маску к слою перед удалением? The layer or its mask changed. Drag the mask onto the Trash again. Слой или его маска изменились. Перетащите маску в корзину ещё раз. Delete the vector mask? Удалить векторную маску? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Экспортирован {n} образец в {file}|Экспортированы {n} образца в {file}|Экспортировано {n} образцов в {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} Импортирована {n} кисть из {group}|Импортированы {n} кисти из {group}|Импортировано {n} кистей из {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Импортирован {n} образец из {file}|Импортированы {n} образца из {file}|Импортировано {n} образцов из {file} @@ -3146,7 +3199,6 @@ curves Smooth Сгладить @plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (и ещё {n} предупреждение о наборах кистей)|{warning} (и ещё {n} предупреждения о наборах кистей)|{warning} (и ещё {n} предупреждений о наборах кистей) @plural {what} with {n} warning|{what} with {n} warnings {what}: {n} предупреждение|{what}: {n} предупреждения|{what}: {n} предупреждений -# View › Guides › New Guide… › Color Light Blue Светло-синий Light Red Светло-красный Medium Blue Средне-синий @@ -3167,8 +3219,6 @@ curves Smooth Сгладить Clear Brush Controls Очистить параметры кисти Reset All Locked Settings Сбросить все заблокированные параметры Copy Texture to Other Tools Скопировать текстуру в другие инструменты - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Загрузить операции… Save Actions… Сохранить операции… New Action… Новая операция… @@ -3180,10 +3230,11 @@ curves Smooth Сгладить @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} шаг пока не имеет эквивалента в PhotoCraft|{n} шага пока не имеют эквивалента в PhotoCraft|{n} шагов пока не имеют эквивалента в PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранены {n} операции в {file}|Сохранено {n} операций в {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущены {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop - +@plural {n} action file loaded|{n} action files loaded Загружен {n} файл операций|Загружены {n} файла операций|Загружено {n} файлов операций + {set} from {file} replaced the one from {earlier} Набор {set} из {file} заменил набор из {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Не удалось загрузить {n} файл: {errors}|Не удалось загрузить {n} файла: {errors}|Не удалось загрузить {n} файлов: {errors} File Naming Именование файлов Starting serial Начальный порядковый номер -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставить остановку… Save Set… Сохранить набор… Message: Сообщение: @@ -3195,11 +3246,8 @@ curves Smooth Сгладить @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций Background (Default) Фон (по умолчанию) Content-Aware Fill Заливка с учётом содержимого -# Batch / Image Processor Include All Subfolders Включить все вложенные папки Auto Add/Delete Автодобавление/удаление - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не включайте также зеркальное отражение в параметрах печати: два отражения отменяют друг друга. Print Settings… Параметры печати… Remember to turn off the printer's own colour management in Print Settings… Не забудьте отключить управление цветом самого принтера в параметрах печати… @@ -3217,16 +3265,11 @@ curves Smooth Сгладить Could not write {name}: {error} Не удалось записать {name}: {error} Kept {name}: written by another application {name} сохранён без изменений: записан другим приложением Shadow Tint Оттенок теней -# 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 Инвертировать выделение Pro (Light) Pro (светлая) Pro (Light Gray) Pro (светло-серая) - -# Measurement Log column headers (analysis_cmds::COLUMNS) Label Метка Date and Time Дата и время Scale Units Единицы масштаба @@ -3240,6 +3283,7 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина + Memory usage percent Использование памяти (%) Show Favorite Fonts Показать избранные шрифты No favorite fonts yet: click the star beside a font to add it. Избранных шрифтов пока нет: нажмите на звёздочку рядом со шрифтом, чтобы добавить его. Favorite fonts Избранные шрифты @@ -3267,45 +3311,29 @@ curves Smooth Сгладить Slab Serif С брусковыми засечками TrueType font Шрифт TrueType Variable font Вариативный шрифт -# Bevel & Emboss shading controls. Highlight Mode Режим светлых тонов Highlight Color Цвет светлых тонов Highlight Opacity Непрозрачность светлых тонов Shadow Mode Режим теней Shadow Color Цвет теней Shadow Opacity Непрозрачность теней -# PhotoCraft release checks. Check for Updates… Проверить обновления… - PhotoCraft Updates Обновления PhotoCraft - Checking for updates… Проверка обновлений… Open the release page to choose an installer for your system. Откройте страницу выпуска и выберите установщик для своей системы. - Open Download Page Открыть страницу загрузки - PhotoCraft is up to date. Установлена последняя версия PhotoCraft. - Could not check for updates. Не удалось проверить обновления. - Updates have not been checked yet. Обновления ещё не проверялись. - Check Again Проверить снова Automatically Check for Updates Автоматически проверять обновления - Installed version: {version} Установленная версия: {version} - PhotoCraft {version} is available. Доступна версия PhotoCraft {version}. -# Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Использовать встроенный профиль ({profile}) Convert to the working space ({profile}) Преобразовать в рабочее пространство ({profile}) Remember this choice for future images Запомнить этот выбор для следующих изображений Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Снова включить вопрос можно параметром Ask On Mismatch в Редактирование › Настройки цвета. Button Mode Режим кнопок Button Mode On Режим кнопок включён - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Сделать интерфейс темнее Lighten Interface Сделать интерфейс светлее -# Bevel & Emboss Technique. Technique Метод 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 + Save Full-Size Composite in .pcraft Files Сохранять сведённое изображение в полном размере в файлах .pcraft Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие Bicubic Automatic Бикубическая, автоматически @@ -3331,8 +3359,6 @@ curves Smooth Сгладить {set} (modified) {set} (изменён) Random Случайно Show file name in title Показывать имя файла в заголовке - -# Image › Mode › Bitmap (#5559) 50% Threshold Порог 50% Pattern Dither Регулярный дизеринг Diffusion Dither Диффузионный дизеринг @@ -3352,10 +3378,8 @@ curves Smooth Сгладить Undo Changes Отменить изменения {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) 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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Слоям нужно {needed} памяти GPU; бюджет оставляет им {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта, не больше 3/8 её памяти. «Автоматически» следует параметру «Память», в пределах четверти памяти этого компьютера и 3/8 памяти видеокарты, и не учитывает документ на видеокарте с известным объёмом памяти. Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». GPU Memory Full Память GPU заполнена @@ -3365,16 +3389,11 @@ curves Smooth Сгладить 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 = автоматически) + Scratch Size Limit (MB, 0 = No Limit) Предел размера рабочего диска (МБ, 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. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. - - -# 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 бит @@ -3837,7 +3856,6 @@ curves Smooth Сгладить Hearts Сердца Heptagon Семиугольник Hexagon Шестиугольник - Hidden Скрыто Hide Grid Скрыть сетку Hide Others Скрыть остальные Hide the layer's effects (⌥-click: all layers) Скрыть эффекты слоя (⌥+щелчок: все слои) @@ -3883,8 +3901,6 @@ curves Smooth Сгладить 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 Первая кисть @@ -3900,18 +3916,13 @@ curves Smooth Сгладить Use Использовать In Front Спереди Behind Сзади - -# DDS Options (#1327) DDS Options Параметры DDS 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Содержимое AI/PDF/EPS будет редактироваться как SVG. Сохранение заменит встроенный источник; особенности исходного формата могут измениться. # Styles panel: Import / Export Styles (.asl) Import Styles… Импортировать стили… @@ -3920,7 +3931,6 @@ curves Smooth Сгладить {file} is not a style library (.asl) {file} не является библиотекой стилей (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Импортирован {n} стиль из {file}|Импортировано {n} стиля из {file}|Импортировано {n} стилей из {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Экспортирован {n} стиль в {file}|Экспортировано {n} стиля в {file}|Экспортировано {n} стилей в {file} -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Сначала подтвердите или отмените открытый диалог шага операции Pro Medium Gray Средне-серая Pro Solarized Solarized @@ -4495,7 +4505,6 @@ curves Smooth Сгладить Type Options Параметры текста Unaltered Без изменений Undefined Areas Неопределённые области - Underlying Layer: Нижележащий слой: Underpainting Подмалёвок Underwater Под водой Ungroup Разгруппировать @@ -4605,7 +4614,6 @@ curves Smooth Сгладить ↑↓ 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 Обвести… @@ -4651,14 +4659,11 @@ 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Куб цветового тона Brightness Cube Куб яркости Color Wheel Цветовой круг @@ -4674,7 +4679,8 @@ shape Appearance Оформление Grayscale Ramp Шкала градаций серого Current Colors Текущие цвета Make Ramp Web Safe Сделать шкалу безопасной для web -# Art History Brush (#213). + The path was kept as the Work Path Контур сохранён как рабочий контур + The path was discarded Контур удалён Art History Brush Tool Инструмент «Кисть истории искусств» Tight Short Плотный короткий Tight Medium Плотный средний @@ -4688,3 +4694,26 @@ shape Appearance Оформление Font size presets Наборы размеров шрифта Leading presets Наборы интерлиньяжа Tracking presets Наборы трекинга + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Недопустимое числовое значение. Требуется целое число от {min}% до {max}%. Вставлено предыдущее значение. + Color Aware С учетом цвета + Decontaminate Colors Очистить цвета + Edge Detection Обнаружение краев + Global Refinements Глобальное уточнение + High Quality Preview Высококачественный просмотр + Object Aware С учетом объектов + Real-Time Refinement Уточнение в реальном времени + Refine Edge Brush Tool Инструмент «Кисть уточнения края» + Refine Hair Уточнить волосы + Refine Mode Режим уточнения + Remember Settings Запомнить параметры + View Mode Режим просмотра + Object Aware needs a segmentation model, which isn't available yet Для режима «С учетом объектов» нужна модель сегментации, которая пока недоступна + Refine Hair needs hair detection, which isn't available yet Для уточнения волос нужно распознавание волос, которое пока недоступно + Select and Mask was closed: its document is no longer the active one Окно «Выделение и маска» закрыто: его документ больше не активен + Select and Mask was closed: another layer was selected Окно «Выделение и маска» закрыто: выбран другой слой + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Недавно использованные цвета + Stop Color Цвет контрольной точки + Shapes can't use patterns yet Фигуры пока не могут использовать узоры + Edit Brush… Изменить кисть… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a81eb06da2..2ebc6c2e70 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -23,6 +23,136 @@ # out of noun agreement ("Broj: {n}", "slojeva: {n}") so every number reads correctly. # Guide layout dialog + +# Select and Mask dialog labels + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). + +# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) + +# Camera Raw scopes and the Patch tool + +# Warp grid presets (#388) + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Camera Raw: contextual controls and window title + +# Counted status messages and labels (Serbian: one | few | many) + +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) + +# Path, channel, layer style, status and System Info labels the Czech catalog added + +# Command-id overrides where the plain key is also a dialog button + +# Layer colour labels distinct from channel and ink names + + +# Character, Paragraph and Glyphs panels + + +# Camera Raw labels + +# Keyboard Shortcuts and Menus editor + +# Color Settings dialog + + +# Camera Raw chrome, tone, curve, color mixer, grading, detail and effects + +# New Document, Home and Image Size + +# Warp, Liquify, Perspective and Puppet + +# Contextual labels + +# Added on merge with main (spelling, Camera Raw settings files, printing, Object Finder, brush +# controls, actions, gradient presets, Pen Auto Add/Delete) + +# Native Windows printing qualification +# Custom print paper (#3820) + +# ICO dimensions + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + +# Channel Options and New Spot Channel (#3761) + +# Bevel & Emboss shading controls. +# Edit › Content-Aware Fill workspace +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + + +# Image › Mode › Bitmap (#5559) + +# Layers panel options + +# Smart Filter editors +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# PhotoCraft release checks. +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). +# Smart Filter row actions (#1265). + Rows Redovi Number Broj Margin Margina @@ -39,7 +169,6 @@ 5 Columns, 3 Rows 5 kolona, 3 reda 8 Columns 8 kolona 12 Columns 12 kolona - File Datoteka Edit Uređivanje Image Slika @@ -427,6 +556,7 @@ Intersect Shape Areas Presek oblasti oblika Exclude Overlapping Shapes Isključi preklapajuće oblike Merge Shape Components Spoji komponente oblika + Path operations Operacije s putanjama Align Poravnaj Top Edges Gornje ivice Vertical Centers Vertikalni centri @@ -506,7 +636,6 @@ Color Range… Opseg boja… Focus Area… Oblast fokusa… Select and Mask… Izaberi i maskiraj… -# Select and Mask dialog labels Smart Radius Pametni poluprečnik Shift Edge Pomeri ivicu Decontaminate Očisti boje @@ -813,6 +942,9 @@ Intersect Mask with Selection Presek maske i selekcije Add Stop Dodaj tačku Add a layer style Dodaj stil sloja + Move effect up Pomeri efekat nagore + Move effect down Pomeri efekat nadole + Delete effect Obriši efekat Add a mask (from the selection; {key} inverts) Dodaj masku (iz selekcije; {key} invertuje) Add an adjustment Dodaj korekciju Add disk Dodaj disk @@ -1024,8 +1156,6 @@ Delete current channel Obriši tekući kanal Delete current state Obriši tekuće stanje Delete effect layer Obriši sloj efekta - -# Layers-panel trash confirmation Delete layer ‘{name}’? Obrisati sloj „{name}“? Delete {count} layers? {names} Obrisati izabrane slojeve ({count})? {names} Delete layer? Obrisati sloj? @@ -1034,7 +1164,6 @@ Missing delete command Nedostaje komanda za brisanje Invalid delete command Neispravna komanda za brisanje Layer deleted, but confirmation preference could not be saved: {error} Sloj je obrisan, ali podešavanje potvrde nije moglo da se sačuva: {error} - Delete layer Obriši sloj Delete layer comp Obriši kompoziciju slojeva Delete note Obriši belešku @@ -2123,7 +2252,6 @@ Align with layer Poravnaj sa slojem Reset Alignment Resetuj poravnanje Centre the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Uređivač gradijenta Gradient picker Birač gradijenta Save As Preset Sačuvaj kao šablon @@ -2641,8 +2769,6 @@ Expand group (⌥-click: all groups) Proširi grupu (⌥-klik: sve grupe) Hide the layer's effects (⌥-click: all layers) Sakrij efekte sloja (⌥-klik: svi slojevi) Show the layer's effects (⌥-click: all layers) Prikaži efekte sloja (⌥-klik: svi slojevi) - -# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). New from Clipboard Novo iz ostave Free Transform a Copy Slobodna transformacija kopije Transform Again on a Copy Ponovi transformaciju na kopiji @@ -2652,8 +2778,6 @@ Select Bottom Layer Izaberi donji sloj Add Layer Above to Selection Dodaj sloj iznad u izbor Add Layer Below to Selection Dodaj sloj ispod u izbor - -# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) 8 bit 8 bita Integer Celobrojno 16 bit 16 bita @@ -2689,8 +2813,6 @@ GPU Backend GPU pozadinski sistem Details Detalji Rendering mode Režim renderovanja - -# Camera Raw scopes and the Patch tool Shadow Clipping Warning (U) Upozorenje o odsecanju senki (U) Highlight Clipping Warning (O) Upozorenje o odsecanju svetla (O) Show Lab Color Readouts Prikaži Lab očitavanja boja @@ -2715,20 +2837,15 @@ cameraRaw Blending Stapanje Transform On Drop Transformiši pri otpuštanju Scale or rotate the moved area, then press {key} Skalirajte ili rotirajte pomerenu oblast, a zatim pritisnite {key} Now drag the selection onto another area Sada prevucite selekciju na drugu oblast - -# Warp grid presets (#388) 3 x 3 3 x 3 4 x 4 4 x 4 5 x 5 5 x 5 Grid: Mreža: Edit Type Uredi tekst - New Style… Novi stil… Style {name} saved Stil {name} je sačuvan - Create new style (from the selected type) Napravi novi stil (od izabranog teksta) Create new style from the selected layer Napravi novi stil od izabranog sloja - Make Default Postavi kao podrazumevano Reset to Default Vrati na podrazumevano Full-resolution preview unavailable Pregled u punoj rezoluciji nije dostupan @@ -2757,8 +2874,6 @@ cameraRaw Blending Stapanje Magnetic Lasso Tool Alatka magnetni laso Frequency Frekvencija Use tablet pressure to change pen width Koristi pritisak na tabletu za promenu širine pera - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About O programu Contributors Saradnici Models Modeli @@ -2789,8 +2904,6 @@ cameraRaw Blending Stapanje % of all commits % svih komitova Lines +/− Linije +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR-ova: {prs}, komitova: {commits}, linija: +{added} / −{deleted} (Δ {delta}), binarnih resursa: +{binAdded} / −{binDeleted}, {first} – {last} - -# Swatches panel Create new swatch Napravi novi uzorak boje New Swatch… Novi uzorak boje… New Swatch Group… Nova grupa uzoraka boja… @@ -2800,13 +2913,10 @@ cameraRaw Blending Stapanje Export Swatches for Exchange… Izvezi uzorke boja za razmenu… Reset Swatches Resetuj uzorke boja Add to Swatches Dodaj u uzorke boja -# Layer Style > Blending Options (Advanced Blending) Blend If: Stapaj ako: Channels: Kanali: Knockout Izbijanje Apply At Canvas Bounds Primeni na granicama platna - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Birač boja (boja teksta) Color Picker ({name}) Birač boja ({name}) Color Picker (Fill Color) Birač boja (boja popune) @@ -2822,8 +2932,6 @@ cameraRaw Blending Stapanje Show/Hide Panels Prikaži/sakrij panele Could not open Otvaranje nije uspelo {path}: the path is not valid Unicode; rename the file and open it again. {path}: putanja nije ispravan Unicode; preimenujte datoteku i ponovo je otvorite. - -# Fill dialog, layer style contours and Image Size Contents: Sadržaj: Custom Pattern: Prilagođena šara: Foreground Color Boja prednjeg plana @@ -2850,14 +2958,12 @@ cameraRaw Blending Stapanje Rolling Slope - Descending Talasasta kosina - opadajuća Rounded Steps Zaobljeni stepenici Sawtooth 1 Testera 1 - Red Eye Tool Alatka za crvene oči Pupil Size Veličina zenice Darken Amount Količina zatamnjenja Click a red pupil to neutralize it Kliknite crvenu zenicu da je neutrališete open source otvoreni kod …and {n} more …i još {n} - Eyedroppers: Pipete: Set Black Point Postavi crnu tačku Set Neutral Gray Point Postavi tačku neutralne sive @@ -2866,8 +2972,6 @@ cameraRaw Blending Stapanje Selection Preview: Pregled selekcije: Black Matte Crna podloga White Matte Bela podloga - -# Layer colour labels. Seafoam Morska pena Indigo Indigo Fuchsia Fuksija @@ -2886,20 +2990,14 @@ fontWeight Bold Podebljano fontWeight ExtraBold Ekstra podebljano fontWeight Black Crno fontWeight Italic Kurziv - -# Pattern Stamp tool Pattern Stamp Tool Alatka pečat za šaru Impressionist Impresionistički Rotate View Tool Alatka za rotiranje prikaza Reset View Resetuj prikaz Drag around the centre to rotate the view · Shift constrains to 15° Prevlačite oko centra da rotirate prikaz · Shift ograničava na korake od 15° - -# Painting symmetry presets. Dual Axis Dvostruka osa Diagonal Dijagonala Transform Symmetry Transformiši simetriju - -# Eyedropper Sample Size and Sample (#1649). Point Sample Uzorak tačke 3 by 3 Average Prosek 3 x 3 5 by 5 Average Prosek 5 x 5 @@ -2911,15 +3009,12 @@ fontWeight Italic Kurziv All Layers No Adjustments Svi slojevi bez korekcija Current & Below No Adjustments Tekući i ispod bez korekcija Show Sampling Ring Prikaži prsten uzorkovanja - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju Color to Alpha… Boja u alfu… Transparency Threshold Prag providnosti Opacity Threshold Prag neprozirnosti PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana - Rasterize svg groups deeper than Rasterizuj SVG grupe dublje od 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 isključuje ovaj prag; SVG grupe ugnežđene dublje od 100 nivoa uvek se rasterizuju. Rule of Thirds Pravilo trećina @@ -2933,8 +3028,6 @@ fontWeight Italic Kurziv Cycle Orientation Smenjuj orijentaciju Set the overlay options for the Crop tool Podesi opcije preklopa za alatku za opsecanje 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-klik je preuzelo radno okruženje, koje pomera prozore Alt-prevlačenjem. Postavite taster za pomeranje prozora u njemu na Super ili zaključajte Alt u Prozor › Modifikatorski tasteri. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Birač boja (boja stila sloja) Auto Hide Notifications Automatski sakrij obaveštenja Notification Duration (seconds) Trajanje obaveštenja (sekunde) @@ -3011,7 +3104,6 @@ fontWeight Italic Kurziv Appearance Settings… Podešavanja izgleda… Add to favorites Dodaj u omiljene Remove from favorites Ukloni iz omiljenih -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Proizvođač cameraData Model Model cameraData Date Taken Datum snimanja @@ -3021,7 +3113,6 @@ cameraData ISO Speed ISO osetljivost cameraData Focal Length Žižna daljina cameraData Focal Length (35 mm) Žižna daljina (35 mm) cameraData Lens Objektiv -# Shared panel docking Activate Panel Aktiviraj panel Change Panel Group Promeni grupu panela Change Panel Layout Promeni raspored panela @@ -3090,7 +3181,6 @@ curves Smooth Izgladi Fix the red plane first: its corners must form a convex quad Prvo ispravite crvenu ravan: njeni uglovi moraju činiti konveksan četvorougao Alt-click a plane to set the clone source Alt-kliknite ravan da postavite izvor kloniranja Too many clone strokes: apply Vanishing Point first Previše poteza kloniranja: prvo primenite tačku nestajanja -# Select and Mask view modes (#1754) Onion Skin Prozirni prikaz Marching Ants Pokretne crtice On Black Na crnoj @@ -3099,19 +3189,15 @@ curves Smooth Izgladi Show Edge Prikaži ivicu Show Original Prikaži original F cycles the views; hold X to see the image F smenjuje prikaze; držite X da vidite sliku -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Četkica za doterivanje ivice (R) Expand Detection Area Proširi oblast detekcije Restore Original Edge Vrati originalnu ivicu Paint over hair or fur on the image; Alt paints the other mode Slikajte preko kose ili krzna na slici; Alt slika u drugom režimu Clear Brush Strokes Obriši poteze četkicom Show rulers Prikaži lenjire - -# Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Kliknite da postavite · Alt-klik ili prevlačenje van platna da uklonite No document open Nijedan dokument nije otvoren {n} of {max} sample points {n} od {max} tačaka uzorkovanja -# Print dialog (#2697) Emulsion Down Emulzija nadole Functions Funkcije Print Resolution: Rezolucija štampe: @@ -3123,7 +3209,6 @@ curves Smooth Izgladi Apply mask to layer before removing? Primeniti masku na sloj pre uklanjanja? The layer or its mask changed. Drag the mask onto the Trash again. Sloj ili njegova maska su se promenili. Ponovo prevucite masku u kantu. Delete the vector mask? Obrisati vektorsku masku? -# View › Guides › New Guide… › Color Light Blue Svetloplava Light Red Svetlocrvena Medium Blue Srednje plava @@ -3137,11 +3222,7 @@ curves Smooth Izgladi 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) - -# Camera Raw: contextual controls and window title cameraRaw Red Crvena cameraRaw Orange Narandžasta cameraRaw Yellow Žuta @@ -3151,8 +3232,6 @@ cameraRaw Blue Plava cameraRaw Purple Ljubičasta cameraRaw Magenta Magenta cameraRaw Temperature Temperatura - -# Counted status messages and labels (Serbian: one | few | many) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Izvezen je {n} uzorak boje u {file}|Izvezena su {n} uzorka boja u {file}|Izvezeno je {n} uzoraka boja u {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} Uvezena je {n} četkica iz {group}|Uvezene su {n} četkice iz {group}|Uvezeno je {n} četkica iz {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Uvezen je {n} uzorak boje iz {file}|Uvezena su {n} uzorka boja iz {file}|Uvezeno je {n} uzoraka boja iz {file} @@ -3167,8 +3246,6 @@ cameraRaw Temperature Temperatura @plural {n} step|{n} steps {n} korak|{n} koraka|{n} koraka @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+ još {n} upozorenje za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica) @plural {what} with {n} warning|{what} with {n} warnings {what} sa {n} upozorenjem|{what} sa {n} upozorenja|{what} sa {n} upozorenja - -# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) @id edit.stroke Ocrtaj… @id edit.transform.scale Skaliranje cameraRaw Luminance Luminansa @@ -3949,8 +4026,6 @@ layerLabel No Color Bez boje Set Colors Postavi boje Switch Foreground and Background Colors Zameni boje prednjeg plana i pozadine Target Cilj - -# Path, channel, layer style, status and System Info labels the Czech catalog added Make Work Path… Napravi radnu putanju… Create Vector Mask Napravi vektorsku masku Make Selection… Napravi selekciju… @@ -3993,7 +4068,7 @@ layerLabel No Color Bez boje Layer Mask Hides Effects Maska sloja skriva efekte Vector Mask Hides Effects Vektorska maska skriva efekte General Blending Opšte stapanje - This Layer: Ovaj sloj: + Current Layer: Tekući sloj: Underlying Layer: Sloj ispod: Nothing to paste: the clipboard holds no image Nema šta da se nalepi: u ostavi nema slike Preferences saved Podešavanja su sačuvana @@ -4099,20 +4174,13 @@ navigator Zoom Uvećanje {index} of {total} {index} od {total} {shortcut} assigned. {shortcut} je dodeljena. {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} već koristi {commands} i biće uklonjena odatle kada kliknete U redu. - -# Command-id overrides where the plain key is also a dialog button @id file.close Zatvori @id layer.layerMask.apply Primeni @id edit.clear Obriši - -# Layer colour labels distinct from channel and ink names layerLabel Red Crvena layerLabel Magenta Magenta - Drag: draw freehand · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · Otpuštanje na početnoj tački: zatvori Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · {commit} potvrđuje · Esc otkazuje - -# Character, Paragraph and Glyphs panels Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: metrički, optički ili vrednost u 1/1000 em (Alt+←/→ kod kursora) Basic Latin Osnovna latinica Latin 1 Latinica 1 @@ -4124,14 +4192,9 @@ layerLabel Magenta Magenta Currency Valute Math Symbols Matematički simboli Arrows Strelice - Save Path Sačuvaj putanju - -# Camera Raw labels cameraRaw Highlights Svetla cameraRaw Shadows Senke - -# Keyboard Shortcuts and Menus editor All shortcuts reset to Photoshop defaults. Sve prečice su vraćene na podrazumevane vrednosti Photoshop-a. Temporary Privremeno none nijedna @@ -4142,8 +4205,6 @@ cameraRaw Shadows Senke blue plava violet ljubičasta gray siva - -# Color Settings dialog Working Rgb Radni RGB Working Cmyk Radni CMYK Working Gray Radna siva @@ -4166,10 +4227,7 @@ cameraRaw Shadows Senke Coated-cmyk Premazani CMYK Sgray sGray Gray-gamma-2.2 Siva gama 2.2 - Import Photoshop brushes (.abr) Uvezi Photoshop četkice (.abr) - -# Camera Raw chrome, tone, curve, color mixer, grading, detail and effects cameraRaw Effects Efekti cameraRaw Exposure Ekspozicija cameraRaw Contrast Kontrast @@ -4187,12 +4245,8 @@ cameraRaw Feather Omekšavanje cameraRaw Midpoint Središte cameraRaw Roundness Zaobljenost cameraRaw Size Veličina - -# New Document, Home and Image Size Default Photoshop Size Podrazumevana Photoshop veličina document Orientation Orijentacija - -# Warp, Liquify, Perspective and Puppet Arc Lower Donji luk Arc Upper Gornji luk Forward Warp Guranje napred @@ -4206,15 +4260,10 @@ document Orientation Orijentacija Freeze Lasso Laso za zamrzavanje Reconstruct All Rekonstruiši sve Mask None Bez maske - -# Contextual labels colorRange Invert Invertuj layers Fill Popuna layers Fill: Popuna: shape Appearance Izgled - -# Added on merge with main (spelling, Camera Raw settings files, printing, Object Finder, brush -# controls, actions, gradient presets, Pen Auto Add/Delete) Add to Dictionary Dodaj u rečnik No Spelling Suggestions Nema predloga za pravopis Save Settings… Sačuvaj podešavanja… @@ -4243,6 +4292,9 @@ shape Appearance Izgled @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} korak još nema ekvivalent u PhotoCraftu|{n} koraka još nemaju ekvivalent u PhotoCraftu|{n} koraka još nema ekvivalent u PhotoCraftu @plural Saved {n} action to {file}|Saved {n} actions to {file} Sačuvana je {n} radnja u {file}|Sačuvane su {n} radnje u {file}|Sačuvano je {n} radnji u {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Izostavljen je {n} korak bez ekvivalenta u Photoshopu|Izostavljena su {n} koraka bez ekvivalenta u Photoshopu|Izostavljeno je {n} koraka bez ekvivalenta u Photoshopu +@plural {n} action file loaded|{n} action files loaded Učitana je {n} datoteka radnji|Učitane su {n} datoteke radnji|Učitano je {n} datoteka radnji + {set} from {file} replaced the one from {earlier} Skup {set} iz {file} zamenio je skup iz {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Nije moguće učitati {n} datoteku: {errors}|Nije moguće učitati {n} datoteke: {errors}|Nije moguće učitati {n} datoteka: {errors} Insert Stop… Umetni zaustavljanje… Save Set… Sačuvaj skup… Message: Poruka: @@ -4256,25 +4308,16 @@ shape Appearance Izgled Import .GRD Uvezi .GRD Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). Auto Add/Delete Automatsko dodavanje/brisanje - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Nemojte uključiti i ogledalsku sliku u podešavanjima štampanja: dva preslikavanja se poništavaju. Print Settings… Podešavanja štampanja… Remember to turn off the printer's own colour management in Print Settings… Ne zaboravite da isključite upravljanje bojama samog štampača u podešavanjima štampanja… Sent to {printer} Poslato na {printer} set in Print Settings… podešava se u podešavanjima štampanja… -# Custom print paper (#3820) Paper Width Širina papira Paper Height Visina papira - -# 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 @@ -4291,6 +4334,7 @@ shape Appearance Izgled Kept {name}: written by another application {name} je zadržan: upisala ga je druga aplikacija Shadow Tint Nijansa senki Redrawing Ponovno iscrtavanje + Memory usage percent Проценат употребе меморије Show Favorite Fonts Прикажи омиљене фонтове No favorite fonts yet: click the star beside a font to add it. Још нема омиљених фонтова: кликните на звездицу поред фонта да бисте га додали. Favorite fonts Омиљени фонтови @@ -4318,15 +4362,12 @@ shape Appearance Izgled Slab Serif Плочасти серифи TrueType font TrueType фонт Variable font Варијабилни фонт - -# 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 Show Sampling Area Prikaži oblast uzorkovanja @@ -4343,29 +4384,23 @@ shape Appearance Izgled Output Settings Podešavanja izlaza Output To Izlaz u Response curve Kriva odziva -# Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Koristi ugrađeni profil ({profile}) Convert to the working space ({profile}) Pretvori u radni prostor ({profile}) Remember this choice for future images Zapamti ovaj izbor za buduće slike Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Pitanje ponovo uključite opcijom Ask On Mismatch u Uređivanje › Podešavanja boja. Button Mode Režim dugmadi Button Mode On Režim dugmadi uključen - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Zatamni interfejs Lighten Interface Posvetli interfejs -# Bevel & Emboss Technique. Technique Tehnika 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 + Save Full-Size Composite in .pcraft Files Čuvaj složenu sliku u punoj veličini u .pcraft fajlovima 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 @@ -4388,8 +4423,6 @@ 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 @@ -4399,8 +4432,6 @@ shape Appearance Izgled 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 @@ -4417,8 +4448,6 @@ shape Appearance Izgled 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%. @@ -4434,10 +4463,8 @@ shape Appearance Izgled Undo Changes Poništi izmene {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi. The transform couldn't be applied. Transformacija nije mogla da se primeni. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovim slojevima treba {needed} GPU memorije; budžet im ostavlja {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi, najviše 3/8 njene memorije. Automatski prati Korišćenje memorije, u okviru četvrtine memorije ovog računara i 3/8 memorije kartice, i ne računa dokument kod kartice čija je memorija poznata. Dok ne staju, statusna traka prikazuje GPU memorija je puna. GPU Memory Full GPU memorija je puna @@ -4447,6 +4474,7 @@ shape Appearance Izgled 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) + Scratch Size Limit (MB, 0 = No Limit) Ograničenje veličine radnog diska (MB, 0 = bez ograničenja) {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. @@ -4463,8 +4491,6 @@ shape Appearance Izgled 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 @@ -4479,17 +4505,21 @@ shape Appearance Izgled Use Koristi In Front Ispred Behind Iza - -# DDS Options (#1327) 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 +smartSvg Choose executable… Izaberi izvršnu datoteku… +smartSvg Choose the application used to edit this SVG: Izaberite aplikaciju za uređivanje ovog SVG-a: +smartSvg Edit SVG Smart Object Uredi SVG pametni objekat +smartSvg Other application Druga aplikacija +smartSvg PhotoCraft (internal editor) PhotoCraft (interni uređivač) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG uređivač je otvoren. Sačuvajte SVG da biste ažurirali ovaj pametni objekat. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Sačuvajte u otvorenu SVG datoteku. Svaka sačuvana izmena postaje jedan korak opoziva u PhotoCraft-u. +smartSvg Stop syncing this Smart Object Zaustavi sinhronizaciju ovog pametnog objekta +smartSvg Updates wait while another document or an editing dialog is active. Ažuriranja čekaju dok je aktivan drugi dokument ili dijalog za uređivanje. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS садржај ће се уређивати као SVG. Чување замењује уграђени извор; својства специфична за формат могу се променити. # Styles panel: Import / Export Styles (.asl) Import Styles… Uvezi stilove… @@ -4498,7 +4528,6 @@ shape Appearance Izgled {file} is not a style library (.asl) {file} nije biblioteka stilova (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Uvezen je {n} stil iz {file}|Uvezena su {n} stila iz {file}|Uvezeno je {n} stilova iz {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Izvezen je {n} stil u {file}|Izvezena su {n} stila u {file}|Izvezeno je {n} stilova u {file} -# 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 @@ -4507,27 +4536,14 @@ shape Appearance Izgled 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 -# PhotoCraft release checks. Check for Updates… Proveri ažuriranja… - PhotoCraft Updates Ažuriranja za PhotoCraft - Checking for updates… Provera ažuriranja… Open the release page to choose an installer for your system. Otvorite stranicu izdanja da biste izabrali instalacioni program za svoj sistem. - Open Download Page Otvori stranicu za preuzimanje - PhotoCraft is up to date. PhotoCraft je ažuran. - Could not check for updates. Nije moguće proveriti ažuriranja. - Updates have not been checked yet. Ažuriranja još nisu proverena. - Check Again Proveri ponovo Automatically Check for Updates Automatski proveravaj ažuriranja - Installed version: {version} Instalirana verzija: {version} - PhotoCraft {version} is available. Dostupan je PhotoCraft {version}. -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Kocka nijanse Brightness Cube Kocka osvetljenosti Color Wheel Točak boja @@ -4543,7 +4559,8 @@ shape Appearance Izgled Grayscale Ramp Skala nijansi sive Current Colors Trenutne boje Make Ramp Web Safe Učini skalu bezbednom za veb -# Art History Brush (#213). + The path was kept as the Work Path Putanja je sačuvana kao radna putanja + The path was discarded Putanja je odbačena Art History Brush Tool Alatka umetnička četkica istorije Tight Short Usko kratko Tight Medium Usko srednje @@ -4557,3 +4574,28 @@ shape Appearance Izgled Font size presets Unapred podešene veličine fonta Leading presets Unapred podešeni proredi Tracking presets Unapred podešeni razmaci slova + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Neispravan numerički unos. Potreban je ceo broj između {min}% i {max}%. Umetnuta je prethodna vrednost. + Color Aware Prema boji + Decontaminate Colors Dekontaminiraj boje + Edge Detection Detekcija ivice + Global Refinements Globalna doterivanja + High Quality Preview Pregled visokog kvaliteta + Object Aware Prema objektu + Real-Time Refinement Doterivanje u realnom vremenu + Refine Edge Brush Tool Alatka četkica za doterivanje ivice + Refine Hair Doteraj kosu + Refine Mode Režim doterivanja + Remember Settings Zapamti podešavanja + View Mode Režim prikaza + Object Aware needs a segmentation model, which isn't available yet Režim Prema objektu zahteva model segmentacije, koji još nije dostupan + Refine Hair needs hair detection, which isn't available yet Doterivanje kose zahteva detekciju kose, koja još nije dostupna + Select and Mask was closed: its document is no longer the active one Izaberi i maskiraj je zatvoren: njegov dokument više nije aktivan + Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj + Select Layer Under Cursor Izaberi sloj ispod kursora + Drag to move the selected path Prevucite da pomerite izabranu putanju + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Nedavno korišćene boje + Stop Color Boja tačke + Shapes can't use patterns yet Oblici još ne mogu da koriste šare + Edit Brush… Uredi četkicu… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv new file mode 100644 index 0000000000..b9c3721765 --- /dev/null +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -0,0 +1,4773 @@ +# Swedish (sv) 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.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. +# +# Combined from the community Swedish catalogs in #5985 and #9536: each row takes the best of their +# translations, with one term per concept across the UI (lager, markering, mask, gradient, +# fjädring, ombilda, rutnät, stödlinje, rityta …); the contested terms follow a native Swedish +# speaker's answers to a terminology questionnaire (#10021). Clean-room: written from the plain +# meaning of the English labels; no proprietary localisation resource was consulted or copied. +# Labels use sentence case, commands are imperatives and the user is addressed as "du". Product, +# format and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab …) and key caps keep their +# spelling; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). +# Native-speaker review of the remaining specialist terms is welcome. + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskalternativ… + Layer Mask Display Options… Visningsalternativ för lagermask… + +# Channel Options and New Spot Channel (#3761) + Channel Color Kanalfärg + Color Indicates: Färgen anger: + Solidity: Täckning: + Ink Characteristics Tryckfärgsegenskaper + +# Menus + +# Guide layout dialog + +# Custom print paper (#3820) + Paper Width Pappersbredd + Paper Height Pappershöjd + Rows Rader + Number Antal + Margin Marginal + Top: Överkant: + Left: Vänster: + Bottom: Nederkant: + Right: Höger: + Target: Mål: + Selected Artboards Markerade ritytor + Center Columns Centrera kolumner + Clear Existing Guides Rensa befintliga stödlinjer + Invalid guide layout Ogiltig stödlinjelayout + Leave empty for automatic size Lämna tomt för automatisk storlek + 5 Columns, 3 Rows 5 kolumner, 3 rader + 8 Columns 8 kolumner + 12 Columns 12 kolumner + + File Arkiv + Edit Redigera + Image Bild + Layer Lager + Type Text + Select Markera + Selected Markerade + Scope Omfång + Top Left Uppe till vänster + Top Right Uppe till höger + Bottom Left Nere till vänster + Bottom Right Nere till höger + Filter Filter + View Visa + Window Fönster + Help Hjälp + +# File + New… Nytt… + New from Clipboard Nytt från Urklipp + New Nytt + Open… Öppna… + Open Öppna + Open As… Öppna som… + Open EXR Parts… Öppna EXR-delar… + Add beauty precomp from the AOVs Lägg till beauty-förkomposition från AOV:er + Both Båda + Left Vänster + Right Höger + Open Recent Öppna senaste + Clear Recent Files Rensa senaste filer + Remove from Recent Ta bort från senaste + Remove {name} from Recent Ta bort {name} från senaste + Close Stäng + Minimize Minimera + Maximize Maximera + Restore Återställ + Close All Stäng alla + Close Others Stäng övriga + Save Spara + Save As… Spara som… + Save a Copy… Spara en kopia… + {name} can't keep everything in this document {name} kan inte behålla allt i det här dokumentet + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. Välj ett format som behåller det, till exempel Photoshop (PSD), eller spara en kopia. + Save… Spara… + Revert Återgå + Import Importera + Import Presets Importera förinställningar + Export As… Exportera som… + Export Exportera + Export… Exportera… + Quick Export As PNG Snabbexportera som PNG + Quick Export as PNG Snabbexportera som PNG + Place Embedded… Placera inbäddad… + Place Linked… Placera länkad… + Package… Paketera… + Save for Web (Legacy)… Spara för webben (äldre)… + Generate Generera + Image Assets Bildresurser + +# Edit + Undo Ångra + Redo Gör om + Fade… Tona… + Cut Klipp ut + Copy Kopiera + Copy Merged Kopiera sammanfogade + Paste Klistra in + Paste Special Klistra in special + Paste Into Klistra in i + Paste Outside Klistra in utanför + Paste in Place Klistra in på plats + Clear Rensa + Search Sök + Search… Sök… + Check Spelling… Kontrollera stavning… + Fill… Fyll… + Stroke… Linje… + Content-Aware Fill… Innehållsmedveten fyllning… + Delete and Fill Selection Ta bort och fyll markering + Define Brush Preset… Definiera penselförinställning… + Define Pattern… Definiera mönster… + Define Custom Shape… Definiera anpassad form… + Purge Rensa + +# Image + Image Size… Bildstorlek… + Image Size Bildstorlek + Canvas Size… Storlek på arbetsyta… + Image Rotation Bildrotation + Rotate 90° Clockwise Rotera 90° medurs + Rotate 90° Counter Clockwise Rotera 90° moturs + Rotate 180° Rotera 180° + Flip Canvas Horizontal Vänd arbetsytan vågrätt + Flip Canvas Vertical Vänd arbetsytan lodrätt + Crop Beskär + Crop and Straighten Photos Beskär och räta upp foton + Trim… Trimma… + Reveal All Visa allt + Hide Selection Dölj markering + Auto Tone Autoton + Auto Contrast Autokontrast + Auto Color Autofärg + Adjustments Justeringar + 16 Bits/Channel 16 bitar/kanal + 32 Bits/Channel 32 bitar/kanal + 8 Bits/Channel 8 bitar/kanal + 8 bit 8 bitar + Integer Heltal + 16 bit 16 bitar + 32 bit (float) 32 bitar (flyttal) + Floating point Flyttal + Mode Läge + Grayscale Gråskala + RGB Color RGB-färg + CMYK Color CMYK-färg + Lab Color Lab-färg + Indexed Color Indexerad färg + Multichannel Flerkanal + Bitmap… Bitmapp… + Bitmap Bitmapp + Duotone… Duoton… + Duotone Duoton + Trap… Överfyllning… + +# Layer + Layer via Copy Lager via kopiering + Layer via Cut Lager via urklippning + Duplicate Layer… Duplicera lager… + Duplicate… Duplicera… + Delete Ta bort + Delete Layer Ta bort lager + Delete Layers Ta bort lager + Rename Layer Byt namn på lager + Layer From Background… Lager från bakgrund… + Layer from Background… Lager från bakgrund… + Background from Layer Bakgrund från lager + Background From Layer Bakgrund från lager + Group Layers Gruppera lager + Group… Grupp… + Ungroup Layers Dela upp lagergrupp + Group from Layers… Grupp från lager… + New Group from Layers… Ny grupp från lager… + Hide Layers Dölj lager + Arrange Ordna + Bring to Front Flytta längst fram + Bring Forward Flytta framåt + Send Backward Flytta bakåt + Send to Back Flytta längst bak + Convert to Smart Object Konvertera till smart objekt + Convert for Smart Filters Konvertera för smarta filter + New Smart Object via Copy Nytt smart objekt via kopiering + Edit Contents Redigera innehåll + Replace Contents… Ersätt innehåll… + Export Contents… Exportera innehåll… + Rasterize Rasterisera + Rasterize Type Rasterisera text + Rasterize Shape Rasterisera form + Rasterize Layer Rasterisera lager + Rasterize Layers Rasterisera lager + New Adjustment Layer Nytt justeringslager + New Fill Layer Nytt fyllningslager + Layer Mask Lagermask + Add Layer Mask Lägg till lagermask + Delete Layer Mask Ta bort lagermask + Enable Layer Mask Aktivera lagermask + Disable Layer Mask Inaktivera lagermask + Apply Layer Mask Tillämpa lagermask + Add Mask to Selection Lägg till mask i markeringen + Subtract Mask from Selection Subtrahera mask från markering + Intersect Mask with Selection Skär mask med markering + Link Layer Mask Länka lagermask + Unlink Layer Mask Ta bort länk till lagermask + Vector Mask Vektormask + Add Vector Mask Lägg till vektormask + Delete Vector Mask Ta bort vektormask + Enable Vector Mask Aktivera vektormask + Link Vector Mask Länka vektormask + Create Clipping Mask Skapa klippmask + Release Clipping Mask Frigör klippmask + New Shape Layer Nytt formlager + New Type Layer Nytt textlager + New Video Layer from File… Nytt videolager från fil… + New Blank Video Layer Nytt tomt videolager + Smart Filters Smarta filter + Delete Smart Filters Ta bort smarta filter + Enable Smart Filters Aktivera smarta filter + Disable Smart Filters Inaktivera smarta filter + Clear Smart Filters Rensa smarta filter + Delete Filter Mask Ta bort filtermask + Disable Filter Mask Inaktivera filtermask + New Layer Based Slice Ny lagerbaserad skiva + Layer Style Lagerstil + Blending Options… Blandningsalternativ… + Blending Options Blandningsalternativ + Drop Shadow… Skugga… + Inner Shadow… Inre skugga… + Outer Glow… Yttre glöd… + Inner Glow… Inre glöd… + Bevel & Emboss… Fasning och relief… + Use Global Light Använd globalt ljus + Overprint Övertryck + Satin… Satin… + Color Overlay… Färgöverlägg… + Gradient Overlay… Gradientöverlägg… + Pattern Overlay… Mönsteröverlägg… + Bevel & Emboss Fasning och relief + Inner Shadow Inre skugga + Inner Glow Inre glöd + Satin Satin + Color Overlay Färgöverlägg + Gradient Overlay Gradientöverlägg + Pattern Overlay Mönsteröverlägg + Outer Glow Yttre glöd + Drop Shadow Skugga + Copy Layer Style Kopiera lagerstil + Paste Layer Style Klistra in lagerstil + Clear Layer Style Rensa lagerstil + Layer Content Options… Alternativ för lagerinnehåll… + Scale Effects… Skala effekter… + New Layer… Nytt lager… + Delete Layer… Ta bort lager… + +# Type + Font Teckensnitt + Font Preview Size Storlek på förhandsgranskning av teckensnitt + Orientation Orientering + Horizontal Vågrät + Vertical Lodrät + Rasterize Type Layer Rasterisera textlager + Create Work Path Skapa arbetsbana + Convert to Shape Konvertera till form + Convert to Paragraph Text Konvertera till stycketext + Convert to Point Text Konvertera till punkttext + Warp Text… Deformera text… + Character Panel Teckenpanel + Paragraph Panel Panelen Stycke + Check Spelling Kontrollera stavning + Language Options Språkalternativ + Find and Replace Text… Sök och ersätt text… + Add to Dictionary Lägg till i ordlistan + No Spelling Suggestions Inga stavningsförslag + Paste Lorem Ipsum Klistra in Lorem Ipsum + Glyphs Panel Glyfpanelen + Type Styles Textformat + Character Styles Teckenformat + Paragraph Styles Styckeformat + Load Default Type Styles Läs in standardtextformat + Save Default Type Styles Spara standardtextformat + Type… Text… + +# Select + All Alla + Deselect Avmarkera + Reselect Markera igen + Inverse Selection Omvänd markering + Inverse Omvänd + Color Range… Färgintervall… + Select and Mask… Markera och maskera… +# Select and Mask dialog labels + Smart Radius Smart radie + Shift Edge Flytta kant + Decontaminate Sanera + Output Utdata + Refine Edge Förfina kant + Select Subject Markera motiv + Sky Himmel + Focus Area… Fokusområde… + Subject Motiv + Color Range Färgintervall + Similar Liknande + Grow Väx + Select Similar Colors Markera liknande färger + Load Selection… Läs in markering… + Save Selection… Spara markering… + New Selection Ny markering + Select All Markera allt + Deselect All Avmarkera alla + Deselect Layers Avmarkera lager + Find Layers Sök efter lager + Isolate Layers Isolera lager + Select Linked Layers Markera länkade lager + All Layers Alla lager + Current & Below Aktuellt och underliggande + Current Layer Aktuellt lager + Refine Strokes Förfina penseldrag + Edits Redigeringar + +# Filter + Last Filter Senaste filter + Filter Gallery… Filtergalleri… + Liquify… Gör flytande… + Camera Raw Filter… Camera Raw-filter… + Adaptive Wide Angle… Adaptiv vidvinkel… + Lens Correction… Objektivkorrigering… + Vanishing Point… Fluktpunkt… + Oil Paint… Oljemålning… + Blur Gallery Oskärpegalleri + Field Blur… Fältoskärpa… +# Field Blur pins (#3373) + No pin selected Ingen nål markerad + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicka i bilden för att lägga till en nål. Dra en nål för att flytta den, eller dess ring för att ändra oskärpan. Delete tar bort den markerade nålen. + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Dra handtagen på konturen för att ändra storlek och vrida, kvadraten för att runda hörnen och punkterna inuti för att ange var oskärpan börjar. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. Dra nålen för att flytta bandet, eller dess ring för att ändra oskärpan. Dra de heldragna linjerna för att bredda det skarpa bandet, de streckade linjerna för att ange övergången och punkterna för att vrida det. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. Dra handtagen på konturen för att ändra storlek och vrida; ringen anger oskärpevinkeln. + Blur Angle Oskärpevinkel + Iris Blur… Irisoskärpa… + Tilt-Shift… Tilt-shift… + Path Blur… Banoskärpa… + Spin Blur… Snurroskärpa… + Auto-Align Layers… Justera lager automatiskt… + Auto-Blend Layers… Blanda lager automatiskt… + Photomerge… Photomerge… + Content-Aware Scale Innehållsmedveten skalning + Adaptive Wide Angle Adaptiv vidvinkel + Apply Image… Tillämpa bild… + Calculations… Beräkningar… + Crystallize… Kristallisera… + Facet Fasett + Fragment Fragment + Mosaic… Mosaik… + Pointillize… Pointillera… + Chalk & Charcoal Krita och kol + Charcoal Kol + Chrome Krom + Conté Crayon Conté-krita + Graphic Pen Grafisk penna + Halftone Pattern Halvtonsmönster + Note Paper Anteckningspapper + Photocopy Fotokopia + Plaster Gips + Reticulation Retikulering + Stamp Stämpel + Torn Edges Trasiga kanter + Water Paper Vattenpapper + Clouds Moln + Difference Clouds Differensmoln + Fibers… Fibrer… + Lens Flare… Motljus… + Picture Frame… Bildram… + Tree… Träd… + Flame… Flamma… + Sharpen Edges Skärpa kanter + Dust & Scratches… Damm och repor… + Noise Brus + Add Noise… Lägg till brus… + Reduce Noise… Minska brus… + Despeckle Ta bort fläckar + Median… Median… + Median Median + Maximum… Maximum… + Maximum Maximum + Minimum… Minimum… + Minimum Minimum + Custom… Anpassad… + High Pass… Högpass… + Emboss… Relief… + Extrude… Extrudera… + Find Edges Hitta kanter + Solarize Solarisera + Tiles… Plattor… + Trace Contour… Spåra kontur… + Wind… Vind… + Offset… Förskjutning… + Pinch… Nyp… + Polar Coordinates… Polära koordinater… + Ripple… Krusning… + Shear… Skjuva… + Spherize… Sfärisera… + Twirl… Virvel… + Wave… Våg… + ZigZag… Sicksack… + Average Medelvärde + Blur More Mer oskärpa + Box Blur… Rutoskärpa… + Gaussian Blur… Gaussisk oskärpa… + Motion Blur… Rörelseoskärpa… + Radial Blur… Radiell oskärpa… + Shape Blur… Formoskärpa… + Smart Blur… Smart oskärpa… + Surface Blur… Ytoskärpa… + Sharpen Skärpa + Sharpen More Skärpa mer + Unsharp Mask… Oskarp mask… + Smart Sharpen… Smart skärpa… + Diffuse… Diffusera… + Mezzotint… Mezzotint… + Color Halftone… Färgraster… + Layer… Lager… + +# View + Proof Setup Korrekturinställningar + Proof Colors Korrekturfärger + Pixel Aspect Ratio Pixelproportioner + Pixel Aspect Ratio Correction Korrigering av pixelproportioner + Zoom In Zooma in + Zoom Out Zooma ut + Fit on Screen Anpassa till skärmen + 100% 100 % + 105% 105 % + 110% 110 % + 115% 115 % + 120% 120 % + 200% 200 % + 75% 75 % + 80% 80 % + 85% 85 % + 90% 90 % + 95% 95 % + 125% 125 % + 150% 150 % + 175% 175 % + 250% 250 % + 300% 300 % + Print Size Utskriftsstorlek + Screen Mode Skärmläge + Standard Screen Mode Standardskärmläge + Full Screen Mode With Menu Bar Helskärmsläge med menyrad + Full Screen Mode Helskärmsläge + Extras Extrafunktioner + Show Visa + Rulers Linjaler + Grid Rutnät + Guides Stödlinjer + Slices Skivor + Snap Fäst + Snap To Fäst mot + Lock Guides Lås stödlinjer + Clear Guides Rensa stödlinjer + New Guide… Ny stödlinje… + Lock Slices Lås skivor + Clear Slices Rensa skivor + New Guide Layout… Ny stödlinjelayout… + New Guides From Shape Nya stödlinjer från form + Canvas rotation Rotation av arbetsytan + Zoom in Zooma in + Zoom out Zooma ut + +# Window + Cascade Överlappande + Tile Sida vid sida + 2-up Horizontal 2 vågrätt + 3-up Horizontal 3 vågrätt + 2-up Vertical 2 lodrätt + 3-up Vertical 3 lodrätt + 3-up Stacked 3 staplade + 4-up 4 fönster + 6-up 6 fönster + Consolidate All to Tabs Samla alla i flikar + Match Zoom Matcha zoom + Match Location Matcha plats + Match Rotation Matcha rotation + Match All Matcha alla + New Window for Document Nytt fönster för dokument + Workspace Arbetsyta + Extensions Tillägg + Panels Paneler + Actions Åtgärder + Brush Settings Penselinställningar + Brushes Penslar + Channels Kanaler + Character Tecken + Clone Source Klonkälla + Color Mixer Färgmixer + Glyphs Glyfer + Gradients Gradienter + Histogram Histogram + History Historik + Info Info + Layer Comps Lagerkompositioner + Layers Lager + Measurement Log Mätlogg + Notes Anteckningar + Navigator Navigator + Paragraph Stycke + Paths Banor + Patterns Mönster + Properties Egenskaper + Styles Stilar + Swatches Färgrutor + Timeline Tidslinje + Tool Presets Verktygsförinställningar + Tools Verktyg + Libraries Bibliotek + 3D 3D + Toolbars Verktygsfält + Hide All Dölj alla + +# Help + About PhotoCraft Om PhotoCraft + PhotoCraft Website PhotoCrafts webbplats + PhotoCraft on GitHub PhotoCraft på GitHub + Report an Issue… Rapportera ett problem… + ArtCraft Website ArtCrafts webbplats + Join the ArtCraft Discord… Gå med i ArtCraft på Discord… + System Info… Systeminformation… + +# Tools and options bar + Move Flytta + Marquee Markeringsram + Lasso Lasso + Wand Trollstav + Eyedropper Pipett + Brush Pensel + Block Block + Pencil Blyertspenna + Eraser Suddgummi + Clone Stamp Klonstämpel + Healing Lagning + Blur Oskärpa + Dodge Skuggning + Burn Efterbelys + Gradient Gradient + Shape Form + Pen Penna + Hand Hand + Zoom Zoom + Sampler Provpunkt + Ruler Tool Linjalverktyg + Count Tool Räkneverktyg + Note tool Anteckningsverktyg + Quick Selection Snabbmarkering + Magnetic Lasso Magnetiskt lasso + Mixer Brush Blandningspensel + Mixer Brush Tool Blandningspenselverktyg + Color Replacement Tool Färgersättningsverktyg + History Brush Historikpensel + Art History Brush Konsthistorikpensel + Pattern Stamp Mönsterstämpel + Spot Healing Punktlagning + Red Eye Röda ögon + Patch Lapp + Content-Aware Move Innehållsmedveten flytt + Rotate View Rotera vy + Perspective Crop Perspektivbeskärning + Color Replacement Färgersättning + Dodge/Burn Skuggning/efterbelysning + Foreground Förgrund + Background Bakgrund + Set foreground color Ange förgrundsfärg + Set background color Ange bakgrundsfärg + Swap colours (X) Byt plats på färgerna (X) + Default colours (D) Standardfärger (D) + Brush Tool Options Alternativ för penselverktyget + Opacity Opacitet + Flow Flöde + Smoothing Utjämning + Size Storlek + Hardness Hårdhet + Angle: Vinkel: + Roundness: Rundhet: + Spacing Avstånd + Dynamics Dynamik + Pressure Tryck + Tilt Lutning + Rotation Rotation + Align Justera + Aligned Justerad + +# Panels, dialogs and common widgets + OK OK + Cancel Avbryt + Cancel (Esc) Avbryt (Esc) + Manage submitted jobs in the system print queue Hantera skickade jobb i systemets utskriftskö + Done Klart + Apply Tillämpa + Reset Återställ + Reset Section Återställ avsnitt + Reset All to Defaults Återställ alla till standardvärden + Import Shortcuts… Importera kortkommandon… + Load a keyboard shortcut set saved as .kys Läs in en uppsättning kortkommandon som sparats som .kys + Imported {n} shortcuts from {file}. Click OK to keep them. {n} kortkommandon har importerats från {file}. Klicka på OK för att behålla dem. + {n} commands are not in PhotoCraft. {n} kommandon finns inte i PhotoCraft. + Keyboard shortcuts imported Kortkommandon har importerats + 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. Din kortkommandouppsättning {set} används här: {n} kortkommandon skiljer sig från standardvärdena. Redigera › Kortkommandon visar dem; Återställ alla till standardvärden ångrar detta. + Import keyboard shortcuts? Vill du importera kortkommandon? + 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 hittade din uppsättning kortkommandon {set} från en annan bildredigerare på den här datorn: {n} av dess kortkommandon skiljer sig från PhotoCrafts. Vill du importera den? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Redigera › Kortkommandon › Återställ alla till standardvärden ångrar en import. + Don't Import Importera inte + The keyboard shortcut set was not imported: {error} Kortkommandouppsättningen importerades inte: {error} + Defaults restored. Standardvärdena har återställts. + Restore Defaults Återställ standardvärden + Restore original size Återställ ursprunglig storlek + Use Default Använd standard + Dismiss Stäng + Preview Förhandsgranskning + Options Alternativ + Settings… Inställningar… + Rename… Byt namn… + Load… Läs in… + Define… Definiera… + Custom Anpassad + Default Standard + None Ingen + Add Lägg till + Remove Ta bort + Remove Background Ta bort bakgrund + Rename Byt namn + Ignore Ignorera + Ignore All Ignorera alla + Change Ändra + Change All Ändra alla + Next frame Nästa bildruta + Previous frame Föregående bildruta + Play Spela upp + Pause Pausa + Stop Stoppa + Begin recording Börja spela in + Stop playing/recording Stoppa uppspelning/inspelning + Record ● a sequence of edits, then play ▶ it on any document. Spela in ● en sekvens med redigeringar och spela sedan upp ▶ den i valfritt dokument. + Create new action Skapa ny åtgärd + Play selection Spela upp markering + Create new set Skapa ny uppsättning + Export selected measurements (CSV) Exportera markerade mätningar (CSV) + Record Measurements Registrera mätningar + Delete selected measurements Ta bort markerade mätningar + Select Data Points… Välj datapunkter… + Set Measurement Scale… Ange mätskala… + Use Measurement Scale Använd måttskala + +# Preferences dialog + Preferences Inställningar + General… Allmänt… + Interface… Gränssnitt… + File Handling… Filhantering… + Performance… Prestanda… + Cursors… Markörer… + Transparency & Gamut… Genomskinlighet och färgomfång… + Units & Rulers… Enheter och linjaler… + Guides, Grid & Slices… Stödlinjer, rutnät och skivor… + Plug-ins… Plugin-program… + Technology Previews Teknikförhandsgranskningar + Scratch Disks… Arbetsdiskar… + Export Preferences… Exportinställningar… + Keyboard Shortcuts… Kortkommandon… + Menus… Menyer… + Export/Import Presets… Exportera/importera förinställningar… + Additional plugins folder Ytterligare plugin-mapp + Install Plug-in… Installera plugin… + Export Presets Exportera förinställningar + Import brushes (.abr) Importera penslar (.abr) + Migrate Presets Migrera förinställningar + Thumbnail size (smallest: list) Miniatyrstorlek (minsta: lista) + Reset GPU Backend Återställ GPU-backend + Applies at next launch. Gäller från nästa start. + +# Document and canvas status bar + Untitled Namnlös + Untitled-1 Namnlös-1 + Doc: {flat}/{layered} Dok: {flat}/{layered} + Scratch: {size} Arbetsdisk: {size} + Efficiency: 100% Effektivitet: 100 % + Dimensions: Mått: + Width Bredd + Slant Lutning + Height Höjd + Width: Bredd: + Height: Höjd: + Resolution Upplösning + Resolution: Upplösning: + Resolution: {dpi} pixels/inch Upplösning: {dpi} pixlar/tum + {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) + Pixels: Pixlar: + Pixel Length: Pixellängd: + Logical Length: Logisk längd: + Logical Units: Logiska enheter: + Length: Längd: + Interpolation: Interpolering: + Resample Sampla om + Constrain proportions Behåll proportioner + Link width and height Länka bredd och höjd + Maintain aspect ratio Behåll proportioner + Current Size Aktuell storlek + New Size Ny storlek + Fit Anpassa + Percent Procent + Pixels/Inch Pixlar/tum + Pixels/Centimeter Pixlar/centimeter + Relative Relativ + Canvas extension color: Färg för utökad arbetsyta: + Anchor: Ankare: + Both Axes Båda axlarna + Horizontally Vågrätt + Vertically Lodrätt + +# Layers panel + Layers to Files… Lager till filer… + Duplicate Layers… Duplicera lager… + Group name Gruppnamn + New Group Ny grupp + Layer Comp Lagerkomposition + Create new layer Skapa nytt lager + Create new group Skapa ny grupp + Create new channel Skapa ny kanal + Create new path Skapa ny bana + Toggle visibility Visa/dölj + Clipped Urklippt + Group · {n} layer Grupp · {n} lager + Lock: keep these settings when picking another preset Lås: behåll de här inställningarna när du väljer en annan förinställning + Unlock: picking a preset replaces these settings Lås upp: om du väljer en förinställning ersätts de här inställningarna + Show the layer's effects ({key}-click: all layers) Visa lagrets effekter ({key}-klicka: alla lager) + Hide the layer's effects ({key}-click: all layers) Dölj lagrets effekter ({key}-klicka: alla lager) + Expand group ({key}-click: all groups) Expandera grupp ({key}-klicka: alla grupper) + Collapse group ({key}-click: all groups) Fäll ihop grupp ({key}-klicka: alla grupper) + Effects Effekter + Blending Blandning + Blend If Blandningsvillkor + General Blend Allmän blandning + Advanced Blending Avancerad blandning + Opacity: Opacitet: + Fill: Fyllning: + Lock: Lås: + Smart Filter Smart filter + Fill Content Fyllningsinnehåll + Adjustment Preview Förhandsgranskning av justering + +# Channels panel + Channel: Kanal: + Channel Mixer… Kanalblandare… + Output Channel: Utdatakanal: + RGB RGB + Red Röd + Green Grön + Blue Blå + Alpha Alfa + Load channel as selection Läs in kanal som markering + Save selection as channel Spara markeringen som kanal + Delete current channel Ta bort aktuell kanal + Spot Channel Dekorfärgskanal + Mask All Maskera allt + Mask All Objects Maskera alla objekt + +# Color picker and swatches + Color Färg + Color: Färg: + Colors Färger + HSB/HSL HSB/HSL + No Color Ingen färg + Set shape fill type Ange formens fyllningstyp + Set shape stroke type Ange formens linjetyp + Set the text color Ange textfärg + Create warped text Skapa deformerad text + Only Web Colors Endast webbfärger + Overlay Color Överläggsfärg + Overlay Opacity Överläggsopacitet + Glow Color Glödfärg + Web Snap Fäst mot webbfärger + +# Filters and adjustments dialogs + Brightness Ljusstyrka + Contrast Kontrast + Brightness/Contrast… Ljusstyrka/kontrast… + Levels… Nivåer… + Curves… Kurvor… + Curve Kurva + Exposure… Exponering… + Exposure Exponering + Vibrance… Lyster… + Vibrance Lyster + Hue/Saturation… Nyans/mättnad… + Hue Nyans + Saturation Mättnad + Lightness Ljushet + Color Balance… Färgbalans… + Black & White… Svartvitt… + Photo Filter… Fotofilter… + Color Lookup… Färguppslag… + Color Lookup Tables… Färguppslagstabeller… + 3D LUT File 3D LUT-fil + Load 3D LUT… Läs in 3D-LUT… + Invert… Invertera… + Invert Invertera + Invert has no settings. Invertera har inga inställningar. + Threshold… Tröskelvärde… + Threshold Level Tröskelvärdesnivå + Posterize… Posterisera… + Gradient Map… Gradientkarta… + Selective Color… Selektiv färg… + Match Color… Matcha färg… + Replace Color… Ersätt färg… + Equalize Utjämna + HDR Toning… HDR-toning… + Shadows/Highlights… Skuggor/högdagrar… + Amount Mängd + Radius Radie + Radius: Radie: + Tolerance: Tolerans: + Density Täthet + Density: Täthet: + Feather Fjädring + Feather… Fjädring… + Anti-alias Kantutjämning + Anti-Alias Kantutjämning + Contiguous Sammanhängande + Sample All Layers Ta prov på alla lager + Protect Foreground Color Skydda förgrundsfärg + Matte Kantfärg + Matting Kantbehandling + Color Decontaminate… Färgsanering… + Remove Black Matte Ta bort svart kantfärg + Remove White Matte Ta bort vit kantfärg + Defringe… Ta bort kantfärg… + Source Källa + Working CMYK Arbets-CMYK + Working Cyan Plate Cyan arbetsplåt + Working Magenta Plate Magenta arbetsplåt + Working Yellow Plate Gul arbetsplåt + Working Black Plate Svart arbetsplåt + Working Gray Plate Grå arbetsplåt + +# Camera Raw and lens dialogs + Basic Grundläggande + Detail Detalj + HSL/Grayscale HSL/gråskala + Tone Curve Tonkurva + Split Toning Delad toning + Lens Corrections Objektivkorrigeringar + Camera Calibration Kamerakalibrering + Presets Förinställningar + Snapshots Ögonblicksbilder + Temperature Temperatur + Tint Färgton + Exposure: Exponering: + Recovery Återställning + Fill Light Upplättningsljus + Blacks Svarta + Clarity Klarhet + Convert to Grayscale Konvertera till gråskala + Auto Auto + Auto collapse icon panels Fäll ihop ikonpaneler automatiskt + Auto show hidden panels Visa dolda paneler automatiskt + Auto show home screen Visa startskärmen automatiskt + Sharpness Skärpa + Noise Reduction Brusreducering + Color Noise Reduction Brusreducering för färg + Luminance Detail Luminansdetalj + Color Detail Färgdetalj + Luminance Smoothness Luminansutjämning + Vignetting Vinjettering + White Balance: As Shot Vitbalans: som tagen + Camera Raw… Camera Raw… + Camera Raw isn't open Camera Raw är inte öppet + Dehaze Ta bort dis + Texture Struktur + Focal Length Brännvidd + Make Tillverkare + Model Modell + Lens Objektiv + F F + ISO ISO + Focal Length35mm Brännvidd (35 mm) + Aspect Ratio Bildförhållande + Time Tid + Artist Konstnär + Copyright Upphovsrätt + Enter description… Ange beskrivning… + Adobe RGB (1998) compatible Kompatibel med Adobe RGB (1998) + Gray Gamma 2.2 Grå gamma 2.2 + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Branschstandard + Web Webb + Internet Standard RGB Internetstandard-RGB + Legacy Macintosh RGB Äldre Macintosh RGB + Monitor RGB Bildskärms-RGB + Windows LCD Windows LCD + Coated CMYK Bestruket CMYK + Uncoated CMYK Obestruket CMYK + Newspaper Tidningspapper + Working Spaces Arbetsfärgrymder + Color Management Policies Principer för färghantering + Assign working space Tilldela arbetsfärgrymd + Leave as is (don't color manage) Lämna som det är (ingen färghantering) + Discard the embedded profile (don't color manage) Ignorera den inbäddade profilen (ingen färghantering) + Embed Color Profile Bädda in färgprofil + Assign Profile… Tilldela profil… + Convert to Profile… Konvertera till profil… + PhotoCraft Manages Colors PhotoCraft hanterar färger + Printer Manages Colors Skrivaren hanterar färger + (print to the printer) (skriv ut på skrivaren) + Absolute Colorimetric Absolut kolorimetrisk + Relative Colorimetric Relativ kolorimetrisk + Perceptual Perceptuell + Black Point Compensation Svartpunktskompensering + Use Legacy Använd äldre + Dither Dithering + No Dither Ingen dithering + Diffusion Diffusion + Pattern Mönster + Fuzziness: Tolerans: + Range: Intervall: + Select: Markera: + Detect Faces Identifiera ansikten + Localize Color Clusters Lokalisera färgkluster + Sample: Prov: + Add to Sample Lägg till i prov + Subtract from Sample Subtrahera från prov + Selections Markeringar + Duplicated selection name Namn på duplicerad markering + Operation Åtgärd + New channel only Endast ny kanal + Masked Areas Maskerade områden + Selected Areas Markerade områden + Spot Color Dekorfärg + Color indicates Färgen anger + +# Adjustments panel + Curves Kurvor + Levels Nivåer + Color Balance Färgbalans + Black & White Svartvitt + Photo Filter Fotofilter + Color Lookup Färguppslag + Threshold Tröskelvärde + Posterize Posterisera + Brightness/Contrast Ljusstyrka/kontrast + Channel Mixer Kanalblandare + Gradient Map Gradientkarta + Selective Color Selektiv färg + +# History panel + History Log… Historiklogg… + Create Snapshot Skapa ögonblicksbild + Delete Snapshot Ta bort ögonblicksbild + New Snapshot Ny ögonblicksbild + Snapshot 1 Ögonblicksbild 1 + Step Forward Stega framåt + Step Backward Stega bakåt + Histories Historik + Nonlinear History Icke-linjär historik + Set New Snapshot as Default Ange ny ögonblicksbild som standard + Automatically Create First Snapshot Skapa första ögonblicksbilden automatiskt + Automatically Create New Snapshot When Saving Skapa ny ögonblicksbild automatiskt vid sparande + Show New Snapshot Dialog by Default Visa dialogrutan Ny ögonblicksbild som standard + Clear History Rensa historik + +# Navigator panel + Panel Options… Panelalternativ… + View menu Menyn Visa + Keep view in sync Håll vyn synkroniserad + {zoom}% {zoom} % + +# Brush Settings panel + Brush name Penselnamn + Brush Preview Förhandsgranskning av pensel + Brush Tip Shape Penselspetsens form + Shape Dynamics Formdynamik + Scattering Spridning + Dual Brush Dubbel pensel + Color Dynamics Färgdynamik + Transfer Överföring + Brush Pose Penselställning + Mouse Controls Muskontroller + Wet Edges Våta kanter + Build-up Uppbyggnad + Protect Texture Skydda struktur + Blend Mode Blandningsläge + Size Jitter Storleksvariation + Minimum Diameter Minsta diameter + Tilt Scale Lutningsskala + Angle Jitter Vinkelvariation + Roundness Jitter Rundhetsvariation + Minimum Roundness Minsta rundhet + Flip X Jitter Variation för vänd X + Flip Y Jitter Variation för vänd Y + Brush Projection Penselprojektion + Control: Kontroll: + Off: the speed of the pointer sets the spacing Av: pekarens hastighet styr avståndet + Fade steps Toningssteg + Pen Pressure Penntryck + Pen Tilt Pennlutning + Stylus Wheel Pennhjul + Initial Direction of Stroke Penseldragets startriktning + Direction of Stroke Penseldragets riktning + Opacity Jitter Opacitetsvariation + Flow Jitter Flödesvariation + Humidity Fuktighet + Load Läs in + Mix Blandning + Auto Clean Brush Rengör penseln automatiskt + Wet Våt + Current Brush Load Aktuell penselladdning + Load Brush Ladda pensel + Clean Brush Rengör pensel + Load the brush after each stroke Ladda penseln efter varje penseldrag + Clean the brush after each stroke Rengör penseln efter varje penseldrag + Rate Hastighet + Airbrush: keeps depositing paint while the pointer rests. Airbrush: fortsätter att lägga på färg medan pekaren står still. + Pulled String Mode Läge med dragen tråd + Catch-up on Stroke End Hinn ikapp vid penseldragets slut + Adjust for Zoom Anpassa efter zoom + Apply Per Tip Tillämpa per spets + Hardness: Hårdhet: + Spacing: Avstånd: + Size: Storlek: + Flip X Vänd X + Flip Y Vänd Y + Brush Tip Penselspets + Diameter: Diameter: + New Layer from Brush Nytt lager från pensel + Rename Brush… Byt namn på pensel… + Delete Brush Ta bort pensel + Import Brushes… Importera penslar… + Import Brushes… (.abr or image) Importera penslar… (.abr eller bild) + Import Brush from Image… Importera pensel från bild… + Gradient Preset Options Alternativ för gradientförinställningar + Import .GRD Importera .GRD + Choose a gradient file (.grd). Välj en gradientfil (.grd). + Brush Name Penselnamn + Brush Stroke Penseldrag + New Brush Preset… Ny penselförinställning… + Capture Brush Size in Preset Inkludera penselstorlek i förinställning + Include Tool Settings Ta med verktygsinställningar + Include Color Ta med färg + Enter a name for the brush preset Ange ett namn på penselförinställningen + A brush preset with this name already exists Det finns redan en penselförinställning med det här namnet + Brush Preset Options Alternativ för penselförinställningar + New Brush Ny pensel + Create new brush from these settings Skapa ny pensel från dessa inställningar + Update the current brush with these settings Uppdatera den aktuella penseln med de här inställningarna + Reset the brush to the defaults Återställ penseln till standardvärdena + No brush presets Inga penselförinställningar + Override Pressure Åsidosätt tryck + Override Tilt Åsidosätt lutning + Override Tilt X Åsidosätt lutning X + Override Tilt Y Åsidosätt lutning Y + Override Rotation Åsidosätt rotation + Apply the pen's tilt and rotation to the tip shape Tillämpa pennans lutning och rotation på spetsformen + +# Tool presets + Preset Type Typ av förinställning + Preset Manager… Förinställningshanterare… + Rename Tool Preset… Byt namn på verktygsförinställning… + Delete Tool Preset Ta bort verktygsförinställning + New Tool Preset Ny verktygsförinställning + Create new tool preset Skapa ny verktygsförinställning + Current Tool Only Endast aktuellt verktyg + No tool presets. Inga verktygsförinställningar. + No presets for the current tool. Inga förinställningar för det aktuella verktyget. + No presets of this type. Inga förinställningar av den här typen. + Preset: Förinställning: + Preset Förinställning + Presets: Förinställningar: + +# Paragraph and character panels + Font Size: Teckenstorlek: + Font size Teckenstorlek + Type: Typ: + Style Stil + Style: Normal Stil: Normal + Tracking Spärrning + Kerning Kerning + Leading Radavstånd + Auto-Leading Automatiskt radavstånd + Scale Horizontally Skala vågrätt + Scale Vertically Skala lodrätt + Baseline Shift Baslinjeförskjutning + Language Språk + Anti-aliasing method Kantutjämningsmetod + Sharp Skarp + Crisp Krispig + Strong Stark + Smooth Mjuk + Mac LCD Mac LCD + Mac Sharp Mac skarp + Mac Crisp Mac krispig + Mac Strong Mac stark + Mac Smooth Mac jämn + Faux Bold Falsk fetstil + Faux Italic Falsk kursiv + All Caps Versaler + Small Caps Kapitäler + Superscript Upphöjd + Subscript Nedsänkt + Underline Understrykning + Strikethrough Genomstruken + Oldstyle Medievalsiffror + Ligature Ligatur + Alternative Alternativ + Hyphenate Avstava + Roman Roman + Italic Kursiv + Bold Fet + Bold Italic Fet kursiv + Light Ljus + Medium Medel + Black Svart + Thin Tunn + Ultra Light Ultratunn + Extra Light Extratunn + Semi Bold Halvfet + Extra Bold Extrafet + Ultra Bold Ultrafet + Condensed Smal + Left align text Vänsterjustera text + Center text Centrera text + Right align text Högerjustera text + Justify last left Marginaljustera med sista raden till vänster + Justify last centered Marginaljustera med sista raden centrerad + Justify last right Marginaljustera med sista raden till höger + Justify all Marginaljustera alla + Indent Left Indrag vänster + Indent Right Indrag höger + First Line Indent Indrag första raden + Space Before Avstånd före + Space After Avstånd efter + Hanging Punctuation Hängande skiljetecken + Roman Hanging Punctuation Hängande interpunktion (roman) + Avoid Widows/Orphans Undvik änkor/horungar + Compose Komponera + Adobe Single-line Composer Adobe Single-line Composer + Adobe Every-line Composer Adobe Every-line Composer + Justification Marginaljustering + Word Spacing Ordavstånd + Letter Spacing Teckenavstånd + Glyph Scaling Glyfskalning + Auto-leading % Automatiskt radavstånd % + Single Word Justification Justering av enstaka ord + Alignment Justering + Indent First Indrag första raden + Center Center + Justify Marginaljustera + +# Glyphs panel + Glyph Glyf + Select a type layer or click into text to insert glyphs Markera ett textlager eller klicka i texten för att infoga glyfer + Entire Font Hela teckensnittet + Alternates for Current Selection Alternativ för aktuell markering + Recently Used Senast använda + Digits Siffror + Symbols Symboler + Punctuation Skiljetecken + OpenType OpenType + Standard Ligatures Standardligaturer + Discretionary Ligatures Valfria ligaturer + Contextual Alternates Kontextuella alternativ + Swash Swash + Titling Alternates Rubrikalternativ + Ornaments Ornament + Fractions Bråk + Ordinals Ordningstal + East Asian Features Östasiatiska funktioner + Middle Eastern Features Mellanösternfunktioner + +# Paths panel + Path Bana + Path: Bana: + Work Path Arbetsbana + Path to Illustrator… Bana till Illustrator… + Make Work Path Skapa arbetsbana + Make Selection Skapa markering + Fill Path Fyll bana + Stroke Path Linje på bana + Clipping Path Urklippsbana + New Path Ny bana + Duplicate Path Duplicera bana + Rename Path… Byt namn på bana… + Delete Path Ta bort bana + Current Path Aktuell bana + Vector Mask from Current Path Vektormask från aktuell bana + Create new shape from the current path Skapa ny form från den aktuella banan + Drag to move the active shape's path or the Work Path Dra för att flytta den aktiva formens bana eller arbetsbanan + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klick: markera ankarpunkt · Dra: flytta ankarpunkt, handtag eller segment · {shift}-klick: lägg till · {alt}-klick: hela delbanan + Draw with the Pen tool (P) or make a work path from a selection. Rita med pennverktyget (P) eller skapa en arbetsbana från en markering. + +# Clone Source panel + Source: not set ({key}-click with Clone Stamp) Källa: inte angiven ({key}-klicka med klonstämpeln) + Clone source {n} ({key}-click with Clone Stamp to set) Klonkälla {n} ({key}-klicka med klonstämpeln för att ange) + {key}-click to set the source {key}-klicka för att ange källan + Option-click to define a source point to clone from Option-klicka för att ange en källpunkt att klona från + Show Overlay Visa överlägg + Auto-Hide Dölj automatiskt + W B + H H + Angle Vinkel + Scale Skala + X X + Y Y + Offset Förskjutning + Reset Transform Återställ ombildning + Rotate Rotera + Set horizontal skew Ange vågrät snedställning + Set vertical skew Ange lodrät snedställning + Flip Horizontal Vänd vågrätt + Flip Vertical Vänd lodrätt + Frame Sync Bildrutesynkronisering + Quick Sync Snabbsynkronisering + Enabled Aktiverad + +# Timeline panel + Create Video Timeline Skapa videotidslinje + Open a document to use the timeline. Öppna ett dokument för att använda tidslinjen. + Video Layers Videolager + Video Video + Audio Ljud + Frame Duration Bildrutevaraktighet + 30 frames @ 30 fps 30 bildrutor vid 30 bild/s + Loop Slinga + Render Video… Rendera video… + Split at Playhead Dela vid spelmarkören + Delete Timeline Ta bort tidslinje + Reload Frame Läs in bildruta igen + Restore Frame Återställ bildruta + Insert Blank Frame Infoga tom bildruta + Duplicate Frame Duplicera bildruta + Delete Frame Ta bort bildruta + Restore All Frames Återställ alla bildrutor + Flatten Frames Into Clips Sammanfoga bildrutor till klipp + Flatten Frames Into Layers Sammanfoga bildrutor till lager + Convert Frames > Video Layers Konvertera bildrutor > videolager + New Video Layer Nytt videolager + New Video Group Ny videogrupp + Interpret Footage… Tolka material… + Replace Footage… Ersätt material… + Show Altered Video Visa ändrad video + Limit Playback Begränsa uppspelning + Enable Timeline Quick Config Aktivera snabbkonfiguration för tidslinje + Set Start Timecode… Ange starttidskod… + Timecode Tidskod + 30 fps 30 bild/s + 25 fps 25 bild/s + 24 fps 24 bild/s + 15 fps 15 bild/s + 10 fps 10 bild/s + Current Time Aktuell tid + Out Ut + In In + Duration Varaktighet + Repeat Upprepa + Play Once Spela upp en gång + Video Cache Videocache + +# Info panel + Info Options… Infoalternativ… + First Color Readout Första färgavläsning + Second Color Readout Andra färgavläsning + Actual Color Faktisk färg + Grayscale Slider Gråskalereglage + Web Color Webbfärg + HSB Color HSB-färg + Total Ink Total färgmängd + Opacity Mask Opacitetsmask + Ruler Linjal + Current Tool Aktuellt verktyg + Document Size Dokumentstorlek + Document Profile Dokumentprofil + Document Dimensions Dokumentmått + Scratch Sizes Arbetsdiskstorlekar + Efficiency Effektivitet + Timing Tidsinställning + Status Information Statusinformation + Saved Multichannel Sparad flerkanal + Mouse Coordinates Muskoordinater + Ruler Units Linjalenheter + Hide Dölj + +# Layer Comps panel + Layer Comps to Files… Lagerkompositioner till filer… + Apply next selected layer comp Tillämpa nästa markerade lagerkomposition + Apply previous selected layer comp Tillämpa föregående markerade lagerkomposition + Update layer comp (visibility, position and appearance) Uppdatera lagerkomposition (synlighet, position och utseende) + New Layer Comp Ny lagerkomposition + Duplicate Layer Comp Duplicera lagerkomposition + Delete layer comp Ta bort lagerkomposition + Rename Layer Comp Byt namn på lagerkomposition + Create new layer comp Skapa ny lagerkomposition + Capture the visibility, position and style of every layer with + below, then switch between versions. Spara synlighet, position och stil för alla lager med + nedan och växla sedan mellan versionerna. + Apply Layer Comp Använd lagerkomposition + Update Layer Comp Uppdatera lagerkomposition + Restore last viewed comp Återställ senast visade komposition + Last Document State Senaste dokumenttillstånd + Visibility Synlighet + Position Position + Appearance (Layer Style) Utseende (lagerstil) + +# Analysis and variables + Analysis Analys + Variables Variabler + Variable Data Sets… Variabla datauppsättningar… + Data Sets… Datauppsättningar… + Data Sets Datauppsättningar + Data Sets as Files… Datauppsättningar som filer… + Apply Data Set… Tillämpa datauppsättning… + Define Definiera + + Add variable + Lägg till variabel + Remove variable Ta bort variabel + Define at least one variable first. Definiera minst en variabel först. + Open a document to define variables. Öppna ett dokument för att definiera variabler. + Variables bind a layer's visibility, text or pixels to a named data slot. Variabler binder ett lagers synlighet, text eller pixlar till en namngiven dataplats. + Pixel Replacement Pixelersättning + Text Replacement Textersättning + Name Namn + Method Metod + Select All Layers Markera alla lager + Fill Fyllning + Keep proportions Behåll proportioner + Interpolation Interpolering + Author: Författare: + Author Författare + Name: Namn: + Caption Bildtext + Caption Credits Bildtext och kreditering + Description Beskrivning + Keywords Nyckelord + Copyright Info URL URL för upphovsrättsinformation + Copyright Status Upphovsrättsstatus + Copyright Notice Upphovsrättsmeddelande + Document Title Dokumenttitel + Copyrighted Upphovsrättsskyddad + Public Domain Allmän egendom + Unknown Okänd + Copyright and Contact Info Upphovsrätt och kontaktinformation + Credit Kreditering + Transmission Reference Överföringsreferens + Urgency Prioritet + High Hög + Normal Normal + Lossless Förlustfri + Low Låg + Category Kategori + Supplemental Categories Tilläggskategorier + Object Name / Title Objektnamn/titel + Date Created Skapandedatum + City Ort + Location Plats + Province / State Provins / delstat + Country Land + Byline Upphovsperson + Byline Title Upphovspersonens titel + Phone Telefon + Email E-post + URLs URL:er + Last Modified Senast ändrad + EXIF EXIF + GPS GPS + Camera Data Kameradata + Camera Model Kameramodell + Camera Make Kameratillverkare + EXIF Version EXIF-version + Exposure Program Exponeringsprogram + Aperture Bländare + Shutter Speed Slutartid + Metering Mode Mätmetod + Light Source Ljuskälla + Flash Blixt + Lens Info Objektivinfo + ISO Speed ISO-känslighet + Subject Distance Motivavstånd + DateTime Original Ursprungligt datum och tid + Create Date Skapandedatum + Modify Date Ändringsdatum + Software Programvara + Raw Data Rådata + Version {version} Version {version} + Advanced Avancerat + Import… Importera… + +# Automate + Automate Automatisera + Batch… Batch… + Create Droplet… Skapa droplet… + Create Droplet Skapa droplet + PDF Presentation… PDF-presentation… + Create Multiple Picture Packages Skapa flera bildpaket + Contact Sheet II Kontaktkarta II + Contact Sheet II… Kontaktkarta II… + Conditional Mode Change… Villkorsstyrt lägesbyte… + Fit Image… Anpassa bild… + Picture Package… Bildpaket… + Web Photo Gallery… Webbfotogalleri… + Merge to HDR Pro… Sammanfoga till HDR Pro… + Load Files into Stack… Läs in filer i stapel… + Scripts Skript + Script Events Manager… Skripthändelsehanterare… + Image Processor… Bildprocessor… + Destination Mål + Choose… Välj… + Select Folder… Välj mapp… + Override Action "Save" Commands Åsidosätt åtgärdens ”Spara”-kommandon + Compatibility Kompatibilitet + Windows Windows + Mac OS Mac OS + Unix Unix + Include ICC Profile Bädda in ICC-profil + Error File Felfil + Stop For Errors Stoppa vid fel + Log Errors To File Logga fel till fil + Errors Fel + +# Color Settings dialog + Color Settings… Färginställningar… + Settings Inställningar + Settings: Inställningar: + Working Space Arbetsfärgrymd + RGB: RGB: + CMYK: CMYK: + Gray: Grå: + Spot: Dekorfärg: + Advanced Controls Avancerade kontroller + Desaturate Monitor Colors By Avmätta bildskärmsfärger med + Blend RGB Colors Using Gamma 1.0 Blanda RGB-färger med gamma 1,0 + Profile Mismatches: Ask When Opening Profilavvikelser: Fråga vid öppning + Ask When Pasting Fråga vid inklistring + Missing Profiles: Ask When Opening Saknade profiler: fråga vid öppning + Embed profiles Bädda in profiler + Ask Before Saving Saved Embedded Fråga innan inbäddade sparas + Coerce to sRGB Tvinga till sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + +# Themes + Theme Tema + Switch theme Byt tema + Next Theme Nästa tema + Classic Theme Klassiskt tema + Solarized dark Solarized mörk + Solarized Dark Theme Solarized mörkt tema + Breeze dark Breeze mörkt + Breeze Dark Theme Mörkt Breeze-tema + Breeze light Breeze ljust + Breeze Light Theme Ljust Breeze-tema + Soft dark Mjukt mörkt + Soft light Mjukt ljust + Soft Dark Theme Mjukt mörkt tema + Soft Light Theme Mjukt ljust tema + Studio Theme Studio-tema + Studio Light Theme Studio Light-tema + Adwaita Light Theme Adwaita ljust tema + Adwaita Dark Theme Adwaita mörkt tema + Pro Theme Pro-tema + Pro Medium Gray Theme Pro mellangrått tema + Pro Light Gray Theme Pro ljusgrått tema + Pro Light Theme Pro ljust tema + +# Workspaces + Workspace… Arbetsyta… + Essentials Grundläggande + New Workspace… Ny arbetsyta… + Delete Workspace… Ta bort arbetsyta… + Reset Workspace Återställ arbetsyta + Reset Tool Återställ verktyg + Reset All Tools Återställ alla verktyg + Reset all tools to their default settings? Vill du återställa alla verktyg till standardinställningarna? + Lock Workspace Lås arbetsyta + Photography Fotografi + Graphic and web Grafik och webb + Painting Målning + Motion Rörelse + Minimalistic Minimalistisk + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panelernas placering sparas i arbetsytan. Kortkommandon, menyer och verktygsfält är valfria. + Keyboard Shortcuts Kortkommandon + Keyboard Shortcuts and Menus Kortkommandon och menyer + Menus Menyer + More panels Fler paneler + More documents Fler dokument + Collapse All Groups Komprimera alla grupper + Expand All Groups Expandera alla grupper + Collapse Komprimera + Expand Expandera + Collapse Panel Group Komprimera panelgrupp + Expand Panel Group Expandera panelgrupp + Close Tab Group Stäng flikgrupp + +# New Document dialog + PRESET DETAILS DETALJER FÖR FÖRINSTÄLLNING + BLANK DOCUMENT PRESETS ({n}) FÖRINSTÄLLNINGAR FÖR TOMMA DOKUMENT ({n}) + Create a new document or open an existing file. Skapa ett nytt dokument eller öppna en befintlig fil. + Drop an image or PSD anywhere to open it. Släpp en bild eller PSD var som helst för att öppna den. + Use File › Open, or paste a copied image with {paste}. Använd Arkiv › Öppna, eller klistra in en kopierad bild med {paste}. + Native file drag-and-drop is unavailable Inbyggt dra och släpp av filer är inte tillgängligt + 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}. Inbyggt dra och släpp av filer stöds inte på Wayland ännu. Använd Arkiv › Öppna, eller kopiera bilden i filhanteraren och klistra in den med {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Starta PhotoCraft under XWayland för att kunna släppa filer: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Eller ställ in Inställningar › Prestanda › Linux-visningsserver på X11: då startar PhotoCraft alltid under XWayland. + Edit › Paste Redigera › Klistra in + Photo Foto + Print Skriv ut + Art & Illustration Konst och illustration + Mobile Mobil + Film & Video Film och video + Default PhotoCraft Size Standardstorlek för PhotoCraft + Clipboard Urklipp + Photo (2 × 3 in) Foto (2 × 3 tum) + Photo (4 × 6 in) Foto (4 × 6 tum) + Photo (5 × 7 in) Foto (5 × 7 tum) + Photo (8 × 10 in) Foto (8 × 10 tum) + Landscape Liggande + Portrait Stående + Recent Items Senaste objekt + Save Preset… Spara förinställning… + Delete Preset… Ta bort förinställning… + Preset Name: Namn på förinställning: + Width and Height Bredd och höjd + Background Contents Bakgrundsinnehåll + Custom background color Anpassad bakgrundsfärg + White Vit + Background Color Bakgrundsfärg + Transparent Genomskinlig + Color Mode Färgläge + Depth Djup + Color Profile Färgprofil + Create Skapa + Document Dokument + Units Enheter + Pixels Pixlar + Inches Tum + Centimeters Centimeter + Millimeters Millimeter + Points Punkter + Picas Pica + Columns Kolumner + +# Placeholders, tooltips, status and keyboard hints + {name} options Alternativ för {name} + Align {what} Justera {what} + Untagged {mode} Otaggad {mode} + Invalid {mode} profile Ogiltig {mode}-profil + Unnamed {mode} profile Namnlös {mode}-profil + Constraints ({n}) Begränsningar ({n}) + Count: {n} Antal: {n} + Click to add a point · drag off or {key}-click or Delete to remove Klicka för att lägga till en punkt · dra bort, {key}-klicka eller tryck på Delete för att ta bort + Click to apply, {key}-click to clear overrides, double-click for Style Options Klicka för att tillämpa, {key}-klicka för att rensa åsidosättningar, dubbelklicka för stilalternativ + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klick: hörn · Dra: mjuk · Klicka på första punkten: stäng · {key} slutför · Esc avbryt + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klick: hörn · Dra: mjuk · {alt}-dra: bryt handtag · Klicka eller dra första punkten: stäng · {key} slutför · Esc avbryt + Commit Perspective Warp ({key}) Verkställ perspektivdeformering ({key}) + Commit Puppet Warp ({key}) Verkställ marionettdeformering ({key}) + Commit any current edits ({key}) Verkställ aktuella ändringar ({key}) + Commit transform ({key}) Verkställ ombildning ({key}) + Commit warp ({key}) Verkställ deformering ({key}) + Cancel any current edits (Esc) Avbryt alla pågående ändringar (Esc) + Cancel transform (Esc) Avbryt ombildning (Esc) + Cancel warp (Esc) Avbryt deformering (Esc) + Do you want to save the changes you made to “{name}” before closing? Vill du spara ändringarna du gjort i ”{name}” innan du stänger? + Revert “{name}” to the last saved version? Your changes will be lost. Vill du återgå till den senast sparade versionen av ”{name}”? Dina ändringar går förlorade. + {key} to subtract {key} för att subtrahera + ↑↓ navigate {key} run esc close ↑↓ navigera {key} kör esc stäng + Click to restore the current colour Klicka för att återställa den aktuella färgen + Click with the Note tool to add a note. Klicka med anteckningsverktyget för att lägga till en anteckning. + Drag a rectangle around the object Dra en rektangel runt objektet + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Dra i bilden för att lägga till en begränsning; Shift för vågrätt/lodrätt; högerklicka på en begränsning för att ändra eller ta bort den. + Drag the arrow to set the angle, the dots to set the roundness Dra pilen för att ange vinkeln och punkterna för att ange rundheten + Create new pattern from the selection Skapa nytt mönster från markeringen + Create new gradient Skapa ny gradient + Create new count group Skapa ny räknegrupp + Search commands, tools and panels… Sök kommandon, verktyg och paneler… + Search Brushes Sök penslar + Search fonts Sök teckensnitt + No matching commands Inga matchande kommandon + No document Inget dokument + Pixel Layer Pixellager + Type Layer Textlager + Shape Layer Formlager + Group Layer Grupplager + Fill Layer Fyllningslager + Smart Object Layer Lager med smart objekt + Adjustment Layer Justeringslager + {name} Properties Egenskaper för {name} + Move Tool Flyttverktyg + Could not use the move tool because the layer is locked. Det gick inte att använda flyttverktyget eftersom lagret är låst. + Could not complete the Free Transform command because the layer is locked. Det gick inte att slutföra kommandot Ombilda fritt eftersom lagret är låst. + Rectangular Marquee Tool Verktyget Rektangulär markeringsram + Elliptical Marquee Tool Verktyget Elliptisk markeringsram + Single Row Marquee Tool Verktyget Enradsmarkeringsram + Single Column Marquee Tool Verktyget Enkolumnsmarkeringsram + Brush Tool Penselverktyg + Pencil Tool Blyertspennverktyg + Eraser Tool Suddgummiverktyg + Background Eraser Tool Bakgrundssuddgummiverktyg + Magic Eraser Tool Verktyget Magiskt suddgummi + Eyedropper Tool Pipettverktyg + Note Tool Anteckningsverktyg + Lasso Tool Lassoverktyg + Polygonal Lasso Tool Verktyget Polygonlasso + Magic Wand Tool Trollstavsverktyg + Crop Tool Beskärningsverktyg + Slice Tool Skivverktyg + Slice Select Tool Skivmarkeringsverktyg + Gradient Tool Gradientverktyg + Paint Bucket Tool Färgpytsverktyg + Horizontal Type Tool Verktyget Vågrät text + Horizontal Type Mask Tool Verktyget Vågrät textmask + Vertical Type Mask Tool Verktyget Lodrät textmask + Hand Tool Handverktyg + Zoom Tool Zoomverktyg + Spot Healing Brush Tool Verktyget Punktlagningspensel + Healing Brush Tool Lagningspenselverktyg + Clone Stamp Tool Klonstämpelverktyg + History Brush Tool Historikpenselverktyg + Blur Tool Oskärpeverktyg + Sharpen Tool Skärpeverktyg + Smudge Tool Verktyget Smeta ut + Dodge Tool Skuggningsverktyg + Burn Tool Efterbelysningsverktyg + Sponge Tool Svampverktyg + Quick Selection Tool Snabbmarkeringsverktyg + Object Selection Tool Objektmarkeringsverktyg + Pen Tool Pennverktyg + Convert Point Tool Verktyget Konvertera punkt + Path Selection Tool Banmarkeringsverktyg + Direct Selection Tool Direktmarkeringsverktyg + Add Anchor Point Tool Verktyget Lägg till ankarpunkt + Delete Anchor Point Tool Verktyget Ta bort ankarpunkt + Click a segment to add an anchor point · {alt}-click an anchor to delete it Klicka på ett segment för att lägga till en ankarpunkt · {alt}-klicka på en ankarpunkt för att ta bort den + Click an anchor point to delete it · {alt}-click a segment to add one Klicka på en ankarpunkt för att ta bort den · {alt}-klicka på ett segment för att lägga till en + Rectangle Tool Rektangelverktyg + Ellipse Tool Ellipsverktyg + Triangle Tool Triangelverktyg + Polygon Tool Polygonverktyg + Line Tool Linjeverktyg + Custom Shape Tool Verktyget Anpassad form + Pixel Pixel + Group Grupp + Adjustment Justering + Smart Object Smart objekt + Drag to move the active layer Dra för att flytta det aktiva lagret + Drag to pan · hold Space with any tool Dra för att panorera · håll ned blanksteg med valfritt verktyg + Drag to draw a gradient Dra för att rita en gradient + Click to fill similar colours Klicka för att fylla liknande färger + Click to add text Klicka för att lägga till text + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Dra (lasso) eller klicka på punkter (polygonal) · {add} lägg till · {sub} subtrahera + 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 Dra en beskärningsram · dra inuti för att flytta · kanterna ändrar storlek ({ratio} proportioner, {centre} centrerat) · Blanksteg flyttar medan du ritar · piltangenterna flyttar stegvis · X byter orientering · {commit} verkställer · Esc avbryter + {mode} Color · {bits} bit {mode}-färg · {bits} bitar + Drag to move the layer Dra för att flytta lagret + Fit Screen Anpassa till skärmen + Fill Screen Fyll skärmen + Scrubby Zoom Dragzoom + Auto Erase Radera automatiskt + Color Adaptation Färganpassning + Preserve Transparency Bevara genomskinlighet + Reverse Omvänd + Align with layer Justera efter lager + Join us on Discord Gå med i vår Discord + PhotoCraft website PhotoCrafts webbplats + ArtCraft ArtCraft + GitHub GitHub + No character style Inget teckenformat + No author Ingen författare + Not in Dictionary: Finns inte i ordlistan: + Suggestions: Förslag: + Spell check complete. Stavningskontrollen är klar. + Check All Layers Kontrollera alla lager + Select a type layer to edit its character and paragraph settings. Markera ett textlager för att redigera dess tecken- och styckeinställningar. + Select text to apply a character style Markera text för att använda ett teckenformat + Redefine style by current selection Omdefiniera format efter aktuell markering + Duplicate style Duplicera stil + Delete style Ta bort stil + Style Options Stilalternativ + Style Options… Stilalternativ… + Close Style Options Stäng stilalternativ + Appearance Utseende + Text Position: Textposition: + Text color Textfärg + Uses the foreground and background colours. Använder förgrunds- och bakgrundsfärgerna. + Answer the unsaved-changes prompt first Svara först på frågan om osparade ändringar + Unsaved changes Osparade ändringar + Don't Save Spara inte + No Nej + Yes Ja + These settings aren't available in PhotoCraft yet. De här inställningarna finns inte i PhotoCraft ännu. + Shortcut removed. Kortkommandot har tagits bort. + All shortcuts reset to PhotoCraft defaults. Alla kortkommandon har återställts till PhotoCrafts standardvärden. + Add Shortcut Lägg till kortkommando + Delete Shortcut Ta bort kortkommando + Press keys… Tryck på tangenter… + shortcut kortkommando + command or shortcut kommando eller kortkommando + current aktuell + image file path sökväg till bildfil + modified ändrad + name namn + new ny + Go to Layer Gå till lager + Open a document to select a colour range. Öppna ett dokument för att välja ett färgintervall. + Saved Sparat + Reveal in Finder Visa i Finder + Show in Explorer Visa i Utforskaren + Show in Folder Visa i mapp + Before (Y) Före (Y) + Save Settings… Spara inställningar… + Load Settings… Läs in inställningar… + Save these settings as a Camera Raw .xmp file Spara de här inställningarna som en .xmp-fil för Camera Raw + Load settings from a Camera Raw .xmp file Läs in inställningar från en Camera Raw .xmp-fil + Loaded Camera Raw settings from {file} Läste in Camera Raw-inställningar från {file} + Loaded Camera Raw settings from {file} ({n} not supported) Läste in Camera Raw-inställningar från {file} ({n} stöds inte) + After (X) Efter (X) + Original Original + Current Aktuell + {added}, {removed} subtracted {added}, {removed} subtraherade + Filter: Filter: + +# Transform / warp overlays + Free Transform Ombilda fritt + Free Transform a Copy Ombilda en kopia fritt + Transform Ombilda + Transform Selection Ombilda markering + Switch between free transform and warp modes Växla mellan fri ombildning och deformering + Perspective Perspektiv + Skew Snedställ + Distort Förvräng + Rotate: Rotera: + Flip Vänd + Warp: Deformering: + Warp Deformera + Bend: Böjning: + Reference point location Referenspunktens placering + Toggle reference point Växla referenspunkt + Free Fri + Arc Båge + Arch Valv + Bulge Utbuktning + Shell Lower Nedre skal + Shell Upper Övre skal + Flag Flagga + Fish Fisk + Wave Våg + Rise Stigning + Fisheye Fisköga + Inflate Blås upp + Twist Vridning + Squeeze Kläm ihop + Corner radius Hörnradie + Split Warp Crosswise Dela deformering korsvis + Split Warp Horizontally Dela deformering vågrätt + Split Warp Vertically Dela deformering lodrätt + Remove Warp Split Ta bort deformeringsdelning + Puppet Warp Marionettdeformering + Puppet Warp is not active Marionettdeformering är inte aktiv + Perspective Warp Perspektivdeformering + Perspective Warp is not active Perspektivdeformering är inte aktiv + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivdeformering: rita plan längs bildens perspektiv och växla sedan till Deformera + Remove Planes Ta bort plan + Edit Pins Redigera nålar + Add Pins Lägg till nålar + Delete Pins Ta bort nålar + Remove All Pins Ta bort alla nålar + Remove Tool Borttagningsverktyg + Pin Depth: Nåldjup: + Bring the selected pin forward Flytta fram den markerade nålen + Send the selected pin backward Flytta den markerade nålen bakåt + Rotate Auto Rotera automatiskt + Rotate Fixed Fast rotation + Adaptive Wide Angle isn't open Adaptiv vidvinkel är inte öppen + Liquify is not open Gör flytande är inte öppet + +# Vanishing Point + Create Plane Skapa plan + Edit Plane Redigera plan + Select Plane Välj plan + Measure Mät + Heal Laga + Off Av + Symmetry Off Symmetri av + Heal Off Lagning av + Heal On Lagning på + Heal Auto Automatisk lagning + Blend Blanda + Brush Size Penselstorlek + Heal Color Lagningsfärg + +# Misc dialog strings + Mask Mask + Channel Kanal + Rectangle Rektangel + Subtract Subtrahera + Multiply Multiplicera + Divide Dividera + Result Resultat + New Document Nytt dokument + New Document With Mask Nytt dokument med mask + New Layer With Mask Nytt lager med mask + New Layer Nytt lager + Preset Name Namn på förinställning + No saved presets yet. Inga sparade förinställningar än. + New Channel Ny kanal + Selection Markering + Save Selection Spara markering + Load Selection Läs in markering + Duplicate Duplicera + Fast Snabb + Accurate Exakt + Add to Lägg till i + Subtract from Subtrahera från + Intersect with Skär med + +# Blend modes + Pass Through Genomsläpp + Dissolve Upplös + Darken Mörkare + Color Burn Färgbränning + Linear Burn Linjär efterbelysning + Darker Color Mörkare färg + Lighten Ljusare + Screen Skärm + Color Dodge Färgskuggning + Linear Dodge (Add) Linjär skugga (lägg till) + Lighter Color Ljusare färg + Overlay Överlägg + Soft Light Mjukt ljus + Hard Light Hårt ljus + Vivid Light Intensivt ljus + Linear Light Linjärt ljus + Pin Light Punktljus + Hard Mix Hård blandning + Difference Differens + Exclusion Uteslutning + Luminosity Luminositet + +# Saved views and other bits + Capture Comp Spara komposition + No Layer Comps Inga lagerkompositioner + +# Plurals +@plural {n} item|{n} items {n} objekt|{n} objekt +@plural {n} Layer|{n} Layers {n} lager|{n} lager +@plural {n} layer|{n} layers {n} lager|{n} lager +@plural Group · {n} layer|Group · {n} layers Grupp · {n} lager|Grupp · {n} lager +@plural {n} sample|{n} samples {n} prov|{n} prov + +# Remaining menu strings (second pass) + 180° 180° + 32-bit Preview Options… Alternativ för 32-bitars förhandsgranskning… + 90° Clockwise 90° medurs + 90° Counter Clockwise 90° moturs + AI Integrations… AI-integrationer… + Again Igen + Transform Again on a Copy Ombilda igen på en kopia + Anamorphic 2:1 (2) Anamorfisk 2:1 (2) + Arbitrary… Godtyckligt… + Artboard Guides Stödlinjer för ritytor + Artboard from Group… Rityta från grupp… + Artboard from Layers… Rityta från lager… + Artboards to Files… Ritytor till filer… + Artboards to PDF… Ritytor till PDF… + Artboard… Rityta… + Border… Ram… + Bottom Edges Nederkanter + Browse… Bläddra… + Source Files Källfiler + Use: Använd: + Files Filer + Folder Mapp + Add Open Files Lägg till öppna filer + Sort By Name Sortera efter namn + Attempt to Automatically Align Source Images Försök justera källbilderna automatiskt + Create Smart Object after Loading Layers Skapa smart objekt efter inläsning av lager + Canvas Guides Stödlinjer för arbetsytan + Character Styles Panel Panelen Teckenformat + Clear Canvas Guides Rensa stödlinjer för arbetsytan + Clear Selected Artboard Guides Rensa stödlinjer för markerad rityta + Color Blindness — Deuteranopia-type Färgblindhet — deuteranopityp + Color Blindness — Protanopia-type Färgblindhet — protanopityp + Color Table… Färgtabell… + Combine Shapes Kombinera former + Contract… Dra ihop… + Convert to Embedded Konvertera till inbäddat + Convert to Layers Konvertera till lager + Convert to Linked… Konvertera till länkat… + Count Antal + Create Layer Skapa lager + Custom Pixel Aspect Ratio… Anpassat pixelförhållande… + Cycle Screen Mode Växla skärmläge + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC bredbild (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL bredbild (1,46) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + De-Interlace… Avfläta… + Default Features Standardfunktioner + Delete All Empty Layers Ta bort alla tomma lager + Desaturate Avmätta + Displace… Förskjut… + Distribute Fördela + Document Bounds Dokumentgränser + Edit in Quick Mask Mode Redigera i snabbmaskläge + Enhanced Controls… Utökade kontroller… + Entropy Entropi + Exclude Overlapping Shapes Uteslut överlappande former + Exit Avsluta + Expand… Expandera… + Extra Large Extra stor + File Info… Filinformation… + Fit Artboard on Screen Anpassa ritytan till skärmen + Fit Layer(s) on Screen Anpassa lager till skärmen + Flatten All Layer Effects Sammanfoga alla lagereffekter + Flatten All Masks Sammanfoga alla masker + Flatten Image Kombinera lager + Float All in Windows Gör alla flytande i fönster + Float in Window Gör flytande i fönster + Frame from Layers Ram från lager + From Transparency Från genomskinlighet + Gamut Warning Färgomfångsvarning + Global Light… Globalt ljus… + Gradient… Gradient… + Graphic and Web Grafik och webb + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Hidden Layers Dolda lager + Hide All Effects Dölj alla effekter + Horizontal Centers Vågräta mittpunkter + Huge Enorm + Indexed Color… Indexerad färg… + Integrations… Integrationer… + Intersect Shape Areas Skär formområden + Kurtosis Kurtosis + Large Stor + Layer Edges Lagerkanter + Left Edges Vänsterkanter + Lens Blur… Objektivoskärpa… + Lighting Effects… Ljuseffekter… + Link Layers Länka lager + Lock Layers… Lås lager… + Mean Medelvärde + Merge Down Sammanfoga nedåt + Merge Group Sammanfoga grupp + Merge Layers Sammanfoga lager + Merge Shape Components Sammanfoga formkomponenter + Merge Visible Sammanfoga synliga + Stamp Visible Stämpla synliga + Select Layer Above Markera lagret ovanför + Select Layer Below Markera lagret under + Select Top Layer Markera översta lagret + Select Bottom Layer Markera nedersta lagret + Add Layer Above to Selection Lägg till lagret ovanför i markeringen + Add Layer Below to Selection Lägg till lagret nedanför i markeringen + Stamp Down Stämpla nedåt + Mesh Nät + Middle Eastern & South Asian Composer Radbrytning för Mellanöstern och Sydasien + Modifier Keys Modifieringstangenter + Modify Ändra + NTSC Colors NTSC-färger + Notes… Anteckningar… + Other Annat + Other… Annat… + Paragraph Styles Panel Panelen Styckeformat + Paths to Illustrator… Banor till Illustrator… + Pattern Preview Förhandsgranskning av mönster + Pattern… Mönster… + Pixel Art Pixelgrafik + Pixel Art Preview Förhandsgranskning av pixelgrafik + Pixel Grid Pixelrutnät + Pixelate Pixla + Place Scale Marker… Placera skalmarkör… + Plug-ins Plugin-program + Print One Copy Skriv ut en kopia + Print… Skriv ut… + Range Intervall + Raw Defaults… Raw-standardvärden… + Relink to File… Länka om till fil… + Relight… Ändra belysning… + Render Rendera + Replace All Missing Fonts Ersätt alla saknade teckensnitt + Resolve Missing Fonts… Lös saknade teckensnitt… + Reveal Selection Visa markering + Right Edges Högerkanter + Selection Edges Markeringskanter + Shapes Former + Show Extras Options… Alternativ för extrafunktioner… + Skewness Skevhet + Small Liten + Smart Guides Smarta stödlinjer + Smart Objects Smarta objekt + Smooth… Jämna ut… + Solid Color… Enfärgad… + Square Kvadratisk + Stack Mode Staplingsläge + Choose Stack Mode: Välj staplingsläge: + Standard Deviation Standardavvikelse + Statistics… Statistik… + Stylistic Alternates Stilistiska alternativ + Stylize Stilisera + Subtract Front Shape Subtrahera främre form + Summation Summa + Target Path Målbana + Tile All Horizontally Lägg alla sida vid sida vågrätt + Tile All Vertically Lägg alla sida vid sida lodrätt + Toggle Last State Växla till senaste tillstånd + Toolbar… Verktygsfält… + Tools… Verktyg… + Top Edges Överkanter + Unite Shapes Förena former + Update All Modified Content Uppdatera allt ändrat innehåll + Update All Text Layers Uppdatera alla textlager + Update Modified Content Uppdatera ändrat innehåll + Variance Varians + Vertical Centers Lodräta mittpunkter + Video Frames to Layers… Videobildrutor till lager… + WIA Support… WIA-stöd… + Working CMY Plate CMY-arbetsplåt + +# tl! literals (third pass) + Absolute Absolut + Action Åtgärd + Adaptive Adaptiv + Add Stop Lägg till färgstopp + Add disk Lägg till disk + Align and Distribute Justera och fördela + All Slices Alla skivor + All User Slices Alla användarskivor + Alt Alt + Artboard Rityta + Artboard Background Color Bakgrundsfärg för rityta + Auto Hide Dölj automatiskt + Automatically level near-horizontal line segments Gör nästan vågräta linjesegment vågräta automatiskt + Automatically straighten and level Räta upp och gör vågrätt automatiskt + Automatically straighten near-vertical line segments Räta upp nästan lodräta linjesegment automatiskt + Balance Balans + Baseline Baslinje + Bicubic Bikubisk + Bicubic (smooth gradients) Bikubisk (jämna övergångar) + Automatic Automatisk + Bicubic Smoother (enlargement) Bikubisk mjukare (förstoring) + Bicubic Sharper (reduction) Bikubisk skarpare (förminskning) + Bilinear Bilinjär + Black, White Svart, vit + Blue, Red, Yellow Blå, röd, gul + Bottom Nederkant + Brightness Jitter Variation i ljusstyrka + Brush Reconstruct Options Alternativ för penselrekonstruktion + Cache Level: Cachenivå: + Canvas Arbetsyta + Capture Fånga + Center Crop Marks Centrerade beskärningsmärken + Centre the gradient (offset 0, 0) Centrera gradienten (förskjutning 0, 0) + Change Screen Mode (F) Ändra skärmläge (F) + Change To: Ändra till: + Clear All Rensa alla + Clear Override Rensa åsidosättning + Clear Samples Rensa prover + Cmd Cmd + Color Grading Färggradering + Color Management Färghantering + Color Priority Färgprioritet + Color Range preview Förhandsgranskning av färgintervall + Colorize Färglägg + Constant Konstant + Content-Aware Innehållsmedveten + Convert to sRGB Konvertera till sRGB + Copies Kopior + Copper Koppar + Corner Crop Marks Beskärningsmärken i hörnen + Correction Korrigering + Count Jitter Antalsvariation + Create Texture Skapa struktur + Create a new count group Skapa en ny räknegrupp + Crop Factor Beskärningsfaktor + Ctrl Ctrl + Custom Shapes Anpassade former + Cyan Cyan + Cyan · Red Cyan · Röd + Darks Mörka toner + Default printer Standardskrivare + Default restored. Standardvärdet har återställts. + Delete Group Ta bort grupp + Delete count group Ta bort räknegrupp + Delete effect layer Ta bort effektlager + Delete note Ta bort anteckning + Delete tool preset Ta bort verktygsförinställning + Depth Jitter Djupvariation + Discontiguous Ej sammanhängande + Discord Discord + Display P3 Display P3 + Display Text Visa text + Divide… Dela… + Do you want to save the changes you made to “{name}” before quitting? Vill du spara ändringarna du gjort i ”{name}” innan du avslutar? + Edit Toolbar… Redigera verktygsfält… + Edit in Quick Mask Mode (Q) Redigera i snabbmaskläge (Q) + Edit in Standard Mode (Q) Redigera i standardläge (Q) + Embedded Profile Mismatch Inbäddad profil stämmer inte + Enhance Edge Förbättra kant + Exact Exakt + Expansion: Expansion: + Exposure and Gamma Exponering och gamma + Factor: Faktor: + Fewer Points Färre punkter + File Settings Filinställningar + Finger Painting Fingermålning + Fit in View Anpassa till vyn + Fixed Fast + Foreground to Background Förgrund till bakgrund + Foreground/Background Jitter Förgrunds-/bakgrundsvariation + Format Format + Frame from Layers… Ram från lager… + Full Spherical Helt sfärisk + Gamma Correction Gammakorrigering + Gamma: Gamma: + Global Global + Grain Korn + Graphics Grafik + Gray Grå + Heart Hjärta + Hide Auto Slices Dölj automatiska skivor + Highlight Compression Högdagerkomprimering + Highlight Priority Högdagerprioritet + Highlights Högdagrar + Home Start + Hue Jitter Nyansvariation + Image Size: Bildstorlek: + Imported Importerad + Input: Indata: + Interlaced Sammanflätad + Invert All Invertera alla + Label Size Etikettstorlek + Labels Etiketter + Lanczos (sharp) Lanczos (skarp) + Level Nivå + Lights Ljusa toner + Limits: Gränser: + Link the mask to the layer Länka masken till lagret + Localized Color Clusters Lokaliserade färgkluster + Luminance Luminans + Magenta Magenta + Magenta · Green Magenta · Grön + Marker Size Markörstorlek + Mask Options Maskalternativ + Masking Maskering + Master Huvud + Mean: Medelvärde: + Median: Median: + Mesh Size Nätstorlek + Metadata Metadata + Midpoint Mittpunkt + Midtones Mellantoner + Midtones from: Mellantoner från: + Midtones to: Mellantoner till: + Minimum Depth Minsta djup + Mix Jitter Blandningsvariation + Mode: Läge: + Monochrome Monokrom + More Points Fler punkter + Move Group Down Flytta grupp nedåt + Move Group Up Flytta grupp uppåt + Move effect layer down Flytta effektlager nedåt + Move effect layer up Flytta effektlager uppåt + Move to Flytta till + Nearest Neighbor Närmaste granne + Nearest Neighbor (hard edges) Närmaste granne (skarpa kanter) + New effect layer Nytt effektlager + Next note Nästa anteckning + No Color Management Ingen färghantering + Optimized Optimerad + Output Levels: Utdatanivåer: + Output: Utdata: + Paint Overlay Målningsöverlägg + Pattern Size Mönsterstorlek + PhotoCraft PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — en inbyggd bildredigerare med öppen källkod, skriven i Rust. + Position and Size Position och storlek + Preserve Details Bevara detaljer + Preserve Luminosity Bevara luminositet + Previous note Föregående anteckning + Printer Skrivare + Printer Setup Skrivarinställningar + Printing Marks Tryckmärken + Progressive Progressiv + Promote Höj nivå + Protect Detail Skydda detaljer + Protect Tones Skydda toner + Proximity Match Närhetsmatchning + Purity Renhet + Quality Kvalitet + Recent Senaste + Reconstruct Rekonstruera + Refine Förfina + Registration Marks Passmärken + Regular Normal + Remove stop Ta bort stopp + Rename Group… Byt namn på grupp… + Rename Layer… Byt namn på lager… + Restore All Återställ alla + Restrictive (Web) Begränsad (webb) + Rigid Styv + Roughness Grovhet + Roundness Rundhet + Rulers & Grids Linjaler och rutnät + Sample: foreground colour Prov: förgrundsfärg + Sampling: Background Swatch Provtagning: bakgrundsfärgruta + Sampling: Continuous Provtagning: kontinuerlig + Sampling: Once Provtagning: en gång + Saturate Mätta + Saturation Jitter Mättnadsvariation + Save HTML and Images Spara HTML och bilder + Save as PDF Spara som PDF + Scale to Fit Media Skala för att passa mediet + Scatter Spridning + Scratch disks Arbetsdiskar + Selective Selektiv + Shadows Skuggor + Shape: Form: + Sharpening Skärpning + Shift Shift + Show All Menu Items Visa alla menyalternativ + Show Backdrop Visa bakgrund + Show Mask Visa mask + Show Mesh Visa nät + Show or hide the Notes panel Visa eller dölj panelen Anteckningar + Show the layer mask as an overlay Visa lagermasken som ett överlägg + Show: Visa: + Sides Sidor + Sides: Sidor: + Slice Options… Skivalternativ… + Slices From Guides Skivor från stödlinjer + Slices are locked (View › Lock Slices) Skivorna är låsta (Visa › Lås skivor) + Split: Dela: + Star ratio Stjärnförhållande + Std Dev: Std.avv.: + Straighten Räta upp + Straighten Layer Räta upp lager + Strength: Styrka: + Stroke Linje + Stroke Catch-up Låt penseldraget komma ikapp + Stroke: Linje: + Location: Plats: + Inside Inuti + Outside Utanför + Tetrahedral Tetraedrisk + Texture Each Tip Strukturera varje spets +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradientredigerare + Gradient picker Gradientväljare + Save As Preset Spara som förinställning + The gradient the Gradient tool paints with Gradienten som Gradientverktyget målar med + Adds the current gradient to the Gradients panel Lägger till den aktuella gradienten i panelen Gradienter + Gradient stops Gradientstopp + Click to edit the gradient Klicka för att redigera gradienten + Tilt X Lutning X + Tilt Y Lutning Y + Toggle count group visibility Visa/dölj räknegruppen + Toggle text orientation Växla textorientering + Tone: Ton: + Toolbar Verktygsfält + Top Överkant + Total: Totalt: + Transparency Genomskinlighet + Trilinear Trilinjär + Unlink the mask from the layer Ta bort länken mellan masken och lagret + View Options Visningsalternativ + Violet, Orange Violett, orange + Visible Synlig + Weight Tjocklek + Weight: Tjocklek: + Wetness Jitter Våthetsvariation + Whites Vita + Yellow Gul + Yellow · Blue Gul · Blå + +# Preference dialog: generated setting labels and dropdown choices + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Tillåt agentstyrning + Allow scripts to connect Tillåt skript att ansluta + Always Alltid + Always create smart objects when placing Skapa alltid smarta objekt vid placering + Animated zoom Animerad zoom + Apply auto tone Tillämpa autoton + Ask Fråga + Ask before saving layered tiff Fråga innan TIFF med lager sparas + Autosave Spara automatiskt + Autosave minutes Minuter för automatisk sparning + Beep when done Pip när det är klart + Bicubic automatic Bikubisk automatisk + Bicubic sharper Bikubisk skarpare + Bicubic smoother Bikubisk mjukare + Bit depth Bitdjup + Brush picker Penselväljare + Brush preview color Färg för penselförhandsgranskning + CPU (no GPU acceleration) CPU (ingen GPU-acceleration) + Cache levels Cachenivåer + Cache tile size Storlek på cacheblock + Camera Raw Defaults Standardinställningar för Camera Raw + Canvas border Arbetsytans kant + Show canvas dots Visa punkter på arbetsytan + Clip thumbnails to layer bounds Begränsa miniatyrer till lagergränser + Clip Thumbnails to Layer Bounds Begränsa miniatyrer till lagergränser + Canvas color Arbetsytans färg + Canvas custom color Anpassad färg för arbetsytan + Classic Klassisk + Color picker Färgväljare + Color space Färgrymd + Column width Kolumnbredd + Concise Koncis + Control port Kontrollport + Convert to srgb Konvertera till sRGB + Cursors Markörer + Custom dark Anpassad mörk + Custom light Anpassad ljus + Dark Mörk + Dark gray Mörkgrå + Dashed lines Streckade linjer + Detailed Detaljerad + DirectX 12 DirectX 12 + Disks Diskar + Display p3 Display P3 + Dots Punkter + Double click layer mask launches select and mask Dubbelklick på lagermask öppnar Markera och maskera + Drop shadow Skugga + Dynamic color sliders Dynamiska färgreglage + East asian Östasiatisk + Effect cache mb Effektcache (MB) + Enable flick panning Aktivera snärtpanorering + Enable floating document window docking Aktivera dockning av flytande dokumentfönster + Enable narrow options bar Aktivera smalt alternativfält + Enhanced Controls Utökade kontroller + Erase Radera + Export clipboard Exportera urklipp + Extra large Extra stor + File Handling Filhantering + File path Filsökväg + Fill new type layers with placeholder Fyll nya textlager med platshållartext + Font preview Förhandsgranskning av teckensnitt + Full size tip Spets i full storlek + GPU backend GPU-backend + Linux display server Linux-visningsserver + X11 X11 + Gamut warning color Varningsfärg för färgomfång + Gamut warning opacity Opacitet för färgomfångsvarning + General Allmänt + Enable Generator Aktivera Generator + Gif GIF + Glossy Glansig + Grid color Rutnätsfärg + Grid colors Rutnätsfärger + Grid size Rutnätsstorlek + Grid style Rutnätsstil + Grid unit Rutnätsenhet + Gridline every Rutnätslinje var + Guide color Stödlinjefärg + Guide style Stödlinjestil + Guides, Grid & Slices Stödlinjer, rutnät och skivor + Gutter Spaltmellanrum + History Log Historiklogg + History states Historiktillstånd + Ignore exif profile tag Ignorera EXIF-profiltagg + Image interpolation Bildinterpolering + Image previews Bildförhandsgranskningar + Integrations Integrationer + Interface Gränssnitt + Jpeg quality JPEG-kvalitet + Jpg JPG + Large tabs Stora flikar + Legacy compositing Äldre sammansättning + Light gray Ljusgrå + Line Linje + Lines Linjer + Lowercase extension Filändelse med gemener + Maximize psd compatibility Maximera PSD-kompatibilitet + Medium gray Mellangrå + Memory usage mb Minnesanvändning (MB) + Low resolution previews Förhandsgranskningar med låg upplösning + Metal Metal + Missing glyph protection Skydd mot saknade glyfer + Modern Modern + Nearest neighbor Närmaste granne + Never Aldrig + Normal tip Normal spets + Open as smart object Öppna som smart objekt + Open in camera raw Öppna i Camera Raw + Open documents as tabs Öppna dokument som flikar + OpenGL OpenGL + Orange Orange + Overscroll Överrullning + Performance Prestanda + Png PNG + Point size Punktstorlek + PostScript (72 points/inch) PostScript (72 punkter/tum) + Precise Exakt + Preserve details Bevara detaljer + Print resolution Utskriftsupplösning + Pro Pro + Pro light Pro ljus + Pro light gray Pro ljusgrå + Pro medium Pro medel + Pro photo ProPhoto + Purple Lila + Quick export format Format för snabbexport + Quick export location Plats för snabbexport + Recent file count Antal senaste filer + LUT folder LUT-mapp + Recent files Senaste filer + Recent fonts Senaste teckensnitt + Recover on launch Återställ vid start + Remember workspace changes Kom ihåg ändringar i arbetsytan + Resize image during place Ändra bildstorlek vid placering + Right click with painting tools Högerklick med målarverktyg + Rotate view with trackpad Rotera vyn med styrplattan + Same folder Samma mapp + Save in background Spara i bakgrunden + Scratch Disks Arbetsdiskar + Screen resolution Skärmupplösning + Scrubby slider acceleration Acceleration för dragreglage + Sessions only Endast sessioner + Sharpen for Skärpa för + Show channels in color Visa kanaler i färg + Show crosshair in brush tip Visa hårkors i penselspetsen + Show extension panels Visa tilläggspaneler + Show font names in english Visa teckensnittsnamn på engelska + Show menu colors Visa menyfärger + Show only crosshair while painting Visa bara hårkors vid målning + Show slice numbers Visa skivnummer + Show tooltips Visa verktygstips + Enable animations Aktivera animeringar + Show highlight on rollover Visa framhävning när pekaren förs över + Show bounding box when dragging layer Visa begränsningsram när lager dras + System title bar Systemets namnlist + Show transformation values Visa ombildningsvärden + Slice color Skivfärg + Smart guide color Färg på smarta stödlinjer + Smart quotes Typografiska citattecken + Snap vector tools and transforms to pixel grid Fäst vektorverktyg och ombildningar vid pixelrutnätet + Srgb sRGB + Standard Standard + Studio Studio + Studio light Studio ljus + Adwaita Adwaita + Adwaita dark Adwaita mörkt + Subdivisions Underindelningar + System System + Text engine Textmotor + Text file Textfil + Tiny Mycket liten + Touch gestures Pekgester + Traditional (72.27 points/inch) Traditionell (72,27 punkter/tum) + Transparency & Gamut Genomskinlighet och färgomfång + Type units Textenheter + UI font size Teckenstorlek i gränssnittet + UI scale Gränssnittsskala + Units & Rulers Enheter och linjaler + Use additional plugins folder Använd ytterligare plugin-mapp + Use esc to commit Använd Esc för att verkställa + Use gpu Använd GPU + Use legacy free transform Använd äldre fri ombildning + Use shift key for tool switch Använd Shift för att byta verktyg + Use tablet pressure Använd tryckkänslighet på ritplattan + Vary round brush hardness on hud Variera hårdhet för rund pensel på HUD + Vulkan Vulkan + Webp WebP + World ready World-Ready + Zoom clicked point to center Centrera klickad punkt vid zoomning + Zoom resizes windows Zoomning ändrar fönsterstorlek + Zoom with scroll wheel Zooma med rullhjulet + Zoom with trackpad pinch Zooma genom att nypa på styrplattan + Alt-click to define a source point to clone from Alt-klicka för att ange en källpunkt att klona från + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Det finns ingen bana att skapa en markering från: rita en med pennverktyget eller markera en i panelen Banor. + Cancel opening Avbryt öppning + Cancelled opening {name} Öppningen av {name} avbröts + Damaged Photoshop document Skadat Photoshop-dokument + The document “{name}” may be damaged. Dokumentet ”{name}” kan vara skadat. + Some layers, pixels or masks could not be read. Do you want to continue opening it? Vissa lager, pixlar eller masker kunde inte läsas. Vill du fortsätta att öppna det? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. Om du fortsätter och sparar över originalet kan oläsbara data gå förlorade permanent. Spara en kopia om du vill behålla originalet. + Continue Fortsätt + The document “{name}” does not have an embedded {mode} profile. Dokumentet ”{name}” har ingen inbäddad {mode}-profil. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. Dokumentet ”{name}” har en inbäddad färgprofil som inte matchar den aktuella arbetsfärgrymden för {mode}. + Working: {working} Arbetsfärgrymd: {working} + Developing {name} Framkallar {name} + Cancelled {label} {label} avbröts + Esc to cancel Esc för att avbryta + Opened {name} Öppnade {name} + Opening {name} Öppnar {name} + Saved {name} Sparade {name} + Saving {name} Sparar {name} + Saving {percent} Sparar {percent} + A save to {name} is still running; try again when it finishes En sparning till {name} pågår fortfarande; försök igen när den är klar + {label} is writing the file and can no longer be cancelled {label} skriver filen och kan inte längre avbrytas + Opening {name}… Öppnar {name}… + Opening… Öppnar… + Reading… Läser… + Working… Arbetar… + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Rutnät: +# Rendering mode and recovery + Rendering Mode Renderingsläge + Automatic (recommended) Automatiskt (rekommenderas) + CPU / Compatibility CPU / kompatibilitet + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatiskt använder GPU-acceleration när det är tillgängligt och återgår till CPU-rendering vid fel. + Switched to CPU rendering Bytte till CPU-rendering + GPU acceleration could not continue. Your documents are unchanged. GPU-accelerationen kunde inte fortsätta. Dina dokument är oförändrade. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft använder CPU-bildkompositören. Fönstret kan fortfarande använda ditt grafikkort. + Retry GPU requires a restart. Save your work first. Att försöka med GPU igen kräver en omstart. Spara ditt arbete först. + Retry GPU tries the graphics processor again now. ”Försök med GPU igen” provar grafikprocessorn på nytt nu. + GPU acceleration resumed. GPU-accelerationen har återupptagits. + Keep Using CPU Fortsätt använda CPU + Retry GPU Försök med GPU igen + GPU Backend GPU-backend + Details Detaljer + GPU GPU + Rendering mode Renderingsläge + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Frys-lasso: dra för att frysa ett område, Alt-dra för att tina det (L) + Shadow Clipping Warning (U) Varning för klippta skuggor (U) + Highlight Clipping Warning (O) Klippningsvarning för högdagrar (O) + Show Lab Color Readouts Visa Lab-färgvärden + Show Vectorscope Visa vektorskop + Show Red at 3 o'clock Visa rött vid klockan 3 + Show Skin Tone Indicator Visa hudtonsindikator + Tone Histogram Tonhistogram + Show Selected Region Visa markerat område +cameraRaw Light Ljus +# Camera Raw: contextual controls and window title + Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Aqua Akvamarin +cameraRaw Amount Mängd +cameraRaw Blacks Svarta +cameraRaw Vibrance Lyster +cameraRaw Color Grading Färggradering +cameraRaw Lights Ljusa +cameraRaw Darks Mörka +cameraRaw Global Global +cameraRaw Red Röd +cameraRaw Orange Orange +cameraRaw Yellow Gul +cameraRaw Green Grön +cameraRaw Aqua Akvamarin +cameraRaw Blue Blå +cameraRaw Purple Lila +cameraRaw Magenta Magenta +cameraRaw Hue Nyans +cameraRaw Blending Blandning +cameraRaw Luminance Detail Luminansdetalj +cameraRaw Color Detail Färgdetalj +cameraRaw Roughness Grovhet + Patch Tool Lappverktyg + Patch: Lapp: + Lasso around an area, then drag the selection Rita lasso runt ett område och dra sedan markeringen + Transform On Drop Ombilda vid släpp + Scale or rotate the moved area, then press {key} Skala eller rotera det flyttade området och tryck sedan på {key} + Now drag the selection onto another area Dra nu markeringen till ett annat område + Edit Type Redigera text + + New Style… Ny stil… + Style {name} saved Stilen {name} har sparats + + Create new style (from the selected type) Skapa ny stil (från markerad text) + Create new style from the selected layer Skapa ny stil från det markerade lagret + + Make Default Gör till standard + Reset to Default Återställ till standard + Full-resolution preview unavailable Förhandsgranskning i full upplösning är inte tillgänglig + No patterns Inga mönster + Enable {name} Aktivera {name} + Hold Alt while drawing for straight segments Håll ned Alt medan du ritar för raka segment + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Dra eller klicka på polygonpunkter · håll ned Alt under lasso för raka segment · {add} lägg till · {sub} före ritning subtraherar + Extend Utöka + Structure: Struktur: + Content-Aware Move Tool Verktyget Innehållsmedveten flytt + TIFF Options TIFF-alternativ + Targa Options Targa-alternativ + JPEG Options JPEG-alternativ + Image Options Bildalternativ + Format Options Formatalternativ + Baseline ("Standard") Baslinje ("Standard") + Baseline Optimized Baslinje optimerad + 24 bits/pixel 24 bitar/pixel + 32 bits/pixel 32 bitar/pixel + Including layers will increase file size. Om lager tas med blir filen större. + Discard Layers and Save a Copy Släng lager och spara en kopia + Don't show again Visa inte igen + Save failed: {error} Det gick inte att spara: {error} + Save cancelled Sparandet avbröts + Couldn't place {name}: {error} Det gick inte att placera {name}: {error} + Magnetic Lasso Tool Verktyget Magnetiskt lasso + Frequency Frekvens + Use tablet pressure to change pen width Använd tryckkänslighet på ritplattan för att ändra pennans bredd + +# About ▸ Contributors / Models (credits) + About Om + Contributors Medverkande + Models Modeller + Sort Sortera + Username Användarnamn + Display name Visningsnamn + Real name Riktigt namn + Reverse the order Vänd på ordningen + Grab bag Blandat + Table Tabell + Name (A–Z) Namn (A–Ö) + Merged PRs Sammanfogade PR:er + Commits Commits + Lines added Tillagda rader + Lines deleted Borttagna rader + Line delta Raddifferens + Binary assets added Binära resurser tillagda + Binary assets removed Binära resurser borttagna + First commit Första commit + First Första + Last commit Senaste commit + Last Senaste + Contributors: {contributors} · Commits: {commits} Medverkande: {contributors} · Commits: {commits} + No contributor data was built into this copy. Ingen information om medverkande är inbyggd i den här kopian. + No model credits were built into this copy. Inga modellkrediteringar finns inbyggda i den här kopian. + Company Företag + Version Version + % of all commits % av alla commits + Lines +/− Rader +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{binAdded} / −{binDeleted} binära resurser, {first} – {last} + +# Swatches panel + Create new swatch Skapa ny färgruta + New Swatch… Ny färgruta… + New Swatch Group… Ny färgrutegrupp… + Import Swatches… Importera färgrutor… + Replace Swatches… Ersätt färgrutor… + Export Swatches… Exportera färgrutor… + Export Swatches for Exchange… Exportera färgrutor för utbyte… + Reset Swatches Återställ färgrutor + Add to Swatches Lägg till i färgrutor +# Layer Style > Blending Options (Advanced Blending) + Blend If: Blandningsvillkor: + Channels: Kanaler: + Knockout Utsparning + Apply At Canvas Bounds Tillämpa vid arbetsytans gränser + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Färgväljare (textfärg) + Color Picker ({name}) Färgväljare ({name}) + Color Picker (Fill Color) Färgväljare (fyllningsfärg) + Fill color Fyllningsfärg + Color Picker (Stroke Color) Färgväljare (linjefärg) + Stroke color Linjefärg + Pick screen color Hämta färg från skärmen + Sample a pixel anywhere on the screen Ta prov på en pixel var som helst på skärmen + Click to pick · Esc to cancel Klicka för att välja · Esc för att avbryta + Pick a screen pixel or press Esc to cancel Välj en pixel på skärmen eller tryck på Esc för att avbryta + Scroll to zoom Rulla för att zooma + Drag to pan · Scroll to zoom · Double-click to fit Dra för att panorera · Rulla för att zooma · Dubbelklicka för att anpassa + Show/Hide All Panels Visa/dölj alla paneler + Show/Hide Panels Visa/dölj paneler + Could not open Det gick inte att öppna + {path}: the path is not valid Unicode; rename the file and open it again. {path}: sökvägen är inte giltig Unicode; byt namn på filen och öppna den igen. + +# Fill dialog, layer style contours and Image Size + Contents: Innehåll: + Custom Pattern: Anpassat mönster: + Foreground Color Förgrundsfärg + Color… Färg… + 50% Gray 50 % grått + {size} (was {old}) {size} (var {old}) + Cone Kon + Cone (Inverted) Kon (omvänd) + Domed Välvd + Domed (Inverted) Välvd (omvänd) + Diagonal (Descending) Diagonal (fallande) + Contour Editor Konturredigerare + Anti-aliased Kantutjämnad + Corner Hörn + Gloss Contour Glanskontur + Contour Kontur + Cone - Inverted Kon - omvänd + Cove - Deep Hålkäl - djup + Cove - Shallow Hålkäl - grund + Gaussian Gaussisk + Half Round Halvrund + Ring Ring + Ring - Double Ring - dubbel + Rolling Slope - Descending Rullande lutning - fallande + Rounded Steps Avrundade steg + Sawtooth 1 Sågtand 1 + Linear Linjär + + Red Eye Tool Verktyget Röda ögon + Pupil Size Pupillstorlek + Darken Amount Mörkningsgrad + Click a red pupil to neutralize it Klicka på en röd pupill för att neutralisera den + open source öppen källkod + …and {n} more …och {n} till + + Eyedroppers: Pipetter: + Set Black Point Ange svartpunkt + Set Neutral Gray Point Ange neutral gråpunkt + Set White Point Ange vitpunkt + Unavailable in this color mode Inte tillgängligt i det här färgläget + Selection Preview: Förhandsgranskning av markering: + Black Matte Svart kantfärg + White Matte Vit mattering + Quick Mask Snabbmask + +# Layer colour labels. + Seafoam Havsskum + Indigo Indigo + Fuchsia Fuchsia + Violet Violett + Large document: redrawing on the CPU Stort dokument: ritar om på CPU:n + Redrawing Ritar om + This redraw took {seconds} s on the CPU. Den här omritningen tog {seconds} s på CPU:n. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Ändringar som påverkar hela dokumentet, till exempel ett lagers blandningsläge, opacitet eller synlighet, ritar om allt. + The GPU compositor wasn't used: {reason} GPU-kompositören användes inte: {reason} +fontWeight Thin Tunn +fontWeight ExtraLight Extra mager +fontWeight Light Mager +fontWeight Regular Normal +fontWeight Medium Medium +fontWeight SemiBold Halvfet +fontWeight Bold Fet +fontWeight ExtraBold Extrafet +fontWeight Black Svart +fontWeight Italic Kursiv + +# Pattern Stamp tool + Pattern Stamp Tool Mönsterstämpelverktyg + Impressionist Impressionistisk + Rotate View Tool Verktyget Rotera vy + Reset View Återställ vy + Drag around the centre to rotate the view · Shift constrains to 15° Dra runt mitten för att rotera vyn · Shift begränsar till 15° + +# Painting symmetry presets. + Dual Axis Dubbelaxel + Diagonal Diagonal + Transform Symmetry Ombilda symmetri + Set painting symmetry options Ange alternativ för målningssymmetri + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Punktprov + 3 by 3 Average Medelvärde 3 x 3 + 5 by 5 Average Medelvärde 5 x 5 + 11 by 11 Average Medelvärde 11 x 11 + 31 by 31 Average Medelvärde 31 x 31 + 51 by 51 Average Medelvärde 51 x 51 + 101 by 101 Average Medelvärde 101 x 101 + Sample Size: Provstorlek: + All Layers No Adjustments Alla lager utan justeringar + Current & Below No Adjustments Aktuellt och under utan justeringar + Show Sampling Ring Visa provtagningsring + Click to sample the foreground colour · {key}-click for background Klicka för att ta prov på förgrundsfärgen · {key}-klicka för bakgrundsfärgen + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Utanför färgomfånget för utskrift: klicka för närmaste utskrivbara färg + Not a web colour: click for the nearest web colour Inte en webbfärg: klicka för närmaste webbfärg + Color to Alpha… Färg till alfa… + Transparency Threshold Tröskelvärde för genomskinlighet + Opacity Threshold Opacitetströskelvärde + PhotoCraft — Screen Color Picker PhotoCraft — Skärmfärgväljare + + Rasterize svg groups deeper than Rasterisera SVG-grupper djupare än + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 stänger av tröskelvärdet; SVG-grupper som är nästlade djupare än 100 nivåer rasteriseras alltid. + Rule of Thirds Tredjedelsregeln + Triangle Triangel + Golden Ratio Gyllene snittet + Golden Spiral Gyllene spiral + Auto Show Overlay Visa överlägg automatiskt + Always Show Overlay Visa alltid överlägg + Never Show Overlay Visa aldrig överlägg + Cycle Overlay Växla överlägg + Cycle Orientation Växla orientering + Set the overlay options for the Crop tool Ange överläggsalternativ för beskärningsverktyget + 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-klicket gick till skrivbordsmiljön, som flyttar fönster med Alt-dra. Ställ in dess tangent för fönsterflytt på Super, eller lås Alt i Fönster › Modifieringstangenter. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Färgväljare (lagerstilsfärg) + Auto Hide Notifications Dölj aviseringar automatiskt + Notification Duration (seconds) Visningstid för aviseringar (sekunder) + Bundle Arrow-Key Nudges Samla stegvisa flyttningar med piltangenterna + Nudge Bundle Pause (ms) Paus för gruppering av stegvisa flyttningar (ms) + Appearance Mode Utseendeläge + Dark Theme Mörkt tema + Light Theme Ljust tema + Pro Medium Pro medel + Studio Light Studio Light + Next Appearance Mode Nästa utseendeläge + Sync with system Synkronisera med systemet + Active Aktiv + Color Picker (Solid Color) Färgväljare (enfärgad) + Straighten the image by drawing a line on it Räta upp bilden genom att rita en linje på den + Set additional Crop options Ange fler beskärningsalternativ + Show Cropped Area Visa beskuret område + Enable Crop Shield Aktivera beskärningsskydd + Match Canvas Anpassa till arbetsytan + Auto Adjust Opacity Justera opacitet automatiskt + Crop Shield color Färg för beskärningsskydd + Color Picker (Crop Shield Color) Färgväljare (färg för beskärningsskydd) + From Center Från mitten + Link radii Länka radier + Star Ratio: Stjärnförhållande: + Create Rectangle Skapa rektangel + Create Ellipse Skapa ellips + Create Triangle Skapa triangel + Create Polygon Skapa polygon + Pressure curve Tryckkurva + Click to add a point, right-click to remove Klicka för att lägga till en punkt, högerklicka för att ta bort + Soft Mjuk + Hard Hård + Some brush presets could not be loaded Vissa penselförinställningar kunde inte läsas in + Retry saving presets Försök spara förinställningar igen + Brush preset changes are not saved: {error} Ändringar i penselförinställningar sparas inte: {error} + Keep this tab open to retain your brushes. Håll den här fliken öppen för att behålla dina penslar. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Webbläsarens lagring är inte tillgänglig, så penslarna finns bara kvar under den här sessionen. Håll fliken öppen för att behålla dem. + Saving brush presets… Keep this tab open until saving finishes. Sparar penselförinställningar… Håll fliken öppen tills sparandet är klart. + Reflect Spegla + Glow Glöd + Negation Negation + XOR XOR + Color Burn (Paint.NET) Färgbränning (Paint.NET) + Color Dodge (Paint.NET) Färgskuggning (Paint.NET) + Stroke Options Linjealternativ + Solid Heldragen + Dashed Streckad + Dotted Prickad + Caps Ändar + Butt / Flat Rak / Platt + Corners Hörn + Miter Gering + Round Rund + Bevel Fasat + Miter limit Geringsgräns + Dashed line Streckad linje + Dash Streck + Gap Mellanrum + Dash / gap lengths × stroke width Streck-/mellanrumslängder × linjebredd + Add pair Lägg till par + Remove pair Ta bort par + Dash offset Streckförskjutning + Open paths use centered strokes Öppna banor använder centrerade linjer + Use finite lengths and a positive pattern total Använd ändliga längder och en positiv mönstersumma + No subject found — try Object Selection or the Lasso Inget motiv hittades — prova objektmarkering eller lasso + Quick Selection found nothing there Snabbmarkering hittade ingenting där + Object Selection found nothing there — try a tighter rectangle Objektmarkering hittade ingenting där — prova en snävare rektangel + No subject found — try a rectangle around the object Inget motiv hittades — prova en rektangel runt objektet + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Vill du ta bort lagret ”{name}”? + Delete {count} layers? {names} Vill du ta bort {count} lager? {names} + Delete layer? Vill du ta bort lagret? + The document is closed Dokumentet är stängt + The document or selected layers changed. Please request deletion again. Dokumentet eller de markerade lagren har ändrats. Begär borttagningen igen. + Missing delete command Borttagningskommando saknas + Invalid delete command Ogiltigt borttagningskommando + Layer deleted, but confirmation preference could not be saved: {error} Lagret togs bort, men bekräftelseinställningen kunde inte sparas: {error} + Add a layer style Lägg till en lagerstil + Add a mask (from the selection; {key} inverts) Lägg till en mask (från markeringen; {key} inverterar) + Add an adjustment Lägg till en justering + Add to selection ({key}) Lägg till i markeringen ({key}) + Adjustment visible Justering synlig + Align and distribute Justera och fördela + Align horizontal centers Justera vågräta mittpunkter + Align left edges Justera vänsterkanter + Align right edges Justera högerkanter + Always use pressure for opacity Använd alltid tryck för opacitet + Always use pressure for size Använd alltid tryck för storlek + Angle Gradient Vinkelgradient + Auto-Select: Markera automatiskt: + Brush Preset picker Väljare för penselförinställningar + Cancel current crop operation (Esc) Avbryt aktuell beskärning (Esc) + Click the first point or press {key} to close · Esc cancels Klicka på första punkten eller tryck på {key} för att stänga · Esc avbryter + Click the first point or press {key} to close · {del} removes a point · Esc cancels Klicka på första punkten eller tryck på {key} för att stänga · {del} tar bort en punkt · Esc avbryter + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klicka och följ sedan en kant · klicka för att fästa en punkt · {alt}-klicka för ett rakt segment · dubbelklicka för att stänga + Clip to layer below Klipp mot lagret under + Color & Swatches Färg och färgrutor + Commit current crop operation ({key}) Verkställ aktuell beskärning ({key}) + Create a new group Skapa en ny grupp + Create a new layer Skapa ett nytt lager + Create new document from current state Skapa nytt dokument från aktuellt tillstånd + Create new fill or adjustment layer Skapa nytt fyllnings- eller justeringslager + Create new snapshot Skapa ny ögonblicksbild + From: Från: + Full Document Hela dokumentet + Merged Layers Sammanfogade lager + Delete Cropped Pixels Ta bort beskurna pixlar + Delete current state Ta bort aktuellt tillstånd + Delete layer Ta bort lager + Diamond Gradient Rombgradient + Doc: {w} × {h} px Dok: {w} × {h} px + Enable airbrush-style build-up effects Aktivera uppbyggnadseffekter som med airbrush + Filter for adjustment layers Filtrera efter justeringslager + Filter for pixel layers Filtrera efter pixellager + Filter for shape layers Filtrera efter formlager + Filter for smart objects Filtrera efter smarta objekt + Filter for type layers Filtrera efter textlager + Fixed Ratio Fast proportion + Fixed Size Fast storlek + Follows only edges this close to the pointer ([ and ] change it) Följer bara kanter så här nära pekaren (ändra med [ och ]) + Hide Properties Dölj egenskaper + Higher values follow only edges that contrast sharply with their surroundings Högre värden följer bara kanter med skarp kontrast mot omgivningen + How often fastening points are placed by themselves Hur ofta fästpunkter placeras automatiskt + Hue/Saturation Nyans/mättnad + Intersect with selection ({key}) Skär med markering ({key}) + Kind Typ + Linear Gradient Linjär gradient + Link layers Länka lager + Lock Lås + Lock all Lås alla + Lock image pixels Lås bildpixlar + Lock layer Lås lager + Lock position Lås position + Lock transparent pixels Lås genomskinliga pixlar + New selection Ny markering + No properties Inga egenskaper + Pressure for Opacity Tryck för opacitet + Pressure for Size Tryck för storlek + Prevent auto-nesting in and out of Artboards and Frames Förhindra automatisk kapsling i och ut ur ritytor och ramar + Radial Gradient Radiell gradient + Reflected Gradient Speglad gradient + Reset to defaults Återställ till standardvärden + Set additional smoothing options Ange fler utjämningsalternativ + Show Transform Controls Visa ombildningskontroller + Subtract from selection ({key}) Subtrahera från markering ({key}) + Swaps height and width Byter plats på höjd och bredd + Tolerance Tolerans + Add vector mask Lägg till vektormask + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klicka för att zooma in · {key}-klicka för att zooma ut · dra åt höger/vänster för att zooma in/ut + Appearance Settings… Utseendeinställningar… + Add to favorites Lägg till i favoriter + Remove from favorites Ta bort från favoriter +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Tillverkare +cameraData Model Modell +cameraData Date Taken Fotograferingsdatum +cameraData Exposure Exponering +cameraData F-Stop Bländartal +cameraData ISO Speed ISO-tal +cameraData Focal Length Brännvidd +cameraData Focal Length (35 mm) Brännvidd (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktivera panel + Change Panel Group Ändra panelgrupp + Change Panel Layout Ändra panellayout + Close Panel Stäng panel + Dock Panel Docka panel + Float Panel Gör panelen flytande + Move Panel Flytta panel + Group with Gruppera med + Panel menu Panelmeny + Resize panel window Ändra storlek på panelfönster + Resize panels Ändra storlek på paneler + Use Classic Mode Använd klassiskt läge + Auto Center Preview Centrera förhandsgranskning automatiskt + Next Document Nästa dokument + Previous Document Föregående dokument + W x H x Resolution B x H x upplösning + Front Image Främre bild + Frame Tool Ramverktyg + Create a new rectangular frame Skapa en ny rektangulär ram + Create a new elliptical frame Skapa en ny elliptisk ram + Include in playback Ta med i uppspelning + Perspective Crop Tool Perspektivbeskärningsverktyg + Show Grid Visa rutnät + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Dra en ruta eller klicka på fyra hörn · dra hörnen till kanterna på en snedställd form · {commit} verkställer · Esc avbryter + Artboard Tool Ritytsverktyg + Make Portrait Gör stående + Make Landscape Gör liggande + Add New Artboard Lägg till ny rityta + Freeform Pen Tool Verktyget Frihandspenna + Curvature Pen Tool Verktyget Krökningspenna + Curve Fit: Kurvanpassning: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Dra: rita på frihand · {alt}-klick: rakt segment · Släpp på startpunkten: stäng + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klick: mjuk punkt · Dubbelklick: hörn · Dra en punkt: flytta · Klicka på första punkten: stäng · {key} slutför + Scale: Skala: + Snap to Origin Fäst vid origo + Link with Layer Länka med lager + Rasterize Layer Style Rasterisera lagerstil + Search settings… Sök inställningar… + No matching settings Inga matchande inställningar +# Edit › Content-Aware Fill workspace + Sampling Brush Tool Provtagningspenselverktyg + Sampling Area Overlay Överlägg för provtagningsområde + Show Sampling Area Visa provtagningsområde + Indicates Anger + Sampling Area Provtagningsområde + Excluded Area Uteslutet område + Sampling Area Options Alternativ för provtagningsområde + Rectangular Rektangulär + Fill Settings Fyllningsinställningar + Rotation Adaptation Rotationsanpassning + Very High Mycket hög + Full Full + Mirror Spegla + Output Settings Utdatainställningar + Output To Utdata till + Duplicate Layer Duplicera lager + Contract Dra ihop + + Animation Options Animeringsalternativ + Entire Document Hela dokumentet + Filter Options Filteralternativ + Layer Bounds Lagergränser + Layers Panel Options Alternativ för panelen Lager + Lock Layers Lås lager + New Artboard… Ny rityta… + New Group… Ny grupp… + Prevent Auto-Nesting Förhindra automatisk kapsling + Show Layers Visa lager + Thumbnail Contents Miniatyrinnehåll + Thumbnail Size Miniatyrstorlek + Use Default Masks on Fill Layers Använd standardmasker på fyllningslager + Expand New Effects Expandera nya effekter + Add “copy” to Copied Layers and Groups Lägg till ”kopia” i kopierade lager och grupper + Preview on canvas when reordering layers Förhandsgranska på arbetsytan när lager ordnas om + Show layer mask badges Visa märken för lagermasker + +# Smart Filter editors + Blending Options ({filter}) Blandningsalternativ ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Dölj lagrets effekter och smarta filter ({key}-klicka: alla lager) + Show the layer's effects and Smart Filters ({key}-click: all layers) Visa lagrets effekter och smarta filter ({key}-klick: alla lager) + Wait for the current job to finish. Vänta tills det aktuella jobbet är klart. + The document or Smart Filter changed. Reopen its dialog. Dokumentet eller det smarta filtret har ändrats. Öppna dialogrutan igen. + Opacity must be between 0% and 100%. Opaciteten måste vara mellan 0 % och 100 %. + Dissolve is approximated: it blends without Photoshop's dither. Upplös är en approximation: den blandar utan Photoshops dithering. + Loading fonts… Läser in teckensnitt… + Response curve Responskurva + Tool Verktyg + Simulate Pressure Simulera tryck + Stroke Path… Linje på bana… + Background Eraser Bakgrundssuddgummi + Healing Brush Lagningspensel + Spot Healing Brush Punktlagningspensel + Smudge Smeta ut + Sponge Svamp + Amount: Mängd: + Protect: Skydda: + Protect skin tones Skydda hudtoner + Edit points to modify the curve Redigera punkter för att ändra kurvan + Draw to modify the curve Rita för att ändra kurvan +curves Smooth Jämna ut + Smooth the curve values Jämna ut kurvvärdena + Drag to draw the curve · {key}-click draws a straight line Dra för att rita kurvan · {key}-klick ritar en rak linje + Grid Size: Rutnätsstorlek: + Vanishing Point: click four corners to create a plane Fluktpunkt: klicka på fyra hörn för att skapa ett plan + Vanishing Point is not active Fluktpunkt är inte aktivt + Apply Vanishing Point ({key}) Tillämpa fluktpunkt ({key}) + Fix the red plane first: its corners must form a convex quad Korrigera det röda planet först: dess hörn måste bilda en konvex fyrhörning + Alt-click a plane to set the clone source Alt-klicka på ett plan för att ange klonkällan + Too many clone strokes: apply Vanishing Point first För många klonpenseldrag: tillämpa Fluktpunkt först +# Select and Mask view modes (#1754) + Onion Skin Lökskal + Marching Ants Marscherande myror + On Black På svart + On White På vitt + On Layers På lager + Show Edge Visa kant + Show Original Visa original + F cycles the views; hold X to see the image F växlar mellan vyerna; håll ned X för att se bilden +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Penseln Förfina kant (R) + Expand Detection Area Utöka identifieringsområde + Restore Original Edge Återställ ursprunglig kant + Paint over hair or fur on the image; Alt paints the other mode Måla över hår eller päls i bilden; Alt målar i det andra läget + Clear Brush Strokes Rensa penseldrag + Show rulers Visa linjaler + New Crop Preset… Ny beskärningsförinställning… + Delete Crop Preset… Ta bort beskärningsförinställning… + Select the crop presets to delete: Markera beskärningsförinställningarna som ska tas bort: + Select the crop presets to delete Markera beskärningsförinställningarna som ska tas bort + Enter a name for the crop preset Ange ett namn på beskärningsförinställningen + There is no crop size or ratio to save Det finns ingen beskärningsstorlek eller proportion att spara + Presentation Presentation + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Klicka för att placera · Alt-klicka eller dra utanför arbetsytan för att ta bort + No document open Inget dokument är öppet + {n} of {max} sample points {n} av {max} provpunkter + +# History panel menu (#2966) + New Snapshot… Ny ögonblicksbild… + History Options… Historikalternativ… +# History panel › History Options… (#2966) + Allow Non-Linear History Tillåt icke-linjär historik + Make Layer Visibility Changes Undoable Gör det möjligt att ångra ändringar av lagersynlighet +# Print dialog (#2697) + Emulsion Down Emulsion nedåt + Functions Funktioner + Print Resolution: Utskriftsupplösning: + Use Previous Layer to Create Clipping Mask Använd föregående lager för att skapa klippmask + Fill with {mode}-neutral color (white) Fyll med {mode}-neutral färg (vit) + Fill with {mode}-neutral color (black) Fyll med {mode}-neutral färg (svart) + Fill with {mode}-neutral color (50% gray) Fyll med {mode}-neutral färg (50 % grå) + (No neutral color exists for {mode} mode.) (Det finns ingen neutral färg för läget {mode}.) + Apply mask to layer before removing? Vill du tillämpa masken på lagret innan den tas bort? + The layer or its mask changed. Drag the mask onto the Trash again. Lagret eller dess mask har ändrats. Dra masken till papperskorgen igen. + Delete the vector mask? Ta bort vektormasken? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Exporterade {n} färgruta till {file}|Exporterade {n} färgrutor till {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importerade {n} pensel från {group}|Importerade {n} penslar från {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importerade {n} färgruta från {file}|Importerade {n} färgrutor från {file} +@plural Played {n} step|Played {n} steps Spelade upp {n} steg|Spelade upp {n} steg +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Uppdaterade {n} länkat smart objekt som ändrats på disken|Uppdaterade {n} länkade smarta objekt som ändrats på disken +@plural Saved for Web: {n} file|Saved for Web: {n} files Sparat för webben: {n} fil|Sparat för webben: {n} filer +@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} lager som sparats i den här kompositionen togs bort. Uppdatera kompositionen eller rensa varningen.|{n} lager som sparats i den här kompositionen togs bort. Uppdatera kompositionen eller rensa varningen. +@plural {n} plane|{n} planes {n} plan|{n} plan +@plural {n} preset|{n} presets {n} förinställning|{n} förinställningar +@plural {n} sample point removed|{n} sample points removed {n} provpunkt borttagen|{n} provpunkter borttagna +@plural {n} slice|{n} slices {n} skiva|{n} skivor +@plural {n} step|{n} steps {n} steg|{n} steg +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} varning till om penselförinställningar)|{warning} (+{n} varningar till om penselförinställningar) +@plural {what} with {n} warning|{what} with {n} warnings {what} med {n} varning|{what} med {n} varningar +# View › Guides › New Guide… › Color + Light Blue Ljusblå + Light Red Ljusröd + Medium Blue Mellanblå + Light Gray Ljusgrå + Object Finder Objektsökare + No object found there — drag a rectangle around it Inget objekt hittades där – dra en rektangel runt det + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Det aktiva lagret har inga pixlar här – aktivera Ta prov på alla lager eller markera ett lager med pixlar + Remove after each stroke Ta bort efter varje penseldrag + Apply the removal ({key}) Tillämpa borttagningen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Penseldrag att ta bort: {n} (Enter tillämpar, Esc avbryter) + Load Contours… Läs in konturer… + Replace Contours… Ersätt konturer… + Save Contours… Spara konturer… + Delete Contour Ta bort kontur + Loaded contours from {file} Konturer inlästa från {file} + Saved contours to {file} Konturer sparade i {file} + Show home recent files Visa senaste filer på startsidan + Clear Brush Controls Rensa penselkontroller + Reset All Locked Settings Återställ alla låsta inställningar + Copy Texture to Other Tools Kopiera struktur till andra verktyg + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Läs in åtgärder… + Save Actions… Spara åtgärder… + New Action… Ny åtgärd… + Start Recording Starta inspelning + Stop Recording Stoppa inspelning + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Läste in {n} åtgärd från {file}|Läste in {n} åtgärder från {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} steg saknar ännu motsvarighet i PhotoCraft|{n} steg saknar ännu motsvarighet i PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Sparade {n} åtgärd i {file}|Sparade {n} åtgärder i {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} steg utan motsvarighet i Photoshop utelämnades|{n} steg utan motsvarighet i Photoshop utelämnades +@plural {n} action file loaded|{n} action files loaded {n} åtgärdsfil inläst|{n} åtgärdsfiler inlästa + {set} from {file} replaced the one from {earlier} Uppsättningen {set} från {file} ersatte den från {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fil kunde inte läsas in: {errors}|{n} filer kunde inte läsas in: {errors} + + File Naming Filnamngivning + Starting serial Startnummer +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Infoga stopp… + Save Set… Spara uppsättning… + Message: Meddelande: + Allow Continue Tillåt att fortsätta + Show dialog during playback Visa dialogruta under uppspelning + Select an action first Markera en åtgärd först + Action playback stopped Uppspelning av åtgärd stoppades + Step {step} is waiting for its dialog Steg {step} väntar på sin dialogruta +@plural {n} action|{n} actions {n} åtgärd|{n} åtgärder + Background (Default) Bakgrund (standard) + Content-Aware Fill Innehållsmedveten fyllning +# Batch / Image Processor + Include All Subfolders Ta med alla undermappar + Auto Add/Delete Lägg till/ta bort automatiskt + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Markera inte också Spegelvänd bild i Utskriftsinställningar…: de två vändningarna tar ut varandra. + Print Settings… Utskriftsinställningar… + Remember to turn off the printer's own colour management in Print Settings… Kom ihåg att stänga av skrivarens egen färghantering i Utskriftsinställningar… + Sent to {printer} Skickat till {printer} + set in Print Settings… anges i Utskriftsinställningar… + ICO is limited to 256 × 256 pixels ICO är begränsat till 256 × 256 pixlar + Calibration Kalibrering + Red Primary Röd primärfärg + Green Primary Grön primärfärg + Blue Primary Blå primärfärg + Write xmp sidecars Skriv XMP-sidofiler + Set the tone sliders from the image Ställ in tonreglagen utifrån bilden + Settings from {name} Inställningar från {name} + Settings from {name}; {count} not supported: {names} Inställningar från {name}; {count} stöds inte: {names} + Could not write {name}: {error} Det gick inte att skriva {name}: {error} + Kept {name}: written by another application Behöll {name}: skrivet av ett annat program + Shadow Tint Skuggtoning +# History panel › History Brush source (#2966) + Paints from the History panel's source state Målar från källtillståndet i panelen Historik + Sets the source for the history brush Anger källan för historikpenseln + +# Dynamic selection context menu and appearance favorites + Select Inverse Omvänd markering + Pro (Light) Pro (ljus) + Pro (Light Gray) Pro (ljusgrå) + +# Measurement Log column headers (analysis_cmds::COLUMNS) + Label Etikett + Date and Time Datum och tid + Scale Units Skalenheter + Scale Factor Skalfaktor + Area Yta + Perimeter Omkrets + Circularity Rundhet + Gray Value (Minimum) Gråvärde (minimum) + Gray Value (Maximum) Gråvärde (maximum) + Gray Value (Mean) Gråvärde (medelvärde) + Gray Value (Median) Gråvärde (median) + Integrated Density Integrerad densitet + Length Längd + Show Favorite Fonts Visa favoritteckensnitt + No favorite fonts yet: click the star beside a font to add it. Inga favoritteckensnitt än: klicka på stjärnan bredvid ett teckensnitt för att lägga till det. + Favorite fonts Favoritteckensnitt + Add to Favorites Lägg till i favoriter + Remove from Favorites Ta bort från favoriter + Show All Fonts Visa alla teckensnitt + All Classes Alla klasser + Blackletter Fraktur + Decorative Dekorativ + Font preview: Förhandsgranskning av teckensnitt: + Handwritten Handskrift + Hide Font Classes Dölj teckensnittsklasser + Hide Styles Dölj stilar + Monospace Fast teckenbredd + No fonts match these filters. Inga teckensnitt matchar de här filtren. + No similar fonts: the current font doesn't say what it looks like. Inga liknande teckensnitt: det aktuella teckensnittet beskriver inte hur det ser ut. + OpenType font OpenType-teckensnitt + Sans Serif Sans serif + Script Skrivstil + Serif Serif + Show Font Classes Visa teckensnittsklasser + Show Similar Fonts Visa liknande teckensnitt + Show Styles Visa stilar + Show Variable Fonts Visa variabla teckensnitt + Slab Serif Slab serif + TrueType font TrueType-teckensnitt + Variable font Variabelt teckensnitt +# Bevel & Emboss shading controls. + Highlight Mode Högdagerläge + Highlight Color Högdagerfärg + Highlight Opacity Högdageropacitet + Shadow Mode Skuggläge + Shadow Color Skuggfärg + Shadow Opacity Skuggopacitet +# PhotoCraft release checks. + Check for Updates… Sök efter uppdateringar… + Open the release page to choose an installer for your system. Öppna versionssidan för att välja ett installationsprogram för ditt system. + Automatically Check for Updates Sök efter uppdateringar automatiskt +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + Use the embedded profile ({profile}) Använd den inbäddade profilen ({profile}) + Convert to the working space ({profile}) Konvertera till arbetsfärgrymden ({profile}) + Remember this choice for future images Kom ihåg det här valet för framtida bilder + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Slå på frågan igen med Fråga vid avvikelse i Redigera › Färginställningar. + Button Mode Knappläge + Button Mode On Knappläge på + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) + Darken Interface Mörkare gränssnitt + Lighten Interface Ljusare gränssnitt +# Bevel & Emboss Technique. + Technique Teknik + Chisel Hard Mejsel, hård + Chisel Soft Mejsel, mjuk + Open color-less EXR as ACEScg Öppna EXR utan färginformation som ACEScg + Do not append "copy" to filename when saving a copy Lägg inte till ”kopia” i filnamnet när en kopia sparas + +# View › Guides › right-click a guide + Edit Selected Guides Redigera markerade stödlinjer + Clear Selected Guides Rensa markerade stödlinjer + Bicubic Automatic Bikubisk automatisk + Bicubic Smoother Bikubisk mjukare + Bicubic Sharper Bikubisk skarpare + Pasting from other apps is unavailable Det går inte att klistra in från andra appar + 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 kommer inte åt systemets urklipp i den här sessionen. Under Wayland döljer en Flatpak-sandlåda protokollet som bilder från andra appar läses via. + To paste from other apps, start PhotoCraft under XWayland: `{command}` Starta PhotoCraft under XWayland för att klistra in från andra appar: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here Inget att klistra in: PhotoCraft kan inte läsa systemets urklipp här + Delete this set Ta bort den här uppsättningen + New Set… Ny uppsättning… + PhotoCraft Defaults PhotoCraft-standard + Save a list of these shortcuts as a web page Spara en lista över dessa kortkommandon som en webbsida + Save the changes to this set Spara ändringarna i den här uppsättningen + Save these shortcuts as a .kys file Spara dessa kortkommandon som en .kys-fil + Save these shortcuts as a new set Spara dessa kortkommandon som en ny uppsättning + Set deleted. Uppsättningen har tagits bort. + Set saved. Uppsättningen har sparats. + Set: Uppsättning: + Summarize… Sammanfatta… + There are too many sets: delete one first. Det finns för många uppsättningar: ta bort en först. + set name uppsättningens namn + {set} (modified) {set} (ändrad) + Random Slumpmässig + Show file name in title Visa filnamn i titeln + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 % tröskelvärde + Pattern Dither Mönsterdithering + Diffusion Dither Diffusionsdithering + Halftone Screen Halvtonsraster + Custom Pattern Anpassat mönster + Frequency: Frekvens: + Lines/Inch Linjer/tum + Lines/Centimeter Linjer/centimeter + Ellipse Ellips + Diamond Romb + Cross Kors + Accept and Go to Conflict Acceptera och gå till konflikt + Changes undone. Ändringarna har ångrats. + Choose a new shortcut for this command, or leave it without one. Välj ett nytt kortkommando för det här kommandot, eller lämna det utan. + Put this command's shortcuts back as they were Återställ det här kommandots kortkommandon som de var + Take the shortcut from the other command and select it Ta kortkommandot från det andra kommandot och markera det + Undo Changes Ångra ändringar + {shortcut} is reserved by the system and can't be assigned. {shortcut} är reserverat av systemet och kan inte tilldelas. + Low-Latency Drawing (vsync off, applies at next launch) Ritning med låg latens (vsync av, gäller från nästa start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Ombilda fritt håller fortfarande på att tillämpas. Försök igen när den är klar. + The transform couldn't be applied. Ombildningen kunde inte tillämpas. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Dess lager behöver {needed} GPU-minne; budgeten ger dem {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ställ in GPU-minne i Inställningar › Prestanda på vad ditt grafikkort rymmer, som mest 3/8 av dess minne. Automatiskt följer Minnesanvändning, inom en fjärdedel av den här datorns minne och 3/8 av kortets, och räknar inte dokumentet mot ett kort vars minne är känt. Statusfältet visar GPU-minne fullt så länge de inte ryms. + GPU Memory Full GPU-minne fullt + 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. Lagren behöver {needed} GPU-minne men får använda {budget}, så redigeringar som ritar om hela dokumentet görs på CPU:n. Klicka för att ställa in GPU-minne. + GPU memory budget: {budget} Budget för GPU-minne: {budget} + Graphics memory: {size} Grafikminne: {size} + This document's layers: {needed} of {budget} for layers Lagren i det här dokumentet: {needed} av {budget} för lager + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Lagren i det här dokumentet behöver {needed} men får använda {budget}: omritningar av hela dokumentet använder CPU:n + GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Storleksgräns för arbetsdisk (MB, 0 = ingen gräns) + {path}: the path is not valid Unicode; save under another name. {path}: sökvägen är inte giltig Unicode; spara under ett annat namn. + Replace Ersätt + {name} was changed by another app after it was opened or saved here. {name} har ändrats av en annan app efter att den öppnades eller sparades här. + Replace it with this document, or cancel and use File › Save As to keep both. Ersätt den med det här dokumentet, eller avbryt och använd Arkiv › Spara som för att behålla båda. + +# 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) (kommer snart) + (none) (ingen) + 32-bit Preview Options Alternativ för 32-bitars förhandsgranskning + 3D… 3D… + A: V: + AI Integrations AI-integrationer + About {app} Om {app} + Accented Edges Accentuerade kanter + Action playback failed: {error} Uppspelning av åtgärd misslyckades: {error} + Actual Pixels Faktiska pixlar + Add Mask Lägg till mask + Add Noise Lägg till brus + Add a mask (from the selection; ⌥ inverts) Lägg till en mask (från markeringen; ⌥ inverterar) + Add space after paragraph Lägg till avstånd efter stycke + Add space before paragraph Lägg till avstånd före stycke + Add to selection (⇧) Lägg till i markeringen (⇧) + Added swatch {name} Färgrutan {name} har lagts till + Adds grain to the soft edges of the brush tip. Lägger till korn i penselspetsens mjuka kanter. + Adjust Interval For Loops Justera intervall för slingor + Adjustment Layers Justeringslager + Adjustment Properties Justeringsegenskaper + Adjustment layers have nothing to transform Justeringslager har inget att ombilda + Adobe PDF Presets… Adobe PDF-förinställningar… + Adobe-rgb-compat Adobe RGB (kompatibel) + Adwaita (Dark) Adwaita (mörkt) + Adwaita (Light) Adwaita (ljust) + After Efter + Airbrush: keeps painting while the pointer rests. Airbrush: fortsätter att måla medan pekaren står still. + All shortcuts reset to Photoshop defaults. Alla kortkommandon har återställts till Photoshops standardvärden. + Altitude Höjd + Ambience Atmosfär + Ambient Omgivande + Amplitude Max Max. amplitud + Amplitude Min Min. amplitud + Android 1080p Android 1080p + Angled Strokes Vinklade penseldrag + Anisotropic Anisotropisk + Any Valfri + Apple Watch 45mm Apple Watch 45 mm + Apply Data Set Tillämpa datauppsättning + Apply Image Använd bild + Apply Mask Använd mask + Apply to All Använd på alla + Apply {option}: {state} Tillämpa {option}: {state} + Arbitrary Godtycklig + Arc Lower Nedre båge + Arc Upper Övre båge + Are you sure you want to quit? Vill du verkligen avsluta? + Are you sure? Är du säker? + Around Center Runt centrum + Arrangement Arrangemang + Arrows Pilar + Artboard from Group Rityta från grupp + Artboard from Layers Rityta från lager + Artboards Ritytor + Artboards to Files Ritytor till filer + Artboards to PDF Ritytor till PDF + Artistic Konstnärligt + Ask On Mismatch Fråga vid avvikelse + Ask On Missing Fråga vid saknad profil + Ask On Paste Fråga vid inklistring + Assign Profile Tilldela profil + At Vid + Author Title Författarens titel + Auto Scale Skala automatiskt + Auto-Align Layers Justera lager automatiskt + Auto-Blend Layers Blanda lager automatiskt + Autosave failed: {error} Automatisk sparning misslyckades: {error} + Back Tillbaka + Backend: {backend} ({type}) Backend: {backend} ({type}) + Background Level Bakgrundsnivå + Bamboo Bambu + Bas Relief Basrelief + Base Tree Type Typ av basträd + Based On Baserat på + Baseline shift Baslinjeförskjutning + Basic Latin Grundläggande latin + Batch Batch + Before Före + Bend Böjning + Berries Bär + Best Bästa + Black Clip Svartklippning + Black Intensity Svartintensitet + Blade Curvature Bladkrökning + Blast Vindstöt + Bleach Bypass Bleach bypass + Blend Clipped Layers as Group Blanda urklippta lager som grupp + Blend Interior Effects as Group Blanda inre effekter som grupp + Blend Text Gamma Blanda textgamma + Blending Mode Blandningsläge + Bloat Blås upp + Blocks Block + Blue Yellow Blå/gul + Blues Blå toner + Border Ram + Border Thickness Kanttjocklek + Box Blur Rutoskärpa + Bpc Svartpunktskompensering + Branches Color Grenfärg + Branches Height Grenhöjd + Branches Thickness Grentjocklek + Breeze Dark Breeze mörkt + Breeze Light Breeze ljust + Brick Tegel + Bristle Detail Borstdetalj + Browse Bläddra + Brush Detail Penseldetalj + Brush Settings… (F5): every option of this brush Penselinställningar… (F5): alla alternativ för den här penseln + Brush Strokes Penseldrag + Brush Type Penseltyp + Burlap Säckväv + Calculations Beräkningar + Camera Raw Camera Raw + Camera Raw Filter Camera Raw-filter + Cancel transform Avbryt ombildning + Candle Light Stearinljus + Canvas Size Storlek på arbetsyta + Case Sensitive Skiftlägeskänslig + Ccw Moturs + Cell Size Cellstorlek + Center X Centrum X + Center Y Centrum Y + Chalk Area Kritområde + Change Find Ersätt och sök + Changes Saved Ändringar sparade + Changes have not been saved. Ändringarna har inte sparats. + Change… Ändra… + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Charcoal Area Kolområde + Charcoal Thickness Koltjocklek + Check for Updates Sök efter uppdateringar + Checkerboard Schackbräde + Choke Krympning + Choose Folder Välj mapp + Circle Cirkel + Cleanliness Renhet + Clear Amount Rensningsgrad + Clear Selection Rensa markering + Click sets foreground · right-click sets background Klick anger förgrund · högerklick anger bakgrund + Click the first point or press ↵ to close · Esc cancels Klicka på första punkten eller tryck på ↵ för att stänga · Esc avbryter + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Klicka för att lägga till en punkt · dra bort, ⌘/Ctrl-klicka eller tryck på Delete för att ta bort + Click to apply, ⌥-click to clear overrides, double-click for Style Options Klicka för att tillämpa, ⌥-klicka för att rensa åsidosättningar, dubbelklicka för stilalternativ + Click to zoom in · ⌥-click to zoom out Klicka för att zooma in · ⌥-klicka för att zooma ut + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klick: hörn · Dra: mjuk · Klicka på första punkten: stäng · ↩ slutför · Esc avbryt + Clipping Mask Klippmask + Clipping Path… Urklippsbana… + Clone Klona + Clone source set at {x}, {y} Klonkälla inställd på {x}, {y} + Close Window Stäng fönster + Clumped Klumpad + Cmyk CMYK + Coarse Dots Grova punkter + Coated-cmyk Bestruket CMYK + Collapse group (⌥-click: all groups) Fäll ihop grupp (⌥-klicka: alla grupper) + Color Decontaminate Färgsanering + Color Halftone Färgraster + Color Indicates Masked Areas Färgen anger maskerade områden + Color Indicates Selected Areas Färgen anger markerade områden + Color Lookup Tables Färguppslagstabeller + Color Picker Färgväljare + Color Picker (Background Color) Färgväljare (bakgrundsfärg) + Color Picker (Custom Canvas Color) Färgväljare (anpassad färg för arbetsytan) + Color Picker (Foreground Color) Färgväljare (förgrundsfärg) + Color Sampler Tool Färgprovsverktyg + Color Settings Färginställningar + Color Swatches Färgrutor + Color Table Färgtabell + Color management: {error} Färghantering: {error} + Colored Pencil Färgpenna + Comic Book Serietidning + Commit Perspective Warp (↩) Verkställ perspektivdeformering (↩) + Commit Puppet Warp (↩) Verkställ marionettdeformering (↩) + Commit any current edits (⌘↩) Verkställ aktuella ändringar (⌘↩) + Commit current crop operation (↵) Verkställ aktuell beskärning (↵) + Commit transform Verkställ ombildning + Commit transform (↩) Verkställ ombildning (↩) + Commit warp (↩) Verkställ deformering (↩) + Compare Jämför + Conditional Mode Change Villkorsstyrt lägesbyte + Confirm Bekräfta + Conform Anpassa + Constraints Begränsningar + Content-Aware Fill needs a selection on a pixel layer Innehållsmedveten fyllning kräver en markering på ett pixellager + Contrasty Kontrastrik + Convert Konvertera + Convert to Alpha Channel Konvertera till alfakanal + Convert to Linked Konvertera till länkat + Convert to Profile Konvertera till profil + Cool Kall + Cooling L B B Kylfilter (LBB) + Cooling80 Kylfilter (80) + Cooling82 Kylfilter (82) + Copy Complete Stroke Kopiera fullständig linje + Copy Fill Kopiera fyllning + Copyright Url Upphovsrätts-URL + Correct C A Korrigera kromatisk aberration + Correct Distortion Korrigera distorsion + Correct Vignette Korrigera vinjettering + Could not rename the action: {error} Det gick inte att byta namn på åtgärden: {error} + Could not transform: the layer is empty Det gick inte att ombilda: lagret är tomt + Could not use Perspective Warp: the layer is empty Det gick inte att använda Perspektivdeformering: lagret är tomt + Could not use Puppet Warp: no opaque pixels Det gick inte att använda Marionettdeformering: inga ogenomskinliga pixlar + Could not use Puppet Warp: the layer is empty Det gick inte att använda Marionettdeformering: lagret är tomt + Couldn't open {name}: {error} Det gick inte att öppna {name}: {error} + Couldn't save preferences: {error}. Retrying… Det gick inte att spara inställningarna: {error}. Försöker igen… + Crack Brightness Sprickornas ljusstyrka + Crack Depth Sprickdjup + Crack Spacing Sprickavstånd + Craquelure Krakelering + Create By Skapa genom + Create Vector Mask Skapa vektormask + Create Warped Text Skapa deformerad text + Create a new layer (⇧⌘N) Skapa ett nytt lager (⇧⌘N) + Create new brush from the current settings Skapa ny pensel från de aktuella inställningarna + Create new brush preset from the current settings Skapa ny penselförinställning från de aktuella inställningarna + Create… Skapa… + Cross Process Korsframkallning + Crosshatch Korsskraffering + Crosswise Tvärs + Crystallize Kristallisera + Currency Valuta + Curve Darks Kurva: mörka toner + Curve Highlights Kurva: högdagrar + Curve Lights Kurva: ljusa toner + Curve Shadows Kurva: skuggor + Curve Splits Kurva: delningspunkter + Custom Branch Color Anpassad grenfärg + Custom Pixel Aspect Ratio Anpassat pixelförhållande + Custom pattern Anpassat mönster + Cutout Urklipp + Cw Medurs + Cyans Cyantoner + Cyrillic Cyrilliska + Daisies Prästkragar + Dark Gray Mörkgrå + Dark Intensity Mörk intensitet + Dark Rough Mörk grov + Dark Strokes Mörka penseldrag + Darken Only Endast mörkare + Darkness Mörker + Data Data + Data Set: {name} ({index}/{total}) Datauppsättning: {name} ({index}/{total}) + Data Sets as Files Datauppsättningar som filer + Date Datum + Day For Night Dag till natt + De-Interlace Avfläta + Decrease Brush Hardness Minska penselns hårdhet + Decrease Brush Size Minska penselstorlek + Decrease Type Size Minska textstorlek + Decrease Type Size by 10 Minska textstorlek med 10 + Deep Djup + Deep Blue Djupblå + Deep Emerald Djup smaragd + Deep Red Djupröd + Deep Yellow Djupgul + Default Foreground and Background Colors Standardfärger för förgrund och bakgrund + Default Leaves Standardlöv + Default Photoshop Size Standardstorlek för Photoshop + Defaults Standardvärden + Define Brush Preset Definiera penselförinställning + Define Custom Shape Definiera anpassad form + Define Pattern Definiera mönster + Definition Definition + Defringe Ta bort kantfärg + Degrees Grader + Delete Channel Ta bort kanal + Delete Mask Ta bort mask + Delete Workspace Ta bort arbetsyta + Delete brush Ta bort pensel + Delete path Ta bort bana + Delete vector mask Ta bort vektormask + Delete… Ta bort… + Depth Map Djupkarta + Depth Mode Djupläge + Diameter Diameter + Diffuse Diffusera + Diffuse Glow Diffust sken + Direction Riktning + Direction Balance Riktningsbalans + Disable Inaktivera + Disable Mask Inaktivera mask + Disable Symmetry Path Inaktivera symmetribana + Disabled Inaktiverad + Discard Changes Ignorera ändringar + Displace Förskjut + Display Bildskärm + Display profile not used Bildskärmsprofilen används inte + Display profiles: {reason} Bildskärmsprofiler: {reason} + Display-p3 Display P3 + Distance Avstånd + Distortion Distorsion + Distribution Fördelning + Do you want to save you made to “{name}” before quitting? Vill du spara ändringarna du gjort i ”{name}” innan du avslutar? + Document Sizes Dokumentstorlekar + Documentation Dokumentation + Documents Dokument + Dont Enlarge Förstora inte + Dot Punkt + Double Line Dubbel linje + Down Nedåt + Draft Utkast + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Dra en beskärningsram · dra inuti för att flytta · kanterna ändrar storlek ({ratio} proportioner, {centre} centrerat) · Blanksteg flyttar medan du ritar · {commit} verkställer · Esc avbryter + Drag: draw freehand · Release on the start point: close Dra: rita på frihand · Släpp på startpunkten: stäng + Driver: {driver} Drivrutin: {driver} + Dry Brush Torr pensel + Duplicate Channel Duplicera kanal + Duplicate Layers Duplicera lager + Duplication Duplicering + Dust & Scratches Damm och repor + Edge Kant + Edge Brightness Kantljusstyrka + Edge Extension Kantförlängning + Edge Fidelity Kanttrohet + Edge Intensity Kantintensitet + Edge Only Endast kanter + Edge Simplicity Kantenkelhet + Edge Thickness Kanttjocklek + Edge Width Kantbredd + Edit Toolbar Redigera verktygsfältet + Edit… Redigera… + Elevation Höjd + Eliminate Eliminera + Emboss Relief + Enable Aktivera + Enable Mask Aktivera mask + End X Slut X + End Y Slut Y + Enlarged Förstorad + Erase with Brush or Pencil (hold) Radera med pensel eller blyertspenna (håll ned) + Error Fel + Even Fields Jämna fält + Exit Quick Mask Avsluta snabbmask + Expand group (⌥-click: all groups) Expandera grupp (⌥-klicka: alla grupper) + Export As Exportera som + Export As: {name} Exportera som: {name} + Export Contents Exportera innehåll + Export Image Exportera bild + Export Options Exportalternativ + Export Preferences Exportera inställningar + Export Settings Exportera inställningar + Export to Exportera till + Export/Import Presets Exportera/importera förinställningar + Exported contents to {path} Exporterade innehåll till {path} + Exported presets to {path} Exporterade förinställningar till {path} + Exported {path} Exporterade {path} + Exported {path} ({size}) Exporterade {path} ({size}) + Exporting… Exporterar… + ExtraBold Extrafet + ExtraLight Extratunn + Extrude Extrudera + Fade Tona ut + Faded Film Blekt film + Failed Misslyckades + Failed to open file Det gick inte att öppna filen + Failed to save file Det gick inte att spara filen + Fallback: {reason} Reserv: {reason} + Feather Radius Fjädringsradie + Fiber Length Fiberlängd + Fibers Fibrer + Field Blur Fältoskärpa + File Format Filformat + File Info Filinformation + File Name Filnamn + File Size Filstorlek + File not found Filen hittades inte + Filename Filnamn + Fill Color Fyllningsfärg + Fill Opacity Fyllningsopacitet + Fill Path… Fyll bana… + Fill path with foreground color Fyll banan med förgrundsfärgen + Fill with Background Color Fyll med bakgrundsfärg + Fill with Background Color, Preserve Transparency Fyll med bakgrundsfärg, bevara genomskinlighet + Fill with Foreground Color Fyll med förgrundsfärg + Fill with Foreground Color, Preserve Transparency Fyll med förgrundsfärg, bevara genomskinlighet + Film Grain Filmkorn + Filter Gallery Filtergalleri + Find Sök + Find and Replace Text Sök och ersätt text + Fine Fin + Fine Dots Fina punkter + Finish Slutför + Fit Image Anpassa bild + Fit to Window Anpassa till fönstret + Flame Flamma + Flame Bottom Alignment Justering av flammans nederkant + Flame Lines Flamlinjer + Flame Shape Flamform + Flame Style Flamstil + Flame Type Flamtyp + Flat Platt + Flat Shading Platt skuggning + Flower Color Blomfärg + Focal Distance Fokusavstånd + Focus Fokus + Focus Area Fokusområde + Foreground Level Förgrundsnivå + Forward Framåt + Forward Warp Deformera framåt + Frame Ram + Frame {current} / {total} Bildruta {current} / {total} + Free Transform Path Ombilda bana fritt + Freeze Lasso Frys-lasso + Freeze Mask Frysmask + Fresco Fresk + From Från + From Left Från vänster + From Right Från höger + Frosted Frostad + Full Screen Helskärm + Fullscreen Helskärm + Fuzziness Tolerans + GPU backend preference: {backend} Önskad GPU-backend: {backend} + GPU device was lost; using the CPU renderer. GPU-enheten förlorades; CPU-renderaren används. + GPU error; using the CPU renderer. GPU-fel; CPU-renderaren används. + GPU retry scheduled Nytt GPU-försök schemalagt + Gamma Gamma + Gaussian Blur Gaussisk oskärpa + General Blending Allmän blandning + General Brushes Allmänna penslar + Generators Generatorer + Generic Allmän + Glass Glas + Global Light Globalt ljus + Gloss Glans + Glow Amount Glödmängd + Glow Brightness Glödens ljusstyrka + Glow Size Glödstorlek + Glowing Edges Glödande kanter + Good Bra + Grade Balance Gradering: balans + Grade Blending Gradering: blandning + Grade Global Gradering: global + Grade Highlights Gradering: högdagrar + Grade Midtones Gradering: mellantoner + Grade Shadows Gradering: skuggor + Gradient Options Gradientalternativ + Grain Amount Kornmängd + Grain Roughness Kornets grovhet + Grain Size Kornstorlek + Grain Type Korntyp + Graininess Kornighet + Grainy Dots Korniga punkter + Graphics adapter Grafikkort + Graphics adapter: {adapter} Grafikkort: {adapter} + Gray-gamma-2.2 Grå gamma 2,2 + Greek Grekiska + Greens Gröna + Grid (⌘') Rutnät (⌘') + Grid view Rutnätsvy + Group from Layers Grupp från lager + Groups Grupper + Grout Width Fogbredd + Guide Layout… Stödlinjelayout… + HDR Toning HDR-toning + Hand Tool (hold) Handverktyg (håll ned) + Hearts Hjärtan + Heptagon Heptagon + Hexagon Hexagon + Hidden Dold + Hide Grid Dölj rutnät + Hide Others Göm övriga + Hide the layer's effects (⌥-click: all layers) Dölj lagrets effekter (⌥-klicka: alla lager) + Hide {app} Göm {app} + High Pass Högpass + Highlight Högdager + Highlight Amount Mängd för högdagrar + Highlight Area Högdagerområde + Highlight Radius Högdagerradie + Highlight Strength Högdagerstyrka + Highlight Tone Tonbredd för högdagrar + Highlights from: Högdagrar från: + Horizontal Distortion Vågrät distorsion + Horizontal Only Endast vågrätt + Horizontal scale Vågrät skala + Horizontal type Vågrät text + Hotspot Brännpunkt + Hsb HSB + Hsl HSL + Hsl Hue HSL-nyans + Hsl Lum HSL-luminans + Hsl Sat HSL-mättnad + Image Balance Bildbalans + Image Processor Bildprocessor + Image compositor: {processor} Bildkompositör: {processor} + Import Brushes Importera penslar + Import Brushes… (.abr) Importera penslar… (.abr) + Import Image Importera bild + Import Options Importalternativ + Import Photoshop brushes (.abr) Importera Photoshop-penslar (.abr) + Import Settings Importera inställningar + Importing {what} from {file}… Importerar {what} från {file}… + Importing… Importerar… + In Black Svart in + In White Vitt in + Increase Brush Hardness Öka penselns hårdhet + Demosaic Demosaicering + 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. Hur kamerans färgfiltermosaik fylls i. Den tillämpas när filen öppnas; förhandsgranskningen visar metoden som den öppnades med. + AHD (adaptive homogeneity) AHD (adaptiv homogenitet) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: renast kanter och minst färgfransning, men långsammast. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientkorrigerad interpolering: skarp och snabb, med viss färgfransning i fina detaljer. + The fastest and the softest, with colour fringing on fine detail. Snabbast och mjukast, med färgfransar vid fina detaljer. + “{name}” already exists. Replace it? ”{name}” finns redan. Vill du ersätta den? + The name in the save dialog was “{name}”. Namnet i dialogrutan Spara var ”{name}”. + Move Up Flytta uppåt + Move Down Flytta nedåt + Previous Brush Föregående pensel + Next Brush Nästa pensel + First Brush Första penseln + Last Brush Sista penseln + Preview size Storlek på förhandsgranskning + Memory Usage Minnesanvändning + Per-document RAM limit (MB) RAM-gräns per dokument (MB) + Installed RAM: {ram} MB; this limit: {percent}% Installerat RAM: {ram} MB; den här gränsen: {percent} % + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. Begränsar pixlarna i varje öppet dokument plus historiken. De äldsta ångrastegen tas bort när gränsen överskrids. + Applies immediately when saved. Memory is not reserved in advance. Gäller direkt när du sparar. Minnet reserveras inte i förväg. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. När GPU-minne är inställt på Automatiskt begränsar detta även GPU:ns lagerbudget. Budgeten är begränsad till en fjärdedel av det installerade RAM-minnet och, när det är känt, tre åttondelar av grafikkortets minne, med minst 512 MB. Att höja den här gränsen utöver dessa tak ökar inte GPU-budgeten. + Command Kommando + Use Använd + In Front Framför + Behind Bakom + +# DDS Options (#1327) + DDS Options DDS-alternativ + Compression Komprimering + Generate Mipmaps Generera mipmappar + +# Color Table (.act) dialog + There is no color table to save. Det finns ingen färgtabell att spara. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Sparade {n} färg i {file}|Sparade {n} färger i {file} + +# Graphics Adapter preference (#1885) + GPU adapter GPU-adapter + +# Styles panel: Import / Export Styles (.asl) + Import Styles… Importera stilar… + Export Selected Styles… Exportera markerade stilar… + Export Styles… Exportera stilar… + {file} is not a style library (.asl) {file} är inte ett stilbibliotek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importerade {n} stil från {file}|Importerade {n} stilar från {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Exporterade {n} stil till {file}|Exporterade {n} stilar till {file} +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bekräfta eller avbryt den öppna dialogrutan för åtgärdssteget först + Pro Medium Gray Pro mellangrå + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ljusgrå + Pro Light Pro ljus + Resize Windows to Fit Anpassa fönstrens storlek + EXR EXR + Increase Brush Size Öka penselstorlek + Increase Type Size Öka textstorlek + Increase Type Size by 10 Öka textstorlek med 10 + Indent first line Indrag för första raden + Indent left margin Indrag för vänstermarginal + Indent right margin Indrag för högermarginal + Indexed Indexerad + Infinite Oändlig + Information Information + Initial Direction Startriktning + Ink Outlines Bläckkonturer + Inner Bevel Inre fasning + Input Indata + Input Levels Ingångsnivåer + Input Mode Inmatningsläge + Install Plug-in Installera plugin + Intensity Intensitet + Intent Återgivningsmetod + Interpret Footage Tolka material + Intersect with selection (⇧⌥) Skär med markering (⇧⌥) + Interval Intervall + Invalid panel layout; restored the default dock Ogiltig panellayout; standarddockningen har återställts + Invert Mask Invertera mask + Invert Selection Omvänd markering + Iris Blur Irisoskärpa + Ivy Murgröna + Jag Taggig + Join the ArtCraft Discord Gå med i ArtCraft på Discord + Join the ArtCraft Discord ({url}) Gå med i ArtCraft på Discord ({url}) + Keeps the current texture when switching presets. Behåller den aktuella texturen när du byter förinställning. + Kernel Kärna + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: Metrik, Optisk eller ett värde i 1/1000 em (Alt+←/→ vid markören) + Keyboard Tangentbord + Lab Lab + Landscape, 10 x 8 Liggande, 10 x 8 + Landscape, 6 x 4 Liggande, 6 x 4 + Landscape, 7 x 5 Liggande, 7 x 5 + Latin 1 Latin 1 + Latin Extended-A Latin Extended-A + Latin Extended-B Latin Extended-B + Layer Comps to Files Lagerkompositioner till filer + Layer Content Options Alternativ för lagerinnehåll + Layer Count Antal lager + Layer From Background Lager från bakgrund + Layer Knocks Out Drop Shadow Lagret sparar ut skuggan + Layer Mask Hides Effects Lagermasken döljer effekter + Layer Properties Lageregenskaper + Layer Styles Lagerstilar + Layer from Background Lager från bakgrund + Layers to Files Lager till filer + Layout Layout + Leading (set to the font size × auto-leading when Auto) Radavstånd (sätts till teckenstorleken × automatiskt radavstånd vid Auto) + Leaf Color Bladfärg + Learn More Läs mer + Leaves Amount Bladmängd + Leaves Color Bladfärg + Leaves Size Bladstorlek + Left Diagonal Vänsterdiagonal + Legacy Äldre + Legal Legal + Lens Blur Linsoskärpa + Lens Correction Objektivkorrigering + Lens Flare Motljus + Less Mindre + Letter Letter + Level Based Nivåbaserad + Library Bibliotek + Light Dark Balance Ljus/mörk-balans + Light Direction Ljusriktning + Light Intensity Ljusintensitet + Light Rough Lätt ojämn + Light Type Ljustyp + Light X Ljus X + Light Y Ljus Y + Light Z Ljus Z + Lighten Grout Gör fogar ljusare + Lighten Only Endast ljusare + Lighting Belysning + Lighting Effects Ljuseffekter + Linear Dodge Linjär skugga + Linear Height Linjär höjd + Linear-srgb Linjär sRGB + Link Länka + Liquify Gör flytande + List Lista + List view Listvy + Live Preview Live-förhandsgranskning + Load 3D LUT Läs in 3D-LUT + Load Files into Stack Läs in filer i stapel + Load path as a selection Läs in bana som markering + Loading… Läser in… + Lock All Lås alla + Lock Image Pixels Lås bildpixlar + Lock Position Lås position + Lock Transparent Pixels Lås genomskinliga pixlar + Locked Låst + Long Lines Långa linjer + Long Strokes Långa penseldrag + Lorem Ipsum Lorem Ipsum + Lower Nedre + Lum Lum + Lut LUT + Magentas Magentatoner + Make Selection from Path Skapa markering från bana + Make Selection… Skapa markering… + Make Symmetry Path Skapa symmetribana + Make Work Path… Skapa arbetsbana… + Make work path from selection Skapa arbetsbana från markeringen + Manual Manuell + Map Document Mappa dokument + Map Layer Mappa lager + Map Path Mappa bana + Mask Channel Maskkanal + Mask Incomplete Blocks Maskera ofullständiga block + Mask None Maskera inget + Masks Masker + Match Color Matcha färg + Math Symbols Matematiska symboler + Max Offset Max. förskjutning + Max Radius Max. radie + Measurement Scale Mätskala + Measurement Scale Marker Mätskalemarkör + Medium Dots Medelstora punkter + Medium Gray Mellangrå + Medium Lines Medellånga linjer + Medium Strokes Medellånga penseldrag + Merge Channels… Sammanfoga kanaler… + Merge Spot Channel Sammanfoga dekorfärgskanal + Merge to HDR Pro Sammanfoga till HDR Pro + Metallic Metallisk + Metrics Metrik + Mezzotint Mezzotint + Middle Mitten + Midtone Mellanton + Monitor Profile Bildskärmsprofil + Monitor profile in use on {display}: {profile} Bildskärmsprofil som används på {display}: {profile} + Monitor profile in use: {profile} Bildskärmsprofil som används: {profile} + Monitor profile on {display} (main window): {profile} Bildskärmsprofil på {display} (huvudfönster): {profile} + Monitor profile on {display}: {profile} Bildskärmsprofil på {display}: {profile} + Monitor profile: {profile} Bildskärmsprofil: {profile} + Mono Contrast Monokontrast + Monochromatic Monokromatisk + More Mer + Mosaic Mosaik + Mosaic Tiles Mosaikplattor + Motion Blur Rörelseoskärpa + Mouse Mus + Move Layer Flytta lager + Movie Prime Film med fast brännvidd + Multiple Flames Along Path Flera lågor längs banan + Multiple Flames One Direction Flera lågor i en riktning + Multiple Flames Path Directions Flera lågor i banans riktningar + Multiple Flames Various Length Flera lågor med olika längd + My Brushes Mina penslar + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Inbyggt dra och släpp av filer stöds inte på Wayland ännu. Använd Arkiv › Öppna, eller kör PhotoCraft under XWayland med `WAYLAND_DISPLAY= photocraft`. + Nearest Närmaste + Neon Glow Neonglöd + Neutralize Neutralisera + Neutrals Neutrala toner + New Brush Preset Ny penselförinställning + New Channel… Ny kanal… + New Guide Ny stödlinje + New Guide Layout Ny stödlinjelayout + New Spot Channel… Ny dekorfärgskanal… + New Video Layer from File Nytt videolager från fil + New Window Nytt fönster + New Workspace Ny arbetsyta + New document Nytt dokument + New document… Nytt dokument… + New document… ⌘N Nytt dokument… ⌘N + Next Nästa + No Document Inget dokument + No Image Ingen bild + No Layers Inga lager + No Results Inga resultat + No Results Found Inga resultat hittades + No Selection Ingen markering + No custom shape "{name}": pick one in the options bar or Window › Shapes Ingen egen form ”{name}”: välj en i alternativfältet eller Fönster › Former + No layers Inga lager + No recent files Inga senaste filer + No selection Ingen markering + Noise Color Färgbrus + Noise Color Detail Färgbrusdetalj + Noise Luminance Luminansbrus + Noise Luminance Detail Luminansbrusdetalj + Nothing to Redo Inget att göra om + Nothing to Undo Inget att ångra + Nothing to paste: the clipboard holds no image Inget att klistra in: urklipp innehåller ingen bild + Number Of Levels Antal nivåer + Numbers Siffror + Ocean Ripple Havskrusning + Octagon Oktagon + Odd Fields Udda fält + Oil Paint Oljemålning + On På + One Flame Along Path En låga längs banan + Open As Öppna som + Open File Öppna fil + Open Folder Öppna mapp + Open Image Öppna bild + Open Recent is unavailable on the web Öppna senaste är inte tillgängligt på webben + Opened {url} Öppnade {url} + Opening Öppnar + Open… ⌘O Öppna… ⌘O + Operation canceled Åtgärden avbröts + Operation cancelled Åtgärden avbröts + Operation completed Åtgärden slutfördes + Operation failed Åtgärden misslyckades + Optical Optisk + Original Ratio Ursprungliga proportioner + Out Black Utdata svart + Out From Center Utåt från centrum + Out Of Gamut Utanför färgomfånget + Out White Utdata vitt + Out of memory Slut på minne + Outer Bevel Yttre fasning + Output Levels Utgångsnivåer + Oval Oval + Overlay Edge Överläggskant + Overlay: Rule of Thirds Överlägg: tredjedelsregeln + PSD PSD + Package Paketera + Page Setup… Utskriftsformat… + Paint Daubs Färgklickar + Paints from the document's opening state Målar från dokumentets tillstånd när det öppnades + Palette Knife Palettkniv + Panel Panel + Paper Papper + Paper Brightness Pappersljusstyrka + Parallel Parallell + Paste Complete Stroke Klistra in fullständig linje + Paste Fill Klistra in fyllning + Patchwork Lapptäcke + Path Blur Banoskärpa + Paths to Illustrator Banor till Illustrator + Pattern Fill… Mönsterfyllning… + Pattern Type Mönstertyp + Pencil Width Pennbredd + Pentagon Pentagon + Percentage Procent + Permission denied Åtkomst nekad + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft fortsätter att försöka; tills en sparning lyckas går ändrade inställningar förlorade när programmet stängs. + Photomerge Photomerge + Pick Color From Image Hämta färg från bilden + Picture Frame Bildram + Pillow Emboss Kuddrelief + Pinch Nyp + Pins Nålar + Place Embedded Placera inbäddad + Place Linked Placera länkad + Place Scale Marker Placera skalmarkör + Plane Plan + Planes Plan + Plastic Wrap Plastfolie + Platform: {os} {arch} Plattform: {os} {arch} + Please wait… Vänta… + Point Punkt + Point Curve Punktkurva + Point Curve Blue Punktkurva blå + Point Curve Green Punktkurva grön + Point Curve Red Punktkurva röd + Pointed Spetsig + Pointillize Pointillera + Polar Coordinates Polära koordinater + Polar To Rectangular Polär till rektangulär + Policy Cmyk Princip för CMYK + Policy Gray Princip för grå + Policy Rgb Princip för RGB + Pond Ripples Vattenringar + Portrait, 4 x 6 Stående, 4 x 6 + Portrait, 5 x 7 Stående, 5 x 7 + Postcard Vykort + Poster Affisch + Poster Edges Affischkanter + Posterization Posterisering + Preferences can't be saved Inställningarna kan inte sparas + Preferences saved Inställningarna har sparats + Preferences were reset: {error} Inställningarna återställdes: {error} + Preferences… Inställningar… + Preserve Bevara + Preset Manager Förinställningshanterare + Press keys Tryck på tangenter + Preview Image Förhandsgranskningsbild + Previous Föregående + Prime105 Fast 105 mm + Prime35 Fast 35 mm + Printed to {path} Skrevs ut till {path} + Pro (Dark) Pro (mörk) + Pro (Medium Gray) Pro (mellangrå) + Processing… Bearbetar… + Profile Profil + Proof Setup… Korrekturinställningar… + Prophoto-compat ProPhoto-kompatibel + Pucker Skrynkla ihop + Push Left Skjut åt vänster + Pyramids Pyramider + Quick Actions Snabbåtgärder + Quit Avsluta + Quit PhotoCraft Avsluta PhotoCraft + Quit {app} Avsluta {app} + R G B RGB + R g b RGB + Radial Radiell + Radial Blur Radiell oskärpa + Radius X Radie X + Radius Y Radie Y + Ran script {name} Körde skriptet {name} + Randomize Length Slumpmässig längd + Randomize Shapes Slumpmässiga former + Rasterize Fill Content Rasterisera fyllningsinnehåll + Rasterize Smart Object Rasterisera smart objekt + Ratio Förhållande + Raw Defaults Raw-standardvärden + Reading Läsning + Ready Klar + Rec2020 Rec2020 + Recent Files Senaste filer + Reconstruct All Rekonstruera alla + Recovered {name} Återställde {name} + Rectangular Selection Rektangulär markering + Rectangular To Polar Rektangulär till polär + Red Cyan Röd/cyan + Reds Röda + Reduce Color Noise Minska färgbrus + Reduce Noise Minska brus + Reflected Speglad + Relief Relief + Relink to File Länka om till fil + Remove All Ta bort alla + Remove Jpeg Artifact Ta bort JPEG-artefakter + Rename Brush Byt namn på pensel + Rename Channel… Byt namn på kanal… + Rename Group Byt namn på grupp + Rename Tool Preset Byt namn på verktygsförinställning + Render Video Rendera video + Rendering… Renderar… + Reorder Layer Ändra lagerordning + Replace Color Ersätt färg + Replace Contents Ersätt innehåll + Replace Footage Ersätt material + Report a Bug Rapportera en bugg + Report an Issue Rapportera ett problem + Reset Alignment Återställ justering + Reset All Återställ alla + Reset Preferences Återställ inställningar + Resolve Missing Fonts Lös saknade teckensnitt + Restart Starta om + Restart Required Omstart krävs + Resume Återuppta + Retry Försök igen + Rgb RGB + Ridges Åsar + Right Diagonal Höger diagonal + Ripple Krusning + Ripple Magnitude Krusningsstyrka + Ripple Size Krusningsstorlek + Rope Rep + Roses Rosor + Rough Pastels Grova pasteller + Row Order Radordning + Rulers (⌘R) Linjaler (⌘R) + Sample Size Provstorlek + Sampled Colors Provtagna färger + Sandstone Sandsten + Sat Mätt. + Save As Spara som + Save Changes Spara ändringar + Save File Spara fil + Save Image Spara bild + Save Path Spara bana + Save Pixel Aspect Ratio Spara pixelbildförhållande + Save a Copy Spara en kopia + Save before closing? Vill du spara innan du stänger? + Save for Web (Legacy) Spara för webben (äldre) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Spara ditt arbete och starta om PhotoCraft för att försöka med GPU-acceleration igen. CPU-rendering används under resten av sessionen. + Saved a copy as {path} Sparade en kopia som {path} + Saved {path}; image assets: {n} in {dir} Sparade {path}; bildresurser: {n} i {dir} + Saving… Sparar… + Scale Effects Skala effekter + Scaling Skalning + Scallops Bågkanter + Script Events Manager Skripthändelsehanterare + Scroll menu down Rulla menyn nedåt + Scroll menu up Rulla menyn uppåt + Search commands Sök kommandon + Search commands (⌘K) Sök kommandon (⌘K) + Search commands, tools and panels Sök efter kommandon, verktyg och paneler + Search menus Sök i menyer + Seed Startvärde + Select Custom Color… Välj anpassad färg… + Select Data Points Markera datapunkter + Select Layer Markera lager + Select and Mask Markera och maskera + Color Aware Färgmedveten + Decontaminate Colors Sanera färger + Edge Detection Kantidentifiering + Global Refinements Globala förfiningar + High Quality Preview Förhandsgranskning med hög kvalitet + Object Aware Objektmedveten + Object Aware needs a segmentation model, which isn't available yet Objektmedveten behöver en segmenteringsmodell, som inte är tillgänglig än + Real-Time Refinement Förfining i realtid + Refine Edge Brush Tool Kantförfiningspenselverktyg + Refine Hair Förfina hår + Refine Hair needs hair detection, which isn't available yet Förfina hår behöver håridentifiering, som inte är tillgänglig än + Refine Mode Förfiningsläge + Select and Mask was closed: another layer was selected Markera och maskera stängdes: ett annat lager markerades + Select and Mask was closed: its document is no longer the active one Markera och maskera stängdes: dess dokument är inte längre det aktiva + View Mode Visningsläge + Selected at launch: {backend} Valdes vid start: {backend} + SemiBold Halvfet + Sensitivity Känslighet + Sent to the printer ({spooler}) Skickat till skrivaren ({spooler}) + Sepia Sepia + Services Tjänster + Set Colors Ange färger + Set Measurement Scale Ange mätskala + Sgray sGray + Shadow Skugga + Shadow Amount Skuggmängd + Shadow Intensity Skuggintensitet + Shadow Radius Skuggradie + Shadow Tone Skuggton + Shadows up to: Skuggor upp till: + Shadows/Highlights Skuggor/högdagrar + Shallow Grund + Shape Blur Formoskärpa + Sharpen Amount Skärpemängd + Sharpen Detail Skärpedetalj + Sharpen Details Skärpa detaljer + Sharpen Masking Skärpemaskering + Sharpen Radius Skärperadie + Shear Skjuva + Shine Glans + Short Lines Korta linjer + Short Strokes Korta streck + Shortcut Kortkommando + Shortcuts Kortkommandon + Show All Visa alla + Show Extras Options Alternativ för extrafunktioner + Show Guides Visa stödlinjer + Show Guides (⌘;) Visa stödlinjer (⌘;) + Show Rulers Visa linjaler + Show the layer's effects (⌥-click: all layers) Visa lagrets effekter (⌥-klicka: alla lager) + Simple Enkel + Simulate Paper Simulera papper + Sine Sinus + Sketch Skiss + Skip Hoppa över + Slice Options Alternativ för skivor + Smart Blur Smart oskärpa + Smart Sharpen Smart skärpa + Smoothness Jämnhet + Smudge Stick Smetkrita + Snap to Grid Fäst vid rutnät + Snowflakes Snöflingor + Soften Mjuka upp + Softness Mjukhet + Software adapter Programvaruadapter + Solarized Dark Solarized mörk + Solid Color Enfärgad + Solid Front Faces Enfärgade framsidor + Source Channel Källkanal + Source Invert Invertera källa + Source Layer Källager + Source1 Channel Kanal för källa 1 + Source1 Invert Invertera källa 1 + Source2 Channel Kanal för källa 2 + Source2 Invert Invertera källa 2 + Source: set Källa: angiven + Space Blanksteg + Sparkle Gnistor + Spatter Stänk + Speckle Fläckar + Speed Hastighet + Spherize Sfärisera + Spin Snurra + Spin Blur Snurroskärpa + Split Channels Dela upp kanaler + Spot Dekor + Spray Radius Sprutradie + Sprayed Strokes Sprutade penseldrag + Spread Spridning + Sprinkles Strössel + Square Size Kvadratstorlek + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Squareness Kvadratiskhet + Stagger Förskjut + Stained Glass Målat glas + Star Stjärna + Stars Stjärnor + Start X Start X + Start Y Start Y + Statistics Statistik + Step {step} ({id}) failed: {error} Steg {step} ({id}) misslyckades: {error} + Stippled Punkterad + Stops Stopp + Strength Styrka + Stretch Sträck ut + Stroke Color Linjefärg + Stroke Detail Penseldragsdetalj + Stroke Direction Penseldragsriktning + Stroke Length Penseldragslängd + Stroke Pressure Penseltryck + Stroke Size Penseldragsstorlek + Stroke Width Linjebredd + Stroke path with brush Linje på bana med pensel + Strokes Penseldrag + Studio (Dark) Studio (mörk) + Studio (Light) Studio (ljus) + Stylization Stilisering + Subtract Shapes at Overlap Subtrahera former vid överlappning + Subtract from selection (⌥) Subtrahera från markering (⌥) + Sumi-e Sumi-e + Surface Blur Ytoskärpa + Switch Foreground and Background Colors Växla förgrunds- och bakgrundsfärg + System Info Systeminformation + Tablet Ritplatta + Tabloid Tabloid + Taper Avsmalning + Target Mål + Target Channel 3 Välj kanal 3 + Target Channel 4 Välj kanal 4 + Target Channel 5 Välj kanal 5 + Target Channel 6 Välj kanal 6 + Target Channel 7 Välj kanal 7 + Target Channel 8 Välj kanal 8 + Target Channel 9 Välj kanal 9 + Target Composite Channel Välj sammansatt kanal + Target X Mål X + Target Y Mål Y + Teal Orange Blågrön/orange + Technology Previews… Teknikförhandsgranskningar… + Temporary Tillfällig + Text Layer Textlager + Texture Coverage Strukturtäckning + Texturizer Strukturskapare + Thaw Mask Tina mask + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Arbetsytan visas som sRGB. Välj en bildskärmsprofil i Redigera › Färginställningar. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Arbetsytan på {display} visas som sRGB. Välj en bildskärmsprofil i Redigera › Färginställningar. + The current gradient (pick one in Window › Gradients) Den aktuella gradienten (välj en i Fönster › Gradienter) + The document does not have an embedded colour profile. Working space: {working}. Dokumentet har ingen inbäddad färgprofil. Arbetsfärgrymd: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokumentet har en inbäddad färgprofil som inte matchar arbetsfärgrymden.\nInbäddad: {embedded}\nArbetsfärgrymd: {working} + The file could not be opened. Det gick inte att öppna filen. + The file could not be saved. Det gick inte att spara filen. + Thickness Tjocklek + This Layer: Det här lagret: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Det här smarta objektet måste rasteriseras innan du fortsätter. Innehållet går inte längre att redigera. Vill du rasterisera det smarta objektet? + This adjustment ({key}) is kept as imported and can't be edited yet. Den här justeringen ({key}) behålls som den importerades och kan inte redigeras ännu. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Det här fyllningslagret måste rasteriseras innan du fortsätter. Fyllningsinnehållet går inte längre att redigera. Vill du rasterisera lagret? + This layer must be rasterized before proceeding. Det här lagret måste rasteriseras innan du fortsätter. + This session: {reason} Den här sessionen: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Det här formlagret måste rasteriseras innan du fortsätter. Det går inte längre att redigera som form. Vill du rasterisera formen? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Det här textlagret måste rasteriseras innan du fortsätter. Texten går inte längre att redigera. Vill du rasterisera texten? + Tile Size Rutstorlek + Tiles Rutor + Tilt-Shift Tilt-shift + Tint Color Toningsfärg + Tiny Lens Liten lins + Title Titel + To Till + To Center Mot mitten + Toggle Layer Mask Overlay Visa/dölj lagermaskens överlägg + Toggle the Brush Settings panel Visa/dölj panelen Penselinställningar + Toggle the Brush Settings panel (F5) Visa/dölj panelen Penselinställningar (F5) + Tool Options Verktygsalternativ + Trace Contour Spåra kontur + Tracking (1/1000 em) Spärrning (1/1000 em) + Transformation Ombildning + Transition Övergång + Transparency Shapes Layer Genomskinlighet formar lager + Trap Överfyllning + Tree Träd + Trim Trimma + Turbulent Turbulent + Twirl Virvel + Twirl Clockwise Virvel medurs + Twirl Counterclockwise Virvel moturs + Type Options Textalternativ + Unaltered Oförändrad + Undefined Areas Odefinierade områden + Underlying Layer: Underliggande lager: + Underpainting Undermålning + Underwater Under vatten + Ungroup Dela upp grupp + Uniform Enhetlig + Unite Shapes at Overlap Förena former vid överlappning + Unlink Ta bort länk + Unlock Lås upp + Unlocked Upplåst + Unsaved Changes Osparade ändringar + Unsharp Mask Oskarp mask + Unsupported file format Filformatet stöds inte + Unsupported format Formatet stöds inte + Up Upp + Update Uppdatera + Updates Uppdateringar + Upper Övre + Use Custom Color Använd anpassad färg + Use File › Open to open an image. Öppna en bild med Arkiv › Öppna. + Use Selection In Source Använd markering i källan + Use Selection In Target Använd markering i målet + Uses the foreground and background colors. Använder förgrunds- och bakgrundsfärgerna. + Vanishing Point Fluktpunkt + Variable Data Sets Variabla datauppsättningar + Vector Mask Hides Effects Vektormask döljer effekter + Vertical Distortion Lodrät distorsion + Vertical Only Endast lodrätt + Vertical Type Tool Verktyget Lodrät text + Vertical scale Lodrät skala + Vertical type Lodrät text + Video Frames to Layers Videobildrutor till lager + Vignette Amount Vinjettmängd + Vignette Feather Vinjettfjädring + Vignette Highlights Vinjetthögdagrar + Vignette Midpoint Vinjettmittpunkt + Vignette Roundness Vinjettrundhet + Vignette Style Vinjettstil + Vine Color Rankans färg + Vine With Flowers Ranka med blommor + Vine With Leaves Ranka med blad + Violent Våldsam + W: B: + WIA Support WIA-stöd + Warm Varm + Warming Filter (85) Värmefilter (85) + Warming L B A Värmefilter (LBA) + Warming81 Värmefilter (81) + Warming85 Värmefilter (85) + Warmth Värme + Warning Varning + Warp Text Deformera text + Watercolor Akvarell + Watercolour look: lighter interior, darker rims. Akvarellutseende: ljusare inre, mörkare kanter. + Wavelength Max Max. våglängd + Wavelength Min Min. våglängd + Waves Vågor + Web Large Webb, stor + Web Minimum Webb, minimum + Web Most Common Webb, vanligast + WebP (lossless) WebP (förlustfri) + Wetness Väta + White Clip Vitklippning + White Intensity Vitintensitet + White Is High Vitt är högt + Whole Word Hela ord + Wide Blurry Bred oskarp + Wide Sharp Bred skarp + Wind Vind + Window renderer: {renderer} Fönsterrenderare: {renderer} + Working Arbetsfärgrymd + Working Cmyk Arbets-CMYK + Working Gray Arbetsgrå + Working Rgb Arbets-RGB + Working-cmyk Arbets-CMYK + Workspaces Arbetsytor + Wrap Radbryt + Yellows Gula + ZigZag Sicksack + Zigzag Sicksack + Zoom In (hold, drag to scrub) Zooma in (håll ned, dra för att skrubba) + Zoom Out (hold) Zooma ut (håll ned) + auto auto + blue blå + brushes penslar + fallback reserv + gradients gradienter + gray grå + green grön + layer mask lagermask + manual manuell + none ingen + off av + on på + orange orange + previous reading kept; the last re-read failed: {reason} föregående läsning behölls; den senaste omläsningen misslyckades: {reason} + red röd + unknown okänd + unknown type okänd typ + vector mask vektormask + violet violett + yellow gul + {index} of {total} {index} av {total} + {label} is not available: {reason} {label} är inte tillgängligt: {reason} + {message} (notes: {n}; {warning}) {message} (anteckningar: {n}; {warning}) + {shortcut} assigned. {shortcut} har tilldelats. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} används redan av {commands} och tas bort därifrån när du klickar på OK. + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ navigera ↵ kör esc stäng + ✓ Color Indicates Masked Areas ✓ Färg anger maskerade områden + ✓ Color Indicates Selected Areas ✓ Färg anger markerade områden +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Radera +@id edit.purge.undo Ångra +@id edit.stroke Linje… +@id edit.transform.scale Skala +@id file.close Stäng +@id filter.sharpen.sharpen Skärpa +@id layer.layerMask.apply Tillämpa +@id select.all Markera allt +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Importerade {n} pensel från {file}|Importerade {n} penslar från {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Importerade {n} gradient från {file}|Importerade {n} gradienter från {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Importerade {n} mönster från {file}|Importerade {n} mönster från {file} +cameraRaw Balance Balans +cameraRaw Clarity Klarhet +cameraRaw Color Färg +cameraRaw Color Mixer Färgmixer +cameraRaw Contrast Kontrast +cameraRaw Curve Kurva +cameraRaw Dehaze Ta bort dis +cameraRaw Detail Detalj +cameraRaw Effects Effekter +cameraRaw Exposure Exponering +cameraRaw Feather Fjädring +cameraRaw Highlights Högdagrar +cameraRaw Luminance Luminans +cameraRaw Masking Maskering +cameraRaw Midpoint Mittpunkt +cameraRaw Midtones Mellantoner +cameraRaw Radius Radie +cameraRaw Roundness Rundhet +cameraRaw Saturation Mättnad +cameraRaw Shadows Skuggor +cameraRaw Size Storlek +cameraRaw Temperature Temperatur +cameraRaw Texture Struktur +cameraRaw Tint Färgton +cameraRaw Whites Vita +colorRange Invert Omvänd +document Orientation Orientering +layerLabel Magenta Magenta +layerLabel No Color Ingen färg +layerLabel Red Röd +layers Fill Fyllning +layers Fill: Fyllning: +navigator Zoom Zoom +shape Appearance Utseende +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatisk styckeriktning + Left-to-right paragraph direction Styckeriktning från vänster till höger + Right-to-left paragraph direction Styckeriktning från höger till vänster + Not rendered: this adjustment ({key}) is kept as imported. Renderas inte: den här justeringen ({key}) behålls som importerad. +# Print preview image (#8236) + Print preview image Förhandsgranskningsbild för utskrift +# Color panel menu: modes, copy commands and colour ramps (#6000) + Hue Cube Nyanskub + Brightness Cube Ljusstyrkekub + Color Wheel Färghjul + RGB Sliders RGB-skjutreglage + HSB Sliders HSB-skjutreglage + CMYK Sliders CMYK-skjutreglage + Lab Sliders Lab-skjutreglage + Web Color Sliders Skjutreglage för webbfärger + Copy Color as HTML Kopiera färg som HTML + Copy Color's Hex Code Kopiera färgens hexkod + RGB Spectrum RGB-spektrum + CMYK Spectrum CMYK-spektrum + Grayscale Ramp Gråskaleramp + Current Colors Aktuella färger + Make Ramp Web Safe Gör rampen webbsäker +# Art History Brush (#213). + Art History Brush Tool Konsthistorikpenselverktyg + Tight Short Tät, kort + Tight Medium Tät, medel + Tight Long Tät, lång + Loose Medium Löst medellångt + Loose Long Löst långt + Dab Avtryck + Tight Curl Tät, lockig + Loose Curl Löst lockigt + Stylized dabs from a history state Stiliserade penselavtryck från ett historiktillstånd + Font size presets Förinställningar för teckenstorlek + Leading presets Förinställningar för radavstånd + Tracking presets Förinställningar för spärrning + +# Recent labels (histogram statistics, self-update, Artboards and others) + CPU Rendering CPU-rendering + Calculating… Beräknar… + Click to refresh histogram with uncached data Klicka för att uppdatera histogrammet med data som inte har cachelagrats + Convert to Shape… Konvertera till form… + Cooling Filter (80) Kylfilter (80) + Cooling Filter (82) Kylfilter (82) + Cooling Filter (LBB) Kylfilter (LBB) + Count: Antal: + Delete Hidden Layers Ta bort dolda lager + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Dra i bilden för att lägga till en begränsning; Shift för vågrätt/lodrätt; högerklicka för att ta bort. + Drawn on the CPU: {reason}. Click for System Info. Ritas på CPU:n: {reason}. Klicka för systeminformation. + Exported {n} files Exporterade {n} filer + Fit Layer on Screen Anpassa lager till skärmen + Flow: Flöde: + Level: Nivå: + Percentile: Percentil: + Refine Edge… Förfina kant… + Remember Settings Kom ihåg inställningar + Reset Essentials Återställ Grundläggande + Rounded Rectangle Tool Verktyget Rundad rektangel + Show Channels in Color Visa kanaler i färg + Show Statistics Visa statistik + Source: Källa: + Status Bar Statusfält + Tile Horizontally Lägg sida vid sida vågrätt + Tile Vertically Lägg sida vid sida lodrätt + Uncached Refresh Uppdatera utan cache + Warming Filter (81) Värmefilter (81) + Warming Filter (LBA) Värmefilter (LBA) +@plural {n} document|{n} documents {n} dokument|{n} dokument +@plural {n} pixel|{n} pixels {n} pixel|{n} pixlar +cameraRaw Before (Y) Före (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw-filter ({layer}) +cameraRaw Camera Raw isn't open Camera Raw är inte öppet +cameraRaw Cancel Avbryt +cameraRaw Cancelled opening {name} Öppningen av {name} avbröts +cameraRaw Cancelled {label} {label} avbröts +cameraRaw Color Priority Färgprioritet +cameraRaw Developing {name} Framkallar {name} +cameraRaw Full-resolution preview unavailable Förhandsgranskning i full upplösning är inte tillgänglig +cameraRaw Grain Korn +cameraRaw Highlight Clipping Warning (O) Varning för klippning i högdagrar (O) +cameraRaw Highlight Priority Högdagerprioritet +cameraRaw Load Settings… Läs in inställningar… +cameraRaw Load settings from a Camera Raw .xmp file Läs in inställningar från en Camera Raw .xmp-fil +cameraRaw Loaded Camera Raw settings from {file} Läste in Camera Raw-inställningar från {file} +cameraRaw Noise Reduction Brusreducering +cameraRaw OK OK +cameraRaw Open Öppna +cameraRaw Opened {name} Öppnade {name} +cameraRaw Paint Overlay Målningsöverlägg +cameraRaw Save Settings… Spara inställningar… +cameraRaw Save these settings as a Camera Raw .xmp file Spara de här inställningarna som en .xmp-fil för Camera Raw +cameraRaw Shadow Clipping Warning (U) Varning för klippning i skuggor (U) +cameraRaw Sharpening Skärpning +cameraRaw Show Lab Color Readouts Visa Lab-färgvärden +cameraRaw Show Red at 3 o'clock Visa rött vid klockan 3 +cameraRaw Show Selected Region Visa markerat område +cameraRaw Show Skin Tone Indicator Visa hudtonsindikator +cameraRaw Show Vectorscope Visa vektorskop +cameraRaw Tone Histogram Tonhistogram +cameraRaw Vignetting Vinjettering +cameraRaw White Balance: As Shot Vitbalans: som tagen + Current Layer: Aktuellt lager: + Delete effect Ta bort effekt + Move effect down Flytta effekten nedåt + Move effect up Flytta effekten uppåt + Path operations Banåtgärder + The path was discarded Banan kasserades + The path was kept as the Work Path Banan behölls som arbetsbana + Edit Brush… Redigera pensel… diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 25cc9fec4f..b2bef186e9 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -57,6 +57,69 @@ fn detect_system_tags() -> Vec { Vec::new() } +/// The decimal separator of the system's number format (Windows: Region › Decimal symbol), when it +/// is `.` or `,` and can be read: Photoshop's numeric fields take only that one (a German Windows +/// refuses a typed `.`). `None` where it can't be read (macOS, Linux and the web for now, or another +/// symbol): the fields then take both. +pub fn decimal_separator() -> Option { + #[cfg(test)] + { + TEST_DECIMAL.get() + } + #[cfg(not(test))] + { + static SYSTEM: std::sync::OnceLock> = std::sync::OnceLock::new(); + *SYSTEM.get_or_init(detect_decimal_separator) + } +} + +/// `.` or `,` as a decimal separator; anything else (empty, `٫`, two characters) is not one. +#[cfg_attr(not(any(test, target_os = "windows")), allow(dead_code))] +fn separator_from(text: &str) -> Option { + match text.trim() { + "." => Some('.'), + "," => Some(','), + _ => None, + } +} + +/// The user's own number format (`HKCU\Control Panel\International\sDecimal`, where Windows keeps +/// the decimal symbol of the format chosen in Region, with any change made to it), read through a +/// safe binding. +#[cfg(target_os = "windows")] +fn detect_decimal_separator() -> Option { + use winsafe::{self as w, co}; + match w::HKEY::CURRENT_USER.RegGetValue(Some("Control Panel\\International"), Some("sDecimal"), co::RRF::RT_REG_SZ) { + Ok(w::RegistryValue::Sz(text)) => separator_from(&text), + _ => None, + } +} + +#[cfg(all(not(test), not(target_os = "windows")))] +fn detect_decimal_separator() -> Option { + None +} + +#[cfg(test)] +thread_local! { + // Tests take both separators unless they choose one (`with_decimal_separator`), whatever the + // developer's own number format is. + static TEST_DECIMAL: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Runs `run` as on a system whose decimal separator is `sep` (`None`: unknown, both taken). +#[cfg(test)] +pub(crate) fn with_decimal_separator(sep: Option, run: impl FnOnce() -> R) -> R { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + TEST_DECIMAL.set(self.0); + } + } + let _restore = Restore(TEST_DECIMAL.replace(sep)); + run() +} + #[cfg(test)] thread_local! { // Keep ordinary tests independent of the developer's locale, without mutating OS or @@ -86,12 +149,13 @@ mod tests { (vec!["ja-JP", "en-US"], "ja"), (vec!["fr-CA", "en-US"], "fr"), (vec!["uk-UA", "en-US"], "uk"), - (vec!["sv-SE", "uk_UA.UTF-8", "ru-RU"], "uk"), - (vec!["sv-SE", "ko-KR", "fr-FR"], "ko"), + (vec!["sw-KE", "uk_UA.UTF-8", "ru-RU"], "uk"), + (vec!["sw-KE", "ko-KR", "fr-FR"], "ko"), (vec!["zh-Hant-HK", "zh-CN"], "zh-hant"), (vec!["zh-Hans-CN", "zh-TW"], "zh-hans"), (vec!["en-US", "ru-RU"], "en"), - (vec!["sv-SE", "ar-SA"], "en"), + (vec!["is-IS", "ar-SA"], "ar"), + (vec!["sw-KE", "is-IS"], "en"), (vec!["de-AT", "en-US"], "de"), (vec!["pt-PT"], "pt-br"), (vec!["it-IT", "en-US"], "it"), @@ -99,8 +163,15 @@ mod tests { (vec!["hu-HU", "en-US"], "hu"), (vec!["tr-TR", "en-US"], "tr"), (vec!["sr-Latn-RS", "en-US"], "sr"), - (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), + (vec!["sw-KE", "sr_RS@latin", "ru-RU"], "sr"), (vec!["hi-IN", "en-US"], "hi"), + (vec!["sv-SE", "en-US"], "sv"), + (vec!["sv_FI.UTF-8", "fi-FI", "en-US"], "sv"), + (vec!["fi-FI", "sv-FI", "en-US"], "fi"), + (vec!["da-DK", "en-US"], "da"), + (vec!["nb-NO", "en-US"], "nb"), + (vec!["nn_NO.UTF-8", "en-US"], "nb"), + (vec!["is-IS", "no-NO"], "nb"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); @@ -119,6 +190,27 @@ mod tests { assert_eq!(system_lang(), Lang::EN); } + #[test] + fn only_a_point_or_a_comma_is_a_decimal_separator() { + assert_eq!(separator_from(","), Some(',')); + assert_eq!(separator_from(" . "), Some('.')); + for other in ["", "\u{66b}", ",,", "'", "x"] { + assert_eq!(separator_from(other), None, "{other:?}"); + } + assert_eq!(decimal_separator(), None, "tests take both unless they choose"); + with_decimal_separator(Some(','), || assert_eq!(decimal_separator(), Some(','))); + assert_eq!(decimal_separator(), None); + } + + /// The real Windows number format is read (the developer's or CI's: `,` or `.`). + #[cfg(target_os = "windows")] + #[test] + fn the_windows_number_format_has_a_decimal_separator() { + let sep = detect_decimal_separator(); + eprintln!("Windows decimal separator: {sep:?}"); + assert!(matches!(sep, Some('.' | ',')), "{sep:?}"); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn native_locale_results_are_bounded_and_trimmed() { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a8497bb5ec..a67be38ff5 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -104,6 +104,8 @@ fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]); assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]); assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]); + assert_eq!(candidates("nn_NO.UTF-8"), ["nn-no", "nb", "nn"]); + assert_eq!(candidates("no"), ["nb", "no"]); } #[test] @@ -135,6 +137,65 @@ fn spanish_resolves_and_pluralises() { assert_eq!(forms, [1, 0, 1]); } +#[test] +fn swedish_resolves_and_pluralises() { + let sv = Lang::from_code("sv").expect("sv registered"); + assert_eq!(sv.name(), "Svenska"); + for tag in ["sv", "SV", "sv-SE", "sv_SE.UTF-8", "sv-FI", "sv_FI.UTF-8", "sv-AX"] { + assert_eq!(lang_from_tag(tag), Some(sv), "{tag}"); + assert_eq!(Lang::from_pref(tag), sv, "{tag}"); + } + let forms: Vec = [0, 1, 2, 21].into_iter().map(sv.0.plural).collect(); + assert_eq!(forms, [1, 0, 1, 1]); + assert_eq!(tr(sv, "File"), "Arkiv"); + assert_eq!(trn(sv, 1, "{n} layer", "{n} layers"), "1 lager"); + assert_eq!(trn(sv, 3, "{n} layer", "{n} layers"), "3 lager"); +} + +#[test] +fn finnish_resolves_and_pluralises() { + let fi = Lang::from_code("fi").expect("fi registered"); + assert_eq!(fi.name(), "Suomi"); + for tag in ["fi", "FI", "fi-FI", "fi_FI.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(fi), "{tag}"); + assert_eq!(Lang::from_pref(tag), fi, "{tag}"); + } + // Filipino and Swedish in Finland don't pick Finnish. + assert_ne!(lang_from_tag("fil"), Some(fi)); + assert_ne!(lang_from_tag("sv-FI"), Some(fi)); + assert_eq!(tr(fi, "File"), "Tiedosto"); + // The partitive singular follows any number but 1. + for (n, layer) in [(0, "tasoa"), (1, "taso"), (2, "tasoa"), (21, "tasoa")] { + assert_eq!(trn(fi, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); + } +} + +#[test] +fn danish_resolves_and_pluralises() { + let da = Lang::from_code("da").expect("da registered"); + assert_eq!(da.name(), "Dansk"); + for tag in ["da", "DA", "da-DK", "da_DK.UTF-8", "da-GL"] { + assert_eq!(lang_from_tag(tag), Some(da), "{tag}"); + assert_eq!(Lang::from_pref(tag), da, "{tag}"); + } + assert_eq!(tr(da, "File"), "Filer"); + assert_eq!(trn(da, 1, "{n} document", "{n} documents"), "1 dokument"); + assert_eq!(trn(da, 3, "{n} document", "{n} documents"), "3 dokumenter"); +} + +#[test] +fn norwegian_resolves_and_pluralises() { + let nb = Lang::from_code("nb").expect("nb registered"); + assert_eq!(nb.name(), "Norsk bokmål"); + for tag in ["nb", "NB", "nb-NO", "nb_NO.UTF-8", "no", "no-NO", "no_NO.UTF-8", "nn", "nn-NO", "nn_NO.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(nb), "{tag}"); + assert_eq!(Lang::from_pref(tag), nb, "{tag}"); + } + assert_eq!(tr(nb, "File"), "Fil"); + assert_eq!(trn(nb, 1, "{n} item", "{n} items"), "1 element"); + assert_eq!(trn(nb, 3, "{n} item", "{n} items"), "3 elementer"); +} + #[test] fn hungarian_resolves_and_keeps_the_singular_after_counts() { let hu = Lang::from_code("hu").expect("hu registered"); @@ -194,6 +255,25 @@ fn ukrainian_plural_rule_handles_teens_and_compound_counts() { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]); } +#[test] +fn arabic_resolves_locales_and_preferences() { + let ar = Lang::from_code("ar").expect("ar registered"); + assert_eq!(ar.name(), "العربية"); + for tag in ["ar", "AR", "ar-SA", "ar_EG", "ar_EG.UTF-8", "ar-AE", "ar-MA"] { + assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); + assert_eq!(Lang::from_pref(tag), ar, "{tag}"); + } + assert_eq!(dir_for_code("ar"), Dir::Rtl); +} + +#[test] +fn arabic_plural_rule_has_six_forms() { + let ar = Lang::from_code("ar").expect("ar registered"); + let counts = [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 199, 1000, u64::MAX]; + let forms: Vec = counts.into_iter().map(ar.0.plural).collect(); + assert_eq!(forms, [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 3, 4, 4, 5, 4]); +} + #[test] fn serbian_resolves_locales_and_preferences() { let sr = Lang::from_code("sr").expect("sr registered"); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5b9d13fd6..007d756f89 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -8,9 +8,51 @@ # stay as they are; the trailing "…" of dialog commands is kept. # # Custom paper dimensions in Print Settings. + + + +# Edit › Content-Aware Fill workspace + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop + +# Native Windows printing qualification + +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + +# Channel Options and New Spot Channel (#3761) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) + +# Layers panel options and Smart Filter editors + +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). Paper Width Kağıt Genişliği Paper Height Kağıt Yüksekliği - Rows Satırlar Number Sayı Top: Üst: @@ -429,6 +471,7 @@ Intersect Shape Areas Şekil Alanlarını Kesiştir Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut Merge Shape Components Şekil Bileşenlerini Birleştir + Path operations Yol işlemleri Align Hizala Top Edges Üst Kenarlar Vertical Centers Dikey Merkezler @@ -957,7 +1000,6 @@ Contiguous Bitişik Contrast Kontrast Control: Kontrol: - Convert to sRGB sRGB'ye dönüştür Copies Kopyalar Copper Bakır @@ -1537,7 +1579,6 @@ 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 @@ -3879,7 +3920,6 @@ cameraData Lens Lens 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ı Full Tam @@ -3900,8 +3940,6 @@ cameraData Lens Lens Clear Brush Controls Fırça Denetimlerini Temizle Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Eylemleri Yükle… Save Actions… Eylemleri Kaydet… New Action… Yeni Eylem… @@ -3913,9 +3951,10 @@ cameraData Lens Lens @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} adımın PhotoCraft’ta henüz karşılığı yok @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı - +@plural {n} action file loaded|{n} action files loaded {n} eylem dosyası yüklendi + {set} from {file} replaced the one from {earlier} {file} dosyasındaki {set} kümesi, {earlier} dosyasındakinin yerini aldı +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} dosya yüklenemedi: {errors} Starting serial Başlangıç sıra numarası -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Durdurma Ekle… Save Set… Seti Kaydet… Message: İleti: @@ -3939,20 +3978,14 @@ cameraData Lens Lens Loaded Camera Raw settings from {file} Camera Raw ayarları {file} dosyasından yüklendi Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw ayarları {file} dosyasından yüklendi ({n} desteklenmiyor) Auto Add/Delete Otomatik Ekle/Sil - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Yazdırma Ayarlarında ayrıca Ayna Görüntüsünü açmayın: iki yansıtma birbirini iptal eder. Print Settings… Yazdırma Ayarları… Remember to turn off the printer's own colour management in Print Settings… Yazdırma Ayarlarında yazıcının kendi renk yönetimini kapatmayı unutmayın… Sent to {printer} {printer} yazıcısına gönderildi 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 @@ -3969,6 +4002,7 @@ cameraData Lens Lens Kept {name}: written by another application {name} korundu: başka bir uygulama tarafından yazılmış Shadow Tint Gölge tonu Redrawing Yeniden çiziliyor + Memory usage percent Bellek kullanım yüzdesi Show Favorite Fonts Favori yazı tiplerini göster No favorite fonts yet: click the star beside a font to add it. Henüz favori yazı tipi yok: eklemek için bir yazı tipinin yanındaki yıldıza tıklayın. Favorite fonts Favori yazı tipleri @@ -3996,7 +4030,6 @@ cameraData Lens Lens Slab Serif Kalın tırnaklı TrueType font TrueType yazı tipi Variable font Değişken yazı tipi -# Bevel & Emboss shading controls. Highlight Mode Vurgu Modu Highlight Color Vurgu Rengi Highlight Opacity Vurgu Opaklığı @@ -4004,36 +4037,22 @@ cameraData Lens Lens Shadow Color Gölge Rengi Shadow Opacity Gölge Opaklığı Check for Updates… Güncellemeleri Denetle… - PhotoCraft Updates PhotoCraft Güncellemeleri - Checking for updates… Güncellemeler denetleniyor… Open the release page to choose an installer for your system. Sisteminize uygun yükleyiciyi seçmek için sürüm sayfasını açın. - Open Download Page İndirme Sayfasını Aç - PhotoCraft is up to date. PhotoCraft güncel. - Could not check for updates. Güncellemeler denetlenemedi. - Updates have not been checked yet. Güncellemeler henüz denetlenmedi. - Check Again Yeniden Denetle Automatically Check for Updates Güncellemeleri Otomatik Denetle - Installed version: {version} Yüklü sürüm: {version} - PhotoCraft {version} is available. PhotoCraft {version} kullanılabilir. -# 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}) Remember this choice for future images Bu seçimi sonraki görüntüler için hatırla Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Soruyu Düzen › Renk Ayarları'ndaki Ask On Mismatch ile yeniden açabilirsiniz. Button Mode Düğme modu Button Mode On Düğme modu açık - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Arayüzü koyulaştır Lighten Interface Arayüzü açıklaştır -# Bevel & Emboss Technique. Technique Teknik 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 + Save Full-Size Composite in .pcraft Files .pcraft dosyalarına tam boyutlu bileşik görüntüyü kaydet Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle Bicubic Automatic Bikübik Otomatik @@ -4058,8 +4077,6 @@ 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 @@ -4069,8 +4086,6 @@ cameraData Lens Lens 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 @@ -4087,7 +4102,6 @@ cameraData Lens Lens 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. @@ -4103,10 +4117,8 @@ cameraData Lens Lens Undo Changes Değişiklikleri Geri Al {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli) -# Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin. The transform couldn't be applied. Dönüştürme uygulanamadı. -# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Katmanlarının {needed} GPU belleğine ihtiyacı var; bütçe onlara {budget} bırakıyor. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara, en fazla belleğinin 3/8'ine ayarlayın. Otomatik, Bellek kullanımını izler; bu bilgisayarın belleğinin dörtte biri ve kartın belleğinin 3/8'i içinde kalır, belleği bilinen bir kartta belgeyi hesaba katmaz. Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. GPU Memory Full GPU Belleği Dolu @@ -4116,6 +4128,7 @@ cameraData Lens Lens 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) + Scratch Size Limit (MB, 0 = No Limit) Geçici disk boyut sınırı (MB, 0 = Sınırsız) {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. @@ -4132,8 +4145,6 @@ cameraData Lens Lens 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 @@ -4148,17 +4159,21 @@ cameraData Lens Lens Use Kullan In Front Önde Behind Arkada - -# DDS Options (#1327) 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ı +smartSvg Choose executable… Çalıştırılabilir dosya seç… +smartSvg Choose the application used to edit this SVG: Bu SVG'yi düzenlemek için kullanılacak uygulamayı seçin: +smartSvg Edit SVG Smart Object SVG Akıllı Nesnesini Düzenle +smartSvg Other application Diğer uygulama +smartSvg PhotoCraft (internal editor) PhotoCraft (dahili düzenleyici) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG düzenleyicisi açıldı. Bu Akıllı Nesneyi güncellemek için SVG'yi kaydedin. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Açılan SVG dosyasına kaydedin. Kaydedilen her değişiklik PhotoCraft'ta bir geri alma adımı olur. +smartSvg Stop syncing this Smart Object Bu Akıllı Nesnenin eşitlemesini durdur +smartSvg Updates wait while another document or an editing dialog is active. Başka bir belge veya düzenleme iletişim kutusu etkin olduğunda güncellemeler bekler. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS içeriği SVG olarak düzenlenecek. Kaydetmek gömülü kaynağı değiştirir; biçime özgü özellikler değişebilir. # Styles panel: Import / Export Styles (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} {file} dosyasından {n} stil içe aktarıldı @@ -4167,7 +4182,6 @@ cameraData Lens Lens Export Selected Styles… Seçili Stilleri Dışa Aktar… Export Styles… Stilleri Dışa Aktar… {file} is not a style library (.asl) {file} bir stil kitaplığı değil (.asl) -# 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 Solarized @@ -4176,14 +4190,11 @@ cameraData Lens Lens Pro Light Pro Açı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 Baskı Önizleme Görüntüsü -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Renk Tonu Küpü Brightness Cube Parlaklık Küpü Color Wheel Renk Çarkı @@ -4199,7 +4210,8 @@ cameraData Lens Lens Grayscale Ramp Gri Tonlama Şeridi Current Colors Geçerli Renkler Make Ramp Web Safe Şeridi Web Renkleriyle Sınırla -# Art History Brush (#213). + The path was kept as the Work Path Yol, Çalışma Yolu olarak korundu + The path was discarded Yol atıldı Art History Brush Tool Sanat Tarihi Fırçası Aracı Tight Short Sıkı Kısa Tight Medium Sıkı Orta @@ -4213,3 +4225,32 @@ cameraData Lens Lens Font size presets Yazı tipi boyutu hazır ayarları Leading presets Satır aralığı hazır ayarları Tracking presets İzleme hazır ayarları + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Geçersiz sayısal giriş. {min}% ile {max}% arasında bir tam sayı gerekiyor. Önceki değer eklendi. + Color Aware Renge Duyarlı + Decontaminate Colors Renkleri Arındır + Edge Detection Kenar Algılama + Global Refinements Genel Hassaslaştırmalar + High Quality Preview Yüksek Kaliteli Önizleme + Object Aware Nesneye Duyarlı + Real-Time Refinement Gerçek Zamanlı Hassaslaştırma + Refine Edge Brush Tool Kenarı Hassaslaştırma Fırçası Aracı + Refine Hair Saçı Hassaslaştır + Refine Mode Hassaslaştırma Modu + Remember Settings Ayarları Hatırla + View Mode Görünüm Modu + Object Aware needs a segmentation model, which isn't available yet Nesneye Duyarlı mod henüz kullanılamayan bir bölütleme modeli gerektirir + Refine Hair needs hair detection, which isn't available yet Saçı Hassaslaştır henüz kullanılamayan saç algılama gerektirir + Select and Mask was closed: its document is no longer the active one Seç ve Maskele kapatıldı: belgesi artık etkin değil + Select and Mask was closed: another layer was selected Seç ve Maskele kapatıldı: başka bir katman seçildi + Move effect up Efekti yukarı taşı + Move effect down Efekti aşağı taşı + Delete effect Efekti sil + Current Layer: Geçerli Katman: + Underlying Layer: Alttaki Katman: + Drag to move the selected path Seçili yolu taşımak için sürükleyin + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Recently Used Colors Son Kullanılan Renkler + Stop Color Durak Rengi + Shapes can't use patterns yet Şekiller henüz desen kullanamaz + Edit Brush… Fırçayı Düzenle… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d911b19a8..68128b8584 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -9,20 +9,206 @@ # 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: Кольором позначено: - Solidity: Щільність: - Ink Characteristics Характеристики фарби # Menus # Guide layout dialog # Custom print paper (#3820) + + +# File + +# Edit + +# Image + +# Layer + +# Type + +# Select +# Select and Mask dialog labels + +# Filter +# Field Blur pins (#3373) + +# View + +# Window + +# Help + +# Tools and options bar + +# Panels, dialogs and common widgets + +# Preferences dialog + +# Document and canvas status bar + +# Layers panel + +# Channels panel + +# Color picker and swatches + +# Filters and adjustments dialogs + +# Camera Raw and lens dialogs + +# Adjustments panel + +# History panel + +# Navigator panel + +# Brush Settings panel + +# Tool presets + +# Paragraph and character panels + +# Glyphs panel + +# Paths panel + +# Clone Source panel + +# Timeline panel + +# Info panel + +# Layer Comps panel + +# Analysis and variables + +# Automate + +# Color Settings dialog + +# Themes + +# Workspaces + +# New Document dialog + +# Placeholders, tooltips, status and keyboard hints + +# Transform / warp overlays + +# Vanishing Point + +# Misc dialog strings + +# Blend modes + +# Saved views and other bits + +# Plurals + +# Remaining menu strings (second pass) + +# tl! literals (third pass) +# Gradient Editor window, opened from the options bar's gradient swatch. + +# Preference dialog: generated setting labels and dropdown choices +# Rendering mode and recovery +# Camera Raw: contextual controls and window title + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Additional shell controls, dynamic dialog fields and command labels. + +# Layers-panel trash confirmation + + +# Native macOS application menu. + +# Painting symmetry presets. + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice +# Dynamic color-picker titles and warnings about losing editable layer content. + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Mask Options… Параметри маски… + Layer Mask Display Options… Параметри відображення маски шару… + Channel Color Колір каналу + Color Indicates: Кольором позначено: + Solidity: Щільність: + Ink Characteristics Характеристики фарби Paper Width Ширина паперу Paper Height Висота паперу Rows Рядки @@ -40,7 +226,6 @@ 5 Columns, 3 Rows 5 стовпців, 3 рядки 8 Columns 8 стовпців 12 Columns 12 стовпців - File Файл Edit Редагування Image Зображення @@ -57,8 +242,6 @@ View Перегляд Window Вікно Help Довідка - -# File New… Створити… New from Clipboard Створити з буфера обміну New Створити @@ -100,8 +283,6 @@ Save for Web (Legacy)… Зберегти для вебу (застаріле)… Generate Генерувати Image Assets Ресурси зображення - -# Edit Undo Скасувати Redo Повторити Fade… Послабити… @@ -125,8 +306,6 @@ Define Pattern… Визначити візерунок… Define Custom Shape… Визначити довільну фігуру… Purge Очистити пам'ять - -# Image Image Size… Розмір зображення… Image Size Розмір зображення Canvas Size… Розмір полотна… @@ -165,8 +344,6 @@ Duotone… Дуплекс… Duotone Дуплекс Trap… Трепінг… - -# Layer Layer via Copy Шар через копіювання Layer via Cut Шар через вирізання Duplicate Layer… Дублювати шар… @@ -251,8 +428,6 @@ Scale Effects… Масштабувати ефекти… New Layer… Новий шар… Delete Layer… Видалити шар… - -# Type Font Шрифт Font Preview Size Розмір перегляду шрифту Orientation Орієнтація @@ -279,8 +454,6 @@ Load Default Type Styles Завантажити типові текстові стилі Save Default Type Styles Зберегти типові текстові стилі Type… Текст… - -# Select All Усе Deselect Зняти виділення Reselect Відновити виділення @@ -288,7 +461,6 @@ Inverse Інвертувати Color Range… Колірний діапазон… Select and Mask… Виділити та маскувати… -# Select and Mask dialog labels Smart Radius Розумний радіус Shift Edge Змістити край Decontaminate Очистити @@ -316,8 +488,6 @@ Current Layer Поточний шар Refine Strokes Штрихи уточнення Edits Правки - -# Filter Last Filter Останній фільтр Filter Gallery… Галерея фільтрів… Liquify… Пластика… @@ -328,7 +498,6 @@ Oil Paint… Олійний живопис… Blur Gallery Галерея розмиття Field Blur… Розмиття поля… -# Field Blur pins (#3373) No pin selected Шпильку не вибрано Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Клацніть зображення, щоб додати шпильку; перетягніть шпильку, щоб перемістити її, або її кільце, щоб змінити розмиття. Delete видаляє вибрану шпильку. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Перетягуйте маркери на контурі, щоб змінити розмір і повернути його, квадрат — щоб заокруглити кути, а точки всередині — щоб задати, де починається розмиття. @@ -417,8 +586,6 @@ Mezzotint… Мецотинто… Color Halftone… Колірний растр… Layer… Шар… - -# View Proof Setup Налаштування кольоропроби Proof Colors Кольори кольоропроби Pixel Aspect Ratio Співвідношення сторін пікселя @@ -465,8 +632,6 @@ Canvas rotation Поворот полотна Zoom in Збільшити Zoom out Зменшити - -# Window Cascade Каскадом Tile Плиткою 2-up Horizontal 2 по горизонталі @@ -515,8 +680,6 @@ 3D 3D Toolbars Панелі інструментів Hide All Приховати все - -# Help About PhotoCraft Про PhotoCraft PhotoCraft Website Вебсайт PhotoCraft PhotoCraft on GitHub PhotoCraft на GitHub @@ -524,8 +687,6 @@ ArtCraft Website Вебсайт ArtCraft Join the ArtCraft Discord… Приєднатися до Discord ArtCraft… System Info… Інформація про систему… - -# Tools and options bar Move Переміщення Marquee Прямокутне виділення Lasso Ласо @@ -586,8 +747,6 @@ Rotation Обертання Align Вирівняти Aligned Вирівняно - -# Panels, dialogs and common widgets OK Гаразд Cancel Скасувати Cancel (Esc) Скасувати (Esc) @@ -647,8 +806,6 @@ Select Data Points… Вибрати точки даних… Set Measurement Scale… Установити масштаб вимірювань… Use Measurement Scale Використовувати масштаб вимірювань - -# Preferences dialog Preferences Налаштування General… Загальні… Interface… Інтерфейс… @@ -673,8 +830,6 @@ Thumbnail size (smallest: list) Розмір мініатюр (найменший: список) Reset GPU Backend Скинути графічний бекенд Applies at next launch. Набуде чинності після перезапуску. - -# Document and canvas status bar Untitled Без назви Untitled-1 Без назви-1 Doc: {flat}/{layered} Документ: {flat}/{layered} @@ -713,8 +868,6 @@ Both Axes Обидві осі Horizontally Горизонтально Vertically Вертикально - -# Layers panel Layers to Files… Шари у файли… Duplicate Layers… Дублювати шари… Group name Назва групи @@ -744,8 +897,6 @@ Smart Filter Смартфільтр Fill Content Вміст заливки Adjustment Preview Перегляд корекції - -# Channels panel Channel: Канал: Channel Mixer… Змішувач каналів… Output Channel: Вихідний канал: @@ -760,8 +911,6 @@ Spot Channel Канал плашкового кольору Mask All Маскувати все Mask All Objects Маскувати всі об'єкти - -# Color picker and swatches Color Колір Color: Колір: Colors Кольори @@ -776,8 +925,6 @@ Overlay Opacity Непрозорість накладання Glow Color Колір світіння Web Snap Прив'язка до вебкольорів - -# Filters and adjustments dialogs Brightness Яскравість Contrast Контрастність Brightness/Contrast… Яскравість/контрастність… @@ -838,8 +985,6 @@ Working Yellow Plate Робоча жовта форма Working Black Plate Робоча чорна форма Working Gray Plate Робоча сіра форма - -# Camera Raw and lens dialogs Basic Основні Detail Деталі HSL/Grayscale HSL/градації сірого @@ -936,8 +1081,6 @@ Selected Areas Виділені області Spot Color Плашковий колір Color indicates Колір позначає - -# Adjustments panel Curves Криві Levels Рівні Color Balance Колірний баланс @@ -950,8 +1093,6 @@ Channel Mixer Змішувач каналів Gradient Map Карта градієнта Selective Color Вибіркова корекція кольору - -# History panel History Log… Журнал історії… Create Snapshot Створити знімок стану Delete Snapshot Видалити знімок стану @@ -966,14 +1107,10 @@ Automatically Create New Snapshot When Saving Автоматично створювати знімок стану під час збереження Show New Snapshot Dialog by Default Типово показувати діалог нового знімка стану Clear History Очистити історію - -# Navigator panel Panel Options… Параметри панелі… View menu Меню перегляду Keep view in sync Синхронізувати перегляд {zoom}% {zoom}% - -# Brush Settings panel Brush name Назва пензля Brush Preview Перегляд пензля Brush Tip Shape Форма кінчика пензля @@ -1057,8 +1194,6 @@ Override Tilt Y Перевизначити нахил Y Override Rotation Перевизначити обертання Apply the pen's tilt and rotation to the tip shape Застосувати нахил і обертання пера до форми кінчика - -# Tool presets Preset Type Тип набору Preset Manager… Менеджер наборів… Rename Tool Preset… Перейменувати набір інструмента… @@ -1072,8 +1207,6 @@ Preset: Набір: Preset Набір Presets: Набори: - -# Paragraph and character panels Font Size: Розмір шрифту: Type: Тип: Style Стиль @@ -1150,8 +1283,6 @@ Indent First Відступ першого рядка Center По центру Justify По ширині - -# Glyphs panel Glyph Гліф Select a type layer or click into text to insert glyphs Виберіть текстовий шар або клацніть у тексті, щоб вставити гліфи Entire Font Увесь шрифт @@ -1171,8 +1302,6 @@ Ordinals Порядкові числа East Asian Features Східноазійські можливості Middle Eastern Features Близькосхідні можливості - -# Paths panel Path Контур Path: Контур: Work Path Робочий контур @@ -1189,11 +1318,9 @@ Current Path Поточний контур Vector Mask from Current Path Векторна маска з поточного контуру Create new shape from the current path Створити нову фігуру з поточного контуру - Drag to move the active shape's path or the Work Path Перетягніть, щоб перемістити контур активної фігури або робочий контур + Drag to move the selected 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) або створіть робочий контур із виділення. - -# Clone Source panel Source: not set ({key}-click with Clone Stamp) Джерело: не задано ({key}+клацання клонувальним штампом) Clone source {n} ({key}-click with Clone Stamp to set) Джерело клонування {n} ({key}+клацання клонувальним штампом для встановлення) {key}-click to set the source {key}+клацання, щоб установити джерело @@ -1216,8 +1343,6 @@ Frame Sync Синхронізація кадрів Quick Sync Швидка синхронізація Enabled Увімкнено - -# Timeline panel Create Video Timeline Створити відеошкалу часу Open a document to use the timeline. Відкрийте документ, щоб користуватися шкалою часу. Video Layers Відеошари @@ -1259,8 +1384,6 @@ Repeat Повторення Play Once Відтворити один раз Video Cache Відеокеш - -# Info panel Info Options… Параметри інформації… First Color Readout Перше колірне значення Second Color Readout Друге колірне значення @@ -1283,8 +1406,6 @@ Mouse Coordinates Координати миші Ruler Units Одиниці лінійки Hide Приховати - -# Layer Comps panel Layer Comps to Files… Композиції шарів у файли… Apply next selected layer comp Застосувати наступну вибрану композицію шарів Apply previous selected layer comp Застосувати попередню вибрану композицію шарів @@ -1302,8 +1423,6 @@ Visibility Видимість Position Положення Appearance (Layer Style) Вигляд (стиль шару) - -# Analysis and variables Analysis Аналіз Variables Змінні Variable Data Sets… Набори даних змінних… @@ -1385,8 +1504,6 @@ Version {version} Версія {version} Advanced Розширені Import… Імпортувати… - -# Automate Automate Автоматизація Batch… Пакетна обробка… Create Droplet… Створити дроплет… @@ -1417,8 +1534,6 @@ Stop For Errors Зупинятися в разі помилок Log Errors To File Записувати помилки у файл Errors Помилки - -# Color Settings dialog Color Settings… Налаштування кольору… Settings Налаштування Settings: Налаштування: @@ -1447,8 +1562,6 @@ SMPTE C SMPTE C HDTV (Rec. 709) HDTV (Rec. 709) CIE RGB CIE RGB - -# Themes Theme Тема Switch theme Перемкнути тему Next Theme Наступна тема @@ -1473,8 +1586,6 @@ Pro Medium Gray Theme Середньосіра тема «Профі» Pro Light Gray Theme Світло-сіра тема «Профі» Pro Light Theme Світла тема «Профі» - -# Workspaces Workspace… Робочий простір… Essentials Основне New Workspace… Новий робочий простір… @@ -1502,8 +1613,6 @@ Collapse Panel Group Згорнути групу панелей Expand Panel Group Розгорнути групу панелей Close Tab Group Закрити групу вкладок - -# New Document dialog PRESET DETAILS ПАРАМЕТРИ НАБОРУ BLANK DOCUMENT PRESETS ({n}) НАБОРИ ПОРОЖНІХ ДОКУМЕНТІВ ({n}) Create a new document or open an existing file. Створіть новий документ або відкрийте наявний файл. @@ -1550,8 +1659,6 @@ Points Пункти Picas Піки Columns Стовпці - -# Placeholders, tooltips, status and keyboard hints {name} options Параметри: {name} Align {what} Вирівняти: {what} Untagged {mode} {mode} без профілю @@ -1728,8 +1835,6 @@ Current Поточний {added}, {removed} subtracted {added}, віднято {removed} Filter: Фільтр: - -# Transform / warp overlays Free Transform Вільна трансформація Free Transform a Copy Вільна трансформація копії Transform Трансформація @@ -1781,8 +1886,6 @@ Rotate Fixed Фіксоване обертання Adaptive Wide Angle isn't open Адаптивний широкий кут не відкрито Liquify is not open Пластику не відкрито - -# Vanishing Point Create Plane Створити площину Edit Plane Редагувати площину Select Plane Вибрати площину @@ -1796,8 +1899,6 @@ Blend Змішування Brush Size Розмір пензля Heal Color Відновити колір - -# Misc dialog strings Mask Маска Channel Канал Rectangle Прямокутник @@ -1820,8 +1921,6 @@ Add to Додати до Subtract from Відняти від Intersect with Перетнути з - -# Blend modes Pass Through Наскрізне Dissolve Розчинення Darken Затемнення @@ -1843,19 +1942,13 @@ Difference Різниця Exclusion Виключення Luminosity Світність - -# Saved views and other bits Capture Comp Зберегти композицію No Layer Comps Немає композицій шарів - -# Plurals @plural {n} item|{n} items {n} елемент|{n} елементи|{n} елементів @plural {n} Layer|{n} Layers {n} Шар|{n} Шари|{n} Шарів @plural {n} layer|{n} layers {n} шар|{n} шари|{n} шарів @plural Group · {n} layer|Group · {n} layers Група · {n} шар|Група · {n} шари|Група · {n} шарів @plural {n} sample|{n} samples {n} зразок|{n} зразки|{n} зразків - -# Remaining menu strings (second pass) 180° 180° 32-bit Preview Options… Параметри 32-бітного перегляду… 90° Clockwise 90° за годинниковою стрілкою @@ -1952,6 +2045,7 @@ Merge Group Об'єднати групу Merge Layers Об'єднати шари Merge Shape Components Об'єднати компоненти фігури + Path operations Операції з контурами Merge Visible Об'єднати видимі Stamp Visible Створити об'єднану копію видимих Select Layer Above Вибрати шар вище @@ -2024,8 +2118,6 @@ Video Frames to Layers… Відеокадри в шари… WIA Support… Підтримка WIA… Working CMY Plate Робоча форма CMY - -# tl! literals (third pass) Absolute Абсолютний Action Дія Adaptive Адаптивний @@ -2059,7 +2151,6 @@ Capture Зберегти стан Center Crop Marks Центральні мітки обрізання Centre the gradient (offset 0, 0) Центрувати градієнт (зсув 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Редактор градієнтів Gradient picker Вибір градієнта Gradient stops Точки градієнта @@ -2276,8 +2367,6 @@ Whites Білі тони Yellow Жовтий Yellow · Blue Жовтий · Синій - -# Preference dialog: generated setting labels and dropdown choices Adobe Adobe Adobe rgb Adobe RGB Allow agent control Дозволити керування агентом @@ -2500,7 +2589,6 @@ 4 x 4 4 x 4 5 x 5 5 x 5 Grid: Сітка: -# Rendering mode and recovery Rendering Mode Режим рендерингу Automatic (recommended) Автоматичний (рекомендовано) CPU / Compatibility CPU / сумісність @@ -2527,7 +2615,6 @@ Tone Histogram Тонова гістограма Show Selected Region Показувати вибрану область cameraRaw Light Світло -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Фільтр Camera Raw ({layer}) Camera Raw ({name}) Camera Raw ({name}) Aqua Аквамариновий @@ -2557,14 +2644,10 @@ cameraRaw Roughness Шорсткість Transform On Drop Трансформувати після відпускання Scale or rotate the moved area, then press {key} Змініть масштаб або поверніть переміщену область, потім натисніть {key} Edit Type Редагувати текст - - New Style… Новий стиль… Style {name} saved Стиль {name} збережено - Create new style (from the selected type) Створити новий стиль (із вибраного тексту) Create new style from the selected layer Створити новий стиль із вибраного шару - Make Default Зробити типовим Reset to Default Скинути до типового Full-resolution preview unavailable Перегляд у повній роздільності недоступний @@ -2593,9 +2676,6 @@ cameraRaw Roughness Шорсткість Magnetic Lasso Tool Інструмент «Магнітне ласо» Frequency Частота Use tablet pressure to change pen width Використовувати натиск планшета для зміни ширини пера - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About Про програму Contributors Учасники Models Моделі @@ -2626,8 +2706,6 @@ cameraRaw Roughness Шорсткість % of all commits % усіх комітів Lines +/− Рядки +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} комітів, +{added} / −{deleted} рядків (Δ {delta}), +{binAdded} / −{binDeleted} бінарних ресурсів, {first} – {last} - -# Swatches panel Create new swatch Створити новий зразок кольору New Swatch… Новий зразок кольору… New Swatch Group… Нова група зразків кольорів… @@ -2637,13 +2715,12 @@ cameraRaw Roughness Шорсткість Export Swatches for Exchange… Експортувати зразки кольорів для обміну… Reset Swatches Скинути зразки кольорів Add to Swatches Додати до зразків кольорів -# Layer Style > Blending Options (Advanced Blending) Blend If: Накладати, якщо: + Current Layer: Поточний шар: + Underlying Layer: Нижній шар: Channels: Канали: Knockout Вибивання Apply At Canvas Bounds Застосувати на межах полотна - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Вибір кольору (колір тексту) Color Picker ({name}) Вибір кольору ({name}) Color Picker (Fill Color) Вибір кольору (колір заливки) @@ -2660,8 +2737,6 @@ cameraRaw Roughness Шорсткість Show/Hide Panels Показати/приховати панелі Could not open Не вдалося відкрити {path}: the path is not valid Unicode; rename the file and open it again. {path}: шлях не є коректним Unicode; перейменуйте файл і відкрийте його знову. - -# Fill dialog, layer style contours and Image Size Contents: Вміст: Custom Pattern: Довільний візерунок: Foreground Color Колір переднього плану @@ -2687,14 +2762,12 @@ cameraRaw Roughness Шорсткість Rounded Steps Заокруглені сходинки Sawtooth 1 Пилка 1 Linear Лінійний - Red Eye Tool Інструмент «Червоні очі» Pupil Size Розмір зіниці Darken Amount Величина затемнення Click a red pupil to neutralize it Клацніть червону зіницю, щоб усунути ефект open source відкритий код …and {n} more …і ще {n} - Eyedroppers: Піпетки: Set Black Point Установити точку чорного Set Neutral Gray Point Установити точку нейтрального сірого @@ -2704,8 +2777,6 @@ cameraRaw Roughness Шорсткість Black Matte Чорна підкладка White Matte Біла підкладка Quick Mask Швидка маска - -# Layer colour labels. Seafoam Морська піна Indigo Індиго Fuchsia Фуксія @@ -2725,17 +2796,16 @@ fontWeight Bold Жирний fontWeight ExtraBold Екстражирний fontWeight Black Наджирний fontWeight Italic Курсивний - -# Pattern Stamp tool Pattern Stamp Tool Інструмент «Візерунковий штамп» Impressionist Імпресіоністичний Rotate View Tool Інструмент «Поворот перегляду» Reset View Скинути перегляд Drag around the centre to rotate the view · Shift constrains to 15° Перетягніть навколо центру, щоб повернути перегляд · Shift обмежує крок до 15° - -# Additional shell controls, dynamic dialog fields and command labels. (coming soon) (незабаром) Add a layer style Додати стиль шару + Move effect up Перемістити ефект вгору + Move effect down Перемістити ефект вниз + Delete effect Видалити ефект Add a mask (from the selection; {key} inverts) Додати маску (із виділення; {key} інвертує) Add an adjustment Додати корекцію Add space after paragraph Додати інтервал після абзацу @@ -2770,8 +2840,6 @@ fontWeight Italic Курсивний Merged Layers Об'єднані шари Delete Cropped Pixels Видаляти обрізані пікселі Delete current state Видалити поточний стан - -# Layers-panel trash confirmation Delete layer ‘{name}’? Видалити шар «{name}»? Delete {count} layers? {names} Видалити шари ({count})? {names} Delete layer? Видалити шар? @@ -2780,7 +2848,6 @@ fontWeight Italic Курсивний Missing delete command Відсутня команда видалення Invalid delete command Недійсна команда видалення Layer deleted, but confirmation preference could not be saved: {error} Шар видалено, але не вдалося зберегти налаштування підтвердження: {error} - Delete layer Видалити шар Diamond Gradient Ромбічний градієнт Enable airbrush-style build-up effects Увімкнути ефекти накопичення як в аерографі @@ -3598,8 +3665,6 @@ cameraRaw Masking Маскування Expand group (⌥-click: all groups) Розгорнути групу (⌥+клацання: усі групи) Hide the layer's effects (⌥-click: all layers) Приховати ефекти шару (⌥+клацання: усі шари) Show the layer's effects (⌥-click: all layers) Показати ефекти шару (⌥+клацання: усі шари) - -# Native macOS application menu. About {app} Про {app} Hide {app} Приховати {app} Quit {app} Вийти з {app} @@ -3623,12 +3688,9 @@ cameraRaw Masking Маскування Show Sampling Ring Показувати кільце вибірки Transparency Threshold Поріг прозорості Opacity Threshold Поріг непрозорості - -# Painting symmetry presets. Dual Axis Подвійна вісь Diagonal Діагональна Transform Symmetry Трансформувати симетрію - Rasterize svg groups deeper than Растеризувати групи SVG глибше цього рівня 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 вимикає цей поріг; групи SVG із вкладеністю понад 100 рівнів завжди растеризуються. Rule of Thirds Правило третин @@ -3641,8 +3703,6 @@ cameraRaw Masking Маскування Cycle Orientation Змінити орієнтацію Set the overlay options for the Crop tool Задати параметри накладання для інструмента «Рамка» 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-клік перехопило робоче середовище, яке переміщує вікна через Alt-перетягування. Призначте для переміщення вікон клавішу Super або зафіксуйте Alt у меню Вікно › Клавіші-модифікатори. - -# Shared Layer Style color picker Color Picker (Layer Style Color) Палітра кольорів (колір стилю шару) Auto Hide Notifications Автоматично приховувати сповіщення Notification Duration (seconds) Тривалість сповіщення (секунди) @@ -3717,7 +3777,6 @@ cameraRaw Masking Маскування Appearance Settings… Налаштування вигляду… Add to favorites Додати до вибраного Remove from favorites Видалити з вибраного -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). cameraData Make Виробник cameraData Model Модель cameraData Date Taken Дата зйомки @@ -3727,7 +3786,6 @@ cameraData ISO Speed Світлочутливість ISO cameraData Focal Length Фокусна відстань cameraData Focal Length (35 mm) Фокусна відстань (35 мм) cameraData Lens Об'єктив -# Shared panel docking Activate Panel Активувати панель Change Panel Group Змінити групу панелей Change Panel Layout Змінити розташування панелей @@ -3760,7 +3818,6 @@ cameraData Lens Об'єктив Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана -# Edit › Content-Aware Fill workspace Sampling Brush Tool Пензель області вибірки Sampling Area Overlay Накладання області вибірки Show Sampling Area Показати область вибірки @@ -3776,7 +3833,6 @@ cameraData Lens Об'єктив Mirror Віддзеркалення Output Settings Параметри виводу Output To Вивести в - Animation Options Параметри анімації Entire Document Увесь документ Filter Options Параметри фільтрів @@ -3793,8 +3849,6 @@ cameraData Lens Об'єктив 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}-клік: усі шари) @@ -3825,7 +3879,6 @@ curves Smooth Згладити Fix the red plane first: its corners must form a convex quad Спершу виправте червону площину: її кути мають утворювати опуклий чотирикутник Alt-click a plane to set the clone source Клацніть площину з Alt, щоб задати джерело клонування Too many clone strokes: apply Vanishing Point first Забагато мазків клонування: спочатку застосуйте «Точку сходження» -# Select and Mask view modes (#1754) Onion Skin Калька Marching Ants Мурашки On Black На чорному @@ -3834,7 +3887,6 @@ curves Smooth Згладити Show Edge Показати край Show Original Показати початкове F cycles the views; hold X to see the image F перемикає види; утримуйте X, щоб побачити зображення -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) Пензель уточнення краю (R) Expand Detection Area Розширити область виявлення Restore Original Edge Відновити початковий край @@ -3854,19 +3906,13 @@ curves Smooth Згладити Scale: Масштаб: Snap to Origin Прив'язати до початку координат Presentation Представлення - -# Color Sampler (added with the i18n coverage repair) 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… Параметри історії… -# History panel › History Options… (#2966) Allow Non-Linear History Дозволити нелінійну історію Make Layer Visibility Changes Undoable Дозволити скасування змін видимості шарів -# Print dialog (#2697) Emulsion Down Емульсією донизу Functions Функції Print Resolution: Роздільна здатність друку: @@ -3878,7 +3924,6 @@ curves Smooth Згладити Apply mask to layer before removing? Застосувати маску до шару перед видаленням? The layer or its mask changed. Drag the mask onto the Trash again. Шар або його маска змінилися. Перетягніть маску до кошика ще раз. Delete the vector mask? Видалити векторну маску? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Експортовано {n} зразок кольору до {file}|Експортовано {n} зразки кольорів до {file}|Експортовано {n} зразків кольорів до {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} Імпортовано {n} пензель з {group}|Імпортовано {n} пензлі з {group}|Імпортовано {n} пензлів з {group} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Імпортовано {n} зразок кольору з {file}|Імпортовано {n} зразки кольорів з {file}|Імпортовано {n} зразків кольорів з {file} @@ -3893,7 +3938,6 @@ curves Smooth Згладити @plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попереджень щодо наборів пензлів) @plural {what} with {n} warning|{what} with {n} warnings {what}: {n} попередження|{what}: {n} попередження|{what}: {n} попереджень -# View › Guides › New Guide… › Color Light Blue Світло-синій Light Red Світло-червоний Medium Blue Середньо-синій @@ -3914,8 +3958,6 @@ curves Smooth Згладити Clear Brush Controls Очистити параметри пензля Reset All Locked Settings Скинути всі заблоковані параметри Copy Texture to Other Tools Скопіювати текстуру в інші інструменти - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Завантажити операції… Save Actions… Зберегти операції… New Action… Нова операція… @@ -3927,10 +3969,11 @@ curves Smooth Згладити @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} крок ще не має відповідника в PhotoCraft|{n} кроки ще не мають відповідника в PhotoCraft|{n} кроків ще не мають відповідника в PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop - +@plural {n} action file loaded|{n} action files loaded Завантажено {n} файл операцій|Завантажено {n} файли операцій|Завантажено {n} файлів операцій + {set} from {file} replaced the one from {earlier} Набір {set} з {file} замінив набір з {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Не вдалося завантажити {n} файл: {errors}|Не вдалося завантажити {n} файли: {errors}|Не вдалося завантажити {n} файлів: {errors} File Naming Іменування файлів Starting serial Початковий порядковий номер -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Вставити зупинку… Save Set… Зберегти набір… Message: Повідомлення: @@ -3941,17 +3984,15 @@ curves Smooth Згладити Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій Background (Default) Фон (типовий) -# Batch / Image Processor Include All Subfolders Включити всі вкладені папки Auto Add/Delete Автододавання/видалення - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Не вмикайте також дзеркальне відображення в параметрах друку: два відображення скасовують одне одного. Print Settings… Параметри друку… Remember to turn off the printer's own colour management in Print Settings… Не забудьте вимкнути керування кольором самого принтера в параметрах друку… Sent to {printer} Надіслано на {printer} set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів + Memory usage percent Використання пам’яті (%) Show Favorite Fonts Показати вибрані шрифти No favorite fonts yet: click the star beside a font to add it. Вибраних шрифтів ще немає: натисніть зірочку біля шрифту, щоб додати його. Favorite fonts Вибрані шрифти @@ -3990,48 +4031,31 @@ curves Smooth Згладити Could not write {name}: {error} Не вдалося записати {name}: {error} Kept {name}: written by another application {name} залишено без змін: записаний іншою програмою Shadow Tint Відтінок тіней -# PhotoCraft release checks. Check for Updates… Перевірити оновлення… - PhotoCraft Updates Оновлення PhotoCraft - Checking for updates… Перевірка оновлень… Open the release page to choose an installer for your system. Відкрийте сторінку випуску й виберіть інсталятор для своєї системи. - Open Download Page Відкрити сторінку завантаження - PhotoCraft is up to date. PhotoCraft оновлено до останньої версії. - Could not check for updates. Не вдалося перевірити оновлення. - Updates have not been checked yet. Оновлення ще не перевірялися. - Check Again Перевірити знову Automatically Check for Updates Автоматично перевіряти оновлення - Installed version: {version} Встановлена версія: {version} - PhotoCraft {version} is available. Доступна версія PhotoCraft {version}. -# 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}) Remember this choice for future images Запам’ятати цей вибір для наступних зображень Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Знову ввімкнути запитання можна параметром Ask On Mismatch у Редагування › Налаштування кольору. Button Mode Режим кнопок Button Mode On Режим кнопок увімкнено - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Зробити інтерфейс темнішим Lighten Interface Зробити інтерфейс світлішим -# Bevel & Emboss Technique. Technique Метод 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 + Save Full-Size Composite in .pcraft Files Зберігати зведене зображення в повному розмірі у файлах .pcraft Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні Bicubic Automatic Бікубічна, автоматично @@ -4056,8 +4080,6 @@ curves Smooth Згладити set name назва набору {set} (modified) {set} (змінено) Show file name in title Показувати ім’я файла в заголовку - -# Image › Mode › Bitmap (#5559) 50% Threshold Поріг 50% Pattern Dither Візерунковий дизеринг Diffusion Dither Дифузійний дизеринг @@ -4075,10 +4097,8 @@ curves Smooth Згладити Undo Changes Скасувати зміни {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) 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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Його шарам потрібно {needed} пам'яті GPU; бюджет залишає їм {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта, не більше 3/8 її пам'яті. «Автоматично» стежить за використанням пам'яті, у межах чверті пам'яті цього комп'ютера й 3/8 пам'яті відеокарти, і не враховує документ на відеокарті з відомим обсягом пам'яті. Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». GPU Memory Full Пам'ять GPU заповнена @@ -4088,6 +4108,7 @@ curves Smooth Згладити 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 = автоматично) + Scratch Size Limit (MB, 0 = No Limit) Обмеження розміру робочого диска (МБ, 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} змінила інша програма після того, як його тут відкрили або зберегли. @@ -4103,7 +4124,6 @@ curves Smooth Згладити 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) Вибір кольору (основний колір) Color Picker (Custom Canvas Color) Вибір кольору (власний колір полотна) @@ -4118,8 +4138,6 @@ curves Smooth Згладити 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}% @@ -4129,18 +4147,13 @@ curves Smooth Згладити Use Використати In Front Спереду Behind Ззаду - -# DDS Options (#1327) DDS Options Параметри DDS 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 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Вміст AI/PDF/EPS редагуватиметься як SVG. Збереження замінить вбудоване джерело; властивості початкового формату можуть змінитися. # Styles panel: Import / Export Styles (.asl) Import Styles… Імпортувати стилі… @@ -4149,7 +4162,6 @@ curves Smooth Згладити {file} is not a style library (.asl) {file} не є бібліотекою стилів (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Імпортовано {n} стиль з {file}|Імпортовано {n} стилі з {file}|Імпортовано {n} стилів з {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Експортовано {n} стиль до {file}|Експортовано {n} стилі до {file}|Експортовано {n} стилів до {file} -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first Спочатку підтвердьте або скасуйте відкритий діалог кроку операції Pro Medium Gray Середньосіра «Профі» Solarized Solarized @@ -4158,14 +4170,11 @@ curves Smooth Згладити 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Куб тону Brightness Cube Куб яскравості Color Wheel Колірне коло @@ -4181,7 +4190,8 @@ curves Smooth Згладити Grayscale Ramp Шкала градацій сірого Current Colors Поточні кольори Make Ramp Web Safe Зробити шкалу безпечною для вебу -# Art History Brush (#213). + The path was kept as the Work Path Контур збережено як робочий контур + The path was discarded Контур видалено Art History Brush Tool Інструмент «Художній пензель історії» Tight Short Щільний короткий Tight Medium Щільний середній @@ -4192,9 +4202,32 @@ curves Smooth Згладити Tight Curl Щільний завиток Loose Curl Вільний завиток Stylized dabs from a history state Стилізовані мазки зі стану історії - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Target Ціль Font size presets Набори розмірів шрифту Leading presets Набори інтерліньяжу Tracking presets Набори трекінгу + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Неприпустиме числове значення. Потрібне ціле число від {min}% до {max}%. Вставлено попереднє значення. + Clear Selection Очистити виділення + Color Aware З урахуванням кольору + Decontaminate Colors Очистити кольори + Edge Detection Виявлення краю + Global Refinements Глобальне уточнення + High Quality Preview Високоякісний перегляд + Object Aware З урахуванням об'єктів + Real-Time Refinement Уточнення в реальному часі + Refine Edge Brush Tool Інструмент «Пензель уточнення краю» + Refine Hair Уточнити волосся + Refine Mode Режим уточнення + Remember Settings Запам'ятати параметри + View Mode Режим перегляду + Object Aware needs a segmentation model, which isn't available yet Для режиму «З урахуванням об'єктів» потрібна модель сегментації, яка ще недоступна + Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне + Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний + Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker Вибір кольору + Recently Used Colors Нещодавно використані кольори + Stop Color Колір контрольної точки + Shapes can't use patterns yet Фігури поки не можуть використовувати візерунки + Edit Brush… Редагувати пензель… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f348662975..d868ab099a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -9,8 +9,8 @@ # escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. # lines starting with # and blank lines are ignored. # -# Status: complete. Every source key is translated (`complete_menus`), and the coverage test in -# xtask/src/i18n_coverage.rs keeps it that way: new `tl!` strings need a Vietnamese row. +# Status: complete when last measured. `cargo xtask i18n-coverage` lists any `tl!` string without a +# Vietnamese row; a missing row shows in English. # # Clean-room: every entry was written from the plain meaning of the English label using ordinary # Vietnamese computing and image-editing vocabulary. No proprietary localisation resource, string @@ -21,20 +21,151 @@ # 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) + +# Phase 3.1 — top-level menus, File menu, Edit menu +# Custom paper dimensions in Print Settings. + + + +# Phase 3.2 — Image menu, Layer menu + + +# Phase 3.3 — Type menu, Select menu + + +# Phase 3.4 — Filter menu +# Field Blur pins (#3373) + + +# Phase 3.5 — View menu + + +# Phase 3.6 — Window menu, Help menu + + +# Phase 3.7 — tool names + Tool Options bar + + +# Phase 3.8 — Layers + Properties + History panel internals + + +# Phase 3.9 — Character + Paragraph + Glyphs typography panels + + +# Phase 3.10 — Brushes + Brush Settings panel internals + +# Phase 3.11 — Gradients + Patterns + Swatches asset panels + +# Phase 3.12 — Channels + Paths + Navigator + Info + Histogram + +# Mixer Brush (required for every registered non-English language) + +# Camera Raw labels (required for every registered non-English language) + +# Phase 4.1 — Preferences dialog + + +# Phase 4.2 — general dialogs, status/progress, warnings/errors + +# Phase 4.3 — Keyboard Shortcuts and Menus editor + +# Phase 4.4 — Color Settings dialog + +# Phase 4.5 — Preset Manager + Export/Import Presets + +# Phase 4.6 — Camera Raw chrome + Light/Color basics (tone, WB, presence) + +# Phase 4.7 — Camera Raw Curve + Color Mixer + +# Phase 4.8 — Camera Raw Color Grading + Detail + +# Phase 4.10 — Camera Raw Effects (Texture/Clarity/Dehaze + Vignetting + Grain) + +# Phase 4.13 — New Document + Home + Image Size + +# Phase 4.14 — Export / Save for Web / TIFF options + +# Phase 4.15 — Warp / Liquify / Perspective / Puppet + +# Gap Batch 4 — Layer Comps / Styles leftovers / Color Range / plurals / command palette + +# Visual QA finalization + +# Post-rebase gate-required strings + +# Phase 5.1 — tools / paths / assets / filters / layers + +# Phase 5.2 — dialogs / chrome / preferences / file UI / engine labels + +# Phase 5.3A — print / proof + +# Phase 5.3B — specialist workflows + +# Phase 5.4 — Help / About / credits + +# Phase 5.5 — final stable-key completion + +# Phase 5.6 — upstream kys first-launch import prompt + +# Upstream sync after rebase onto main + +# Labels added on main since this catalog was written (maintainer pass) + +# Rows from @tonguyenducmanh's Vietnamese catalog (#3453), merged here so the catalog is complete + +# Remaining labels (maintainer pass) +# Edit › Content-Aware Fill workspace + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Batch / Image Processor + +# Native Windows printing qualification +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) + +# Layers panel options and Smart Filter editors + +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + 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 Color Màu kênh Color Indicates: Màu biểu thị: Solidity: Độ đặc: Ink Characteristics Đặc tính mực - -# Phase 3.1 — top-level menus, File menu, Edit menu -# Custom paper dimensions in Print Settings. Paper Width Chiều rộng giấy Paper Height Chiều cao giấy - Again Lặp lại AI Integrations… Tích hợp AI… All Tất cả @@ -200,9 +331,6 @@ WIA Support… Hỗ trợ WIA… Window Cửa sổ Workspace… Không gian làm việc… - - -# Phase 3.2 — Image menu, Layer menu 16 Bits/Channel 16 bit/kênh 180° 180° 32 Bits/Channel 32 bit/kênh @@ -335,6 +463,7 @@ Median Trung vị Merge Layers Gộp lớp Merge Shape Components Gộp thành phần hình + Path operations Thao tác đường dẫn Merge Visible Gộp các lớp hiển thị Minimum Cực tiểu Mode Chế độ @@ -410,9 +539,6 @@ Vibrance… Độ sống động… Video Video Video Layers Lớp video - - -# Phase 3.3 — Type menu, Select menu Anti-Alias Khử răng cưa Border… Viền… Character Panel Bảng ký tự @@ -486,9 +612,6 @@ Warp Text… Biến dạng chữ… Windows Windows Windows LCD Windows LCD - - -# Phase 3.4 — Filter menu Adaptive Wide Angle… Góc rộng thích ứng… Add Noise… Thêm nhiễu… Average Trung bình @@ -513,7 +636,6 @@ Facet Mặt cắt Fibers… Sợi… Field Blur… Làm mờ vùng… -# Field Blur pins (#3373) No pin selected Chưa chọn ghim nào Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Bấm vào ảnh để thêm ghim; kéo ghim để di chuyển, hoặc kéo vòng của ghim để đổi độ mờ. Delete xóa ghim đã chọn. Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. Kéo các tay nắm trên đường viền để đổi kích thước và xoay, kéo ô vuông để bo tròn góc, và kéo các chấm bên trong để đặt nơi bắt đầu làm mờ. @@ -575,9 +697,6 @@ Wave… Sóng… Wind… Gió… ZigZag… Zigzag… - - -# Phase 3.5 — View menu 100% 100% 105% 105% 110% 110% @@ -654,9 +773,6 @@ Working Yellow Plate Bản Yellow làm việc Zoom In Phóng to Zoom Out Thu nhỏ - - -# Phase 3.6 — Window menu, Help menu 2-up Horizontal 2 cửa sổ ngang 2-up Vertical 2 cửa sổ dọc 3-up Horizontal 3 cửa sổ ngang @@ -729,9 +845,6 @@ Tool Presets Thiết lập sẵn công cụ Tools Công cụ Workspace Không gian làm việc - - -# Phase 3.7 — tool names + Tool Options bar Move Tool Công cụ Di chuyển Rectangular Marquee Tool Công cụ Vùng chọn chữ nhật Elliptical Marquee Tool Công cụ Vùng chọn elip @@ -923,11 +1036,9 @@ Align horizontal centers Căn tâm ngang Align right edges Căn cạnh phải Drag to move the active shape's path or the Work Path Kéo để di chuyển đường dẫn hình đang chọn hoặc Đường dẫn làm việc + Drag to move the selected path Kéo để di chuyển đường dẫn đã chọn Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Bấm: góc · Kéo: mượt · Bấm điểm đầu: đóng · {key} xong · Esc hủy Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Bấm: chọn điểm neo · Kéo: di chuyển điểm neo, tay nắm hoặc đoạn · {shift}-bấm: thêm · {alt}-bấm: cả nhánh con - - -# Phase 3.8 — Layers + Properties + History panel internals Pixel Layer Lớp pixel Type Layer Lớp chữ Shape Layer Lớp hình @@ -957,6 +1068,9 @@ Delete layer Xóa lớp Link layers Liên kết lớp Add a layer style Thêm kiểu lớp + Move effect up Chuyển hiệu ứng lên + Move effect down Chuyển hiệu ứng xuống + Delete effect Xóa hiệu ứng Add a mask (from the selection; {key} inverts) Thêm mặt nạ (từ vùng chọn; {key} đảo) No document Không có tài liệu Collapse Thu gọn @@ -1057,9 +1171,6 @@ Use Legacy Dùng bản cũ Violet, Orange Tím, Cam Yellow · Blue Vàng · Xanh dương - - -# Phase 3.9 — Character + Paragraph + Glyphs typography panels Font size Cỡ chữ Leading Giãn dòng Leading (set to the font size × auto-leading when Auto) Giãn dòng (đặt bằng cỡ chữ × giãn dòng tự động khi Tự động) @@ -1131,9 +1242,6 @@ Symbols Ký hiệu Math Symbols Ký hiệu toán Arrows Mũi tên - - -# Phase 3.10 — Brushes + Brush Settings panel internals Brush Tip Shape Hình dạng đầu cọ Shape Dynamics Biến thiên hình dạng Scattering Phân tán @@ -1231,8 +1339,6 @@ Linear Burn Đốt tuyến tính Hard Mix Trộn cứng Linear Height Cao tuyến tính - -# Phase 3.11 — Gradients + Patterns + Swatches asset panels Gradient Chuyển sắc Gradient Options Tùy chọn chuyển sắc Linear Tuyến tính @@ -1254,8 +1360,6 @@ Thumbnail size (smallest: list) Cỡ hình thu nhỏ (nhỏ nhất: danh sách) Color & Swatches Màu & Mẫu màu Click sets foreground · right-click sets background Bấm đặt nền trước · chuột phải đặt nền - -# Phase 3.12 — Channels + Paths + Navigator + Info + Histogram Show the layer mask as an overlay Hiện mặt nạ lớp dạng phủ Toggle visibility Hiện/ẩn Load channel as selection Tải kênh thành vùng chọn @@ -1308,8 +1412,6 @@ Median: Trung vị: Pixels: Pixel: Cache Level: Mức đệm: - -# Mixer Brush (required for every registered non-English language) Mixer Brush Cọ trộn Mixer Brush Tool Công cụ Cọ trộn Wet Ướt @@ -1322,8 +1424,6 @@ Mix Trộn Flow Lưu lượng Sample All Layers Lấy mẫu mọi lớp - -# Camera Raw labels (required for every registered non-English language) Amount Mức Aqua Xanh ngọc Balance Cân bằng @@ -1408,8 +1508,6 @@ cameraRaw Masking Mặt nạ cameraRaw Blending Độ hòa trộn cameraRaw Highlights Vùng sáng cameraRaw Shadows Vùng tối - -# Phase 4.1 — Preferences dialog 125% 125% 150% 150% 175% 175% @@ -1559,9 +1657,6 @@ cameraRaw Shadows Vùng tối Vulkan Vulkan Webp WebP Zoom with scroll wheel Thu phóng bằng bánh xe cuộn - - -# Phase 4.2 — general dialogs, status/progress, warnings/errors Yes Có No Không Don't Save Không lưu @@ -1621,8 +1716,6 @@ cameraRaw Shadows Vùng tối 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 Keyboard Shortcuts and Menus Phím tắt và menu Keyboard Shortcuts Phím tắt Menus Menu @@ -1653,8 +1746,6 @@ cameraRaw Shadows Vùng tối blue Xanh dương violet Tím gray Xám - -# Phase 4.4 — Color Settings dialog Color Settings Cài đặt màu Black Point Compensation Bù điểm đen Working Rgb RGB làm việc @@ -1688,8 +1779,6 @@ cameraRaw Shadows Vùng tối Color Management Policies Chính sách quản lý màu Relative Colorimetric Đo màu tương đối Absolute Colorimetric Đo màu tuyệt đối - -# Phase 4.5 — Preset Manager + Export/Import Presets Preset Manager Quản lý thiết lập sẵn Export/Import Presets Xuất/Nhập thiết lập sẵn Preset Type Loại thiết lập sẵn @@ -1701,8 +1790,6 @@ cameraRaw Shadows Vùng tối Action Thao tác Export Presets Xuất thiết lập sẵn Import Presets Nhập thiết lập sẵn - -# Phase 4.6 — Camera Raw chrome + Light/Color basics (tone, WB, presence) Fit in View Vừa khung nhìn Camera Raw Camera Raw cameraRaw Color Màu @@ -1716,8 +1803,6 @@ cameraRaw Dehaze Khử sương cameraRaw Temperature Nhiệt độ màu cameraRaw Vibrance Độ sống động cameraRaw Saturation Độ bão hòa - -# Phase 4.7 — Camera Raw Curve + Color Mixer cameraRaw Curve Đường cong cameraRaw Color Mixer Bộ trộn màu cameraRaw Hue Sắc màu @@ -1730,8 +1815,6 @@ cameraRaw Aqua Xanh ngọc cameraRaw Blue Xanh dương cameraRaw Purple Tím cameraRaw Magenta Đỏ tía - -# Phase 4.8 — Camera Raw Color Grading + Detail cameraRaw Color Grading Phân cấp màu cameraRaw Midtones Tông trung gian cameraRaw Global Tổng thể @@ -1740,15 +1823,11 @@ cameraRaw Detail Chi tiết cameraRaw Radius Bán kính cameraRaw Luminance Detail Chi tiết độ sáng cameraRaw Color Detail Chi tiết màu - -# Phase 4.10 — Camera Raw Effects (Texture/Clarity/Dehaze + Vignetting + Grain) cameraRaw Feather Độ mềm cạnh cameraRaw Midpoint Điểm giữa cameraRaw Roundness Độ tròn cameraRaw Roughness Độ thô cameraRaw Size Kích thước - -# Phase 4.13 — New Document + Home + Image Size Home Trang chủ Recent Gần đây Photo Ảnh @@ -1798,8 +1877,6 @@ document Orientation Hướng trang 32 bit (float) 32-bit (float) Integer Số nguyên Floating point Số thực - -# Phase 4.14 — Export / Save for Web / TIFF options File Settings Cài đặt tệp Transparency Độ trong suốt TIFF Options Tùy chọn TIFF @@ -1828,8 +1905,6 @@ document Orientation Hướng trang All Slices Mọi lát cắt All User Slices Mọi lát cắt người dùng Matte Matte - -# Phase 4.15 — Warp / Liquify / Perspective / Puppet Warp: Biến dạng: Bend: Độ uốn: Split: Chia: @@ -1904,8 +1979,6 @@ document Orientation Hướng trang Fixed Cố định Remove All Pins Xóa mọi ghim Commit Puppet Warp ({key}) Áp dụng biến dạng con rối ({key}) - -# Gap Batch 4 — Layer Comps / Styles leftovers / Color Range / plurals / command palette Last Document State Trạng thái tài liệu cuối Capture the visibility, position and style of every layer with + below, then switch between versions. Dùng + bên dưới để ghi hiển thị, vị trí và kiểu của mọi lớp, rồi chuyển giữa các phiên bản. Visibility Hiển thị @@ -1990,8 +2063,6 @@ colorRange Invert Đảo ngược No matching commands Không tìm thấy lệnh ↑↓ navigate {key} run esc close ↑↓ điều hướng {key} chạy esc đóng Tool Công cụ - -# Visual QA finalization New document… Tài liệu mới… Join us on Discord Tham gia Discord PhotoCraft website Trang web PhotoCraft @@ -1999,8 +2070,6 @@ colorRange Invert Đảo ngược Canvas extension color: Màu mở rộng canvas: layers Fill Độ tô layers Fill: Độ tô: - -# Post-rebase gate-required strings Pattern Stamp Tool Công cụ Con dấu họa tiết Pattern Stamp Con dấu họa tiết Impressionist Ấn tượng @@ -2009,8 +2078,6 @@ layers Fill: Độ tô: Open Mở Camera Raw ({name}) Camera Raw ({name}) Developing {name} Đang xử lý {name} - -# Phase 5.1 — tools / paths / assets / filters / layers 101 by 101 Average Trung bình 101 × 101 11 by 11 Average Trung bình 11 × 11 3 by 3 Average Trung bình 3 × 3 @@ -2030,6 +2097,8 @@ layers Fill: Độ tô: Bitmap… Bitmap… Blend If Điều kiện hòa trộn Blend If: Điều kiện hòa trộn: + Current Layer: Lớp hiện tại: + Underlying Layer: Lớp bên dưới: Bottom Left Dưới trái Bottom Right Dưới phải Brush Name Tên cọ @@ -2148,8 +2217,6 @@ layers Fill: Độ tô: Weight Độ dày Zoom in Phóng to Zoom out Thu nhỏ - -# Phase 5.2 — dialogs / chrome / preferences / file UI / engine labels A save to {name} is still running; try again when it finishes Đang lưu {name}; thử lại khi hoàn tất Adwaita Dark Theme Chủ đề Adwaita tối Adwaita Light Theme Chủ đề Adwaita sáng @@ -2239,8 +2306,6 @@ layers Fill: Độ tô: {size} (was {old}) {size} (trước là {old}) {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) …and {n} more …và {n} nữa - -# Phase 5.3A — print / proof (print to the printer) (in ra máy in) Adobe RGB (1998) compatible Tương thích Adobe RGB (1998) Center Crop Marks Dấu cắt giữa @@ -2256,8 +2321,6 @@ layers Fill: Độ tô: Printer Setup Thiết lập máy in Printing Marks Dấu in Registration Marks Dấu chồng màu - -# Phase 5.3B — specialist workflows + Add variable + Thêm biến 30 frames @ 30 fps 30 khung hình @ 30 fps 3D LUT File Tệp 3D LUT @@ -2317,8 +2380,6 @@ layers Fill: Độ tô: Suggestions: Gợi ý: Text Position: Vị trí văn bản: Variables bind a layer's visibility, text or pixels to a named data slot. Biến gắn trạng thái hiện, văn bản hoặc pixel của lớp với một khe dữ liệu có tên. - -# Phase 5.4 — Help / About / credits % of all commits % tổng số commit @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, dòng +{added} / −{deleted} (Δ {delta}), tài sản nhị phân +{binAdded} / −{binDeleted}, {first} – {last} 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 tắt ngưỡng này; nhóm SVG lồng nhau sâu hơn 100 cấp luôn được raster hóa. @@ -2349,8 +2410,6 @@ layers Fill: Độ tô: Table Bảng Username Tên người dùng Version Phiên bản - -# Phase 5.5 — final stable-key completion Active Đang dùng Add Thêm Appearance Giao diện @@ -2377,16 +2436,12 @@ shape Appearance Diện mạo Tetrahedral Tetrahedral Top Trên Trilinear Trilinear - -# Phase 5.6 — upstream kys first-launch import prompt Show channels in color Hiện kênh bằng màu Import keyboard shortcuts? Nhập phím tắt? 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 tìm thấy bộ phím tắt {set} từ trình chỉnh sửa ảnh khác trên máy này: {n} phím tắt khác với PhotoCraft. Nhập vào? Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Chỉnh sửa › Phím tắt › Đặt lại tất cả về mặc định sẽ hoàn tác lần nhập. Don't Import Không nhập The keyboard shortcut set was not imported: {error} Không nhập được bộ phím tắt: {error} - -# Upstream sync after rebase onto main Auto Center Preview Tự động căn giữa xem trước Brush Cọ Color Picker ({name}) Bộ chọn màu ({name}) @@ -2402,8 +2457,6 @@ shape Appearance Diện mạo Use Classic Mode Dùng chế độ cổ điển W x H x Resolution Rộng x Cao x Độ phân giải Block Khối - -# Labels added on main since this catalog was written (maintainer pass) (No neutral color exists for {mode} mode.) (Không có màu trung tính cho chế độ {mode}.) 24 bits/pixel 24 bit/pixel 32 bits/pixel 32 bit/pixel @@ -2558,8 +2611,6 @@ shape Appearance Diện mạo @plural {n} step|{n} steps {n} bước @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} cảnh báo thiết lập sẵn cọ khác) @plural {what} with {n} warning|{what} with {n} warnings {what} với {n} cảnh báo - -# Rows from @tonguyenducmanh's Vietnamese catalog (#3453), merged here so the catalog is complete (coming soon) (sắp có) Add beauty precomp from the AOVs Thêm precomp beauty từ các AOV Show in Explorer Hiện trong Explorer @@ -3596,8 +3647,6 @@ cameraData Lens Ống kính sRGB IEC61966-2.1 sRGB IEC61966-2.1 shortcut phím tắt Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Kéo (dây mềm) hoặc bấm các điểm (đa giác) · {add} thêm · {sub} trừ - -# Remaining labels (maintainer pass) Selected Artboards Các artboard đã chọn 5 Columns, 3 Rows 5 cột, 3 hàng 8 Columns 8 cột @@ -3646,7 +3695,6 @@ curves Smooth Làm mượt 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ô Full Toàn phần @@ -3666,8 +3714,6 @@ curves Smooth Làm mượt Clear Brush Controls Xóa điều khiển cọ Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác - -# 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… @@ -3679,10 +3725,11 @@ curves Smooth Làm mượt @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} bước chưa có chức năng tương ứng trong PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop - +@plural {n} action file loaded|{n} action files loaded Đã nạp {n} tệp hành động + {set} from {file} replaced the one from {earlier} Bộ {set} từ {file} đã thay thế bộ từ {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Không thể nạp {n} tệp: {errors} File Naming Đặt tên tệp Starting serial Số thứ tự bắt đầu -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… Chèn điểm dừng… Save Set… Lưu bộ… Message: Thông báo: @@ -3692,30 +3739,26 @@ 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) -# History panel › History Options… (#2966) Allow Non-Linear History Cho phép lịch sử không tuyến tính Make Layer Visibility Changes Undoable Cho phép hoàn tác thay đổi hiển thị lớp Presentation Trình bày -# Batch / Image Processor Include All Subfolders Bao gồm tất cả thư mục con Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc Import .GRD Nhập .GRD Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). Auto Add/Delete Tự động thêm/xóa - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. Đừng bật thêm Ảnh phản chiếu trong thiết lập in: hai lần lật sẽ triệt tiêu nhau. Print Settings… Thiết lập in… Remember to turn off the printer's own colour management in Print Settings… Nhớ tắt quản lý màu riêng của máy in trong thiết lập in… 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 + Memory usage percent Tỷ lệ sử dụng bộ nhớ Show Favorite Fonts Hiển thị phông chữ yêu thích No favorite fonts yet: click the star beside a font to add it. Chưa có phông chữ yêu thích: nhấp vào ngôi sao bên cạnh phông chữ để thêm. Favorite fonts Phông chữ yêu thích @@ -3755,46 +3798,30 @@ curves Smooth Làm mượt Kept {name}: written by another application Đã giữ {name}: do ứng dụng khác ghi Shadow Tint Sắc độ bóng tối Check for Updates… Kiểm tra bản cập nhật… - PhotoCraft Updates Bản cập nhật PhotoCraft - Checking for updates… Đang kiểm tra bản cập nhật… Open the release page to choose an installer for your system. Mở trang phát hành để chọn bộ cài đặt cho hệ thống của bạn. - Open Download Page Mở trang tải xuống - PhotoCraft is up to date. PhotoCraft đã được cập nhật. - Could not check for updates. Không thể kiểm tra bản cập nhật. - Updates have not been checked yet. Chưa kiểm tra bản cập nhật. - Check Again Kiểm tra lại Automatically Check for Updates Tự động kiểm tra bản cập nhật - Installed version: {version} Phiên bản đã cài: {version} - PhotoCraft {version} is available. PhotoCraft {version} đã có sẵn. -# 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}) Remember this choice for future images Ghi nhớ lựa chọn này cho các ảnh sau Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Bật lại câu hỏi bằng Ask On Mismatch trong Chỉnh sửa › Cài đặt màu. Button Mode Chế độ nút Button Mode On Chế độ nút bật - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface Làm tối giao diện Lighten Interface Làm sáng giao diện -# Bevel & Emboss Technique. Technique Kỹ thuậ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 + Save Full-Size Composite in .pcraft Files Lưu ảnh tổng hợp kích thước đầy đủ trong tệp .pcraft 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 @@ -3819,8 +3846,6 @@ 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 @@ -3830,8 +3855,6 @@ curves Smooth Làm mượt 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 @@ -3849,7 +3872,6 @@ curves Smooth Làm mượt 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%. @@ -3865,11 +3887,9 @@ curves Smooth Làm mượt Undo Changes Hoàn tác thay đổi {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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}. Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được, tối đa 3/8 bộ nhớ của card. Tự động theo mức sử dụng bộ nhớ, trong giới hạn một phần tư bộ nhớ máy này và 3/8 bộ nhớ card, và không tính tài liệu vào card đã biết dung lượng bộ nhớ. Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. GPU Memory Full Bộ nhớ GPU đầy @@ -3879,6 +3899,7 @@ curves Smooth Làm mượt 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) + Scratch Size Limit (MB, 0 = No Limit) Giới hạn dung lượng đĩa tạm (MB, 0 = Không giới hạn) {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. @@ -3896,8 +3917,6 @@ curves Smooth Làm mượt 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 @@ -3912,18 +3931,22 @@ curves Smooth Làm mượt Use Dùng In Front Phía trước Behind Phía sau - -# DDS Options (#1327) DDS Options Tùy chọn DDS 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 +smartSvg Choose executable… Chọn tệp thực thi… +smartSvg Choose the application used to edit this SVG: Chọn ứng dụng dùng để chỉnh sửa SVG này: +smartSvg Edit SVG Smart Object Chỉnh sửa Đối tượng thông minh SVG +smartSvg Other application Ứng dụng khác +smartSvg PhotoCraft (internal editor) PhotoCraft (trình chỉnh sửa nội bộ) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. Đã mở trình chỉnh sửa SVG. Lưu SVG để cập nhật Đối tượng thông minh này. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Lưu vào tệp SVG đã mở. Mỗi lần lưu thay đổi tạo một bước hoàn tác trong PhotoCraft. +smartSvg Stop syncing this Smart Object Dừng đồng bộ Đối tượng thông minh này +smartSvg Updates wait while another document or an editing dialog is active. Các cập nhật sẽ chờ khi tài liệu khác hoặc hộp thoại chỉnh sửa đang hoạt động. +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. Nội dung AI/PDF/EPS sẽ được chỉnh sửa dưới dạng SVG. Lưu sẽ thay thế nguồn nhúng; các tính năng riêng của định dạng có thể thay đổi. # Styles panel: Import / Export Styles (.asl) Import Styles… Nhập kiểu… @@ -3932,7 +3955,6 @@ curves Smooth Làm mượt {file} is not a style library (.asl) {file} không phải là thư viện kiểu (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} Đã nhập {n} kiểu từ {file} @plural Exported {n} style to {file}|Exported {n} styles to {file} Đã xuất {n} kiểu sang {file} -# 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 @@ -3941,14 +3963,11 @@ curves Smooth Làm mượ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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube Khối sắc màu Brightness Cube Khối độ sáng Color Wheel Bánh xe màu @@ -3965,7 +3984,8 @@ curves Smooth Làm mượt Grayscale Ramp Dải thang xám Current Colors Màu hiện tại Make Ramp Web Safe Giới hạn dải màu ở màu web -# Art History Brush (#213). + The path was kept as the Work Path Đường dẫn được giữ lại làm Đường dẫn làm việc + The path was discarded Đường dẫn đã bị loại bỏ Art History Brush Cọ lịch sử nghệ thuật Art History Brush Tool Công cụ Cọ lịch sử nghệ thuật Tight Short Chặt ngắn @@ -3977,9 +3997,33 @@ curves Smooth Làm mượt Tight Curl Xoắn chặt Loose Curl Xoắn lỏng Stylized dabs from a history state Các chấm cách điệu từ một trạng thái lịch sử - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio Tỷ lệ khung hình Font size presets Cỡ chữ đặt sẵn Leading presets Giãn dòng đặt sẵn Tracking presets Khoảng cách chữ đặt sẵn + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. Giá trị số không hợp lệ. Cần một số nguyên từ {min}% đến {max}%. Đã chèn giá trị trước đó. + Clear Selection Xóa vùng chọn + Color Aware Nhận biết màu + Decontaminate Colors Khử nhiễm màu + Edge Detection Phát hiện cạnh + Global Refinements Tinh chỉnh tổng thể + High Quality Preview Xem trước chất lượng cao + Object Aware Nhận biết đối tượng + Real-Time Refinement Tinh chỉnh theo thời gian thực + Refine Edge Brush Tool Công cụ cọ tinh chỉnh cạnh + Refine Hair Tinh chỉnh tóc + Refine Mode Chế độ tinh chỉnh + Remember Settings Ghi nhớ cài đặt + View Mode Chế độ xem + Object Aware needs a segmentation model, which isn't available yet Nhận biết đối tượng cần mô hình phân đoạn, hiện chưa có + Refine Hair needs hair detection, which isn't available yet Tinh chỉnh tóc cần tính năng phát hiện tóc, hiện chưa có + Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động + Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn + Select Layer Under Cursor Chọn lớp dưới con trỏ + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker Bộ chọn màu + Recently Used Colors Màu dùng gần đây + Stop Color Màu điểm dừng + Shapes can't use patterns yet Hình chưa thể dùng mẫu tô + Edit Brush… Chỉnh sửa cọ… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9f437214a..d9b554acfa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -12,10 +12,114 @@ # 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) + +# Guide layout dialog + +# Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) +# Counted status and panel messages + + +# Layers-panel trash confirmation + +# Camera Raw: contextual controls and window title +# Current shell labels and shortcut templates. Keep {key} platform-dependent. +# Gradient Editor window, opened from the options bar's gradient swatch. +# Layer navigation, copy transforms and cancellable opening (labels added on main since this catalog). +# Rendering mode and recovery + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# Export As metadata and profile options + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) + +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# File opening, export status and graphics driver information + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + + Mask Options… 蒙版选项… + Layer Mask Display Options… 图层蒙版显示选项… Channel Color 通道颜色 Color Indicates: 色彩指示: Solidity: 密度: @@ -23,10 +127,6 @@ Selected Areas 所选区域 Spot Color 专色 Ink Characteristics 油墨特性 - -# Guide layout dialog - -# Custom print paper (#3820) Paper Width 纸张宽度 Paper Height 纸张高度 Columns 列 @@ -46,7 +146,6 @@ 5 Columns, 3 Rows 5列,3行 8 Columns 8列 12 Columns 12列 - File 文件 Edit 编辑 Image 图像 @@ -458,6 +557,7 @@ Intersect Shape Areas 形状区域相交 Exclude Overlapping Shapes 排除重叠形状 Merge Shape Components 合并形状组件 + Path operations 路径操作 Align 对齐 Top Edges 顶边 Vertical Centers 垂直居中 @@ -537,7 +637,6 @@ Color Range… 色彩范围… Focus Area… 焦点区域… Select and Mask… 选择与蒙版… -# Select and Mask dialog labels Smart Radius 智能半径 Shift Edge 移动边缘 Decontaminate 净化 @@ -564,7 +663,6 @@ Blur 模糊 Blur Gallery 模糊库 Field Blur… 场景模糊… -# Field Blur pins (#3373) No pin selected 未选择图钉 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 单击图像以添加图钉;拖动图钉可移动它,拖动其圆环可更改模糊程度。按 Delete 键删除所选图钉。 Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 拖动轮廓上的手柄可调整大小并旋转,拖动方块可将角变圆,拖动内部的点可设置模糊开始的位置。 @@ -829,7 +927,6 @@ Statistics… 统计… Refine Strokes 调整描边 Edits 编辑 -# 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} 个画笔 @@ -844,7 +941,6 @@ @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) (输出到打印机) @@ -860,6 +956,9 @@ Add Layer Mask 添加图层蒙版 Add Stop 添加色标 Add a layer style 添加图层样式 + Move effect up 上移效果 + Move effect down 下移效果 + Delete effect 删除效果 Add a mask (from the selection; ⌥ inverts) 添加蒙版 (基于选区;⌥ 反相) Add an adjustment 添加调整 Add disk 添加磁盘 @@ -1090,8 +1189,6 @@ Delete current channel 删除当前通道 Delete current state 删除当前状态 Delete effect layer 删除效果图层 - -# Layers-panel trash confirmation Delete layer ‘{name}’? 删除图层“{name}”? Delete {count} layers? {names} 删除 {count} 个图层?{names} Delete layer? 删除图层? @@ -1100,7 +1197,6 @@ Missing delete command 缺少删除命令 Invalid delete command 无效的删除命令 Layer deleted, but confirmation preference could not be saved: {error} 图层已删除,但无法保存确认设置:{error} - Delete layer 删除图层 Delete layer comp 删除图层复合 Delete note 删除注释 @@ -1133,7 +1229,6 @@ Dots 点 Drag a rectangle around the object 围绕对象拖出矩形 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)绘制,或从选区创建工作路径。 Drop an image or PSD anywhere to open it. 将图像或 PSD 拖放到任意位置即可打开。 @@ -2171,7 +2266,6 @@ Tone Histogram 色调直方图 Show Selected Region 显示选定区域 cameraRaw Light 光线 -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Camera Raw 滤镜({layer}) Camera Raw ({name}) Camera Raw({name}) cameraRaw Lights 亮调 @@ -2187,7 +2281,6 @@ cameraRaw Blending 混合 Transform On Drop 放下时变换 Scale or rotate the moved area, then press {key} 缩放或旋转移动的区域,然后按 {key} Now drag the selection onto another area 现在将选区拖动到另一个区域 -# Current shell labels and shortcut templates. Keep {key} platform-dependent. Add a mask (from the selection; {key} inverts) 添加蒙版 (基于选区;{key} 反相) Add to selection ({key}) 添加到选区 ({key}) Click the first point or press {key} to close · Esc cancels 单击起点或按 {key} 闭合 · Esc 取消 @@ -2282,7 +2375,6 @@ cameraRaw Blending 混合 Align with layer 与图层对齐 Reset Alignment 重置对齐 Centre the gradient (offset 0, 0) 将渐变居中(偏移量 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor 渐变编辑器 Gradient picker 渐变拾色器 Save As Preset 存储为预设 @@ -2313,7 +2405,6 @@ cameraRaw Blending 混合 Use tablet pressure 使用数位板压感 Vulkan Vulkan No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 没有可用于生成选区的路径:请用钢笔工具绘制路径,或在路径面板中选择路径。 -# Layer navigation, copy transforms and cancellable opening (labels added on main since this catalog). Cancel opening 取消打开 Cancelled opening {name} 已取消打开 {name} Damaged Photoshop document 已损坏的 Photoshop 文档 @@ -2338,7 +2429,6 @@ cameraRaw Blending 混合 Opening… 正在打开… Reading… 正在读取… Working… 正在处理… -# Rendering mode and recovery Rendering Mode 渲染模式 Automatic (recommended) 自动(推荐) CPU / Compatibility CPU / 兼容模式 @@ -2356,14 +2446,10 @@ cameraRaw Blending 混合 Details 详细信息 Rendering mode 渲染模式 Edit Type 编辑文字 - - New Style… 新建样式… Style {name} saved 已保存样式 {name} - Create new style (from the selected type) 新建样式(基于所选文本) Create new style from the selected layer 从所选图层新建样式 - Make Default 设为默认 Reset to Default 重置为默认 Full-resolution preview unavailable 全分辨率预览不可用 @@ -2392,9 +2478,6 @@ cameraRaw Blending 混合 Magnetic Lasso Tool 磁性套索工具 Frequency 频率 Use tablet pressure to change pen width 使用绘图板压力以更改钢笔宽度 - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About 关于 Contributors 贡献者 Models 模型 @@ -2426,8 +2509,6 @@ cameraRaw Blending 混合 % of all commits 占全部提交的 % Lines +/− 行数 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 个 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二进制文件 +{binAdded} / −{binDeleted},{first} – {last} - -# Swatches panel Create new swatch 创建新色板 New Swatch… 新建色板… New Swatch Group… 新建色板组… @@ -2437,13 +2518,12 @@ cameraRaw Blending 混合 Export Swatches for Exchange… 导出色板以供交换… Reset Swatches 复位色板 Add to Swatches 添加到色板 -# Layer Style > Blending Options (Advanced Blending) Blend If: 混合颜色带: + Current Layer: 当前图层: + Underlying Layer: 下一图层: Channels: 通道: Knockout 挖空 Apply At Canvas Bounds 应用画布边界的效果 - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) 拾色器(文字颜色) Color Picker ({name}) 拾色器({name}) Color Picker (Fill Color) 拾色器(填充颜色) @@ -2460,8 +2540,6 @@ cameraRaw Blending 混合 Show/Hide Panels 显示/隐藏面板 Could not open 无法打开 {path}: the path is not valid Unicode; rename the file and open it again. {path}:路径不是有效的 Unicode;请重命名文件后再打开。 - -# Fill dialog, layer style contours and Image Size Contents: 内容: Custom Pattern: 自定义图案: Foreground Color 前景色 @@ -2489,7 +2567,6 @@ cameraRaw Blending 混合 Rolling Slope - Descending 滚动斜坡 - 递减 Rounded Steps 圆角梯形 Sawtooth 1 锯齿 1 - Red Eye Tool 红眼工具 Red Eye 红眼 Pupil Size 瞳孔大小 @@ -2497,7 +2574,6 @@ cameraRaw Blending 混合 Click a red pupil to neutralize it 单击红色瞳孔以消除反光 open source 开源 …and {n} more …以及另外 {n} 项 - Eyedroppers: 吸管: Set Black Point 设置黑场 Set Neutral Gray Point 设置中性灰场 @@ -2506,8 +2582,6 @@ cameraRaw Blending 混合 Selection Preview: 选区预览: Black Matte 黑色背景 White Matte 白色背景 - -# Layer colour labels. Seafoam 海沫色 Indigo 靛蓝色 Fuchsia 紫红色 @@ -2528,8 +2602,6 @@ fontWeight Bold 粗体 fontWeight ExtraBold 特粗 fontWeight Black 黑体 fontWeight Italic 斜体 - -# Pattern Stamp tool Pattern Stamp Tool 图案图章工具 Pattern Stamp 图案图章 Impressionist 印象派 @@ -2537,13 +2609,9 @@ fontWeight Italic 斜体 Rotate View Tool 旋转视图工具 Reset View 重置视图 Drag around the centre to rotate the view · Shift constrains to 15° 绕中心拖动以旋转视图 · Shift 限制为 15° - -# Painting symmetry presets. Dual Axis 双轴 Diagonal 对角线 Transform Symmetry 变换对称轴 - -# Eyedropper Sample Size and Sample (#1649). Point Sample 采样点 3 by 3 Average 3 × 3 平均 5 by 5 Average 5 × 5 平均 @@ -2555,15 +2623,12 @@ fontWeight Italic 斜体 All Layers No Adjustments 所有图层(不含调整) Current & Below No Adjustments 当前及下方图层(不含调整) Show Sampling Ring 显示采样环 - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色 Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色 Color to Alpha… 颜色转为 Alpha… Transparency Threshold 透明度阈值 Opacity Threshold 不透明度阈值 PhotoCraft — Screen Color Picker PhotoCraft — 屏幕取色器 - Rasterize svg groups deeper than 栅格化超过此深度的 SVG 组 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 设为 0 可关闭此阈值;嵌套超过 100 层的 SVG 组始终会被栅格化。 Rule of Thirds 三等分 @@ -2577,8 +2642,6 @@ fontWeight Italic 斜体 Cycle Orientation 循环切换取向 Set the overlay options for the Crop tool 设置裁剪工具的叠加选项 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 单击被桌面环境截获了,它用 Alt 拖动来移动窗口。请把桌面的窗口移动键改为 Super,或在 窗口 › 修饰键 中锁定 Alt。 - -# Shared Layer Style color picker Color Picker (Layer Style Color) 拾色器(图层样式颜色) Auto Hide Notifications 自动隐藏通知 Notification Duration (seconds) 通知持续时间(秒) @@ -2659,7 +2722,6 @@ fontWeight Italic 斜体 Appearance Settings… 外观设置… Add to favorites 添加到收藏 Remove from favorites 从收藏中移除 -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data 相机数据 cameraData Make 制造商 cameraData Model 型号 @@ -2670,7 +2732,6 @@ cameraData ISO Speed ISO 感光度 cameraData Focal Length 焦距 cameraData Focal Length (35 mm) 焦距(35 毫米等效) cameraData Lens 镜头 -# Shared panel docking Activate Panel 激活面板 Change Panel Group 更改面板组 Change Panel Layout 更改面板布局 @@ -2710,7 +2771,6 @@ cameraData Lens 镜头 Snap to Origin 贴紧原点 Search settings… 搜索设置… No matching settings 没有匹配的设置 -# Edit › Content-Aware Fill workspace Sampling Brush Tool 取样画笔工具 Sampling Area Overlay 取样区域叠加 Show Sampling Area 显示取样区域 @@ -2730,7 +2790,6 @@ cameraData Lens 镜头 Output To 输出到 Duplicate Layer 复制图层 Contract 收缩 - Animation Options 动画选项 Entire Document 整个文档 Filter Options 过滤选项 @@ -2751,8 +2810,6 @@ cameraData Lens 镜头 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} 单击:所有图层) @@ -2792,7 +2849,6 @@ curves Smooth 平滑 Alt-click a plane to set the clone source 按住 Alt 键单击平面以设置仿制源 Too many clone strokes: apply Vanishing Point first 仿制描边过多:请先应用消失点 Stamp 图章 -# Select and Mask view modes (#1754) Onion Skin 洋葱皮 Marching Ants 蚂蚁线 On Black 黑底 @@ -2801,7 +2857,6 @@ curves Smooth 平滑 Show Edge 显示边缘 Show Original 显示原始 F cycles the views; hold X to see the image 按 F 切换视图;按住 X 查看图像 -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) 调整边缘画笔 (R) Expand Detection Area 扩展检测区域 Restore Original Edge 恢复原始边缘 @@ -2815,22 +2870,16 @@ curves Smooth 平滑 Enter a name for the crop preset 输入裁剪预设的名称 There is no crop size or ratio to save 没有可保存的裁剪大小或比例 Presentation 演示 - -# Color Sampler (added with the i18n coverage repair) 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… 历史记录选项… -# History panel › History Options… (#2966) Allow Non-Linear History 允许非线性历史记录 Make Layer Visibility Changes Undoable 允许撤销图层可见性更改 -# Print dialog (#2697) Emulsion Down 药膜朝下 Functions 功能 Print Resolution: 打印分辨率: @@ -2842,7 +2891,6 @@ curves Smooth 平滑 Apply mask to layer before removing? 移去之前将蒙版应用到图层吗? The layer or its mask changed. Drag the mask onto the Trash again. 图层或其蒙版已更改。请再次将蒙版拖到垃圾桶。 Delete the vector mask? 要删除矢量蒙版吗? -# View › Guides › New Guide… › Color Light Blue 浅蓝色 Light Red 浅红色 Medium Blue 中蓝色 @@ -2864,8 +2912,6 @@ curves Smooth 平滑 Clear Brush Controls 清除画笔控件 Reset All Locked Settings 复位所有锁定的设置 Copy Texture to Other Tools 将纹理复制到其他工具 - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 载入动作… Save Actions… 存储动作… New Action… 新建动作… @@ -2877,10 +2923,11 @@ curves Smooth 平滑 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 个步骤在 PhotoCraft 中尚无对应功能 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 - +@plural {n} action file loaded|{n} action files loaded 已载入 {n} 个动作文件 + {set} from {file} replaced the one from {earlier} {file} 中的组 {set} 替换了 {earlier} 中的同名组 +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} 个文件无法载入:{errors} File Naming 文件命名 Starting serial 起始序号 -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 插入停止… Save Set… 存储组… Message: 信息: @@ -2892,17 +2939,15 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 个动作 Background (Default) 背景(默认) Content-Aware Fill 内容识别填充 -# Batch / Image Processor Include All Subfolders 包含所有子文件夹 Auto Add/Delete 自动添加/删除 - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 不要同时勾选打印设置中的镜像:两次翻转会相互抵消。 Print Settings… 打印设置… Remember to turn off the printer's own colour management in Print Settings… 请记得在打印设置中关闭打印机自身的色彩管理… Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + Memory usage percent 内存使用比例 (%) Show Favorite Fonts 显示收藏的字体 No favorite fonts yet: click the star beside a font to add it. 还没有收藏的字体:点击字体旁边的星标即可添加。 Favorite fonts 收藏的字体 @@ -2941,51 +2986,33 @@ curves Smooth 平滑 Could not write {name}: {error} 无法写入 {name}:{error} Kept {name}: written by another application 已保留 {name}:由其他应用程序写入 Shadow Tint 阴影色调 -# PhotoCraft release checks. Check for Updates… 检查更新… - PhotoCraft Updates PhotoCraft 更新 - Checking for updates… 正在检查更新… Open the release page to choose an installer for your system. 打开发布页,选择适合您系统的安装包。 - Open Download Page 打开下载页面 - PhotoCraft is up to date. PhotoCraft 已是最新版本。 - Could not check for updates. 无法检查更新。 - Updates have not been checked yet. 尚未检查更新。 - Check Again 重新检查 Automatically Check for Updates 自动检查更新 - Installed version: {version} 已安装版本:{version} - PhotoCraft {version} is available. PhotoCraft {version} 已发布。 -# 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}) Remember this choice for future images 以后打开的图像都使用此选择 Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 可在 编辑 › 颜色设置 中打开 Ask On Mismatch,重新显示此提示。 Button Mode 按钮模式 Button Mode On 按钮模式 开 - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface 界面变暗 Lighten Interface 界面变亮 -# Bevel & Emboss Technique. Technique 方法 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 + Save Full-Size Composite in .pcraft Files 在 .pcraft 文件中存储全尺寸合成图像 Color Profile 颜色配置文件 EXIF EXIF - -# View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 Make Selection 建立选区 @@ -3012,8 +3039,6 @@ curves Smooth 平滑 {set} (modified) {set}(已修改) Random 随机 Show file name in title 标题中显示文件名 - -# Image › Mode › Bitmap (#5559) 50% Threshold 50% 阈值 Pattern Dither 图案仿色 Diffusion Dither 扩散仿色 @@ -3033,11 +3058,9 @@ curves Smooth 平滑 Undo Changes 撤消更改 {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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。 Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小,最多为其显存的 3/8。自动跟随内存使用量,不超过本机内存的四分之一和显卡显存的 3/8,且在显存已知的显卡上不把文档计入。在图层放不下期间,状态栏显示“GPU 内存已满”。 GPU Memory Full GPU 内存已满 @@ -3047,6 +3070,7 @@ curves Smooth 平滑 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 = 自动) + Scratch Size Limit (MB, 0 = No Limit) 暂存盘大小上限(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} 在此处打开或存储后已被其他应用更改。 @@ -3064,8 +3088,6 @@ curves Smooth 平滑 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 第一个画笔 @@ -3081,18 +3103,13 @@ curves Smooth 平滑 In Front 前面 Behind 后面 Blend 混合 - -# DDS Options (#1327) DDS Options DDS 选项 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 适配器 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS 内容将以 SVG 格式编辑。保存会替换嵌入的源文件;格式特有的功能可能会发生变化。 # Styles panel: Import / Export Styles (.asl) Import Styles… 导入样式… @@ -3101,7 +3118,6 @@ curves Smooth 平滑 {file} is not a style library (.asl) {file} 不是样式库 (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} 已从 {file} 导入 {n} 个样式 @plural Exported {n} style to {file}|Exported {n} styles to {file} 已将 {n} 个样式导出到 {file} -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 请先确认或取消打开的动作步骤对话框 Pro Medium Gray 专业中灰 Solarized Solarized @@ -3110,15 +3126,11 @@ curves Smooth 平滑 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 打印预览图像 - -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 色相立方体 Brightness Cube 亮度立方体 Color Wheel 色轮 @@ -3135,14 +3147,13 @@ curves Smooth 平滑 Grayscale Ramp 灰度色带 Current Colors 当前颜色 Make Ramp Web Safe 使色带成为 Web 安全色 - -# File opening, export status and graphics driver information Couldn't open {name}: {error} 无法打开 {name}:{error} Exported {path} ({size}) 已导出 {path}({size}) Exported {path} 已导出 {path} Exported {n} files 已导出 {n} 个文件 Driver: {driver} 驱动程序:{driver} -# Art History Brush (#213). + The path was kept as the Work Path 路径已保留为工作路径 + The path was discarded 路径已丢弃 Art History Brush 艺术历史画笔 Art History Brush Tool 艺术历史画笔工具 Tight Short 紧密短 @@ -3155,8 +3166,6 @@ curves Smooth 平滑 Loose Curl 宽松卷曲 Area 区域 Stylized dabs from a history state 从历史状态绘制风格化点涂 - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio 长宽比 Category 类别 Color Table 颜色表 @@ -3168,3 +3177,31 @@ curves Smooth 平滑 Font size presets 字号预设 Leading presets 行距预设 Tracking presets 字距调整预设 + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 数值输入无效。需要介于 {min}% 和 {max}% 之间的整数。已插入先前的值。 + Clear Selection 清除选区 + Color Aware 颜色识别 + Decontaminate Colors 净化颜色 + Edge Detection 边缘检测 + Global Refinements 全局调整 + High Quality Preview 高品质预览 + Object Aware 对象识别 + Real-Time Refinement 实时调整 + Refine Edge Brush Tool 调整边缘画笔工具 + Refine Hair 调整细线 + Refine Mode 调整模式 + Remember Settings 记住设置 + View Mode 视图模式 + Object Aware needs a segmentation model, which isn't available yet 对象识别需要分割模型,目前尚不可用 + Refine Hair needs hair detection, which isn't available yet 调整细线需要毛发检测,目前尚不可用 + Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档 + Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层 + Drag to move the selected path 拖动以移动选中的路径 + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker 拾色器 + Recently Used Colors 最近使用的颜色 + Stop Color 色标颜色 + Shapes can't use patterns yet 形状暂不支持图案 + Solid Color 纯色 + Tint Color 色调颜色 + Edit Brush… 编辑画笔… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9d46dcb21e..9379418b9e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -20,10 +20,108 @@ # 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) + +# Guide layout dialog + +# Custom print paper (#3820) + +# Select and Mask dialog labels +# Field Blur pins (#3373) + +# Layers-panel trash confirmation + +# Gradient Editor window, opened from the options bar's gradient swatch. +# Rendering mode and recovery +# Camera Raw: contextual controls and window title + + + + + + +# About ▸ Contributors / Models (credits) + +# Swatches panel +# Layer Style > Blending Options (Advanced Blending) + +# Shared screen eyedropper and text Color Picker (#1128). + +# Fill dialog, layer style contours and Image Size + + + +# Layer colour labels. + +# Pattern Stamp tool + +# Painting symmetry presets. + +# Eyedropper Sample Size and Sample (#1649). + +# Color Picker warnings + + +# Shared Layer Style color picker +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +# Shared panel docking +# Edit › Content-Aware Fill workspace + + +# Smart Filter editors +# Select and Mask view modes (#1754) +# Select and Mask Refine Edge Brush (#2972) + +# Color Sampler (added with the i18n coverage repair) + +# History panel menu (#2966) +# History panel › History Options… (#2966) +# Print dialog (#2697) +# Counted status messages and labels (#3454; adapted from PR #3821) +# View › Guides › New Guide… › Color + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + +# Actions panel: set folders, dialog toggle, Insert Stop +# Batch / Image Processor + +# Native Windows printing qualification +# PhotoCraft release checks. +# History panel › History Brush source (#2966) +# Bevel & Emboss shading controls. +# Embedded Profile Mismatch: profile names beside the choices, Remember this choice + +# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) +# Bevel & Emboss Technique. +# Export As metadata and profile options + +# View › Guides › right-click a guide + +# Image › Mode › Bitmap (#5559) +# Free Transform: a new layer while the box is open (#5859) +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + +# DDS Options (#1327) + +# Color Table (.act) dialog + +# Graphics Adapter preference (#1885) + +# Styles panel: Import / Export Styles (.asl) +# Actions: a step dialog holds a suspended recording (#7365) +# Paragraph direction (TYPE-218-2) +# Print preview image (#8236) +# Color panel menu: modes, copy commands and colour ramps (#6000) + +# Pen draft after a refused commit (#7648) +# Art History Brush (#213). + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) +# Smart Filter row actions (#1265) + + Mask Options… 遮色片選項… + Layer Mask Display Options… 圖層遮色片顯示選項… Channel Color 色版顏色 Color Indicates: 色彩指示: Solidity: 硬度: @@ -31,10 +129,6 @@ Selected Areas 選取區域 Spot Color 特別色 Ink Characteristics 油墨特性 - -# Guide layout dialog - -# Custom print paper (#3820) Paper Width 紙張寬度 Paper Height 紙張高度 Columns 欄 @@ -54,7 +148,6 @@ 5 Columns, 3 Rows 5欄,3列 8 Columns 8欄 12 Columns 12欄 - File 檔案 Edit 編輯 Image 影像 @@ -463,6 +556,7 @@ Intersect Shape Areas 交集形狀區域 Exclude Overlapping Shapes 排除重疊形狀 Merge Shape Components 合併形狀元件 + Path operations 路徑操作 Align 對齊 Top Edges 頂端邊緣 Vertical Centers 垂直置中 @@ -542,7 +636,6 @@ Color Range… 顏色範圍… Focus Area… 焦點區域… Select and Mask… 選取並遮住… -# Select and Mask dialog labels Smart Radius 智慧型半徑 Shift Edge 移動邊緣 Decontaminate 淨化 @@ -569,7 +662,6 @@ Blur 模糊 Blur Gallery 模糊收藏館 Field Blur… 區域模糊… -# Field Blur pins (#3373) No pin selected 未選取圖釘 Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 按一下影像即可新增圖釘;拖移圖釘可移動它,拖移其圓環可變更模糊程度。按 Delete 鍵刪除所選圖釘。 Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. 拖移輪廓上的控點可調整大小並旋轉,拖移方塊可將角變圓,拖移內部的點可設定模糊開始的位置。 @@ -849,6 +941,9 @@ Add Layer Mask 新增圖層遮色片 Add Stop 新增色標 Add a layer style 新增圖層樣式 + Move effect up 上移效果 + Move effect down 下移效果 + Delete effect 刪除效果 Add a mask (from the selection; {key} inverts) 新增遮色片 (依選取範圍;{key} 反轉) Add an adjustment 新增調整 Add disk 新增磁碟 @@ -1077,8 +1172,6 @@ Delete current channel 刪除目前色版 Delete current state 刪除目前狀態 Delete effect layer 刪除效果圖層 - -# Layers-panel trash confirmation Delete layer ‘{name}’? 刪除圖層「{name}」? Delete {count} layers? {names} 刪除 {count} 個圖層?{names} Delete layer? 刪除圖層? @@ -1087,7 +1180,6 @@ Missing delete command 缺少刪除指令 Invalid delete command 無效的刪除指令 Layer deleted, but confirmation preference could not be saved: {error} 圖層已刪除,但無法儲存確認設定:{error} - Delete layer 刪除圖層 Delete layer comp 刪除圖層構圖 Delete note 刪除註解 @@ -1120,7 +1212,6 @@ Dots 點 Drag a rectangle around the object 在物件周圍拖曳出矩形 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) 繪製,或從選取範圍建立工作路徑。 Drop an image or PSD anywhere to open it. 將影像或 PSD 拖放到任何位置即可開啟。 @@ -2200,7 +2291,6 @@ Align with layer 與圖層對齊 Reset Alignment 重設對齊 Centre the gradient (offset 0, 0) 置中漸層 (位移 0, 0) -# Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor 漸層編輯器 Gradient picker 漸層挑選器 Save As Preset 儲存為預設 @@ -2260,7 +2350,6 @@ 4 x 4 4 × 4 5 x 5 5 × 5 Grid: 格點: -# Rendering mode and recovery Rendering Mode 渲染模式 Automatic (recommended) 自動(建議) CPU / Compatibility CPU / 相容模式 @@ -2287,7 +2376,6 @@ Tone Histogram 色調直方圖 Show Selected Region 顯示選取區域 cameraRaw Light 光線 -# Camera Raw: contextual controls and window title Camera Raw Filter ({layer}) Camera Raw 濾鏡({layer}) Camera Raw ({name}) Camera Raw({name}) cameraRaw Lights 亮調 @@ -2305,14 +2393,10 @@ cameraRaw Blending 混合 Scale or rotate the moved area, then press {key} 縮放或旋轉移動的區域,然後按 {key} Now drag the selection onto another area 現在將選取範圍拖曳到另一個區域 Edit Type 編輯文字 - - New Style… 新增樣式… Style {name} saved 已儲存樣式 {name} - Create new style (from the selected type) 新增樣式(基於所選文字) Create new style from the selected layer 從所選圖層新增樣式 - Make Default 設為預設 Reset to Default 重設為預設 Full-resolution preview unavailable 無法使用完整解析度預覽 @@ -2341,9 +2425,6 @@ cameraRaw Blending 混合 Magnetic Lasso Tool 磁性套索工具 Frequency 頻率 Use tablet pressure to change pen width 使用繪圖板壓力以變更鋼筆寬度 - - -# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) About 關於 Contributors 貢獻者 Models 模型 @@ -2375,8 +2456,6 @@ cameraRaw Blending 混合 % of all commits 佔全部提交的 % Lines +/− 行數 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 個 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二進位檔 +{binAdded} / −{binDeleted},{first} – {last} - -# Swatches panel Create new swatch 建立新色票 New Swatch… 新增色票… New Swatch Group… 新增色票群組… @@ -2386,13 +2465,12 @@ cameraRaw Blending 混合 Export Swatches for Exchange… 匯出色票以供交換… Reset Swatches 重設色票 Add to Swatches 加入色票 -# Layer Style > Blending Options (Advanced Blending) Blend If: 混合範圍: + Current Layer: 目前圖層: + Underlying Layer: 下一圖層: Channels: 色版: Knockout 去底色 Apply At Canvas Bounds 套用效果至版面邊界 - -# Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) 檢色器(文字顏色) Color Picker ({name}) 檢色器({name}) Color Picker (Fill Color) 檢色器(填滿顏色) @@ -2409,8 +2487,6 @@ cameraRaw Blending 混合 Show/Hide Panels 顯示/隱藏面板 Could not open 無法開啟 {path}: the path is not valid Unicode; rename the file and open it again. {path}:路徑不是有效的 Unicode;請重新命名檔案後再開啟。 - -# Fill dialog, layer style contours and Image Size Contents: 內容: Custom Pattern: 自訂圖樣: Foreground Color 前景色 @@ -2438,7 +2514,6 @@ cameraRaw Blending 混合 Rolling Slope - Descending 滾動斜坡 - 遞減 Rounded Steps 圓角梯形 Sawtooth 1 鋸齒 1 - Red Eye Tool 紅眼工具 Red Eye 紅眼 Pupil Size 瞳孔大小 @@ -2446,7 +2521,6 @@ cameraRaw Blending 混合 Click a red pupil to neutralize it 按一下紅色瞳孔以消除反光 open source 開源 …and {n} more …以及另外 {n} 項 - Eyedroppers: 滴管: Set Black Point 設定黑點 Set Neutral Gray Point 設定中性灰點 @@ -2455,8 +2529,6 @@ cameraRaw Blending 混合 Selection Preview: 選取範圍預視: Black Matte 黑色背景 White Matte 白色背景 - -# Layer colour labels. Seafoam 海沫色 Indigo 靛藍色 Fuchsia 紫紅色 @@ -2477,8 +2549,6 @@ fontWeight Bold 粗體 fontWeight ExtraBold 特粗 fontWeight Black 黑體 fontWeight Italic 斜體 - -# Pattern Stamp tool Pattern Stamp Tool 圖案圖章工具 Pattern Stamp 圖案圖章 Impressionist 印象派 @@ -2486,13 +2556,9 @@ fontWeight Italic 斜體 Rotate View Tool 旋轉檢視工具 Reset View 重設檢視 Drag around the centre to rotate the view · Shift constrains to 15° 繞中心拖曳以旋轉檢視 · Shift 限制為 15° - -# Painting symmetry presets. Dual Axis 雙軸 Diagonal 對角線 Transform Symmetry 變形對稱軸 - -# Eyedropper Sample Size and Sample (#1649). Point Sample 取樣點 3 by 3 Average 3 × 3 平均 5 by 5 Average 5 × 5 平均 @@ -2504,15 +2570,12 @@ fontWeight Italic 斜體 All Layers No Adjustments 所有圖層(不含調整) Current & Below No Adjustments 目前及以下(不含調整) Show Sampling Ring 顯示取樣環 - -# Color Picker warnings Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色 Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色 Color to Alpha… 顏色轉為 Alpha… Transparency Threshold 透明度臨界值 Opacity Threshold 不透明度臨界值 PhotoCraft — Screen Color Picker PhotoCraft — 螢幕取色器 - Rasterize svg groups deeper than 點陣化超過此深度的 SVG 群組 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 設為 0 可關閉此臨界值;巢狀超過 100 層的 SVG 群組一律會點陣化。 Rule of Thirds 三等分 @@ -2526,8 +2589,6 @@ fontWeight Italic 斜體 Cycle Orientation 循環切換方向 Set the overlay options for the Crop tool 設定裁切工具的覆蓋選項 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 按一下被桌面環境攔截了,它用 Alt 拖曳來移動視窗。請把桌面的視窗移動鍵改為 Super,或在 視窗 › 輔助鍵 中鎖定 Alt。 - -# Shared Layer Style color picker Color Picker (Layer Style Color) 檢色器(圖層樣式顏色) Auto Hide Notifications 自動隱藏通知 Notification Duration (seconds) 通知持續時間(秒) @@ -2608,7 +2669,6 @@ fontWeight Italic 斜體 Appearance Settings… 外觀設定… Add to favorites 加入最愛 Remove from favorites 從最愛移除 -# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). Camera Data 相機資料 cameraData Make 製造商 cameraData Model 型號 @@ -2619,7 +2679,6 @@ cameraData ISO Speed ISO 感光度 cameraData Focal Length 焦距 cameraData Focal Length (35 mm) 焦距(35 公釐等效) cameraData Lens 鏡頭 -# Shared panel docking Activate Panel 啟用面板 Change Panel Group 變更面板群組 Change Panel Layout 變更面板配置 @@ -2659,7 +2718,6 @@ cameraData Lens 鏡頭 Snap to Origin 靠齊原點 Search settings… 搜尋設定… No matching settings 沒有符合的設定 -# Edit › Content-Aware Fill workspace Sampling Brush Tool 取樣筆刷工具 Sampling Area Overlay 取樣區域覆蓋 Show Sampling Area 顯示取樣區域 @@ -2679,7 +2737,6 @@ cameraData Lens 鏡頭 Output To 輸出至 Duplicate Layer 複製圖層 Contract 縮減 - Animation Options 動畫選項 Entire Document 整份文件 Filter Options 篩選選項 @@ -2700,8 +2757,6 @@ cameraData Lens 鏡頭 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} 點擊:所有圖層) @@ -2741,7 +2796,6 @@ curves Smooth 平滑 Alt-click a plane to set the clone source 按住 Alt 鍵按一下平面以設定仿製來源 Too many clone strokes: apply Vanishing Point first 仿製筆畫過多:請先套用消失點 Stamp 印章 -# Select and Mask view modes (#1754) Onion Skin 洋蔥皮 Marching Ants 螞蟻線 On Black 黑底 @@ -2750,7 +2804,6 @@ curves Smooth 平滑 Show Edge 顯示邊緣 Show Original 顯示原始 F cycles the views; hold X to see the image 按 F 切換檢視;按住 X 檢視影像 -# Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) 調整邊緣筆刷 (R) Expand Detection Area 擴展偵測區域 Restore Original Edge 恢復原始邊緣 @@ -2764,22 +2817,16 @@ curves Smooth 平滑 Enter a name for the crop preset 輸入裁切預設集的名稱 There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例 Presentation 呈現 - -# Color Sampler (added with the i18n coverage repair) 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… 步驟記錄選項… -# History panel › History Options… (#2966) Allow Non-Linear History 允許非線性步驟記錄 Make Layer Visibility Changes Undoable 允許復原圖層可見度變更 -# Print dialog (#2697) Emulsion Down 感光乳劑面朝下 Functions 功能 Print Resolution: 列印解析度: @@ -2791,7 +2838,6 @@ curves Smooth 平滑 Apply mask to layer before removing? 移除之前將遮色片套用到圖層嗎? The layer or its mask changed. Drag the mask onto the Trash again. 圖層或其遮色片已變更。請再次將遮色片拖曳到垃圾桶。 Delete the vector mask? 要刪除向量遮色片嗎? -# Counted status messages and labels (#3454; adapted from PR #3821) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已將 {n} 個色票匯出至 {file} @plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已從 {group} 匯入 {n} 個筆刷 @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已從 {file} 匯入 {n} 個色票 @@ -2806,7 +2852,6 @@ curves Smooth 平滑 @plural {n} step|{n} steps {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} 個警告) -# View › Guides › New Guide… › Color Light Blue 淺藍色 Light Red 淺紅色 Medium Blue 中藍色 @@ -2828,8 +2873,6 @@ curves Smooth 平滑 Clear Brush Controls 清除筆刷控制項 Reset All Locked Settings 重設所有鎖定的設定 Copy Texture to Other Tools 複製紋理至其他工具 - -# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 載入動作… Save Actions… 儲存動作… New Action… 新增動作… @@ -2841,10 +2884,11 @@ curves Smooth 平滑 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個步驟在 PhotoCraft 中尚無對應功能 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 - +@plural {n} action file loaded|{n} action files loaded 已載入 {n} 個動作檔案 + {set} from {file} replaced the one from {earlier} {file} 中的組合 {set} 取代了 {earlier} 中的同名組合 +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} 個檔案無法載入:{errors} File Naming 檔案命名 Starting serial 起始序號 -# Actions panel: set folders, dialog toggle, Insert Stop Insert Stop… 插入停止… Save Set… 儲存組合… Message: 訊息: @@ -2856,17 +2900,15 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 個動作 Background (Default) 背景(預設) Content-Aware Fill 內容感知填滿 -# Batch / Image Processor Include All Subfolders 包含所有子資料夾 Auto Add/Delete 自動增加/刪除 - -# Native Windows printing qualification Don't also tick Mirror Image in Print Settings…: the two flips cancel out. 請勿同時勾選列印設定中的鏡像:兩次翻轉會互相抵消。 Print Settings… 列印設定… Remember to turn off the printer's own colour management in Print Settings… 請記得在列印設定中關閉印表機本身的色彩管理… Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + Memory usage percent 記憶體使用比例 (%) Show Favorite Fonts 顯示我的最愛字體 No favorite fonts yet: click the star beside a font to add it. 尚無我的最愛字體:按一下字體旁的星號即可加入。 Favorite fonts 我的最愛字體 @@ -2905,51 +2947,33 @@ curves Smooth 平滑 Could not write {name}: {error} 無法寫入 {name}:{error} Kept {name}: written by another application 已保留 {name}:由其他應用程式寫入 Shadow Tint 陰影色調 -# PhotoCraft release checks. Check for Updates… 檢查更新… - PhotoCraft Updates PhotoCraft 更新 - Checking for updates… 正在檢查更新… Open the release page to choose an installer for your system. 開啟發佈頁,選擇適合您系統的安裝程式。 - Open Download Page 開啟下載頁面 - PhotoCraft is up to date. PhotoCraft 已是最新版本。 - Could not check for updates. 無法檢查更新。 - Updates have not been checked yet. 尚未檢查更新。 - Check Again 重新檢查 Automatically Check for Updates 自動檢查更新 - Installed version: {version} 已安裝版本:{version} - PhotoCraft {version} is available. PhotoCraft {version} 已發佈。 -# 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}) Remember this choice for future images 之後開啟的影像都使用此選擇 Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. 可在 編輯 › 色彩設定 中開啟 Ask On Mismatch,重新顯示此提示。 Button Mode 按鈕模式 Button Mode On 按鈕模式 開 - -# Darken / Lighten Interface (⇧⌘F1 / ⇧⌘F2) Darken Interface 介面變暗 Lighten Interface 介面變亮 -# Bevel & Emboss Technique. Technique 方法 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 + Save Full-Size Composite in .pcraft Files 在 .pcraft 檔案中儲存全尺寸合成影像 Color Profile 色彩描述檔 EXIF EXIF - -# View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 Make Selection 建立選取範圍 @@ -2976,8 +3000,6 @@ curves Smooth 平滑 {set} (modified) {set}(已修改) Random 隨機 Show file name in title 標題中顯示檔案名稱 - -# Image › Mode › Bitmap (#5559) 50% Threshold 50% 臨界值 Pattern Dither 圖樣混色 Diffusion Dither 擴散混色 @@ -2997,11 +3019,9 @@ curves Smooth 平滑 Undo Changes 還原變更 {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 Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。 Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小,最多為其記憶體的 3/8。自動會跟隨記憶體使用量,不超過本機記憶體的四分之一和顯示卡記憶體的 3/8,且在記憶體已知的顯示卡上不把文件計入。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 GPU Memory Full GPU 記憶體已滿 @@ -3011,6 +3031,7 @@ curves Smooth 平滑 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 = 自動) + Scratch Size Limit (MB, 0 = No Limit) 暫存磁碟大小上限(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 更改。 @@ -3028,8 +3049,6 @@ curves Smooth 平滑 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 第一個筆刷 @@ -3045,18 +3064,13 @@ curves Smooth 平滑 In Front 前面 Behind 後面 Blend 混合 - -# DDS Options (#1327) DDS Options DDS 選項 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 介面卡 +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS 內容將以 SVG 格式編輯。儲存會取代嵌入的來源檔案;格式特有的功能可能會改變。 # Styles panel: Import / Export Styles (.asl) Import Styles… 匯入樣式… @@ -3065,7 +3079,6 @@ curves Smooth 平滑 {file} is not a style library (.asl) {file} 不是樣式庫 (.asl) @plural Imported {n} style from {file}|Imported {n} styles from {file} 已從 {file} 匯入 {n} 個樣式 @plural Exported {n} style to {file}|Exported {n} styles to {file} 已將 {n} 個樣式匯出至 {file} -# Actions: a step dialog holds a suspended recording (#7365) Confirm or cancel the open action step dialog first 請先確認或取消開啟中的動作步驟對話框 Pro Medium Gray 專業中灰 Solarized Solarized @@ -3074,14 +3087,11 @@ curves Smooth 平滑 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 -# Color panel menu: modes, copy commands and colour ramps (#6000) Hue Cube 色相立方體 Brightness Cube 亮度立方體 Color Wheel 色輪 @@ -3098,7 +3108,8 @@ curves Smooth 平滑 Grayscale Ramp 灰階色帶 Current Colors 目前色彩 Make Ramp Web Safe 讓色帶成為網頁安全色 -# Art History Brush (#213). + The path was kept as the Work Path 路徑已保留為工作路徑 + The path was discarded 路徑已捨棄 Art History Brush 藝術歷史畫筆 Art History Brush Tool 藝術歷史畫筆工具 Tight Short 緊密短 @@ -3111,8 +3122,6 @@ curves Smooth 平滑 Loose Curl 寬鬆捲曲 Area 區域 Stylized dabs from a history state 從歷史狀態繪製風格化點塗 - -# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Aspect Ratio 外觀比例 Category 類別 Color Table 色彩表 @@ -3124,3 +3133,31 @@ curves Smooth 平滑 Font size presets 字體大小預設 Leading presets 行距預設 Tracking presets 字距調整預設 + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. 數值輸入無效。需要介於 {min}% 和 {max}% 之間的整數。已插入先前的值。 + Clear Selection 清除選取範圍 + Color Aware 顏色感知 + Decontaminate Colors 淨化顏色 + Edge Detection 邊緣偵測 + Global Refinements 整體調整 + High Quality Preview 高品質預視 + Object Aware 物件感知 + Real-Time Refinement 即時調整 + Refine Edge Brush Tool 調整邊緣筆刷工具 + Refine Hair 調整毛髮 + Refine Mode 調整模式 + Remember Settings 記住設定 + View Mode 檢視模式 + Object Aware needs a segmentation model, which isn't available yet 物件感知需要分割模型,目前尚無法使用 + Refine Hair needs hair detection, which isn't available yet 調整毛髮需要毛髮偵測,目前尚無法使用 + Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件 + Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層 + Drag to move the selected path 拖曳以移動選取的路徑 + +# Shape Fill/Stroke popover and colour swatches opening the Color Picker + Color Picker 檢色器 + Recently Used Colors 最近使用的顏色 + Stop Color 色標顏色 + Shapes can't use patterns yet 形狀尚不支援圖樣 + Solid Color 純色 + Tint Color 色調顏色 + Edit Brush… 編輯筆刷… diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 18101a5903..f4552128ce 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -50,6 +50,13 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("convert-point", include_bytes!("../../../assets/icons/convert-point.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), ("crop", include_bytes!("../../../assets/icons/crop.svg")), + // Original: the Crop tool's overlay glyphs (#10889), on the Lucide grid and stroke. + ("crop-overlay-diagonal", include_bytes!("../../../assets/icons/crop-overlay-diagonal.svg")), + ("crop-overlay-golden-ratio", include_bytes!("../../../assets/icons/crop-overlay-golden-ratio.svg")), + ("crop-overlay-golden-spiral", include_bytes!("../../../assets/icons/crop-overlay-golden-spiral.svg")), + ("crop-overlay-grid", include_bytes!("../../../assets/icons/crop-overlay-grid.svg")), + ("crop-overlay-thirds", include_bytes!("../../../assets/icons/crop-overlay-thirds.svg")), + ("crop-overlay-triangle", include_bytes!("../../../assets/icons/crop-overlay-triangle.svg")), ("diamond", include_bytes!("../../../assets/icons/diamond.svg")), ("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")), ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), @@ -104,6 +111,12 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("palette", include_bytes!("../../../assets/icons/palette.svg")), ("panel-right", include_bytes!("../../../assets/icons/panel-right.svg")), ("panels-top-left", include_bytes!("../../../assets/icons/panels-top-left.svg")), + ("path-op-combine", include_bytes!("../../../assets/icons/path-op-combine.svg")), + ("path-op-exclude", include_bytes!("../../../assets/icons/path-op-exclude.svg")), + ("path-op-intersect", include_bytes!("../../../assets/icons/path-op-intersect.svg")), + ("path-op-merge", include_bytes!("../../../assets/icons/path-op-merge.svg")), + ("path-op-new-layer", include_bytes!("../../../assets/icons/path-op-new-layer.svg")), + ("path-op-subtract", include_bytes!("../../../assets/icons/path-op-subtract.svg")), ("pen-curvature", include_bytes!("../../../assets/icons/pen-curvature.svg")), ("pen-freeform", include_bytes!("../../../assets/icons/pen-freeform.svg")), ("pen-add-anchor", include_bytes!("../../../assets/icons/pen-add-anchor.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f1bd354a1c..0a42a03527 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -23,6 +23,13 @@ pub fn exists(name: &str) -> bool { white_icons().contains(name) } +/// Panel footer buttons share one glyph per action and one size, so the Layers footer and the +/// Swatches, Gradients and Patterns footers read the same, as in Photoshop (#10656). +pub const FOOTER_SIZE: f32 = 26.0; +pub const FOOTER_NEW: &str = "square-plus"; +pub const FOOTER_NEW_GROUP: &str = "folder"; +pub const FOOTER_DELETE: &str = "trash"; + /// An egui image for an icon, tinted. pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> { egui::Image::new(white_icons().source(name)).fit_to_exact_size(Vec2::splat(size)).tint(tint) @@ -345,6 +352,14 @@ mod tests { } } + /// #10656: a misspelt footer glyph would silently draw the fallback square. + #[test] + fn footer_glyphs_are_bundled() { + for name in [FOOTER_NEW, FOOTER_NEW_GROUP, FOOTER_DELETE] { + assert!(exists(name), "{name}"); + } + } + #[test] fn shared_catalog_preserves_fallback() { let egui::ImageSource::Bytes { uri, bytes } = white_icons().source("missing-icon") else { panic!("expected SVG bytes") }; diff --git a/crates/ui-egui/src/input/blend_keys.rs b/crates/ui-egui/src/input/blend_keys.rs index 6339b64c24..5a126268b7 100644 --- a/crates/ui-egui/src/input/blend_keys.rs +++ b/crates/ui-egui/src/input/blend_keys.rs @@ -210,7 +210,7 @@ fn set_tool_mode(app: &mut PhotocraftApp, mode: Option) { } } -fn set_gradient_mode(app: &mut PhotocraftApp, mode: BlendMode) { +pub(crate) fn set_gradient_mode(app: &mut PhotocraftApp, mode: BlendMode) { let before = app.ui.tool_options.clone(); app.ui.tool_options.gradient_blend_mode = mode; // Live mode: a selected gradient fill layer follows, as with the dropdown. diff --git a/crates/ui-egui/src/input/hold_keys/tests.rs b/crates/ui-egui/src/input/hold_keys/tests.rs index 7251859a5e..8a2fbec54e 100644 --- a/crates/ui-egui/src/input/hold_keys/tests.rs +++ b/crates/ui-egui/src/input/hold_keys/tests.rs @@ -705,6 +705,59 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() { } } +/// With the Hand tool, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ Zoom Out, as in Photoshop; the +/// Hand is back when the key comes up (#6434). +#[test] +fn cmd_and_alt_with_the_hand_tool_zoom_in_and_out_while_held() { + 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::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), "⌥ turns ⌘'s Zoom In around"); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + // Released: a drag pans again. + let (center, zoom) = (h.state().ui.views[0].center, h.state().ui.views[0].zoom); + drag_with(&mut h, Modifiers::NONE, 200.0, 150.0, 260.0, 190.0); + assert_ne!(h.state().ui.views[0].center, center, "the Hand pans"); + assert_eq!(h.state().ui.views[0].zoom, zoom); + assert_eq!(h.state().ui.tool, Tool::Hand); +} + +/// ⌘ pressed in the middle of a Hand drag doesn't stop the pan, and an open Free Transform keeps ⌘ +/// for its handles. +#[test] +fn cmd_mid_pan_or_during_free_transform_is_not_the_hand_tools_zoom() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + press_at(&mut h, 200.0, 150.0); + hold_mods(&mut h, Modifiers::COMMAND); + let center = h.state().ui.views[0].center; + move_to(&mut h, 300.0, 150.0); + assert_ne!(h.state().ui.views[0].center, center, "the drag kept panning after ⌘ went down"); + release(&mut h, 300.0, 150.0, Modifiers::COMMAND); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(h.state().ui.views[0].zoom, z0, "no zoom"); + + 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); + hold_mods(&mut h, Modifiers::NONE); +} + /// The params of the last brush stroke (`paint.stroke`, or the Pencil's `paint.pencil`). fn last_stroke(app: &PhotocraftApp) -> serde_json::Value { let journal = &app.session.journal; diff --git a/crates/ui-egui/src/input/shortcut_dispatch.rs b/crates/ui-egui/src/input/shortcut_dispatch.rs index ef250cf7a2..dcff8d4d2c 100644 --- a/crates/ui-egui/src/input/shortcut_dispatch.rs +++ b/crates/ui-egui/src/input/shortcut_dispatch.rs @@ -140,8 +140,9 @@ pub fn claim_delete_keys(ctx: &egui::Context, id: egui::Id) { impl Focus { pub fn of(ctx: &egui::Context) -> Focus { - // A layer rename keeps its keys even in the frame egui drops its focus (Esc, Tab). - if ctx.text_edit_focused() || crate::layer_row_ui::rename_active(ctx) { + // A layer rename and a zoom field keep their keys even in the frame egui drops their + // focus (Esc, Tab). + if ctx.text_edit_focused() || crate::layer_row_ui::rename_active(ctx) || crate::zoom_field::editing(ctx) { Focus::Text } else if ctx.egui_wants_keyboard_input() { let owner = ctx.data(|d| d.get_temp::(delete_owner_id())); @@ -209,9 +210,17 @@ impl Focus { /// What decides which keys [`crate::shortcuts::handle`] gives the shortcuts: an open menu, a /// dialog, the unsaved-changes prompt, a warp or the Filter Gallery, inline type. -fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bool, bool) { +fn key_owner(app: &PhotocraftApp, ctx: &egui::Context) -> (bool, usize, bool, bool, bool, bool, bool) { let distort = app.distort.active() || app.distort.gallery.is_some(); - (crate::menu_nav::is_open(ctx), app.ui.dialogs.len(), app.discard.is_some(), distort, app.ui.text_edit.is_some()) + ( + crate::menu_nav::is_open(ctx), + app.ui.dialogs.len(), + app.discard.is_some(), + distort, + app.ui.text_edit.is_some(), + app.save_options_open(), + app.smart_svg.modal_open(), + ) } /// Keys whose shortcuts follow the key's position, as in Photoshop: ⇧⌘. is ⇧⌘> on a US layout, diff --git a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs index df8eb96608..633bb89ca8 100644 --- a/crates/ui-egui/src/input/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/input/shortcut_dispatch/tests.rs @@ -14,6 +14,56 @@ use crate::PhotocraftApp; use crate::shortcuts::parse; use crate::state::Tool; +#[test] +fn svg_editor_prompt_owns_later_shortcuts_in_same_frame() { + let launched = std::rc::Rc::new(std::cell::Cell::new(false)); + let launch_flag = launched.clone(); + let services = crate::Services { + open_external_svg: Some(Box::new(move |_, _| { + launch_flag.set(true); + Err("The editor must not launch before a choice is confirmed".into()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.session.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + photocraft_engine::file_cmds::place_bytes( + &mut app.session, + "Artwork.svg", + br#""#.to_vec(), + None, + &json!({"smartObject":true}), + ) + .unwrap(); + app.run("edit.keyboardShortcuts", json!({"set": {"layer.smartObjects.editContents": "F6"}})).unwrap(); + app.sync_views(); + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + let history = app.session.active().unwrap().history.past_len(); + let modifiers = platform(Modifiers::COMMAND); + let input = egui::RawInput { + events: vec![ + egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key: egui::Key::J, physical_key: None, pressed: true, repeat: false, modifiers }, + ], + ..Default::default() + }; + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(input, |ui| { + assert!(super::dispatch_pressed(&mut app, ui.ctx(), Focus::None, false)); + assert!(ui.ctx().input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::J, pressed: true, .. })))); + }); + output.textures_delta.clear(); + assert!(app.smart_svg.modal_open()); + assert!(!launched.get()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.revision, revision); + assert_eq!(st.history.past_len(), history); + assert_eq!(take_log(&ctx), vec![("layer.smartObjects.editContents".into(), Outcome::Ran)]); +} + /// Background, a filled pixel layer "paint" (active, with a selection), a type layer and a group /// holding a layer: what a real layout PSD looks like. fn realistic() -> photocraft_engine::Session { @@ -638,10 +688,12 @@ fn delete_with_several_layers_selected_deletes_them_even_with_the_mask_targeted( let first = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap()["layer"].as_u64().unwrap()); let second = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap()); s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap(); + // The new layers took the harness's selection as their masks (#8801): select again. + s.execute("select.all", json!({})).unwrap(); let st = s.active().unwrap(); assert_eq!(st.selected_layers().len(), 2); assert!(st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some()), "the active adjustment layer has a mask"); - assert!(st.doc.selection.is_some(), "the harness keeps a selection"); + assert!(st.doc.selection.is_some(), "a selection is active"); h.state_mut().ui.mask_target = true; h.run_steps(2); let n = names(&h).len(); diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 40ebeb1804..fb3f2cae5d 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -488,6 +488,7 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings // it back later (#2608). app.ui.remember_group_tool(); + crate::canvas::polygon_cancel_stale(app); // Camera Raw is modal like Photoshop's filter dialog: no application shortcut (Save, Undo, // tools) runs beneath it, and it handles its own keys (Y, U, O, S). Unlike the other dialogs // below it covers the canvas, so canvas zoom keys are blocked too. @@ -504,6 +505,9 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { // 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 app.ui.dialogs.is_empty() && app.discard.is_none() && crate::type_tool::commit_shortcut(app, ctx) { + return false; + } if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) { return false; } @@ -552,6 +556,10 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.distort.caf.is_some() && !ctx.text_edit_focused() { crate::caf_ui::keys(app, ctx); } + // And Select and Mask's tools, views and brush size. + if app.distort.select_mask.is_some() && !ctx.text_edit_focused() { + crate::select_mask_ui::keys(app, ctx); + } true } @@ -559,8 +567,8 @@ fn handle_modal_canvas_keys(app: &mut PhotocraftApp, ctx: &egui::Context, focus: use crate::shortcut_dispatch::{Focus, dispatch_pressed}; // Dialogs (and Liquify's panel while one of its controls has focus) keep canvas zoom; a // focused text field keeps its keys. - let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some()) && focus != Focus::None; - if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() { + let liquify_focus = (app.distort.liquify.is_some() || app.distort.caf.is_some() || app.distort.select_mask.is_some()) && focus != Focus::None; + if liquify_focus || !app.ui.dialogs.is_empty() || app.discard.is_some() || app.smart_svg.modal_open() || app.save_options_open() { if focus != Focus::Text { nav_keys(app, ctx); } @@ -688,10 +696,15 @@ fn handle_pen_and_work_path_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f } } // Pen path in progress: ↩ and Esc both end it as an open path and keep it, as in Photoshop (#1769). - if app.ui.pen.is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Escape)) { + // When the document refuses it, ↩ keeps the draft to commit again; Esc keeps it as the Work Path. + if app.ui.pen.is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) { crate::vector_ui::pen_commit(app, false); return true; } + if app.ui.pen.is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + crate::vector_ui::pen_end(app); + return true; + } // A finished work path shown by the Pen or Path Selection: ⌫/Delete deletes it, as in // Photoshop (#5702), never Edit › Clear on the pixels under it. if focus != crate::shortcut_dispatch::Focus::DeletingWidget && crate::vector_ui::delete_key_deletes_work_path(app) { @@ -805,7 +818,8 @@ fn handle_registry_and_tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context, f if !untouched && pressed(Key::Escape) { app.ui.polygon.clear(); app.ui.polygon_mode.clear(); - crate::crop_ui::cancel(app); + app.ui.polygon_doc = None; + crate::crop_ui::dismiss(app); return true; } } @@ -830,6 +844,7 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { || app.ui.tool == Tool::RotateView || app.wide_angle.is_some() || app.save_options_open() + || app.smart_svg.modal_open() || app.distort.gallery.is_some() || ctx.any_popup_open() || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some()); @@ -849,8 +864,10 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { } return; } - // ⇧+ / ⇧− step the blend mode and ⇧⌥ letters pick one (`blend_keys`). Before the tool - // letters: `consume_key` ignores extra ⇧ and ⌥, so ⇧⌥M would otherwise pick a tool. + // + / − pick a Path Operation with the Pen tools and Path Selection (`path_components`)… + crate::path_components::handle_keys(app, ctx); + // … and otherwise ⇧+ / ⇧− step the blend mode and ⇧⌥ letters pick one (`blend_keys`). Before the + // tool letters: `consume_key` ignores extra ⇧ and ⌥, so ⇧⌥M would otherwise pick a tool. crate::blend_keys::handle(app, ctx); // Tool keys; pressing the key of the current group cycles within it. With Preferences › // Tools › Use Shift Key for Tool Switch, only ⇧+key cycles and the plain key keeps the diff --git a/crates/ui-egui/src/input/shortcuts/tests.rs b/crates/ui-egui/src/input/shortcuts/tests.rs index c6b8694a6e..cfb3203f10 100644 --- a/crates/ui-egui/src/input/shortcuts/tests.rs +++ b/crates/ui-egui/src/input/shortcuts/tests.rs @@ -163,6 +163,91 @@ fn pen_custom_undo_binding_and_menu_retract_pending_points() { assert!(app.ui.pen.is_none()); } +/// A 200×200 document for the ⌘⇧↩ / ⌘⌥↩ tests. +fn selection_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); + app +} + +fn set_work_rect(app: &mut PhotocraftApp, x: i32, y: i32, w: i32, h: i32) { + let knots = serde_json::json!([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]); + app.run("path.set", serde_json::json!({"path": {"subpaths": [{"closed": true, "op": "combine", "knots": knots}]}})).unwrap(); +} + +/// The selection's bounds (x0, y0, x1, y1), if any. +fn selection_bounds(app: &PhotocraftApp) -> Option<[i32; 4]> { + let r = app.session.active().unwrap().doc.selection.as_ref()?.content_bounds(); + Some([r.x0, r.y0, r.x1, r.y1]) +} + +fn press_key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + ctx.begin_pass(egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }); + handle(app, ctx); + ctx.end_pass().textures_delta.clear(); +} + +#[test] +fn ctrl_shift_enter_adds_the_path_to_the_selection() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + app.run("select.rect", serde_json::json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + + set_work_rect(&mut app, 100, 100, 40, 40); + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 2, "path.set + selection: {}", app.ui.status); + // Both rectangles are selected: the key added the path, it did not replace the selection. + assert_eq!(selection_bounds(&app), Some([10, 10, 140, 140])); +} + +#[test] +fn ctrl_alt_enter_subtracts_the_path_from_the_selection() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + assert!(selection_bounds(&app).is_some()); + + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(selection_bounds(&app), None, "the only selected area was subtracted: {}", app.ui.status); +} + +#[test] +fn add_and_subtract_path_keys_are_ignored_while_a_text_field_has_focus() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + + // Give a text field the keyboard focus, as a panel's numeric field would have it. + let mut text = String::new(); + ctx.run_ui(Default::default(), |ui| { + ui.text_edit_singleline(&mut text).request_focus(); + }) + .textures_delta + .clear(); + + let history = app.session.active().unwrap().history.past_len(); + for modifiers in [Modifiers::COMMAND | Modifiers::ALT, Modifiers::COMMAND | Modifiers::SHIFT] { + let events = + vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers }]; + // The field is drawn every frame, so it keeps the focus. + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + assert!(ui.ctx().text_edit_focused()); + handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut text); + }) + .textures_delta + .clear(); + } + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(selection_bounds(&app).is_some()); +} + #[test] fn parses_registry_shortcuts() { let sc = parse("Cmd+Shift+N").unwrap(); diff --git a/crates/ui-egui/src/input/tool_shortcuts.rs b/crates/ui-egui/src/input/tool_shortcuts.rs index 77d36b1d1c..16a8d4710a 100644 --- a/crates/ui-egui/src/input/tool_shortcuts.rs +++ b/crates/ui-egui/src/input/tool_shortcuts.rs @@ -92,7 +92,7 @@ mod tests { h.key_press(egui::Key::Q); h.event(egui::Event::Text("q".into())); h.run_steps(2); - assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.tool, Tool::Move); assert_eq!(h.state().ui.status, "q"); } } diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 4826872457..fb708d9438 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -605,7 +605,7 @@ fn alt_scroll_zooms_while_a_temporary_tool_is_held() { assert!((zoom(&h) / z0 - 1.1f32.powi(2)).abs() < 1e-3, "{z0} -> {}", zoom(&h)); h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); - assert_eq!(h.state().ui.tool, crate::state::Tool::Brush); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move); } /// #8049 (Windows 11): the Alt key's press arrives, but the modifier state stays without Alt, so @@ -1022,6 +1022,35 @@ fn paint_bucket_shows_a_bucket_cursor() { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Crosshair, "Precise keeps the crosshair"); } +#[test] +fn move_alt_copy_cursor_follows_the_latched_duplication_drag() { + let mut h = harness(); + h.state_mut().run("shape.create", json!({"kind": "rect", "rect": [150, 100, 100, 100], "fill": "#ff0000"})).unwrap(); + h.state_mut().ui.tool = crate::state::Tool::Move; + h.state_mut().ui.tool_options.move_show_transform = false; + let before = h.state().session.active().unwrap().doc.layers.len(); + let p = h.state().last_canvas_rect.center(); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Copy); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before, "hovering never duplicates"); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::ALT }); + h.run_steps(1); + let end = p + vec2(25.0, 15.0); + h.hover_at(end); + h.run_steps(2); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Copy, "releasing Alt does not undo an active copy"); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before + 1); +} + /// The Hand tool, and Space held over another tool, show an open hand while hovering and a fist /// while they pan (#2196). The OS draws those everywhere but Windows, where winit maps `Grab` to /// the four-arrow move cursor, so the app hands it a bitmap there (`Crosshair` is the fallback). @@ -1099,7 +1128,9 @@ fn hand_pointer_over_the_canvas_under_a_dialog() { fn esc_ends_a_pen_path_and_keeps_it() { let mut h = harness(); h.state_mut().ui.tool = crate::state::Tool::Pen; - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = + Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], owner, ..Default::default() }); h.key_press(Key::Escape); h.run_steps(2); assert!(h.state().ui.pen.is_none(), "the Pen leaves drawing state"); @@ -1118,7 +1149,8 @@ fn esc_ends_a_pen_path_and_keeps_it() { fn pen_shows_the_close_path_pointer_over_the_first_anchor() { let mut h = harness(); h.state_mut().ui.tool = crate::state::Tool::Pen; - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3]], owner, ..Default::default() }); let screen = |h: &Harness<'static, PhotocraftApp>, x: f32, y: f32| { let v = &h.state().ui.views[0]; let c = h.state().last_canvas_rect.center(); @@ -1254,6 +1286,40 @@ fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() { assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}"); } +/// #10488: a Windows reporter pressed Ctrl + Alt + the wheel over the canvas and nothing happened. +/// winit reports a held Ctrl as both `ctrl` and `command`, so the Alt zoom gesture has to accept +/// `command` when `ctrl` is set as well. Ctrl + Alt + wheel now zooms like ⌥ + wheel, ×1.1 a notch +/// around the pointer, and holds the document point under it fixed. +#[test] +fn ctrl_alt_wheel_zooms_the_canvas_like_alt_wheel() { + let mut h = harness(); + // Animated Zoom off: a notch is one instant step, so the frame count does not matter here. + h.state_mut().session.edit_prefs(|p| p.general.animated_zoom = false); + let r = h.state().last_canvas_rect; + let p = pos2(r.center().x - 150.0, r.center().y - 100.0); + h.hover_at(p); + h.run_steps(2); + let (z0, d0) = (zoom(&h), doc_at(&h, p)); + assert!(d0[0] > 0.0 && d0[1] > 0.0, "the pointer starts on the image: {d0:?}"); + let ctrl_alt = Modifiers { alt: true, ctrl: true, command: true, ..Modifiers::NONE }; + for _ in 0..5 { + wheel(&h, 1.0, ctrl_alt); + h.run_steps(3); + } + h.run_steps(20); + let (z1, d1) = (zoom(&h), doc_at(&h, p)); + assert!((z1 / z0 - 1.1f32.powi(5)).abs() < 1e-3, "five Ctrl + Alt notches, ×1.1 each: {z0} -> {z1}"); + assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "the point under the pointer held: {d0:?} -> {d1:?}"); + // A plain Ctrl + wheel keeps its sideways scroll (#635). + let ca = h.state().ui.views[0].center; + let za = zoom(&h); + wheel(&h, -1.0, Modifiers { ctrl: true, command: true, ..Modifiers::NONE }); + h.run_steps(40); + assert_eq!(zoom(&h), za, "Ctrl + wheel does not zoom"); + let c1 = h.state().ui.views[0].center; + assert!(c1[0] > ca[0] && c1[1] == ca[1], "Ctrl + wheel still pans sideways: {ca:?} -> {c1:?}"); +} + /// #4658: with the Move tool's Show Transform Controls, the band just outside the box shows the /// curved rotate arrow, not a plain pointer, and one drag from there rotates the layer. #[test] @@ -1289,6 +1355,54 @@ fn move_tool_controls_rotate_from_just_outside_the_box() { assert!((tilt - 90.0).abs() < 8.0 || (tilt + 270.0).abs() < 8.0, "rotated about a quarter turn, got {tilt}° (zoom {zoom})"); } +#[test] +fn assignable_pick_layer_shortcut_follows_transformed_canvas_without_editing() { + let mut h = harness(); + let base = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("layer.new.layer", json!({"name": "Paint"})).unwrap(); + let paint = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("select.rect", json!({"x": 100, "y": 100, "width": 40, "height": 40})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + h.state_mut().run("layer.new.layer", json!({"name": "Empty"})).unwrap(); + h.state_mut().run("prefs.set", json!({"path": "shortcuts.layer.selectUnderCursor", "value": "Cmd+Alt+K"})).unwrap(); + h.state_mut().ui.tool = crate::state::Tool::Brush; + for (zoom, rotation, flip) in [(1.0, 0.0, false), (2.0, 37.0, true), (0.5, -90.0, false)] { + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].rotation = rotation; + h.state_mut().ui.views[0].center = [180.0, 145.0]; + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(2); + for (point, expected) in [([120.0, 120.0], paint), ([155.0, 155.0], base)] { + let pos = crate::canvas::ViewXform::active(h.state()).unwrap().to_screen(point[0], point[1]); + let history = h.state().session.active().unwrap().history.past_len(); + h.hover_at(pos); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::ALT, Key::K); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(expected)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history, "picking never adds a document edit"); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke")); + } + } + h.state_mut().run("layer.setProps", json!({"layer": paint.0, "visible": false})).unwrap(); + let p = crate::canvas::ViewXform::active(h.state()).unwrap().to_screen(120.0, 120.0); + h.hover_at(p); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::ALT, Key::K); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(base)); + // Outside the document and modal dialogs preserve the current selection. + h.state_mut().run("layer.select", json!({"layer": paint.0})).unwrap(); + h.hover_at(pos2(0.0, 0.0)); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::ALT, Key::K); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(paint)); + open_dialog(&mut h); + h.hover_at(p); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::ALT, Key::K); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(paint)); +} + #[test] fn marquee_drag_after_hiding_extras_restores_selection_visibility() { let mut h = harness(); diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index a4c30646e7..620574f909 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -20,12 +20,12 @@ //! The canvas keeps showing the pre-job state: workers never touch the session's documents. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use egui::{Color32, Rect, RichText, Sense, Stroke, vec2}; use photocraft_doc::DocId; -use photocraft_engine::jobs::{JobCtx, JobEvent, JobId, JobInfo, JobOutcome, OPEN_JOB, OpenSource, Started}; +use photocraft_engine::jobs::{CancelRefused, JobCtx, JobEvent, JobId, JobInfo, JobOutcome, OPEN_JOB, OpenSource, Started}; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -73,23 +73,14 @@ pub(crate) struct OpenBatch { /// The job command of a background save. pub const SAVE_JOB: &str = "file.save"; -const ENCODING: u8 = 0; -const WRITING: u8 = 1; -const CANCELLED: u8 = 2; - -/// A save worker's stage, shared between the worker and the UI. +/// A save worker's state, shared between the worker and the UI. Whether it may still be +/// cancelled is its job's ([`JobCtx::commit`]), so every cancel path agrees. #[derive(Debug, Default)] pub struct SaveState { - stage: AtomicU8, finished: AtomicBool, } impl SaveState { - /// Cancel unless writing has started; false once it has. - fn try_cancel(&self) -> bool { - matches!(self.stage.compare_exchange(ENCODING, CANCELLED, Ordering::AcqRel, Ordering::Acquire), Ok(_) | Err(CANCELLED)) - } - /// The worker has returned (or unwound): nothing of this save can still touch the file. pub fn finished(&self) -> bool { self.finished.load(Ordering::Acquire) @@ -110,7 +101,6 @@ impl Drop for WorkerDone { /// be cancelled. pub struct SaveCtl { pub job: JobCtx, - state: Arc, } impl SaveCtl { @@ -120,15 +110,15 @@ impl SaveCtl { /// `Err` once the save was cancelled: stop, write nothing. pub fn check(&self) -> Result<(), String> { - if self.job.cancelled() || self.state.stage.load(Ordering::Acquire) == CANCELLED { Err(SAVE_CANCELLED.into()) } else { Ok(()) } + if self.job.cancelled() { Err(SAVE_CANCELLED.into()) } else { Ok(()) } } /// Call once, right before replacing the file. `Ok`: the save can no longer be cancelled - /// (Cancel and Esc are refused) and must run to the end; `Err`: it was cancelled first and - /// nothing may be written. + /// (Cancel, Esc, `jobs.cancel`, cancelling every job and closing the document all leave it + /// running) and must run to the end; `Err`: it was cancelled first and nothing may be + /// written. pub fn commit(&self) -> Result<(), String> { - self.check()?; - self.state.stage.compare_exchange(ENCODING, WRITING, Ordering::AcqRel, Ordering::Acquire).map(|_| ()).map_err(|_| SAVE_CANCELLED.into()) + self.job.commit().map_err(|_| SAVE_CANCELLED.into()) } } @@ -175,6 +165,8 @@ pub struct JobsUi { pub focus: Option, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option, + /// Cached read-only canvas image of the current folder Batch input. + batch_preview_texture: Option<(JobId, u64, egui::TextureHandle)>, } /// Run command `id`: in the background when background jobs are on and the command is a job @@ -197,14 +189,24 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result, source: OpenSource) -> Result<(), String> { +/// replaces it when decoded. `path` is remembered for File › Save and Open Recent. `slot` (a file +/// dropped on the tabs) and `order` (its place in a set opened together) survive an inline +/// completion, a memory prompt and the open itself, so the tab lands where it belongs either way. +pub fn start_open( + app: &mut PhotocraftApp, + name: &str, + path: Option, + source: OpenSource, + slot: Option, + order: Option, +) -> Result<(), String> { let name = &app.open_name(name); - match app.session.start_open_for_user(name, source).map_err(|e| e.to_string())? { - // Inline (wasm): finish now, like a background open that ended at once. - Started::Done(v) => finish_open(app, name, path.as_deref(), &v, None), + match app.session.start_open_for_user_with_background(name, source, app.background_jobs).map_err(|e| e.to_string())? { + // Inline (wasm, or background jobs off): finish now, like a background open that ended at + // once. A preflight keeps the slot and set order in its memory prompt. + Started::Done(v) => finish_open(app, name, path.as_deref(), &v, slot, order), Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot: None, order: None }); + app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot, order }); app.jobs.focus = Some(job); app.ui.chrome.home = None; app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", name)]); @@ -226,16 +228,20 @@ pub(crate) fn begin_batch(app: &mut PhotocraftApp) -> u64 { /// A member of a set opened at once (not in the background): its document is in place at the /// end, so later members go after it. +#[cfg(test)] pub(crate) fn opened_in_order(jobs: &mut JobsUi, order: OpenOrder) { if let Some(batch) = jobs.batches.iter_mut().find(|b| b.id == order.batch) { batch.opened.push(order.seq); } } -/// Every member of the set has been started: forget it unless some are still opening. -pub(crate) fn end_batch(jobs: &mut JobsUi) { - let opens = &jobs.opens; - jobs.batches.retain(|b| opens.iter().any(|o| o.order.is_some_and(|x| x.batch == b.id))); +/// Every member of the set has been started: forget it unless some are still opening, waiting on +/// their prompt (memory, profile or damage), or waiting for a prompt to clear. +pub(crate) fn end_batch(app: &mut PhotocraftApp) { + let waiting: Vec = + app.held_memory_prompts.iter().filter_map(|m| m.order.map(|o| o.batch)).chain(app.held_opens.iter().filter_map(|h| h.order.map(|o| o.batch))).collect(); + let JobsUi { opens, batches, .. } = &mut app.jobs; + batches.retain(|b| opens.iter().any(|o| o.order.is_some_and(|x| x.batch == b.id)) || waiting.contains(&b.id)); } /// The tab position for a member of a set that just opened: after the members before it that @@ -247,12 +253,19 @@ fn batch_slot(jobs: &mut JobsUi, order: OpenOrder) -> Option { Some(slot) } +/// Where a finished open puts its tab: an explicit slot (a file dropped on the tabs) wins; +/// otherwise its place in a set opened together, if that set is still tracked. +pub(crate) fn tab_slot(app: &mut PhotocraftApp, slot: Option, order: Option) -> Option { + let in_set = order.and_then(|o| batch_slot(&mut app.jobs, o)); + slot.or(in_set) +} + /// A background open's result (`{document, warnings, color}`) for `tab`, already taken out of -/// [`JobsUi::opens`]: the document goes to the tab's slot, or to its place in its set. +/// [`JobsUi::opens`]: the document goes to the tab's slot, or to its place in its set. A memory +/// preflight keeps both in its prompt. pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) -> Result<(), String> { - let slot = tab.slot.or_else(|| tab.order.and_then(|o| batch_slot(&mut app.jobs, o))); - let r = finish_open(app, &tab.name, tab.path.as_deref(), v, slot); - end_batch(&mut app.jobs); + let r = finish_open(app, &tab.name, tab.path.as_deref(), v, tab.slot, tab.order); + end_batch(app); r } @@ -290,8 +303,8 @@ pub fn start_save( &label, true, move |ctx| { - let _done = WorkerDone(Arc::clone(&worker)); - let ctl = SaveCtl { job: ctx.clone(), state: worker }; + let _done = WorkerDone(worker); + let ctl = SaveCtl { job: ctx.clone() }; save(&snapshot, &target, &settings, &ctl) .map_err(|e| if ctl.check().is_err() { photocraft_engine::EngineError::Cancelled } else { photocraft_engine::EngineError::Other(e) }) }, @@ -382,9 +395,18 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { } if let Some(job) = job_in_view(app) && !ctx.text_edit_focused() + && !crate::zoom_field::editing(ctx) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - cancel(app, job.id); + if job.command == "file.automate.batch" { + if app.session.batch_interrupt_waiting(job.id) { + app.session.decide_batch_interrupt_job(job.id, false); + } else { + app.session.request_batch_interrupt_job(job.id); + } + } else { + cancel(app, job.id); + } } } @@ -425,31 +447,38 @@ pub fn job_in_view(app: &PhotocraftApp) -> Option { /// Cancel job `id` (the status bar's ×, the dialog's Cancel, Esc, closing an opening tab). pub fn cancel(app: &mut PhotocraftApp, id: JobId) -> bool { + try_cancel(app, id).is_ok() +} + +/// [`cancel`], saying why a job was left running. +pub fn try_cancel(app: &mut PhotocraftApp, id: JobId) -> Result<(), CancelRefused> { let label = app.session.job(id).map(|j| j.label); + let r = app.session.try_cancel_job(id); // A save that has started replacing the file runs to the end: cancelling it now would leave // the file written but the document marked unsaved. - if let Some(save) = app.jobs.saves.iter().find(|s| s.job == id) - && !save.state.try_cancel() - { + if r == Err(CancelRefused::Committing) { app.ui.status = crate::i18n::fmt(tl!("{label} is writing the file and can no longer be cancelled"), &[("label", label.as_deref().unwrap_or_default())]); app.ui.status_error = false; - return false; } - let cancelled = app.session.cancel_job(id); - if cancelled { + if r.is_ok() { // The event (and its status text) arrives with the next poll; say so now as well. if let Some(l) = label { app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &l)]); app.ui.status_error = false; } } - cancelled + r } +/// Handles one finished job: optional completion beep, waiting control replies, and the +/// command-specific open, save, transform and recovery paths. fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { + let cancelled_batch = + e.command == "file.automate.batch" && matches!(&e.outcome, JobOutcome::Done(result) if result.get("cancelled").and_then(Value::as_bool) == Some(true)); // Preferences ▸ General ▸ Beep When Done (off by default): a finished operation beeps, like // Photoshop's cue. Cancelling is the user's own action and stays silent. if matches!(e.outcome, JobOutcome::Done(_)) + && !cancelled_batch && app.session.prefs().general.beep_when_done && let Some(beep) = app.services.beep { @@ -457,6 +486,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } // Control requests waiting on this job. let reply = match &e.outcome { + JobOutcome::Done(v) if cancelled_batch => json!({"ok": false, "error": "cancelled", "result": v}), JobOutcome::Done(v) => json!({"ok": true, "result": v}), JobOutcome::Failed(err) => json!({"ok": false, "error": err}), JobOutcome::Cancelled => json!({"ok": false, "error": "cancelled"}), @@ -506,7 +536,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.ui.status_error = false; } } - end_batch(&mut app.jobs); + end_batch(app); return; } match e.outcome { @@ -525,6 +555,10 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.sync_views(); crate::file_ui::print_status(app, result); } + JobOutcome::Done(_) if cancelled_batch => { + app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &e.label)]); + app.ui.status_error = false; + } JobOutcome::Done(_) => { app.sync_views(); app.ui.status = e.label; @@ -545,8 +579,29 @@ 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, docId, warnings, color}` or `{held, …}`). -fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { +/// (`{document, docId, warnings, color}` or `{held, …}`). `slot` and `order` are the tab position +/// and set place the file keeps: through a memory prompt, a profile/damage prompt and the final +/// document, so an inline completion lands where a background tab would. +fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option, order: Option) -> Result<(), String> { + if let Some(preflight_id) = v.get("pendingPreflight").and_then(Value::as_u64) { + let estimated = v.get("estimated").and_then(Value::as_u64).unwrap_or(0); + let available = v.get("availableRam").and_then(Value::as_u64); + let capacity_unknown = v.get("capacityUnknown").and_then(Value::as_bool).unwrap_or(available.is_none()); + let uncertain_capacity_warning = v.get("uncertainCapacityWarning").and_then(Value::as_bool).unwrap_or(false); + crate::open_prompt::arrive_memory( + app, + preflight_id, + name.to_string(), + path.map(str::to_string), + slot, + order, + estimated, + available, + capacity_unknown, + uncertain_capacity_warning, + ); + return Ok(()); + } 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(); let held = |doc, read_only, question, policy_applied| crate::open_prompt::Held { @@ -558,6 +613,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu warnings: warnings.clone(), bytes: None, slot, + order, question, policy_applied, }; @@ -580,7 +636,7 @@ 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 { + if let Some(slot) = tab_slot(app, slot, order) { let _ = app.run("document.move", json!({"document": index, "to": slot})); } after_open(app, document, name, path, &warnings, None) @@ -698,6 +754,9 @@ pub fn save_progress(app: &PhotocraftApp, doc: DocId) -> Option { /// (the file name on hover). Draws nothing when no job runs. pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(j) = job_in_view(app) else { return }; + if j.command == "file.automate.batch" { + return; + } let saving = app.jobs.saves.iter().any(|s| s.job == j.id); let t = Tokens::get(ui.ctx()); let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; @@ -729,17 +788,63 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Show the folder Batch worker's immutable current document in the canvas area. The worker +/// publishes snapshots rather than adding them to `Session`, so no edit or Save can target one. +pub(crate) fn batch_preview(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { + let current = app + .session + .jobs() + .into_iter() + .filter(|job| job.command == "file.automate.batch") + .find_map(|job| app.session.batch_preview(job.id).map(|preview| (job.id, preview))); + let Some((id, preview)) = current else { + app.jobs.batch_preview_texture = None; + return false; + }; + if !app.jobs.batch_preview_texture.as_ref().is_some_and(|(cached_id, generation, _)| *cached_id == id && *generation == preview.generation) { + let image = photocraft_compose::thumbnail(&preview.document, 1024); + let pixels = egui::ColorImage::from_rgba_unmultiplied([image.width as usize, image.height as usize], &image.pixels); + let texture = ui.ctx().load_texture(format!("batch-preview-{}-{}", id.0, preview.generation), pixels, egui::TextureOptions::LINEAR); + app.jobs.batch_preview_texture = Some((id, preview.generation, texture)); + } + let Some((_, _, texture)) = &app.jobs.batch_preview_texture else { return false }; + let t = Tokens::get(ui.ctx()); + let name = std::path::Path::new(&preview.path).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_else(|| preview.path.clone()); + ui.horizontal(|ui| { + ui.label(RichText::new(name).color(t.text).font(crate::theme::semibold(12.5))); + ui.label(RichText::new(tl!("Batch preview")).color(t.text_faint).size(11.0)); + }); + let area = ui.available_rect_before_wrap(); + ui.allocate_rect(area, Sense::hover()); + let size = texture.size_vec2(); + if size.x > 0.0 && size.y > 0.0 { + let scale = (area.width() / size.x).min(area.height() / size.y); + if !scale.is_finite() { + return true; + } + let scale = scale.clamp(0.0, 1.0); + let target = Rect::from_center_size(area.center(), size * scale); + ui.painter().rect_filled(target, 0.0, Color32::WHITE); + ui.painter().image(texture.id(), target, Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE); + } + true +} + /// 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`]). 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) { + if let Some(job) = app.session.jobs().into_iter().find(|job| job.command == "file.automate.batch" && app.session.batch_interrupt_waiting(job.id)) { + batch_interrupt_dialog(app, ctx, job.id); + 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) { + if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { return; } j @@ -779,6 +884,31 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Photoshop's question after Esc interrupts one input. The worker is parked before its save; +/// Continue advances the same run to the next input, and Stop returns its partial result. +fn batch_interrupt_dialog(app: &mut PhotocraftApp, ctx: &egui::Context, id: JobId) { + let t = Tokens::get(ctx); + let mut decision = None; + egui::Modal::new(egui::Id::unique("batch-interrupted")).show(ctx, |ui| { + ui.set_width(390.0); + ui.label(RichText::new(tl!("Batch")).font(crate::theme::semibold(15.0))); + ui.add_space(8.0); + ui.label(RichText::new(tl!("A command was cancelled or stopped during batch processing. Continue with the next file or stop?")).color(t.text_dim)); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::primary_button(ui, tl!("Continue"), 96.0).clicked() { + decision = Some(true); + } + if crate::widgets::secondary_button(ui, tl!("Stop"), 84.0).clicked() { + decision = Some(false); + } + }); + }); + if let Some(continue_next) = decision { + app.session.decide_batch_interrupt_job(id, continue_next); + } +} + fn elapsed(ms: f64) -> String { let s = (ms / 1000.0).max(0.0); if s < 60.0 { format!("{s:.1} s") } else { format!("{}:{:02}", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } diff --git a/crates/ui-egui/src/jobs_ui/tests.rs b/crates/ui-egui/src/jobs_ui/tests.rs index 4377074ed5..764936b46f 100644 --- a/crates/ui-egui/src/jobs_ui/tests.rs +++ b/crates/ui-egui/src/jobs_ui/tests.rs @@ -604,6 +604,95 @@ fn a_save_that_has_started_writing_cannot_be_cancelled() { assert!(!h.state().session.active().unwrap().is_dirty(), "the written save is recorded"); } +/// A `save_file` service held by `gates` that really replaces the file: it encodes until +/// `encode` opens, commits, then writes `new` once `write` opens. +fn file_save(gates: &Gates) -> crate::SaveFileFn { + let g = gates.clone(); + Arc::new(move |_, path, _, ctl| { + ctl.progress(0.0, "Encoding"); + while !g.encode.load(Ordering::Relaxed) { + ctl.check()?; + std::thread::sleep(Duration::from_millis(1)); + } + ctl.commit()?; + ctl.progress(0.0, "Writing"); + while !g.write.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1)); + } + std::fs::write(path, b"new").map_err(|e| e.to_string())?; + Ok(Vec::new()) + }) +} + +/// A file holding `old`, in a fresh directory named for the test. +fn old_file(test: &str) -> String { + let dir = std::env::temp_dir().join(format!("photocraft-jobs-ui-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("w.psd"); + std::fs::write(&path, b"old").unwrap(); + path.to_string_lossy().into_owned() +} + +fn control(h: &mut Harness<'static, PhotocraftApp>, method: &str, params: Value) -> Value { + let (req, _rx) = crate::ControlRequest::new(method, params); + let ctx = h.ctx.clone(); + let crate::control::Outcome::Done(v) = crate::control::handle(h.state_mut(), &ctx, &req) else { panic!("{method} replies at once") }; + v +} + +#[test] +fn jobs_cancel_before_a_save_writes_stops_it_and_leaves_the_target_untouched() { + let mut h = app_harness(); + let path = old_file("cancel-before-write"); + let gates = Gates::default(); + h.state_mut().services.save_file = Some(file_save(&gates)); + h.state_mut().save_as(Some(path.clone())).unwrap(); + h.run_steps(2); + let job = h.state().jobs.saves[0].job; + let v = control(&mut h, "jobs.cancel", json!({"job": job.0})); + assert_eq!(v["result"], json!({"cancelled": 1}), "{v}"); + // The worker reaches its commit point only now, and must give up there. + gates.encode.store(true, Ordering::Relaxed); + gates.write.store(true, Ordering::Relaxed); + step_until(&mut h, |a| !a.saving()); + assert_eq!(std::fs::read(&path).unwrap(), b"old", "target untouched"); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty() && st.path.is_none(), "the document is not marked saved"); + assert_eq!(h.state().ui.status, "Cancelled Saving w.psd"); +} + +#[test] +fn jobs_cancel_during_the_write_reports_the_save_as_not_cancelled_and_it_finishes() { + let mut h = app_harness(); + let path = old_file("cancel-during-write"); + let gates = Gates { encode: Arc::new(AtomicBool::new(true)), ..Gates::default() }; + h.state_mut().services.save_file = Some(file_save(&gates)); + h.state_mut().save_as(Some(path.clone())).unwrap(); + let saved_doc = h.state().session.active().unwrap().doc.id; + until_writing(&mut h); + let save = h.state().jobs.saves[0].job; + // A second document with a job that can still be cancelled. + h.state_mut().session.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let slow = slow_job(h.state_mut(), &Arc::new(AtomicBool::new(false))); + + let refused = json!([{"job": save.0, "reason": "committing"}]); + let v = control(&mut h, "jobs.cancel", json!({"job": save.0})); + assert_eq!(v["result"], json!({"cancelled": 0, "notCancelled": refused}), "{v}"); + assert_eq!(h.state().ui.status, "Saving w.psd is writing the file and can no longer be cancelled"); + // Cancel everything: the other job stops, the save is reported as left running. + let v = control(&mut h, "jobs.cancel", json!({})); + assert_eq!(v["result"], json!({"cancelled": 1, "notCancelled": refused}), "{v}"); + assert!(h.state().session.job(slow).is_none()); + assert!(h.state().session.job(save).is_some(), "the save still runs and keeps its document locked"); + + gates.write.store(true, Ordering::Relaxed); + step_until(&mut h, |a| !a.saving()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + let st = h.state().session.documents().iter().find(|st| st.doc.id == saved_doc).unwrap(); + assert!(!st.is_dirty() && st.path.as_deref() == Some(path.as_str()), "the save that happened is recorded"); +} + /// #3389: like Photoshop, a running save reads "Saving 45%" in the status bar and appends /// " - Saving 45%" to its document's tab title, in both tab strips; both go when it ends. #[test] @@ -748,6 +837,104 @@ fn a_finished_job_beeps_when_the_preference_is_on() { assert_eq!(BEEPS.load(Ordering::Relaxed), 1, "one beep for the finished job"); } +#[test] +fn cancelled_batch_with_partial_results_reports_cancellation_without_beeping() { + static BEEPS: AtomicUsize = AtomicUsize::new(0); + fn count_beep() { + BEEPS.fetch_add(1, Ordering::Relaxed); + } + BEEPS.store(0, Ordering::Relaxed); + let mut h = app_harness_with(crate::Services { beep: Some(count_beep), ..Default::default() }); + h.state_mut().run("prefs.set", json!({"path": "general.beepWhenDone", "value": true})).unwrap(); + let Started::Job(id) = h + .state_mut() + .session + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + |ctx| { + while !ctx.cancelled() { + std::thread::sleep(Duration::from_millis(1)); + } + Ok(json!({"files": ["done.png"], "cancelled": true})) + }, + |_, result| Ok(result), + ) + .unwrap() + else { + panic!("batch did not start as a job") + }; + assert!(h.state_mut().session.cancel_job(id)); + let started = Instant::now(); + let event = loop { + if let Some(event) = h.state_mut().session.poll_jobs().into_iter().find(|event| event.id == id) { + break event; + } + assert!(started.elapsed() < Duration::from_secs(10), "batch did not finish"); + std::thread::sleep(Duration::from_millis(1)); + }; + let ctx = h.ctx.clone(); + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + h.state_mut().jobs.waiters.push((id, reply_tx)); + crate::jobs_ui::on_event(h.state_mut(), &ctx, event); + assert!(h.state().ui.status.contains("Cancelled"), "{}", h.state().ui.status); + assert_eq!(BEEPS.load(Ordering::Relaxed), 0); + let reply = reply_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(reply["ok"], false); + assert_eq!(reply["error"], "cancelled"); + assert_eq!(reply["result"]["files"], json!(["done.png"])); +} + +#[test] +fn interactive_batch_shows_its_read_only_input_and_asks_after_escape() { + let mut h = app_harness(); + let original = h.state().session.active().unwrap().doc.clone(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let Started::Job(id) = h + .state_mut() + .session + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + move |ctx| { + let mut scratch = photocraft_engine::Session::new(); + scratch.execute("file.new", json!({"name": "input.png", "width": 16, "height": 12, "background": "#00ff00"}))?; + ctx.publish_batch_preview("/source/input.png", scratch.active().map(|state| state.doc.clone())); + let _ = ready_tx.send(()); + while !ctx.batch_take_interrupt() { + ctx.check()?; + std::thread::sleep(Duration::from_millis(1)); + } + let continued = ctx.batch_wait_for_decision(); + ctx.publish_batch_preview("/source/input.png", None); + Ok(json!({"continued": continued, "files": []})) + }, + |_, result| Ok(result), + ) + .unwrap() + else { + panic!("batch did not start as a job") + }; + ready_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + frames_until(&mut h, 1, |h| h.query_by_label("Batch preview").is_some()); + assert!(h.query_by_label("input.png").is_some(), "the current input appears in the canvas"); + assert!(h.query_by_label("Batch").is_none(), "the Batch progress readout is hidden while its input is visible"); + assert!(Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc), "preview cannot replace the editable document"); + h.key_press(egui::Key::Escape); + frames_until(&mut h, 1, |h| h.query_by_label("Continue").is_some()); + assert!(h.state().session.batch_interrupt_waiting(id)); + h.get_by_label("Continue").click(); + frames_until(&mut h, 1, |h| h.state().session.job(id).is_none()); + let result = h.state().session.jobs_with_recent().into_iter().find(|job| job.id == id).and_then(|job| job.result).unwrap(); + assert_eq!(result["continued"], true); + assert!(h.query_by_label("Batch preview").is_none(), "the input closes after the decision"); + assert!(Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc)); +} + #[test] fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { for success in [true, false] { @@ -940,7 +1127,7 @@ fn background_open_warns_about_missing_fonts() { // What the open job leaves behind: the decoded document in the session, then its result. let (index, _) = app.session.open_document(doc, None); 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(); + super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "docId": id.0, "warnings": []}), None, 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); } @@ -1022,7 +1209,7 @@ fn open_identity_survives_tab_reordering() { 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(); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, None, 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")); @@ -1037,7 +1224,7 @@ fn open_identity_survives_an_earlier_tab_closing() { 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(); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, Some(0), None).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"); @@ -1059,13 +1246,13 @@ fn open_identity_rejects_closed_missing_and_invalid_identities() { } 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}"); + assert!(super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, Some(0), None).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(), + super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, None, None).is_err(), "a closed identity must not fall back to the old tab index" ); control.assert_untouched(&app); @@ -1086,9 +1273,9 @@ fn open_identity_keeps_same_poll_results_in_the_profile_queue() { 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(); + super::finish_open(&mut app, "profile.png", Some("profile.png"), &prompt, None, None).unwrap(); + super::finish_open(&mut app, "first.png", Some("first.png"), &first, None, None).unwrap(); + super::finish_open(&mut app, "second.png", Some("second.png"), &second, None, 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"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 67b54ef73e..5cb1ef33af 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -5,20 +5,33 @@ //! data). The [`control`] module exposes both to automation, so agents can drive and inspect every //! part of the interface. #![forbid(unsafe_code)] +// `control::inspect` builds the whole UI snapshot in one `json!`, past the default limit of 128. +#![recursion_limit = "256"] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// PhotoCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.photocraft.png")), + accent: Some([0x2f, 0x7b, 0xf5]), + ..craft_update::suite::PHOTOCRAFT +}; + /// Translate a string literal into the current UI language: `tl!("Image Size…")`. macro_rules! tl { ($s:expr) => { $crate::i18n::t($s) }; } +mod brush_mode_menu; pub mod canvas; pub mod control; pub mod dialogs; pub mod dock; pub mod file_dialog; pub mod file_open; +pub mod frame_stages; pub mod gpu_status; pub mod i18n; mod icon_data; @@ -30,6 +43,7 @@ pub mod menu_catalog; pub mod menus; pub mod monitor_status; pub mod notices; +pub mod os_window; pub mod panels; pub mod preset_files_ui; pub mod proxy; @@ -60,6 +74,11 @@ mod app; mod input; mod prefs; mod services; +#[cfg(not(target_arch = "wasm32"))] +mod smart_svg_ui; +#[cfg(target_arch = "wasm32")] +#[path = "smart_svg_ui/browser.rs"] +mod smart_svg_ui; mod text; mod tools; @@ -95,6 +114,7 @@ use canvas::tile_overlay; use canvas::tool_cursor; pub use canvas::tool_feedback; pub use canvas::wheel_nav; +pub use canvas::zoom_field; pub use canvas::zoom_hold; pub use canvas::zoom_levels; pub use canvas::zoom_window_fit; @@ -111,6 +131,7 @@ use dialogs::camera_raw_preview_ui; use dialogs::camera_raw_scope_ui; pub use dialogs::camera_raw_ui; pub use dialogs::channel_options_ui; +pub(crate) use dialogs::color_picker_preview; pub use dialogs::color_picker_ui; pub use dialogs::color_range_ui; pub use dialogs::color_table_ui; @@ -144,10 +165,12 @@ pub use dialogs::new_layer_ui; pub use dialogs::open_prompt; pub use dialogs::palette; pub(crate) use dialogs::pattern_fill_ui; +pub use dialogs::preset_group_prompt; pub use dialogs::rasterize_prompt; pub use dialogs::replace_prompt; use dialogs::rgb_histogram; pub use dialogs::screen_picker; +pub use dialogs::select_mask_ui; pub use dialogs::shape_dialog; pub(crate) use dialogs::shape_fill_ui; use dialogs::sizing; @@ -196,6 +219,7 @@ pub use panels::brushes_tab; pub use panels::channels_panel; pub use panels::chrome_ui; pub use panels::clip_line_ui; +pub use panels::color_hud; pub use panels::color_panel_ui; pub use panels::comps_ui; pub use panels::doc_props_ui; @@ -229,9 +253,10 @@ pub(crate) use services::SAVE_OPTIONS_OPEN; pub(crate) use services::saved_revision_after_write; pub use services::{ AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn, - CaptionStyleFn, ClipboardGetFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, FileStamp, - FileStampFn, FormatChoices, ImportFn, Inbox, LoadTextFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, - SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, + CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, + ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, + LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, + SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, }; pub use text::bidi_text; pub use text::cjk_fonts; @@ -270,6 +295,8 @@ pub use tools::move_hover; pub use tools::move_lock; pub use tools::move_mods; pub use tools::object_finder; +use tools::paint_picker; +use tools::path_components; pub use tools::perspective_crop_ui; pub use tools::perspective_ui; pub use tools::puppet_ui; @@ -323,12 +350,17 @@ pub struct PhotocraftApp { navigator_gpu: HashMap, /// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`). navigator_plain: std::collections::HashSet, + /// Viewport pages of documents too large for their GPU texture set (`canvas::viewport_pages`). + streams: HashMap, /// The Info panel's Color Sampler readouts for (document, revision) (`panels::sampler_readouts`). info_samplers: Option, /// Display profile readings (#569). monitors: monitor_status::State, checker: Option, drag: Option, + /// An owned vector drag was cancelled (`canvas::cancel_stale_vector_drag`): the rest of its + /// pointer events go nowhere. + pub(crate) cancelled_vector_drag: bool, /// A Move-tool press landed on a locked layer: the first pointer move shows Photoshop's /// message (`move_lock`), a plain click shows nothing. pub(crate) move_blocked: bool, @@ -450,6 +482,9 @@ pub struct PhotocraftApp { pub(crate) drop_places: std::collections::VecDeque, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, + /// The document `ui.selected_path` belongs to, as of the last [`Self::sync_views`]: a Paths + /// panel selection never carries over to a same-named path in another tab. + path_document: Option<(DocId, u64, std::sync::Weak)>, pub fps: f32, pub(crate) last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, @@ -504,6 +539,10 @@ pub struct PhotocraftApp { /// makes shortcut- and dialog-triggered commands pass the same policy as /// direct control calls. pub(crate) automation_input: bool, + /// How many `run_command` calls are on the stack. Only the outermost one writes the frame's + /// command stage: a nested run (`file.save` → `layer.smartObjects.saveContents`, + /// `actions.play` → its steps) is already inside the outer call's time. + run_depth: u32, /// Action playbacks paused at a step dialog, by the token in that dialog's fields /// ([`actions::RESUME`]): what to play next and the automation gate it paused under. pub(crate) paused_playbacks: actions::PausedPlaybacks, @@ -532,6 +571,9 @@ pub struct PhotocraftApp { pub(crate) pattern_fill_preview: Option, /// Shape fill Color Picker preview (shape_fill_ui). pub(crate) shape_fill_preview: Option, + /// Colours a colour swatch widget's Color Picker returned with OK, waiting for the widget to + /// take them next frame (`color_picker_ui::widget_tick`): (widget id, sRGB). + pub(crate) widget_colors: Vec<(u64, [f32; 3])>, /// Liquify dialog, Puppet Warp and Perspective Warp sessions (distort_ui). pub(crate) distort: distort_ui::Distort, /// Gradient tool live-mode drags and previews (gradient_ui). @@ -548,11 +590,16 @@ pub struct PhotocraftApp { pub(crate) wide_angle: Option, /// ⌘Z history of each open parameter dialog (edit_history). pub(crate) dialog_edits: edit_history::DialogEdits, + /// What each open Color Picker's live target was when it opened, to put back on Cancel + /// (`color_picker_ui::live_preview`). + pub(crate) color_picker_live: Vec, /// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the /// egui context is available again (rasterize_prompt). pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, /// Last mirrored or imported OS image; this can predate the current internal copy. os_clip_sig: Option, + /// Last observed SVG bytes, to distinguish an unchanged OS source from a newer internal copy. + os_clip_svg: Option>>, /// The session copy, if any, need not track the OS clipboard. An older OS signature is kept /// to recognize unchanged system contents when exporting a newer copy failed or was disabled. clip_unmirrored: bool, @@ -562,6 +609,7 @@ pub struct PhotocraftApp { /// The OS clipboard was already read for the paste in flight (Edit › Paste positions it first), /// so `run` doesn't read it a second time. pub(crate) clip_read_for_paste: bool, + pub(crate) smart_svg: smart_svg_ui::State, /// Pointer position over the canvas (document px), for the Info panel and status bar. pub(crate) hover_doc: Option<[f64; 2]>, /// Layer hovered in the Layers panel, outlined on the canvas. @@ -586,6 +634,8 @@ pub struct PhotocraftApp { pub(crate) type_transform_preview: Option, /// A font hovered in the Type tool's font picker, shown live (`type_font_preview`). pub(crate) type_font_preview: Option, + /// The colour under a text or gradient stop Color Picker, shown live before OK (`color_picker_preview`). + pub(crate) color_picker_preview: Option, /// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels. channel_thumbs: Option<(DocId, std::sync::Weak, bool, Vec)>, /// Channels panel overlays / channel views drawn over the canvas, per document id. @@ -618,6 +668,8 @@ pub struct PhotocraftApp { pub stroke_trace: stroke_trace::StrokeTrace, /// Why each document's view is drawn on the CPU, and the history of changes (`render_path`). pub render_path: render_path::RenderPath, + /// The last slow frames and their stages (`perf.slowFrames`, see `frame_stages`). + pub slow_frames: frame_stages::SlowFrames, /// Preferences, autosave and snapping runtime state (see `prefs_ui`, `snap_ui`). pub(crate) prefs_rt: prefs_ui::Runtime, pub(crate) updates_rt: updates_ui::Runtime, @@ -625,8 +677,10 @@ pub struct PhotocraftApp { pub(crate) discard: Option, /// The Open or Save dialog in progress, and the action waiting on it (see `file_dialog`). pub(crate) file_dialog: Option, - /// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`). + /// A Save As or Save a Copy to a TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`). pub(crate) tiff_options: Option, + /// The last TIFF Options compression and pyramid answer, where the next prompt starts. Session-only. + pub(crate) tiff_remembered: tiff_options_ui::Remembered, /// Where each document was last saved or exported to through a dialog (#1826): its next Save /// As or export dialog starts there rather than beside the document. Session-only. pub(crate) save_dirs: HashMap, @@ -642,6 +696,11 @@ pub struct PhotocraftApp { /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it /// (see `open_prompt`). pub(crate) held_opens: std::collections::VecDeque, + /// PSD files whose memory preflight requires user approval before pixel decoding (see `open_prompt`). + pub(crate) held_memory_prompts: std::collections::VecDeque, + pub(crate) prompt_order: std::collections::VecDeque, + /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). + pub(crate) held_preset_import: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). @@ -738,9 +797,15 @@ mod transform_switch_tests; #[cfg(test)] mod transform_undo_tests; +#[cfg(test)] +mod transform_rotate_snap_tests; + #[cfg(test)] mod transform_type_tests; +#[cfg(test)] +mod transform_several_tests; + #[cfg(test)] mod psd_open_tests; #[cfg(test)] @@ -840,3 +905,9 @@ mod browse_field_tests; mod options_hit_area_tests; #[cfg(test)] mod select_and_mask_button_tests; + +#[cfg(test)] +mod saved_path_edit_tests; + +#[cfg(test)] +mod pen_path_ownership_tests; diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 1d98028625..09f732dc7a 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -474,6 +474,15 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Type", "OpenType"], "Ornaments", None, "type.openType.ornaments"), (&["Type", "OpenType"], "Ordinals", None, "type.openType.ordinals"), (&["Type", "OpenType"], "Fractions", None, "type.openType.fractions"), + (&["Type", "OpenType", "Numerals"], "Default", None, "type.openType.numerals.default"), + (&["Type", "OpenType", "Numerals"], "Lining", None, "type.openType.numerals.lining"), + (&["Type", "OpenType", "Numerals"], "Oldstyle", None, "type.openType.numerals.oldstyle"), + (&["Type", "OpenType", "Numerals"], "Proportional", None, "type.openType.numerals.proportional"), + (&["Type", "OpenType", "Numerals"], "Tabular", None, "type.openType.numerals.tabular"), + (&["Type", "OpenType", "Numerals"], "Proportional Lining", None, "type.openType.numerals.proportionalLining"), + (&["Type", "OpenType", "Numerals"], "Tabular Lining", None, "type.openType.numerals.tabularLining"), + (&["Type", "OpenType", "Numerals"], "Proportional Oldstyle", None, "type.openType.numerals.proportionalOldstyle"), + (&["Type", "OpenType", "Numerals"], "Tabular Oldstyle", None, "type.openType.numerals.tabularOldstyle"), (&["Type"], "---", None, "---"), (&["Type"], "Create Work Path", None, "type.createWorkPath"), (&["Type"], "Convert to Shape", None, "type.convertToShape"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cff7c22749..6d92287cd7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -40,11 +40,11 @@ use state::{checked, translated_menu_label}; /// Shared gate for native menu clicks and control-channel `ui.menu.invoke`. /// Dialogs and unsaved-changes prompts block edits, but allow the four view-navigation -/// commands also usable through shortcuts. Camera Raw and the full-window workspaces +/// commands also usable through shortcuts. Camera Raw, the SVG editor choice and full-window workspaces /// (`distort_ui::modal_workspace`) block menu commands entirely, and a pending crop blocks the /// commands Photoshop greys during one (`crop_ui::blocks`). pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { - if app.camera_raw.is_some() || crate::distort_ui::modal_workspace(app).is_some() || crate::crop_ui::blocks(app, id) { + if app.camera_raw.is_some() || app.smart_svg.modal_open() || crate::distort_ui::modal_workspace(app).is_some() || crate::crop_ui::blocks(app, id) { return false; } (app.ui.dialogs.is_empty() && app.ui.layers_panel_dialog.is_none() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index db9e8aeac3..6047c8074b 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -107,6 +107,9 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { buttons.push(title); } edge = if rtl { buttons.iter().map(|b| b.rect.left()).fold(edge, f32::min) } else { buttons.iter().map(|b| b.rect.right()).fold(edge, f32::max) }; + if let (Some(first), Some(last)) = (buttons.first(), buttons.last()) { + crate::zoom_field::set_menu_bar_rect(ui.ctx(), first.rect.union(last.rect)); + } switch_on_hover(ui.ctx(), &buttons); // ← and → step to the menu on that side, so in screen order. let mut tops: Vec = buttons.iter().map(egui::Popup::default_response_id).collect(); diff --git a/crates/ui-egui/src/menus/enable_rules.rs b/crates/ui-egui/src/menus/enable_rules.rs index e3712c1a9a..782735e2dd 100644 --- a/crates/ui-egui/src/menus/enable_rules.rs +++ b/crates/ui-egui/src/menus/enable_rules.rs @@ -126,6 +126,26 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool { if transform && (target.is_object() || target == "quickMask") { return false; } + // Several layers selected: one box moves them all, the Background left out (#6501). It doesn't + // warp, and doesn't distort while editable type is among them. + if transform { + let targets = photocraft_engine::transform_cmds::selection_targets(st); + // Judged on the layers the box moves, linked ones included (#11217): one linked layer + // selected is a box over its link group, which doesn't warp either. + let targets = photocraft_engine::transform_cmds::box_targets(&st.doc, &targets); + if targets.len() > 1 || targets.first() != st.active_layer.as_ref() { + let text = targets.iter().any(|t| st.doc.layer(*t).is_some_and(|l| matches!(l.content, LayerContent::Text(_)))); + return match id { + "edit.transform.warp" + | "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" => true, + "edit.transform.distort" | "edit.transform.perspective" => text, + _ => false, + }; + } + } // Editable type can be scaled, rotated and skewed but not distorted (#2630): Photoshop greys // Distort and Perspective until the type is rasterized, and the split warps (Warp on type // offers only the warp styles, #3004). A targeted unlinked mask or channel of the type layer @@ -345,6 +365,27 @@ mod tests { assert_eq!(checked, selections.len() * 2 * commands.len()); } + /// #11217 review: one linked layer selected opens a box over its link group, which refuses + /// Warp, so the menu greys Warp the same way it does for several selected layers; an unlinked + /// layer keeps it. + #[test] + fn warp_is_greyed_for_a_layer_with_linked_partners() { + let mut s = session(); + let id = |r: serde_json::Value| photocraft_doc::LayerId(r["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 c = id(s.execute("layer.new.layer", json!({})).unwrap()); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(s.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let mut app = crate::PhotocraftApp::new(s, crate::Services::default()); + assert!(disabled(&app, "edit.transform.warp"), "a linked layer warps with its partner: not available"); + assert!(!disabled(&app, "edit.freeTransform") && !disabled(&app, "edit.transform.distort"), "the box itself is"); + app.session.execute("layer.select", json!({"layer": c.0})).unwrap(); + assert!(!disabled(&app, "edit.transform.warp"), "an unlinked layer warps"); + } + #[test] fn ordinary_layers_keep_their_items() { let mut s = session(); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 850ac92fdd..a089803be8 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -187,6 +187,12 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, // ⌘↩ / Ctrl+Enter (#306): load the path selected in the Paths panel (or the one being // drawn) as a selection. "path.toSelection" if params.get("name").is_none() => crate::vector_ui::path_to_selection(app, params.clone()), + // ⌘⇧↩ / ⌘⌥↩ load the same path, adding it to or subtracting it from the selection. + "path.addToSelection" | "path.subtractFromSelection" if params.get("name").is_none() => { + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("mode".into(), json!(if id == "path.addToSelection" { "add" } else { "subtract" })); + crate::vector_ui::path_to_selection(app, Value::Object(p)) + } // Layer › Rename Layer from a menu starts in-place renaming in the Layers panel. "layer.renameLayer" if params.get("name").is_none() => { let st = app.session.active().ok_or("no document")?; @@ -214,6 +220,18 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, "file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => { app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string())) } + // File > Import > Variable Data Sets… asks for the CSV (#8816); with a `path` the engine reads it itself. + "file.import.variableDataSets" if params.get("path").and_then(Value::as_str).is_none() => { + let doc = app.active_doc_id()?; + let params = params.clone(); + app.pick_file_bytes_filtered(&["csv", "txt"], move |app, _name, bytes| { + app.refocus(doc)?; + let text = data_sets_text(bytes)?; + let r = photocraft_engine::import_data_sets_text(&mut app.session, &text, ¶ms).map_err(|e| e.to_string()); + app.sync_views(); + r + }) + } "file.save" => { // Writes back only to a layered file that holds the whole document; anything else // goes through Save As, so the original is never overwritten with less (#3806). @@ -284,6 +302,12 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, } pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + if let Some(result) = crate::smart_svg_ui::intercept(app, id, ¶ms) { + return result; + } + if id == "layer.selectUnderCursor" { + return select_layer_under_cursor(app, ctx); + } if let Some(result) = invoke_early_commands(app, ctx, id, ¶ms) { return result; } @@ -405,7 +429,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } Ok(json!({"dialog": crate::fill_ui::open(app)})) } - "select.selectAndMask" => Ok(json!({"dialog": crate::filter_dialog::open(app, "select.refineEdge")})), + // Photoshop's workspace (select_mask_ui); `distort_ui::menu` routes it first. + "select.selectAndMask" => crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]})), // Select › Transform Selection from the menu: the interactive box (with params: the engine). "select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::transform_tool::begin_selection(app, ctx).map(|_| json!({"transform": app.ui.transform})) @@ -444,6 +469,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } } +/// The text of a picked Variable Data Sets file. Invalid UTF-8 is refused, as the engine's +/// path-based import refuses it, so a failed read never replaces the document's data sets with +/// U+FFFD-substituted values. +pub(super) fn data_sets_text(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|e| format!("the data set file is not valid UTF-8: {e}")) +} + fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { app.open_path(path).map(|w| json!({"warnings": w})) } @@ -630,7 +662,7 @@ fn invoke_window_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) let doc = app.session.active_index().ok_or("no document")?; let wid = app.ui.alloc_id(); let view = app.ui.views[doc].clone(); - app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None }); + app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, floating: false, focused: false, position: None }); Ok(json!({"window": wid})) })()), "window.theme.toggle" => { @@ -675,7 +707,9 @@ fn invoke_window_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) let count = app.session.documents().len(); let active = app.session.active_index().ok_or("no document open")?; let next = if id == "window.nextDocument" { (active + 1) % count } else { (active + count - 1) % count }; - app.session.set_active(next); + if next != active { + app.run("document.activate", json!({"document": next}))?; + } app.jobs.focus = None; Ok(json!({"document": next})) })()), @@ -780,3 +814,25 @@ pub fn apply_workspace(app: &mut PhotocraftApp) { app.ui.timeline.playing = false; } } + +/// Pointer-dependent shell command; pixel picking and selection remain engine commands. +fn select_layer_under_cursor(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result { + if app.drag.is_some() || app.brush_resize.is_some() { + return Ok(json!({"layer": null})); + } + let Some(xf) = crate::canvas::ViewXform::active(app) else { return Ok(json!({"layer": null})) }; + let Some(pointer) = ctx.pointer_hover_pos().filter(|p| xf.rect.contains(*p)) else { return Ok(json!({"layer": null})) }; + let [x, y] = xf.to_doc(pointer); + let inside = app + .session + .active() + .is_some_and(|st| x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < f64::from(st.doc.size.width) && y < f64::from(st.doc.size.height)); + if !inside { + return Ok(json!({"layer": null})); + } + let picked = app.run("layer.pickAt", json!({"x": x, "y": y, "target": "layer", "select": false}))?; + if let Some(layer) = picked.get("layer").and_then(Value::as_u64) { + app.run("layer.select", json!({"layer": layer, "mode": "replace"}))?; + } + Ok(picked) +} diff --git a/crates/ui-egui/src/menus/menu_keys.rs b/crates/ui-egui/src/menus/menu_keys.rs index 903d2e683d..cfdf2e9297 100644 --- a/crates/ui-egui/src/menus/menu_keys.rs +++ b/crates/ui-egui/src/menus/menu_keys.rs @@ -333,6 +333,12 @@ impl Keys { _ => {} } } + // Typing a zoom: Alt only answers with the system alert. The menu bar stays off and the + // field keeps its focus and its text. + if tap && crate::zoom_field::is_focused(ctx) { + crate::zoom_field::request_alert(ctx); + tap = false; + } if events.iter().any(|e| matches!(e, Event::PointerButton { pressed: true, .. })) { self.focus = None; self.underline = false; @@ -364,8 +370,10 @@ impl Keys { } if open { // A letter runs the row with that mnemonic in the level the arrows are in, unless it is - // typed into a field in the menu: Help › Search took "S" for System Info (#5996). - let typing = ctx.text_edit_focused(); + // typed into a field in the menu: Help › Search took "S" for System Info (#5996). A zoom + // field that kept the keyboard through the click on the menu title is not in the menu, + // but while Help › Search is open the letters are still typing. + let typing = ctx.text_edit_focused() && !(crate::zoom_field::is_focused(ctx) && !super::search::shown(ctx)); let letter = events.iter().filter(|_| !typing).find_map(|e| match e { Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => { key_char(*key).map(|c| (*key, c)) @@ -376,6 +384,7 @@ impl Keys { && let Some(row) = nav.row_with_mnemonic(c) { consume(Modifiers::NONE, key); + crate::zoom_field::drop_text(ctx, c); self.underline = true; if let Some(cmd) = row.command.clone() { *clicked = Some(cmd); diff --git a/crates/ui-egui/src/menus/menu_keys/harness_tests.rs b/crates/ui-egui/src/menus/menu_keys/harness_tests.rs index 1fcde2bd54..f4a5b796a7 100644 --- a/crates/ui-egui/src/menus/menu_keys/harness_tests.rs +++ b/crates/ui-egui/src/menus/menu_keys/harness_tests.rs @@ -504,3 +504,112 @@ fn alt_held_during_a_drag_is_no_tap() { alt_tap(&mut h); assert!(Keys::current(&h.ctx).focus.is_some(), "a lone tap still focuses the bar"); } + +/// Typing a zoom: Alt only asks for the system alert. The bar does not take the keyboard, and the +/// field keeps its focus and its text. +#[test] +fn alt_in_a_zoom_field_only_alerts() { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).build_ui_state( + |ui, (app, pct): &mut (PhotocraftApp, f32)| { + crate::menus::menu_bar(app, ui); + crate::widgets::compact_wrapping_value_field(ui, pct, 0.0..=12800.0, "%", egui::vec2(90.0, 20.0)).accessible_name("Zoom"); + }, + (app(), 100.0), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + for c in "200".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(2); + } + 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, "Alt does not put the keyboard on the bar"); + assert!(crate::zoom_field::alert_pending(&h.ctx), "it asks for the alert instead"); + assert_eq!(h.state().1, 100.0, "and the zoom stays"); + assert!(h.get_by_role(egui::accesskit::Role::SpinButton).is_focused(), "the field keeps the keyboard"); + // The text was not selected by Alt: this adds to 200 instead of replacing it. + h.event(egui::Event::Text("3".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().1, 2003.0, "typing went on in the same text"); +} + +/// The menu bar and a zoom field, with `zoom_field::tick` as the app runs it. `beep` stands in for +/// the platform's alert. +fn bar_and_zoom_field(beep: fn()) -> Harness<'static, (PhotocraftApp, f32)> { + let mut app = app(); + app.services.beep = Some(beep); + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).build_ui_state( + |ui, (app, pct): &mut (PhotocraftApp, f32)| { + crate::zoom_field::tick(app, &ui.ctx().clone()); + crate::menus::menu_bar(app, ui); + crate::widgets::compact_wrapping_value_field(ui, pct, 0.0..=12800.0, "%", egui::vec2(90.0, 20.0)).accessible_name("Zoom"); + }, + (app, 100.0), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + for c in "300".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(2); + } + h +} + +/// A letter as the platform sends it: the key, and the text it types. +fn letter(h: &mut Harness<'static, (PhotocraftApp, f32)>, c: char) { + let key = egui::Key::from_name(&c.to_string()).unwrap(); + h.event(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::Text(c.to_lowercase().to_string())); + h.event(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(6); +} + +/// A zoom field keeps the keyboard, and what was typed, through a click on a menu title. The menu +/// that click opened still takes its keys: → moves to the next menu and a letter runs its row, +/// without the letter also reaching the field. +#[test] +fn a_menu_opened_while_typing_a_zoom_takes_its_keys() { + static BEEPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let mut h = bar_and_zoom_field(|| { + BEEPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }); + h.get_by_label("Filter").click(); + h.run_steps(4); + assert!(is_open(&h.ctx), "the click opened Filter"); + assert!(crate::zoom_field::is_focused(&h.ctx), "the zoom field kept the keyboard"); + h.key_press(egui::Key::ArrowRight); + h.run_steps(5); + assert!(h.query_by_label_contains("Zoom In").is_some(), "→ moved to View"); + let row = Nav::current(&h.ctx).rows.first().and_then(|l| l.iter().find(|r| r.command.as_deref() == Some("view.zoomIn")).cloned()); + let c = row.and_then(|r| r.mnemonic).expect("Zoom In has a mnemonic"); + let zoom = h.state().0.ui.views[0].zoom; + letter(&mut h, c); + assert!(h.state().0.ui.views[0].zoom > zoom, "its letter ran Zoom In ({c})"); + assert_eq!(BEEPS.load(std::sync::atomic::Ordering::SeqCst), 0, "and did not reach the zoom field, which would alert"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().1, 300.0, "the field still holds the typed 300, nothing more"); +} + +/// Help has a text field of its own: while a zoom is typed, its letters stay typing, never run +/// Help's rows (#5996: S for System Info). +#[test] +fn help_letters_stay_typing_while_a_zoom_is_typed() { + let mut h = bar_and_zoom_field(|| {}); + h.get_by_label("Help").click(); + h.run_steps(4); + assert!(is_open(&h.ctx), "the click opened Help"); + let rows = Nav::current(&h.ctx).rows.first().cloned().unwrap_or_default(); + let c = rows.iter().find(|r| r.enabled && r.mnemonic.is_some()).and_then(|r| r.mnemonic).expect("a Help row with a mnemonic"); + letter(&mut h, c); + assert!(is_open(&h.ctx), "{c} ran no row: Help stays open"); +} diff --git a/crates/ui-egui/src/menus/menu_nav.rs b/crates/ui-egui/src/menus/menu_nav.rs index fb17c46643..c7b3eb28f4 100644 --- a/crates/ui-egui/src/menus/menu_nav.rs +++ b/crates/ui-egui/src/menus/menu_nav.rs @@ -341,8 +341,10 @@ impl Nav { let level = self.level.min(self.rows.len().saturating_sub(1)); self.level = level; // A text field in the menu (Help › Search) keeps the keys that edit it: ← / → move its - // cursor and Space types a space. - let typing = ctx.text_edit_focused(); + // cursor and Space types a space. A zoom field keeps the keyboard while a menu title is + // clicked (zoom_field.rs), but is not in the menu: the menu takes the keys, unless it has + // a text field of its own (Help › Search, which the zoom field keeps out). + let typing = ctx.text_edit_focused() && !(crate::zoom_field::is_focused(ctx) && !super::search::shown(ctx)); let press = |k: Key| !(typing && matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::Space)) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); let current = self.highlighted(level).and_then(|i| self.rows.get(level)?.get(i)).cloned(); let switch = |by: usize| { @@ -373,6 +375,7 @@ impl Nav { switch(tops.len() - 1); } } else if press(Key::Enter) || press(Key::Space) { + crate::zoom_field::drop_text(ctx, ' '); match current { Some(Row { enabled: true, command: Some(c), .. }) => { *clicked = Some(c); diff --git a/crates/ui-egui/src/menus/parity.rs b/crates/ui-egui/src/menus/parity.rs index ab5fd5eee7..2aa25e9b40 100644 --- a/crates/ui-egui/src/menus/parity.rs +++ b/crates/ui-egui/src/menus/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 631; +pub const FLOOR: usize = 640; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/crates/ui-egui/src/menus/search.rs b/crates/ui-egui/src/menus/search.rs index a1eed7981c..f51172892d 100644 --- a/crates/ui-egui/src/menus/search.rs +++ b/crates/ui-egui/src/menus/search.rs @@ -44,6 +44,12 @@ pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::L hits.into_iter().filter(|(_, _, it)| seen.insert(it.id.as_str())).take(HELP_SEARCH_MAX).map(|(_, _, it)| it).collect() } +/// Help › Search was drawn in this frame (or the last): the open menu has a text field of its own. +pub(super) fn shown(ctx: &egui::Context) -> bool { + let pass_id = egui::Id::unique("help-menu-search").with("pass"); + ctx.data(|d| d.get_temp::(pass_id)).is_some_and(|p| p + 1 >= ctx.cumulative_pass_nr()) +} + /// Help › Search : a field at the top of the Help menu that finds any menu command /// by name. Results show their menu path; click, ↓ then ↩, or ↩ in the field runs one. /// `opening`: this frame's release ends the click on the Help title that opened the menu. @@ -60,7 +66,7 @@ pub(super) fn help_search(ui: &mut egui::Ui, items: &[MenuItem], opening: bool, let field = ui.add(egui::TextEdit::singleline(&mut query).id(text_id.with("field")).hint_text(crate::i18n::tr(lang, "Search menus")).desired_width(220.0)); // The menu opens on the press; the release a frame later completes a click on the title, // outside the field, and egui takes the focus away from it again (#1447, #1438). - if reopened || opening { + if (reopened || opening) && !crate::zoom_field::editing(&ctx) { field.request_focus(); } let results = search_items(items, &query, lang); diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 7baa0b9bf6..c932ca092c 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -3,6 +3,9 @@ use super::items::MenuItem; use super::{DOCUMENT_ITEM, PhotocraftApp, Value}; pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { + if id == "layer.selectUnderCursor" { + return app.session.active().is_some() && app.drag.is_none() && app.brush_resize.is_none(); + } if crate::tool_shortcuts::tool(id).is_some() { return true; } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 2527b0fc3f..c3ec327b15 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1,5 +1,12 @@ +use super::invoke::data_sets_text; use super::*; +#[test] +fn data_set_files_must_be_valid_utf8() { + assert_eq!(data_sets_text(b"DataSet,headline\nS,Jos\xc3\xa9\n".to_vec()).unwrap(), "DataSet,headline\nS,José\n"); + assert!(data_sets_text(b"DataSet,headline\nS\xff,Jos\xe9\n".to_vec()).is_err()); +} + #[test] fn showing_grid_restores_hidden_extras() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/menus/view_cmds.rs b/crates/ui-egui/src/menus/view_cmds.rs index 3bc850e12f..bbe985ab6e 100644 --- a/crates/ui-egui/src/menus/view_cmds.rs +++ b/crates/ui-egui/src/menus/view_cmds.rs @@ -19,14 +19,14 @@ mod dispatch; mod options; pub use arrange::cells; -pub(crate) use arrange::float_window; +pub(crate) use arrange::{document_is_floating, float_window}; pub(crate) use dialogs::write_copy; #[cfg(test)] use dialogs::{form, front, label_of}; pub use dialogs::{form_body, form_keys, reset_form}; #[cfg(test)] use dispatch::is_enabled_with_engine; -pub use dispatch::{checked, handles, invoke, is_enabled}; +pub use dispatch::{checked, handles, invoke, is_enabled, leave_full_screen_without_document}; #[cfg(test)] use options::screen_mode_commands; pub use options::{persist, pixel_aspect_ratio, restore}; diff --git a/crates/ui-egui/src/menus/view_cmds/arrange.rs b/crates/ui-egui/src/menus/view_cmds/arrange.rs index 0759cf4b56..9cf7a61d80 100644 --- a/crates/ui-egui/src/menus/view_cmds/arrange.rs +++ b/crates/ui-egui/src/menus/view_cmds/arrange.rs @@ -36,7 +36,7 @@ fn screen_position(v: &Value) -> Option<[f32; 2]> { Some([n(0)?, n(1)?]) } -pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 { +pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize, floating: bool) -> u64 { let wid = app.ui.alloc_id(); let mut view = app.ui.views.get(doc).cloned().unwrap_or_default(); view.zoom_animating = false; @@ -44,7 +44,7 @@ pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) - 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 }); + app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, floating, focused: false, position: None }); wid } @@ -63,7 +63,7 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { let doc = app.session.active_index().ok_or("no document")?; - Ok(json!({"window": float_window(app, doc, 0)})) + Ok(json!({"window": float_window(app, doc, 0, false)})) } // Optional `document` (index; default the active one) and `position` (`[x, y]`, the // window's screen position in points): dragging a tab off the tab strip uses both (#3733). @@ -79,7 +79,14 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result None, Some(v) => Some(screen_position(v).ok_or("position must be [x, y] in points")?), }; - let wid = float_window(app, doc, 0); + // Tearing off the same tab again must not create another viewport for the + // same document. Keep its existing view and position if it is already open. + if let Some(w) = app.ui.windows.iter().find(|w| w.open && w.floating && w.document == doc) { + let wid = w.id; + app.session.set_active(doc); + return Ok(json!({"window": wid})); + } + let wid = float_window(app, doc, 0, true); if let Some(w) = app.ui.windows.iter_mut().find(|w| w.id == wid) { w.position = position; } @@ -88,7 +95,7 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { let n = app.session.documents().len(); - let ids: Vec = (0..n).map(|d| float_window(app, d, d)).collect(); + let ids: Vec = (0..n).map(|d| float_window(app, d, d, true)).collect(); Ok(json!({"windows": ids})) } "matchZoom" | "matchLocation" | "matchRotation" | "matchAll" => { @@ -121,6 +128,10 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result bool { + app.ui.windows.iter().any(|w| w.open && w.floating && w.document == doc) +} + /// Screen rectangles for `n` documents in an arrangement (`None` = tabs). The active document /// always gets a cell; n-up layouts show that many documents starting from the active one. pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option> { diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index f4bcd87542..2df2c64bd6 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -136,7 +136,18 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { // Data-driven labels (a per-part checkbox shows the part's name, not a catalogue entry) // 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 file_info = f.get("__command").and_then(Value::as_str) == Some("file.fileInfo"); + let label = |k: &str| { + if file_info { + match k { + "title" => return tl!("Document Title").to_owned(), + "copyright" => return tl!("Copyright Notice").to_owned(), + "copyrightUrl" => return tl!("Copyright Info URL").to_owned(), + _ => {} + } + } + labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string) + }; 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| { @@ -185,13 +196,22 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { crate::widgets::dropdown(ui, &format!("form-{k}"), &label(&k), &mut s, &refs, 200.0); } None => { - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)).accessible_name(label(&k)); - // A folder field gets the system folder picker (#7942, #9357). - if crate::dialogs::is_folder_field(&k) && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() { - browse = Some(k.clone()); - } - }); + if file_info && matches!(k.as_str(), "description" | "keywords" | "copyright") { + egui::ScrollArea::vertical().id_salt(("form-text", &k)).max_height(80.0).show(ui, |ui| { + let response = ui.add(egui::TextEdit::multiline(&mut s).desired_rows(3).desired_width(260.0)).accessible_name(label(&k)); + if response.has_focus() { + ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + }); + } else { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)).accessible_name(label(&k)); + // A folder field gets the system folder picker (#7942, #9357). + if crate::dialogs::is_folder_field(&k) && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() { + browse = Some(k.clone()); + } + }); + } } } json!(s) @@ -396,7 +416,7 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let (_, _, name) = doc?; dialog( app, - json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true}), + json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}), ) } @@ -415,6 +435,18 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let stem = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string(); dialog(app, json!({"path": format!("{dir}/{stem}.pdf"), "quality": 10}), json!({})) } + "file.export.dataSetsAsFiles" => { + #[cfg(target_arch = "wasm32")] + { + let _ = (app, dir, doc); + Some(Err(tl!("Data Sets as Files is unavailable on the web").to_string())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let (_, _, _) = doc?; + dialog(app, json!({"dir": dir, "naming": "{name}", "format": "png"}), json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]})) + } + } "layer.new.artboard" => { let (w, h, _) = doc?; let presets: Vec<&str> = std::iter::once("").chain(photocraft_engine::artboard_cmds::PRESETS.iter().map(|p| p.0)).collect(); @@ -470,8 +502,15 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let name = action.name.clone(); dialog( app, - json!({"action": name, "steps": steps, "input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/batch"), "format": "same", "quality": null, "__batchQuality": 8, "naming": "", "startingSerial": 1}), - json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"]}), + json!({ + "action": name, "steps": steps, "source": "folder", "input": dir, + "includeSubfolders": false, "__labels": subfolders_label(), + "output": format!("{dir}/batch"), "format": "same", "quality": null, + "__batchQuality": 8, "naming": "", "startingSerial": 1, + "stopOnError": false, "errorLog": "", + "__order": ["action", "source", "input", "includeSubfolders", "output", "format", "naming", "startingSerial", "quality", "stopOnError", "errorLog"] + }), + json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"], "source": ["folder", "open"]}), ) } // Channel workflow dialogs (see channels_panel.rs for the channel references). @@ -569,8 +608,9 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { 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, a DDS texture for its compression, and - // a JPEG for its JPEG Options; the copy is written from the prompt. + // An RGB Targa asks for its bits per pixel first, a DDS texture for its compression, a + // JPEG for its JPEG Options and a TIFF for its TIFF 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")?; @@ -584,6 +624,10 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { crate::jpeg_options_ui::park(app, path.clone(), true, "file.saveACopy")?; return Ok(true); } + if crate::tiff_options_ui::wants_prompt(app, &path) { + crate::tiff_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); + } Ok(false) })?; if parked { diff --git a/crates/ui-egui/src/menus/view_cmds/dispatch.rs b/crates/ui-egui/src/menus/view_cmds/dispatch.rs index 4a9884c6c0..a002a8a47d 100644 --- a/crates/ui-egui/src/menus/view_cmds/dispatch.rs +++ b/crates/ui-egui/src/menus/view_cmds/dispatch.rs @@ -82,7 +82,10 @@ pub(super) fn is_enabled_with_engine(app: &PhotocraftApp, id: &str, engine_enabl } let doc = app.session.active().is_some(); Some(match id { - "view.screenMode.cycle" => app.ui.text_edit.is_none(), + // Full Screen Mode shows a document, so without one it is unavailable, as in Photoshop + // (#9349); with none open, Cycle can only bring a full screen mode back to Standard. + "view.screenMode.cycle" => app.ui.text_edit.is_none() && (doc || app.ui.view.screen_mode != "standard"), + "view.screenMode.fullScreen" | "view.screenMode.fullScreenWithMenuBar" => doc, "view.twoHundredPercent" | "view.printSize" | "view.fitLayersOnScreen" | "view.rotateView" | "view.resetView" => doc, "view.fitArtboardOnScreen" => app.session.active().is_some_and(|d| d.doc.has_artboards()), i if i.starts_with("window.arrange.") => match &i["window.arrange.".len()..] { @@ -100,11 +103,21 @@ pub(super) fn is_enabled_with_engine(app: &PhotocraftApp, id: &str, engine_enabl // Batch's dialog is built from an Actions-panel action. Keep it disabled until one // exists instead of opening a menu item that can only report "record an action". "file.automate.batch" => crate::actions::selected_action(app).is_some() && engine_enabled(), + #[cfg(target_arch = "wasm32")] + "file.export.dataSetsAsFiles" => false, i if wraps(i) => engine_enabled(), _ => true, }) } +/// A full screen mode with no document left (the last one closed) goes back to Standard Screen +/// Mode, rather than showing the start screen on the full screen pasteboard (#9349). +pub fn leave_full_screen_without_document(app: &mut PhotocraftApp, ctx: &egui::Context) { + if app.ui.view.screen_mode != "standard" && app.session.documents().is_empty() { + let _ = crate::menus::invoke(app, ctx, "view.screenMode.standard", json!({})); + } +} + pub fn checked(app: &PhotocraftApp, id: &str) -> Option { let o = &app.ui.view; if let Some(k) = id.strip_prefix("view.show.") { @@ -174,6 +187,13 @@ fn type_checked(app: &PhotocraftApp, id: &str) -> Option { return Some(t.is_some_and(|t| (t.orientation == photocraft_doc::text::Orientation::Vertical) == (k == "vertical"))); } let t = t?; + // The style where the menu reads: the selection start while editing a range (as the Character + // panel shows it), else the layer's first run. + let start = crate::type_tool::target(app).and_then(|(_, range)| range).map_or(0, |r| r[0]); + if let Some(key) = id.strip_prefix("type.openType.numerals.") { + let want = photocraft_engine::type_extra_cmds::NumeralsStyle::from_id(key)?; + return Some(photocraft_engine::type_extra_cmds::numerals_style_at(t, start) == Some(want)); + } let tag = match id.strip_prefix("type.openType.")? { "standardLigatures" => "liga", "contextualAlternates" => "calt", @@ -187,7 +207,7 @@ fn type_checked(app: &PhotocraftApp, id: &str) -> Option { "fractions" => "frac", _ => return None, }; - Some(t.char_runs().first().is_some_and(|r| photocraft_engine::type_extra_cmds::feature_on(&r.style, tag))) + Some(photocraft_engine::type_extra_cmds::feature_on_at(t, start, tag)) } /// Engine commands that get a dialog or file picker here when invoked without parameters. @@ -218,6 +238,7 @@ fn wraps(id: &str) -> bool { | "file.export.layerCompsToFiles" | "file.export.artboardsToFiles" | "file.export.artboardsToPdf" + | "file.export.dataSetsAsFiles" | "layer.new.artboard" | "file.export.colorLookupTables" | "view.newGuideLayout" @@ -318,7 +339,16 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res return Ok(json!(*slot)); } if let Some(k) = id.strip_prefix("view.screenMode.") { - let next = if k == "cycle" { + // Full Screen Mode shows a document (#9349): refused without one, from any caller (the + // toolbar's list, automation), before a viewport command can go out. + if matches!(k, "fullScreen" | "fullScreenWithMenuBar") && app.session.documents().is_empty() { + return Err("Full Screen Mode needs an open document".into()); + } + let next = if k == "cycle" && app.session.documents().is_empty() { + // Without a document Cycle can only leave a full screen mode (#9349): straight back + // to Standard, not on to the next full screen mode. + "standard" + } else if k == "cycle" { let i = SCREEN_MODES.iter().position(|m| *m == o.screen_mode).unwrap_or(0); SCREEN_MODES[(i + 1) % SCREEN_MODES.len()] } else { diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index 05c1f1bb60..2e05f28d99 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -40,6 +40,29 @@ fn place_embedded_enablement_does_not_require_native_doc() { } } +#[test] +fn data_sets_as_files_enablement_respects_target_platform() { + let (app, _) = app_with(1); + let enabled = is_enabled_with_engine(&app, "file.export.dataSetsAsFiles", || true); + if cfg!(target_arch = "wasm32") { + assert_eq!(enabled, Some(false)); + } else { + assert_eq!(enabled, Some(true)); + } +} + +#[test] +fn data_sets_as_files_dialog_respects_target_platform() { + let (mut app, ctx) = app_with(1); + app.run("image.variables.dataSets", json!({"dataSets": [{"name": "Set 1", "values": []}]})).unwrap(); + let res = front(&mut app, &ctx, "file.export.dataSetsAsFiles", &json!({})); + if cfg!(target_arch = "wasm32") { + assert!(matches!(res, Some(Err(msg)) if msg.contains("unavailable on the web"))); + } else { + assert!(matches!(res, Some(Ok(v)) if v.get("dialog").is_some())); + } +} + #[test] fn show_snap_extras_and_screen_modes_are_state() { let (mut app, ctx) = app_with(1); @@ -255,6 +278,26 @@ fn type_menu_checkmarks_follow_the_layer() { assert_eq!(checked(&app, "type.orientation.horizontal"), Some(true)); } +#[test] +fn numerals_checkmark_follows_the_selection() { + let (mut app, ctx) = app_with(1); + let id = "type.openType.numerals.tabularLining"; + assert!(!crate::menus::is_enabled(&app, id)); + assert_eq!(checked(&app, id), None, "no type layer"); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Type", "size": 24})).unwrap(); + assert!(crate::menus::is_enabled(&app, id)); + assert_eq!(checked(&app, "type.openType.numerals.default"), Some(true)); + assert_eq!(checked(&app, id), Some(false)); + menu(&mut app, &ctx, id, json!({})).unwrap(); + assert_eq!(checked(&app, id), Some(true)); + assert_eq!(checked(&app, "type.openType.numerals.default"), Some(false)); + assert_eq!(checked(&app, "type.openType.numerals.lining"), Some(false)); + menu(&mut app, &ctx, "type.openType.numerals.default", json!({})).unwrap(); + assert_eq!(checked(&app, "type.openType.numerals.default"), Some(true)); + assert_eq!(checked(&app, id), Some(false)); + assert_eq!(checked(&app, "type.openType.numerals.notAStyle"), None); +} + #[test] fn batch_menu_requires_a_recorded_action() { let (mut app, ctx) = app_with(1); @@ -437,12 +480,22 @@ fn batch_dialogs_include_all_subfolders() { let fields = &mut app.ui.dialog_mut(d).unwrap().fields; assert_eq!(fields["includeSubfolders"], json!(false), "{id}"); assert_eq!(fields["__labels"]["includeSubfolders"], json!("Include All Subfolders"), "{id}"); + if id == "file.automate.batch" { + assert_eq!(fields["source"], json!("folder")); + assert_eq!(fields["stopOnError"], json!(false)); + assert_eq!(fields["errorLog"], json!("")); + } fields.insert("input".into(), json!(input)); fields.insert("output".into(), json!(dir.join(out).to_string_lossy())); fields.insert("includeSubfolders".into(), json!(true)); let r = crate::dialogs::confirm(&mut app, d).unwrap(); assert_eq!(r["files"].as_array().map(Vec::len), Some(1), "{id}: {r}"); - assert!(dir.join(out).join("sub").is_dir(), "{id} keeps the subfolder: {r}"); + if id == "file.automate.batch" { + assert!(dir.join(out).join("a.png").is_file(), "{id} writes subfolder inputs flat: {r}"); + assert!(!dir.join(out).join("sub").exists()); + } else { + assert!(dir.join(out).join("sub").is_dir(), "{id} keeps the subfolder: {r}"); + } } let _ = std::fs::remove_dir_all(&dir); } @@ -456,9 +509,12 @@ fn float_in_window_takes_a_document_and_position() { let w = app.ui.windows.last().unwrap(); assert_eq!(r, json!({"window": w.id})); assert_eq!((w.document, w.position), (1, Some([1950.0, 40.5]))); - assert_eq!(app.session.active_index(), Some(1)); - menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({})).unwrap(); - assert_eq!(app.ui.windows.last().map(|w| (w.document, w.position)), Some((1, None)), "defaults: the active document, placed by the OS"); + assert!(w.floating); + assert_eq!(app.session.active_index(), Some(1), "the floated document stays active"); + let again = menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({"document": 1})).unwrap(); + assert_eq!(again, r, "tearing off the same document reuses its window"); + assert_eq!(app.ui.windows.len(), 1); + assert_eq!(app.ui.windows[0].position, Some([1950.0, 40.5])); for bad in [ json!({"document": 3}), json!({"document": -1}), @@ -470,7 +526,31 @@ fn float_in_window_takes_a_document_and_position() { ] { assert!(menu(&mut app, &ctx, "window.arrange.floatInWindow", bad.clone()).is_err(), "{bad}"); } - assert_eq!(app.ui.windows.len(), 2, "rejected params open no window"); + assert_eq!(app.ui.windows.len(), 1, "rejected params open no window"); + app.ui.windows[0].open = false; + let reopened = menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({"document": 1})).unwrap(); + assert_ne!(reopened, r, "a closed window can be floated again"); + assert_eq!(app.ui.windows.last().map(|w| (w.document, w.position)), Some((1, None))); +} + +#[test] +fn float_in_window_defaults_to_the_active_document() { + let (mut app, ctx) = app_with(3); + app.session.set_active(1); + let r = menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({})).unwrap(); + let window = app.ui.windows.last().unwrap(); + assert_eq!(r, json!({"window": window.id})); + assert_eq!(window.document, 1); + assert!(window.floating); + assert_eq!(app.session.active_index(), Some(1)); +} + +#[test] +fn float_all_in_windows_gives_each_document_its_own_view() { + let (mut app, ctx) = app_with(3); + menu(&mut app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); + assert_eq!(app.ui.windows.iter().map(|w| w.document).collect::>(), [0, 1, 2]); + assert!(app.ui.windows.iter().all(|w| w.floating)); } #[test] @@ -778,3 +858,41 @@ fn form_keys_orders_by_order_then_catalogue() { let f = json!({"zeta": "", "alpha": ""}).as_object().unwrap().clone(); assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["alpha", "zeta"]); } + +/// #9349: Full Screen Mode shows a document. Photoshop refuses it with none open, so the start +/// screen never sits on the full screen pasteboard; closing the last document in a full screen +/// mode goes back to Standard. +#[test] +fn full_screen_modes_need_a_document() { + let (mut app, ctx) = app_with(0); + for id in ["view.screenMode.fullScreen", "view.screenMode.fullScreenWithMenuBar", "view.screenMode.cycle"] { + assert_eq!(is_enabled(&app, id), Some(false), "{id} without a document"); + } + // #9354 review: run anyway (the toolbar list, automation), they are refused and Standard stays. + for id in ["view.screenMode.fullScreen", "view.screenMode.fullScreenWithMenuBar"] { + assert!(menu(&mut app, &ctx, id, json!({})).is_err(), "{id} is refused without a document"); + assert_eq!(app.ui.view.screen_mode, "standard", "{id}"); + } + assert_eq!(is_enabled(&app, "view.screenMode.standard"), Some(true)); + let (mut with_doc, _) = app_with(1); + for id in ["view.screenMode.fullScreen", "view.screenMode.fullScreenWithMenuBar", "view.screenMode.cycle"] { + assert_eq!(is_enabled(&with_doc, id), Some(true), "{id} with a document"); + } + // The last document closed in Full Screen Mode: back to Standard. + menu(&mut with_doc, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + leave_full_screen_without_document(&mut with_doc, &ctx); + assert_eq!(with_doc.ui.view.screen_mode, "fullScreen", "a document is still open"); + with_doc.run("file.close", json!({"save": false})).unwrap(); + assert!(with_doc.session.documents().is_empty()); + leave_full_screen_without_document(&mut with_doc, &ctx); + assert_eq!(with_doc.ui.view.screen_mode, "standard"); + // A full screen mode reached anyway (automation) with no document drops back the same way. + app.ui.view.screen_mode = "fullScreenWithMenuBar".into(); + assert_eq!(is_enabled(&app, "view.screenMode.cycle"), Some(true), "Cycle can still leave it"); + // #9354 review: and Cycle itself goes straight back to Standard, not on to Full Screen. + menu(&mut app, &ctx, "view.screenMode.cycle", json!({})).unwrap(); + assert_eq!(app.ui.view.screen_mode, "standard", "Cycle without a document returns to Standard"); + app.ui.view.screen_mode = "fullScreenWithMenuBar".into(); + leave_full_screen_without_document(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); +} diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 91eb91e5d3..792c30e50e 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -200,12 +200,12 @@ fn every_theme_shows_the_type_options() { ); PhotocraftApp::setup_context(&h.ctx, theme); h.run_steps(4); - let combos: Vec = h - .query_all_by_role(egui::accesskit::Role::ComboBox) + let fields: Vec = h + .query_all_by_role(egui::accesskit::Role::TextInput) .map(|n| format!("{:?} {:?}", n.accesskit_node().label(), n.accesskit_node().value())) .collect(); let font = h.state().ui.tool_options.type_font.clone(); - assert!(combos.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; combo boxes: {combos:?}"); + assert!(fields.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; font fields: {fields:?}"); } } diff --git a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs index 1c65cf5ec9..48a15b4224 100644 --- a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs +++ b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs @@ -56,11 +56,14 @@ fn the_wheel_over_the_brush_mode_dropdown_steps_the_painting_mode() { wheel(&mut h, at, -1.0); assert_eq!(brush_mode(&h), BlendMode::Dissolve); wheel(&mut h, at, -1.0); - assert!(h.state().ui.tool_options.brush_clear, "Clear follows Dissolve, as in Photoshop"); + assert!(h.state().session.tools.brush.behind, "Behind follows Dissolve"); + wheel(&mut h, at, -1.0); + assert!(h.state().ui.tool_options.brush_clear, "Clear follows Behind"); + assert!(!h.state().session.tools.brush.behind); wheel(&mut h, at, -1.0); assert!(!h.state().ui.tool_options.brush_clear); assert_eq!(brush_mode(&h), BlendMode::Darken); - wheel(&mut h, at, 3.0); + wheel(&mut h, at, 4.0); assert_eq!(brush_mode(&h), BlendMode::Normal, "up steps back and stops at the first mode"); } diff --git a/crates/ui-egui/src/os_window.rs b/crates/ui-egui/src/os_window.rs new file mode 100644 index 0000000000..4fa88f65e3 --- /dev/null +++ b/crates/ui-egui/src/os_window.rs @@ -0,0 +1,19 @@ +//! Extra OS windows (document windows, Camera Raw, the screen color picker) share the main +//! window's app id, so docks show the PhotoCraft icon on them and Wayland compositors' window +//! rules can match them (Hyprland's `class:`, #6697). Without one their app id is empty. + +/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon. +pub const APP_ID: &str = "ai.storyteller.photocraft"; + +/// A builder for an extra OS window, carrying [`APP_ID`]. +pub fn builder() -> egui::ViewportBuilder { + egui::ViewportBuilder::default().with_app_id(APP_ID) +} + +#[cfg(test)] +mod tests { + #[test] + fn extra_windows_carry_the_app_id() { + assert_eq!(super::builder().app_id.as_deref(), Some("ai.storyteller.photocraft")); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2fcf3fee80..9f4ea4e0a9 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -25,6 +25,7 @@ pub mod channels_panel; pub mod chrome_ui; pub mod clip_line_ui; mod color; +pub mod color_hud; pub mod color_panel_ui; pub mod comps_ui; pub mod doc_props_ui; @@ -32,7 +33,7 @@ mod dock; pub mod histogram_panel; mod history; pub mod history_menu_ui; -mod info; +pub(crate) mod info; mod layer_drag; pub mod layer_filter_ui; pub mod layer_menu_ui; @@ -57,7 +58,7 @@ pub mod smart_ui; mod status_bar; pub mod swatches_ui; pub(super) mod timeline_ui; -mod title_bar; +pub(crate) mod title_bar; pub(super) mod titlebar; mod toolbar; pub mod type_panels_ui; @@ -87,7 +88,7 @@ pub(crate) use layer_drag::{dropped_on, layer_drag_edge_scroll}; pub(crate) use layer_row::thumb_load_badge; #[cfg(test)] use layer_row::{layer_thumb_fit, paint_clipping_indicator}; -pub(crate) use layers::blend_options; +pub(crate) use layers::{blend_options, fx_button}; #[cfg(test)] use layers::{layers, opacity_row_layout, simple_lock_toggle}; #[cfg(test)] @@ -109,7 +110,9 @@ pub use status_bar::{status_bar, status_bar_heights}; use title_bar::title_x; pub use title_bar::{sync_window_title, system_title_bar, title_bar, window_title}; #[cfg(test)] -use toolbar::{STUDIO_CHIP, TOOL_SECTIONS, chips_height, color_chips, tool_flyout_drag_open, tool_flyout_release, toolbar_icon_size, visible_sections}; +use toolbar::{ + STUDIO_CHIP, TOOL_SECTIONS, chips_height, color_chips, next_slot_tool, tool_flyout_drag_open, tool_flyout_release, toolbar_icon_size, visible_sections, +}; pub use toolbar::{paint_toolbar_flyout_indicator, toolbar}; fn c32(c: [f32; 4]) -> Color32 { @@ -144,7 +147,7 @@ mod title_tests { #[test] fn without_the_preference_the_mark_stays() { - // The default (e.g. the web build or the snapshot example): no custom bar, no preference. + // The default (e.g. the web build or snapshot example): the integrated title bar. let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); assert!(!system_title_bar(&app)); } @@ -1952,6 +1955,45 @@ mod sampler_readout_tests { } } +#[cfg(test)] +mod brush_chip_tests { + use super::brush_controls::brush_preset_chip; + + /// The options-bar chip's tip texture for `brush`, row-major alpha (32 × 32). + fn chip_tip(brush: &photocraft_engine::BrushSettings) -> Vec { + let ctx = egui::Context::default(); + let mut state = crate::state::UiState::default(); + let mut out = ctx.run_ui(Default::default(), |ui| brush_preset_chip(ui, brush, &mut state)); + // `Painter::image` is a textured mesh. + let id = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Mesh(m) if (m.calc_bounds().width() - 14.0).abs() < 0.01 => Some(m.texture_id), + _ => None, + }); + let image = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.last()).map(|d| match &d.image { + egui::ImageData::Color(c) => c.clone(), + }); + out.textures_delta.clear(); + let image = image.expect("the chip draws its tip as an uploaded texture"); + assert_eq!(image.size, [32, 32]); + image.pixels.iter().map(|p| p.a()).collect() + } + + /// The chip shows the brush's own tip: a soft brush fades from its centre as its dabs do, a + /// hard one is solid to its rim, and a flattened tip is flat, not a round blob. + #[test] + fn the_brush_chip_draws_the_brushs_own_tip() { + let at = |a: &[u8], x: usize, y: usize| a[y * 32 + x]; + let hard = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, ..Default::default() }); + let soft = chip_tip(&photocraft_engine::BrushSettings { hardness: 0.0, ..Default::default() }); + assert!(at(&hard, 16, 16) == 255 && at(&hard, 16 + 11, 16) == 255, "hard: solid to near the rim"); + assert!(at(&soft, 16, 16) > 230, "soft: full at the centre"); + assert!(at(&soft, 16 + 11, 16) < 80, "soft: faint near the rim: {}", at(&soft, 16 + 11, 16)); + assert!(at(&soft, 16 + 6, 16) > at(&soft, 16 + 11, 16), "and fading outwards"); + let flat = chip_tip(&photocraft_engine::BrushSettings { hardness: 1.0, roundness: 0.3, ..Default::default() }); + assert!(at(&flat, 16 + 11, 16) > 200 && at(&flat, 16, 16 + 11) == 0, "a flattened tip is flat"); + } +} + #[cfg(test)] mod panel_state_migration_tests { use super::{migrate_panel_state, panel_state_id}; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 092ada6a43..f2c32e341a 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use photocraft_engine::actions_cmds::{self, Action}; use crate::PhotocraftApp; +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; use crate::icons; use crate::theme::Tokens; @@ -27,6 +28,10 @@ pub struct ActionsUi { /// Photoshop's Button Mode: each action is a coloured button, and a click plays it. #[serde(default)] pub button_mode: bool, + /// The set whose folder row was clicked last (or New Set made), while no action is selected: + /// New Action records into it, as Photoshop's does into the selected set (#7535). + #[serde(skip)] + pub selected_set: Option, #[serde(skip)] reveal_recording: Option<(usize, usize)>, /// The action whose name is being edited in place (double-click on its name). @@ -34,6 +39,16 @@ pub struct ActionsUi { renaming: Option, } +impl ActionsUi { + /// Select an action (and one of its steps): it replaces a selected set, so a later deselect + /// (Delete, a removed row) doesn't bring back a set the user moved away from. + fn select(&mut self, action: usize, step: Option) { + self.selected = Some(action); + self.selected_step = step; + self.selected_set = None; + } +} + #[derive(Clone, Debug, PartialEq)] struct RenameAction { index: usize, @@ -51,11 +66,87 @@ pub(crate) fn label_of(id: &str) -> String { .unwrap_or_else(|| id.to_string()) } -/// The selected action, or the first one when nothing is selected. +/// The selected action, else the first action of the selected set (Batch and Create Droplet start +/// on the panel's selection), else the first one. pub fn selected_action(app: &PhotocraftApp) -> Option<&Action> { let list = &app.session.actions.list; let idx = app.ui.actions.selected.filter(|i| *i < list.len()); - idx.and_then(|i| list.get(i)).or_else(|| list.first()) + idx.and_then(|i| list.get(i)).or_else(|| selected_set_actions(app).first().and_then(|i| list.get(*i))).or_else(|| list.first()) +} + +/// The selected set folder, if no action is selected and the set still exists. +fn selected_set(app: &PhotocraftApp) -> Option<&str> { + if app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()) { + return None; + } + let set = app.ui.actions.selected_set.as_deref()?; + photocraft_engine::actions_edit_cmds::set_names(&app.session).iter().any(|n| n == set).then_some(set) +} + +/// The actions of the selected set, in list order (empty when no set is selected, or it is empty). +fn selected_set_actions(app: &PhotocraftApp) -> Vec { + let Some(set) = selected_set(app) else { return Vec::new() }; + app.session.actions.list.iter().enumerate().filter(|(_, a)| a.set.as_deref() == Some(set)).map(|(i, _)| i).collect() +} + +/// What Play plays: the selected action; with a set folder selected, every action of that set in +/// order, as Photoshop plays a selected set (nothing for an empty set); else the first action. +fn playback_targets(app: &PhotocraftApp) -> Vec { + if selected_set(app).is_some() { + return selected_set_actions(app); + } + playback_selection(app).into_iter().collect() +} + +/// Play `targets` one after another, stopping at the first that fails or can't start. One that +/// pauses for a step dialog of its own hands the rest to that dialog, which plays them after its OK +/// (Cancel stops them all, as it stops one action). A pause inside an action it calls resumes only +/// the called action, so the set stops there rather than run its next action on an unfinished one. Returns the last result with `ran` counting the steps +/// of all of them plus `before` (steps the same Play ran before a pause), or `None` when there was nothing to play. +fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Result, String> { + let (mut last, mut ran) = (None, before); + for (n, &i) in targets.iter().enumerate() { + let name = app.session.actions.list.get(i).map(|a| a.name.clone()); + let mut v = app.run("actions.play", json!({"action": i}))?; + ran = ran.saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); + let failed = v.get("failed").is_some_and(Value::is_object); + let paused = v.get("paused").filter(|p| p.is_object()).and_then(waiting_dialog).cloned(); + if let Some(o) = v.as_object_mut() { + o.insert("ran".into(), json!(ran)); + } + last = Some(v); + if let Some(dialog) = paused.filter(|_| !failed) { + // As they are now, not by index: the list can be reordered while the dialog is open + // (`actions.move`), and one deleted and made again under its name isn't the same action. + let list = &app.session.actions.list; + let rest: Vec = targets.get(n + 1..).unwrap_or_default().iter().filter_map(|i| list.get(*i).cloned()).collect(); + let token = paused_token(app, &dialog); + if let Some(entry) = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)) + && name.as_ref() == Some(&entry.action) + { + entry.then = rest; + entry.ran = ran; + } + break; + } + if failed { + break; + } + } + Ok(last) +} + +/// The dialog a paused playback waits at: a step dialog by id, or Check Spelling. +fn waiting_dialog(paused: &Value) -> Option<&Value> { + paused.get("dialog").filter(|d| d.is_u64() || d.as_str() == Some(crate::type_panels_ui::SPELLING)) +} + +/// The token of the paused playback waiting at `dialog` (see [`waiting_dialog`]). +fn paused_token(app: &mut PhotocraftApp, dialog: &Value) -> Option { + match dialog.as_u64() { + Some(id) => app.ui.dialog_mut(id).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64), + None => app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), + } } /// Index of the playback target. Photoshop's Play button uses the first action when @@ -105,6 +196,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) let (action, from) = match actions_cmds::playback_plan(&app.session, params) { Ok(plan) => plan, Err(e) => { + actions_cmds::finish_pending_history(&mut app.session); restore_recording(app, suspended.as_ref()); return Err(e.to_string()); } @@ -112,6 +204,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) let recording = match actions_cmds::begin_playback(&mut app.session, &action) { Ok(r) => r, Err(e) => { + actions_cmds::finish_pending_history(&mut app.session); restore_recording(app, suspended.as_ref()); return Err(e.to_string()); } @@ -130,6 +223,14 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", actions_cmds::MAX_PLAYBACK_STEPS)})); break; } + let resolved = match actions_cmds::playback_params(&app.session, id, p) { + Ok(p) => p, + Err(e) => { + failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); + break; + } + }; + let p = &resolved; // The gate judges the step before its dialog opens: a denied step neither runs nor // pauses. if let Some(auth) = app.session.authorize @@ -142,21 +243,58 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) // from the next step when it is confirmed (`resume`). Agents play headlessly. if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) { let label = label_of(id); + let active_layer = app.session.active().and_then(|st| st.active_layer); let dialog = crate::dialogs::open_command_dialog(app, id, &label); let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let mut receiver = None; if let Some(d) = app.ui.dialog_mut(dialog) { if let Some(o) = p.as_object() { // `__` keys are the dialog's own markers (`params_of` drops them), not step values. d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); } d.fields.insert(RESUME.into(), json!(token)); + // Kept for every implicit step, also one opened on a document target: the dialog + // may switch it to a layer-owned one, and `run_step_dialog` checks the receiver + // for the target it confirms. + if p.get("layer").is_none_or(Value::is_null) { + receiver = Some(active_layer); + } + } + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.filter_receiver = receiver; } paused = Some(json!({"step": i, "id": id, "dialog": dialog})); break; } + // Check Spelling's dialog opens with Check All Layers as recorded; Done continues the action. + // While the dialog is open (for another action or by hand), this step fails and the open + // one keeps its place and ignored words. + // A correction (`action` set) opens no dialog and runs below. + if action.dialogs.contains(&i) && !app.automation_input && id == "edit.checkSpelling" && p.get("action").is_none() { + if let Some(open) = app.ui.type_panels.spell.as_ref() { + let error = if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }; + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + if let Err(error) = crate::type_panels_ui::open_spelling(app, p.get("allLayers").and_then(Value::as_bool).unwrap_or(true)) { + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + if let Some(entry) = app.paused_playbacks.paused.get_mut(&token) { + entry.doc = app.session.active().map(|d| d.doc.id); + } + if let Some(d) = app.ui.type_panels.spell.as_mut() { + d.resume = Some(token); + } + paused = Some(json!({"step": i, "id": id, "dialog": crate::type_panels_ui::SPELLING})); + break; + } let target = app.session.active().map(|d| d.doc.id); let result = if id == "actions.play" { play(app, p) + } else if actions_cmds::saves_smart_contents(&app.session, id) { + app.session.execute(actions_cmds::SAVE_CONTENTS, json!({})).map_err(|e| e.to_string()) } else if actions_cmds::shell_save_command(id) { // Write synchronously, so the next step doesn't meet a document locked by a // background save. @@ -167,6 +305,9 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } else if actions_cmds::shell_view_command(id) { app.sync_views(); app.run(id, p.clone()) + } else if id == "edit.paste" && p.get("as").is_some() { + // An explicit vector paste needs the original OS source, not just an old pixel copy. + app.run(id, p.clone()) } else { app.session.execute(id, p.clone()).map_err(|e| e.to_string()) }; @@ -189,18 +330,29 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } } } + let history = if failed.is_none() && paused.is_some() { actions_cmds::suspend_history(&mut app.session) } else { None }; actions_cmds::finish_playback(&mut app.session, None, &action, from); - match paused.as_ref().and_then(|p| p.get("dialog")).and_then(Value::as_u64).and_then(|d| app.ui.dialog_mut(d)) { + match paused.as_ref().and_then(waiting_dialog).cloned() { // Paused for a dialog: the call goes on after it, so recording stays suspended. - Some(d) if failed.is_none() => { - let entry = d.fields.get(RESUME).and_then(Value::as_u64).and_then(|t| app.paused_playbacks.paused.get_mut(&t)); - match (owner, entry) { - (Some(owner), Some(p)) => p.record = Some(owner), + Some(dialog) if failed.is_none() => { + let token = paused_token(app, &dialog); + let entry = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)); + match entry { + Some(p) => { + p.record = owner; + p.history = history; + } // No continuation to carry the recording: the call ends here. - (owner, _) => restore_recording(app, owner.as_ref()), + _ => { + drop(history); + restore_recording(app, owner.as_ref()); + } } } - _ => restore_recording(app, owner.as_ref()), + _ => { + drop(history); + restore_recording(app, owner.as_ref()); + } } Ok(match (failed, paused) { (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), @@ -243,19 +395,35 @@ pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Resu Ok(Value::Null) } +/// The set New Action records into: the selected action's, else the selected set folder's. +fn new_action_set(app: &PhotocraftApp) -> Option { + match app.ui.actions.selected.and_then(|i| app.session.actions.list.get(i)) { + Some(action) => action.set.clone(), + None => app.ui.actions.selected_set.clone().filter(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)), + } +} + fn begin_recording(app: &mut PhotocraftApp, append: bool) { if app.session.actions.recording.is_some() { return; } - let params = if append { app.ui.actions.selected.map_or(json!({}), |i| json!({"action": i})) } else { json!({}) }; + // Recording appends to the selected action; with none (a set folder selected, say) it makes a + // new action like New Action does, in the selected set. + let params = match app.ui.actions.selected { + Some(i) if append => json!({"action": i}), + _ => new_action_set(app).map_or(json!({}), |set| json!({"set": set})), + }; let Ok(v) = app.run("actions.record", params) else { return }; let Some(i) = v.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()) else { return }; - app.ui.actions.selected = Some(i); - app.ui.actions.selected_step = None; + app.ui.actions.select(i, None); if app.ui.actions.expanded.len() <= i { app.ui.actions.expanded.resize(i + 1, false); } app.ui.actions.expanded[i] = true; + // Its set too, in case its folder is collapsed. + if let Some(set) = app.session.actions.list.get(i).and_then(|a| a.set.as_ref()) { + app.ui.actions.collapsed_sets.retain(|c| c != set); + } } pub(crate) fn report_play(app: &mut PhotocraftApp, v: &Value) { @@ -362,7 +530,12 @@ fn source(name: &str, bytes: &[u8]) -> Value { /// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…). pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { - let r = app.run("actions.load", source(name, bytes))?; + load_source(app, name, source(name, bytes)) +} + +/// Import one set and reveal its first action, for both native paths and browser contents. +fn load_source(app: &mut PhotocraftApp, name: &str, params: Value) -> Result { + let r = app.run("actions.load", params)?; let n = r["actions"].as_array().map_or(0, Vec::len) as u64; let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); let shown = crate::file_open::display_name(name); @@ -386,8 +559,7 @@ pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result Result Result { - app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes)) + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: Some(vec!["atn".into()]) }, |app, answer| { + let files: Vec<_> = match answer { + FileDialogAnswer::Paths(paths) => paths.into_iter().map(|name| (name, None)).collect(), + FileDialogAnswer::Contents(name, bytes) => vec![(name, Some(bytes))], + FileDialogAnswer::ContentsMany(files) => files.into_iter().map(|(name, bytes)| (name, Some(bytes))).collect(), + _ => return Err("the file dialog gave an unexpected answer".into()), + }; + let count = files.len(); + if count == 0 { + return Err(crate::file_dialog::CANCELLED.into()); + } + let mut loaded = 0usize; + // What the panel holds afterwards, per set name: `actions.load` replaces a set of the same + // name, so a later file's set takes the earlier one's place (and its counts). + let mut sets: Vec<(String, String, usize, usize)> = Vec::new(); // set, file, actions, unsupported + let mut replaced = Vec::new(); + let mut errors = Vec::new(); + let mut last = Value::Null; + for (name, bytes) in files { + // Native paths go through the engine's size check before reading the file. + let params = match bytes { + Some(bytes) => source(&name, &bytes), + None => json!({"path": name}), + }; + match load_source(app, &name, params) { + Ok(r) => { + loaded += 1; + let shown = crate::file_open::display_name(&name); + let set = r["set"].as_str().unwrap_or_default().to_string(); + let n = r["actions"].as_array().map_or(0, Vec::len); + let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); + if let Some(entry) = sets.iter_mut().find(|e| e.0 == set) { + replaced.push(crate::i18n::fmt( + tl!("{set} from {file} replaced the one from {earlier}"), + &[("file", &shown), ("earlier", &entry.1), ("set", &set)], + )); + *entry = (set, shown, n, unsupported); + } else { + sets.push((set, shown, n, unsupported)); + } + last = r; + } + Err(e) => errors.push(format!("{}: {e}", crate::file_open::display_name(&name))), + } + } + if count == 1 { + return match errors.pop() { + Some(e) => Err(e), + None => Ok(last), + }; + } + let actions = sets.iter().fold(0usize, |sum, e| sum.saturating_add(e.2)); + let unsupported = sets.iter().fold(0usize, |sum, e| sum.saturating_add(e.3)); + let lang = crate::i18n::current(); + let files = crate::i18n::trn(lang, loaded as u64, "{n} action file loaded", "{n} action files loaded"); + let mut notes = vec![crate::i18n::trn(lang, actions as u64, "{n} action", "{n} actions")]; + if unsupported > 0 { + notes.push(crate::i18n::trn(lang, unsupported as u64, "{n} step has no PhotoCraft equivalent yet", "{n} steps have no PhotoCraft equivalent yet")); + } + notes.extend(replaced); + if !errors.is_empty() { + let failed = crate::i18n::trn(lang, errors.len() as u64, "{n} file could not be loaded: {errors}", "{n} files could not be loaded: {errors}"); + notes.push(crate::i18n::fmt(&failed, &[("errors", &errors.join("; "))])); + } + let template = "{message} ({note})"; + let summary = crate::i18n::fmt(tl!(template), &[("message", &files), ("note", ¬es.join("; "))]); + if !errors.is_empty() { + return Err(summary); + } + app.ui.status = summary; + app.ui.status_error = false; + Ok(json!({"files": loaded, "actions": actions, "unsupported": unsupported})) + }) } -/// Save Actions…: the selected action's set (every action of it), or every action when none -/// came from a set, as a `.atn` file Photoshop loads. +/// Save Actions…: the selected action's set (every action of it), else the selected set folder, +/// or every action when none came from a set, as a `.atn` file Photoshop loads. fn save_dialog(app: &mut PhotocraftApp, set: Option) -> Result { - let set = set.or_else(|| selected_action(app).and_then(|a| a.set.clone())); + let selected_set = app.ui.actions.selected.is_none().then(|| app.ui.actions.selected_set.clone()).flatten(); + let selected_set = selected_set.filter(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)); + let set = set.or(selected_set).or_else(|| selected_action(app).and_then(|a| a.set.clone())); let params = match &set { Some(set) => json!({"set": set, "skipUnsupported": true}), None => json!({"skipUnsupported": true}), @@ -413,7 +661,22 @@ fn save_dialog(app: &mut PhotocraftApp, set: Option) -> Result' | '|') { '_' } else { c }) + .collect(); + // A file name holds at most 255 bytes: the stem gets what `.atn` leaves, cut at a character. + let mut stem = photocraft_engine::file_cmds::portable_device_name(stem); + let mut cut = 255 - ".atn".len(); + while !stem.is_char_boundary(cut.min(stem.len())) { + cut -= 1; + } + stem.truncate(cut); + let file = format!("{stem}.atn"); app.pick_save(&file, move |app, path| { let write = app.services.write.as_mut().ok_or("no writer configured")?; write(&path, &bytes)?; @@ -439,6 +702,40 @@ fn save_dialog(app: &mut PhotocraftApp, set: Option) -> Result) -> bool { + fields.contains_key(NEW_SET) +} + +/// New Set…: name an empty set folder (Photoshop's default "Set N"). +fn new_set_dialog(app: &mut PhotocraftApp) -> Result { + let names = photocraft_engine::actions_edit_cmds::set_names(&app.session); + let name = (1u32..).map(|n| format!("Set {n}")).find(|n| !names.contains(n)).unwrap_or_default(); + let fields = json!({NEW_SET: true, "__command": "actions.newSet", "__label": "New Set", "name": name}); + let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Ok(json!({"dialog": id})) +} + +pub fn new_set_body(ui: &mut egui::Ui, fields: &mut serde_json::Map) { + ui.horizontal(|ui| { + let label = ui.label(tl!("Name:")); + let mut name = fields.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(220.0)).labelled_by(label.id); + fields.insert("name".into(), json!(name)); + }); +} + +/// Make the set and select it, so New Action records into it. +pub fn new_set_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result { + let r = app.run("actions.newSet", json!({"name": fields.get("name").cloned().unwrap_or(Value::Null)}))?; + app.ui.actions.selected_set = r["set"].as_str().map(str::to_string); + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + Ok(r) +} + pub fn owns_insert_stop(fields: &serde_json::Map) -> bool { fields.contains_key(INSERT_STOP) } @@ -475,8 +772,7 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map Result<(), Strin /// A step dialog's command failed: the action ends there. pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let record = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t)).and_then(|p| p.record); + let paused = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t)); + let record = paused.and_then(|p| { + drop(p.history); + p.record + }); + actions_cmds::finish_pending_history(&mut app.session); restore_recording(app, record.as_ref()); } /// The automation gate a session installs ([`photocraft_engine::Session::authorize`]). type Gate = fn(&str, &Value) -> photocraft_engine::Result<()>; +/// A function-pointer dispatch gate cannot carry two different policies into nested jobs. +/// Changed gates and distinct simultaneous Session/service gates both end this continuation; +/// hosts that need both policies must supply one composed gate at both boundaries. +fn continuation_gate(captured: Option, live: Option) -> Result, String> { + if captured.zip(live).is_some_and(|(before, now)| !std::ptr::fn_addr_eq(before, now)) { + return Err("the authorization context changed or uses policies that cannot be combined for this paused action".into()); + } + Ok(live.or(captured)) +} + /// A playback paused at a step dialog: the action and step it continues from, and the gate it /// was playing under (`None` for one a person started). #[derive(Clone, Debug)] @@ -549,6 +860,20 @@ pub(crate) struct PausedPlayback { /// box, automation), which end the call through `resume`, `abandoned` or `stopped`, so the /// recording comes back exactly once. record: Option, + history: Option, + /// The actions still to play after this one, as they were when Play started a whole set. + /// Actions have no id, so the whole action stands in for one: an action deleted and made again + /// under the same name, or edited, while the dialog is open no longer matches. + then: Vec, + /// The steps that Play ran before this pause (earlier actions of the set, and this one's + /// steps before the dialog), so the final count covers the whole Play. + ran: u64, + /// The document a pause at Check Spelling was playing on. That dialog doesn't block the + /// document tabs, so Done ([`resume_spelling`]) continues only on this document. + doc: Option, + /// An implicit filter/adjustment step resolves this active layer at the pause. The outer Option marks + /// that binding even when there was no active layer; explicit-layer and other dialogs omit it. + filter_receiver: Option>, } /// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the @@ -560,17 +885,123 @@ pub(crate) struct PausedPlaybacks { } impl PausedPlaybacks { + /// Allocate an opaque token retaining the action, resume position and captured authorization gate. fn pause(&mut self, action: String, from: usize, gate: Option) -> u64 { self.last = self.last.wrapping_add(1); - self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None }); + self.paused + .insert(self.last, PausedPlayback { action, from, gate, record: None, history: None, then: Vec::new(), ran: 0, doc: None, filter_receiver: None }); self.last } + /// Consume the continuation named by a numeric dialog token; malformed or expired tokens identify no playback. fn take(&mut self, token: &Value) -> Option { token.as_u64().and_then(|token| self.paused.remove(&token)) } } +/// Whether a dialog command's `target` belongs to the document rather than to a layer: alpha +/// channels and Quick Mask do; masks and colour components still belong to a layer. +fn document_target(params: &Value) -> bool { + params.get("target").is_some_and(|target| target.as_str() == Some("quickMask") || target.get("channel").and_then(Value::as_u64).is_some()) +} + +/// Admit the final dialog command before resuming history or starting a worker. The captured +/// policy judges the edited parameters; a current live gate still governs dispatch and nested +/// work. With no live gate, reinstall the captured gate for job admission and rendering. +pub(crate) fn run_step_dialog(app: &mut PhotocraftApp, fields: &serde_json::Map, command: &str, params: Value) -> Result { + let Some(token) = fields.get(RESUME) else { return app.run(command, params) }; + let token = token.as_u64().ok_or("this dialog doesn't belong to a paused action")?; + let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?; + if let Some(receiver) = paused.filter_receiver + && !document_target(¶ms) + && app.session.active().and_then(|st| st.active_layer) != receiver + { + return Err("the paused command's receiver changed while its dialog was open".into()); + } + let captured = paused.gate; + let saved = app.session.authorize; + let service = if app.automation_input { app.services.automation_authorize } else { None }; + for authorize in [captured, saved, service].into_iter().flatten() { + authorize(command, ¶ms).map_err(|e| e.to_string())?; + } + if app.automation_input + && let Some(authorize) = app.services.automation_command.as_ref() + { + authorize(command, ¶ms)?; + } + let gate = continuation_gate(captured, continuation_gate(saved, service)?)?; + prepare_history(app, fields)?; + app.session.authorize = gate; + let result = app.run(command, params); + app.session.authorize = saved; + result +} + +/// Bind the dialog's own edit to its suspended action before it runs or starts a worker. +pub(crate) fn prepare_history(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { + let Some(token) = fields.get(RESUME).and_then(Value::as_u64) else { return Ok(()) }; + let history = app.paused_playbacks.paused.get_mut(&token).ok_or("this dialog doesn't belong to a paused action")?.history.take(); + if let Some(history) = history + && let Err(e) = history.resume(&mut app.session) + { + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.history = Some(history); + } + return Err(e.to_string()); + } + Ok(()) +} + +/// Check Spelling owns each correction only while it runs; the open window owns a bound token. +pub(crate) fn spelling_change(app: &mut PhotocraftApp, params: Value) -> Result { + let Some(token) = app.ui.type_panels.spell.as_ref().and_then(|dialog| dialog.resume) else { + return app.run("edit.checkSpelling", params); + }; + let paused = app.paused_playbacks.paused.get(&token).ok_or("this dialog doesn't belong to a paused action")?; + if paused.doc.is_some_and(|doc| Some(doc) != app.session.active().map(|state| state.doc.id)) { + return Err("the active document changed while Check Spelling was open".into()); + } + let history = paused.history.clone(); + let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]); + let result = run_step_dialog(app, &fields, "edit.checkSpelling", params); + // A rejected admission did not resume this continuation. Keep the original token intact. + if app.paused_playbacks.paused.get(&token).is_some_and(|paused| paused.history.is_some()) { + return result; + } + if let Some(history) = history { + match history.suspend_after_edit(&mut app.session) { + Ok(history) => { + let paused = app.paused_playbacks.paused.get_mut(&token).ok_or("the spelling continuation ended during its correction")?; + paused.history = Some(history); + } + Err(error) => { + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.history = Some(history); + } + return Err(error.to_string()); + } + } + } + result +} + +/// Check Spelling's Done for the action paused at its step: the action continues when the document +/// it was playing on is still the active one, and otherwise ends there with the reason in the +/// status bar. Returns `Ok` when nothing failed. +pub(crate) fn resume_spelling(app: &mut PhotocraftApp, token: u64) -> Result<(), String> { + let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]); + let Some((from, doc)) = app.paused_playbacks.paused.get(&token).map(|p| (p.from, p.doc)) else { + return resume(app, &fields); + }; + if doc.is_none() || doc == app.session.active().map(|d| d.doc.id) { + return resume(app, &fields); + } + abandoned(app, &fields); + let error = "the active document changed while Check Spelling was open"; + report_play(app, &json!({"failed": {"step": from.saturating_sub(1), "id": "edit.checkSpelling", "error": error}})); + Err(error.into()) +} + /// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this). /// /// The remaining steps run under the gate the playback paused with, however the dialog was @@ -581,10 +1012,8 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) return Err("this dialog doesn't belong to a paused action".into()); }; let owner = paused.record.clone(); - // The dialog's command may run as a background job; the next step needs its result. - if let Some(doc) = app.session.active().map(|d| d.doc.id) - && let Some(job) = app.session.job_on(doc) - && let Err(e) = app.session.wait_job(job.id) + if let Some(history) = &paused.history + && let Err(e) = history.resume(&mut app.session) { restore_recording(app, owner.as_ref()); let template = "Action playback failed: {error}"; @@ -592,10 +1021,78 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) app.ui.status_error = true; return Err(e.to_string()); } - let params = json!({"action": paused.action, "from": paused.from}); + // The dialog's command may run as a background job; the next step needs its result. + if let Some(doc) = app.session.active().map(|d| d.doc.id) + && let Some(job) = app.session.job_on(doc) + && let Err(e) = app.session.wait_job(job.id) + { + actions_cmds::finish_pending_history(&mut app.session); + restore_recording(app, owner.as_ref()); + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + app.ui.status_error = true; + return Err(e.to_string()); + } let saved = app.session.authorize; - app.session.authorize = paused.gate.or(saved); - let played = play_owned(app, ¶ms, owner); + let service = if app.automation_input { app.services.automation_authorize } else { None }; + let remaining = !paused.then.is_empty() + || app + .session + .actions + .list + .iter() + .find(|action| action.name == paused.action) + .is_some_and(|action| action.enabled_steps().any(|(i, _)| i >= paused.from)); + let gate = + if remaining { continuation_gate(saved, service).and_then(|live| continuation_gate(paused.gate, live)) } else { Ok(saved.or(service).or(paused.gate)) }; + let gate = match gate { + Ok(gate) => gate, + Err(error) => { + // The admitted dialog edit has completed; preserve it while rejecting the tail. + actions_cmds::finish_pending_history(&mut app.session); + restore_recording(app, owner.as_ref()); + app.ui.status = error.clone(); + app.ui.status_error = true; + return Err(error); + } + }; + let params = json!({"action": paused.action, "from": paused.from}); + app.session.authorize = gate; + let mut played = play_owned(app, ¶ms, owner); + // Paused again at a later dialog of the same action (not of one it calls, which resumes + // alone): that dialog carries the rest of the set. + if let Ok(v) = &played + && let Some(dialog) = v.get("paused").and_then(waiting_dialog) + && let Some(token) = paused_token(app, dialog) + && let Some(entry) = app.paused_playbacks.paused.get_mut(&token) + && entry.action == paused.action + { + entry.then = paused.then.clone(); + entry.ran = paused.ran.saturating_add(1).saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); + } + // The count covers the steps this Play ran before the pause, and the step the dialog ran. + let ran = paused.ran.saturating_add(1).saturating_add(played.as_ref().ok().and_then(|v| v.get("ran")).and_then(Value::as_u64).unwrap_or(0)); + if let Ok(Some(o)) = played.as_mut().map(Value::as_object_mut) + && o.contains_key("ran") + { + o.insert("ran".into(), json!(ran)); + } + // The rest of a set Play started: under the same gate, and only if this action finished. An + // action of it deleted, renamed, edited or replaced by a new one of the same name meanwhile + // ends the set there. + if let Ok(v) = &played + && !paused.then.is_empty() + && v.get("failed").is_none() + && v.get("paused").is_none() + { + let list = &app.session.actions.list; + let rest: Vec = paused.then.iter().map_while(|then| list.iter().position(|a| a == then)).collect(); + match play_targets(app, &rest, ran) { + Ok(Some(r)) => played = Ok(r), + Ok(None) => {} + Err(e) => played = Err(e), + } + } app.session.authorize = saved; match played { Ok(v) => { @@ -630,6 +1127,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled(!recording, egui::Button::new(tl!("New Action…"))).clicked() { act = Some("new"); } + if ui.button(tl!("New Set…")).clicked() { + act = Some("newSet"); + } if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { act = Some("play"); } @@ -651,7 +1151,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); } - if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { + // A selected set folder saves even when empty, as Photoshop saves an empty set. + let set_selected = app.ui.actions.selected_set.as_ref().is_some_and(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)); + if ui.add_enabled(any || set_selected, egui::Button::new(tl!("Save Actions…"))).clicked() { act = Some("save"); } let Some(a) = act else { return }; @@ -666,16 +1168,21 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Ok(Value::Null) } "stopRec" => app.run("actions.stop", json!({})), - "play" => playback_selection(app).map_or(Ok(Value::Null), |i| { - let v = app.run("actions.play", json!({"action": i}))?; - report_play(app, &v); - Ok(v) - }), + "play" => { + let targets = playback_targets(app); + play_targets(app, &targets, 0).map(|v| { + if let Some(v) = &v { + report_play(app, v); + } + v.unwrap_or(Value::Null) + }) + } "delete" => { delete_selection(app); Ok(Value::Null) } "load" => load_dialog(app), + "newSet" => new_set_dialog(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), }; @@ -719,16 +1226,14 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { return; } if source.step.is_some() { - app.ui.actions.selected = Some(source.action); - app.ui.actions.selected_step = Some(to); + app.ui.actions.select(source.action, Some(to)); } else { app.ui.actions.expanded.resize(app.session.actions.list.len(), false); if source.action < app.ui.actions.expanded.len() && to < app.ui.actions.expanded.len() { let expanded = app.ui.actions.expanded.remove(source.action); app.ui.actions.expanded.insert(to, expanded); } - app.ui.actions.selected = Some(to); - app.ui.actions.selected_step = None; + app.ui.actions.select(to, None); // Dropped among another set's actions: the action joins that set (the row above wins). let list = &app.session.actions.list; let neighbour = to.checked_sub(1).and_then(|j| list.get(j)).or_else(|| list.get(to + 1)).and_then(|a| a.set.clone()); @@ -823,14 +1328,19 @@ fn display_order(rows: &[Row]) -> Vec { } struct SetHeaderResult { + /// A click on the disclosure triangle (or an activation without a pointer): collapse or expand. toggled: bool, + /// A click on the rest of the row (or an activation without a pointer): select the set. + picked: bool, save: bool, } -fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, tokens: &Tokens) -> SetHeaderResult { +fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, selected: bool, tokens: &Tokens) -> SetHeaderResult { let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !collapsed, set)); - if response.hovered() { + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, set)); + if selected { + ui.painter().rect_filled(rect, 0.0, tokens.row_selected); + } else if response.hovered() { ui.painter().rect_filled(rect, 0.0, tokens.hover.gamma_multiply(0.5)); } let triangle = Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(8.0, 8.0)); @@ -850,7 +1360,12 @@ fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, to ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, set, egui::FontId::proportional(12.0), tokens.text); let summary = crate::i18n::trn(crate::i18n::current(), count, "{n} action", "{n} actions"); ui.painter().text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, summary, egui::FontId::proportional(11.0), tokens.text_faint); - let toggled = response.clicked(); + // As on an action row, a click in the triangle's column collapses or expands and one on the + // rest of the row selects. An activation without a pointer (keyboard, accessibility) does + // both, so the set can still be collapsed and expanded without a mouse. + let pointer = response.interact_pointer_pos(); + let toggled = response.clicked() && pointer.is_none_or(|p| p.x < rect.left() + 20.0); + let picked = response.clicked() && pointer.is_none_or(|p| p.x >= rect.left() + 20.0); let mut save = false; response.context_menu(|ui| { if ui.button(tl!("Save Set…")).clicked() { @@ -858,7 +1373,26 @@ fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, to ui.close(); } }); - SetHeaderResult { toggled, save } + SetHeaderResult { toggled, picked, save } +} + +/// The sets New Set made that hold no action yet: empty folder rows after the rest. Returns the +/// set whose row was clicked (whether it toggles, whether it selects) and the set whose Save Set… +/// was chosen. +fn draw_empty_sets(app: &PhotocraftApp, ui: &mut egui::Ui, rows: &[Row], t: &Tokens) -> (Option<(String, bool, bool)>, Option) { + let (mut clicked, mut save) = (None, None); + for set in app.session.actions.sets.iter().filter(|set| !rows.iter().any(|r| r.set.as_ref() == Some(*set))) { + let selected = app.ui.actions.selected.is_none() && app.ui.actions.selected_set.as_ref() == Some(set); + let collapsed = app.ui.actions.collapsed_sets.contains(set); + let result = draw_set_header(ui, set, 0, collapsed, selected, t); + if result.toggled || result.picked { + clicked = Some((set.clone(), result.toggled, result.picked)); + } + if result.save { + save = Some(set.clone()); + } + } + (clicked, save) } fn action_context_menu(app: &mut PhotocraftApp, response: &egui::Response, index: usize, name: &str, swatch: &str) { @@ -939,10 +1473,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut renamed = None; let mut toggled = None; let mut dialog_toggled = None; - let mut set_toggled: Option = None; + let mut set_toggled: Option<(String, bool, bool)> = None; let mut save_set: Option = None; let max_h = (ui.available_height() - 38.0).max(0.0); - let mut play_idx = None; + let mut play_idx: Option> = None; let mut moved = None; let recording_row = recording.and_then(|(_, i)| rows.get(i).map(|r| (i, r.steps.len()))); let reveal = recording_row.filter(|row| app.ui.actions.reveal_recording != Some(*row)); @@ -968,9 +1502,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { shown_set = Some(set); let collapsed = app.ui.actions.collapsed_sets.iter().any(|c| c == set); let count = rows.iter().filter(|r| r.set.as_deref() == Some(set)).count() as u64; - let result = draw_set_header(ui, set, count, collapsed, &t); - if result.toggled { - set_toggled = Some(set.to_string()); + let selected = app.ui.actions.selected.is_none() && app.ui.actions.selected_set.as_deref() == Some(set); + let result = draw_set_header(ui, set, count, collapsed, selected, &t); + if result.toggled || result.picked { + set_toggled = Some((set.to_string(), result.toggled, result.picked)); } if result.save { save_set = Some(set.to_string()); @@ -1040,11 +1575,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().circle_filled(pos2(rect.right() - 64.0, rect.center().y), 4.0, Color32::from_rgb(230, 60, 60)); } if resp.clicked() { - app.ui.actions.selected = Some(i); - app.ui.actions.selected_step = None; + app.ui.actions.select(i, None); if button_mode { if !resp.double_clicked() { - play_idx = Some(i); + play_idx = Some(vec![i]); } } else if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) { if app.ui.actions.expanded.len() <= i { @@ -1061,7 +1595,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if resp.interact_pointer_pos().is_some_and(|p| name_rect.expand2(vec2(4.0, 13.0)).contains(p)) { app.ui.actions.renaming = Some(RenameAction { index: i, text: name.clone(), focused: false }); } else { - play_idx = Some(i); + play_idx = Some(vec![i]); } } if expanded { @@ -1072,6 +1606,9 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { dialog_toggled = dialog_toggled.or(events.dialog_toggled); } } + let (empty_toggled, empty_save) = draw_empty_sets(app, ui, &rows, &t); + set_toggled = set_toggled.take().or(empty_toggled); + save_set = save_set.take().or(empty_save); }); if let Some((action, step)) = toggled { let _ = app.run("actions.toggle", json!({"action": action, "step": step})); @@ -1079,12 +1616,21 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some((action, step)) = dialog_toggled { let _ = app.run("actions.dialog", json!({"action": action, "step": step})); } - if let Some(set) = set_toggled { - match app.ui.actions.collapsed_sets.iter().position(|c| *c == set) { - Some(i) => { - app.ui.actions.collapsed_sets.remove(i); + if let Some((set, toggle, pick)) = set_toggled { + // The rest of the folder row selects the set, for New Action and Play. + if pick { + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + app.ui.actions.selected_set = Some(set.clone()); + } + // The disclosure triangle collapses or expands the set and leaves the selection alone. + if toggle { + match app.ui.actions.collapsed_sets.iter().position(|c| *c == set) { + Some(i) => { + app.ui.actions.collapsed_sets.remove(i); + } + None => app.ui.actions.collapsed_sets.push(set), } - None => app.ui.actions.collapsed_sets.push(set), } } if let Some(set) = save_set @@ -1118,7 +1664,13 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let play = crate::icons::button(ui, "play", 24.0, false, tl!("Play selection")); play.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Play selection"))); if play.clicked() { - play_idx = playback_selection(app); + play_idx = Some(playback_targets(app)); + } + if crate::icons::button(ui, "folder", 24.0, false, tl!("Create new set")).clicked() + && let Err(e) = new_set_dialog(app) + { + app.ui.status = e; + app.ui.status_error = true; } if crate::icons::button(ui, "plus", 24.0, false, tl!("Create new action")).clicked() && !recording_now { begin_recording(app, false); @@ -1138,9 +1690,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.actions.button_mode = !app.ui.actions.button_mode; } }); - if let Some(i) = play_idx { - match app.run("actions.play", json!({"action": i})) { - Ok(v) => report_play(app, &v), + if let Some(targets) = play_idx { + match play_targets(app, &targets, 0) { + Ok(Some(v)) => report_play(app, &v), + Ok(None) => {} Err(e) => { let template = "Action playback failed: {error}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); @@ -1203,8 +1756,7 @@ fn draw_step_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, context: &StepRows } else if dialog.clicked() { events.dialog_toggled = Some((context.action, step)); } else if response.clicked() { - app.ui.actions.selected = Some(context.action); - app.ui.actions.selected_step = Some(step); + app.ui.actions.select(context.action, Some(step)); } } events diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 551bcf7b21..99e44ec3f9 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1,5 +1,341 @@ use super::*; +/// A small valid Photoshop set, generated without external action files. +fn action_set_bytes(set: &str, action: &str) -> Vec { + use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; + use photocraft_psd::descriptor::UnicodeString; + photocraft_psd::atn::write(&AtnFile { + name: UnicodeString::new_nul(set), + actions: vec![AtnAction { + name: UnicodeString::new_nul(action), + items: vec![AtnItem { event: AtnEvent::Code(*b"Invr"), dictionary: "Invert".into(), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// Dialog confirmation, worker completion and cancellation retain the original action boundary. +#[test] +fn single_history_dialog_confirmation_and_cancel() { + for (single, confirm, background) in [(true, true, false), (true, true, true), (true, false, false), (false, true, false)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap(); + app.run("actions.setHistory", json!({"singleState":single})).unwrap(); + app.background_jobs = background; + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); + let mut action = Action::new( + "Dialog", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("filter.blur.gaussianBlur".into(), json!({"radius":1})), + ("layer.renameLayer".into(), json!({"name":"Suffix"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + let paused = play(&mut app, &json!({"action":"Dialog"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), count + 1); + if confirm { + crate::dialogs::confirm(&mut app, dialog).unwrap(); + } else { + crate::dialogs::cancel(&mut app, dialog).unwrap(); + } + let st = app.session.active().unwrap(); + let after = st.doc.clone(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, if confirm { "Suffix" } else { "Prefix" }); + if single { + assert_eq!(st.history.past_len(), count + 1); + assert_eq!(st.history.undo_label(), Some("Action: Dialog")); + } else { + assert!(st.history.past_len() >= count + 2); + } + assert!(app.session.undo()); + if single { + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } else { + assert_ne!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + assert!(app.session.redo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + let count = app.session.active().unwrap().history.past_len(); + app.session.execute("layer.renameLayer", json!({"name":"Outside"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), count + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +/// A stale dialog must reject its own edit before it can change an unrelated document/history. +#[test] +fn single_history_dialog_refuses_intervening_edit_or_document_switch() { + for (single_state, switch) in [(true, false), (true, true), (false, false), (false, true)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":4,"height":4,"background":"#804020"})).unwrap(); + app.run("actions.setHistory", json!({"singleState":single_state})).unwrap(); + let mut action = + Action::new("Bound", vec![("layer.renameLayer".into(), json!({"name":"Prefix"})), ("filter.blur.gaussianBlur".into(), json!({"radius":1}))]); + action.dialogs.insert(1); + app.session.actions.list.push(action); + let paused = play(&mut app, &json!({"action":0})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + if switch { + app.session.execute("file.new", json!({"width":2,"height":2})).unwrap(); + } else { + app.session.execute("layer.renameLayer", json!({"name":"Unrelated"})).unwrap(); + } + let before = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.entries(); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err()); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before)); + assert_eq!(app.session.active().unwrap().history.entries(), history); + assert!(app.filter_preview.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } +} + +/// Prepare a real paused spelling step with a known document and history boundary, under either grouping policy. +fn paused_history_spelling(single: bool, gate: Option) -> (PhotocraftApp, std::sync::Arc, usize) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":300,"height":100})).unwrap(); + app.run("type.create", json!({"x":10,"y":50,"text":"Teh brwon"})).unwrap(); + app.run("actions.setHistory", json!({"singleState":single})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let past = app.session.active().unwrap().history.past_len(); + let mut action = Action::new( + "Spell", + vec![ + ("layer.renameLayer".into(), json!({"name":"Prefix"})), + ("edit.checkSpelling".into(), json!({"allLayers":false})), + ("layer.renameLayer".into(), json!({"name":"Tail"})), + ], + ); + action.dialogs.insert(1); + app.session.actions.list.push(action); + app.session.authorize = gate; + let paused = play(&mut app, &json!({"action":"Spell"})).unwrap(); + assert_eq!(paused["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{paused}"); + (app, before, past) +} + +#[test] +/// Repeated corrections share one owned history entry; a rejected correction cannot lose earlier admitted edits. +fn paused_spelling_repeated_changes_and_errors_keep_one_owned_history() { + for single in [false, true] { + let (mut app, before, past) = paused_history_spelling(single, None); + let paused_doc = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.entries(); + assert!(spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"wrong","replace":"The"})).is_err()); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &paused_doc)); + assert_eq!(app.session.active().unwrap().history.entries(), rows); + for params in + [json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"}), json!({"action":"changeAll","word":"brwon","replace":"brown"})] + { + spelling_change(&mut app, params).unwrap(); + let token = app.ui.type_panels.spell.as_ref().unwrap().resume.unwrap(); + assert!(app.paused_playbacks.paused[&token].history.is_some()); + assert_eq!(app.session.actions.playing, 0); + } + let st = app.session.active().unwrap(); + let texts: Vec<_> = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, layer)| match &layer.content { + photocraft_doc::LayerContent::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["The brown"]); + let before_tail = st.doc.clone(); + crate::type_panels_ui::close_spelling(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(app.paused_playbacks.paused.is_empty()); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), past + if single { 1 } else { 4 }); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "Tail"); + if single { + assert_eq!(st.history.undo_label(), Some("Action: Spell")); + } + let after = st.doc.clone(); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), if single { before.as_ref() } else { before_tail.as_ref() }); + assert!(app.session.redo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + app.run("layer.renameLayer", json!({"name":"Outside"})).unwrap(); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), after.as_ref()); + } +} + +fn deny_second_spelling_change(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "edit.checkSpelling" && params["word"] == "brwon" { Err(photocraft_engine::EngineError::Other("original spelling gate".into())) } else { Ok(()) } +} + +#[test] +/// Reject a spelling continuation after document/history drift or captured-policy denial before any correction. +fn paused_spelling_rejects_external_edit_switch_and_original_gate_before_change() { + for single in [false, true] { + for scenario in ["edit", "switch", "gate"] { + let gate = (scenario == "gate").then_some(deny_second_spelling_change as Gate); + let (mut app, _, _) = paused_history_spelling(single, gate); + spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); + let corrected = app.session.active().unwrap().doc.clone(); + if scenario == "edit" { + let past = app.session.active().unwrap().history.past_len(); + app.run("layer.renameLayer", json!({"name":"External"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1, "idle edits have their own history"); + } else if scenario == "switch" { + app.run("file.new", json!({"width":4,"height":4})).unwrap(); + } + // The original playback gate still applies even if the live gate has gone away. + app.session.authorize = None; + let before: Vec<_> = app.session.documents().iter().map(|st| (st.doc.clone(), st.history.entries())).collect(); + let error = spelling_change(&mut app, json!({"action":"changeAll","word":"brwon","replace":"brown"})).unwrap_err(); + if scenario == "gate" { + assert!(error.contains("original spelling gate"), "{error}"); + } + for (state, (doc, rows)) in app.session.documents().iter().zip(&before) { + assert!(std::sync::Arc::ptr_eq(&state.doc, doc), "{scenario}"); + assert_eq!(&state.history.entries(), rows); + } + crate::type_panels_ui::close_spelling(&mut app); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + if scenario == "gate" { + assert_eq!(app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().name, "Tail"); + } else { + assert!(!app.session.documents().iter().any(|st| st.doc.walk().iter().any(|(_, _, layer)| layer.name == "Tail"))); + assert!(app.ui.status_error, "{}", app.ui.status); + if scenario == "edit" { + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref()); + } else { + assert!(app.session.documents().iter().any(|st| std::sync::Arc::ptr_eq(&st.doc, &corrected))); + } + } + } + } +} + +#[test] +/// Browser multi-selection must not be truncated to the first file. +fn bulk_load_imports_every_chosen_action_file() { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + let files = vec![("first.atn".into(), action_set_bytes("First set", "First")), ("second.atn".into(), action_set_bytes("Second set", "Second"))]; + let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::ContentsMany(files))]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["First", "Second"]); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { multiple: true, extensions: Some(ext), .. }] if ext == &["atn"])); + assert!(!app.file_dialog_open()); + assert!(!app.ui.status_error, "{}", app.ui.status); +} + +#[test] +/// A corrupt middle file cannot hide warnings or prevent a later set replacement. +fn bulk_load_keeps_selection_order_and_earlier_warnings_and_errors() { + use crate::file_dialog::FileDialogAnswer; + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("z-first.atn"); + let broken = dir.path().join("broken.atn"); + let last = dir.path().join("a-last.atn"); + let mut unsupported = photocraft_psd::atn::parse(&action_set_bytes("Same set", "Old")).unwrap(); + unsupported.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + std::fs::write(&first, photocraft_psd::atn::write(&unsupported).unwrap()).unwrap(); + std::fs::write(&broken, b"not an action file").unwrap(); + std::fs::write(&last, action_set_bytes("Same set", "Replacement")).unwrap(); + let paths = [first, broken, last].map(|p| p.to_string_lossy().into_owned()).to_vec(); + let mut app = disk_app(vec![Some(FileDialogAnswer::Paths(paths))]); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Replacement"]); + assert_eq!(app.ui.actions.selected, Some(0)); + assert!(app.ui.status_error, "a later success must not hide an earlier failure"); + // The replaced set's unsupported step is gone, so it is not counted. + for part in ["2 action files loaded", "1 action;", "Same set from a-last.atn replaced the one from z-first.atn", "1 file could not be loaded", "broken.atn"] + { + assert!(app.ui.status.contains(part), "{part}: {}", app.ui.status); + } + assert!(!app.ui.status.contains("PhotoCraft equivalent"), "{}", app.ui.status); +} + +/// Load `files` (name, bytes) in one Load Actions… selection and return the status bar text. +fn bulk_load_status(app: &mut PhotocraftApp, files: Vec<(String, Vec)>) -> String { + use crate::file_dialog::FileDialogAnswer; + let (show, _) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::ContentsMany(files))]); + app.services.file_dialog = Some(show); + load_dialog(app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + app.ui.status.clone() +} + +#[test] +/// Two files whose sets share a name: the later replaces the earlier, and the summary counts only +/// what the panel holds afterwards and says which set was replaced. +fn bulk_load_summary_counts_a_replaced_set_once() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut a = photocraft_psd::atn::parse(&action_set_bytes("Set 1", "A")).unwrap(); + a.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + let files = vec![("a.atn".into(), photocraft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set 1", "B"))]; + let status = bulk_load_status(&mut app, files); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["B"]); + assert!(!app.ui.status_error, "{status}"); + assert_eq!(status, "2 action files loaded (1 action; Set 1 from b.atn replaced the one from a.atn)"); +} + +#[test] +/// Distinct set names: every set counts, nothing is reported as replaced, and the file count reads +/// right for one loaded file as well as several. +fn bulk_load_summary_counts_distinct_sets_and_pluralises_files() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let files = vec![("a.atn".into(), action_set_bytes("Set A", "A")), ("b.atn".into(), action_set_bytes("Set B", "B"))]; + assert_eq!(bulk_load_status(&mut app, files), "2 action files loaded (2 actions)"); + let files = vec![("c.atn".into(), action_set_bytes("Set C", "C")), ("bad.atn".into(), b"not an action file".to_vec())]; + let status = bulk_load_status(&mut app, files); + assert!(app.ui.status_error); + assert!(status.starts_with("1 action file loaded (1 action; 1 file could not be loaded: bad.atn: "), "{status}"); +} + +#[test] +/// The multi-file summary is translated like the rest of the Actions panel. +fn bulk_load_summary_is_translated() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let files = vec![("a.atn".into(), action_set_bytes("Set 1", "A")), ("b.atn".into(), action_set_bytes("Set 1", "B"))]; + let status = crate::i18n::with_language(crate::i18n::Lang::from_code("de").unwrap(), || bulk_load_status(&mut app, files)); + assert_eq!(status, "2 Aktionsdateien geladen (1 Aktion; Satz Set 1 aus b.atn hat den aus a.atn ersetzt)"); +} + +#[test] +/// Dismissing the picker is inert; one selected file keeps its existing status message. +fn bulk_load_keeps_cancel_empty_and_single_file_behavior() { + use crate::file_dialog::FileDialogAnswer; + for answer in [None, Some(FileDialogAnswer::Paths(Vec::new())), Some(FileDialogAnswer::ContentsMany(Vec::new()))] { + let mut app = disk_app(vec![answer]); + app.session.actions.list.push(Action::new("Existing", vec![])); + app.ui.actions.selected = Some(0); + app.ui.status = "Unchanged".into(); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list[0].name, "Existing"); + assert_eq!(app.ui.actions.selected, Some(0)); + assert_eq!(app.ui.status, "Unchanged"); + } + let mut app = disk_app(vec![Some(FileDialogAnswer::Contents("single.atn".into(), action_set_bytes("Single", "One")))]); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list[0].name, "One"); + assert!(app.ui.status.contains("single.atn")); + assert!(!app.ui.status_error); +} + #[test] fn panel_menu_lists_load_save_and_existing_action_commands() { use egui_kittest::{Harness, kittest::Queryable}; @@ -105,6 +441,23 @@ fn a_function_key_with_shift_and_command_plays_its_action() { assert_eq!(layers(&app), before + 1, "Cmd+Shift+F2 plays it"); } +#[test] +fn shell_playback_resolves_recorded_eye_target_on_new_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.run("layer.new.layer", json!({"name": "Target"})).unwrap(); + app.run("actions.record", json!({"name": "Hide"})).unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.setProps", json!({"layer": id.0, "visible": false})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.run("layer.new.layer", json!({"name": "Target"})).unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let result = play(&mut app, &json!({"action": "Hide"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(!app.session.active().unwrap().doc.layer(id).unwrap().visible); +} + #[test] fn double_clicking_an_action_name_renames_it_in_place() { use egui_kittest::{Harness, kittest::Queryable}; @@ -537,15 +890,15 @@ fn recorded_save_never_flattens_layers_over_a_flat_file() { assert!(!b_changed && !a_changed); } -/// Loads a Photoshop set holding Invert + a Save As into `dest` and plays it on a PSD. -/// Returns the played result, whether the PSD changed on disk and the files in `out`. -fn play_imported_save_as(dest: &str) -> (Value, bool, Vec) { +/// Loads a Photoshop set holding Invert + a Save As into `dest` (in `out`; with `in_folder`, +/// Photoshop's "save in directory", `out` itself) and plays it on each PSD in `sources`. +/// Returns the played results, whether any PSD changed on disk and the files in `out`. +fn play_imported_save_as(dest: &str, in_folder: bool, sources: &[&str]) -> (Vec, bool, Vec) { use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; - let dir = std::env::temp_dir().join(format!("photocraft-atn-save-as-{}-{}", dest.len(), std::process::id())); + let dir = std::env::temp_dir().join(format!("photocraft-atn-save-as-{dest}-{in_folder}-{}", std::process::id())); let out = dir.join("out"); std::fs::create_dir_all(&out).unwrap(); - let src = dir.join("src.psd").to_string_lossy().into_owned(); let path = |text: &str| { let units: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); let mut b = b"txtu".to_vec(); @@ -554,10 +907,13 @@ fn play_imported_save_as(dest: &str) -> (Value, bool, Vec) { units.iter().for_each(|u| b.extend_from_slice(&u.to_le_bytes())); D::Path(b) }; - let save = Descriptor::new("null") - .with("As ", D::Descriptor(Descriptor::new("PNGF"))) - .with("In ", path(&out.join(dest).to_string_lossy())) - .with("DocI", D::Integer(53)); + let mut save = Descriptor::new("null").with("As ", D::Descriptor(Descriptor::new("PNGF"))); + save = if in_folder { + save.with("In ", path(&out.to_string_lossy())).with("DIDr", D::Boolean(true)) + } else { + save.with("In ", path(&out.join(dest).to_string_lossy())) + }; + let save = save.with("DocI", D::Integer(53)); let item = |code: &[u8; 4], label: &str, d: Option| AtnItem { event: AtnEvent::Code(*code), dictionary: label.into(), @@ -574,37 +930,99 @@ fn play_imported_save_as(dest: &str) -> (Value, bool, Vec) { ..Default::default() }; let mut app = disk_app(vec![]); - app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); - app.save_as(Some(src.clone())).unwrap(); - let before = std::fs::read(&src).unwrap(); + let sources: Vec = sources.iter().map(|n| dir.join(format!("{n}.psd")).to_string_lossy().into_owned()).collect(); + let mut before = Vec::new(); + for src in &sources { + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.save_as(Some(src.clone())).unwrap(); + before.push(std::fs::read(src).unwrap()); + } app.run("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&photocraft_psd::atn::write(&set).unwrap())})).unwrap(); - app.open_path(&src).unwrap(); - let played = app.run("actions.play", json!({"action": 0})).unwrap(); + let mut played = Vec::new(); + for src in &sources { + app.open_path(src).unwrap(); + played.push(app.run("actions.play", json!({"action": 0})).unwrap()); + } let mut written: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); written.sort(); - let r = (played, std::fs::read(&src).unwrap() != before, written); + let changed = sources.iter().zip(&before).any(|(src, before)| std::fs::read(src).unwrap() != *before); let _ = std::fs::remove_dir_all(&dir); - r + (played, changed, written) } #[test] fn imported_save_as_writes_the_recorded_file_not_the_played_one() { // #7102: the Save As imported as File › Save, which wrote over src.psd. - let (played, src_changed, written) = play_imported_save_as("result.png"); - assert!(played.get("failed").is_none(), "{played}"); + let (played, src_changed, written) = play_imported_save_as("result.png", false, &["src"]); + assert!(played[0].get("failed").is_none(), "{}", played[0]); assert!(!src_changed, "the Save As wrote over the file the action was played on"); assert_eq!(written, ["result.png"]); } #[test] -fn imported_save_as_into_a_folder_stops_before_writing() { - // A folder destination isn't mapped yet (#5430): playback stops on it, nothing is saved. - let (played, src_changed, written) = play_imported_save_as("folder"); - assert!(played.get("failed").is_some(), "{played}"); +fn imported_save_as_to_a_file_without_an_extension_stops_before_writing() { + // A destination that isn't a file of the recorded format stays a kept step (#7102): + // playback stops on it, nothing is saved. + let (played, src_changed, written) = play_imported_save_as("folder", false, &["src"]); + assert!(played[0].get("failed").is_some(), "{}", played[0]); assert!(!src_changed); assert!(written.is_empty(), "{written:?}"); } +#[test] +fn imported_save_as_into_a_folder_saves_each_document_under_its_own_name() { + // #5305: Photoshop's "save in directory" Save As, played on two documents, writes one file + // for each, named after it, in the recorded folder, and leaves the played files alone. + let (played, src_changed, written) = play_imported_save_as("", true, &["first", "second"]); + for p in &played { + assert!(p.get("failed").is_none(), "{p}"); + } + assert!(!src_changed); + assert_eq!(written, ["first.png", "second.png"]); +} + +/// #5305: a Save As recorded with the document's own name (the one Save As suggests) keeps +/// only the folder, so playing the action saves each document under its own name instead of +/// over the recorded file; a name typed in is recorded as it is. +#[test] +fn recorded_save_as_with_the_suggested_name_saves_each_document_under_its_own() { + let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let log = written.clone(); + let services = crate::Services { + 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()))), + write: Some(Box::new(move |path, _| { + log.borrow_mut().push(path.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.background_jobs = false; + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 16, "height": 16, "name": "first"})).unwrap(); + app.run("actions.record", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "out/first.psd"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "out/renamed.psd"})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + let steps = &app.session.actions.list[0].steps; + assert_eq!(steps, &[("file.saveAs".into(), json!({"folder": "out", "extension": "psd"})), ("file.saveAs".into(), json!({"path": "out/renamed.psd"}))]); + // Played on two other documents: each under its own name in the recorded folder. + app.session.actions.list[0].steps.truncate(1); + written.borrow_mut().clear(); + let mut expected = Vec::new(); + for name in ["second", "third"] { + app.run("file.new", json!({"width": 16, "height": 16, "name": name})).unwrap(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let own = std::path::Path::new("out").join(format!("{name}.psd")).to_string_lossy().into_owned(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(own.as_str())); + expected.push(own); + } + assert_eq!(*written.borrow(), expected); + // A folder step needs its extension. + assert!(app.replay_save("file.saveAs", &json!({"folder": "out"})).is_err()); +} + #[test] fn set_folders_group_actions_and_collapse() { use egui_kittest::{Harness, kittest::Queryable}; @@ -626,18 +1044,664 @@ fn set_folders_group_actions_and_collapse() { assert!(h.get_by_label("Sharpen").rect().top() > folder.top()); assert!(h.get_by_label("Soften").rect().top() > h.get_by_label("Sharpen").rect().top()); assert_eq!(display_order(&[]), Vec::::new()); - // Clicking the folder collapses it; its actions disappear and the state is remembered. - h.get_by_label("Retouch").click(); - h.run_steps(3); + // Clicking the folder's triangle collapses it; its actions disappear and the state is remembered. + click_set_triangle(&mut h, "Retouch"); assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]); assert!(h.query_by_label("Sharpen").is_none()); assert!(h.query_by_label("Loose").is_some()); - h.get_by_label("Retouch").click(); - h.run_steps(3); + click_set_triangle(&mut h, "Retouch"); assert!(h.state().ui.actions.collapsed_sets.is_empty()); assert!(h.query_by_label("Sharpen").is_some()); } +/// Two sets, Web listed first: "Resize" (Web) adds a layer "w", "Sharpen" and "Soften" (Retouch) +/// add "r1" and "r2". +fn two_set_panel() -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for (name, set, layer) in [("Resize", "Web", "w"), ("Sharpen", "Retouch", "r1"), ("Soften", "Retouch", "r2")] { + let mut a = Action::new(name, vec![("layer.new.layer".into(), json!({"name": layer}))]); + a.set = Some(set.into()); + app.session.actions.list.push(a); + } + let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + h +} + +fn layer_names(app: &PhotocraftApp) -> Vec { + app.session.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).filter(|n| n != "Background").collect() +} + +/// Click a set folder row's disclosure triangle (a plain `click()` lands on the row's middle, +/// which selects the set). +fn click_set_triangle(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, set: &str) { + use egui_kittest::kittest::Queryable; + let pos = h.get_by_label(set).rect().left_center() + vec2(12.0, 0.0); + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); +} + +/// #10459 fix-up: a click on a set folder row (not its triangle) selects the set, and Play +/// then plays every action of that set in order, as Photoshop plays a selected set, not the first +/// action of the whole list. +#[test] +fn play_with_a_set_folder_selected_plays_that_sets_actions() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + h.get_by_label("Resize").click(); + h.run_steps(2); + h.get_by_label("Retouch").click(); + h.run_steps(2); + assert!(h.state().ui.actions.collapsed_sets.is_empty(), "a click on the row selects, it doesn't collapse"); + h.get_by_label("Play selection").click(); + h.run_steps(2); + let mut names = layer_names(h.state()); + names.sort(); + assert_eq!(names, ["r1", "r2"], "the Retouch set played, not Resize"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + // Batch and Create Droplet start on the selected set too. + assert_eq!(selected_action(h.state()).map(|a| a.name.as_str()), Some("Sharpen")); +} + +/// #10459 fix-up: Play with an empty set selected plays nothing. +#[test] +fn play_with_an_empty_set_selected_plays_nothing() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + h.state_mut().run("actions.newSet", json!({"name": "Print"})).unwrap(); + h.run_steps(2); + h.get_by_label("Print").click(); + h.run_steps(2); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Print")); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert!(layer_names(h.state()).is_empty(), "{:?}", layer_names(h.state())); +} + +/// CodeRabbit on batch 11: with an empty set selected, Batch and Create Droplet start on the first +/// action, as `selected_action` documents. +#[test] +fn an_empty_selected_set_falls_back_to_the_first_action() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + h.state_mut().run("actions.newSet", json!({"name": "Print"})).unwrap(); + h.run_steps(2); + h.get_by_label("Print").click(); + h.run_steps(2); + assert_eq!(selected_action(h.state()).map(|a| a.name.as_str()), Some("Resize")); +} + +/// #10459 fix-up: the common case is unchanged: a selected action plays alone, also after a set +/// folder was selected before it, and a later Delete doesn't bring that set back. +#[test] +fn play_with_an_action_selected_plays_only_that_action() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + h.get_by_label("Retouch").click(); + h.run_steps(2); + h.get_by_label("Retouch").click(); + h.run_steps(2); + h.get_by_label("Sharpen").click(); + h.run_steps(2); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["r1"]); + assert_eq!(selected_action(h.state()).map(|a| a.name.as_str()), Some("Sharpen")); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, None)); +} + +/// #10459 fix-up: the disclosure triangle collapses and expands a set and leaves the selection +/// alone, so Play still plays the selected action, not the set that was looked into. +#[test] +fn collapsing_a_set_keeps_the_selected_action_for_play() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + h.get_by_label("Resize").click(); + h.run_steps(2); + click_set_triangle(&mut h, "Retouch"); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]); + assert!(h.query_by_label("Sharpen").is_none()); + click_set_triangle(&mut h, "Retouch"); + assert!(h.state().ui.actions.collapsed_sets.is_empty()); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (Some(0), None)); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["w"]); +} + +/// #10459 fix-up: selecting a step (or any other way an action becomes the selection) drops a +/// selected set, so deleting that step doesn't bring the set back as Play's target. +#[test] +fn a_selected_step_replaces_the_selected_set() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + h.state_mut().session.actions.list[0].steps.push(("select.all".into(), json!({}))); + h.state_mut().ui.actions.expanded = vec![true, false, false]; + h.run_steps(2); + h.get_by_label("Retouch").click(); + h.run_steps(2); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Retouch")); + h.get_by_label(&label_of("select.all")).click(); + h.run_steps(2); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_step), (Some(0), Some(1))); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert_eq!(h.state().session.actions.list[0].steps.len(), 1, "the step went"); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, None)); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["w"], "the first action, not the Retouch set"); +} + +/// #10459 fix-up: a set whose first action pauses at step dialogs plays the rest of the set +/// after OK, and Cancel stops the whole set, as it stops one action. +#[test] +fn a_set_paused_at_a_step_dialog_plays_its_other_actions_after_ok() { + use egui_kittest::kittest::Queryable; + for ok in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut a = Action::new( + "A", + vec![ + ("layer.new.layer".into(), json!({"name": "a"})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 1})), + ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), + ("layer.new.layer".into(), json!({"name": "a2"})), + ], + ); + a.set = Some("S".into()); + a.dialogs.extend([1, 2]); + let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "b"}))]); + b.set = Some("S".into()); + app.session.actions.list.extend([a, b]); + let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + h.get_by_label("S").click(); + h.run_steps(2); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["a"]); + assert!(h.state().ui.status.contains("dialog"), "{}", h.state().ui.status); + let first = h.state().ui.dialogs.last().map(|d| d.id).expect("the first blur's dialog"); + if !ok { + crate::dialogs::cancel(h.state_mut(), first).unwrap(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["a"], "Cancel stops the set"); + continue; + } + crate::dialogs::confirm(h.state_mut(), first).unwrap(); + h.run_steps(2); + // The second dialog of A pauses again; the rest of the set waits behind it. + let second = h.state().ui.dialogs.last().map(|d| d.id).expect("the second blur's dialog"); + assert_ne!(first, second); + crate::dialogs::confirm(h.state_mut(), second).unwrap(); + h.run_steps(2); + let mut names = layer_names(h.state()); + names.sort(); + assert_eq!(names, ["a", "a2", "b"], "B played after A's dialogs"); + // CodeRabbit on batch 11: the count covers the whole Play, also the steps before each pause. + assert_eq!(h.state().ui.status, "Played 5 steps"); + } +} + +/// CodeRabbit on batch 11: the rest of a paused set is kept by name, so an action moved into the +/// middle of the list while the dialog is open neither plays in its place nor skips one of the set. +#[test] +fn a_set_paused_at_a_dialog_plays_its_own_actions_after_a_move() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for (name, set, layer) in [("A", "S", "a"), ("B", "S", "b"), ("C", "S", "c"), ("X", "Other", "x")] { + let mut a = Action::new(name, vec![("layer.new.layer".into(), json!({"name": layer}))]); + a.set = Some(set.into()); + app.session.actions.list.push(a); + } + app.session.actions.list[0].steps.push(("filter.blur.gaussianBlur".into(), json!({"radius": 1}))); + app.session.actions.list[0].dialogs.insert(1); + let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + h.get_by_label("S").click(); + h.run_steps(2); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["a"]); + let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("the blur's dialog"); + h.state_mut().run("actions.move", json!({"action": "X", "to": 1})).unwrap(); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + h.run_steps(2); + let mut names = layer_names(h.state()); + names.sort(); + assert_eq!(names, ["a", "b", "c"]); +} + +/// CodeRabbit on batch 11: an action of the paused set deleted and made again under its name while +/// the dialog is open is a different action, so the set ends there instead of playing it. +#[test] +fn a_set_paused_at_a_dialog_ends_at_an_action_replaced_under_its_name() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for (name, layer) in [("A", "a"), ("B", "b"), ("C", "c")] { + let mut a = Action::new(name, vec![("layer.new.layer".into(), json!({"name": layer}))]); + a.set = Some("S".into()); + app.session.actions.list.push(a); + } + app.session.actions.list[0].steps.push(("filter.blur.gaussianBlur".into(), json!({"radius": 1}))); + app.session.actions.list[0].dialogs.insert(1); + let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + h.get_by_label("S").click(); + h.run_steps(2); + h.get_by_label("Play selection").click(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["a"]); + let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("the blur's dialog"); + h.state_mut().run("actions.delete", json!({"action": "B"})).unwrap(); + let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "new b"}))]); + b.set = Some("S".into()); + h.state_mut().session.actions.list.insert(1, b); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["a"], "neither the new B nor C after it played"); +} + +/// #10459 fix-up: a set whose action pauses at a dialog inside an action it calls stops there: +/// the called action resumes alone, so the next action of the set must not run on a caller that +/// never finished. +#[test] +fn a_set_paused_inside_a_called_action_stops_there() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); + let mut a = Action::new("A", vec![("actions.play".into(), json!({"action": "C"})), ("layer.new.layer".into(), json!({"name": "a-tail"}))]); + a.set = Some("S".into()); + let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "b"}))]); + b.set = Some("S".into()); + let mut c = Action::new("C", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1})), ("layer.new.layer".into(), json!({"name": "c"}))]); + c.dialogs.insert(0); + app.session.actions.list.extend([a, b, c]); + let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + h.get_by_label("S").click(); + h.run_steps(2); + h.get_by_label("Play selection").click(); + h.run_steps(2); + let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("C's blur dialog"); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + h.run_steps(2); + assert_eq!(layer_names(h.state()), ["c"], "C finished; B didn't run ahead of A's tail"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), count + 1); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Action: A")); + let after = h.state().session.active().unwrap().doc.clone(); + assert!(h.state_mut().session.undo()); + assert_eq!(h.state().session.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(h.state_mut().session.redo()); + assert_eq!(h.state().session.active().unwrap().doc.as_ref(), after.as_ref()); +} + +/// #10459 fix-up: a set folder activated without a pointer (keyboard, accessibility) still +/// collapses and expands, and selects the set; the row reports whether it is selected. +#[test] +fn a_set_folder_toggles_without_a_pointer() { + use egui_kittest::kittest::Queryable; + let mut h = two_set_panel(); + use egui::accesskit::Toggled; + use egui_kittest::kittest::NodeT; + let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.get_by_label("Retouch").accesskit_node().toggled(); + assert_eq!(selected(&h), Some(Toggled::False)); + h.get_by_label("Retouch").click_accesskit(); + h.run_steps(2); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Retouch")); + assert_eq!(selected(&h), Some(Toggled::True), "selected and collapsed reads as selected"); + h.get_by_label("Retouch").click_accesskit(); + h.run_steps(2); + assert!(h.state().ui.actions.collapsed_sets.is_empty()); +} + +/// #7535: the folder button (and the panel menu's New Set…) names an empty set, shown as a folder +/// row with no actions; New Action then records into it, as into any selected set. +#[test] +fn new_set_makes_an_empty_folder_that_new_action_records_into() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut retouch = Action::new("Sharpen", vec![]); + retouch.set = Some("Retouch".into()); + app.session.actions.list.push(retouch); + let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + h.get_by_label("Create new set").click(); + h.run_steps(2); + let id = h.state().ui.dialogs.last().map(|d| d.id).expect("the New Set dialog"); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + assert!(owns_new_set(&d.fields)); + assert_eq!(d.fields["name"], "Set 1"); + d.fields.insert("name".into(), json!("Web")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + // An empty folder row, selected. + assert!(h.query_by_label("Web").is_some()); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Web")); + h.get_by_label("Create new action").click(); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let list = &h.state().session.actions.list; + assert_eq!((list[1].name.as_str(), list[1].set.as_deref()), ("Action 2", Some("Web"))); + // With an action selected, New Action records into that action's set. + h.state_mut().ui.actions.selected = Some(0); + h.run_steps(2); + h.get_by_label("Create new action").click(); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[2].set.as_deref(), Some("Retouch")); +} + +/// FredApps review on #10459: with no action and no set selected, New Action makes an action +/// outside every set, even when sets (empty ones included) exist. +#[test] +fn new_action_with_no_set_selected_is_in_no_set() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut retouch = Action::new("Sharpen", vec![]); + retouch.set = Some("Retouch".into()); + app.session.actions.list.push(retouch); + app.run("actions.newSet", json!({"name": "Web"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(2); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, None)); + h.get_by_label("Create new action").click(); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let list = &h.state().session.actions.list; + assert_eq!((list.len(), list[1].name.as_str(), list[1].set.as_deref()), (2, "Action 2", None)); +} + +/// FredApps review on #10459: closing the Actions panel and opening it again keeps the selected +/// set and the empty sets New Set made, so New Action still records into the selected one. +#[test] +fn reopening_the_actions_panel_keeps_the_selected_set_and_empty_sets() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut retouch = Action::new("Sharpen", vec![]); + retouch.set = Some("Retouch".into()); + app.session.actions.list.push(retouch); + app.run("actions.newSet", json!({"name": "Web"})).unwrap(); + app.run("actions.newSet", json!({"name": "Print"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state( + |ui, app| { + // The Actions panel is the History group's second tab. + if app.ui.panels.history && app.ui.dock_tabs.history == 1 { + panel(app, ui); + } + }, + app, + ); + let ctx = h.ctx.clone(); + let toggle = |h: &mut Harness<'_, PhotocraftApp>| { + crate::menus::invoke(h.state_mut(), &ctx, "window.panel.actions", json!({})).unwrap(); + h.run_steps(2); + }; + if !(h.state().ui.panels.history && h.state().ui.dock_tabs.history == 1) { + toggle(&mut h); + } + h.run_steps(2); + h.get_by_label("Web").click(); + h.run_steps(2); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Web")); + toggle(&mut h); + assert!(!h.state().ui.panels.history, "closed"); + assert!(h.query_by_label("Web").is_none()); + toggle(&mut h); + assert!(h.state().ui.panels.history && h.state().ui.dock_tabs.history == 1, "open again"); + assert!(h.query_by_label("Web").is_some() && h.query_by_label("Print").is_some(), "the empty sets"); + assert_eq!(h.state().session.actions.sets, ["Web", "Print"]); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, Some("Web"))); + h.get_by_label("Create new action").click(); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[1].set.as_deref(), Some("Web")); +} + +/// #7535: with a set's folder row selected and no action, Start Recording (like the Record button) +/// has no action to append to, so it records a new action into that set, as New Action does. +#[test] +fn start_recording_with_a_set_selected_records_a_new_action_into_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut retouch = Action::new("Sharpen", vec![]); + retouch.set = Some("Retouch".into()); + app.session.actions.list.push(retouch); + app.run("actions.newSet", json!({"name": "Web"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state( + |ui, app| { + panel_menu(app, ui); + panel(app, ui); + }, + app, + ); + h.run_steps(2); + h.get_by_label("Web").click(); + h.run_steps(2); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, Some("Web"))); + h.get_by_label("Start Recording").click(); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let list = &h.state().session.actions.list; + assert_eq!(list.len(), 2); + assert_eq!(list[1].set.as_deref(), Some("Web")); +} + +/// Recording into a collapsed, selected set (New Action or Start Recording) expands the set +/// again, so the new action and its steps show. +#[test] +fn recording_into_a_collapsed_set_expands_it() { + use egui_kittest::{Harness, kittest::Queryable}; + for label in ["Create new action", "Start Recording"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut retouch = Action::new("Sharpen", vec![]); + retouch.set = Some("Retouch".into()); + app.session.actions.list.push(retouch); + let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state( + |ui, app| { + panel_menu(app, ui); + panel(app, ui); + }, + app, + ); + h.run_steps(2); + click_set_triangle(&mut h, "Retouch"); + h.get_by_label("Retouch").click(); + h.run_steps(2); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"], "{label}"); + assert!(h.query_by_label("Sharpen").is_none(), "{label}"); + h.get_by_label(label).click(); + h.run_steps(2); + assert!(h.state().ui.actions.collapsed_sets.is_empty(), "{label}"); + assert_eq!(h.state().ui.actions.selected, Some(1), "{label}"); + assert!(h.query_by_label("Action 2").is_some(), "{label}"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + } +} + +/// Save Actions… after a click on set B's folder row saves set B, not the first action's set. +#[test] +fn save_actions_with_a_set_selected_saves_that_set() { + use egui_kittest::{Harness, kittest::Queryable}; + let (file_dialog, asked) = crate::file_dialog::fake(vec![None]); + let services = crate::Services { file_dialog: Some(file_dialog), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + for (name, set) in [("Sharpen", "Retouch"), ("Resize", "Web")] { + let mut action = Action::new(name, vec![("layer.new.layer".into(), json!({}))]); + action.set = Some(set.into()); + app.session.actions.list.push(action); + } + let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state( + |ui, app| { + panel_menu(app, ui); + panel(app, ui); + }, + app, + ); + h.run_steps(2); + h.get_by_label("Web").click(); + h.run_steps(2); + assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, Some("Web"))); + h.get_by_label("Save Actions…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + assert!(matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Web.atn"), "{:?}", asked.borrow()); +} + +/// FredApps review on #10459: a set name can hold characters a file name can't. Save Actions… +/// suggests the file with them replaced, and the set keeps its name in the file. The set here +/// is an empty one New Set made, which Save Actions… writes as Photoshop does. +#[test] +fn save_actions_suggests_a_file_name_without_path_characters() { + use egui_kittest::{Harness, kittest::Queryable}; + let (file_dialog, asked) = crate::file_dialog::fake(vec![None]); + let services = crate::Services { file_dialog: Some(file_dialog), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.session.actions.list.push(Action::new("Loose", vec![])); + let set = "Web/Print\\Mail: v2"; + app.run("actions.newSet", json!({"name": set})).unwrap(); + let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state( + |ui, app| { + panel_menu(app, ui); + panel(app, ui); + }, + app, + ); + h.run_steps(2); + h.get_by_label(set).click(); + h.run_steps(2); + h.get_by_label("Save Actions…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + assert!( + matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Web_Print_Mail_ v2.atn"), + "{:?}", + asked.borrow() + ); + let saved = h.state_mut().run("actions.save", json!({"set": set})).unwrap(); + assert_eq!(saved["set"], set, "the file keeps the set's name"); +} + +/// An app with no actions and one empty set New Set made, its folder row shown with the panel +/// menu, and a fake Save dialog whose requests the test reads. +fn empty_set_save_harness( + set: &str, +) -> (egui_kittest::Harness<'static, PhotocraftApp>, std::rc::Rc>>) { + let (file_dialog, asked) = crate::file_dialog::fake(vec![None]); + let services = crate::Services { file_dialog: Some(file_dialog), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.run("actions.newSet", json!({"name": set})).unwrap(); + // With no action, the panel shows its "Record ● … play ▶" hint, which the test fonts lack. + let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 600.0)).allow_missing_glyphs().build_ui_state( + |ui, app| { + panel_menu(app, ui); + panel(app, ui); + }, + app, + ); + h.run_steps(2); + (h, asked) +} + +/// CodeRabbit review on #10459: with no action at all, Save Actions… is still enabled for a +/// selected empty set (the engine saves it), and stays disabled with nothing to save. +#[test] +fn save_actions_is_enabled_for_a_selected_empty_set_with_no_actions() { + use egui_kittest::kittest::Queryable; + let (mut h, asked) = empty_set_save_harness("Later"); + h.get_by_label("Save Actions…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + assert!(asked.borrow().is_empty(), "no set selected, nothing to save: {:?}", asked.borrow()); + h.get_by_label("Later").click(); + h.run_steps(2); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Later")); + h.get_by_label("Save Actions…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + assert!( + matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Later.atn"), + "{:?}", + asked.borrow() + ); +} + +/// CodeRabbit review on #10459: Save Set… in an empty set folder's context menu saves that set, +/// as it does for a folder with actions. +#[test] +fn save_set_on_an_empty_set_folder_opens_the_save_dialog() { + use egui_kittest::kittest::Queryable; + let (mut h, asked) = empty_set_save_harness("Later"); + h.get_by_label("Later").click_secondary(); + h.run_steps(2); + h.get_by_label("Save Set…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + assert!( + matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Later.atn"), + "{:?}", + asked.borrow() + ); +} + +/// CodeRabbit review on #10459: a set named after a Windows device (`CON`, `com1.v2`) or holding a +/// control character gets a suggested file name Windows can create. +#[test] +fn save_actions_suggests_a_windows_safe_name_for_device_and_control_character_sets() { + use egui_kittest::kittest::Queryable; + for (set, suggested) in [("CON", "CON_.atn"), ("com1.v2", "com1_.v2.atn"), ("Web\u{1}Print\tMail", "Web_Print_Mail.atn"), ("Print", "Print.atn")] { + let (mut h, asked) = empty_set_save_harness(set); + h.get_by_label(set).click(); + h.run_steps(2); + h.get_by_label("Save Actions…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + assert!( + matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested: s }] if s == suggested), + "{set:?}: {:?}", + asked.borrow() + ); + } +} + +/// CodeRabbit on batch 11: a set name longer than a file name may be is cut to 255 bytes with its +/// suffix, at a character boundary. +#[test] +fn save_actions_suggests_a_file_name_of_at_most_255_bytes() { + use egui_kittest::kittest::Queryable; + let set = "é".repeat(200); + let (mut h, asked) = empty_set_save_harness(&set); + h.get_by_label(set.as_str()).click(); + h.run_steps(2); + h.get_by_label("Save Actions…").click(); + h.run_steps(2); + h.state_mut().poll_file_dialog(&egui::Context::default(), None); + let asked = asked.borrow(); + let [crate::file_dialog::FileDialogRequest::Save { suggested }] = asked.as_slice() else { panic!("{asked:?}") }; + assert_eq!(*suggested, format!("{}.atn", "é".repeat(125))); +} + #[test] fn moving_an_action_next_to_another_set_joins_it() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -723,6 +1787,161 @@ fn dialog_worker_failure_stops_action_before_following_steps() { } } +#[test] +fn a_check_spelling_step_with_its_dialog_opens_it_and_done_continues() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!((v["ran"].as_u64(), v["paused"]["step"].as_u64()), (Some(0), Some(0)), "{v}"); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING); + let d = app.ui.type_panels.spell.clone().expect("Check Spelling is open"); + assert!(!d.all_layers, "the dialog opens with Check All Layers as recorded"); + assert_eq!(d.items.len(), 1); + assert!(!layer_names(&app).contains(&"after".to_string()), "nothing after the dialog ran yet"); + crate::type_panels_ui::close_spelling(&mut app); + assert!(layer_names(&app).contains(&"after".to_string()), "Done played the rest"); + assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status); + // With its dialog off, the step checks without opening anything and changes nothing. + app.session.actions.list[0].dialogs.clear(); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["ran"], 2, "{v}"); + assert!(app.ui.type_panels.spell.is_none()); + let st = app.session.active().unwrap(); + let texts: Vec = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["Teh cat"]); +} + +/// Check Spelling opened by hand keeps its place: an action's Check Spelling step fails instead +/// of replacing it. +#[test] +fn a_check_spelling_step_fails_while_the_dialog_is_open_by_hand() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + crate::type_panels_ui::open_spelling(&mut app, true).unwrap(); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["failed"]["error"], "Check Spelling is already open", "{v}"); + let d = app.ui.type_panels.spell.as_ref().expect("the dialog stays open"); + assert!(d.all_layers && d.resume.is_none(), "the hand-opened dialog is kept"); + assert!(!layer_names(&app).contains(&"after".to_string())); +} + +/// A correction step (`action` set) runs as it did before the dialog toggle applied to Check +/// Spelling: its dialog switch opens nothing. +#[test] +fn a_check_spelling_correction_with_its_dialog_on_runs_without_opening_it() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let change = json!({"action": "change", "start": 0, "end": 3, "word": "Teh", "replace": "The"}); + let mut a = Action::new("Fix", vec![("edit.checkSpelling".into(), change), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Fix"})).unwrap(); + assert_eq!(v["ran"], 2, "{v}"); + assert!(app.ui.type_panels.spell.is_none(), "no dialog opened"); + let st = app.session.active().unwrap(); + let texts: Vec = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["The cat"]); +} + +/// Done after another document became active ends the waiting action there: its remaining steps +/// don't run on the other document. +#[test] +fn check_spelling_done_on_another_document_ends_the_waiting_action() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{v}"); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + crate::type_panels_ui::close_spelling(&mut app); + for st in app.session.documents() { + assert!(!st.doc.walk().iter().any(|(_, _, l)| l.name == "after"), "a step after the dialog ran"); + } + assert!(app.paused_playbacks.paused.is_empty()); + assert!(app.ui.status_error, "{}", app.ui.status); + assert!(app.ui.status.contains("document"), "{}", app.ui.status); +} + +fn deny_spelling(id: &str, p: &Value) -> photocraft_engine::Result<()> { + if id == "edit.checkSpelling" && p.get("action").is_none() { Err(photocraft_engine::EngineError::Other("spelling denied".into())) } else { Ok(()) } +} + +/// Done records the step and continues the waiting action: when recording it fails, the status +/// keeps that error next to the playback's outcome. +#[test] +fn check_spelling_done_keeps_a_recording_error_beside_the_playback_status() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{v}"); + app.run("actions.record", json!({"name": "Rec"})).unwrap(); + app.session.authorize = Some(deny_spelling); + crate::type_panels_ui::close_spelling(&mut app); + app.session.authorize = None; + assert!(layer_names(&app).contains(&"after".to_string()), "Done played the rest"); + assert!(app.ui.status_error, "{}", app.ui.status); + assert!(app.ui.status.contains("spelling denied"), "{}", app.ui.status); + assert!(app.ui.status.contains("Played"), "{}", app.ui.status); +} + +#[test] +fn check_spelling_opened_again_while_an_action_waits_keeps_its_continuation() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{v}"); + let token = app.ui.type_panels.spell.as_ref().and_then(|d| d.resume); + assert!(token.is_some()); + // Edit › Check Spelling… while the action waits. + crate::type_panels_ui::open_spelling(&mut app, true).unwrap(); + assert_eq!(app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), token, "the reopened dialog keeps the token"); + // A second playback that reaches a Check Spelling step with its dialog fails there. + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["failed"]["step"], 0, "{v}"); + assert_eq!(app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), token, "the waiting action keeps the dialog"); + assert!(!layer_names(&app).contains(&"after".to_string())); + crate::type_panels_ui::close_spelling(&mut app); + let after = layer_names(&app).iter().filter(|n| *n == "after").count(); + assert_eq!(after, 1, "Done continued the waiting action once"); + assert!(app.paused_playbacks.paused.is_empty()); +} + #[test] fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1027,6 +2246,465 @@ fn deny_blur(id: &str, _: &Value) -> photocraft_engine::Result<()> { if id == "filter.blur.gaussianBlur" { Err(photocraft_engine::EngineError::Other("blur denied".into())) } else { Ok(()) } } +/// Permit the recorded small blur but reject an edited radius, so admission is tested against final dialog parameters. +fn deny_wide_blur(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" && params["radius"].as_f64().is_some_and(|radius| radius > 1.0) { + Err(photocraft_engine::EngineError::Other("edited blur radius denied".into())) + } else { + Ok(()) + } +} + +/// Build a two-tone raster whose blur changes pixels, with a real action step configured to open its dialog. +fn gated_blur_fixture(single: bool, background: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16, "background": "#000000"})).unwrap(); + app.run("select.rect", json!({"x": 8, "y": 0, "width": 8, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("actions.setHistory", json!({"singleState": single})).unwrap(); + app.background_jobs = background; + let mut action = Action::new("Gated blur", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1}))]); + action.dialogs.insert(0); + app.session.actions.list.push(action); + app +} + +/// Simulate a temporary control gate: capture it at the pause and release the session gate before later confirmation. +fn pause_gated_blur(app: &mut PhotocraftApp, gate: Option) -> u64 { + app.session.authorize = gate; + let paused = play(app, &json!({"action": "Gated blur"})).unwrap(); + app.session.authorize = None; // The temporary control gate ends before a later human OK. + paused["paused"]["dialog"].as_u64().unwrap() +} + +#[test] +/// Denied edited parameters must change neither pixels nor history and must admit no background worker. +fn a_paused_dialog_authorizes_edited_params_before_history_or_worker_admission() { + for (single, background) in [(true, false), (true, true), (false, false)] { + let mut app = gated_blur_fixture(single, background); + let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur)); + app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(4)); + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + let count = app.session.active().unwrap().history.past_len(); + let error = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert!(error.contains("edited blur radius denied"), "{error}"); + let state = app.session.active().unwrap(); + assert_eq!(state.doc.as_ref(), before.as_ref(), "denial changes no pixels"); + assert_eq!(state.revision, revision); + assert_eq!(state.history.past_len(), count); + assert!(app.session.job_on(state.doc.id).is_none(), "no worker was admitted"); + assert!(app.session.authorize.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + app.run("layer.renameLayer", json!({"name": "Outside"})).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), count + 1, "no action scope leaked"); + } +} + +#[test] +/// Allowed captured edits and unrelated manual dialogs retain their normal synchronous and worker behavior. +fn an_allowed_paused_edit_and_an_ordinary_manual_dialog_still_run() { + for (paused, background) in [(true, false), (true, true), (false, false)] { + let mut app = gated_blur_fixture(true, background); + let before = app.session.active().unwrap().doc.clone(); + let dialog = if paused { + pause_gated_blur(&mut app, Some(deny_wide_blur)) + } else { + crate::dialogs::open_command_dialog(&mut app, "filter.blur.gaussianBlur", "Gaussian Blur") + }; + app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(if paused { 1 } else { 4 })); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let state = app.session.active().unwrap(); + assert_ne!(state.doc.layers[0].surface().unwrap().pixel(7, 8), before.layers[0].surface().unwrap().pixel(7, 8)); + assert!(app.session.job_on(state.doc.id).is_none(), "the resumed action waited for its worker"); + assert!(app.session.authorize.is_none(), "the transient captured gate was restored"); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } +} + +#[test] +/// A paused action's captured permission cannot bypass a stricter live policy on confirmation. +fn a_stricter_current_gate_still_denies_the_captured_dialog_edit() { + for service in [false, true] { + let mut app = gated_blur_fixture(true, true); + let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur)); + let before = app.session.active().unwrap().doc.clone(); + if service { + app.automation_input = true; + app.services.automation_authorize = Some(deny_blur); + } else { + app.session.authorize = Some(deny_blur); + } + let error = crate::dialogs::confirm(&mut app, dialog).unwrap_err(); + assert!(error.contains("blur denied"), "{error}"); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + if service { + assert!(app.session.authorize.is_none()); + } else { + assert!(app.session.authorize.unwrap()("filter.blur.gaussianBlur", &json!({"radius": 1})).is_err(), "the live gate was restored"); + } + assert!(app.paused_playbacks.paused.is_empty()); + } +} + +#[test] +/// Spelling corrections consult live Session and automation-service policies before resuming the owned history. +fn paused_spelling_checks_the_live_gate_before_resuming_history() { + fn deny_correction(id: &str, p: &Value) -> photocraft_engine::Result<()> { + if id == "edit.checkSpelling" && p["action"] == "changeAll" { Err(photocraft_engine::EngineError::Other("live spelling gate".into())) } else { Ok(()) } + } + for service in 0..3 { + let (mut app, _, _) = paused_history_spelling(true, Some(deny_second_spelling_change)); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.entries(); + let token = app.ui.type_panels.spell.as_ref().unwrap().resume.unwrap(); + if service != 1 { + app.session.authorize = None; + } + match service { + 0 => app.session.authorize = Some(deny_correction), + 1 => { + app.automation_input = true; + app.services.automation_authorize = Some(deny_correction); + } + _ => { + app.automation_input = true; + app.services.automation_command = Some(Box::new(|id, _| if id == "edit.checkSpelling" { Err("live spelling gate".into()) } else { Ok(()) })); + } + } + assert!(spelling_change(&mut app, json!({"action":"changeAll","word":"Teh","replace":"The"})).is_err()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(app.session.active().unwrap().history.entries(), rows); + assert!(app.paused_playbacks.paused.get(&token).unwrap().history.is_some(), "denial must leave the bound continuation untouched"); + assert!(app.session.execute("actions.setHistory", json!({"singleState":false})).is_err()); + let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]); + abandoned(&mut app, &fields); + app.session.execute("actions.setHistory", json!({"singleState":false})).unwrap(); + } +} + +#[test] +/// A changed authority context cancels both direct and nested tails while keeping the admitted prefix undoable. +fn a_changed_live_gate_cannot_be_replaced_for_the_remaining_action() { + fn deny_tail(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "layer.renameLayer" { Err(photocraft_engine::EngineError::Other("live tail denied".into())) } else { Ok(()) } + } + for service in [false, true] { + for nested in [false, true] { + let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur)); + if nested { + app.session.actions.list.push(Action::new("Nested tail", vec![("layer.renameLayer".into(), json!({"name":"Forbidden tail"}))])); + app.session.actions.list[0].steps[2] = ("actions.play".into(), json!({"action":"Nested tail"})); + } + spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); + let corrected = app.session.active().unwrap().doc.clone(); + if service { + app.automation_input = true; + app.services.automation_authorize = Some(deny_tail); + } else { + app.session.authorize = Some(deny_tail); + } + crate::type_panels_ui::close_spelling(&mut app); + assert!(app.ui.status_error && app.ui.status.contains("authorization context changed"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref(), "the admitted prefix remains, with no tail edit"); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } + } +} + +#[test] +/// Distinct captured/live gates fail before applying the dialog or starting a worker, even if the edit is allowed. +fn a_changed_context_is_rejected_before_dialog_or_worker_admission() { + fn allow_all(_: &str, _: &Value) -> photocraft_engine::Result<()> { + Ok(()) + } + for background in [false, true] { + let mut app = gated_blur_fixture(true, background); + let dialog = pause_gated_blur(&mut app, Some(deny_wide_blur)); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.entries(); + app.session.authorize = Some(allow_all); + assert!(crate::dialogs::confirm(&mut app, dialog).is_err_and(|e| e.contains("authorization context changed"))); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(app.session.active().unwrap().history.entries(), rows); + assert!(app.session.job_on(before.id).is_none()); + } +} + +#[test] +/// The same gate, a released temporary gate and a newly installed gate permit supported continuation paths. +fn unchanged_and_released_gates_allow_the_action_tail() { + for scenario in ["released", "same", "new"] { + let mut app = gated_blur_fixture(true, false); + app.session.actions.list[0].steps.push(("layer.renameLayer".into(), json!({"name":"Allowed tail"}))); + let dialog = pause_gated_blur(&mut app, (scenario != "new").then_some(deny_wide_blur as Gate)); + match scenario { + "same" | "new" => app.session.authorize = Some(deny_wide_blur), + _ => {} + } + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let state = app.session.active().unwrap(); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().name, "Allowed tail"); + } +} + +#[test] +/// Distinct configured policies permit manual confirmation but cannot be combined for automated dispatch. +/// The identical-gate automated control proves the supported composed-policy path still completes and undoes once. +fn configured_gates_require_one_composed_policy_for_automated_resume() { + fn deny_file_open(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "file.open" { Err(photocraft_engine::EngineError::Other("file open denied".into())) } else { Ok(()) } + } + assert!(!std::ptr::fn_addr_eq(deny_wide_blur as Gate, deny_file_open as Gate)); + for (automated, identical) in [(false, false), (true, false), (true, true)] { + let mut app = gated_blur_fixture(true, false); + app.session.actions.list[0].steps.push(("layer.renameLayer".into(), json!({"name":"Allowed tail"}))); + app.session.authorize = Some(deny_wide_blur); + let service: Gate = if identical { deny_wide_blur } else { deny_file_open }; + app.services.automation_authorize = Some(service); + // The service is configured before the real local pause, but becomes effective only + // for the automated OK. Both callbacks allow this edit and its remaining command. + for gate in [deny_wide_blur as Gate, deny_file_open as Gate] { + gate("filter.blur.gaussianBlur", &json!({"radius":1})).unwrap(); + gate("layer.renameLayer", &json!({"name":"Allowed tail"})).unwrap(); + } + let dialog = play(&mut app, &json!({"action":"Gated blur"})).unwrap()["paused"]["dialog"].as_u64().unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.past_len(); + app.automation_input = automated; + let result = crate::dialogs::confirm(&mut app, dialog); + let state = app.session.active().unwrap(); + if automated && !identical { + assert!(result.is_err_and(|error| error.contains("policies that cannot be combined"))); + assert_eq!(state.doc.as_ref(), before.as_ref()); + assert_eq!(state.history.past_len(), rows); + assert!(app.session.job_on(before.id).is_none()); + } else { + result.unwrap(); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().name, "Allowed tail"); + assert_eq!(state.history.past_len(), rows + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + assert!(std::ptr::fn_addr_eq(app.session.authorize.unwrap(), deny_wide_blur as Gate)); + assert!(std::ptr::fn_addr_eq(app.services.automation_authorize.unwrap(), service)); + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } +} + +#[test] +/// A policy change after the final spelling step must not discard completed edits when no tail remains to dispatch. +fn a_changed_gate_after_the_last_spelling_step_keeps_completed_edits() { + let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur)); + app.session.actions.list[0].steps.truncate(2); + spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); + let corrected = app.session.active().unwrap().doc.clone(); + app.session.authorize = Some(|_, _| Ok(())); + crate::type_panels_ui::close_spelling(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.as_ref(), corrected.as_ref()); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); +} + +#[test] +/// Implicit layer-owned adjustments reject a receiver switch; explicit receivers change only their intended surface. +/// An omitted target separately inherits the mask captured when its dialog opens. +fn paused_adjustments_bind_implicit_pixel_and_mask_receivers() { + for targeting in ["implicit", "null", "explicit"] { + for mask in [false, true] { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.run("edit.fill", json!({"color":"#808080"})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("edit.fill", json!({"color":"#808080","target":"mask"})).unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap()); + // Native mask eligibility checks the active layer, so keep both receivers eligible. + // The assertions below still require the explicitly named A mask alone to change. + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.session.select_layer(a).unwrap(); + let mut params = json!({"brightness":40,"target":"pixels"}); + if mask { + params["target"] = json!("mask"); + } + if targeting == "explicit" { + params["layer"] = json!(a.0); + } else if targeting == "null" { + params["layer"] = Value::Null; + } + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), params); + let dialog = pause_gated_blur(&mut app, None); + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + if targeting == "explicit" { + result.unwrap(); + let after = &app.session.active().unwrap().doc; + assert_eq!(after.layer(b).unwrap(), before.layer(b).unwrap()); + if mask { + assert_ne!(after.layer(a).unwrap().mask, before.layer(a).unwrap().mask); + assert_eq!(after.layer(a).unwrap().surface(), before.layer(a).unwrap().surface()); + } else { + assert_ne!(after.layer(a).unwrap().surface(), before.layer(a).unwrap().surface()); + assert_eq!(after.layer(a).unwrap().mask, before.layer(a).unwrap().mask); + } + } else { + assert!(result.is_err_and(|e| e.contains("receiver changed"))); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + } + } + + // Batch 16 review: a dialog paused on a document target (an alpha channel) and switched to + // the layer's pixels checks the layer that was active at the pause. + { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap()); + app.session.select_layer(a).unwrap(); + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40,"target":{"channel":0}})); + let dialog = pause_gated_blur(&mut app, None); + app.ui.dialog_mut(dialog).unwrap().fields.insert("target".into(), json!("pixels")); + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + assert!(result.is_err_and(|e| e.contains("receiver changed"))); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + } + + // An omitted target inherits the mask selected when the paused dialog opens. This is + // distinct from native playback without a dialog, which resolves the current channel. + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("edit.fill", json!({"color":"#808080","target":"mask"})).unwrap(); + assert!(app.layer_mask_targeted()); + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40})); + let before = app.session.active().unwrap().doc.clone(); + let rows = app.session.active().unwrap().history.past_len(); + let dialog = pause_gated_blur(&mut app, None); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["target"], "mask"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let state = app.session.active().unwrap(); + assert_ne!(state.doc.layer(a).unwrap().mask, before.layer(a).unwrap().mask); + assert_eq!(state.doc.layer(a).unwrap().surface(), before.layer(a).unwrap().surface()); + assert_eq!(state.history.past_len(), rows + 1); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); +} + +#[test] +/// Document-owned alpha channels remain valid targets when the active layer changes during the pause. +fn a_document_channel_adjustment_survives_an_active_layer_change() { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap()); + app.run("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + app.run("edit.fill", json!({"color":"#808080","target":{"channel":0}})).unwrap(); + app.session.select_layer(a).unwrap(); + app.session.actions.list[0].steps[0] = ("image.adjustments.brightnessContrast".into(), json!({"brightness":40,"target":{"channel":0}})); + let dialog = pause_gated_blur(&mut app, None); + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let after = &app.session.active().unwrap().doc; + assert_eq!(after.layers, before.layers); + assert_ne!(after.channels, before.channels); +} + +#[test] +/// A public Session layer switch invalidates an implicit receiver while explicit-layer playback remains usable. +fn an_implicit_paused_filter_refuses_a_public_api_receiver_change() { + for single in [false, true] { + for explicit in [false, true] { + let mut app = gated_blur_fixture(single, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap()); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.session.select_layer(a).unwrap(); + if explicit { + app.session.actions.list[0].steps[0].1["layer"] = json!(a.0); + } + let dialog = pause_gated_blur(&mut app, None); + // Supported host/session API, not a modal-blocked native menu/control command. + app.session.select_layer(b).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + let after = &app.session.active().unwrap().doc; + if explicit { + result.unwrap(); + assert_ne!(after.layer(a).unwrap().surface().unwrap().pixel(7, 8), before.layer(a).unwrap().surface().unwrap().pixel(7, 8)); + assert_eq!(after.layer(b).unwrap().surface().unwrap().pixel(7, 8), before.layer(b).unwrap().surface().unwrap().pixel(7, 8)); + } else { + assert!(result.is_err_and(|e| e.contains("receiver changed"))); + assert_eq!(after.as_ref(), before.as_ref(), "neither layer may be edited after stale implicit targeting"); + } + assert!(app.paused_playbacks.paused.is_empty()); + assert_eq!(app.session.actions.playing, 0); + } + } +} + +#[test] +/// Changes to other selected layers do not invalidate the unchanged active receiver captured by a filter dialog. +fn an_implicit_filter_keeps_its_receiver_when_only_other_selected_layers_change() { + let mut app = gated_blur_fixture(true, false); + let a = app.session.active().unwrap().active_layer.unwrap(); + let b = photocraft_doc::LayerId(app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().unwrap()); + app.session.select_layer(a).unwrap(); + let dialog = pause_gated_blur(&mut app, None); + app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + let before = app.session.active().unwrap().doc.clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let after = &app.session.active().unwrap().doc; + assert_ne!(after.layer(a).unwrap().surface().unwrap().pixel(7, 8), before.layer(a).unwrap().surface().unwrap().pixel(7, 8)); + assert_eq!(after.layer(b).unwrap().surface().unwrap().content_bounds(), before.layer(b).unwrap().surface().unwrap().content_bounds()); +} + +fn deny_displace_file(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.distort.displace" && params.get("mapPath").is_some() { + Err(photocraft_engine::EngineError::Other("chosen displacement map denied".into())) + } else { + Ok(()) + } +} + +#[test] +/// Authorize a deferred displacement-map path before file access, worker admission or continuation history changes. +fn a_deferred_displace_map_is_authorized_before_reading_or_resuming_history() { + let mut app = recording_with_displace_child(); + app.background_jobs = true; + let (show, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec!["denied-map.png".into()]))]); + app.services.file_dialog = Some(show); + app.session.authorize = Some(deny_displace_file); + let dialog = pause_child(&mut app); + app.session.authorize = None; + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.file_dialog_open()); + app.poll_file_dialog(&egui::Context::default(), None); + assert!(app.ui.status.contains("chosen displacement map denied"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().doc.as_ref(), before.as_ref()); + assert_eq!(app.session.active().unwrap().history.past_len(), count); + assert!(app.session.authorize.is_none()); + assert!(app.session.job_on(before.id).is_none()); + assert_displace_call_finished(&mut app, 0); +} + #[test] fn a_step_the_gate_denies_opens_no_dialog() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1174,3 +2852,20 @@ fn save_hook_closing_the_target_stops_an_imported_close_with_save() { assert!(app.session.actions.playback_stack.is_empty()); } } + +#[test] +/// An unsupported step in a set that stays loaded is counted in the multi-file summary, unlike +/// one in a set a later file replaced (CodeRabbit on #10776). +fn bulk_load_summary_counts_unsupported_steps_of_surviving_sets() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut a = photocraft_psd::atn::parse(&action_set_bytes("Set A", "A")).unwrap(); + a.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + let files = vec![("a.atn".into(), photocraft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set B", "B"))]; + let status = bulk_load_status(&mut app, files); + assert!(status.contains("1 step has no PhotoCraft equivalent yet"), "{status}"); + assert!(!app.ui.status_error, "{status}"); + // Both sets stay loaded: the unsupported step is counted for one that is in the panel. + let names: Vec<&str> = app.session.actions.list.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["A", "B"]); + assert!(app.session.actions.list[0].steps.iter().any(|(id, _)| id == "actions.photoshopStep")); +} diff --git a/crates/ui-egui/src/panels/blend_preview.rs b/crates/ui-egui/src/panels/blend_preview.rs index 06e7a208e1..37617ea6cf 100644 --- a/crates/ui-egui/src/panels/blend_preview.rs +++ b/crates/ui-egui/src/panels/blend_preview.rs @@ -101,7 +101,7 @@ pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, /// Limit an active preview to the view. Returning to the document restores the whole region. pub(crate) fn clip_damage(app: &mut PhotocraftApp, doc: DocId, revision: u64, key: u64, region: Rect, visible: Rect) -> Rect { let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision) else { return region }; - if !p.shown.as_ref().is_some_and(|(shown, _)| *shown == key) { + if p.shown.as_ref().is_none_or(|(shown, _)| *shown != key) { p.covered = None; return region; } diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d6a96112a7..d618999d9f 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -1,18 +1,5 @@ use super::{Align2, BlendMode, Color32, PhotocraftApp, Rect, Sense, Stroke, StrokeKind, Tokens, c32, icons, json, pos2, vec2, widgets}; -/// Photoshop's brush preset picker chip: a soft/hard round tip preview with the size underneath. -/// Draw a round brush tip preview (hard core fading to a soft edge). -fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: Color32) { - let inner = rad * hardness.clamp(0.05, 1.0); - let steps = 8; - for k in (0..=steps).rev() { - let f = k as f32 / steps as f32; - let rr = inner + (rad - inner) * f; - let a = if hardness >= 0.99 { 1.0 } else { (1.0 - f).powf(1.5) }; - p.circle_filled(c, rr, color.gamma_multiply(a)); - } -} - /// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the /// mode. Returns the mode picked this frame. pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { @@ -26,13 +13,21 @@ pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); - let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); - opts.insert(at, (None, "Clear")); - let mut mode = if *clear || held { None } else { Some(b.mode) }; + let opts = crate::brush_mode_menu::choices(); + let mut mode = if *clear || held { + (None, false) + } else if b.behind { + (None, true) + } else { + (Some(b.mode), false) + }; if !widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0.is_empty() { - *clear = mode.is_none(); - if let Some(m) = mode { + *clear = mode == (None, false); + b.behind = mode.1; + if mode.1 { + b.mode = BlendMode::Normal; + } + if let Some(m) = mode.0 { b.mode = m; } } @@ -114,7 +109,11 @@ pub(super) fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushS let c = pos2(r.left() + 14.0, r.top() + 11.0); // Photoshop's white tip vanishes on light chrome; light themes use their icon colour. let tip = if t.dark() { Color32::WHITE } else { t.icon }; - brush_tip(ui.painter(), c, 7.0, b.hardness, tip); + // The brush's own tip, as the Brush Preset picker's thumbnails draw it: the engine's falloff + // for its hardness, its angle and roundness, a sampled tip's shape. + let tex = crate::brush_preview::tip_texture(ui.ctx(), "options-bar-chip", &b.tip, (b.hardness, b.angle, b.roundness), 32, tip); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + ui.painter().image(tex.id(), Rect::from_center_size(c, vec2(14.0, 14.0)), uv, Color32::WHITE); ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker"))); diff --git a/crates/ui-egui/src/panels/brushes_tab/tests.rs b/crates/ui-egui/src/panels/brushes_tab/tests.rs index 85bf63d38e..7f8de2b568 100644 --- a/crates/ui-egui/src/panels/brushes_tab/tests.rs +++ b/crates/ui-egui/src/panels/brushes_tab/tests.rs @@ -12,6 +12,7 @@ fn dragging_a_brush_shows_its_tip_preview() { for theme in crate::theme::ThemeKind::ALL { for mode in 0..3 { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.ui.tool = crate::Tool::Brush; app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); app.ui.panels.brush_settings = mode != 2; app.ui.brush_tab = 1; diff --git a/crates/ui-egui/src/panels/channels_panel.rs b/crates/ui-egui/src/panels/channels_panel.rs index 912d82c298..3baa1b99bf 100644 --- a/crates/ui-egui/src/panels/channels_panel.rs +++ b/crates/ui-egui/src/panels/channels_panel.rs @@ -34,6 +34,17 @@ impl Row { Row::LayerMask => json!("mask"), } } + + /// The `channel.target` reference a click records: a colour row by the mode's colour name, + /// which a recorded action can save as Photoshop's component select (an index can't be). + fn target_reference(self, mode: photocraft_doc::ColorMode) -> Value { + let names = channel_cmds::color_names(mode); + match self { + Row::QuickMask => json!("composite"), + Row::Color(k) if names.len() > 1 => names.get(k).map_or_else(|| self.reference(), |name| json!({ "color": name.to_ascii_lowercase() })), + _ => self.reference(), + } + } } /// ⌘ = new selection, ⌘⇧ add, ⌘⌥ subtract, ⌘⌥⇧ intersect (Photoshop's thumbnail ⌘-clicks). @@ -113,8 +124,8 @@ fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) { if matches!(row.row, Row::Composite | Row::Color(_)) { *row.mask_click = Some(false); } - let target = if row.row == Row::QuickMask { json!("composite") } else { row.row.reference() }; - row.actions.push(("channel.target".into(), json!({ "channel": target }))); + let target = row.row.target_reference(row.doc.pixel_format().mode); + row.actions.push(("channel.target".into(), json!({ "channel": target, "extend": modifiers.shift && matches!(row.row, Row::Color(_)) }))); } fn handle_channel_row_double_click(ui: &mut egui::Ui, row: &mut RowInteractions<'_>, rename_id: egui::Id) { @@ -260,7 +271,8 @@ fn row_presentation(row: Row, context: &RowPresentationContext<'_>, mask_visible 1 + k, Some(3 + k), context.view.color_visible(k) && !context.gray_view, - (context.view.target == ChannelTarget::Composite && !context.quick && !context.mask_targeted) || context.view.target == ChannelTarget::Color(k), + (context.view.target == ChannelTarget::Composite && !context.quick && !context.mask_targeted) + || context.view.color_selection(context.colors).is_some_and(|c| c.contains(k)), ), Row::Alpha(i) => ( context.doc.channels[i].name.clone(), diff --git a/crates/ui-egui/src/panels/channels_panel/tests.rs b/crates/ui-egui/src/panels/channels_panel/tests.rs index 428b9de1e7..3d57762d03 100644 --- a/crates/ui-egui/src/panels/channels_panel/tests.rs +++ b/crates/ui-egui/src/panels/channels_panel/tests.rs @@ -131,6 +131,72 @@ fn alphas(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec { h.state().session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect() } +/// Row selection sets the visible components; eye clicks subsequently override only the +/// view. Exercise real Shift-clicks so panel event routing and later frames keep that split. +#[test] +fn channel_selection_sets_visibility_and_eye_overrides_do_not_retarget() { + use egui::{Event, Modifiers, PointerButton}; + for (mode, first, second, third, selected) in + [("rgb", "Red", "Blue", "Green", vec![0, 2]), ("cmyk", "Cyan", "Magenta", "Yellow", vec![0, 1]), ("lab", "a", "b", "Lightness", vec![1, 2])] + { + let (mut h, _) = channels_harness(); + if mode != "rgb" { + h.state_mut().run(&format!("image.mode.{mode}"), json!({})).unwrap(); + h.run_steps(2); + } + let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, label: &str, modifiers| { + let pos = at(h, label); + h.event(Event::ModifiersChanged(modifiers)); + h.hover_at(pos); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + }; + let visible = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + (0..st.doc.pixel_format().mode.color_channels()).filter(|k| st.channel_view.color_visible(*k)).collect::>() + }; + let colors = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + photocraft_engine::color_channels::selected(&h.state().session).unwrap().indices().collect::>() + }; + + click(&mut h, first, Modifiers::NONE); + assert_eq!(visible(&h), vec![selected[0]], "{mode}: clicking one row shows it alone"); + assert!(!h.state().session.active().unwrap().channel_view.alpha_shown(0)); + click(&mut h, second, Modifiers::SHIFT); + assert_eq!(colors(&h), selected); + assert_eq!(visible(&h), selected, "{mode}: extending the selection shows just its components"); + + click(&mut h, &format!("Visibility {third}"), Modifiers::NONE); + assert_eq!(visible(&h).len(), 3, "{mode}: the eye adds a visible component"); + assert_eq!(colors(&h), selected, "the eye does not extend the editing target"); + click(&mut h, &format!("Visibility {first}"), Modifiers::NONE); + let manual = visible(&h); + assert!(!manual.contains(&selected[0])); + assert_eq!(colors(&h), selected, "a selected component can be hidden manually"); + h.state_mut().run("image.adjustments.invert", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(visible(&h), manual, "editing and rendering preserve manual visibility"); + + click(&mut h, first, Modifiers::NONE); + assert_eq!(visible(&h), vec![selected[0]], "a new row selection resets its default view"); + click(&mut h, second, Modifiers::SHIFT); + assert_eq!(visible(&h), selected); + click(&mut h, third, Modifiers::SHIFT); + assert_eq!(visible(&h).len(), 3); + if mode == "cmyk" { + click(&mut h, "Black", Modifiers::SHIFT); + } + let st = h.state().session.active().unwrap(); + assert_eq!(st.channel_view.target, ChannelTarget::Composite); + assert_eq!(visible(&h).len(), st.doc.pixel_format().mode.color_channels()); + } +} + /// #9163: alpha channels reorder by dragging, as in Photoshop: a drop in a row's upper half /// lands above it, over the colour channels it lands first among the alphas, colour channels /// don't move, and each reorder is one history step. @@ -312,3 +378,65 @@ fn the_layer_mask_row_on_the_trash_asks_first() { let st = h.state().session.active().unwrap(); assert!(st.doc.layer(photocraft_doc::LayerId(masked)).unwrap().mask.is_some(), "nothing deleted yet"); } + +/// A colour channel click recorded into an action saves as Photoshop's component select and +/// targets the same channel as the index did. +#[test] +fn recorded_colour_clicks_save_as_component_selects() { + use photocraft_doc::ColorMode; + for (mode, color_mode, count) in [("rgb", ColorMode::Rgb, 3), ("cmyk", ColorMode::Cmyk, 4), ("lab", ColorMode::Lab, 3)] { + for k in 0..count { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap(); + let by_index = s.execute("channel.target", json!({"channel": Row::Color(k).reference(), "extend": false})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let params = json!({"channel": Row::Color(k).target_reference(color_mode), "extend": false}); + assert_eq!(s.execute("channel.target", params.clone()).unwrap(), by_index, "{mode}/{k}"); + s.actions.list.push(photocraft_engine::actions_cmds::Action::new("Click", vec![("channel.target".into(), params)])); + let saved = s.execute("actions.save", json!({"action": "Click"})).unwrap_or_else(|e| panic!("{mode}/{k}: {e}")); + assert_eq!(saved["skipped"], json!([])); + let loaded = s.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{mode}/{k}"); + let name = photocraft_engine::channel_cmds::color_names(color_mode)[k].to_ascii_lowercase(); + assert_eq!(s.actions.list.last().unwrap().steps, vec![("channel.target".to_string(), json!({"channel": {"color": name}}))]); + } + } + // Rows the colour names can't express keep their reference. + assert_eq!(Row::Color(0).target_reference(ColorMode::Grayscale), Row::Color(0).reference()); + assert_eq!(Row::Alpha(1).target_reference(ColorMode::Rgb), json!(1)); + assert_eq!(Row::QuickMask.target_reference(ColorMode::Rgb), json!("composite")); +} + +/// A real click on a colour row while recording records the colour by name, and the recorded +/// action saves strictly and reloads as the same step. +#[test] +fn recording_a_colour_row_click_saves_as_photoshop_select() { + use egui::{Event, Modifiers, PointerButton}; + for (mode, label, name) in [("rgb", "Red", "red"), ("cmyk", "Cyan", "cyan"), ("lab", "Lightness", "lightness")] { + let (mut h, _) = channels_harness(); + if mode != "rgb" { + h.state_mut().run(&format!("image.mode.{mode}"), json!({})).unwrap(); + h.run_steps(2); + } + h.state_mut().run("actions.record", json!({"name": "Click"})).unwrap(); + let pos = at(&h, label); + h.hover_at(pos); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let s = &mut h.state_mut().session; + let recorded = s.actions.list.iter().find(|a| a.name == "Click").unwrap().steps.clone(); + let step = ("channel.target".to_string(), json!({"channel": {"color": name}, "extend": false})); + assert_eq!(recorded, vec![step], "{mode}"); + let saved = s.execute("actions.save", json!({"action": "Click"})).unwrap_or_else(|e| panic!("{mode}: {e}")); + assert_eq!(saved["skipped"], json!([]), "{mode}"); + let loaded = s.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{mode}"); + let reloaded = &s.actions.list.last().unwrap().steps; + assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}"); + } +} diff --git a/crates/ui-egui/src/panels/chrome_ui.rs b/crates/ui-egui/src/panels/chrome_ui.rs index f5e58a455d..82a70fd408 100644 --- a/crates/ui-egui/src/panels/chrome_ui.rs +++ b/crates/ui-egui/src/panels/chrome_ui.rs @@ -157,14 +157,11 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); let mut pct = app.ui.views[i].zoom * 100.0; let control_h = crate::panels::status_bar_heights(&t).1; - if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h)) - .accessible_name(tl!("Zoom")) - .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; + let field = widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h)) + .accessible_name(tl!("Zoom")); + if field.changed() { + let animated = app.session.prefs().general.animated_zoom; + crate::zoom_levels::set_from_field(&mut app.ui.views[i], field.dragged() || field.drag_started(), pct, animated); } ui.add_space(12.0); ui.label(RichText::new(tl!(&text)).color(t.text_dim).size(12.0)); diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 90cfa9d8e6..59539891aa 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -121,8 +121,10 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let (kx, ky) = if brightness { (hsva.h, 1.0 - hsva.s) } else { (hsva.s, 1.0 - hsva.v) }; let knob = pos2(field.left() + kx * field.width(), field.top() + ky * field.height()); - ui.painter().circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE)); - ui.painter().circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160))); + // At the extremes the knob sits on the edge: clip it to the field (#8968). + let painter = ui.painter().with_clip_rect(field); + painter.circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE)); + painter.circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160))); // The strip: hue (red at both ends, as Photoshop's) or brightness (the colour down to black). let (strip, sresp) = ui.allocate_exact_size(vec2(strip_w, h), Sense::click_and_drag()); let strip_at = |f: f32| { diff --git a/crates/ui-egui/src/panels/color_hud.rs b/crates/ui-egui/src/panels/color_hud.rs new file mode 100644 index 0000000000..8ca7522147 --- /dev/null +++ b/crates/ui-egui/src/panels/color_hud.rs @@ -0,0 +1,537 @@ +//! Photoshop's HUD Color Picker (#6098): with a painting tool, Shift+Alt+right-click on the +//! canvas opens a floating saturation/value square with a hue strip to its right, placed so the +//! current colour sits under the pointer (as the reference application does). +//! While the right button stays down, moving the pointer over the HUD picks the foreground colour +//! live (`tools.setColors` calls sharing one `coalesce` key, so the gesture is one history step, +//! Rule 1); releasing the button or pressing Escape closes the HUD and keeps the colour picked so +//! far, as in the reference application (Undo takes it back). A left press closes it too, which is +//! how the control channel ends a gesture it opened with `ui.pointer`. +//! +//! The HUD is drawn as plain meshes and hit-tested from the raw pointer position rather than with +//! interactive widgets: the selection follows the pointer while the *right* button is held, which +//! egui's widgets (left-button only) cannot express. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use egui::{Color32, Mesh, PointerButton, Rect, Stroke, Vec2, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use photocraft_color::convert::{hsb_to_rgb, rgb_to_hsb}; + +use crate::PhotocraftApp; +use crate::paint_mouse::has_brush_picker; +use crate::state::UiState; + +/// Width of the hue strip. +const HUE_W: f32 = 16.0; +/// Side of the saturation/value square, and the strip's height. +const SQUARE: f32 = 128.0; +/// Gap between the square and the strip. +const GAP: f32 = 8.0; +/// The HUD's content size (the frame's chrome sits around it). +pub const SIZE: [f32; 2] = [SQUARE + GAP + HUE_W, SQUARE]; + +/// The square (left) and the hue strip (right) inside the HUD's content rect. +fn layout(content: Rect) -> (Rect, Rect) { + let square = Rect::from_min_size(content.min, vec2(SQUARE, SQUARE)); + let strip = Rect::from_min_size(content.min + vec2(SQUARE + GAP, 0.0), vec2(HUE_W, SQUARE)); + (strip, square) +} + +/// Where the current colour (saturation, value) sits inside the content rect, from its top left. +fn colour_offset(hsv: [f32; 3]) -> Vec2 { + let f = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + vec2(f(hsv[1]) * SQUARE, (1.0 - f(hsv[2])) * SQUARE) +} + +/// The HUD Color Picker while it is open. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Hud { + /// Where the gesture began, in screen points: the HUD opens with the starting colour here. + pub anchor: [f32; 2], + /// The colour being picked: hue 0..360, saturation and value 0..1. + pub hsv: [f32; 3], + /// The foreground colour when the gesture began: where the HUD opens, and the alpha it keeps. + pub start: [f32; 4], + /// The `coalesce` key of this gesture's `tools.setColors` calls. + pub key: u64, + /// Begun by a real right-press: it ends when the right button comes up. One opened through + /// the control channel ends with its Up event instead. + pub hold: bool, + /// The gesture has just opened: near a screen edge the clamped HUD can sit under the still + /// pointer, so hover picking waits until the pointer moves from the anchor. + #[serde(default)] + pub just_opened: bool, +} + +pub(crate) fn hud_id() -> egui::Id { + egui::Id::unique("canvas-color-hud") +} + +/// Is the HUD open? +pub fn is_open(ui: &UiState) -> bool { + ui.color_hud.is_some() +} + +/// Open the HUD at `at`, seeded from the current foreground colour. `hold` marks a real-mouse +/// gesture (see [`Hud::hold`]). +pub fn open(app: &mut PhotocraftApp, at: [f32; 2], hold: bool) { + static KEY: AtomicU64 = AtomicU64::new(0); + let fg = app.session.tools.foreground; + let hsv = rgb_to_hsb([fg[0], fg[1], fg[2]]); + app.ui.color_hud = Some(Hud { anchor: at, hsv: [hsv[0], hsv[1], hsv[2]], start: fg, key: KEY.fetch_add(1, Ordering::Relaxed), hold, just_opened: true }); +} + +/// Close the HUD, keeping the picked colour. +pub fn close(ui: &mut UiState) { + ui.color_hud = None; +} + +/// Close a control-channel HUD on its Up event; a real-mouse HUD closes on the button release. +pub fn release(ui: &mut UiState) { + if ui.color_hud.is_some_and(|h| !h.hold) { + close(ui); + } +} + +/// The hue, saturation and value a pointer at `p` selects inside these rects (Photoshop's strip +/// runs red at the top down through the spectrum; the square is saturation left→right, value +/// top→bottom). `None` when the point is on neither. +pub fn pick_at(hsv: [f32; 3], strip: Rect, square: Rect, p: egui::Pos2) -> Option<[f32; 3]> { + if square.contains(p) { + let s = ((p.x - square.left()) / square.width()).clamp(0.0, 1.0); + let v = 1.0 - ((p.y - square.top()) / square.height()).clamp(0.0, 1.0); + Some([hsv[0], s, v]) + } else if strip.contains(p) { + let h = ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0) * 360.0; + Some([h, hsv[1], hsv[2]]) + } else { + None + } +} + +fn rgb_color(c: [f32; 3]) -> Color32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(c[0]), q(c[1]), q(c[2])) +} + +/// The vertical hue strip: red at the top, a full spectrum cycle. One mesh of bands, so its cost +/// doesn't grow with the HUD. Row `k` (of `n + 1`) holds the left vertex at index `2k` and the +/// right one at `2k + 1`; band `k` joins rows `k` and `k + 1`. +fn hue_strip_mesh(r: Rect) -> Mesh { + let n = 12; + let mut mesh = Mesh::default(); + for k in 0..=n { + let y = r.top() + r.height() * k as f32 / n as f32; + let c = rgb_color(hsb_to_rgb([k as f32 / n as f32 * 360.0, 1.0, 1.0])); + mesh.colored_vertex(egui::pos2(r.left(), y), c); + mesh.colored_vertex(egui::pos2(r.right(), y), c); + } + for k in 0..n { + let (a, b) = (2 * k, 2 * k + 1); + mesh.add_triangle(a, b, b + 2); + mesh.add_triangle(a, b + 2, a + 2); + } + mesh +} + +fn paint_hue_strip(painter: &egui::Painter, r: Rect) { + painter.add(hue_strip_mesh(r)); +} + +/// The saturation/value square for `hue`: white→hue horizontally, then clear→black vertically. +fn paint_sv_square(painter: &egui::Painter, r: Rect, hue: f32) { + let hue_c = rgb_color(hsb_to_rgb([hue, 1.0, 1.0])); + let mut mesh = Mesh::default(); + mesh.colored_vertex(r.left_top(), Color32::WHITE); + mesh.colored_vertex(r.right_top(), hue_c); + mesh.colored_vertex(r.left_bottom(), Color32::WHITE); + mesh.colored_vertex(r.right_bottom(), hue_c); + mesh.add_triangle(0, 1, 3); + mesh.add_triangle(0, 3, 2); + painter.add(mesh); + let mut mesh = Mesh::default(); + mesh.colored_vertex(r.left_top(), Color32::TRANSPARENT); + mesh.colored_vertex(r.right_top(), Color32::TRANSPARENT); + mesh.colored_vertex(r.left_bottom(), Color32::BLACK); + mesh.colored_vertex(r.right_bottom(), Color32::BLACK); + mesh.add_triangle(0, 1, 3); + mesh.add_triangle(0, 3, 2); + painter.add(mesh); +} + +/// Draw the HUD and track the pointer: called once per frame while a gesture is open. +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(mut hud) = app.ui.color_hud else { return }; + if !has_brush_picker(app.ui.tool) { + close(&mut app.ui); + return; + } + let (released, esc, pressed_left, hover) = ctx.input(|i| { + ( + i.pointer.button_released(PointerButton::Secondary), + i.key_pressed(egui::Key::Escape), + i.pointer.button_pressed(PointerButton::Primary), + i.pointer.hover_pos(), + ) + }); + if esc || (hud.hold && released) || pressed_left { + close(&mut app.ui); + return; + } + // The starting colour's spot in the square sits under the press point, as in the reference + // application; near a screen edge the HUD is pulled in so it stays whole. + let screen = ctx.content_rect(); + let style = ctx.global_style(); + let m = style.spacing.menu_margin; + let stroke = style.visuals.window_stroke().width; + let size = Vec2::from(SIZE) + m.sum() + egui::Vec2::splat(2.0 * stroke); + let start = rgb_to_hsb([hud.start[0], hud.start[1], hud.start[2]]); + let at = egui::pos2(hud.anchor[0], hud.anchor[1]) - colour_offset(start) - vec2(f32::from(m.left) + stroke, f32::from(m.top) + stroke); + let pos = egui::pos2(at.x.min(screen.right() - size.x).max(screen.left()), at.y.min(screen.bottom() - size.y).max(screen.top())); + let area = egui::Area::new(hud_id()).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + egui::Frame::popup(ui.style()) + .show(ui, |ui| { + let (rect, _) = ui.allocate_exact_size(vec2(SIZE[0], SIZE[1]), egui::Sense::hover()); + let (strip, square) = layout(rect); + let p = ui.painter(); + paint_hue_strip(p, strip); + paint_sv_square(p, square, hud.hsv[0]); + p.rect_stroke(strip, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), egui::StrokeKind::Inside); + p.rect_stroke(square, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), egui::StrokeKind::Inside); + // Markers: a ring in the square at the picked s/v, a bar on the strip at the hue. + let sv = egui::pos2(square.left() + hud.hsv[1] * square.width(), square.top() + (1.0 - hud.hsv[2]) * square.height()); + p.circle(sv, 4.0, Color32::TRANSPARENT, Stroke::new(1.5, Color32::WHITE)); + p.circle(sv, 5.5, Color32::TRANSPARENT, Stroke::new(1.0, Color32::from_black_alpha(160))); + let hy = strip.top() + hud.hsv[0] / 360.0 * strip.height(); + p.line_segment([egui::pos2(strip.left() + 1.0, hy), egui::pos2(strip.right(), hy)], Stroke::new(2.0, Color32::WHITE)); + (strip, square) + }) + .inner + }); + let (strip, square) = area.inner; + // The HUD opens under the stationary pointer: picking starts only once the pointer has moved + // from where the gesture began, so the opening press alone never changes the colour. + if hud.just_opened && hover.is_some_and(|p| p != egui::pos2(hud.anchor[0], hud.anchor[1])) { + hud.just_opened = false; + } + if !hud.just_opened + && let Some(p) = hover + && let Some(new) = pick_at(hud.hsv, strip, square, p) + && new != hud.hsv + { + hud.hsv = new; + let [r, g, b] = hsb_to_rgb(new); + let fg = app.session.tools.foreground; + if (r - fg[0]).abs() > f32::EPSILON || (g - fg[1]).abs() > f32::EPSILON || (b - fg[2]).abs() > f32::EPSILON { + set_foreground(app, [r, g, b, hud.start[3]], hud.key); + } + } + app.ui.color_hud = Some(hud); + // The selection follows the pointer even when it rests between frames. + ctx.request_repaint(); +} + +/// One foreground-colour edit of the gesture: coalesced under `key`, so the whole pick is a single +/// history step (the colour is tool state, so it never touches the document). +fn set_foreground(app: &mut PhotocraftApp, rgba: [f32; 4], key: u64) { + let p = json!({ "foreground": rgba, "coalesce": format!("color-hud:{key}") }); + if let Err(e) = app.run("tools.setColors", p) { + app.ui.status = e; + } +} + +#[cfg(test)] +mod tests { + use egui::{Modifiers, PointerButton, Pos2, vec2}; + use egui_kittest::Harness; + use serde_json::json; + + use super::*; + use crate::state::Tool; + + const SHIFT_ALT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: false, command: false }; + + fn harness() -> Harness<'static, PhotocraftApp> { + harness_size(400, 300) + } + + fn harness_size(width: u32, height: u32) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": width, "height": height})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + app.ui.tool = Tool::Brush; + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h + } + + fn button(h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.event(egui::Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers }); + h.run_steps(2); + } + + /// The HUD's strip and square rects as drawn this frame. + fn rects(h: &Harness<'static, PhotocraftApp>) -> (Rect, Rect) { + let area = h.ctx.memory(|m| m.area_rect(hud_id())).expect("the HUD is shown"); + let style = h.ctx.global_style(); + let m = style.spacing.menu_margin; + let w = style.visuals.window_stroke().width; + let inner = + Rect::from_min_max(area.min + vec2(f32::from(m.left) + w, f32::from(m.top) + w), area.max - vec2(f32::from(m.right) + w, f32::from(m.bottom) + w)); + layout(inner) + } + + fn foreground(h: &Harness<'static, PhotocraftApp>) -> [f32; 4] { + h.state().session.tools.foreground + } + + #[test] + fn shift_alt_right_press_opens_the_hud_not_the_picker() { + let mut h = harness(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, SHIFT_ALT); + let hud = h.state().ui.color_hud.expect("the HUD opened"); + assert!(hud.hold); + assert_eq!(hud.start, [0.0, 0.0, 1.0, 1.0], "seeded from the foreground"); + assert_eq!(h.state().ui.brush_picker, None, "the Brush Preset picker stays closed"); + // The square is left of the strip, and the pointer sits on the current colour: blue is + // full saturation and value, the square's top-right corner. + let (strip, square) = rects(&h); + assert!(strip.height() > 100.0 && square.width() > 100.0); + assert!(square.right() <= strip.left(), "square {square:?} left of strip {strip:?}"); + assert!(strip.right() < h.ctx.content_rect().right()); + assert!((square.right_top() - c).length() < 1.0, "the current colour {:?} is under the pointer {c:?}", square.right_top()); + // Nothing painted. + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke")); + } + + /// Quick Selection's canvas right-click is the selection menu (#7268), so Shift+Alt+right + /// must not open the HUD from the same gesture (#6098). + #[test] + fn quick_selections_shift_alt_right_opens_only_the_selection_menu() { + let mut h = harness(); + h.state_mut().ui.tool = Tool::QuickSelection; + h.run_steps(1); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, SHIFT_ALT); + assert_eq!(h.state().ui.color_hud, None, "no HUD for Quick Selection"); + assert_eq!(h.state().ui.brush_picker, None, "and no Brush Preset picker"); + button(&mut h, c, false, SHIFT_ALT); + assert_eq!(h.state().ui.color_hud, None, "the release does not open it either"); + assert!(h.state().ui.canvas_tool_menu.is_some(), "the selection menu opens, once"); + } + + /// The HUD opens with the current colour under the still pointer: the opening press alone + /// must not pick, the colour changes only once the pointer moves (#6098). Near the + /// bottom-right edge the HUD is pulled in to stay on screen, and still picks nothing. + #[test] + fn the_opening_press_picks_nothing_until_the_pointer_moves() { + let mut h = harness_size(1200, 800); + h.state_mut().run("tools.setColors", json!({"foreground": [0.4, 0.3, 0.6, 1.0]})).unwrap(); + let start = foreground(&h); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, SHIFT_ALT); + assert!(h.state().ui.color_hud.is_some_and(|hud| hud.just_opened), "the HUD opened"); + let (_strip, square) = rects(&h); + let hsv = rgb_to_hsb([start[0], start[1], start[2]]); + let spot = square.min + colour_offset([hsv[0], hsv[1], hsv[2]]); + assert!(square.contains(c) && (spot - c).length() < 1.0, "the current colour {spot:?} is under the pointer {c:?}"); + h.run_steps(4); + assert_eq!(foreground(&h), start, "the stationary opening pointer picks nothing"); + // Moving inside the HUD now picks, as during any other gesture. + let to = square.min + vec2(1.0, square.height() - 1.0); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(2); + assert_ne!(foreground(&h), start, "the move picks"); + button(&mut h, to, false, SHIFT_ALT); + // At the bottom-right corner the HUD stays whole on screen, and the press alone picks nothing. + let near = egui::pos2(h.state().last_canvas_rect.max.x - 10.0, h.state().last_canvas_rect.max.y - 10.0); + let start = foreground(&h); + h.event(egui::Event::PointerMoved(near)); + h.run_steps(1); + button(&mut h, near, true, SHIFT_ALT); + let (strip, square) = rects(&h); + let screen = h.ctx.content_rect(); + assert!(screen.contains_rect(square.union(strip)), "on screen: {square:?} {strip:?} in {screen:?}"); + h.run_steps(4); + assert_eq!(foreground(&h), start, "the press near the edge picks nothing"); + button(&mut h, near, false, SHIFT_ALT); + } + + #[test] + fn dragging_over_the_hud_picks_the_foreground_live_and_release_keeps_it() { + let mut h = harness(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, SHIFT_ALT); + let j0 = h.state().session.journal.len(); + let (_strip, square) = rects(&h); + // Top-right of the square: full saturation, full value at the current hue (blue). + let pick = egui::pos2(square.max.x - 1.0, square.min.y + 1.0); + h.event(egui::Event::PointerMoved(pick)); + h.run_steps(2); + let fg = foreground(&h); + assert!(fg[2] > 0.9 && fg[0] < 0.1 && fg[1] < 0.1, "still blue at the top of the square: {fg:?}"); + // Bottom-left: value 0 → black. + h.event(egui::Event::PointerMoved(square.min + vec2(1.0, square.height() - 1.0))); + h.run_steps(2); + let fg = foreground(&h); + assert!(fg[..3].iter().all(|v| *v < 0.02), "black at the bottom: {fg:?}"); + // The hue strip: its middle is cyan-ish (180°), at the square's current s/v. + let (strip, _) = rects(&h); + h.event(egui::Event::PointerMoved(egui::pos2(square.max.x - 1.0, square.min.y + 1.0))); + h.run_steps(2); + h.event(egui::Event::PointerMoved(strip.center())); + h.run_steps(2); + let fg = foreground(&h); + assert!(fg[2] > 0.5 && fg[0] < 0.5, "hue moved off red: {fg:?}"); + // The gesture's edits share one coalesce key (one history step, like the brush HUD). + let sets: Vec<&serde_json::Value> = h.state().session.journal[j0..].iter().filter(|(id, _)| id == "tools.setColors").map(|(_, p)| p).collect(); + assert!(!sets.is_empty(), "the picks are journaled"); + let keys: Vec<&str> = sets.iter().map(|p| p["coalesce"].as_str().unwrap()).collect(); + assert!(keys.windows(2).all(|w| w[0] == w[1]), "one gesture, one key: {keys:?}"); + // Releasing the right button closes the HUD and keeps the colour. + let kept = foreground(&h); + button(&mut h, strip.center(), false, SHIFT_ALT); + assert_eq!(h.state().ui.color_hud, None); + assert_eq!(foreground(&h), kept); + } + + /// The reference application's Escape ends the pick where it is: the HUD closes, the colour + /// picked so far stays, and moving afterwards changes nothing. + #[test] + fn escape_closes_the_hud_and_keeps_the_picked_colour() { + let mut h = harness(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, SHIFT_ALT); + let (_strip, square) = rects(&h); + h.event(egui::Event::PointerMoved(square.center())); + h.run_steps(2); + let picked = foreground(&h); + assert_ne!(picked, [0.0, 0.0, 1.0, 1.0], "the pick is live"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().ui.color_hud, None); + assert_eq!(foreground(&h), picked, "Escape keeps the picked colour"); + h.event(egui::Event::PointerMoved(square.min + vec2(2.0, 2.0))); + h.run_steps(2); + assert_eq!(foreground(&h), picked, "moving after Escape picks nothing"); + button(&mut h, square.min + vec2(2.0, 2.0), false, SHIFT_ALT); + assert_eq!(h.state().ui.color_hud, None); + assert_eq!(h.state().ui.brush_picker, None, "releasing the button afterwards opens nothing"); + assert_eq!(foreground(&h), picked); + } + + #[test] + fn plain_right_click_still_opens_the_picker_and_alt_drag_still_resizes() { + let mut h = harness(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, Modifiers::NONE); + button(&mut h, c, false, Modifiers::NONE); + assert!(h.state().ui.brush_picker.is_some() && h.state().ui.color_hud.is_none()); + // Alt+right-drag (no Shift) resizes the brush, as before. + h.state_mut().ui.brush_picker = None; + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, Modifiers::ALT); + h.event(egui::Event::PointerMoved(c + vec2(40.0, 0.0))); + h.run_steps(2); + assert!(h.state().brush_resize.is_some_and(|r| r.secondary), "Alt+right still resizes"); + assert!(h.state().ui.color_hud.is_none()); + button(&mut h, c + vec2(40.0, 0.0), false, Modifiers::ALT); + } + + #[test] + fn shift_alt_right_drag_does_not_resize_the_brush() { + let mut h = harness(); + let size0 = h.state().session.tools.brush.size; + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + button(&mut h, c, true, SHIFT_ALT); + for k in 1..=6 { + h.event(egui::Event::PointerMoved(c + vec2(20.0 * k as f32, 0.0))); + h.run_steps(1); + } + assert!(h.state().brush_resize.is_none(), "the HUD gesture is not a resize"); + assert_eq!(h.state().session.tools.brush.size, size0); + button(&mut h, c + vec2(120.0, 0.0), false, SHIFT_ALT); + } + + #[test] + fn the_control_channel_opens_and_closes_the_hud() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + app.ui.tool = Tool::Brush; + let ctx = egui::Context::default(); + let (req, _rx) = crate::control::ControlRequest::new( + "ui.pointer", + json!({"button": "right", "shift": true, "alt": true, "events": [{"kind": "down", "x": 50, "y": 50}, {"kind": "up", "x": 50, "y": 50}]}), + ); + let _ = crate::control::handle(&mut app, &ctx, &req); + assert!(app.ui.color_hud.is_none(), "the Up event closed it"); + assert!(app.ui.brush_picker.is_none(), "the picker stayed closed"); + // A down without its up leaves the HUD open for the next frame's edits. + let (req, _rx) = crate::control::ControlRequest::new( + "ui.pointer", + json!({"button": "right", "shift": true, "alt": true, "events": [{"kind": "down", "x": 50, "y": 50}]}), + ); + let _ = crate::control::handle(&mut app, &ctx, &req); + assert!(app.ui.color_hud.is_some_and(|h| !h.hold)); + } + + #[test] + fn hue_strip_mesh_indices_stay_within_its_rows() { + let mesh = hue_strip_mesh(Rect::from_min_size(Pos2::ZERO, vec2(HUE_W, SQUARE))); + let rows = 13; // n + 1 rows, two vertices each + assert_eq!(mesh.vertices.len(), 2 * rows); + assert_eq!(mesh.indices.len(), 3 * 2 * (rows - 1), "two triangles per band"); + for (j, tri) in mesh.indices.chunks(3).enumerate() { + // Band k = j/2 joins rows k and k+1: vertices 2k..=2k+3, nothing further ahead. + let k = j / 2; + for &i in tri { + assert!((2 * k) as u32 <= i && i <= (2 * k + 3) as u32, "band {k} uses index {i}: {tri:?}"); + assert!((i as usize) < mesh.vertices.len(), "index {i} out of range"); + } + } + } + + #[test] + fn pick_at_maps_the_rects_and_ignores_the_gaps() { + let (strip, square) = layout(Rect::from_min_size(Pos2::ZERO, Vec2::from(SIZE))); + assert_eq!(pick_at([120.0, 0.5, 0.5], strip, square, square.center()), Some([120.0, 0.5, 0.5])); + let p = pick_at([120.0, 0.5, 0.5], strip, square, square.left_top() + vec2(1.0, 1.0)).expect("in the square"); + assert!(p[0] == 120.0 && p[1] < 0.02 && p[2] > 0.98, "top-left is saturated and bright: {p:?}"); + assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, strip.center()), Some([180.0, 0.5, 0.5])); + assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, Pos2::new(SQUARE + GAP / 2.0, 10.0)), None, "the gap picks nothing"); + assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, Pos2::new(1e9, -1e9)), None); + } +} diff --git a/crates/ui-egui/src/panels/color_panel_ui.rs b/crates/ui-egui/src/panels/color_panel_ui.rs index 798e24dea1..22665b2150 100644 --- a/crates/ui-egui/src/panels/color_panel_ui.rs +++ b/crates/ui-egui/src/panels/color_panel_ui.rs @@ -768,3 +768,26 @@ pub(crate) fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.separator(); ramp_menu(app, ui); } + +/// Remember the Color panel's options in the preferences when they change, so they survive a +/// restart (#9592). Cheap: a small JSON compare per frame. +pub fn persist(app: &mut PhotocraftApp) { + let stored = app.session.prefs().color_panel.clone(); + // Changed outside the panel since the last sync (`prefs.set`, a reset): the panel takes it. + if app.ui.color_panel_synced.as_ref().is_some_and(|synced| *synced != stored) { + restore(app); + app.ui.color_panel_synced = Some(stored); + return; + } + let Ok(now) = serde_json::to_value(app.ui.color_panel) else { return }; + if stored != now { + app.session.prefs.edit(|p| p.color_panel = now.clone()); + } + app.ui.color_panel_synced = Some(now); +} + +/// Restore the remembered Color panel options (at launch, or after a preference change made +/// outside the panel); none stored (a reset) or unreadable ones are the defaults. +pub fn restore(app: &mut PhotocraftApp) { + app.ui.color_panel = serde_json::from_value(app.session.prefs().color_panel.clone()).unwrap_or_default(); +} diff --git a/crates/ui-egui/src/panels/color_tests.rs b/crates/ui-egui/src/panels/color_tests.rs index 8abdec859a..30ecfaf3e7 100644 --- a/crates/ui-egui/src/panels/color_tests.rs +++ b/crates/ui-egui/src/panels/color_tests.rs @@ -409,6 +409,36 @@ fn docked_color_field_keeps_rgb_readout_at_compact_height() { assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "hex field stays visible"); } +/// #8968: the round cursor sits on the field's edge at the extremes and must not paint past it +/// (over the hue strip or the chips). +#[test] +fn color_field_cursor_stays_inside_the_field() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // Black is s = 0, v = 0: the bottom-left corner. + app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0]; + let h = field_harness(app); + let mut field = None; + let mut circles = Vec::new(); + for clipped in &h.output().shapes { + match &clipped.shape { + // The saturation/brightness field is the 17 x 17 mesh; the hue strip is a 25 x 25 grid. + egui::Shape::Mesh(m) if m.vertices.len() == 17 * 17 => field = Some(m.calc_bounds()), + egui::Shape::Circle(c) => circles.push((c.center, c.radius, clipped.clip_rect)), + _ => {} + } + } + let field = field.expect("colour field drawn"); + let expected_center = pos2(field.left(), field.bottom()); + let rings: Vec<_> = + circles.into_iter().filter(|(center, radius, _)| center.distance(expected_center) < 0.01 && (*radius == 5.0 || *radius == 6.5)).collect(); + assert!(rings.iter().any(|(_, radius, _)| *radius == 5.0), "inner ring sits at the field's bottom-left corner"); + assert!(rings.iter().any(|(_, radius, _)| *radius == 6.5), "outer ring sits at the field's bottom-left corner"); + for (center, radius, clip) in rings { + let visible = egui::Rect::from_center_size(center, vec2(2.0 * radius, 2.0 * radius)).intersect(clip); + assert!(field.contains_rect(visible), "cursor ring {visible:?} leaves the field {field:?}"); + } +} + 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( diff --git a/crates/ui-egui/src/panels/dock.rs b/crates/ui-egui/src/panels/dock.rs index 4bc72ab57b..5784e0042d 100644 --- a/crates/ui-egui/src/panels/dock.rs +++ b/crates/ui-egui/src/panels/dock.rs @@ -16,6 +16,7 @@ pub fn dock_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); crate::dock::persist(app, ui.ctx()); + crate::color_panel_ui::persist(app); } /// The docking layout's protected canvas panel: the document area, with the icon rail on its end diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 56a60e82d0..eeca9db7e3 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -23,6 +23,12 @@ pub(crate) fn pointer_sample(app: &mut PhotocraftApp, (x, y): (i32, i32)) -> Opt Some(v) } +/// The Info panel's W and H for a size of `wh` px (the selection's or the pending crop frame's): +/// both go through the ruler unit's formatter at `dpi`, percent of the document's `extent`. +pub(crate) fn wh_readout(ur: &photocraft_engine::prefs::UnitsAndRulers, dpi: f64, extent: [f64; 2], wh: [f64; 2]) -> [String; 2] { + [ur.format(wh[0], dpi, extent[0]), ur.format(wh[1], dpi, extent[1])] +} + /// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size. pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -37,8 +43,12 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let dpi = st.doc.resolution_dpi as f64; let fx = move |px: f64| units.format(px, dpi, size.width as f64); let units_y = app.session.prefs().units_and_rulers.clone(); + let units_wh = app.session.prefs().units_and_rulers.clone(); let fy = move |px: f64| units_y.format(px, dpi, size.height as f64); - let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds); + let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds).map(|r| [r.width() as f64, r.height() as f64]); + // With the Crop tool, W and H are the pending frame's, as in Photoshop (#3145). + let crop = crate::crop_ui::info_size(app); + let wh = crop.or(sel); let pos = hover_pixel(app); // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). @@ -75,8 +85,9 @@ pub(super) fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Measured from the rulers' zero point (#2955). row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); - row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); - row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); + let [w, h] = wh.map_or_else(|| [dash(), dash()], |s| wh_readout(&units_wh, dpi, [size.width as f64, size.height as f64], s)); + row(&mut cols[1], "W:", w); + row(&mut cols[1], "H:", h); }); widgets::hairline(ui); ui.label( diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index fd035f3284..d9c9041dd7 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,10 +1,15 @@ //! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the -//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode -//! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel -//! can read and set them. +//! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Effect +//! picks one effect kind (#10880), Mode picks one blend mode, Attribute one layer attribute or +//! its opposite (#10881), Color one layer colour label (#5898), Smart Object toggles linked, +//! missing and embedded smart objects, Artboard lists only the artboards that hold the selected +//! layers. The criteria live in `UiState` (`layer_filter*`), so the control channel can read and +//! set them. + +use std::collections::HashSet; use photocraft_color::BlendMode; -use photocraft_doc::{Layer, LayerContent}; +use photocraft_doc::{Document, Fill, LabelColor, Layer, LayerContent, LayerId, SmartSource}; use serde::{Deserialize, Serialize}; use crate::state::UiState; @@ -17,7 +22,12 @@ pub enum FilterBy { #[default] Kind, Name, + Effect, Mode, + Attribute, + Color, + SmartObject, + Artboard, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -28,20 +38,69 @@ pub struct Filter { /// As typed; matched trimmed and ignoring case. pub name: String, pub mode: BlendMode, + pub effect: effect::EffectKind, + pub attribute: attribute::Attribute, + pub color: LabelColor, + /// The Smart Object toggles that are on, from [`SMART`]. + pub smart: Vec, + /// The linked files the Smart Object filter's Missing toggle lists + /// ([`photocraft_engine::Session::missing_links`]). + pub missing: HashSet, + /// The rows the Artboard filter lists ([`board_rows`]); empty lists every row. + pub board_rows: HashSet, + /// The empty layers the Attribute filter's Empty and Not Empty use + /// ([`attribute::empty_layers`]); `None` reads each layer's pixels. + pub empty: Option>>, } impl Filter { pub fn of(ui: &UiState) -> Self { - Self { by: ui.layer_filter_by, kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode } + Self { + by: ui.layer_filter_by, + kinds: ui.layer_filter.clone(), + name: ui.layer_filter_name.clone(), + mode: ui.layer_filter_mode, + effect: ui.layer_filter_effect, + attribute: ui.layer_filter_attribute, + color: ui.layer_filter_color, + smart: ui.layer_filter_smart.clone(), + missing: HashSet::new(), + board_rows: HashSet::new(), + empty: None, + } } - /// Whether the panel is filtered. Like Photoshop, Mode always filters (by Normal at first); - /// Kind and Name only once a kind is toggled or some text is typed. + /// Returns [`Filter::of`] for `app`'s settings, with the active document's missing linked + /// files while the panel filters by Smart Object, and `board_rows` set to the rows of the + /// artboards that hold the active document's selected layers while it filters by Artboard, + /// and `empty` set while it filters by Empty or Not Empty. + pub fn of_app(app: &PhotocraftApp) -> Self { + let mut f = Self::of(&app.ui); + if f.by == FilterBy::SmartObject { + f.missing = app.session.missing_links(); + } + if f.by == FilterBy::Artboard + && let Some(d) = app.session.active() + { + f.board_rows = board_rows(&d.doc, &d.selected_layers()); + } + if f.by == FilterBy::Attribute { + f.empty = attribute::empty_layers(app, f.attribute); + } + f + } + + /// Whether the panel is filtered. Like Photoshop, Effect, Mode, Attribute and Color always + /// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first); Kind, Name and + /// Smart Object only once a toggle is on or some text is typed; Artboard only while some layers + /// are selected and each of them is in an artboard. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Mode => true, + FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true, + FilterBy::SmartObject => !self.smart.is_empty(), + FilterBy::Artboard => !self.board_rows.is_empty(), } } @@ -53,26 +112,81 @@ impl Filter { match self.by { FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), + FilterBy::Effect => self.effect.shows(l), FilterBy::Mode => l.blend == self.mode, + FilterBy::Attribute => self.attribute.shows(l, self.empty.as_deref()), + FilterBy::Color => l.label == self.color, + FilterBy::SmartObject => { + let LayerContent::Smart(sm) = &l.content else { return false }; + self.smart.iter().any(|k| match (k.as_str(), &sm.source) { + ("linked", SmartSource::Linked { .. }) | ("embedded", SmartSource::Embedded { .. }) => true, + ("missing", SmartSource::Linked { path }) => self.missing.contains(path), + _ => false, + }) + } + FilterBy::Artboard => self.board_rows.contains(&l.id), } } } +/// The Artboard filter's rows for `selection`: the artboards that hold the selected layers and +/// every layer inside them. The set is empty when nothing is selected or a selected layer is in +/// no artboard, and the panel is then not filtered. The panel lists these rows as its tree does: the contents of a closed +/// group stay hidden until the group is opened (`layer_tree_ui::panel_rows` opens every group only +/// for the types whose matches can sit in a closed group). +pub fn board_rows(doc: &Document, selection: &[LayerId]) -> HashSet { + fn add(l: &Layer, rows: &mut HashSet) { + let mut pending = vec![l]; + while let Some(layer) = pending.pop() { + rows.insert(layer.id); + pending.extend(layer.children().unwrap_or_default()); + } + } + let selected: HashSet = selection.iter().copied().collect(); + if selected.is_empty() { + return HashSet::new(); + } + // Artboards are top-level groups ([`Document::artboard_of`]): each one's layers are walked once. + let (mut rows, mut held) = (HashSet::new(), 0); + for board in doc.layers.iter().filter(|l| l.artboard().is_some()) { + let mut inside = HashSet::new(); + add(board, &mut inside); + let n = selected.iter().filter(|id| inside.contains(id)).count(); + if n > 0 { + held += n; + rows.extend(inside); + } + } + if held < selected.len() { + return HashSet::new(); + } + rows +} + /// The kinds the Kind filter toggles. pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; -/// `ui.set {layerFilter: {by?, kinds?, name?, mode?}}`: `current` with the given fields replaced, -/// or an error naming the bad field (nothing changes then). `by` is `kind`, `name` or `mode`; -/// `kinds` a list of [`KINDS`]; `mode` a blend mode name such as `Multiply` or `Pass Through`. +/// The Smart Object filter's toggles: smart objects linked to a file, linked to a file that is +/// not there, and embedded in the document. +pub const SMART: [&str; 3] = ["linked", "missing", "embedded"]; + +/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}}`: +/// `current` with the given fields replaced, or an error naming the bad field (nothing changes +/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or +/// `artboard`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of +/// the layer blend modes such as `Multiply` (not Pass Through, which the Mode list doesn't offer, +/// #10878); `attribute` an [`attribute::Attribute`] id; `color` a label colour id (`none`, `red`, +/// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its +/// own: the selected layers pick the artboards. pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; - if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, attribute, color, smart)")); } - let mut f = - Filter { by: current.layer_filter_by, kinds: current.layer_filter.clone(), name: current.layer_filter_name.clone(), mode: current.layer_filter_mode }; + let mut f = Filter::of(current); if let Some(by) = o.get("by") { - f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name or mode".to_string())?; + f.by = serde_json::from_value(by.clone()) + .map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject or artboard".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -91,9 +205,35 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result = BlendMode::LAYER_MODES.iter().map(|m| m.label()).collect(); + format!("layerFilter.mode: `{name}` is not a layer blend mode the filter lists (modes: {})", modes.join(", ")) + })?; + } + if let Some(e) = o.get("effect") { + f.effect = effect::parse(e)?; + } + if let Some(a) = o.get("attribute") { + f.attribute = attribute::parse(a)?; + } + if let Some(color) = o.get("color") { + let id = color.as_str().ok_or("layerFilter.color must be a label colour id")?; + f.color = LabelColor::ALL.into_iter().find(|c| c.id() == id).ok_or_else(|| { + let ids: Vec<&str> = LabelColor::ALL.iter().map(|c| c.id()).collect(); + format!("layerFilter.color: unknown colour `{id}` (colours: {})", ids.join(", ")) + })?; + } + if let Some(smart) = o.get("smart") { + let list = smart.as_array().ok_or("layerFilter.smart must be a list")?; + f.smart = Vec::new(); + for k in list { + let Some(k) = k.as_str().filter(|k| SMART.contains(k)) else { + return Err(format!("layerFilter.smart: unknown toggle {k} (toggles: {})", SMART.join(", "))); + }; + if !f.smart.iter().any(|have| have == k) { + f.smart.push(k.to_string()); + } + } } Ok(f) } @@ -104,13 +244,21 @@ pub fn apply(ui: &mut UiState, f: Filter) { ui.layer_filter = f.kinds; ui.layer_filter_name = f.name; ui.layer_filter_mode = f.mode; + ui.layer_filter_effect = f.effect; + ui.layer_filter_attribute = f.attribute; + ui.layer_filter_color = f.color; + ui.layer_filter_smart = f.smart; } /// The kind filter's name for `l`'s content ("" for groups, which no kind matches). fn kind(l: &Layer) -> &'static str { match &l.content { LayerContent::Raster(_) => "pixel", - LayerContent::Adjustment(_) | LayerContent::Fill(_) => "adjustment", + LayerContent::Adjustment(_) => "adjustment", + // A fill layer (Layer › New Fill Layer) matches no Kind button, as in the reference + // application, except a solid colour with a vector mask, which lists as a shape (#10878). + LayerContent::Fill(Fill::Solid(_)) if l.vector_mask.is_some() => "shape", + LayerContent::Fill(_) => "", LayerContent::Text(_) => "type", LayerContent::Shape(_) => "shape", LayerContent::Smart(_) => "smart", @@ -122,7 +270,16 @@ fn kind(l: &Layer) -> &'static str { pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + let by = [ + (FilterBy::Kind, tl!("Kind")), + (FilterBy::Name, tl!("Name")), + (FilterBy::Effect, tl!("Effect")), + (FilterBy::Mode, tl!("Mode")), + (FilterBy::Attribute, tl!("Attribute")), + (FilterBy::Color, tl!("Color")), + (FilterBy::SmartObject, tl!("Smart Object")), + (FilterBy::Artboard, tl!("Artboard")), + ]; widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); match app.ui.layer_filter_by { @@ -148,14 +305,59 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let width = ui.available_width().max(48.0); widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); } + FilterBy::Effect => effect::controls(app, ui), FilterBy::Mode => { - let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); + // The 27 layer modes, without Pass Through: a group set to Pass Through is never + // listed by Mode (#10878). + let modes: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.into_iter().map(|m| (m, m.label())).collect(); let width = ui.available_width().max(96.0); widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width); } + FilterBy::Attribute => attribute::controls(app, ui), + FilterBy::Color => { + // Photoshop's list: None, then the label colours in the Layers panel menu's order. + let colors: Vec<(LabelColor, &str)> = + LabelColor::ALL.into_iter().map(|c| (c, if c == LabelColor::None { tl!("None") } else { tl!(c.label()) })).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-color", tl!("Color"), &mut app.ui.layer_filter_color, &colors, width); + } + FilterBy::SmartObject => { + for (kind, icon, tip) in [ + ("linked", "link", tl!("Filter for smart objects linked locally")), + ("missing", "unlink", tl!("Filter for linked smart objects that are missing")), + ("embedded", "file", tl!("Filter for embedded smart objects")), + ] { + let on = app.ui.layer_filter_smart.iter().any(|k| k == kind); + if icons::button(ui, icon, 22.0, on, tip).clicked() { + if on { + app.ui.layer_filter_smart.retain(|k| k != kind); + } else { + app.ui.layer_filter_smart.push(kind.to_string()); + } + } + } + } + // Artboard has no controls: the selected layers pick the artboards ([`board_rows`]). + FilterBy::Artboard => {} } }); + selection::follow(app, ui.ctx()); } +pub mod effect; +pub mod selection; + +#[cfg(test)] +mod effect_tests; + +pub mod attribute; + +#[cfg(test)] +mod attribute_tests; +#[cfg(test)] +mod selection_tests; #[cfg(test)] mod tests; + +#[cfg(test)] +mod kind_mode_tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs new file mode 100644 index 0000000000..10e8edc541 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -0,0 +1,260 @@ +//! The Layers panel's Attribute filter (#10881): lists the layers with one attribute (visible, +//! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +use photocraft_color::SampleType; +use photocraft_doc::{AdvancedBlending, Document, Layer, LayerContent, LayerId}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The attributes the Attribute filter picks from, in Photoshop's order: the nine attributes, +/// then their opposites. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Attribute { + #[default] + Visible, + Locked, + Empty, + Linked, + Clipped, + LayerMask, + VectorMask, + LayerEffects, + AdvancedBlending, + NotVisible, + NotLocked, + NotEmpty, + NotLinked, + NotClipped, + NoLayerMask, + NoVectorMask, + NoLayerEffects, + NoAdvancedBlending, +} + +impl Attribute { + pub const ALL: [Attribute; 18] = [ + Attribute::Visible, + Attribute::Locked, + Attribute::Empty, + Attribute::Linked, + Attribute::Clipped, + Attribute::LayerMask, + Attribute::VectorMask, + Attribute::LayerEffects, + Attribute::AdvancedBlending, + Attribute::NotVisible, + Attribute::NotLocked, + Attribute::NotEmpty, + Attribute::NotLinked, + Attribute::NotClipped, + Attribute::NoLayerMask, + Attribute::NoVectorMask, + Attribute::NoLayerEffects, + Attribute::NoAdvancedBlending, + ]; + + /// The id `ui.set {layerFilter: {attribute}}` takes. + pub fn id(self) -> &'static str { + match self { + Attribute::Visible => "visible", + Attribute::Locked => "locked", + Attribute::Empty => "empty", + Attribute::Linked => "linked", + Attribute::Clipped => "clipped", + Attribute::LayerMask => "layerMask", + Attribute::VectorMask => "vectorMask", + Attribute::LayerEffects => "layerEffects", + Attribute::AdvancedBlending => "advancedBlending", + Attribute::NotVisible => "notVisible", + Attribute::NotLocked => "notLocked", + Attribute::NotEmpty => "notEmpty", + Attribute::NotLinked => "notLinked", + Attribute::NotClipped => "notClipped", + Attribute::NoLayerMask => "noLayerMask", + Attribute::NoVectorMask => "noVectorMask", + Attribute::NoLayerEffects => "noLayerEffects", + Attribute::NoAdvancedBlending => "noAdvancedBlending", + } + } + + fn label(self) -> &'static str { + match self { + Attribute::Visible => tl!("Visible"), + Attribute::Locked => tl!("Locked"), + Attribute::Empty => tl!("Empty"), + Attribute::Linked => tl!("Linked"), + Attribute::Clipped => tl!("Clipped"), + Attribute::LayerMask => tl!("Layer Mask"), + Attribute::VectorMask => tl!("Vector Mask"), + Attribute::LayerEffects => tl!("Layer Effects"), + Attribute::AdvancedBlending => tl!("Advanced Blending"), + Attribute::NotVisible => tl!("Not Visible"), + Attribute::NotLocked => tl!("Not Locked"), + Attribute::NotEmpty => tl!("Not Empty"), + Attribute::NotLinked => tl!("Not Linked"), + Attribute::NotClipped => tl!("Not Clipped"), + Attribute::NoLayerMask => tl!("No Layer Mask"), + Attribute::NoVectorMask => tl!("No Vector Mask"), + Attribute::NoLayerEffects => tl!("No Layer Effects"), + Attribute::NoAdvancedBlending => tl!("No Adv. Blending"), + } + } + + /// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers + /// (and groups) its positive entry doesn't. `empty` is the document's empty layers + /// ([`empty_layers_of`]); without it, Empty reads `l`'s pixels without knowing the canvas, so a + /// layer with an opaque default pixel is never empty. + pub fn shows(self, l: &Layer, empty: Option<&HashSet>) -> bool { + match self { + Attribute::Visible => l.visible, + // Any lock counts, the Background's too. + Attribute::Locked => l.locks.any(), + // A pixel layer with no visible pixels; an empty group isn't listed. + Attribute::Empty => empty.map_or_else(|| is_empty(l, None), |set| set.contains(&l.id)), + Attribute::Linked => l.link_group.is_some(), + // The clipped layer, not its base. + Attribute::Clipped => l.clipped, + Attribute::LayerMask => l.mask.is_some(), + // A shape layer's outline is its vector mask. + Attribute::VectorMask => l.vector_mask.is_some() || matches!(l.content, LayerContent::Shape(_)), + // Also a layer whose only effect is turned off, unlike the Effect filter. + Attribute::LayerEffects => !l.effects.items.is_empty(), + Attribute::AdvancedBlending => advanced_blending(l), + Attribute::NotVisible => !Attribute::Visible.shows(l, empty), + Attribute::NotLocked => !Attribute::Locked.shows(l, empty), + Attribute::NotEmpty => !Attribute::Empty.shows(l, empty), + Attribute::NotLinked => !Attribute::Linked.shows(l, empty), + Attribute::NotClipped => !Attribute::Clipped.shows(l, empty), + Attribute::NoLayerMask => !Attribute::LayerMask.shows(l, empty), + Attribute::NoVectorMask => !Attribute::VectorMask.shows(l, empty), + Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l, empty), + Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l, empty), + } + } +} + +/// Whether `l` is a pixel layer with no visible pixels; an empty group isn't listed. Stored pixels +/// past the canvas count (they are the layer's, Reveal All brings them back); `canvas` only bounds +/// the check for a layer whose default pixel is opaque (see [`has_visible_pixels`]). +fn is_empty(l: &Layer, canvas: Option) -> bool { + matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s, canvas)) +} + +/// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps +/// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes, +/// #9379). A surface without an alpha channel covers everything. With an opaque default pixel, +/// every part of `canvas` without a stored tile shows it, so only stored tiles that cover the +/// canvas and are clear on it make the layer empty (pixels outside the canvas are left out there, +/// as the default reaches beyond it anyway); without a `canvas` such a layer always counts. +fn has_visible_pixels(s: &Surface, canvas: Option) -> bool { + let fmt = s.format(); + if !fmt.alpha { + return true; + } + let clip = match (s.default_pixel().last().is_none_or(|a| *a > 0.0), canvas) { + (false, _) => None, + (true, Some(canvas)) if covers(s, canvas) => Some(canvas), + (true, _) => return true, + }; + let (n, bpp, size) = (fmt.channels(), fmt.bytes_per_pixel(), fmt.sample.bytes()); + let Some(at) = n.checked_sub(1).and_then(|i| i.checked_mul(size)).filter(|_| bpp > 0) else { return false }; + // Integer alpha is above 0 where any of its bytes is, so a block of pixels is OR-ed together + // (which vectorises) before looking for one; only float alpha needs decoding. + let visible = |block: &[u8]| match fmt.sample { + SampleType::F32 => block.chunks_exact(bpp).any(|px| px.get(at..at + size).is_some_and(|a| a.try_into().is_ok_and(|b| f32::from_ne_bytes(b) > 0.0))), + SampleType::U8 | SampleType::U16 => { + block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0 + } + }; + let tile_visible = |c: &TileCoord, t: &Arc| { + let bytes = t.bytes(); + let r = c.rect(); + let Some(clip) = clip.filter(|clip| !clip.contains_rect(&r)) else { return bytes.chunks(bpp.saturating_mul(1024)).any(visible) }; + // Only the part of the tile on the canvas, row by row. + let on = r.intersect(&clip); + let row = (TILE_SIZE as usize).saturating_mul(bpp); + let (x0, x1) = ((on.x0 - r.x0) as usize * bpp, (on.x1 - r.x0) as usize * bpp); + (on.y0 - r.y0..on.y1 - r.y0).any(|y| bytes.get(y as usize * row + x0..y as usize * row + x1).is_some_and(visible)) + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + let tiles: Vec<_> = s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).collect(); + tiles.par_iter().any(|(c, t)| tile_visible(c, t)) + } + #[cfg(target_arch = "wasm32")] + s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).any(|(c, t)| tile_visible(c, t)) +} + +/// Whether `s` stores a tile for every part of `canvas`. +fn covers(s: &Surface, canvas: Rect) -> bool { + if canvas.is_empty() { + return true; + } + let (first, last) = (TileCoord::containing(canvas.x0, canvas.y0), TileCoord::containing(canvas.x1 - 1, canvas.y1 - 1)); + let needed = (i64::from(last.tx) - i64::from(first.tx) + 1) * (i64::from(last.ty) - i64::from(first.ty) + 1); + let stored = s.tiles().filter(|(c, _)| (first.tx..=last.tx).contains(&c.tx) && (first.ty..=last.ty).contains(&c.ty)).count(); + i64::try_from(stored).is_ok_and(|stored| stored == needed) +} + +/// The empty pixel layers of `doc`. +pub fn empty_layers_of(doc: &Document) -> HashSet { + let canvas = Some(doc.bounds()); + doc.walk().into_iter().filter(|(_, _, l)| is_empty(l, canvas)).map(|(_, _, l)| l.id).collect() +} + +/// The document [`empty_layers`] last read and what it found. A `Weak` keeps the document's +/// allocation from being reused while it is held, and makes `Arc::make_mut` copy the document on +/// its next edit even when nothing else holds it, so the same allocation always means the same +/// layers and pixels. +static EMPTY: Mutex> = Mutex::new(None); + +type EmptyCache = (Weak, Arc>); + +/// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per +/// document: a layer erased to nothing has every pixel read, too much for every repaint of the +/// Layers panel. Keyed by the document itself rather than its revision, which also moves when only +/// the selection does. +pub fn empty_layers(app: &PhotocraftApp, attribute: Attribute) -> Option>> { + if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) { + return None; + } + let d = app.session.active()?; + let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((doc, set)) = cache.as_ref() + && std::ptr::eq(doc.as_ptr(), Arc::as_ptr(&d.doc)) + { + return Some(set.clone()); + } + let set = Arc::new(empty_layers_of(&d.doc)); + *cache = Some((Arc::downgrade(&d.doc), set.clone())); + Some(set) +} + +/// Whether any Advanced Blending option is away from its default: a channel left out, Knockout, +/// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range. +/// Fill opacity alone doesn't count. +fn advanced_blending(l: &Layer) -> bool { + l.excluded_channels != 0 || l.advanced != AdvancedBlending::default() || !l.blend_if.is_default() +} + +/// `layerFilter.attribute`: the attribute named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || Attribute::ALL.map(Attribute::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.attribute must be one of {}", ids()))?; + Attribute::ALL.into_iter().find(|a| a.id() == name).ok_or_else(|| format!("layerFilter.attribute: unknown attribute `{name}` (attributes: {})", ids())) +} + +/// The Attribute filter's controls: the attribute dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let attributes: Vec<(Attribute, &str)> = Attribute::ALL.into_iter().map(|a| (a, a.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-attribute", tl!("Attribute"), &mut app.ui.layer_filter_attribute, &attributes, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs new file mode 100644 index 0000000000..fad5460990 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -0,0 +1,321 @@ +//! #10881: the Layers panel's Attribute filter, against the cases measured in Photoshop. + +use std::collections::BTreeSet; + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::{BlendRange, Document, Group, Knockout, Layer, LayerContent, LayerMask, VectorMask}; +use serde_json::json; + +use super::attribute::Attribute; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// One layer per measured case. Every pixel layer has pixels except "empty"; the adjustment, fill +/// and shape layers come from their commands, so they carry the masks those give them. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let pixel_layers = [ + "pixels", + "hidden", + "position lock", + "all locked", + "empty", + "linked a", + "linked b", + "base", + "masked", + "vector masked", + "fx", + "fx off", + "fill 50", + "adv channels", + "adv knockout", + "adv interior", + "adv clipped", + "adv shapes", + "adv layer mask", + "adv vector mask", + "adv blend if", + ]; + for name in pixel_layers { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + match name { + "base" => { + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + rename_active(&mut s, "clipped"); + s.execute("layer.createClippingMask", json!({})).unwrap(); + } + "fx" => { + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + } + "fx off" => { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "params": {"enabled": false}}]})).unwrap(); + } + _ => {} + } + } + s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); + rename_active(&mut s, "fill"); + s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + rename_active(&mut s, "shape"); + + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + let full = photocraft_geom::Rect::new(0, 0, 64, 48); + for name in pixel_layers.into_iter().filter(|n| *n != "empty") { + let l = layer(doc, name); + let LayerContent::Raster(surface) = &mut l.content else { panic!("{name} is a pixel layer") }; + surface.fill_rect(full, &RED); + } + layer(doc, "hidden").visible = false; + layer(doc, "position lock").locks.position = true; + layer(doc, "all locked").locks.all = true; + layer(doc, "linked a").link_group = Some(7); + layer(doc, "linked b").link_group = Some(7); + layer(doc, "masked").mask = Some(LayerMask::reveal_all()); + layer(doc, "vector masked").vector_mask = Some(VectorMask { path: Default::default(), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + layer(doc, "fill 50").fill_opacity = 0.5; + layer(doc, "adv channels").excluded_channels = 0b010; + layer(doc, "adv knockout").advanced.knockout = Knockout::Shallow; + layer(doc, "adv interior").advanced.blend_interior = true; + layer(doc, "adv clipped").advanced.blend_clipped = false; + layer(doc, "adv shapes").advanced.transparency_shapes = false; + layer(doc, "adv layer mask").advanced.layer_mask_hides_effects = true; + layer(doc, "adv vector mask").advanced.vector_mask_hides_effects = true; + layer(doc, "adv blend if").blend_if.ranges = vec![[BlendRange { black: [40, 40], white: [255, 255] }, BlendRange::FULL]]; + // A visible group holding a pixel layer, and an empty group. + let mut inside = Layer::new("inside", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut inside.content { + surface.fill_rect(full, &RED); + } + doc.layers.push(Layer::new("group", LayerContent::Group(Group { children: vec![inside], expanded: true, artboard: None, frame_stroke: None }))); + doc.layers.push(Layer::new("empty group", LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }))); + // A hidden group whose child is visible: Visible goes by the layer's own flag. + let mut child = Layer::new("in hidden group", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut child.content { + surface.fill_rect(full, &RED); + } + let mut hidden_group = Layer::new("hidden group", LayerContent::Group(Group { children: vec![child], expanded: true, artboard: None, frame_stroke: None })); + hidden_group.visible = false; + doc.layers.push(hidden_group); + s +} + +fn layer<'a>(doc: &'a mut Document, name: &str) -> &'a mut Layer { + let id = doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}")); + doc.layer_mut(id).unwrap() +} + +fn rename_active(s: &mut photocraft_engine::Session, name: &str) { + let d = s.active_mut().unwrap(); + let id = d.active_layer.unwrap(); + std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().name = name.into(); +} + +/// The names `attribute` lists. +fn listed(doc: &Document, attribute: Attribute) -> BTreeSet { + let mut filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() }); + // As the panel does ([`Filter::of_app`]): Empty knows the canvas. + filter.empty = Some(std::sync::Arc::new(super::attribute::empty_layers_of(doc))); + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn names(list: &[&str]) -> BTreeSet { + list.iter().map(|n| n.to_string()).collect() +} + +fn all(doc: &Document) -> BTreeSet { + doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect() +} + +#[test] +fn each_attribute_lists_the_measured_layers() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + let but = |gone: &[&str]| -> BTreeSet { everything.difference(&names(gone)).cloned().collect() }; + // Every visible layer and every visible group. + assert_eq!(listed(doc, Attribute::Visible), but(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::Visible).contains("in hidden group"), "a visible child of a hidden group"); + // Any lock counts, the Background's too. + assert_eq!(listed(doc, Attribute::Locked), names(&["position lock", "all locked", "Background"])); + // A pixel layer without pixels; the empty group isn't listed. + assert_eq!(listed(doc, Attribute::Empty), names(&["empty"])); + assert_eq!(listed(doc, Attribute::Linked), names(&["linked a", "linked b"])); + // The clipped Levels layer, not its base. + assert_eq!(listed(doc, Attribute::Clipped), names(&["clipped"])); + // The adjustment and fill layers count with the masks they're created with. + assert_eq!(listed(doc, Attribute::LayerMask), names(&["masked", "clipped", "fill"])); + // A shape layer counts as having a vector mask. + assert_eq!(listed(doc, Attribute::VectorMask), names(&["vector masked", "shape"])); + // A layer whose only effect is turned off counts. + assert_eq!(listed(doc, Attribute::LayerEffects), names(&["fx", "fx off"])); + // Each Advanced Blending option on its own; Fill opacity alone doesn't count. + assert_eq!( + listed(doc, Attribute::AdvancedBlending), + names(&["adv channels", "adv knockout", "adv interior", "adv clipped", "adv shapes", "adv layer mask", "adv vector mask", "adv blend if"]) + ); +} + +#[test] +fn a_layer_erased_completely_is_empty() { + // The Eraser only clears alpha, so the erased pixels keep their colour bytes. + for depth in [8, 16, 32] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let name = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).next().unwrap(); + let erase = |s: &mut photocraft_engine::Session, x: i32| { + s.execute("paint.stroke", json!({"points": [[x, -10], [x, 40]], "size": 30, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + }; + erase(&mut s, 10); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[]), "{depth}: partly erased"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[&name]), "{depth}"); + erase(&mut s, 30); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[&name]), "{depth}: erased completely"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[]), "{depth}"); + } +} + +#[test] +fn a_layer_with_an_opaque_default_pixel_is_not_empty() { + // No stored tiles, but the default pixel covers the whole canvas. + let mut doc = Document::new("Opaque default", photocraft_geom::Size::new(40, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let opaque = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + doc.layers.push(Layer::new("opaque", LayerContent::Raster(opaque))); + doc.layers.push(Layer::new("clear", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8)))); + assert_eq!(listed(&doc, Attribute::Empty), names(&["clear"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["opaque"])); +} + +#[test] +fn an_opaque_default_layer_erased_on_the_whole_canvas_is_empty() { + // 300 px wide: the second column of tiles reaches past the canvas, where the default stays. + let mut doc = Document::new("Opaque erased", photocraft_geom::Size::new(300, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let clear = [1.0, 0.0, 0.0, 0.0]; + for (name, erased) in [("erased", photocraft_geom::Rect::new(0, 0, 300, 30)), ("half erased", photocraft_geom::Rect::new(0, 0, 200, 30))] { + let mut s = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + s.fill_rect(erased, &clear); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + let mut dot = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + dot.fill_rect(photocraft_geom::Rect::new(0, 0, 300, 30), &clear); + dot.fill_rect(photocraft_geom::Rect::new(299, 29, 300, 30), &RED); + doc.layers.push(Layer::new("one pixel", LayerContent::Raster(dot))); + // The half erased layer has no tile over x 256..300, so the default shows there. + assert_eq!(listed(&doc, Attribute::Empty), names(&["erased"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["half erased", "one pixel"])); +} + +#[test] +fn the_empty_layers_are_found_once_per_document() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter_by = FilterBy::Attribute; + app.ui.layer_filter_attribute = Attribute::Empty; + let first = Filter::of_app(&app).empty.expect("found for Empty"); + assert!(first.is_empty(), "the filled layer has pixels"); + let again = Filter::of_app(&app).empty.unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same document reuses the result"); + // Selecting a layer moves the revision but changes no pixels. + let id = app.session.active().unwrap().doc.layers[0].id; + app.session.select_layer(id).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &Filter::of_app(&app).empty.unwrap()), "a selection reuses the result"); + app.session.execute("paint.stroke", json!({"points": [[20, -10], [20, 40]], "size": 60, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + let after = Filter::of_app(&app); + assert_eq!(after.empty.as_ref().map(|set| set.len()), Some(1), "an edit finds them again"); + let doc = &app.session.active().unwrap().doc; + assert!(doc.walk().into_iter().all(|(_, _, l)| after.shows(l)), "the erased layer is listed"); + app.ui.layer_filter_attribute = Attribute::NotEmpty; + assert!(Filter::of_app(&app).empty.is_some(), "Not Empty uses them too"); + // Another document, with no empty layer, gets its own result. + app.session.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + assert_eq!(Filter::of_app(&app).empty.map(|set| set.len()), Some(0), "the new document's layers"); + app.ui.layer_filter_attribute = Attribute::Visible; + assert!(Filter::of_app(&app).empty.is_none(), "other attributes don't read pixels"); +} + +#[test] +fn each_opposite_lists_the_rest_groups_included() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + for (positive, negative) in Attribute::ALL[..9].iter().zip(&Attribute::ALL[9..]) { + let rest: BTreeSet = everything.difference(&listed(doc, *positive)).cloned().collect(); + assert_eq!(listed(doc, *negative), rest, "{negative:?} lists what {positive:?} doesn't"); + } + // The three measured ones, spelled out. + assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::NotLocked).contains("group"), "groups are listed too"); + assert!(!listed(doc, Attribute::NotLocked).contains("Background")); + assert_eq!(listed(doc, Attribute::NotLinked), everything.difference(&names(&["linked a", "linked b"])).cloned().collect()); +} + +#[test] +fn attribute_filters_at_once_with_visible() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, ..UiState::default() }); + assert_eq!(f.attribute, Attribute::Visible); + assert!(f.active(), "Attribute filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_attribute_by_id() { + let mut ui = UiState::default(); + for attribute in Attribute::ALL { + let f = super::patched(&ui, &json!({"by": "attribute", "attribute": attribute.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_attribute), (FilterBy::Attribute, attribute)); + } + assert_eq!(Attribute::NoAdvancedBlending.id(), "noAdvancedBlending"); + let err = super::patched(&ui, &json!({"attribute": "hidden"})).unwrap_err(); + assert!(err.contains("hidden") && err.contains("notVisible") && err.contains("noAdvancedBlending"), "{err}"); + assert!(super::patched(&ui, &json!({"attribute": 3})).is_err()); + assert!(super::patched(&ui, &json!({"attribute": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_attribute_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_attribute: Attribute::NotLinked, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_attribute"], "notLinked"); + saved.as_object_mut().unwrap().remove("layer_filter_attribute"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_attribute, Attribute::Visible); +} + +/// The names of the rows the Layers panel drew. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> BTreeSet { + let doc = &h.state().session.active().unwrap().doc; + let rows = crate::layer_row_ui::recorded(&h.ctx); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_attribute_picked_in_the_dropdown() { + let session = session(); + 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(session, crate::Services::default()) + }); + h.run_steps(8); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Attribute").last().expect("Attribute in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Attribute); + assert!(!drawn(&h).contains("hidden"), "Visible at first"); + h.get_by_value("Visible").click(); + h.run_steps(4); + h.query_all_by_label("Not Visible").last().expect("Not Visible in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_attribute, Attribute::NotVisible); + assert_eq!(drawn(&h), names(&["hidden", "hidden group"])); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/effect.rs b/crates/ui-egui/src/panels/layer_filter_ui/effect.rs new file mode 100644 index 0000000000..1ce24754ae --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/effect.rs @@ -0,0 +1,95 @@ +//! The Layers panel's Effect filter (#10880): lists the layers with an enabled effect of one kind. +//! Overlay covers Color, Gradient and Pattern Overlay, as in Photoshop. + +use photocraft_doc::{Effect, Layer}; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The effect kinds the Effect filter picks from, in Photoshop's order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EffectKind { + #[default] + BevelEmboss, + Stroke, + InnerShadow, + InnerGlow, + Satin, + Overlay, + OuterGlow, + DropShadow, +} + +impl EffectKind { + pub const ALL: [EffectKind; 8] = [ + EffectKind::BevelEmboss, + EffectKind::Stroke, + EffectKind::InnerShadow, + EffectKind::InnerGlow, + EffectKind::Satin, + EffectKind::Overlay, + EffectKind::OuterGlow, + EffectKind::DropShadow, + ]; + + /// The id `ui.set {layerFilter: {effect}}` takes. + pub fn id(self) -> &'static str { + match self { + EffectKind::BevelEmboss => "bevelEmboss", + EffectKind::Stroke => "stroke", + EffectKind::InnerShadow => "innerShadow", + EffectKind::InnerGlow => "innerGlow", + EffectKind::Satin => "satin", + EffectKind::Overlay => "overlay", + EffectKind::OuterGlow => "outerGlow", + EffectKind::DropShadow => "dropShadow", + } + } + + fn label(self) -> &'static str { + match self { + EffectKind::BevelEmboss => tl!("Bevel & Emboss"), + EffectKind::Stroke => tl!("Stroke"), + EffectKind::InnerShadow => tl!("Inner Shadow"), + EffectKind::InnerGlow => tl!("Inner Glow"), + EffectKind::Satin => tl!("Satin"), + EffectKind::Overlay => tl!("Overlay"), + EffectKind::OuterGlow => tl!("Outer Glow"), + EffectKind::DropShadow => tl!("Drop Shadow"), + } + } + + fn matches(self, e: &Effect) -> bool { + matches!( + (self, e), + (EffectKind::BevelEmboss, Effect::BevelEmboss(_)) + | (EffectKind::Stroke, Effect::Stroke(_)) + | (EffectKind::InnerShadow, Effect::InnerShadow(_)) + | (EffectKind::InnerGlow, Effect::InnerGlow(_)) + | (EffectKind::Satin, Effect::Satin(_)) + | (EffectKind::Overlay, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::PatternOverlay { .. }) + | (EffectKind::OuterGlow, Effect::OuterGlow(_)) + | (EffectKind::DropShadow, Effect::DropShadow(_)) + ) + } + + /// Whether `l` has an enabled effect of this kind; a turned-off one doesn't count. + pub fn shows(self, l: &Layer) -> bool { + l.effects.items.iter().any(|e| e.enabled() && self.matches(e)) + } +} + +/// `layerFilter.effect`: the kind named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || EffectKind::ALL.map(EffectKind::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.effect must be one of {}", ids()))?; + EffectKind::ALL.into_iter().find(|k| k.id() == name).ok_or_else(|| format!("layerFilter.effect: unknown effect `{name}` (effects: {})", ids())) +} + +/// The Effect filter's controls: the effect kind dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let kinds: Vec<(EffectKind, &str)> = EffectKind::ALL.into_iter().map(|k| (k, k.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-effect", tl!("Effect"), &mut app.ui.layer_filter_effect, &kinds, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs new file mode 100644 index 0000000000..326de21e4e --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/effect_tests.rs @@ -0,0 +1,128 @@ +//! #10880: the Layers panel's Effect filter, against the cases measured in Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::Document; +use serde_json::json; + +use super::effect::EffectKind; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +/// One pixel layer per effect kind (the three overlays each on their own), "shadow off" whose +/// Drop Shadow is turned off, and "plain" without effects, over the Background. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for (name, kind, params) in [ + ("plain", "", json!({})), + ("bevel", "bevelEmboss", json!({})), + ("stroke", "stroke", json!({})), + ("inner shadow", "innerShadow", json!({})), + ("inner glow", "innerGlow", json!({})), + ("satin", "satin", json!({})), + ("color", "colorOverlay", json!({"color": "#ff8000"})), + ("gradient", "gradientOverlay", json!({})), + ("pattern", "patternOverlay", json!({})), + ("outer glow", "outerGlow", json!({})), + ("shadow", "dropShadow", json!({})), + ("shadow off", "dropShadow", json!({"enabled": false})), + ] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + if !kind.is_empty() { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]})).unwrap(); + } + } + s +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn by_effect(effect: EffectKind) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Effect, layer_filter_effect: effect, ..UiState::default() }) +} + +#[test] +fn each_effect_lists_the_layers_with_it_enabled() { + let s = session(); + let doc = &s.active().unwrap().doc; + for (effect, want) in [ + (EffectKind::BevelEmboss, &["bevel"][..]), + (EffectKind::Stroke, &["stroke"]), + (EffectKind::InnerShadow, &["inner shadow"]), + (EffectKind::InnerGlow, &["inner glow"]), + (EffectKind::Satin, &["satin"]), + // Overlay covers Color, Gradient and Pattern Overlay. + (EffectKind::Overlay, &["pattern", "gradient", "color"]), + (EffectKind::OuterGlow, &["outer glow"]), + // A turned-off Drop Shadow doesn't count. + (EffectKind::DropShadow, &["shadow"]), + ] { + assert_eq!(listed(doc, &by_effect(effect)), want, "{effect:?}"); + } +} + +#[test] +fn effect_filters_at_once_with_bevel_and_emboss() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Effect, ..UiState::default() }); + assert_eq!(f.effect, EffectKind::BevelEmboss); + assert!(f.active(), "Effect filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_effect_by_id() { + let mut ui = UiState::default(); + for effect in EffectKind::ALL { + let f = super::patched(&ui, &json!({"by": "effect", "effect": effect.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_effect), (FilterBy::Effect, effect)); + } + let err = super::patched(&ui, &json!({"effect": "colorOverlay"})).unwrap_err(); + assert!(err.contains("colorOverlay") && err.contains("bevelEmboss") && err.contains("dropShadow"), "{err}"); + assert!(super::patched(&ui, &json!({"effect": 3})).is_err()); + assert!(super::patched(&ui, &json!({"effect": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_effect_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_effect: EffectKind::Satin, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_effect"], "satin"); + saved.as_object_mut().unwrap().remove("layer_filter_effect"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_effect, EffectKind::BevelEmboss); +} + +/// The names of the rows the Layers panel drew, top to bottom. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> Vec { + let doc = &h.state().session.active().unwrap().doc; + let mut rows = crate::layer_row_ui::recorded(&h.ctx); + rows.sort_by(|a, b| a.row.top().total_cmp(&b.row.top())); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_effect_picked_in_the_dropdown() { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 1200.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Effect").last().expect("Effect in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Effect); + assert_eq!(drawn(&h), ["bevel"], "Bevel & Emboss at first"); + h.get_by_value("Bevel & Emboss").click(); + h.run_steps(4); + h.query_all_by_label("Drop Shadow").last().expect("Drop Shadow in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_effect, EffectKind::DropShadow); + assert_eq!(drawn(&h), ["shadow"]); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/kind_mode_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/kind_mode_tests.rs new file mode 100644 index 0000000000..260bda8812 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/kind_mode_tests.rs @@ -0,0 +1,117 @@ +//! #10878: the Kind and Mode filters list the same layers as the reference application. Fill +//! layers (solid colour without a vector shape, gradient, pattern) match no Kind button, a solid +//! colour fill with a vector mask lists as a shape, and the Mode list has no Pass Through. + +use photocraft_color::BlendMode; +use photocraft_doc::Document; +use serde_json::json; + +use super::{Filter, FilterBy, KINDS}; +use crate::state::UiState; + +fn new_session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +/// Make a layer with `cmd`, name it `name` and return its id. +fn make(s: &mut photocraft_engine::Session, cmd: &str, params: serde_json::Value, name: &str) -> u64 { + let id = s.execute(cmd, params).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + id +} + +/// The names `filter` lists, top to bottom. +fn listed(doc: &Document, filter: &Filter) -> Vec { + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn kinds(kinds: &[&str]) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Kind, layer_filter: kinds.iter().map(|k| k.to_string()).collect(), ..UiState::default() }) +} + +fn mode(mode: BlendMode) -> Filter { + Filter::of(&UiState { layer_filter_by: FilterBy::Mode, layer_filter_mode: mode, ..UiState::default() }) +} + +/// Every kind of fill layer, a solid fill with a vector mask, and a Gradient Map adjustment. +fn fills() -> photocraft_engine::Session { + let mut s = new_session(); + make(&mut s, "layer.newFillLayer.solidColor", json!({"color": "#112233"}), "Solid"); + let shaped = make(&mut s, "layer.newFillLayer.solidColor", json!({"color": "#445566"}), "Solid shape"); + s.execute("layer.vectorMask.revealAll", json!({"layer": shaped})).unwrap(); + make(&mut s, "layer.newFillLayer.gradient", json!({}), "Gradient"); + make(&mut s, "layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"}), "Pattern"); + make(&mut s, "layer.newAdjustmentLayer.gradientMap", json!({}), "Gradient Map"); + s +} + +#[test] +fn fill_layers_match_no_kind_button_and_a_shaped_solid_fill_is_a_shape() { + let s = fills(); + let doc = &s.active().unwrap().doc; + // Each button on its own: the measured table, row by row. + assert_eq!(listed(doc, &kinds(&["adjustment"])), ["Gradient Map"], "only the adjustment layer is an adjustment"); + assert_eq!(listed(doc, &kinds(&["shape"])), ["Solid shape"], "a solid fill with a vector mask lists as a shape"); + assert!(listed(doc, &kinds(&["pixel"])).iter().all(|n| n == "Background"), "no fill layer is a pixel layer"); + // With all five buttons on, the plain solid, gradient and pattern fills are still not listed. + let all = listed(doc, &kinds(&KINDS)); + for fill in ["Solid", "Gradient", "Pattern"] { + assert!(!all.iter().any(|n| n == fill), "{fill} matches no Kind button: {all:?}"); + } + assert!(all.iter().any(|n| n == "Solid shape") && all.iter().any(|n| n == "Gradient Map")); +} + +#[test] +fn a_normal_group_is_listed_by_mode_and_a_pass_through_group_never_is() { + let mut s = new_session(); + make(&mut s, "layer.new.layer", json!({}), "Child"); + let normal = s.execute("layer.groupLayers", json!({"name": "Normal group"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": normal, "blend": "Normal"})).unwrap(); + let child = { + let doc = &s.active().unwrap().doc; + doc.walk().into_iter().find(|(_, _, l)| l.name == "Child").map(|(_, _, l)| l.id.0).unwrap() + }; + s.execute("layer.setProps", json!({"layer": child, "blend": "Multiply"})).unwrap(); + make(&mut s, "layer.new.layer", json!({}), "Other"); + s.execute("layer.groupLayers", json!({"name": "Pass group"})).unwrap(); + let doc = &s.active().unwrap().doc; + let pass = doc.walk().into_iter().find(|(_, _, l)| l.name == "Pass group").map(|(_, _, l)| l.blend).unwrap(); + assert_eq!(pass, BlendMode::PassThrough, "a new group passes through"); + + let normal_rows = listed(doc, &mode(BlendMode::Normal)); + assert!(normal_rows.iter().any(|n| n == "Normal group"), "a Normal group is listed under Normal: {normal_rows:?}"); + assert!(!normal_rows.iter().any(|n| n == "Child"), "its Multiply child is not"); + assert!(listed(doc, &mode(BlendMode::Multiply)).iter().any(|n| n == "Child"), "a Multiply layer inside a group is listed under Multiply"); + assert!(!normal_rows.iter().any(|n| n == "Pass group"), "a Pass Through group is not"); + for m in BlendMode::LAYER_MODES { + assert!(!listed(doc, &mode(m)).iter().any(|n| n == "Pass group"), "no mode lists the Pass Through group ({m:?})"); + } +} + +#[test] +fn the_mode_filter_takes_no_pass_through() { + let ui = UiState::default(); + let err = super::patched(&ui, &json!({"mode": "Pass Through"})).unwrap_err(); + assert!(err.contains("Pass Through"), "the error names what was asked for: {err}"); + for m in BlendMode::LAYER_MODES { + assert!(err.contains(m.label()), "the error names the valid mode {}: {err}", m.label()); + } + // The 27 layer modes still work. + assert_eq!(BlendMode::LAYER_MODES.len(), 27); + assert_eq!(super::patched(&ui, &json!({"mode": "Multiply"})).unwrap().mode, BlendMode::Multiply); + assert_eq!(super::patched(&ui, &json!({"mode": "Normal"})).unwrap().mode, BlendMode::Normal); +} + +#[test] +fn a_stored_pass_through_filter_mode_loads_as_normal() { + let mut stored = serde_json::to_value(UiState::default()).unwrap(); + stored["layer_filter_mode"] = serde_json::to_value(BlendMode::PassThrough).unwrap(); + let ui: UiState = serde_json::from_value(stored.clone()).unwrap(); + assert_eq!(ui.layer_filter_mode, BlendMode::Normal); + // Any other stored mode is kept as it was. + stored["layer_filter_mode"] = serde_json::to_value(BlendMode::Screen).unwrap(); + let ui: UiState = serde_json::from_value(stored).unwrap(); + assert_eq!(ui.layer_filter_mode, BlendMode::Screen); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/selection.rs b/crates/ui-egui/src/panels/layer_filter_ui/selection.rs new file mode 100644 index 0000000000..50eb4f29f1 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/selection.rs @@ -0,0 +1,50 @@ +//! #10879: applying the Layers panel filter, or changing its criteria while it filters, deselects +//! the selected layers it no longer lists, as in Photoshop. The listed ones stay selected (one of +//! them becomes the active layer when the active one is hidden), and turning the filter off changes +//! nothing. Like any layer selection this is no history step. A layer selected later, while the +//! filter hides it, stays selected: the filter only acts when it changes. + +use photocraft_doc::LayerId; +use serde_json::json; + +use super::Filter; +use crate::PhotocraftApp; + +/// Deselect the layers the filter hides when its criteria changed since the last call. The +/// criteria are kept in `ctx`'s memory, so a change from the filter row or from `ui.set +/// {layerFilter}` is seen the next time the row is drawn; with nothing kept yet (the row's first +/// draw, e.g. after `ui.set` while the panel was closed) a filter that is on counts as applied. +pub fn follow(app: &mut PhotocraftApp, ctx: &egui::Context) { + let id = egui::Id::unique("layer-filter-applied"); + let now = Filter::of(&app.ui); + let before = ctx.data_mut(|d| { + let before = d.get_temp::(id); + d.insert_temp(id, now.clone()); + before + }); + if before.is_none_or(|b| b != now) && now.active() { + deselect_hidden(app); + } +} + +/// Deselect the active document's selected layers that [`Filter::of_app`] doesn't list: through +/// `select.deselectLayers` when none stays listed, else by keeping only the listed ones. +pub fn deselect_hidden(app: &mut PhotocraftApp) { + let f = Filter::of_app(app); + if !f.active() { + return; + } + let Some(st) = app.session.active() else { return }; + let selected = st.selected_layers(); + let listed: Vec = selected.iter().copied().filter(|id| st.doc.layer(*id).is_some_and(|l| f.shows(l))).collect(); + if listed.len() == selected.len() && st.active_layer.is_none_or(|a| listed.contains(&a)) { + return; + } + if listed.is_empty() { + let _ = app.run("select.deselectLayers", json!({})); + return; + } + let active = st.active_layer.filter(|a| listed.contains(a)).or(listed.last().copied()); + let anchor = st.layer_anchor.filter(|a| listed.contains(a)).or(active); + let _ = photocraft_engine::layer_multi_cmds::set_selection(&mut app.session, listed, active, anchor); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/selection_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/selection_tests.rs new file mode 100644 index 0000000000..a468741190 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/selection_tests.rs @@ -0,0 +1,138 @@ +//! #10879: applying a Layers panel filter deselects the selected layers it hides, against the +//! cases measured in Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +use super::FilterBy; +use crate::PhotocraftApp; + +/// "Red" and "Deep Pixel" (pixel layers) and "Hello" (type) over the Background, in the panel. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Deep Pixel"})).unwrap(); + let text = s.execute("type.create", json!({"text": "Hello", "x": 4, "y": 20, "size": 12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": text, "name": "Hello"})).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); + h +} + +fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId { + h.state() + .session + .active() + .unwrap() + .doc + .walk() + .into_iter() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| l.id) + .unwrap_or_else(|| panic!("no layer {name}")) +} + +fn select(h: &mut Harness<'_, PhotocraftApp>, names: &[&str]) { + for (i, name) in names.iter().enumerate() { + let layer = id(h, name).0; + h.state_mut().session.execute("layer.select", json!({"layer": layer, "mode": if i == 0 { "replace" } else { "add" }})).unwrap(); + } + h.run_steps(4); +} + +/// The selected layers' names, bottom to top, and the active layer's. +fn selection(h: &Harness<'_, PhotocraftApp>) -> (Vec, Option) { + let st = h.state().session.active().unwrap(); + let name = |id: LayerId| st.doc.layer(id).unwrap().name.clone(); + (st.selected_layers().into_iter().map(name).collect(), st.active_layer.map(name)) +} + +fn none() -> (Vec, Option) { + (Vec::new(), None) +} + +#[test] +fn a_filter_that_hides_the_selected_layer_deselects_it_and_turning_it_off_keeps_that() { + let mut h = harness(); + select(&mut h, &["Red"]); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(selection(&h), none(), "Kind › Type hides Red: no layer is selected"); + h.state_mut().ui.layer_filter.clear(); + h.run_steps(4); + assert_eq!(selection(&h), none(), "turning the filter off does not bring the selection back"); +} + +/// Batch 16 review: a filter already on when the row is first drawn (set before the panel drew, +/// as `ui.set {layerFilter}` can) deselects the layers it hides too. +#[test] +fn a_filter_on_before_the_rows_first_draw_deselects_the_hidden_layers() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("type.create", json!({"text": "Hello", "x": 4, "y": 20, "size": 12})).unwrap(); + let red = s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == "Red").map(|(_, _, l)| l.id).unwrap(); + s.execute("layer.select", json!({"layer": red.0})).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()); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter = vec!["type".into()]; + app + }); + h.run_steps(8); + assert_eq!(selection(&h), none(), "Kind › Type hides Red from the first draw on"); +} + +#[test] +fn a_filter_that_lists_none_of_the_selected_layers_deselects_them_all() { + let mut h = harness(); + select(&mut h, &["Hello", "Deep Pixel"]); + h.state_mut().ui.layer_filter_by = FilterBy::SmartObject; + h.state_mut().ui.layer_filter_smart = vec!["embedded".into()]; + h.run_steps(4); + assert_eq!(selection(&h), none(), "Smart Object › Embedded matches neither"); +} + +#[test] +fn the_listed_layers_stay_selected_and_one_of_them_becomes_active() { + let mut h = harness(); + select(&mut h, &["Red", "Hello", "Deep Pixel"]); + assert_eq!(selection(&h).1.as_deref(), Some("Deep Pixel")); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(selection(&h), (vec!["Hello".to_string()], Some("Hello".to_string()))); + let history = h.state().session.active().unwrap().history.entries().len(); + // Changing the criteria while the filter is on acts again: Name "zzzz" lists nothing. + h.state_mut().ui.layer_filter_by = FilterBy::Name; + h.state_mut().ui.layer_filter_name = "zzzz".into(); + h.run_steps(4); + assert_eq!(selection(&h), none()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), history, "deselecting is no history step"); +} + +#[test] +fn a_layer_selected_while_the_filter_hides_it_stays_selected() { + let mut h = harness(); + select(&mut h, &["Hello"]); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(selection(&h).0, ["Hello"]); + select(&mut h, &["Red"]); + assert_eq!(selection(&h).0, ["Red"], "the filter only acts when it is applied"); +} + +#[test] +fn a_filter_set_through_the_control_channel_deselects_too() { + let mut h = harness(); + select(&mut h, &["Red"]); + let f = super::patched(&h.state().ui, &json!({"by": "name", "name": "hello"})).unwrap(); + super::apply(&mut h.state_mut().ui, f); + h.run_steps(4); + assert_eq!(selection(&h), none()); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/tests.rs index 89e1a6a0b9..50eeb863cd 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/tests.rs @@ -1,4 +1,4 @@ -//! #5048: the Layers panel's filter-type dropdown (Kind, Name, Mode). +//! #5048: the Layers panel's filter-type dropdown (Kind, Name, Mode, Color, Smart Object, Artboard). use egui::vec2; use egui_kittest::Harness; @@ -10,6 +10,7 @@ use serde_json::json; use super::{Filter, FilterBy}; use crate::PhotocraftApp; use crate::state::UiState; +use crate::theme::ThemeKind; /// Background, "Ink" (Multiply) and "Paper" in a group "Sheets", and "ink notes" (type) on top. fn session() -> photocraft_engine::Session { @@ -91,10 +92,17 @@ fn saved_settings_without_the_new_fields_still_load() { } fn harness() -> Harness<'static, PhotocraftApp> { + harness_in(ThemeKind::default()) +} + +fn harness_in(theme: ThemeKind) -> Harness<'static, PhotocraftApp> { let session = session(); 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(session, crate::Services::default()) + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); + app.ui.theme = theme; + app }); h.run_steps(8); h @@ -136,3 +144,285 @@ fn the_dropdown_switches_the_panel_between_filter_types() { h.run_steps(4); assert_eq!(drawn(&h), ["ink notes"]); } + +/// The filter row was drawn in the Pro themes only, while Filter Options › Show is in every +/// theme's panel menu: in Studio and the others it did nothing. +#[test] +fn every_theme_shows_the_filter_row() { + for theme in ThemeKind::ALL { + let mut h = harness_in(theme); + assert!(h.query_by_value("Kind").is_some(), "{theme:?} draws the filter row"); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes"], "{theme:?} filters by kind"); + } +} + +/// #5898: Photoshop's Color filter lists the layers with one colour label; it filters at once, +/// by no colour (None) at first, like Mode. Agents set it with `layerFilter.color`. +#[test] +fn the_color_filter_lists_one_label() { + let mut s = session(); + let ink = layer(&s.active().unwrap().doc, "Ink"); + let sheets = layer(&s.active().unwrap().doc, "Sheets"); + // A group's colour goes to its layers too (Photoshop), so the group first, then Ink inside it. + s.execute("layer.setLabelColor", json!({"layer": sheets, "color": "seafoam"})).unwrap(); + s.execute("layer.setLabelColor", json!({"layer": ink, "color": "red"})).unwrap(); + let doc = &s.active().unwrap().doc; + let by_color = |c: photocraft_doc::LabelColor| Filter::of(&UiState { layer_filter_by: FilterBy::Color, layer_filter_color: c, ..UiState::default() }); + assert!(by_color(photocraft_doc::LabelColor::None).active(), "Color filters at once, as in Photoshop"); + assert_eq!(listed(doc, &by_color(photocraft_doc::LabelColor::Red)), ["Ink"]); + assert_eq!(listed(doc, &by_color(photocraft_doc::LabelColor::Seafoam)), ["Sheets", "Paper"]); + assert_eq!(listed(doc, &by_color(photocraft_doc::LabelColor::None)), ["ink notes", "Background"]); + assert!(listed(doc, &by_color(photocraft_doc::LabelColor::Blue)).is_empty(), "a colour no layer has lists nothing"); + // Over the control channel. + let f = super::patched(&UiState::default(), &json!({"by": "color", "color": "red"})).unwrap(); + assert_eq!((f.by, f.color), (FilterBy::Color, photocraft_doc::LabelColor::Red)); + assert!(super::patched(&UiState::default(), &json!({"color": "crimson"})).unwrap_err().contains("crimson")); + assert!(super::patched(&UiState::default(), &json!({"color": 2})).is_err()); +} + +/// On a Background: the artboard "Back" holding "Grass", the artboard "Front" holding the group +/// "Trees" with "Oak", and "Loose" in no artboard, on top. +fn boards_session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + // Every layer is made before the artboards, since a new layer goes into the artboard it is on. + for name in ["Grass", "Oak", "Loose"] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + } + let grass = layer(&s.active().unwrap().doc, "Grass"); + s.execute("layer.select", json!({"layer": grass})).unwrap(); + s.execute("layer.new.artboardFromLayers", json!({"name": "Back"})).unwrap(); + let oak = layer(&s.active().unwrap().doc, "Oak"); + s.execute("layer.select", json!({"layer": oak})).unwrap(); + s.execute("layer.groupLayers", json!({"name": "Trees"})).unwrap(); + s.execute("layer.new.artboardFromLayers", json!({"name": "Front"})).unwrap(); + let loose = layer(&s.active().unwrap().doc, "Loose"); + s.execute("layer.select", json!({"layer": loose})).unwrap(); + s.execute("layer.arrange.bringToFront", json!({})).unwrap(); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + s +} + +/// The names the Artboard filter lists with the layers `names` selected, every group open. +fn listed_by_artboard(s: &mut photocraft_engine::Session, names: &[&str]) -> Vec { + s.execute("select.deselectLayers", json!({})).unwrap(); + for (i, name) in names.iter().enumerate() { + let id = layer(&s.active().unwrap().doc, name); + s.execute("layer.select", json!({"layer": id, "mode": if i == 0 { "replace" } else { "add" }})).unwrap(); + } + let d = s.active().unwrap(); + let mut f = filter(FilterBy::Artboard, &[], "", BlendMode::Normal); + f.board_rows = super::board_rows(&d.doc, &d.selected_layers()); + listed(&d.doc, &f) +} + +#[test] +fn the_artboard_filter_lists_the_artboards_that_hold_the_selected_layers() { + let mut s = boards_session(); + let all = ["Loose", "Front", "Trees", "Oak", "Back", "Grass", "Background"]; + assert_eq!(listed_by_artboard(&mut s, &[]), all, "nothing selected: every layer"); + // A layer at any depth in an artboard, or the artboard itself: that artboard only. + assert_eq!(listed_by_artboard(&mut s, &["Oak"]), ["Front", "Trees", "Oak"]); + assert_eq!(listed_by_artboard(&mut s, &["Front"]), ["Front", "Trees", "Oak"]); + assert_eq!(listed_by_artboard(&mut s, &["Grass"]), ["Back", "Grass"]); + // Layers in two artboards: both artboards. + assert_eq!(listed_by_artboard(&mut s, &["Oak", "Grass"]), ["Front", "Trees", "Oak", "Back", "Grass"]); + // A selected layer in no artboard: every layer again. + assert_eq!(listed_by_artboard(&mut s, &["Loose"]), all); + assert_eq!(listed_by_artboard(&mut s, &["Oak", "Loose"]), all); + let round = serde_json::to_value(UiState { layer_filter_by: FilterBy::Artboard, ..UiState::default() }).unwrap(); + assert_eq!(round["layer_filter_by"], "artboard"); +} + +#[test] +fn the_artboard_filter_follows_the_selection_in_the_panel() { + let session = boards_session(); + 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(session, crate::Services::default()) + }); + h.run_steps(8); + let all = ["Loose", "Front", "Trees", "Oak", "Back", "Grass", "Background"]; + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Artboard").last().expect("Artboard in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Artboard); + assert_eq!(drawn(&h), all, "Loose, in no artboard, is selected: every layer"); + let oak = layer(&h.state().session.active().unwrap().doc, "Oak"); + h.state_mut().session.execute("layer.select", json!({"layer": oak})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Front", "Trees", "Oak"]); + // The artboard's tree as the panel shows it: a closed group keeps its contents hidden. + let trees = layer(&h.state().session.active().unwrap().doc, "Trees"); + h.state_mut().session.execute("layer.setExpanded", json!({"layer": trees, "expanded": false})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Front", "Trees"]); + h.state_mut().session.execute("select.deselectLayers", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Loose", "Front", "Trees", "Back", "Grass", "Background"], "nothing selected: every row"); +} + +#[test] +fn the_artboard_filter_walks_deep_groups_without_recursion() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size}; + use photocraft_geom::Rect; + use std::collections::HashSet; + + let mut nested = Layer::new("Leaf", LayerContent::Group(Group { children: vec![], expanded: false, artboard: None, frame_stroke: None })); + let selected = nested.id; + let mut expected = HashSet::from([selected]); + for _ in 0..16_384 { + nested = Layer::new("Group", LayerContent::Group(Group { children: vec![nested], expanded: false, artboard: None, frame_stroke: None })); + expected.insert(nested.id); + } + let mut doc = Document::new("Deep groups", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let board = Layer::new( + "Board", + LayerContent::Group(Group { children: vec![nested], expanded: true, artboard: Some(Artboard::new(Rect::new(0, 0, 8, 8))), frame_stroke: None }), + ); + expected.insert(board.id); + doc.layers.push(board); + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn(move || { + let actual = super::board_rows(&doc, &[selected]); + let mut pending = std::mem::take(&mut doc.layers); + while let Some(mut layer) = pending.pop() { + if let Some(children) = layer.children_mut() { + pending.append(children); + } + } + assert_eq!(actual, expected); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Creates a PNG fixture and returns its temporary directory with the file path. +fn art_file() -> (tempfile::TempDir, std::path::PathBuf) { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("art.png"); + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ff0000"})).unwrap(); + s.execute("file.saveACopy", json!({"path": file.to_string_lossy(), "layers": false})).unwrap(); + (directory, file) +} + +/// "Linked art" (linked to `file`) above "Embedded art" (a copy of `file`), "Plain" and +/// "Background". +fn smart_session(file: &std::path::Path) -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Plain"})).unwrap(); + for (command, name) in [("file.placeEmbedded", "Embedded art"), ("file.placeLinked", "Linked art")] { + s.execute(command, json!({"path": file.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + } + s +} + +fn by_smart(smart: &[&str], missing: std::collections::HashSet) -> Filter { + Filter { smart: smart.iter().map(|k| k.to_string()).collect(), missing, ..filter(FilterBy::SmartObject, &[], "", BlendMode::Normal) } +} + +#[test] +fn the_smart_object_filter_lists_linked_missing_and_embedded_smart_objects() { + let (_directory, file) = art_file(); + let mut s = smart_session(&file); + let doc = &s.active().unwrap().doc; + let none = std::collections::HashSet::new; + assert!(!by_smart(&[], none()).active(), "no toggle on: every layer"); + assert_eq!(listed(doc, &by_smart(&[], none())), ["Linked art", "Embedded art", "Plain", "Background"]); + assert_eq!(listed(doc, &by_smart(&["linked"], none())), ["Linked art"]); + assert_eq!(listed(doc, &by_smart(&["embedded"], none())), ["Embedded art"]); + assert_eq!(listed(doc, &by_smart(&["linked", "embedded"], none())), ["Linked art", "Embedded art"]); + assert_eq!(listed(doc, &by_smart(&["missing"], s.missing_links())), Vec::::new(), "the file is there"); + std::fs::remove_file(&file).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, &by_smart(&["missing"], s.missing_links())), ["Linked art"]); + assert_eq!(listed(doc, &by_smart(&["missing", "embedded"], s.missing_links())), ["Linked art", "Embedded art"]); +} + +#[test] +fn saved_settings_without_the_smart_object_toggles_still_load() { + let mut saved = serde_json::to_value(UiState::default()).unwrap(); + assert!(saved.as_object_mut().unwrap().remove("layer_filter_smart").is_some(), "layer_filter_smart is saved"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert!(ui.layer_filter_smart.is_empty()); + let round = + serde_json::to_value(UiState { layer_filter_by: FilterBy::SmartObject, layer_filter_smart: vec!["missing".into()], ..UiState::default() }).unwrap(); + let back: UiState = serde_json::from_value(round.clone()).unwrap(); + assert_eq!( + (round["layer_filter_by"].as_str(), back.layer_filter_by, back.layer_filter_smart), + (Some("smartObject"), FilterBy::SmartObject, vec!["missing".to_string()]) + ); +} + +#[test] +fn the_smart_object_toggles_filter_the_panel() { + let (_directory, file) = art_file(); + let session = smart_session(&file); + 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(session, crate::Services::default()) + }); + h.run_steps(8); + let all = ["Linked art", "Embedded art", "Plain", "Background"]; + assert_eq!(drawn(&h), all); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Smart Object").last().expect("Smart Object in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::SmartObject); + assert_eq!(drawn(&h), all, "no toggle on yet: every layer"); + h.get_by_label("Filter for embedded smart objects").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_smart, ["embedded"]); + assert_eq!(drawn(&h), ["Embedded art"]); + h.get_by_label("Filter for embedded smart objects").click(); + h.get_by_label("Filter for smart objects linked locally").click(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Linked art"]); + h.get_by_label("Filter for smart objects linked locally").click(); + h.get_by_label("Filter for linked smart objects that are missing").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_smart, ["missing"]); + assert_eq!(drawn(&h), Vec::::new(), "the file is there"); + std::fs::remove_file(&file).unwrap(); + h.state_mut().session.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Linked art"]); +} + +#[test] +fn the_artboard_filter_lists_the_outer_artboard_for_a_layer_in_a_nested_artboard() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size}; + use photocraft_geom::Rect; + use std::collections::HashSet; + + fn group(name: &str, children: Vec, artboard: Option) -> Layer { + Layer::new(name, LayerContent::Group(Group { children, expanded: true, artboard, frame_stroke: None })) + } + let leaf = group("Leaf", vec![], None); + let inner = group("Inner", vec![leaf.clone()], Some(Artboard::new(Rect::new(0, 0, 4, 4)))); + let sibling = group("Sibling", vec![], None); + let outer = group("Outer", vec![inner.clone(), sibling.clone()], Some(Artboard::new(Rect::new(0, 0, 8, 8)))); + let other = group("Other", vec![], Some(Artboard::new(Rect::new(8, 0, 8, 8)))); + let mut doc = Document::new("Nested artboards", Size::new(16, 8), ColorMode::Rgb, SampleType::U8); + let (outer_id, inner_id, leaf_id, sibling_id, other_id) = (outer.id, inner.id, leaf.id, sibling.id, other.id); + doc.layers.push(outer); + doc.layers.push(other); + let outer_rows = HashSet::from([outer_id, inner_id, leaf_id, sibling_id]); + assert_eq!(super::board_rows(&doc, &[leaf_id]), outer_rows, "a layer in a nested artboard: the outer artboard's rows"); + assert_eq!(super::board_rows(&doc, &[inner_id]), outer_rows, "the nested artboard itself: the outer artboard's rows"); + let both: HashSet<_> = outer_rows.iter().copied().chain([other_id]).collect(); + assert_eq!(super::board_rows(&doc, &[leaf_id, other_id]), both); +} diff --git a/crates/ui-egui/src/panels/layer_pct_slider_tests.rs b/crates/ui-egui/src/panels/layer_pct_slider_tests.rs index 1d22cd255f..e8d73d94a2 100644 --- a/crates/ui-egui/src/panels/layer_pct_slider_tests.rs +++ b/crates/ui-egui/src/panels/layer_pct_slider_tests.rs @@ -178,3 +178,23 @@ fn opacity_and_fill_stay_visible_but_disabled_with_no_layer_selected() { assert!(node.accesskit_node().is_disabled(), "{name}: should be greyed out"); } } + +/// #9546: the layer list starts at the same place with or without a selected layer. With none, +/// the greyed-out Blend/Opacity and Lock/Fill rows sat in one disabled wrapper, which added an +/// item gap under them: in the themes whose docks keep item spacing (Studio, Classic) the first +/// row moved down 6 points. +#[test] +fn layer_rows_do_not_move_when_no_layer_is_selected() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Classic, crate::theme::ThemeKind::ProMedium] { + let mut h = harness(); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let row_top = |h: &Harness<'_, PhotocraftApp>| by_role(h, "Paint", Role::Button).map(|r| r.top()); + let selected = row_top(&h); + h.state_mut().session.execute("select.deselectLayers", json!({})).unwrap(); + h.run_steps(4); + let none = row_top(&h); + assert!(selected.is_some(), "{theme:?}: the layer row"); + assert_eq!(none, selected, "{theme:?}: the row moved with no layer selected"); + } +} diff --git a/crates/ui-egui/src/panels/layer_props_ui.rs b/crates/ui-egui/src/panels/layer_props_ui.rs index 62f84ecc8a..ea5908abe2 100644 --- a/crates/ui-egui/src/panels/layer_props_ui.rs +++ b/crates/ui-egui/src/panels/layer_props_ui.rs @@ -14,11 +14,14 @@ use crate::{PhotocraftApp, widgets}; /// the top-left corner (Photoshop's Properties W/H fields), or `layer.translate` params for X/Y. /// `tol` is the smallest change, in px, that counts as an edit (see [`edit_tolerance`]). pub fn transform_params(layer: u64, b: [f64; 4], w: Option, h: Option, linked: bool, tol: f32) -> Option { - let (bw, bh) = ((b[2] - b[0]) as f32, (b[3] - b[1]) as f32); - if bw <= 0.0 || bh <= 0.0 { + // Keep the transform geometry in f64. Rounding the linked W/H ratio and + // quad corners to f32 can introduce a tiny anisotropic scale, which makes + // a diagonal live ArrowLine lose its editable parameters after resizing. + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if !bw.is_finite() || !bh.is_finite() || bw <= 0.0 || bh <= 0.0 { return None; } - let (mut nw, mut nh) = (w.unwrap_or(bw).max(1.0), h.unwrap_or(bh).max(1.0)); + let (mut nw, mut nh) = (w.map(f64::from).unwrap_or(bw).max(1.0), h.map(f64::from).unwrap_or(bh).max(1.0)); if linked { if w.is_some() { nh = (nw * bh / bw).max(1.0); @@ -26,10 +29,10 @@ pub fn transform_params(layer: u64, b: [f64; 4], w: Option, h: Option, nw = (nh * bw / bh).max(1.0); } } - if (nw - bw).abs() < tol && (nh - bh).abs() < tol { + if (nw - bw).abs() < f64::from(tol) && (nh - bh).abs() < f64::from(tol) { return None; } - let (x0, y0) = (b[0] as f32, b[1] as f32); + let (x0, y0) = (b[0], b[1]); let quad = [[x0, y0], [x0 + nw, y0], [x0 + nw, y0 + nh], [x0, y0 + nh]]; Some(json!({"layer": layer, "rect": b, "quad": quad})) } @@ -48,15 +51,30 @@ fn edit_tolerance(layer: &Layer, u: LenUnit, extent: f64) -> f32 { /// A number field that reports a value once committed (drag released, Enter, focus lost). `label` is its own /// label and the one stacked with it in the same column ("W" over "X"), which share a centre. -/// `len` is the unit shown and its 100% extent (for percent); `name` is the field's accessible name. +/// `len` is the unit shown, its 100% extent (for percent) and the length a percentage typed into a +/// length field is a share of (`50%` in a px W is half the current width, as in Photoshop, +/// #10784); `name` is the field's accessible name. // The explicit accessible name accompanies the existing numeric-field parameters. #[expect(clippy::too_many_arguments)] -fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), name: &str, current: f32, width: f32, len: (LenUnit, f64), tol: f32) -> Option { - let (u, extent) = len; +fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), name: &str, current: f32, width: f32, len: (LenUnit, f64, f64), tol: f32) -> Option { + let (u, extent, typed_pct) = len; stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0); let key = ui.make_persistent_id(("layer-props-field", id)); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); - let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width).accessible_name(tl!(name)); + // A field showing percent keeps its own 100%, so a length typed into it converts as shown. + // Otherwise the current size, while the field is edited: clicking from W into a linked H + // commits W as H gains focus, and H's `50%` must take half the height W left. Only the size + // a commit was read against is kept, for the next frame: egui reads the text once more the + // frame after it commits, which against the new size would apply `50%` twice. + let pct_key = key.with("pct"); + let typed_extent = if u.unit == photocraft_engine::prefs::Unit::Percent { extent } else { ui.data(|d| d.get_temp(pct_key)).unwrap_or(typed_pct) }; + let units = widgets::FieldUnits { unit: u.unit, dpi: u.dpi, ppi: u.ppi }; + let resp = widgets::value_field_len(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width, units, typed_extent).accessible_name(tl!(name)); + if resp.lost_focus() { + ui.data_mut(|d| d.insert_temp(pct_key, typed_extent)); + } else { + ui.data_mut(|d| d.remove::(pct_key)); + } if resp.dragged() || resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; @@ -164,24 +182,24 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", ("W", "X"), "Width", (bb[2] - bb[0]) as f32, w, (u, dw), tw) { + if let Some(v) = field(ui, "w", ("W", "X"), "Width", (bb[2] - bb[0]) as f32, w, (u, dw, bb[2] - bb[0]), tw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked, tw).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", ("H", "Y"), "Height", (bb[3] - bb[1]) as f32, w, (u, dh), th) { + if let Some(v) = field(ui, "h", ("H", "Y"), "Height", (bb[3] - bb[1]) as f32, w, (u, dh, bb[3] - bb[1]), th) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked, th).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", ("X", "W"), "X", bb[0] as f32, w, (u, dw), 0.5) { + if let Some(v) = field(ui, "x", ("X", "W"), "X", bb[0] as f32, w, (u, dw, dw), 0.5) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - bb[0] as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", ("Y", "H"), "Y", bb[1] as f32, w, (u, dh), 0.5) { + if let Some(v) = field(ui, "y", ("Y", "H"), "Y", bb[1] as f32, w, (u, dh, dh), 0.5) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - bb[1] as f32).round() as i32}))); } }); @@ -345,6 +363,81 @@ mod tests { assert!((at("H") - at("Y")).abs() < 0.01, "H and Y: {centres:?}"); } + /// Types each `(name, text)` of `edits` in turn into the Transform field named `name` of an + /// 80 × 40 px rectangle shape at (10, 10) in a 200 × 120 document, with the rulers in `unit`, + /// and returns its path's [x, y, w, h] (a shape's path scales exactly, where resampled pixels + /// would blur its edge). Each edit is committed with Enter. + fn type_into_transform_fields(unit: photocraft_engine::prefs::Unit, edits: &[(&str, &str)]) -> [f64; 4] { + type_into_transform_fields_by(unit, edits, true) + } + + /// [`type_into_transform_fields`], committing each edit with Enter when `enter`, or else by + /// clicking into the next field (the last edit still with Enter). + fn type_into_transform_fields_by(unit: photocraft_engine::prefs::Unit, edits: &[(&str, &str)], enter: bool) -> [f64; 4] { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.prefs.edit(|p| p.units_and_rulers.rulers = unit); + app.run("file.new", json!({"width": 200, "height": 120})).unwrap(); + app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 80, 40], "fill": "#336699"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(280.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().cumulative_pass_nr() == 0 { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + return; + } + let state = app.session.active().unwrap(); + let layer = state.doc.layer(state.active_layer.unwrap()).unwrap().clone(); + properties(app, ui, &layer); + }, + app, + ); + h.run_steps(3); + for (i, (name, text)) in edits.iter().enumerate() { + h.get_by_label(name).click(); + h.run(); + for c in text.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } + if enter || i + 1 == edits.len() { + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + } + let st = h.state().session.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("still a shape") }; + let c = sh.path.control_bounds().unwrap(); + [c.0, c.1, c.2 - c.0, c.3 - c.1].map(|v| (v * 100.0).round() / 100.0) + } + + /// #10784: a percentage typed into a W or H showing a length is that share of the layer's + /// size, and into X or Y that share of the document, as in Photoshop; never that many pixels. + #[test] + fn transform_fields_take_a_typed_percentage() { + use photocraft_engine::prefs::Unit; + // Linked W/H: half the width halves the height too (typed live, `50` alone would be 50 px). + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "50%")]), [10.0, 10.0, 40.0, 20.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Height", "150%")]), [10.0, 10.0, 120.0, 60.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("X", "50%")]), [100.0, 10.0, 80.0, 40.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Y", "50%")]), [10.0, 60.0, 80.0, 40.0]); + // A second edit takes its percentage of the size the first one left. + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "50%"), ("Width", "50%")]), [10.0, 10.0, 20.0, 10.0]); + // Clicking into the linked H commits W as H takes the focus: H's `50%` is half the height + // W left (#10802 review), and the same from H into W. + assert_eq!(type_into_transform_fields_by(Unit::Pixels, &[("Width", "50%"), ("Height", "50%")], false), [10.0, 10.0, 20.0, 10.0]); + assert_eq!(type_into_transform_fields_by(Unit::Pixels, &[("Height", "50%"), ("Width", "50%")], false), [10.0, 10.0, 20.0, 10.0]); + // And with Enter between them. + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "50%"), ("Height", "50%")]), [10.0, 10.0, 20.0, 10.0]); + // In inches too (72 dpi: 80 px is 1.11 in). + assert_eq!(type_into_transform_fields(Unit::Inches, &[("Width", "50%")]), [10.0, 10.0, 40.0, 20.0]); + // A plain number and another length are unchanged. + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "60")]), [10.0, 10.0, 60.0, 30.0]); + assert_eq!(type_into_transform_fields(Unit::Pixels, &[("Width", "1in")]), [10.0, 10.0, 72.0, 36.0]); + // Rulers in percent show W as a share of the 200 px document; a typed length is that length. + assert_eq!(type_into_transform_fields(Unit::Percent, &[("Width", "40px")]), [10.0, 10.0, 40.0, 20.0]); + assert_eq!(type_into_transform_fields(Unit::Percent, &[("Width", "25")]), [10.0, 10.0, 50.0, 25.0]); + } + #[test] fn transform_fields_show_and_take_the_ruler_unit() { use photocraft_engine::prefs::Unit; @@ -385,6 +478,67 @@ mod tests { assert_eq!((b.x0, b.y0, b.width(), b.height()), (10, 20, 50, 25)); } + /// Linked W/H edits must generate a truly uniform affine scale, not one + /// rounded independently on each axis to f32 precision. + #[test] + fn linked_transform_preserves_scale_precision() { + let b = [203.6, 171.7, 712.4, 690.3]; + let p = transform_params(7, b, Some(600.0), None, true, 0.05).unwrap(); + let x0 = p["quad"][0][0].as_f64().unwrap(); + let y0 = p["quad"][0][1].as_f64().unwrap(); + let x1 = p["quad"][1][0].as_f64().unwrap(); + let y1 = p["quad"][2][1].as_f64().unwrap(); + let sx = (x1 - x0) / (b[2] - b[0]); + let sy = (y1 - y0) / (b[3] - b[1]); + assert!((sx - sy).abs() < 1e-12, "linked resize produced different scales: {sx} and {sy}"); + } + + /// A diagonal arrow must remain live after a proportional width edit in + /// Properties, while its outline is transformed by the engine as usual. + #[test] + fn linked_properties_resize_keeps_diagonal_arrow_editable() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 1200, "height": 1200})).unwrap(); + s.execute( + "shape.create", + json!({ + "kind": "line", + "from": [203.6, 171.7], + "to": [712.4, 690.3], + "weight": 20.0, + "arrowStart": false, + "arrowEnd": true, + "arrowWidth": 55.0, + "arrowLength": 85.0, + "arrowConcavity": 50.0 + }), + ) + .unwrap(); + let (id, b) = { + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let layer = st.doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected shape layer") }; + assert!(matches!(shape.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { .. }))); + let px = photocraft_compose::bounds::content_bounds(layer.surface().unwrap()); + (id, frame(layer, px)) + }; + let p = transform_params(id.0, b, Some(600.0), None, true, 0.05).unwrap(); + s.execute("edit.transform", p).unwrap(); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected shape layer") }; + assert!( + matches!(shape.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { .. })), + "proportional Properties resize must preserve live arrow settings" + ); + + // The next live-property edit must still be accepted after resizing. + s.execute("shape.edit", json!({"layer": id.0, "arrowConcavity": 60.0})).unwrap(); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("expected shape layer") }; + assert!(matches!(shape.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { concavity, .. }) if (*concavity - 60.0).abs() < 1e-9)); + } + /// A stroked shape's Transform fields show its path, not the stroke's pixels, and a W edit /// scales the path to that width, as in Photoshop. #[test] diff --git a/crates/ui-egui/src/panels/layer_row.rs b/crates/ui-egui/src/panels/layer_row.rs index 6bc0ac85b2..cb29276091 100644 --- a/crates/ui-egui/src/panels/layer_row.rs +++ b/crates/ui-egui/src/panels/layer_row.rs @@ -112,6 +112,11 @@ pub(super) fn paint_clipping_indicator(painter: &egui::Painter, at: Pos2, color: painter.add(egui::Shape::line(vec![at + vec2(2.0, 0.0), tip, at + vec2(8.0, 0.0)], stroke)); } +/// Height of an artboard's row. Photoshop sets artboards apart from layers and groups in the +/// Layers panel: a shorter row with the name in bold and no thumbnail (a board is a canvas, not a +/// folder), so the boards read as headings over their layers (#10218). +pub(super) const ARTBOARD_ROW_H: f32 = 26.0; + /// 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)] @@ -129,6 +134,12 @@ pub(super) fn layer_row( let t = Tokens::get(ctx); // 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); + // A board's row is a compact heading with no thumbnail, never taller than a layer's (with + // thumbnails off every row is already compact). A board with a mask keeps its usual row, + // thumbnail and all: the thumbnail is what clicks go back to from the mask, what ends the mask + // view, what carries the target brackets, and what spaces the mask's chain from the triangle. + let compact = l.artboard().is_some() && l.mask.is_none() && l.vector_mask.is_none(); + let (base, row_h) = if compact { (0.0, row_h.min(ARTBOARD_ROW_H)) } else { (base, row_h) }; 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 { @@ -292,6 +303,11 @@ fn paint_row_background(painter: &egui::Painter, t: &Tokens, l: &Layer, rect: Re painter.rect_filled(rect, t.radius, t.hover.gamma_multiply(0.5)); painter.rect_filled(rect, t.radius, t.accent.gamma_multiply(0.12)); } + if l.artboard().is_some() && !selected && !is_hovered { + // A faint band behind a board's row, so where each board starts stands out in a long + // list. Under the label colour and the dividers, which keep their look on every row. + painter.rect_filled(rect, if t.pro { 0.0 } else { t.radius }, t.hover.gamma_multiply(0.35)); + } let eye_cell = eye_cell(rect); if let Some((_, background)) = t.layer_label_colors(l.label) { painter.rect_filled(eye_cell, 0.0, background); @@ -360,7 +376,15 @@ fn paint_target_brackets(app: &PhotocraftApp, painter: &egui::Painter, t: &Token fn paint_row_name(painter: &egui::Painter, t: &Tokens, rc: &RowCtx<'_>, x: f32, name_right: f32) -> Option { let (l, doc, rect, ts, selected) = (rc.l, rc.doc, rc.rect, rc.ts, rc.row.selected); let name_color = if l.visible { t.text } else { t.text_faint }; - let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; + let size = if t.pro { 12.0 } else { 13.0 }; + let font = if l.artboard().is_some() { + // Artboards are named in bold, as in Photoshop. + theme::semibold(size) + } else if selected && !t.pro { + theme::medium(13.0) + } else { + egui::FontId::proportional(size) + }; // 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. diff --git a/crates/ui-egui/src/panels/layer_row_ui.rs b/crates/ui-egui/src/panels/layer_row_ui.rs index 7e89c50519..82d924bacf 100644 --- a/crates/ui-egui/src/panels/layer_row_ui.rs +++ b/crates/ui-egui/src/panels/layer_row_ui.rs @@ -127,14 +127,14 @@ pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color } painter.layout_job(job) }; - // A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end and - // then put in display order, so its start stays and "…" is at the left (`bidi_text::elide`). + // A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end, so its + // start stays and "…" is at the left (`bidi_text::elide`). if let Some(shown) = crate::bidi_text::elide(text, |s| layout(s, false).size().x <= max_width) { return Some(layout(&shown, false)); } - // Right-to-left names in display order: egui lays words out left to right. Left-to-right - // names are cut at their right end by egui. - Some(layout(&crate::bidi_text::visual(text), true)) + // egui puts a right-to-left name into display order itself; left-to-right names are cut at + // their right end by egui. + Some(layout(text, true)) } /// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the diff --git a/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs index 0c217dd3d0..65496cbe30 100644 --- a/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/panels/layer_row_ui/edit_tests.rs @@ -153,8 +153,8 @@ fn mask_double_click_launches_select_and_mask_with_preference() { let at = masks.iter().find(|(kind, _)| *kind == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center(); double_click(&mut h, at); assert!(h.state().ui.mask_target); - let dialog = h.state().ui.dialogs.last().expect("select and mask dialog should be opened"); - assert_eq!(dialog.fields.get("__command").and_then(|v| v.as_str()), Some("select.refineEdge")); + let w = h.state().distort.describe()["selectAndMask"].clone(); + assert_eq!(w["settings"]["output"].as_str(), Some("layerMask"), "the Select and Mask workspace opens on the mask: {w}"); } #[test] diff --git a/crates/ui-egui/src/panels/layer_row_ui/tests.rs b/crates/ui-egui/src/panels/layer_row_ui/tests.rs index d1a3dec1e0..d78b37a262 100644 --- a/crates/ui-egui/src/panels/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/panels/layer_row_ui/tests.rs @@ -742,3 +742,90 @@ fn a_hidden_layers_eye_box_paints_nothing() { assert_eq!(in_eye_box(hidden), 0, "{theme}: a hidden layer's eye box is left empty"); } } + +/// #10218: an artboard's row is set apart from layers and groups, as in Photoshop: shorter, with no +/// thumbnail (a board isn't a folder), while a plain group keeps its folder thumbnail. +#[test] +fn artboard_rows_have_no_thumbnail_and_a_shorter_row() { + // A board holding a layer, and a plain group beside it; returns their ids. + let make = || { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let layer = s.execute("layer.new.layer", json!({"name": "Inside"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let board = s.execute("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.new.layer", json!({"name": "Loose"})).unwrap(); + let group = s.execute("layer.groupLayers", json!({"name": "Folder"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + (s, board, layer, group) + }; + for theme in ["pro", "studio"] { + let (s, board, layer, group) = make(); + let h = harness(s, 1.0, theme, 320.0); + let rows = recorded(&h.ctx); + let row = |id: u64| rows.iter().find(|r| r.layer == id).unwrap_or_else(|| panic!("{theme}: row for {id}")); + let (b, l, g) = (row(board), row(layer), row(group)); + assert!(b.thumbnail.is_none(), "{theme}: the artboard has no thumbnail"); + assert!(g.thumbnail.is_some(), "{theme}: a plain group keeps its folder thumbnail"); + assert!(l.thumbnail.is_some(), "{theme}: a layer keeps its thumbnail"); + assert!(b.row.height() < l.row.height(), "{theme}: the artboard row is shorter ({} vs {})", b.row.height(), l.row.height()); + assert!(b.name.is_some(), "{theme}: the board's name is shown"); + } +} + +/// A board with a mask keeps its usual row, thumbnail and all, so the mask thumbnail and +/// everything the board's thumbnail does on a masked layer still work there (#10218). +#[test] +fn a_masked_artboard_row_keeps_its_thumbnail_and_mask_thumbnail() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let layer = s.execute("layer.new.layer", json!({"name": "Inside"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let board = s.execute("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerMask.revealAll", json!({"layer": board})).unwrap(); + let h = harness(s, 1.0, "pro", 320.0); + let rows = recorded(&h.ctx); + let row = |id: u64| rows.iter().find(|r| r.layer == id).unwrap_or_else(|| panic!("row for {id}")); + let (b, l) = (row(board), row(layer)); + assert!(b.thumbnail.is_some(), "a masked board keeps its thumbnail"); + let (thumbs, chains) = crate::mask_thumbs_ui::recorded(&h.ctx, board).expect("the board's mask thumbnail is drawn"); + let mask = thumbs.iter().find(|(k, _)| *k == crate::mask_thumbs_ui::MaskKind::Pixel).expect("a pixel mask thumbnail").1; + assert!(b.row.contains_rect(mask), "the mask thumbnail sits in the board's row"); + assert_eq!(b.row.height(), l.row.height(), "a layer's height"); + // The triangle's widget is its whole hit area; the chain sits clear of it. + let hit = h.get_by_label("Collapse group Board").rect(); + let chain = chains.iter().find(|(k, _)| *k == crate::mask_thumbs_ui::MaskKind::Pixel).expect("a mask chain").1; + assert!(chain.left() >= hit.right(), "the chain ({chain:?}) is clear of the triangle's hit area ({hit:?})"); +} + +/// On a masked board, as on any masked layer: ⌥-clicking the mask thumbnail shows the mask, and +/// clicking the board's thumbnail ends the mask view and targets the board. +#[test] +fn a_masked_artboards_thumbnail_retargets_it_and_ends_the_mask_view() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Inside"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let board = s.execute("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerMask.revealAll", json!({"layer": board})).unwrap(); + s.execute("layer.select", json!({"layer": board})).unwrap(); + let mut h = harness(s, 1.0, "pro", 320.0); + let (thumbs, _) = crate::mask_thumbs_ui::recorded(&h.ctx, board).expect("mask thumbnail drawn"); + let mask = thumbs.iter().find(|(k, _)| *k == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1; + let viewing = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some(); + // ⌥-click the mask thumbnail: the mask is shown. + h.hover_at(mask.center()); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: mask.center(), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: mask.center(), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + assert!(viewing(&h), "⌥-click shows the mask"); + let thumb = recorded(&h.ctx).iter().find(|r| r.layer == board).and_then(|r| r.thumbnail).expect("the board's thumbnail"); + click(&mut h, thumb.center()); + assert!(!h.state().ui.mask_target, "the board is targeted again"); + assert!(!viewing(&h), "and the mask view ends"); +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index fe0f8c9577..ea5d672058 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -49,56 +49,59 @@ pub(super) fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> /// greyed out at Normal, 100% and unlocked, instead of removing them (#8880). Same layout as the /// active layer's rows in `layers`. fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { - ui.add_enabled_ui(false, |ui| { - ui.horizontal(|ui| { - let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") }; - let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); - let mut m = BlendMode::Normal; - let options = blend_options(false, m); - widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut o = 100.0; - widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); - if show_opacity_label { - scrub_pct_label(ui, opacity_label, &mut o, ui.available_width()); + // Each row is disabled on its own and laid out as the active layer's rows are: a disabled + // wrapper around both added an item gap under the Fill row, which pushed the layer list down + // in the themes whose docks keep item spacing (#9546). + ui.horizontal(|ui| { + ui.disable(); + let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") }; + let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); + let mut m = BlendMode::Normal; + let options = blend_options(false, m); + widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut o = 100.0; + widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); + if show_opacity_label { + scrub_pct_label(ui, opacity_label, &mut o, ui.available_width()); + } + }); + }); + ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0)); + ui.horizontal(|ui| { + ui.disable(); + let lock_label = if pro { tl!("Lock:") } else { tl!("Lock") }; + let lang = crate::i18n::current(); + let fill_label = if pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") }; + let gap = ui.spacing().item_spacing.x; + let icons_w = if pro { 5.0 * 20.0 + gap } else { 22.0 + gap }; + let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width(); + if fits { + label(ui, lock_label); + } + if pro { + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (icon, tip) in [ + ("grid-3x3", tl!("Lock transparent pixels")), + ("brush", tl!("Lock image pixels")), + ("move", tl!("Lock position")), + ("scan", tl!("Prevent auto-nesting in and out of Artboards and Frames")), + ("lock", tl!("Lock all")), + ] { + icons::button(ui, icon, 20.0, false, tip); } }); + } else { + icons::button(ui, "lock-open", 22.0, false, tl!("Lock layer")); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut f = 100.0; + widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); + scrub_pct_label(ui, fill_label, &mut f, ui.available_width()); }); - ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0)); - ui.horizontal(|ui| { - let lock_label = if pro { tl!("Lock:") } else { tl!("Lock") }; - let lang = crate::i18n::current(); - let fill_label = if pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") }; - let gap = ui.spacing().item_spacing.x; - let icons_w = if pro { 5.0 * 20.0 + gap } else { 22.0 + gap }; - let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width(); - if fits { - label(ui, lock_label); - } - if pro { - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (icon, tip) in [ - ("grid-3x3", tl!("Lock transparent pixels")), - ("brush", tl!("Lock image pixels")), - ("move", tl!("Lock position")), - ("scan", tl!("Prevent auto-nesting in and out of Artboards and Frames")), - ("lock", tl!("Lock all")), - ] { - icons::button(ui, icon, 20.0, false, tip); - } - }); - } else { - icons::button(ui, "lock-open", 22.0, false, tl!("Lock layer")); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut f = 100.0; - widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); - scrub_pct_label(ui, fill_label, &mut f, ui.available_width()); - }); - }); - ui.add_space(2.0); }); + ui.add_space(2.0); } pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { @@ -120,7 +123,8 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 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 { + // Every theme draws the filter row: Filter Options › Show is in every theme's panel menu. + if app.ui.layers_panel_options.show_filters { crate::layer_filter_ui::bar(app, ui); ui.add_space(4.0); } @@ -267,7 +271,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.scroll_with_delta(vec2(0.0, delta)); ctx.request_repaint(); } - let filter = crate::layer_filter_ui::Filter::of(&app.ui); + let filter = crate::layer_filter_ui::Filter::of_app(app); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last @@ -377,7 +381,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else { ("layer.delete", "Delete layer") }; - let trash = icons::button(ui, "trash", 26.0, false, tl!(label)); + let trash = icons::button(ui, icons::FOOTER_DELETE, icons::FOOTER_SIZE, false, tl!(label)); // A selected smart filter row is what Delete removes, as in Photoshop (#4751). let picked_filter = crate::smart_ui::selected(&ctx, &doc, active); if trash.clicked() @@ -408,7 +412,8 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A click follows the selected thumbnail; name that action for screen readers even // though dropping a whole layer row onto the same button always deletes the layer. trash.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(label))); - let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); + let new_layer = + icons::button(ui, icons::FOOTER_NEW, icons::FOOTER_SIZE, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); if new_layer.clicked() { // A click makes the layer above the selection; ⌥-click asks first (the New Layer // dialog) and ⌘/Ctrl-click adds it below, as in Photoshop. @@ -423,7 +428,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.push(("layer.new.layer".into(), params)); } actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); - let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); + let group = icons::button(ui, icons::FOOTER_NEW_GROUP, icons::FOOTER_SIZE, false, tl!("Create a new group")); if group.clicked() { // Like Photoshop CC 2014 and later, a click with two or more layers selected puts them // in the new group (Layer › Group Layers); with one layer or none it adds an empty @@ -460,6 +465,9 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if menu.item(ui, tl!("Gradient…")) { actions.push(("layer.newFillLayer.gradient".into(), json!({}))); } + if menu.item(ui, tl!("Pattern…")) { + actions.push(("layer.newFillLayer.pattern".into(), json!({}))); + } }); // Like Photoshop the button never replaces a mask (#2075): with a layer mask it adds a // vector mask, and with both it is greyed. @@ -512,6 +520,9 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; app.ui.filter_mask_target = None; + if app.ui.mask_target { + app.target_mask_over_colour_rows(); + } app.sync_mask_targets(); continue; } @@ -534,9 +545,10 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.dock.collapsed.retain(|g| *g != crate::dock::Group::Properties); continue; } - if p.is_null() || id == "layer.newFillLayer.solidColor" { - // Context-menu items and the Layers footer's Solid Color… action behave like - // their menu-bar twins. In particular, a new Solid Color fill opens its picker. + if p.is_null() || id == "layer.newFillLayer.solidColor" || id == photocraft_engine::pattern_fill_cmds::CREATE { + // Context-menu items and the Layers footer's Solid Color… and Pattern… actions behave + // like their menu-bar twins: a new Solid Color fill opens its picker, a new Pattern + // fill the Pattern Fill dialog. let ctx = ui.ctx().clone(); let params = if p.is_null() { json!({}) } else { p }; if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { @@ -573,7 +585,7 @@ fn footer_menu_button(ui: &mut egui::Ui, icon: &str, size: f32, tip: &str) -> eg } /// Photoshop's italic "fx" footer button (no icon-font equivalent). -fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response { +pub(crate) fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); if resp.hovered() { diff --git a/crates/ui-egui/src/panels/mask_properties_tests.rs b/crates/ui-egui/src/panels/mask_properties_tests.rs index 782337ed36..0fcc9de195 100644 --- a/crates/ui-egui/src/panels/mask_properties_tests.rs +++ b/crates/ui-egui/src/panels/mask_properties_tests.rs @@ -293,21 +293,22 @@ fn refine_invert_inverts_the_mask_and_leaves_the_layer_alone() { } #[test] -fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { - for (label, open) in [("Select and Mask…", "select.refineEdge"), ("Color Range…", crate::color_range_ui::COMMAND)] { - let mut h = harness(false, crate::theme::ThemeKind::Pro); - assert!(h.state().ui.dialogs.is_empty()); - h.get_by_label(label).click(); - h.run_steps(3); - let dialogs = &h.state().ui.dialogs; - assert_eq!(dialogs.len(), 1, "{label} opens one dialog"); - let fields = &dialogs[0].fields; - let opened = fields.get("__command").and_then(Value::as_str).unwrap_or(crate::color_range_ui::COMMAND); - assert_eq!(opened, open, "{label} opened {opened}"); - if open == crate::color_range_ui::COMMAND { - assert!(crate::color_range_ui::owns(fields), "the Color Range dialog, not the generic one"); - } - } +fn refine_row_opens_the_select_and_mask_workspace_and_color_range_dialog() { + // Select and Mask… opens Photoshop's workspace on the targeted mask. + let mut h = harness(false, crate::theme::ThemeKind::Pro); + h.get_by_label("Select and Mask…").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty(), "a workspace, not a dialog"); + let w = h.state().distort.describe()["selectAndMask"].clone(); + assert_eq!(w["settings"]["output"].as_str(), Some("layerMask"), "it refines the mask: {w}"); + // Color Range… opens its own dialog. + let mut h = harness(false, crate::theme::ThemeKind::Pro); + assert!(h.state().ui.dialogs.is_empty()); + h.get_by_label("Color Range…").click(); + h.run_steps(3); + let dialogs = &h.state().ui.dialogs; + assert_eq!(dialogs.len(), 1, "Color Range… opens one dialog"); + assert!(crate::color_range_ui::owns(&dialogs[0].fields), "the Color Range dialog, not the generic one"); } /// A Studio-layout theme's floating Properties card, with no mask targeted. diff --git a/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs index f5ea5f9fc1..aeb8538256 100644 --- a/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/panels/mask_thumbs_ui/tests.rs @@ -611,7 +611,9 @@ fn clicking_the_vector_mask_targets_it_for_the_path_tools() { let k = layer(&h, masked).vector_mask.unwrap().path.subpaths[0].knots[0].anchor; assert_eq!((k.x, k.y), (25.0, 12.0)); // The Pen adds a subpath to it. - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = + Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], owner, ..Default::default() }); crate::vector_ui::pen_commit(h.state_mut(), true); assert_eq!(layer(&h, masked).vector_mask.unwrap().path.subpaths.len(), 2); assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "not a work path"); @@ -836,3 +838,34 @@ fn a_new_adjustment_layer_brings_properties_to_the_front() { assert!(!h.state().ui.dock.is_collapsed(crate::dock::Group::Properties), "the group is expanded"); assert!(h.query_all_by_label("Reset to defaults").next().is_some(), "the Curves settings show"); } + +#[test] +fn selecting_an_adjustment_layer_from_another_layer_targets_its_mask() { + // The reference application: an adjustment layer picked by its name targets its mask; a + // masked pixel layer targets its contents; the content thumbnail still targets contents. + for command in ["layer.newAdjustmentLayer.hueSaturation", "layer.newAdjustmentLayer.curves"] { + let (mut s, masked, other) = session(); + s.execute(command, json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + let mut h = harness(s, 0, 1.0, 360.0); + assert!(layer(&h, id).mask.is_some(), "{command}: the adjustment layer has a mask"); + let name_of = + |h: &Harness<'_, PhotocraftApp>, l: u64| crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == l).unwrap().name.unwrap().center(); + let at = name_of(&h, other); + click_with(&mut h, at, Modifiers::NONE); + let at = name_of(&h, id); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id); + assert!(h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a name click targets the mask"); + assert_eq!(crate::canvas::paint_target(h.state()), json!("mask")); + // The content thumbnail targets the contents. + let thumb = layer_thumb_center(&h, id); + click_with(&mut h, thumb, Modifiers::NONE); + assert!(!h.state().ui.mask_target, "{command}: the content thumbnail targets contents"); + // A masked pixel layer picked by its name targets its contents. + let at = name_of(&h, masked); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, masked); + assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a pixel layer targets its contents"); + } +} diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index b588288c47..256d0577da 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -121,7 +121,8 @@ pub(super) fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 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)) .accessible_name(tl!("Zoom")); if field.changed() { - set(app, pct / 100.0, false); + let animated = app.session.prefs().general.animated_zoom; + crate::zoom_levels::set_from_field(&mut app.ui.views[idx], field.dragged() || field.drag_started(), pct, animated); } let out = crate::brush_picker::named(icons::button(ui, "minus", NAV_ROW_H, false, tl!("Zoom Out")), "Zoom Out"); if out.clicked() { diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index e03fa24e7a..55d160d459 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -40,6 +40,7 @@ pub(super) fn options_bar_height(pro: bool, narrow: bool) -> f32 { } pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + crate::vector_ui::track_arrow_default_selection(app, ui.ctx()); let t = Tokens::get(ui.ctx()); let narrow = app.session.prefs().workspace.narrow_options_bar(); egui::Panel::top("options_bar") @@ -539,7 +540,7 @@ fn selection_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: crate::canvas::commit_crop(app); } if icons::button(ui, "ban", 26.0, false, tl!("Cancel current crop operation (Esc)")).clicked() { - crate::crop_ui::cancel(app); + crate::crop_ui::dismiss(app); } }); } diff --git a/crates/ui-egui/src/panels/preset_panels.rs b/crates/ui-egui/src/panels/preset_panels.rs index fcd33e89cb..29b5d85a95 100644 --- a/crates/ui-egui/src/panels/preset_panels.rs +++ b/crates/ui-egui/src/panels/preset_panels.rs @@ -383,6 +383,7 @@ fn draw_group_header(ui: &mut egui::Ui, group: &GroupView, context: GroupHeaderC if response.hovered() || response.dragged() { ui.painter().rect_filled(rect, 2.0, tokens.hover.gamma_multiply(0.5)); } + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), open, &group.name)); let triangle = Rect::from_center_size(pos2(rect.left() + 9.0, rect.center().y), vec2(12.0, 12.0)); crate::icons::paint(ui, triangle, if open { "chevron-down" } else { "chevron-right" }, 10.0, tokens.text_dim); let folder = Rect::from_center_size(pos2(rect.left() + 25.0, rect.center().y), vec2(14.0, 14.0)); @@ -598,7 +599,7 @@ pub(crate) fn browser( ev.push(Ev::Drop(key, pos)); } } - // Footer: thumbnail size | New Group, New, Delete. + // Footer: thumbnail size | New Group, New, Delete (same glyphs and size as the Layers footer). let footer_top = ui.min_rect().bottom(); ui.add_space(4.0); ui.horizontal(|ui| { @@ -611,15 +612,15 @@ pub(crate) fn browser( st.sizes.insert(panel.to_string(), s); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::icons::button(ui, "trash", 24.0, false, tl!("Delete")).clicked() + if crate::icons::button(ui, crate::icons::FOOTER_DELETE, crate::icons::FOOTER_SIZE, false, tl!("Delete")).clicked() && let Some(k) = selected.clone() { ev.push(Ev::Delete(k)); } - if crate::icons::button(ui, "plus", 24.0, false, new_tip).clicked() { + if crate::icons::button(ui, crate::icons::FOOTER_NEW, crate::icons::FOOTER_SIZE, false, new_tip).clicked() { ev.push(Ev::New); } - if crate::icons::button(ui, "folder-plus", 24.0, false, tl!("Create new group")).clicked() { + if crate::icons::button(ui, crate::icons::FOOTER_NEW_GROUP, crate::icons::FOOTER_SIZE, false, tl!("Create new group")).clicked() { ev.push(Ev::NewGroup); } }); diff --git a/crates/ui-egui/src/panels/preset_panels/panels.rs b/crates/ui-egui/src/panels/preset_panels/panels.rs index 7aa89c5ccc..4b0e05fbd6 100644 --- a/crates/ui-egui/src/panels/preset_panels/panels.rs +++ b/crates/ui-egui/src/panels/preset_panels/panels.rs @@ -47,7 +47,11 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // ------------------------------------------------------------------ Patterns pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - photocraft_engine::presets::patterns::sync(&mut app.session); + // Until the preset store has loaded the saved patterns, their folder entries would look + // deleted: don't prune them yet. + if app.services.preset_store.is_none() { + photocraft_engine::presets::patterns::sync(&mut app.session); + } let lib = app.session.patterns.items.clone(); let groups: Vec = app .session diff --git a/crates/ui-egui/src/panels/props_layout/tests.rs b/crates/ui-egui/src/panels/props_layout/tests.rs index 88a61c518c..78dae11c4f 100644 --- a/crates/ui-egui/src/panels/props_layout/tests.rs +++ b/crates/ui-egui/src/panels/props_layout/tests.rs @@ -147,14 +147,22 @@ fn type_controls_fill_the_panel_at_every_width_and_scale() { let hs = headers(&h); let header = hs.iter().find(|(n, _)| n == "Character").unwrap().1; let what = format!("{ppp}x @ {dw}"); - // Font family and style dropdowns span the section. + // The editable family field and the style dropdown span the section. + let name = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .find(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0) + .expect("editable family field"); + let mut family = name.expand2(egui::vec2(6.0, 2.0)); + family.max.x += crate::widgets::POPUP_ARROW_W; + assert!((family.left() - header.left()).abs() <= 2.0 && (family.right() - header.right()).abs() <= 2.0, "{what}: {family:?} vs {header:?}"); let combos: Vec = h .query_all_by_role(egui::accesskit::Role::ComboBox) .map(|n| n.rect()) .filter(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0) .collect(); - assert!(combos.len() >= 2, "{what}: {combos:?}"); - for r in &combos[..2] { + assert!(!combos.is_empty(), "{what}: {combos:?}"); + for r in &combos[..1] { assert!((r.left() - header.left()).abs() <= 2.0 && (r.right() - header.right()).abs() <= 2.0, "{what}: {r:?} vs {header:?}"); } // Number fields end at the section's right edge (the second column), never past it. @@ -195,7 +203,9 @@ fn layers_opacity_and_fill_rows_keep_a_gap() { let (s, layers) = kinds(); let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - PhotocraftApp::new(s, crate::Services::default()) + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = crate::state::Tool::Brush; + app }); set(&mut h, json!({"dockWidth": dw})); select(&mut h, layers[0].1); diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 8e531b47b6..7fda8c2ee4 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -97,6 +97,18 @@ pub fn paint_badge(painter: &egui::Painter, center: egui::Pos2, color: Color32) } } +/// Select smart object `layer` now, ahead of its filter editor. The editor captures the +/// document's revision as its target, so the click's queued `layer.select` must not run after it +/// opens (OK would refuse the edit): select first, as the Layers panel runs it. False when the +/// selection failed and the editor must stay closed. +fn select_before_editor(app: &mut PhotocraftApp, layer: u64, actions: &mut Vec<(String, Value)>) -> bool { + actions.retain(|(id, p)| !(id == "layer.select" && p.get("layer").and_then(Value::as_u64) == Some(layer))); + if app.ui.text_edit.is_some() { + crate::type_tool::commit(app); + } + app.run("layer.select", json!({"layer": layer})).is_ok() +} + /// 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 }; @@ -202,6 +214,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l } if let Some(i) = index && hit(egui::Response::double_clicked) + && (active || select_before_editor(app, l.id.0, actions)) { open_editor(app, ui.ctx(), l.id.0, sm, i); } diff --git a/crates/ui-egui/src/panels/status_bar.rs b/crates/ui-egui/src/panels/status_bar.rs index 79ca2ef355..4313e64839 100644 --- a/crates/ui-egui/src/panels/status_bar.rs +++ b/crates/ui-egui/src/panels/status_bar.rs @@ -31,14 +31,12 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (w, h, mode, bits, layers) = (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); let mut pct = app.ui.views[i].zoom * 100.0; - if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(78.0, control_h)) - .accessible_name(tl!("Zoom")) - .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; + let field = + widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(78.0, control_h)) + .accessible_name(tl!("Zoom")); + if field.changed() { + let animated = app.session.prefs().general.animated_zoom; + crate::zoom_levels::set_from_field(&mut app.ui.views[i], field.dragged() || field.drag_started(), pct, animated); } widgets::vline(ui, 16.0); label(ui, &crate::i18n::fmt(tl!("{mode} Color · {bits} bit"), &[("mode", mode), ("bits", &bits.to_string())])); diff --git a/crates/ui-egui/src/panels/title_bar.rs b/crates/ui-egui/src/panels/title_bar.rs index d93315af10..fd760ec711 100644 --- a/crates/ui-egui/src/panels/title_bar.rs +++ b/crates/ui-egui/src/panels/title_bar.rs @@ -132,24 +132,23 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if system { return; } - // A right-to-left document name in display order (the OS title keeps the logical text), - // in a left-to-right line: "— PhotoCraft" stays after the name, at the right. - let title = crate::bidi_text::visual_ltr(&title).into_owned(); let font = theme::medium(13.0); - let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim); + let galley = ui.painter().layout_job(title_job(&title, font.clone(), t.text_dim)); if let Some(x) = title_x(full.center().x, free.0, free.1, galley.size().x) { ui.painter().galley(egui::pos2(x, full.center().y - galley.size().y / 2.0), galley, t.text_dim); } else if free.1 - free.0 > 80.0 { // Too narrow for the whole name: show the start of it, elided, in the free gap // next to the menus. let avail = free.1 - free.0 - 2.0 * TITLE_GAP; - let mut job = egui::text::LayoutJob::simple_singleline(title, font, t.text_dim); + let mut job = title_job(&title, font, t.text_dim); job.wrap = egui::text::TextWrapping::truncate_at_width(avail); let g = ui.painter().layout_job(job); let x = if rtl { free.1 - TITLE_GAP - g.size().x } else { free.0 + TITLE_GAP }; ui.painter().galley(egui::pos2(x, full.center().y - g.size().y / 2.0), g, t.text_dim); } }); + // A click anywhere on the bar keeps a zoom being typed (zoom_field.rs). + crate::zoom_field::set_title_bar_rect(ui.ctx(), bar.response.rect); if custom { crate::titlebar::caption_buttons(app, ui, bar.response.rect); } @@ -185,6 +184,14 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// The in-app title line. It reads left to right as a whole, so a right-to-left document name keeps +/// "— PhotoCraft" after it, at the right; egui orders the name's own words. +pub(crate) fn title_job(title: &str, font: egui::FontId, color: egui::Color32) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::simple_singleline(title.to_owned(), font, color); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + job +} + /// Minimum space kept between the window title and the menus or controls beside it. const TITLE_GAP: f32 = 16.0; diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index c6c7a3567d..c8f62a602e 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -44,6 +44,13 @@ fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool ui.data(|d| d.get_temp::(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0]) } +/// Next tool in a toolbar flyout slot after `from`, wrapping (Photoshop Alt/Option-click). An +/// empty slot returns `from` unchanged rather than dividing by zero. +pub(super) fn next_slot_tool(slot: &[Tool], from: Tool) -> Tool { + let i = slot.iter().position(|&t| t == from).unwrap_or(0); + i.checked_add(1).and_then(|n| n.checked_rem(slot.len())).and_then(|n| slot.get(n)).copied().unwrap_or(from) +} + /// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the /// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A /// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is @@ -167,19 +174,59 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if slot.len() > 1 { paint_toolbar_flyout_indicator(ui, &t, &resp); } - if resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key) { - crate::tool_memory::select(app, tool); + // Plain click selects the shown tool; Alt/Option-click cycles the + // flyout slot to the next tool, as Shift+letter does for the key group. + // Prefer the primary-release event's modifiers (Alt may already be + // up), and only a release on this slot — the frame's event list is + // shared with every other widget. + let click_mods = + ui.input(|i| { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::PointerButton { + pos, button: egui::PointerButton::Primary, pressed: false, modifiers, .. + } if resp.rect.contains(*pos) => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) + }); + let alt = click_mods.alt; + let modified = click_mods.alt || click_mods.shift || click_mods.ctrl || click_mods.command; + // egui's double-click ignores modifiers: a plain click after a + // modified click within max_double_click_delay still reports + // double_clicked(). Remember the previous click so Hand fit / + // Zoom 100% only run for two plain clicks. + let prev_mod_id = key.with("click-mod"); + let prev_modified = ui.data(|d| d.get_temp::(prev_mod_id)).unwrap_or(false); + let slot_click = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key); + if slot_click { + let pick = if slot.len() > 1 && alt { + let from = if slot.contains(&app.ui.tool) { app.ui.tool } else { tool }; + next_slot_tool(slot, from) + } else { + tool + }; + crate::tool_memory::select(app, pick); } // Double-clicking the Hand tool fits the image on screen (Photoshop). - if resp.double_clicked() && tool == Tool::Hand { + // Modified clicks only cycle / select: a second rapid Alt-click, or a + // plain click that egui pairs with a prior modified click, must not + // count as Hand/Zoom double-click after cycling onto those tools. + let plain_double = resp.double_clicked() && !modified && !prev_modified; + if plain_double && tool == Tool::Hand { crate::tool_memory::select(app, tool); // With no document open there is nothing to fit. let _ = app.run("view.fitOnScreen", json!({})); } - if resp.double_clicked() && tool == Tool::Zoom { + if plain_double && tool == Tool::Zoom { crate::tool_memory::select(app, tool); let _ = app.run("view.actualPixels", json!({})); } + if slot_click { + ui.data_mut(|d| d.insert_temp(prev_mod_id, modified)); + } // 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(); @@ -326,7 +373,14 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); } - let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); + // Greyed out while Cycle is unavailable (no document in Standard, #9349): its + // list would offer nothing else either. + let cycle_enabled = crate::view_cmds::is_enabled(app, "view.screenMode.cycle").unwrap_or(true); + let sm = ui + .add_enabled_ui(cycle_enabled, |ui| { + icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips) + }) + .inner; paint_toolbar_flyout_indicator(ui, &t, &sm); // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, @@ -366,7 +420,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("view.screenMode.fullScreen", tl!("Full Screen Mode")), ] { let on = crate::view_cmds::checked(app, id).unwrap_or(false); - if ui.add(egui::Button::selectable(on, label)).clicked() { + let enabled = crate::view_cmds::is_enabled(app, id).unwrap_or(true); + if ui.add_enabled(enabled, egui::Button::selectable(on, label)).clicked() { let _ = crate::menus::invoke(app, &ctx, id, json!({})); ui.close(); } diff --git a/crates/ui-egui/src/panels/type_flyout_tests.rs b/crates/ui-egui/src/panels/type_flyout_tests.rs index 27659cf8b5..5144379a3e 100644 --- a/crates/ui-egui/src/panels/type_flyout_tests.rs +++ b/crates/ui-egui/src/panels/type_flyout_tests.rs @@ -46,11 +46,54 @@ fn long_press_type_button_selects_type_then_vertical_from_the_flyout() { assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_none()); } +/// #9354 review: with no document in Standard Screen Mode, Cycle is unavailable (#9349), so +/// Change Screen Mode is greyed out: a click sends no viewport command. +#[test] +fn screen_mode_button_is_inert_without_a_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"width": 64, "height": 48})).unwrap(); + 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 bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + // Found while a document makes it clickable: the toolbar's last button. + let at = 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) + .max_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left()))) + }) + .unwrap() + .center(); + app.run("file.close", serde_json::json!({"save": false})).unwrap(); + frame(&mut app, &ctx, 1.0, vec![]); + assert_eq!(crate::view_cmds::is_enabled(&app, "view.screenMode.cycle"), Some(false)); + let pointer = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut sent = Vec::new(); + for (t, events) in [(2.0, vec![egui::Event::PointerMoved(at), pointer(true)]), (2.1, vec![pointer(false)]), (2.15, vec![])] { + let mut out = ctx.run_ui( + egui::RawInput { time: Some(t), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(&mut app, ui), + ); + out.textures_delta.clear(); + sent.extend(out.viewport_output.values().flat_map(|v| v.commands.clone())); + } + assert!(!sent.iter().any(|c| matches!(c, egui::ViewportCommand::Fullscreen(_))), "no screen mode change was sent: {sent:?}"); + assert_eq!(app.ui.view.screen_mode, "standard"); +} + /// #4444: click and hold on Change Screen Mode opens its list like the tool flyouts, without /// cycling the mode on release; a plain click still cycles, and right-click still opens it. #[test] fn long_press_screen_mode_opens_the_modes_without_cycling() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // A document open: Full Screen Mode needs one (#9349), so without it a click can't cycle. + app.run("file.new", serde_json::json!({"width": 64, "height": 48})).unwrap(); let ctx = egui::Context::default(); PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); frame(&mut app, &ctx, 0.0, vec![]); @@ -383,3 +426,176 @@ fn long_press_y_button_selects_art_history() { frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); assert_eq!(app.ui.tool, Tool::ArtHistoryBrush); } + +#[test] +fn next_slot_tool_wraps_around_the_flyout() { + // One-tool slot: Alt-click is a no-op (still “cycles” to itself). + assert_eq!(next_slot_tool(&[Tool::Frame], Tool::Frame), Tool::Frame); + // Two-tool slot (Hand ↔ RotateView). + let two = [Tool::Hand, Tool::RotateView]; + assert_eq!(next_slot_tool(&two, Tool::Hand), Tool::RotateView); + assert_eq!(next_slot_tool(&two, Tool::RotateView), Tool::Hand, "wrap"); + // Three-tool slot. + let three = [Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso]; + assert_eq!(next_slot_tool(&three, Tool::Lasso), Tool::PolygonLasso); + assert_eq!(next_slot_tool(&three, Tool::PolygonLasso), Tool::MagneticLasso); + assert_eq!(next_slot_tool(&three, Tool::MagneticLasso), Tool::Lasso, "wrap"); + // Four-tool marquee flyout: starts from the current tool, not always the first. + let slot = [Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee]; + assert_eq!(next_slot_tool(&slot, Tool::RectMarquee), Tool::EllipseMarquee); + assert_eq!(next_slot_tool(&slot, Tool::EllipseMarquee), Tool::SingleRowMarquee); + assert_eq!(next_slot_tool(&slot, Tool::SingleColumnMarquee), Tool::RectMarquee); + assert_eq!(next_slot_tool(&slot, Tool::Brush), Tool::EllipseMarquee, "unknown from → after the first"); + // Empty slot: no panic, the current tool stays. + assert_eq!(next_slot_tool(&[], Tool::Brush), Tool::Brush); +} + +/// Edit › Toolbar hidden tools shrink the Alt-click cycle to the remaining flyout members. +#[test] +fn visible_sections_drop_hidden_tools_from_alt_click_slots() { + let hidden = vec!["EllipseMarquee".into(), "SingleRowMarquee".into()]; + let sections = visible_sections(&hidden); + let marquee = sections + .iter() + .flat_map(|s| s.iter()) + .find(|(_, tools)| tools.contains(&Tool::RectMarquee)) + .map(|(_, tools)| tools.as_slice()) + .expect("marquee slot remains"); + assert_eq!(marquee, &[Tool::RectMarquee, Tool::SingleColumnMarquee]); + assert_eq!(next_slot_tool(marquee, Tool::RectMarquee), Tool::SingleColumnMarquee); + assert_eq!(next_slot_tool(marquee, Tool::SingleColumnMarquee), Tool::RectMarquee, "wrap over the visible pair"); +} + +/// Photoshop: Alt/Option-click a toolbar button cycles its flyout tools without opening the menu. +#[test] +fn alt_click_cycles_the_tool_flyout_slot() { + 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 index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::RectMarquee)).unwrap(); + let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap(); + assert_eq!(slot, &[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee]); + 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 at = buttons[index].center(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_some(); + app.ui.tool = Tool::RectMarquee; + // Plain click keeps the shown tool. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true, Default::default())]); + frame(&mut app, &ctx, 1.05, vec![pointer(at, false, Default::default())]); + frame(&mut app, &ctx, 1.1, vec![]); + assert_eq!(app.ui.tool, Tool::RectMarquee); + assert!(!flyout(&ctx)); + // Alt-click advances through every flyout entry, including ones Shift+M skips. + // Clear i.modifiers before the release while keeping Alt on the release event, so a + // regression that only reads i.modifiers (and ignores the pointer-release modifiers) fails. + for (t, want) in [(1.5, Tool::EllipseMarquee), (2.0, Tool::SingleRowMarquee), (2.5, Tool::SingleColumnMarquee), (3.0, Tool::RectMarquee)] { + frame(&mut app, &ctx, t, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, t + 0.05, vec![egui::Event::ModifiersChanged(Default::default()), pointer(at, false, alt)]); + frame(&mut app, &ctx, t + 0.1, vec![]); + assert_eq!(app.ui.tool, want, "alt-click at {t}"); + assert!(!flyout(&ctx), "alt-click must not open the flyout"); + } + // From another tool, Alt-click advances from the slot's remembered (shown) tool: Rect after the wrap. + app.ui.tool = Tool::Brush; + frame(&mut app, &ctx, 4.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 4.05, vec![egui::Event::ModifiersChanged(Default::default()), pointer(at, false, alt)]); + frame(&mut app, &ctx, 4.1, vec![]); + assert_eq!(app.ui.tool, Tool::EllipseMarquee, "inactive slot cycles from its shown tool"); +} + +/// #10301 CodeRabbit: two rapid Alt-clicks on Hand ↔ RotateView must not fit-on-screen. +#[test] +fn rapid_alt_clicks_on_hand_slot_do_not_fit_on_screen() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + 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 index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Hand)).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 at = buttons[index].center(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + app.ui.tool = Tool::RotateView; + let zoom_before = app.ui.views.first().map(|v| v.zoom).unwrap_or(1.0); + // Two Alt-clicks within egui's double-click window: cycle Hand → RotateView, never fit-on-screen. + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.02, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.04, vec![]); + assert_eq!(app.ui.tool, Tool::Hand); + frame(&mut app, &ctx, 1.08, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.10, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.12, vec![]); + assert_eq!(app.ui.tool, Tool::RotateView, "second Alt-click cycles back; must not stick on Hand from double-click"); + let zoom_after = app.ui.views.first().map(|v| v.zoom).unwrap_or(1.0); + assert_eq!(zoom_after, zoom_before, "Alt double-click must not run view.fitOnScreen"); +} + +/// #10301 CodeRabbit: plain click after Alt-click within the double-click window must not fit. +#[test] +fn plain_click_after_alt_on_hand_slot_does_not_fit_on_screen() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + // Far from fit-on-screen so a real Hand double-click is observable. + if let Some(v) = app.ui.views.first_mut() { + v.zoom = 4.0; + } + 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 index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Hand)).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 at = buttons[index].center(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let none = egui::Modifiers::default(); + let pointer = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + app.ui.tool = Tool::RotateView; + let zoom_of = |app: &PhotocraftApp| app.ui.views.first().map(|v| v.zoom).unwrap_or(1.0); + assert_eq!(zoom_of(&app), 4.0); + // Alt-click cycles to Hand; a plain click soon after is egui's "second" click of a double. + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(at), pointer(at, true, alt)]); + frame(&mut app, &ctx, 1.02, vec![egui::Event::ModifiersChanged(alt), pointer(at, false, alt)]); + frame(&mut app, &ctx, 1.04, vec![]); + assert_eq!(app.ui.tool, Tool::Hand); + frame(&mut app, &ctx, 1.08, vec![egui::Event::ModifiersChanged(none), egui::Event::PointerMoved(at), pointer(at, true, none)]); + frame(&mut app, &ctx, 1.10, vec![egui::Event::ModifiersChanged(none), pointer(at, false, none)]); + frame(&mut app, &ctx, 1.12, vec![]); + assert_eq!(app.ui.tool, Tool::Hand, "plain click keeps Hand selected"); + assert_eq!(zoom_of(&app), 4.0, "plain click paired with prior Alt-click must not fitOnScreen"); +} diff --git a/crates/ui-egui/src/panels/type_panels_ui.rs b/crates/ui-egui/src/panels/type_panels_ui.rs index d95c22addb..607e4f6c64 100644 --- a/crates/ui-egui/src/panels/type_panels_ui.rs +++ b/crates/ui-egui/src/panels/type_panels_ui.rs @@ -47,9 +47,19 @@ pub struct SpellDialog { pub ignore: Vec, pub change_to: String, pub all_layers: bool, + /// Single-layer checks remain bound to their opening layer across refreshes and toggles. + #[serde(skip)] + pub layer: Option, pub message: String, + /// The token of the action playback that opened the dialog at a Check Spelling step: Done + /// continues it ([`crate::actions::resume`]). + #[serde(skip)] + pub resume: Option, } +/// The `dialog` that Check Spelling's results and a playback paused at its step name. +pub const SPELLING: &str = "checkSpelling"; + const IDS: [&str; 6] = [ "type.panels.characterStyles", "window.panel.characterStyles", @@ -756,25 +766,68 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // ------------------------------------------------------------------ spelling +/// Refresh the opening layer when checking one layer; an absent opening receiver must not adopt the newly active layer. fn refresh_spelling(app: &mut PhotocraftApp) { let Some(d) = app.ui.type_panels.spell.clone() else { return }; - let p = json!({ "action": "list", "allLayers": d.all_layers, "ignore": d.ignore, "suggestions": 8 }); - let items = run(app, "edit.checkSpelling", p).and_then(|r| r["misspellings"].as_array().cloned()).unwrap_or_default(); + let mut p = json!({ "action": "list", "allLayers": d.all_layers, "ignore": d.ignore, "suggestions": 8 }); + if let Some(layer) = d.layer.filter(|_| !d.all_layers) { + p["layer"] = json!(layer.0); + } + let items = if !d.all_layers && d.layer.is_none() { + Vec::new() + } else { + run(app, "edit.checkSpelling", p).and_then(|r| r["misspellings"].as_array().cloned()).unwrap_or_default() + }; if let Some(s) = app.ui.type_panels.spell.as_mut() { s.items = items; s.change_to = s.items.get(s.index).and_then(|i| i["suggestions"][0].as_str()).unwrap_or("").to_string(); } } -/// Opens Edit › Check Spelling (returns the misspellings). +/// Opens Edit › Check Spelling (returns the misspellings). Opening it again while an action waits +/// at a Check Spelling step keeps that action's token, so Done still continues it. pub fn open_spelling(app: &mut PhotocraftApp, all_layers: bool) -> Result { - app.session.execute("edit.checkSpelling", json!({ "action": "list", "allLayers": all_layers })).map_err(|e| e.to_string())?; - app.ui.type_panels.spell = Some(SpellDialog { all_layers, ..Default::default() }); + let resume = app.ui.type_panels.spell.as_ref().and_then(|d| d.resume); + let layer = if resume.is_some() { app.ui.type_panels.spell.as_ref().and_then(|d| d.layer) } else { app.session.active().and_then(|st| st.active_layer) }; + let mut params = json!({"action":"list","allLayers":all_layers}); + if !all_layers { + params["layer"] = json!(layer.ok_or("no layer was selected for this spelling check")?.0); + } + app.session.execute("edit.checkSpelling", params).map_err(|e| e.to_string())?; + app.ui.type_panels.spell = Some(SpellDialog { all_layers, layer, resume, ..Default::default() }); refresh_spelling(app); let d = app.ui.type_panels.spell.as_ref().map(|d| d.items.clone()).unwrap_or_default(); - Ok(json!({ "dialog": "checkSpelling", "misspellings": d })) + Ok(json!({ "dialog": SPELLING, "misspellings": d })) } +/// Done: an action being recorded gets one Check Spelling step with Check All Layers as the dialog +/// has it, and an action paused at a Check Spelling step continues on the document it was playing +/// on. When recording the step fails, the status keeps that error with the playback's outcome. +pub fn close_spelling(app: &mut PhotocraftApp) { + let Some(d) = app.ui.type_panels.spell.take() else { return }; + let mut recording_error = None; + if app.session.actions.recording.is_some() && run(app, "edit.checkSpelling", json!({ "allLayers": d.all_layers })).is_none() { + recording_error = Some(app.ui.status.clone()); + } + if let Some(token) = d.resume { + let _ = crate::actions::resume_spelling(app, token); + if let Some(error) = recording_error { + app.ui.status = format!("{error} ({})", app.ui.status); + app.ui.status_error = true; + } + } +} + +/// Bind single-layer Change All to the listed item; all-layer corrections intentionally omit a layer restriction. +fn spelling_change_all_params(item: &Value, word: &str, replace: &str, all_layers: bool) -> Value { + let mut params = json!({"action":"changeAll", "word":word, "replace":replace, "allLayers":all_layers}); + if !all_layers { + params["layer"] = item["layer"].clone(); + } + params +} + +/// Render spelling navigation and corrections using the retained opening receiver and paused-action continuation. fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let Some(d) = app.ui.type_panels.spell.clone() else { return }; @@ -875,7 +928,7 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(a) = act else { return }; let word = item.as_ref().and_then(|i| i["word"].as_str()).unwrap_or("").to_string(); match a { - "done" => app.ui.type_panels.spell = None, + "done" => close_spelling(app), "relist" => { if let Some(s) = app.ui.type_panels.spell.as_mut() { s.index = 0; @@ -903,9 +956,12 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let p = if a == "change" { json!({ "action": "change", "layer": i["layer"], "start": i["start"], "end": i["end"], "word": word, "replace": change_to }) } else { - json!({ "action": "changeAll", "word": word, "replace": change_to, "allLayers": all_layers }) + spelling_change_all_params(i, &word, &change_to, all_layers) }; - let _ = run(app, "edit.checkSpelling", p); + if let Err(error) = crate::actions::spelling_change(app, p) { + app.ui.status = error; + app.ui.status_error = true; + } refresh_spelling(app); } } @@ -941,6 +997,61 @@ mod tests { assert!(app.ui.type_panels.glyphs); } + #[test] + /// Single-layer Change All edits the listed text layer even after the host selects another layer. + fn change_all_keeps_the_listed_layer_after_a_session_selection_change() { + for all_layers in [false, true] { + let mut app = app_with_text("Teh cat"); + let listed = app.session.active().unwrap().active_layer.unwrap(); + app.run("type.create", json!({"x":10,"y":90,"text":"Teh dog"})).unwrap(); + let other = app.session.active().unwrap().active_layer.unwrap(); + app.session.select_layer(listed).unwrap(); + menu(&mut app, "edit.checkSpelling", &json!({"allLayers":false})).unwrap().unwrap(); + app.session.select_layer(other).unwrap(); + refresh_spelling(&mut app); + let item = app.ui.type_panels.spell.as_ref().unwrap().items[0].clone(); + assert_eq!(item["layer"], listed.0); + let params = spelling_change_all_params(&item, "Teh", "The", all_layers); + if all_layers { + assert!(params.get("layer").is_none()); + } + crate::actions::spelling_change(&mut app, params).unwrap(); + refresh_spelling(&mut app); + assert!(app.ui.type_panels.spell.as_ref().unwrap().items.is_empty()); + let doc = &app.session.active().unwrap().doc; + for (id, expected) in [(listed, "The cat"), (other, if all_layers { "The dog" } else { "Teh dog" })] { + let photocraft_doc::LayerContent::Text(text) = &doc.layer(id).unwrap().content else { panic!("not text") }; + assert_eq!(text.text, expected); + } + } + } + + #[test] + /// Refreshing and toggling Check All Layers must retain the original single-layer receiver, including the absent-layer case. + fn spelling_refresh_and_all_layers_toggles_keep_the_opening_layer() { + let mut app = app_with_text("Teh cat"); + let original = app.session.active().unwrap().active_layer.unwrap(); + app.run("type.create", json!({"x":10,"y":90,"text":"Teh dog"})).unwrap(); + let other = app.session.active().unwrap().active_layer.unwrap(); + app.session.select_layer(original).unwrap(); + open_spelling(&mut app, false).unwrap(); + app.session.select_layer(other).unwrap(); + for all_layers in [false, true, false] { + app.ui.type_panels.spell.as_mut().unwrap().all_layers = all_layers; + refresh_spelling(&mut app); + let items = &app.ui.type_panels.spell.as_ref().unwrap().items; + assert!(items.iter().any(|item| item["layer"] == original.0)); + assert_eq!(items.iter().any(|item| item["layer"] == other.0), all_layers); + } + app.session.restore_layer_selection(None, &[], None).unwrap(); + open_spelling(&mut app, true).unwrap(); + assert!(app.ui.type_panels.spell.as_ref().unwrap().layer.is_none()); + app.session.select_layer(other).unwrap(); + app.ui.type_panels.spell.as_mut().unwrap().all_layers = false; + refresh_spelling(&mut app); + assert!(app.ui.type_panels.spell.as_ref().unwrap().items.is_empty(), "a missing opening target must not adopt the new active layer"); + } + #[test] fn glyph_insertion_and_recent() { let mut app = app_with_text("ab"); @@ -964,6 +1075,25 @@ mod tests { assert!(menu(&mut app, "edit.checkSpelling", &json!({"action": "list"})).is_none()); } + #[test] + fn recording_the_dialog_records_one_step_with_its_dialog_on() { + let mut app = app_with_text("Teh cat"); + app.run("actions.record", json!({"name": "Spell"})).unwrap(); + menu(&mut app, "edit.checkSpelling", &json!({})).unwrap().unwrap(); + let item = app.ui.type_panels.spell.as_ref().unwrap().items[0].clone(); + // Change, as the dialog's Change button runs it, then untick Check All Layers and press Done. + let change = json!({"action": "change", "layer": item["layer"], "start": item["start"], "end": item["end"], "word": "Teh", "replace": "The"}); + run(&mut app, "edit.checkSpelling", change).unwrap(); + refresh_spelling(&mut app); + app.ui.type_panels.spell.as_mut().unwrap().all_layers = false; + close_spelling(&mut app); + app.run("actions.stop", json!({})).unwrap(); + assert!(app.ui.type_panels.spell.is_none()); + let action = &app.session.actions.list[0]; + assert_eq!(action.steps, vec![("edit.checkSpelling".to_string(), json!({"allLayers": false}))]); + assert_eq!(action.dialogs.iter().copied().collect::>(), [0]); + } + /// #5496: Check Spelling's close box is Done, as in Photoshop on Windows: the window closes /// and the text keeps what it had. None on macOS. #[test] diff --git a/crates/ui-egui/src/pen_gesture_owner_tests.rs b/crates/ui-egui/src/pen_gesture_owner_tests.rs new file mode 100644 index 0000000000..6f6a093d19 --- /dev/null +++ b/crates/ui-egui/src/pen_gesture_owner_tests.rs @@ -0,0 +1,92 @@ +use egui::Modifiers; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app.ui.extras.snap = false; + app +} + +fn down(app: &mut PhotocraftApp) { + tool_event(app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 50.0, y: 35.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 90.0, y: 80.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.drag.is_some()); +} + +#[test] +fn freeform_and_path_selection_releases_cannot_edit_a_different_document() { + for tool in [Tool::FreeformPen, Tool::PathSelection] { + let mut app = app(tool); + app.run("path.set", json!({"path": {"subpaths": [{"knots": [[20, 20], [50, 35], [90, 80]]}]}})).unwrap(); + let original = app.session.active().unwrap().doc.work_path.clone(); + down(&mut app); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + let revision = app.session.active().unwrap().revision; + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + assert!(app.drag.is_none()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!(app.session.documents()[0].doc.work_path, original); + } +} + +#[test] +fn freeform_release_cancels_after_revision_or_target_change() { + for change in ["revision", "selected", "mode"] { + let mut app = app(Tool::FreeformPen); + down(&mut app); + match change { + "revision" => { + app.run("path.set", json!({"path": {"subpaths": [{"knots": [[10, 10], [15, 15]]}]}})).unwrap(); + } + "selected" => app.ui.selected_path = Some("Another path".into()), + _ => app.ui.tool_options.vector_mode = "shape".into(), + } + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + assert!(app.drag.is_none()); + assert_eq!(app.session.active().unwrap().doc.work_path, before.work_path); + assert_eq!(app.session.active().unwrap().doc.layers, before.layers); + assert_eq!(app.session.active().unwrap().revision, revision); + } +} + +#[test] +fn freeform_valid_release_is_one_undoable_path_edit() { + let mut app = app(Tool::FreeformPen); + let history = app.session.active().unwrap().history.entries().len(); + down(&mut app); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, Modifiers::NONE); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + assert_eq!(app.session.active().unwrap().history.entries().len(), history + 1); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + app.run("edit.redo", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_some()); +} + +#[test] +fn frame_cancellation_consumes_old_events_after_switching_tools() { + let mut app = app(Tool::FreeformPen); + down(&mut app); + app.ui.tool = Tool::PolygonLasso; + app.ui.polygon = vec![[10.0, 10.0], [20.0, 20.0]]; + let polygon = app.ui.polygon.clone(); + app.sync_views(); + assert!(app.drag.is_none()); + let modifiers = Modifiers { alt: true, ..Modifiers::NONE }; + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 100.0, pressure: 1.0 }, modifiers); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, modifiers); + assert_eq!(app.ui.polygon, polygon, "old pointer events must not become a Polygonal Lasso gesture"); + assert!(!app.cancelled_vector_drag); +} diff --git a/crates/ui-egui/src/pen_path_ownership_tests.rs b/crates/ui-egui/src/pen_path_ownership_tests.rs new file mode 100644 index 0000000000..ea2363a67b --- /dev/null +++ b/crates/ui-egui/src/pen_path_ownership_tests.rs @@ -0,0 +1,478 @@ +//! Pen drafts and explicit document paths retain their destination until a single commit. + +use egui::Modifiers; +use photocraft_doc::{LayerContent, Path}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; +use crate::vector_ui; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = tool; + app +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +fn seed(app: &mut PhotocraftApp, name: &str, y: f64) { + app.run("path.set", json!({"name": name, "path": {"subpaths": [{"closed": false, "knots": [[10.0, y], [90.0, y]]}]}})).unwrap(); +} + +fn path(app: &PhotocraftApp, name: &str) -> Path { + let doc = &app.session.active().unwrap().doc; + if name == "work" { doc.work_path.clone().unwrap() } else { doc.paths.iter().find(|p| p.name == name).unwrap().path.clone() } +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE); +} + +fn draft(app: &mut PhotocraftApp) { + click(app, 30.0, 60.0); + click(app, 130.0, 60.0); +} + +#[test] +fn selected_work_and_saved_paths_append_pen_and_curvature_components_once() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + for name in ["work", "Saved drawing"] { + let mut app = app(tool); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some(name.into()); + let (original, other) = (path(&app, name), path(&app, if name == "work" { "Saved drawing" } else { "work" })); + let n = steps(&app); + draft(&mut app); + assert_eq!(steps(&app), n, "placing anchors is not a history step"); + vector_ui::pen_commit(&mut app, false); + let after = path(&app, name); + assert_eq!(after.subpaths.len(), 2, "{tool:?}: {name}"); + assert_eq!(after.subpaths[0], original.subpaths[0]); + let anchors: Vec<_> = after.subpaths[1].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, [[30.0, 60.0], [130.0, 60.0]]); + assert_eq!(path(&app, if name == "work" { "Saved drawing" } else { "work" }), other); + assert_eq!(app.ui.selected_path.as_deref(), Some(name)); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, name), original); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(path(&app, name), after); + } + } +} + +#[test] +fn deselected_work_path_is_replaced_without_touching_saved_paths() { + let mut app = app(Tool::Pen); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = None; + let (before, saved, n) = (path(&app, "work"), path(&app, "Saved drawing"), steps(&app)); + draft(&mut app); + vector_ui::pen_commit(&mut app, false); + let after = path(&app, "work"); + assert_eq!(after.subpaths.len(), 1); + assert_eq!(after.subpaths[0].knots[0].anchor.y, 60.0); + assert_eq!(path(&app, "Saved drawing"), saved); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "work"), before); +} + +#[test] +fn captured_path_and_shape_mode_do_not_follow_later_ui_selection() { + for shape in [false, true] { + let mut app = app(Tool::Pen); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some("Saved drawing".into()); + app.ui.tool_options.vector_mode = if shape { "shape" } else { "path" }.into(); + let (work, saved, n) = (path(&app, "work"), path(&app, "Saved drawing"), steps(&app)); + click(&mut app, 30.0, 60.0); + app.ui.selected_path = Some("work".into()); + app.ui.tool_options.vector_mode = if shape { "path" } else { "shape" }.into(); + app.ui.vector_mask_target = true; + click(&mut app, 130.0, 60.0); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "work"), work); + if shape { + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.layer(st.active_layer.unwrap()).unwrap().content, LayerContent::Shape(_))); + assert_eq!(path(&app, "Saved drawing"), saved); + assert_eq!(app.ui.selected_path.as_deref(), Some("layer")); + } else { + assert_eq!(path(&app, "Saved drawing").subpaths.len(), 2); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + } + assert_eq!(steps(&app), n + 1); + } +} + +#[test] +fn captured_pen_paint_preserves_float_colour_and_alpha_after_toolbar_changes() { + for closed in [false, true] { + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.tool_options.shape_fill = true; + app.ui.tool_options.stroke_width = 3.0; + let foreground = [0.12345, 0.34567, 0.56789, 0.375]; + let background = [0.76543, 0.54321, 0.32109, 0.625]; + app.session.tools.foreground = foreground; + app.session.tools.background = background; + click(&mut app, 30.0, 60.0); + app.session.tools.foreground = [1.0; 4]; + app.session.tools.background = [0.0; 4]; + click(&mut app, 130.0, 60.0); + if closed { + click(&mut app, 90.0, 130.0); + } + vector_ui::pen_commit(&mut app, closed); + let st = app.session.active().unwrap(); + let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected Pen shape") }; + let paint = |c: [f32; 4]| photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3])); + assert_eq!(shape.fill, closed.then(|| paint(foreground))); + assert_eq!(shape.stroke.as_ref().unwrap().paint, paint(if closed { background } else { foreground })); + } +} + +#[test] +fn stale_drafts_are_cancelled_before_tool_switch_or_pointer_input() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + for change in ["document", "revision", "reopen"] { + let mut app = app(tool); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + let source = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + match change { + "document" => { + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("path.set", json!({"name":"Saved drawing", "path": {"subpaths": [{"knots": [[50, 50], [80, 80]]}]}})).unwrap(); + } + "revision" => { + app.session.execute("path.set", json!({"name":"work", "path": {"subpaths": [{"knots": [[50, 50], [80, 80]]}]}})).unwrap(); + } + "reopen" => { + app.session.close(0).unwrap(); + app.session.add_document((*source).clone(), None); + app.session.active_mut().unwrap().revision = revision; + } + _ => panic!("unknown document-change fixture: {change}"), + } + let (current, n) = (app.session.active().unwrap().doc.clone(), steps(&app)); + app.ui.tool = Tool::Brush; + vector_ui::finish_pen_if_left(&mut app); + assert!(app.ui.pen.is_none(), "{tool:?}: {change}"); + vector_ui::pen_move(&mut app, 160.0, 80.0, false); + vector_ui::curvature_move(&mut app, 160.0, 80.0); + vector_ui::pen_up(&mut app); + vector_ui::curvature_up(&mut app); + vector_ui::pen_commit(&mut app, false); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, ¤t)); + assert_eq!(steps(&app), n); + assert_eq!(source.paths[0].path.subpaths.len(), 1); + } + } +} + +#[test] +fn sync_views_and_input_apis_discard_unowned_or_closed_document_drafts() { + for entry in ["sync", "move", "up", "commit", "undo", "toggle", "down"] { + let mut app = app(Tool::CurvaturePen); + draft(&mut app); + app.session.close(0).unwrap(); + match entry { + "sync" => app.sync_views(), + "move" => vector_ui::curvature_move(&mut app, 160.0, 80.0), + "up" => vector_ui::curvature_up(&mut app), + "commit" => { + vector_ui::pen_commit(&mut app, false); + } + "undo" => { + assert!(!vector_ui::pen_undo_last_point(&mut app), "a closed document has no current draft point to undo"); + } + "toggle" => vector_ui::curvature_toggle(&mut app, 30.0, 60.0), + "down" => vector_ui::curvature_down(&mut app, 160.0, 80.0), + _ => panic!("unknown stale-draft entry fixture: {entry}"), + } + assert!(app.ui.pen.is_none(), "{entry}"); + } + let mut app = app(Tool::Pen); + draft(&mut app); + let restored = serde_json::from_value(serde_json::to_value(app.ui.pen.as_ref().unwrap()).unwrap()).unwrap(); + app.ui.pen = Some(restored); + let n = steps(&app); + vector_ui::pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert_eq!(steps(&app), n); +} + +#[test] +fn same_named_saved_paths_do_not_share_selection_or_drafts_across_tabs() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + let first = path(&app, "Saved drawing"); + app.run("file.new", json!({"width":200,"height":200})).unwrap(); + seed(&mut app, "Saved drawing", 90.0); + let second = path(&app, "Saved drawing"); + assert!(app.session.set_active(0)); + app.sync_views(); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + assert!(app.session.set_active(1)); + app.sync_views(); + assert!(app.ui.selected_path.is_none()); + assert!(app.ui.pen.is_none()); + let n = steps(&app); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "Saved drawing"), second); + assert_eq!(steps(&app), n); + // A fresh drawing without explicitly selecting B's saved path creates a work path. + draft(&mut app); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "Saved drawing"), second); + assert_eq!(path(&app, "work").subpaths[0].knots[0].anchor.y, 60.0); + assert!(app.session.set_active(0)); + app.sync_views(); + assert!(app.ui.selected_path.is_none()); + // The captured-target reconciliation preserves main's valid-source commit on a tab + // switch. Only the original path receives that draft; the same-named B path stayed exact. + let committed = path(&app, "Saved drawing"); + assert_eq!(committed.subpaths.len(), first.subpaths.len() + 1); + assert_eq!(committed.subpaths[0], first.subpaths[0]); + assert_eq!(committed.subpaths.last().unwrap().knots[0].anchor.y, 60.0); +} + +#[test] +fn reopened_same_id_and_revision_do_not_reuse_the_saved_path_selection() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + let original = path(&app, "Saved drawing"); + draft(&mut app); + let closed = app.session.close(0).unwrap(); + // Recovery can preserve both the document ID and revision before another frame is drawn. + app.session.add_document((*closed.doc).clone(), None); + app.session.active_mut().unwrap().revision = closed.revision; + assert_eq!(app.session.active().unwrap().doc.id, closed.doc.id); + app.sync_views(); + assert!(app.ui.selected_path.is_none()); + assert!(app.ui.pen.is_none()); + draft(&mut app); + vector_ui::pen_commit(&mut app, false); + assert_eq!(path(&app, "Saved drawing"), original); + assert_eq!(path(&app, "work").subpaths[0].knots[0].anchor.y, 60.0); +} + +#[test] +fn ordinary_path_edits_undo_and_redo_preserve_the_explicit_saved_path_selection() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + let original = path(&app, "Saved drawing"); + let revision = app.session.active().unwrap().revision; + seed(&mut app, "Saved drawing", 90.0); + assert!(app.session.active().unwrap().revision > revision); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + let changed = path(&app, "Saved drawing"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), original); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), changed); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); + app.sync_views(); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved drawing")); +} + +#[test] +fn failed_pen_and_curvature_commits_keep_the_exact_draft_and_do_not_retry_each_frame() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(tool); + click(&mut app, 30.0, 60.0); + click(&mut app, 1e40, 60.0); + let (before, original, n) = (app.session.active().unwrap().doc.clone(), app.ui.pen.clone().unwrap(), steps(&app)); + vector_ui::pen_commit(&mut app, false); + let failed = app.ui.pen.as_ref().expect("invalid geometry leaves an editable draft"); + assert_eq!(failed.knots, original.knots); + assert_eq!(failed.unlinked, original.unlinked); + assert_eq!(failed.owner, original.owner); + assert!(failed.commit_failed && app.ui.status_error); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(steps(&app), n); + app.ui.tool = Tool::Brush; + let journal = app.session.journal.len(); + vector_ui::finish_pen_if_left(&mut app); + assert_eq!(app.session.journal.len(), journal, "tool switch must not continually retry a failing commit"); + assert!(vector_ui::path_to_selection(&mut app, Value::Null).is_err(), "a failed draft cannot select an unrelated path"); + app.ui.pen.as_mut().unwrap().knots[1] = [[130.0, 60.0]; 3]; + vector_ui::pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert_eq!(path(&app, "work").subpaths[0].knots[1].anchor.x, 130.0); + assert_eq!(steps(&app), n + 1); + } +} + +#[test] +fn ordinary_tool_switch_commits_the_owned_draft_once() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(tool); + draft(&mut app); + let n = steps(&app); + app.ui.tool = Tool::Brush; + vector_ui::finish_pen_if_left(&mut app); + vector_ui::finish_pen_if_left(&mut app); + assert_eq!(path(&app, "work").subpaths[0].knots.len(), 2); + assert_eq!(steps(&app), n + 1); + assert!(app.ui.pen.is_none()); + } +} + +#[test] +fn selected_saved_paths_move_and_free_transform_without_changing_work_or_shape() { + for tool in [Tool::PathSelection, Tool::DirectSelection] { + let mut app = app(tool); + app.run("shape.create", json!({"kind": "rect", "rect": [110, 110, 50, 40]})).unwrap(); + let shape = app.run("shape.info", json!({})).unwrap(); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some("Saved drawing".into()); + let (original, work, n) = (path(&app, "Saved drawing"), path(&app, "work"), steps(&app)); + let (params, selected) = vector_ui::free_transform_path(&app).unwrap(); + assert_eq!(params, json!({"name":"Saved drawing"})); + assert_eq!(selected, original); + vector_ui::path_selection_finish(&mut app, [0.0, 0.0], [10.4, -4.6]); + let moved = path(&app, "Saved drawing"); + for (a, b) in original.subpaths[0].knots.iter().zip(&moved.subpaths[0].knots) { + assert_eq!(b.anchor.x, a.anchor.x + 10.0); + assert_eq!(b.anchor.y, a.anchor.y - 5.0); + assert_eq!(b.in_ctrl.x, a.in_ctrl.x + 10.0); + assert_eq!(b.out_ctrl.y, a.out_ctrl.y - 5.0); + } + assert_eq!(path(&app, "work"), work); + assert_eq!(app.run("shape.info", json!({})).unwrap(), shape); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), original); + let mut params = params; + params["matrix"] = json!([1, 0, 0, 1, 7, 9]); + app.run("path.transform", params).unwrap(); + assert_eq!(path(&app, "Saved drawing").subpaths[0].knots[0].anchor.x, 17.0); + assert_eq!(path(&app, "work"), work); + assert_eq!(app.run("shape.info", json!({})).unwrap(), shape); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app, "Saved drawing"), original); + app.ui.selected_path = None; + assert_eq!(vector_ui::free_transform_path(&app).unwrap().0["name"], "layer", "deselection restores the shape fallback"); + } +} + +#[test] +fn explicit_work_path_precedes_vector_mask_and_deselection_restores_mask_fallback() { + let mut app = app(Tool::PathSelection); + seed(&mut app, "work", 20.0); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.vectorMask.add", json!({"layer":layer.0,"path":{"subpaths":[{"knots":[[30,40],[80,40]]}]}})).unwrap(); + app.ui.vector_mask_target = true; + app.ui.selected_path = Some("work".into()); + assert_eq!(vector_ui::free_transform_path(&app).unwrap().0, json!({"name":"work"})); + app.ui.selected_path = None; + assert_eq!(vector_ui::free_transform_path(&app).unwrap().0, json!({"name":"layer","layer":layer.0})); +} + +#[test] +fn pen_keeps_its_captured_vector_mask_when_another_path_is_selected() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.vectorMask.add", json!({"layer":layer.0,"path":{"subpaths":[{"knots":[[30,40],[80,40]]}]}})).unwrap(); + app.ui.selected_path = None; + app.ui.vector_mask_target = true; + let (saved, original, n) = (path(&app, "Saved drawing"), app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.clone().unwrap(), steps(&app)); + click(&mut app, 30.0, 60.0); + app.ui.vector_mask_target = false; + app.ui.selected_path = Some("Saved drawing".into()); + click(&mut app, 130.0, 60.0); + vector_ui::pen_commit(&mut app, false); + let mask = app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.clone().unwrap(); + assert_eq!(mask.path.subpaths.len(), 2); + assert_eq!(mask.path.subpaths[0], original.path.subpaths[0]); + assert_eq!(mask.path.subpaths[1].knots[0].anchor.y, 60.0); + assert_eq!(path(&app, "Saved drawing"), saved); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert_eq!(steps(&app), n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().vector_mask.as_ref(), Some(&original)); +} + +#[test] +fn freeform_finish_uses_its_captured_saved_path_and_rejects_foreign_documents() { + let mut app = app(Tool::FreeformPen); + seed(&mut app, "work", 20.0); + seed(&mut app, "Saved drawing", 40.0); + app.ui.selected_path = Some("Saved drawing".into()); + let owner = vector_ui::capture_pen_owner(&app).unwrap(); + let points = [[30.0, 60.0, 1.0], [80.0, 60.0, 1.0], [130.0, 60.0, 1.0]]; + let (original, work, n) = (path(&app, "Saved drawing"), path(&app, "work"), steps(&app)); + app.ui.selected_path = Some("work".into()); + app.ui.tool_options.vector_mode = "shape".into(); + vector_ui::freeform_finish_owned(&mut app, &points, &[], owner); + assert_eq!(path(&app, "Saved drawing").subpaths.len(), 2); + assert_eq!(path(&app, "Saved drawing").subpaths[0], original.subpaths[0]); + assert_eq!(path(&app, "work"), work); + assert_eq!(steps(&app), n + 1); + app.ui.tool_options.vector_mode = "path".into(); + let owner = vector_ui::capture_pen_owner(&app).unwrap(); + app.session.execute("file.new", json!({"width":200,"height":200})).unwrap(); + let (source, n) = (app.session.active().unwrap().doc.clone(), steps(&app)); + vector_ui::freeform_finish_owned(&mut app, &points, &[], owner); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(steps(&app), n); +} + +#[test] +fn tab_switch_commits_only_the_valid_original_captured_saved_path() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + let source = app.session.active().unwrap().doc.id; + app.session.execute("file.new", json!({"width":200,"height":200})).unwrap(); + // Seed through the engine so UI sync cannot process the tab switch before this assertion. + app.session.execute("path.set", json!({"name":"Destination", "path":{"subpaths":[{"closed":false,"knots":[[10.0,100.0],[90.0,100.0]]}]}})).unwrap(); + app.ui.selected_path = Some("Destination".into()); + let current = app.session.active().unwrap().doc.clone(); + assert!(vector_ui::cancel_stale_pen(&mut app)); + assert!(app.ui.pen.is_none()); + app.sync_views(); + assert_eq!(app.ui.selected_path.as_deref(), Some("Destination")); + assert!(std::sync::Arc::ptr_eq(¤t, &app.session.active().unwrap().doc)); + let old = app.session.documents().iter().find(|s| s.doc.id == source).unwrap(); + assert_eq!(old.doc.paths.iter().find(|p| p.name == "Saved drawing").unwrap().path.subpaths.len(), 2); + assert!(old.doc.work_path.is_none()); +} +#[test] +fn deleting_the_captured_target_cancels_without_recreating_it() { + let mut app = app(Tool::Pen); + seed(&mut app, "Saved drawing", 20.0); + app.ui.selected_path = Some("Saved drawing".into()); + draft(&mut app); + app.session.execute("path.delete", json!({"name":"Saved drawing"})).unwrap(); + let current = app.session.active().unwrap().doc.clone(); + let n = steps(&app); + vector_ui::cancel_stale_pen(&mut app); + assert!(app.ui.pen.is_none()); + assert!(app.ui.status_error); + assert!(std::sync::Arc::ptr_eq(¤t, &app.session.active().unwrap().doc)); + assert_eq!(steps(&app), n); + assert!(app.session.active().unwrap().doc.paths.is_empty()); +} diff --git a/crates/ui-egui/src/pen_path_visual_tests.rs b/crates/ui-egui/src/pen_path_visual_tests.rs new file mode 100644 index 0000000000..6fc23e8ade --- /dev/null +++ b/crates/ui-egui/src/pen_path_visual_tests.rs @@ -0,0 +1,101 @@ +//! Synthetic offscreen evidence for editing a selected saved path beside the Work Path. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use photocraft_doc::Path; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +fn path(app: &PhotocraftApp, name: &str) -> Path { + let doc = &app.session.active().unwrap().doc; + if name == "work" { doc.work_path.clone().unwrap() } else { doc.paths.iter().find(|p| p.name == name).unwrap().path.clone() } +} + +fn translated(mut path: Path, dx: f64, dy: f64) -> Path { + for knot in path.subpaths.iter_mut().flat_map(|s| &mut s.knots) { + for point in [&mut knot.anchor, &mut knot.in_ctrl, &mut knot.out_ctrl] { + point.x += dx; + point.y += dy; + } + } + path +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +/// Run explicitly with a wgpu adapter. PHOTOCRAFT_PEN_VISUAL_DIR chooses the output directory; +/// PHOTOCRAFT_PEN_VISUAL_BASELINE=1 characterizes the old Work Path targeting for a before capture. +#[test] +#[ignore = "manual offscreen visual fixture; writes synthetic screenshots"] +fn saved_path_drag_visual_fixture() { + let baseline = std::env::var("PHOTOCRAFT_PEN_VISUAL_BASELINE").is_ok_and(|v| v == "1"); + let output = std::env::var_os("PHOTOCRAFT_PEN_VISUAL_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| "log/pen-path-visual".into()); + std::fs::create_dir_all(&output).expect("create synthetic screenshot directory"); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.edit_prefs(|p| p.interface.language = "en".into()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"name": "Saved path targeting", "width": 400, "height": 260, "background": "white"})).unwrap(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": true, "knots": [[40, 80], [140, 80], [90, 180]]}]}})).unwrap(); + app.run( + "path.set", + json!({"name": "Saved drawing", "path": {"subpaths": [{"closed": false, "knots": [ + {"anchor": [220, 90], "in": [220, 90], "out": [260, 40]}, + {"anchor": [320, 180], "in": [280, 230], "out": [320, 180]} + ]}]}}), + ) + .unwrap(); + app.sync_views(); + app.ui.tool = Tool::PathSelection; + app.ui.extras.snap = false; + app.ui.dock_tabs.layers = 2; + app.ui.views[0].fit_pending = false; + app.ui.views[0].zoom = 1.5; + app.ui.views[0].center = [200.0, 130.0]; + app + }); + h.run_steps(8); + h.get_by_label("Saved drawing").click(); + h.run_steps(4); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Saved drawing"), "the Paths panel row selects the saved path"); + let (work, saved) = (path(h.state(), "work"), path(h.state(), "Saved drawing")); + let history = h.state().session.active().unwrap().history.past_len(); + let mode = if baseline { "baseline" } else { "fixed" }; + let before = output.join(format!("{mode}-before.png")); + h.render().expect("render initial scene").save(&before).expect("save initial scene"); + + // Screen coordinates come from the real full-app layout, including its dock and rulers. + let xf = ViewXform::active(h.state()).expect("active document canvas"); + let start = xf.to_screen(220.0, 90.0); + let end = xf.to_screen(245.0, 70.0); + assert!(xf.rect.contains(start) && xf.rect.contains(end)); + h.event(Event::PointerMoved(start)); + h.event(button(start, true)); + h.step(); + h.event(Event::PointerMoved(end)); + h.step(); + assert!(h.state().drag.is_some(), "native movement past the drag threshold starts a path gesture"); + h.event(button(end, false)); + h.step(); + h.run_steps(12); + assert!(h.state().drag.is_none()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1, "one native drag is one Undo step"); + if baseline { + assert_eq!(path(h.state(), "Saved drawing"), saved, "baseline incorrectly leaves the selected saved path untouched"); + assert_eq!(path(h.state(), "work"), translated(work, 25.0, -20.0), "baseline incorrectly moves the Work Path"); + } else { + assert_eq!(path(h.state(), "work"), work, "editing a saved path preserves the Work Path"); + assert_eq!(path(h.state(), "Saved drawing"), translated(saved, 25.0, -20.0), "the selected saved path and its curve handles move together"); + } + let after = output.join(format!("{mode}-after.png")); + h.render().expect("render completed drag").save(&after).expect("save completed drag"); + println!("{mode}: selected Saved drawing; native drag (+25, -20); screenshots: {}, {}", before.display(), after.display()); +} diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 330f2bc6f3..4b5cf04d61 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -211,7 +211,8 @@ fn the_pen_carries_on_an_open_path_from_either_end() { } /// #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. +/// meanwhile refuses without overwriting newer work. The historical test name is retained +/// for reviewed-head provenance; captured-revision validation supersedes its stale fallback. #[test] fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { let mut app = app(Tool::Pen); @@ -223,21 +224,28 @@ fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { 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. + // Replacing the source invalidates the draft; neither explicit commit nor a frame retries it. 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(); + let newer = work_path(&app); + let steps = app.session.active().unwrap().history.past_len(); 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, newer, "the replaced source is unchanged by a stale draft"); + assert_eq!(work.subpaths.len(), 1); + assert!(app.ui.pen.is_none() && app.ui.status_error); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); 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. + // Deselecting the path does not allow a stale draft to replace the newer source. 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"); + assert_eq!(work_path(&app).subpaths.len(), 1, "no stale component is appended"); + assert_eq!(work_path(&app).subpaths[0].knots.iter().map(|k| k.anchor.x).collect::>(), [120.0, 160.0]); + assert!(app.ui.pen.is_none() && app.ui.status_error); // 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); @@ -245,7 +253,9 @@ fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { 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"); + assert_eq!(work_path(&app).subpaths.len(), 1, "neither selected path accepts stale geometry"); + assert_eq!(work_path(&app).subpaths[0].knots.iter().map(|k| k.anchor.x).collect::>(), [120.0, 150.0]); + assert!(app.ui.pen.is_none() && app.ui.status_error); 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"); diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index 6101436a17..f3f713ed6f 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -26,6 +26,7 @@ fn harness() -> Harness<'static, PhotocraftApp> { } crate::shortcuts::handle(app, &ctx); egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::dialogs::show(app, &ctx); }, app, ); @@ -209,6 +210,217 @@ fn steps(h: &Harness<'static, PhotocraftApp>) -> usize { h.state().session.active().unwrap().history.entries().len() } +fn zoomed_polygon() -> Harness<'static, PhotocraftApp> { + let mut h = harness(); + h.state_mut().ui.views[0].zoom = 4.0; + h.run_steps(2); + click(&mut h, 170.0, 130.0, Modifiers::NONE); + h.run_steps(30); + h +} + +#[test] +fn polygon_edge_scroll_continues_with_button_up_and_keeps_vertices_fixed() { + for (dx, dy, flip, rotation) in [ + (-1.0, 0.0, false, 0.0), + (1.0, 0.0, false, 0.0), + (0.0, -1.0, false, 0.0), + (0.0, 1.0, false, 0.0), + (-1.0, -1.0, false, 0.0), + (1.0, -1.0, false, 0.0), + (-1.0, 1.0, false, 0.0), + (1.0, 1.0, false, 0.0), + (1.0, 0.0, true, 0.0), + (1.0, 0.0, false, 90.0), + ] { + let mut h = zoomed_polygon(); + h.state_mut().ui.view.flip_horizontal = flip; + h.state_mut().ui.views[0].rotation = rotation; + h.run_steps(1); + let xf = ViewXform::active(h.state()).unwrap(); + // Just beyond each viewport edge, still inside the application's window. + let at = xf.rect.center() + vec2(dx * (xf.rect.width() / 2.0 + 4.0), dy * (xf.rect.height() / 2.0 + 4.0)); + let points = h.state().ui.polygon.clone(); + let before = steps(&h); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(1); + let first = h.state().ui.views[0].center; + h.run_steps(4); // No pointer events or pressed buttons: scrolling must keep going. + let next = h.state().ui.views[0].center; + let direction = xf.unmap_vec(vec2(dx, dy)); + let movement = vec2(next[0] - xf.center[0], next[1] - xf.center[1]); + assert!(movement.dot(direction) > 0.1, "edge ({dx},{dy}), flip={flip}, rotation={rotation}: {movement:?}"); + assert!((movement.x * direction.y - movement.y * direction.x).abs() < 0.001, "scroll follows the displayed axes"); + assert!((next[0] - first[0]).hypot(next[1] - first[1]) > 0.1, "stationary pointer keeps scrolling"); + assert_eq!(h.state().ui.polygon, points, "view motion never adds or moves vertices"); + assert_eq!(steps(&h), before); + assert!(selection(&h).is_none()); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + assert!(h.output().viewport_output[&egui::ViewportId::ROOT].repaint_delay.is_zero()); + h.event(egui::Event::PointerMoved(xf.rect.center())); + h.run_steps(1); + let stopped = h.state().ui.views[0].center; + h.run_steps(4); + assert_eq!(h.state().ui.views[0].center, stopped, "returning to the interior stops scrolling"); + } + + let mut h = zoomed_polygon(); + h.state_mut().run("prefs.set", json!({"path":"tools.overscroll","value":false})).unwrap(); + let xf = ViewXform::active(h.state()).unwrap(); + let (points, before) = (h.state().ui.polygon.clone(), steps(&h)); + h.event(egui::Event::PointerMoved(xf.rect.right_center() + vec2(4.0, 0.0))); + h.run_steps(90); + let center = h.state().ui.views[0].center; + assert!((center[0] - (400.0 - xf.rect.width() / (2.0 * xf.zoom))).abs() < 0.001, "Overscroll off stops at the image's right edge"); + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, center, "a long edge hold stays clamped"); + assert_eq!(h.state().ui.polygon, points); + assert_eq!(steps(&h), before); +} + +#[test] +fn polygon_edge_scroll_places_the_next_vertex_in_the_panned_view_and_commits_once() { + let mut h = zoomed_polygon(); + let before = steps(&h); + let original = ViewXform::active(h.state()).unwrap(); + h.event(egui::Event::PointerMoved(original.rect.right_center() - vec2(4.0, 0.0))); + h.run_steps(10); + let panned = ViewXform::active(h.state()).unwrap(); + assert!(panned.center[0] > original.center[0], "the zoomed image pans toward the right edge"); + let at = panned.rect.center() + vec2(60.0, 0.0); + let expected = panned.to_doc(at); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(20); + button(&mut h, at, true, Modifiers::NONE); + button(&mut h, at, false, Modifiers::NONE); + assert_eq!(h.state().ui.polygon[1], expected, "the next click uses the updated view transform"); + h.run_steps(30); + click(&mut h, expected[0] as f32, 190.0, Modifiers::NONE); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(h.state().ui.polygon.is_empty()); + assert!(selection(&h).is_some_and(|r| near(r, 170, 130, expected[0].ceil() as i32, 190)), "{:?}", selection(&h)); + assert_eq!(steps(&h), before + 1); + let finished_center = h.state().ui.views[0].center; + h.event(egui::Event::PointerMoved(original.rect.right_center())); + h.run_steps(4); + assert_eq!(h.state().ui.views[0].center, finished_center, "a finished polygon never scrolls"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(selection(&h).is_none(), "one undo removes the completed selection"); +} + +#[test] +fn polygon_edge_scroll_keeps_pixel_aspect_and_shift_constrained_segments() { + for (flip, rotation) in [(false, 0.0), (true, 0.0), (false, 45.0), (true, 90.0)] { + let mut h = zoomed_polygon(); + h.state_mut().ui.view.pixel_aspect = "custom:2:Wide".into(); + h.state_mut().ui.view.flip_horizontal = flip; + h.state_mut().ui.views[0].rotation = rotation; + h.run_steps(2); + let original = ViewXform::active(h.state()).unwrap(); + let points = h.state().ui.polygon.clone(); + h.event(egui::Event::PointerMoved(original.rect.right_center() + vec2(4.0, 0.0))); + h.run_steps(5); + let panned = ViewXform::active(h.state()).unwrap(); + let movement = original.map_vec(vec2(panned.center[0] - original.center[0], panned.center[1] - original.center[1])); + assert!(movement.x > 1.0 && movement.y.abs() < 0.001, "wide pixels, flip={flip}, rotation={rotation}: {movement:?}"); + assert_eq!(h.state().ui.polygon, points, "scrolling keeps existing document-space vertices"); + + let at = panned.rect.center() + vec2(60.0, 30.0); + let expected = crate::canvas::constrained_polygon_point(points[0], panned.to_doc(at)); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT)); + h.run_steps(30); + button(&mut h, at, true, Modifiers::SHIFT); + button(&mut h, at, false, Modifiers::SHIFT); + assert_eq!(h.state().ui.polygon.len(), 2); + assert_eq!(h.state().ui.polygon[1], expected, "Shift still snaps in document space after panning"); + assert_eq!(h.state().ui.polygon_mode, "replace", "Shift after the first vertex does not change selection intent"); + } + + let mut h = zoomed_polygon(); + h.state_mut().ui.view.pixel_aspect = "custom:2:Wide".into(); + h.state_mut().run("prefs.set", json!({"path":"tools.overscroll","value":false})).unwrap(); + let xf = ViewXform::active(h.state()).unwrap(); + h.event(egui::Event::PointerMoved(xf.rect.right_center() + vec2(4.0, 0.0))); + h.run_steps(180); + let center = h.state().ui.views[0].center; + let rightmost = 400.0 - xf.rect.width() / (2.0 * xf.zoom_x()); + assert!((center[0] - rightmost).abs() < 0.001, "Overscroll off clamps using the displayed pixel aspect"); + h.run_steps(10); + assert_eq!(h.state().ui.views[0].center, center); +} + +#[test] +fn polygon_edge_scroll_pauses_for_dialogs_and_temporary_navigation() { + for pause in ["dialog", "space", "middle"] { + let mut h = zoomed_polygon(); + let rect = ViewXform::active(h.state()).unwrap().rect; + let edge = rect.right_center() + vec2(4.0, 0.0); + h.event(egui::Event::PointerMoved(edge)); + h.run_steps(1); + match pause { + "dialog" => { + h.state_mut().ui.open_dialog(crate::state::DialogKind::Error, json!({"message":"Pause"}).as_object().unwrap().clone()); + } + "space" => h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }), + _ => h.event(egui::Event::PointerButton { pos: edge, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }), + } + h.run_steps(2); + let center = h.state().ui.views[0].center; + let points = h.state().ui.polygon.clone(); + h.run_steps(5); + assert_eq!(h.state().ui.views[0].center, center, "paused for {pause}"); + assert_eq!(h.state().ui.polygon, points, "{pause} retains the unfinished selection"); + } +} + +#[test] +fn polygon_edge_scroll_never_moves_an_inactive_pane_or_survives_a_document_or_tool_switch() { + let mut h = zoomed_polygon(); + // Creating a document now cancels the old polygon immediately. Start a fresh one after + // returning, so the inactive-pane and Enter checks exercise an outline that can commit. + h.state_mut().run("file.new", json!({"width":400,"height":300})).unwrap(); + h.state_mut().session.set_active(0); + h.run_steps(2); + for (x, y) in [(170.0, 130.0), (200.0, 130.0), (200.0, 170.0)] { + click(&mut h, x, y, Modifiers::NONE); + h.run_steps(30); + } + assert_eq!(h.state().ui.polygon.len(), 3); + h.state_mut().ui.views[1].zoom = 4.0; + h.state_mut().ui.views[1].center = [200.0, 150.0]; + h.state_mut().ui.views[1].fit_pending = false; + h.state_mut().ui.view.arrange = "twoUpVertical".into(); + h.run_steps(2); + let other = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap().into_iter().find(|(i, _)| *i == 1).unwrap().1; + let centers: Vec<_> = h.state().ui.views.iter().map(|v| v.center).collect(); + h.event(egui::Event::PointerMoved(other.left_center() + vec2(4.0, 0.0))); + h.run_steps(5); + assert_eq!(h.state().ui.views.iter().map(|v| v.center).collect::>(), centers, "hovering the other pane never pans either view"); + assert_eq!(h.state().ui.polygon.len(), 3, "the active polygon is still unfinished"); + h.state_mut().session.set_active(1); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(h.state().ui.polygon.is_empty(), "the old document's unfinished polygon is discarded before Enter"); + assert!(selection(&h).is_none()); + + let mut h = zoomed_polygon(); + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(2); + assert!(h.state().ui.polygon.is_empty(), "changing tools drops the polygon"); + h.state_mut().ui.tool = Tool::PolygonLasso; + click(&mut h, 170.0, 130.0, Modifiers::NONE); + h.key_press(egui::Key::Escape); + h.run_steps(2); + let center = h.state().ui.views[0].center; + let edge = ViewXform::active(h.state()).unwrap().rect.right_center(); + h.event(egui::Event::PointerMoved(edge)); + h.run_steps(5); + assert!(h.state().ui.polygon.is_empty()); + assert_eq!(h.state().ui.views[0].center, center, "Escape stops edge scrolling"); +} + /// A new polygon hides the selection it will replace; Esc brings it back untouched. #[test] fn a_new_polygon_hides_the_selection_and_esc_restores_it() { diff --git a/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs b/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs index c1519b48d9..7f3586af55 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/dialogs.rs @@ -101,6 +101,8 @@ pub fn apply(app: &mut PhotocraftApp, id: u64) -> Result { return Err("Apply is only available for Preferences".into()); } let fields = d.fields.clone(); + // As OK does: an open colour picker would leave a previewed colour in the applied values. + crate::dialogs::confirm_preflight(app, id)?; let result = confirm(app, &fields)?; let values = Value::Object(preference_values(app.session.prefs())); if let Some(d) = app.ui.dialog_mut(id) { diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index 2258a3f7be..2accc50371 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -63,6 +63,9 @@ pub(super) fn humanize(key: &str) -> String { if key == "doNotAppendCopy" { return "Do not append \"copy\" to filename when saving a copy".into(); } + if key == "pcraftFullComposite" { + return "Save Full-Size Composite in .pcraft Files".into(); + } // Plug-ins: Photoshop's wording (#9392). if key == "generatorEnabled" { return "Enable Generator".into(); @@ -71,6 +74,10 @@ pub(super) fn humanize(key: &str) -> String { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } // Performance: the canvas compositor's GPU memory, 0 for the automatic budget (L3). + // Scratch Disks: how large the scratch file may grow; the free-space reserve always applies. + if key == "sizeLimitMb" { + return "Scratch Size Limit (MB, 0 = No Limit)".into(); + } if key == "gpuMemoryMb" { return "GPU Memory (MB, 0 = Automatic)".into(); } @@ -371,7 +378,7 @@ pub(super) fn field_visible(section: &str, obj: &Map, k: &str, qu (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) - && obj.get(k).is_some_and(|v| !matches!(v, Value::Null | Value::Object(_))) + && obj.get(k).is_some_and(|v| (path == "performance.memoryUsagePercent" && v.is_null()) || !matches!(v, Value::Null | Value::Object(_))) } /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, @@ -410,6 +417,18 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke let v = obj.get(&k).cloned().unwrap_or(Value::Null); let human = humanize(&k); let label = tl!(&human).to_string(); + if path == "performance.memoryUsagePercent" { + ui.label(RichText::new(&label).color(t.text_dim)); + let mut enabled = v.is_number(); + let mut percent = v.as_u64().and_then(|p| u32::try_from(p).ok()).unwrap_or(70); + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, &mut enabled, ""); + ui.add_enabled(enabled, egui::DragValue::new(&mut percent).range(1..=95).suffix("%")); + }); + obj.insert(k, if enabled { json!(percent) } else { Value::Null }); + ui.end_row(); + continue; + } match &v { Value::Bool(b) => { ui.label(""); diff --git a/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs b/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs index c7853067aa..f59bf278e8 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/shortcut_capture_tests.rs @@ -200,6 +200,27 @@ fn shift_is_recorded_when_modifier_key_events_carry_no_bits() { assert_eq!(field(&h, id, "capture"), json!(false)); } +/// ⌘Z can be bound like any other key (#9487): while a field waits for keys it is the shortcut +/// to record, not the dialog's own Undo (#4697), which used to undo the click that started the +/// capture. Once recorded, ⌘Z steps the dialog back again. +#[test] +fn cmd_z_is_recorded_while_capturing_and_undoes_otherwise() { + let (mut h, id) = dialog(1.0, "edit.search", "search…"); + capture(&mut h, &crate::shortcuts::pretty("Cmd+F"), Key::ControlLeft, Modifiers::COMMAND, Key::Z); + assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Cmd+Z")); + assert_eq!(field(&h, id, "capture"), json!(false)); + key(&mut h, Key::Z, true, Modifiers::COMMAND); + h.run_steps(2); + assert_ne!(field(&h, id, "overrides")["edit.search"], json!("Cmd+Z"), "⌘Z outside a capture undoes the change"); + assert_eq!(field(&h, id, "capture"), json!(false), "and doesn't wait for keys again"); + // CodeRabbit on #9504: clearing the restored capture isn't a new edit, so Redo still works. + h.run_steps(2); + key(&mut h, Key::Z, true, Modifiers::COMMAND | Modifiers::SHIFT); + h.run_steps(2); + assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Cmd+Z"), "⇧⌘Z redoes the recorded shortcut"); + 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] { diff --git a/crates/ui-egui/src/prefs/prefs_ui/startup.rs b/crates/ui-egui/src/prefs/prefs_ui/startup.rs index 1d4005f17a..16b945b49e 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/startup.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/startup.rs @@ -40,7 +40,10 @@ pub fn load(app: &mut PhotocraftApp) { } } } + // Applies Memory Usage and the scratch disks even when the stored preferences were reset. + app.session.apply_prefs(); crate::dock::restore(app); + crate::color_panel_ui::restore(app); crate::brush_picker::restore(app); crate::last_brush::restore(app); sync_rulers(app); diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 40fdf6ed7b..21994b7352 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -28,8 +28,6 @@ fn the_generator_checkbox_reads_enable_generator() { h.get_by_label("Enable Generator").click(); h.run_steps(2); assert_eq!(h.state()["generatorEnabled"], true); - let de = crate::i18n::Lang::from_code("de").unwrap(); - assert_eq!(crate::i18n::tr(de, "Enable Generator"), "Generator aktivieren"); } #[test] @@ -1025,10 +1023,15 @@ fn selected_tool_survives_a_restart_without_option_changes() { let (mut app, store) = app_with_store(); let ctx = egui::Context::default(); tick(&mut app, &ctx); - assert_eq!(app.ui.tool, Tool::Brush); - // Every tool, including flyout-only tools, can be restored. Return to Brush last to + assert_eq!(app.ui.tool, Tool::Move); + // Start from a non-default tool so selecting every tool (including Move) is an + // explicit change that gets persisted. + app.ui.tool = Tool::Brush; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + // Every tool, including flyout-only tools, can be restored. Return to Move last to // prove selecting the default overwrites a previously remembered nondefault tool. - for tool in Tool::ALL.into_iter().chain([Tool::Brush]) { + for tool in Tool::ALL.into_iter().chain([Tool::Move]) { app.ui.tool = tool; crate::tool_memory::persist(&mut app, &ctx); tick(&mut app, &ctx); @@ -1043,10 +1046,10 @@ fn selected_tool_survives_a_restart_without_option_changes() { #[test] fn selected_tool_is_flushed_on_exit_without_another_frame() { let (mut app, store) = app_with_store(); - app.ui.tool = crate::state::Tool::Move; + app.ui.tool = crate::state::Tool::Brush; crate::tool_memory::save_on_exit(&mut app).unwrap(); let (app2, _) = app_with_saved(store.lock().unwrap().clone()); - assert_eq!(app2.ui.tool, crate::state::Tool::Move); + assert_eq!(app2.ui.tool, crate::state::Tool::Brush); } #[test] @@ -1083,7 +1086,7 @@ fn invalid_selected_tool_preserves_other_preferences() { }) .to_string(), )); - assert_eq!(app.ui.tool, crate::state::Tool::Brush, "{bad}"); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "{bad}"); assert_eq!(app.ui.tool_options.feather, 12.0); } } @@ -1093,19 +1096,19 @@ fn selected_tool_excludes_temporary_switches() { use crate::state::Tool; let (mut app, store) = app_with_store(); let ctx = egui::Context::default(); - app.ui.tool = Tool::Move; + app.ui.tool = Tool::Brush; crate::tool_memory::persist(&mut app, &ctx); tick(&mut app, &ctx); app.tool_override = Some(Tool::Hand); crate::tool_memory::persist(&mut app, &ctx); tick(&mut app, &ctx); - assert_eq!(stored(&store)["selectedTool"], json!("Move")); + assert_eq!(stored(&store)["selectedTool"], json!("Brush")); app.tool_override = None; app.ui.tool = Tool::Zoom; - crate::spring_tools::pressed(&mut app, 'Z', Tool::Move, true, 0.0); + crate::spring_tools::pressed(&mut app, 'Z', Tool::Brush, true, 0.0); crate::tool_memory::persist(&mut app, &ctx); tick(&mut app, &ctx); - assert_eq!(stored(&store)["selectedTool"], json!("Move")); + assert_eq!(stored(&store)["selectedTool"], json!("Brush")); // A quick tap commits the choice after spring_tools clears the pending switch. crate::spring_tools::tick(&mut app, &ctx); crate::tool_memory::persist(&mut app, &ctx); @@ -1130,15 +1133,17 @@ fn tool_options_survive_a_restart() { tick(&mut app, &ctx); app.ui.tool_options.move_auto_select = false; app.ui.tool_options.feather = 12.0; + app.ui.tool_options.transform_interpolation = Some(photocraft_engine::prefs::Interpolation::Nearest); crate::tool_memory::persist(&mut app, &ctx); tick(&mut app, &ctx); // Only what differs from the defaults is stored. - assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0})); + assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0, "transform_interpolation": "nearestNeighbor"})); let saved = store.lock().unwrap().clone().unwrap(); let (mut app2, _) = app_with_saved(Some(saved)); tick(&mut app2, &ctx); assert!(!app2.ui.tool_options.move_auto_select); assert_eq!(app2.ui.tool_options.feather, 12.0); + assert_eq!(app2.ui.tool_options.transform_interpolation, Some(photocraft_engine::prefs::Interpolation::Nearest)); assert_eq!(app2.ui.tool_options.tolerance, crate::state::ToolOptions::default().tolerance); // A hand-edited file: unreadable fields keep their defaults, the good ones still load. for bad in [json!("junk"), json!({"move_auto_select": "no", "feather": 1e300, "tolerance": 5.0, "unknown": 1})] { @@ -1439,8 +1444,8 @@ fn unimplemented_preferences_are_hidden_from_the_dialog() { let values = prefs::Preferences::default().to_json(); assert!(has_visible_fields(&values, "general")); assert!(has_visible_fields(&values, "fileHandling")); - // Every setting of this section is still unimplemented. - assert!(!has_visible_fields(&values, "scratchDisks"), "scratchDisks"); + assert!(has_visible_fields(&values, "scratchDisks")); + assert!(!prefs::is_hidden("scratchDisks.disks")); // Allow Agent Control and Control Port are read at launch (apps/photocraft main.rs). assert!(has_visible_fields(&values, "integrations")); assert!(!prefs::is_hidden("integrations.allowAgentControl")); diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 74be08360c..5ea7b04615 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -95,18 +95,37 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option return Some(crate::kys_import::open_bytes(app, name, bytes).map(|_| ())), _ => return None, }; - let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Imported").into()); + // Trimmed like the engine trims `group`, so the clash check sees the name the import uses. + let stem = std::path::Path::new(name) + .file_stem() + .map(|s| s.to_string_lossy().trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| tl!("Imported").into()); let mut p = source(name, bytes); p["group"] = json!(stem); if what == "brushes" { p["select"] = json!(true); } - Some(app.run(cmd, p).map(|r| { + // A group of the same name with the user's presets: ask before importing (#10640). + if matches!(what, "brushes" | "gradients") { + let held = crate::preset_group_prompt::Held { cmd, what, stem, params: p }; + return Some(match crate::preset_group_prompt::intercept(app, held) { + Ok(Some(held)) => run_import(app, held.cmd, held.what, &held.stem, held.params), + Ok(None) => Ok(()), + Err(e) => Err(e), + }); + } + Some(run_import(app, cmd, what, &stem, p)) +} + +/// Run the import command for a preset file and report it in the status bar. +pub(crate) fn run_import(app: &mut PhotocraftApp, cmd: &str, what: &str, stem: &str, p: Value) -> Result<(), String> { + app.run(cmd, p).map(|r| { // A background import (#210): `jobs_ui` reports the count when it lands. if r.get("pending").and_then(Value::as_bool) == Some(true) { app.ui.status_error = false; let template = "Importing {what} from {file}…"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("what", tl!(what)), ("file", &stem)]); + app.ui.status = crate::i18n::fmt(tl!(template), &[("what", tl!(what)), ("file", stem)]); return; } let n = r.get("count").and_then(Value::as_u64).unwrap_or(0); @@ -118,7 +137,12 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), _ => ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), }; - let imported = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, one, other), &[("file", &stem)]); + let imported = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, one, other), &[("file", stem)]); + // Name the group too when it isn't the file's: "Kit (2)" beside an existing "Kit" (#10640). + let imported = match r.get("group").and_then(Value::as_str) { + Some(group) if group != stem.trim() => crate::i18n::fmt(tl!("{message} into “{group}”"), &[("message", &imported), ("group", group)]), + _ => imported, + }; app.ui.status = match warnings.first() { Some(warning) => { let template = "{message} (notes: {n}; {warning})"; @@ -134,7 +158,7 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option 3 * gb, "the charged estimate is shown: {fields:?}"); + assert_eq!(fields["available"], json!(5 * gb)); + assert_eq!(fields["capacityUnknown"], json!(false)); + assert_eq!(fields["uncertainCapacityWarning"], json!(false)); + let dlg_id = app.ui.dialogs[0].id; + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true, "{res}"); + assert!(app.ui.dialogs.is_empty(), "dialog dismissed after inline resume"); + assert_eq!(app.session.documents().len(), 1, "document opened via inline resume"); + assert_eq!(app.session.documents()[0].doc.name, "inline_resume.psd"); + assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/inline_resume.psd")); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.held_opens.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.jobs.opens.is_empty()); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0, "the approval consumed and released the preflight"); + }); + }); +} + +/// #10897: a background open carries the same scoped estimate into its worker through the +/// explicitly captured test scope (never a global mock), so the real job raises the approval +/// prompt; the retry then opens the file only with the one-shot approval. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn memory_confirmation_resume_background_success() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let mut app = app(); + app.background_jobs = true; + let psd = recovery_psd::make_psd(false); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + app.open_bytes_from("bg_resume.psd", &psd, Some("/test/bg_resume.psd")).unwrap(); + assert_eq!(app.jobs.opens.len(), 1, "a background open tab appears at once"); + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + app.session.wait_job(id).unwrap(); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + assert!(app.session.documents().is_empty(), "the file waits for approval"); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert!(fields["estimated"].as_u64().unwrap() > 3 * gb, "the worker made the charged estimate: {fields:?}"); + assert_eq!(fields["available"], json!(5 * gb)); + assert_eq!(fields["capacityUnknown"], json!(false)); + let dlg_id = app.ui.dialogs[0].id; + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true, "{res}"); + assert_eq!(app.jobs.opens.len(), 1, "background retry job registered in jobs.opens"); + + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + app.session.wait_job(id).unwrap(); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + assert_eq!(app.session.documents().len(), 1, "document opened via background resume"); + assert_eq!(app.session.documents()[0].doc.name, "bg_resume.psd"); + assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/bg_resume.psd")); + assert!(app.ui.dialogs.is_empty()); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.held_opens.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.jobs.opens.is_empty()); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0); + }); + }); +} + +#[test] +fn memory_confirmation_refuses_when_source_changed_inline() { + let mut app = app(); + app.background_jobs = false; + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("changed.psd"); + std::fs::write(&path, b"initial file contents").unwrap(); + + let src = photocraft_engine::jobs::OpenSource::Path(path.to_string_lossy().to_string()); + let fp = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "changed.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 50_000, + available_ram: Some(100_000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory( + &mut app, + preflight_id, + "changed.psd".into(), + Some(path.to_string_lossy().to_string()), + None, + None, + 1000, + Some(100_000), + false, + false, + ); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + // Mutate file before confirming: + std::fs::write(&path, b"modified file contents longer").unwrap(); + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], false, "confirming changed file inline must fail"); + let error = res["error"].as_str().unwrap_or_default(); + assert!(error.contains("changed since it was checked"), "the refusal names the changed identity, not a parser failure: {res}"); + assert!(app.session.documents().is_empty()); + assert!(app.held_memory_prompts.is_empty()); +} + +#[test] +fn memory_confirmation_refuses_when_source_changed_async() { + let mut app = app(); + app.background_jobs = true; + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("changed_async.psd"); + std::fs::write(&path, b"initial file contents").unwrap(); + + let src = photocraft_engine::jobs::OpenSource::Path(path.to_string_lossy().to_string()); + let fp = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "changed_async.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 50_000, + available_ram: Some(100_000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory( + &mut app, + preflight_id, + "changed_async.psd".into(), + Some(path.to_string_lossy().to_string()), + None, + None, + 1000, + Some(100_000), + false, + false, + ); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + // Mutate file before confirming: + std::fs::write(&path, b"modified file contents longer").unwrap(); + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true, "confirmation kicks off background job"); + assert_eq!(app.jobs.opens.len(), 1); + + // Run the job to completion and observe async failure: + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + let _ = app.session.wait_job(id); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + + assert!(app.session.documents().is_empty(), "no document opened on changed file failure"); + assert!(app.jobs.opens.is_empty(), "opens cleared on job failure"); + assert!(app.ui.status_error, "error recorded in UI status"); + assert!(app.ui.status.contains("changed since it was checked"), "the background refusal names the changed identity: {}", app.ui.status); +} + +#[test] +fn memory_decline_advances_queue_and_cleans_batch() { + let mut app = app(); + let batch_id = crate::jobs_ui::begin_batch(&mut app); + let order = crate::jobs_ui::OpenOrder { batch: batch_id, seq: 0 }; + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "declined.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, preflight_id, "declined.psd".into(), None, None, Some(order), 1000, Some(4000), false, false); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + let res = call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true); + assert!(app.ui.dialogs.is_empty()); + assert!(app.session.take_pending_preflight(preflight_id).is_none()); + assert!(app.jobs.batches.is_empty(), "batch cleaned up on decline"); + assert!(app.prompt_order.is_empty()); +} + +#[test] +fn batch_single_file_cancel_preserves_other_held_memory_orders() { + let mut app = app(); + let batch_id = crate::jobs_ui::begin_batch(&mut app); + let order0 = crate::jobs_ui::OpenOrder { batch: batch_id, seq: 0 }; + let order1 = crate::jobs_ui::OpenOrder { batch: batch_id, seq: 1 }; + + let p0 = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "first.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + let p1 = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "second.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, p0, "first.psd".into(), None, None, Some(order0), 1000, Some(4000), false, false); + crate::open_prompt::arrive_memory(&mut app, p1, "second.psd".into(), None, None, Some(order1), 1000, Some(4000), false, false); + + // Cancel first file: + let dlg0 = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg0}))["ok"], true); + + // Batch must be preserved because second file is still waiting: + assert_eq!(app.jobs.batches.len(), 1, "batch preserved while second file is waiting"); + assert_eq!(app.held_memory_prompts.len(), 1); + + // Now cancel second file: + let dlg1 = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg1}))["ok"], true); + assert!(app.jobs.batches.is_empty(), "batch cleaned after all members cancelled"); + assert!(app.prompt_order.is_empty()); +} + +#[test] +fn profile_cancel_cancels_whole_queue_including_memory_prompts() { + let mut app = app(); + let gb = 1u64 << 30; + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "high_mem.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 7 * gb, + soft_limit: 4 * gb, + hard_limit: 8 * gb, + available_ram: Some(10 * gb), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb), false, false); + + crate::open_prompt::arrive( + &mut app, + crate::open_prompt::Held { + doc: photocraft_doc::Document::new("test", photocraft_geom::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8), + read_only: false, + damage_pending: false, + name: "profile.psd".into(), + path: None, + warnings: vec![], + bytes: None, + slot: None, + order: None, + question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})), + policy_applied: false, + }, + ); + + let r = crate::open_prompt::answer(&mut app, None).unwrap(); + assert_eq!(r["cancelled"], 2, "both held open and memory prompt dropped"); + assert!(app.held_opens.is_empty()); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.session.take_pending_preflight(preflight_id).is_none(), "pending preflight cancelled"); +} + +#[test] +fn mixed_prompts_follow_arrival_order_fifo() { + let mut app = app(); + let gb = 1u64 << 30; + + // 1. Arrive damage prompt first + crate::open_prompt::arrive( + &mut app, + crate::open_prompt::Held { + doc: photocraft_doc::Document::new("test", photocraft_geom::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8), + read_only: false, + damage_pending: true, + name: "first_damage.psd".into(), + path: None, + warnings: vec!["damaged data".into()], + bytes: None, + slot: None, + order: None, + question: None, + policy_applied: false, + }, + ); + + // 2. Arrive memory prompt second + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "second_mem.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 7 * gb, + soft_limit: 4 * gb, + hard_limit: 8 * gb, + available_ram: Some(10 * gb), + confirm_damage: true, + }) + .unwrap(); + crate::open_prompt::arrive_memory(&mut app, preflight_id, "second_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb), false, false); + + // First prompt shown must be damage (since it arrived first)! + assert_eq!(app.ui.dialogs.len(), 1); + assert!(crate::open_prompt::owns_damage(&app.ui.dialogs[0].fields)); + + // Cancel damage prompt: + let dlg_id = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_id}))["ok"], true); + + // Now memory prompt must be shown! + assert_eq!(app.ui.dialogs.len(), 1); + assert!(crate::open_prompt::owns_memory(&app.ui.dialogs[0].fields)); + let dlg_mem = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_mem}))["ok"], true); + assert!(app.ui.dialogs.is_empty()); +} + +/// A stored preflight for the prompt tests, with a tiny in-memory source so nothing large is +/// ever held. +fn store_preflight(app: &mut PhotocraftApp, name: &str, required: u64, available: Option) -> u64 { + let bytes = std::sync::Arc::new(vec![0u8; 16]); + let source = photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::clone(&bytes)); + let fingerprint = photocraft_engine::jobs::SourceFingerprint::of(&source).unwrap(); + app.session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: name.into(), + source, + fingerprint, + required, + soft_limit: required / 2, + hard_limit: required.saturating_mul(2), + available_ram: available, + confirm_damage: true, + }) + .unwrap() +} + +/// #10897: the browser and desktop share one consent route. The web shell runs every open inline +/// (`background_jobs == false`), answers the same `MemoryPrompt`, and dispatches the same +/// `file.approveMemory` command: it is not menu-only, journaled or gated off wasm. The web's own +/// allowance and 2 GiB product cap stay in `photocraft-io`'s policy, so approval never raises +/// them. This checks the routing half on the host; the web policy half is covered by +/// `photocraft_io::memory`'s web-policy tests. +#[test] +fn memory_approval_routes_through_the_file_approve_memory_command() { + let app = app(); + assert!(!app.background_jobs, "the browser shell is always inline"); + assert!(app.session.is_enabled("file.approveMemory"), "the one-shot approval command is available on every target"); + let spec = photocraft_engine::commands::find("file.approveMemory").expect("registered"); + assert!(spec.menu.is_empty(), "it is not a menu item"); + assert!(!spec.journal, "a one-shot permission is never journaled or replayed"); +} + +/// #10897: on the web the same prompt offers consent, and `file.approveMemory` may authorize an +/// estimate above the 1 GiB automatic allowance only within the 2 GiB product cap: a 1.5 GiB +/// estimate is approved and opens, a 3 GiB estimate is refused before decoding. The fixed mock +/// snapshot is ignored on wasm (the web policy does not read one), so the charge alone sets the +/// window. This target is compile-checked, not executed, by the current CI (`cargo xtask wasm` +/// checks the crates without test targets); the same web policy is exercised on native by +/// `photocraft_io::memory`'s web-policy tests. +#[cfg(target_arch = "wasm32")] +#[test] +fn web_memory_prompt_offers_consent_within_the_two_gib_product_cap() { + let gb = 1u64 << 30; + let psd = recovery_psd::make_psd(false); + let mut approved = app(); + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb / 2, || { + approved.open_bytes_from("web.psd", &psd, Some("web.psd")).unwrap(); + assert_eq!(approved.held_memory_prompts.len(), 1, "1.5 GiB is above the web's 1 GiB automatic allowance"); + let fields = &approved.ui.dialogs[0].fields; + assert_eq!(fields["available"], json!(null)); + assert_eq!(fields["capacityUnknown"], json!(true), "the browser cannot measure the tab's capacity"); + let dlg = approved.ui.dialogs[0].id; + assert_eq!(call(&mut approved, "ui.dialog.confirm", json!({"dialog": dlg}))["ok"], true); + assert_eq!(approved.session.documents().len(), 1, "consent opens an estimate within the product cap"); + }); + let mut refused = app(); + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + assert!(refused.open_bytes_from("too-big.psd", &psd, Some("too-big.psd")).is_err(), "consent cannot raise the 2 GiB product cap"); + assert!(refused.ui.dialogs.is_empty()); + assert!(refused.session.documents().is_empty()); + }); +} + +/// #10897: the prompt shows an unmeasured capacity honestly (no invented number) and adds the +/// above-8-GiB warning, both from the engine's typed flags, so the browser and desktop consent +/// text matches what was actually measured. +#[test] +fn memory_prompt_states_unknown_capacity_and_the_eight_gib_warning() { + let gb = 1u64 << 30; + let mut app = app(); + let id = store_preflight(&mut app, "unknown.psd", 9 * gb, None); + let tab = crate::jobs_ui::OpenTab { + job: photocraft_engine::jobs::JobId(1), + name: "unknown.psd".into(), + path: Some("/pics/unknown.psd".into()), + slot: None, + order: None, + }; + crate::jobs_ui::finish_open_tab( + &mut app, + &tab, + &json!({"pendingPreflight": id, "estimated": 9 * gb, "availableRam": null, "capacityUnknown": true, "uncertainCapacityWarning": true}), + ) + .unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert_eq!(fields["available"], json!(null), "no number is invented for an unmeasured capacity"); + assert_eq!(fields["capacityUnknown"], json!(true)); + assert_eq!(fields["uncertainCapacityWarning"], json!(true)); + let message = fields["message"].as_str().unwrap(); + assert!(message.contains("could not be measured"), "{message}"); + assert!(message.contains("8 GiB") && message.contains("may run out of memory"), "{message}"); + assert!(!message.contains("available)"), "the unknown-capacity message claims no available figure: {message}"); + let dialog = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); + + // A measured capacity shows the number and drops the uncertainty warning. + let id = store_preflight(&mut app, "known.psd", 3 * gb, Some(5 * gb)); + let tab = crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(2), name: "known.psd".into(), path: None, slot: None, order: None }; + crate::jobs_ui::finish_open_tab( + &mut app, + &tab, + &json!({"pendingPreflight": id, "estimated": 3 * gb, "availableRam": 5 * gb, "capacityUnknown": false, "uncertainCapacityWarning": false}), + ) + .unwrap(); + let fields = &app.ui.dialogs[0].fields; + assert_eq!(fields["available"], json!(5 * gb)); + assert_eq!(fields["capacityUnknown"], json!(false)); + let message = fields["message"].as_str().unwrap(); + assert!(message.contains("5.0 GiB available"), "{message}"); + assert!(!message.contains("could not be measured") && !message.contains("may run out of memory"), "{message}"); + let dialog = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); + assert!(app.ui.dialogs.is_empty()); +} + +/// #10897: reopening an ordinary file whose high-memory prompt is already waiting shows that +/// prompt instead of queueing a second one and a second open; a template still opens again as +/// another Untitled, like one that is still loading. +#[test] +fn reopening_a_file_whose_memory_prompt_waits_does_not_queue_a_duplicate() { + let gb = 1u64 << 30; + let mut app = app(); + let id = store_preflight(&mut app, "slow.psd", 3 * gb, Some(5 * gb)); + crate::open_prompt::arrive_memory(&mut app, id, "slow.psd".into(), Some("/pics/slow.psd".into()), None, None, 3 * gb, Some(5 * gb), false, false); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.ui.dialogs.len(), 1); + assert!(app.show_if_open("/pics/slow.psd"), "the waiting prompt is shown"); + assert_eq!(app.held_memory_prompts.len(), 1, "no second prompt for the same file"); + assert_eq!(app.ui.dialogs.len(), 1); + + // The template exception: a repeat open still creates another Untitled. + let id = store_preflight(&mut app, "card.psdt", 3 * gb, Some(5 * gb)); + crate::open_prompt::arrive_memory(&mut app, id, "card.psdt".into(), Some("/pics/card.psdt".into()), None, None, 3 * gb, Some(5 * gb), false, false); + assert_eq!(app.held_memory_prompts.len(), 2); + assert!(!app.show_if_open("/pics/card.psdt"), "a template reopens instead of reusing the waiting prompt"); + assert_eq!(app.held_memory_prompts.len(), 2); +} + +/// A tiny PNG so a set can hold one PSD that needs approval and one flat file that opens at once. +#[cfg(not(target_arch = "wasm32"))] +fn png_file() -> Vec { + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![10; 2 * 2 * 3]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +/// #10897: a set opened inline keeps the charged PSD's place in the set through its memory +/// prompt: the prompt carries the batch order and the answered file lands before the later member. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_set_keeps_the_memory_prompted_file_in_batch_order() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let dir = tempfile::tempdir().unwrap(); + let psd = dir.path().join("a_high.psd"); + let png = dir.path().join("b_flat.png"); + std::fs::write(&psd, recovery_psd::make_psd(false)).unwrap(); + std::fs::write(&png, png_file()).unwrap(); + let paths = vec![psd.to_string_lossy().into_owned(), png.to_string_lossy().into_owned()]; + let mut app = app(); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + assert_eq!(app.open_paths(&paths), 2); + assert_eq!(app.held_memory_prompts.len(), 1, "the charged PSD waits for approval"); + assert_eq!(app.held_memory_prompts.front().unwrap().order.map(|o| o.seq), Some(0), "the set order travels with the prompt"); + // The flat file waits behind the prompt, keeping its own place in the set. + assert_eq!(app.held_opens.len(), 1); + assert_eq!(app.held_opens.front().unwrap().order.map(|o| o.seq), Some(1)); + let dlg = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg}))["ok"], true); + let names: Vec<_> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["a_high.psd", "b_flat.png"]); + assert!(app.held_memory_prompts.is_empty() && app.held_opens.is_empty()); + assert!(app.jobs.batches.is_empty(), "the set is forgotten once every member opened"); + assert!(app.prompt_order.is_empty()); + }); + }); +} + +/// A real tiny layered TIFF: a small two-layer RGB document exported with Photoshop layer data. +/// The fixture is the actual file the UI opens, not a fabricated preflight. +#[cfg(not(target_arch = "wasm32"))] +fn exported_layered_tiff(w: u32, h: u32) -> Vec { + use photocraft_doc::{Document, Layer, LayerContent}; + use photocraft_raster::Surface; + + let mut doc = Document::new("layers.tif", photocraft_geom::Size::new(w, h), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + let bytes = (w as usize * h as usize * fmt.bytes_per_pixel()).max(1); + for name in ["bottom", "top"] { + let mut s = Surface::new(fmt); + s.write_interleaved(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &vec![96u8; bytes]); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + photocraft_io::export(&doc, "layers.tif", &photocraft_io::ExportOptions { tiff_layers: true, ..Default::default() }).expect("export a layered TIFF").bytes +} + +/// #10897: a layered TIFF opened inline reaches the same high-memory prompt a PSD does. Only the +/// typed IO path carries `PsdApprovalRequired`; the shell's plain import service turns it into a +/// terminal string, so without that routing this open fails instead of prompting. The real +/// two-layer fixture plus the scoped estimate charge land in the confirmation band under a fixed +/// 5 GiB, and the prompt arrives before any composite pixel is decoded. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_layered_tiff_prompts_before_decoding_and_opens_or_declines() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let bytes = exported_layered_tiff(8, 6); + let path = "/pics/layered.tif"; + let mut app = app(); + assert!(!app.background_jobs, "the inline path the web shell uses"); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + let order = crate::jobs_ui::OpenOrder { batch: crate::jobs_ui::begin_batch(&mut app), seq: 0 }; + app.open_bytes_placed("layered.tif", &bytes, Some(path), None, Some(order)).unwrap(); + // The prompt comes first: nothing is decoded, no tab is opened, and the engine holds + // the source for the approval. The set order travels with the prompt. + assert!(app.session.documents().is_empty(), "the layered TIFF waits for approval instead of opening"); + assert!(app.jobs.opens.is_empty(), "inline: no background tab"); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.held_memory_prompts.front().unwrap().order, Some(order)); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert!(fields["estimated"].as_u64().unwrap() > 3 * gb, "the charged estimate is shown: {fields:?}"); + assert_eq!(fields["available"], json!(5 * gb)); + assert!(app.session.pending_preflight_bytes().unwrap() > 0, "the engine preflight holds the source"); + + // Accepting runs the typed TIFF import with the one-shot approval: both layers, in + // their order, keep the document's path, and the preflight is released. + let dlg = app.ui.dialogs[0].id; + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg})); + assert_eq!(res["ok"], true, "{res}"); + assert_eq!(app.session.documents().len(), 1, "the approved TIFF opens"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.name, "layered.tif"); + assert_eq!(st.path.as_deref(), Some(path), "the approved open keeps its path"); + let names: Vec<_> = st.doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, ["bottom", "top"], "both layers open in their order"); + assert!(app.ui.dialogs.is_empty(), "a TIFF has no damage prompt"); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.jobs.batches.is_empty(), "the set is forgotten once its prompted file opened"); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0, "accepting clears the engine preflight"); + + // Declining opens nothing and releases the engine preflight too. + app.open_bytes_from("declined.tif", &bytes, Some("/pics/declined.tif")).unwrap(); + assert_eq!(app.held_memory_prompts.len(), 1); + assert!(app.session.pending_preflight_bytes().unwrap() > 0); + let dlg = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg}))["ok"], true); + assert!(app.held_memory_prompts.is_empty() && app.ui.dialogs.is_empty()); + assert_eq!(app.session.documents().len(), 1, "declining opens nothing"); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0, "declining clears the engine preflight"); + }); + }); +} + +/// A TIFF whose Photoshop layer tag cannot be read still opens flattened through the typed path, +/// with its warning: routing TIFF magic to typed IO must not turn a malformed tag into a refusal. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_tiff_with_a_malformed_layer_tag_opens_flattened() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![40; 4 * 3 * 3]).unwrap(); + img.meta.photoshop_layers = Some(b"not Photoshop image source data".to_vec()); + let bytes = photocraft_codecs::encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let mut app = app(); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(16 << 30), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snap, || { + let warnings = app.open_bytes("bogus.tif", &bytes).unwrap(); + assert_eq!(app.session.documents().len(), 1, "the composite still opens"); + assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "a malformed tag opens one flat layer"); + assert!(warnings.iter().any(|w| w.contains("opened flattened")), "{warnings:?}"); + assert!(app.ui.dialogs.is_empty()); + }); +} + +/// #10897: a file dropped on a tab slot keeps that slot through an inline memory prompt, even +/// though the open completes later (before, it appended to the end of the tab strip). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_drop_slot_survives_the_memory_prompt() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let mut app = app(); + for name in ["first.psd", "second.psd"] { + app.open_bytes(name, &recovery_psd::make_psd(false)).unwrap(); + } + assert_eq!(app.session.documents().len(), 2); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + app.open_bytes_placed("dropped.psd", &recovery_psd::make_psd(false), None, Some(0), None).unwrap(); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.held_memory_prompts.front().unwrap().slot, Some(0), "the drop slot travels with the prompt"); + let dlg = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg}))["ok"], true); + let names: Vec<_> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["dropped.psd", "first.psd", "second.psd"], "the approved drop keeps its tab slot"); + }); + }); +} diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 97f252772f..f23a440d4d 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -248,6 +248,29 @@ fn maximize_compatibility_preference_reaches_the_exporter() { } } +/// Preferences › File Handling › Save Full-Size Composite in .pcraft Files reaches the exporter +/// of Save As, Save and automation saves, and is off by default. +#[test] +fn full_composite_preference_reaches_the_exporter() { + let seen = Rc::new(RefCell::new(Vec::new())); + let mut app = app(); + assert!(!app.save_settings().pcraft_full_composite, "off by default"); + let log = seen.clone(); + app.services.export = Some(Box::new(move |_, _, settings: &ExportSettings| { + log.borrow_mut().push(settings.io_options().pcraft_full_composite); + Ok((b"out".to_vec(), Vec::new())) + })); + let ctx = egui::Context::default(); + for value in [true, false] { + app.run("prefs.set", json!({"path": "fileHandling.pcraftFullComposite", "value": value})).unwrap(); + seen.borrow_mut().clear(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": format!("{value}.pcraft")})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.save_automation(Some(format!("{value} agent.pcraft"))).unwrap(); + assert_eq!(*seen.borrow(), [value; 3], "{value}"); + } +} + /// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality, /// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults. #[test] diff --git a/crates/ui-egui/src/saved_path_edit_tests.rs b/crates/ui-egui/src/saved_path_edit_tests.rs new file mode 100644 index 0000000000..efa3d9faf1 --- /dev/null +++ b/crates/ui-egui/src/saved_path_edit_tests.rs @@ -0,0 +1,192 @@ +//! #8478: the saved path selected in the Paths panel is Photoshop's target path, and Path +//! Selection, Direct Selection and the anchor tools edit it, as they edit the work path. It was +//! drawn (#6550) but none of the tools picked it, so a path that arrived in a PSD ("Path 1") could +//! not be selected or changed. + +use egui::Modifiers; +use photocraft_doc::vector::Path; +use photocraft_geom::Point; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::direct_select::PathRef; +use crate::state::Tool; + +const NAME: &str = "Path 1"; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = tool; + app +} + +fn square(x: f64, y: f64, w: f64) -> serde_json::Value { + json!({"subpaths": [{"closed": true, "knots": [[x, y], [x + w, y], [x + w, y + w], [x, y + w]]}]}) +} + +/// A saved path, a square from (40, 40) to (120, 120), selected in the Paths panel. +fn with_saved_path(app: &mut PhotocraftApp) { + app.run("path.set", json!({"name": NAME, "path": square(40.0, 40.0, 80.0)})).unwrap(); + app.ui.selected_path = Some(NAME.into()); +} + +fn saved(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.paths.iter().find(|p| p.name == NAME).unwrap().path.clone() +} + +fn anchors(p: &Path) -> Vec { + p.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor)).collect() +} + +fn steps(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.entries().len() +} + +fn 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); + let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0]; + tool_event(app, ToolEvent::Move { x: mid[0], y: mid[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); +} + +#[test] +fn direct_selection_drags_an_anchor_of_the_selected_saved_path() { + let mut app = app(Tool::DirectSelection); + with_saved_path(&mut app); + let (before, n) = (saved(&app), steps(&app)); + drag(&mut app, [120.5, 120.5], [130.5, 135.5], Modifiers::NONE); + let after = saved(&app); + assert_eq!(after.subpaths[0].knots[2].anchor, Point::new(130.0, 135.0), "the dragged anchor moved"); + assert_eq!(after.subpaths[0].knots[0].anchor, Point::new(40.0, 40.0), "the others stay"); + assert_eq!(steps(&app), n + 1, "one history step"); + assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Saved(0)), vec![[0, 2]])); + assert!(app.session.active().unwrap().doc.work_path.is_none(), "no work path was made"); + assert!(app.session.undo()); + assert_eq!(saved(&app), before); +} + +#[test] +fn direct_selection_marquee_selects_and_arrow_params_name_the_saved_path() { + let mut app = app(Tool::DirectSelection); + with_saved_path(&mut app); + drag(&mut app, [30.0, 30.0], [50.0, 50.0], Modifiers::NONE); + assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Saved(0)), vec![[0, 0]])); + assert_eq!(PathRef::Saved(0).params(&app), json!({"name": NAME})); + // A path that is gone gets no name, which the engine refuses instead of editing another one. + assert_eq!(PathRef::Saved(7).params(&app), json!({"name": ""})); + assert!(app.run("path.moveAnchors", json!({"name": "", "anchors": [[0, 0]], "move": [1, 1]})).is_err()); +} + +#[test] +fn the_saved_path_is_only_edited_while_the_paths_panel_selects_it() { + let mut app = app(Tool::DirectSelection); + with_saved_path(&mut app); + app.ui.selected_path = None; + let before = saved(&app); + drag(&mut app, [120.5, 120.5], [130.5, 135.5], Modifiers::NONE); + assert_eq!(saved(&app), before, "an unselected saved path is not the target path"); + assert!(!crate::direct_select::active_paths(&app).iter().any(|(r, _)| matches!(r, PathRef::Saved(_)))); +} + +#[test] +fn the_selected_saved_path_comes_before_the_work_path() { + let mut app = app(Tool::DirectSelection); + with_saved_path(&mut app); + // A work path on top of the same square: the saved path the panel selects wins. + app.run("path.set", json!({"name": "work", "path": square(40.0, 40.0, 80.0)})).unwrap(); + let work = app.session.active().unwrap().doc.work_path.clone().unwrap(); + let refs: Vec = crate::direct_select::active_paths(&app).into_iter().map(|(r, _)| r).collect(); + assert_eq!(refs, vec![PathRef::Saved(0), PathRef::Work]); + drag(&mut app, [40.5, 40.5], [20.5, 30.5], Modifiers::NONE); + assert_eq!(saved(&app).subpaths[0].knots[0].anchor, Point::new(20.0, 30.0)); + assert_eq!(app.session.active().unwrap().doc.work_path.clone().unwrap(), work, "the work path is untouched"); +} + +#[test] +fn path_selection_moves_the_selected_saved_path() { + let mut app = app(Tool::PathSelection); + with_saved_path(&mut app); + let n = steps(&app); + drag(&mut app, [80.0, 40.0], [90.0, 55.0], Modifiers::NONE); + assert_eq!( + anchors(&saved(&app)), + vec![Point::new(50.0, 55.0), Point::new(130.0, 55.0), Point::new(130.0, 135.0), Point::new(50.0, 135.0)], + "the whole path moved by (10, 15)" + ); + assert_eq!(steps(&app), n + 1); + assert!(app.session.active().unwrap().doc.work_path.is_none()); +} + +#[test] +fn free_transform_path_names_the_selected_saved_path() { + let mut app = app(Tool::PathSelection); + with_saved_path(&mut app); + let (params, path) = crate::vector_ui::free_transform_path(&app).expect("the saved path is transformable"); + assert_eq!(params, json!({"name": NAME})); + assert_eq!(path, saved(&app)); +} + +#[test] +fn add_and_delete_anchor_point_edit_the_selected_saved_path() { + let mut app = app(Tool::AddAnchorPoint); + with_saved_path(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 80.0, y: 40.0 }, Modifiers::NONE); + assert_eq!(anchors(&saved(&app)).len(), 5, "an anchor was added on the top segment"); + app.ui.tool = Tool::DeleteAnchorPoint; + tool_event(&mut app, ToolEvent::Down { x: 120.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 120.0 }, Modifiers::NONE); + assert!(!anchors(&saved(&app)).contains(&Point::new(120.0, 120.0)), "the corner anchor was deleted"); +} + +/// The report's file: a PSD whose saved path "Path 1" is picked in the Paths panel. A synthetic +/// one, written and read back through the PSD codec. +#[test] +fn a_saved_path_read_from_a_psd_is_editable() { + let mut src = app(Tool::DirectSelection); + with_saved_path(&mut src); + let doc = src.session.active().unwrap().doc.clone(); + let bytes = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes; + let mut app = app(Tool::DirectSelection); + photocraft_engine::file_cmds::open_bytes_as(&mut app.session, "paths.psd", &bytes, None, None).unwrap(); + app.sync_views(); + assert!(app.session.active().unwrap().doc.paths.iter().any(|p| p.name == NAME), "the PSD keeps its saved path"); + app.ui.selected_path = Some(NAME.into()); + let n = steps(&app); + drag(&mut app, [120.5, 120.5], [130.5, 135.5], Modifiers::NONE); + // PSD stores path points as 8.24 fixed-point fractions of the canvas: compare loosely. + let a = saved(&app).subpaths[0].knots[2].anchor; + assert!((a.x - 130.0).abs() < 1e-3 && (a.y - 135.0).abs() < 1e-3, "the anchor moved to (130, 135): {a:?}"); + assert_eq!(steps(&app), n + 1); +} + +/// Anchors selected on one saved path are dropped once the Paths panel picks another: the arrow +/// keys must not nudge a path that is no longer the target path. +#[test] +fn arrows_leave_a_saved_path_the_paths_panel_no_longer_selects() { + let mut app = app(Tool::DirectSelection); + with_saved_path(&mut app); + app.run("path.set", json!({"name": "Path 2", "path": square(10.0, 10.0, 20.0)})).unwrap(); + drag(&mut app, [30.0, 30.0], [50.0, 50.0], Modifiers::NONE); + assert_eq!((app.ui.direct_selection.target, app.ui.direct_selection.anchors.clone()), (Some(PathRef::Saved(0)), vec![[0, 0]])); + // One → press through the canvas arrow keys, as egui delivers it. + let arrow = |app: &mut PhotocraftApp| { + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }) + }); + crate::direct_select::arrow_keys(app, &ctx) + }; + assert!(arrow(&mut app), "the selected path's anchor is nudged"); + assert_eq!(saved(&app).subpaths[0].knots[0].anchor, Point::new(41.0, 40.0)); + app.ui.selected_path = Some("Path 2".into()); + let (one, two) = (saved(&app), app.session.active().unwrap().doc.paths[1].path.clone()); + assert!(!arrow(&mut app), "no key is used for a path that is not the target"); + assert_eq!(saved(&app), one, "Path 1 stays"); + assert_eq!(app.session.active().unwrap().doc.paths[1].path, two, "Path 2 stays"); + assert_eq!(app.ui.direct_selection.target, None, "the stale anchor selection is dropped"); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 53d3c2e7f0..7f05fdc1de 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,20 +1,22 @@ use super::{ CaptionStyle, Document, FileDialogFn, OsEvent, Value, dds_options_ui, jobs_ui, jpeg_options_ui, monitor_status, native_menu, screen_picker, - targa_options_ui, + targa_options_ui, tiff_options_ui, }; /// 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 +/// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`), JPEG Options (`"quality"` 0–12, +/// `"jpegFormat"`) and TIFF Options (`"tiffCompression"`, `"tiffTransparency"`, `"tiffPyramid"`, +/// `"tiffLayers"`). `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, + pub tiff: Option, } impl FormatChoices { @@ -24,6 +26,7 @@ impl 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)?, + tiff: tiff_options_ui::choice_param(params, cmd)?, }) } @@ -34,12 +37,17 @@ impl 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)?, + tiff: tiff_options_ui::choice_param(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 }; + let settings = match self.tiff { + Some(t) => t.apply(settings), + None => settings, + }; match self.jpeg { Some(j) => j.apply(settings), None => settings, @@ -57,6 +65,9 @@ impl FormatChoices { if let Some(j) = self.jpeg { j.record(step); } + if let Some(t) = self.tiff { + t.record(step); + } } } @@ -76,6 +87,13 @@ pub struct ExportSettings { pub webp_quality: Option, /// TIFF: keep the layers (Photoshop layer data); `false` is "Discard Layers and Save a Copy". pub tiff_layers: bool, + /// TIFF Options › Image Compression. + pub tiff_compression: photocraft_codecs::TiffCompression, + /// TIFF Options › Save Transparency (`None`: kept unless the document has alpha or spot + /// channels; see [`photocraft_io::ExportOptions::tiff_transparency`]). + pub tiff_transparency: Option, + /// TIFF Options › Save Image Pyramid. + pub tiff_pyramid: bool, /// Embed the document's whole XMP packet. `true` by default (Save As keeps the metadata); /// Export As starts at Metadata: None, since the packet can carry the text of every type /// layer and one id per placed document (#647). @@ -92,6 +110,9 @@ pub struct ExportSettings { /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility; see [`PhotocraftApp::save_settings`]). pub psd_maximize_compatibility: bool, + /// `.pcraft`: also write the full-size composite (Preferences › File Handling › Save Full-Size + /// Composite in .pcraft Files; see [`PhotocraftApp::save_settings`]). Off by default. + pub pcraft_full_composite: bool, /// Embed the document's ICC profile when the format supports it. On for Save As and, unless /// Export As turns it off, for export. pub embed_icc: bool, @@ -116,11 +137,15 @@ impl Default for ExportSettings { webp_lossless: true, webp_quality: None, tiff_layers: true, + tiff_compression: photocraft_codecs::TiffCompression::None, + tiff_transparency: None, + tiff_pyramid: false, xmp_all: true, tga_bits: None, dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, + pcraft_full_composite: false, embed_icc: true, embed_metadata: true, document_path: None, @@ -146,11 +171,15 @@ impl ExportSettings { opts.encode.embed_icc = self.embed_icc; opts.encode.embed_metadata = self.embed_metadata; opts.tiff_layers = self.tiff_layers; + opts.tiff_transparency = self.tiff_transparency; + opts.encode.tiff_compression = self.tiff_compression; + opts.encode.tiff_pyramid = self.tiff_pyramid; opts.xmp = if self.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = self.tga_bits; opts.encode.dds = self.dds; opts.image_previews = self.image_previews; opts.psd_maximize_compatibility = self.psd_maximize_compatibility; + opts.pcraft_full_composite = self.pcraft_full_composite; opts.document_path = self.document_path.clone(); opts.pcraft_cache = self.pcraft_cache.clone(); opts.read_linked_files = self.read_linked_files; @@ -247,9 +276,59 @@ pub type ClipboardSetFn = Box Result<(), String>>; /// `Err` means the contents could not be read and must not invalidate the internal copy. pub type ClipboardGetFn = Box Result)>, String>>; +/// Read original SVG markup for an explicit Paste As. A read error preserves the internal copy. +pub type ClipboardGetSvgFn = Box Result>, String>>; + /// Whether the last clipboard read found no way to reach the OS clipboard at all. pub type ClipboardUnreadableFn = Box bool>; +/// An editor selected by the user, never an executable supplied by document metadata. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExternalSvgEditor { + /// A known VectorCraft installation, found by the desktop shell. + VectorCraft, + /// A known Adobe Illustrator installation (offered where the shell can discover one). + Illustrator, + /// The absolute path the user chose with "Other application": an executable, or on macOS + /// an application bundle. The shell validates it before starting anything. + Program(String), +} + +/// An immutable saved source and optional display-only preview of those exact bytes. +#[derive(Debug)] +pub struct ExternalVectorSave { + /// The saved file in its original format (SVG, AI, PDF or EPS). + pub bytes: Vec, + /// For AI/PDF/EPS, an SVG rendering of `bytes` used only for display; `None` for SVG. + pub preview_svg: Option>, +} + +/// A native editing session supplies stable saved source snapshots without blocking the UI. +pub trait ExternalSvgSession { + /// Take the next settled save (its bytes) or error, if one is ready; never blocks. + fn poll(&mut self) -> Option, String>>; + /// SVG-only services remain compatible; native services return source and preview together. + fn poll_vector(&mut self) -> Option> { + self.poll().map(|result| result.map(|bytes| ExternalVectorSave { bytes, preview_svg: None })) + } + /// No more snapshots can arrive, for example after the desktop chooser was cancelled. + fn finished(&self) -> bool { + false + } +} + +/// An immutable embedded source. The shell keeps the original format in its owned working directory. +#[derive(Clone)] +pub struct ExternalVectorSource { + /// The embedded file, shared rather than copied. + pub bytes: std::sync::Arc>, + /// Its whitelisted format: `svg`, `ai`, `pdf` or `eps`. + pub extension: String, + /// The placed object's source-pixel frame, which a converted preview is fitted to. + pub size: Option<(f64, f64)>, +} + +pub type OpenExternalSvgFn = Box Result, String>>; /// Shared queue of (file name, bytes) delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; @@ -323,6 +402,8 @@ pub type RightButtonDownFn = Box bool + Send + Sync>; /// I/O dependencies. #[derive(Default)] pub struct Services { + /// Explicit interactive SVG smart-object editing; unavailable in browser and automation. + pub open_external_svg: Option, /// User-initiated desktop/browser pixel sampling. pub screen_pick: Option, /// Decode a file's bytes into a document (PSD, PNG, JPEG, …). @@ -368,6 +449,10 @@ pub struct Services { /// Preferences ▸ General ▸ Beep When Done: the platform's default beep, played when a /// background job finishes successfully. `None` in the web build (and in tests). pub beep: Option, + /// The system's alert sound, played when a key does nothing in a zoom field being typed in + /// (Tab, Alt; see `zoom_field`). No platform sets it yet, so the alert falls back to + /// [`Self::beep`]. + pub alert: Option, /// Starts a background check for published releases (desktop, outside Flatpak). pub check_updates: Option, /// Files delivered asynchronously (web drag-and-drop): drained every frame and opened as @@ -380,6 +465,9 @@ pub struct Services { /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, + /// SVG text copied by another app: imported only by an explicit pixels/path/shape paste, + /// and read after an ordinary paste too, only to remember which SVG came with that image. + pub clipboard_get_svg: Option, /// True when the last `clipboard_get_image` read had no clipboard to read, as in a Flatpak on /// Wayland (#5031), rather than a clipboard that holds no image. `None` where that can't happen. pub clipboard_unreadable: Option, diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs index 7457b0c780..722fc58f60 100644 --- a/crates/ui-egui/src/smart_filter_rows_tests.rs +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -9,6 +9,7 @@ use photocraft_doc::{LayerContent, LayerId}; use serde_json::json; use crate::PhotocraftApp; +use crate::canvas::ToolEvent; /// Smart objects "Smart A" (Gaussian Blur and Offset) and "Smart B" (no filters), and a pixel /// layer "Pixels" on top: (harness, A, B, pixels). @@ -296,6 +297,11 @@ fn a_hidden_filter_row_is_deselected() { h.state_mut().ui.layer_filter_name.clear(); h.run_steps(4); assert!(!selected(&h, "Gaussian Blur")); + // The Name filter hid Smart A too, so it deselected it (#10879): select it again. + assert!(h.state().session.active().unwrap().selected_layers().is_empty()); + h.state_mut().session.execute("layer.select", json!({"layer": a})).unwrap(); + h.run_steps(4); + assert!(!selected(&h, "Gaussian Blur"), "selecting the layer does not bring the filter row back"); h.key_press(egui::Key::Delete); h.run_steps(4); let st = h.state().session.active().unwrap(); @@ -339,3 +345,57 @@ fn a_click_selects_a_filter_of_the_bottom_layer() { assert_eq!(st.selected_layers(), [LayerId(id)], "the click did not deselect the layers"); assert!(selected(&h, "Gaussian Blur")); } + +/// A double-click whose two clicks land in one frame, as a real mouse delivers them when a frame +/// takes longer than the gap between the clicks (`Harness::event` would run a frame per event). +fn double_click_in_one_frame(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false, true, false] { + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(4); +} + +/// #7153: a double-click on a filter of a smart object that isn't the active layer also selects +/// it. That selection must not land after the editor captured its target, or OK refuses the edit +/// ("The document or Smart Filter changed"). +#[test] +fn double_clicking_a_filter_of_an_inactive_smart_object_edits_it() { + let (mut h, a, _, c) = panel(); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + let blur = at(&h, "Gaussian Blur"); + double_click_in_one_frame(&mut h, blur); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(a)), "the smart object becomes active"); + let d = h.state_mut().ui.dialogs.last_mut().expect("the filter's dialog opened"); + d.fields.insert("radius".into(), json!(3.0)); + let dialog = d.id; + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + let LayerContent::Smart(sm) = &h.state().session.active().unwrap().doc.layer(LayerId(a)).unwrap().content else { panic!("smart object") }; + assert_eq!(sm.smart_filters.len(), 2, "edited in place, not added"); + assert_eq!(sm.smart_filters[0].params["radius"], json!(3.0)); +} + +/// The same for Liquify, whose workspace takes the strokes with it: a refused OK lost them. +#[test] +fn double_clicking_the_liquify_filter_of_an_inactive_smart_object_edits_it() { + let (mut h, a, _, c) = panel(); + let stroke = json!({"tool": "forwardWarp", "size": 8, "points": [[4.0, 8.0, 1.0], [8.0, 8.0, 1.0]]}); + h.state_mut().run("layer.select", json!({"layer": a})).unwrap(); + h.state_mut().run("filter.liquify", json!({"strokes": [stroke], "meshSize": 3.0})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": c})).unwrap(); + h.run_steps(4); + let liquify = at(&h, "Liquify"); + double_click_in_one_frame(&mut h, liquify); + assert!(h.state().distort.liquify.is_some(), "the Liquify workspace opens"); + for ev in [ToolEvent::Down { x: 6.0, y: 4.0, pressure: 1.0 }, ToolEvent::Move { x: 10.0, y: 4.0, pressure: 1.0 }, ToolEvent::Up { x: 10.0, y: 4.0 }] { + crate::liquify_ui::pointer(h.state_mut(), ev, Modifiers::NONE); + } + h.run_steps(2); + crate::liquify_ui::control(h.state_mut(), &json!({"commit": true})).unwrap(); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + let LayerContent::Smart(sm) = &h.state().session.active().unwrap().doc.layer(LayerId(a)).unwrap().content else { panic!("smart object") }; + let liquify = sm.smart_filters.iter().find(|f| f.command == photocraft_engine::distort_cmds::LIQUIFY).unwrap(); + assert_eq!(liquify.params["strokes"].as_array().map(Vec::len), Some(2), "the new stroke was kept"); +} diff --git a/crates/ui-egui/src/smart_svg_ui.rs b/crates/ui-egui/src/smart_svg_ui.rs new file mode 100644 index 0000000000..fc61944906 --- /dev/null +++ b/crates/ui-egui/src/smart_svg_ui.rs @@ -0,0 +1,502 @@ +//! Explicit desktop SVG editor selection and bounded, undoable Smart Object save-back. + +use base64::Engine as _; +use std::sync::Arc; +use std::time::Duration; + +use photocraft_doc::{DocId, LayerContent, LayerId, SmartSource}; +use serde_json::{Value, json}; + +use crate::{ExternalSvgEditor, ExternalSvgSession, PhotocraftApp}; + +const MAX_SESSIONS: usize = 8; +const MAX_SVG_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Editor { + VectorCraft, + Illustrator, + Program, + Internal, +} + +struct Target { + doc: DocId, + layer: LayerId, + contents_id: Value, + fingerprint: Value, + svg: Arc>, + extension: String, + size: Option<(f64, f64)>, +} + +struct Pending { + target: Target, + editor: Editor, + program: Option, +} + +struct Editing { + target: Target, + session: Box, + saved: Option, +} + +#[derive(Default)] +pub(crate) struct State { + pending: Option, + editing: Vec, + internal_once: bool, +} + +impl State { + /// True while the editor-choice dialog awaits OK or Cancel. + pub(crate) fn modal_open(&self) -> bool { + self.pending.is_some() + } +} + +/// Translate dialog and status text in the `smartSvg` context. +fn tr(source: &str) -> &str { + crate::i18n::tr_ctx(crate::i18n::current(), "smartSvg", source) +} + +/// Surface a non-fatal editing or sync failure in the status bar; the document is unchanged. +fn report(app: &mut PhotocraftApp, error: String) { + app.ui.status = error; + app.ui.status_error = true; +} + +/// Automation retains the engine's internal Edit Contents path; it never launches a program. +pub(crate) fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option> { + if id != "layer.smartObjects.editContents" { + return None; + } + if std::mem::take(&mut app.smart_svg.internal_once) || app.automation_input || app.services.open_external_svg.is_none() { + return None; + } + if !(params.is_null() || params.as_object().is_some_and(|p| p.keys().all(|key| key == "layer"))) { + return None; + } + let st = app.session.active()?; + let layer = match params.get("layer") { + Some(v) => LayerId(v.as_u64()?), + None => st.active_layer?, + }; + let LayerContent::Smart(smart) = &st.doc.layer(layer)?.content else { return None }; + let bytes = match &smart.source { + SmartSource::Embedded { bytes, .. } => bytes.as_slice(), + SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&st.doc.metadata, path)?.1, + }; + if !(photocraft_io::svg::is_svg(bytes) || bytes.starts_with(b"%PDF-") || bytes.starts_with(b"%!PS-Adobe-") || bytes.starts_with(&[0xc5, 0xd0, 0xd3, 0xc6])) + { + return None; + } + let doc = st.doc.id; + // Contents already open in PhotoCraft's own editor: the engine switches to that document. + if app.session.smart_links.iter().any(|link| link.parent == doc && link.contents_id == smart.contents_id) { + return None; + } + let result: Result = (|| { + if app.smart_svg.pending.is_some() { + return Ok(json!({"deferred": true})); + } + if !safe_to_apply(app) { + return Err("Finish the current edit or dialog before editing this SVG Smart Object".into()); + } + let exported = app.run("layer.smartObjects.exportSvg", json!({"layer": layer.0}))?; + let extension = exported.get("extension").and_then(Value::as_str).unwrap_or("svg").to_string(); + let svg = if let Some(svg) = exported.get("svg").and_then(Value::as_str) { + svg.as_bytes().to_vec() + } else { + let encoded = exported.get("sourceBase64").and_then(Value::as_str).ok_or("The Smart Object did not export a vector")?; + if encoded.len() > MAX_SVG_BYTES.div_ceil(3) * 4 { + return Err("The vector exceeds the 16 MiB editor limit".into()); + } + base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| "Invalid vector source encoding")? + }; + let size: Option<(f64, f64)> = + serde_json::from_value(exported.get("sourceSize").cloned().unwrap_or(Value::Null)).map_err(|_| "Invalid vector source size")?; + if svg.len() > MAX_SVG_BYTES { + return Err("The SVG exceeds the 16 MiB editor limit".into()); + } + let fingerprint = exported.get("fingerprint").filter(|v| !v.is_null()).cloned().ok_or("The SVG fingerprint is missing")?; + let contents_id = exported.get("contentsId").filter(|v| !v.is_null()).cloned().ok_or("The Smart Object identity is missing")?; + app.smart_svg.pending = Some(Pending { + target: Target { doc, layer, contents_id, fingerprint, svg: Arc::new(svg), extension, size }, + editor: Editor::VectorCraft, + program: None, + }); + Ok(json!({"deferred": true})) + })(); + if let Err(error) = &result { + report(app, error.clone()); + } + Some(result) +} + +/// Saves apply only while the target's document is active, never by switching documents. +fn same_target(app: &PhotocraftApp, target: &Target) -> bool { + app.session.active().is_some_and(|st| st.doc.id == target.doc) +} + +/// The update command must be enabled and no layer sharing these contents (every placed +/// instance changes together) may be fully or pixel locked. +fn writable_target(app: &PhotocraftApp, target: &Target) -> bool { + let Some(st) = app.session.active().filter(|st| st.doc.id == target.doc) else { return false }; + if !app.session.is_enabled_with("layer.smartObjects.updateSvg", &json!({"document":target.doc.0,"layer":target.layer.0})) { + return false; + } + st.doc + .walk() + .into_iter() + .filter(|(_, _, layer)| matches!(&layer.content, LayerContent::Smart(smart) if json!(smart.contents_id.0) == target.contents_id)) + .all(|(_, _, layer)| { + let locks = st.doc.effective_locks(layer.id); + !locks.all && !locks.pixels + }) +} + +/// Act on the dialog's choice after re-exporting to confirm the fingerprint and contents id +/// still match: run the internal editor, or start (or replace) one external session per +/// shared contents, at most [`MAX_SESSIONS`]. Errors leave the document unchanged. +fn confirm(app: &mut PhotocraftApp) -> Result { + let Some(pending) = app.smart_svg.pending.take() else { return Ok(json!({"cancelled": true})) }; + if !safe_to_apply(app) { + return Err("Finish the current edit or dialog before editing this SVG Smart Object".into()); + } + if !same_target(app, &pending.target) { + return Err("SVG editing cancelled: the active document changed".into()); + } + let exported = app.run("layer.smartObjects.exportSvg", json!({"layer": pending.target.layer.0}))?; + if exported.get("fingerprint") != Some(&pending.target.fingerprint) || exported.get("contentsId") != Some(&pending.target.contents_id) { + return Err("SVG editing cancelled: the Smart Object contents changed".into()); + } + if pending.editor == Editor::Internal { + // Re-enter through the normal shell dispatch, retaining its authorization and bookkeeping. + app.smart_svg.internal_once = true; + let result = app.run("layer.smartObjects.editContents", json!({"layer": pending.target.layer.0})); + app.smart_svg.internal_once = false; + if result.is_ok() { + app.smart_svg.editing.retain(|edit| edit.target.doc != pending.target.doc || edit.target.contents_id != pending.target.contents_id); + } + return result; + } + let existing = app.smart_svg.editing.iter().position(|edit| edit.target.doc == pending.target.doc && edit.target.contents_id == pending.target.contents_id); + if existing.is_none() && app.smart_svg.editing.len() >= MAX_SESSIONS { + return Err("Close a document or stop an SVG editor session before starting another (limit: 8)".into()); + } + let editor = match pending.editor { + Editor::VectorCraft => ExternalSvgEditor::VectorCraft, + Editor::Illustrator => ExternalSvgEditor::Illustrator, + Editor::Program => ExternalSvgEditor::Program(pending.program.ok_or("Choose an editor executable first")?), + Editor::Internal => return Err("Invalid SVG editor choice".into()), + }; + let open = app.services.open_external_svg.as_mut().ok_or("External SVG editing is unavailable")?; + let session = open( + editor, + crate::ExternalVectorSource { bytes: pending.target.svg.clone(), extension: pending.target.extension.clone(), size: pending.target.size }, + )?; + if let Some(index) = existing { + app.smart_svg.editing.remove(index); + } + let mut target = pending.target; + target.svg = Arc::default(); + let target_is_native = target.extension != "svg"; + app.smart_svg.editing.push(Editing { target, session, saved: None }); + app.ui.status = tr(if target_is_native { + "Vector editor opened. Save the original-format file to update this Smart Object." + } else { + "SVG editor opened. Save the SVG to update this Smart Object." + }) + .into(); + app.ui.status_error = false; + Ok(json!({"externalEditor": true})) +} + +/// Ask the user for one local executable; the answer applies only if the same pending target +/// is still open. Executable validation happens when the session starts. +fn choose_program(app: &mut PhotocraftApp) -> Result { + let pending = app.smart_svg.pending.as_ref().ok_or("The SVG editor choice was cancelled")?; + let doc = pending.target.doc; + let layer = pending.target.layer; + let contents_id = pending.target.contents_id.clone(); + app.ask_file( + crate::file_dialog::FileDialogRequest::Open { + multiple: false, + initial_dir: None, + extensions: if cfg!(windows) { Some(vec!["exe".into()]) } else { None }, + }, + move |app, answer| { + let crate::file_dialog::FileDialogAnswer::Paths(paths) = answer else { return Err("Choose a local editor executable".into()) }; + if paths.len() != 1 { + return Err("Choose one editor executable".into()); + } + let path = paths.into_iter().next().ok_or("No editor executable selected")?; + if let Some(pending) = app + .smart_svg + .pending + .as_mut() + .filter(|pending| pending.target.doc == doc && pending.target.layer == layer && pending.target.contents_id == contents_id) + { + pending.program = Some(path); + pending.editor = Editor::Program; + } else { + return Err("The SVG editor choice was cancelled".into()); + } + Ok(json!({"editorSelected": true})) + }, + ) +} + +/// Draw the editor-choice dialog while a choice is pending. The internal editor is offered +/// only for SVG; "Stop syncing" ends a running session for the same contents. +pub(crate) fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let file_dialog_open = app.file_dialog_open(); + let can_pick = app.services.file_dialog.is_some() && !file_dialog_open; + let Some(pending) = app.smart_svg.pending.as_mut() else { return }; + let mut ok = false; + let mut cancel = false; + let mut choose = false; + let mut stop = false; + let running = app.smart_svg.editing.iter().any(|edit| edit.target.doc == pending.target.doc && edit.target.contents_id == pending.target.contents_id); + let modal = egui::Modal::new(egui::Id::unique("smart-svg-editor")).show(ctx, |ui| { + let tokens = crate::theme::Tokens::get(ctx); + ui.set_max_width(470.0); + ui.label( + egui::RichText::new(tr(if pending.target.extension == "svg" { "Edit SVG Smart Object" } else { "Edit Vector Smart Object" })) + .font(crate::theme::semibold(15.0)), + ); + ui.add_space(4.0); + crate::widgets::hairline(ui); + ui.add_space(8.0); + ui.label(tr(if pending.target.extension == "svg" { + "Choose the application used to edit this SVG:" + } else { + "Choose the application used to edit this vector:" + })); + if pending.target.extension != "svg" { + ui.label(tr( + "AI/PDF/EPS files keep their original format. Unsupported features may change when saved. Original contents remain available through Undo.", + )); + } + for (choice, label) in [ + (Editor::VectorCraft, "VectorCraft"), + (Editor::Illustrator, "Adobe Illustrator"), + (Editor::Program, "Other application"), + (Editor::Internal, "PhotoCraft (internal editor)"), + ] { + // The desktop shell discovers Illustrator installations only on Windows and macOS. + if choice == Editor::Illustrator && !cfg!(any(windows, target_os = "macos")) { + continue; + } + let available = pending.target.extension == "svg" || choice != Editor::Internal; + ui.add_enabled_ui(available, |ui| ui.radio_value(&mut pending.editor, choice, tr(label))); + } + if pending.editor == Editor::Program { + if let Some(program) = &pending.program { + let name = std::path::Path::new(program).file_name().and_then(|s| s.to_str()).unwrap_or(program); + ui.label(name); + } + choose = ui.add_enabled(can_pick, egui::Button::new(tr("Choose executable…"))).clicked(); + } + ui.add_space(8.0); + ui.label( + egui::RichText::new(tr(if pending.target.extension == "svg" { + "Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft." + } else { + "Save to the opened original-format file. Each saved change becomes one undo step in PhotoCraft." + })) + .small() + .color(tokens.text_dim), + ); + ui.label(egui::RichText::new(tr("Updates wait while another document or an editing dialog is active.")).small().color(tokens.text_dim)); + if running { + stop = ui.button(tr("Stop syncing this Smart Object")).clicked(); + } + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let enabled = !file_dialog_open && (pending.editor != Editor::Program || pending.program.is_some()); + ok = ui.add_enabled_ui(enabled, |ui| crate::widgets::primary_button(ui, "OK", 84.0)).inner.clicked(); + cancel = crate::widgets::secondary_button(ui, "Cancel", 84.0).clicked(); + }); + }); + let owns_keys = modal.is_top_modal || ctx.memory(|memory| memory.top_modal_layer().is_none()); + cancel |= + modal.should_close() || (owns_keys && !modal.any_popup_open && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape))); + if stop || cancel { + if stop && let Some(pending) = app.smart_svg.pending.as_ref() { + app.smart_svg.editing.retain(|edit| edit.target.doc != pending.target.doc || edit.target.contents_id != pending.target.contents_id); + } + app.smart_svg.pending = None; + return; + } + if choose { + if let Err(error) = choose_program(app) { + report(app, error); + } + return; + } + let enter = owns_keys && !modal.any_popup_open && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let ready = !app.file_dialog_open() && app.smart_svg.pending.as_ref().is_some_and(|pending| pending.editor != Editor::Program || pending.program.is_some()); + if ready + && (ok || enter) + && let Err(error) = confirm(app) + { + report(app, error); + } +} + +/// True only when no save, dialog, mask/channel target, transform, preview or drag is in +/// progress, so an external save cannot land in the middle of another edit. +fn safe_to_apply(app: &PhotocraftApp) -> bool { + !app.automation_input + && !app.saving() + && !app.save_options_open() + && !app.file_dialog_open() + && !app.ui.mask_target + && !app.layer_mask_targeted() + && !app.ui.vector_mask_target + && !app.session.tools.mask_targeted + && app.session.active().is_some_and(|st| { + st.doc.quick_mask.is_none() + && st.channel_view.layer_mask.is_none() + && st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite + && app.session.job_on(st.doc.id).is_none() + }) + && app.ui.dialogs.is_empty() + && app.ui.layers_panel_dialog.is_none() + && app.discard.is_none() + && app.camera_raw.is_none() + && !app.smart_svg.modal_open() + && app.ui.transform.is_none() + && app.ui.cam_transform.is_none() + && app.ui.text_edit.is_none() + && app.ui.type_transform.is_none() + && app.ui.symmetry_transform.is_none() + && app.ui.stroke_editor.is_none() + && app.ui.direct_selection.drag.is_none() + && app.ui.corner_radius.is_none() + && !app.distort.active() + && crate::distort_ui::modal_workspace(app).is_none() + && app.wide_angle.is_none() + && app.color_range.is_none() + && app.text_style_preview.is_none() + && !crate::crop_ui::pending(app) + && !crate::gradient_ui::editing(app) + && app.move_preview.is_none() + && app.patch_preview.is_none() + && app.filter_preview.is_none() + && app.adjust_preview.is_none() + && app.live_adjust.is_none() + && app.transform_preview.is_none() + && app.shape_fill_preview.is_none() + && app.solid_fill_preview.is_none() + && app.live_stroke.is_none() + && app.drag.is_none() + && !app.press_stroke +} + +/// Poll platform snapshots without changing the active document or selection. +pub(crate) fn poll(app: &mut PhotocraftApp, ctx: &egui::Context) { + if app + .smart_svg + .pending + .as_ref() + .is_some_and(|pending| !app.session.documents().iter().any(|st| st.doc.id == pending.target.doc && st.doc.layer(pending.target.layer).is_some())) + { + app.smart_svg.pending = None; + } + if app.smart_svg.editing.is_empty() { + return; + } + let safe = safe_to_apply(app) && ctx.memory(|memory| memory.top_modal_layer().is_none()) && !ctx.input(|input| input.pointer.any_down()); + let mut editing = std::mem::take(&mut app.smart_svg.editing); + for mut edit in editing.drain(..) { + let exists = app + .session + .documents() + .iter() + .find(|st| st.doc.id == edit.target.doc) + .and_then(|st| st.doc.layer(edit.target.layer)) + .is_some_and(|layer| matches!(&layer.content, LayerContent::Smart(smart) if json!(smart.contents_id.0) == edit.target.contents_id)); + if !exists { + continue; + } + if let Some(result) = edit.session.poll_vector() { + match result { + Ok(saved) if saved.bytes.len() <= MAX_SVG_BYTES && saved.preview_svg.as_ref().is_none_or(|svg| svg.len() <= MAX_SVG_BYTES) => { + edit.saved = Some(saved) + } + Ok(_) => report(app, "The SVG exceeds the 16 MiB editor limit; save a smaller SVG to retry".into()), + Err(error) => report(app, error), + } + } + // Release admission capacity after cancellation/startup failure. Retain a final + // pending save until its target is safe to update. + if edit.saved.is_none() && edit.session.finished() { + continue; + } + let internal_edit = app.session.smart_links.iter().any(|link| link.parent == edit.target.doc && json!(link.contents_id.0) == edit.target.contents_id); + if safe + && !internal_edit + && same_target(app, &edit.target) + && edit.saved.is_some() + && writable_target(app, &edit.target) + && let Some(bytes) = edit.saved.take() + { + let native = edit.target.extension != "svg"; + let preview = if native { bytes.preview_svg } else { Some(bytes.bytes.clone()) }; + let svg = match preview.and_then(|svg| String::from_utf8(svg).ok()) { + Some(svg) => svg, + None => { + report(app, "The saved vector has no valid display preview; save again to retry".into()); + app.smart_svg.editing.push(edit); + continue; + } + }; + let mut params = json!({"document": edit.target.doc.0, "layer": edit.target.layer.0, "fingerprint": edit.target.fingerprint, "contentsId": edit.target.contents_id}); + let command = if native { + params["previewSvg"] = json!(svg); + params["sourceBase64"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes.bytes)); + params["extension"] = json!(edit.target.extension); + "layer.smartObjects.updateVectorSource" + } else { + params["svg"] = json!(svg); + "layer.smartObjects.updateSvg" + }; + match app.run(command, params) { + Ok(result) => { + if let Some(fingerprint) = result.get("fingerprint").filter(|v| !v.is_null()) { + edit.target.fingerprint = fingerprint.clone(); + } else { + report(app, "The SVG update did not return its fingerprint; syncing stopped".into()); + continue; + } + } + Err(error) => { + let stale = error.contains("contents changed") || error.contains("conflicting contents"); + let kind = if native { "Vector" } else { "SVG" }; + report( + app, + if stale { + format!("{kind} syncing stopped: {error}") + } else { + format!("{kind} update failed; change the working file and save to retry: {error}") + }, + ); + if stale { + continue; + } + } + } + } + app.smart_svg.editing.push(edit); + } + if !app.smart_svg.editing.is_empty() { + ctx.request_repaint_after(Duration::from_millis(500)); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/smart_svg_ui/browser.rs b/crates/ui-egui/src/smart_svg_ui/browser.rs new file mode 100644 index 0000000000..5cffdf1b00 --- /dev/null +++ b/crates/ui-egui/src/smart_svg_ui/browser.rs @@ -0,0 +1,27 @@ +//! Browser builds retain internal Edit Contents without desktop editor sessions. + +use serde_json::Value; + +use crate::PhotocraftApp; + +/// No external editor sessions exist in the browser. +#[derive(Default)] +pub(crate) struct State {} + +impl State { + /// The editor-choice dialog is never shown in the browser. + pub(crate) fn modal_open(&self) -> bool { + false + } +} + +/// Leave every command to normal dispatch, so Edit Contents uses the internal editor. +pub(crate) fn intercept(_app: &mut PhotocraftApp, _id: &str, _params: &Value) -> Option> { + None +} + +/// No sessions to poll. +pub(crate) fn poll(_app: &mut PhotocraftApp, _ctx: &egui::Context) {} + +/// No dialog to draw. +pub(crate) fn show(_app: &mut PhotocraftApp, _ctx: &egui::Context) {} diff --git a/crates/ui-egui/src/smart_svg_ui/tests.rs b/crates/ui-egui/src/smart_svg_ui/tests.rs new file mode 100644 index 0000000000..bce275addb --- /dev/null +++ b/crates/ui-egui/src/smart_svg_ui/tests.rs @@ -0,0 +1,769 @@ +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; + +use photocraft_engine::Session; + +use super::*; +use crate::Services; + +const SVG: &str = r#""#; + +type Saves = Rc, String>>>>; +type Calls = Rc>>; + +struct TestSession(Saves); + +impl ExternalSvgSession for TestSession { + fn poll(&mut self) -> Option, String>> { + self.0.borrow_mut().pop_front() + } +} + +struct NativeTestSession(Saves); +impl ExternalSvgSession for NativeTestSession { + fn poll(&mut self) -> Option, String>> { + self.0.borrow_mut().pop_front() + } + fn poll_vector(&mut self) -> Option> { + self.poll().map(|r| r.map(|bytes| crate::ExternalVectorSave { bytes, preview_svg: Some(SVG.as_bytes().to_vec()) })) + } +} + +fn fixture() -> (PhotocraftApp, Saves, Calls) { + let saves = Saves::default(); + let calls = Calls::default(); + let (queue, launched) = (saves.clone(), calls.clone()); + let services = Services { + open_external_svg: Some(Box::new(move |editor, bytes| { + assert_eq!(&*bytes.bytes, SVG.as_bytes()); + launched.borrow_mut().push(editor); + Ok(Box::new(TestSession(queue.clone()))) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.session.execute("file.new", json!({"width":64,"height":64})).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut app.session, "Artwork.svg", SVG.as_bytes().to_vec(), None, &json!({"smartObject":true})).unwrap(); + app.sync_views(); + (app, saves, calls) +} + +fn open(app: &mut PhotocraftApp) { + assert!(intercept(app, "layer.smartObjects.editContents", &json!({})).unwrap().unwrap()["deferred"].as_bool().unwrap()); +} + +#[test] +fn control_menu_invoke_preserves_svg_editor_choice_and_rejects_other_dialogs() { + let (mut app, saves, calls) = fixture(); + open(&mut app); + let pending = app.smart_svg.pending.as_mut().unwrap(); + pending.editor = Editor::Program; + pending.program = Some("synthetic-editor".into()); + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + let history = app.session.active().unwrap().history.past_len(); + let journal = app.session.journal.clone(); + let view = (app.ui.views[0].center, app.ui.views[0].zoom); + let pending = app.smart_svg.pending.as_ref().unwrap(); + let target = (pending.target.doc, pending.target.layer, pending.target.contents_id.clone(), pending.target.fingerprint.clone()); + let svg = pending.target.svg.clone(); + let program = pending.program.clone(); + let internal_once = app.smart_svg.internal_once; + let authorizations = Rc::new(std::cell::Cell::new(0)); + let authorized = authorizations.clone(); + app.services.automation_command = Some(Box::new(move |_, _| { + authorized.set(authorized.get() + 1); + Ok(()) + })); + let clipboard_reads = Rc::new(std::cell::Cell::new(0)); + let reads = clipboard_reads.clone(); + app.services.clipboard_get_image = Some(Box::new(move || { + reads.set(reads.get() + 1); + Ok(None) + })); + let ctx = egui::Context::default(); + for id in ["layer.duplicate", "image.imageSize", "edit.paste", "view.zoomIn"] { + let (request, _) = crate::control::ControlRequest::new("ui.menu.invoke", json!({"id": id})); + let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &request) else { + panic!("blocked menu call must return immediately") + }; + assert_eq!(result["ok"], false, "{id}: {result}"); + assert!(result.to_string().contains("modal dialog")); + assert_eq!(authorizations.get(), 0); + assert_eq!(clipboard_reads.get(), 0); + assert!(calls.borrow().is_empty()); + assert!(saves.borrow().is_empty()); + assert!(app.smart_svg.editing.is_empty()); + assert_eq!(app.smart_svg.internal_once, internal_once); + assert!(app.ui.dialogs.is_empty()); + let state = app.session.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.history.past_len(), history); + assert_eq!(app.session.journal, journal); + assert_eq!((app.ui.views[0].center, app.ui.views[0].zoom), view); + let pending = app.smart_svg.pending.as_ref().unwrap(); + assert_eq!((pending.target.doc, pending.target.layer, pending.target.contents_id.clone(), pending.target.fingerprint.clone()), target); + assert!(Arc::ptr_eq(&pending.target.svg, &svg)); + assert_eq!(&**pending.target.svg, SVG.as_bytes()); + assert!(pending.editor == Editor::Program); + assert_eq!(pending.program, program); + } +} + +#[test] +fn routing_keeps_automation_non_svg_and_unrelated_commands_internal() { + let (mut app, _, calls) = fixture(); + assert!(intercept(&mut app, "edit.copy", &json!({})).is_none()); + assert!(intercept(&mut app, "layer.smartObjects.editContents", &json!({"other":true})).is_none()); + app.automation_input = true; + assert!(intercept(&mut app, "layer.smartObjects.editContents", &json!({})).is_none()); + app.automation_input = false; + let st = app.session.active_mut().unwrap(); + let layer = st.active_layer.unwrap(); + let LayerContent::Smart(smart) = &mut Arc::make_mut(&mut st.doc).layer_mut(layer).unwrap().content else { panic!("smart") }; + smart.source = SmartSource::Embedded { file_name: "Embedded.pcraft".into(), bytes: Arc::new(vec![]) }; + assert!(intercept(&mut app, "layer.smartObjects.editContents", &json!({})).is_none()); + assert!(calls.borrow().is_empty()); +} + +#[test] +fn svg_source_without_svg_filename_still_opens_editor_choice() { + let (mut app, _, calls) = fixture(); + let st = app.session.active_mut().unwrap(); + let layer = st.active_layer.unwrap(); + let LayerContent::Smart(smart) = &mut Arc::make_mut(&mut st.doc).layer_mut(layer).unwrap().content else { panic!("smart") }; + let SmartSource::Embedded { file_name, .. } = &mut smart.source else { panic!("embedded") }; + *file_name = "Unknown clipboard object".into(); + open(&mut app); + assert!(calls.borrow().is_empty()); +} + +#[test] +fn prompt_and_escape_never_launch_or_change_document() { + let (mut app, _, calls) = fixture(); + let before = app.session.active().unwrap().doc.clone(); + open(&mut app); + open(&mut app); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let mut input = egui::RawInput::default(); + input.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut output = ctx.run_ui(input, |ui| show(&mut app, ui.ctx())); + output.textures_delta.clear(); + assert!(!app.smart_svg.modal_open()); + assert!(calls.borrow().is_empty()); + assert_eq!(app.session.active().unwrap().doc, before); +} + +#[test] +fn manual_internal_choice_uses_existing_child_document_flow() { + let (mut app, _, calls) = fixture(); + let parent = app.session.active().unwrap().doc.id; + open(&mut app); + app.smart_svg.pending.as_mut().unwrap().editor = Editor::Internal; + confirm(&mut app).unwrap(); + assert_ne!(app.session.active().unwrap().doc.id, parent); + assert_eq!(app.session.smart_links.len(), 1); + assert!(calls.borrow().is_empty()); + assert!(!app.smart_svg.internal_once); +} + +/// Edit Contents again on contents already open in the internal editor switches to that +/// document, as it does for any smart object, instead of an error. +#[test] +fn edit_contents_again_switches_to_the_open_internal_copy() { + let (mut app, _, calls) = fixture(); + let parent = app.session.active_index().unwrap(); + open(&mut app); + app.smart_svg.pending.as_mut().unwrap().editor = Editor::Internal; + confirm(&mut app).unwrap(); + let child = app.session.active().unwrap().doc.id; + app.session.set_active(parent); + app.run("layer.smartObjects.editContents", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.id, child); + assert_eq!(app.session.smart_links.len(), 1); + assert!(app.smart_svg.pending.is_none()); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(calls.borrow().is_empty()); +} + +#[test] +fn missing_external_service_keeps_internal_edit_and_save_back() { + let (mut app, saves, calls) = fixture(); + app.services.open_external_svg = None; + let parent_index = app.session.active_index().unwrap(); + let st = app.session.active().unwrap(); + let (parent, layer, before, history) = (st.doc.id, st.active_layer.unwrap(), st.doc.clone(), st.history.past_len()); + let opened = app.run("layer.smartObjects.editContents", json!({})).unwrap(); + assert_eq!(opened["document"].as_u64(), app.session.active_index().map(|index| index as u64)); + assert_ne!(app.session.active().unwrap().doc.id, parent); + assert_eq!(app.session.smart_links.len(), 1); + let link = &app.session.smart_links[0]; + assert_eq!((link.parent, link.layer, link.child), (parent, layer, app.session.active().unwrap().doc.id)); + assert!(!app.smart_svg.modal_open()); + assert!(app.smart_svg.editing.is_empty()); + assert!(calls.borrow().is_empty()); + assert!(saves.borrow().is_empty()); + + app.run("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint child", |doc, active| { + let bounds = doc.bounds(); + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.run("layer.smartObjects.saveContents", json!({})).unwrap(); + assert!(!app.session.active().unwrap().is_dirty()); + app.session.set_active(parent_index); + let st = app.session.active().unwrap(); + let LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("smart") }; + let SmartSource::Embedded { file_name, bytes } = &smart.source else { panic!("embedded") }; + assert!(file_name.ends_with(".pcraft")); + assert_ne!(&**bytes, SVG.as_bytes()); + assert_eq!(smart.cache.as_ref().unwrap().rgba(32, 32), [0.0, 1.0, 0.0, 1.0]); + assert_eq!(st.history.past_len(), history + 1); + assert!(!app.smart_svg.modal_open()); + assert!(calls.borrow().is_empty()); + assert!(saves.borrow().is_empty()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); +} + +#[test] +fn choosing_internal_editor_ends_existing_external_session() { + let (mut app, _, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(app.smart_svg.editing.len(), 1); + open(&mut app); + app.smart_svg.pending.as_mut().unwrap().editor = Editor::Internal; + confirm(&mut app).unwrap(); + assert!(app.smart_svg.editing.is_empty()); + assert_eq!(app.session.smart_links.len(), 1); +} + +#[test] +fn failed_launch_is_visible_and_keeps_source_unchanged() { + let (mut app, _, _) = fixture(); + app.services.open_external_svg = Some(Box::new(|_, _| Err("Editor not installed".into()))); + let before = app.session.active().unwrap().doc.clone(); + open(&mut app); + assert_eq!(confirm(&mut app).unwrap_err(), "Editor not installed"); + assert!(app.smart_svg.editing.is_empty()); + assert_eq!(app.session.active().unwrap().doc, before); +} + +#[test] +fn selected_editor_and_repeated_saves_update_with_undo_and_preserve_selection() { + let (mut app, saves, calls) = fixture(); + let before = app.session.active().unwrap().doc.clone(); + let target = app.session.active().unwrap().active_layer; + open(&mut app); + app.smart_svg.pending.as_mut().unwrap().editor = Editor::Illustrator; + confirm(&mut app).unwrap(); + assert!(matches!(calls.borrow().first(), Some(ExternalSvgEditor::Illustrator))); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let selection = app.session.active().unwrap().active_layer; + let ctx = egui::Context::default(); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + poll(&mut app, &ctx); + let blue = app.session.active().unwrap().doc.clone(); + assert_ne!(blue, before); + assert_ne!(selection, target); + assert_eq!(app.session.active().unwrap().active_layer, selection); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "green").into_bytes())); + poll(&mut app, &ctx); + assert_ne!(app.session.active().unwrap().doc, blue); + assert_eq!(app.smart_svg.editing.len(), 1); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, blue); + app.session.execute("edit.undo", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); +} + +#[test] +fn saved_changes_wait_on_document_switch_and_active_preview() { + let (mut app, saves, _) = fixture(); + let parent = app.session.active().unwrap().doc.id; + open(&mut app); + confirm(&mut app).unwrap(); + app.session.execute("file.new", json!({"width":32,"height":32})).unwrap(); + let other = app.session.active().unwrap().doc.clone(); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + let ctx = egui::Context::default(); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, other); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + let index = app.session.documents().iter().position(|st| st.doc.id == parent).unwrap(); + app.session.set_active(index); + app.ui.open_dialog(crate::state::DialogKind::Command, serde_json::Map::new()); + poll(&mut app, &ctx); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + app.ui.dialogs.clear(); + poll(&mut app, &ctx); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); +} + +#[test] +fn changed_source_stops_syncing_instead_of_overwriting_user_edit() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + let target = &app.smart_svg.editing.first().unwrap().target; + app.session.execute("layer.smartObjects.updateSvg", json!({"document": target.doc.0,"layer": target.layer.0,"contentsId":target.contents_id,"fingerprint":target.fingerprint,"svg":SVG.replace("red","yellow")})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + poll(&mut app, &egui::Context::default()); + assert!(app.smart_svg.editing.is_empty()); + assert!(app.ui.status_error); + assert_eq!(app.session.active().unwrap().doc, before); +} + +#[test] +fn stale_prompt_never_launches_into_another_document_or_changed_source() { + for switched in [false, true] { + let (mut app, _, calls) = fixture(); + open(&mut app); + if switched { + app.session.execute("file.new", json!({"width":32,"height":32})).unwrap(); + } else { + let target = &app.smart_svg.pending.as_ref().unwrap().target; + app.session.execute("layer.smartObjects.updateSvg", json!({"document":target.doc.0,"layer":target.layer.0,"contentsId":target.contents_id,"fingerprint":target.fingerprint,"svg":SVG.replace("red","blue")})).unwrap(); + } + assert!(confirm(&mut app).is_err()); + assert!(calls.borrow().is_empty()); + } +} + +#[test] +fn malformed_and_transient_saves_can_recover_and_deleted_layer_stops_session() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + saves.borrow_mut().push_back(Ok(vec![255])); + poll(&mut app, &egui::Context::default()); + assert_eq!(app.smart_svg.editing.len(), 1); + assert_eq!(app.session.active().unwrap().doc, before); + saves.borrow_mut().push_back(Err("Editor is replacing its SVG file".into())); + poll(&mut app, &egui::Context::default()); + assert_eq!(app.smart_svg.editing.len(), 1); + saves.borrow_mut().push_back(Ok(b"<".to_vec())); + poll(&mut app, &egui::Context::default()); + assert_eq!(app.smart_svg.editing.len(), 1); + assert_eq!(app.session.active().unwrap().doc, before); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + poll(&mut app, &egui::Context::default()); + assert_ne!(app.session.active().unwrap().doc, before); + let st = app.session.active_mut().unwrap(); + Arc::make_mut(&mut st.doc).layers.clear(); + poll(&mut app, &egui::Context::default()); + assert!(app.smart_svg.editing.is_empty()); +} + +#[test] +fn custom_executable_requires_explicit_selection() { + let (mut app, _, calls) = fixture(); + open(&mut app); + app.smart_svg.pending.as_mut().unwrap().editor = Editor::Program; + assert!(confirm(&mut app).is_err()); + assert!(calls.borrow().is_empty()); + open(&mut app); + let pending = app.smart_svg.pending.as_mut().unwrap(); + pending.editor = Editor::Program; + pending.program = Some("C:/synthetic/editor.exe".into()); + confirm(&mut app).unwrap(); + assert!(matches!(calls.borrow().first(), Some(ExternalSvgEditor::Program(path)) if path == "C:/synthetic/editor.exe")); +} + +#[test] +fn idle_gradient_tool_does_not_block_svg_editing() { + let (mut app, _, calls) = fixture(); + app.ui.tool = crate::state::Tool::Gradient; + open(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(calls.borrow().len(), 1); +} + +#[test] +fn saved_changes_wait_on_idle_cam_box_then_resume_after_cancel() { + let (mut app, saves, calls) = fixture(); + let smart_layer = app.session.active().unwrap().active_layer.unwrap(); + open(&mut app); + confirm(&mut app).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x":8,"y":8,"width":12,"height":8})).unwrap(); + app.ui.tool = crate::state::Tool::ContentAwareMove; + assert!(crate::cam_transform::begin(&mut app, [8, 4])); + assert!(app.drag.is_none() && app.patch_preview.is_none() && app.ui.transform.is_none()); + let before = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + let history = app.session.active().unwrap().history.past_len(); + let raster_layer = app.session.active().unwrap().active_layer.unwrap(); + let cam = app.ui.cam_transform.clone(); + let blue = SVG.replace("red", "blue"); + saves.borrow_mut().push_back(Ok(blue.as_bytes().to_vec())); + let ctx = egui::Context::default(); + + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(app.ui.cam_transform, cam); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + assert!(saves.borrow().is_empty()); + + crate::cam_transform::cancel(&mut app); + poll(&mut app, &ctx); + let st = app.session.active().unwrap(); + let LayerContent::Smart(smart) = &st.doc.layer(smart_layer).unwrap().content else { panic!("smart") }; + let SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") }; + assert_eq!(&**bytes, blue.as_bytes()); + assert_eq!(st.doc.layer(raster_layer), before.layer(raster_layer)); + assert_eq!(st.doc.selection, before.selection); + assert_eq!(st.history.past_len(), history + 1); + assert!(app.ui.cam_transform.is_none()); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); + assert_eq!(app.smart_svg.editing.len(), 1); + assert_eq!(calls.borrow().len(), 1); +} + +#[test] +fn saved_changes_wait_on_mask_target_then_apply_without_another_save() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + app.ui.mask_target = true; + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + let ctx = egui::Context::default(); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + app.ui.mask_target = false; + poll(&mut app, &ctx); + assert_ne!(app.session.active().unwrap().doc, before); +} + +#[test] +fn saved_changes_wait_on_implicit_mask_then_resume_without_another_save() { + let (mut app, saves, calls) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + let st = app.session.active_mut().unwrap(); + let layer = st.active_layer.unwrap(); + Arc::make_mut(&mut st.doc).layer_mut(layer).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all()); + st.channel_view.color_hidden = vec![true; photocraft_engine::channel_cmds::color_count(&st.doc)]; + st.channel_view.alpha_visible.clear(); + assert_eq!(st.channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Composite); + assert!(st.channel_view.layer_mask.is_none()); + let before = st.doc.clone(); + let revision = st.revision; + assert!(!app.ui.mask_target); + assert!(!app.session.tools.mask_targeted); + assert!(app.layer_mask_targeted()); + let blue = SVG.replace("red", "blue"); + saves.borrow_mut().push_back(Ok(blue.as_bytes().to_vec())); + let ctx = egui::Context::default(); + + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + assert!(saves.borrow().is_empty()); + + app.session.active_mut().unwrap().channel_view.color_hidden.clear(); + assert!(!app.layer_mask_targeted()); + poll(&mut app, &ctx); + let st = app.session.active().unwrap(); + assert_ne!(st.doc, before); + assert_eq!(st.doc.layer(layer).unwrap().mask, before.layer(layer).unwrap().mask); + let LayerContent::Smart(smart) = &st.doc.layer(layer).unwrap().content else { panic!("smart") }; + let SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") }; + assert_eq!(&**bytes, blue.as_bytes()); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); + assert_eq!(calls.borrow().len(), 1); +} + +#[test] +fn explicit_mask_channel_roundtrip_resumes_queued_svg_save_with_masks_preserved() { + for channel in ["mask", "vectorMask"] { + let (mut app, saves, calls) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + app.session.execute("layer.layerMask.revealAll", json!({})).unwrap(); + app.session.execute("layer.vectorMask.revealAll", json!({})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + + app.run("channel.target", json!({"channel":channel})).unwrap(); + assert_eq!(app.ui.mask_target, channel == "mask"); + assert_eq!(app.ui.vector_mask_target, channel == "vectorMask"); + let blue = SVG.replace("red", "blue"); + saves.borrow_mut().push_back(Ok(blue.as_bytes().to_vec())); + let ctx = egui::Context::default(); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + assert!(saves.borrow().is_empty()); + + app.run("channel.target", json!({"channel":"composite"})).unwrap(); + assert!(!app.ui.mask_target); + assert!(!app.ui.vector_mask_target); + assert!(!app.session.tools.mask_targeted); + poll(&mut app, &ctx); + let st = app.session.active().unwrap(); + let updated = st.doc.layer(layer).unwrap(); + assert_eq!(updated.mask, before.layer(layer).unwrap().mask); + assert_eq!(updated.vector_mask, before.layer(layer).unwrap().vector_mask); + let LayerContent::Smart(smart) = &updated.content else { panic!("smart") }; + let SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") }; + assert_eq!(&**bytes, blue.as_bytes()); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); + assert_eq!(app.smart_svg.editing.len(), 1); + assert_eq!(calls.borrow().len(), 1); + } +} + +#[test] +fn active_gradient_preview_defers_save_until_gesture_ends() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + app.ui.tool = crate::state::Tool::Gradient; + app.ui.tool_options.gradient_classic = false; + assert!(crate::gradient_ui::pointer(&mut app, crate::canvas::ToolEvent::Down { x: 0.0, y: 0.0, pressure: 1.0 }, egui::Modifiers::NONE)); + assert!(crate::gradient_ui::editing(&app)); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + let ctx = egui::Context::default(); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + app.gradient = Default::default(); + poll(&mut app, &ctx); + assert_ne!(app.session.active().unwrap().doc, before); +} + +#[test] +fn bounded_sessions_require_reuse_or_closing_a_document() { + let (mut app, _, calls) = fixture(); + for _ in 0..MAX_SESSIONS { + open(&mut app); + confirm(&mut app).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut app.session, "Artwork.svg", SVG.as_bytes().to_vec(), None, &json!({"smartObject":true})).unwrap(); + } + open(&mut app); + assert!(confirm(&mut app).unwrap_err().contains("limit: 8")); + assert_eq!(calls.borrow().len(), MAX_SESSIONS); + assert_eq!(app.smart_svg.editing.len(), MAX_SESSIONS); +} + +#[test] +fn capture_smart_svg_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_SMART_SVG_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::StudioLight] { + let (mut app, _, _) = fixture(); + open(&mut app); + let mut ready = false; + let lang = crate::i18n::lang_from_tag("en").unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 620.0)).wgpu().build_ui_state( + move |ui, app: &mut PhotocraftApp| { + let _language = crate::i18n::language_scope(lang); + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme); + ready = true; + return; + } + show(app, ui.ctx()); + }, + app, + ); + harness.ctx.set_os(egui::os::OperatingSystem::Windows); + harness.run_steps(5); + harness.render().unwrap().save(directory.join(format!("photocraft-smart-svg-editor-{}-en.png", theme.id()))).unwrap(); + } +} + +/// An AI payload in the PSD link table must reach explicit editor selection without becoming +/// a filesystem path. A saved SVG replaces it through the normal optimistic transaction. +#[test] +fn embedded_ai_link_table_handoff_preserves_source_until_saved_and_undo_restores_it() { + let (mut app, saves, calls) = fixture(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let original = b"%PDF-1.6\nsynthetic vector".to_vec(); + { + let doc = Arc::make_mut(&mut app.session.active_mut().unwrap().doc); + let payload = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { + uuid: "embedded-ai".into(), + file_name: "Logo.ai".into(), + bytes: original.clone(), + }); + doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(payload))); + let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart") }; + sm.source = SmartSource::Linked { path: "embedded-ai".into() }; + } + let before = app.session.active().unwrap().doc.clone(); + let queue = saves.clone(); + app.services.open_external_svg = Some(Box::new(move |editor, source| { + assert_eq!(editor, ExternalSvgEditor::VectorCraft); + assert_eq!(*source.bytes, original); + assert_eq!(source.extension, "ai"); + Ok(Box::new(NativeTestSession(queue.clone()))) + })); + open(&mut app); + assert!(calls.borrow().is_empty()); + confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc, before); + saves.borrow_mut().push_back(Ok(b"%PDF-1.6\nedited native AI".to_vec())); + poll(&mut app, &egui::Context::default()); + let layer = app.session.active().unwrap().doc.layer(id).unwrap(); + assert!( + matches!(&layer.content,LayerContent::Smart(sm) if matches!(&sm.source,SmartSource::Embedded {file_name,bytes} if file_name=="Logo.ai" && bytes.as_slice()==b"%PDF-1.6\nedited native AI")) + ); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc, before); +} + +/// Optional screenshot uses authored bytes and mock services, never private artwork or an editor. +#[test] +fn capture_embedded_vector_conversion_prompt() { + let Some(directory) = std::env::var_os("CRAFT_SMART_SVG_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + let (mut app, _, _) = fixture(); + let state = app.session.active_mut().unwrap(); + let id = state.active_layer.unwrap(); + let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") }; + sm.source = SmartSource::Embedded { file_name: "Authored.ai".into(), bytes: Arc::new(b"%PDF-1.6\nsynthetic vector prompt".to_vec()) }; + open(&mut app); + let mut ready = false; + let lang = crate::i18n::lang_from_tag("en").unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 620.0)).wgpu().build_ui_state( + move |ui, app: &mut PhotocraftApp| { + let _language = crate::i18n::language_scope(lang); + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::Pro); + ready = true; + return; + } + show(app, ui.ctx()); + }, + app, + ); + harness.run_steps(5); + harness.render().unwrap().save(directory.join("photocraft-embedded-vector-editor-en.png")).unwrap(); +} + +/// Filter Gallery owns a document preview even though `distort.active()` excludes it. +#[test] +fn saved_changes_wait_for_filter_gallery_and_resume_after_cancel() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + crate::gallery_ui::open(&mut app).unwrap(); + assert!(app.distort.gallery.is_some()); + assert!(!app.distort.active()); + let before = app.session.active().unwrap().doc.clone(); + let changed = SVG.replace("red", "blue"); + saves.borrow_mut().push_back(Ok(changed.as_bytes().to_vec())); + let ctx = egui::Context::default(); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + crate::gallery_ui::control(&mut app, &json!({"cancel":true})).unwrap(); + poll(&mut app, &ctx); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); + let state = app.session.active().unwrap(); + let LayerContent::Smart(smart) = &state.doc.layer(state.active_layer.unwrap()).unwrap().content else { panic!("smart object") }; + let photocraft_doc::SmartSource::Embedded { bytes, .. } = &smart.source else { panic!("embedded") }; + assert_eq!(bytes.as_slice(), changed.as_bytes()); +} + +#[test] +fn saved_changes_wait_through_quick_mask_and_resume_without_another_save() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + app.run("select.editInQuickMaskMode", json!({"on":true})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + let ctx = egui::Context::default(); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + app.run("select.editInQuickMaskMode", json!({"on":false})).unwrap(); + poll(&mut app, &ctx); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("smart object") }; + let photocraft_doc::SmartSource::Embedded { bytes, .. } = &sm.source else { panic!("embedded") }; + assert_eq!(bytes.as_slice(), SVG.replace("red", "blue").as_bytes()); +} + +#[test] +fn saved_changes_wait_for_layers_panel_dialog_before_first_modal_frame() { + let (mut app, saves, _) = fixture(); + open(&mut app); + confirm(&mut app).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + app.ui.layers_panel_dialog = Some(crate::state::LayersPanelDialog::NewGroup("New group".into())); + saves.borrow_mut().push_back(Ok(SVG.replace("red", "blue").into_bytes())); + let ctx = egui::Context::default(); + assert!(ctx.memory(|memory| memory.top_modal_layer().is_none())); + poll(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().doc, before); + assert!(app.smart_svg.editing.first().unwrap().saved.is_some()); + app.ui.layers_panel_dialog = None; + poll(&mut app, &ctx); + assert_ne!(app.session.active().unwrap().doc, before); + assert!(app.smart_svg.editing.first().unwrap().saved.is_none()); +} + +struct FinishedSession(Option, String>>); +impl ExternalSvgSession for FinishedSession { + fn poll(&mut self) -> Option, String>> { + self.0.take() + } + fn finished(&self) -> bool { + true + } +} + +#[test] +fn cancelled_desktop_choosers_release_session_capacity_without_changing_content() { + let (mut app, _, _) = fixture(); + app.services.open_external_svg = Some(Box::new(|_, _| Ok(Box::new(FinishedSession(Some(Err("Editor selection cancelled".into()))))))); + let before = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + for _ in 0..MAX_SESSIONS + 1 { + open(&mut app); + confirm(&mut app).unwrap(); + poll(&mut app, &ctx); + assert!(app.smart_svg.editing.is_empty()); + assert_eq!(app.session.active().unwrap().doc, before); + } +} + +#[test] +fn finished_session_retains_final_save_until_target_is_safe() { + let (mut app, _, _) = fixture(); + app.services.open_external_svg = Some(Box::new(|_, _| Ok(Box::new(FinishedSession(Some(Ok(SVG.replace("red", "blue").into_bytes()))))))); + open(&mut app); + confirm(&mut app).unwrap(); + let ctx = egui::Context::default(); + app.run("select.editInQuickMaskMode", json!({"on":true})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + poll(&mut app, &ctx); + assert_eq!(app.smart_svg.editing.len(), 1); + assert!(app.smart_svg.editing[0].saved.is_some()); + assert_eq!(app.session.active().unwrap().doc, before); + app.run("select.editInQuickMaskMode", json!({"on":false})).unwrap(); + poll(&mut app, &ctx); + assert_ne!(app.session.active().unwrap().doc, before); + poll(&mut app, &ctx); + assert!(app.smart_svg.editing.is_empty()); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index eac69a2263..ca0259a434 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -16,8 +16,10 @@ pub use panel_state::{ }; pub use tool::Tool; pub use tool_options::{SampleAllLayers, ToolOptions}; -use tool_options::{default_brush_tool, default_layer_filter_mode, yes}; -pub use transform::{BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeTransform}; +use tool_options::{default_brush_tool, default_layer_filter_mode, layer_filter_mode_or_normal, yes}; +pub use transform::{ + BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeInsertionStyle, TypeTransform, +}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Panels { @@ -188,6 +190,12 @@ pub struct DocWindow { pub document: usize, pub view: View, pub open: bool, + /// This window owns the document's view; the document is removed from the main canvas. + #[serde(default)] + pub floating: bool, + /// Transient focus edge used to activate the document once when this viewport gains focus. + #[serde(skip)] + pub focused: bool, /// Where the window opens on screen (outer top-left, in points); `None` lets the OS place it. /// A tab dragged off the tab strip opens its window where it was dropped (#3733). #[serde(default)] @@ -215,6 +223,14 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// Whether the text being edited overflows its paragraph box (the box's lower-right handle + /// shows the overflow "+"). + /// Read by automation while a session edits that box; never written to settings. + #[serde(skip)] + pub type_box_overflow: bool, + /// Font for subsequent typing at this caret, separate from existing character runs. + #[serde(default)] + pub type_insertion_style: Option, /// Clock time in seconds when type cursor blink was last reset (e.g. on keypress/caret move). #[serde(skip)] pub caret_blink_time: f64, @@ -273,6 +289,13 @@ pub struct UiState { /// its screen position (points). #[serde(default)] pub brush_picker: Option<[f32; 2]>, + /// The painting tools' Shift+right-click menu (Edit Brush… and the tool's Mode list, + /// `crate::brush_mode_menu`): screen position in points. + #[serde(default)] + pub brush_mode_menu: Option<[f32; 2]>, + /// The tool whose Shift+right-click menu is open: another tool closes it. + #[serde(default)] + pub brush_mode_menu_tool: Option, /// The Brush Preset picker's preset list: search, collapsed groups, view, a rename in progress. #[serde(default = "crate::brush_picker::list_state")] pub brush_picker_list: crate::brush_panel::BrushesPanelState, @@ -280,6 +303,10 @@ pub struct UiState { /// ([`crate::brush_picker::DEFAULT_SIZE`] before that). #[serde(default)] pub brush_picker_size: Option<[f32; 2]>, + /// Photoshop's HUD Color Picker (#6098), open while Shift+Alt+right-click is held on the + /// canvas with a painting tool (`crate::color_hud`). + #[serde(default)] + pub color_hud: Option, /// Layers under the pointer, listed by a right-click on the canvas with the Move tool or /// ⌘/Ctrl+right-click with any tool (`layer_pick_ui`, #307). #[serde(default)] @@ -322,6 +349,9 @@ pub struct UiState { /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, + /// Layers panel Smart Object filter ("linked", "missing", "embedded"); empty = all. + #[serde(default)] + pub layer_filter_smart: Vec, /// Thumbnail dimensions, content cropping and filter-strip visibility. #[serde(default)] pub layers_panel_options: LayersPanelOptions, @@ -335,8 +365,17 @@ pub struct UiState { #[serde(default)] pub layer_filter_name: String, /// Layers panel blend-mode filter. - #[serde(default = "default_layer_filter_mode")] + #[serde(default = "default_layer_filter_mode", deserialize_with = "layer_filter_mode_or_normal")] pub layer_filter_mode: photocraft_color::BlendMode, + /// Layers panel effect filter (#10880). + #[serde(default)] + pub layer_filter_effect: crate::layer_filter_ui::effect::EffectKind, + /// Layers panel attribute filter (#10881). + #[serde(default)] + pub layer_filter_attribute: crate::layer_filter_ui::attribute::Attribute, + /// Layers panel colour filter (#5898): the label colour listed (None: layers without one). + #[serde(default)] + pub layer_filter_color: photocraft_doc::LabelColor, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -408,6 +447,10 @@ pub struct UiState { /// Which chip the Color panel edits, and how it shows the colour. #[serde(default)] pub color_panel: ColorPanelState, + /// The Color panel options as last synced with the preferences: a stored value that differs + /// from it was changed outside the panel (`prefs.set`, a reset) and is taken, not overwritten. + #[serde(skip)] + pub color_panel_synced: Option, /// Histogram panel options: view, channel, statistics, colours, source. #[serde(default)] pub histogram_panel: crate::histogram_panel::HistogramPanelState, @@ -488,12 +531,14 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { Self { - tool: Tool::Brush, + tool: Tool::Move, group_tools: Vec::new(), spring: None, recent_files: Vec::new(), recent_fonts: Vec::new(), text_edit: None, + type_box_overflow: false, + type_insertion_style: None, caret_blink_time: 0.0, text_edit_return_tool: None, type_transform: None, @@ -508,8 +553,11 @@ impl Default for UiState { filter_mask_target: None, properties_layer: None, brush_picker: None, + brush_mode_menu: None, + brush_mode_menu_tool: None, brush_picker_list: crate::brush_picker::list_state(), brush_picker_size: None, + color_hud: None, layer_menu: None, canvas_tool_menu: None, selected_guide: None, @@ -532,6 +580,7 @@ impl Default for UiState { artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layer_filter_smart: Vec::new(), layers_panel_options: LayersPanelOptions::default(), histogram_panel: Default::default(), histogram_range: None, @@ -539,6 +588,9 @@ impl Default for UiState { layer_filter_by: Default::default(), layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), + layer_filter_effect: Default::default(), + layer_filter_attribute: Default::default(), + layer_filter_color: Default::default(), pen: None, pen_doc: None, stroke_editor: None, @@ -562,6 +614,7 @@ impl Default for UiState { docking_collapsed: Vec::new(), docking_reveal: None, color_panel: Default::default(), + color_panel_synced: None, brush_section: 0, brush_tab: 0, brushes_panel: Default::default(), @@ -740,7 +793,7 @@ mod tests { fn ui_state_serializes() { let s = UiState::default(); let v = serde_json::to_value(&s).unwrap(); - assert_eq!(v["tool"], "Brush"); + assert_eq!(v["tool"], "Move"); let back: UiState = serde_json::from_value(v).unwrap(); assert_eq!(back, s); } diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index 8b8bad1630..36ee1a5036 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -91,6 +91,11 @@ pub struct ToolOptions { pub enhance_edge: bool, /// Pen: "path" (work path) or "shape" (shape layer). pub vector_mode: String, + /// Pen, Freeform Pen and Curvature Pen › Path Operations (#1397): how a new path combines with + /// the path or shape it is drawn into: "newLayer" (Shape mode: a new shape layer; Path mode + /// draws it as "combine"), "combine", "subtract", "intersect" or "exclude". + #[serde(default = "default_pen_path_op")] + pub pen_path_op: String, /// Shape tools: fill with the foreground colour, stroke width (0 = none), rectangle corner /// radius, polygon sides, line weight. pub shape_fill: bool, @@ -104,6 +109,12 @@ pub struct ToolOptions { pub corner_radius: f32, pub polygon_sides: u32, pub line_weight: f32, + /// Line arrowheads. + pub arrow_start: bool, + pub arrow_end: bool, + pub arrow_width: f32, + pub arrow_length: f32, + pub arrow_concavity: f32, /// Freeform Pen › Curve Fit: how far (px) the fitted path may stray from the pointer trace. #[serde(default = "default_curve_fit")] pub curve_fit: f32, @@ -139,6 +150,9 @@ pub struct ToolOptions { pub move_target: String, #[serde(default)] pub move_show_transform: bool, + /// Last interpolation selected in the Transform options bar; unset follows General's default. + #[serde(default)] + pub transform_interpolation: Option, /// Crop options bar: aspect ratio ("" = Ratio/unconstrained, "w:h", or "original") and /// Delete Cropped Pixels. #[serde(default)] @@ -262,6 +276,10 @@ fn default_move_target() -> String { "group".into() } +fn default_pen_path_op() -> String { + "newLayer".into() +} + fn default_curve_fit() -> f32 { 2.0 } @@ -295,6 +313,14 @@ pub(super) fn default_layer_filter_mode() -> photocraft_color::BlendMode { photocraft_color::BlendMode::Normal } +/// A stored Mode filter set to Pass Through, which the Mode list offered until #10878, loads as +/// Normal: Pass Through isn't a mode the filter lists any more. +pub(super) fn layer_filter_mode_or_normal<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + use serde::Deserialize; + let mode = photocraft_color::BlendMode::deserialize(d)?; + Ok(if mode == photocraft_color::BlendMode::PassThrough { photocraft_color::BlendMode::Normal } else { mode }) +} + fn one_u32() -> u32 { 1 } @@ -402,6 +428,7 @@ impl Default for ToolOptions { finger_painting: false, enhance_edge: false, vector_mode: "path".into(), + pen_path_op: default_pen_path_op(), shape_fill: true, stroke_width: 0.0, shape_stroke: Default::default(), @@ -410,6 +437,11 @@ impl Default for ToolOptions { corner_radius: 0.0, polygon_sides: 5, line_weight: 3.0, + arrow_start: false, + arrow_end: false, + arrow_width: 52.0, + arrow_length: 50.0, + arrow_concavity: 0.0, curve_fit: default_curve_fit(), pen_auto_add_delete: true, marquee_style: default_marquee_style(), @@ -422,6 +454,7 @@ impl Default for ToolOptions { move_auto_select: true, move_target: default_move_target(), move_show_transform: false, + transform_interpolation: None, crop_ratio: String::new(), crop_custom_ratio_width: 0.0, crop_custom_ratio_height: 0.0, diff --git a/crates/ui-egui/src/state/transform.rs b/crates/ui-egui/src/state/transform.rs index 5eb2064f0d..fe381c336b 100644 --- a/crates/ui-egui/src/state/transform.rs +++ b/crates/ui-egui/src/state/transform.rs @@ -71,6 +71,12 @@ pub struct TransformSession { /// skew and rotation with any vertical skew, so the bar keeps showing the typed one. #[serde(default)] pub skew_v: f64, + /// Free Transform of several selected layers (#6501): every layer the box moves, in + /// `edit.transform`'s `layers` order. Empty when it moves `layer` alone. `layer` stays the + /// active layer, which need not be one of them (the Background is left out, a selected group + /// carries its selected children). + #[serde(default)] + pub layers: Vec, } /// Content-Aware Scale's options bar: Amount, Protect and Protect Skin Tones. @@ -113,6 +119,17 @@ pub struct BoxResize { pub aff: photocraft_geom::Affine, } +/// A font choice for subsequent typing, invalidated by navigation or a document edit. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TypeInsertionStyle { + pub document: u64, + pub layer: u64, + pub session: String, + pub revision: u64, + pub caret: usize, + pub style: photocraft_doc::text::CharStyle, +} + /// In-progress inline type editing (Type tool). Offsets are character indices. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { diff --git a/crates/ui-egui/src/status_bar_tests.rs b/crates/ui-egui/src/status_bar_tests.rs index 1d42a65560..551eee0e61 100644 --- a/crates/ui-egui/src/status_bar_tests.rs +++ b/crates/ui-egui/src/status_bar_tests.rs @@ -218,3 +218,90 @@ fn appearance_toggle_preserves_pro_layout_and_has_no_fit_button() { assert!(Tokens::get(&h.ctx).pro); assert!(h.query_by_label("Fit").is_none(), "Fit button must not appear in light mode (#5016)"); } + +/// A zoom typed into the status bar, then any other zoom before the first one has settled: the new +/// zoom wins and lands exactly, with nothing of the typed one left to multiply it (Ctrl+1 right +/// after a typed 1600 % is 100 %, not about 240 %). A typed zoom also lands exactly on its target. +#[test] +fn a_typed_zoom_leaves_nothing_for_the_next_zoom_and_lands_exactly() { + for animated in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1080, "height": 1080, "resolution": 72, "background": "white"})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated}})).unwrap(); + app.sync_views(); + let mut h = Harness::builder().with_size(SIZE).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(SIZE.x, 120.0)); + let view = app.ui.views[0].clone(); + app.ui.views[0] = crate::canvas::canvas_view(app, ui, 0, canvas, view, true); + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::status_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium); + h.run_steps(3); + for (typed, want) in [("1600", 16.0), ("200", 2.0), ("6400", 64.0)] { + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + h.event(egui::Event::Text(typed.into())); + h.key_press(egui::Key::Enter); + h.run_steps(60); + assert_eq!(h.state().ui.views[0].zoom, want, "typed {typed}, animated {animated}"); + } + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + h.event(egui::Event::Text("1600".into())); + h.key_press(egui::Key::Enter); + h.run_steps(1); + crate::panels::actions::run_view(h.state_mut(), "view.actualPixels", serde_json::Value::Null).unwrap(); + h.run_steps(60); + assert_eq!(h.state().ui.views[0].zoom, 1.0, "Actual Pixels after a typed 1600 %, animated {animated}"); + } +} + +/// `80,,2` typed into the status bar's zoom field: Enter opens Photoshop's "Invalid numeric entry" +/// message (the field's range with the system's decimal separator) and applies 80 %. +#[test] +fn a_second_separator_in_the_zoom_field_opens_the_invalid_entry_message_and_applies_the_leading_number() { + crate::i18n::with_decimal_separator(Some(','), || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1080, "height": 1080, "resolution": 72, "background": "white"})).unwrap(); + app.sync_views(); + let mut h = Harness::builder().with_size(SIZE).build_ui_state( + |ui, app: &mut PhotocraftApp| { + crate::zoom_field::tick(app, &ui.ctx().clone()); + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::status_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium); + h.run_steps(3); + let range = crate::zoom_levels::percent_range(&h.state().ui.views[0]); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + for c in "80,,2".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(1); + } + assert!(h.state().ui.dialogs.is_empty(), "no message while typing"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.views[0].zoom, 0.8, "the number before the second separator"); + let messages: Vec = h + .state() + .ui + .dialogs + .iter() + .filter(|d| d.kind == crate::state::DialogKind::Error) + .filter_map(|d| d.fields.get("message")?.as_str().map(String::from)) + .collect(); + let pct = |v: f32| format!("{v:.2}").replace('.', ","); + let want = + format!("Invalid numeric entry. An integer between {}% and {}% is required. Previous value was inserted.", pct(*range.start()), pct(*range.end())); + assert_eq!(messages, [want], "one message, in the dialog the app shows errors in"); + }); +} diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index ac88a6c899..ed59943fae 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -387,11 +387,10 @@ fn the_stroke_trace_times_each_frame_of_a_stroke_and_ends_on_release() { } let t0 = crate::gpu_canvas::now_ms(); let moves = ctx.input(|i| i.raw.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count()); - app.stroke_trace.begin_frame(t0, moves); - app.stroke_trace.cpu_prev(Some(0.004)); + app.perf.stage.start(t0, moves); + app.perf.stage.ui_start(t0, Some(0.004)); egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); - let stroking = app.live_stroke.is_some(); - app.stroke_trace.end_frame(crate::gpu_canvas::now_ms() - t0, stroking, || "test".into()); + app.finish_frame(crate::gpu_canvas::now_ms() - t0); }, app, ); @@ -409,7 +408,7 @@ fn the_stroke_trace_times_each_frame_of_a_stroke_and_ends_on_release() { assert!(s.frames >= 6, "{s:?}"); assert!(s.stroke[1] > 0.0 && s.ui[0] >= s.stroke[0], "{s:?}"); assert_eq!(s.moves[1], 1.0, "one move per test frame: {s:?}"); - assert_eq!(s.backend, "test"); + assert_eq!(s.backend, "", "the app's adapter (none in a test)"); } /// A Clone Stamp stroke through the canvas renders the points its commit gets (position and diff --git a/crates/ui-egui/src/text/bidi_text.rs b/crates/ui-egui/src/text/bidi_text.rs index babb0d0c48..c37ae42f56 100644 --- a/crates/ui-egui/src/text/bidi_text.rs +++ b/crates/ui-egui/src/text/bidi_text.rs @@ -1,31 +1,11 @@ //! Right-to-left text in labels that egui draws (#5159). //! -//! egui shapes text in runs of one font face and places the runs left to right. An Arabic or -//! Hebrew word is one run, which the shaper draws right to left, but the space between two words -//! comes from another font, so a phrase came out with every word correct and the word order -//! reversed ("THIS IS A TEXT" read as "TEXT A IS THIS"). [`visual`] puts a label into display -//! order with the Unicode Bidirectional Algorithm before egui lays it out: words of a -//! right-to-left script stay in logical order (the shaper reverses them itself), and everything -//! else in a right-to-left run is reversed around them, with brackets mirrored. -//! -//! For display only: never store the result or put it in an editable field. +//! egui lays text out with the Unicode Bidirectional Algorithm: each paragraph takes the direction +//! of its first strong letter, and a right-to-left run is placed right to left. Labels, names and +//! translations are therefore handed to egui in logical order, as typed. What egui can't know is +//! where a right-to-left name may be cut: [`elide`] keeps the *start* of the name. -use std::borrow::Cow; - -use unicode_bidi::{BidiClass, BidiInfo, Level, bidi_class}; - -/// `text` in the order egui should lay it out from left to right. Text without right-to-left -/// characters comes back unchanged, without allocating. -pub fn visual(text: &str) -> Cow<'_, str> { - visual_at(text, None) -} - -/// [`visual`] for a line that reads left to right as a whole even when it starts with a -/// right-to-left name, such as the title bar's "*صورة.psd — PhotoCraft": with the paragraph -/// direction taken from the name, the "— PhotoCraft" tail moved to the left. -pub fn visual_ltr(text: &str) -> Cow<'_, str> { - visual_at(text, Some(Level::ltr())) -} +use unicode_bidi::{BidiClass, bidi_class}; /// Whether `text` reads right to left as a paragraph: its first strong letter is Hebrew, Arabic… pub fn is_rtl_paragraph(text: &str) -> bool { @@ -38,20 +18,19 @@ pub fn is_rtl_paragraph(text: &str) -> bool { .unwrap_or(false) } -/// `text` in display order, cut with "…" where it doesn't fit (`fits` measures a display-order -/// string). A right-to-left name is cut in logical order before it is reordered, so its *start* -/// stays and the "…" lands at the left, the end of a right-to-left line; cutting after the -/// reordering lost the start of the name instead. `None`: `text` fits as it is (or a -/// left-to-right one, which the caller elides at its right end as usual). +/// A right-to-left `text` cut at its logical end with "…" where it doesn't fit (`fits` measures a +/// candidate). The start of the name stays, and egui puts the "…" at the left, the end of a +/// right-to-left line. `None`: `text` fits as it is (or a left-to-right one, which the caller +/// elides at its right end as usual). pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option { - if !is_rtl_paragraph(text) || fits(&visual(text)) { + if !is_rtl_paragraph(text) || fits(text) { return None; } // The longest logical prefix (whole characters) that fits with "…" after it. let ends: Vec = text.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(text.len())).collect(); let cut = |n: usize| -> String { let end = if n == 0 { 0 } else { ends.get(n - 1).copied().unwrap_or(text.len()) }; - visual(&format!("{}…", text.get(..end).unwrap_or_default().trim_end())).into_owned() + format!("{}…", text.get(..end).unwrap_or_default().trim_end()) }; let (mut lo, mut hi) = (0usize, ends.len()); while lo < hi { @@ -61,143 +40,21 @@ pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option { Some(cut(lo)) } -fn visual_at(text: &str, level: Option) -> Cow<'_, str> { - if !text.chars().any(is_rtl) { - return Cow::Borrowed(text); - } - let info = BidiInfo::new(text, level); - let mut out = String::with_capacity(text.len()); - for para in &info.paragraphs { - let (levels, runs) = info.visual_runs(para, para.range.clone()); - for run in runs { - let Some(slice) = text.get(run.clone()) else { continue }; - if levels.get(run.start).is_some_and(|level| level.is_rtl()) { - push_reversed(slice, &mut out); - } else { - out.push_str(slice); - } - } - } - Cow::Owned(out) -} - -/// A strong right-to-left letter, or any character of a right-to-left script block (Arabic -/// punctuation such as "،" belongs with the word it follows: the same font shapes both). -fn is_rtl(c: char) -> bool { - matches!(bidi_class(c), BidiClass::R | BidiClass::AL) || matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}') -} - -/// Characters that stay inside a right-to-left word: its letters, combining marks, and joiners. -fn keeps_with_word(c: char) -> bool { - is_rtl(c) || matches!(bidi_class(c), BidiClass::NSM | BidiClass::BN) -} - -/// Append a right-to-left run in left-to-right display order: right-to-left words stay whole and -/// in logical order, every other character is reversed around them, and brackets are mirrored. -fn push_reversed(run: &str, out: &mut String) { - let mut units: Vec<&str> = Vec::new(); - let mut word: Option = None; - for (i, c) in run.char_indices() { - if keeps_with_word(c) { - word.get_or_insert(i); - continue; - } - if let Some(start) = word.take() - && let Some(w) = run.get(start..i) - { - units.push(w); - } - if let Some(single) = run.get(i..i + c.len_utf8()) { - units.push(single); - } - } - if let Some(start) = word - && let Some(w) = run.get(start..) - { - units.push(w); - } - for unit in units.into_iter().rev() { - let mut chars = unit.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) if !keeps_with_word(c) => out.push(mirror(c)), - _ => out.push_str(unit), - } - } -} - -/// The mirrored form of a bracket shown in right-to-left text. -fn mirror(c: char) -> char { - match c { - '(' => ')', - ')' => '(', - '[' => ']', - ']' => '[', - '{' => '}', - '}' => '{', - '<' => '>', - '>' => '<', - '«' => '»', - '»' => '«', - other => other, - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn left_to_right_text_is_untouched_and_not_copied() { - for text in ["PhotoCraft", "Layer 1", "", "Background copy (2)", "日本語のレイヤー"] { - assert!(matches!(visual(text), Cow::Borrowed(t) if t == text), "{text}"); - } - } - - /// #5159: the reported layer name. Each word stays as typed; the word order flips so that - /// egui, placing words left to right, shows the first word at the right. - #[test] - fn arabic_words_are_placed_right_to_left() { - assert_eq!(visual("لاشي مكتوب هنا"), "هنا مكتوب لاشي"); - assert_eq!(visual("שלום עולם"), "עולם שלום"); - } - - #[test] - fn numbers_and_latin_keep_their_own_order() { - // A number inside right-to-left text reads left to right, at the start of the line. - assert_eq!(visual("طبقة 2"), "2 طبقة"); - assert_eq!(visual("طبقة 12"), "12 طبقة"); - // A left-to-right name with an Arabic word at the end is left as it is. - assert_eq!(visual("Layer 1 نص"), "Layer 1 نص"); - // Latin inside an Arabic name stays readable. - assert_eq!(visual("نص PhotoCraft هنا"), "هنا PhotoCraft نص"); - } - - /// #5385 review: the title bar's line is left to right as a whole, so a right-to-left - /// document name keeps "— PhotoCraft" after it, at the right. - #[test] - fn the_title_line_stays_left_to_right_around_a_right_to_left_name() { - assert_eq!(visual_ltr("*صورة.psd — PhotoCraft"), "*صورة.psd — PhotoCraft"); - assert_eq!(visual_ltr("لاشي مكتوب.png — PhotoCraft"), "مكتوب لاشي.png — PhotoCraft"); - assert!(matches!(visual_ltr("Untitled-1 — PhotoCraft"), Cow::Borrowed(_))); - } - /// #5385 review: a right-to-left name that doesn't fit keeps its first words and ends with - /// "…" at the left; a left-to-right or fitting name is left to the caller. + /// "…"; a left-to-right or fitting name is left to the caller. #[test] fn right_to_left_names_are_cut_at_their_logical_end() { let fits = |n: usize| move |s: &str| s.chars().count() <= n; // Cut anywhere, as egui elides: the first word and the start of the second. - assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("…مك لاشي")); - assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("…עולם לשלום")); + assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("لاشي مك…")); + assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("לשלום עולם…")); assert_eq!(elide("لاشي مكتوب هنا", fits(40)), None, "fits"); assert_eq!(elide("Layer copy 12", fits(4)), None, "left to right: elided by egui at its end"); assert_eq!(elide("نص", fits(0)).as_deref(), Some("…"), "nothing fits: just the ellipsis"); assert!(is_rtl_paragraph("12 نص") && !is_rtl_paragraph("Layer نص") && !is_rtl_paragraph("123")); } - - #[test] - fn brackets_mirror_and_arabic_punctuation_stays_with_its_word() { - assert_eq!(visual("نص (1)"), "(1) نص"); - assert_eq!(visual("مرحبا، عالم"), "عالم مرحبا،"); - } } diff --git a/crates/ui-egui/src/text/cjk_fonts.rs b/crates/ui-egui/src/text/cjk_fonts.rs index 674ae6948e..b74edce8a2 100644 --- a/crates/ui-egui/src/text/cjk_fonts.rs +++ b/crates/ui-egui/src/text/cjk_fonts.rs @@ -1014,4 +1014,4 @@ pub(crate) fn set_craft_font_scale(ctx: &egui::Context, scale: f32) { } #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] -mod tests; +pub(crate) mod tests; diff --git a/crates/ui-egui/src/text/cjk_fonts/tests.rs b/crates/ui-egui/src/text/cjk_fonts/tests.rs index 06e8ec4b14..1451a53284 100644 --- a/crates/ui-egui/src/text/cjk_fonts/tests.rs +++ b/crates/ui-egui/src/text/cjk_fonts/tests.rs @@ -433,7 +433,7 @@ fn two_face_ttc(first: &[u8], second: &[u8]) -> Vec { /// A minimal TrueType font mapping the `ranges` (inclusive) to box glyphs, so tests have a /// font for a script without committing font files. -fn synthetic_font(ranges: &[(u32, u32)]) -> Vec { +pub(crate) fn synthetic_font(ranges: &[(u32, u32)]) -> Vec { let be16 = |v: &mut Vec, x: i32| v.extend((x as u16).to_be_bytes()); let be32 = |v: &mut Vec, x: u32| v.extend(x.to_be_bytes()); let chars: u32 = ranges.iter().map(|(a, b)| b - a + 1).sum(); diff --git a/crates/ui-egui/src/text/direction/tests.rs b/crates/ui-egui/src/text/direction/tests.rs index f8ddafc2c4..c15b1927f6 100644 --- a/crates/ui-egui/src/text/direction/tests.rs +++ b/crates/ui-egui/src/text/direction/tests.rs @@ -1,6 +1,6 @@ //! Right-to-left shell layout: the direction rules, and where the mirrored chrome lands in a -//! running shell. No right-to-left catalog is registered yet, so the shell tests force the -//! direction with `direction_scope`. +//! running shell. The shell tests force the direction with `direction_scope`, so they don't +//! depend on a right-to-left language being selected. use super::*; use crate::PhotocraftApp; @@ -17,9 +17,10 @@ fn right_to_left_scripts_by_language_code() { for code in ["en", "ja", "zh-hans", "pt-br", "ku", "arn", ""] { assert_eq!(i18n::dir_for_code(code), Dir::Ltr, "{code}"); } - // No right-to-left catalog is registered yet, so the shell stays as it was. + // Arabic is the one right-to-left catalog; every other registered language stays left to right. for lang in Lang::all() { - assert_eq!(lang.dir(), Dir::Ltr, "{}", lang.code()); + let want = if lang.code() == "ar" { Dir::Rtl } else { Dir::Ltr }; + assert_eq!(lang.dir(), want, "{}", lang.code()); } } diff --git a/crates/ui-egui/src/text/served_fonts.rs b/crates/ui-egui/src/text/served_fonts.rs index b7ae085f41..e457fc3bf2 100644 --- a/crates/ui-egui/src/text/served_fonts.rs +++ b/crates/ui-egui/src/text/served_fonts.rs @@ -1,8 +1,13 @@ //! Fonts a host serves next to the app, for the platform shell that fetches them (the web build; //! see `photocraft_text::served`): reading the manifest, the families to fetch, and installing //! what arrived, which registers the files and re-renders the type layers drawn without them. +//! A served script fallback (Arabic, Japanese…) also joins the UI's font stack: the web build has +//! no system fonts, so it is what draws an Arabic or Japanese interface. -use std::sync::{Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; +use egui::{FontData, FontFamily}; use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests}; @@ -12,12 +17,73 @@ use crate::PhotocraftApp; /// Layers whose document was busy with a background job when their font arrived. static PENDING: Mutex> = Mutex::new(Vec::new()); +/// Served script fallbacks in the UI's font stack, by egui font name, unscaled: kept so the stack +/// `theme::install_fonts` rebuilds on a language change includes them again. +static UI_FACES: Mutex)>> = Mutex::new(Vec::new()); + +/// The served faces for the end of the UI's font stack (see [`UI_FACES`]), the interface +/// language's own first: a Japanese and a Simplified Chinese face both cover Han, and egui draws a +/// character with the first face in the stack that has it, so a Chinese interface must not get +/// the Japanese face only because it arrived first. +pub fn ui_faces() -> Vec<(String, Arc)> { + let mut faces = UI_FACES.lock().unwrap_or_else(PoisonError::into_inner).clone(); + if let Some(own) = ui_language_face() { + faces.sort_by_key(|(n, _)| *n != own); + } + faces +} + +/// The egui font name of the interface language's served script fallback, if the host serves one. +fn ui_language_face() -> Option { + photocraft_text::served::fallback_for_language(crate::i18n::current().code()).map(|f| face_name(&f)) +} + +fn face_name(family: &str) -> String { + format!("served-{family}") +} + +/// Puts a delivered script fallback at the end of every UI font family, once. A file egui cannot +/// read is left out: egui panics on a configured font it cannot parse. +fn add_ui_face(ctx: &egui::Context, family: &str, bytes: &[u8]) { + if !photocraft_text::cjk::font_face_is_readable(bytes, 0) { + log::warn!("the served font {family:?} is not a readable font file; the interface does not use it"); + return; + } + let name = face_name(family); + let mut data = { + let mut faces = UI_FACES.lock().unwrap_or_else(PoisonError::into_inner); + if faces.iter().any(|(n, _)| *n == name) { + return; + } + let data = FontData::from_owned(bytes.to_vec()); + faces.push((name.clone(), Arc::new(data.clone()))); + data + }; + // The interface language's face arriving after another served face must go ahead of it, which + // appending cannot do: rebuild the stack, as a language change does. + let others = ctx.fonts(|f| f.definitions().font_data.keys().any(|n| n.starts_with("served-"))); + if others && ui_language_face().as_ref() == Some(&name) { + crate::theme::install_fonts(ctx); + return; + } + crate::theme::size_ui_font(ctx, &name, &mut data); + let mut families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + for f in [FontFamily::Proportional, FontFamily::Monospace] { + if !families.contains(&f) { + families.push(f); + } + } + let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect(); + ctx.add_font(FontInsert { name, data, families }); +} + /// Registers delivered font files (`(family, bytes)`, the family as the manifest lists it) and /// re-renders the type layers that use those families, or whose text falls back to them (a script /// fallback, e.g. Arabic set in Inter), and show our own layout. A PSD layer still /// showing Photoshop's pixels (not edited yet) keeps them, as it does when its font is installed -/// from the start. Call it every frame: it also retries the layers of a document that was busy. -pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { +/// from the start. A script fallback also joins the UI's font stack. Call it every frame: it also +/// retries the layers of a document that was busy. +pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(String, Vec)>) { let mut layers = std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)); if !delivered.is_empty() { let families: Vec = delivered.iter().map(|(f, _)| f.clone()).collect(); @@ -34,6 +100,12 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { } } } + let fallbacks = photocraft_text::served::script_fallback_families(); + for (family, bytes) in &delivered { + if fallbacks.contains(family) { + add_ui_face(ctx, family, bytes); + } + } let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner); for (family, bytes) in delivered { let added = eng.fonts.register_font_data(bytes); @@ -89,4 +161,97 @@ mod tests { assert!(!redraws_for(&layer("hello"), &[thai.into()])); assert!(redraws_for(&layer("hello"), &["Inter".into()]), "and a layer still redraws for its own family"); } + + /// #10006: the web build has no system fonts, so a served Arabic fallback is what draws an + /// Arabic interface, also after a language change rebuilds the UI's font stack. + #[test] + fn a_served_script_fallback_draws_the_ui_and_survives_a_font_rebuild() { + // The web build's sources: no system fonts, no embedded craft fonts. + let web = || crate::cjk_fonts::Sources { + locale: || None, + files: |_| Vec::new(), + last_resort: Vec::new, + pending: || false, + scripts: |_| Vec::new(), + covers: |_, _| None, + embedded: crate::cjk_fonts::no_embedded, + eager_craft_fonts: false, + }; + let ctx = egui::Context::default(); + crate::theme::install_fonts_with(&ctx, web()); + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); + // Font changes take effect on the next pass: one empty pass, then draw Arabic. + let draw = |ctx: &egui::Context, text: &str| { + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label(text); + }) + .textures_delta + .clear(); + }; + draw(&ctx, "File"); + add_ui_face(&ctx, "Served Test Arabic", &crate::cjk_fonts::tests::synthetic_font(&[(0x0600, 0x06FF)])); + draw(&ctx, "File"); + draw(&ctx, "ملف"); + // A language change rebuilds the stack; the served face comes back with it. + crate::theme::install_fonts_with(&ctx, web()); + draw(&ctx, "File"); + draw(&ctx, "ملف"); + } + + /// #10006: a host serving a broken file as a script fallback must not stop the interface; + /// egui panics on a configured font it cannot parse. + #[test] + fn an_unreadable_served_font_stays_out_of_the_ui() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |ctx: &egui::Context| { + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("File ملف"); + }) + .textures_delta + .clear(); + }; + draw(&ctx); + add_ui_face(&ctx, "Served Test Broken", b"not found"); + draw(&ctx); + crate::theme::install_fonts(&ctx); + draw(&ctx); + assert!(!ui_faces().iter().any(|(n, _)| n == "served-Served Test Broken")); + } + + /// #10006: a Japanese and a Simplified Chinese face both cover Han. A Chinese interface draws + /// it with the Chinese one, even when the Japanese face arrived first. + #[test] + fn the_interface_languages_served_face_comes_first_in_the_ui_stack() { + let (jpan, hans) = ("Served Test Jpan", "Served Test Hans"); + photocraft_text::served::add_fonts(&parse_manifest(&format!("{jpan} | Regular | fonts/j/J.ttf | Jpan\n{hans} | Regular | fonts/h/H.ttf | Hans\n")).0); + crate::i18n::set_current(crate::i18n::Lang::from_code("zh-hans").expect("Simplified Chinese")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |ctx: &egui::Context| { + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("文件"); + }) + .textures_delta + .clear(); + }; + let order = |ctx: &egui::Context| { + let stack = ctx.fonts(|f| f.definitions().families[&FontFamily::Proportional].clone()); + let at = |family: &str| stack.iter().position(|n| *n == format!("served-{family}")); + (at(hans), at(jpan)) + }; + draw(&ctx); + let han = crate::cjk_fonts::tests::synthetic_font(&[(0x4E00, 0x9FFF)]); + add_ui_face(&ctx, jpan, &han); + draw(&ctx); + add_ui_face(&ctx, hans, &han); + draw(&ctx); + let (h, j) = order(&ctx); + assert!(h.is_some() && h < j, "the Chinese face goes ahead of the Japanese one: {h:?} {j:?}"); + crate::theme::install_fonts(&ctx); + draw(&ctx); + let (h, j) = order(&ctx); + assert!(h.is_some() && h < j, "also after a rebuild: {h:?} {j:?}"); + crate::i18n::set_current(crate::i18n::Lang::EN); + } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 88672fe75c..2c08f63faa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -363,7 +363,9 @@ impl Tokens { radius_lg: 12.0, bevel: false, pro: false, - round_chips: true, + // Studio Dark uses the compact overlapping square foreground/background + // chips rather than the large stacked circles (#3494). + round_chips: false, tab_strip: Color32::TRANSPARENT, row_selected: Color32::TRANSPARENT, histogram_bg: Color32::from_rgb(14, 14, 15), @@ -878,6 +880,13 @@ pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) { // family stack ahead of the Japanese or Korean face, and a locale without a Traditional Chinese // craft face leaves Han to the system font. (A single linear per-family chain still decides a // glyph once a broad face is discovered; see `cjk_fonts`.) + // Served script fallbacks the web build has received (Arabic, Japanese…), after everything else. + for (name, data) in crate::served_fonts::ui_faces() { + fonts.font_data.insert(name.clone(), data); + for family in [FontFamily::Proportional, FontFamily::Monospace] { + fonts.families.entry(family).or_default().push(name.clone()); + } + } // Named weights fall back to the default stack for missing glyphs. let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); for (fam, primary) in [("medium", medium), ("semibold", semibold)] { diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index e6b548089d..d869649853 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -191,6 +191,14 @@ fn applying_a_palette_clears_a_pinned_native_appearance_preference() { } } +#[test] +fn studio_dark_keeps_square_colour_chips() { + assert!(!Tokens::for_kind(ThemeKind::Studio).round_chips); + // Only the dark variant changes; the light palette retains its layout. + assert!(Tokens::for_kind(ThemeKind::StudioLight).round_chips); + assert!(!Tokens::for_kind(ThemeKind::Pro).round_chips); +} + #[test] fn studio_text_contrast_is_high() { let t = Tokens::for_kind(ThemeKind::Studio); diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 7ece83cb26..2b05c523d6 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -287,12 +287,12 @@ mod tests { #[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 = Tool::Lasso; 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.selected_tool, serde_json::to_value(Tool::Lasso).unwrap()); assert_eq!(saved.tool_options["tolerance"], Value::from(42.5)); assert!(saved.tool_options.get("contiguous").is_none(), "unchanged defaults are not written"); @@ -302,7 +302,7 @@ mod tests { p.tool_options = saved.tool_options.clone(); }); restore(&mut restored); - assert_eq!(restored.ui.tool, Tool::Move); + assert_eq!(restored.ui.tool, Tool::Lasso); assert_eq!(restored.ui.tool_options.tolerance, 42.5); } diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 27dfed9190..e15ab5385c 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -28,6 +28,8 @@ pub mod move_hover; pub mod move_lock; pub mod move_mods; pub mod object_finder; +pub(super) mod paint_picker; +pub mod path_components; pub mod perspective_crop_ui; pub mod perspective_ui; pub mod puppet_ui; diff --git a/crates/ui-egui/src/tools/align_bar.rs b/crates/ui-egui/src/tools/align_bar.rs index d663283e02..ebe159471e 100644 --- a/crates/ui-egui/src/tools/align_bar.rs +++ b/crates/ui-egui/src/tools/align_bar.rs @@ -29,18 +29,20 @@ const DISTRIBUTE: [(&str, &str, &str); 8] = [ ]; /// The six align icons followed by a menu holding both groups with icons and names. +/// Each icon uses 20 pt artwork inside a 28 pt hit target for high-DPI legibility +/// without changing enabled-state or command dispatch (#8849). pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); ui.spacing_mut().item_spacing.x = 0.0; for (cmd, icon, label) in ALIGN { let tip = format!("{}: {}", tl!("Align"), tl!(label)); let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, &tip)).inner.clicked() { + if ui.add_enabled_ui(on, |ui| icons::button_with_icon_size(ui, icon, 28.0, 20.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")); + let more = icons::button_with_icon_size(ui, "ellipsis", 28.0, 20.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, items: &[(&str, &str, &str)]| { diff --git a/crates/ui-egui/src/tools/anchor_tools.rs b/crates/ui-egui/src/tools/anchor_tools.rs index 62384c330a..e280b3382c 100644 --- a/crates/ui-egui/src/tools/anchor_tools.rs +++ b/crates/ui-egui/src/tools/anchor_tools.rs @@ -1,6 +1,6 @@ //! Add Anchor Point and Delete Anchor Point tools (P group with the Pen, #1044), as in Photoshop. -//! They edit the paths Direct Selection edits, front first: the targeted vector mask or the active -//! shape layer's path, then the work path. +//! They edit the paths Direct Selection edits, front first: the work or saved path selected in the +//! Paths panel, the targeted vector mask or the active shape layer's path, then the work path. //! //! * Add Anchor Point: hovering a segment highlights it and shows where the anchor would go; a //! click inserts it there (`path.addAnchor`). A curve is split without changing its shape. @@ -38,9 +38,7 @@ pub fn effective(tool: Tool, mods: Modifiers) -> Option { /// segment (Add) or anchor (Delete) hit on it. An anchor hit with Add, or a segment with Delete, /// edits nothing. fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path, Hit)> { - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let (r, path, hit) = - crate::direct_select::candidates(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let (r, path, hit) = crate::direct_select::hit_target(app, p, false)?; match (tool, hit) { (Tool::AddAnchorPoint, Hit::Segment(..)) | (Tool::DeleteAnchorPoint, Hit::Anchor(_)) => Some((r, path, hit)), _ => None, @@ -135,12 +133,12 @@ fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { Hit::Handle(..) => return, }; // A hit right at a segment's end is an anchor (anchors win the hit test), so `t` is inside. - if let (Some(o), Some(t)) = (params.as_object_mut(), r.params().as_object()) { + if let (Some(o), Some(t)) = (params.as_object_mut(), r.params(app).as_object()) { o.extend(t.clone()); } match app.run(id, params) { // The anchors shift: a Direct Selection on this path would point at the wrong ones. - Ok(_) if app.ui.direct_selection.target == Some(r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, + Ok(_) if app.ui.direct_selection.target.as_ref() == Some(&r) => app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }, Ok(_) => {} Err(e) => { app.ui.status = e; diff --git a/crates/ui-egui/src/tools/anchor_tools/tests.rs b/crates/ui-egui/src/tools/anchor_tools/tests.rs index c21cc5aaf6..02d0f45dc8 100644 --- a/crates/ui-egui/src/tools/anchor_tools/tests.rs +++ b/crates/ui-egui/src/tools/anchor_tools/tests.rs @@ -231,6 +231,32 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() { assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); } +#[test] +fn add_and_delete_anchor_tools_edit_the_selected_saved_path() { + let mut app = app(Tool::AddAnchorPoint); + with_path(&mut app); + let before_work = work(&app); + let path = photocraft_engine::vector_cmds::path_json(&before_work); + app.run("path.set", json!({"name":"Saved", "path":path})).unwrap(); + app.ui.selected_path = Some("Saved".into()); + let n = steps(&app); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + let saved = || app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone(); + let added = saved(); + assert_eq!(added.subpaths[0].knots.len(), 5); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n + 1); + app.ui.tool = Tool::DeleteAnchorPoint; + let anchor = added.subpaths[0].knots[3].anchor; + click(&mut app, [anchor.x, anchor.y], Modifiers::NONE); + let deleted = app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone(); + assert_eq!(deleted.subpaths[0].knots.len(), 4); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n + 2); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path, added); +} + fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> { app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default() } diff --git a/crates/ui-egui/src/tools/brush_cursor.rs b/crates/ui-egui/src/tools/brush_cursor.rs index 6bc71a78f7..1710679e95 100644 --- a/crates/ui-egui/src/tools/brush_cursor.rs +++ b/crates/ui-egui/src/tools/brush_cursor.rs @@ -134,43 +134,6 @@ pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> O traced } -/// Which texels of the document-pixel `rect` (`x0, y0, x1, y1`, row-major) lie inside the tip -/// `rings` (radius units, even-odd so holes stay clear) when the tip is centred on `at` with -/// `radius` document pixels. The Clone Source overlay uses it to clip to a shaped tip. -pub(crate) fn tip_mask(rings: &[Vec<[f32; 2]>], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec { - let [x0, y0, x1, y1] = rect; - let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0)); - let mut mask = vec![false; w.saturating_mul(h)]; - let mut hits: Vec = Vec::new(); - for row in 0..h { - let y = f64::from(y0) + row as f64 + 0.5; - hits.clear(); - for ring in rings { - for (i, a) in ring.iter().enumerate() { - let Some(b) = ring.get((i + 1) % ring.len()) else { continue }; - let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius); - let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius); - if (ay <= y) != (by <= y) { - hits.push(ax + (y - ay) / (by - ay) * (bx - ax)); - } - } - } - hits.sort_by(f64::total_cmp); - for pair in hits.chunks(2) { - let [a, b] = pair else { continue }; - let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5); - let start = from.ceil().max(0.0) as usize; - let end = ((to.floor() + 1.0).max(0.0) as usize).min(w); - for x in start..end { - if let Some(m) = mask.get_mut(row * w + x) { - *m = true; - } - } - } - } - mask -} - #[cfg(test)] mod tests { use super::*; @@ -295,19 +258,4 @@ mod tests { assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); } } - - /// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear, - /// and a rotated flat tip leaves the corners of the brush square outside. - #[test] - fn tip_mask_follows_the_ring_shape() { - let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; - let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]]; - let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]); - let at = |x: usize, y: usize| mask[y * 20 + x]; - assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square"); - assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square"); - assert!(!at(10, 10) && at(8, 10), "the hole is clear"); - assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole"); - assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless"); - } } diff --git a/crates/ui-egui/src/tools/brush_resize.rs b/crates/ui-egui/src/tools/brush_resize.rs index 1afba1bd1c..e22faf2347 100644 --- a/crates/ui-egui/src/tools/brush_resize.rs +++ b/crates/ui-egui/src/tools/brush_resize.rs @@ -49,9 +49,10 @@ pub fn is_gesture(mods: egui::Modifiers) -> bool { } /// Alt (Option) with the right button: Photoshop's Windows gesture (#297). The left button with -/// Alt alone stays the painting tools' eyedropper. +/// Alt alone stays the painting tools' eyedropper. Shift+Alt+right is Photoshop's HUD Color +/// Picker (#6098), so Shift disqualifies the resize gesture. pub fn is_right_gesture(mods: egui::Modifiers) -> bool { - mods.alt + mods.alt && !mods.shift } /// The diameter and hardness a drag from `start` by (`dx`, `dy`) document pixels gives at `zoom`. @@ -137,14 +138,14 @@ fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { } /// Draw the resize feedback: the brush circle at the anchor, filled with the brush preview -/// colour whose solid core shows the hardness (Photoshop's), and the readout beside the pointer. +/// colour as a dab of the brush's hardness covers (Photoshop's), and the readout beside the pointer. /// Returns true while a resize is in progress (the canvas then hides its cursor). pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) -> bool { let Some(r) = app.brush_resize else { return false }; let b = &app.session.tools.brush; let c = xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32); let radius = (b.size / 2.0 * xf.zoom).max(1.0); - tip_preview(painter, c, radius, b.hardness, preview_tint(app)); + tip_preview(painter, c, b.size / 2.0, xf.zoom, b.hardness, preview_tint(app)); painter.circle_stroke(c, radius + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140))); painter.circle_stroke(c, radius, Stroke::new(1.0, Color32::from_white_alpha(220))); // Photoshop hides the pointer while the circle stays put. @@ -168,9 +169,10 @@ fn preview_tint(app: &PhotocraftApp) -> Color32 { }) } +/// Liquify density preview, kept separate from the paint engine's hardness falloff. /// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear /// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush. -fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) { +pub(crate) fn liquify_tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) { let clip = painter.clip_rect(); if !clip.expand(radius).contains(c) { return; @@ -198,5 +200,47 @@ fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, col painter.add(mesh); } +/// The round tip as the brush engine paints it: `radius` document pixels at `zoom` screen points +/// per document pixel, solid out to its core and then the engine's falloff (`tip_falloff`) out to +/// its reach, past the radius for a soft tip, so the disc shows what a dab of this hardness covers. +/// One mesh of rings, so its cost doesn't grow with the brush. +fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, zoom: f32, hardness: f32, color: Color32) { + /// Rings across the falloff: the shoulder is smooth, so linear steps between them are close. + const RINGS: usize = 16; + if !(radius.is_finite() && zoom.is_finite() && radius > 0.0 && zoom > 0.0) { + return; + } + let (core, _) = photocraft_paint::tip_shape(radius, hardness); + let reach = photocraft_paint::tip_reach(radius, hardness).max(core); + let outer = reach * zoom; + let clip = painter.clip_rect(); + if !clip.expand(outer).contains(c) { + return; + } + // Enough segments for a smooth edge at any size, capped so huge brushes stay cheap. + let n = ((outer * 0.5) as usize).clamp(24, 160); + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(c, color); + for k in 0..=RINGS { + let d = core + (reach - core) * k as f32 / RINGS as f32; + // The last ring is the reach, where the falloff has run out. + let tint = if k == RINGS { Color32::TRANSPARENT } else { color.gamma_multiply(photocraft_paint::tip_falloff(d, radius, hardness)) }; + for j in 0..n { + let a = j as f32 / n as f32 * std::f32::consts::TAU; + mesh.colored_vertex(c + vec2(a.cos(), a.sin()) * d * zoom, tint); + } + } + let n = n as u32; + let at = |ring: u32, j: u32| 1 + ring * n + j % n; + for j in 0..n { + mesh.add_triangle(0, at(0, j), at(0, j + 1)); + for ring in 0..RINGS as u32 { + mesh.add_triangle(at(ring, j), at(ring + 1, j), at(ring + 1, j + 1)); + mesh.add_triangle(at(ring, j), at(ring + 1, j + 1), at(ring, j + 1)); + } + } + painter.add(mesh); +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/tools/brush_resize/tests.rs b/crates/ui-egui/src/tools/brush_resize/tests.rs index 6967c7bb9a..3cba8bef99 100644 --- a/crates/ui-egui/src/tools/brush_resize/tests.rs +++ b/crates/ui-egui/src/tools/brush_resize/tests.rs @@ -344,6 +344,45 @@ fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() { assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); } +/// The tip's mesh, painted at the origin: (distance from the centre, alpha) per vertex. +fn tip_mesh(radius: f32, zoom: f32, hardness: f32) -> Vec<(f32, u8)> { + let ctx = egui::Context::default(); + let color = Color32::from_rgba_unmultiplied(255, 0, 0, 200); + let mut out = ctx.run_ui(Default::default(), |ui| { + let painter = ui.painter().with_clip_rect(egui::Rect::from_center_size(Pos2::ZERO, vec2(4000.0, 4000.0))); + tip_preview(&painter, Pos2::ZERO, radius, zoom, hardness, color); + }); + out.textures_delta.clear(); + let mesh = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Mesh(m) => Some(m.clone()), + _ => None, + }) + .expect("the tip is drawn"); + mesh.vertices.iter().map(|v| (v.pos.to_vec2().length(), v.color.a())).collect() +} + +/// The disc follows the engine's round tip: a hard one is solid to its rim, a soft one fades +/// as the engine's falloff does and reaches past the radius as its dabs do. +#[test] +fn the_tip_preview_follows_the_brush_falloff() { + for zoom in [1.0, 2.5] { + let hard = tip_mesh(50.0, zoom, 1.0); + assert!(hard.iter().filter(|(d, _)| *d < 48.0 * zoom).all(|(_, a)| *a == 200), "zoom {zoom}: a hard tip is solid inside"); + assert!(hard.iter().all(|(d, a)| *d <= 52.0 * zoom || *a == 0), "zoom {zoom}: and ends at its rim"); + let soft = tip_mesh(50.0, zoom, 0.0); + for (d, a) in &soft { + let want = 200.0 * photocraft_paint::tip_falloff(d / zoom, 50.0, 0.0); + assert!((f32::from(*a) - want).abs() <= 3.0 || *a == 0, "zoom {zoom}: at {d}: {a}, the engine {want}"); + } + let reach = photocraft_paint::tip_reach(50.0, 0.0); + assert!(soft.iter().any(|(d, a)| *d > 60.0 * zoom && *a > 0), "zoom {zoom}: a soft tip shows past its radius"); + assert!(soft.iter().all(|(d, _)| *d <= reach * zoom + 0.5), "zoom {zoom}: and no further than it reaches"); + } +} + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { @@ -383,3 +422,26 @@ fn stylus_barrel_button_resizes_with_alt_and_never_samples_color() { h.run_steps(2); assert!(h.state().brush_resize.is_none()); } + +/// Liquify density keeps its radius-bounded preview; the paint engine's soft-brush reach is +/// deliberately used only by the paint HUD. +#[test] +fn liquify_density_preview_stays_inside_its_radius() { + let ctx = egui::Context::default(); + let radius = 50.0; + let mut output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.painter().with_clip_rect(egui::Rect::from_center_size(Pos2::ZERO, vec2(400.0, 400.0))); + liquify_tip_preview(&painter, Pos2::ZERO, radius, 0.0, Color32::RED); + }); + output.textures_delta.clear(); + let mesh = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Mesh(mesh) => Some(mesh), + _ => None, + }) + .expect("the Liquify preview is drawn"); + assert!(mesh.vertices.iter().all(|v| v.pos.to_vec2().length() <= radius + 0.01)); + assert!(mesh.vertices.iter().any(|v| v.pos.to_vec2().length() >= radius - 0.01)); +} diff --git a/crates/ui-egui/src/tools/cam_transform.rs b/crates/ui-egui/src/tools/cam_transform.rs index 1914dc883a..d6e58bd1fd 100644 --- a/crates/ui-egui/src/tools/cam_transform.rs +++ b/crates/ui-egui/src/tools/cam_transform.rs @@ -51,7 +51,7 @@ pub(crate) fn begin(app: &mut PhotocraftApp, offset: [i32; 2]) -> bool { rect: r, quad: [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]], pivot: [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0], - interpolation: "bilinear".into(), + interpolation: crate::transform_tool::default_interpolation(app).into(), warp: None, selection: false, target: None, @@ -60,6 +60,7 @@ pub(crate) fn begin(app: &mut PhotocraftApp, offset: [i32; 2]) -> bool { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }; app.ui.cam_transform = Some(CamTransform { document, revision, offset, frame, gesture: None }); app.ui.status = crate::i18n::fmt(tl!("Scale or rotate the moved area, then press {key}"), &[("key", &crate::shortcuts::pretty("Enter"))]); diff --git a/crates/ui-egui/src/tools/cas_transform.rs b/crates/ui-egui/src/tools/cas_transform.rs index 0a1fa7d5b5..1e1a0f51ff 100644 --- a/crates/ui-egui/src/tools/cas_transform.rs +++ b/crates/ui-egui/src/tools/cas_transform.rs @@ -77,7 +77,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result begin(&mut app, &ctx).map(|_| ()), + "edit.puppetWarp" => crate::puppet_ui::begin(&mut app, &ctx, cmd), + _ => crate::perspective_ui::begin(&mut app, &ctx, cmd), + }; + assert!(result.is_err(), "{cmd}"); + assert!(app.ui.transform.is_none()); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } + } + #[test] fn menu_opens_a_scale_box_and_enter_commits_one_step() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/tools/corner_radius.rs b/crates/ui-egui/src/tools/corner_radius.rs index a5199b6c9d..6358ffd139 100644 --- a/crates/ui-egui/src/tools/corner_radius.rs +++ b/crates/ui-egui/src/tools/corner_radius.rs @@ -178,7 +178,7 @@ fn target(layer: u64, live: &LiveShape) -> Option { let vertices = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices }, radii: [corner_radius.max(0.0); 4] }) } - LiveShape::Ellipse { .. } | LiveShape::Line { .. } => None, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } | LiveShape::ArrowLine { .. } => None, } } diff --git a/crates/ui-egui/src/tools/crop_mode.rs b/crates/ui-egui/src/tools/crop_mode.rs index cc946b30b9..14eb64c8d3 100644 --- a/crates/ui-egui/src/tools/crop_mode.rs +++ b/crates/ui-egui/src/tools/crop_mode.rs @@ -3,7 +3,8 @@ //! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on //! screen and the image turns behind it. A drag outside the box turns the image about the box's //! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise -//! positive), a drag inside moves the image under the box (the box stays put; with View › Snap +//! positive) and keeps the frame inside the turned image ([`keep_inside`]; not with ⌘ held), a +//! drag inside moves the image under the box (the box stays put; with View › Snap //! on, the upright frame's edges and centre snap as a Classic Mode frame's do, #8580), the arrow //! keys nudge the image, and the handles resize the box on screen as usual. With **Auto Center //! Preview** on (the default), a box being resized keeps its centre where it is on screen (also @@ -114,6 +115,74 @@ pub fn image_turn(frame_angle: f64) -> f64 { if a == 0.0 { 0.0 } else { a } } +/// How far (document px) a turned frame's corner may lie past the canvas and still count as on +/// it: rounding, not a visible gap. +const FIT_TOLERANCE: f64 = 1e-6; + +/// Frame `rect` turned `deg` degrees about its centre, kept inside a `cw`×`ch` canvas (#10869): +/// `rect` itself when it fits; else moved the shortest way in; else shrunk about its centre, +/// keeping its proportions, to the largest size that fits anywhere, then moved in. `None` when a +/// size or the angle isn't finite, or a size isn't positive. +pub fn kept_inside(rect: [f64; 4], deg: f64, cw: f64, ch: f64) -> Option<[f64; 4]> { + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + if ![w, h, cw, ch, deg].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 || cw <= 0.0 || ch <= 0.0 { + return None; + } + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + // Half the turned frame's bounds: its corners are on the canvas when these are. + let (ex, ey) = ((w * c + h * s) / 2.0, (w * s + h * c) / 2.0); + let m = crate::crop_ui::center(rect); + // A frame that fits stays bit for bit, also one whose corners a rounding error puts a hair + // past the edges (a frame fitted by an earlier turn touches them). + let fits = |v: f64, e: f64, len: f64| v - e >= -FIT_TOLERANCE && v + e <= len + FIT_TOLERANCE; + if fits(m[0], ex, cw) && fits(m[1], ey, ch) { + return Some(rect); + } + // The scale that makes the bounds fit the canvas; a rounding error short of 1 (180°) fits. + let k = (cw / (2.0 * ex)).min(ch / (2.0 * ey)); + let k = if k >= 1.0 - 1e-9 { 1.0 } else { k }; + // Each axis on its own: the nearest centre that keeps the bounds on the canvas. + let along = |v: f64, e: f64, len: f64| if 2.0 * e >= len { len / 2.0 } else { v.clamp(e, len - e) }; + let n = [along(m[0], ex * k, cw), along(m[1], ey * k, ch)]; + let r = if k == 1.0 { + // Kept size: moved by exactly the offset. + let d = [n[0] - m[0], n[1] - m[1]]; + [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]] + } else { + let (hw, hh) = (w * k / 2.0, h * k / 2.0); + [n[0] - hw, n[1] - hh, n[0] + hw, n[1] + hh] + }; + r.iter().all(|v| v.is_finite()).then_some(r) +} + +/// A default-mode turn keeps the frame inside the turned image, as in Photoshop (#10869): the +/// frame the drag started from, `rect` (turned `from` degrees then), keeps its size and place +/// when it fits the image at the frame's angle now, else [`kept_inside`] moves it in or shrinks +/// it. Each step starts from the press frame, so turning back gives the frame back. The view +/// moves with the frame's centre, so the box stays put on screen. With ⌘ (Ctrl) `held` the frame +/// stays as it was at the press and ↵ fills the uncovered corners. +/// +/// Not measured in Photoshop, so they keep the frame as before: Fill set to Content-Aware Fill, +/// and a frame that already reached past the canvas at the press. +pub fn keep_inside(app: &mut PhotocraftApp, rect: [f64; 4], from: f64, held: bool) { + let Some(now) = finite_frame(app) else { return }; + let Some((cw, ch)) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; + let inside_at_press = kept_inside(rect, from, cw, ch) == Some(rect); + let want = if held || app.ui.tool_options.crop_content_aware || !inside_at_press { + rect + } else { + kept_inside(rect, crate::crop_ui::angle(app), cw, ch).unwrap_or(rect) + }; + if want == now { + return; + } + // Moves the frame's centre and the view together (the box stays put), then sets the size. + let (a, b) = (crate::crop_ui::center(now), crate::crop_ui::center(want)); + shift_image(app, [a[0] - b[0], a[1] - b[1]]); + app.ui.crop_rect = Some(want); +} + /// A live-centred resize ([`live_resize`]): the frame `rect` and handle (`hx`, `hy`) at the press, /// grabbed at document point `start`; the pointer now at `q` (in the press view); the frame's turn /// `deg`, the aspect `ratio` to hold and ⌥ (resize about the centre). diff --git a/crates/ui-egui/src/tools/crop_mode/tests.rs b/crates/ui-egui/src/tools/crop_mode/tests.rs index 5f059917c9..4401e9b15d 100644 --- a/crates/ui-egui/src/tools/crop_mode/tests.rs +++ b/crates/ui-egui/src/tools/crop_mode/tests.rs @@ -537,7 +537,7 @@ fn arrows_nudge_the_image_shift_by_ten_and_x_recentres() { #[test] fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { let mut app = app(); - crate::crop_ui::ensure_frame(&mut app); + with_frame(&mut app); app.ui.tool_options.crop_shield.show_cropped_area = false; assert!(crate::crop_shield::hides_outside(&app)); app.ui.tool_options.crop_shield.classic_mode = true; @@ -554,9 +554,9 @@ fn classic_mode_shows_the_cropped_area_and_h_does_nothing() { } #[test] -fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { +fn p_toggles_classic_mode_while_a_crop_is_in_progress() { let mut app = app(); - crate::crop_ui::ensure_frame(&mut app); + with_frame(&mut app); let press = |app: &mut PhotocraftApp| { let ctx = egui::Context::default(); ctx.input_mut(|i| i.events.push(egui::Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: NONE })); @@ -579,6 +579,29 @@ fn p_toggles_classic_mode_while_the_crop_tool_shows_a_box() { assert!(!classic(&app)); } +/// After a commit the tool shows no frame until the next press, which brings its untouched default +/// frame back: H and P are the Hand and Pen keys there, as when the tool is picked, not Show +/// Cropped Area and Classic Mode (#9731, #6536). +#[test] +fn h_and_p_pick_their_tools_over_the_default_frame_after_a_commit() { + for (k, tool) in [(egui::Key::H, Tool::Hand), (egui::Key::P, Tool::Pen)] { + let mut app = app(); + with_frame(&mut app); + key(&mut app, egui::Key::Enter, NONE); + assert_eq!(app.session.active().map(|st| st.doc.size), Some(Size::new(60, 40)), "the crop committed"); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_none(), "the commit hides the frame"); + crate::crop_ui::show_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame, "a press brings the default frame back"); + key(&mut app, k, NONE); + assert_eq!(app.ui.tool, tool, "{k:?}"); + let s = &app.ui.tool_options.crop_shield; + assert!(s.show_cropped_area && !s.classic_mode, "{k:?} changed no crop option"); + crate::crop_ui::ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_none(), "leaving the tool drops the default frame"); + } +} + #[test] fn straighten_turns_the_image_so_the_line_is_level_on_screen() { let mut app = app(); @@ -716,7 +739,8 @@ fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() { let v = &mut app.ui.views[0]; (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0); app.ui.extras.snap = true; - crate::crop_ui::ensure_frame(&mut app); + // A press brings back the default frame the commit hid. + crate::crop_ui::show_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); if ratio { app.ui.tool_options.crop_ratio = "original".into(); diff --git a/crates/ui-egui/src/tools/crop_overlay.rs b/crates/ui-egui/src/tools/crop_overlay.rs index c93f5e12e1..92a4fa32d9 100644 --- a/crates/ui-egui/src/tools/crop_overlay.rs +++ b/crates/ui-egui/src/tools/crop_overlay.rs @@ -9,7 +9,7 @@ //! The state lives in `ToolOptions` (`crop_overlay`, `crop_overlay_show`, //! `crop_overlay_orientation`), which `ui.inspect` reports and `ui.set` drives. -use egui::{Key, Modifiers}; +use egui::{Key, Modifiers, Rect, pos2, vec2}; use serde::{Deserialize, Serialize}; use crate::PhotocraftApp; @@ -55,6 +55,19 @@ impl CropOverlay { } } + /// The icon the overlay menus and the options-bar button show (#10889), drawn in the house + /// icon style with the frame's crop marks around the guide it names. + pub fn icon(self) -> &'static str { + match self { + Self::Thirds => "crop-overlay-thirds", + Self::Grid => "crop-overlay-grid", + Self::Diagonal => "crop-overlay-diagonal", + Self::Triangle => "crop-overlay-triangle", + Self::GoldenRatio => "crop-overlay-golden-ratio", + Self::GoldenSpiral => "crop-overlay-golden-spiral", + } + } + pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|k| k.id() == id) } @@ -234,15 +247,92 @@ fn golden_spiral() -> Polyline { pts.into_iter().map(|[x, y]| [(x / PHI).clamp(0.0, 1.0), y.clamp(0.0, 1.0)]).collect() } -/// The guides the pending crop box shows now: none when the options say not to. +/// Full-frame guides, or none when the options say not to. Canvas painting uses +/// [`current_guides_for_frame`] to restrict the turning grid to its viewport. pub fn current_guides(app: &PhotocraftApp, w: f32, h: f32) -> Vec { + current_guides_visible(app, w, h, Rect::from_min_size(egui::Pos2::ZERO, vec2(w, h))) +} + +/// Guides for the frame actually being painted. Use the caller's quad and clip: +/// canvas navigation may not have written its local camera back to UI state yet. +pub fn current_guides_for_frame(app: &PhotocraftApp, q: [egui::Pos2; 4], clip: Rect) -> Vec { + let (w, h) = (q[0].distance(q[1]), q[0].distance(q[3])); + let Some(visible) = visible_frame(q, clip, w, h) else { return Vec::new() }; + current_guides_visible(app, w, h, visible) +} + +fn current_guides_visible(app: &PhotocraftApp, w: f32, h: f32, visible: Rect) -> Vec { let o = &app.ui.tool_options; if app.ui.tool != Tool::Crop || !o.crop_overlay_show.shows(app.crop.drag.is_some()) { return Vec::new(); } + // Classic Mode and Straighten were not measured: only the default mode's turn + // replaces the chosen composition overlay (#10931). + if !o.crop_shield.classic_mode && crate::crop_ui::turning(app).is_some() { + return turn_grid(w, h, visible); + } guides(o.crop_overlay, o.crop_overlay_orientation, w, h) } +/// The canvas's visible extent in the crop frame's own screen-point axes. The +/// inverse basis also handles a flipped/rotated view and pixel aspect correction. +fn visible_frame(q: [egui::Pos2; 4], clip: Rect, w: f32, h: f32) -> Option { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 { + return None; + } + if !clip.is_finite() || !clip.is_positive() { + return None; + } + let (u, v) = (q[1] - q[0], q[3] - q[0]); + let det = f64::from(u.x) * f64::from(v.y) - f64::from(u.y) * f64::from(v.x); + if !det.is_finite() || det.abs() < f64::EPSILON { + return None; + } + let mut visible = Rect::NOTHING; + for p in [clip.left_top(), clip.right_top(), clip.right_bottom(), clip.left_bottom()] { + let d = p - q[0]; + let x = (f64::from(d.x) * f64::from(v.y) - f64::from(d.y) * f64::from(v.x)) / det * f64::from(w); + let y = (f64::from(u.x) * f64::from(d.y) - f64::from(u.y) * f64::from(d.x)) / det * f64::from(h); + if !(x.is_finite() && y.is_finite()) { + return None; + } + visible.extend_with(pos2(x.clamp(0.0, f64::from(w)) as f32, y.clamp(0.0, f64::from(h)) as f32)); + } + Some(visible) +} + +/// The temporary turning grid: 48 logical screen points between lines, centred +/// on the frame. `visible` is in the frame's screen-point axes, before mapping +/// back to its unit square. Generate only visible lines, however far the frame +/// is zoomed or panned, rather than truncating around its offscreen centre. +pub fn turn_grid(w: f32, h: f32, visible: Rect) -> Vec { + const SPACING: f64 = 48.0; + // Covers 196,608 logical points per axis; bound hostile viewport dimensions, + // not the size of the projected document or the grid's distance from centre. + const MAX_VISIBLE_LINES: usize = 4096; + if !(w.is_finite() && h.is_finite() && visible.is_finite()) || w < 1.0 || h < 1.0 { + return Vec::new(); + } + let visible = visible.intersect(Rect::from_min_size(egui::Pos2::ZERO, vec2(w, h))); + if !visible.is_positive() { + return Vec::new(); + } + let axis = |len: f32, lo: f32, hi: f32, vertical: bool| { + let len = f64::from(len); + let mid = len * 0.5; + // Keep the centre-based phase, but index directly into the visible range. + // Lines coincident with the frame outline need not be drawn twice. + let first = ((f64::from(lo) - mid) / SPACING).ceil().max((-mid / SPACING).floor() + 1.0); + let last = ((f64::from(hi) - mid) / SPACING).floor().min((mid / SPACING).ceil() - 1.0); + let count = (last - first + 1.0).clamp(0.0, MAX_VISIBLE_LINES as f64) as usize; + (0..count).map(move |i| { + let f = ((mid + (first + i as f64) * SPACING) / len) as f32; + if vertical { vec![[f, 0.0], [f, 1.0]] } else { vec![[0.0, f], [1.0, f]] } + }) + }; + axis(w, visible.min.x, visible.max.x, true).chain(axis(h, visible.min.y, visible.max.y, false)).collect() +} + /// O: the next overlay (the orientation starts over). pub fn cycle(o: &mut ToolOptions) { o.crop_overlay = o.crop_overlay.next(); @@ -283,32 +373,44 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// The options bar's overlay button and its menu (Photoshop's "Set the overlay options for the /// Crop tool"). pub fn options_button(o: &mut ToolOptions, ui: &mut egui::Ui) { - let r = - crate::icons::button(ui, "grid-3x3", 24.0, false, &format!("{} ({})", tl!("Set the overlay options for the Crop tool"), tl!(o.crop_overlay.label()))); + let r = crate::icons::button( + ui, + o.crop_overlay.icon(), + 24.0, + false, + &format!("{} ({})", tl!("Set the overlay options for the Crop tool"), tl!(o.crop_overlay.label())), + ); egui::Popup::menu(&r).show(|ui| { ui.set_min_width(220.0); - let item = |ui: &mut egui::Ui, on: bool, label: &str, key: Option<&str>| { - let mut b = egui::Button::new(format!("{} {}", if on { "✔" } else { " " }, label)); + // Check mark, then the entry's icon (the overlays have one, #10889), then the label. + let item = |ui: &mut egui::Ui, on: bool, icon: Option<&str>, label: &str, key: Option<&str>| { + let mut atoms = egui::Atoms::default(); + atoms.push_right(format!("{} ", if on { "✔" } else { " " })); + if let Some(name) = icon { + atoms.push_right(crate::icons::image(name, 14.0, crate::theme::Tokens::get(ui.ctx()).icon)); + } + atoms.push_right(label.to_owned()); + let mut b = egui::Button::new(atoms); if let Some(k) = key { b = b.shortcut_text(crate::shortcuts::pretty(k)); } ui.add(b).clicked() }; for k in CropOverlay::ALL { - if item(ui, o.crop_overlay == k, tl!(k.label()), None) { + if item(ui, o.crop_overlay == k, Some(k.icon()), tl!(k.label()), None) { set_overlay(o, k); ui.close(); } } ui.separator(); for s in OverlayShow::ALL { - if item(ui, o.crop_overlay_show == s, tl!(s.label()), None) { + if item(ui, o.crop_overlay_show == s, None, tl!(s.label()), None) { o.crop_overlay_show = s; ui.close(); } } ui.separator(); - if item(ui, false, tl!("Cycle Overlay"), Some("O")) { + if item(ui, false, None, tl!("Cycle Overlay"), Some("O")) { cycle(o); ui.close(); } diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index 4ffaf19062..6c5f36973b 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -120,11 +120,13 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && !app.ui.tool_options.crop_shield.shows_cropped_area() } -/// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in +/// H with a crop in progress (a frame drawn or changed) and no drag toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic -/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. +/// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's, +/// also over the untouched default frame the tool shows when picked or after a commit (#6536, +/// #9731). pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { + if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.default_frame || app.crop.drag.is_some() { return false; } if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { @@ -335,8 +337,10 @@ mod tests { } #[test] - fn h_toggles_show_cropped_area_while_the_crop_tool_shows_a_box() { + fn h_toggles_show_cropped_area_while_a_crop_is_in_progress() { let mut app = crop_app(); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 90.0]); + app.crop.default_frame = false; assert!(!hides_outside(&app)); press_h(&mut app); assert_eq!(app.ui.tool, Tool::Crop, "H doesn't switch to the Hand tool"); @@ -354,6 +358,12 @@ mod tests { press_h(&mut app); assert_eq!(app.ui.tool, Tool::Hand); assert!(app.ui.tool_options.crop_shield.show_cropped_area); + // The untouched default frame (#9731). + let mut app = crop_app(); + assert!(app.crop.default_frame); + press_h(&mut app); + assert_eq!(app.ui.tool, Tool::Hand); + assert!(app.ui.tool_options.crop_shield.show_cropped_area); // Another tool. let mut app = crop_app(); app.ui.tool = Tool::Brush; diff --git a/crates/ui-egui/src/tools/crop_size.rs b/crates/ui-egui/src/tools/crop_size.rs index ee1b939e15..c9ce39f83d 100644 --- a/crates/ui-egui/src/tools/crop_size.rs +++ b/crates/ui-egui/src/tools/crop_size.rs @@ -232,6 +232,8 @@ pub fn clear_ratio(o: &mut ToolOptions) { clear(o); } else { o.crop_ratio.clear(); + o.crop_custom_ratio_width = 0.0; + o.crop_custom_ratio_height = 0.0; } } @@ -385,6 +387,26 @@ mod tests { assert_eq!(width / height, 4.0 / 3.0); } + #[test] + fn clear_ratio_discards_complete_and_partial_ratio_fields() { + for (width, height) in [(16.0, 9.0), (4.0, 0.0), (0.0, 3.0)] { + let mut options = ToolOptions::default(); + set_custom_ratio_fields(&mut options, width, height); + clear_ratio(&mut options); + assert!(options.crop_ratio.is_empty()); + assert_eq!(custom_ratio_fields(&options, (300.0, 200.0)), (0.0, 0.0)); + set_custom_ratio_fields(&mut options, 5.0, 0.0); + assert!(options.crop_ratio.is_empty(), "entering a new width must not reuse the old height"); + assert_eq!(custom_ratio_fields(&options, (300.0, 200.0)), (5.0, 0.0)); + } + let mut options = whr("4 in", "6 in", "300", PX_PER_IN); + clear_ratio(&mut options); + assert_eq!( + (options.crop_ratio.as_str(), options.crop_width.as_str(), options.crop_height.as_str(), options.crop_resolution.as_str()), + (WHR, "", "", "") + ); + } + #[test] fn custom_ratio_keeps_three_decimals() { // #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop. diff --git a/crates/ui-egui/src/tools/crop_straighten.rs b/crates/ui-egui/src/tools/crop_straighten.rs index 5f663f2e6b..939953fd02 100644 --- a/crates/ui-egui/src/tools/crop_straighten.rs +++ b/crates/ui-egui/src/tools/crop_straighten.rs @@ -8,11 +8,13 @@ //! - Holding ⌘ (Ctrl off the Mac) with the Crop tool is a temporary Straighten: a ⌘-drag draws the //! line; releasing ⌘ gives the normal crop gestures back. //! -//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle -//! and fits the frame to the image ([`fitted`]); in Classic Mode the frame then shows turned over -//! the image, in the default mode the image turns behind the upright box (`crop_mode`). It is only a pending frame edit: -//! ↵ commits it and Esc cancels it as any crop. A line shorter than [`MIN_LINE_PX`] screen points -//! does nothing. +//! While the line is drawn its angle shows beside the pointer. Straighten sets the frame's angle. +//! The button's line also fits the frame to the image ([`fitted`]); a ⌘-held line keeps the +//! frame's size and position, so ↵ fills the corners the turn uncovers, as Photoshop does +//! (measured: an 800 × 500 frame stays 800 × 500, #10870). In Classic Mode the frame then shows +//! turned over the image, in the default mode the image turns behind the upright box +//! (`crop_mode`). It is only a pending frame edit: ↵ commits it and Esc cancels it as any crop. A +//! line shorter than [`MIN_LINE_PX`] screen points does nothing. use egui::{Modifiers, Pos2}; @@ -87,10 +89,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => { let Some([a, _]) = app.crop.straighten.line.take() else { return false }; - // One line per click of the button. - app.crop.straighten.armed = false; + // One line per click of the button. Only the button's line fits the frame (also with + // ⌘ held while it is armed); a ⌘ line keeps the frame's size, even when ⌘ is let go + // before the mouse button. + let fit = std::mem::take(&mut app.crop.straighten.armed); let zoom = f64::from(app.point_zoom()); - apply(app, a, [x, y], zoom); + apply(app, a, [x, y], zoom, fit); true } } @@ -129,15 +133,23 @@ pub fn fitted(w: f64, h: f64, cw: f64, ch: f64, deg: f64) -> Option<[f64; 4]> { Some([cx - hw, cy - hh, cx + hw, cy + hh]) } -/// The line from `a` to `b` (document px, at `zoom` screen points per px) straightens the frame. -fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64) { +/// The line from `a` to `b` (document px, at `zoom` screen points per px) straightens the frame, +/// fitting it to the image when `fit` (the options-bar button) and keeping its size and position +/// otherwise (⌘ held). +fn apply(app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], zoom: f64, fit: bool) { let min_len = if zoom.is_finite() && zoom > 0.0 { MIN_LINE_PX / zoom } else { MIN_LINE_PX }; let Some(deg) = frame_angle(a, b, min_len) else { return }; let Some(size) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; - // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame - // takes the canvas's. - let (w, h) = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())).map_or(size, |r| (r[2] - r[0], r[3] - r[1])); - let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; + let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite())); + let r = if fit { + // The frame keeps its proportions (an options-bar ratio is already the frame's); no frame + // takes the canvas's. + let (w, h) = frame.map_or(size, |r| (r[2] - r[0], r[3] - r[1])); + let Some(r) = fitted(w, h, size.0, size.1, deg) else { return }; + r + } else { + frame.unwrap_or([0.0, 0.0, size.0, size.1]) + }; if app.ui.crop_rect == Some(r) && crate::crop_ui::angle(app) == deg { return; } diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 3a05ba2cc7..d79bf478fc 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -8,8 +8,8 @@ //! being drawn; //! - inside the frame, a drag moves it; //! - on an edge or corner, a drag resizes it along the frame's own (possibly turned) axes; ⇧ keeps -//! the frame's aspect ratio, ⌥ resizes about the centre, and an options-bar ratio preset always -//! holds; +//! the frame's aspect ratio (on a corner the pointer is followed along the frame's diagonal), +//! ⌥ resizes about the centre, and an options-bar ratio preset always holds; //! - outside the frame (off its handles) the pointer is the rotate cursor and a drag turns the //! frame about its centre, ⇧ in 15° steps, with the angle beside the pointer (#1792). As in //! Photoshop, a new frame is drawn after the pending one is committed or cancelled; @@ -51,6 +51,10 @@ pub struct CropState { pub default_frame: bool, /// The document (index and size) the default frame was made for. frame_for: Option<(usize, u32, u32)>, + /// The document (id and size) whose frame [`hide_frame`] hid: no default frame is made for it + /// while this matches. Keyed by id, not tab index, so closing it never hides the frame of the + /// tab that takes its place. + dismissed: Option<(photocraft_doc::DocId, u32, u32)>, /// The frame is being edited (pressed since it was made): the canvas shows what lies past it. pub editing: bool, /// The document the pending frame (or gesture) belongs to ([`cancel_stale`], #1918). @@ -94,8 +98,9 @@ pub enum CropDrag { MoveImage { start: [f64; 2], rect: [f64; 4], view: Option }, /// Default mode, outside the box: the image turns about the frame's `center` (which stays put /// on screen). `grab` is the pointer's direction from it at the press (radians, in the press - /// `view`), `from` the frame's angle then (degrees). - TurnImage { center: [f64; 2], grab: f64, from: f64, view: Option }, + /// `view`), `from` the frame's angle then (degrees) and `rect` the frame then, which the turn + /// keeps inside the turned image (`crop_mode::keep_inside`). + TurnImage { center: [f64; 2], grab: f64, from: f64, rect: [f64; 4], view: Option }, } /// What the pointer is over, relative to a crop frame. @@ -204,6 +209,30 @@ pub fn resized_turned(r: [f64; 4], deg: f64, hx: i8, hy: i8, d: [f64; 2], ratio: [nc[0] - hw, nc[1] - hh, nc[0] + hw, nc[1] + hh] } +/// Drag `d` on corner (`hx`, `hy`) of frame `r` turned `deg` degrees, projected onto the frame's +/// diagonal through that corner: a ⇧ corner drag with a free ratio slides the corner along it, as +/// in Photoshop (#10871). A corner drag on a ratio otherwise lets the larger relative extent win, +/// so (−100, −50) on 800 × 500 gave 720 × 450 instead of Photoshop's 706 × 441. +fn along_diagonal(r: [f64; 4], deg: f64, hx: i8, hy: i8, d: [f64; 2]) -> [f64; 2] { + let v = turn([f64::from(hx) * (r[2] - r[0]), f64::from(hy) * (r[3] - r[1])], [0.0, 0.0], deg); + let vv = v[0] * v[0] + v[1] * v[1]; + let t = (d[0] * v[0] + d[1] * v[1]) / vv; + if vv > 0.0 && t.is_finite() { [t * v[0], t * v[1]] } else { d } +} + +/// The pointer at `q` of a resize drag on handle (`hx`, `hy`) of frame `rect`, pressed at +/// `start`, as the resize reads it. ⇧ on a corner with no ratio preset and no ⌥ follows the +/// frame's diagonal ([`along_diagonal`]); a preset, ⌥ ⇧ and the edge handles keep the +/// larger-extent rule, and the point is unchanged. The resize, Auto Center Preview and handle +/// snapping all read the pointer through this, so the handle snaps where it is drawn. +pub(crate) fn resize_point(app: &PhotocraftApp, rect: [f64; 4], hx: i8, hy: i8, start: [f64; 2], q: [f64; 2], mods: Modifiers) -> [f64; 2] { + if !(mods.shift && !mods.alt && hx != 0 && hy != 0 && preset_ratio(app).is_none()) { + return q; + } + let d = along_diagonal(rect, angle(app), hx, hy, [q[0] - start[0], q[1] - start[1]]); + [start[0] + d[0], start[1] + d[1]] +} + /// `[x0, y0, x1, y1]` with x0 ≤ x1, y0 ≤ y1. fn normalized(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] @@ -244,6 +273,8 @@ pub(crate) fn apply_ratio(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() { return; } + // A ratio or size chosen after a commit fits a frame, not nothing. + show_frame(app); frame(app); fit_frame(app); crate::crop_mode::sync_frame(app); @@ -377,6 +408,11 @@ fn frame(app: &mut PhotocraftApp) { if app.ui.crop_rect.is_some() || app.crop.drag.is_some() { return; } + // A committed crop or Esc hid the frame: it stays hidden for this document until the next press. + if app.crop.dismissed.is_some() && app.crop.dismissed == dismiss_key(app) { + return; + } + app.crop.dismissed = None; let Some(st) = app.session.active() else { return }; let canvas = st.doc.bounds(); // Computed once per new frame, not per frame drawn. @@ -412,6 +448,9 @@ fn commit_on_leave(app: &mut PhotocraftApp) { cancel(app); } app.ui.tool = tool; + // Committed by leaving the tool: back on it the default frame shows at once, as in Photoshop + // (only a commit with the Crop tool still picked hides it). + app.crop.dismissed = None; } /// A crop gesture is in progress: Space repositions the frame rather than panning. @@ -433,6 +472,36 @@ pub fn cancel(app: &mut PhotocraftApp) { crate::crop_straighten::reset(app); } +/// After a committed crop, with the Crop tool picked: no new default frame for the cropped +/// document until a press on the canvas (or a ratio), so its corners don't make the applied crop +/// look still pending. Picking the tool again, also after another tool, keeps it hidden, as in +/// Photoshop. +pub fn hide_frame(app: &mut PhotocraftApp) { + if app.ui.tool == Tool::Crop { + app.crop.dismissed = dismiss_key(app); + } +} + +/// Ends [`hide_frame`] and makes the default frame now, so what follows acts on it as on any +/// default frame: a press records it as the box's Undo step, a ratio fits it. +pub(crate) fn show_frame(app: &mut PhotocraftApp) { + if app.crop.dismissed.take().is_some() { + frame(app); + } +} + +/// Esc or the options bar's cancel: [`cancel`], then [`hide_frame`], so the frame disappears as in +/// Photoshop (#10370) and stays hidden as after a commit. +pub fn dismiss(app: &mut PhotocraftApp) { + cancel(app); + hide_frame(app); +} + +/// The active document's identity for [`CropState::dismissed`]. +fn dismiss_key(app: &PhotocraftApp) -> Option<(photocraft_doc::DocId, u32, u32)> { + app.session.active().map(|st| (st.doc.id, st.doc.size.width, st.doc.size.height)) +} + /// Drop the box's own Undo/Redo (leaving the tool, cancelling or committing a crop). pub fn clear_history(app: &mut PhotocraftApp) { app.crop.undo.clear(); @@ -666,6 +735,16 @@ pub fn sizing(app: &PhotocraftApp) -> Option<[f64; 2]> { frame_size(app.ui.crop_rect?) } +/// The Info panel's W and H while the Crop tool shows a frame: the frame's size (its own, before +/// any turn), as Photoshop's Info panel shows it, so its ratio can be matched (#3145). `None` +/// with another tool or no frame, and the panel shows the selection's size. +pub fn info_size(app: &PhotocraftApp) -> Option<[f64; 2]> { + if app.ui.tool != Tool::Crop { + return None; + } + frame_size(app.ui.crop_rect?) +} + /// The Crop tool owns the arrow keys and X: a pending frame and no gesture in progress. pub fn takes_keys(app: &PhotocraftApp) -> bool { app.ui.tool == Tool::Crop && app.crop.drag.is_none() && app.ui.transform.is_none() && app.ui.crop_rect.is_some_and(|r| r.iter().all(|v| v.is_finite())) @@ -796,6 +875,11 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; if !crate::crop_straighten::pointer(app, ev, mods) { return false; } + // A press it takes brings the hidden frame back like any other press, even when the line is + // too short to turn the frame. + if matches!(ev, ToolEvent::Down { .. }) { + show_frame(app); + } claim(app); // Default mode: the image turns so the line ends up level. // The turn is one box Undo step, like any other frame gesture. @@ -815,6 +899,7 @@ fn straighten(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers, p: [f64; /// Starts the drag a press at `p` selects: resize, move, turn or draw. fn begin_drag(app: &mut PhotocraftApp, p: [f64; 2]) { + show_frame(app); claim(app); app.crop.editing = true; app.crop.press_step = Some(step_of(app)); @@ -851,7 +936,7 @@ fn outside_drag(app: &PhotocraftApp, p: [f64; 2], rect: [f64; 4], deg: f64) -> C let c = center(rect); let grab = (p[1] - c[1]).atan2(p[0] - c[0]); if crate::crop_mode::moves_image(app) { - CropDrag::TurnImage { center: c, grab, from: deg, view: crate::crop_mode::press_view(app) } + CropDrag::TurnImage { center: c, grab, from: deg, rect, view: crate::crop_mode::press_view(app) } } else { // Outside the frame a drag turns it, as in Photoshop (#1792). CropDrag::Rotate { center: c, grab, from: deg } @@ -919,13 +1004,14 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bool { match app.crop.drag { Some(CropDrag::Rotate { center, grab, from }) => app.ui.crop_angle = turned_angle(center, grab, from, p, mods.shift), - Some(CropDrag::TurnImage { center, grab, from, view }) => { + Some(CropDrag::TurnImage { center, grab, from, rect, view }) => { // The image turns with the pointer's sweep on screen, so the frame turns against it. let at = crate::crop_mode::to_press(app, view, p); let turned = from - ((at[1] - center[1]).atan2(at[0] - center[0]) - grab).to_degrees(); if turned.is_finite() { app.ui.crop_angle = normalized_angle(if mods.shift { (turned / 15.0).round() * 15.0 } else { turned }); crate::crop_mode::sync_frame(app); + crate::crop_mode::keep_inside(app, rect, from, mods.command); } } Some(CropDrag::MoveImage { start, rect, view }) => { @@ -935,7 +1021,7 @@ fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bo } Some(CropDrag::Resize { hx, hy, start, rect, view: Some(view) }) => { // Auto Center Preview keeps the box centred while the handle follows the pointer. - let at = crate::crop_mode::to_press(app, Some(view), p); + let at = resize_point(app, rect, hx, hy, start, crate::crop_mode::to_press(app, Some(view), p), mods); let ratio = resize_ratio(app, rect, mods); let deg = angle(app); crate::crop_mode::live_resize(app, crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q: at, ratio, alt: mods.alt }, view); @@ -950,6 +1036,10 @@ fn dragged_frame(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Optio let ratio = preset_ratio(app); let space = app.crop.space; let deg = angle(app); + let resize_at = match app.crop.drag { + Some(CropDrag::Resize { hx, hy, start, rect, .. }) => resize_point(app, rect, hx, hy, start, p, mods), + _ => p, + }; match app.crop.drag.as_mut()? { CropDrag::Draw { anchor, cur, last, .. } => { if space { @@ -973,7 +1063,7 @@ fn dragged_frame(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Optio (w > 0.0 && h > 0.0).then(|| w / h) }; let ratio = ratio.or_else(|| if mods.shift { keep() } else { None }); - Some(resized_turned(*rect, deg, *hx, *hy, [p[0] - start[0], p[1] - start[1]], ratio, mods.alt)) + Some(resized_turned(*rect, deg, *hx, *hy, [resize_at[0] - start[0], resize_at[1] - start[1]], ratio, mods.alt)) } CropDrag::Rotate { .. } | CropDrag::TurnImage { .. } | CropDrag::MoveImage { .. } => None, } diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 6fba4e57d4..6a5a4b1c9c 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -122,8 +122,16 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { assert_eq!(doc.size, Size::new(30, 40), "{depth:?}"); assert_eq!(doc.depth, depth); assert!(doc.selection.is_none(), "cropping to the selection deselects, as in Photoshop"); - // The new default frame is the whole (cropped) canvas: ↵ on it does nothing. + // No frame until a press, also after another tool and back; then the default one is the + // whole (cropped) canvas, and ↵ on it does nothing. ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picking the tool again keeps it hidden"); + show_frame(&mut app); assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 30.0, 40.0])); let steps = app.session.active().unwrap().history.past_len(); crate::canvas::commit_crop(&mut app); @@ -132,6 +140,49 @@ fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { } } +/// A commit hides the frame; picking the Crop tool again keeps it hidden, as in Photoshop; a ratio +/// or a press brings back the default frame, and the press's crop keeps its box Undo and Redo. +#[test] +fn a_hidden_frame_comes_back_on_a_ratio_or_a_press_with_redo_intact() { + let committed = || { + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 10.0, 120.0, 60.0]); + crate::canvas::commit_crop(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "hidden after the commit"); + app + }; + let whole = Some([0.0, 0.0, 100.0, 50.0]); + // Picking the Crop tool while it is still picked, or after another tool: still hidden. + let mut app = committed(); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "re-picked"); + crate::tool_memory::select(&mut app, Tool::Move); + ensure_frame(&mut app); + crate::tool_memory::select(&mut app, Tool::Crop); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picked after another tool"); + show_frame(&mut app); + assert_eq!(app.ui.crop_rect, whole); + assert!(app.crop.default_frame); + // A ratio fits the frame it brings back. + let mut app = committed(); + app.ui.tool_options.crop_ratio = "1:1".into(); + apply_ratio(&mut app); + assert_eq!(app.ui.crop_rect, Some([25.0, 0.0, 75.0, 50.0])); + // A drawn crop undoes to the default frame, and redoes. + let mut app = committed(); + drag(&mut app, &[[10.0, 10.0], [40.0, 30.0]], NONE); + let drawn = app.ui.crop_rect; + assert_eq!(drawn, Some([10.0, 10.0, 40.0, 30.0])); + assert!(intercept(&mut app, "edit.undo").is_some()); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, whole, "undo goes back to the default frame"); + assert!(intercept(&mut app, "edit.redo").is_some()); + assert_eq!(app.ui.crop_rect, drawn, "and redo still has the drawn frame"); +} + #[test] fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() { let mut app = app(SampleType::U8); @@ -152,7 +203,7 @@ fn the_frame_is_edited_from_a_press_until_commit_cancel_or_another_tool() { assert!(shows_beyond_canvas(&app)); crate::canvas::commit_crop(&mut app); ensure_frame(&mut app); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 50.0, 30.0])); + assert_eq!(app.ui.crop_rect, None, "no frame until the next press"); assert!(!shows_beyond_canvas(&app)); // Or until another tool is picked. drag(&mut app, &[[5.0, 5.0], [20.0, 20.0]], NONE); @@ -387,13 +438,14 @@ fn handles_resize_with_shift_and_alt() { // Bottom-right corner, free. drag(&mut app, &[[120.0, 60.0], [140.0, 70.0]], NONE); assert_eq!(app.ui.crop_rect, Some([20.0, 20.0, 140.0, 70.0])); - // ⇧ keeps 120:50 from the top-left corner: the larger relative extent wins. + // ⇧ keeps 120:50 from the top-left corner: the corner follows the frame's diagonal, so the + // drag (−10, −20) projected onto (−120, −50) grows the frame by 2200 / 16900 (#10871). app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]); drag(&mut app, &[[20.0, 20.0], [10.0, 0.0]], SHIFT); let r = app.ui.crop_rect.unwrap(); assert_eq!((r[2], r[3]), (140.0, 70.0), "opposite corner fixed"); assert!(((r[2] - r[0]) / (r[3] - r[1]) - 120.0 / 50.0).abs() < 1e-9, "{r:?}"); - assert_eq!(r[3] - r[1], 70.0); + assert!((r[3] - r[1] - 50.0 * (1.0 + 2200.0 / 16900.0)).abs() < 1e-9, "{r:?}"); // ⌥ resizes about the centre. app.ui.crop_rect = Some([40.0, 20.0, 80.0, 60.0]); drag(&mut app, &[[80.0, 40.0], [90.0, 40.0]], Modifiers::ALT); @@ -405,6 +457,26 @@ fn handles_resize_with_shift_and_alt() { assert_eq!(app.ui.crop_rect, Some([40.0, 10.0, 100.0, 70.0])); } +/// The top-right and bottom-left corners follow the other diagonal, (120, −50) and (−120, 50): +/// the drag projected onto it grows the frame by 2200 / 16900, as on the top-left corner. Along +/// the top-left / bottom-right diagonal the top-right x1 would move to about 141.4. +#[test] +fn shift_resize_follows_the_diagonal_of_the_top_right_and_bottom_left_corners() { + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]); + drag(&mut app, &[[140.0, 20.0], [150.0, 0.0]], SHIFT); + let r = app.ui.crop_rect.unwrap(); + assert_eq!((r[0], r[3]), (20.0, 70.0), "top-right: opposite corner fixed"); + assert!((r[2] - (140.0 + 2640.0 / 169.0)).abs() < 1e-9, "top-right: {r:?}"); + assert!((r[1] - (20.0 - 1100.0 / 169.0)).abs() < 1e-9, "top-right: {r:?}"); + app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]); + drag(&mut app, &[[20.0, 70.0], [10.0, 90.0]], SHIFT); + let r = app.ui.crop_rect.unwrap(); + assert_eq!((r[2], r[1]), (140.0, 20.0), "bottom-left: opposite corner fixed"); + assert!((r[0] - (20.0 - 2640.0 / 169.0)).abs() < 1e-9, "bottom-left: {r:?}"); + assert!((r[3] - (70.0 + 1100.0 / 169.0)).abs() < 1e-9, "bottom-left: {r:?}"); +} + #[test] fn dragging_a_handle_past_the_other_side_flips_and_collapse_reverts() { let mut app = app(SampleType::U8); @@ -851,7 +923,7 @@ fn enter_during_a_drag_ends_the_drag() { tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, NONE); ensure_frame(&mut app); assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); - assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + assert_eq!(app.ui.crop_rect, None); assert!(!pending(&app)); } @@ -1144,3 +1216,201 @@ fn readout_shows_the_frame_size_while_drawing_or_resizing() { assert_eq!(frame_size([f64::NAN, 0.0, 1.0, 1.0]), None); assert_eq!(frame_size([-f64::MAX, 0.0, f64::MAX, 1.0]), None); } + +/// Esc through the real shortcut handler. +fn press_esc(app: &mut PhotocraftApp) { + let key = egui::Key::Escape; + let input = egui::RawInput { + focused: true, + events: vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + egui::Context::default().run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn esc_hides_the_frame_until_a_press() { + // #10370: Esc cancelled the crop but the next frame made the default frame again. It now hides + // the frame as a commit does: picking the tool again keeps it hidden, a press brings it back. + for classic in [false, true] { + let mut app = app(SampleType::U8); + app.ui.tool_options.crop_shield.classic_mode = classic; + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + // The untouched default frame: Esc hides it, and it stays hidden frame after frame. + press_esc(&mut app); + for _ in 0..3 { + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "classic={classic}"); + } + // A press on the canvas draws a new frame. + drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE); + assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0])); + // A drawn frame: Esc hides it too, and crops nothing. + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); + // Leaving the tool after Esc crops nothing, and back on it the frame stays hidden. + app.ui.tool = Tool::Brush; + ensure_frame(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100), "classic={classic}"); + app.ui.tool = Tool::Crop; + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None, "picking the tool again keeps it hidden"); + // A press brings it back; the options bar's cancel hides it as well. + show_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); + dismiss(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + // A plain cancel (stale document) does not hide it. + show_frame(&mut app); + cancel(&mut app); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some()); + } +} + +#[test] +fn closing_a_dismissed_document_never_hides_the_next_tabs_frame() { + // A same-size tab that takes the closed document's index must still show its frame. + let mut app = app(SampleType::U8); + let doc = Document::with_background("other", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE); + app.session.add_document(doc, None); + assert!(app.session.set_active(0)); + ensure_frame(&mut app); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert!(app.session.close(0).is_some()); + assert_eq!(app.session.active_index(), Some(0)); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); +} + +#[test] +fn a_straighten_press_ends_the_dismissal() { + // Straighten takes the press before `begin_drag`; a click too short to level anything still + // ends the dismissal. + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + app.crop.straighten.armed = true; + drag(&mut app, &[[50.0, 20.0], [50.5, 20.0]], NONE); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some()); +} + +#[test] +fn undoing_a_crop_after_esc_brings_the_frame_back() { + // Esc crops nothing, so it adds no step; an Undo that changes the canvas size shows a frame for it. + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + drag(&mut app, &[[20.0, 10.0], [70.0, 30.0], [120.0, 60.0]], NONE); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 50)); + ensure_frame(&mut app); + let steps = app.session.active().unwrap().history.entries().len(); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); + ensure_frame(&mut app); + assert!(app.ui.crop_rect.is_some() && app.crop.default_frame); +} + +#[test] +fn an_undo_that_keeps_the_canvas_size_leaves_the_frame_hidden() { + // Only a size change brings the frame back: undoing an edit that keeps the size doesn't. + let mut app = app(SampleType::U8); + app.run("image.adjustments.invert", json!({})).unwrap(); + ensure_frame(&mut app); + press_esc(&mut app); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100)); + ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, None); +} + +/// #3145: the Info panel shows the pending frame's W × H while the Crop tool is active +/// (between gestures too, and its own size when turned), and nothing for other tools. +#[test] +fn info_panel_shows_the_pending_frame_size() { + let mut app = app(SampleType::U8); + app.ui.tool = Tool::Crop; + app.ui.crop_rect = None; + assert_eq!(info_size(&app), None, "no frame"); + app.ui.crop_rect = Some([20.0, 20.0, 100.0, 65.4]); + assert_eq!(info_size(&app), Some([80.0, 45.0]), "rounded as ↵ crops it"); + app.ui.crop_angle = 30.0; + assert_eq!(info_size(&app), Some([80.0, 45.0]), "a turned frame keeps its own size"); + app.ui.crop_rect = Some([f64::NAN, 0.0, 1.0, 1.0]); + assert_eq!(info_size(&app), None, "a broken frame shows nothing"); + app.ui.crop_rect = Some([20.0, 20.0, 100.0, 60.0]); + app.ui.tool = Tool::Move; + assert_eq!(info_size(&app), None, "another tool shows the selection instead"); +} + +/// #3145 review: the crop frame's W and H use the Info panel's formatter for the ruler unit, +/// the same one the selection's go through. +#[test] +fn info_size_is_shown_in_the_ruler_unit() { + use crate::panels::info::wh_readout; + use photocraft_engine::prefs::{Unit, UnitsAndRulers}; + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([0.0, 0.0, 144.0, 72.0]); + let wh = info_size(&app).unwrap(); + let mut ur = UnitsAndRulers::default(); + let extent = [200.0, 100.0]; + ur.rulers = Unit::Pixels; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["144", "72"]); + ur.rulers = Unit::Inches; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["2", "1"]); + assert_eq!(wh_readout(&ur, 144.0, extent, wh), ["1", "0.5"], "at the document's resolution"); + ur.rulers = Unit::Centimeters; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["5.08", "2.54"]); + ur.rulers = Unit::Percent; + assert_eq!(wh_readout(&ur, 72.0, extent, wh), ["72", "72"], "W of the width, H of the height"); +} + +/// #3145 review: after the crop is cancelled or committed, or the tool is left, W and H are the +/// selection's again. With the Crop tool still picked, the new default frame is the selection's +/// bounds (or the canvas), as in Photoshop, so the panel shows the same size. After a commit that +/// frame waits for the next press. +#[test] +fn info_size_returns_to_the_selection_after_cancel_or_commit() { + let mut app = app(SampleType::U8); + app.run("select.rect", json!({"x": 20, "y": 10, "width": 80, "height": 40})).unwrap(); + // Cancel: no frame, then the new default frame frames the selection. + ensure_frame(&mut app); + app.ui.crop_rect = Some([10.0, 10.0, 50.0, 40.0]); + assert_eq!(info_size(&app), Some([40.0, 30.0])); + cancel(&mut app); + assert_eq!(info_size(&app), None, "cancelled: the panel falls back to the selection"); + ensure_frame(&mut app); + assert_eq!(info_size(&app), Some([80.0, 40.0]), "the new frame is the selection's bounds"); + // Commit: the document is cropped and the frame is gone. + app.run("select.deselect", json!({})).unwrap(); + cancel(&mut app); + drag(&mut app, &[[10.0, 10.0], [50.0, 40.0]], NONE); + assert_eq!(info_size(&app), Some([40.0, 30.0])); + crate::canvas::commit_crop(&mut app); + assert_eq!(app.session.active().unwrap().doc.size, Size::new(40, 30)); + assert_eq!(info_size(&app), None, "committed: no frame"); + ensure_frame(&mut app); + assert_eq!(info_size(&app), None, "committed: the frame stays hidden"); + // A press brings it back. + show_frame(&mut app); + assert_eq!(info_size(&app), Some([40.0, 30.0]), "the new frame is the cropped canvas"); + // Leaving the tool: the panel shows the selection, whatever the frame. + app.ui.tool = Tool::Move; + ensure_frame(&mut app); + assert_eq!(info_size(&app), None); +} diff --git a/crates/ui-egui/src/tools/direct_select.rs b/crates/ui-egui/src/tools/direct_select.rs index 577ecda468..86df553ddc 100644 --- a/crates/ui-egui/src/tools/direct_select.rs +++ b/crates/ui-egui/src/tools/direct_select.rs @@ -41,17 +41,24 @@ pub enum PathRef { Layer(u64), /// A type layer's Type Path (#9114), apart from a vector mask the layer may have. TypePath(u64), + /// A saved path, by its index in the document's `paths`: the one selected in the Paths panel. + Saved(usize), /// The Pen path being drawn (`ui.pen`): edited in place, not through the engine. Pen, } impl PathRef { - /// The engine's target params for this path. - pub(crate) fn params(self) -> Value { + /// The engine's target params for this path. A saved path goes by its name; one that is gone + /// gets an empty name, which the engine refuses. + pub(crate) fn params(self, app: &PhotocraftApp) -> Value { match self { PathRef::Work => json!({"name": "work"}), PathRef::Layer(id) => json!({"name": "layer", "layer": id}), PathRef::TypePath(id) => json!({"name": "layer", "layer": id, "typePath": true}), + PathRef::Saved(i) => { + let name = app.session.active().and_then(|st| st.doc.paths.get(i)).map(|p| p.name.clone()).unwrap_or_default(); + json!({"name": name}) + } // Never sent: edits to the Pen path being drawn don't go through the engine. PathRef::Pen => json!({}), } @@ -74,6 +81,94 @@ pub struct DirectSelection { /// adds the anchor it then drags, and both are one step. #[serde(skip)] pub coalesce: Option, + #[serde(skip)] + pub(crate) owner: Option, + #[serde(skip)] + pub(crate) cancelled_drag: bool, +} + +/// Runtime selection ownership is never restored from settings onto another document. +#[derive(Clone, Debug)] +pub(crate) struct Owner { + document: photocraft_doc::DocId, + source: std::sync::Weak, + revision: u64, + layer: Option, + selected_path: Option, + vector_mask: bool, + tool: Tool, +} + +impl PartialEq for Owner { + fn eq(&self, other: &Self) -> bool { + self.source.ptr_eq(&other.source) + && (self.document, self.revision, self.layer, &self.selected_path, self.vector_mask, self.tool) + == (other.document, other.revision, other.layer, &other.selected_path, other.vector_mask, other.tool) + } +} + +impl Owner { + pub(crate) fn current(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + Some(Self { + document: st.doc.id, + source: std::sync::Arc::downgrade(&st.doc), + revision: st.revision, + layer: st.active_layer, + selected_path: app.ui.selected_path.clone(), + vector_mask: app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask, + tool: app.ui.tool, + }) + } + + fn same_target(&self, app: &PhotocraftApp) -> bool { + self.same_path_target(app) && app.ui.tool == self.tool + } + + fn same_path_target(&self, app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| { + st.doc.id == self.document + && st.active_layer == self.layer + && (app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask) == self.vector_mask + }) && app.ui.selected_path == self.selected_path + } + + fn valid(&self, app: &PhotocraftApp) -> bool { + self.valid_path(app) && app.ui.tool == self.tool + } + + fn valid_path(&self, app: &PhotocraftApp) -> bool { + self.same_path_target(app) + && app.session.active().is_some_and(|st| st.revision == self.revision && self.source.ptr_eq(&std::sync::Arc::downgrade(&st.doc))) + } +} + +/// Is `ds` still about the document, revision and target it was made on? Convert Point works on +/// the handles selected with Direct Selection, so an idle selection crosses those two tools; a +/// drag keeps its own tool. +pub(crate) fn still_valid(app: &PhotocraftApp, ds: &DirectSelection) -> bool { + ds.owner.as_ref().is_some_and(|owner| owner.valid(app) || (ds.drag.is_none() && owns(owner.tool) && owns(app.ui.tool) && owner.valid_path(app))) +} + +/// Clear stale anchors and gestures, keeping the pointer until the old release arrives. True +/// when a drag was cancelled. +pub(crate) fn cancel_stale(app: &mut PhotocraftApp) -> bool { + let ds = &app.ui.direct_selection; + let empty = ds.target.is_none() && ds.anchors.is_empty() && ds.segment.is_none() && ds.drag.is_none(); + if (ds.owner.is_none() && empty) || still_valid(app, ds) { + return false; + } + let cancelled_drag = ds.cancelled_drag || ds.drag.is_some(); + app.ui.direct_selection = DirectSelection { cancelled_drag, ..Default::default() }; + cancelled_drag +} + +/// Rebind a selection to the revision its own command made; another target or tool drops it. +pub(crate) fn keep_after_command(app: &mut PhotocraftApp, mut selection: DirectSelection) { + if selection.owner.as_ref().is_some_and(|owner| owner.same_target(app)) { + selection.owner = Owner::current(app); + app.ui.direct_selection = selection; + } } #[derive(Clone, Debug, PartialEq)] @@ -174,12 +269,20 @@ pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { .or_else(|| crate::vector_ui::active_shape_path(app)) .map(|(id, p)| (PathRef::Layer(id), p)) .or_else(|| crate::vector_ui::active_type_path(app).map(|(id, p)| (PathRef::TypePath(id), p))); - let work = app.session.active().and_then(|st| st.doc.work_path.clone()).filter(|_| crate::vector_ui::work_path_shown(app)); - layer.into_iter().chain(work.map(|p| (PathRef::Work, p))).collect() + let saved = crate::vector_ui::selected_saved_path_index(app).map(|(i, p)| (PathRef::Saved(i), p)); + let work = app.session.active().and_then(|st| st.doc.work_path.clone()).filter(|_| crate::vector_ui::work_path_shown(app)).map(|p| (PathRef::Work, p)); + let mut out: Vec<_> = saved.into_iter().chain(layer).chain(work).collect(); + if app.ui.selected_path.as_deref() == Some("work") + && let Some(index) = out.iter().position(|(target, _)| *target == PathRef::Work) + { + out.swap(0, index); + } + out.retain(|(target, _)| writable(app, target)); + out } -/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, then -/// every visible shape layer. +/// The paths Direct Selection edits, front first: the Pen path being drawn, the active paths, every +/// visible shape layer, then the work path. pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { let active = app.session.active().map(|st| st.doc.id); let pen = app @@ -201,10 +304,37 @@ pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { out.push((PathRef::Layer(l.id.0), sh.path.clone())); } } + if !out.iter().any(|(r, _)| *r == PathRef::Work) { + out.extend(st.doc.work_path.clone().map(|p| (PathRef::Work, p))); + } } out } +/// Can `target` be edited: a layer's path only while the layer is not locked. +pub(crate) fn writable(app: &PhotocraftApp, target: &PathRef) -> bool { + match target { + PathRef::Layer(id) | PathRef::TypePath(id) => app.session.active().is_some_and(|st| { + let id = photocraft_doc::LayerId(*id); + let locks = st.doc.effective_locks(id); + st.doc.layer(id).is_some() && !locks.all && !locks.pixels + }), + _ => app.session.active().is_some(), + } +} + +/// The frontmost editable path under `p` and what is hit on it: the handles shown on a selection +/// still valid count when `handles` is set. The hover and the press share it. +pub(crate) fn hit_target(app: &PhotocraftApp, p: [f64; 2], handles: bool) -> Option<(PathRef, Path, Hit)> { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let ds = &app.ui.direct_selection; + let valid = handles && still_valid(app, ds); + candidates(app).into_iter().filter(|(r, _)| writable(app, r)).find_map(|(r, path)| { + let shown = if valid && ds.target.as_ref() == Some(&r) { shown_handles(ds, &path) } else { Vec::new() }; + edit::hit(&path, Point::new(p[0], p[1]), tol, &shown).map(|h| (r, path, h)) + }) +} + fn retracted(k: &photocraft_doc::Knot, h: Handle) -> bool { let c = if h == Handle::In { k.in_ctrl } else { k.out_ctrl }; (c.x - k.anchor.x).abs() < 1e-9 && (c.y - k.anchor.y).abs() < 1e-9 @@ -230,6 +360,18 @@ fn shown_handles(ds: &DirectSelection, path: &Path) -> Vec<([usize; 2], Handle)> /// Canvas pointer events for Direct Selection and the Pen's ⌘ / ⌥ modes; false when the event is /// not theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + cancel_stale(app); + if app.ui.direct_selection.cancelled_drag { + // The rest of a gesture cancelled on another document or target goes nowhere. + match ev { + ToolEvent::Down { .. } => app.ui.direct_selection.cancelled_drag = false, + ToolEvent::Up { .. } => { + app.ui.direct_selection.cancelled_drag = false; + return true; + } + ToolEvent::Move { .. } => return true, + } + } let tool = app.ui.tool; match ev { ToolEvent::Down { x, y, .. } => { @@ -244,12 +386,20 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } if !pen_idle && tool != Tool::DirectSelection && !hits_pen(app, [x, y]) { // ⌘-click off the path being drawn ends it, which becomes editable, as in - // Photoshop. A press on it edits it in place and the Pen carries on (#4022). - crate::vector_ui::pen_commit(app, false); + // Photoshop. A press on it edits it in place and the Pen carries on (#4022). A + // refused commit keeps the draft and the press does nothing more. + if !crate::vector_ui::pen_commit(app, false) { + app.ui.direct_selection.cancelled_drag = true; + return true; + } } if tool == Tool::FreeformPen { // The same for a Freeform Pen path waiting for its next ⌥-click. crate::lasso_ui::commit(app); + if app.ui.pen.is_some() { + app.ui.direct_selection.cancelled_drag = true; + return true; + } } press(app, [x, y], mods, convert) } @@ -283,29 +433,21 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// Does a press at `p` hit the Pen path being drawn: one of its anchors, shown handles or segments? fn hits_pen(app: &PhotocraftApp, p: [f64; 2]) -> bool { - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let ds = &app.ui.direct_selection; - candidates(app).into_iter().filter(|(r, _)| *r == PathRef::Pen).any(|(r, path)| { - let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() }; - edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).is_some() - }) + hit_target(app, p, true).is_some_and(|(r, ..)| r == PathRef::Pen) } /// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press /// that hit nothing; the Convert Point tool has nothing else to do, so it keeps the press. fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool { let own_tool = app.ui.tool == Tool::ConvertPoint; - let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let ds = &app.ui.direct_selection; - let found = candidates(app).into_iter().find_map(|(r, path)| { - let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() }; - edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).map(|h| (r, path, h)) - }); - let Some((target, base, hit)) = found else { + let Some((target, base, hit)) = hit_target(app, p, true) else { if convert { return own_tool; } - app.ui.direct_selection.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift }); + let owner = Owner::current(app); + let ds = &mut app.ui.direct_selection; + ds.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift }); + ds.owner = owner; return true; }; if let PathRef::Layer(id) | PathRef::TypePath(id) = target @@ -313,10 +455,12 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) - { let _ = app.run("layer.select", json!({"layer": id})); } + let owner = Owner::current(app); let ds = &mut app.ui.direct_selection; - if ds.target != Some(target) { + if ds.target.as_ref() != Some(&target) { *ds = DirectSelection { target: Some(target), ..Default::default() }; } + ds.owner = owner; let edit = match hit { Hit::Handle(k, h) => { let kn = base.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])); @@ -393,7 +537,7 @@ pub fn curvature_hover(app: &PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Opt pub(crate) fn curvature_toggle_active(app: &mut PhotocraftApp, p: [f64; 2]) { let Some((target, _, Hit::Anchor([s, k]))) = curvature_target(app, p) else { return }; let mut params = json!({"subpath": s, "knot": k, "curvature": true}); - if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params().as_object()) { + if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params(app).as_object()) { o.extend(tp.clone()); } if let Err(e) = app.run("path.convertPoint", params) { @@ -416,7 +560,7 @@ fn curvature_press(app: &mut PhotocraftApp, p: [f64; 2]) -> bool { Hit::Segment([s, k], t) => { let key = format!("curvature-add-{}", app.ui.alloc_id()); let mut params = json!({"subpath": s, "knot": k, "t": t.clamp(0.01, 0.99), "coalesce": key}); - if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params().as_object()) { + if let (Some(o), Some(tp)) = (params.as_object_mut(), target.params(app).as_object()) { o.extend(tp.clone()); } let added = match app.run("path.addAnchor", params) { @@ -437,7 +581,7 @@ fn curvature_press(app: &mut PhotocraftApp, p: [f64; 2]) -> bool { } Hit::Handle(..) => return false, }; - app.ui.direct_selection = DirectSelection { target: Some(target), anchors: vec![k], coalesce, ..Default::default() }; + app.ui.direct_selection = DirectSelection { target: Some(target), anchors: vec![k], coalesce, owner: Owner::current(app), ..Default::default() }; app.ui.direct_selection.drag = Some(Drag::Edit { target, base, edit: Edit::Curvature(vec![k]), start: p, end: p, preview: None }); true } @@ -465,15 +609,24 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s _ if !moved => return, e => e.command(start, end), }; - if let (Some(p), Some(t)) = (params.as_object_mut(), target.params().as_object()) { + if let (Some(p), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { p.extend(t.clone()); } if let (Some(p), Some(key)) = (params.as_object_mut(), app.ui.direct_selection.coalesce.take()) { p.insert("coalesce".into(), json!(key)); } - if let Err(e) = app.run(id, params) { - app.ui.status = e; - app.ui.status_error = true; + // sync_views checks ownership while the command runs: keep this selection off the live UI + // until its own command is done, then bind it to the revision that made. + let selection = std::mem::take(&mut app.ui.direct_selection); + match app.run(id, params) { + Ok(_) => keep_after_command(app, selection), + Err(e) => { + if selection.owner.as_ref().is_some_and(|owner| owner.valid(app)) { + app.ui.direct_selection = selection; + } + app.ui.status = e; + app.ui.status_error = true; + } } } @@ -482,9 +635,10 @@ fn release(app: &mut PhotocraftApp, target: PathRef, base: &Path, edit: &Edit, s fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) { let rect = [start[0], start[1], end[0], end[1]]; let hits = candidates(app).into_iter().map(|(r, path)| (r, edit::anchors_in(&path, rect))).find(|(_, a)| !a.is_empty()); + let owner = Owner::current(app); let ds = &mut app.ui.direct_selection; match hits { - Some((r, anchors)) if add && ds.target == Some(r) => { + Some((r, anchors)) if add && ds.target.as_ref() == Some(&r) => { for a in anchors { if !ds.anchors.contains(&a) { ds.anchors.push(a); @@ -499,17 +653,26 @@ fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) { ds.segment = None; } } + ds.owner = owner; } /// Arrow keys with Direct Selection and selected anchors (#4486), as in Photoshop: nudge them /// 1 px (⇧ 10 px) through `path.moveAnchors`, one history step per press. Not while a drag is /// in progress. Returns true when a key was used. pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + cancel_stale(app); let ds = &app.ui.direct_selection; let Some(target) = ds.target.filter(|_| app.ui.tool == Tool::DirectSelection && !ds.anchors.is_empty() && ds.drag.is_none()) else { return false; }; - let anchors = ds.anchors.clone(); + // The anchors belong to a path the canvas no longer offers (another saved path picked in the + // Paths panel, the path deleted): drop them rather than nudge that path, or another one that + // took its index. + if !candidates(app).iter().any(|(r, _)| *r == target) { + app.ui.direct_selection = DirectSelection::default(); + return false; + } + let anchors = app.ui.direct_selection.anchors.clone(); use egui::Key; let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)]; for (key, ux, uy) in keys { @@ -518,12 +681,18 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if ctx.input_mut(|i| i.consume_key(mods, key)) { let k = if mods.shift { 10.0 } else { 1.0 }; let mut params = json!({"anchors": anchors, "move": [ux * k, uy * k]}); - if let (Some(p), Some(t)) = (params.as_object_mut(), target.params().as_object()) { + if let (Some(p), Some(t)) = (params.as_object_mut(), target.params(app).as_object()) { p.extend(t.clone()); } - if let Err(e) = app.run("path.moveAnchors", params) { - app.ui.status = e; - app.ui.status_error = true; + // The nudge makes a new revision: the selection follows it, as after a drag. + let selection = std::mem::take(&mut app.ui.direct_selection); + match app.run("path.moveAnchors", params) { + Ok(_) => keep_after_command(app, selection), + Err(e) => { + app.ui.direct_selection = selection; + app.ui.status = e; + app.ui.status_error = true; + } } return true; } @@ -551,7 +720,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F _ => path, }; crate::vector_ui::draw_outline(painter, &path, to_scr, accent); - let ours = ds.target == Some(r); + let ours = ds.target.as_ref() == Some(&r); for &(k, h) in if ours { shown_handles(ds, &path) } else { Vec::new() }.iter() { if let Some(kn) = path.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])) { let c = if h == Handle::In { kn.in_ctrl } else { kn.out_ctrl }; @@ -572,3 +741,70 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F #[cfg(test)] mod tests; + +#[cfg(test)] +mod freeform_command_tests { + use super::*; + + /// Is a Direct Selection drag under way on the document and target it started on? + fn captured_drag(app: &PhotocraftApp) -> bool { + app.ui.direct_selection.drag.is_some() && app.ui.direct_selection.owner.as_ref().is_some_and(|owner| owner.valid(app)) + } + + fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + for name in ["work", "Saved drawing"] { + app.run("path.set", json!({"name": name, "path":{"subpaths":[{"knots":[[20,20],[120,20]]}]}})).unwrap(); + } + app.sync_views(); + app.ui.tool = tool; + app.ui.selected_path = Some("Saved drawing".into()); + app + } + + #[test] + fn command_with_freeform_and_curvature_edits_the_selected_saved_path_after_modifier_release() { + for tool in [Tool::FreeformPen, Tool::CurvaturePen] { + let mut app = app(tool); + let original = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + let command = Modifiers { command: true, ctrl: true, ..Default::default() }; + assert!(pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, command)); + assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0))); + assert!(captured_drag(&app)); + assert!(pointer(&mut app, ToolEvent::Move { x: 30.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE)); + assert!(pointer(&mut app, ToolEvent::Up { x: 30.0, y: 40.0 }, Modifiers::NONE)); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.work_path, original.work_path); + assert_eq!(st.doc.paths[0].path.subpaths[0].knots[0].anchor, Point::new(30.0, 40.0)); + assert_eq!(st.history.past_len(), n + 1); + assert!(app.ui.direct_selection.owner.as_ref().unwrap().valid(&app)); + assert!(!captured_drag(&app)); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.paths, original.paths); + } + } + + #[test] + fn command_cannot_edit_an_existing_path_when_the_freeform_trace_failed_to_finish() { + let mut app = app(Tool::FreeformPen); + let original = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] { + assert!(crate::lasso_ui::pointer(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::ALT)); + assert!(crate::lasso_ui::pointer(&mut app, ToolEvent::Up { x, y }, Modifiers::ALT)); + } + let command = Modifiers { command: true, ctrl: true, ..Default::default() }; + assert!(pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, command)); + let pen = app.ui.pen.as_ref().expect("failed Freeform commit must preserve its fitted draft"); + assert!(pen.commit_failed && pen.owner.is_some()); + assert!(app.ui.direct_selection.drag.is_none()); + assert!(app.ui.direct_selection.cancelled_drag); + assert!(pointer(&mut app, ToolEvent::Up { x: 30.0, y: 40.0 }, command)); + assert!(app.ui.direct_selection.drag.is_none()); + assert_eq!(app.session.active().unwrap().doc.paths, original.paths); + assert_eq!(app.session.active().unwrap().doc.work_path, original.work_path); + assert_eq!(app.session.active().unwrap().history.past_len(), n); + } +} diff --git a/crates/ui-egui/src/tools/direct_select/tests.rs b/crates/ui-egui/src/tools/direct_select/tests.rs index 27a9fc59d8..c7c5dfec77 100644 --- a/crates/ui-egui/src/tools/direct_select/tests.rs +++ b/crates/ui-egui/src/tools/direct_select/tests.rs @@ -569,3 +569,251 @@ fn a_targeted_vector_mask_on_type_on_a_path_is_edited_not_the_type_path() { assert!((type_path(&app).subpaths[0].knots[1].anchor.x - 160.0).abs() < 1e-6); assert_eq!(layer(&app).vector_mask.unwrap().path.subpaths[0].knots[0].anchor, Point::new(40.0, 30.0)); } + +fn save_path(app: &mut PhotocraftApp) { + let path = photocraft_engine::vector_cmds::path_json(&work(app)); + app.run("path.set", json!({"name": "Saved", "path": path})).unwrap(); +} + +fn saved(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.paths.iter().find(|path| path.name == "Saved").unwrap().path.clone() +} + +/// The arrow keys nudge the anchors of the selected saved path, and the selection follows each +/// nudge's new revision; the work path stays. +#[test] +fn arrow_keys_nudge_the_selected_saved_path() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + save_path(&mut app); + app.ui.selected_path = Some("Saved".into()); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0))); + let (work_before, n) = (work(&app), steps(&app)); + assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE)); + assert!(press(&mut app, egui::Key::ArrowRight, Modifiers::NONE)); + assert_eq!(saved(&app).subpaths[0].knots[3].anchor, Point::new(42.0, 120.0)); + assert_eq!(work(&app), work_before); + assert_eq!(steps(&app), n + 2); +} + +#[test] +fn selected_saved_and_work_paths_win_overlapping_shape_hits() { + for name in ["Saved", "work"] { + let mut app = app(Tool::DirectSelection); + app.ui.extras.snap = false; // This case isolates hit priority and exact anchor movement. + let id = app.run("shape.create", json!({"kind":"rect", "rect":[40,40,80,80]})).unwrap()["layer"].as_u64().unwrap(); + with_path(&mut app); + save_path(&mut app); + app.ui.selected_path = Some(name.into()); + let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app)); + let before_shape = app.run("shape.info", json!({"layer":id})).unwrap()["path"].clone(); + drag(&mut app, [40.0, 120.0], [55.0, 130.0], Modifiers::NONE); + let (actual, unchanged, target) = if name == "Saved" { (saved(&app), work(&app), PathRef::Saved(0)) } else { (work(&app), saved(&app), PathRef::Work) }; + assert_eq!(actual.subpaths[0].knots[3].anchor, Point::new(55.0, 130.0)); + assert_eq!(unchanged, if name == "Saved" { before_work.clone() } else { before_saved.clone() }); + assert_eq!(app.ui.direct_selection.target.as_ref(), Some(&target)); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.run("shape.info", json!({"layer":id})).unwrap()["path"], before_shape); + assert!(app.session.undo()); + assert_eq!(work(&app), before_work); + assert_eq!(saved(&app), before_saved); + app.sync_views(); + assert!(app.ui.direct_selection.anchors.is_empty(), "undo invalidates runtime anchor ownership"); + } +} + +fn start_command_drag(app: &mut PhotocraftApp) { + assert!(super::pointer(app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::COMMAND)); + assert!(matches!(app.ui.direct_selection.drag, Some(Drag::Edit { .. }))); +} + +fn consume_stale_drag(app: &mut PhotocraftApp) { + assert!(super::pointer(app, ToolEvent::Move { x: 60.0, y: 140.0, pressure: 1.0 }, Modifiers::NONE)); + assert!(super::pointer(app, ToolEvent::Up { x: 60.0, y: 140.0 }, Modifiers::NONE)); + assert!(app.ui.direct_selection.drag.is_none()); + assert!(app.ui.direct_selection.anchors.is_empty()); + assert!(!app.ui.direct_selection.cancelled_drag); +} + +#[test] +fn direct_selection_cannot_apply_to_another_document_or_a_reopened_identity() { + for reopen in [false, true] { + let mut app = app(Tool::Pen); + with_path(&mut app); + let source = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + start_command_drag(&mut app); + if reopen { + app.session.close(0).unwrap(); + } + app.session.add_document((*source).clone(), None); + app.session.active_mut().unwrap().revision = revision; + let (before, n) = (work(&app), steps(&app)); + if !reopen { + assert_ne!(source.id, app.session.active().unwrap().doc.id); + } else { + assert_eq!(source.id, app.session.active().unwrap().doc.id, "a reopened document may preserve its ID"); + } + // The frame hook may cancel before the old pointer event arrives. + app.sync_views(); + consume_stale_drag(&mut app); + assert_eq!(work(&app), before); + assert_eq!(steps(&app), n); + assert!(app.ui.pen.is_none(), "an old release must not start a new Pen draft"); + if !reopen { + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), source.work_path.as_ref()); + } + } +} + +#[test] +fn external_revision_cancels_preview_and_preserves_the_new_path() { + let mut app = app(Tool::Pen); + with_path(&mut app); + start_command_drag(&mut app); + assert!(super::pointer(&mut app, ToolEvent::Move { x: 50.0, y: 130.0, pressure: 1.0 }, Modifiers::COMMAND)); + app.session.execute("path.moveAnchors", json!({"name":"work", "anchors":[[0,0]], "move":[-5,7]})).unwrap(); + let (before, n) = (work(&app), steps(&app)); + consume_stale_drag(&mut app); + assert_eq!(work(&app), before); + assert_eq!(steps(&app), n); + assert!(app.ui.pen.is_none()); +} + +#[test] +fn layer_path_and_tool_changes_cancel_the_owned_edit() { + for change in ["layer", "path", "vectorMask", "tool"] { + let mut app = app(Tool::Pen); + with_path(&mut app); + save_path(&mut app); + let first = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let second = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer":first.0})).unwrap(); + start_command_drag(&mut app); + match change { + "layer" => { + app.session.execute("layer.select", json!({"layer":second.0})).unwrap(); + } + "path" => app.ui.selected_path = Some("Saved".into()), + "vectorMask" => app.ui.vector_mask_target = true, + "tool" => app.ui.tool = Tool::Brush, + _ => panic!("unknown target-change fixture: {change}"), + } + let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app)); + consume_stale_drag(&mut app); + assert_eq!(work(&app), before_work, "{change}"); + assert_eq!(saved(&app), before_saved, "{change}"); + assert_eq!(steps(&app), n, "{change}"); + assert!(app.ui.pen.is_none()); + } +} + +#[test] +fn closing_a_document_and_changing_marquee_target_discard_old_events() { + let mut app = app(Tool::Pen); + with_path(&mut app); + start_command_drag(&mut app); + let closed = app.session.close(0).unwrap(); + app.sync_views(); + consume_stale_drag(&mut app); + assert_eq!(closed.doc.work_path.as_ref().unwrap().subpaths[0].knots[3].anchor, Point::new(40.0, 120.0)); + + app.session.add_document((*closed.doc).clone(), None); + app.sync_views(); + save_path(&mut app); + app.ui.tool = Tool::DirectSelection; + assert!(super::pointer(&mut app, ToolEvent::Down { x: 30.0, y: 110.0, pressure: 1.0 }, Modifiers::NONE)); + assert!(matches!(app.ui.direct_selection.drag, Some(Drag::Marquee { .. }))); + app.ui.selected_path = Some("Saved".into()); + let n = steps(&app); + consume_stale_drag(&mut app); + assert_eq!(steps(&app), n); +} + +#[test] +fn serialized_anchor_selection_has_no_runtime_ownership_and_is_not_restored() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + let serialized = serde_json::to_value(&app.ui.direct_selection).unwrap(); + assert!(serialized.get("owner").is_none()); + assert!(serialized.get("drag").is_none()); + assert!(serialized.get("cancelled_drag").is_none()); + app.ui.direct_selection = serde_json::from_value(serialized).unwrap(); + assert!(app.ui.direct_selection.owner.is_none()); + super::cancel_stale(&mut app); + assert!(app.ui.direct_selection.anchors.is_empty()); + assert!(app.ui.direct_selection.target.is_none()); +} + +#[test] +fn command_drag_after_failed_pen_commit_preserves_draft_and_existing_work_path() { + let mut app = app(Tool::Pen); + with_path(&mut app); + app.ui.extras.snap = false; + for [x, y] in [[1e40, 0.0], [40.0, 120.0]] { + tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x, y }, Modifiers::NONE); + } + let before_draft = app.ui.pen.as_ref().unwrap().knots.clone(); + let (before_work, n, revision) = (work(&app), steps(&app), app.session.active().unwrap().revision); + // ⌘ off the draft ends it (a press on it would edit it in place, #4022); here that fails. + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, Modifiers::COMMAND); + assert!(app.ui.pen.as_ref().unwrap().commit_failed); + assert!(app.ui.direct_selection.drag.is_none(), "the press must not go on to edit the old work path"); + assert!(app.ui.direct_selection.cancelled_drag); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 140.0, pressure: 1.0 }, Modifiers::COMMAND); + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 140.0 }, Modifiers::COMMAND); + assert_eq!(app.ui.pen.as_ref().unwrap().knots, before_draft); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(app.ui.status_error); + assert!(!app.ui.direct_selection.cancelled_drag); +} + +#[test] +fn switching_between_selection_and_convert_tools_cancels_an_in_flight_drag() { + for (from, to) in [(Tool::DirectSelection, Tool::ConvertPoint), (Tool::ConvertPoint, Tool::DirectSelection)] { + let mut app = app(from); + with_path(&mut app); + let (before, n) = (work(&app), steps(&app)); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.ui.direct_selection.drag.is_some()); + app.ui.tool = to; + app.sync_views(); + consume_stale_drag(&mut app); + assert_eq!((work(&app), steps(&app)), (before, n)); + } +} + +#[test] +fn the_convert_point_tool_edits_selected_saved_paths_without_changing_the_work_path() { + let mut app = app(Tool::ConvertPoint); + with_path(&mut app); + save_path(&mut app); + app.ui.selected_path = Some("Saved".into()); + let (before_work, before_saved, n) = (work(&app), saved(&app), steps(&app)); + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(saved(&app).subpaths[0].knots[1], photocraft_doc::Knot::corner(120.0, 40.0)); + assert_eq!(work(&app), before_work); + assert_eq!(steps(&app), n + 1); + assert_eq!(app.ui.direct_selection.target, Some(PathRef::Saved(0))); + assert!(app.session.undo()); + assert_eq!(saved(&app), before_saved); +} + +#[test] +fn changing_the_channel_vector_mask_target_cancels_an_owned_edit() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 120.0, pressure: 1.0 }, Modifiers::NONE); + assert!(app.ui.direct_selection.drag.is_some()); + let (before, n) = (work(&app), steps(&app)); + app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::VectorMask; + assert!(!app.ui.vector_mask_target, "the channel target can change before the UI syncs its mask flag"); + consume_stale_drag(&mut app); + assert_eq!((work(&app), steps(&app)), (before, n)); +} diff --git a/crates/ui-egui/src/tools/eyedropper_ui.rs b/crates/ui-egui/src/tools/eyedropper_ui.rs index 1a225592af..10fcaaa603 100644 --- a/crates/ui-egui/src/tools/eyedropper_ui.rs +++ b/crates/ui-egui/src/tools/eyedropper_ui.rs @@ -94,6 +94,32 @@ mod tests { tool_event(app, ToolEvent::Up { x, y }, mods); } + #[test] + fn mask_sampling_sets_gray_swatches_without_changing_pixel_sampling() { + for depth in [8, 16, 32] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 20, "depth": depth, "background": "transparent"})).unwrap(); + app.session + .edit("red", |doc, _| { + let surface = doc.layers[0].surface_mut().unwrap(); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 20, 20), &photocraft_raster::from_rgba(&surface.format(), [1.0, 0.0, 0.0, 1.0])); + doc.layers[0].mask = Some(photocraft_doc::LayerMask::reveal_all()); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Eyedropper; + app.ui.mask_target = true; + click(&mut app, 5.0, 5.0, egui::Modifiers::NONE); + let gray = photocraft_color::convert::rgb_to_gray([1.0, 0.0, 0.0]); + assert_eq!(fg(&app), [gray, gray, gray, 1.0], "depth {depth}"); + click(&mut app, 5.0, 5.0, egui::Modifiers::ALT); + assert_eq!(app.session.tools.background, [gray, gray, gray, 1.0]); + app.ui.mask_target = false; + click(&mut app, 5.0, 5.0, egui::Modifiers::NONE); + assert_eq!(fg(&app), [1.0, 0.0, 0.0, 1.0]); + } + } + #[test] fn a_click_samples_the_average_of_the_sample_size() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index 4ea5da8a72..1978f27086 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -77,6 +77,11 @@ pub struct LiveGradient { preview: Option<(u64, Arc)>, } +/// Whether an uncommitted gradient gesture must finish before applying a saved vector. +pub(crate) fn editing(app: &PhotocraftApp) -> bool { + app.gradient.drag.is_some() || app.gradient.panel.is_some() || app.gradient.preset_stop.is_some() +} + /// Whether the Gradient tool is in its live (Gradient Fill layer) mode. /// Painting into a layer mask, an alpha channel or the Quick Mask stays classic (pixels), except /// that a selected gradient fill layer is always edited live (selecting it targets its mask). @@ -845,7 +850,9 @@ fn stops_editor_for(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: F }, None => {} } - properties_panel::stop_fields(app, ui, sink); + // A preset invalidates an in-progress numeric draft from the old stop list. + let generation = ui.data(|d| d.get_temp::(selection_key.with("generation"))).unwrap_or_default(); + ui.push_id((selection_key, generation), |ui| properties_panel::stop_fields(app, ui, sink)); } /// A shared stop strip keeps previews local; its caller chooses the command or paint target. diff --git a/crates/ui-egui/src/tools/lasso_ui.rs b/crates/ui-egui/src/tools/lasso_ui.rs index 72f486378e..38dfaecdb1 100644 --- a/crates/ui-egui/src/tools/lasso_ui.rs +++ b/crates/ui-egui/src/tools/lasso_ui.rs @@ -155,7 +155,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool if !p.iter().all(|n| n.is_finite()) { return true; } + if tool == Tool::FreeformPen && matches!(ev, ToolEvent::Down { .. }) && !active(app) && crate::vector_ui::freeform_locked_shape(app) { + app.ui.status = tl!("Locked").into(); + app.ui.status_error = true; + return true; + } modifiers(app, mods); + // A fitted path whose commit failed remains the user's draft. Starting another trace + // must neither retry it automatically nor replace it on the next release. Releasing Alt + // in this press may itself have finished the previous trace into that failed draft. + if tool == Tool::FreeformPen && matches!(ev, ToolEvent::Down { .. }) && !active(app) && app.ui.pen.is_some() { + return true; + } match ev { ToolEvent::Down { .. } => { if active(app) { @@ -200,6 +211,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let polygonal = mods.alt && nothing_selected; let intent = if polygonal { Modifiers { alt: false, ..mods } } else { mods }; let mut d = Drag::new(tool, p, vec![[p[0], p[1], 1.0]], intent, false); + if tool == Tool::FreeformPen { + d.capture_vector_owners(app); + } let document = app.session.active().map(|st| st.doc.id); d.lasso = Some(Lasso { cursor: p, down: true, polygonal, document, straight: Vec::new() }); app.drag = Some(d); diff --git a/crates/ui-egui/src/tools/lasso_ui/tests.rs b/crates/ui-egui/src/tools/lasso_ui/tests.rs index 71a5b68777..af1d45a2e0 100644 --- a/crates/ui-egui/src/tools/lasso_ui/tests.rs +++ b/crates/ui-egui/src/tools/lasso_ui/tests.rs @@ -170,6 +170,142 @@ fn mouse(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, kind: &str, x: f h.run_steps(2); } +fn freeform_harness() -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut h = harness(); + let app = h.state_mut(); + for name in ["work", "Saved drawing"] { + app.run("path.set", json!({"name": name, "path":{"subpaths":[{"knots":[[20,20],[220,20]]}]}})).unwrap(); + } + app.ui.tool = Tool::FreeformPen; + app.ui.tool_options.vector_mode = "path".into(); + app.ui.selected_path = Some("Saved drawing".into()); + app.ui.extras.snap = false; + h.run_steps(2); + h +} + +fn freeform_run_and_straight_click(h: &mut egui_kittest::Harness<'static, PhotocraftApp>) { + mouse(h, "down", 50.0, 50.0, Modifiers::NONE); + mouse(h, "move", 90.0, 40.0, Modifiers::NONE); + mouse(h, "move", 120.0, 50.0, Modifiers::NONE); + mouse(h, "up", 120.0, 50.0, Modifiers::ALT); + assert!(active(h.state()), "Alt-held release keeps the fitted path in progress"); + mouse(h, "down", 180.0, 100.0, Modifiers::ALT); + mouse(h, "up", 180.0, 100.0, Modifiers::ALT); + assert!(active(h.state())); +} + +#[test] +fn real_freeform_freehand_and_alt_straight_segments_append_to_the_owned_saved_path_once() { + let mut h = freeform_harness(); + let original = h.state().session.active().unwrap().doc.clone(); + let n = h.state().session.active().unwrap().history.past_len(); + freeform_run_and_straight_click(&mut h); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + let app = h.state(); + assert!(!active(app) && app.ui.pen.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.work_path, original.work_path); + let path = &st.doc.paths[0].path; + assert_eq!(path.subpaths.len(), 2); + assert_eq!(path.subpaths[0], original.paths[0].path.subpaths[0]); + let knots = &path.subpaths[1].knots; + assert_eq!(knots.last().unwrap().anchor, photocraft_geom::Point::new(180.0, 100.0)); + assert_eq!(knots.last().unwrap().in_ctrl, knots.last().unwrap().anchor); + assert_eq!(knots[knots.len() - 2].out_ctrl, knots[knots.len() - 2].anchor); + assert_eq!(st.history.past_len(), n + 1); + assert!(h.state_mut().session.undo()); + assert_eq!(h.state().session.active().unwrap().doc.paths, original.paths); +} + +#[test] +fn real_freeform_stale_target_source_revision_and_document_cancel_without_finishing_elsewhere() { + for change in ["target", "source", "revision", "document"] { + let mut h = freeform_harness(); + freeform_run_and_straight_click(&mut h); + let app = h.state_mut(); + match change { + "target" => app.ui.selected_path = Some("work".into()), + "source" => { + let st = app.session.active_mut().unwrap(); + st.doc = std::sync::Arc::new((*st.doc).clone()); + } + "revision" => app.session.active_mut().unwrap().revision += 1, + "document" => { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + } + _ => panic!("unexpected stale-gesture test case: {change}"), + } + let source = h.state().session.active().unwrap().doc.clone(); + let n = h.state().session.active().unwrap().history.past_len(); + mouse(&mut h, "move", 200.0, 120.0, Modifiers::ALT); + mouse(&mut h, "up", 200.0, 120.0, Modifiers::NONE); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let app = h.state(); + assert!(!active(app) && app.ui.pen.is_none(), "{change}: no stale trace remains"); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc), "{change}: late input changed document pixels or paths"); + assert_eq!(app.session.active().unwrap().history.past_len(), n, "{change}"); + } +} + +#[test] +fn another_freeform_trace_cannot_replace_a_failed_owned_draft_or_retry_it_automatically() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + app.ui.extras.snap = false; + for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + } + modifiers(&mut app, Modifiers::NONE); + let failed = app.ui.pen.clone().expect("invalid fitted trace must remain available"); + assert!(failed.commit_failed && failed.owner.is_some()); + assert!(!active(&app)); + let source = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + let journal = app.session.journal.len(); + for middle in [[1e40, 90.0], [220.0, 90.0]] { + for [x, y] in [[160.0, 120.0], middle, [260.0, 140.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "move", x + 10.0, y + 10.0, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + assert!(!active(&app), "pending fitted draft owns the next drawing attempt"); + } + modifiers(&mut app, Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref(), Some(&failed)); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); + assert_eq!(app.session.journal.len(), journal, "drawing must not retry the failed engine command"); + } +} + +#[test] +fn freeform_alt_release_in_the_next_press_keeps_the_failed_fit() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + for [x, y] in [[40.0, 60.0], [1e40, 60.0], [140.0, 80.0]] { + event(&mut app, "down", x, y, Modifiers::ALT); + event(&mut app, "up", x, y, Modifiers::ALT); + } + assert!(active(&app)); + let source = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + // No separate modifier-change event: this press both releases Alt and tries a new trace. + event(&mut app, "down", 200.0, 100.0, Modifiers::NONE); + let failed = app.ui.pen.clone().expect("failed fit must survive the press that finished it"); + assert!(failed.commit_failed && failed.owner.is_some()); + assert!(!active(&app)); + let journal = app.session.journal.len(); + event(&mut app, "move", 240.0, 120.0, Modifiers::NONE); + event(&mut app, "up", 240.0, 120.0, Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref(), Some(&failed)); + assert_eq!(app.session.journal.len(), journal); + assert!(std::sync::Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); +} + #[test] fn real_canvas_alt_clicks_enter_escape_and_resume() { for finish in ["enter", "escape", "resume", "close"] { diff --git a/crates/ui-egui/src/tools/move_box.rs b/crates/ui-egui/src/tools/move_box.rs index f49a5061be..05f12222b4 100644 --- a/crates/ui-egui/src/tools/move_box.rs +++ b/crates/ui-egui/src/tools/move_box.rs @@ -154,6 +154,31 @@ mod tests { assert!(app.layer_box.is_none()); } + /// #10665: a fill layer, or pixels hanging past the canvas edge, aren't drawn on the + /// pasteboard, so a click there still clears the layer selection rather than picking them. + #[test] + fn pasteboard_click_deselects_over_fill_layers_and_off_canvas_pixels() { + 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.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let off = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("paint past the edge", |doc, _| { + doc.layer_mut(off).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(-20, 0, 10, 64), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + tool_event(&mut app, ToolEvent::Down { x: -10.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: -10.0, y: 30.0 }, egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert!(st.selected_layers().is_empty() && st.active_layer.is_none(), "selected {:?}", st.selected_layers()); + assert_eq!(st.doc.layer(off).unwrap().surface().unwrap().content_bounds().x0, -20, "nothing moved"); + } + /// #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/tools/move_hover.rs b/crates/ui-egui/src/tools/move_hover.rs index ff9d425f84..aef1f315ef 100644 --- a/crates/ui-egui/src/tools/move_hover.rs +++ b/crates/ui-egui/src/tools/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, over_ruler: false }); + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0, over_ruler: false, flipped: false }); assert_eq!(active_outline(&mut app, None, false, false), None); app.guide_drag = None; diff --git a/crates/ui-egui/src/tools/move_mods.rs b/crates/ui-egui/src/tools/move_mods.rs index 84bf2c085f..4c96702694 100644 --- a/crates/ui-egui/src/tools/move_mods.rs +++ b/crates/ui-egui/src/tools/move_mods.rs @@ -84,6 +84,11 @@ fn moving(app: &PhotocraftApp) -> bool { app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move) } +/// The pointer reflects the pending copy, then the latched duplication for the whole drag. +pub fn copying(app: &PhotocraftApp, alt: bool) -> bool { + if moving(app) && app.move_mods.start.is_some() { app.move_mods.alt || app.move_mods.dup_from.is_some() } else { alt } +} + /// Duplicates the selected layers (the copies become the selection), remembering the history /// length before so [`fold_history`] can merge the copy and the move into one step. fn duplicate(app: &mut PhotocraftApp) -> Option { @@ -198,6 +203,9 @@ pub fn abandon(app: &mut PhotocraftApp) { /// where ⌘ is the temporary Move tool (`hold_keys::cmd_nudges`, #2474). Returns true when a key /// was used. pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + if app.ui.text_edit.is_some() { + return false; + } use egui::{Key, Modifiers}; let cmd = ctx.input(|i| i.modifiers.command) && crate::hold_keys::cmd_nudges(app); if app.ui.tool != Tool::Move && app.ui.transform.is_none() && !cmd { diff --git a/crates/ui-egui/src/tools/move_mods/tests.rs b/crates/ui-egui/src/tools/move_mods/tests.rs index bb8b39f0d8..e42e24875e 100644 --- a/crates/ui-egui/src/tools/move_mods/tests.rs +++ b/crates/ui-egui/src/tools/move_mods/tests.rs @@ -499,3 +499,27 @@ fn free_transform_nudges_bundle_into_one_undo_step() { app.run("edit.undo", json!({})).unwrap(); assert_eq!(quad(&app), moved(2.0, 0.0)); } + +/// Arrow keys while editing text must not nudge layers, leaving the keys to the type tool (#5355). +#[test] +fn arrow_keys_ignored_during_text_edit() { + let mut app = app_with_layer(); + let id = app.session.active().unwrap().active_layer.unwrap(); + let b0 = bounds(&app, id); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + document: None, + caret: 0, + anchor: 0, + session: "test".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert!(!press(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT)); + assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT)); + assert_eq!(bounds(&app, id), b0, "arrow keys do not nudge while editing text"); +} diff --git a/crates/ui-egui/src/tools/paint_picker.rs b/crates/ui-egui/src/tools/paint_picker.rs new file mode 100644 index 0000000000..ac848f8e8b --- /dev/null +++ b/crates/ui-egui/src/tools/paint_picker.rs @@ -0,0 +1,383 @@ +//! Photoshop's Fill and Stroke popover for shapes (the options bar's Fill/Stroke swatches and the +//! Properties panel's Appearance, [`crate::shape_paint_ui::swatch`]): the paint type along the top +//! (No Color, Solid Color, Gradient, Pattern) with the Color Picker button at the right, then the +//! Recently Used Colors row and the swatch library in folders for Solid Color, or the Gradient tab +//! ([`crate::shape_gradient_ui`]) for Gradient. +//! +//! The Color Picker button opens the full Color Picker dialog (HSB, RGB, Lab, CMYK, hex and the +//! eyedropper); the popover only reports it ([`PaintChange::OpenPicker`]), since opening a dialog +//! needs the app. Recently Used Colors live in `Preferences::recent_colors`, so they survive a +//! restart. + +use std::sync::Arc; + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; + +use crate::PhotocraftApp; +use crate::shape_gradient_ui::{self as shape_gradient, GradientChoices}; +use crate::shape_paint_ui::PaintChange; +use crate::theme::Tokens; + +/// How many Recently Used Colors are kept (one row of the popover). +pub(crate) const MAX_RECENT: usize = 15; +/// Side of a swatch cell, as Photoshop's small swatches. +const CELL: f32 = 16.0; +/// Room between swatch cells. +const GAP: f32 = 2.0; +/// The popover's content width: fifteen cells and their gaps. +const WIDTH: f32 = MAX_RECENT as f32 * (CELL + GAP) - GAP; +/// Height of the swatch folders' scrolling list. +const LIST_HEIGHT: f32 = 200.0; +/// Height of a type button along the top. +const TYPE_BUTTON: f32 = 26.0; + +/// The popover's paint types, in Photoshop's order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + None, + Solid, + Gradient, + Pattern, +} + +/// A folder of the library: its name and (name, display colour, sRGB) per swatch. +pub(crate) struct SwatchGroup { + name: String, + items: Vec<(String, Color32, [f32; 3])>, +} + +/// What the Solid Color tab lists, cached per library revision, colour settings and recent +/// colours (see [`library`]). +pub(crate) struct Library { + recent: Vec<[f32; 3]>, + swatches: Vec, +} + +/// The Recently Used Colors, newest first (at most [`MAX_RECENT`]; unreadable entries skipped). +pub(crate) fn recent_colors(session: &photocraft_engine::Session) -> Vec<[f32; 3]> { + session.prefs().recent_colors.iter().filter_map(|h| crate::color_picker_ui::parse_hex(h)).take(MAX_RECENT).collect() +} + +/// Put `rgb` at the front of the Recently Used Colors (once), as Photoshop does for a colour that +/// was used. +pub(crate) fn remember(app: &mut PhotocraftApp, rgb: [f32; 3]) { + if rgb.iter().any(|c| !c.is_finite()) { + return; + } + let hex = crate::color_picker_ui::hex(rgb); + let mut next: Vec = vec![hex.clone()]; + next.extend(recent_colors(&app.session).into_iter().map(crate::color_picker_ui::hex).filter(|h| *h != hex)); + next.truncate(MAX_RECENT); + if app.session.prefs().recent_colors != next { + app.session.prefs.edit(|p| p.recent_colors = next); + } +} + +fn c32(c: [f32; 3]) -> Color32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(c[0]), q(c[1]), q(c[2])) +} + +/// The popover's lists. Rebuilt only when what they show changes, so drawing the swatches every +/// frame costs a lookup. +pub(crate) fn library(session: &photocraft_engine::Session, ctx: &egui::Context) -> Arc { + let c = &session.color.settings; + let recent = &session.prefs().recent_colors; + let key = egui::Id::unique(("paint-picker-library", session.presets.rev, &c.working_rgb, &c.working_cmyk, &c.working_gray, recent)); + if let Some(v) = ctx.data(|d| d.get_temp::>(key)) { + return v; + } + let conv = photocraft_engine::presets::swatches::SwatchConv::new(session); + let swatches = session + .presets + .swatches + .iter() + .map(|g| SwatchGroup { + name: g.name.clone(), + items: g + .items + .iter() + .map(|s| { + let rgb = conv.rgb(&s.color); + (s.name.clone(), c32(rgb), rgb) + }) + .collect(), + }) + .collect(); + let lib = Arc::new(Library { recent: recent_colors(session), swatches }); + ctx.data_mut(|d| { + // Only the newest table is kept. + let latest = egui::Id::unique("paint-picker-library-key"); + if let Some(old) = d.get_temp::(latest) { + d.remove::>(old); + } + d.insert_temp(latest, key); + d.insert_temp(key, lib.clone()); + }); + lib +} + +/// The paint type the popover opens on: the shape's current paint. +fn kind_of(fill: Option<&photocraft_doc::Fill>) -> Kind { + match fill { + None => Kind::None, + Some(photocraft_doc::Fill::Solid(_)) => Kind::Solid, + Some(photocraft_doc::Fill::Gradient { .. }) => Kind::Gradient, + Some(photocraft_doc::Fill::Pattern { .. }) => Kind::Pattern, + } +} + +/// One of the type buttons: its icon drawn by `draw`, an accent frame when `on`. +fn type_button(ui: &mut egui::Ui, on: bool, enabled: bool, label: &str, tip: &str, draw: impl FnOnce(&egui::Ui, Rect, &Tokens)) -> bool { + let t = Tokens::get(ui.ctx()); + let sense = if enabled { Sense::click() } else { Sense::hover() }; + let (r, resp) = ui.allocate_exact_size(vec2(TYPE_BUTTON + 6.0, TYPE_BUTTON), sense); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, on, label)); + // Photoshop frames the selected type; the chrome paints the selected and hover states. + crate::icons::button_chrome(ui, r, on, resp.hovered() && enabled); + draw(ui, Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &t); + if !enabled { + // Greyed out: the popover's background over the icon at half strength. + ui.painter().rect_filled(r.shrink(1.0), t.radius_sm, ui.visuals().window_fill.gamma_multiply(0.6)); + } + resp.on_hover_text(tip).clicked() +} + +/// A small white square crossed by a red diagonal, Photoshop's "No Color". +fn draw_no_color(ui: &egui::Ui, r: Rect, t: &Tokens) { + let p = ui.painter(); + p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + p.line_segment([r.left_bottom() + vec2(1.5, -1.5), r.right_top() + vec2(-1.5, 1.5)], Stroke::new(1.5, t.danger)); +} + +fn draw_gradient(ui: &egui::Ui, r: Rect, t: &Tokens) { + let p = ui.painter(); + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(r.left_top(), Color32::from_gray(30)); + mesh.colored_vertex(r.right_top(), Color32::from_gray(235)); + mesh.colored_vertex(r.right_bottom(), Color32::from_gray(235)); + mesh.colored_vertex(r.left_bottom(), Color32::from_gray(30)); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + p.add(mesh); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); +} + +fn draw_pattern(ui: &egui::Ui, r: Rect, t: &Tokens) { + let p = ui.painter(); + let s = r.width() / 4.0; + for j in 0..4 { + for i in 0..4 { + if (i + j) % 2 == 0 { + let cell = Rect::from_min_size(r.min + vec2(i as f32 * s, j as f32 * s), vec2(s, s)); + p.rect_filled(cell, 0.0, t.text); + } + } + } + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); +} + +/// The Color Picker button: a hue ramp fading to white, as Photoshop's rainbow square. +fn picker_button(ui: &mut egui::Ui) -> bool { + let t = Tokens::get(ui.ctx()); + let label = tl!("Color Picker"); + let (r, resp) = ui.allocate_exact_size(vec2(TYPE_BUTTON, TYPE_BUTTON), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); + let inner = r.shrink(4.0); + let mut mesh = egui::Mesh::default(); + const STEPS: usize = 6; + for i in 0..=STEPS { + let x = inner.left() + inner.width() * i as f32 / STEPS as f32; + let hue = photocraft_color::convert::hsb_to_rgb([360.0 * i as f32 / STEPS as f32, 1.0, 1.0]); + mesh.colored_vertex(pos2(x, inner.top()), c32(hue)); + mesh.colored_vertex(pos2(x, inner.bottom()), Color32::WHITE); + if i > 0 { + let k = (i as u32) * 2; + mesh.add_triangle(k - 2, k - 1, k); + mesh.add_triangle(k - 1, k, k + 1); + } + } + ui.painter().add(mesh); + let border = if resp.hovered() || resp.has_focus() { t.accent } else { t.text }; + ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.5, border), StrokeKind::Outside); + resp.on_hover_text(label).clicked() +} + +/// One swatch cell; true when clicked. +fn cell(ui: &mut egui::Ui, color: Color32, label: &str, selected: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); + ui.painter().rect_filled(r, 0.0, color); + if selected || resp.hovered() || resp.has_focus() { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, if selected { t.accent } else { t.text }), StrokeKind::Inside); + } + resp.on_hover_text(label).clicked() +} + +/// A folder row (disclosure triangle, folder icon, name); flips `open` when clicked. +fn folder_row(ui: &mut egui::Ui, name: &str, open: &mut bool) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, ui.is_enabled(), *open, name)); + if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + let chevron = Rect::from_min_size(r.min + vec2(4.0, 4.0), vec2(16.0, 16.0)); + crate::icons::paint(ui, chevron, if *open { "chevron-down" } else { "chevron-right" }, 12.0, t.text_dim); + let folder = Rect::from_min_size(r.min + vec2(22.0, 3.0), vec2(18.0, 18.0)); + crate::icons::paint(ui, folder, "folder", 16.0, t.text_dim); + ui.painter().text(pos2(r.left() + 46.0, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text); + if resp.clicked() { + *open = !*open; + } +} + +/// A wrapped grid of `n` cells drawn by `draw`, `WIDTH` wide. +fn grid(ui: &mut egui::Ui, n: usize, mut draw: impl FnMut(&mut egui::Ui, usize)) { + ui.scope(|ui| { + ui.set_max_width(WIDTH); + ui.spacing_mut().item_spacing = vec2(GAP, GAP); + ui.horizontal_wrapped(|ui| { + for i in 0..n { + draw(ui, i); + } + }); + }); +} + +/// The popover's body for a paint (`fill`; `None` is No Color) of swatch `id`. `original` is the +/// solid colour a pick keeps the alpha of; `gradients` feeds the Gradient tab. +pub(crate) fn body( + ui: &mut egui::Ui, + lib: &Library, + fill: Option<&photocraft_doc::Fill>, + original: [f32; 4], + gradients: &GradientChoices, + id: egui::Id, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + let current = kind_of(fill); + // The tab on show: the paint's own type when the popover opens, then the one clicked. + let tab_id = id.with("paint-picker-tab"); + let mut tab = ui.data(|d| d.get_temp::<(Kind, Kind)>(tab_id)).filter(|(was, _)| *was == current).map_or(current, |(_, tab)| tab); + let solid_now = match fill { + Some(photocraft_doc::Fill::Solid(c)) => Some(c.to_rgb()), + _ => None, + }; + ui.set_width(WIDTH); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if type_button(ui, tab == Kind::None, true, tl!("No Color"), tl!("No Color"), draw_no_color) { + tab = Kind::None; + if current != Kind::None { + out = Some(PaintChange::None); + } + } + if type_button(ui, tab == Kind::Solid, true, tl!("Solid Color"), tl!("Solid Color"), |ui, r, t| crate::icons::paint(ui, r, "grid-3x3", 16.0, t.text)) { + tab = Kind::Solid; + if current != Kind::Solid { + out = Some(PaintChange::Color(original)); + } + } + if type_button(ui, tab == Kind::Gradient, true, tl!("Gradient"), tl!("Gradient"), draw_gradient) { + tab = Kind::Gradient; + if current != Kind::Gradient { + out = Some(PaintChange::Gradient(gradients.default_fill())); + } + } + // Shapes don't render pattern paint yet: the type shows, as in Photoshop, but is off. + type_button(ui, tab == Kind::Pattern, false, tl!("Pattern"), tl!("Shapes can't use patterns yet"), draw_pattern); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if picker_button(ui) { + out = Some(PaintChange::OpenPicker); + } + }); + }); + ui.add_space(6.0); + match (tab, fill) { + (Kind::Gradient, Some(g @ photocraft_doc::Fill::Gradient { .. })) => { + if let Some(edited) = shape_gradient::gradient_tab(ui, g, gradients, id) { + out = Some(PaintChange::Gradient(edited)); + } + } + // With No Color the colours still show, as in Photoshop: a click makes the paint solid. + (Kind::Solid | Kind::None, _) => { + ui.label(egui::RichText::new(tl!("Recently Used Colors")).color(t.text_dim).size(12.0)); + ui.add_space(2.0); + // One row, kept the same height while it is still empty. + ui.allocate_ui(vec2(WIDTH, CELL), |ui| { + ui.set_min_height(CELL); + grid(ui, lib.recent.len(), |ui, i| { + let Some(&rgb) = lib.recent.get(i) else { return }; + let selected = solid_now.is_some_and(|c| crate::color_picker_ui::hex(c) == crate::color_picker_ui::hex(rgb)); + if cell(ui, c32(rgb), &crate::color_picker_ui::hex(rgb), selected) { + out = Some(PaintChange::Color([rgb[0], rgb[1], rgb[2], original[3]])); + } + }); + }); + ui.add_space(6.0); + folders(ui, &lib.swatches, |ui, g, i| { + let Some((name, color, rgb)) = g.items.get(i) else { return }; + let selected = solid_now.is_some_and(|c| crate::color_picker_ui::hex(c) == crate::color_picker_ui::hex(*rgb)); + if cell(ui, *color, name, selected) { + out = Some(PaintChange::Color([rgb[0], rgb[1], rgb[2], original[3]])); + } + }); + } + // The Gradient type was just clicked: the gradient shows once the paint holds it. + _ => {} + } + ui.data_mut(|d| d.insert_temp(tab_id, (current, tab))); + out +} + +/// The swatch library's folders in a scrolling list, each opening to a grid of its swatches. +fn folders(ui: &mut egui::Ui, groups: &[SwatchGroup], mut item: impl FnMut(&mut egui::Ui, &SwatchGroup, usize)) { + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.field).corner_radius(t.radius_sm).inner_margin(4.0).show(ui, |ui| { + // A fixed height, as Photoshop's list: opening a folder scrolls instead of growing the + // popover under the pointer. + egui::ScrollArea::vertical().max_height(LIST_HEIGHT).min_scrolled_height(LIST_HEIGHT).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(WIDTH - 8.0); + ui.spacing_mut().item_spacing.y = 2.0; + for (gi, g) in groups.iter().enumerate() { + let (name, n) = (&g.name, g.items.len()); + let open_id = ui.scope_id().with(("paint-picker-folder", gi, name)); + let mut open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); + folder_row(ui, name, &mut open); + ui.data_mut(|d| d.insert_temp(open_id, open)); + if open { + ui.indent(open_id, |ui| grid(ui, n, |ui, i| item(ui, g, i))); + ui.add_space(4.0); + } + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remember_moves_a_colour_to_the_front_once_and_caps_the_row() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + for i in 0..20 { + remember(&mut app, [i as f32 / 255.0, 0.0, 0.0]); + } + let recent = recent_colors(&app.session); + assert_eq!(recent.len(), MAX_RECENT); + assert_eq!(crate::color_picker_ui::hex(recent[0]), "#130000", "newest first"); + remember(&mut app, [10.0 / 255.0, 0.0, 0.0]); + let recent = recent_colors(&app.session); + assert_eq!(crate::color_picker_ui::hex(recent[0]), "#0a0000"); + assert_eq!(recent.iter().filter(|c| crate::color_picker_ui::hex(**c) == "#0a0000").count(), 1, "no duplicates"); + // Non-finite colours and hand-edited junk never reach the row. + remember(&mut app, [f32::NAN, 0.0, 0.0]); + app.session.prefs.edit(|p| p.recent_colors.insert(0, "not a colour é".into())); + assert_eq!(crate::color_picker_ui::hex(recent_colors(&app.session)[0]), "#0a0000"); + } +} diff --git a/crates/ui-egui/src/tools/path_components.rs b/crates/ui-egui/src/tools/path_components.rs new file mode 100644 index 0000000000..33aef52d8d --- /dev/null +++ b/crates/ui-egui/src/tools/path_components.rs @@ -0,0 +1,217 @@ +//! Selected path components and their Path Operations, as in Photoshop. Path Selection (A) clicks a +//! component to select it (⇧ adds or removes one, a click off every component deselects) and drags +//! the selected components. With components selected, Path Selection's Path Operations menu and +//! its keys (`+` Combine Shapes, `-` Subtract Front Shape) change their operation through +//! `path.setOperation`, one History step; with none selected, and always with the Pen tools (which +//! show no component selection), they set how the next path is added. The first component always +//! combines, so it keeps its operation. The selection is Direct Selection's: every anchor of a +//! selected component. + +use egui::{Event, Key}; +use photocraft_doc::{Path, PathOp}; +use photocraft_geom::Point; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::direct_select::{DirectSelection, PathRef, active_paths}; +use crate::state::Tool; + +/// The `pen_path_op` value of an operation (`Join` belongs to its component's first subpath). +fn op_value(op: PathOp) -> &'static str { + match op { + PathOp::Combine | PathOp::Join => "combine", + PathOp::Subtract => "subtract", + PathOp::Intersect => "intersect", + PathOp::Exclude => "exclude", + } +} + +/// The path Path Selection selects in: the targeted vector mask or the active shape layer's +/// path, else the Work Path (`active_paths`, front first). +fn target(app: &PhotocraftApp) -> Option<(PathRef, Path)> { + active_paths(app).into_iter().next() +} + +/// The selected components: their path and the first subpath of each, in order. `None` with +/// nothing selected, a selection on a path that is not an active one any more, or a tool other +/// than Path Selection (the only one that shows it, so the only one that acts on it). +pub(crate) fn selected(app: &PhotocraftApp) -> Option<(PathRef, Path, Vec)> { + if app.ui.tool != Tool::PathSelection { + return None; + } + let sel = &app.ui.direct_selection; + if !crate::direct_select::still_valid(app, sel) { + return None; + } + let r = sel.target.filter(|r| *r != PathRef::Pen)?; + let (_, path) = active_paths(app).into_iter().find(|(p, _)| *p == r)?; + let components = path.components(); + let mut heads: Vec = sel.anchors.iter().filter_map(|[s, _]| components.iter().find(|c| c.contains(s)).map(|c| c.start)).collect(); + heads.sort_unstable(); + heads.dedup(); + (!heads.is_empty()).then_some((r, path, heads)) +} + +/// The operation every selected component has, for the menu's icon; `None` when they differ. +pub(crate) fn selected_op(app: &PhotocraftApp) -> Option<&'static str> { + let (_, path, heads) = selected(app)?; + let mut ops = heads.iter().filter(|h| **h < path.subpaths.len()).map(|h| op_value(path.effective_op(*h))); + let first = ops.next()?; + ops.all(|o| o == first).then_some(first) +} + +/// A Path Operations pick (menu or key): the selected components take `op`, and the tool keeps it +/// for the next path. New Layer is not an operation a component can have: it only sets the tool. +/// The first component always combines (it is what the others act on), so it keeps its operation. +pub(crate) fn set_op(app: &mut PhotocraftApp, op: &str) -> Result<(), String> { + app.ui.tool_options.pen_path_op = op.into(); + if op == "newLayer" { + return Ok(()); + } + let Some((r, path, heads)) = selected(app) else { return Ok(()) }; + let heads: Vec = heads.into_iter().filter(|h| *h != 0).collect(); + if heads.iter().all(|h| path.subpaths.get(*h).is_some_and(|s| op_value(s.op) == op)) { + return Ok(()); + } + let mut params = r.params(app); + params["subpaths"] = json!(heads); + params["op"] = json!(op); + run_selected_command(app, "path.setOperation", params) +} + +fn run_selected_command(app: &mut PhotocraftApp, id: &str, params: serde_json::Value) -> Result<(), String> { + let selection = std::mem::take(&mut app.ui.direct_selection); + let result = app.run(id, params); + if result.is_ok() || crate::direct_select::still_valid(app, &selection) { + crate::direct_select::keep_after_command(app, selection); + } + result.map(|_| ()) +} + +/// The component of `path` under `at`: on its outline within `tol`, else inside it. The frontmost +/// (last) component wins. +fn component_at(path: &Path, at: [f64; 2], tol: f64) -> Option> { + let components = path.components(); + if let Some(hit) = photocraft_vector::edit::hit(path, Point::new(at[0], at[1]), tol, &[]) { + let s = match hit { + photocraft_vector::edit::Hit::Anchor([s, _]) + | photocraft_vector::edit::Hit::Handle([s, _], _) + | photocraft_vector::edit::Hit::Segment([s, _], _) => s, + }; + return components.into_iter().find(|c| c.contains(&s)); + } + components.into_iter().rev().find(|c| { + // Even-odd over the component's flattened subpaths: a hole in it is not inside. + let crossings: usize = path + .subpaths + .get(c.clone()) + .unwrap_or_default() + .iter() + .filter(|s| s.closed) + .map(|s| crossings(&photocraft_vector::flatten::flatten_subpath(s, 0.25).pts, at)) + .sum(); + crossings % 2 == 1 + }) +} + +/// How many edges of the closed polygon `pts` a ray from `at` to the right crosses. +fn crossings(pts: &[(f64, f64)], at: [f64; 2]) -> usize { + let n = pts.len(); + (0..n) + .filter(|&i| { + let (Some(&(xi, yi)), Some(&(xj, yj))) = (pts.get(i), pts.get((i + 1) % n)) else { return false }; + (yi > at[1]) != (yj > at[1]) && at[0] < (xj - xi) * (at[1] - yi) / (yj - yi) + xi + }) + .count() +} + +/// Path Selection's press at `at`: selects the component there (⇧ adds or removes it). Pressing a +/// selected component keeps the selection, so the drag moves all of it; pressing off every +/// component deselects, unless ⇧ is held. +pub(crate) fn press(app: &mut PhotocraftApp, at: [f64; 2], shift: bool) { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let Some((r, path)) = target(app) else { + app.ui.direct_selection = DirectSelection::default(); + return; + }; + let current = selected(app).filter(|(sr, ..)| *sr == r).map(|(.., heads)| heads).unwrap_or_default(); + let Some(hit) = component_at(&path, at, tol) else { + if !shift { + app.ui.direct_selection = DirectSelection::default(); + } + return; + }; + let was_selected = current.contains(&hit.start); + let heads: Vec = match (shift, was_selected) { + (true, true) => current.into_iter().filter(|h| *h != hit.start).collect(), + (true, false) => current.into_iter().chain([hit.start]).collect(), + (false, true) => current, + (false, false) => vec![hit.start], + }; + let components = path.components(); + let anchors = components + .iter() + .filter(|c| heads.contains(&c.start)) + .flat_map(|c| c.clone()) + .flat_map(|s| (0..path.subpaths.get(s).map_or(0, |sp| sp.knots.len())).map(move |k| [s, k])) + .collect::>(); + app.ui.direct_selection = if anchors.is_empty() { + DirectSelection::default() + } else { + DirectSelection { target: Some(r), anchors, owner: crate::direct_select::Owner::current(app), ..Default::default() } + }; +} + +/// Path Selection's drag by `d` with components selected on the path it edits: moves those +/// components, one History step. `None` with none or all of them selected: the whole path moves +/// instead, which keeps a live rectangle or ellipse live. +pub(crate) fn move_selected(app: &mut PhotocraftApp, d: [f64; 2]) -> Option> { + let (r, path, heads) = selected(app)?; + if target(app).map(|(t, _)| t) != Some(r) || heads.len() >= path.components().len() { + return None; + } + let mut params = r.params(app); + params["anchors"] = json!(app.ui.direct_selection.anchors); + params["move"] = json!(d); + Some(run_selected_command(app, "path.moveAnchors", params)) +} + +/// Do `+` and `-` pick a Path Operation with `tool`? The tools whose options bar has the menu. +fn has_keys(tool: Tool) -> bool { + matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) +} + +/// Consumes this frame's `+` (Combine Shapes) and `-` (Subtract Front Shape) presses while a tool +/// with the Path Operations menu is active, as in Photoshop. `+` is ⇧= on many layouts and the +/// keypad's key on all, so ⇧ is allowed; with these tools ⇧+ / ⇧− step no blend mode. +pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + if !has_keys(app.ui.tool) { + return; + } + let ops: Vec<&str> = ctx.input_mut(|i| { + let mut out = Vec::new(); + i.events.retain(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.ctrl && !modifiers.alt => { + let op = match key { + Key::Plus => "combine", + Key::Equals if modifiers.shift => "combine", + Key::Minus => "subtract", + _ => return true, + }; + out.push(op); + false + } + _ => true, + }); + out + }); + for op in ops { + if let Err(e) = set_op(app, op) { + app.ui.status = e; + app.ui.status_error = true; + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/tools/path_components/tests.rs b/crates/ui-egui/src/tools/path_components/tests.rs new file mode 100644 index 0000000000..e883cf6d4d --- /dev/null +++ b/crates/ui-egui/src/tools/path_components/tests.rs @@ -0,0 +1,324 @@ +//! Path components after they are drawn (feedback on #7648): Path Selection selects them, the +//! Path Operations menu and `+` / `-` change their operation, and the Pen carries on an open +//! shape path from its endpoint. + +use egui::Key; +use photocraft_doc::vector::PathOp; +use photocraft_doc::{LayerContent, LayerId, Path}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::direct_select::PathRef; +use crate::state::Tool; +use crate::vector_ui::{pen_commit, pen_down, pen_up}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = Tool::Pen; + app.ui.tool_options.vector_mode = "shape".into(); + app +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64) { + pen_down(app, x, y, false); + pen_up(app); +} + +/// Clicks a closed square with the Pen, from `(x, y)` with side `side`. +fn square(app: &mut PhotocraftApp, x: f64, y: f64, side: f64) { + for (dx, dy) in [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side), (0.5, 0.5)] { + click(app, x + dx, y + dy); + } +} + +fn shape(app: &PhotocraftApp) -> (LayerId, photocraft_doc::ShapeLayer) { + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + match &st.doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => (id, sh.clone()), + _ => panic!("not a shape layer"), + } +} + +fn ops(path: &Path) -> Vec { + path.subpaths.iter().map(|s| s.op).collect() +} + +fn covered(path: &Path, x: i32, y: i32) -> f32 { + photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0] +} + +fn history(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().history.past_len() +} + +/// Feeds one key press through the app's shortcut handling. +fn key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: egui::Modifiers) { + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::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(); +} + +/// A body and an inner square drawn with Combine Shapes: the inner one is selected with Path +/// Selection and turned into a hole with the menu's Subtract Front Shape, one History step. +#[test] +fn a_selected_component_takes_the_picked_operation() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 120.0); + app.ui.tool_options.pen_path_op = "combine".into(); + square(&mut app, 60.0, 60.0, 40.0); + let (id, sh) = shape(&app); + assert_eq!(covered(&sh.path, 80, 80), 1.0); + app.ui.tool = Tool::PathSelection; + super::press(&mut app, [80.0, 80.0], false); + let (r, _, heads) = super::selected(&app).expect("the inner square is selected"); + assert_eq!((r, heads), (PathRef::Layer(id.0), vec![1])); + let steps = history(&app); + super::set_op(&mut app, "subtract").unwrap(); + let (_, sh) = shape(&app); + assert_eq!(ops(&sh.path), [PathOp::Combine, PathOp::Subtract]); + assert_eq!((covered(&sh.path, 80, 80), covered(&sh.path, 30, 30)), (0.0, 1.0), "a hole"); + assert_eq!(history(&app), steps + 1); + assert_eq!(super::selected_op(&app), Some("subtract"), "the menu shows the component's"); + // Picking what it already has adds no step; New Layer only sets the tool. + super::set_op(&mut app, "subtract").unwrap(); + super::set_op(&mut app, "newLayer").unwrap(); + assert_eq!(history(&app), steps + 1); + assert!(app.session.undo()); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Combine]); +} + +/// `+` and `-` with the Pen tools or Path Selection: Combine Shapes and Subtract Front Shape, on +/// the selected components and for the next path. `+` arrives as ⇧= on a US keyboard, which +/// otherwise steps the blend mode; with the Move tool it still does. +#[test] +fn plus_and_minus_pick_combine_and_subtract() { + let ctx = egui::Context::default(); + let mut app = app(); + square(&mut app, 20.0, 20.0, 120.0); + app.ui.tool_options.pen_path_op = "combine".into(); + square(&mut app, 60.0, 60.0, 40.0); + app.ui.tool = Tool::PathSelection; + super::press(&mut app, [80.0, 80.0], false); + key(&mut app, &ctx, Key::Minus, egui::Modifiers::NONE); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Subtract]); + assert_eq!(app.ui.tool_options.pen_path_op, "subtract"); + key(&mut app, &ctx, Key::Equals, egui::Modifiers::SHIFT); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Combine]); + key(&mut app, &ctx, Key::Minus, egui::Modifiers::NONE); + key(&mut app, &ctx, Key::Plus, egui::Modifiers::NONE); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Combine], "the keypad's +"); + // With nothing selected the Pen's next path takes it. + app.ui.direct_selection = Default::default(); + app.ui.tool = Tool::Pen; + key(&mut app, &ctx, Key::Minus, egui::Modifiers::NONE); + assert_eq!(app.ui.tool_options.pen_path_op, "subtract"); + // The blend mode is the Move tool's to step. + let blend = |app: &PhotocraftApp| app.session.active().unwrap().doc.layer(shape(app).0).unwrap().blend; + let before = blend(&app); + key(&mut app, &ctx, Key::Equals, egui::Modifiers::SHIFT); + assert_eq!(blend(&app), before, "the Pen picks Combine, not a blend mode"); + assert_eq!(app.ui.tool_options.pen_path_op, "combine"); + app.ui.tool = Tool::Move; + key(&mut app, &ctx, Key::Equals, egui::Modifiers::SHIFT); + assert_ne!(blend(&app), before); + assert_eq!(app.ui.tool_options.pen_path_op, "combine"); +} + +/// ⇧-click adds and removes components, a click off them deselects, and a drag moves only the +/// selected ones. +#[test] +fn path_selection_selects_and_moves_components() { + let mut app = app(); + square(&mut app, 10.0, 10.0, 40.0); + app.ui.tool_options.pen_path_op = "combine".into(); + square(&mut app, 100.0, 10.0, 40.0); + square(&mut app, 100.0, 100.0, 40.0); + app.ui.tool = Tool::PathSelection; + let heads = |app: &PhotocraftApp| super::selected(app).map(|(.., h)| h).unwrap_or_default(); + super::press(&mut app, [30.0, 30.0], false); + super::press(&mut app, [120.0, 120.0], true); + assert_eq!(heads(&app), [0, 2]); + // Pressing a selected one keeps both, for the drag. + super::press(&mut app, [120.0, 120.0], false); + assert_eq!(heads(&app), [0, 2]); + crate::vector_ui::path_selection_finish(&mut app, [120.0, 120.0], [125.0, 130.0]); + let (_, sh) = shape(&app); + let first = |s: usize| sh.path.subpaths[s].knots[0].anchor; + assert_eq!((first(0).x, first(0).y), (15.0, 20.0)); + assert_eq!((first(1).x, first(1).y), (100.0, 10.0), "not selected: stays"); + assert_eq!((first(2).x, first(2).y), (105.0, 110.0)); + super::press(&mut app, [125.0, 130.0], true); + assert_eq!(heads(&app), [0]); + super::press(&mut app, [190.0, 190.0], false); + assert!(super::selected(&app).is_none(), "a click off every component deselects"); +} + +/// A selection left on a path that has fewer subpaths now (undone, edited elsewhere) selects +/// nothing and changes nothing, never panics. +#[test] +fn a_stale_selection_changes_nothing() { + let mut app = app(); + square(&mut app, 10.0, 10.0, 40.0); + let (id, _) = shape(&app); + app.ui.tool = Tool::PathSelection; + app.ui.direct_selection = crate::direct_select::DirectSelection { target: Some(PathRef::Layer(id.0)), anchors: vec![[5, 0]], ..Default::default() }; + assert!(super::selected(&app).is_none()); + let steps = history(&app); + super::set_op(&mut app, "subtract").unwrap(); + assert_eq!(history(&app), steps); + assert_eq!(super::move_selected(&mut app, [3.0, 3.0]), None); +} + +/// The first component always draws as Combine (feedback on #7648): selected alone, `-` and the +/// menu leave it as it is, with no History step, and the menu shows Combine; selected with +/// another, only the other takes the operation. +#[test] +fn the_first_component_keeps_combining() { + let ctx = egui::Context::default(); + let mut app = app(); + square(&mut app, 20.0, 20.0, 120.0); + app.ui.tool_options.pen_path_op = "combine".into(); + square(&mut app, 60.0, 60.0, 40.0); + app.ui.tool = Tool::PathSelection; + super::press(&mut app, [30.0, 30.0], false); + assert_eq!(super::selected(&app).map(|(.., h)| h), Some(vec![0])); + let steps = history(&app); + key(&mut app, &ctx, Key::Minus, egui::Modifiers::NONE); + super::set_op(&mut app, "intersect").unwrap(); + assert_eq!(history(&app), steps, "no step"); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Combine]); + assert_eq!(super::selected_op(&app), Some("combine"), "the menu shows what it draws as"); + super::press(&mut app, [80.0, 80.0], true); + super::set_op(&mut app, "subtract").unwrap(); + assert_eq!(history(&app), steps + 1); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Subtract]); + assert_eq!(super::selected_op(&app), None, "they differ"); +} + +/// A component selection is Path Selection's: with the Pen, which doesn't show it, `-` and the +/// menu set the next path's operation and leave the components alone (feedback on #7648). +#[test] +fn the_pen_does_not_edit_a_hidden_component_selection() { + let ctx = egui::Context::default(); + let mut app = app(); + square(&mut app, 20.0, 20.0, 120.0); + app.ui.tool_options.pen_path_op = "combine".into(); + square(&mut app, 60.0, 60.0, 40.0); + app.ui.tool = Tool::PathSelection; + super::press(&mut app, [80.0, 80.0], false); + assert!(super::selected(&app).is_some()); + app.ui.tool = Tool::Pen; + let steps = history(&app); + key(&mut app, &ctx, Key::Minus, egui::Modifiers::NONE); + assert_eq!(app.ui.tool_options.pen_path_op, "subtract", "the next path's"); + assert_eq!(super::selected_op(&app), None); + assert_eq!(history(&app), steps); + assert_eq!(ops(&shape(&app).1.path), [PathOp::Combine, PathOp::Combine]); +} + +/// Dragging a live rectangle or ellipse with Path Selection (its only component selected by the +/// press) moves the whole shape, which stays live: its corner-radius widgets stay (feedback on +/// #7648). +#[test] +fn dragging_a_live_shape_keeps_it_live() { + for kind in ["rect", "ellipse"] { + let mut app = app(); + app.run("shape.create", json!({"kind": kind, "rect": [20, 20, 60, 40], "fill": "#ff0000"})).unwrap(); + assert!(shape(&app).1.live.is_some(), "{kind}"); + app.ui.tool = Tool::PathSelection; + super::press(&mut app, [50.0, 40.0], false); + assert_eq!(super::selected(&app).map(|(.., h)| h), Some(vec![0]), "{kind}"); + let steps = history(&app); + crate::vector_ui::path_selection_finish(&mut app, [50.0, 40.0], [60.0, 45.0]); + let (_, sh) = shape(&app); + assert_eq!(history(&app), steps + 1, "{kind}"); + let (x0, y0, ..) = sh.path.control_bounds().unwrap(); + assert_eq!((x0.round(), y0.round()), (30.0, 25.0), "{kind}: moved"); + match sh.live { + Some(photocraft_doc::vector::LiveShape::Rect { rect, .. } | photocraft_doc::vector::LiveShape::Ellipse { rect }) => { + assert_eq!(rect, [30.0, 25.0, 60.0, 40.0], "{kind}") + } + other => panic!("{kind}: still live, got {other:?}"), + } + } +} + +/// A shape path left open (Enter before closing it) is carried on from its endpoint and closed on +/// its first anchor: the same layer and subpath, one History step, and it takes the options bar's +/// fill and stroke as if it had been drawn closed. +#[test] +fn the_pen_carries_on_an_open_shape_path() { + let mut app = app(); + app.ui.tool_options.shape_fill = true; + app.ui.tool_options.stroke_width = 0.0; + for (x, y) in [(20.0, 20.0), (120.0, 20.0), (120.0, 120.0)] { + click(&mut app, x, y); + } + assert!(pen_commit(&mut app, false)); + let (id, sh) = shape(&app); + assert!(sh.fill.is_none() && sh.stroke.is_some(), "an open path is stroked"); + let count = app.session.active().unwrap().doc.layers.len(); + let steps = history(&app); + click(&mut app, 120.0, 120.0); // its last anchor: carry on + click(&mut app, 20.0, 120.0); + click(&mut app, 20.5, 20.5); // its first anchor: close + assert!(app.ui.pen.is_none(), "closing commits"); + let (same, sh) = shape(&app); + assert_eq!((same, app.session.active().unwrap().doc.layers.len()), (id, count), "no new layer"); + assert_eq!(sh.path.subpaths.len(), 1); + assert!(sh.path.subpaths[0].closed); + assert_eq!(sh.path.subpaths[0].knots.len(), 4); + assert!(sh.fill.is_some() && sh.stroke.is_none(), "filled like a path drawn closed"); + assert_eq!(history(&app), steps + 1); + assert!(app.session.undo()); + assert!(!shape(&app).1.path.subpaths[0].closed); +} + +/// From the first anchor too, and a closed component's fill and stroke are the layer's own. +#[test] +fn an_open_component_of_a_filled_shape_keeps_its_paint() { + let mut app = app(); + square(&mut app, 10.0, 10.0, 60.0); + let (id, before) = shape(&app); + // An open component added to the layer. + app.ui.tool_options.pen_path_op = "combine".into(); + for (x, y) in [(100.0, 100.0), (150.0, 100.0)] { + click(&mut app, x, y); + } + assert!(pen_commit(&mut app, false)); + app.ui.tool_options.shape_fill = false; + click(&mut app, 100.0, 100.0); // its first anchor + click(&mut app, 100.0, 150.0); + assert!(pen_commit(&mut app, false)); + let (same, sh) = shape(&app); + assert_eq!(same, id); + assert_eq!(sh.path.subpaths.len(), 2); + assert_eq!(sh.path.subpaths[1].knots.len(), 3); + assert_eq!((sh.fill, sh.stroke), (before.fill, before.stroke)); +} + +/// A targeted vector mask's open subpath is carried on in place too. +#[test] +fn the_pen_carries_on_a_vector_masks_open_subpath() { + let mut app = app(); + app.ui.tool_options.vector_mode = "path".into(); + app.run("layer.new.layer", json!({})).unwrap(); + let id = app.session.active().unwrap().active_layer.unwrap().0; + let open = json!({"subpaths": [{"closed": false, "knots": [[20, 20], [120, 20]]}]}); + app.run("layer.vectorMask.add", json!({"layer": id, "path": open})).unwrap(); + app.ui.vector_mask_target = true; + click(&mut app, 120.0, 20.0); + click(&mut app, 120.0, 120.0); + assert!(pen_commit(&mut app, false)); + let st = app.session.active().unwrap(); + let mask = st.doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap(); + assert_eq!(mask.path.subpaths.len(), 1); + assert_eq!(mask.path.subpaths[0].knots.len(), 3); + assert!(st.doc.work_path.is_none()); +} diff --git a/crates/ui-egui/src/tools/perspective_ui.rs b/crates/ui-egui/src/tools/perspective_ui.rs index b383184975..67dfa84519 100644 --- a/crates/ui-egui/src/tools/perspective_ui.rs +++ b/crates/ui-egui/src/tools/perspective_ui.rs @@ -120,6 +120,9 @@ impl PerspSession { } pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Result<(), String> { + if let Some(why) = app.session.disabled_reason(command) { + return Err(why); + } if app.ui.transform.is_some() { return Err("finish Free Transform first".into()); } diff --git a/crates/ui-egui/src/tools/puppet_ui.rs b/crates/ui-egui/src/tools/puppet_ui.rs index bf905c4c29..b86e65ba05 100644 --- a/crates/ui-egui/src/tools/puppet_ui.rs +++ b/crates/ui-egui/src/tools/puppet_ui.rs @@ -188,6 +188,9 @@ fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { /// Starts Puppet Warp on the active layer. pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Result<(), String> { + if let Some(why) = app.session.disabled_reason(command) { + return Err(why); + } if app.ui.transform.is_some() { return Err("finish Free Transform first".into()); } diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index b8c56bf497..2b1a4f916a 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -132,7 +132,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m remove_pending_add(app, points); return true; } - ("paint.remove", remove_params(&o)) + ("paint.remove", remove_params(&o, crate::canvas::paint_target(app).as_str() == Some("pixels"))) } Tool::MixerBrush => ("paint.mixerBrush", json!({})), Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)), @@ -198,8 +198,8 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m } /// `paint.remove` params from the options bar (without the strokes). -fn remove_params(o: &crate::state::ToolOptions) -> Value { - json!({"sampleAllLayers": o.sample_all.remove, "newLayer": o.remove_new_layer}) +fn remove_params(o: &crate::state::ToolOptions, composite: bool) -> Value { + json!({"sampleAllLayers": o.sample_all.remove, "newLayer": o.remove_new_layer && composite}) } /// The selection a queued Remove stroke was drawn against. A later selection must not @@ -248,7 +248,7 @@ pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool { let strokes = std::mem::take(&mut app.remove_pending); app.remove_trail = None; app.remove_pending_target = None; - let mut p = remove_params(&app.ui.tool_options); + let mut p = remove_params(&app.ui.tool_options, crate::canvas::paint_target(app).as_str() == Some("pixels")); p["strokes"] = json!(strokes); p["target"] = crate::canvas::paint_target(app); app.ui.status.clear(); @@ -503,6 +503,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo { return false; } + let composite = crate::canvas::paint_target(app).as_str() == Some("pixels"); let o = &mut app.ui.tool_options; match tool { Tool::SpotHealing => { @@ -520,7 +521,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // Photoshop's options bar: Sample All Layers, Create a new layer, Remove after each // stroke; with strokes waiting, ✓ applies them and ⊘ drops them. crate::widgets::checkbox(ui, &mut o.sample_all.remove, tl!("Sample All Layers")); - crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer")); + ui.add_enabled_ui(composite, |ui| crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer"))); crate::widgets::checkbox(ui, &mut o.remove_after_each_stroke, tl!("Remove after each stroke")); if !app.remove_pending.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/tools/retouch_ui/tests.rs b/crates/ui-egui/src/tools/retouch_ui/tests.rs index d157336960..a9659eb23d 100644 --- a/crates/ui-egui/src/tools/retouch_ui/tests.rs +++ b/crates/ui-egui/src/tools/retouch_ui/tests.rs @@ -52,6 +52,28 @@ fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { } } +/// A Clone Stamp click paints with the brush's hardness (the Brush popup's), not a hard tip: +/// near the rim a soft brush paints only part of the source, a hard one all of it. +#[test] +fn clone_stamp_paints_with_the_brush_hardness() { + let rim = |hardness: f32| { + let mut app = app(); + app.run("tools.setBrush", json!({"brush": {"size": 24, "hardness": hardness}})).unwrap(); + app.run("paint.pencil", json!({"points": [[15, 30]], "size": 40, "color": "#ff0000"})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.ui.clone_source = Some([15.0, 30.0]); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 70.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + // 9 px from the centre of a 12 px radius: inside a hard tip, on a soft tip's falloff. + 1.0 - active(&app).surface().unwrap().rgba(79, 30)[1] + }; + let (hard, soft) = (rim(1.0), rim(0.0)); + assert!(hard > 0.99, "a hard tip clones fully near its rim: {hard}"); + assert!(soft < 0.8, "a soft tip clones only part of the source there: {soft}"); +} + #[test] fn alt_flips_dodge_burn_and_blur_sharpen() { for (tool, flipped) in [(Tool::Dodge, Tool::Burn), (Tool::Burn, Tool::Dodge), (Tool::Blur, Tool::Sharpen), (Tool::Sharpen, Tool::Blur)] { @@ -703,6 +725,25 @@ fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { assert!(app.remove_pending.is_empty(), "the strokes belong to the Remove tool"); } +#[test] +fn subset_remove_uses_the_existing_layer_without_losing_the_saved_new_layer_option() { + let mut app = app(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_new_layer = true; + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + let layers = app.session.active().unwrap().doc.walk().len(); + tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 18.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 42.0 }, egui::Modifiers::NONE); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (id, params) = app.session.journal.last().unwrap(); + assert_eq!(id, "paint.remove"); + assert_eq!(params["newLayer"], json!(false)); + assert_eq!(app.session.active().unwrap().doc.walk().len(), layers); + assert!(app.ui.tool_options.remove_new_layer); + app.run("channel.target", json!({"channel":"composite"})).unwrap(); + assert_eq!(remove_params(&app.ui.tool_options, true)["newLayer"], json!(true)); +} + #[test] fn create_a_new_layer_sends_new_layer_to_the_command() { let mut app = app(); diff --git a/crates/ui-egui/src/tools/shape_options.rs b/crates/ui-egui/src/tools/shape_options.rs index 8271388f25..f7ca997539 100644 --- a/crates/ui-egui/src/tools/shape_options.rs +++ b/crates/ui-egui/src/tools/shape_options.rs @@ -51,6 +51,7 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool (o.shape_fill, o.shape_fill.then_some(fill), (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, stroke))) } }; + let lib = crate::paint_picker::library(&app.session, ui.ctx()); let mut gradients = GradientChoices { presets: &app.session.presets.gradients, current: &app.session.presets.gradient, recent: &mut app.ui.recent_gradients, fg, bg }; let mut width = stroke.as_ref().map_or(0.0, |s| s.width); @@ -65,9 +66,9 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool o.shape_fill = has_fill; edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }})); } - let fill_color = swatch(ui, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); + let fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); lbl(ui, tl!("Stroke:")); - let stroke_color = swatch(ui, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); + let stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() { o.stroke_width = width; // A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke. @@ -139,11 +140,28 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool } _ => {} } + // A swatch picked in a Fill/Stroke popover joins the Recently Used Colors once it was applied. + let mut recent: Option<[f32; 3]> = None; for (is_stroke, change) in [(false, fill_color), (true, stroke_color)] { let Some(change) = change else { continue }; - if let Err(e) = set_tool_paint(app, is_stroke, change.clone()) { - app.ui.status = e; - app.ui.status_error = true; + match change { + PaintChange::OpenPicker => { + let (which, fallback) = if is_stroke { (crate::shape_fill_ui::Paint::Stroke, bg) } else { (crate::shape_fill_ui::Paint::Fill, fg) }; + crate::shape_fill_ui::open_paint(app, which, target.as_ref().map(|s| s.layer), true, fallback); + continue; + } + PaintChange::Color(_) | PaintChange::Gradient(_) | PaintChange::None => {} + } + match set_tool_paint(app, is_stroke, change.clone()) { + Ok(_) => { + if let PaintChange::Color(c) = &change { + recent = Some([c[0], c[1], c[2]]); + } + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } } edit = Some(match (is_stroke, change) { (false, PaintChange::None) => json!({"fill": null}), @@ -152,6 +170,7 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool (true, PaintChange::None) => json!({"stroke": null}), (true, PaintChange::Color(color)) => json!({"stroke": {"color": color, "width": enabled_stroke_width(width)}, "coalesce": key("stroke")}), (true, PaintChange::Gradient(g)) => json!({"stroke": {"color": paint_param(&g), "width": enabled_stroke_width(width)}, "coalesce": key("stroke")}), + (_, PaintChange::OpenPicker) => continue, }); } if let (Some(mut p), Some(s)) = (edit, &target) { @@ -159,6 +178,7 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool if let Err(e) = app.run("shape.edit", p) { app.ui.status = e; app.ui.status_error = true; + recent = None; } if !ui.input(|i| i.pointer.any_down()) { ui.data_mut(|d| { @@ -167,6 +187,9 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool }); } } + if let Some(rgb) = recent { + crate::paint_picker::remember(app, rgb); + } } /// Tool-bar paint changes affect the next shape; Properties edits the selected shape instead. @@ -178,6 +201,8 @@ pub(crate) fn set_tool_paint(app: &mut PhotocraftApp, stroke: bool, change: Pain PaintChange::Gradient(g) => *gradient = Some(g.clone()), PaintChange::Color(_) => *gradient = None, PaintChange::None => {} + // Opening the Color Picker changes nothing until its OK (a `Color` then). + PaintChange::OpenPicker => return Ok(()), } if let PaintChange::Color(color) = change { app.run("tools.setColors", if stroke { json!({"background": color}) } else { json!({"foreground": color}) })?; diff --git a/crates/ui-egui/src/tools/shape_paint_ui.rs b/crates/ui-egui/src/tools/shape_paint_ui.rs index b256bcf661..10992dea24 100644 --- a/crates/ui-egui/src/tools/shape_paint_ui.rs +++ b/crates/ui-egui/src/tools/shape_paint_ui.rs @@ -4,7 +4,7 @@ use egui::{Color32, Sense, Stroke, vec2}; use serde_json::{Value, json}; -use crate::shape_gradient_ui::{self as shape_gradient, GradientChoices}; +use crate::shape_gradient_ui::GradientChoices; use crate::theme::Tokens; pub(crate) fn color_of(f: &photocraft_doc::Fill) -> Option<[f32; 4]> { @@ -40,6 +40,9 @@ pub(crate) enum PaintChange { /// A `Fill::Gradient` (the swatch's Gradient tab). Gradient(photocraft_doc::Fill), None, + /// The popover's Color Picker button: open the full Color Picker dialog on this paint + /// (`shape_fill_ui::open_paint`). + OpenPicker, } pub(crate) fn rgba32(c: [f32; 4]) -> Color32 { @@ -59,10 +62,12 @@ pub(crate) fn picker_rgba(hsva: egui::ecolor::Hsva) -> [f32; 4] { [gamma(r), gamma(g), gamma(b), a] } -/// The shared compact picker keeps the float RGB paint and its alpha. Color32 is only display -/// data: opening the picker must never quantize an existing high-depth colour or enable paint. +/// A shape paint swatch (Fill or Stroke) opening Photoshop's paint popover +/// ([`crate::paint_picker`]). The swatch shows the float paint as it is: opening the popover never +/// quantizes a high-depth colour or enables paint. pub(crate) fn swatch( ui: &mut egui::Ui, + lib: &crate::paint_picker::Library, fill: Option<&photocraft_doc::Fill>, fallback: [f32; 4], tip: &str, @@ -72,7 +77,8 @@ pub(crate) fn swatch( let (r, resp) = ui.allocate_exact_size(vec2(30.0, 22.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ColorWell, ui.is_enabled(), tip)); let current = fill.and_then(color_of); - crate::widgets::checker(ui.painter(), r.shrink(1.0), 4.0); + // Clipped to the rounded corners: square checker cells showed white past them (#8741, #3208). + crate::widgets::checker_rounded(ui.painter(), r.shrink(1.0), 4.0, t.radius_sm); match (current, fill) { (Some(c), _) => { ui.painter().rect_filled(r.shrink(1.0), t.radius_sm, rgba32(c)); @@ -114,81 +120,15 @@ pub(crate) fn swatch( }; ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, border), egui::StrokeKind::Inside); let resp = resp.on_hover_text(tip); - let screen_id = resp.id.with("screen-color"); let original = current.unwrap_or(fallback); - let sampled = |rgb: [f32; 3]| PaintChange::Color([rgb[0], rgb[1], rgb[2], original[3]]); - let mut out = crate::screen_picker::take(ui.ctx(), screen_id).map(sampled); - let cache = resp.id.with("shape-paint-hsva"); - let mut hsva = ui - .data(|d| d.get_temp::<([f32; 4], egui::ecolor::Hsva)>(cache)) - .filter(|(previous, _)| *previous == original) - .map_or_else(|| picker_color(original), |(_, hsva)| hsva); - if let Some(PaintChange::Color(color)) = out { - hsva = picker_color(color); - } let gradient = fill.filter(|f| matches!(f, photocraft_doc::Fill::Gradient { .. })); let popup_id = resp.id; + let mut out = None; let shown = crate::widgets::swatch_popup(&resp).show(|ui| { - ui.horizontal(|ui| { - if ui.selectable_label(fill.is_none(), tl!("No Color")).clicked() && fill.is_some() { - out = Some(PaintChange::None); - } - if ui.selectable_label(current.is_some(), tl!("Solid")).clicked() && current.is_none() { - out = Some(PaintChange::Color(original)); - } - if ui.selectable_label(gradient.is_some(), tl!("Gradient")).clicked() && gradient.is_none() { - out = Some(PaintChange::Gradient(gradients.default_fill())); - } - }); - ui.separator(); - if let Some(g) = gradient { - if let Some(edited) = shape_gradient::gradient_tab(ui, g, gradients, popup_id) { - out = Some(PaintChange::Gradient(edited)); - } - return; - } - let before = picker_rgba(hsva); - if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::OnlyBlend) { - let color = picker_rgba(hsva); - // egui's HSV gamma/linear round trip can move a float by an ulp without input. - if color.iter().zip(before).any(|(a, b)| (a - b).abs() > 0.000001) { - 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( - || crate::color_picker_ui::hex([selected[0], selected[1], selected[2]]).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]]); + out = crate::paint_picker::body(ui, lib, fill, original, gradients, popup_id); + if out == Some(PaintChange::OpenPicker) { + // The Color Picker dialog takes over, as Photoshop's does. + ui.close(); } }); // The gradient a swatch's popup closes on joins the Gradient tab's Recent row. @@ -202,10 +142,5 @@ pub(crate) fn swatch( gradients.remember(g); } ui.data_mut(|d| d.insert_temp(open_key, open)); - let cached_color = match out { - Some(PaintChange::Color(color)) => color, - _ => original, - }; - ui.data_mut(|d| d.insert_temp(cache, (cached_color, hsva))); out } diff --git a/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs b/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs index fe83b3bbee..cfa3ba432b 100644 --- a/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs +++ b/crates/ui-egui/src/tools/shape_stroke_ui/shortcut_tests.rs @@ -77,7 +77,8 @@ fn escape_dismisses_stroke_child_then_parent_without_deselecting_the_document() #[test] fn enter_selects_a_stroke_style_and_escape_closes_its_popups_without_committing_pen() { let mut app = app(Tool::Pen); - app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(&app); + app.ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[12.0, 12.0]; 3], [[60.0, 40.0]; 3]], owner, ..Default::default() }); let pen = app.ui.pen.clone(); let original = app.session.active().unwrap().doc.clone(); let history = app.session.active().unwrap().history.past_len(); diff --git a/crates/ui-egui/src/tools/stroke_trace.rs b/crates/ui-egui/src/tools/stroke_trace.rs index 809414ed49..8bbd1c887a 100644 --- a/crates/ui-egui/src/tools/stroke_trace.rs +++ b/crates/ui-egui/src/tools/stroke_trace.rs @@ -11,9 +11,14 @@ //! submission, without the vsync wait). What the app can't see (the OS input queue before a frame //! starts, frames queued for the display, the compositor) is the rest of the input-to-photon time; //! the summary names the GPU backend because GL ignores the frame-latency setting. +//! +//! The trace keeps no clock of its own: each row is the frame's `frame_stages::FrameStages`, +//! which the app gathers every frame (the slow-frame ring reads the same record). use std::fmt::Write as _; +use crate::frame_stages::FrameStages; + /// One frame of a stroke. Times in ms. #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct Row { @@ -33,6 +38,13 @@ pub(crate) struct Row { pub cpu_prev: Option, } +impl Row { + /// The trace's row for a frame's stages. + pub(crate) fn from_stages(s: &FrameStages, interval: f64) -> Self { + Self { t: s.t, interval, moves: s.moves, stroke: s.stroke_ms, canvas: s.composite_ms + s.upload_ms, ui: s.ui_ms, cpu_prev: s.cpu_prev_ms } + } +} + /// Percentiles of a stroke's frames, for the summary line and the control channel. #[derive(Clone, Debug, Default, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -86,8 +98,6 @@ pub(crate) enum Output { #[derive(Debug, Default)] pub struct StrokeTrace { pub(crate) output: Output, - /// The frame being built (started by `begin_frame`). - frame: Option, last_start: Option, /// The current stroke's frames. rows: Vec, @@ -143,49 +153,20 @@ impl StrokeTrace { self.output != Output::Off } - /// A frame starts (the raw input hook) with `moves` pointer moves. - pub fn begin_frame(&mut self, now: f64, moves: usize) { + /// A frame ended, with the stages the app gathered for it (`frame_stages`; the trace keeps + /// no clock of its own). `stroking`: a stroke is being drawn (a frame that rendered stroke + /// points counts too, such as the release's commit). A stroke's frames are kept until the + /// first frame without one, which ends it. + pub fn frame(&mut self, s: &FrameStages, stroking: bool, backend: impl FnOnce() -> String) { if !self.enabled() { return; } - let interval = self.last_start.map_or(0.0, |t| now - t); - self.last_start = Some(now); - self.frame = Some(Row { t: now, interval, moves, ..Default::default() }); - } - - /// eframe's CPU time for the previous frame, in seconds (`Frame::info().cpu_usage`). - pub fn cpu_prev(&mut self, secs: Option) { - if let Some(f) = self.frame.as_mut() { - f.cpu_prev = secs.map(|s| f64::from(s) * 1000.0); - } - } - - /// Time spent rendering stroke points this frame. - pub fn stroke(&mut self, ms: f64) { - if let Some(f) = self.frame.as_mut() { - f.stroke += ms; - } - } - - /// Time spent refreshing the canvas this frame. - pub fn canvas(&mut self, ms: f64) { - if let Some(f) = self.frame.as_mut() { - f.canvas += ms; - } - } - - /// The frame's UI is built (`ui` ms). `stroking`: a stroke is being drawn (a frame that - /// rendered stroke points counts too, such as the release's commit). A stroke's frames are - /// kept until the first frame without one, which ends it. - pub fn end_frame(&mut self, ui: f64, stroking: bool, backend: impl FnOnce() -> String) { - let Some(mut f) = self.frame.take() else { return }; - if stroking || f.stroke > 0.0 { - f.ui = ui; + let interval = self.last_start.map_or(0.0, |t| s.t - t); + self.last_start = Some(s.t); + if stroking || s.stroke_ms > 0.0 { // The first frame of a stroke doesn't follow a stroke frame: its interval is idle time. - if self.rows.is_empty() { - f.interval = 0.0; - } - self.rows.push(f); + let interval = if self.rows.is_empty() { 0.0 } else { interval }; + self.rows.push(Row::from_stages(s, interval)); } else if !self.rows.is_empty() { self.finish(backend()); } @@ -251,14 +232,38 @@ mod tests { StrokeTrace { output: Output::Stderr, ..Default::default() } } - fn frame(t: &mut StrokeTrace, now: f64, cpu: f32, stroking: bool) { - t.begin_frame(now, 3); - t.cpu_prev(Some(cpu / 1000.0)); + fn stages(now: f64, cpu: f32, stroking: bool) -> FrameStages { + let mut s = FrameStages::default(); + s.start(now, 3); + s.ui_start(now, Some(cpu / 1000.0)); if stroking { - t.stroke(1.0); - t.canvas(2.0); + s.stroke_ms = 1.0; + s.refresh("rect", 10, 1.5, 0.5); } - t.end_frame(4.0, stroking, || "vulkan Test GPU".into()); + s.ui_ms = 4.0; + s + } + + fn frame(t: &mut StrokeTrace, now: f64, cpu: f32, stroking: bool) { + t.frame(&stages(now, cpu, stroking), stroking, || "vulkan Test GPU".into()); + } + + #[test] + fn stroke_trace_reads_the_shared_frame_stages() { + let mut t = on(); + let mut ring = crate::frame_stages::SlowFrames::default(); + let mut last = None; + for (k, now) in [100.0, 116.0, 132.0].into_iter().enumerate() { + let s = ring.finish(stages(now, 6.0, true), now + 5.0, false, None); + t.frame(&s, true, || "test".into()); + let interval = if k == 0 { 0.0 } else { 16.0 }; + let row = t.rows.last().expect("a stroke frame").clone(); + assert_eq!((row.t, row.interval, row.moves, row.stroke, row.canvas, row.ui), (s.t, interval, 3, 1.0, 2.0, 4.0)); + assert_eq!(row.cpu_prev, s.cpu_prev_ms); + assert_eq!(t.rows.last(), Some(&Row::from_stages(&s, interval)), "the row is the frame's stages"); + last = Some(s); + } + assert_eq!(last.map(|s| s.composite_ms + s.upload_ms), Some(2.0)); } #[test] diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index 6381244af3..4a6ee2c2d8 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -156,7 +156,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { }}; } match tool { - Tool::Move => reset!(move_auto_select, move_target, move_show_transform), + Tool::Move => reset!(move_auto_select, move_target, move_show_transform, transform_interpolation), 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), @@ -208,10 +208,26 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), Tool::Slice => reset!(slice_style, slice_width, slice_height), - Tool::Pen => reset!(vector_mode, pen_auto_add_delete), - Tool::CurvaturePen => reset!(vector_mode), - Tool::FreeformPen => reset!(vector_mode, curve_fit), - Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { + Tool::Pen => reset!(vector_mode, pen_path_op, pen_auto_add_delete), + Tool::CurvaturePen => reset!(vector_mode, pen_path_op), + Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit), + Tool::Line => reset!( + vector_mode, + shape_fill, + stroke_width, + shape_stroke, + shape_fill_gradient, + shape_stroke_gradient, + corner_radius, + polygon_sides, + line_weight, + arrow_start, + arrow_end, + arrow_width, + arrow_length, + arrow_concavity, + ), + Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 60fc45d993..0ad888536d 100644 --- a/crates/ui-egui/src/tools/transform_fx.rs +++ b/crates/ui-egui/src/tools/transform_fx.rs @@ -256,7 +256,8 @@ pub fn compose(doc: &Document, work: &Layer, plan: &Plan, source: &Source) -> Op let band = Rect::new(shown.x0, y0, shown.x1, y0.saturating_add((px.len() / width.max(1)) as i32)); let mut over = Buffer::transparent(band); effects::composite_with_effects(work, &crop(&content, band), &mut over, &maps, plan.bounds, &doc.patterns); - for (o, p) in px.iter_mut().zip(over.px) { + // Copied, not moved out, so `over` hands its band back to the scratch pool on drop. + for (o, p) in px.iter_mut().zip(over.px.iter().copied()) { *o = p; } }); diff --git a/crates/ui-egui/src/tools/transform_tool.rs b/crates/ui-egui/src/tools/transform_tool.rs index 672bd82868..bf11ca303e 100644 --- a/crates/ui-egui/src/tools/transform_tool.rs +++ b/crates/ui-egui/src/tools/transform_tool.rs @@ -32,7 +32,10 @@ mod preview; mod warp; pub(crate) use begin::begin_content_aware; -pub use begin::{begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type}; +pub(crate) use begin::moving_layers; +#[cfg(test)] +pub(crate) use begin::moving_texel; +pub use begin::{WARP_ONE_LAYER, begin, begin_copy, begin_placed, begin_selection, begin_warp, enter_warp, leave_warp, warps_type}; pub(crate) use commit::{SAVE_FOR_WEB, committing, nudge}; pub use commit::{cancel, commit, end_if_left, follow_edits, intercept, is_enabled, on_job_event, track_steps}; #[cfg(test)] @@ -45,7 +48,7 @@ pub(crate) use draw::draw_handle_cursor; use draw::scale_values; #[cfg(test)] pub(crate) use draw::{Readout, live_readout, styled_frame}; -pub(crate) use draw::{carries_selection_of, draw_selection}; +pub(crate) use draw::{carries_selection_of, selection_ants}; pub use draw::{draw_content, draw_overlay}; #[cfg(test)] use draw::{front_vertices, handle_anchor}; @@ -60,10 +63,10 @@ pub(crate) use pointer::{Gesture, Hit, hit, rotate_cursor_dir}; #[cfg(test)] use pointer::{accepts_outside_click, carry_pivot, near_rotate_corner, point_on_quad, rotation_degrees}; pub use pointer::{cursor, pointer}; -use preview::Above; pub(crate) use preview::solo_layer; +use preview::{Above, composite_image, show_only}; #[cfg(test)] -use preview::{covering, preview_image, show_only}; +use preview::{covering, preview_image}; pub use warp::{edit_session_warp, split}; #[cfg(test)] use warp::{on_section_edge, single_patch, split_cursor, surface_at, warp_hit, warp_pointer}; @@ -137,15 +140,94 @@ pub struct TransformPreview { committing: Option, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option, + /// A box over several layers (#6501): their composite, which `texture` holds. + several: Option, +} + +/// The moving pixels of a box over several layers ([`begin_several`]): the layers composited on +/// their own, with their styles, opacity and masks. Composited again when an edit made while the +/// box is open changes how they look ([`follow_edits`]), since nothing is tinted at draw time. +struct Several { + ctx: egui::Context, + name: String, + max_side: usize, + ids: Vec, + /// The pixels a selection lifted out of moving pixel layers, as the box opened. Every other + /// moving layer (groups, type, shapes, smart objects, pixel layers without a selection) moves + /// whole, so the live document holds what it moves, children and their looks included. + lifted: Vec<(LayerId, photocraft_raster::Surface)>, + /// The document showing only the moving layers, as last composited. + moving: Document, + /// The box's frame. + frame: photocraft_geom::Rect, + /// The document area the texture covers: the frame grown by the reach of the moving layers' + /// styles, so a shadow, glow or outside stroke past their content moves with the box. + rect: photocraft_geom::Rect, +} + +impl Several { + /// `live` showing only the layers `ids`, with the `lifted` pixels in place of their layers'. + /// Unclipped: a clipping base that isn't moving would clip them away (#5239). + fn shown(live: &Document, ids: &[LayerId], lifted: &[(LayerId, photocraft_raster::Surface)]) -> Document { + let mut d = live.clone(); + show_only(&mut d.layers, ids); + for id in ids { + if let Some(path) = d.path_of(*id) { + for n in 1..path.len() { + if let Some(ancestor) = path.get(..n).and_then(|p| d.layer_at_mut(p)) { + ancestor.clipped = false; + } + } + } + if let Some(l) = d.layer_mut(*id) { + l.clipped = false; + } + } + for (id, surf) in lifted { + if let Some(s) = d.layer_mut(*id).and_then(|l| l.surface_mut()) { + *s = surf.clone(); + } + } + d + } + + /// Composite the moving layers over their frame and their styles' reach. + fn render(&mut self) -> crate::transform_tex::PreviewTextures { + self.rect = self.frame.inflate(style_reach(&self.moving, &self.ids)); + let (image, uv) = composite_image(&self.moving, self.rect, self.max_side); + crate::transform_tex::PreviewTextures::new(&self.ctx, self.name.clone(), image, uv) + } +} + +/// How far past their content the styles of the layers `ids` can draw, in document pixels: their +/// own, the layers' inside them and the groups' around them, since a group's style draws around +/// its children's composite and so adds to theirs. +fn style_reach(doc: &Document, ids: &[LayerId]) -> i32 { + let reach_of = + |l: &photocraft_doc::Layer| if l.visible && photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l).max(0) } else { 0 }; + // The groups around each layer: everything on its path but itself. + let around = + |id: LayerId| doc.path_of(id).map_or(0, |p| (1..p.len()).filter_map(|n| doc.layer_at(p.get(..n)?)).fold(0i32, |r, g| r.saturating_add(reach_of(g)))); + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack: Vec<(&photocraft_doc::Layer, i32)> = ids.iter().filter_map(|id| Some((doc.layer(*id)?, around(*id)))).collect(); + let mut reach = 0; + while let Some((l, outer)) = stack.pop() { + let here = outer.saturating_add(reach_of(l)); + reach = reach.max(here); + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().map(|c| (c, here))); + } + } + reach } /// What [`follow_edits`] needs to rebuild the preview document. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct Follow { /// The document revision `doc` was built from. revision: u64, - /// The moving layer is hidden in `doc` (nothing was lifted out of it). - hidden: bool, + /// The moving layers hidden in `doc` (nothing was lifted out of them). + hidden: Vec, } /// Photoshop's alert when Free Transform (⌘T, Edit › Transform, the Move tool's transform controls) @@ -229,9 +311,19 @@ impl Steps { } } -fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { +pub(crate) fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] } +/// The transform choices supported by the options bar, using its remembered choice or the general default. +pub(crate) fn default_interpolation(app: &PhotocraftApp) -> &'static str { + use photocraft_engine::prefs::Interpolation; + match app.ui.tool_options.transform_interpolation.unwrap_or(app.session.prefs().general.image_interpolation) { + Interpolation::Nearest => "nearest", + Interpolation::Bilinear => "bilinear", + _ => "bicubic", + } +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/tools/transform_tool/begin.rs b/crates/ui-egui/src/tools/transform_tool/begin.rs index 44bea250aa..8411f747ff 100644 --- a/crates/ui-egui/src/tools/transform_tool/begin.rs +++ b/crates/ui-egui/src/tools/transform_tool/begin.rs @@ -1,8 +1,10 @@ -use super::commit::{commit, start_steps}; +use super::commit::{cancel, commit, start_steps}; +#[cfg(test)] +use super::draw::texture_quad; use super::preview::{Above, preview_image}; use super::{ - Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Steps, - TransformPreview, TransformSession, Warp, WarpStyle, corners, json, + Arc, BezierMesh, CAS_ONLY_SCALES, Color32, DocId, Document, Follow, Homography, LOCKED, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Steps, + TransformPreview, TransformSession, Warp, WarpStyle, corners, default_interpolation, json, }; /// Start Free Transform on the active layer (or its selected pixels), or on the targeted unlinked @@ -11,6 +13,9 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> if let Some((params, path)) = crate::vector_ui::free_transform_path(app) { return begin_path(app, ctx, params, path); } + if crate::canvas::multiple_color_target(app) { + return Err("Move and transforms on several colour channels are not implemented yet".into()); + } if app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()) { app.run("select.drop", json!({}))?; } @@ -22,6 +27,15 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> return begin_lone(app, ctx, doc, surf, target); } let id = st.active_layer.ok_or("no active layer")?; + // Several layers selected: one box moves them all, as in Photoshop (#6501). + let targets = photocraft_engine::transform_cmds::selection_targets(st); + // The selection takes its linked layers along, as `edit.transform` does: in Photoshop the box + // over one linked layer frames and moves the whole link group (#11217), but not a linked + // Background (`box_targets`). Unlinked, unchanged. + let targets = photocraft_engine::transform_cmds::box_targets(&doc, &targets); + if targets != [id] { + return begin_several(app, ctx, doc, revision, id, targets); + } let layer = doc.layer(id).ok_or("no layer")?; if photocraft_engine::transform_cmds::locked_for_transform(&doc, id) { // Photoshop refuses before the box opens rather than when it is committed (#2585). @@ -69,7 +83,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> opacity: layer.opacity * layer.fill_opacity, above: Some(above), styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), - follow: Some(Follow { revision, hidden: lifted.is_none() }), + follow: Some(Follow { revision, hidden: if lifted.is_none() { vec![id] } else { Vec::new() } }), committing: None, gesture: None, warp_drag: None, @@ -81,6 +95,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -88,7 +103,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> rect, quad: corners(rect), pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], - interpolation: "bicubic".into(), + interpolation: default_interpolation(app).into(), warp: None, selection: false, target: None, @@ -97,15 +112,162 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) } +pub(super) fn interpolation_preference(interpolation: &str) -> Option { + use photocraft_engine::prefs::Interpolation; + match interpolation { + "nearest" => Some(Interpolation::Nearest), + "bilinear" => Some(Interpolation::Bilinear), + "bicubic" => Some(Interpolation::Bicubic), + _ => None, + } +} + +/// Free Transform of the selected layers `ids` together (#6501): the box frames them all, and the +/// preview moves their composite, drawn over the layers they cover. `active` stays the session's +/// `layer`, so picking another layer still applies the box. The selected pixels of each pixel +/// layer move when there is a selection; type, shapes, smart objects and groups move whole. +fn begin_several(app: &mut PhotocraftApp, ctx: &egui::Context, doc: Arc, revision: u64, active: LayerId, ids: Vec) -> Result<(), String> { + use photocraft_engine::transform_cmds as tc; + if ids.iter().any(|id| tc::locked_for_transform(&doc, *id)) { + app.ui.open_dialog(crate::state::DialogKind::Error, json!({"message": tl!(LOCKED)}).as_object().cloned().unwrap_or_default()); + return Err(LOCKED.into()); + } + let b = tc::transform_frame_of(&doc, &ids); + let top = *ids.last().ok_or("no layer")?; + if b.is_empty() { + return Err("Could not transform: the layers are empty".into()); + } + crate::type_tool::commit(app); + let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]; + let session = app.ui.alloc_id(); + // `under`: the canvas under the box, without the moving pixels. `moving`: those pixels alone. + let mut under = (*doc).clone(); + let mut lifted = Vec::new(); + let mut hidden = Vec::new(); + for &id in &ids { + let Some(l) = doc.layer(id) else { continue }; + match (doc.selection.as_ref().filter(|_| !tc::moves_whole(l)), l.surface()) { + (Some(sel), Some(surf)) => { + let (part, rest) = tc::split_selected(surf, sel).map_err(|e| e.to_string())?; + if let Some(s) = under.layer_mut(id).and_then(|l| l.surface_mut()) { + *s = rest; + } + lifted.push((id, part)); + } + _ => { + if let Some(l) = under.layer_mut(id) { + l.visible = false; + } + hidden.push(id); + } + } + } + let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side())); + let above = Above::new(ctx, format!("transform-{session}-above"), &doc, top, max_side); + above.hide_in(&mut under.layers); + // Opacity, fill, masks and styles are in the composite already. + let mut several = Several { + ctx: ctx.clone(), + name: format!("transform-{session}"), + max_side, + moving: Several::shown(&doc, &ids, &lifted), + lifted, + ids: ids.clone(), + frame: b, + rect: b, + }; + let texture = several.render(); + app.transform_preview = Some(TransformPreview { + session, + doc: Arc::new(under), + texture, + opacity: 1.0, + above: Some(above), + styled: None, + follow: Some(Follow { revision, hidden }), + committing: None, + gesture: None, + warp_drag: None, + split_tool: None, + split_pointer: None, + split_placing: false, + split_quick: false, + steps: Steps::default(), + tool: app.ui.tool, + path: None, + content_aware: None, + several: Some(several), + }); + app.ui.transform = Some(TransformSession { + session, + layer: active.0, + rect, + quad: corners(rect), + pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], + interpolation: default_interpolation(app).into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + content_aware: None, + skew_v: 0.0, + layers: ids.iter().map(|id| id.0).collect(), + }); + start_steps(app); + Ok(()) +} + +/// The moving texture's texel at document point `p` as the box opened (full resolution), and +/// where the texture's corners are drawn now: what a test of a box over several layers sees. +#[cfg(test)] +pub(crate) fn moving_texel(app: &PhotocraftApp, p: [i32; 2]) -> (Option, [[f64; 2]; 4]) { + let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return (None, [[0.0; 2]; 4]) }; + let r = pv.several.as_ref().map_or(photocraft_geom::Rect::new(t.rect[0] as i32, t.rect[1] as i32, t.rect[2] as i32, t.rect[3] as i32), |sv| sv.rect); + let texel = pv.texture.level_image(0).and_then(|(image, _)| { + let (x, y) = (usize::try_from(p[0] - r.x0).ok()?, usize::try_from(p[1] - r.y0).ok()?); + (x < image.size[0] && y < image.size[1]).then(|| image.pixels[y * image.size[0] + x]) + }); + (texel, texture_quad(t, pv)) +} + +/// The layers the box moves: every layer of a several-layer session (#6501), else its one layer. +pub(crate) fn moving_layers(t: &TransformSession) -> Vec { + if t.layers.is_empty() { vec![LayerId(t.layer)] } else { t.layers.iter().copied().map(LayerId).collect() } +} + +/// Is any of the layers the box moves of this kind? +pub(super) fn moves_kind(app: &PhotocraftApp, t: &TransformSession, kind: fn(&LayerContent) -> bool) -> bool { + app.session.active().is_some_and(|st| moving_layers(t).iter().any(|id| st.doc.layer(*id).is_some_and(|l| kind(&l.content)))) +} + +/// Why Warp refuses with several layers selected. +pub const WARP_ONE_LAYER: &str = "Warp works on one layer at a time: select a single layer"; + /// Free Transform on a copy (⌥⌘T, no menu item): duplicates the active layer (with a selection, /// its selected pixels, as Layer via Copy) and transforms the copy (#352). pub fn begin_copy(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { let selection = app.session.active().is_some_and(|d| d.doc.selection.is_some()); + // A linked layer without a selection: the reference application makes no copy and frames the + // whole link group, as Free Transform does (measured, #11217). Unlinked layers get the copy, + // and so does a targeted mask (a lone target). + let target = crate::canvas::paint_target(app); + let linked = !selection + && app.session.active().is_some_and(|st| { + let lone = photocraft_engine::transform_cmds::lone_target(&st.doc, st.active_layer, &json!({ "target": target })); + let targets = photocraft_engine::transform_cmds::selection_targets(st); + matches!(lone, Ok(None)) && photocraft_engine::transform_cmds::box_targets(&st.doc, &targets) != targets + }); + if linked { + return begin(app, ctx); + } let params = if selection { json!({}) } else { json!({"inPlace": true}) }; app.run(if selection { "layer.new.layerViaCopy" } else { "layer.duplicate" }, params)?; let made = app.session.active().and_then(|st| st.active_layer.map(|layer| (st.doc.id, layer))); @@ -169,7 +331,7 @@ pub(crate) fn begin_content_aware( texture, opacity: layer.opacity * layer.fill_opacity, above: Some(above), - follow: Some(Follow { revision, hidden: false }), + follow: Some(Follow { revision, hidden: Vec::new() }), committing: None, gesture: None, warp_drag: None, @@ -182,6 +344,7 @@ pub(crate) fn begin_content_aware( path: None, content_aware: Some(live), styled: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -189,7 +352,7 @@ pub(crate) fn begin_content_aware( rect, quad: corners(rect), pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], - interpolation: "bicubic".into(), + interpolation: default_interpolation(app).into(), warp: None, selection: false, target: None, @@ -198,6 +361,7 @@ pub(crate) fn begin_content_aware( mode: Default::default(), content_aware: Some(Default::default()), skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -308,6 +472,7 @@ fn begin_lone( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -315,7 +480,7 @@ fn begin_lone( rect, quad: corners(rect), pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], - interpolation: "bicubic".into(), + interpolation: default_interpolation(app).into(), warp: None, selection: false, target: Some(target), @@ -324,6 +489,7 @@ fn begin_lone( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -361,6 +527,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: above: None, follow: None, committing: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -368,7 +535,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: rect, quad: corners(rect), pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], - interpolation: "bicubic".into(), + interpolation: default_interpolation(app).into(), warp: None, selection: false, target: None, @@ -377,6 +544,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -446,6 +614,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( tool: app.ui.tool, path: None, content_aware: None, + several: None, }); app.ui.transform = Some(TransformSession { session, @@ -453,7 +622,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( rect, quad: corners(rect), pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], - interpolation: "bilinear".into(), + interpolation: default_interpolation(app).into(), warp: None, selection: true, target: None, @@ -462,6 +631,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }); start_steps(app); Ok(()) @@ -472,9 +642,16 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St if app.ui.transform.as_ref().is_some_and(|t| t.content_aware.is_some()) { return Err(CAS_ONLY_SCALES.into()); } + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + return Err(WARP_ONE_LAYER.into()); + } let fresh = app.ui.transform.is_none(); if fresh { begin(app, ctx)?; + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + cancel(app); + return Err(WARP_ONE_LAYER.into()); + } } enter_warp(app, ctx); // Started in Warp: that is where Undo stops. Switching an open transform to Warp is a step. @@ -488,14 +665,19 @@ pub fn begin_warp(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), St /// custom mesh following the current box (so a transform made first carries over). Editable type /// gets its own Warp Text style instead ([`enter_type_warp`]). pub fn enter_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { + // Several layers keep the box (#6501), even when the active one is type. + if app.ui.transform.as_ref().is_some_and(|t| !t.layers.is_empty()) { + return; + } if app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()) && warps_type(app) { return enter_type_warp(app, ctx); } let existing = app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space)); let Some(t) = app.ui.transform.as_mut() else { return }; - // Warp moves layers and paths (#3027): a lone mask or channel keeps the box. - if t.warp.is_some() || t.target.is_some() { + // Warp moves layers and paths (#3027): a lone mask or channel keeps the box, and so do + // several layers (#6501). + if t.warp.is_some() || t.target.is_some() || !t.layers.is_empty() { return; } if t.quad == corners(t.rect) diff --git a/crates/ui-egui/src/tools/transform_tool/commit.rs b/crates/ui-egui/src/tools/transform_tool/commit.rs index eebc7120ce..c00a59e971 100644 --- a/crates/ui-egui/src/tools/transform_tool/commit.rs +++ b/crates/ui-egui/src/tools/transform_tool/commit.rs @@ -1,7 +1,8 @@ -use super::begin::{fold_made, rect_to_quad_affine, take_back_made, warps_type}; +use super::begin::{fold_made, moves_kind, moving_layers, rect_to_quad_affine, take_back_made, warps_type}; use super::options_bar::{rotate_about_pivot, scale_about_pivot}; use super::{ - Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Step, Steps, TransformPreview, TransformSession, corners, json, + Arc, DocId, JobEvent, JobId, JobOutcome, LayerContent, LayerId, MadeLayer, PhotocraftApp, Several, Step, Steps, TransformPreview, TransformSession, + corners, json, }; /// Undo and Redo while the commit runs in the background (Free Transform, or Warp). @@ -69,7 +70,12 @@ pub fn commit(app: &mut PhotocraftApp) { if t.quad == corners(t.rect) { return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays } - let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + let mut p = json!({"rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + if t.layers.is_empty() { + p["layer"] = json!(t.layer); + } else { + p["layers"] = json!(t.layers); + } if let Some(target) = &t.target { p["target"] = target.clone(); } @@ -164,7 +170,7 @@ pub fn end_if_left(app: &mut PhotocraftApp) { if editing_placed_smart_object { app.ui.transform = None; app.transform_preview = None; - } else if switched_document || st.doc.layer(layer).is_none() { + } else if switched_document || moving_layers(t).iter().any(|id| st.doc.layer(*id).is_none()) || st.doc.layer(layer).is_none() { cancel(app); } else if switched_away(app) { commit(app); @@ -194,18 +200,33 @@ pub fn follow_edits(app: &mut PhotocraftApp) { let (Some(t), Some(pv)) = (&app.ui.transform, app.transform_preview.as_mut()) else { return }; let Some(follow) = pv.follow.as_mut() else { return }; let Some(st) = app.session.active().filter(|st| st.doc.id == pv.doc.id && st.revision != follow.revision) else { return }; - let id = LayerId(t.layer); - let (Some(live), Some(moving)) = (st.doc.layer(id), pv.doc.layer(id)) else { return }; + let ids = moving_layers(t); let mut pd = (*st.doc).clone(); - if let Some(l) = pd.layer_mut(id) { - l.content = moving.content.clone(); - l.visible = l.visible && !follow.hidden; + for id in &ids { + let (Some(_), Some(moving)) = (st.doc.layer(*id), pv.doc.layer(*id)) else { return }; + if let Some(l) = pd.layer_mut(*id) { + l.content = moving.content.clone(); + l.visible = l.visible && !follow.hidden.contains(id); + } } - if let Some(a) = pv.above.as_mut() { - a.rebuild(&st.doc, id); + if let (Some(a), Some(top)) = (pv.above.as_mut(), ids.last()) { + a.rebuild(&st.doc, *top); a.hide_in(&mut pd.layers); } - pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; + // Several layers' opacity, fill, visibility and styles are in their composite: composite them + // again when an edit changed how they look. + if let Some(sv) = pv.several.as_mut() { + let now = Several::shown(&st.doc, &sv.ids, &sv.lifted); + if now.layers != sv.moving.layers { + sv.moving = now; + pv.texture = sv.render(); + } + } + if t.layers.is_empty() + && let Some(live) = st.doc.layer(LayerId(t.layer)) + { + pv.opacity = if live.visible { live.opacity * live.fill_opacity } else { 0.0 }; + } follow.revision = st.revision; pv.doc = Arc::new(pd); } @@ -263,6 +284,12 @@ pub(crate) const SAVE_FOR_WEB: &str = "file.export.saveForWebLegacy"; /// While transforming, Undo and Redo apply to the box: `Some(enabled)` for those commands (and /// Toggle Last State, which would change the document under the box), `None` for the rest. pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { + if (matches!(id, "edit.freeTransform" | "edit.freeTransformCopy") || id.starts_with("edit.transform.")) + && crate::vector_ui::free_transform_path(app).is_none() + && crate::canvas::multiple_color_target(app) + { + return Some(false); + } let t = app.ui.transform.as_ref()?; if committing(app).is_some() { return box_command(id).then_some(false); @@ -280,6 +307,17 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { SAVE_FOR_WEB => Some(false), // Rotate and Flip turn the box (not while warping, where the mesh is the box). _ if box_preset(id).is_some() => Some(t.warp.is_none()), + // One box over several layers doesn't warp (#6501). + "edit.transform.warp" + | "layer.smartObjects.warp" + | "edit.transform.splitWarpCrosswise" + | "edit.transform.splitWarpHorizontally" + | "edit.transform.splitWarpVertically" + | "edit.transform.removeWarpSplit" + if !t.layers.is_empty() => + { + Some(false) + } // Editable type can't be distorted (#2630, as #2701 rules for the menus outside a box). "edit.transform.distort" | "edit.transform.perspective" if moves_type(app, t) => Some(false), _ => None, @@ -288,10 +326,7 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { /// Does the box move an editable type layer (not its selection outline, a lone mask or a path)? pub(super) fn moves_type(app: &PhotocraftApp, t: &TransformSession) -> bool { - !t.selection - && t.target.is_none() - && t.path.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + !t.selection && t.target.is_none() && t.path.is_none() && moves_kind(app, t, |c| matches!(c, LayerContent::Text(_))) } /// Edit › Transform's Rotate and Flip presets while a box is open: what they do to the box, as diff --git a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs index 0114e57754..bdd01986e3 100644 --- a/crates/ui-egui/src/tools/transform_tool/drag_modes.rs +++ b/crates/ui-egui/src/tools/transform_tool/drag_modes.rs @@ -1,5 +1,6 @@ +use super::begin::moves_kind; use super::pointer::{Gesture, Hit, carry_pivot}; -use super::{Homography, LayerContent, LayerId, PhotocraftApp, TransformMode, TransformSession}; +use super::{Homography, LayerContent, PhotocraftApp, TransformMode, TransformSession}; /// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion /// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. @@ -31,9 +32,10 @@ pub(super) enum HandleIndicator { /// What dragging handle `h` with `mods` does, worked out by the same rules as the drag itself /// ([`drag_mods`], [`mode_mods`], [`apply_mode_drag`]): ⌘ on a corner distorts (⌘⌥⇧ adds -/// perspective) and on an edge skews, Distort and Perspective modes distort from the corners, -/// Skew mode skews from the edges. Rotate mode turns the box whatever the keys, a Skew-mode corner -/// slides along an edge, editable type refuses Distort and Content-Aware Scale only scales: `None`. +/// perspective) and on an edge skews, Distort mode distorts from every handle, Perspective mode +/// distorts from the corners and skews from the edges, Skew mode skews from the edges. Rotate +/// mode turns the box whatever the keys, a Skew-mode corner slides along an edge, editable type +/// refuses Distort and Content-Aware Scale only scales: `None`. pub(super) fn handle_indicator(app: &PhotocraftApp, t: &TransformSession, h: Hit, mods: egui::Modifiers) -> Option { if t.warp.is_some() || t.content_aware.is_some() || !mode_allows(t.mode, h) { return None; @@ -41,7 +43,8 @@ pub(super) fn handle_indicator(app: &PhotocraftApp, t: &TransformSession, h: Hit let (mode, mods) = drag_mods(app, t, h, mods); match (mode, h) { (TransformMode::Rotate, _) | (TransformMode::Skew, Hit::Corner(_)) => None, - (TransformMode::Skew, Hit::Edge(_)) => Some(HandleIndicator::Skew), + (TransformMode::Distort, Hit::Edge(_)) => Some(HandleIndicator::Distort), + (TransformMode::Skew | TransformMode::Perspective, Hit::Edge(_)) => Some(HandleIndicator::Skew), (_, Hit::Corner(_)) if mode_mods(mode, h, mods).command => Some(HandleIndicator::Distort), (_, Hit::Edge(_)) if mods.command => Some(HandleIndicator::Skew), _ => None, @@ -52,10 +55,7 @@ pub(super) fn handle_indicator(app: &PhotocraftApp, t: &TransformSession, h: Hit /// Distort and Perspective in `enable_rules::disabled`, for the box's own layer. A box on a path, /// on the selection outline or on a lone mask or channel moves no type. fn distort_refused(app: &PhotocraftApp, t: &TransformSession) -> bool { - t.path.is_none() - && !t.selection - && t.target.is_none() - && app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Text(_))) + t.path.is_none() && !t.selection && t.target.is_none() && moves_kind(app, t, |c| matches!(c, LayerContent::Text(_))) } /// Where Distort and Perspective are refused ([`distort_refused`]), ⌘-drag and ⌘⌥⇧-drag of a @@ -157,23 +157,45 @@ pub(crate) fn apply_mode_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], g.hit = Hit::Outside; apply_drag(s, g, p, mods); } + // Distort (#10085): a side handle moves its edge freely, both of its corners by the drag + // and the opposite edge staying put, up to where the box would fold over, as a corner + // does (#1323). Like a Distort corner, it takes no ⇧ or ⌥ constraint. + (TransformMode::Distort, Hit::Edge(i)) if i < 4 => { + let (d, q0) = ([p[0] - g.start[0], p[1] - g.start[1]], g.quad0); + let mut q = q0; + for k in [i, (i + 1) % 4] { + if let (Some(dst), Some(src)) = (q.get_mut(k), q0.get(k)) { + *dst = [src[0] + d[0], src[1] + d[1]]; + } + } + s.quad = keep_convex(q0, q); + s.pivot = carry_pivot(q0, s.quad, g.pivot0); + } // Skew: an edge slides only along itself (⌘⇧ in Free Transform). (TransformMode::Skew, Hit::Edge(_)) => { mods.command = true; mods.shift = true; apply_drag(s, g, p, mods); } + // Perspective's side handles, as in Photoshop: the edge slides along itself, stopping + // before the box folds over (#1323). That happens once the opposite edge isn't parallel, + // as after a corner drag on the other axis. + (TransformMode::Perspective, Hit::Edge(_)) => { + mods.command = true; + mods.shift = true; + apply_drag(s, g, p, mods); + s.quad = keep_convex(g.quad0, s.quad); + s.pivot = carry_pivot(g.quad0, s.quad, g.pivot0); + } _ => apply_drag(s, g, p, mode_mods(mode, g.hit, mods)), } } -/// What a drag can do in each Edit › Transform mode: in Distort only the corners (and moving the -/// box from inside); in Skew everything but rotating from outside the box (#3437). A click -/// outside still commits. +/// What a drag can do in each Edit › Transform mode: in Distort and Skew everything but rotating +/// from outside the box (#3437, #10085). A click outside still commits. pub(super) fn mode_allows(mode: TransformMode, h: Hit) -> bool { match mode { - TransformMode::Distort => !matches!(h, Hit::Outside | Hit::Edge(_)), - TransformMode::Skew => h != Hit::Outside, + TransformMode::Distort | TransformMode::Skew => h != Hit::Outside, _ => true, } } @@ -233,10 +255,10 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods let a1 = (p[1] - g.pivot0[1]).atan2(p[0] - g.pivot0[0]); let mut da = a1 - a0; if mods.shift { - // Snap the resulting angle to 15° steps. - let base = (g.quad0[1][1] - g.quad0[0][1]).atan2(g.quad0[1][0] - g.quad0[0][0]); + // Snap the turn made by this drag to 15° steps; the angle the box started at is + // kept (#11099). let step = 15f64.to_radians(); - da = ((base + da) / step).round() * step - base; + da = (da / step).round() * step; } let (sn, cs) = da.sin_cos(); let c = g.pivot0; diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs index fc1babfbdb..7a012db134 100644 --- a/crates/ui-egui/src/tools/transform_tool/draw.rs +++ b/crates/ui-egui/src/tools/transform_tool/draw.rs @@ -21,6 +21,12 @@ pub(crate) fn draw_handle_cursor(app: &PhotocraftApp, ctx: &egui::Context, xf: & draw_distort_cursor(ctx, at, scr(*q) - scr(t.pivot)); } } + // Distort mode's side handles (#10085): the same pointer, out through the edge's middle. + (Hit::Edge(i), Some(HandleIndicator::Distort)) => { + if let (Some(a), Some(b)) = (t.quad.get(i), t.quad.get((i + 1) % 4)) { + draw_distort_cursor(ctx, at, scr(*a).lerp(scr(*b), 0.5) - scr(t.pivot)); + } + } (Hit::Edge(i), Some(HandleIndicator::Skew)) => { if let (Some(a), Some(b)) = (t.quad.get(i), t.quad.get((i + 1) % 4)) { draw_skew_cursor(ctx, at, scr(*b) - scr(*a)); @@ -110,7 +116,7 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); - } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) { + } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], texture_quad(t, pv)) { // A 24×24 grid keeps perspective previews straight. let n = 24; let zoom = xf.zoom * painter.ctx().pixels_per_point(); @@ -147,6 +153,18 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// Where the box puts the moving texture's corners: the box's own, or, when the texture covers +/// more than the frame (the styles of a box over several layers reach past it, [`Several`]), +/// where the frame's transform takes the texture's rect. +pub(super) fn texture_quad(t: &TransformSession, pv: &TransformPreview) -> [[f64; 2]; 4] { + let Some(sv) = pv.several.as_ref().filter(|sv| sv.rect != sv.frame) else { return t.quad }; + let Some(h) = Homography::rect_to_quad(t.rect, t.quad) else { return t.quad }; + corners([sv.rect.x0, sv.rect.y0, sv.rect.x1, sv.rect.y1].map(f64::from)).map(|[x, y]| { + let (a, b) = h.apply(x, y); + [a, b] + }) +} + /// The box, handles, reference point and path outline over the preview that [`draw_content`] /// drew; the warp grid and its split guides for a warp. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { @@ -223,24 +241,25 @@ fn carries_selection(app: &PhotocraftApp, t: &TransformSession) -> bool { } /// A transform of `document` is open that carries its selection along (#5297): the canvas traces -/// the whole outline, not only the part in view where it started, for [`draw_selection`]. +/// the whole outline, not only the part in view where it started, for [`selection_ants`]. pub(crate) fn carries_selection_of(app: &PhotocraftApp, document: DocId) -> bool { matches!((&app.ui.transform, &app.transform_preview), (Some(t), Some(pv)) if pv.doc.id == document && carries_selection(app, t)) } /// The marching ants where the open box puts them (#5297): the selection follows a move, scale, /// rotation, skew, distort, perspective or warp while it is made, to where applying it leaves it. -/// `segs` is the outline of `document`'s selection at rest. False when no open transform carries -/// that selection: the caller draws it where it is. -pub(crate) fn draw_selection( +/// `segs` is the outline of `document`'s selection at rest. The ants are returned as shapes for +/// `painter`, not drawn (the canvas paints them over the guides, #10671). `None` when no open +/// transform carries that selection: the caller draws it where it is. +pub(crate) fn selection_ants( app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, document: DocId, segs: &[crate::outline::Segment], time: f64, -) -> bool { - let Some(t) = app.ui.transform.as_ref().filter(|_| carries_selection_of(app, document)) else { return false }; +) -> Option> { + let t = app.ui.transform.as_ref().filter(|_| carries_selection_of(app, document))?; let scr = |(x, y): (f64, f64)| xf.to_screen(x as f32, y as f32); let doc = |p: [i32; 2]| [f64::from(p[0]), f64::from(p[1])]; let along = |a: [f64; 2], b: [f64; 2], k: f64| [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; @@ -256,7 +275,7 @@ pub(crate) fn draw_selection( }; (0..n).map(move |i| (at(i), at(i + 1))) }); - crate::canvas::marching_ants_lines(painter, lines, time); + Some(crate::canvas::marching_ants_line_shapes(painter, lines, time)) } else if let Some(h) = Homography::rect_to_quad(t.rect, t.quad) { // Past a Distort's horizon the outline has no place, like the pixels (#4863): a run that // crosses it stops short of it, where its points are still a finite way off. @@ -276,9 +295,10 @@ pub(crate) fn draw_selection( } Some((scr(h.apply(a[0], a[1])), scr(h.apply(b[0], b[1])))) }); - crate::canvas::marching_ants_lines(painter, lines, time); + Some(crate::canvas::marching_ants_line_shapes(painter, lines, time)) + } else { + Some(Vec::new()) } - true } /// What the styled preview of the moving layer is drawn from (transform_fx.rs), at `zoom` screen diff --git a/crates/ui-egui/src/tools/transform_tool/options_bar.rs b/crates/ui-egui/src/tools/transform_tool/options_bar.rs index c9393810b5..64778476b4 100644 --- a/crates/ui-egui/src/tools/transform_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/transform_tool/options_bar.rs @@ -1,4 +1,4 @@ -use super::begin::{enter_warp, leave_warp, warps_type}; +use super::begin::{enter_warp, interpolation_preference, leave_warp, warps_type}; use super::commit::{cancel, commit}; use super::pointer::point_on_quad; use super::warp::edit_session_warp; @@ -240,9 +240,11 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes let mut interp = t.interpolation.clone(); let opts = [("bicubic".to_string(), tl!("Bicubic")), ("bilinear".to_string(), tl!("Bilinear")), ("nearest".to_string(), tl!("Nearest Neighbor"))]; if crate::widgets::dropdown(ui, "transform-interp", tl!("Interpolation"), &mut interp, &opts, 130.0) + && let Some(preference) = interpolation_preference(&interp) && let Some(s) = app.ui.transform.as_mut() { s.interpolation = interp; + app.ui.tool_options.transform_interpolation = Some(preference); } } diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 87ccfe2f49..141b493b58 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -1,10 +1,10 @@ -use super::begin::warps_type; +use super::begin::{moving_layers, warps_type}; use super::commit::{commit, committing, moves_type}; use super::drag_modes::{apply_drag, apply_mode_drag, drag_mods, handle_indicator, mode_allows, mode_mods}; use super::warp::{quick_split_kind, split_cursor, surface_at, warp_hit, warp_pointer}; use super::{ - CursorIcon, HANDLE_PX, Homography, LayerContent, LayerId, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, - WarpStyle, handle_tolerance, + CursorIcon, HANDLE_PX, Homography, LayerContent, PhotocraftApp, ToolEvent, TransformMode, TransformPreview, TransformSession, ViewXform, WarpStyle, + handle_tolerance, }; /// Which part of the box a document point hits. @@ -84,7 +84,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> s.pivot = [x, y]; h = Hit::Pivot; } - // Distort only moves corners (and the whole box from inside): no rotating, no edges. + // Distort moves corners and edges (and the whole box from inside), but doesn't rotate. // Even in Distort mode, remember an outside press so a plain click can // accept the transform. A drag outside remains inert in that mode, in Skew, and in // Content-Aware Scale, which only scales and moves. @@ -137,8 +137,9 @@ fn round_to_pixel_grid(app: &mut PhotocraftApp) { if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() { return; } - let vector = - t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + let shapes = + |st: &photocraft_engine::DocState| moving_layers(t).iter().all(|id| st.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))); + let vector = t.path.is_some() || app.session.active().is_some_and(shapes); if !vector || !upright(&t.quad) { return; } diff --git a/crates/ui-egui/src/tools/transform_tool/preview.rs b/crates/ui-egui/src/tools/transform_tool/preview.rs index b9edbc67e8..fb6db2c113 100644 --- a/crates/ui-egui/src/tools/transform_tool/preview.rs +++ b/crates/ui-egui/src/tools/transform_tool/preview.rs @@ -78,13 +78,16 @@ pub(super) struct Above { pub(super) max_side: usize, /// The covering layers (whole layers or groups, at any depth). pub(super) ids: Vec, + /// Adjustment layers over the covering ones: they stay in the document under the box as well, + /// and are composited with the covering layers so those keep the adjustment. + pub(super) applied: Vec, /// Their composite and the canvas area it covers; `None` when nothing visible covers the layer. pub(super) shown: Option<(crate::transform_tex::PreviewTextures, photocraft_geom::Rect)>, } impl Above { pub(super) fn new(ctx: &egui::Context, name: String, doc: &Document, id: LayerId, max_side: usize) -> Self { - let mut a = Self { ctx: ctx.clone(), name, max_side, ids: Vec::new(), shown: None }; + let mut a = Self { ctx: ctx.clone(), name, max_side, ids: Vec::new(), applied: Vec::new(), shown: None }; a.rebuild(doc, id); a } @@ -92,11 +95,16 @@ impl Above { /// Composite what covers layer `id` of `doc` (the live document) again. pub(super) fn rebuild(&mut self, doc: &Document, id: LayerId) { self.ids.clear(); - covering(&doc.layers, id, &mut self.ids); + self.applied.clear(); + covering(&doc.layers, id, &mut self.ids, &mut self.applied); let mut up = doc.clone(); - show_only(&mut up.layers, &self.ids); + let mut covers = doc.clone(); + show_only(&mut covers.layers, &self.ids); + let keep: Vec = self.ids.iter().chain(&self.applied).copied().collect(); + show_only(&mut up.layers, &keep); let canvas = up.bounds(); - let rect = up + // An adjustment touches only pixels the covering layers have, so they alone bound the composite. + let rect = covers .layers .iter() .filter(|l| l.visible) @@ -136,7 +144,7 @@ impl Above { /// `rect` of `doc`'s composite as premultiplied texels: full resolution when it fits in /// `max_side`, composited in bands (no full-size float copy), else area-averaged down to fit. -fn composite_image(doc: &Document, rect: photocraft_geom::Rect, max_side: usize) -> (egui::ColorImage, [f32; 2]) { +pub(super) fn composite_image(doc: &Document, rect: photocraft_geom::Rect, max_side: usize) -> (egui::ColorImage, [f32; 2]) { let (w, h) = (rect.width() as usize, rect.height() as usize); let to_color = |p: &[u8; 4]| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]); if w.max(h) <= max_side.max(1) { @@ -174,11 +182,15 @@ fn plain(l: &photocraft_doc::Layer) -> bool { } /// The layers over `id` in `layers` (bottom to top, groups holding `id` searched too) that cover -/// it in the preview: from the top down, while they are [`plain`]. The first layer that isn't -/// stops the search, so it and everything under it stay in the document under the box as before -/// (an adjustment there keeps applying to the layers under it). A clipping group goes as a whole, -/// and the layers clipped to `id` (or to the group holding it) stay with it. -pub(super) fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut Vec) { +/// it in the preview, into `out`: every [`plain`] one, from the top down. A layer that isn't plain +/// (an adjustment, a blend mode) is passed over and stays in the document under the box: it can't +/// be drawn on its own, so the plain layers under it no longer wait for it to cover the moving +/// layer (#9387). An adjustment layer over covering layers goes to `applied`: it stays under the +/// box and is composited with them as well, so they keep its adjustment. A clipping group goes as +/// a whole, and the layers clipped to `id` (or to the group holding it) stay with it. +pub(super) fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut Vec, applied: &mut Vec) { + // Adjustments met since the last covering layer: they count once one lies under them. + let mut pending: Vec = Vec::new(); let mut end = layers.len(); while end > 0 { // The clipping group ending at `end`: its base and the layers clipped to it. @@ -194,14 +206,25 @@ pub(super) fn covering(layers: &[photocraft_doc::Layer], id: LayerId, out: &mut && let LayerContent::Group(group) = &g.content && matches!(g.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough) { - covering(&group.children, id, out); + let before = out.len(); + covering(&group.children, id, out, applied); + if out.len() > before { + applied.append(&mut pending); + } } return; } - if !unit.iter().all(plain) { - return; + if unit.iter().all(plain) { + out.extend(unit.iter().map(|l| l.id)); + if unit.iter().any(|l| l.visible) { + applied.append(&mut pending); + } + } else if let [l] = unit + && l.visible + && matches!(l.content, LayerContent::Adjustment(_)) + { + pending.push(l.id); } - out.extend(unit.iter().map(|l| l.id)); end = start; } } diff --git a/crates/ui-egui/src/tools/transform_tool/tests.rs b/crates/ui-egui/src/tools/transform_tool/tests.rs index 1646fa0775..8f5a3e6f68 100644 --- a/crates/ui-egui/src/tools/transform_tool/tests.rs +++ b/crates/ui-egui/src/tools/transform_tool/tests.rs @@ -16,6 +16,7 @@ fn session() -> TransformSession { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), } } @@ -129,6 +130,47 @@ fn transform_bar_pins_warp_cancel_and_commit() { assert!(h.state().ui.transform.is_none()); } +#[test] +fn interpolation_selection_is_reused_for_the_next_transform() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let mut h = Harness::builder().with_size(vec2(2400.0, 360.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal_centered(|ui| options_bar(app, ui)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + let ctx = h.ctx.clone(); + begin(h.state_mut(), &ctx).unwrap(); + h.run_steps(6); + assert!(h.state().ui.transform.is_some(), "transform stays open while the bar renders"); + + h.get_by_role(Role::ComboBox).click(); + h.run(); + assert!(h.state().ui.transform.is_some(), "opening interpolation dropdown keeps the transform open"); + h.key_press(egui::Key::ArrowDown); + h.run(); + assert!(h.state().ui.transform.is_some(), "first interpolation step keeps the transform open"); + h.key_press(egui::Key::ArrowDown); + h.run(); + assert!(h.state().ui.transform.is_some(), "second interpolation step keeps the transform open"); + assert_eq!(h.state().ui.transform.as_ref().unwrap().interpolation, "nearest"); + assert_eq!(h.state().ui.tool_options.transform_interpolation, Some(photocraft_engine::prefs::Interpolation::Nearest)); + assert_eq!(h.state().session.prefs().general.image_interpolation, photocraft_engine::prefs::Interpolation::BicubicAutomatic); + + let transform = h.state_mut().ui.transform.as_mut().unwrap(); + transform.quad = transform.quad.map(|[x, y]| [x + 1.0, y]); + commit(h.state_mut()); + assert!(h.state().ui.transform.is_none()); + begin(h.state_mut(), &ctx).unwrap(); + assert_eq!(h.state().ui.transform.as_ref().unwrap().interpolation, "nearest"); +} + #[test] fn free_transform_bar_buttons_follow_the_fields_on_a_wide_bar() { use egui_kittest::Harness; @@ -1343,7 +1385,7 @@ fn selecting_another_layer_applies_the_transform_and_the_next_one_follows() { assert_eq!((t.layer, t.rect), (second.0, [40.0, 40.0, 56.0, 60.0]), "the next box is on the selected layer"); } -/// The screen rectangle the marching ants cover as [`draw_selection`] paints them over the +/// The screen rectangle the marching ants cover as [`selection_ants`] places them over the /// active document's selection; `None` when it leaves them to the canvas. fn ants_bounds(app: &PhotocraftApp, xf: &ViewXform) -> Option { let st = app.session.active().unwrap(); @@ -1353,7 +1395,9 @@ fn ants_bounds(app: &PhotocraftApp, xf: &ViewXform) -> Option { let mut drawn = false; let mut output = ctx.run_ui(Default::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("ants-test"))); - drawn = draw_selection(app, &painter, xf, st.doc.id, &segs, 0.0); + let ants = selection_ants(app, &painter, xf, st.doc.id, &segs, 0.0); + drawn = ants.is_some(); + painter.extend(ants.unwrap_or_default()); }); output.textures_delta.clear(); // The ants are a mesh of straight dashes and line shapes for the slanted ones. @@ -1503,16 +1547,15 @@ fn press_drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], m: egui::Mo } #[test] -fn distort_mode_moves_one_corner_and_ignores_rotate_and_edges() { +fn distort_mode_moves_one_corner_and_ignores_rotate() { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); app.ui.extras.snap = false; let ctx = egui::Context::default(); crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap(); let q0 = app.ui.transform.as_ref().unwrap().quad; assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Distort); - // Outside (rotate) and an edge handle do nothing in Distort. + // Outside (rotate) does nothing in Distort. press_drag(&mut app, [44.0, 44.0], [54.0, 10.0], egui::Modifiers::NONE); - press_drag(&mut app, [24.0, 16.0], [34.0, 16.0], egui::Modifiers::NONE); assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0); // A corner moves alone, with no keys held. press_drag(&mut app, q0[1], [q0[1][0] + 7.0, q0[1][1] - 5.0], egui::Modifiers::NONE); @@ -1524,6 +1567,110 @@ fn distort_mode_moves_one_corner_and_ignores_rotate_and_edges() { assert_eq!(app.ui.transform.as_ref().unwrap().mode, TransformMode::Free); } +/// #10085: in Distort a side handle moves its edge freely, both of its corners by exactly the +/// drag, and the opposite edge stays. ⇧ and ⌥ change nothing, as on a Distort corner. +#[test] +fn distort_mode_moves_an_edge_by_the_drag() { + let distort = TransformMode::Distort; + let none = egui::Modifiers::NONE; + // Top edge (corners 0 and 1) up and to the right. + let top = [[12.0, -7.0], [112.0, -7.0], [100.0, 50.0], [0.0, 50.0]]; + assert_eq!(drag_in(distort, [50.0, 0.0], [62.0, -7.0], none), top); + // Right edge (corners 1 and 2) in and down. + assert_eq!(drag_in(distort, [100.0, 25.0], [90.0, 40.0], none), [[0.0, 0.0], [90.0, 15.0], [90.0, 65.0], [0.0, 50.0]]); + // Bottom (2, 3) and left (3, 0) edges. + assert_eq!(drag_in(distort, [50.0, 50.0], [45.0, 58.0], none), [[0.0, 0.0], [100.0, 0.0], [95.0, 58.0], [-5.0, 58.0]]); + assert_eq!(drag_in(distort, [0.0, 25.0], [-6.0, 21.0], none), [[-6.0, -4.0], [100.0, 0.0], [100.0, 50.0], [-6.0, 46.0]]); + for mods in [egui::Modifiers::SHIFT, egui::Modifiers::ALT, egui::Modifiers::COMMAND, egui::Modifiers::SHIFT | egui::Modifiers::ALT] { + assert_eq!(drag_in(distort, [50.0, 0.0], [62.0, -7.0], mods), top, "{mods:?}"); + } + // The reference point keeps its place in the box. + let mut s = session(); + let g = Gesture { hit: Hit::Edge(0), start: [50.0, 0.0], quad0: s.quad, pivot0: s.pivot }; + apply_mode_drag(&mut s, g, [62.0, -7.0], distort, false, none); + assert_eq!(s.pivot, carry_pivot(g.quad0, top, g.pivot0)); +} + +/// #10085: a Distort edge dragged onto or past the opposite one stops short of folding the box +/// flat, as a corner does (#1323); the opposite edge never moves. +#[test] +fn distort_mode_edge_stops_before_the_box_collapses() { + let distort = TransformMode::Distort; + let start = turns(&corners([0.0, 0.0, 100.0, 50.0])); + for to in [[50.0, 50.0], [50.0, 90.0], [-200.0, 50.0], [50.0, f64::MAX]] { + let q = drag_in(distort, [50.0, 0.0], to, egui::Modifiers::NONE); + assert_eq!((q[2], q[3]), ([100.0, 50.0], [0.0, 50.0]), "the bottom edge stays: {to:?}"); + assert!(turns(&q).iter().zip(start).all(|(t, t0)| t * t0.signum() > 0.0), "still convex, not flat: {to:?} {q:?}"); + assert!(q.iter().flatten().all(|v| v.is_finite()), "{q:?}"); + // The top edge still moved together, by the same offset. + assert_eq!([q[1][0] - q[0][0], q[1][1] - q[0][1]], [100.0, 0.0], "{to:?} {q:?}"); + } +} + +/// #10085 with real pointer events: a Distort side handle drags in the canvas, the committed +/// layer is the quad the box showed (pixels inside it, none outside), in one history step. +#[test] +fn distort_mode_edge_drag_commits_what_it_shows_in_one_step() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.ui.extras.snap = false; + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "edit.transform.distort", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + // The right edge's handle, 8 px right and 8 px down. + press_drag(&mut app, [24.0, 16.0], [32.0, 24.0], egui::Modifiers::NONE); + let shown = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(shown, [q0[0], [q0[1][0] + 8.0, q0[1][1] + 8.0], [q0[2][0] + 8.0, q0[2][1] + 8.0], q0[3]]); + commit(&mut app); + assert!(app.ui.transform.is_none(), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "one undo step"); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + s.read_pixel(x, y, &mut p); + p[3] + }; + // The parallelogram (8, 8) (32, 16) (32, 32) (8, 24): its slanted top and bottom edges. + assert!(alpha(28, 22) > 0.99 && alpha(10, 20) > 0.99, "inside {} {}", alpha(28, 22), alpha(10, 20)); + assert!(alpha(28, 11) < 0.01 && alpha(10, 28) < 0.01, "outside {} {}", alpha(28, 11), alpha(10, 28)); + let b = s.content_bounds(); + // Within the resampling filter's pixel of the quad's bounds (8, 8)..(32, 32). + assert!((7..=8).contains(&b.x0) && (7..=8).contains(&b.y0) && (32..=33).contains(&b.x1) && (32..=33).contains(&b.y1), "{b:?}"); +} + +/// Perspective mode's side handles skew, as in Photoshop: the edge slides along itself only. +#[test] +fn perspective_mode_edge_slides_along_itself() { + let persp = TransformMode::Perspective; + let none = egui::Modifiers::NONE; + assert_eq!(drag_in(persp, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]); + assert_eq!(drag_in(persp, [100.0, 25.0], [94.0, 33.0], none), [[0.0, 0.0], [100.0, 8.0], [100.0, 58.0], [0.0, 50.0]]); + // ⌥: the opposite edge goes the other way. + assert_eq!(drag_in(persp, [50.0, 0.0], [62.0, 7.0], egui::Modifiers::ALT), [[12.0, 0.0], [112.0, 0.0], [88.0, 50.0], [-12.0, 50.0]]); +} + +/// A Perspective side handle on a box whose opposite edge isn't parallel (here its right side +/// already pulled out by a vertical corner pair) stops before the box folds over, as its +/// corners do (#1323): a long slide along the top edge turned it concave. +#[test] +fn perspective_mode_edge_slide_stops_before_the_box_folds() { + let mut s = session(); + s.quad = [[0.0, 0.0], [100.0, -20.0], [100.0, 70.0], [0.0, 50.0]]; + let start = turns(&s.quad); + // Along the top edge, (5, -1), 100 steps the other way. + let g = Gesture { hit: Hit::Edge(0), start: [50.0, -10.0], quad0: s.quad, pivot0: s.pivot }; + apply_mode_drag(&mut s, g, [-450.0, 90.0], TransformMode::Perspective, false, egui::Modifiers::NONE); + let q = s.quad; + assert!(turns(&q).iter().zip(start).all(|(t, t0)| t * t0.signum() > 0.0), "still convex: {q:?}"); + assert!(q.iter().flatten().all(|v| v.is_finite()), "{q:?}"); + assert_eq!((q[2], q[3]), (g.quad0[2], g.quad0[3]), "the other corners stay"); + // The top edge only slid along itself, and some way. + assert!(((q[0][0] - g.quad0[0][0]) + (q[0][1] - g.quad0[0][1]) * 5.0).abs() < 1e-9, "{q:?}"); + assert!(([q[1][0] - q[0][0] - 100.0, q[1][1] - q[0][1] + 20.0]).iter().all(|v| v.abs() < 1e-9), "{q:?}"); + assert!(q[0][0] < -1.0, "it still slid: {q:?}"); +} + /// #3941: Edit › Transform › Rotate must rotate from a corner handle, not scale. #[test] fn rotate_mode_turns_the_box_from_a_corner_handle() { @@ -1919,6 +2066,122 @@ fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp { app } +/// `app_with_square` (A, red over 8..24) plus B (x 40..56, y 8..24) and C (x 8..24, y 40..56) +/// over it. A and B are linked; C isn't. Only A is selected, and active: (app, A, B, C). +fn linked_layers_app() -> (PhotocraftApp, LayerId, LayerId, LayerId) { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let a = app.session.active().unwrap().active_layer.unwrap(); + let add = |app: &mut PhotocraftApp, fill: photocraft_geom::Rect| { + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, l| { + doc.layer_mut(l.unwrap()).unwrap().surface_mut().unwrap().fill_rect(fill, &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.active().unwrap().active_layer.unwrap() + }; + let b = add(&mut app, photocraft_geom::Rect::new(40, 8, 56, 24)); + let c = add(&mut app, photocraft_geom::Rect::new(8, 40, 24, 56)); + app.session.execute("layer.select", json!({"layer": a.0})).unwrap(); + app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + app.session.execute("layer.select", json!({"layer": a.0})).unwrap(); + (app, a, b, c) +} + +/// #11217: ⌥⌘T on a linked layer without a selection makes no copy in the reference application +/// (measured): it frames and moves the whole link group, as Free Transform does. An unlinked +/// layer still gets its copy. +#[test] +fn free_transform_a_copy_of_a_linked_layer_boxes_the_link_group_without_a_copy() { + let ctx = egui::Context::default(); + let (mut app, a, b, c) = linked_layers_app(); + let layers = app.session.active().unwrap().doc.layers.len(); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "no copy of a linked layer"); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!(t.made, None, "nothing made to take back"); + assert_eq!(t.layers, [a.0, b.0], "the box frames the link group"); + cancel(&mut app); + app.session.execute("layer.select", json!({"layer": c.0})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "edit.freeTransformCopy", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "an unlinked layer is copied"); + assert_eq!(app.ui.transform.as_ref().unwrap().made, Some(MadeLayer::Copy)); + cancel(&mut app); +} + +/// #11217: the box over one selected linked layer frames, previews and commits its whole link +/// group, as in Photoshop. It used to frame A alone; B only moved at the commit. +#[test] +fn free_transform_of_a_linked_layer_boxes_its_link_group() { + let (mut app, a, b, c) = linked_layers_app(); + begin(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!(t.layer, a.0, "the active layer stays the session's"); + assert_eq!(t.layers, [a.0, b.0], "a box over the link group, in stack order"); + assert_eq!(t.rect, [8.0, 8.0, 56.0, 24.0], "framing A and B together"); + let pv = app.transform_preview.as_ref().unwrap(); + assert!(pv.several.as_ref().is_some_and(|sv| sv.ids == [a, b]), "the preview moves both"); + // The moving pixels hold B's blue block where B is, beside A's red one: B moves with the box + // on the canvas, not only at the commit. + assert_eq!(moving_texel(&app, [48, 16]).0, Some(Color32::BLUE), "the linked layer's pixels are in the preview"); + assert_eq!(moving_texel(&app, [16, 16]).0, Some(Color32::RED), "and the active layer's"); + assert_eq!(moving_texel(&app, [16, 48]).0, None, "the unlinked layer is outside the moving pixels"); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 4.0, y]); + commit(&mut app); + assert!(app.ui.transform.is_none(), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let bounds = |id: LayerId| st.doc.layer(id).unwrap().surface().unwrap().content_bounds(); + assert_eq!(bounds(a), photocraft_geom::Rect::new(12, 8, 28, 24)); + assert_eq!(bounds(b), photocraft_geom::Rect::new(44, 8, 60, 24)); + assert_eq!(bounds(c), photocraft_geom::Rect::new(8, 40, 24, 56), "an unlinked layer stays put"); +} + +/// #11217: a layer without linked layers still opens the one-layer box. +#[test] +fn free_transform_of_an_unlinked_layer_boxes_that_layer_alone() { + let (mut app, _, _, c) = linked_layers_app(); + app.session.execute("layer.select", json!({"layer": c.0})).unwrap(); + begin(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!((t.layer, t.rect), (c.0, [8.0, 40.0, 24.0, 56.0])); + assert!(t.layers.is_empty(), "a one-layer session"); + assert!(app.transform_preview.as_ref().unwrap().several.is_none()); +} + +/// #11217 review: a layer linked to the Background opens the one-layer box: the link doesn't pull +/// the Background into the box, as the selection doesn't either (#6501). +#[test] +fn free_transform_of_a_layer_linked_to_the_background_boxes_that_layer_alone() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let a = app.session.active().unwrap().active_layer.unwrap(); + let bg = app.session.active().unwrap().doc.layers[0].id; + app.session.execute("layer.select", json!({"layer": bg.0})).unwrap(); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + app.session.execute("layer.select", json!({"layer": a.0})).unwrap(); + begin(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!((t.layer, t.rect), (a.0, [8.0, 8.0, 24.0, 24.0]), "the box frames A, not the canvas-sized Background"); + assert!(t.layers.is_empty(), "a one-layer session"); + let pv = app.transform_preview.as_ref().unwrap(); + assert!(pv.several.is_none()); + assert!(pv.doc.layer(bg).unwrap().visible, "the Background stays under the box"); +} + +/// #11217: a locked layer in the link group refuses the box before it opens, as a locked layer +/// among several selected ones does. +#[test] +fn a_locked_linked_layer_refuses_the_box_over_its_link_group() { + let (mut app, a, b, _) = linked_layers_app(); + app.session.execute("layer.setProps", json!({"layer": b.0, "locks": {"position": true}})).unwrap(); + app.session.execute("layer.select", json!({"layer": a.0})).unwrap(); + assert_eq!(begin(&mut app, &egui::Context::default()), Err(LOCKED.to_string())); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); +} + /// #2439: a layer under others keeps its place in the stack while the box is open. The /// canvas under the box shows only the layers below it; the ones over it are composited on /// their own and drawn over the moving pixels. @@ -1955,6 +2218,53 @@ fn free_transform_keeps_the_layer_under_the_layers_over_it() { cancel(&mut app); } +/// #9387: a layer under others keeps its place in the stack when something over it blends or +/// adjusts (a PSD usually has one): the plain layers over the moving one still cover it. +#[test] +fn plain_layers_still_cover_the_moving_one_under_a_blend_or_adjustment_layer() { + use photocraft_color::BlendMode; + for adjustment in [false, true] { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let red = app.session.active().unwrap().active_layer.unwrap(); + 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(photocraft_geom::Rect::new(16, 16, 40, 40), &[0.0, 0.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + let blue = app.session.active().unwrap().active_layer.unwrap(); + let bg = app.session.active().unwrap().doc.layers[0].id; + let top = if adjustment { + app.session.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + app.session.active().unwrap().active_layer.unwrap() + } else { + app.session.execute("layer.new.layer", json!({})).unwrap(); + let top = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("blend", |doc, _| { + doc.layer_mut(top).unwrap().blend = BlendMode::Multiply; + Ok(()) + }) + .unwrap(); + top + }; + app.session.execute("layer.select", json!({"layer": red.0})).unwrap(); + begin(&mut app, &gpu_ctx()).unwrap(); + let pv = app.transform_preview.as_ref().unwrap(); + assert!(!pv.doc.layer(blue).unwrap().visible, "adjustment={adjustment}: the plain layer is drawn over the box, not under it"); + assert!(pv.doc.layer(top).unwrap().visible, "adjustment={adjustment}: the layer that blends stays in the document under the box"); + assert!(pv.doc.layer(bg).unwrap().visible); + let (texture, rect) = pv.above.as_ref().and_then(|a| a.shown.as_ref()).expect("the plain layer covers the moving one"); + assert_eq!(*rect, photocraft_geom::Rect::new(16, 16, 40, 40), "adjustment={adjustment}"); + let (image, _) = texture.level_image(0).unwrap(); + let centre = image.pixels[12 * image.size[0] + 12]; + // The adjustment is composited with the layer it covers: inverted blue is yellow. + assert_eq!(centre, if adjustment { Color32::from_rgb(255, 255, 0) } else { Color32::BLUE }, "adjustment={adjustment}"); + cancel(&mut app); + } +} + #[test] fn plain_layers_from_the_top_cover_the_moving_one() { use photocraft_color::BlendMode; @@ -1962,7 +2272,7 @@ fn plain_layers_from_the_top_cover_the_moving_one() { let px = |name: &str| Layer::raster(name, photocraft_color::PixelFormat::RGBA8); let covers = |layers: &[Layer], id: LayerId| { let mut out = Vec::new(); - covering(layers, id, &mut out); + covering(layers, id, &mut out, &mut Vec::new()); out }; // In a group: the group's layers over it, then the ones over the group; never the layers @@ -1975,7 +2285,7 @@ fn plain_layers_from_the_top_cover_the_moving_one() { assert_eq!(covers(&layers, c), [tp, inner], "a clipped layer: covered by what is over its clipping group"); // Under the box, the covering layers leave the document; over it, they alone show. - let above = Above { ctx: egui::Context::default(), name: String::new(), max_side: 64, ids: vec![tp, inner], shown: None }; + let above = Above { ctx: egui::Context::default(), name: String::new(), max_side: 64, ids: vec![tp, inner], applied: Vec::new(), shown: None }; let mut under = layers.clone(); above.hide_in(&mut under); let vis = |ls: &[Layer]| [ls[0].visible, ls[1].visible, ls[2].visible]; @@ -1989,20 +2299,50 @@ fn plain_layers_from_the_top_cover_the_moving_one() { }; assert_eq!(inside, [false, false, true]); - // A layer that blends with what is under it stops the search: it and everything under it - // stay in the document under the box. + // A layer that blends with what is under it can't be drawn on its own: it stays in the + // document under the box, and the plain layers around it still cover (#9387). layers[2].blend = BlendMode::Multiply; - assert!(covers(&layers, t).is_empty()); + assert_eq!(covers(&layers, t), [inner]); layers[2].blend = BlendMode::Normal; let mut multiply = px("multiply"); multiply.blend = BlendMode::Multiply; + let m = multiply.id; layers.insert(2, multiply); - assert_eq!(covers(&layers, t), [tp]); + assert_eq!(covers(&layers, t), [tp, inner]); + assert!(!covers(&layers, t).contains(&m)); // A group that isn't Normal or Pass Through keeps what is inside it. layers[1].blend = BlendMode::Screen; layers.remove(2); assert_eq!(covers(&layers, t), [tp]); - assert_eq!(covers(&layers, LayerId::fresh()), [tp], "not in the stack: down to the first layer that isn't plain"); + assert_eq!(covers(&layers, LayerId::fresh()), [tp, layers[0].id], "not in the stack: every plain layer, the Screen group passed over"); +} + +/// #9387: an adjustment layer over covering layers is composited with them as well as staying +/// under the box; one with nothing covering under it has nothing to adjust. +#[test] +fn adjustments_over_covering_layers_are_applied_to_them() { + use photocraft_doc::{Adjustment, Layer}; + let px = |name: &str| Layer::raster(name, photocraft_color::PixelFormat::RGBA8); + let adjustment = |name: &str| Layer::new(name, LayerContent::Adjustment(Adjustment::Invert)); + let find = |layers: &[Layer], id: LayerId| { + let (mut out, mut applied) = (Vec::new(), Vec::new()); + covering(layers, id, &mut out, &mut applied); + (out, applied) + }; + let (target, under, over, adj_top, adj_between, adj_under) = + (px("target"), px("under"), px("over"), adjustment("top"), adjustment("between"), adjustment("under")); + let (t, u, o, top, between, below) = (target.id, under.id, over.id, adj_top.id, adj_between.id, adj_under.id); + // Bottom to top: target, an adjustment with nothing to adjust over it, two plain layers + // around another adjustment, and an adjustment on top. + let layers = vec![target, adj_under, under, adj_between, over, adj_top]; + let (covers, applied) = find(&layers, t); + assert_eq!(covers, [o, u]); + assert_eq!(applied, [top, between], "adjustments over a covering layer, top down"); + assert!(!applied.contains(&below), "nothing covers under it"); + + // Nothing covers: no adjustment applies. + let alone = vec![px("a"), adjustment("b")]; + assert_eq!(find(&alone, alone[0].id), (Vec::new(), Vec::new())); } /// A context that reports a real GPU's texture limit (the default reports 2048). @@ -2143,6 +2483,28 @@ fn nested_group_preview_detaches_every_clipped_ancestor() { assert_eq!(image.pixels[8 * image.size[0] + 8], Color32::from_rgb(255, 0, 0), "hidden clipping bases must not make a nested target disappear"); } +#[test] +fn several_preview_detaches_clipped_ancestors() { + use photocraft_color::PixelFormat; + use photocraft_doc::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 first = doc.layers.pop().unwrap(); + let first_id = first.id; + let mut second = Layer::raster("second", PixelFormat::RGBA8); + second.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0., 1., 0., 1.]); + let second_id = second.id; + let mut group = Layer::group("clipped", vec![first, second]); + group.clipped = true; + doc.layers.push(Layer::raster("hidden base", PixelFormat::RGBA8)); + doc.layers.push(group); + let shown = Several::shown(doc, &[first_id, second_id], &[]); + let (image, _) = composite_image(&shown, photocraft_geom::Rect::new(0, 0, 64, 64), 4096); + assert_eq!(image.pixels[16 * image.size[0] + 16], Color32::from_rgb(255, 0, 0)); + assert_eq!(image.pixels[16 * image.size[0] + 40], Color32::from_rgb(0, 255, 0)); +} + #[test] fn unknown_preview_target_keeps_the_previous_document_fallback() { let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); @@ -2887,6 +3249,58 @@ fn targeted_unlinked_mask_transforms_alone_through_the_box() { assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } +#[test] +fn mask_view_keeps_move_and_transform_available_behind_a_colour_set() { + for view in ["gray", "overlay"] { + let mut app = masked_app(false); + app.run("channel.target", json!({"colors":[0,2]})).unwrap(); + app.run("view.layerMask", json!({"mode":view})).unwrap(); + assert!(crate::menus::is_enabled(&app, "edit.freeTransform")); + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let original = st.doc.layer(layer).unwrap().surface().unwrap().clone(); + begin(&mut app, &egui::Context::default()).unwrap(); + let transform = app.ui.transform.as_mut().unwrap(); + assert_eq!(transform.target, Some(json!("mask"))); + transform.quad = transform.quad.map(|[x, y]| [x + 20.0, y]); + commit(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + app.ui.tool = crate::state::Tool::Move; + app.ui.tool_options.move_auto_select = false; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let mods = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 28.0, y: 30.0, pressure: 1.0 }, mods); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Move { x: 38.0, y: 30.0, pressure: 1.0 }, mods); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Up { x: 38.0, y: 30.0 }, mods); + assert!(!app.ui.status_error, "{}", app.ui.status); + let actual = app.session.active().unwrap().doc.layer(layer).unwrap(); + assert_eq!(actual.surface().unwrap(), &original); + let mask = &actual.mask.as_ref().unwrap().surface; + assert!(mask.sample_channel(24, 30, 0) > 0.99 && mask.sample_channel(40, 30, 0) < 0.01, "only the mask moved"); + } +} + +/// Batch 16 review: ⌥⌘T with the unlinked mask of a linked layer targeted still copies; only the +/// layer's own pixels take the link group's box without a copy. +#[test] +fn free_transform_a_copy_of_a_targeted_mask_on_a_linked_layer_still_copies() { + let mut app = masked_app(false); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let b = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.select", json!({"layer": a.0})).unwrap(); + app.session.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.execute("layer.linkLayers", json!({})).unwrap()["linked"], true); + app.session.execute("layer.select", json!({"layer": a.0})).unwrap(); + app.ui.mask_target = true; + let layers = app.session.active().unwrap().doc.layers.len(); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransformCopy", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the layer is copied"); + assert_eq!(app.ui.transform.as_ref().unwrap().made, Some(MadeLayer::Copy)); + cancel(&mut app); +} + #[test] fn linked_mask_target_transforms_the_layer() { let mut app = masked_app(true); @@ -3135,11 +3549,11 @@ fn handle_pointers_follow_what_the_drag_does() { // A Skew-mode corner slides along one edge, with or without Cmd; its edges skew. assert_eq!(indicators("edit.transform.skew", cmd()), (None, Some(Skew))); assert_eq!(indicators("edit.transform.skew", none), (None, Some(Skew))); - // Distort mode leaves the edges alone, Cmd or not. - assert_eq!(indicators("edit.transform.distort", none), (Some(Distort), None)); - assert_eq!(indicators("edit.transform.distort", cmd()), (Some(Distort), None)); - // Perspective mode: corners move in pairs; Cmd on an edge skews (`apply_drag`). - assert_eq!(indicators("edit.transform.perspective", none), (Some(Distort), None)); + // Distort mode distorts from every handle, Cmd or not (#10085). + assert_eq!(indicators("edit.transform.distort", none), (Some(Distort), Some(Distort))); + assert_eq!(indicators("edit.transform.distort", cmd()), (Some(Distort), Some(Distort))); + // Perspective mode: corners move in pairs, and an edge skews along itself, Cmd or not. + assert_eq!(indicators("edit.transform.perspective", none), (Some(Distort), Some(Skew))); assert_eq!(indicators("edit.transform.perspective", cmd()), (Some(Distort), Some(Skew))); assert_eq!(indicators("edit.transform.scale", cmd()), (Some(Distort), Some(Skew))); } @@ -3199,3 +3613,20 @@ fn applying_the_box_hides_no_pointer() { assert_eq!(cursor(&app, &xf, [32.0, 32.0], cmd()), Some(CursorIcon::ResizeNwSe)); assert_eq!(cursor(&app, &xf, [96.0, 32.0], cmd()), Some(CursorIcon::ResizeVertical)); } + +#[test] +fn several_layers_reuse_the_transform_interpolation_preference() { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let first = st.active_layer.unwrap(); + let mut second = photocraft_doc::Layer::raster("second", photocraft_color::PixelFormat::RGBA8); + second.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(32, 8, 48, 24), &[0., 1., 0., 1.]); + let second_id = second.id; + Arc::make_mut(&mut st.doc).layers.push(second); + st.selected_layers = vec![first, second_id]; + app.ui.tool_options.transform_interpolation = Some(photocraft_engine::prefs::Interpolation::Nearest); + begin(&mut app, &egui::Context::default()).unwrap(); + let transform = app.ui.transform.as_ref().unwrap(); + assert_eq!(transform.layers.len(), 2); + assert_eq!(transform.interpolation, "nearest"); +} diff --git a/crates/ui-egui/src/tools/type_font_preview.rs b/crates/ui-egui/src/tools/type_font_preview.rs index bb91b61ea0..a334642c99 100644 --- a/crates/ui-egui/src/tools/type_font_preview.rs +++ b/crates/ui-egui/src/tools/type_font_preview.rs @@ -109,6 +109,11 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc (usize, usize) { #[cfg(test)] mod canvas_tests; +#[cfg(test)] +mod font_commit_tests; #[cfg(test)] mod font_picker_tests; diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index a6dd09e44a..65a06ae936 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -1382,7 +1382,9 @@ fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() { } "rotate" | "snap" => { let a = [frame.quad[1][0] + 20.0, frame.quad[1][1] - 10.0]; - let da: f64 = 0.42; + // A snapped drag turns in 15° steps from where it started, so the first preview steps need a + // turn of at least half a step to change anything (#11099). + let da: f64 = if gesture == "snap" { 0.9 } else { 0.42 }; let (x, y) = (a[0] - frame.pivot[0], a[1] - frame.pivot[1]); let b = [frame.pivot[0] + x * da.cos() - y * da.sin(), frame.pivot[1] + x * da.sin() + y * da.cos()]; (a, b, if gesture == "snap" { type_command() | Modifiers::SHIFT } else { type_command() }) @@ -1420,8 +1422,9 @@ fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() { .mul(&initial), ); } else if gesture == "snap" { - let angle = after.transform.m[1].atan2(after.transform.m[0]).to_degrees(); - assert!((angle / 15.0 - (angle / 15.0).round()).abs() < 1e-8); + // Shift snaps the turn made by the drag, not the absolute angle (#11099). + let turn = (after.transform.m[1].atan2(after.transform.m[0]) - initial.m[1].atan2(initial.m[0])).to_degrees(); + assert!((turn / 15.0 - (turn / 15.0).round()).abs() < 1e-8); } else if gesture == "center" { let p = after.transform.apply(Point::new((frame.rect[0] + frame.rect[2]) / 2.0, (frame.rect[1] + frame.rect[3]) / 2.0)); assert!((p.x - frame.pivot[0]).abs() < 1e-8 && (p.y - frame.pivot[1]).abs() < 1e-8); @@ -1943,9 +1946,12 @@ mod multi_layer_formatting { fn controls(app: PhotocraftApp, properties: bool) -> Harness<'static, PhotocraftApp> { // The lazily populated font popup introduces widgets in successive passes. egui 0.37 - // styles each new widget on its following pass; keep a bounded settle assertion while - // allowing those additional passes (the loaded menu requires six under parallel tests). - let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).with_max_steps(8).build_ui_state( + // styles each new widget on its following pass (`widget_style` requests a repaint for a + // widget it has not seen yet), so `run` settles only after the last batch of the font + // list has arrived. The loaded menu takes six passes alone, but under a loaded parallel + // run the list arrives in more batches and it took ten. Allow 16 passes; a UI that never + // settles still fails, because `run` errors on any pass past the cap. + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).with_max_steps(16).build_ui_state( move |ui, app| { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { if properties { @@ -2179,7 +2185,7 @@ mod multi_layer_formatting { let (app, ids) = fixture(); let before = ids.map(|id| style(&app, id, 0)); let mut h = controls(app, false); - h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.get_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); h.get_by_role(Role::TextInput).type_text("JetBrains"); h.key_press(egui::Key::ArrowDown); @@ -2202,7 +2208,7 @@ mod multi_layer_formatting { assert_eq!(style(h.state(), ids[i], 0), expected, "family changes preserve each peer's face and metrics"); } assert_eq!(style(h.state(), ids[2], 0), before[2]); - h.get_all_by_role(Role::ComboBox).nth(2).unwrap().click(); + h.get_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Smooth")).unwrap().click(); h.run(); h.get_by_label("Sharp").click(); h.run(); @@ -2217,6 +2223,57 @@ mod multi_layer_formatting { assert_ne!(text(h.state(), ids[2]).antialias, AntiAlias::Sharp); } + /// The family field and the style menu describe the selected run, not the layer's first run + /// (#9024), whichever way the selection was made; without a selection the first run shows, and + /// without a type target the tool defaults. Showing them never adds history. "Mé Regular" has + /// a two-byte `é`, so a byte offset taken for a character offset picks the wrong run. + #[test] + fn options_bar_font_and_style_follow_the_selected_run() { + let mut app = new_app(); + let id = LayerId( + app.run("type.create", json!({"text": "Mé Regular", "font": "Inter", "fontStyle": "Medium", "size": 20, "x": 300, "y": 420})).unwrap()["layer"] + .as_u64() + .unwrap(), + ); + app.run("type.setStyle", json!({"layer": id.0, "range": [3, 10], "font": "JetBrains Mono", "fontStyle": "Regular"})).unwrap(); + assert_eq!((style(&app, id, 0).font_family.as_str(), style(&app, id, 5).font_family.as_str()), ("Inter", "JetBrains Mono")); + let edit = |anchor: usize, caret: usize| crate::state::TextEdit { + layer: id.0, + document: None, + caret, + anchor, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }; + let mut h = controls(app, false); + let shown = |h: &Harness<'static, PhotocraftApp>| { + (h.get_all_by_role(Role::TextInput).next().and_then(|n| n.value()).unwrap_or_default(), h.get_by_role_and_label(Role::ComboBox, "Style").value()) + }; + let steps = super::history_len(&h); + let (inter, mono) = (("Inter".to_string(), Some("Medium".to_string())), ("JetBrains Mono".to_string(), Some("Regular".to_string()))); + for (anchor, caret, want) in [(3, 10, &mono), (10, 3, &mono), (0, 2, &inter), (2, 0, &inter)] { + h.state_mut().ui.text_edit = Some(edit(anchor, caret)); + h.run_steps(4); + assert_eq!(&shown(&h), want, "selection {anchor}..{caret}"); + } + // Ending the edit keeps the whole-layer rule: the layer is still selected, its style is the + // first run's (Medium) and, the runs using two families, the family field is blank. + h.state_mut().ui.text_edit = None; + h.run_steps(4); + assert_eq!(shown(&h), (String::new(), inter.1.clone()), "no edit: first run's style, mixed family blank"); + assert_eq!(super::history_len(&h), steps, "displaying the controls adds no history"); + // Control: no type target shows the tool defaults. + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().ui.tool_options.type_font = "JetBrains Mono".into(); + h.state_mut().ui.tool_options.type_style = "Regular".into(); + h.run_steps(4); + assert_eq!(shown(&h), mono, "no type target: the tool defaults"); + } + /// Other engines registering fonts and the served catalog growing (tests running in parallel) /// used to move the key of the font menu's preview cache, so its samples re-rendered and asked /// for a repaint every pass and `Harness::run` never settled (a flaky `exceeded max_steps`). @@ -2239,7 +2296,7 @@ mod multi_layer_formatting { ); PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.get_all_by_role(Role::TextInput).next().unwrap().click(); // The font menu's search caret repaints every pass; step a bounded number of times. h.run_steps(2); h.run_steps(2); @@ -2254,9 +2311,9 @@ mod multi_layer_formatting { let pick = super::super::styles("Inter").into_iter().find(|s| s != "Regular" && s != "Bold Italic").expect("Inter has several styles"); assert_ne!(app.ui.tool_options.type_style, pick); let mut h = controls(app, true); - h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.get_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); - h.get_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("the search field has the focus").type_text("Inter"); + h.get_all_by_role(Role::TextInput).find(|n| n.is_focused()).expect("the family field has the focus").type_text("Inter"); h.run(); h.get_all_by_label("Show Styles").next().unwrap().click(); h.run(); @@ -3006,6 +3063,237 @@ fn justify_buttons_are_greyed_on_point_text() { assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text"); } +/// A box that can't hold its text shows the overflow handle (a black "+" in its enlarged +/// lower-right handle): `ui.type_box_overflow` is the automation-visible flag. A fitting box (trailing breaks +/// included) and point text — which has no box — never set it. +#[test] +fn the_overflow_handle_flags_a_box_that_cannot_hold_its_text() { + let build = |mut app: PhotocraftApp, p: serde_json::Value, caret: usize| { + let id = app.run("type.create", p).unwrap()["layer"].as_u64().unwrap(); + app.session.select_layer(LayerId(id)).unwrap(); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + document: None, + caret, + anchor: caret, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let mut h = harness(1.0, app); + h.run_steps(4); + h + }; + // In the harness's window a 40 pt box holds 1.5 lines, so the second line is dropped. + let mut h = build(new_app(), json!({"text": "ab\ncd\nef", "size": 24, "box": [200, 150, 260, 40]}), 0); + assert!(h.state().ui.type_box_overflow, "a box shorter than the text"); + // The same text in a tall box fits: nothing overflows. + h = build(new_app(), json!({"text": "ab cdef gh", "size": 24, "box": [200, 150, 260, 600]}), 0); + assert!(!h.state().ui.type_box_overflow, "a fitting box"); + // Point text has no box: much text is fine. + h = build(new_app(), json!({"text": "ab cdef gh ab cdef gh ab cdef gh", "size": 24, "x": 200, "y": 200}), 0); + assert!(!h.state().ui.type_box_overflow, "point text"); +} + +/// `ui.inspect` reports the overflow handle only while an edit session is open: the flag is a +/// per-frame overlay snapshot, and a commit or cancel can end the session before the overlay +/// frame that resets the flag — an agent would otherwise read `typeBoxOverflow: true` beside +/// a null `textEdit`. +#[test] +fn inspect_gates_the_overflow_flag_on_the_session() { + let mut a = new_app(); + let id = a.run("type.create", json!({"text": "ab\n.cd", "size": 24, "box": [200, 150, 260, 40]})).unwrap()["layer"].as_u64().unwrap(); + a.session.select_layer(LayerId(id)).unwrap(); + a.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + document: None, + caret: 0, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + // Snapshot state as the overlay wrote it while the session was still open. + a.ui.type_box_overflow = true; + let ctx = egui::Context::default(); + assert_eq!(crate::control::inspect(&a, &ctx)["typeBoxOverflow"], json!(true), "an open session reports the overlay's snapshot"); + // A commit (or cancel) between frames: `textEdit` is gone, so the flag must read as off. + a.ui.text_edit = None; + assert_eq!(crate::control::inspect(&a, &ctx)["typeBoxOverflow"], json!(false), "no session, no overflow"); + assert_eq!(crate::control::inspect(&a, &ctx)["textEdit"], json!(null), "the session is closed either way"); +} + +/// The shapes the type overlay draws for an edit session on a paragraph box with `text`, `turn` +/// radians rotated, and the box's lower-right corner on screen. +fn box_overlay(text: &str, height: u32, turn: f64) -> (Vec, Pos2) { + let mut app = new_app(); + let id = app.run("type.create", json!({"text": text, "size": 24, "box": [200, 150, 260, height]})).unwrap()["layer"].as_u64().unwrap(); + if turn != 0.0 { + let aff = text_layer(&app.session.active().unwrap().doc, LayerId(id)).unwrap().transform; + app.run("type.edit", json!({"layer": id, "transform": aff.mul(&Affine::rotate(turn)).m})).unwrap(); + } + app.session.select_layer(LayerId(id)).unwrap(); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + document: None, + caret: 0, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let xf = ViewXform { + rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), + zoom: 1.0, + center: [400.0, 300.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let (_, aff, _) = layout(&mut app, LayerId(id)).unwrap(); + let photocraft_doc::text::TextShape::Box { x, y, width, height } = text_layer(&app.session.active().unwrap().doc, LayerId(id)).unwrap().shape.clone() + else { + panic!("a paragraph box") + }; + let p = aff.apply(Point::new(f64::from(x + width), f64::from(y + height))); + let corner = xf.to_screen(p.x as f32, p.y as f32); + let mut h = + Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(move |ui, app: &mut PhotocraftApp| super::draw_overlay(app, ui.painter(), &xf), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.run(); + (h.output().shapes.iter().map(|s| s.shape.clone()).collect(), corner) +} + +/// The handle square centred on `at` (within half a pixel), and how many black strokes lie +/// inside it, drawn after its fill, one horizontal and one vertical through its centre. +fn handle_at(shapes: &[egui::Shape], at: Pos2) -> (Option, usize) { + let near = |p: Pos2| (p - at).length() < 0.5; + let Some((k, rect)) = shapes.iter().enumerate().find_map(|(k, s)| match s { + egui::Shape::Rect(r) if near(r.rect.center()) && r.fill == egui::Color32::WHITE => Some((k, r.rect)), + _ => None, + }) else { + return (None, 0); + }; + let strokes: Vec<[Pos2; 2]> = shapes[k..] + .iter() + .filter_map(|s| match s { + egui::Shape::LineSegment { points, stroke } if stroke.color == egui::Color32::BLACK && rect.contains(points[0]) && rect.contains(points[1]) => { + Some(*points) + } + _ => None, + }) + .collect(); + let across = |f: fn(egui::Vec2) -> bool| strokes.iter().any(|[a, b]| near(a.lerp(*b, 0.5)) && (*b - *a).length() >= 4.0 && f(*b - *a)); + let plus = usize::from(across(|d| d.y.abs() < 0.01)) + usize::from(across(|d| d.x.abs() < 0.01)); + (Some(rect), if strokes.len() == 2 { plus } else { strokes.len() }) +} + +/// The reference application marks a paragraph box that can't hold its text with a "+" in an +/// enlarged lower-right handle: nothing else is drawn (no disc below the box), and a box that +/// fits keeps the ordinary 7 px handle. The mark follows the box when the layer is rotated. +#[test] +fn an_overflowing_box_shows_a_plus_in_its_enlarged_lower_right_handle() { + let circles = |shapes: &[egui::Shape]| shapes.iter().filter(|s| matches!(s, egui::Shape::Circle(_))).count(); + for turn in [0.0, 0.5] { + let (shapes, corner) = box_overlay( + "ab +cd +ef", 40, turn, + ); + let (rect, plus) = handle_at(&shapes, corner); + let rect = rect.unwrap_or_else(|| panic!("a handle on the lower-right corner at {turn} rad")); + assert_eq!(rect.size(), vec2(super::overlay::OVERFLOW_HANDLE, super::overlay::OVERFLOW_HANDLE), "enlarged at {turn} rad"); + assert!(rect.width() > 7.0, "larger than the other handles"); + assert_eq!(plus, 2, "a black + on top of it at {turn} rad"); + assert_eq!(circles(&shapes), 0, "no disc at {turn} rad"); + } + let (shapes, corner) = box_overlay( + "ab +cd +ef", 600, 0.0, + ); + let (rect, plus) = handle_at(&shapes, corner); + assert_eq!(rect.map(|r| r.size()), Some(vec2(7.0, 7.0)), "a box that fits keeps the ordinary handle"); + assert_eq!(plus, 0, "and no +"); +} + +/// An overflowing box, in an edit session, with its handle spots in document space: `[0]` the +/// lower-right corner, `[1]` the top-left, `[2]` the right-middle. +fn overflowing_box(height: f64) -> (PhotocraftApp, u64, [Point; 3]) { + let mut app = new_app(); + let id = app + .run( + "type.create", + json!({"text": "ab +cd +ef", "size": 24, "box": [200.0, 150.0, 260.0, height]}), + ) + .unwrap()["layer"] + .as_u64() + .unwrap(); + app.session.select_layer(LayerId(id)).unwrap(); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id, + document: None, + caret: 0, + anchor: 0, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let (_, aff, _) = layout(&mut app, LayerId(id)).unwrap(); + let photocraft_doc::text::TextShape::Box { x, y, width, height } = text_layer(&app.session.active().unwrap().doc, LayerId(id)).unwrap().shape.clone() + else { + panic!("a paragraph box") + }; + let pt = |a: f32, b: f32| aff.apply(Point::new(f64::from(a), f64::from(b))); + let spots = [pt(x + width, y + height), pt(x, y), pt(x + width, y + height / 2.0)]; + // As the overlay sets it each frame for the box it draws. + app.ui.type_box_overflow = true; + (app, id, spots) +} + +/// The enlarged overflow handle is square on screen. In a view turned 45°, its outer corner +/// lies half a diagonal from the box corner along a document axis, past the ordinary handles' +/// 6 px reach: a press there still grabs the lower-right handle. Only that handle, only on the +/// box being edited, and only where no handle answers at the ordinary reach. +#[test] +fn the_whole_enlarged_overflow_handle_grabs_in_a_rotated_view() { + let (mut app, id, [corner, top_left, _]) = overflowing_box(40.0); + // The handle's corner, 5.5 px right and down on a screen turned 45°: 7.6 px along one axis. + let reach = f64::from(super::overlay::OVERFLOW_HANDLE / 2.0 * std::f32::consts::SQRT_2 * 0.98 / app.point_zoom()); + assert_eq!(super::box_handle_at(&mut app, LayerId(id), corner.x + reach, corner.y), Some(2), "the enlarged handle"); + assert_eq!(super::box_handle_at(&mut app, LayerId(id), corner.x + 3.0 * reach, corner.y), None, "but not far beyond it"); + assert_eq!(super::box_handle_at(&mut app, LayerId(id), top_left.x - reach, top_left.y), None, "an ordinary handle keeps its reach"); + app.ui.type_box_overflow = false; + assert_eq!(super::box_handle_at(&mut app, LayerId(id), corner.x + reach, corner.y), None, "a box that fits has the ordinary handle"); + app.ui.type_box_overflow = true; + app.ui.text_edit.as_mut().unwrap().layer = id + 1; + assert_eq!(super::box_handle_at(&mut app, LayerId(id), corner.x + reach, corner.y), None, "the flag is the edited box's"); +} + +/// A box short on screen: its right-middle handle sits 6.75 px above the corner, inside the +/// enlarged handle's wide reach but outside the ordinary one. A press on it is still that +/// handle's, not the corner's. +#[test] +fn a_short_overflowing_box_keeps_its_middle_handles() { + let zoom = f64::from(new_app().point_zoom()); + let (mut app, id, [_, _, right]) = overflowing_box(13.5 / zoom); + assert_eq!(super::box_handle_at(&mut app, LayerId(id), right.x, right.y), Some(5)); +} + fn ellipse_layer(app: &mut PhotocraftApp) -> u64 { app.run("shape.create", json!({"kind": "ellipse", "rect": [200, 200, 400, 300], "fill": "#dddddd"})).unwrap()["layer"].as_u64().unwrap() } diff --git a/crates/ui-egui/src/tools/type_tool/defaults.rs b/crates/ui-egui/src/tools/type_tool/defaults.rs index 203c3e9e93..939ec4966d 100644 --- a/crates/ui-egui/src/tools/type_tool/defaults.rs +++ b/crates/ui-egui/src/tools/type_tool/defaults.rs @@ -1,4 +1,5 @@ use super::editing::edit_home; +use super::insertion_style; use super::{LayerId, PhotocraftApp, TextLayer, hex, json, text_layer}; /// Representative for the displayed properties: the edit layer, else a selected type layer. @@ -204,6 +205,10 @@ pub(super) fn apply_defaults(app: &mut PhotocraftApp, props: &serde_json::Value) /// 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. pub(super) fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) { + if insertion_style::set_font(app, &props) { + return; + } + app.ui.type_insertion_style = None; let Some(mut p) = formatting_params(app) else { apply_defaults(app, &props); return; diff --git a/crates/ui-egui/src/tools/type_tool/editing.rs b/crates/ui-egui/src/tools/type_tool/editing.rs index 57c3a3177f..f93826c1d1 100644 --- a/crates/ui-egui/src/tools/type_tool/editing.rs +++ b/crates/ui-egui/src/tools/type_tool/editing.rs @@ -1,7 +1,10 @@ use super::ime::interrupt_incoming; use super::ime_edit::note_ime_application_change; use super::layout::{hit_layer, hit_offset, shows_own_layout}; -use super::{LayerId, PhotocraftApp, TextEdit, TextLayer, active_doc_id, byte_range, current_text, json, selected_range, session_key, text_layer}; +use super::{ + Arc, FontFieldEdit, HashMap, LayerId, PhotocraftApp, TextEdit, TextLayer, accept_row, active_doc_id, apply, byte_range, current_text, families, + font_labels_id, font_menu_rows, insertion_style, json, selected_range, session_key, text_layer, +}; /// Start editing an existing type layer. Like Photoshop, editing shows the text as the type /// engine lays it out, so a PSD layer is re-rendered first (inside the edit session's history @@ -20,6 +23,7 @@ pub(super) fn begin_edit(app: &mut PhotocraftApp, id: LayerId, key: &str) -> Res /// Edit the active type layer from its thumbnail, selecting all text like Photoshop. Reuse the /// current session when already editing it, so Cancel and undo still cover the whole edit. pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { + app.ui.type_insertion_style = None; let st = app.session.active().ok_or("no document open")?; let id = st.active_layer.ok_or("no active layer")?; let n = text_layer(&st.doc, id).ok_or("active layer is not a type layer")?.text.chars().count(); @@ -142,7 +146,7 @@ pub(super) fn insert(app: &mut PhotocraftApp, s: &str) { return; } crate::type_transform::finish(app); - let applied = app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": a, "end": b, "text": s}, "coalesce": ed.session})).is_ok(); + let applied = insertion_style::replace(app, a, b, &s); if applied { let mut cancelled = false; if let Some(e) = app.ui.text_edit.as_mut() { @@ -257,7 +261,7 @@ impl CharToggle { /// The character styles of the selection, or of the typing style when the text is empty. Empty /// when nothing is selected in non-empty text. -fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { +pub(super) fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { let runs = t.char_runs(); if t.text.is_empty() { return runs.into_iter().take(1).map(|r| r.style).collect(); @@ -350,10 +354,63 @@ pub fn commit(app: &mut PhotocraftApp) { commit_with(app, egui::Modifiers::NONE); } +/// The document's commit shortcut also works while a Type field owns keyboard input. +/// Resolve a pending font query before ending the edit; composition still owns its keys. +pub(crate) fn commit_shortcut(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + let Some(ed) = app.ui.text_edit.as_ref() else { return false }; + let composing = ed.preedit.is_some(); + let mods = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.command => Some(*modifiers), + _ => None, + }) + }); + let Some(mods) = mods else { return false }; + let focused = ctx.memory(|m| m.focused()); + let font_edit = focused.and_then(|id| ctx.data(|d| d.get_temp::(id.with("edit")))); + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Enter, modifiers, .. } if modifiers.command))); + if composing || font_edit.as_ref().is_some_and(|e| e.composing) || ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Ime(_)))) { + // Suppress the document/path shortcut, but leave IME events for their focused editor. + return false; + } + end_if_left(app); + if app.ui.text_edit.is_none() { + return true; + } + if let Some(edit) = font_edit.filter(|e| !e.accepted && !e.text.trim().is_empty() && !e.query.is_empty()) { + let families = families(); + let labels: Option>> = ctx.data(|d| d.get_temp(font_labels_id())); + let (rows, _) = font_menu_rows(&families, labels.as_deref(), &edit.query, &edit.recents); + let selected = + edit.row.filter(|&i| rows.get(i).is_some_and(|(f, _)| **f == edit.current)).or_else(|| rows.iter().position(|(f, _)| **f == edit.current)); + let family = accept_row(&rows, &edit.text, selected).and_then(|i| rows.get(i)).map(|(f, _)| (*f).clone()); + if let Some(family) = family { + photocraft_text::served::request(&family); + app.note_recent_font(&family); + app.ui.tool_options.type_font = family.clone(); + apply(app, ctx, json!({"font": family})); + } + } + if let Some(field) = focused { + ctx.memory_mut(|m| m.surrender_focus(field)); + ctx.data_mut(|d| d.remove::(field.with("edit"))); + } + egui::Popup::close_all(ctx); + ctx.data_mut(|d| { + d.remove::(egui::Id::unique("type-font-hover")); + d.remove::<(String, f64)>(egui::Id::unique("type-font-hover-since")); + }); + crate::type_font_preview::clear(app); + commit_with(app, mods); + true +} + /// [`commit`] with the modifiers held while committing: a Type Mask adds its glyphs to the /// 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) { + app.ui.type_insertion_style = None; + crate::type_font_preview::clear(app); 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); @@ -450,6 +507,7 @@ pub fn end_if_left(app: &mut PhotocraftApp) { /// Cancel the editing session: undo it back to where it started (removes a new layer). pub fn cancel(app: &mut PhotocraftApp) { + app.ui.type_insertion_style = None; crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; app.type_ime.clear_preedit(); diff --git a/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs b/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs new file mode 100644 index 0000000000..bb80791b79 --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/font_commit_tests.rs @@ -0,0 +1,596 @@ +//! Selected-range font acceptance, caret typing styles and focused global commit. +use crate::{PhotocraftApp, Services}; +use egui::{Key, Modifiers, accesskit::Role, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::{Value, json}; + +const FONTS: [&str; 2] = ["Inter", "JetBrains Mono"]; + +struct Probe { + app: PhotocraftApp, + character: bool, +} + +impl eframe::App for Probe { + fn logic(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) { + crate::shortcuts::handle(&mut self.app, ctx); + crate::type_font_preview::follow_hover(&mut self.app, ctx); + } + fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) { + if self.character { + ui.vertical(|ui| { + ui.set_width(340.0); + super::character_panel(&mut self.app, ui, false); + }); + } + ui.horizontal(|ui| super::options_bar(&mut self.app, ui)); + } +} + +fn layer(doc: &Document, id: LayerId) -> &photocraft_doc::TextLayer { + let LayerContent::Text(t) = &doc.layer(id).unwrap().content else { panic!("text layer") }; + t +} + +fn styles(doc: &Document, id: LayerId) -> Value { + let t = layer(doc, id); + let runs = t.char_runs(); + let mut at = 0; + let mut chars = Vec::new(); + for r in runs { + let end = at + r.len; + for ch in t.text.get(at..end).unwrap().chars() { + chars.push(json!({"char": ch.to_string(), "font": r.style.font_family, + "face": r.style.postscript_name, "weight": r.style.weight, "italic": r.style.italic})); + } + at = end; + } + json!({"text": t.text, "summary": t.font_family, "chars": chars}) +} + +fn saved(h: &Harness<'_, Probe>, id: LayerId) -> Value { + styles(&h.state().app.session.active().unwrap().doc, id) +} + +fn families(s: &Value) -> Vec { + s["chars"].as_array().unwrap().iter().map(|c| c["font"].as_str().unwrap().to_owned()).collect() +} + +fn fields(h: &Harness<'_, Probe>) -> Vec> { + h.query_all_by_role(Role::TextInput).map(|n| n.value()).collect() +} + +fn select(h: &mut Harness<'_, Probe>, range: [usize; 2]) { + let e = h.state_mut().app.ui.text_edit.as_mut().unwrap(); + e.anchor = range[0]; + e.caret = range[1]; +} + +fn fixture(new: bool, pattern: usize, character: bool, content: &str) -> (Harness<'static, Probe>, LayerId) { + fixture_renderer(new, pattern, character, content, false, 1.0) +} + +fn fixture_renderer(new: bool, pattern: usize, character: bool, content: &str, gpu: bool, scale: f32) -> (Harness<'static, Probe>, LayerId) { + let builder = Harness::builder().with_size(vec2(1100.0, 1000.0)).with_pixels_per_point(scale).with_max_steps(64); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.session.edit_prefs(|p| p.type_.font_preview = photocraft_engine::prefs::FontPreview::Off); + Probe { app, character } + }); + let app = &mut h.state_mut().app; + app.run("file.new", json!({"width": 240, "height": 160})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Type; + app.ui.tool_options.type_font = FONTS[pattern % 2].into(); + app.ui.tool_options.type_size = 18.0; + super::pointer_up(app, [30.0, 70.0], [30.0, 70.0]); + super::insert(app, content); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let key = app.ui.text_edit.as_ref().unwrap().session.clone(); + // Build mixed styles through the same live edit session, preserving history/coalescing. + if pattern >= 2 { + app.run("type.setStyle", json!({"layer": id.0, "range": [2,4], "font": FONTS[1-pattern%2], "coalesce": key})).unwrap(); + } + if !new { + super::commit(app); + super::edit_active(app).unwrap(); + } + h.run_steps(3); + (h, id) +} + +fn choose(h: &mut Harness<'_, Probe>, font: &str, accept: &str) { + h.query_all_by_role(Role::TextInput).next().unwrap().click(); + h.run(); + h.query_all_by_role(Role::TextInput).next().unwrap().type_text(font); + h.run(); + match accept { + "enter" => h.key_press(Key::Enter), + "arrow-enter" => { + h.key_press(Key::ArrowDown); + h.run(); + h.key_press(Key::Enter); + } + "click" => h.get_by_label(font).click(), + "hover" => h.get_by_label(font).hover(), + "query" => {} + "tab" => h.key_press(Key::Tab), + "escape" => h.key_press(Key::Escape), + _ => panic!("unknown acceptance"), + } + h.run(); +} + +fn commit(h: &mut Harness<'_, Probe>, method: &str) { + match method { + "checkmark" => { + let label = crate::i18n::fmt("Commit any current edits ({key})", &[("key", &crate::shortcuts::pretty("Cmd+Enter"))]); + h.get_by_role_and_label(Role::Button, &label).click(); + h.run(); + } + "outside" => { + super::pointer_down(&mut h.state_mut().app, 230.0, 150.0, Modifiers::NONE); + h.run(); + } + "direct" => { + super::commit(&mut h.state_mut().app); + h.run(); + } + "shortcut-focused" => { + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run(); + } + "shortcut-unfocused" => { + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run(); + } + "layer-switch" => { + h.state_mut().app.run("layer.new.layer", json!({})).unwrap(); + super::end_if_left(&mut h.state_mut().app); + h.run(); + } + "document-switch" => { + h.state_mut().app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + super::end_if_left(&mut h.state_mut().app); + h.state_mut().app.session.set_active(0); + h.run(); + } + _ => panic!("unknown commit"), + } +} + +#[test] +fn selected_fonts_commit_pattern_0() { + selected_fonts_commit_cases(0); +} + +#[test] +fn selected_fonts_commit_pattern_1() { + selected_fonts_commit_cases(1); +} + +#[test] +fn selected_fonts_commit_pattern_2() { + selected_fonts_commit_cases(2); +} + +#[test] +fn selected_fonts_commit_pattern_3() { + selected_fonts_commit_cases(3); +} + +fn selected_fonts_commit_cases(pattern: usize) { + let mut cases = 0; + for new in [false, true] { + for character in [false, true] { + for range in [[0, 2], [2, 4], [4, 6], [0, 6], [4, 2], [1, 5]] { + for target in FONTS { + for accept in ["enter", "click", "arrow-enter"] { + for method in ["checkmark", "outside"] { + let label = (new, pattern, character, range, target, accept, method); + let (mut h, id) = fixture(new, pattern, character, "abcdef"); + select(&mut h, range); + h.run(); + let mut expected = families(&saved(&h, id)); + for font in &mut expected[range[0].min(range[1])..range[0].max(range[1])] { + *font = target.into(); + } + choose(&mut h, target, accept); + let applied = saved(&h, id); + assert_eq!(families(&applied), expected, "{label:?}"); + assert_eq!(fields(&h)[0].as_deref(), Some(target), "selected font caption: {label:?}"); + let pixels = layer(&h.state().app.session.active().unwrap().doc, id).cache.clone(); + commit(&mut h, method); + assert!(h.state().app.ui.text_edit.is_none(), "{label:?}"); + assert_eq!(saved(&h, id), applied, "{label:?}"); + assert_eq!(layer(&h.state().app.session.active().unwrap().doc, id).cache, pixels, "{label:?}"); + let uniform = expected.iter().all(|f| f == &expected[0]); + assert_eq!( + fields(&h)[0].clone().unwrap_or_default(), + if uniform { expected[0].clone() } else { String::new() }, + "mixed caption after commit: {label:?}" + ); + super::edit_active(&mut h.state_mut().app).unwrap(); + select(&mut h, range); + h.run(); + assert_eq!(fields(&h)[0].as_deref(), Some(target), "reselected caption: {label:?}"); + cases += 1; + } + } + } + } + } + } + assert_eq!(cases, 288); +} + +#[test] +fn caret_font_choices_style_only_subsequent_typing_including_ime() { + for new in [false, true] { + for character in [false, true] { + for original in 0..2 { + for text in ["abcdef", "AéB👩‍💻", "ab\ncdé"] { + let n = text.chars().count(); + for caret in [0, n / 2, n] { + for accept in ["enter", "click", "arrow-enter"] { + let (mut h, id) = fixture(new, original, character, text); + select(&mut h, [caret, caret]); + h.run(); + let before = saved(&h, id); + let steps = h.state().app.session.active().unwrap().history.entries().len(); + let font = FONTS[1 - original]; + choose(&mut h, font, accept); + assert_eq!(saved(&h, id), before, "a font choice at a caret never restyles existing characters"); + assert_eq!(h.state().app.session.active().unwrap().history.entries().len(), steps); + assert_eq!(fields(&h)[0].as_deref(), Some(font)); + crate::type_font_preview::set(&mut h.state_mut().app, font.into()); + assert!(crate::type_font_preview::display_doc(&mut h.state_mut().app, 0).is_none()); + // Multiple preedit replacements and final commit keep the pending font. + super::ime_update(&mut h.state_mut().app, "é", false); + super::ime_update(&mut h.state_mut().app, "éX", true); + super::insert(&mut h.state_mut().app, "Z"); + let mut expected = families(&before); + expected.splice(caret..caret, std::iter::repeat_n(font.to_string(), 3)); + assert_eq!(families(&saved(&h, id)), expected); + let typed = saved(&h, id); + commit(&mut h, "checkmark"); + assert_eq!(saved(&h, id), typed); + assert!(h.state().app.ui.type_insertion_style.is_none()); + } + } + } + } + } + } +} + +#[test] +fn global_commit_accepts_pending_font_queries_and_leaves_no_focused_field() { + for new in [false, true] { + for character in [false, true] { + for accept in ["enter", "click", "arrow-enter", "query", "hover", "tab", "escape"] { + for method in ["shortcut-focused", "shortcut-unfocused", "layer-switch", "document-switch"] { + let (mut h, id) = fixture(new, 0, character, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, FONTS[1], accept); + commit(&mut h, method); + assert!(h.state().app.ui.text_edit.is_none(), "{new}/{character}/{accept}/{method}"); + let mut expected = vec![FONTS[0].to_string(); 6]; + if matches!(accept, "enter" | "click" | "arrow-enter") || (method == "shortcut-focused" && matches!(accept, "query" | "hover")) { + expected[2..4].fill(FONTS[1].into()); + } + assert_eq!(families(&saved(&h, id)), expected, "{new}/{character}/{accept}/{method}"); + } + } + } + } +} + +#[test] +fn global_commit_clears_hover_before_preview_can_expand_to_the_whole_layer() { + let (mut h, id) = fixture(false, 2, false, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, FONTS[1], "hover"); + h.event(egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.run_steps(1); + assert!(h.state().app.ui.text_edit.is_none()); + let (shown, _) = crate::canvas::display_doc(&mut h.state_mut().app, 0); + assert_eq!(styles(&shown, id), saved(&h, id), "the first committed frame must show real runs, not a full-layer hover preview"); +} + +#[test] +fn caret_style_is_discarded_by_navigation_cancel_undo_and_document_switch() { + for action in ["navigation", "cancel", "undo", "document-switch"] { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + match action { + "navigation" => { + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press(Key::ArrowLeft); + h.run(); + h.key_press(Key::ArrowRight); + h.run(); + super::insert(&mut h.state_mut().app, "Z"); + assert!(families(&saved(&h, id)).iter().all(|f| f == FONTS[0])); + } + "cancel" => { + super::cancel(&mut h.state_mut().app); + assert!(h.state().app.ui.type_insertion_style.is_none()); + } + "undo" => { + h.state_mut().app.run("type.setStyle", json!({"layer":id.0,"range":[0,1],"font":FONTS[1]})).unwrap(); + assert!(h.state_mut().app.session.undo()); + assert!(super::insertion_style::pending(&h.state().app).is_none()); + } + _ => { + commit(&mut h, "document-switch"); + assert!(h.state().app.ui.type_insertion_style.is_none()); + } + } + } +} + +#[test] +fn returning_focus_to_the_same_canvas_caret_keeps_the_chosen_typing_font() { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [6, 6]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + let (layout, aff, text) = super::layout(&mut h.state_mut().app, id).unwrap(); + let (x, top, bottom) = layout.caret(text.len()); + let p = aff.apply(photocraft_geom::Point::new(f64::from(x) - 0.1, f64::from((top + bottom) * 0.5))); + assert!(super::pointer_down(&mut h.state_mut().app, p.x, p.y, Modifiers::NONE)); + super::pointer_up(&mut h.state_mut().app, [p.x, p.y], [p.x, p.y]); + assert_eq!(h.state().app.ui.text_edit.as_ref().unwrap().caret, 6); + assert!(super::insertion_style::pending(&h.state().app).is_some()); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.event(egui::Event::Text("XYZ".into())); + h.run(); + assert_eq!(families(&saved(&h, id)), [FONTS[0], FONTS[0], FONTS[0], FONTS[0], FONTS[0], FONTS[0], FONTS[1], FONTS[1], FONTS[1]]); +} + +#[test] +fn backspace_and_delete_preserve_pending_caret_font() { + for key in [Key::Backspace, Key::Delete] { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press(key); + h.run(); + assert!(super::insertion_style::pending(&h.state().app).is_some()); + h.event(egui::Event::Text("Z".into())); + h.run(); + let expected = if key == Key::Backspace { + vec![FONTS[0], FONTS[0], FONTS[1], FONTS[0], FONTS[0], FONTS[0]] + } else { + vec![FONTS[0], FONTS[0], FONTS[0], FONTS[1], FONTS[0], FONTS[0]] + }; + assert_eq!(families(&saved(&h, id)), expected); + } + + // Multiple backspaces in a row still preserve the pending font + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.key_press(Key::Backspace); + h.run(); + h.key_press(Key::Backspace); + h.run(); + assert!(super::insertion_style::pending(&h.state().app).is_some()); + h.event(egui::Event::Text("Z".into())); + h.run(); + assert_eq!(families(&saved(&h, id)), [FONTS[0], FONTS[1], FONTS[0], FONTS[0], FONTS[0]]); +} + +#[test] +fn commit_shortcut_resolves_a_pending_query_like_enter() { + // "n" matches several families, and the current one is not the first row, so a fallback to + // the first row would apply a family the menu never highlighted. + let query = "n"; + let all = super::families(); + let (rows, _) = super::font_menu_rows(&all, None, query, &[]); + assert!(rows.first().is_some_and(|(f, _)| f.as_str() != FONTS[1]), "the test needs a first row other than the current family"); + assert!(rows.iter().any(|(f, _)| f.as_str() == FONTS[1])); + for accept in ["enter", "shortcut"] { + let (mut h, id) = fixture(false, 1, false, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, query, "query"); + if accept == "enter" { + h.key_press(Key::Enter); + h.run(); + } else { + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_none(), "the shortcut still commits"); + } + assert_eq!(families(&saved(&h, id)), vec![FONTS[1]; 6], "{accept} keeps the highlighted current family"); + } +} + +#[test] +fn commit_does_not_accept_an_invalid_font_or_interrupt_ime() { + for (character, query) in [(false, "no such font zzz"), (true, "no such font zzz"), (false, " "), (true, " ")] { + let (mut h, id) = fixture(false, 0, character, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, query, "query"); + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_none()); + assert_eq!(families(&saved(&h, id)), vec![FONTS[0]; 6]); + super::edit_active(&mut h.state_mut().app).unwrap(); + super::ime_update(&mut h.state_mut().app, "é", false); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run(); + assert!(h.state().app.ui.text_edit.is_some(), "composition owns its confirmation"); + } +} + +#[test] +fn font_field_composition_keeps_confirmation_then_releases_global_commit() { + for cancelled in [false, true] { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [2, 4]); + h.run(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); + h.run(); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: FONTS[1].into(), active_range_chars: None })); + h.run(); + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_some()); + assert_eq!(families(&saved(&h, id)), vec![FONTS[0]; 6]); + if cancelled { + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None })); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(egui::Event::Text(FONTS[1].into())); + h.run(); + } else { + h.event(egui::Event::Ime(egui::ImeEvent::Commit(FONTS[1].into()))); + h.run(); + } + commit(&mut h, "shortcut-focused"); + assert!(h.state().app.ui.text_edit.is_none()); + assert_eq!(families(&saved(&h, id)), [FONTS[0], FONTS[0], FONTS[1], FONTS[1], FONTS[0], FONTS[0]]); + } +} + +#[test] +fn caret_font_insertion_preserves_variable_axes_and_style_changes_can_clear_weight_axis() { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + h.state_mut().app.run("type.setStyle", json!({"layer":id.0,"variations":{"wght":650,"wdth":95}})).unwrap(); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + super::insert(&mut h.state_mut().app, "X"); + let variations = |app: &PhotocraftApp, at: usize| { + let mut offset = 0; + layer(&app.session.active().unwrap().doc, id) + .char_runs() + .into_iter() + .find_map(|r| { + offset += r.len; + (offset > at).then_some(r.style.variations) + }) + .unwrap() + }; + let axes = variations(&h.state().app, 3); + assert!(axes.iter().any(|v| v.axis == "wght" && v.value == 650.0)); + assert!(axes.iter().any(|v| v.axis == "wdth" && v.value == 95.0)); + let ctx = h.ctx.clone(); + super::apply(&mut h.state_mut().app, &ctx, json!({"fontStyle":"Bold"})); + super::insert(&mut h.state_mut().app, "Y"); + let axes = variations(&h.state().app, 4); + assert!(!axes.iter().any(|v| v.axis == "wght")); + assert!(axes.iter().any(|v| v.axis == "wdth" && v.value == 95.0)); +} + +#[test] +fn caret_font_and_face_persist_across_more_font_choices_and_paste() { + let (mut h, id) = fixture(false, 0, false, "abcdef"); + select(&mut h, [3, 3]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + let ctx = h.ctx.clone(); + super::apply(&mut h.state_mut().app, &ctx, json!({"fontStyle":"Bold"})); + choose(&mut h, FONTS[0], "enter"); + h.ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + h.event(egui::Event::Paste("éX".into())); + h.run(); + let t = saved(&h, id); + for (i, c) in t["chars"].as_array().unwrap().iter().enumerate() { + assert_eq!(c["font"], FONTS[0]); + assert_eq!(c["weight"], if (3..5).contains(&i) { 700 } else { 400 }); + } + let applied = saved(&h, id); + commit(&mut h, "shortcut-focused"); + assert_eq!(saved(&h, id), applied); + for name in ["probe.pcraft", "probe.psd"] { + let doc = h.state().app.session.active().unwrap().doc.clone(); + let out = photocraft_io::export(&doc, name, &Default::default()).unwrap(); + let back = photocraft_io::import(name, &out.bytes).unwrap().document; + let imported_id = back.walk().into_iter().find_map(|(_, _, l)| matches!(l.content, LayerContent::Text(_)).then_some(l.id)).unwrap(); + assert_eq!(families(&styles(&back, imported_id)), families(&applied)); + } + assert!(h.state_mut().app.session.undo()); + assert_eq!(saved(&h, id)["text"], "abcdef"); + assert!(h.state_mut().app.session.redo()); + assert_eq!(saved(&h, id), applied); +} + +#[test] +fn an_existing_empty_layer_keeps_its_font_when_committed() { + for character in [false, true] { + let (mut h, id) = fixture(false, 0, character, "abcdef"); + h.state_mut().app.run("type.edit", json!({"layer":id.0,"text":""})).unwrap(); + select(&mut h, [0, 0]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + commit(&mut h, "checkmark"); + assert_eq!(saved(&h, id)["summary"], FONTS[1]); + super::edit_active(&mut h.state_mut().app).unwrap(); + super::insert(&mut h.state_mut().app, "X"); + assert_eq!(families(&saved(&h, id)), [FONTS[1]]); + } +} + +#[test] +fn font_commit_gpu_snapshots() { + let Some(path) = std::env::var_os("FONT_COMMIT_VISUALS").map(std::path::PathBuf::from) else { return }; + let _gpu = photocraft_testkit::gpu_lock(); + std::fs::create_dir_all(&path).unwrap(); + for character in [false, true] { + for scale in [1.0, 2.0] { + let (mut h, _) = fixture_renderer(false, 0, character, "abcdef", true, scale); + select(&mut h, [2, 4]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.render().unwrap().save(path.join(format!("selected-{character}-{scale}x.png"))).unwrap(); + commit(&mut h, "checkmark"); + h.render().unwrap().save(path.join(format!("committed-mixed-{character}-{scale}x.png"))).unwrap(); + super::edit_active(&mut h.state_mut().app).unwrap(); + select(&mut h, [2, 4]); + h.run(); + h.render().unwrap().save(path.join(format!("reselected-{character}-{scale}x.png"))).unwrap(); + select(&mut h, [6, 6]); + h.run(); + choose(&mut h, FONTS[1], "enter"); + h.render().unwrap().save(path.join(format!("caret-{character}-{scale}x.png"))).unwrap(); + } + } +} diff --git a/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs b/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs index 7d24fbef42..7144b2dcfc 100644 --- a/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/font_picker_tests.rs @@ -1,4 +1,4 @@ -//! The Type tool's searchable font menu (#1369): it grows back after a search and focuses its search field. +//! The Type tool's editable family field and searchable menu (#9338, #1369). use std::collections::HashMap; @@ -62,10 +62,237 @@ fn popup_rect(h: &Harness<'static, State>) -> Option { } fn open(h: &mut Harness<'static, State>) { - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); } +#[test] +fn clicking_the_name_selects_all_and_the_first_letter_replaces_it() { + let mut h = harness(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + open(&mut h); + let range = egui::TextEdit::load_state(&h.ctx, h.state().button).unwrap().cursor.char_range().unwrap(); + assert_eq!(range.sorted_cursors().map(|c| c.index.0), [0, "Family 00".chars().count()]); + h.event(egui::Event::Text("z".into())); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("z")); + assert_eq!(h.state().current, "Family 00", "typing only filters"); + assert!(h.query_by_label("Family 01").is_none()); + assert!(h.query_by_label("Zeta Sans").is_some()); + assert_eq!(h.query_all_by_role(Role::TextInput).count(), 1, "there is no second search box"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Zeta Sans"); + assert!(popup_rect(&h).is_none()); + assert!(h.get_by_role(Role::TextInput).is_focused()); +} + +#[test] +fn enter_prefers_a_full_name_over_an_earlier_substring_match() { + let mut h = harness(); + h.state_mut().families = vec!["Zeta Sans Display".into(), "Zeta Sans".into()]; + open(&mut h); + h.get_by_role(Role::TextInput).type_text("zEtA sAnS"); + h.run(); + assert_eq!(h.state().current, "Family 00"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Zeta Sans"); +} + +#[test] +fn empty_or_unmatched_input_applies_nothing_and_escape_restores_the_name() { + for query in ["", "no such font"] { + let mut h = harness(); + open(&mut h); + h.key_press(Key::Backspace); + h.run(); + h.event(egui::Event::Text(query.into())); + h.run(); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 00"); + assert!(popup_rect(&h).is_some()); + h.key_press(Key::Escape); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + assert!(!h.get_by_role(Role::TextInput).is_focused()); + assert!(popup_rect(&h).is_none()); + } +} + +#[test] +fn no_matching_fonts_shows_a_compact_message_and_clearing_restores_the_list() { + for preview in [FontPreview::Off, FontPreview::Medium, FontPreview::ExtraLarge] { + let mut h = harness_with(preview); + open(&mut h); + let full = popup_rect(&h).expect("full menu open"); + let field = h.get_by_role(Role::TextInput).rect(); + h.get_by_role(Role::TextInput).type_text("fdsfdsfds"); + h.run(); + let empty = popup_rect(&h).expect("unmatched input keeps the menu open"); + assert!(empty.width() >= field.width(), "{preview:?}: empty popup collapsed to {empty:?}"); + // The list shrinks to its message, so the menu ends with the class list beside it. + let classes = h.get_by_label("Decorative").rect(); + assert!( + empty.height() < full.height() - 100.0 && empty.bottom() < classes.bottom() + 20.0, + "{preview:?}: empty popup retained the full height: {empty:?} vs {full:?}" + ); + assert!(empty.contains(h.get_by_label("No matching fonts").rect().center())); + assert_eq!(h.state().current, "Family 00"); + h.key_press(Key::ArrowDown); + h.key_press(Key::ArrowUp); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 00", "empty-result navigation applies nothing"); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.key_press(Key::Backspace); + h.run(); + let restored = popup_rect(&h).expect("clearing restores the list"); + assert!(h.query_by_label("No matching fonts").is_none()); + assert!(h.query_by_label("Family 01").is_some()); + assert!((restored.width() - full.width()).abs() < 0.5, "{preview:?}: width {restored:?} != {full:?}"); + assert!((restored.height() - full.height()).abs() < 0.5, "{preview:?}: height {restored:?} != {full:?}"); + } +} + +#[test] +fn clicking_elsewhere_discards_unaccepted_input() { + let mut h = harness(); + open(&mut h); + h.get_by_role(Role::TextInput).type_text("Zeta"); + h.run(); + let outside = egui::pos2(550.0, 850.0); + h.hover_at(outside); + h.event(egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(h.state().current, "Family 00"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + assert!(popup_rect(&h).is_none()); +} + +#[test] +fn tab_leaves_the_field_and_discards_its_pending_query() { + let mut h = harness(); + open(&mut h); + h.get_by_role(Role::TextInput).type_text("Zeta"); + h.run(); + h.key_press(Key::Tab); + h.run(); + assert_eq!(h.state().current, "Family 00"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + assert!(popup_rect(&h).is_none()); + assert!(!h.get_by_role(Role::TextInput).is_focused()); +} + +#[test] +fn the_arrow_keeps_press_drag_release_selection() { + let mut h = harness(); + let arrow = h.get_by_role(Role::Button).rect().center(); + h.hover_at(arrow); + h.run(); + h.event(egui::Event::PointerButton { pos: arrow, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(popup_rect(&h).is_some()); + let row = h.get_by_label("Family 03").rect().center(); + h.hover_at(row); + h.run(); + h.event(egui::Event::PointerButton { pos: row, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none()); + assert!(h.get_by_role(Role::TextInput).is_focused()); +} + +#[test] +fn two_fields_keep_their_names_and_queries_independent() { + let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( + |ui, names: &mut [String; 2]| { + let families = vec!["Family 00".to_string(), "Zeta Sans".to_string()]; + for (i, name) in names.iter_mut().enumerate() { + super::family_picker_in(ui, &format!("font-{i}"), name, 170.0, &families, FontPreview::Off, &mut super::FontLibrary::default()); + } + }, + ["Family 00".to_string(), "Family 00".to_string()], + ); + h.run(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); + h.run(); + h.query_all_by_role(Role::TextInput).find(|n| n.is_focused()).unwrap().type_text("Zeta"); + h.run(); + let values: Vec<_> = h.query_all_by_role(Role::TextInput).map(|n| n.value().unwrap()).collect(); + assert_eq!(values, ["Zeta", "Family 00"]); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state(), &["Zeta Sans", "Family 00"]); + h.query_all_by_role(Role::TextInput).nth(1).unwrap().click(); + h.run(); + assert!(h.query_by_label("Family 00").is_some(), "the peer opens with its own full list"); +} + +#[test] +fn tab_focus_selects_the_name_and_pasted_unicode_can_be_accepted() { + let mut h = harness(); + h.state_mut().families = vec!["Family 00".into(), "Ime Sans".into()]; + h.key_press(Key::Tab); + h.run(); + assert!(h.get_by_role(Role::TextInput).is_focused()); + h.event(egui::Event::Paste("Ime Sans".into())); + h.run(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Ime Sans")); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Ime Sans"); +} + +#[test] +fn ime_confirmation_does_not_accept_a_font_until_a_separate_enter() { + let mut h = harness(); + h.state_mut().families = vec!["Family 00".into(), "Ime Sans".into()]; + open(&mut h); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "Ime Sans".into(), active_range_chars: None })); + h.run(); + h.key_press(Key::ArrowDown); + h.run(); + h.key_press(Key::ArrowUp); + h.run(); + assert_eq!(h.state().current, "Family 00", "IME arrows do not select a font"); + assert!(h.get_by_role(Role::TextInput).is_focused()); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Family 00", "IME Enter only confirms composition"); + assert!(popup_rect(&h).is_some()); + assert!(h.get_by_role(Role::TextInput).is_focused()); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("Ime Sans".into()))); + h.run(); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().current, "Ime Sans"); + assert!(popup_rect(&h).is_none()); +} + +#[test] +fn dragging_inside_the_name_selects_text_without_picking_a_font() { + let mut h = harness(); + open(&mut h); + let field = h.get_by_role(Role::TextInput).rect(); + let from = egui::pos2(field.left() + 5.0, field.center().y); + let to = egui::pos2(field.left() + 30.0, field.center().y); + h.hover_at(from); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run(); + h.hover_at(to); + h.run(); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run(); + let range = egui::TextEdit::load_state(&h.ctx, h.state().button).unwrap().cursor.char_range().unwrap(); + assert!(!range.is_empty()); + assert!(range.as_sorted_char_range().end.0 - range.as_sorted_char_range().start.0 < "Family 00".chars().count()); + assert_eq!(h.state().current, "Family 00"); + assert!(popup_rect(&h).is_some()); +} + /// A native click can leave the opening button held across several rendered frames. fn hold_open(h: &mut Harness<'_, S>, button: egui::Pos2) { h.hover_at(button); @@ -155,10 +382,10 @@ fn the_search_field_stays_put_while_the_list_scrolls() { 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. + // The name stays in the original control while the list scrolls underneath. 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"); + assert!(!popup.contains(after.center()), "the editable name stays above the menu"); } /// The wheel past the end of the list, or over the class list, leaves the preview, filters and @@ -234,42 +461,37 @@ fn clearing_a_query_that_matches_nothing_grows_the_menu_back() { } #[test] -fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() { +fn reopening_selects_the_applied_name_and_clicking_it_keeps_the_menu_open() { let mut h = harness(); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); - hold_open(&mut h, button); - assert!(h.get_by_role(Role::TextInput).is_focused(), "search is focused on first open"); + let field = h.get_by_role(Role::TextInput).rect().center(); + hold_open(&mut h, field); + assert!(h.get_by_role(Role::TextInput).is_focused()); h.event(egui::Event::Text("zeta".into())); h.run(); - assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta"), "typing goes straight to the search field"); - // Close and reopen on consecutive passes: the query stays and the field is focused again. - h.input_mut().events.push(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.step(); - assert!(popup_rect(&h).is_none(), "Escape closes the menu"); - h.query_all_by_role(Role::ComboBox).next().unwrap().click_accesskit(); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("zeta")); + h.key_press(Key::Escape); h.run(); - let search = h.get_by_role(Role::TextInput); - assert_eq!(search.value().as_deref(), Some("zeta")); - assert!(search.is_focused(), "search is focused on reopen with a query"); - // Clicking the search field (to edit the query) must not close the menu. + assert!(popup_rect(&h).is_none()); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("Family 00")); + open(&mut h); + assert!(h.get_by_role(Role::TextInput).is_focused()); + assert!(h.query_by_label("Family 01").is_some(), "reopening starts with the full list"); h.get_by_role(Role::TextInput).click(); h.run(); - assert!(popup_rect(&h).is_some(), "clicking the search field keeps the menu open"); + assert!(popup_rect(&h).is_some(), "editing the name keeps the list open"); h.key_press_modifiers(Modifiers::COMMAND, Key::A); h.get_by_role(Role::TextInput).type_text("Zeta S"); h.run(); - assert!(popup_rect(&h).is_some()); - // Picking a font still closes the menu. h.get_by_label("Zeta Sans").click(); h.run(); assert_eq!(h.state().current, "Zeta Sans"); - assert!(popup_rect(&h).is_none(), "choosing a font closes the menu"); + assert!(popup_rect(&h).is_none()); } #[test] -fn opening_click_release_returns_focus_to_search_before_typing() { +fn opening_arrow_release_returns_focus_to_the_name_before_typing() { let mut h = harness(); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + let button = h.get_by_role(Role::Button).rect().center(); h.hover_at(button); h.run(); h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); @@ -277,7 +499,7 @@ fn opening_click_release_returns_focus_to_search_before_typing() { assert!(h.get_by_role(Role::TextInput).is_focused()); // A native opening click can hand focus back to its button when it ends, after the // search has already been focused for several passes. Model that platform handoff. - let id = h.state().button; + let id = h.state().button.with("arrow"); h.ctx.memory_mut(|m| m.request_focus(id)); h.event(egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.run(); @@ -308,7 +530,7 @@ fn first_options_bar_open_searches_without_changing_the_tool_or_edited_text() { app }); h.run_steps(8); - let button = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Inter")).unwrap().rect().center(); + let button = h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("Inter")).unwrap().rect().center(); hold_open(&mut h, button); // Native typing carries key events as well as text; an unfocused search would let // the first key switch tools, or replace the selected inline text instead. @@ -493,11 +715,10 @@ fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() { ); crate::PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); // The font search field's caret blinks every pass, so settle with bounded steps instead. h.run_steps(2); h.get_by_role(Role::TextInput).type_text("JetBrains"); - h.key_press(Key::ArrowDown); h.run(); let font = |app: &PhotocraftApp| { let doc = &app.session.active().unwrap().doc; @@ -506,9 +727,10 @@ fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() { _ => panic!("type layer"), } }; - assert_eq!(font(h.state()), "JetBrains Mono"); + assert_eq!(font(h.state()), "Inter", "typing creates no document edit"); h.key_press(Key::Enter); h.run(); + assert_eq!(font(h.state()), "JetBrains Mono"); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(font(h.state()), "Inter"); h.state_mut().run("edit.redo", json!({})).unwrap(); @@ -589,7 +811,7 @@ fn options_bar_hover_previews_on_canvas_without_committing() { let layer = h.state().app.session.active().unwrap().active_layer.unwrap(); h.state_mut().app.ui.tool = crate::state::Tool::Type; h.run_steps(3); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); h.get_by_role(Role::TextInput).type_text("JetBrains Mono"); h.run(); @@ -872,7 +1094,7 @@ fn open_menu_shows_the_current_family_without_animating() { h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::duration(10.0)); h.state_mut().current = "Family 40".into(); h.run(); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run_steps(4); let popup = popup_rect(&h).expect("the menu is open"); let row = h.query_all_by_label("Family 40").next().expect("its row").rect(); @@ -901,7 +1123,7 @@ fn open_menu_arrow_up_on_the_top_recent_row_stays_there() { } /// #9160: when the marked recent row fits below the top of the recent group, the menu opens -/// with the whole group in view, its first row just below the search field. +/// with the whole group in view, its first row at the top of the list. #[test] fn open_menu_shows_the_recent_group_when_the_marked_row_fits_below_it() { use egui_kittest::kittest::NodeT; @@ -916,9 +1138,11 @@ fn open_menu_shows_the_recent_group_when_the_marked_row_fits_below_it() { let first = h.query_all_by_label("Family 10").map(|n| n.rect()).min_by(|a, b| a.top().total_cmp(&b.top())).expect("the recent group's first row"); let row = h.query_all_by_label("Family 12").find(|n| n.accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)).expect("its marked row").rect(); assert!(popup.contains_rect(row) && row.top() >= search.bottom(), "recent #3 is in view: {row:?} in {popup:?}"); + // The class list starts level with the font list, under the preview and filters. + let top = h.get_by_label("All Classes").rect().top(); assert!( - popup.contains_rect(first) && (0.0..=16.0).contains(&(first.top() - search.bottom())), - "the group's first row sits just below the search field: {first:?} under {search:?}" + popup.contains_rect(first) && (-1.0..=8.0).contains(&(first.top() - top)), + "the group's first row sits at the top of the list: {first:?}, list top {top}" ); } @@ -999,7 +1223,7 @@ fn options_bar_menu_arrows_walk_on_while_recording_recent_fonts() { ); crate::PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); let mut seen = vec![h.state().ui.tool_options.type_font.clone()]; for _ in 0..4 { @@ -1018,7 +1242,7 @@ fn options_bar_menu_arrows_walk_on_while_recording_recent_fonts() { #[test] fn wheel_over_the_closed_font_button_steps_through_the_families() { let mut h = harness(); - let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + let button = h.query_all_by_role(Role::TextInput).next().unwrap().rect().center(); h.hover_at(button); h.run(); let wheel = |h: &mut Harness<'static, State>, y: f32| { @@ -1043,7 +1267,7 @@ fn wheel_over_the_closed_font_button_steps_through_the_families() { #[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(); + let button = h.query_all_by_role(Role::TextInput).next().unwrap().rect().center(); h.hover_at(button); h.run(); let wheel = |h: &mut Harness<'static, State>, y: f32| { @@ -1116,7 +1340,7 @@ fn library_harness_with( state, ); h.run(); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run(); h } @@ -1429,7 +1653,7 @@ fn a_menu_opened_from_the_right_edge_stays_narrow_after_a_minimize() { state, ); h.run(); - h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.query_all_by_role(Role::TextInput).next().unwrap().click(); h.run_steps(4); let width = |h: &Harness<'static, LibState>| h.ctx.memory(|m| m.area_rect(h.state().popup)).map(|r| r.width()); let before = width(&h).expect("menu open"); @@ -1468,7 +1692,7 @@ fn options_bar_hover_previews_with_the_character_picker_also_shown() { let LayerContent::Text(t) = &shown.layer(layer).unwrap().content else { panic!("type layer") }; t.char_runs()[0].style.font_family.clone() }; - let pickers: Vec<_> = h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Inter")).collect(); + let pickers: Vec<_> = h.query_all_by_role(Role::TextInput).filter(|n| n.value().as_deref() == Some("Inter")).collect(); assert_eq!(pickers.len(), 2, "options bar and Character section both show the font picker"); let button = pickers[0].rect().center(); hold_open(&mut h, button); @@ -1481,3 +1705,45 @@ fn options_bar_hover_previews_with_the_character_picker_also_shown() { h.run(); assert_eq!(font(&mut h), "Inter", "leaving the row restores the committed font"); } + +// FONT_FIELD_VISUAL_FIXTURE_START: kept compatible with the base revision for before/after capture. +#[test] +fn capture_font_field_gpu_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_FONT_FIELD_FIXTURES").map(std::path::PathBuf::from) else { return }; + let _gpu = photocraft_testkit::gpu_lock(); + std::fs::create_dir_all(&directory).unwrap(); + let phase = std::env::var("FONT_FIELD_FIXTURE_PHASE").unwrap_or_else(|_| "after".into()); + for theme in crate::theme::ThemeKind::ALL { + for scale in [1.0, 2.0] { + for width in [140.0, 170.0] { + let mut h = Harness::builder().with_size(vec2(420.0, 560.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, current: &mut String| { + let mut families: Vec = ["Arial", "Consolas", "Inter", "JetBrains Mono"].map(str::to_string).into(); + families.extend((0..60).map(|i| format!("Z Fixture Family {i:02}"))); + font_picker_in(ui, current, width, &families, FontPreview::Medium, &[]); + }, + "Inter".to_string(), + ); + crate::PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let stem = format!("{phase}-{}-{width}-{scale}x", theme.id()); + h.render().unwrap().save(directory.join(format!("{stem}-closed.png"))).unwrap(); + // The base revision has a ComboBox and a separate TextInput inside its popup. + h.query_by_role(Role::TextInput).or_else(|| h.query_by_role(Role::ComboBox)).unwrap().click(); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-selected.png"))).unwrap(); + h.event(egui::Event::Text("C".into())); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-filtered.png"))).unwrap(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(egui::Event::Text("fdsfdsfds".into())); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-no-matches.png"))).unwrap(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.key_press(Key::Backspace); + h.run(); + h.render().unwrap().save(directory.join(format!("{stem}-cleared.png"))).unwrap(); + } + } + } +} diff --git a/crates/ui-egui/src/tools/type_tool/fonts.rs b/crates/ui-egui/src/tools/type_tool/fonts.rs index 266bdb6f02..7c8162e3f4 100644 --- a/crates/ui-egui/src/tools/type_tool/fonts.rs +++ b/crates/ui-egui/src/tools/type_tool/fonts.rs @@ -338,16 +338,10 @@ pub(super) fn font_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui, current: & Some(style) } -/// Maximum height of the font menu: room for the search/filter bar over the font list, so only -/// the list scrolls. -pub(super) const FONT_MENU_HEIGHT: f32 = 520.0; -/// The font list's height (it scrolls inside the menu, under the fixed search field). -const FONT_LIST_HEIGHT: f32 = 440.0; +/// The font list's maximum height: it scrolls inside the menu, under the fixed preview and filters. +pub(super) const FONT_LIST_HEIGHT: f32 = 440.0; /// The font list's width: star, style expander, name, format badge and sample. const FONT_LIST_WIDTH: f32 = 420.0; -/// Room the search row keeps for Clear All, and for the filter buttons when they follow the field. -const FONT_CLEAR_WIDTH: f32 = 84.0; -const FONT_BUTTONS_WIDTH: f32 = 112.0; /// Room right of the font list's viewport for its scroll bar. const FONT_SCROLL_BAR_WIDTH: f32 = 14.0; /// Room for the format badge (O, TT, VAR) left of the sample. @@ -366,6 +360,29 @@ pub(super) fn font_picker_in( family_picker_in(ui, "type-font", current, width, families, preview, &mut FontLibrary { recent: recents.to_vec(), ..FontLibrary::default() }) } +/// The row Enter and ⌘/Ctrl+Enter apply to a pending family query: a full name wins over a +/// substring match (including localized family names), then the highlighted row, then the first. +pub(super) fn accept_row(rows: &[(&String, &str)], text: &str, selected: Option) -> Option { + let exact = text.to_lowercase(); + rows.iter().position(|(f, shown)| f.to_lowercase() == exact || shown.to_lowercase() == exact).or(selected).or_else(|| (!rows.is_empty()).then_some(0)) +} + +/// Transient input belongs to one field, independently of the applied family and its peers. +#[derive(Clone)] +pub(super) struct FontFieldEdit { + pub(super) text: String, + pub(super) query: String, + pub(super) current: String, + pub(super) recents: Vec, + pub(super) row: Option, + pub(super) opening: bool, + pub(super) accepted: bool, + pub(super) composing: bool, + pub(super) menu_height: Option, + /// The current family still has to be scrolled into view after the menu opened. + pub(super) reveal: bool, +} + /// Shared by the Type options, Character, style and Glyphs panels. Selection changes still /// flow through each caller's existing engine command. pub(crate) fn family_picker_in( @@ -377,59 +394,123 @@ pub(crate) fn family_picker_in( preview: photocraft_engine::prefs::FontPreview, library: &mut FontLibrary, ) -> 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.scope_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 field = ui.make_persistent_id(egui::IdSalt::new(salt)); + let popup = field.with("popup"); + let edit_id = field.with("edit"); + let was_open = egui::Popup::is_id_open(ui.ctx(), popup); + let had_focus = ui.memory(|m| m.has_focus(field)); let labels: Option>> = ui.ctx().data(|d| d.get_temp(font_labels_id())); let labels = labels.as_deref(); - let shown = labels.and_then(|l| l.get(current.as_str())).unwrap_or(current).clone(); - let combo = craft_ui::ComboBox::from_id_salt(salt) - .selected_text(shown) - .width(width) - .height(FONT_MENU_HEIGHT) - .icon(crate::widgets::chevron_icon) - // Clicks inside the menu (the search field, the scroll bar) must not close it (#1369); - // picking a font closes it explicitly below. - .close_behavior(if matches!(close, egui::PopupCloseBehavior::IgnoreClicks) { close } else { egui::PopupCloseBehavior::CloseOnClickOutside }); - - let mut response = combo.show_ui(ui, |ui| { + let label = |f: &str| labels.and_then(|l| l.get(f)).cloned().unwrap_or_else(|| f.to_string()); + let mut edit = + ui.data(|d| d.get_temp::(edit_id)).filter(|e| e.current == *current && (was_open || had_focus)).unwrap_or_else(|| FontFieldEdit { + text: label(current), + query: String::new(), + current: current.clone(), + recents: library.recent.clone(), + row: None, + opening: false, + accepted: false, + composing: false, + menu_height: None, + reveal: false, + }); + // Take navigation before TextEdit interprets it, and before the canvas can see it. + let owns_keys = ui.is_enabled() && (had_focus || was_open); + if had_focus { + ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); + } + // IME confirmation/navigation belongs to composition, even when its current spelling + // happens to be an exact font name. A separate Enter accepts the completed name. + let composing = edit.composing || ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Ime(_)))); + if owns_keys { + ui.input(|i| { + for event in &i.events { + match event { + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => edit.composing = !text.is_empty(), + egui::Event::Ime(egui::ImeEvent::Commit(_)) => edit.composing = false, + _ => {} + } + } + }); + } + let key = |k| owns_keys && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)); + let navigation_down = key(egui::Key::ArrowDown); + let navigation_up = key(egui::Key::ArrowUp); + let down = navigation_down && !composing; + let up = navigation_up && !composing; + let enter = key(egui::Key::Enter) && !composing; + let escape = key(egui::Key::Escape) && !composing; + let tab = owns_keys && ui.input(|i| i.key_pressed(egui::Key::Tab)); + if navigation_down || navigation_up { + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if edit.opening { + ui.memory_mut(|m| m.request_focus(field)); + } + let (input, rect) = crate::widgets::text_field_with_trailing(ui, field, &mut edit.text, tl!("Font"), width, crate::widgets::POPUP_ARROW_W); + let arrow_rect = + egui::Rect::from_min_max(egui::pos2(rect.right() - crate::widgets::POPUP_ARROW_W - 2.0, rect.top() + 2.0), rect.max - egui::vec2(2.0, 2.0)); + let arrow = ui.interact(arrow_rect, field.with("arrow"), egui::Sense::click()); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Font"))); + crate::widgets::chevron_icon(ui, arrow_rect, ui.style().interact(&arrow), was_open); + // The arrow owns press-drag-release; dragging in the name remains normal text selection. + // Its menu is anchored to the full control and keeps the existing stable popup id. + let mut anchor = arrow.clone(); + anchor.id = field; + anchor.rect = rect; + anchor.interact_rect = rect; + if arrow.clicked() && !ui.input(|i| i.pointer.any_click()) { + egui::Popup::toggle_id(ui.ctx(), popup); + } + let mut select_all = input.gained_focus() || (!was_open && input.clicked()); + if (input.gained_focus() && !edit.accepted) || (!was_open && input.clicked()) || input.changed() { + egui::Popup::open_id(ui.ctx(), popup); + } + if input.changed() { + edit.query = edit.text.to_lowercase(); + edit.row = None; + edit.accepted = false; + select_all = false; + } + if escape || tab { + egui::Popup::close_id(ui.ctx(), popup); + if escape { + ui.memory_mut(|m| m.surrender_focus(field)); + } + edit.opening = false; + } + let mut changed = false; + let mut picked = false; + // The name is outside the popup: clicking it to move the caret keeps the list open. + let close = if input.contains_pointer() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; + let response = crate::press_menu::show_with_close_behavior(&anchor, close, |ui, press| { // Positions kept between frames are relative to the menu's left edge: the menu moves // (the window's edge pushes it after a resize), and screen positions from an earlier - // frame would pad the search row out and widen the menu, an empty strip beside the list. + // frame would widen the menu, an empty strip beside the list. let origin = ui.cursor().min.x; - // Freshly opened (not drawn last pass): the preview starts on the current font. - let pass_id = search_id.with("pass"); - let pass = ui.ctx().cumulative_pass_nr(); - let opened = ui.data(|d| d.get_temp::(pass_id)).is_none_or(|p| p.saturating_add(1) < pass); - ui.data_mut(|d| d.insert_temp(pass_id, pass)); - // Each opening focuses the search, including a reopen on the very next pass. Keep - // asking through the invisible sizing pass, until the field receives focus. - let mut focus = ui.data(|d| d.get_temp(focus_id)).unwrap_or(true); - let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - // Consume these before TextEdit and the canvas can interpret them as caret movement. - let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); - let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); - let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - // The filter bar beside the search field, as in Photoshop: the class list toggle, Show - // Variable / Favorite / Similar Fonts and Clear All (`font_filters`). - let filters_id = search_id.with("filters"); + // Each opening reveals the current family once, on its first laid-out pass. + if !was_open { + edit.reveal = true; + } + let revealing = edit.reveal && !ui.is_sizing_pass(); + if revealing { + edit.reveal = false; + } + // The filter bar, as in Photoshop: the class list toggle, Show Variable / Favorite / + // Similar Fonts and Clear All (`font_filters`). The name field itself is the search. + let filters_id = field.with("filters"); let mut filters: crate::font_filters::FontFilters = ui.data(|d| d.get_temp(filters_id)).unwrap_or_default(); // The preview on top: the font last under the pointer, else the current one. - let preview_id = search_id.with("preview-font"); - if opened { + let preview_id = field.with("preview-font"); + if !was_open { ui.data_mut(|d| d.remove::(preview_id)); } let previewed: String = ui.data(|d| d.get_temp(preview_id)).unwrap_or_else(|| current.clone()); - let previewed_label = labels.and_then(|l| l.get(previewed.as_str())).map_or(previewed.as_str(), String::as_str).to_string(); - // Everything above the list is as wide as the class and font lists below, measured last + let previewed_label = label(&previewed); + // Everything above the lists is as wide as the class and font lists below, measured last // frame: never wider, so the menu has no empty strip beside the list. - let (list_left_id, body_id) = (search_id.with("list-left"), search_id.with("body")); + let body_id = field.with("body"); let class_w = crate::font_filters::CLASS_COLUMN_WIDTH; let estimate = FONT_LIST_WIDTH + 16.0 + if filters.hide_classes { 0.0 } else { class_w + 14.0 }; let body: Option = ui.data(|d| d.get_temp(body_id)); @@ -439,59 +520,17 @@ pub(crate) fn family_picker_in( ui.set_max_width(menu_width); } crate::font_filters::preview_banner(ui, &previewed, &previewed_label, library.preview_text.as_deref(), menu_width); - // The filter buttons over the class list, and the search field over the fonts, starting - // where the list does. - let list_left: Option = ui.data(|d| d.get_temp::(list_left_id)).map(|x| origin + x); - let menu_right = body.map(|b| origin + b.right()); - let r = ui - .horizontal(|ui| { - if !filters.hide_classes { - ui.allocate_ui_with_layout(egui::vec2(class_w, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - crate::font_filters::filter_buttons(ui, &mut filters); - }); - } - if let Some(x) = list_left.filter(|_| !filters.hide_classes) { - let pad = x - ui.cursor().min.x; - if pad > 0.0 { - ui.add_space(pad); - } - } - // The field fills the row up to Clear All (and the buttons, which follow it when - // the class list is hidden). - let trailing = FONT_CLEAR_WIDTH + if filters.hide_classes { FONT_BUTTONS_WIDTH } else { 0.0 }; - let search_w = menu_right.map_or(FONT_LIST_WIDTH - trailing, |right| right - ui.cursor().min.x - trailing).clamp(120.0, FONT_LIST_WIDTH); - if focus { - ui.memory_mut(|m| m.request_focus(search_id.with("input"))); - } - let r = ui.add(egui::TextEdit::singleline(&mut q).id(search_id.with("input")).hint_text(tl!("Search fonts")).desired_width(search_w)); - if filters.hide_classes { - crate::font_filters::filter_buttons(ui, &mut filters); - } - crate::font_filters::clear_all(ui, &mut filters); - r - }) - .inner; - if focus { - // The opening click can release after several focused frames. Keep the request - // pending through that release, which can hand focus back to the combo button. - focus = !r.has_focus() || ui.input(|i| i.pointer.primary_down() || i.pointer.primary_released()); - } - ui.data_mut(|d| { - d.insert_temp(focus_id, focus); - d.insert_temp(search_id, q.clone()); + ui.horizontal(|ui| { + crate::font_filters::filter_buttons(ui, &mut filters); + crate::font_filters::clear_all(ui, &mut filters); }); - let ql = q.to_lowercase(); let lookup = crate::font_filters::lookup_in(library.traits.clone()); - // 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 opening = ui.data(|d| d.get_temp::>(recents_id).is_none()); - let open_recents: Vec = ui.data_mut(|d| d.get_temp_mut_or_insert_with(recents_id, || library.recent.clone()).clone()); // No recent group while a filter is on: the filters narrow the alphabetical list alone. - let (mut rows, n_recent) = font_menu_rows(families, labels, &ql, if filters.any() { &[] } else { &open_recents }); + let (mut rows, n_recent) = font_menu_rows(families, labels, &edit.query, if filters.any() { &[] } else { &edit.recents }); // Show Similar Fonts compares with the font current when it was turned on (or the menu // opened), as the recent group stays put: each arrow step applies a font, and sorting // around that one sent the next step back to the font before. - let similar_id = search_id.with("similar-to"); + let similar_id = field.with("similar-to"); let reference: String = if filters.similar { ui.data_mut(|d| d.get_temp_mut_or_insert_with(similar_id, || current.clone()).clone()) } else { @@ -499,26 +538,12 @@ pub(crate) fn family_picker_in( current.clone() }; let comparable = crate::font_filters::apply(&mut rows, &filters, &library.favorites, &reference, &lookup); - // 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 first copy: as in Photoshop, the - // one in the recent group when it has one (#9160), and the arrows walk on from there. - 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().position(|(f, _)| *f == current)); + let mut selected = edit.row.filter(|&i| rows.get(i).is_some_and(|(f, _)| *f == current)).or_else(|| rows.iter().position(|(f, _)| *f == current)); // The menu opens on the current family, as Photoshop's does: with the recent group at the // top of the view when the marked row fits below it, else the marked row itself, at the // top of the view when it is not a recent one. Applied on the first laid-out pass (the // popup's first pass only measures), whether or not a row matches, so a missing font or // a filtered list never scrolls the menu later. - let reveal_id = search_id.with("reveal"); - if opening { - ui.data_mut(|d| d.insert_temp(reveal_id, true)); - } - let revealing = ui.data(|d| d.get_temp::(reveal_id)).unwrap_or(false) && !ui.is_sizing_pass(); - if revealing { - ui.data_mut(|d| d.remove::(reveal_id)); - } let reveal = if revealing { selected } else { None }; let reveal_from = reveal.map(|i| if i < n_recent { 0 } else { i }); let mut stepped = None; @@ -529,25 +554,33 @@ pub(crate) fn family_picker_in( None if down => 0, None => rows.len().saturating_sub(1), }; - if let Some((f, _)) = rows.get(next) { + if let Some((f, shown)) = rows.get(next) { changed = **f != *current; current.clone_from(f); - // A served family not fetched yet: start now, as a click does. photocraft_text::served::request(f); - stepped = Some(next); + edit.text = (*shown).to_string(); + edit.row = Some(next); selected = Some(next); - ui.data_mut(|d| d.insert_temp(row_id, next)); + stepped = Some(next); + select_all = true; } } - let accept = enter && rows.iter().any(|(f, _)| *f == current); - picked |= accept; - if accept { - ui.data_mut(|d| d.remove::(search_id)); - ui.close(); + if enter && !edit.text.trim().is_empty() { + // A full name wins over a substring match (including localized family names). + let accept = accept_row(&rows, &edit.text, selected); + if let Some((f, _)) = accept.and_then(|i| rows.get(i)) { + // Acceptance is an application to the selected characters, even when a + // mixed selection's representative already has this family (as row clicks do). + changed = true; + current.clone_from(f); + photocraft_text::served::request(f); + picked = true; + ui.close(); + } } let preview = crate::font_preview::sample(preview); let row_h = preview.map_or(28.0, |(_, [_, h])| (h + 4.0).max(28.0)); - let expanded_id = search_id.with("expanded"); + let expanded_id = field.with("expanded"); let mut expanded: Vec = ui.data(|d| d.get_temp(expanded_id)).unwrap_or_default(); let mut hovered: Option = None; let reveal = if stepped.is_some() { None } else { reveal }; @@ -563,9 +596,16 @@ pub(crate) fn family_picker_in( divider = Some(ui.cursor().min.x + 3.0); ui.add_space(6.0 + ui.spacing().item_spacing.x); } - // The list scrolls under the fixed search field and filters, and shrinks to a short - // search result: the menu grows back with it (craft_ui::ComboBox, #9849). - let list = egui::ScrollArea::vertical().id_salt(search_id.with("list")).max_height(FONT_LIST_HEIGHT).auto_shrink([true, true]); + // The list scrolls under the fixed preview and filters, at its full height or the room + // the window leaves below it. That height is also its minimum, so the menu grows back + // after a search that matched nothing (a popup never grows by itself, egui#5225); the + // row beside the class list is no measure of it. With no rows it shrinks to its message. + let room = ui.ctx().content_rect().bottom() - ui.cursor().top() - 16.0; + let height = FONT_LIST_HEIGHT.min(room).max(row_h); + let mut list = egui::ScrollArea::vertical().id_salt(field.with("list")).auto_shrink([true, true]); + if !rows.is_empty() { + list = list.max_height(height).min_scrolled_height(height).auto_shrink([true, false]); + } // Top to bottom, though the list sits beside the class list. let shown = list.show(ui, |ui| { ui.vertical(|ui| { @@ -581,13 +621,15 @@ pub(crate) fn family_picker_in( ui, if filters.favorites && library.favorites.is_empty() { tl!("No favorite fonts yet: click the star beside a font to add it.") - } else { + } else if filters.any() { tl!("No fonts match these filters.") + } else { + tl!("No matching fonts") }, ); } // The first `n_recent` rows are the recent group, above a line (`font_menu_rows`). - for (i, &(f, label)) in rows.iter().enumerate() { + for (i, &(f, shown)) in rows.iter().enumerate() { let recent_row = i < n_recent; if i == n_recent && n_recent > 0 { ui.separator(); @@ -603,7 +645,7 @@ pub(crate) fn family_picker_in( // row's stroke grew it and pushed the rows below down (#7406). let slot = preview.map_or(0.0, |(size, _)| crate::font_preview::slot_width(ui.ctx(), f, size)); // One row lit, though a recent family is listed twice. - let mut button = egui::Button::selectable(selected == Some(i), label).truncate().stroke(egui::Stroke::NONE); + let mut button = egui::Button::selectable(selected == Some(i), shown).truncate().stroke(egui::Stroke::NONE); let [_, h] = preview.map_or([0.0, 16.0], |(_, b)| b); button = button.right_text(egui::Atom::custom(egui::IdSalt::new(("font-sample", f, recent_row)), egui::vec2(slot + FONT_BADGE_WIDTH, h))); @@ -668,12 +710,11 @@ pub(crate) fn family_picker_in( hovered = Some(f.clone()); } if press.chosen(ui, &response) { - *current = f.clone(); + changed = true; + current.clone_from(f); // 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(); } @@ -692,12 +733,11 @@ pub(crate) fn family_picker_in( }) .inner; if style_row.clicked() { - *current = f.clone(); + changed = true; + current.clone_from(f); photocraft_text::served::request(f); library.picked_style = Some(style.clone()); - changed = true; picked = true; - ui.data_mut(|d| d.remove::(search_id)); hovered = None; ui.close(); } @@ -705,15 +745,19 @@ pub(crate) fn family_picker_in( } }) }); - ui.data_mut(|d| d.insert_temp(list_left_id, shown.inner_rect.left() - origin)); - shown.inner_rect.right() + (shown.inner_rect.right(), (!rows.is_empty()).then_some(shown.inner_rect.height())) }); if let Some(x) = divider { ui.painter().vline(x, body.response.rect.y_range(), ui.visuals().widgets.noninteractive.bg_stroke); } + let (list_right, list_height) = body.inner; + // The list's viewport, while it lists fonts. + if list_height.is_some() { + edit.menu_height = list_height; + } // The lists' own extent, from the class list to the font list's scroll bar: the row // itself stretches with the popup, so it can't tell when the popup is too wide. - let list_right = body.inner + FONT_SCROLL_BAR_WIDTH; + let list_right = list_right + FONT_SCROLL_BAR_WIDTH; let body = egui::Rect::from_min_max(body.response.rect.min, egui::pos2(list_right.min(body.response.rect.right()), body.response.rect.bottom())); ui.data_mut(|d| { d.insert_temp(filters_id, filters); @@ -723,65 +767,29 @@ pub(crate) fn family_picker_in( d.insert_temp(preview_id, f.clone()); } }); - if accept { None } else { hovered } + if picked { None } else { hovered } }); - response.response = response.response.accessible_name(tl!("Font")); - press.after(&response.response); - if !craft_ui::ComboBox::is_open(ui.ctx(), response.response.id) { - ui.data_mut(|d| { - d.remove::(focus_id); - d.remove::>(recents_id); - d.remove::(row_id); - d.remove::(search_id.with("reveal")); - d.remove::(search_id.with("similar-to")); - }); - } - let hovered_font = response.inner.unwrap_or_default(); - - // As in Photoshop, the field keeps the keyboard focus once a font is picked, and ↑ / ↓ on the - // focused, closed field step to the previous / next family, applying each (#7407). The lock - // filter keeps egui from using those arrows to move the focus to another widget. - let field = response.response.id; - if picked { + let open = egui::Popup::is_id_open(ui.ctx(), popup); + if !was_open && open { + edit.recents = library.recent.clone(); + edit.opening = arrow.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()); ui.memory_mut(|m| m.request_focus(field)); + select_all = !input.changed(); } - let open = craft_ui::ComboBox::is_open(ui.ctx(), field); - if !open && ui.is_enabled() && ui.memory(|m| m.has_focus(field)) { - ui.memory_mut(|m| m.set_focus_lock_filter(field, egui::EventFilter { vertical_arrows: true, ..Default::default() })); - let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)); - let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)); - if down || up { - // The lock filter only holds from the frame after the field got the focus: an arrow - // right after a pick was already read by egui as a focus move, applied at the end of - // 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. 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, "", &[]); - 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) - && **f != *current - { - current.clone_from(f); - photocraft_text::served::request(f); - changed = true; - } + // A held opening arrow can regain focus on release; keep the name selected through it. + if edit.opening { + ui.memory_mut(|m| m.request_focus(field)); + select_all |= !input.changed(); + edit.opening = ui.input(|i| i.pointer.primary_down()); } - - // The wheel over the closed button steps through the families, a notch each, like Photoshop's - // options-bar font menu: no need to open the list (#3350). - if !craft_ui::ComboBox::is_open(ui.ctx(), response.response.id) && ui.is_enabled() { - let hovered = response.response.hovered(); + if !open && !escape && ui.is_enabled() { + let notches = if had_focus { f32::from(u8::from(up)) - f32::from(u8::from(down)) } else { 0.0 }; + let hovered = input.hovered() || arrow.hovered(); if hovered { 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); - // 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 notches = notches + crate::widgets::wheel_notches_of(ui, field.with("family-wheel"), hovered, 1.0); + // Closed-field navigation stays alphabetical, independent of recent picks (#3350, #7407). 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) @@ -791,20 +799,42 @@ pub(crate) fn family_picker_in( current.clone_from(f); photocraft_text::served::request(f); changed = true; + select_all |= had_focus; } } - - if salt == "type-font" { + if picked || (!open && (was_open || changed || escape)) { + edit.text = label(current); + edit.query.clear(); + edit.row = None; + edit.accepted = true; + edit.opening = false; + edit.composing = false; + } + if picked { + ui.memory_mut(|m| m.request_focus(field)); + select_all = true; + } + if select_all && !escape { + crate::field_tab::select_all(ui.ctx(), field, &edit.text); + } + if !open { + ui.data_mut(|d| d.remove::(field.with("similar-to"))); + } + edit.current.clone_from(current); + if open || ui.memory(|m| m.has_focus(field)) { + ui.data_mut(|d| d.insert_temp(edit_id, edit)); + } else { + ui.data_mut(|d| d.remove::(edit_id)); + } + if salt == "type-font" && (was_open || open || response.is_some()) { + // Only this popup's owner clears its preview; a closed peer must leave it alone (#5152). let hover_id = egui::Id::unique("type-font-hover"); - if let Some(f) = hovered_font { + if let Some(f) = response.flatten().filter(|_| open) { ui.ctx().data_mut(|d| d.insert_temp(hover_id, f)); - } else if craft_ui::ComboBox::is_open(ui.ctx(), response.response.id) { - // Only the open menu clears it: the options bar and the Character section both draw - // this picker, and the closed one must not wipe the other's hover (#5152). + } else { ui.ctx().data_mut(|d| d.remove::(hover_id)); } } - changed } diff --git a/crates/ui-egui/src/tools/type_tool/ime_edit.rs b/crates/ui-egui/src/tools/type_tool/ime_edit.rs index 33296343d3..6cb48d4741 100644 --- a/crates/ui-egui/src/tools/type_tool/ime_edit.rs +++ b/crates/ui-egui/src/tools/type_tool/ime_edit.rs @@ -4,7 +4,8 @@ use super::ime::{ }; use super::layout::layout; use super::{ - CommittedText, ImeObservation, LayerId, PhotocraftApp, Point, Pos2, Range, TextEdit, ViewXform, byte_of, current_text, json, text_char_count, text_layer, + CommittedText, ImeObservation, LayerId, PhotocraftApp, Point, Pos2, Range, TextEdit, ViewXform, byte_of, current_text, insertion_style, json, + text_char_count, text_layer, }; /// UTF-8 byte offsets of the caret, anchor and pre-edit, found in one traversal. @@ -298,7 +299,7 @@ pub(super) fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { let before = if commit { ime_committed(app, &ed) } else { None }; let len = s.chars().count(); crate::type_transform::finish(app); - if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": s}, "coalesce": ed.session})).is_err() { + if !insertion_style::replace(app, start, end, &s) { return; } let mut after_ed = ed.clone(); diff --git a/crates/ui-egui/src/tools/type_tool/ime_tests.rs b/crates/ui-egui/src/tools/type_tool/ime_tests.rs index 5617eebe5b..a63ede17a0 100644 --- a/crates/ui-egui/src/tools/type_tool/ime_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/ime_tests.rs @@ -1051,12 +1051,16 @@ fn reset_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, ResetHarn photocraft_engine::prefs::FontPreview::Medium, &mut FontLibrary::default(), ); - let search = ui.scope_id().with(("font-search", "reset-font")); - state.search = ui.data(|d| d.get_temp::(search)).unwrap_or_default(); - let input = search.with("input"); - state.search_input = Some(input); - state.search_focused = ui.memory(|m| m.has_focus(input)); - state.search_height = ui.data(|d| d.get_temp::(search.with("body"))).map(|r| r.height()); + // The font field is its own text input: it takes focus as a click would, and + // its edit state holds what is typed and the measured popup height. + if !ui.memory(|m| m.has_focus(button)) { + ui.memory_mut(|m| m.request_focus(button)); + } + let edit = ui.data(|d| d.get_temp::(button.with("edit"))); + state.search = edit.as_ref().map(|e| e.text.clone()).unwrap_or_default(); + state.search_input = Some(button); + state.search_focused = ui.memory(|m| m.has_focus(button)); + state.search_height = edit.and_then(|e| e.menu_height); }); } if state.picker_button { @@ -1094,12 +1098,13 @@ fn actual_font_search_owns_stock_input_without_a_speculative_custom_request() { assert!(h.state().search_focused, "the real font search field, not another stock field, owns focus"); assert_eq!(h.ctx.memory(|m| m.focused()), Some(search_input), "the stock IME owner is the focused font search input"); assert!( - h.state().search_height.is_some_and(|height| height > 0.0 && height <= FONT_MENU_HEIGHT), + h.state().search_height.is_some_and(|height| height > 0.0 && height <= FONT_LIST_HEIGHT), "the real font list measured within its bounded popup height" ); h.input_mut().events = vec![completed(first), egui::Event::Text("Family".into())]; h.step(); - assert_eq!(h.state().search, "Family", "the actual font picker keeps its text event"); + // The field opens holding the current family, so the typed text lands after it. + assert_eq!(h.state().search, "Family 00Family", "the actual font picker keeps its text event"); assert_eq!(layer_text(&h.state().app), before); assert_eq!(pending(&h.state().app), None); let stock = h.output().platform_output.ime.as_ref().expect("stock output survives finalization"); diff --git a/crates/ui-egui/src/tools/type_tool/insertion_style.rs b/crates/ui-egui/src/tools/type_tool/insertion_style.rs new file mode 100644 index 0000000000..611c7ab3ad --- /dev/null +++ b/crates/ui-egui/src/tools/type_tool/insertion_style.rs @@ -0,0 +1,82 @@ +//! A caret's pending font is view state; inserted runs still travel through `type.edit`. +use super::options_bar::styles_at; +use super::{LayerId, PhotocraftApp, json, text_layer}; +use crate::state::TypeInsertionStyle; + +pub(super) fn pending(app: &PhotocraftApp) -> Option<&TypeInsertionStyle> { + let ed = app.ui.text_edit.as_ref()?; + let p = app.ui.type_insertion_style.as_ref()?; + let st = app.session.active()?; + (st.doc.id.0 == p.document && ed.layer == p.layer && ed.session == p.session && ed.caret == ed.anchor && ed.caret == p.caret && st.revision == p.revision) + .then_some(p) +} + +/// Backspace/Delete select the removed character before replacing it; the typing font survives that. +fn pending_across_deletion(app: &PhotocraftApp, start: usize, end: usize, text: &str) -> Option { + if !text.is_empty() { + return None; + } + let ed = app.ui.text_edit.as_ref()?; + let p = app.ui.type_insertion_style.as_ref()?; + let st = app.session.active()?; + (st.doc.id.0 == p.document && ed.layer == p.layer && ed.session == p.session && st.revision == p.revision && (p.caret == start || p.caret == end)) + .then(|| p.clone()) +} + +pub(super) fn shown(app: &PhotocraftApp, c: &mut photocraft_doc::text::CharStyle) { + if let Some(p) = pending(app) { + c.clone_from(&p.style); + } +} + +pub(super) fn set_font(app: &mut PhotocraftApp, props: &serde_json::Value) -> bool { + if app.ui.text_edit.as_ref().is_none_or(|ed| ed.caret != ed.anchor) || (props.get("font").is_none() && props.get("fontStyle").is_none()) { + return false; + } + crate::type_transform::finish(app); + let Some(ed) = app.ui.text_edit.as_ref() else { return false }; + // Empty layers already have a persistent typing run; there are no earlier letters to protect. + if app.session.active().and_then(|st| text_layer(&st.doc, LayerId(ed.layer))).is_some_and(|t| t.text.is_empty()) { + return false; + } + let Some((mut style, _)) = styles_at(app) else { return false }; + let Some(st) = app.session.active() else { return false }; + photocraft_engine::type_cmds::apply_char_props(&mut style, props); + app.ui.type_insertion_style = + Some(TypeInsertionStyle { document: st.doc.id.0, layer: ed.layer, session: ed.session.clone(), revision: st.revision, caret: ed.caret, style }); + true +} + +/// Replace and style the inserted characters atomically, including paste and IME preedit. +pub(super) fn replace(app: &mut PhotocraftApp, start: usize, end: usize, text: &str) -> bool { + let Some(ed) = app.ui.text_edit.clone() else { return false }; + let pending = pending(app).cloned().or_else(|| pending_across_deletion(app, start, end, text)); + let caret = start.saturating_add(text.chars().count()); + let mut params = json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": text}, "coalesce": ed.session}); + if let Some(p) = &pending + && !text.is_empty() + { + let mut run = json!({"start": start, "end": caret, "font": p.style.font_family, "weight": p.style.weight, "italic": p.style.italic}); + // Setting weight clears an inherited wght axis; restore the chosen font's axes last. + run["variations"] = p.style.variations.iter().map(|v| (v.axis.clone(), json!(v.value))).collect::>().into(); + if let Some(face) = &p.style.postscript_name { + run["postscriptName"] = json!(face); + } + params["runs"] = json!([run]); + } + if let Err(e) = app.run("type.edit", params) { + app.ui.status = e; + app.ui.status_error = true; + return false; + } + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.caret = caret; + ed.anchor = caret; + } + app.ui.type_insertion_style = pending.and_then(|mut p| { + p.caret = caret; + p.revision = app.session.active()?.revision; + Some(p) + }); + true +} diff --git a/crates/ui-egui/src/tools/type_tool/keys.rs b/crates/ui-egui/src/tools/type_tool/keys.rs index deb10f77a1..74c81c2f7d 100644 --- a/crates/ui-egui/src/tools/type_tool/keys.rs +++ b/crates/ui-egui/src/tools/type_tool/keys.rs @@ -245,7 +245,8 @@ fn handle_pressed_key( use egui::Key; match key { - Key::Enter if modifiers.command => commit_with(app, *modifiers), + Key::Enter if modifiers.command && edit.edit.preedit.is_none() => commit_with(app, *modifiers), + Key::Enter if modifiers.command => {} // IME owns confirmation until preedit ends. Key::Enter => insert(app, "\n"), Key::Tab if !modifiers.command && !modifiers.alt && !modifiers.shift => { // egui queues focus navigation before this handler runs; consuming the @@ -258,6 +259,7 @@ fn handle_pressed_key( Key::Escape if app.session.prefs().type_.use_esc_to_commit => commit(app), Key::Escape => cancel(app), Key::A if modifiers.command => { + app.ui.type_insertion_style = None; if let Some(state) = app.ui.text_edit.as_mut() { state.anchor = 0; state.caret = edit.char_count; @@ -386,6 +388,9 @@ fn handle_move_key(app: &mut PhotocraftApp, key: egui::Key, modifiers: &egui::Mo fn set_caret(app: &mut PhotocraftApp, char_count: usize, caret: usize, extend: bool) { if let Some(state) = app.ui.text_edit.as_mut() { + if state.caret != caret.min(char_count) || (!extend && state.anchor != caret.min(char_count)) { + app.ui.type_insertion_style = None; + } state.caret = caret.min(char_count); if !extend { state.anchor = state.caret; diff --git a/crates/ui-egui/src/tools/type_tool/layout.rs b/crates/ui-egui/src/tools/type_tool/layout.rs index 45e7cafa3a..3c25568e63 100644 --- a/crates/ui-egui/src/tools/type_tool/layout.rs +++ b/crates/ui-egui/src/tools/type_tool/layout.rs @@ -77,11 +77,11 @@ pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> 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), - }; + let text_start = l.bounds().map_or(0.0, |bounds| f64::from(bounds[0])); + let (tx, ty) = shape + .as_ref() + .and_then(|shape| photocraft_text::warp::Warp::on_path_with_text_start(shape, text_start)) + .and_then(|warp| warp.path_text_x_near_text(f64::from(tx), f64::from(ty), l.bounds().map_or(0.0, |bounds| f64::from(bounds[2])))) + .map_or((tx, ty), |offset| (offset as f32, -1.0)); photocraft_text::hit_char(&l, &text, tx, ty).0 } diff --git a/crates/ui-egui/src/tools/type_tool/options_bar.rs b/crates/ui-egui/src/tools/type_tool/options_bar.rs index e2616982ff..33287d1234 100644 --- a/crates/ui-egui/src/tools/type_tool/options_bar.rs +++ b/crates/ui-egui/src/tools/type_tool/options_bar.rs @@ -2,20 +2,13 @@ use super::defaults::{apply, formatting_params, open_color_picker, shown_scale, use super::editing::{cancel, commit_with}; use super::fonts::{font_picker, fonts_ready, selected_style, style_label, styles}; use super::keys::is_vertical; -use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, json, text_layer}; +use super::{Color32, LayerId, PhotocraftApp, Stroke, byte_of, insertion_style, json, selected_styles, text_layer}; /// Photoshop's Type options bar. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); - // Show the target layer's (first-run) style, else the tool defaults. - let shown = target(app).and_then(|(id, _)| { - let st = app.session.active()?; - let tl = text_layer(&st.doc, LayerId(id))?; - let run = tl.char_runs().into_iter().next().map(|r| r.style); - // The size at the selection (else the first run), as the layer shows it (#124). - let size = styles_at(app).map_or(tl.size_pt, |(c, _)| c.size_pt) * shown_scale(app); - Some((tl.font_family.clone(), run.as_ref().map(selected_style).unwrap_or_default(), size, styles_at(app).map_or(tl.color, |(c, _)| c.color))) - }); + // Both Type controls describe the selected characters, or the caret's typing style. + let shown = styles_at(app).map(|(c, _)| (shown_family(app, &c), selected_style(&c), c.size_pt * shown_scale(app), c.color)); let o = app.ui.tool_options.clone(); let (mut fam, mut style, size) = match &shown { Some((f, s, z, _)) => (f.clone(), if s.is_empty() { "Regular".into() } else { s.clone() }, *z), @@ -50,7 +43,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { None => apply(app, ui.ctx(), json!({"font": fam})), } } - let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); + let style_family = if fam.is_empty() { styles_at(app).map(|(c, _)| c.font_family).unwrap_or_default() } else { fam.clone() }; + let opts: Vec<(String, String)> = styles(&style_family).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); if crate::widgets::dropdown(ui, "type-style", tl!("Style"), &mut style, &opts_ref, 110.0) { app.ui.tool_options.type_style = style.clone(); @@ -168,13 +162,29 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } -/// Character and paragraph style at the target (selection start, else the layer's first run). -/// The paragraph at a collapsed caret is the caret's, as Photoshop's options bar and Paragraph -/// panel show it. +/// A blank family represents mixed fonts, rather than falsely reporting the first one after +/// commit. At a caret, the current/pending insertion font is the relevant single style. +pub(super) fn shown_family(app: &PhotocraftApp, c: &photocraft_doc::text::CharStyle) -> String { + let same = |s: &photocraft_doc::text::CharStyle| s.font_family == c.font_family; + let Some(st) = target_doc(app) else { return c.font_family.clone() }; + let uniform = if let Some(ed) = &app.ui.text_edit { + if ed.caret == ed.anchor { + true + } else { + text_layer(&st.doc, LayerId(ed.layer)).is_none_or(|t| selected_styles(t, ed.anchor, ed.caret).iter().all(same)) + } + } else { + st.selected_layers().into_iter().filter_map(|id| text_layer(&st.doc, id)).all(|t| t.char_runs().iter().all(|r| same(&r.style))) + }; + if uniform { c.font_family.clone() } else { String::new() } +} + +/// Character and paragraph style at the selection, caret, or first run of selected layers. +/// The paragraph at a collapsed caret is the caret's, as the options bar and Paragraph panel show it. pub(crate) fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle)> { let (layer, range) = target(app)?; let t = text_layer(&target_doc(app)?.doc, LayerId(layer))?; - let at = range.map_or(0, |r| byte_of(&t.text, r[0])); + let at = range.map_or_else(|| app.ui.text_edit.as_ref().map_or(0, |ed| byte_of(&t.text, ed.caret.saturating_sub(1))), |r| byte_of(&t.text, r[0])); let para_at = match (range, &app.ui.text_edit) { (None, Some(ed)) => byte_of(&t.text, ed.caret), _ => at, @@ -191,7 +201,8 @@ pub(crate) fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::Ch }; let runs = t.char_runs(); let paras = t.paragraph_runs(); - let c = runs.get(pick_at(at, runs.iter().map(|r| r.len).collect())).map(|r| r.style.clone())?; + let mut c = runs.get(pick_at(at, runs.iter().map(|r| r.len).collect())).map(|r| r.style.clone())?; + insertion_style::shown(app, &mut c); let p = paras.get(pick_at(para_at, paras.iter().map(|r| r.len).collect())).map(|r| r.style.clone()).unwrap_or_default(); Some((c, p)) } diff --git a/crates/ui-egui/src/tools/type_tool/overlay.rs b/crates/ui-egui/src/tools/type_tool/overlay.rs index fbac016e3d..00808bfd3f 100644 --- a/crates/ui-egui/src/tools/type_tool/overlay.rs +++ b/crates/ui-egui/src/tools/type_tool/overlay.rs @@ -5,6 +5,7 @@ use super::{Color32, LayerId, PhotocraftApp, Point, Pos2, Stroke, ViewXform, byt /// Selection highlight, caret and text frame over the canvas. pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + app.ui.type_box_overflow = false; 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() { @@ -26,7 +27,8 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX }; 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); + let text_start = l.bounds().map_or(0.0, |bounds| f64::from(bounds[0])); + let bend = shape.as_ref().and_then(|shape| photocraft_text::warp::Warp::on_path_with_text_start(shape, text_start)); // 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); @@ -53,15 +55,25 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { + // Text the box can't hold: as in the reference application, the lower-right handle + // grows and shows a black "+". Automation reads `ui.type_box_overflow` instead. + let over = l.overflows(&text); + app.ui.type_box_overflow = over; if !has_transform { let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; painter.add(egui::Shape::closed_line(c.to_vec(), frame)); let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; - for p in c.iter().chain(mids.iter()) { - let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); + for (i, p) in c.iter().chain(mids.iter()).enumerate() { + let side = if over && i == 2 { OVERFLOW_HANDLE } else { 7.0 }; + let r = egui::Rect::from_center_size(*p, egui::vec2(side, side)); painter.rect_filled(r, 0.0, Color32::WHITE); painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); } + if over { + let (p, arm) = (c[2], OVERFLOW_HANDLE / 2.0 - 2.5); + painter.line_segment([p - egui::vec2(arm, 0.0), p + egui::vec2(arm, 0.0)], OVERFLOW_PLUS); + painter.line_segment([p - egui::vec2(0.0, arm), p + egui::vec2(0.0, arm)], OVERFLOW_PLUS); + } } } // Area type: the outline of the shape it wraps inside. @@ -138,6 +150,11 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } } +/// The side of a paragraph box's lower-right handle while the box can't hold its text (the +/// other handles are 7 px), and the black "+" drawn inside it. +pub(super) const OVERFLOW_HANDLE: f32 = 11.0; +pub(super) const OVERFLOW_PLUS: Stroke = Stroke { width: 1.0, color: Color32::BLACK }; + /// The text caret, drawn in order: a white halo, then a solid black line inside it, so it reads on /// any background (#6085). The black line used to be a 0.75 pt hairline inside a white one, which /// on a white background left only a faint grey line. diff --git a/crates/ui-egui/src/tools/type_tool/panel.rs b/crates/ui-egui/src/tools/type_tool/panel.rs index 09ebeb70c9..1caa9913a2 100644 --- a/crates/ui-egui/src/tools/type_tool/panel.rs +++ b/crates/ui-egui/src/tools/type_tool/panel.rs @@ -1,7 +1,8 @@ use super::defaults::{apply, apply_defaults, default_styles, formatting_params, open_color_picker, shown_scale, target, type_unit}; use super::fonts::{axis_sliders, font_picker, fonts_ready, selected_style, style_label, styles}; use super::options_bar::{ - align_glyph, apply_kerning, direction_glyph, direction_items, icon_label, kerning_at, kerning_field, kerning_label, num_field, styles_at, toggle_cell, + align_glyph, apply_kerning, direction_glyph, direction_items, icon_label, kerning_at, kerning_field, kerning_label, num_field, shown_family, styles_at, + toggle_cell, }; use super::{Color32, LayerId, PhotocraftApp, Stroke, json, text_layer}; @@ -229,7 +230,7 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa if character && section(ui, "character", tl!("Character")) { let full = ui.available_width(); let w = field_width(full, 2, LABEL_W); - let mut fam = c.font_family.clone(); + let mut fam = shown_family(app, &c); let mut ready = true; row(ui, &mut |ui| { ready = fonts_ready(ui, full); @@ -250,7 +251,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa return; } let mut style = selected_style(&c); - let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); + let opts: Vec<(String, String)> = + styles(if fam.is_empty() { &c.font_family } else { &fam }).into_iter().map(|s| (s.clone(), style_label(&s))).collect(); let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect(); if crate::widgets::dropdown(ui, "props-type-style", tl!("Style"), &mut style, &opts_ref, full) { apply(app, ui.ctx(), json!({"fontStyle": style})); diff --git a/crates/ui-egui/src/tools/type_tool/pointer.rs b/crates/ui-egui/src/tools/type_tool/pointer.rs index 39cce1c91c..19bab778a4 100644 --- a/crates/ui-egui/src/tools/type_tool/pointer.rs +++ b/crates/ui-egui/src/tools/type_tool/pointer.rs @@ -34,14 +34,21 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64 let (r, b) = (bx + w, by + h); let (mx, my) = (bx + w / 2.0, by + h / 2.0); let spots = [(bx, by), (r, by), (r, b), (bx, b), (mx, by), (r, my), (mx, b), (bx, my)]; - let tol = f64::from(6.0 / app.point_zoom().max(0.01)); - spots - .iter() - .position(|&(sx, sy)| { - let p = aff.apply(Point::new(f64::from(sx), f64::from(sy))); - (p.x - x).abs() <= tol && (p.y - y).abs() <= tol - }) - .map(|i| i as u8) + let zoom = app.point_zoom().max(0.01); + let tol = f64::from(6.0 / zoom); + let at = |(sx, sy): (f32, f32), tol: f64| { + let p = aff.apply(Point::new(f64::from(sx), f64::from(sy))); + (p.x - x).abs() <= tol && (p.y - y).abs() <= tol + }; + if let Some(i) = spots.iter().position(|&s| at(s, tol)) { + return Some(i as u8); + } + // The enlarged overflow handle is square on screen, so in a rotated view its corners reach + // half a diagonal from the box corner along the document axes. Only where no handle + // answered at the ordinary reach: the middle handles of a small box keep their presses. + let over = app.ui.type_box_overflow && app.ui.text_edit.as_ref().is_some_and(|ed| ed.layer == id.0); + let big = f64::from(super::overlay::OVERFLOW_HANDLE / 2.0 * std::f32::consts::SQRT_2 / zoom); + (over && at(spots[2], big)).then_some(2) } /// Drag the handle of `r` (the box and transform snapshotted at the press) to the document @@ -108,6 +115,7 @@ 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) { + app.ui.type_insertion_style = None; // 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 }); @@ -120,6 +128,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie // (left of a line's start, below the last line); only a click outside it commits. if hit_layer(app, x, y) == Some(id) || inside_box(app, id, x, y) { let off = hit_offset(app, id, x, y); + if off != ed.caret || (!mods.shift && off != ed.anchor) { + app.ui.type_insertion_style = None; + } if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; if !mods.shift { @@ -133,6 +144,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } + app.ui.type_insertion_style = None; // The Type Mask tools always start new text: existing type layers are just pixels to them; // so does a ⇧-click with the Type tool, as in Photoshop, to start new text over old. if app.ui.tool.is_type_mask() || mods.shift { @@ -171,6 +183,9 @@ pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie } if ed.dragging { let off = hit_offset(app, LayerId(ed.layer), x, y); + if off != ed.caret { + app.ui.type_insertion_style = None; + } if let Some(e) = app.ui.text_edit.as_mut() { e.caret = off; } diff --git a/crates/ui-egui/src/tools/type_tool/tests.rs b/crates/ui-egui/src/tools/type_tool/tests.rs index 77c3db56c2..dbcb62d6a5 100644 --- a/crates/ui-egui/src/tools/type_tool/tests.rs +++ b/crates/ui-egui/src/tools/type_tool/tests.rs @@ -163,6 +163,109 @@ fn click_creates_placeholder_selected_and_typing_replaces_it() { assert!(app.ui.text_edit.is_none()); } +// Independent perimeter oracle for the rectangle used by the endpoint hit tests. +fn closed_path_endpoint_test_point(s: f64) -> (f64, f64) { + if s < 200.0 { + (20.0 + s, 20.0) + } else if s < 300.0 { + (220.0, 20.0 + s - 200.0) + } else if s < 500.0 { + (220.0 - (s - 300.0), 120.0) + } else { + (20.0, 120.0 - (s - 500.0)) + } +} + +#[test] +fn closed_path_forward_hit_after_last_caret() { + for align in ["left", "center", "right"] { + let mut app = app(); + app.ui.tool_options.type_align = align.into(); + let path = photocraft_vector::shapes::rect(20.0, 20.0, 200.0, 100.0); + app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + pointer_up(&mut app, [20.0, 45.0], [20.0, 45.0]); + insert(&mut app, "ssssssssssss"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let (l, _, _) = layout(&mut app, id).unwrap(); + let (last, _, _) = l.caret(12); + let s = (575.0 + f64::from(last) + 2.0).rem_euclid(600.0); + let (x, y) = closed_path_endpoint_test_point(s); + assert_eq!(offset_at(&mut app, id, x, y), 12, "{align}"); + } +} + +#[test] +fn open_path_hits_keep_endpoint_clamping() { + let mut app = app(); + let path = photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(20.0, 100.0), (380.0, 100.0)])]); + app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + pointer_up(&mut app, [200.0, 100.0], [200.0, 100.0]); + insert(&mut app, "ssss"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert_eq!(offset_at(&mut app, id, 198.0, 100.0), 0); + assert_eq!(offset_at(&mut app, id, 20.0, 100.0), 0); + assert_eq!(offset_at(&mut app, id, 380.0, 100.0), 4); +} + +#[test] +fn closed_path_backward_hit_before_first_caret() { + for align in ["left", "center", "right"] { + let mut app = app(); + app.ui.tool_options.type_align = align.into(); + let path = photocraft_vector::shapes::rect(20.0, 20.0, 200.0, 100.0); + app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + pointer_up(&mut app, [20.0, 45.0], [20.0, 45.0]); + insert(&mut app, "ssssssssssss"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let (l, _, _) = layout(&mut app, id).unwrap(); + // The rectangle runs clockwise; the text origin is perimeter distance 575. + // Project 2 px backward from the first caret, using an independent oracle. + let (first, _, _) = l.caret(0); + let s = (575.0 + f64::from(first) - 2.0).rem_euclid(600.0); + let (x, y) = closed_path_endpoint_test_point(s); + assert_eq!(offset_at(&mut app, id, x, y), 0, "{align}"); + } +} + +#[test] +fn closed_path_upstream_caret_rectangle_left_edge_roundtrip() { + let mut app = app(); + let path = photocraft_vector::shapes::rect(20.0, 20.0, 200.0, 100.0); + app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + pointer_up(&mut app, [20.0, 45.0], [20.0, 45.0]); + insert(&mut app, "ssssssssssss"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let (layout, _, _) = layout(&mut app, id).unwrap(); + let shape = text_layer(&app.session.active().unwrap().doc, id).unwrap().shape.clone(); + let photocraft_doc::text::TextShape::Path { start, .. } = shape else { + assert!(matches!(shape, photocraft_doc::text::TextShape::Path { .. }), "outline click must attach path text"); + return; + }; + // Independent rectangle perimeter oracle: never use the warp under test to + // generate expected caret coordinates. Start is 25 px before the top-left seam. + assert!((f64::from(start) - 575.0).abs() < 0.5); + let mut tested_after_seam = false; + for index in [1, 4, 7, 10] { + let (x, _, _) = layout.caret(index); + let distance = f64::from(start) + f64::from(x); + tested_after_seam |= distance > 600.0; + let s = distance.rem_euclid(600.0); + let point = if s < 200.0 { + Point::new(20.0 + s, 20.0) + } else if s < 300.0 { + Point::new(220.0, 20.0 + s - 200.0) + } else if s < 500.0 { + Point::new(220.0 - (s - 300.0), 120.0) + } else { + Point::new(20.0, 120.0 - (s - 500.0)) + }; + // The rectangle oracle already returns document coordinates. + let doc = point; + assert_eq!(offset_at(&mut app, id, doc.x, doc.y), index, "caret round trip at {index}"); + } + assert!(tested_after_seam, "fixture must cross the seam"); +} + /// 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] @@ -450,3 +553,35 @@ fn double_click_from_move_tool_restores_move_tool_on_commit_and_cancel() { commit(&mut app); assert_eq!(app.ui.tool, crate::state::Tool::Type, "Layers-thumbnail edit stays on Type tool"); } + +#[test] +fn closed_path_aligned_selection_highlights_every_visible_cluster() { + for align in ["center", "right"] { + let mut app = app(); + app.ui.tool_options.type_align = align.into(); + let path = photocraft_vector::shapes::rect(20.0, 20.0, 200.0, 100.0); + app.run("path.set", json!({"path":photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + pointer_up(&mut app, [20.0, 45.0], [20.0, 45.0]); + insert(&mut app, "ssssssssssss"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let (l, _, _) = layout(&mut app, id).unwrap(); + assert!(l.bounds().unwrap()[0] < 0.0, "{align} must exercise negative text coordinates"); + let text_layer = text_layer(&app.session.active().unwrap().doc, id).unwrap(); + let rendered = photocraft_text::render::layout_deform(&l, text_layer).unwrap(); + let visible = l.clusters.iter().filter(|c| rendered.place_glyph(f64::from(c.x + c.advance / 2.0)) != photocraft_text::warp::Placement::Hidden).count(); + assert_eq!(visible, l.clusters.len()); + let ed = app.ui.text_edit.as_mut().unwrap(); + ed.anchor = 0; + ed.caret = 12; + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(Default::default(), |ui| { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0)); + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + let xf = ViewXform { rect, zoom: 1.0, center: [120.0, 70.0], flip: false, rotation: 0.0, aspect: 1.0 }; + draw_overlay(&mut app, &painter, &xf); + }); + output.textures_delta.clear(); + let highlights = output.shapes.iter().filter(|s| matches!(&s.shape, egui::Shape::Path(p) if p.closed && p.fill.a() == 110)).count(); + assert_eq!(highlights, visible, "{align}: every visible selected cluster needs its highlight"); + } +} diff --git a/crates/ui-egui/src/tools/type_transform.rs b/crates/ui-egui/src/tools/type_transform.rs index 5c8a148eea..73264dc214 100644 --- a/crates/ui-egui/src/tools/type_transform.rs +++ b/crates/ui-egui/src/tools/type_transform.rs @@ -129,7 +129,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { rect, quad, pivot, - interpolation: "bicubic".into(), + interpolation: crate::transform_tool::default_interpolation(app).into(), warp: None, selection: false, target: None, @@ -138,6 +138,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { mode: Default::default(), content_aware: None, skew_v: 0.0, + layers: Vec::new(), }) } diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index c86ad3a1cd..0749b87434 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -33,6 +33,7 @@ use options::{corner_radii_patch, solid_paint}; pub(crate) use options::{enabled_stroke_width, paint_gesture}; pub use options::{options_bar, shape_properties}; pub use overlay::draw_overlay; +pub(crate) use overlay::selected_saved_path_index; #[cfg(test)] use overlay::{MAX_CURVE_PIECES, bezier}; #[cfg(test)] @@ -51,12 +52,83 @@ pub use paths_panel::{PathEntry, PathRow, path_rows, paths_footer, paths_panel}; pub(crate) use pen::over_first_anchor; #[cfg(test)] use pen::pen_to_json; -pub use pen::{cancel_stale, closes_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_grab_handle, pen_move, pen_undo_last_point, pen_up}; -pub(crate) use pen::{pen_path, pen_replace}; +pub(crate) use pen::{PATH_OPS, pen_path, pen_replace}; +pub use pen::{ + cancel_stale, closes_path_at, freeform_finish, pen_break_handle, pen_commit, pen_down, pen_end, pen_grab_handle, pen_move, pen_undo_last_point, pen_up, +}; +#[cfg(test)] +use pen::{closing, pen_path_op}; pub use shapes::{create_shape, draw_shape_preview, finish_shape, is_shape_tool, shape_bounds}; #[cfg(test)] use shapes::{shape_geometry, shape_preview}; +// "Set as default" is a temporary UI choice, not a document property. +#[derive(Clone, Copy, Default)] +struct ArrowDefaultLink { + target: Option<(u64, u64)>, + enabled: bool, +} + +fn arrow_default_link_id() -> egui::Id { + egui::Id::unique("arrow-default-link") +} + +/// Reset the checkbox whenever the selected document, layer or shape changes. +pub(crate) fn track_arrow_default_selection(app: &PhotocraftApp, ctx: &egui::Context) { + let target = app.session.active().and_then(|st| { + let id = st.active_layer?; + let layer = st.doc.layer(id)?; + match &layer.content { + LayerContent::Shape(sh) if matches!(sh.live.as_ref(), Some(photocraft_doc::vector::LiveShape::ArrowLine { .. })) => Some((st.doc.id.0, id.0)), + _ => None, + } + }); + + ctx.data_mut(|data| { + let key = arrow_default_link_id(); + let previous = data.get_temp::(key).unwrap_or_default(); + if previous.target != target { + data.insert_temp(key, ArrowDefaultLink { target, enabled: false }); + } + }); +} + +/// Copy only the selected arrow's live parameters into new-arrow defaults. +/// Existing shapes and geometry are not modified. +fn copy_arrow_to_tool_defaults(app: &mut PhotocraftApp, id: photocraft_doc::LayerId) { + let values = app.session.active().and_then(|st| st.doc.layer(id)).and_then(|layer| match &layer.content { + LayerContent::Shape(sh) => match sh.live.as_ref() { + Some(photocraft_doc::vector::LiveShape::ArrowLine { start, end, width, length, concavity, weight, .. }) => { + { + // Live dimensions may exceed new-arrow input limits after scaling. + // Clamp only the defaults, never the existing shape. + let bounded = |v: f64, min: f64, max: f64| -> f32 { if v.is_finite() { v.clamp(min, max) as f32 } else { min as f32 } }; + Some(( + *start, + *end, + bounded(*width, 1.0, 4096.0), + bounded(*length, 1.0, 4096.0), + bounded(*concavity, 0.0, 100.0), + bounded(*weight, 1.0, 10000.0), + )) + } + } + _ => None, + }, + _ => None, + }); + + if let Some((start, end, width, length, concavity, weight)) = values { + let o = &mut app.ui.tool_options; + o.arrow_start = start; + o.arrow_end = end; + o.arrow_width = width; + o.arrow_length = length; + o.arrow_concavity = concavity; + o.line_weight = weight; + } +} + /// Pen tool path under construction: knots as [anchor, in, out] (document px). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct PenPath { @@ -69,6 +141,11 @@ pub struct PenPath { /// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put. #[serde(skip)] pub adjusting_last: bool, + /// Space moves the anchor and both handles together during a Pen drag. + #[serde(skip)] + pub reposition: bool, + #[serde(skip)] + pub last_pointer: Option<[f64; 2]>, /// ⌥/Alt-dragging the final anchor's incoming handle: it turns alone (#6077). #[serde(skip)] pub adjusting_in: bool, @@ -79,6 +156,13 @@ pub struct PenPath { /// Curvature Pen: the anchor being dragged. #[serde(skip)] pub moving: Option, + /// The document and target the first anchor was placed on; a draft without one (restored + /// from settings) is never committed. + #[serde(skip)] + pub(crate) owner: Option, + /// The last commit failed: the draft stays as it was and isn't retried on its own. + #[serde(skip)] + pub(crate) commit_failed: bool, /// 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)] @@ -91,7 +175,11 @@ pub struct PenPath { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Resumed { pub doc: u64, + /// A document path's name ("work" or a saved path's), or "layer" for `layer`'s. pub path: String, + /// The shape layer (Shape mode) or targeted vector mask whose path it is. + #[serde(default)] + pub layer: Option, pub index: usize, pub was: photocraft_doc::vector::Subpath, } @@ -112,5 +200,145 @@ mod path_selection_tests; #[cfg(test)] mod pen_draw_tests; +#[cfg(test)] +mod pen_path_ops_tests; + #[cfg(test)] mod paint_tests; + +pub(crate) use pen::freeform_locked_shape; + +/// Where a Pen draft goes, captured at its first anchor. +#[derive(Clone, Debug)] +pub(crate) struct PenOwner { + document: DocId, + revision: u64, + source: Weak, + target: PenTarget, + options: ToolOptions, + layer: Option, + vector_mask: bool, + foreground: [f32; 4], + background: [f32; 4], +} + +impl PartialEq for PenOwner { + fn eq(&self, other: &Self) -> bool { + self.document == other.document && self.revision == other.revision && self.source.ptr_eq(&other.source) && self.target == other.target + } +} + +#[derive(Clone, Debug, PartialEq)] +enum PenTarget { + /// A new shape layer, with the paint chosen when drawing began. + Shape { + fill: Value, + stroke: Value, + open_stroke: Value, + }, + VectorMask(u64), + /// The work path or a saved path; `append` adds the draft as a subpath (#4721). + Path { + name: String, + append: bool, + }, +} + +/// Where the Pen draws now: a new shape layer (Shape mode), else the work or saved path selected +/// in the Paths panel (the new path joins it, #4721), else the targeted vector mask, else a new +/// Work Path that replaces the old one. +pub(crate) fn capture_pen_owner(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let target = if app.ui.tool_options.vector_mode == "shape" { + let mut open_stroke = app.ui.tool_options.shape_stroke.params(); + open_stroke["width"] = json!(app.ui.tool_options.stroke_width.max(1.0)); + open_stroke["color"] = json!(app.session.tools.foreground); + PenTarget::Shape { fill: fill_param(app), stroke: stroke_param(app), open_stroke } + } else if let Some((name, _)) = selected_document_geometry(app) { + PenTarget::Path { name, append: true } + } else if let Some(id) = targeted_vector_mask_id(app) { + PenTarget::VectorMask(id) + } else { + PenTarget::Path { name: "work".into(), append: false } + }; + Some(PenOwner { + document: st.doc.id, + revision: st.revision, + source: Arc::downgrade(&st.doc), + target, + options: app.ui.tool_options.clone(), + layer: st.active_layer, + vector_mask: app.ui.vector_mask_target, + foreground: app.session.tools.foreground, + background: app.session.tools.background, + }) +} + +impl PenOwner { + fn valid(&self, app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| st.doc.id == self.document && st.revision == self.revision && self.source.ptr_eq(&Arc::downgrade(&st.doc))) + } +} + +use photocraft_doc::DocId; +use std::sync::{Arc, Weak}; + +use path_ops::{selected_document_geometry, targeted_vector_mask_id}; +pub(crate) use pen::{finish_pen_if_left, freeform_finish_owned}; +use shapes::{fill_param, stroke_param}; + +/// Commit a valid source-owned draft on a tab switch; otherwise cancel with an actionable diagnostic. +pub(crate) fn cancel_stale_pen(app: &mut PhotocraftApp) -> bool { + let Some(pen) = app.ui.pen.as_ref() else { return false }; + if pen.owner.as_ref().is_some_and(|o| o.valid(app)) { + return false; + } + if pen.commit_failed { + return false; + } + let owner = pen.owner.clone(); + if let Some(owner) = owner.as_ref() { + let index = app + .session + .documents() + .iter() + .position(|st| st.doc.id == owner.document && st.revision == owner.revision && owner.source.ptr_eq(&Arc::downgrade(&st.doc))); + if let Some(index) = index { + let active = app.session.active_index(); + let saved_ui = app.ui.clone(); + let draft = app.ui.pen.take(); + app.session.set_active(index); + app.ui.pen = draft; + // This source matches the immutable owner; normal commit validation still applies. + let result = pen::try_pen_commit(app, false); + if let Some(active) = active { + app.session.set_active(active); + } + let failed = app.ui.pen.take(); + let message = app.ui.status.clone(); + // The temporary source commit ran sync_views on that document. Rebind its runtime + // tracker before restoring the destination UI, so the next frame cannot clear + // an unrelated saved-path selection in the destination tab. + app.path_document = app.session.active().map(|st| (st.doc.id, st.revision, Arc::downgrade(&st.doc))); + app.ui = saved_ui; + app.ui.pen = if result.is_ok() { None } else { failed }; + if result.is_ok() && app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + if result.is_err() { + app.ui.status = message; + app.ui.status_error = true; + } + app.ui.pen_doc = app.ui.pen.as_ref().and_then(|p| p.owner.as_ref().map(|o| o.document)); + return true; + } + } + 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(); + } + app.ui.status = "Pen draft cancelled because its source document or path changed; newer work was preserved".into(); + app.ui.status_error = true; + true +} diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index 5506beb8f7..ebd62becdb 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -32,8 +32,9 @@ 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); + // A Pen draft whose commit is refused stays as it is: no Curvature anchor is added to it. + if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) && !pen_commit(app, false) { + return; } let hit = curvature_hit(app, x, y); if hit == Some(0) && app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 3) { @@ -43,7 +44,12 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); } + let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); + if pen.owner.is_none() { + pen.owner = Some(owner); + } + pen.commit_failed = false; if hit.is_none() { pen.knots.push([[x, y]; 3]); } @@ -52,6 +58,7 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { } pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) { + cancel_stale_pen(app); if let Some(pen) = app.ui.pen.as_mut().filter(|p| p.curvature) && let Some(k) = pen.moving.and_then(|i| pen.knots.get_mut(i)) { @@ -61,6 +68,7 @@ pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) { } pub fn curvature_up(app: &mut PhotocraftApp) { + cancel_stale_pen(app); if let Some(pen) = app.ui.pen.as_mut() { pen.moving = None; } @@ -75,6 +83,7 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { crate::direct_select::curvature_toggle_active(app, [x, y]); return; } + cancel_stale_pen(app); let Some(i) = curvature_hit(app, x, y) else { return }; let Some(pen) = app.ui.pen.as_mut() else { return }; if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { @@ -84,3 +93,5 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { } curvature_refresh(pen, false); } + +use super::{cancel_stale_pen, capture_pen_owner}; diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index e87dd825d9..cd68a6bcb6 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -1,5 +1,7 @@ use super::path_ops::active_path_name; +use super::pen::path_operations_menu; use super::shapes::is_shape_tool; +use super::{ArrowDefaultLink, arrow_default_link_id, copy_arrow_to_tool_defaults}; use super::{GradientChoices, LayerContent, PaintChange, PhotocraftApp, Tokens, Tool, Value, json, paint_param, swatch}; // --------------------------------------------------------------------------------------------- @@ -21,10 +23,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // The caption scrubs and names the control drawn right after it. crate::widgets::scrub_option_label(ui, &label); }; - let o = &mut app.ui.tool_options; let target = selected_shape(&app.session); + if tool == Tool::PathSelection && !crate::direct_select::active_paths(app).is_empty() { + path_operations_menu(app, ui); + crate::widgets::vline(ui, 22.0); + } + let o = &mut app.ui.tool_options; if tool == Tool::PathSelection && target.is_none() { - lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the active shape's path or the Work Path") }); + lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the selected path") }); return true; } if tool == Tool::DirectSelection && target.is_none() { @@ -60,6 +66,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo o.stroke_width = enabled_stroke_width(o.stroke_width); } } + crate::widgets::vline(ui, 22.0); + path_operations_menu(app, ui); + let o = &mut app.ui.tool_options; if tool == Tool::Pen { crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut o.pen_auto_add_delete, tl!("Auto Add/Delete")); @@ -106,11 +115,39 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0); crate::widgets::vline(ui, 22.0); crate::shape_options::options_bar(app, ui, tool); + // Photoshop-style arrowhead settings at the right of Line options. + if tool == Tool::Line { + crate::widgets::vline(ui, 22.0); + ui.menu_image_button(crate::icons::image("settings", 18.0, t.icon), |ui| { + let o = &mut app.ui.tool_options; + ui.set_min_width(170.0); + ui.label(tl!("Arrowheads")); + ui.separator(); + ui.horizontal(|ui| { + ui.checkbox(&mut o.arrow_start, tl!("Start")); + ui.checkbox(&mut o.arrow_end, tl!("End")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Width")); + ui.add(egui::DragValue::new(&mut o.arrow_width).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Length")); + ui.add(egui::DragValue::new(&mut o.arrow_length).range(1.0..=4096.0).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label(tl!("Concavity")); + ui.add(egui::DragValue::new(&mut o.arrow_concavity).range(0.0..=100.0).suffix(" %")); + }); + }) + .response + .on_hover_text(tl!("Arrowhead options")); + } true } /// Whether the active layer supplies the selected-shape options. -fn selected_shape(session: &photocraft_engine::Session) -> Option { +pub(super) fn selected_shape(session: &photocraft_engine::Session) -> Option { let st = session.active()?; let layer = st.active_layer?; matches!(st.doc.layer(layer)?.content, LayerContent::Shape(_)).then_some(layer) @@ -168,6 +205,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }; let t = Tokens::get(ui.ctx()); let mut edit: Option = None; + // A swatch picked in a Fill/Stroke popover joins the Recently Used Colors once the edit took it. + let mut recent: Option<[f32; 3]> = None; let doc_id = app.session.active().map(|st| st.doc.id); let key = |k: &str| format!("shape-{doc_id:?}-{}-{k}", id.0); let gesture_key = ui.scope_id().with(key("paint-gesture")); @@ -176,9 +215,11 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra // The shared collapsible section headers (#155). // Prefs "Appearance" is theme chrome (Giao diện); shape fill/stroke is Diện mạo. if crate::props_layout::section(ui, "appearance", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Appearance")) { + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + let lib = crate::paint_picker::library(&app.session, ui.ctx()); + let mut picks: Vec<(bool, PaintChange)> = Vec::new(); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0)); - let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); let mut gradients = GradientChoices { presets: &app.session.presets.gradients, current: &app.session.presets.gradient, @@ -186,24 +227,32 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra fg, bg, }; - if let Some(change) = swatch(ui, sh.fill.as_ref(), fg, tl!("Set shape fill type"), &mut gradients) { - let fill = match change { - PaintChange::None => Value::Null, - PaintChange::Color(color) => json!(color), - PaintChange::Gradient(g) => paint_param(&g), + if let Some(change) = swatch(ui, &lib, sh.fill.as_ref(), fg, tl!("Set shape fill type"), &mut gradients) { + let fill = match &change { + PaintChange::None => Some(Value::Null), + PaintChange::Color(color) => Some(json!(color)), + PaintChange::Gradient(g) => Some(paint_param(g)), + PaintChange::OpenPicker => None, }; - edit = Some(json!({"fill": fill, "coalesce": coalesce("fill")})); + if let Some(fill) = fill { + edit = Some(json!({"fill": fill, "coalesce": coalesce("fill")})); + } + picks.push((false, change)); } ui.add_space(12.0); ui.label(egui::RichText::new(tl!("Stroke")).color(t.text_dim).size(12.0)); - if let Some(change) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), fg, tl!("Set shape stroke type"), &mut gradients) { + if let Some(change) = swatch(ui, &lib, sh.stroke.as_ref().map(|s| &s.paint), fg, tl!("Set shape stroke type"), &mut gradients) { let width = enabled_stroke_width(sh.stroke.as_ref().map_or(0.0, |s| s.width)); - let stroke = match change { - PaintChange::None => Value::Null, - PaintChange::Color(color) => json!({"color": color, "width": width}), - PaintChange::Gradient(g) => json!({"color": paint_param(&g), "width": width}), + let stroke = match &change { + PaintChange::None => Some(Value::Null), + PaintChange::Color(color) => Some(json!({"color": color, "width": width})), + PaintChange::Gradient(g) => Some(json!({"color": paint_param(g), "width": width})), + PaintChange::OpenPicker => None, }; - edit = Some(json!({"stroke": stroke, "coalesce": coalesce("stroke")})); + if let Some(stroke) = stroke { + edit = Some(json!({"stroke": stroke, "coalesce": coalesce("stroke")})); + } + picks.push((true, change)); } 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).accessible_name(tl!("Stroke:")).changed() && (sh.stroke.is_some() || w > 0.0) { @@ -228,6 +277,16 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } edit = Some(json!({"stroke": stroke, "coalesce": change.coalesce})); } + for (is_stroke, change) in picks { + match change { + PaintChange::OpenPicker => { + let which = if is_stroke { crate::shape_fill_ui::Paint::Stroke } else { crate::shape_fill_ui::Paint::Fill }; + crate::shape_fill_ui::open_paint(app, which, Some(id), false, fg); + } + PaintChange::Color(c) => recent = Some([c[0], c[1], c[2]]), + PaintChange::None | PaintChange::Gradient(_) => {} + } + } } if let Some(live) = &sh.live && crate::props_layout::section(ui, "liveShape", tl!("Shape")) @@ -249,7 +308,7 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } }); } - photocraft_doc::vector::LiveShape::Line { .. } => {} + photocraft_doc::vector::LiveShape::Line { .. } | photocraft_doc::vector::LiveShape::ArrowLine { .. } => {} } ui.vertical(|ui| match live { photocraft_doc::vector::LiveShape::Rect { radii, .. } => { @@ -293,6 +352,59 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }) .inner } + photocraft_doc::vector::LiveShape::ArrowLine { weight, start, end, width, length, concavity, .. } => { + // This is a selected, saved Arrow; unlike the tool's defaults, edits update its geometry. + ui.horizontal(|ui| { + let mut value = *start; + if ui.checkbox(&mut value, tl!("Start")).changed() { + edit = Some(json!({"arrowStart": value, "coalesce": key("arrow-start")})); + } + let mut value = *end; + if ui.checkbox(&mut value, tl!("End")).changed() { + edit = Some(json!({"arrowEnd": value, "coalesce": key("arrow-end")})); + } + }); + ui.horizontal(|ui| { + let mut v = *width as f32; + if num(ui, "Width", &mut v, 1.0..=4096.0, "px") { + edit = Some(json!({"arrowWidth": v, "coalesce": key("arrow-width")})); + } + }); + ui.horizontal(|ui| { + let mut v = *length as f32; + if num(ui, "Length", &mut v, 1.0..=4096.0, "px") { + edit = Some(json!({"arrowLength": v, "coalesce": key("arrow-length")})); + } + }); + ui.horizontal(|ui| { + let mut v = *weight as f32; + if num(ui, "Weight", &mut v, 1.0..=10000.0, "px") { + edit = Some(json!({"weight": v, "coalesce": key("arrow-weight")})); + } + }); + ui.horizontal(|ui| { + let mut v = *concavity as f32; + if num(ui, "Concavity", &mut v, 0.0..=100.0, "%") { + edit = Some(json!({"arrowConcavity": v, "coalesce": key("arrow-concavity")})); + } + }); + + // Explicitly adopt this arrow's values as defaults for new arrows. + let target = app.session.active().map(|st| (st.doc.id.0, id.0)); + let mut linked = ui.ctx().data(|data| { + let state = data.get_temp::(arrow_default_link_id()).unwrap_or_default(); + state.target == target && state.enabled + }); + + if ui.checkbox(&mut linked, tl!("Set as default")).on_hover_text(tl!("Use these settings for new arrows")).changed() { + ui.ctx().data_mut(|data| { + data.insert_temp(arrow_default_link_id(), ArrowDefaultLink { target, enabled: linked }); + }); + if linked { + copy_arrow_to_tool_defaults(app, id); + } + } + } photocraft_doc::vector::LiveShape::Line { weight, .. } => { ui.horizontal(|ui| { let mut w = *weight as f32; @@ -307,9 +419,30 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } if let Some(mut p) = edit { p["layer"] = json!(id.0); - if let Err(e) = app.run("shape.edit", p) { - app.ui.status = e; - app.ui.status_error = true; + + match app.run("shape.edit", p) { + Ok(_) => { + // Remember recently used colors after a successful edit. + if let Some(rgb) = recent { + crate::paint_picker::remember(app, rgb); + } + + // Only a successful Properties edit updates new-arrow defaults, + // and only while the user explicitly enables the checkbox. + let target = app.session.active().map(|st| (st.doc.id.0, id.0)); + let linked = ui.ctx().data(|data| { + let state = data.get_temp::(arrow_default_link_id()).unwrap_or_default(); + state.enabled && state.target == target + }); + + if linked { + copy_arrow_to_tool_defaults(app, id); + } + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } } if !ui.input(|i| i.pointer.any_down()) { ui.data_mut(|d| { diff --git a/crates/ui-egui/src/tools/vector_ui/overlay.rs b/crates/ui-egui/src/tools/vector_ui/overlay.rs index 7e2b6ab4e6..6bc967e34e 100644 --- a/crates/ui-egui/src/tools/vector_ui/overlay.rs +++ b/crates/ui-egui/src/tools/vector_ui/overlay.rs @@ -120,6 +120,13 @@ pub(crate) fn selected_saved_path(app: &PhotocraftApp) -> Option<&Path> { app.session.active()?.doc.paths.iter().find(|p| p.name == sel).map(|p| &p.path) } +/// [`selected_saved_path`] with its index in the document's `paths`: the saved path Path +/// Selection and Direct Selection edit (#8478). +pub(crate) fn selected_saved_path_index(app: &PhotocraftApp) -> Option<(usize, Path)> { + let sel = app.ui.selected_path.as_deref().filter(|s| !matches!(*s, "work" | "layer"))?; + app.session.active()?.doc.paths.iter().enumerate().find(|(_, p)| p.name == sel).map(|(i, p)| (i, p.path.clone())) +} + /// 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) { @@ -141,10 +148,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // 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 { @@ -167,7 +170,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) && let LayerContent::Shape(sh) = &l.content { - draw_path(&sh.path, anchors); + // Hide the stale shape path while its transform preview is active. + let transforming_this_shape = + app.ui.transform.as_ref().is_some_and(|t| t.layer == l.id.0 && !t.selection && t.target.is_none() && t.path.is_none()); + if !transforming_this_shape { + draw_path(&sh.path, anchors); + } } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { @@ -177,6 +185,15 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some((_, p)) = active_type_path(app) { draw_path(&p, anchors); } + // Path Selection's selected components show solid anchors, as in Photoshop. + if tool == Tool::PathSelection + && st.is_some_and(|s| s.doc.id == doc.id) + && let Some((_, path, _)) = crate::path_components::selected(app) + { + for k in app.ui.direct_selection.anchors.iter().filter_map(|[s, k]| path.subpaths.get(*s).and_then(|sp| sp.knots.get(*k))) { + draw_anchor(painter, to_scr([k.anchor.x, k.anchor.y]), true, accent); + } + } crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While diff --git a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs index d71a8fca78..6c738735b0 100644 --- a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs @@ -3,7 +3,10 @@ use super::*; use crate::shape_options::set_tool_paint; use crate::shape_paint_ui::color_of; use egui::{Event, Key, Modifiers, PointerButton}; -use egui_kittest::{Harness, kittest::Queryable}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; use photocraft_doc::ShapeLayer; fn app(mode: &str, depth: u32) -> PhotocraftApp { @@ -58,6 +61,8 @@ fn harness_stepped(app: PhotocraftApp, properties: bool, step_dt: f32) -> Harnes let tool = app.ui.tool; ui.horizontal(|ui| options_bar(app, ui, tool)); } + // The Color Picker the popover opens, as the app draws it. + crate::dialogs::show(app, ui.ctx()); }, app, ); @@ -198,6 +203,26 @@ fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_pain } } +/// Open the popover's swatch folder `folder` and click its swatch `name`. +fn click_swatch(h: &mut Harness<'_, PhotocraftApp>, folder: &str, name: &str) { + if h.query_by_label(name).is_none() { + h.get_by_label(folder).click(); + h.run_steps(2); + } + h.get_by_label(name).click(); + h.run_steps(2); +} + +/// Click the open Color Picker's hex field (found by the colour it shows) and select its text. +fn focus_hex(h: &mut Harness<'_, PhotocraftApp>) { + let shown = h.state().ui.dialogs.last().expect("the Color Picker dialog").fields["color"].as_str().unwrap().trim_start_matches('#').to_ascii_uppercase(); + h.get_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.value().as_deref() == Some(shown.as_str())).expect("the hex field").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); +} + +/// The Color Picker opened from the popover keeps partial hex typing and the paint's alpha. #[test] fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() { for label in ["Fill color", "Stroke color"] { @@ -207,33 +232,32 @@ fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() { 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.get_by_label("Color Picker").click(); + h.run_steps(3); + let id = h.state().ui.dialogs.last().expect("the Color Picker dialog").id; + let opened = h.state().ui.dialogs.last().unwrap().fields["color"].clone(); + focus_hex(&mut h); 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); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["color"], opened, "four digits are not a colour yet"); for ch in "EF".chars() { h.event(Event::Text(ch.to_string())); h.run_steps(1); } + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["color"], "#12abef", "the partial text was kept"); + // An invalid/multi-byte value leaves the colour untouched. + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Text("zé!".into())); + h.run_steps(2); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["color"], "#12abef"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); 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()); @@ -254,12 +278,14 @@ fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() { 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.get_by_label("Color Picker").click(); + h.run_steps(3); + let id = h.state().ui.dialogs.last().expect("the Color Picker dialog").id; + focus_hex(&mut h); h.event(Event::Paste("#12ABEF".into())); h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + 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) @@ -279,6 +305,83 @@ fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() { #[test] fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() { let mut app = app("rgb", 32); + app.ui.tool_options.shape_fill = false; + let mut h = harness(app, false); + for (label, swatch) in [("Fill color", "RGB Red"), ("Stroke color", "RGB Blue")] { + h.get_by_label(label).click(); + h.run_steps(3); + click_swatch(&mut h, "RGB", swatch); + h.key_press(Key::Escape); + h.run_steps(2); + } + assert!(h.state().ui.tool_options.shape_fill); + assert_eq!(h.state().ui.tool_options.stroke_width, 3.0); + assert_eq!(h.state().session.tools.foreground, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(h.state().session.tools.background, [0.0, 0.0, 1.0, 1.0]); + // Both join the Recently Used Colors, newest first. + let recent: Vec = crate::paint_picker::recent_colors(&h.state().session).into_iter().map(crate::color_picker_ui::hex).collect(); + assert_eq!(recent, ["#0000ff", "#ff0000"]); + finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + assert_eq!(shape(h.state()).fill, Some(solid_paint([1.0, 0.0, 0.0, 1.0]))); + assert_eq!(shape(h.state()).stroke.as_ref().unwrap().paint, solid_paint([0.0, 0.0, 1.0, 1.0])); +} + +/// The popover is Photoshop's: the four paint types along the top with the Color Picker button at +/// the right, then Recently Used Colors over the swatch folders. Pattern is off until shapes +/// render pattern paint. +#[test] +fn popover_lays_out_photoshops_paint_types_recent_colours_and_folders() { + let mut app = app("rgb", 8); + crate::paint_picker::remember(&mut app, [0.2, 0.4, 0.6]); + let mut h = harness(app, false); + h.get_by_label("Fill color").click(); + h.run_steps(3); + let types: Vec = ["No Color", "Solid Color", "Gradient", "Pattern"].iter().map(|l| h.get_by_label(l).rect()).collect(); + for w in types.windows(2) { + assert!((w[0].top() - w[1].top()).abs() < 0.5 && w[0].right() <= w[1].left(), "one row, Photoshop's order"); + } + let picker = h.get_by_label("Color Picker").rect(); + assert!((picker.center().y - types[0].center().y).abs() < 2.0 && picker.left() > types[3].right() + 40.0, "Color Picker at the top right"); + assert!(h.get_by_label("Pattern").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Solid Color").accesskit_node().is_disabled() && !h.get_by_label("Gradient").accesskit_node().is_disabled()); + let recent = h.get_by_label("Recently Used Colors").rect(); + let swatch = h.get_by_label("#336699").rect(); + assert!(recent.top() > types[0].bottom() && swatch.top() > recent.bottom()); + let folders: Vec = ["Grays", "RGB", "Pastel", "Pure", "Dark"].iter().map(|l| h.get_by_label(l).rect()).collect(); + assert!(folders[0].top() > swatch.bottom() && folders.windows(2).all(|w| w[1].top() > w[0].top()), "folders below, in library order"); + assert!(h.query_by_label("RGB Red").is_none(), "folders start closed"); + // The egui picker's channel fields are gone. + assert!(h.query_by_label("U8").is_none()); +} + +#[test] +fn colour_picker_button_opens_the_full_picker_on_the_paint() { + let mut app = app("rgb", 8); + app.run("tools.setColors", json!({"foreground": "#102030", "background": "#405060"})).unwrap(); + let mut h = harness(app, false); + h.get_by_label("Stroke color").click(); + h.run_steps(3); + h.get_by_label("Color Picker").click(); + h.run_steps(3); + let d = h.state().ui.dialogs.last().expect("the Color Picker dialog"); + assert_eq!(d.fields["__label"], "Color Picker (Stroke Color)"); + assert_eq!(d.fields["color"], "#405060", "on the next shape's stroke colour"); + assert!(!egui::Popup::is_any_open(&h.ctx), "the popover gives way to the dialog"); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let app = h.state(); + assert_eq!(crate::color_picker_ui::hex([app.session.tools.background[0], app.session.tools.background[1], app.session.tools.background[2]]), "#ff8800"); + assert_eq!(app.ui.tool_options.stroke_width, 3.0, "picking a stroke colour turns the stroke on"); + assert_eq!(app.session.active().unwrap().history.past_len(), 0, "tool defaults do not edit the document"); + assert_eq!(crate::paint_picker::recent_colors(&app.session).first().copied().map(crate::color_picker_ui::hex).as_deref(), Some("#ff8800")); +} + +/// The popover's Color Picker keeps the screen eyedropper: a sampled colour waits in the dialog +/// until OK, then becomes the next shape's fill or stroke colour. +#[test] +fn popover_colour_picker_samples_the_screen_for_the_next_shape() { + let mut app = app("rgb", 8); let mut picks = std::collections::VecDeque::from([[0.91, 0.17, 0.32], [0.12, 0.68, 0.93]]); app.services.screen_pick = Some(Box::new(move |_| { let (tx, receiver) = std::sync::mpsc::channel(); @@ -288,20 +391,30 @@ fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() { app.ui.tool_options.shape_fill = false; let mut h = harness(app, false); for label in ["Fill color", "Stroke color"] { + let tools = (h.state().session.tools.foreground, h.state().session.tools.background); h.get_by_label(label).click(); h.run_steps(3); + h.get_by_label("Color Picker").click(); + h.run_steps(3); h.get_by_label("Pick screen color").click(); h.run_steps(4); - h.key_press(Key::Escape); + assert_eq!((h.state().session.tools.foreground, h.state().session.tools.background), tools, "{label}: sampling waits for OK"); + let id = h.state().ui.dialogs.last().expect("the Color Picker dialog").id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); h.run_steps(2); } + // The picker holds the sample as hex: 8 bits a channel. + let hex = |c: [f32; 3]| { + let [r, g, b] = c.map(|v| (v * 255.0).round() / 255.0); + [r, g, b, 1.0] + }; + let (fill, stroke) = (hex([0.91, 0.17, 0.32]), hex([0.12, 0.68, 0.93])); + assert_eq!((h.state().session.tools.foreground, h.state().session.tools.background), (fill, stroke)); assert!(h.state().ui.tool_options.shape_fill); assert_eq!(h.state().ui.tool_options.stroke_width, 3.0); - assert_eq!(h.state().session.tools.foreground, [0.91, 0.17, 0.32, 1.0]); - assert_eq!(h.state().session.tools.background, [0.12, 0.68, 0.93, 1.0]); finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); - assert_eq!(shape(h.state()).fill, Some(solid_paint([0.91, 0.17, 0.32, 1.0]))); - assert_eq!(shape(h.state()).stroke.as_ref().unwrap().paint, solid_paint([0.12, 0.68, 0.93, 1.0])); + assert_eq!(shape(h.state()).fill, Some(solid_paint(fill))); + assert_eq!(shape(h.state()).stroke.as_ref().unwrap().paint, solid_paint(stroke)); } #[test] @@ -312,14 +425,14 @@ fn no_colour_and_solid_choices_are_explicit_live_tool_defaults() { h.get_by_label("No Color").click(); h.run_steps(2); assert!(!h.state().ui.tool_options.shape_fill); - h.get_by_label("Solid").click(); + h.get_by_label("Solid Color").click(); h.run_steps(2); assert!(h.state().ui.tool_options.shape_fill); h.key_press(Key::Escape); h.run_steps(2); h.get_by_label("Stroke color").click(); h.run_steps(3); - h.get_by_label("Solid").click(); + h.get_by_label("Solid Color").click(); h.run_steps(2); assert_eq!(h.state().ui.tool_options.stroke_width, 3.0, "choosing unchanged white enables a stroke"); h.get_by_label("No Color").click(); @@ -383,7 +496,7 @@ fn transparent_and_absent_paints_remain_distinct_and_open_pen_keeps_foreground() let foreground = [0.836521, 0.125468, 0.745632, 0.438271]; app.run("tools.setColors", json!({"foreground": foreground, "background": [0.0, 1.0, 0.0, 1.0]})).unwrap(); app.ui.tool_options.vector_mode = "shape".into(); - app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[80.0, 60.0]; 3]], ..Default::default() }); + app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[80.0, 60.0]; 3]], owner: super::capture_pen_owner(&app), ..Default::default() }); pen_commit(&mut app, false); assert!(shape(&app).fill.is_none()); assert!(!shape(&app).path.subpaths[0].closed); @@ -417,55 +530,66 @@ fn selected_shape_style_is_available_when_absent_and_dismissal_does_not_enable_i assert_eq!(shape(h.state()).stroke.as_ref().unwrap().dashes, [0.0, 2.0]); } -fn popup_rect(h: &Harness<'_, PhotocraftApp>) -> Rect { - h.ctx - .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))) - .expect("colour picker is open") -} - #[test] -fn selected_shape_colour_drags_apply_live_coalesce_per_gesture_and_keep_tool_defaults() { +fn selected_shape_swatch_clicks_are_one_undo_step_each_and_keep_tool_defaults() { let mut app = app("rgb", 32); - app.run("shape.create", json!({"rect": [20,20,80,60], "fill": "#3270c0", "stroke": {"width": 5, "color": "#ee3355"}})).unwrap(); + app.run("shape.create", json!({"rect": [20,20,80,60], "fill": [0.2, 0.4, 0.6, 0.5], "stroke": {"width": 5, "color": "#ee3355"}})).unwrap(); let original = shape(&app).clone(); let defaults = (app.session.tools.foreground, app.session.tools.background, app.ui.tool_options.clone()); let history = app.session.active().unwrap().history.past_len(); let mut h = harness(app, true); h.get_by_label("Set shape fill type").click(); h.run_steps(4); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history); - let rect = popup_rect(&h); - let start = rect.min + vec2(55.0, rect.height() * 0.5); - h.hover_at(start); - h.step(); - pointer(&mut h, start, true); - for offset in [10.0, 20.0, 30.0] { - h.event(Event::PointerMoved(start + vec2(offset, 6.0))); - h.step(); - } - pointer(&mut h, start + vec2(30.0, 6.0), false); - h.run_steps(2); - let first = shape(h.state()).clone(); - assert_ne!(first.fill, original.fill); - assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); - let next = start + vec2(60.0, 15.0); - h.hover_at(next); - h.step(); - pointer(&mut h, next, true); - h.event(Event::PointerMoved(next + vec2(12.0, 4.0))); - h.step(); - pointer(&mut h, next + vec2(12.0, 4.0), false); - h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history, "opening changes nothing"); + click_swatch(&mut h, "RGB", "RGB Green"); + assert_eq!(shape(h.state()).fill, Some(solid_paint([0.0, 1.0, 0.0, 0.5])), "the fill's alpha stays"); + click_swatch(&mut h, "RGB", "RGB Blue"); assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); + assert_eq!(shape(h.state()).stroke, original.stroke); h.key_press(Key::Escape); h.run_steps(2); assert_eq!((h.state().session.tools.foreground, h.state().session.tools.background, h.state().ui.tool_options.clone()), defaults); h.state_mut().run("edit.undo", json!({})).unwrap(); - assert_eq!(shape(h.state()).fill, first.fill); + assert_eq!(shape(h.state()).fill, Some(solid_paint([0.0, 1.0, 0.0, 0.5]))); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(shape(h.state()).fill, original.fill); - h.state_mut().run("edit.redo", json!({})).unwrap(); - assert_eq!(shape(h.state()).fill, first.fill); +} + +#[test] +fn selected_shape_gradient_and_full_picker_edit_the_layer() { + let mut app = app("rgb", 8); + app.run("shape.create", json!({"rect": [20,20,80,60], "fill": "#3270c0", "stroke": null})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, true); + h.get_by_label("Set shape fill type").click(); + h.run_steps(3); + h.get_by_label("Gradient").click(); + h.run_steps(2); + assert!(matches!(shape(h.state()).fill, Some(photocraft_doc::Fill::Gradient { .. })), "Gradient fills the shape"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.key_press(Key::Escape); + h.run_steps(2); + // The stroke is off: the Color Picker turns it on in the picked colour, as one edit. + h.get_by_label("Set shape stroke type").click(); + h.run_steps(3); + h.get_by_label("Color Picker").click(); + h.run_steps(3); + let d = h.state().ui.dialogs.last().expect("the Color Picker dialog"); + assert_eq!(d.fields["__label"], "Color Picker (Stroke Color)"); + let id = d.id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#00ff00")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let stroke = shape(h.state()).stroke.clone().expect("the stroke is on"); + assert_eq!((stroke.paint, stroke.width), (solid_paint([0.0, 1.0, 0.0, 1.0]), 3.0)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); + // The fill's picker from the gradient: OK on the colour it opened with still makes it solid. + h.get_by_label("Set shape fill type").click(); + h.run_steps(3); + h.get_by_label("Color Picker").click(); + h.run_steps(3); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(shape(h.state()).fill, Some(solid_paint(h.state().session.tools.foreground))); } /// #3997, #3999: after drawing a shape, the options bar's Fill swatch recolours it, as Stroke @@ -478,17 +602,11 @@ fn options_bar_fill_swatch_recolours_the_shape_just_drawn() { 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(); + crate::paint_picker::remember(&mut app, [0.2, 0.4, 0.6]); let mut h = harness(app, false); h.get_by_label("Fill color").click(); h.run_steps(4); - let rect = popup_rect(&h); - let start = rect.min + vec2(55.0, rect.height() * 0.5); - h.hover_at(start); - h.step(); - pointer(&mut h, start, true); - h.event(Event::PointerMoved(start + vec2(30.0, 6.0))); - h.step(); - pointer(&mut h, start + vec2(30.0, 6.0), false); + h.get_by_label("#336699").click(); h.run_steps(2); let edited = shape(h.state()).clone(); assert_ne!(edited.fill, original.fill, "the Fill swatch recoloured the shape"); @@ -499,6 +617,54 @@ fn options_bar_fill_swatch_recolours_the_shape_just_drawn() { assert_eq!(shape(h.state()).fill, original.fill); } +/// #8741: a shape paint swatch draws a rounded fill over its transparency checker, so the checker +/// has to be rounded too. The square one painted light cells past the rounded corners, which read +/// as specks on dark themes; the other colour swatches went to `checker_rounded` for the same +/// reason in #3363. Both callers of the shared `swatch` are pinned: the Properties panel's +/// Appearance row and the shape tools' options bar. +fn assert_swatch_checkers_are_rounded(h: &Harness<'_, PhotocraftApp>, labels: [&str; 2]) { + let panel = h.ctx.global_style().visuals.panel_fill; + // the fill's own corners: a checker rounded less would still show past them + let radius = egui::CornerRadius::from(crate::theme::Tokens::get(&h.ctx).radius_sm); + for label in labels { + let swatch = h.get_by_label(label).rect(); + let (mut square, mut rounded) = (Vec::new(), 0); + for c in &h.output().shapes { + // the harness's own panel background behind the widget is not the swatch's checker + if let egui::Shape::Rect(r) = &c.shape { + if !swatch.contains_rect(r.rect) { + continue; + } + if r.corner_radius == egui::CornerRadius::ZERO && r.fill != panel { + square.push(r.rect); + } else if r.corner_radius == radius && r.brush.is_some() { + // `checker_rounded`: a textured rect rounded like the fill + rounded += 1; + } + } + } + assert!(square.is_empty(), "{label}: square shapes inside the rounded swatch {swatch:?}: {square:?}"); + assert_eq!(rounded, 1, "{label}: exactly one checker rounded like the fill inside the swatch {swatch:?}"); + } +} + +#[test] +fn shape_paint_swatch_checker_keeps_to_the_rounded_corners() { + let shape = |a: &mut PhotocraftApp| { + a.run("shape.create", json!({"rect": [20,20,80,60], "fill": "#80808080", "stroke": {"width": 4, "color": "#ee335580"}})).unwrap(); + }; + // Properties panel (Appearance row) + let mut a = app("rgb", 8); + shape(&mut a); + let h = harness(a, true); + assert_swatch_checkers_are_rounded(&h, ["Set shape fill type", "Set shape stroke type"]); + // Options bar + let mut a = app("rgb", 8); + shape(&mut a); + let h = harness(a, false); + assert_swatch_checkers_are_rounded(&h, ["Fill color", "Stroke color"]); +} + fn test_gradient() -> photocraft_doc::Fill { photocraft_doc::Fill::Gradient { stops: vec![(0.0, photocraft_doc::Color::rgba(0.123456, 0.5, 0.75, 1.0)), (1.0, photocraft_doc::Color::rgba(0.9, 0.2, 0.1, 0.6))], @@ -571,9 +737,9 @@ fn stroke_swatch_gradient_tab_sets_the_next_shapes_stroke() { let (stops, style, _) = stroke_gradient(&h); assert_eq!((stops, style), (blue_stops, photocraft_doc::GradientStyle::Radial), "the style keeps the colours"); assert_eq!(h.state().session.active().unwrap().history.past_len(), 0, "tool defaults do not edit the document"); - h.get_by_label("Solid").click(); + h.get_by_label("Solid Color").click(); h.run_steps(2); - assert!(h.state().ui.tool_options.shape_stroke_gradient.is_none(), "Solid goes back to the colour"); + assert!(h.state().ui.tool_options.shape_stroke_gradient.is_none(), "Solid Color goes back to the colour"); } /// Properties › Stroke › Gradient gives the selected shape a gradient stroke in one undoable @@ -824,3 +990,54 @@ fn arrow_down_at_zero_width_keeps_a_strokeless_shape_strokeless() { assert_eq!(shape(h.state()).stroke.as_ref().map(|s| s.width), Some(1.0), "Properties={properties}"); } } + +/// A swatch the selected shape refuses (a job holds its document) doesn't join the Recently Used +/// Colors, in the Properties panel or the options bar; once the shape takes it, it does. +#[test] +fn a_refused_popover_swatch_is_not_a_recently_used_colour() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + for (properties, popover) in [(true, "Set shape fill type"), (false, "Fill color")] { + let mut app = app("rgb", 8); + app.run("shape.create", json!({"rect": [20,20,80,60], "fill": "#336699"})).unwrap(); + let original = shape(&app).clone(); + let gate = Arc::new(AtomicBool::new(false)); + let open = gate.clone(); + let started = app + .session + .start_job( + "test.hold", + json!({}), + "Hold", + true, + move |ctx| { + while !open.load(Ordering::Relaxed) { + ctx.check()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Ok(()) + }, + |_, ()| Ok(json!({})), + ) + .unwrap(); + let photocraft_engine::jobs::Started::Job(job) = started else { panic!("the job ran inline") }; + let mut h = harness(app, properties); + h.get_by_label(popover).click(); + h.run_steps(3); + click_swatch(&mut h, "RGB", "RGB Green"); + assert_eq!(shape(h.state()).fill, original.fill, "properties={properties}: the shape refused it"); + assert!(crate::paint_picker::recent_colors(&h.state().session).is_empty(), "properties={properties}: a refused colour isn't recent"); + h.key_press(Key::Escape); + h.run_steps(2); + gate.store(true, Ordering::Relaxed); + h.state_mut().session.cancel_job(job); + h.state_mut().session.join_cancelled_jobs(); + h.run_steps(2); + h.get_by_label(popover).click(); + h.run_steps(3); + click_swatch(&mut h, "RGB", "RGB Green"); + assert_eq!(shape(h.state()).fill, Some(solid_paint([0.0, 1.0, 0.0, 1.0])), "properties={properties}"); + let recent: Vec = crate::paint_picker::recent_colors(&h.state().session).into_iter().map(crate::color_picker_ui::hex).collect(); + assert_eq!(recent, ["#00ff00"], "properties={properties}"); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui/path_ops.rs b/crates/ui-egui/src/tools/vector_ui/path_ops.rs index a0f3ca4e6d..e6c3a1a6cc 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_ops.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_ops.rs @@ -1,3 +1,4 @@ +use super::overlay::selected_saved_path_index; use super::paths_panel::{PathEntry, PathRow, path_rows}; use super::pen::pen_commit; use super::{Affine, Document, LayerContent, Path, PhotocraftApp, Tool, Value, json}; @@ -59,9 +60,9 @@ pub fn with_active_path(app: &PhotocraftApp, id: &str, params: Value) -> Value { pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result { if app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2) { // The finished path becomes the work path, or a shape layer's path or a vector mask. - let on_layer = app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some(); - pen_commit(app, false); - app.ui.selected_path = Some(if on_layer { "layer" } else { "work" }.into()); + if !pen_commit(app, false) { + return Err(app.ui.status.clone()); + } } let name = active_path_name(app) .ok_or_else(|| tl!("No path to make a selection from: draw one with the Pen tool or select one in the Paths panel.").to_string())?; @@ -115,16 +116,35 @@ pub(super) enum PathTarget { Shape(u64), /// The active layer's vector mask, when its Layers thumbnail is targeted (#196). VectorMask(u64), + /// The saved path selected in the Paths panel, by name (#8478). + Saved(String), Work, } +/// The work or saved path explicitly selected in the Paths panel, by name, and its geometry. Only +/// such a selection overrides the active layer's path. +pub(super) fn selected_document_geometry(app: &PhotocraftApp) -> Option<(String, &Path)> { + let st = app.session.active()?; + match app.ui.selected_path.as_deref()? { + "work" => st.doc.work_path.as_ref().map(|p| ("work".into(), p)), + "layer" => None, + name => st.doc.paths.iter().find(|p| p.name == name).map(|p| (p.name.clone(), &p.path)), + } +} + /// The targeted vector mask of the active layer, if the Layers panel targets it. pub(crate) fn targeted_vector_mask(app: &PhotocraftApp) -> Option<(u64, Path)> { + let id = targeted_vector_mask_id(app)?; + let mask = app.session.active()?.doc.layer(photocraft_doc::LayerId(id))?.vector_mask.as_ref()?; + Some((id, mask.path.clone())) +} + +pub(super) fn targeted_vector_mask_id(app: &PhotocraftApp) -> Option { let st = app.session.active()?; let l = st.active_layer.and_then(|id| st.doc.layer(id))?; let targeted = app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask; (targeted && !matches!(l.content, LayerContent::Shape(_))).then_some(())?; - l.vector_mask.as_ref().map(|m| (l.id.0, m.path.clone())) + l.vector_mask.as_ref().map(|_| l.id.0) } /// The active type layer's path in document space, if it is type on a path (#9114). @@ -160,15 +180,23 @@ pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool { && matches!(target_path(app), Some((PathTarget::Work, _))) } -/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else -/// the work path. +/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, the +/// saved path selected in the Paths panel (#8478), else the work path. pub(super) fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> { + if let Some((name, p)) = selected_document_geometry(app) { + return Some((if name == "work" { PathTarget::Work } else { PathTarget::Saved(name) }, p.clone())); + } if let Some((id, p)) = targeted_vector_mask(app) { return Some((PathTarget::VectorMask(id), p)); } if let Some((id, p)) = active_shape_path(app) { return Some((PathTarget::Shape(id), p)); } + if let Some(name) = app.ui.selected_path.as_deref() + && let Some((_, p)) = selected_saved_path_index(app) + { + return Some((PathTarget::Saved(name.to_string()), p)); + } if !work_path_shown(app) { return None; } @@ -185,6 +213,7 @@ pub(crate) fn free_transform_path(app: &PhotocraftApp) -> Option<(Value, Path)> path.control_bounds()?; let params = match target { PathTarget::Shape(id) | PathTarget::VectorMask(id) => json!({"name": "layer", "layer": id}), + PathTarget::Saved(name) => json!({"name": name}), PathTarget::Work => json!({"name": "work"}), }; Some((params, path)) @@ -282,6 +311,14 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 let _ = app.run("layer.select", json!({"layer": id.0})); } } + // Selected components move on their own (`path_components`); with none, the whole path. + if let Some(r) = crate::path_components::move_selected(app, [dx.round(), dy.round()]) { + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } let Some((target, path)) = target_path(app) else { return }; let _ = match target { PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx.round(), dy.round()]})), @@ -293,5 +330,9 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64 let moved = path.transform(&Affine::translate(dx.round(), dy.round())); app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)})) } + PathTarget::Saved(name) => { + let moved = path.transform(&Affine::translate(dx.round(), dy.round())); + app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)})) + } }; } diff --git a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs index 8cab12747a..d49eeaae49 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs @@ -67,7 +67,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { let mut h = harness(); h.state_mut().ui.tool = Tool::Pen; let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; - h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); h.run_steps(2); press(&mut h, Key::Enter, command()); let app = h.state(); @@ -80,7 +81,8 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { // Plain ↩ still only finishes a path. let mut h = harness(); h.state_mut().ui.tool = Tool::Pen; - h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); h.run_steps(2); press(&mut h, Key::Enter, Modifiers::NONE); assert!(h.state().session.active().unwrap().doc.work_path.is_some()); @@ -94,7 +96,8 @@ fn selecting_a_saved_path_keeps_the_hidden_work_path_hidden() { h.state_mut().run("path.set", json!({"name": "Saved", "path": rect_path(100.0, 40.0, 60.0, 50.0)})).unwrap(); h.state_mut().ui.tool = Tool::Pen; let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; - h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); h.run_steps(2); press(&mut h, Key::Enter, command()); assert!(!crate::vector_ui::work_path_shown(h.state()), "⌘↩ hid the work path"); @@ -123,7 +126,8 @@ fn reselecting_the_hidden_work_path_ends_the_hide() { let mut h = harness(); h.state_mut().ui.tool = Tool::Pen; let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; - h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); h.run_steps(2); press(&mut h, Key::Enter, command()); assert!(!crate::vector_ui::work_path_shown(h.state()), "⌘↩ hid the work path"); @@ -495,3 +499,41 @@ fn a_hidden_work_path_stays_hidden_when_another_tile_is_clicked() { assert!(right_outlines_on_switch(false) > 0, "a shown Work Path is drawn in that frame"); assert_eq!(right_outlines_on_switch(true), 0, "the hidden one isn't"); } + +/// ⌘⇧↩ / ⌘⌥↩ (#8015) take the same path as ⌘↩: a path being drawn with the Pen is finished first +/// and combined, not the old work path. +#[test] +fn cmd_shift_enter_while_drawing_finishes_the_path_and_adds_it() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(0.0, 0.0, 10.0, 10.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 150, "y": 60, "width": 20, "height": 20})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + // The work path isn't selected in the Paths panel, so the Pen starts a new work path. + h.state_mut().ui.selected_path = None; + let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; + let owner = crate::vector_ui::capture_pen_owner(h.state()); + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], owner, ..Default::default() }); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers { shift: true, ..command() }); + assert!(h.state().ui.pen.is_none(), "the pen path was finished: {}", h.state().ui.status); + // The new triangle (20..120 × 20..80) joins the selected square; the old work path is not used. + assert_eq!(selection(&h), Some([20, 20, 150, 60])); + assert_eq!(h.state().ui.selected_path, None, "loaded path deselected, as with ⌘↩"); +} + +#[test] +fn cmd_shift_and_alt_enter_use_the_path_selected_in_the_paths_panel() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(0.0, 0.0, 30.0, 30.0)})).unwrap(); + h.state_mut().run("path.set", json!({"name": "Path 1", "path": rect_path(100.0, 40.0, 60.0, 50.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + h.state_mut().ui.tool = Tool::Brush; + h.state_mut().ui.selected_path = Some("Path 1".into()); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers { shift: true, ..command() }); + assert_eq!(selection(&h), Some([40, 40, 120, 50]), "{}", h.state().ui.status); + // ⌘⌥↩ with the saved path selected again takes it back out, leaving the square. + h.state_mut().ui.selected_path = Some("Path 1".into()); + press(&mut h, Key::Enter, Modifiers { alt: true, ..command() }); + assert_eq!(selection(&h), Some([40, 40, 20, 20]), "{}", h.state().ui.status); +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index 56b6d79449..baa536f3b8 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -1,7 +1,8 @@ use super::curvature::{curvature_hit, curvature_refresh}; -use super::path_ops::{targeted_vector_mask, work_path_shown}; +use super::options::selected_shape; +use super::path_ops::{active_shape_path, targeted_vector_mask, work_path_shown}; use super::shapes::{fill_param, stroke_param}; -use super::{Document, Path, PenPath, PhotocraftApp, Resumed, Tool, Value, json}; +use super::{Document, LayerContent, Path, PenPath, PhotocraftApp, Resumed, Tokens, Tool, ToolOptions, Value, json, vec2}; // --------------------------------------------------------------------------------------------- // Pen @@ -51,8 +52,15 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { { app.ui.pen = Some(resumed); } + let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(PenPath::default); + if pen.owner.is_none() { + pen.owner = Some(owner); + } + pen.commit_failed = false; pen.adjusting_in = false; + pen.last_pointer = Some([x, y]); + pen.reposition = false; if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) { @@ -89,11 +97,22 @@ pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { /// Pen drag: pull out the handles of the anchor just placed. ⇧ (read live, as it is held) /// turns the handle to the nearest 45° direction from its anchor. pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { + cancel_stale_pen(app); if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { let (adjusting_last, adjusting_in) = (pen.adjusting_last, pen.adjusting_in); if let Some(k) = pen.knots.last_mut() { + if pen.reposition { + if let Some([px, py]) = pen.last_pointer { + for point in k { + point[0] += x - px; + point[1] += y - py; + } + } + pen.last_pointer = Some([x, y]); + return; + } let a = k[0]; let [x, y] = shift_45(a, [x, y], shift); if adjusting_in { @@ -105,6 +124,7 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y]; } } + pen.last_pointer = Some([x, y]); } } @@ -118,6 +138,9 @@ pub(super) 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_pen(app) { + return false; + } if !cancel_stale(app) { return false; } @@ -155,10 +178,13 @@ pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { } pub fn pen_up(app: &mut PhotocraftApp) { + cancel_stale_pen(app); let jitter = 2.0 / f64::from(app.point_zoom().max(0.01)); let Some(pen) = app.ui.pen.as_mut() else { return }; pen.dragging = false; pen.adjusting_last = false; + pen.reposition = false; + pen.last_pointer = None; pen.adjusting_in = false; if closing(pen) { // A click on the first anchor closes the path and leaves that anchor's handles alone. @@ -175,6 +201,7 @@ pub fn pen_up(app: &mut PhotocraftApp) { /// ⌥/Alt during a Pen drag breaks the pair of handles being pulled out: from then on only the /// outgoing handle follows the pointer and the incoming curve stays as it is (#3306). pub fn pen_break_handle(app: &mut PhotocraftApp) { + cancel_stale_pen(app); let Some(pen) = app.ui.pen.as_mut().filter(|p| p.dragging && !p.adjusting_last) else { return }; let Some(i) = pen.knots.len().checked_sub(1) else { return }; if !pen.unlinked.contains(&i) { @@ -186,6 +213,7 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) { /// ⌥/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 { + cancel_stale_pen(app); 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 }; @@ -207,6 +235,8 @@ pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { } pen.dragging = true; pen.adjusting_last = true; + pen.reposition = false; + pen.last_pointer = Some([x, y]); pen.adjusting_in = incoming; true } @@ -281,68 +311,109 @@ 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 + cancel_stale_pen(app); + !app.ui.pen.as_ref().is_some_and(|p| p.commit_failed && p.owner.as_ref().is_some_and(|o| !o.valid(app))) } -/// 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) { +pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) -> bool { + try_pen_commit(app, closed).is_ok() +} + +/// Finish the draft as [`pen_commit`] does. On failure the draft stays as it was, marked +/// `commit_failed`, and the error is shown and returned. +pub(super) fn try_pen_commit(app: &mut PhotocraftApp, closed: bool) -> Result<(), String> { + // This is an explicit commit request; passive frame reconciliation never clears this flag. + if let Some(pen) = app.ui.pen.as_mut() { + pen.commit_failed = false; + } + if cancel_stale_pen(app) { + return if app.ui.pen.is_none() { Ok(()) } else { Err(app.ui.status.clone()) }; + } + let Some(mut pen) = app.ui.pen.take() else { return Ok(()) }; + 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(); + } + let mut original = pen.clone(); + original.dragging = false; + original.adjusting_last = false; + if pen.curvature { + curvature_refresh(&mut pen, closed); + } + if let Err(e) = commit_path(app, &pen, closed) { + app.ui.pen = Some(PenPath { commit_failed: true, ..original }); + app.ui.status = e.clone(); + app.ui.status_error = true; + return Err(e); + } + app.ui.pen_doc = None; + Ok(()) +} + +/// Another tool finishes the Pen's draft, once: a draft whose commit failed waits for the user. +pub(crate) fn finish_pen_if_left(app: &mut PhotocraftApp) { + cancel_stale_pen(app); + let shape_fallback = + app.ui.pen.as_ref().is_some_and(|p| { + !p.drawn_with(app.ui.tool) && p.owner.as_ref().is_some_and(|o| o.valid(app) && matches!(o.target, super::PenTarget::Shape { .. })) + }); + if shape_fallback { + pen_end(app); + } else if app.ui.pen.as_ref().is_some_and(|p| !p.commit_failed && !p.drawn_with(app.ui.tool)) { + pen_commit(app, false); + } +} + +/// End the Pen path for good (Esc, or switching to another tool): commit it, and when the +/// document refuses that (a locked shape layer or vector mask to add to…) keep it as the Work Path +/// instead, so what was drawn is neither lost nor retried on every frame. A draft even the Work +/// Path refuses is dropped; the status bar says which happened. +pub fn pen_end(app: &mut PhotocraftApp) { + if pen_commit(app, false) { + return; + } + let active = app.session.active().map(|st| st.doc.id); + if app.ui.pen_doc.is_some_and(|d| Some(d) != active) { + // Drawn on another document that refused it (`cancel_stale`): left as it was. 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(); } if pen.curvature { - curvature_refresh(&mut pen, closed); + curvature_refresh(&mut pen, false); } - commit_path(app, &pen, closed); + let refused = std::mem::take(&mut app.ui.status); + let kept = app.run("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)})).is_ok(); + if kept { + app.ui.selected_path = Some("work".into()); + } + let outcome = if kept { tl!("The path was kept as the Work Path") } else { tl!("The path was discarded") }; + app.ui.status = format!("{refused} ({outcome})"); + app.ui.status_error = true; } -/// Commit a finished Pen, Freeform Pen or Curvature Pen path, as one History step: a new shape -/// layer (Shape mode), else a subpath of the targeted vector mask, else the work path. -fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { - if pen.knots.len() < 2 { - return; +/// Commit a finished Pen, Freeform Pen or Curvature Pen path, as one History step: in Shape mode a +/// new shape layer, or with a Path Operation other than New Layer a subpath of the selected shape +/// layer; in Path mode a subpath of the targeted vector mask, else of the selected path or a new +/// work path. An added subpath takes the options bar's Path Operation (#1397). `false` when the +/// document refused it, with the reason in the status bar. +fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> bool { + if pen.knots.is_empty() || (pen.knots.len() < 2 && app.ui.tool_options.vector_mode == "shape") { + return true; } let path = pen_to_json(pen, closed); - let r = if app.ui.tool_options.vector_mode == "shape" { + let op = pen_path_op(&app.ui.tool_options); + let into_shape = op.and_then(|_| selected_shape(&app.session)).map(|id| id.0); + let r = if let Some(r) = commit_resumed_on_layer(app, pen, &path, closed) { + r + } else if let (Some(op), Some(layer), "shape") = (op, into_shape, app.ui.tool_options.vector_mode.as_str()) { + // The selected shape layer keeps its fill and stroke; the new component cuts, intersects… + app.run("shape.create", json!({"kind": "path", "path": path, "addTo": layer, "op": op})) + } else if app.ui.tool_options.vector_mode == "shape" { let fill = if closed { fill_param(app) } else { Value::Null }; let stroke = if closed { stroke_param(app) @@ -362,7 +433,10 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. let mut p = photocraft_engine::vector_cmds::path_json(&existing); if let (Some(subs), Some(new)) = (p.get_mut("subpaths").and_then(Value::as_array_mut), path.get("subpaths").and_then(Value::as_array)) { - subs.extend(new.iter().cloned()); + subs.extend(new.iter().cloned().map(|mut sp| { + sp["op"] = json!(op.unwrap_or("combine")); + sp + })); } app.run("layer.vectorMask.edit", json!({"layer": id, "path": p})) } else { @@ -372,19 +446,22 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // 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 resumed_from = + pen.resumes.as_ref().filter(|r| r.layer.is_none() && 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() { - params["op"] = json!("combine"); + params["op"] = json!(op.unwrap_or("combine")); } app.run("path.set", params).inspect(|_| app.ui.selected_path = Some(name)) }; if let Err(e) = r { app.ui.status = e; app.ui.status_error = true; + return false; } + true } /// Does `doc` have the path the Paths panel calls `name`: "work", or a saved path's name? @@ -397,23 +474,30 @@ 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. +/// The open subpath that has its first or last anchor at `(x, y)`, as a Pen path to carry on +/// drawing from there (#6593): of a targeted vector mask, else in Shape mode of the selected shape +/// layer, else of the path a new Pen path would join (the one selected in the Paths panel, else +/// the work path). Its knots run towards that anchor: reversed, handles swapped, when it is the +/// first. 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()); - // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). - if name == "work" && !work_path_shown(app) { - return None; - } + let (name, layer, path) = if let Some((id, p)) = targeted_vector_mask(app) { + ("layer".to_string(), Some(id), p) + } else if app.ui.tool_options.vector_mode == "shape" { + let (id, p) = active_shape_path(app)?; + ("layer".to_string(), Some(id), p) + } else { + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). + if name == "work" && !work_path_shown(app) { + return None; + } + let p = doc_path(&st.doc, &name)?.clone(); + (name, None, p) + }; 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)| { + path.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) { @@ -429,7 +513,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { } } let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone() }; + let resumes = Resumed { doc, path: name.clone(), layer, index: i, was: s.clone() }; Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) }) } @@ -438,7 +522,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { /// 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)?; + let path = resumed_path(doc, r)?; if path.subpaths.get(r.index) != Some(&r.was) { return None; } @@ -451,13 +535,169 @@ fn with_resumed_subpath(doc: &Document, r: &Resumed, drawn: &Value) -> Option(doc: &'a Document, r: &Resumed) -> Option<&'a Path> { + if doc.id.0 != r.doc { + return None; + } + let Some(id) = r.layer else { return doc_path(doc, &r.path) }; + let l = doc.layer(photocraft_doc::LayerId(id))?; + match &l.content { + LayerContent::Shape(sh) => Some(&sh.path), + _ => l.vector_mask.as_ref().map(|m| &m.path), + } +} + +/// Commit a resumed Pen path (#6593) into a shape layer or vector mask: the carried-on subpath +/// takes its old place, one step. Closing the open path a shape layer was made of gives it the +/// options bar's fill and stroke, as drawing it closed would have. `None` when the draft resumes +/// a document path, or its subpath changed meanwhile. +fn commit_resumed_on_layer(app: &mut PhotocraftApp, pen: &PenPath, path: &Value, closed: bool) -> Option> { + let r = pen.resumes.as_ref()?; + let id = r.layer?; + let st = app.session.active()?; + let p = with_resumed_subpath(&st.doc, r, path)?; + let layer = st.doc.layer(photocraft_doc::LayerId(id))?; + let LayerContent::Shape(sh) = &layer.content else { + return Some(app.run("layer.vectorMask.edit", json!({"layer": id, "path": p}))); + }; + let mut params = json!({"layer": id, "path": p}); + if closed && sh.fill.is_none() && sh.path.subpaths.iter().all(|s| !s.closed) { + params["fill"] = fill_param(app); + params["stroke"] = stroke_param(app); + } + Some(app.run("shape.edit", params)) +} + +/// The Pen's Path Operations (#1397), in Photoshop's menu order: the `pen_path_op` value, its +/// label and its icon. +pub(crate) const PATH_OPS: [(&str, &str, &str); 5] = [ + ("newLayer", "New Layer", "path-op-new-layer"), + ("combine", "Combine Shapes", "path-op-combine"), + ("subtract", "Subtract Front Shape", "path-op-subtract"), + ("intersect", "Intersect Shape Areas", "path-op-intersect"), + ("exclude", "Exclude Overlapping Shapes", "path-op-exclude"), +]; + +/// The operation (`path.set` / `shape.create` `op`) a new Pen path is added with; None for a new +/// shape layer (New Layer, in Shape mode). Path mode has no New Layer: there it combines. +pub(crate) fn pen_path_op(o: &ToolOptions) -> Option<&'static str> { + let op = PATH_OPS.iter().map(|(v, ..)| *v).find(|v| *v == o.pen_path_op).unwrap_or("newLayer"); + match op { + "newLayer" if o.vector_mode == "shape" => None, + "newLayer" => Some("combine"), + op => Some(op), + } +} + +/// What the Path Operations menu's Merge Shape Components merges, as a command, its params and +/// the number of shape components there: with Path Selection the path it selects in; in Shape +/// mode the selected shape layer; in Path mode the path the Pen draws into (a targeted vector +/// mask, the selected path, else the Work Path). +fn merge_target(app: &PhotocraftApp) -> Option<(&'static str, Value, usize)> { + if app.ui.tool == Tool::PathSelection { + let (r, p) = crate::direct_select::active_paths(app).into_iter().next()?; + return Some(("path.mergeShapeComponents", r.params(app), p.components().len())); + } + let st = app.session.active()?; + if app.ui.tool_options.vector_mode == "shape" { + let id = st.active_layer?; + let LayerContent::Shape(sh) = &st.doc.layer(id)?.content else { return None }; + return Some(("layer.combineShapes.mergeShapeComponents", json!({"layer": id.0}), sh.path.components().len())); + } + if let Some((id, p)) = targeted_vector_mask(app) { + return Some(("path.mergeShapeComponents", json!({"name": "layer", "layer": id}), p.components().len())); + } + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let path = if name == "work" { st.doc.work_path.as_ref() } else { st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) }?; + Some(("path.mergeShapeComponents", json!({"name": name}), path.components().len())) +} + +/// The Path Operations button of the Pen, Freeform Pen, Curvature Pen and Path Selection (#1397), +/// Photoshop's: the operation of the selected path components, or with none selected how the next +/// path combines with the shape or path it is drawn into; and Merge Shape Components. Its icon +/// shows the selected components' operation, else the tool's. +pub(super) fn path_operations_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // New Layer makes a shape layer: Path mode and Path Selection have none. + let shape = app.ui.tool_options.vector_mode == "shape" && app.ui.tool != Tool::PathSelection; + let current = crate::path_components::selected_op(app) + .or_else(|| pen_path_op(&app.ui.tool_options)) + .map_or("newLayer", |op| if op == "newLayer" && !shape { "combine" } else { op }); + let icon = PATH_OPS.iter().find(|(v, ..)| *v == current).map_or("path-op-combine", |(.., i)| *i); + let resp = crate::icons::button_with_icon_size(ui, icon, 24.0, 16.0, false, tl!("Path operations")); + let resp = crate::brush_picker::named(resp, "Path operations"); + let merge = merge_target(app).filter(|(.., n)| *n > 1); + let mut run = None; + let mut pick = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(240.0); + for (value, label, glyph) in PATH_OPS { + if ui.add_enabled_ui(shape || value != "newLayer", |ui| path_op_button(ui, glyph, tl!(label), value == current)).inner.clicked() { + pick = Some(value); + ui.close(); + } + } + ui.separator(); + if ui.add_enabled_ui(merge.is_some(), |ui| path_op_button(ui, "path-op-merge", tl!("Merge Shape Components"), false)).inner.clicked() { + run = merge.clone(); + ui.close(); + } + }); + if let Some(op) = pick + && let Err(e) = crate::path_components::set_op(app, op) + { + app.ui.status = e; + app.ui.status_error = true; + } + if let Some((id, params, _)) = run + && let Err(e) = app.run(id, params) + { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// A Path Operations menu row: its icon and label, highlighted when it is the current operation. +fn path_op_button(ui: &mut egui::Ui, glyph: &str, label: &str, checked: bool) -> egui::Response { + let icon = egui::IdSalt::new((glyph, "icon")); + let b = egui::Button::new((egui::Atom::custom(icon, vec2(16.0, 16.0)), label, egui::Atom::grow())).selected(checked).atom_ui(ui); + if let Some(r) = b.rect(icon) { + crate::icons::paint(ui, r, glyph, 16.0, Tokens::get(ui.ctx()).icon); + } + b.response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), checked, label)); + b.response +} + // --------------------------------------------------------------------------------------------- // Freeform Pen and Curvature Pen +/// Refuse a Freeform stroke before drawing when its selected shape cannot be edited. +pub(crate) fn freeform_locked_shape(app: &PhotocraftApp) -> bool { + if app.ui.tool_options.vector_mode != "shape" || pen_path_op(&app.ui.tool_options).is_none() { + return false; + } + let Some(id) = selected_shape(&app.session) else { return false }; + app.session.active().is_some_and(|st| { + let locks = st.doc.effective_locks(id); + locks.all || locks.pixels + }) +} + /// Freeform Pen release: fit the drag's trace within the options bar's Curve Fit and commit it /// as the Pen does. Releasing on the start point closes the path; a click draws nothing. The /// points at indices `straight` were ⌥-clicked: the segment into each one stays straight. pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize]) { + let Some(owner) = capture_pen_owner(app) else { return }; + freeform_finish_owned(app, points, straight, owner); +} + +/// [`freeform_finish`] to the destination captured when the trace began. A trace of another +/// document commits nothing; a failed commit keeps the fitted path as a draft. +pub(crate) fn freeform_finish_owned(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize], owner: PenOwner) { + if !owner.valid(app) { + return; + } let trace: Vec<[f64; 2]> = points.iter().map(|p| [p[0], p[1]]).collect(); let (Some(first), Some(last)) = (trace.first(), trace.last()) else { return }; let tol = 6.0 / app.point_zoom().max(0.01) as f64; @@ -472,7 +712,13 @@ pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: & } else { fit_with_straight_segments(&trace, straight, fit, closed.then_some(tol)) }; - commit_path(app, &pen.0, pen.1); + let (mut pen, closed) = pen; + pen.owner = Some(owner); + if let Err(e) = commit_path(app, &pen, closed) { + app.ui.pen = Some(PenPath { commit_failed: true, ..pen }); + app.ui.status = e; + app.ui.status_error = true; + } } fn knot_points(k: &photocraft_doc::Knot) -> [[f64; 2]; 3] { @@ -530,3 +776,40 @@ fn fit_with_straight_segments(trace: &[[f64; 2]], straight: &[usize], fit: f64, corners.dedup(); (PenPath { knots, unlinked: corners, ..Default::default() }, closed) } + +use super::{PenOwner, cancel_stale_pen, capture_pen_owner}; + +fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(), String> { + let Some(owner) = pen.owner.as_ref().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) }; + let options = std::mem::replace(&mut app.ui.tool_options, owner.options.clone()); + let vector_mask = std::mem::replace(&mut app.ui.vector_mask_target, owner.vector_mask); + app.ui.selected_path = match &owner.target { + super::PenTarget::Path { name, append } => append.then(|| name.clone()), + _ => None, + }; + if let Some(st) = app.session.active_mut() { + st.active_layer = owner.layer; + } + let foreground = std::mem::replace(&mut app.session.tools.foreground, owner.foreground); + let background = std::mem::replace(&mut app.session.tools.background, owner.background); + let channel = app.session.active().map(|st| st.channel_view.target); + if let Some(st) = app.session.active_mut() { + st.channel_view.target = if matches!(owner.target, super::PenTarget::VectorMask(_)) { + photocraft_engine::channel_cmds::ChannelTarget::VectorMask + } else { + photocraft_engine::channel_cmds::ChannelTarget::Composite + }; + } + let result = commit_path_current(app, pen, closed); + if result && matches!(owner.target, super::PenTarget::Shape { .. } | super::PenTarget::VectorMask(_)) { + app.ui.selected_path = Some("layer".into()); + } + if let (Some(st), Some(channel)) = (app.session.active_mut(), channel) { + st.channel_view.target = channel; + } + app.session.tools.foreground = foreground; + app.session.tools.background = background; + app.ui.tool_options = options; + app.ui.vector_mask_target = vector_mask; + if result { Ok(()) } else { Err(app.ui.status.clone()) } +} diff --git a/crates/ui-egui/src/tools/vector_ui/pen_draw_tests.rs b/crates/ui-egui/src/tools/vector_ui/pen_draw_tests.rs index e11bd4abf2..720cd7e611 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen_draw_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen_draw_tests.rs @@ -96,6 +96,34 @@ fn near(a: [f64; 2], b: [f64; 2]) -> bool { (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05 } +#[test] +fn space_repositions_a_pen_anchor_and_keeps_its_handles() { + let mut h = harness(); + h.state_mut().ui.extras.snap = false; + click(&mut h, [20.0, 20.0]); + let center = h.state().ui.views[0].center; + let p = screen(&h, [80.0, 50.0]); + h.event(egui::Event::PointerMoved(p)); + button(&mut h, p, true, Modifiers::NONE); + move_to(&mut h, [80.0, 50.0], [100.0, 60.0]); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + move_to(&mut h, [100.0, 60.0], [120.0, 90.0]); + let k = h.state().ui.pen.as_ref().unwrap().knots[1]; + assert!(near(k[0], [100.0, 80.0]) && near(k[1], [80.0, 70.0]) && near(k[2], [120.0, 90.0]), "anchor and handles moved together: {k:?}"); + assert_eq!(h.state().ui.views[0].center, center, "the canvas never pans"); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + move_to(&mut h, [120.0, 90.0], [130.0, 80.0]); + let p = screen(&h, [130.0, 80.0]); + button(&mut h, p, false, Modifiers::NONE); + let k = h.state().ui.pen.as_ref().unwrap().knots[1]; + assert!(near(k[0], [100.0, 80.0]) && near(k[1], [70.0, 80.0]) && near(k[2], [130.0, 80.0]), "releasing Space resumes handle shaping: {k:?}"); + h.key_press(Key::Enter); + h.run_steps(2); + assert!(h.state().session.active().unwrap().doc.work_path.is_some()); +} + #[test] fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { let mut h = harness(); diff --git a/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs new file mode 100644 index 0000000000..25ae41650b --- /dev/null +++ b/crates/ui-egui/src/tools/vector_ui/pen_path_ops_tests.rs @@ -0,0 +1,350 @@ +//! The Pen's Path Operations (#1397, #7533): New Layer, Combine Shapes, Subtract Front Shape, +//! Intersect Shape Areas and Exclude Overlapping Shapes decide how a new path joins the shape or +//! path it is drawn into, and Merge Shape Components bakes them, as in Photoshop's options bar. + +use egui::vec2; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::vector::PathOp; +use photocraft_doc::{LayerContent, LayerId, Path}; +use serde_json::json; + +use super::{pen_down, pen_path_op, pen_up}; +use crate::PhotocraftApp; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = Tool::Pen; + app +} + +/// Clicks a closed square with the Pen, from `(x, y)` with side `side`. +fn square(app: &mut PhotocraftApp, x: f64, y: f64, side: f64) { + for (dx, dy) in [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side)] { + pen_down(app, x + dx, y + dy, false); + pen_up(app); + } + pen_down(app, x + 0.5, y + 0.5, false); // close + pen_up(app); +} + +fn shape_path(app: &PhotocraftApp) -> (LayerId, Path) { + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + match &st.doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => (id, sh.path.clone()), + _ => panic!("not a shape layer"), + } +} + +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().expect("a work path") +} + +fn layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layers.len() +} + +fn covered(path: &Path, x: i32, y: i32) -> f32 { + photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0] +} + +/// The issue's mug: the body is drawn, then the hole in its handle is cut out of the same shape +/// layer with Subtract Front Shape, one History step each. +#[test] +fn subtract_front_shape_cuts_a_hole_in_the_selected_shape_layer() { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + assert_eq!(app.ui.tool_options.pen_path_op, "newLayer", "Photoshop's default in Shape mode"); + square(&mut app, 20.0, 20.0, 100.0); + let (id, _) = shape_path(&app); + let count = layers(&app); + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 50.0, 50.0, 40.0); + let (same, path) = shape_path(&app); + assert_eq!((same, layers(&app)), (id, count), "no new layer"); + assert_eq!(path.subpaths.iter().map(|s| s.op).collect::>(), [PathOp::Combine, PathOp::Subtract]); + assert_eq!((covered(&path, 70, 70), covered(&path, 30, 30)), (0.0, 1.0), "a hole in the body"); + app.session.undo(); + assert_eq!(shape_path(&app).1.subpaths.len(), 1, "undo takes back only the hole"); + app.session.redo(); + // Intersect and Exclude are stored the same way. + for (op, want) in [("intersect", PathOp::Intersect), ("exclude", PathOp::Exclude), ("combine", PathOp::Combine)] { + app.ui.tool_options.pen_path_op = op.into(); + square(&mut app, 10.0, 10.0, 30.0); + assert_eq!(shape_path(&app).1.subpaths.last().map(|s| s.op), Some(want), "{op}"); + } + assert_eq!(layers(&app), count); + // New Layer makes another shape layer again. + app.ui.tool_options.pen_path_op = "newLayer".into(); + square(&mut app, 10.0, 10.0, 30.0); + assert_eq!(layers(&app), count + 1); + assert_ne!(shape_path(&app).0, id); +} + +/// A locked shape layer refuses the new component: the path drawn stays as the Pen's draft, and +/// the document and History are unchanged. Unlocking advances the source revision, so the old +/// captured draft refuses; a fresh draft on the unlocked source cuts the hole. +#[test] +fn a_locked_shape_layer_keeps_the_drawn_path() { + for lock in ["all", "pixels"] { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + square(&mut app, 20.0, 20.0, 100.0); + let (id, body) = shape_path(&app); + app.run("layer.lockLayers", json!({lock: true})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 50.0, 50.0, 40.0); + let draft = app.ui.pen.clone().unwrap_or_else(|| panic!("{lock}: the draft is kept")); + // The four corners and the close's stand-in on the first: still a closed path. + assert!(draft.knots.len() == 5 && super::closing(&draft), "{lock}: {:?}", draft.knots); + assert!(app.ui.status_error, "{lock}: {}", app.ui.status); + assert_eq!(shape_path(&app), (id, body.clone()), "{lock}: the shape is unchanged"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "{lock}: no History step"); + // Undo takes back the lock, not the drawn path. + app.session.undo(); + assert!(app.ui.pen.is_some(), "{lock}"); + assert!(super::pen_commit(&mut app, false), "{lock}: {}", app.ui.status); // ↩ + assert!(app.ui.pen.is_none(), "{lock}"); + assert_eq!(shape_path(&app), (id, body.clone()), "a stale draft cannot overwrite the unlocked source"); + assert!(app.ui.status_error && app.ui.status.contains("newer work was preserved")); + square(&mut app, 50.0, 50.0, 40.0); + let (same, path) = shape_path(&app); + assert_eq!(same, id, "{lock}"); + assert_eq!(path.subpaths.iter().map(|s| (s.op, s.closed)).collect::>(), [(PathOp::Combine, true), (PathOp::Subtract, true)], "{lock}"); + assert_eq!(covered(&path, 70, 70), 0.0, "{lock}: the hole"); + } +} + +/// A hole drawn for a locked shape layer: the Pen's draft, refused, and the History length then. +fn refused_hole(app: &mut PhotocraftApp) -> (LayerId, Path, usize) { + app.ui.tool_options.vector_mode = "shape".into(); + square(app, 20.0, 20.0, 100.0); + let (id, body) = shape_path(app); + app.run("layer.lockLayers", json!({"all": true})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + app.ui.tool_options.pen_path_op = "subtract".into(); + square(app, 50.0, 50.0, 40.0); + assert!(app.ui.pen.is_some() && app.ui.status_error, "refused: {}", app.ui.status); + (id, body, steps) +} + +/// The refused hole ended for good: kept, closed, as the Work Path, one History step; the locked +/// shape is as it was. +fn assert_kept_as_work_path(app: &PhotocraftApp, id: LayerId, body: &Path, steps: usize) { + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none()); + assert_eq!(shape_path(app), (id, body.clone()), "the locked shape is unchanged"); + let w = work(app); + assert_eq!(w.subpaths.len(), 1); + assert_eq!(w.subpaths[0].knots.len(), 4); + assert!(w.subpaths[0].closed); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + assert_eq!(app.ui.selected_path.as_deref(), Some("work")); + assert!(app.ui.status.contains(tl!("The path was kept as the Work Path")), "{}", app.ui.status); +} + +/// Switching to another tool with a refused draft (feedback on #7648): it was committed again on +/// every frame, re-running `shape.create` and the error. Now it is ended once: kept as the Work +/// Path, and later frames do nothing more. +#[test] +fn switching_tools_ends_a_refused_draft_once() { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app() + }); + h.run_steps(2); + let (id, body, steps) = refused_hole(h.state_mut()); + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(2); + assert_kept_as_work_path(h.state(), id, &body, steps); + let status = h.state().ui.status.clone(); + h.run_steps(4); + assert_eq!(h.state().ui.status, status, "no retry"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); +} + +/// Esc with a refused draft ends it the same way; ↩ keeps it as the draft, to commit again once +/// the layer is unlocked. +#[test] +fn esc_ends_a_refused_draft_and_return_keeps_it() { + let ctx = egui::Context::default(); + let key = |app: &mut PhotocraftApp, key: egui::Key| { + let raw = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + for lang in crate::i18n::Lang::all() { + crate::i18n::with_language(lang, || { + for message in ["The path was kept as the Work Path", "The path was discarded"] { + if lang != crate::i18n::Lang::EN { + assert!(crate::i18n::has(lang, message), "{}: {message}", lang.code()); + } + } + let mut app = app(); + let (id, body, steps) = refused_hole(&mut app); + let draft = app.ui.pen.clone(); + key(&mut app, egui::Key::Enter); + assert_eq!(app.ui.pen, draft, "↩ keeps the draft"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + let refused = app.ui.status.clone(); + key(&mut app, egui::Key::Escape); + assert_kept_as_work_path(&app, id, &body, steps); + assert_eq!(app.ui.status, format!("{refused} ({})", tl!("The path was kept as the Work Path"))); + }); + } +} + +/// With no shape layer selected, an operation other than New Layer still starts a shape layer +/// (Photoshop: there is nothing to combine with), and the next path joins it. +#[test] +fn an_operation_without_a_selected_shape_starts_a_new_shape_layer() { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.tool_options.pen_path_op = "subtract".into(); + let count = layers(&app); + square(&mut app, 20.0, 20.0, 100.0); + let (id, path) = shape_path(&app); + assert_eq!((layers(&app), path.subpaths.len()), (count + 1, 1)); + assert_eq!(covered(&path, 60, 60), 1.0, "a lone first component draws"); + square(&mut app, 50.0, 50.0, 40.0); + assert_eq!((shape_path(&app).0, layers(&app)), (id, count + 1)); +} + +/// Path mode has no New Layer (it combines); the other operations are stored on each subpath +/// added to the Work Path, a selected saved path or a targeted vector mask. +#[test] +fn path_mode_adds_subpaths_with_the_operation() { + let mut app = app(); + assert_eq!(pen_path_op(&app.ui.tool_options), Some("combine")); + square(&mut app, 20.0, 20.0, 100.0); + square(&mut app, 30.0, 30.0, 10.0); + app.ui.tool_options.pen_path_op = "intersect".into(); + square(&mut app, 50.0, 50.0, 100.0); + assert_eq!(work(&app).subpaths.iter().map(|s| s.op).collect::>(), [PathOp::Combine, PathOp::Combine, PathOp::Intersect]); + // A selected saved path. + app.run("path.set", json!({"name": "Saved", "path": {"subpaths": [{"closed": true, "knots": [[2, 2], [60, 2], [60, 60]]}]}})).unwrap(); + app.ui.selected_path = Some("Saved".into()); + app.ui.tool_options.pen_path_op = "exclude".into(); + square(&mut app, 10.0, 10.0, 20.0); + let saved = app.session.active().unwrap().doc.paths.iter().find(|p| p.name == "Saved").unwrap().path.clone(); + assert_eq!(saved.subpaths.last().map(|s| s.op), Some(PathOp::Exclude)); + // A targeted vector mask. + let id = app.session.active().unwrap().active_layer.unwrap().0; + app.run("layer.vectorMask.add", json!({"layer": id, "path": {"subpaths": [{"closed": true, "knots": [[0, 0], [100, 0], [100, 100], [0, 100]]}]}})).unwrap(); + app.ui.vector_mask_target = true; + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 20.0, 20.0, 30.0); + let vm = app.session.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap(); + assert_eq!(vm.path.subpaths.iter().map(|s| s.op).collect::>(), [PathOp::Combine, PathOp::Subtract]); +} + +/// The options bar's Path Operations button sets the operation, offers New Layer only in Shape +/// mode, and its Merge Shape Components bakes the path the Pen draws into. +#[test] +fn the_options_bar_menu_sets_the_operation_and_merges() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 100.0); + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 50.0, 50.0, 40.0); + app.ui.tool_options.pen_path_op = "combine".into(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::options_bar(app, ui, Tool::Pen)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let open = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + h.get_by_label("Path operations").click(); + h.run_steps(3); + }; + open(&mut h); + assert!(h.get_by_label("New Layer").accesskit_node().is_disabled(), "Path mode has no New Layer"); + h.get_by_label("Exclude Overlapping Shapes").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.pen_path_op, "exclude"); + assert!(h.query_by_label("Exclude Overlapping Shapes").is_none(), "choosing closes the menu"); + // Merge Shape Components: the Work Path's hole stays a hole, now as plain outlines. + let before = work(h.state()); + open(&mut h); + h.get_by_label("Merge Shape Components").click(); + h.run_steps(3); + let merged = work(h.state()); + assert_ne!(merged, before); + assert_eq!((covered(&merged, 70, 70), covered(&merged, 30, 30)), (0.0, 1.0)); + // Shape mode offers New Layer. + h.state_mut().ui.tool_options.vector_mode = "shape".into(); + h.run_steps(2); + open(&mut h); + assert!(!h.get_by_label("New Layer").accesskit_node().is_disabled()); + h.get_by_label("New Layer").click(); + h.run_steps(3); + assert_eq!(pen_path_op(&h.state().ui.tool_options), None); +} + +/// Merge Shape Components is disabled where there is nothing to merge: one component, or in Shape +/// mode no shape layer selected. +#[test] +fn merge_is_disabled_with_fewer_than_two_components() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 100.0); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::options_bar(app, ui, Tool::Pen)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h.get_by_label("Path operations").click(); + h.run_steps(3); + assert!(h.get_by_label("Merge Shape Components").accesskit_node().is_disabled()); +} + +/// An unknown stored operation (an edited settings file) falls back to New Layer, never panics. +#[test] +fn an_unknown_operation_falls_back_to_new_layer() { + let mut app = app(); + app.ui.tool_options.pen_path_op = "melt".into(); + assert_eq!(pen_path_op(&app.ui.tool_options), Some("combine")); + app.ui.tool_options.vector_mode = "shape".into(); + assert_eq!(pen_path_op(&app.ui.tool_options), None); + let count = layers(&app); + square(&mut app, 20.0, 20.0, 50.0); + square(&mut app, 30.0, 30.0, 50.0); + assert_eq!(layers(&app), count + 2); +} + +#[test] +fn freeform_refuses_a_locked_shape_before_starting_a_stroke() { + use crate::canvas::{ToolEvent, tool_event}; + for lock in ["all", "pixels"] { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + square(&mut app, 20.0, 20.0, 100.0); + let (id, body) = shape_path(&app); + app.run("layer.lockLayers", json!({lock: true})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + app.ui.tool = Tool::FreeformPen; + app.ui.tool_options.pen_path_op = "subtract".into(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, egui::Modifiers::NONE); + assert!(app.drag.is_none(), "{lock}: stroke was not started"); + assert!(app.ui.status_error, "{lock}"); + assert_eq!(shape_path(&app), (id, body), "{lock}"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps, "{lock}"); + app.session.undo(); + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, egui::Modifiers::NONE); + assert!(app.drag.is_some(), "{lock}: unlocked stroke can start"); + } +} diff --git a/crates/ui-egui/src/tools/vector_ui/shapes.rs b/crates/ui-egui/src/tools/vector_ui/shapes.rs index dc72a120f0..fe96e7b177 100644 --- a/crates/ui-egui/src/tools/vector_ui/shapes.rs +++ b/crates/ui-egui/src/tools/vector_ui/shapes.rs @@ -59,7 +59,10 @@ pub(super) fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: if dx.hypot(dy) < 1.0 { return None; } - json!({"kind": "line", "from": start, "to": [start[0] + dx, start[1] + dy], "weight": o.line_weight.max(1.0)}) + json!({"kind": "line", "from": start, "to": [start[0] + dx, start[1] + dy], "weight": o.line_weight.max(1.0), + "arrowStart": o.arrow_start, "arrowEnd": o.arrow_end, + "arrowWidth": o.arrow_width, "arrowLength": o.arrow_length, + "arrowConcavity": o.arrow_concavity}) } else { let (mut w, mut h) = (end[0] - start[0], end[1] - start[1]); if mods.shift { diff --git a/crates/ui-egui/src/tools/vector_ui/tests.rs b/crates/ui-egui/src/tools/vector_ui/tests.rs index 202c2fd184..6f7438b340 100644 --- a/crates/ui-egui/src/tools/vector_ui/tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/tests.rs @@ -78,7 +78,7 @@ fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { a.run("edit.redo", json!({})).unwrap(); assert_eq!(a.run("shape.info", json!({})).unwrap(), info); a.ui.tool_options.vector_mode = "shape".into(); - a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], ..Default::default() }); + a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], owner: capture_pen_owner(&a), ..Default::default() }); pen_commit(&mut a, false); let open = a.run("shape.info", json!({})).unwrap(); assert_eq!(open["stroke"]["dashes"], info["stroke"]["dashes"]); @@ -86,6 +86,44 @@ fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { } } +#[test] +fn freeform_straight_segments_keep_the_captured_saved_path_and_reject_a_foreign_document() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + let seed = json!({"subpaths":[{"knots":[[20,20],[120,20]]}]}); + for name in ["work", "Saved drawing"] { + app.run("path.set", json!({"name": name, "path": seed})).unwrap(); + } + app.ui.selected_path = Some("Saved drawing".into()); + let owner = capture_pen_owner(&app).unwrap(); + let original = app.session.active().unwrap().doc.clone(); + let n = app.session.active().unwrap().history.past_len(); + let points = [[20.0, 60.0, 1.0], [60.0, 60.0, 1.0], [100.0, 80.0, 1.0], [140.0, 80.0, 1.0]]; + app.ui.selected_path = Some("work".into()); + app.ui.tool_options.vector_mode = "shape".into(); + freeform_finish_owned(&mut app, &points, &[1, 3], owner); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.work_path, original.work_path); + let saved = &st.doc.paths.iter().find(|p| p.name == "Saved drawing").unwrap().path; + assert_eq!(saved.subpaths[0], original.paths[0].path.subpaths[0]); + assert_eq!(saved.subpaths.len(), 2); + let knots = &saved.subpaths[1].knots; + assert_eq!(knots.len(), 4); + for (a, b) in [(0, 1), (2, 3)] { + assert_eq!(knots[a].out_ctrl, knots[a].anchor, "straight segment starts at its anchor"); + assert_eq!(knots[b].in_ctrl, knots[b].anchor, "straight segment ends at its anchor"); + } + assert_eq!(st.history.past_len(), n + 1); + assert!(app.ui.pen.is_none()); + app.ui.tool_options.vector_mode = "path".into(); + let owner = capture_pen_owner(&app).unwrap(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let (source, n) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len()); + freeform_finish_owned(&mut app, &points, &[1, 3], owner); + assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); +} + fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); @@ -121,6 +159,24 @@ fn paths_context_menu_uses_the_clicked_row_and_dispatches_commands() { assert_eq!(duplicate["name"], "First copy 2", "duplicate must not overwrite an existing path"); } +#[test] +fn failed_freeform_straight_segment_commit_keeps_its_owned_fitted_draft() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + let owner = capture_pen_owner(&app).unwrap(); + let (source, n) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len()); + let points = [[20.0, 60.0, 1.0], [1e40, 60.0, 1.0], [140.0, 80.0, 1.0]]; + freeform_finish_owned(&mut app, &points, &[1, 2], owner.clone()); + let pen = app.ui.pen.as_ref().expect("invalid fitted geometry must remain available after its failed commit"); + assert!(pen.commit_failed); + assert_eq!(pen.owner.as_ref(), Some(&owner)); + assert!(owner.valid(&app)); + assert_eq!(pen.knots.len(), 3); + assert_eq!(pen.knots[1], [[1e40, 60.0]; 3]); + assert!(Arc::ptr_eq(&source, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), n); +} + #[test] fn work_path_context_save_and_layer_path_deletion_rules() { let mut app = app(); @@ -232,7 +288,7 @@ fn pen_keeps_anchors_of_a_finished_path() { let mut app = app(); app.ui.tool = Tool::Pen; let knots = vec![[[10.0, 10.0]; 3], [[80.0, 10.0]; 3], [[80.0, 80.0]; 3]]; - app.ui.pen = Some(PenPath { knots, ..Default::default() }); + app.ui.pen = Some(PenPath { knots, owner: capture_pen_owner(&app), ..Default::default() }); pen_commit(&mut app, true); assert!(app.ui.pen.is_none()); assert!(app.session.active().unwrap().doc.work_path.is_some()); @@ -329,7 +385,7 @@ fn show_target_path_and_extras_hide_the_path_outlines() { } // The Pen's path in progress is a gesture, not the target path: it stays visible. app.ui.tool = Tool::Pen; - app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() }); + app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], owner: capture_pen_owner(&app), ..Default::default() }); toggle(&mut app, "view.show.targetPath"); assert!(overlay_shapes(&app, &ctx) > 0, "the pen path in progress is still drawn"); } @@ -340,7 +396,7 @@ fn making_a_selection_from_the_pen_path_deselects_and_hides_it() { let ctx = egui::Context::default(); let mut app = app(); app.ui.tool = Tool::Pen; - app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[120.0, 20.0]; 3], [[120.0, 80.0]; 3]], ..Default::default() }); + app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[120.0, 20.0]; 3], [[120.0, 80.0]; 3]], owner: capture_pen_owner(&app), ..Default::default() }); path_to_selection(&mut app, json!({})).unwrap(); let st = app.session.active().unwrap(); assert!(st.doc.selection.is_some(), "the selection is made"); @@ -376,14 +432,14 @@ fn making_a_selection_from_the_pen_path_deselects_and_hides_it() { // A new path drawn after the deselect replaces the work path and is shown. path_to_selection(&mut app, json!({})).unwrap(); assert_eq!(overlay_shapes(&app, &ctx), 0); - app.ui.pen = Some(PenPath { knots: vec![[[10.0, 10.0]; 3], [[40.0, 10.0]; 3]], ..Default::default() }); + app.ui.pen = Some(PenPath { knots: vec![[[10.0, 10.0]; 3], [[40.0, 10.0]; 3]], owner: capture_pen_owner(&app), ..Default::default() }); pen_commit(&mut app, false); assert_eq!(app.ui.selected_path.as_deref(), Some("work")); assert_eq!(app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths.len(), 1, "replaced, not combined"); assert!(overlay_shapes(&app, &ctx) > 0); // A shape layer's path stays the active path: layer targeting is unchanged. app.ui.tool_options.vector_mode = "shape".into(); - app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[120.0, 20.0]; 3], [[120.0, 80.0]; 3]], ..Default::default() }); + app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[120.0, 20.0]; 3], [[120.0, 80.0]; 3]], owner: capture_pen_owner(&app), ..Default::default() }); path_to_selection(&mut app, json!({})).unwrap(); assert_eq!(app.ui.selected_path.as_deref(), Some("layer")); assert!(active_shape_path(&app).is_some()); @@ -734,12 +790,14 @@ fn failed_stale_pen_commit_preserves_the_draft_until_retry() { 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.selected_path = Some("work".into()); 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 mut draft = app.ui.pen.clone(); + draft.as_mut().unwrap().commit_failed = true; 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(); @@ -749,7 +807,9 @@ fn failed_stale_pen_commit_preserves_the_draft_until_retry() { "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), + "commit" => { + pen_commit(&mut app, true); + } _ => { assert!(app.run("file.close", json!({})).is_err()); } @@ -763,6 +823,9 @@ fn failed_stale_pen_commit_preserves_the_draft_until_retry() { assert!(!crate::direct_select::candidates(&app).iter().any(|(target, _)| *target == crate::direct_select::PathRef::Pen)); app.session.authorize = None; app.sync_views(); + assert_eq!(app.ui.pen, draft, "passive frames do not retry a refused commit"); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&original)); + assert!(pen_commit(&mut app, false), "the user explicitly retries the valid captured target"); 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")); @@ -784,7 +847,8 @@ fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() { pen_up(&mut app); pen_down(&mut app, 80.0, 60.0, false); pen_up(&mut app); - let draft = app.ui.pen.clone(); + let mut draft = app.ui.pen.clone(); + draft.as_mut().unwrap().commit_failed = true; 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); @@ -794,6 +858,9 @@ fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() { assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none())); app.session.authorize = None; app.sync_views(); + assert_eq!(app.ui.pen, draft, "passive frames retain the refused draft"); + assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none())); + assert!(pen_commit(&mut app, false), "explicit retry commits to the captured source"); 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()); @@ -853,7 +920,9 @@ fn stale_pen_commits_to_its_owner_across_document_changes() { "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), + "commit" => { + pen_commit(&mut app, true); + } _ => app.sync_views(), } let source = app.session.documents().iter().find(|d| d.doc.id == owner).unwrap(); @@ -1039,11 +1108,14 @@ fn new_fill_layer_takes_the_selected_path_as_its_vector_mask() { // Deselected (or a stale name): the selection is the layer mask, no vector mask. for sel in [None, Some("Gone".to_string())] { app.ui.selected_path = sel; + // The selection goes into the new layer's mask, so each fill layer needs its own (#8801). + app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); let r = app.run("layer.newFillLayer.solidColor", json!({})); let l = layer(&app, r); - assert!(l.vector_mask.is_none() && l.mask.is_some()); + assert!(l.vector_mask.is_none() && l.mask.as_ref().is_some_and(|m| m.value(160, 160) == 1.0 && m.value(5, 5) == 0.0)); } // Make Work Path selects the new work path. + app.run("select.rect", json!({"x": 150, "y": 150, "width": 20, "height": 20})).unwrap(); app.run("select.toWorkPath", json!({"tolerance": 2.0})).unwrap(); assert_eq!(app.ui.selected_path.as_deref(), Some("work")); } @@ -1114,7 +1186,8 @@ fn pen_last_anchor_hit_tolerance_tracks_zoom_and_draft_round_trips() { assert_eq!(pen.knots[1][2], pen.knots[1][0]); let saved = serde_json::to_value(pen).unwrap(); let restored: PenPath = serde_json::from_value(saved).unwrap(); - assert_eq!(restored, *pen); + // The runtime owner isn't saved: a restored draft belongs to no document. + assert_eq!(restored, PenPath { owner: None, ..pen.clone() }); assert_eq!(pen_to_json(&restored, false)["subpaths"][0]["knots"][1]["smooth"], false); pen_down(&mut app, 100.0 + 7.0 / f64::from(zoom), 100.0, false); assert_eq!(app.ui.pen.as_ref().unwrap().knots.len(), 3); @@ -1169,9 +1242,9 @@ fn live_rectangle_corner_radius_edit_preserves_other_corners_and_history() { assert_eq!(get_radii(&app), [12.0; 4]); } -/// #534: dragging in a shape's fill picker, opened from the Properties panel at the right edge -/// of the window, keeps the picker in place (it flipped from side to side as its width -/// followed the colour readouts, which moved it under the pointer). +/// #534: using a shape's fill popover, opened from the Properties panel at the right edge of +/// the window, keeps it in place (it flipped from side to side as its width followed the +/// colour readouts, which moved it under the pointer). #[test] fn shape_colour_picker_stays_put_while_dragging() { use egui::{PointerButton, vec2}; @@ -1214,20 +1287,18 @@ fn shape_colour_picker_stays_put_while_dragging() { } }; let before = fill(&h); - // Drag across the picker's colour area. - let start = opened.min + vec2(60.0, opened.height() * 0.5); - h.hover_at(start); - h.step(); - press(&mut h, start, true); - for i in 1..=10 { - h.event(egui::Event::PointerMoved(start + vec2(i as f32 * 5.0, i as f32))); - h.step(); + // Opening folders scrolls the list: the popover stays where it opened, inside the window. + for label in ["Pure", "Dark"] { + h.get_by_label(label).click(); + h.run_steps(3); let r = popup(&h).expect("the picker stays open"); - assert_eq!(r.left_top(), opened.left_top(), "step {i}: the picker moved from {opened:?} to {r:?}"); - assert!(h.ctx.content_rect().contains_rect(r), "step {i}: {r:?} runs off the window"); + assert_eq!(r.left_top(), opened.left_top(), "{label}: the picker moved from {opened:?} to {r:?}"); + assert!(h.ctx.content_rect().contains_rect(r), "{label}: {r:?} runs off the window"); } - press(&mut h, start, false); - assert_ne!(fill(&h), before, "the drag picked a colour"); + h.get_by_label("Dark Red").click(); + h.run_steps(2); + assert_eq!(popup(&h).map(|r| r.left_top()), Some(opened.left_top()), "picking keeps the popover open in place"); + assert_ne!(fill(&h), before, "the swatch picked a colour"); } /// The options bar edits the selected shape's fill and stroke. diff --git a/crates/ui-egui/src/transform_rotate_snap_tests.rs b/crates/ui-egui/src/transform_rotate_snap_tests.rs new file mode 100644 index 0000000000..8048b1b6fb --- /dev/null +++ b/crates/ui-egui/src/transform_rotate_snap_tests.rs @@ -0,0 +1,73 @@ +//! Free Transform: Shift while rotating snaps the turn made by the drag to 15° steps and keeps +//! the angle the box started at (#11099). + +use crate::state::TransformSession; +use crate::transform_tool::{Gesture, Hit, apply_drag, hit}; + +/// A 200 × 100 box turned by `deg` degrees about its centre (100, 50). +fn session(deg: f64) -> TransformSession { + let (cx, cy) = (100.0, 50.0); + let (sn, cs) = deg.to_radians().sin_cos(); + let turn = |[x, y]: [f64; 2]| [cx + (x - cx) * cs - (y - cy) * sn, cy + (x - cx) * sn + (y - cy) * cs]; + TransformSession { + session: 1, + layer: 1, + rect: [0.0, 0.0, 200.0, 100.0], + quad: [[0.0, 0.0], [200.0, 0.0], [200.0, 100.0], [0.0, 100.0]].map(turn), + pivot: [cx, cy], + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: Default::default(), + content_aware: None, + skew_v: 0.0, + layers: Vec::new(), + } +} + +fn angle(s: &TransformSession) -> f64 { + (s.quad[1][1] - s.quad[0][1]).atan2(s.quad[1][0] - s.quad[0][0]).to_degrees() +} + +/// A drag outside the box that turns the pointer about the reference point by `by` degrees. +fn rotate(start_deg: f64, by: f64, shift: bool) -> f64 { + let mut s = session(start_deg); + let at = |deg: f64| { + let (sn, cs) = deg.to_radians().sin_cos(); + [s.pivot[0] + 400.0 * cs, s.pivot[1] + 400.0 * sn] + }; + let (from, to) = (at(10.0), at(10.0 + by)); + let g = Gesture { hit: hit(&s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot }; + assert_eq!(g.hit, Hit::Outside); + let mods = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + apply_drag(&mut s, g, to, mods); + angle(&s) +} + +fn close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-6, "{a} != {b}"); +} + +#[test] +fn shift_snaps_the_change_not_the_absolute_angle() { + // Measured: a box at 25.59°, dragged ~20° with Shift, ends at 40.59° (not 30° or 45°). + close(rotate(25.59, 20.0, true), 40.59); + close(rotate(25.59, -20.0, true), 10.59); + close(rotate(25.59, 7.0, true), 25.59); + close(rotate(25.59, 8.0, true), 40.59); +} + +#[test] +fn from_zero_it_snaps_as_before() { + close(rotate(0.0, 20.0, true), 15.0); + close(rotate(0.0, 23.0, true), 30.0); + close(rotate(0.0, -20.0, true), -15.0); +} + +#[test] +fn without_shift_the_turn_is_free() { + close(rotate(25.59, 20.0, false), 45.59); +} diff --git a/crates/ui-egui/src/transform_several_tests.rs b/crates/ui-egui/src/transform_several_tests.rs new file mode 100644 index 0000000000..ee593e341f --- /dev/null +++ b/crates/ui-egui/src/transform_several_tests.rs @@ -0,0 +1,318 @@ +//! Free Transform with several layers selected (#6501): ⌘T frames them all and the box moves +//! every one of them, type and pixels alike, in one history step. + +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::PhotocraftApp; + +/// A 200 × 200 document: "Paint" with a red block at x 10..50, y 10..30, and a type layer over it. +/// Both are selected with Paint active, as after ⌘-clicking Paint in the Layers panel (the +/// reporter's steps). +fn app() -> (PhotocraftApp, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap(); + app.sync_views(); + let paint = LayerId(app.run("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap()); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let text = LayerId(app.run("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(paint)); + (app, paint, text) +} + +fn bounds(app: &PhotocraftApp, id: LayerId) -> Rect { + let st = app.session.active().unwrap(); + photocraft_engine::transform_cmds::transform_bounds(&st.doc, st.doc.layer(id).unwrap()) +} + +fn free_transform(app: &mut PhotocraftApp) { + crate::menus::invoke(app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); +} + +#[test] +fn free_transform_frames_every_selected_layer_and_moves_them_all() { + let (mut app, paint, text) = app(); + let (p0, t0) = (bounds(&app, paint), bounds(&app, text)); + free_transform(&mut app); + let t = app.ui.transform.as_ref().unwrap(); + let both = p0.union(&t0); + assert_eq!(t.rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from), "the box frames both layers"); + assert_eq!((t.layer, t.layers.clone()), (paint.0, vec![paint.0, text.0])); + // Both move with the box: neither stays behind on the canvas under it. + let pv = app.transform_preview.as_ref().unwrap(); + assert!(!pv.doc.layer(paint).unwrap().visible && !pv.doc.layer(text).unwrap().visible); + let steps = app.session.active().unwrap().history.past_len(); + // A quarter turn about the box's centre. + let r = t.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [cx - (y - cy), cy + (x - cx)]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + assert!(app.ui.transform.is_none()); + let (p1, t1) = (bounds(&app, paint), bounds(&app, text)); + assert!(p1.width().abs_diff(p0.height()) <= 1 && p1.height().abs_diff(p0.width()) <= 1, "Paint turned: {p0:?} -> {p1:?}"); + assert!(t1.width().abs_diff(t0.height()) <= 2 && t1.height().abs_diff(t0.width()) <= 2, "the type turned: {t0:?} -> {t1:?}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.layer(text).unwrap().content, LayerContent::Text(_)), "still editable type"); + assert_eq!(st.history.past_len(), steps + 1, "one history step"); + assert_eq!(st.selected_layers(), [paint, text], "both stay selected"); +} + +#[test] +fn with_a_selection_the_pixels_are_lifted_and_the_type_moves_whole() { + let (mut app, paint, text) = app(); + let t0 = bounds(&app, text); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap(); + free_transform(&mut app); + let pv = app.transform_preview.as_ref().unwrap(); + let under = pv.doc.layer(paint).unwrap(); + assert!(under.visible, "Paint stays, less the selected pixels"); + assert_eq!(under.surface().unwrap().content_bounds(), Rect::new(30, 10, 50, 30)); + assert!(!pv.doc.layer(text).unwrap().visible, "the type moves whole (#2630)"); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x, y + 100.0]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(paint).unwrap().surface().unwrap().content_bounds(), Rect::new(10, 10, 50, 130)); + assert_eq!(bounds(&app, text), t0.translate(0, 100)); +} + +#[test] +fn cancel_leaves_every_layer_as_it_was() { + let (mut app, paint, text) = app(); + let before = app.session.active().unwrap().doc.clone(); + free_transform(&mut app); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 20.0, y]); + } + crate::transform_tool::cancel(&mut app); + assert!(app.ui.transform.is_none()); + let st = app.session.active().unwrap(); + assert!(st.doc == before); + assert_eq!(st.selected_layers(), [paint, text]); +} + +#[test] +fn warp_is_greyed_and_distort_too_while_type_is_among_them() { + let (mut app, paint, text) = app(); + for id in ["edit.freeTransform", "edit.transform.scale", "edit.transform.rotate", "edit.transform.skew", "edit.transform.flipHorizontal"] { + assert!(crate::menus::is_enabled(&app, id), "{id}"); + } + for id in ["edit.transform.warp", "edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed"); + } + assert_eq!(crate::transform_tool::begin_warp(&mut app, &egui::Context::default()), Err(crate::transform_tool::WARP_ONE_LAYER.into())); + assert!(app.ui.transform.is_none(), "no box is left open"); + // In an open box too. + free_transform(&mut app); + for id in ["edit.transform.warp", "edit.transform.distort", "edit.transform.perspective"] { + assert!(!crate::menus::is_enabled(&app, id), "{id} is greyed in the box"); + } + crate::transform_tool::cancel(&mut app); + // Two pixel layers distort. + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.rasterize.type", json!({})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + assert!(crate::menus::is_enabled(&app, "edit.transform.distort") && crate::menus::is_enabled(&app, "edit.transform.perspective")); + assert!(!crate::menus::is_enabled(&app, "edit.transform.warp")); +} + +#[test] +fn the_warp_button_keeps_the_box_when_the_active_layer_is_type() { + let (mut app, paint, text) = app(); + app.run("layer.select", json!({"layer": paint.0})).unwrap(); + app.run("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(text)); + free_transform(&mut app); + let before = app.ui.transform.as_ref().map(|t| (t.rect, t.quad)); + crate::transform_tool::enter_warp(&mut app, &egui::Context::default()); + let t = app.ui.transform.as_ref().unwrap(); + assert!(t.warp.is_none(), "no warp on several layers"); + assert_eq!(Some((t.rect, t.quad)), before); +} + +#[test] +fn a_locked_layer_among_them_refuses_before_the_box_opens() { + let (mut app, paint, text) = app(); + app.run("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap(); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})); + assert_eq!(r, Err(crate::transform_tool::LOCKED.into())); + assert!(app.ui.transform.is_none()); +} + +#[test] +fn with_the_background_active_the_box_moves_the_other_selected_layers() { + let (mut app, paint, text) = app(); + let bg = app.session.active().unwrap().doc.layers[0].id; + app.run("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(bg)); + assert!(crate::menus::is_enabled(&app, "edit.freeTransform"), "not greyed for the Background"); + free_transform(&mut app); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!((t.layer, t.layers.clone()), (bg.0, vec![paint.0, text.0])); + let p0 = bounds(&app, paint); + if let Some(t) = app.ui.transform.as_mut() { + // Nearest neighbour: no resampling fringe at the block's edge. + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 5.0, y]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(app.ui.status, ""); + let st = app.session.active().unwrap(); + assert!(st.doc.is_background(bg), "the Background is left alone"); + assert_eq!(bounds(&app, paint), p0.translate(5, 0)); +} + +#[test] +fn picking_another_layer_applies_the_box_to_all_of_them() { + let (mut app, paint, text) = app(); + let (p0, t0) = (bounds(&app, paint), bounds(&app, text)); + free_transform(&mut app); + if let Some(t) = app.ui.transform.as_mut() { + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + } + app.run("layer.select", json!({"layer": text.0})).unwrap(); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_none()); + assert_eq!(bounds(&app, paint), p0.translate(10, 0)); + assert_eq!(bounds(&app, text), t0.translate(10, 0)); +} + +/// Paint with a 6 px outside stroke, then both layers selected again (Paint active). +fn stroked() -> (PhotocraftApp, LayerId, LayerId) { + let (mut app, paint, text) = app(); + app.run("layer.select", json!({"layer": paint.0})).unwrap(); + app.run("layer.layerStyle.stroke", json!({"size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + (app, paint, text) +} + +#[test] +fn styles_past_the_frame_move_with_the_box() { + let (mut app, _, _) = stroked(); + free_transform(&mut app); + // The frame is the content's (the block starts at x 10); its stroke reaches 6 px past it. + assert_eq!(app.ui.transform.as_ref().unwrap().rect[0], 10.0, "the handles stay on the content"); + let (stroke, quad) = crate::transform_tool::moving_texel(&app, [6, 20]); + assert!(stroke.is_some_and(|c| c.a() > 200 && c.g() > 200), "the stroke left of the block is in the moving pixels: {stroke:?}"); + assert!(quad[0][0] <= 4.0, "and drawn there: {quad:?}"); + // Moving the box carries the texture's own rect with it. + if let Some(t) = app.ui.transform.as_mut() { + t.quad = t.quad.map(|[x, y]| [x + 50.0, y + 5.0]); + } + let (_, moved) = crate::transform_tool::moving_texel(&app, [6, 20]); + for (a, b) in quad.iter().zip(&moved) { + assert!((b[0] - a[0] - 50.0).abs() < 1e-9 && (b[1] - a[1] - 5.0).abs() < 1e-9, "{quad:?} -> {moved:?}"); + } + // A quarter turn maps the stroke's corner where the frame's map takes it. + let r = app.ui.transform.as_ref().unwrap().rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = crate::transform_tool::corners(r).map(|[x, y]| [cx - (y - cy), cy + (x - cx)]); + } + let (_, turned) = crate::transform_tool::moving_texel(&app, [6, 20]); + let [x0, y0] = quad[0]; + assert!((turned[0][0] - (cx - (y0 - cy))).abs() < 1e-6 && (turned[0][1] - (cy + (x0 - cx))).abs() < 1e-6, "{turned:?}"); +} + +/// A texel of the type's glyphs in the moving pixels as the box opened. +fn glyph_texel(app: &PhotocraftApp, text: LayerId) -> [i32; 2] { + let b = bounds(app, text); + (b.y0..b.y1) + .flat_map(|y| (b.x0..b.x1).map(move |x| [x, y])) + .find(|p| crate::transform_tool::moving_texel(app, *p).0.is_some_and(|c| c.a() > 200)) + .expect("the type is in the moving pixels") +} + +#[test] +fn hiding_or_fading_a_moving_layer_shows_in_the_preview_at_once() { + let (mut app, _, text) = app(); + free_transform(&mut app); + let p = glyph_texel(&app, text); + // Paint stays the active layer, so the box stays open. + app.run("layer.setProps", json!({"layer": text.0, "opacity": 0.5})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_some()); + let half = crate::transform_tool::moving_texel(&app, p).0.unwrap(); + assert!((100..=155).contains(&half.a()), "half opacity: {half:?}"); + app.run("layer.setProps", json!({"layer": text.0, "visible": false})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + assert_eq!(crate::transform_tool::moving_texel(&app, p).0.map(|c| c.a()), Some(0), "a hidden layer moves nothing"); + // The commit still transforms it, hidden, like Photoshop. + let t0 = bounds(&app, text); + if let Some(t) = app.ui.transform.as_mut() { + t.interpolation = "nearest".into(); + t.quad = t.quad.map(|[x, y]| [x + 10.0, y]); + } + crate::transform_tool::commit(&mut app); + assert_eq!(bounds(&app, text), t0.translate(10, 0)); +} + +#[test] +fn a_style_added_while_the_box_is_open_grows_the_moving_pixels() { + let (mut app, paint, _) = app(); + free_transform(&mut app); + assert!(crate::transform_tool::moving_texel(&app, [6, 20]).0.is_none(), "nothing past the block yet"); + app.run("layer.layerStyle.stroke", json!({"layer": paint.0, "size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + let stroke = crate::transform_tool::moving_texel(&app, [6, 20]).0; + assert!(stroke.is_some_and(|c| c.a() > 200), "{stroke:?}"); +} + +fn group(app: &mut PhotocraftApp, ids: &[LayerId]) -> LayerId { + app.run("layer.select", json!({"layer": ids[0].0})).unwrap(); + for id in &ids[1..] { + app.run("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + } + LayerId(app.run("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()) +} + +#[test] +fn edits_to_the_children_of_a_moving_group_show_in_the_preview() { + let (mut app, paint, text) = app(); + let g = group(&mut app, &[paint]); + app.run("layer.select", json!({"layer": g.0})).unwrap(); + app.run("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap(); + free_transform(&mut app); + assert_eq!(app.ui.transform.as_ref().unwrap().layers, [g.0, text.0]); + let red = |app: &PhotocraftApp| crate::transform_tool::moving_texel(app, [20, 20]).0.map(|c| c.a()); + assert_eq!(red(&app), Some(255), "the group's child is in the moving pixels"); + app.run("layer.setProps", json!({"layer": paint.0, "opacity": 0.5})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + crate::transform_tool::end_if_left(&mut app); + assert!(app.ui.transform.is_some(), "the box stays open"); + assert!(red(&app).is_some_and(|a| (100..=155).contains(&a)), "the child at half opacity: {:?}", red(&app)); + app.run("layer.setProps", json!({"layer": paint.0, "visible": false})).unwrap(); + crate::transform_tool::follow_edits(&mut app); + assert_eq!(red(&app), Some(0), "a hidden child moves nothing"); +} + +#[test] +fn a_style_of_the_group_around_the_moving_layers_moves_with_them() { + let (mut app, paint, text) = app(); + let g = group(&mut app, &[paint, text]); + app.run("layer.layerStyle.stroke", json!({"layer": g.0, "size": 6, "color": "#00ff00", "position": "outside"})).unwrap(); + // The two layers in the group, not the group itself. + app.run("layer.select", json!({"layer": text.0})).unwrap(); + app.run("layer.select", json!({"layer": paint.0, "mode": "add"})).unwrap(); + free_transform(&mut app); + assert_eq!(app.ui.transform.as_ref().unwrap().layers, [paint.0, text.0]); + let (stroke, quad) = crate::transform_tool::moving_texel(&app, [6, 20]); + assert!(stroke.is_some_and(|c| c.a() > 200 && c.g() > 200), "the group's stroke left of the block: {stroke:?}"); + assert!(quad[0][0] <= 4.0, "{quad:?}"); +} diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 77e0f43b04..c69a3ec099 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -19,7 +19,7 @@ impl Default for Runtime { startup_checked: false, notified_version: None, #[cfg(not(target_arch = "wasm32"))] - updater: craft_update::updater::Updater::new(&craft_update::suite::PHOTOCRAFT, photocraft_engine::build_info::VERSION), + updater: craft_update::updater::Updater::new(&crate::UPDATE_APP, photocraft_engine::build_info::VERSION), } } } @@ -49,6 +49,8 @@ pub(crate) fn check(app: &mut PhotocraftApp, _ctx: &egui::Context, params: Value if result.is_ok() || failed_to_start { app.ui.updates_open = true; app.updates_rt.startup_checked = true; + // A release the user dismissed is offered again by a manual check. + app.updates_rt.notified_version = None; } result } @@ -78,10 +80,12 @@ pub(crate) fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +#[cfg(target_arch = "wasm32")] +pub(crate) fn show(_app: &mut PhotocraftApp, _ctx: &egui::Context) {} + +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut download: Option = None; - - #[cfg(not(target_arch = "wasm32"))] { let was_open = app.updates_rt.updater.prompt_open(); app.updates_rt.updater.tick(ctx); @@ -97,74 +101,35 @@ pub(crate) fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if was_open && !app.updates_rt.updater.prompt_open() { app.ui.updates_open = false; } - if app.updates_rt.updater.prompt_open() { - return; - } } - - if !app.ui.updates_open { - if let Some(url) = download { - crate::links::open(app, ctx, &url); - } - return; - } - let mut open = true; - let status = app.session.updates.status.clone(); - let mut retry = false; - egui::Window::new(tl!("PhotoCraft Updates")) - .id(egui::Id::unique("photocraft_updates")) - .open(&mut open) - .collapsible(false) - .resizable(false) - .default_width(380.0) - .show(ctx, |ui| { - let t = crate::theme::Tokens::get(ctx); - ui.label(crate::i18n::fmt(crate::i18n::t("Installed version: {version}"), &[("version", photocraft_engine::build_info::VERSION)])); - ui.add_space(8.0); - match &status { - Status::Checking => { - ui.horizontal(|ui| { - ui.spinner(); - ui.label(tl!("Checking for updates…")); - }); - } - Status::Available { release } => { - ui.label(crate::i18n::fmt(crate::i18n::t("PhotoCraft {version} is available."), &[("version", &release.version)])); - #[cfg(not(target_arch = "wasm32"))] - { - if crate::widgets::primary_button(ui, tl!("Update and Restart"), 180.0).clicked() { - app.updates_rt.updater.offer(release_of(release)); - } - if ui.button(tl!("Release Notes")).clicked() { - download = Some(release.url.clone()); - } - } - #[cfg(target_arch = "wasm32")] - if crate::widgets::primary_button(ui, tl!("Open Download Page"), 160.0).clicked() { - download = Some(release.url.clone()); - } - } - Status::UpToDate => { - ui.label(tl!("PhotoCraft is up to date.")); - } - Status::Failed { message } => { - ui.colored_label(t.warning, tl!("Could not check for updates.")); - ui.label(message); - } - Status::Idle => { - ui.label(tl!("Updates have not been checked yet.")); - } - } - if !matches!(status, Status::Checking) && ui.button(tl!("Check Again")).clicked() { - retry = true; - } - }); - app.ui.updates_open = open; if let Some(url) = download { crate::links::open(app, ctx, &url); } - if retry { - let _ = check(app, ctx, serde_json::json!({})); + if app.updates_rt.updater.prompt_open() { + return; + } + if app.ui.updates_open { + let dialog = craft_update::CheckResultDialog::new(&crate::UPDATE_APP, photocraft_engine::build_info::VERSION); + match dialog.show(ctx, &check_status(&app.session.updates.status)) { + craft_update::CheckResultResponse::None => {} + craft_update::CheckResultResponse::Close => app.ui.updates_open = false, + craft_update::CheckResultResponse::CheckAgain => { + let _ = check(app, ctx, serde_json::json!({})); + } + } + } +} + +/// The engine's check state as the result dialog shows it. An available release goes to the +/// startup prompt, so the dialog draws nothing for it. +#[cfg(not(target_arch = "wasm32"))] +fn check_status(status: &Status) -> craft_update::updater::Status { + use craft_update::updater::Status as Shown; + match status { + Status::Checking => Shown::Checking, + Status::UpToDate => Shown::UpToDate, + Status::Failed { message } => Shown::Failed(message.clone()), + Status::Idle | Status::Available { .. } => Shown::Idle, } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8be17ebf3d..cb5354e550 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -7,7 +7,7 @@ use crate::theme::{self, Tokens}; mod angle_dial; mod color_count; -pub use angle_dial::angle_dial_row; +pub use angle_dial::{angle_dial_line, angle_dial_row}; pub use color_count::color_count_row; mod type_ahead; pub use type_ahead::list_open as dropdown_list_open; @@ -57,95 +57,24 @@ pub use panel::{ pub use scrub::{scrub_label, scrub_option_icon, scrub_option_label}; #[cfg(test)] use slider::DOUBLE_CLICK_S; -pub use slider::{RowGestures, second_click, slider, slider_row, slider_row_prec, slider_row_with}; +pub use slider::{RowGestures, row_label, second_click, slider, slider_line, slider_row, slider_row_prec, slider_row_with}; pub(crate) use slider::{count_notches, wheel_notches_of}; #[cfg(test)] pub(crate) use slider::{curve_fraction, curve_value}; -pub use text_field::{INPUT_EVENT_LIMIT, TEXT_FIELD_H, TEXT_FIELD_LIMIT, cap_text_events, focus_first_field, text_field, text_field_ex}; +pub use text_field::{ + INPUT_EVENT_LIMIT, TEXT_FIELD_H, TEXT_FIELD_LIMIT, cap_text_events, focus_first_field, text_field, text_field_ex, text_field_with_trailing, +}; pub use units::{ - FieldUnits, fmt_num, fmt_num_prec, fmt_num2, parse_num, set_field_resolution, unit_decimals, unit_of_key, value_field_extent, value_field_prec, - value_field_units, + FieldUnits, fmt_num, fmt_num_prec, fmt_num2, parse_num, set_field_resolution, unit_decimals, unit_of_key, value_field_extent, value_field_len, + value_field_prec, value_field_units, }; #[cfg(test)] mod tests; #[cfg(test)] -mod screen_color_tests { - use super::*; - use crate::screen_picker::{Capture, Pending}; - use egui_kittest::{Harness, kittest::Queryable}; - #[test] - fn float_picker_keeps_precision_and_converts_screen_srgb_to_linear() { - let services = crate::Services { - screen_pick: Some(Box::new(|_| { - let (tx, receiver) = std::sync::mpsc::channel(); - tx.send(Ok(Capture::Color(Some([0.5, 0.25, 1.0])))).unwrap(); - Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) } - })), - ..Default::default() - }; - let original = [0.123456, 0.234567, 0.345678]; - let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), services); - let mut h = Harness::builder().with_size(vec2(600.0, 500.0)).build_ui_state( - |ui, state: &mut (crate::PhotocraftApp, [f32; 3])| { - crate::screen_picker::tick(&mut state.0, ui.ctx()); - color_edit_button_rgb(ui, &mut state.1); - }, - (app, original), - ); - h.run_steps(2); - assert_eq!(h.state().1, original); - h.get_by_role(egui::accesskit::Role::ColorWell).click(); - h.run_steps(3); - for (actual, expected) in h.state().1.iter().zip(original) { - assert!((actual - expected).abs() < 0.000001, "opening the float popup must not quantize to 8 bits"); - } - h.get_by_label("Pick screen color").click(); - h.run_steps(3); - for (actual, expected) in h.state().1.iter().zip([0.21404114, 0.05087609, 1.0]) { - assert!((actual - expected).abs() < 0.000001); - } - } - #[test] - fn compact_picker_delivers_screen_color_after_popup_closes_and_keeps_alpha() { - for (original, expected) in [ - (Color32::from_rgba_unmultiplied(40, 50, 60, 128), Color32::from_rgba_unmultiplied(255, 0, 128, 128)), - (Color32::from_rgb_additive(40, 50, 60), Color32::from_rgb_additive(255, 0, 128)), - ] { - let (tx, rx) = std::sync::mpsc::channel(); - let mut receiver = Some(rx); - let services = crate::Services { - screen_pick: Some(Box::new(move |_| Pending { - receiver: receiver.take().unwrap(), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - })), - ..Default::default() - }; - let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), services); - let mut h = Harness::builder().with_size(vec2(600.0, 500.0)).build_ui_state( - |ui, state: &mut (crate::PhotocraftApp, Color32, bool)| { - crate::screen_picker::tick(&mut state.0, ui.ctx()); - state.2 = color_edit_button_srgba(ui, &mut state.1).changed(); - }, - (app, original, false), - ); - h.get_by_role(egui::accesskit::Role::ColorWell).click(); - h.run_steps(3); - h.get_by_label("Pick screen color").click(); - h.run_steps(2); - assert_eq!(h.state().1, original); - egui::Popup::close_all(&h.ctx); - tx.send(Ok(Capture::Color(Some([1.0, 0.0, 0.5])))).unwrap(); - h.run_steps(1); - assert!(h.state().2); - assert_eq!(h.state().1.a(), original.a()); - assert_eq!(h.state().1, expected); - h.run_steps(1); - assert!(!h.state().2, "the pick commits once"); - } - } -} +#[path = "widgets/color_button_tests.rs"] +mod color_button_tests; #[cfg(test)] #[path = "widgets/numeric_input_tests.rs"] diff --git a/crates/ui-egui/src/widgets/angle_dial.rs b/crates/ui-egui/src/widgets/angle_dial.rs index a21b453ded..1fd693051b 100644 --- a/crates/ui-egui/src/widgets/angle_dial.rs +++ b/crates/ui-egui/src/widgets/angle_dial.rs @@ -2,19 +2,32 @@ //! Drop Shadow's light (#882), Motion Blur and the other direction angles of the filter //! dialogs (#3152) share it, as Photoshop's dialogs do. -use super::{Response, Sense, Stroke, Tokens, Ui, value_field, vec2}; +use super::{Response, Sense, Stroke, Tokens, Ui, row_label, value_field, vec2}; /// `Label (dial) [field°]` for a direction in document angles: counter-clockwise from three /// o'clock, y up. The dial points anywhere in -180..=180; the result is clamped to `range`, and /// the field accepts the whole of `range` (Motion Blur takes -360..=360, so a typed or /// remembered 270° stays 270°). pub fn angle_dial_row(ui: &mut Ui, label: &str, angle: &mut f32, range: std::ops::RangeInclusive) -> Response { + angle_dial_line(ui, label, None, angle, range) +} + +/// [`angle_dial_row`] with its label right-aligned in a `label_w` column ([`row_label`]) when +/// given, lined up with the [`slider_line`] rows around it (Layer Style). +pub fn angle_dial_line(ui: &mut Ui, label: &str, label_w: Option, angle: &mut f32, range: std::ops::RangeInclusive) -> Response { let t = Tokens::get(ui.ctx()); // The dial clamps with max/min, not `f32::clamp`, which panics if a malformed spec // reverses the range. let (lo, hi) = (*range.start(), *range.end()); ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); + match label_w { + Some(w) => { + row_label(ui, label, w); + } + None => { + ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); + } + } let (rect, mut dial) = ui.allocate_exact_size(vec2(42.0, 42.0), Sense::click_and_drag()); dial.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!(label))); if (dial.clicked() || dial.dragged()) diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index e246afcded..fe5701dc87 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,4 +1,3 @@ -use super::dropdown::swatch_popup; use super::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, pos2}; /// Cached RGB data; drawing work is bounded by the 256 display bins, never the image size. @@ -77,10 +76,13 @@ fn checker_tile(ctx: &egui::Context) -> egui::TextureHandle { tile } -/// A compact colour popup with the shared desktop eyedropper. Keep egui's colour cache so hue -/// survives black/white and alpha edits, and keep linear and encoded call sites distinct. -pub fn color_edit_button_srgba(ui: &mut Ui, color: &mut Color32) -> Response { - color_edit_button(ui, color, egui::color_picker::Alpha::BlendOrAdditive) +/// A colour swatch that opens PhotoCraft's Color Picker dialog (HSB, RGB, Lab, CMYK, hex and the +/// eyedropper), titled `title` as Photoshop's "Color Picker (…)", on its colour. While it is open +/// the swatch previews the colour it shows; OK keeps the colour (the alpha stays), Cancel restores +/// the exact colour the swatch had. The response is `changed` in every frame the colour changes; +/// [`crate::color_picker_ui::widget_picker_open`] tells a preview from the final colour. +pub fn color_edit_button_srgba(ui: &mut Ui, color: &mut Color32, title: &str) -> Response { + color_edit_button(ui, color, title, false) } pub(crate) fn color_swatch(ui: &mut Ui, color: Color32) -> Response { @@ -118,76 +120,109 @@ pub fn color_swatch_button(ui: &mut Ui, color: Color32, label: &str) -> Response response.on_hover_text(label) } -fn color_edit_button(ui: &mut Ui, color: &mut Color32, alpha: egui::color_picker::Alpha) -> Response { - let mut response = color_swatch(ui, *color); - let id = response.id.with("screen-color"); - if let Some(rgb) = crate::screen_picker::take(ui.ctx(), id) { - *color = sampled_color(rgb, *color, alpha); +/// Where a swatch keeps the exact colour it had when it opened its Color Picker, for Cancel. +fn opening_id(response: &Response) -> egui::Id { + response.id.with("picker-opening") +} + +/// Set once the open picker previewed a colour on the swatch. +fn previewed_id(response: &Response) -> egui::Id { + response.id.with("picker-previewed") +} + +/// The swatch was clicked: keep its exact colour for Cancel. +fn stash_opening(ui: &Ui, response: &Response, value: &T) { + ui.data_mut(|d| { + d.insert_temp(opening_id(response), value.clone()); + d.remove::(previewed_id(response)); + }); +} + +/// Follow the swatch's Color Picker: OK's colour (`picked`), the colour it shows while open, or +/// the colour the swatch opened it with when it closes without one. True in the frame a preview +/// changes `value` and in the frame the picker closes after OK or a preview, so a host can commit +/// then; a picker closed without either changes nothing (a host must not rewrite its values). +fn follow_picker(ui: &Ui, response: &Response, value: &mut T, picked: impl Fn([f32; 3], &T) -> T) -> bool { + use crate::color_picker_ui::{WidgetEvent, take_widget_color, take_widget_event}; + let ctx = ui.ctx(); + if let Some(rgb) = take_widget_color(ctx, response.id) { + ctx.data_mut(|d| { + d.remove::(opening_id(response)); + d.remove::(previewed_id(response)); + }); + *value = picked(rgb, value); + return true; + } + match take_widget_event(ctx, response.id) { + Some(WidgetEvent::Preview(rgb)) => { + let v = picked(rgb, value); + let changed = v != *value; + if changed { + ctx.data_mut(|d| d.insert_temp(previewed_id(response), true)); + } + *value = v; + changed + } + Some(WidgetEvent::Restore) => { + let (opening, previewed) = ctx.data_mut(|d| { + let v = d.get_temp::(opening_id(response)); + let previewed = d.get_temp::(previewed_id(response)) == Some(true); + d.remove::(opening_id(response)); + d.remove::(previewed_id(response)); + (v, previewed) + }); + let Some(v) = opening else { return false }; + let changed = previewed || v != *value; + *value = v; + changed + } + None => false, + } +} + +fn color_edit_button(ui: &mut Ui, color: &mut Color32, title: &str, opaque: bool) -> Response { + let mut response = color_swatch_button(ui, *color, title); + if follow_picker(ui, &response, color, |rgb, c| picked_color(rgb, *c, opaque)) { response.mark_changed(); } - swatch_popup(&response).show(|ui| { - if egui::color_picker::color_picker_color32(ui, color, alpha) { - response.mark_changed(); - } - if let Some(rgb) = crate::screen_picker::button(ui, id) { - *color = sampled_color(rgb, *color, alpha); - response.mark_changed(); - } - }); + if response.clicked() { + stash_opening(ui, &response, color); + let [r, g, b, _] = color.to_srgba_unmultiplied(); + crate::color_picker_ui::request_for_widget(ui.ctx(), response.id, title, [r, g, b].map(|v| f32::from(v) / 255.0)); + } response } -fn sampled_color(rgb: [f32; 3], original: Color32, alpha: egui::color_picker::Alpha) -> Color32 { +fn picked_color(rgb: [f32; 3], original: Color32, opaque: bool) -> Color32 { let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - if matches!(alpha, egui::color_picker::Alpha::BlendOrAdditive) && original.is_additive() { + if !opaque && original.is_additive() { return Color32::from_rgb_additive(byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); } - Color32::from_rgba_unmultiplied( - byte(rgb[0]), - byte(rgb[1]), - byte(rgb[2]), - if matches!(alpha, egui::color_picker::Alpha::Opaque) { 255 } else { original.a() }, - ) + Color32::from_rgba_unmultiplied(byte(rgb[0]), byte(rgb[1]), byte(rgb[2]), if opaque { 255 } else { original.to_srgba_unmultiplied()[3] }) } -pub fn color_edit_button_srgb(ui: &mut Ui, rgb: &mut [u8; 3]) -> Response { +/// [`color_edit_button_srgba`] for an opaque 8-bit sRGB colour. +pub fn color_edit_button_srgb(ui: &mut Ui, rgb: &mut [u8; 3], title: &str) -> Response { let mut color = Color32::from_rgb(rgb[0], rgb[1], rgb[2]); - let response = color_edit_button(ui, &mut color, egui::color_picker::Alpha::Opaque); + let response = color_edit_button(ui, &mut color, title, true); if response.changed() { *rgb = [color.r(), color.g(), color.b()]; } response } -/// Like egui's float RGB widget, this entry point stores linear RGB (not encoded hex values). -pub fn color_edit_button_rgb(ui: &mut Ui, rgb: &mut [f32; 3]) -> Response { - let mut response = color_swatch(ui, Color32::from(egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2]))); - let id = response.id.with("screen-color"); - let to_linear = |color: [f32; 3]| color.map(egui::ecolor::linear_from_gamma); - if let Some(color) = crate::screen_picker::take(ui.ctx(), id) { - *rgb = to_linear(color); +/// [`color_edit_button_srgba`] for a float colour stored as linear RGB (like egui's float RGB +/// widget). The Color Picker works in encoded sRGB; opening it and pressing OK unchanged keeps the +/// float value as it was. +pub fn color_edit_button_rgb(ui: &mut Ui, rgb: &mut [f32; 3], title: &str) -> Response { + let mut response = color_swatch_button(ui, Color32::from(egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2])), title); + if follow_picker(ui, &response, rgb, |color, _| color.map(egui::ecolor::linear_from_gamma)) { response.mark_changed(); } - let rgba = egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2]); - let cache = response.id.with("linear-hsva"); - let mut hsva = ui - .ctx() - .data(|d| d.get_temp::<(egui::Rgba, egui::ecolor::Hsva)>(cache)) - .filter(|(previous, _)| *previous == rgba) - .map(|(_, hsva)| hsva) - .unwrap_or_else(|| egui::ecolor::Hsva::from(rgba)); - swatch_popup(&response).show(|ui| { - if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::Opaque) { - let color = egui::Rgba::from(hsva); - *rgb = [color.r(), color.g(), color.b()]; - response.mark_changed(); - } - if let Some(color) = crate::screen_picker::button(ui, id) { - *rgb = to_linear(color); - hsva = egui::ecolor::Hsva::from(egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2])); - response.mark_changed(); - } - }); - ui.ctx().data_mut(|d| d.insert_temp(cache, (egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2]), hsva))); + if response.clicked() { + stash_opening(ui, &response, rgb); + let encoded = rgb.map(|c| if c.is_finite() { egui::ecolor::gamma_from_linear(c.clamp(0.0, 1.0)) } else { 0.0 }); + crate::color_picker_ui::request_for_widget(ui.ctx(), response.id, title, encoded); + } response } diff --git a/crates/ui-egui/src/widgets/color_button_tests.rs b/crates/ui-egui/src/widgets/color_button_tests.rs new file mode 100644 index 0000000000..400b4c630f --- /dev/null +++ b/crates/ui-egui/src/widgets/color_button_tests.rs @@ -0,0 +1,122 @@ +//! The colour swatch buttons open the full Color Picker dialog. +use super::*; +use egui_kittest::{Harness, kittest::Queryable}; + +/// The app, the swatch's value, and whether the swatch committed this frame (changed with its +/// picker closed: a host commits then). +type State = (crate::PhotocraftApp, T, bool); + +/// Draw `swatch` with the dialogs, the frame loop's [`crate::color_picker_ui::widget_tick`] first. +fn harness(value: T, swatch: fn(&mut Ui, &mut T) -> Response) -> Harness<'static, State> { + let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + move |ui, state: &mut State| { + crate::color_picker_ui::widget_tick(&mut state.0, ui.ctx()); + let r = swatch(ui, &mut state.1); + state.2 = r.changed() && !crate::color_picker_ui::widget_picker_open(ui.ctx(), r.id); + crate::dialogs::show(&mut state.0, ui.ctx()); + }, + (app, value, false), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(2); + h +} + +/// Open the swatch's picker and show `hex` in it. +fn show(h: &mut Harness<'static, State>, hex: &str) { + h.get_by_label("Color Picker (Glow Color)").click(); + h.run_steps(3); + let d = h.state().0.ui.dialogs.last().expect("the swatch opens the Color Picker dialog"); + assert_eq!(d.fields["__label"], "Color Picker (Glow Color)"); + let id = d.id; + h.state_mut().0.ui.dialog_mut(id).unwrap().fields.insert("color".into(), serde_json::json!(hex)); + h.run_steps(1); +} + +fn pick(h: &mut Harness<'static, State>, hex: &str, ok: bool) { + show(h, hex); + h.get_by_label(if ok { "OK" } else { "Cancel" }).click(); + h.run_steps(1); +} + +#[test] +fn swatch_opens_the_full_picker_and_ok_sets_the_colour_once_keeping_alpha() { + for (original, expected) in [ + (Color32::from_rgba_unmultiplied(40, 50, 60, 128), Color32::from_rgba_unmultiplied(255, 0, 128, 128)), + (Color32::from_rgb_additive(40, 50, 60), Color32::from_rgb_additive(255, 0, 128)), + ] { + let mut h = harness(original, |ui, c| color_edit_button_srgba(ui, c, "Color Picker (Glow Color)")); + pick(&mut h, "#336699", false); + h.run_steps(3); + assert_eq!(h.state().1, original, "Cancel leaves the colour"); + assert!(h.state().0.ui.dialogs.is_empty()); + pick(&mut h, "#ff0080", true); + let mut changed = 0; + for _ in 0..4 { + h.step(); + changed += usize::from(h.state().2); + } + assert_eq!(changed, 1, "the pick commits once, when the picker closes"); + assert_eq!(h.state().1, expected); + assert_eq!(crate::paint_picker::recent_colors(&h.state().0.session).first().copied().map(crate::color_picker_ui::hex).as_deref(), Some("#ff0080")); + } +} + +#[test] +fn float_swatch_keeps_precision_until_a_new_colour_and_stores_linear() { + let original = [0.123456, 0.234567, 0.345678]; + let mut h = harness(original, |ui, c| color_edit_button_rgb(ui, c, "Color Picker (Glow Color)")); + h.get_by_label("Color Picker (Glow Color)").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(h.state().1, original, "OK on the opening colour must not quantize to 8 bits"); + pick(&mut h, "#8040ff", true); + h.run_steps(3); + for (actual, expected) in h.state().1.iter().zip([0.21586053, 0.051269468, 1.0]) { + assert!((actual - expected).abs() < 0.00001, "{actual} vs {expected}"); + } +} + +#[test] +fn eight_bit_swatch_is_opaque() { + let mut h = harness([10u8, 20, 30], |ui, c| color_edit_button_srgb(ui, c, "Color Picker (Glow Color)")); + pick(&mut h, "#abcdef", true); + h.run_steps(3); + assert_eq!(h.state().1, [0xab, 0xcd, 0xef]); +} + +#[test] +fn swatch_follows_the_open_picker_and_cancel_restores_the_exact_value() { + let original = [0.123456, 0.234567, 0.345678]; + let mut h = harness(original, |ui, c| color_edit_button_rgb(ui, c, "Color Picker (Glow Color)")); + show(&mut h, "#8040ff"); + h.run_steps(2); + assert_eq!(h.state().0.ui.dialogs.len(), 1, "the picker is still open"); + for (actual, expected) in h.state().1.iter().zip([0.21586053, 0.051269468, 1.0]) { + assert!((actual - expected).abs() < 0.00001, "the swatch previews the picker's colour: {actual} vs {expected}"); + } + h.get_by_label("Cancel").click(); + let mut committed = 0; + for _ in 0..4 { + h.step(); + committed += usize::from(h.state().2); + } + assert_eq!(h.state().1, original, "Cancel restores the float exactly"); + assert_eq!(committed, 1, "closing the picker is one commit frame"); + // OK on the opening colour after a preview also goes back to the exact value. + show(&mut h, "#8040ff"); + h.state_mut() + .0 + .ui + .dialogs + .last_mut() + .unwrap() + .fields + .insert("color".into(), serde_json::json!(crate::color_picker_ui::hex(original.map(egui::ecolor::gamma_from_linear)))); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(h.state().1, original, "OK unchanged keeps the float exactly"); +} diff --git a/crates/ui-egui/src/widgets/fields.rs b/crates/ui-egui/src/widgets/fields.rs index 439662724e..c0030bd9c8 100644 --- a/crates/ui-egui/src/widgets/fields.rs +++ b/crates/ui-egui/src/widgets/fields.rs @@ -48,9 +48,11 @@ pub fn compact_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeI value_field_box(ui, value, range, suffix, vec2(width, height), 0.0, FieldOpts::default(), None).0 } -/// A compact field whose mouse scrub wraps at the native fullscreen viewport's horizontal edges. +/// A compact field whose mouse scrub wraps at the native fullscreen viewport's horizontal edges, +/// and whose typed number applies on Enter or click-away, never on Esc, Tab or Alt: the zoom +/// fields (see `zoom_field`). pub(crate) fn compact_wrapping_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, size: Vec2) -> Response { - value_field_box(ui, value, range, suffix, size, 0.0, FieldOpts { wrap_drag: true, ..Default::default() }, None).0 + value_field_box(ui, value, range, suffix, size, 0.0, FieldOpts { wrap_drag: true, hold_typed: true, ..Default::default() }, None).0 } /// How a [`value_field_in`] shows and reads its number, beyond its suffix. @@ -65,6 +67,10 @@ pub(super) struct FieldOpts { /// The length (px) 100 % stands for, so lengths and percentages convert into each other. pub(super) extent: Option, pub(super) wrap_drag: bool, + /// A typed number waits for Enter or click-away, as arithmetic does, instead of applying per + /// keystroke (the zoom fields: typing `150` over `50` must not zoom to 1 % on the way). Esc, + /// Tab and Alt do not apply it (`zoom_field`). + pub(super) hold_typed: bool, } /// Arrow-key and drag step for a value field: [`FieldOpts::step_from_decimals`] uses `decimals`; @@ -110,7 +116,7 @@ fn value_field_box( opts: FieldOpts, name: Option<&str>, ) -> (Response, Rect) { - let FieldOpts { decimals, step_from_decimals, units, extent, wrap_drag } = opts; + let FieldOpts { decimals, step_from_decimals, units, extent, .. } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let last_editor = ui.data(|d| d.get_temp::(slot.id)); @@ -190,7 +196,7 @@ fn value_field_box( ui.spacing_mut().button_padding = Vec2::ZERO; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let layout = egui::Layout::centered_and_justified(ui.layout().main_dir()); - ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals, units, wrap_drag)).inner + ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, units, opts)).inner }) .inner } @@ -367,18 +373,34 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o pub(super) const NUMERIC_INPUT_LIMIT: usize = 128; -/// The number in a [`value_field`]. A typed number applies as it's typed; arithmetic waits for -/// Enter, Tab or click-away, because per keystroke `5/2` would land first and a caller that -/// rounds it would cut `5/2*2` short. `changed()` means a new value, not just a keystroke. +/// Drops text events longer than [`NUMERIC_INPUT_LIMIT`] from this frame's input: oversized events +/// must never reach text layout. +fn drop_oversized_input(ui: &Ui) { + ui.input_mut(|input| { + input.events.retain(|event| { + let text = match event { + egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => text, + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => text, + _ => return true, + }; + text.chars().nth(NUMERIC_INPUT_LIMIT).is_none() + }); + }); +} + +/// The number in a [`value_field`]. A typed number applies as it's typed, unless `opts.hold_typed`; +/// arithmetic always waits for Enter, Tab or click-away, because per keystroke `5/2` would land +/// first and a caller that rounds it would cut `5/2*2` short. `changed()` means a new value, not +/// just a keystroke. fn number_edit( ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, fine: bool, - decimals: Option, units: crate::numeric_expression::Units, - wrap_drag: bool, + opts: FieldOpts, ) -> Response { + let FieldOpts { decimals, wrap_drag, hold_typed, .. } = opts; let (id, ctx) = (ui.next_auto_id(), ui.ctx().clone()); // Focused before the DragValue is drawn, so it gains focus this frame and selects its text. if ui.data_mut(|d| d.remove_temp::(first_field_id())).unwrap_or(false) { @@ -388,6 +410,32 @@ fn number_edit( if ui.is_enabled() { crate::field_tab::register(&ctx, id); } + // Esc, Tab, a click on the pasteboard, the title bar or an open menu, and a window that lost the + // keyboard leave what was typed unapplied (zoom_field.rs). This runs before the field registers its interest in + // the focus: Tab would otherwise already have handed the focus on to the next widget. + // A zoom field shows its number with the system's decimal separator (`12,5` on a German + // Windows), as Photoshop does, so the text it shows is one that can be typed. + let format = move |v: f64| { + let text = match decimals { + Some(d) => fmt_num_prec(v, d), + None if fine => fmt_num2(v), + None => fmt_num(v), + }; + if hold_typed { crate::zoom_field::localize(text) } else { text } + }; + let guard = if hold_typed { + let shown = format(f64::from(*value)); + // The zoom field reads the keys here, before the check below: oversized events go first. + if ui.memory(|m| m.has_focus(id)) { + drop_oversized_input(ui); + } + // Tab and a click that keeps the typed number write it as the zoom Enter would make of it. + let (lo, hi) = (f64::from(*range.start()), f64::from(*range.end())); + let canon = move |t: &str| zoom_number(t, units).filter(|v| v.is_finite()).map(|v| format(if lo <= hi { v.clamp(lo, hi) } else { v })); + crate::zoom_field::before(ui, id, &shown, &canon) + } else { + Default::default() + }; // Use DragValue's focus check before it builds its TextEdit: oversized events must never // reach text layout. Registering interest also handles focus arriving by Tab this frame. let editing = ui.is_enabled() @@ -396,16 +444,7 @@ fn number_edit( m.has_focus(id) }); if editing { - ui.input_mut(|input| { - input.events.retain(|event| { - let text = match event { - egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => text, - egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => text, - _ => return true, - }; - text.chars().nth(NUMERIC_INPUT_LIMIT).is_none() - }); - }); + drop_oversized_input(ui); } let held = id.with("arithmetic"); let math = ui.memory(|m| m.has_focus(id)) && ui.data(|d| d.get_temp(held)).unwrap_or(false); @@ -414,27 +453,45 @@ fn number_edit( let speed = if fine { 0.01 } else { 0.5 }; let wrapping = wrap_drag && crate::scrub_wrap::active(ui); let wrapped_drag = wrapping && ui.input(|i| i.pointer.primary_down()); + // A zoom field edits a copy: what egui reports as typed is applied below, when nothing vetoes + // it (Esc, Tab, a click that keeps it, a window that lost the keyboard), whatever egui itself + // does with the key or the click. + let mut typed = *value; + let target: &mut f32 = if hold_typed { &mut typed } else { &mut *value }; let mut resp = ui.add( - egui::DragValue::new(value) + egui::DragValue::new(target) .range(range.clone()) // Only edits are clamped (below): showing a value past the range, such as a type layer // scaled to 2400 pt in a 1296 pt field, must not change it (#5717). .clamp_existing_to_range(false) // A warp is an absolute-position jump. The scoped scrub below removes that jump. .speed(if wrapped_drag { 0.0 } else { speed }) - .custom_formatter(move |v, _| match decimals { - Some(d) => fmt_num_prec(v, d), - None if fine => fmt_num2(v), - None => fmt_num(v), - }) - .update_while_editing(!math) + .custom_formatter(move |v, _| format(v)) + .update_while_editing(!math && !hold_typed) // Focus is read when parsing, not above: Tab hands it on inside `ui.add`. .custom_parser(move |s| { // Short events can accumulate: reject before either parser normalizes the text. if s.chars().nth(NUMERIC_INPUT_LIMIT).is_some() { return None; } - let v = parse_num_in(s, units); + // A zoom field takes `,` or `.` as its decimal point (`100,90` is `100.90`) and reads + // a division as a ratio (`1/2` is 50 %, `100/5,0` is 2000 %), as Photoshop does. With + // a second separator (`80,,2`) it says the entry is invalid and takes the number + // before it (80 %). + let ready = if hold_typed { + match crate::zoom_field::read(s) { + crate::zoom_field::Entry::Number(ready) => ready, + crate::zoom_field::Entry::Invalid(lead) => { + crate::zoom_field::note_invalid(&ctx, id); + ctx.data_mut(|d| d.insert_temp(held, false)); + return lead.and_then(|lead| zoom_number(&lead, units)); + } + } + } else { + s.to_owned() + }; + let s = ready.as_str(); + let v = if hold_typed { zoom_number(s, units) } else { parse_num_in(s, units) }; if ctx.memory(|m| m.has_focus(id)) && plain(s).is_none() { // Still typing arithmetic: hold it, and stop applying keystrokes once it parses. ctx.data_mut(|d| d.insert_temp(held, v.is_some())); @@ -443,6 +500,18 @@ fn number_edit( v }), ); + if hold_typed { + // A scrub always applies; a typed value applies unless this frame's guard vetoes it. + let scrub = resp.dragged() || resp.drag_started() || resp.drag_stopped(); + if typed != before && (scrub || !guard.vetoes_typed()) { + *value = typed; + } + // An invalid entry that was applied (not one a vetoed commit read) gets Photoshop's message. + if crate::zoom_field::take_invalid(ui.ctx(), id) && !guard.vetoes_typed() { + crate::zoom_field::request_invalid_entry(ui.ctx(), (*range.start(), *range.end())); + } + crate::zoom_field::after(ui, id, guard); + } if wrap_drag { crate::scrub_wrap::update(ui, &resp, value, before, range.clone(), speed, wrapping); } @@ -455,6 +524,15 @@ fn number_edit( resp } +/// The zoom a zoom field's text stands for, with at most one decimal separator (`,` or `.`): a +/// number, or a ratio (`1/2` is 50 %, `5/2` is 250 %). `None` when it is neither, or holds a +/// second separator (`zoom_field::read` takes that apart). +fn zoom_number(text: &str, units: crate::numeric_expression::Units) -> Option { + let crate::zoom_field::Entry::Number(ready) = crate::zoom_field::read(text) else { return None }; + let v = parse_num_in(&ready, units); + if ready.contains('/') { v.map(|r| r * 100.0) } else { v } +} + /// Increments a numerical field with the up/down arrow keys. Increments by 1 by default, 10 with shift, and 0.1 with ctrl/cmd. /// Returns the amount to add, and what to round the value to first. fn arrow_step(ui: &mut Ui, slot: egui::Id, step: f32) -> (f32, f32) { diff --git a/crates/ui-egui/src/widgets/press_menu.rs b/crates/ui-egui/src/widgets/press_menu.rs index 4211464ed6..16db443c53 100644 --- a/crates/ui-egui/src/widgets/press_menu.rs +++ b/crates/ui-egui/src/widgets/press_menu.rs @@ -98,12 +98,17 @@ pub fn above_parent_ui(parent: egui::LayerId, popup_ui: &egui::Ui) { /// 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 { + show_with_close_behavior(button, PopupCloseBehavior::CloseOnClick, content) +} + +/// A press menu whose content owns its close behavior (e.g. a scrollable font list). +pub fn show_with_close_behavior(button: &Response, close_behavior: PopupCloseBehavior, content: impl FnOnce(&mut Ui, &PressMenu) -> R) -> Option { let ctx = button.ctx.clone(); let menu = PressMenu { popup: Popup::default_response_id(button) }; let open = open_command(button, menu.popup); // The release ending the press that opened the menu is a click outside the popup: it must not // close it again. - let close = if button.clicked() && menu.owns_gesture(&ctx) { PopupCloseBehavior::IgnoreClicks } else { PopupCloseBehavior::CloseOnClick }; + let close = if button.clicked() && menu.owns_gesture(&ctx) { PopupCloseBehavior::IgnoreClicks } else { close_behavior }; 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)); diff --git a/crates/ui-egui/src/widgets/slider.rs b/crates/ui-egui/src/widgets/slider.rs index c4112fecb5..72f2987a41 100644 --- a/crates/ui-egui/src/widgets/slider.rs +++ b/crates/ui-egui/src/widgets/slider.rs @@ -1,4 +1,4 @@ -use super::fields::{FieldOpts, value_field_in}; +use super::fields::{FieldOpts, VALUE_FIELD_H, value_field_in}; use super::panel::surface; use super::scrub::scrub_label_within; use super::{Color32, Pos2, Rect, Response, Sense, Stroke, Tokens, Ui, Vec2, pos2, vec2}; @@ -287,3 +287,46 @@ pub fn slider_row_prec( ui.add_space(4.0); r } + +/// A dialog row's label, right-aligned in a column `width` wide, so the controls after it line up +/// (Photoshop's label column in Layer Style). A label too long for it, as some translations are, +/// is cut short and shows in full on hover. +pub fn row_label(ui: &mut Ui, label: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + ui.allocate_ui_with_layout(vec2(width, VALUE_FIELD_H), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_width(width); + ui.add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).truncate()) + }) + .inner +} + +/// [`slider_row`] on one line, as Photoshop lays out Layer Style: the label in a `label_w` column +/// ([`row_label`]), the slider, then the value field. The label still scrubs the field. +pub fn slider_line(ui: &mut Ui, label: &str, label_w: f32, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str) -> Response { + const FIELD_W: f32 = 64.0; + let mut field = None; + let s = ui + .horizontal(|ui| { + let l = row_label(ui, label, label_w); + scrub_label_within(ui, &l, f32::INFINITY); + let w = (ui.available_width() - FIELD_W - ui.spacing().item_spacing.x).max(60.0); + let s = ui + .allocate_ui_with_layout(vec2(w, VALUE_FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| { + slider_with(ui, value, range.clone(), None, RowGestures::default()) + }) + .inner; + field = Some(value_field_in(ui, value, range.clone(), suffix, FIELD_W, 0.0, FieldOpts::default(), Some(label)).0); + s + }) + .inner; + let mut r = s.clone(); + if let Some(v) = field { + if v.changed() { + r.mark_changed(); + } + // A scrub of the field or its label is one drag, like the slider's, for history. + use egui::response::Flags; + r.flags |= v.flags & (Flags::DRAG_STARTED | Flags::DRAGGED | Flags::DRAG_STOPPED); + } + r +} diff --git a/crates/ui-egui/src/widgets/tab_strip.rs b/crates/ui-egui/src/widgets/tab_strip.rs index 728486e973..eb40e09309 100644 --- a/crates/ui-egui/src/widgets/tab_strip.rs +++ b/crates/ui-egui/src/widgets/tab_strip.rs @@ -44,7 +44,7 @@ pub fn overflow_button(ui: &mut Ui, id: egui::Id, r: Rect, tip: &str, labels: &[ if resp.hovered() { paint_ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6)); } - crate::icons::paint(&paint_ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim }); + crate::icons::paint(&paint_ui, r, "chevrons-right", 16.0, t.text); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tip)); let resp = resp.on_hover_text(tip); egui::Popup::menu(&resp).show(|ui| { diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs index f0971e053d..d4c1bd81b3 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -834,6 +834,433 @@ fn value_field_holds_arithmetic_until_committed() { assert_eq!(*h.state(), (25.0, 3)); } +/// What stands in for the app around a zoom field in the tests below. +#[derive(Clone, Copy, PartialEq)] +enum EscEater { + /// Nothing takes Esc. + Nobody, + /// Like `shortcuts::handle`: Esc is taken when no text field has the keyboard. + Shortcuts, + /// Esc is taken before the field can see it, whatever the focus. + Always, +} + +/// The zoom field alone, with a button after it (so Tab has somewhere to go). The state is the +/// field's value and how many times it reported a change. `on_pasteboard` stands in for +/// `zoom_field::tick`: a press at the right of the window is a press on the pasteboard. +fn zoom_harness(start: f32, eater: EscEater, on_pasteboard: bool) -> egui_kittest::Harness<'static, (f32, u32)> { + egui_kittest::Harness::builder().with_size(egui::vec2(300.0, 100.0)).build_ui_state( + move |ui, (v, changes): &mut (f32, u32)| { + let ctx = ui.ctx().clone(); + let typing = ctx.text_edit_focused() || crate::zoom_field::editing(&ctx); + if eater == EscEater::Always || (eater == EscEater::Shortcuts && !typing) { + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + } + let pressed = on_pasteboard && ctx.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| p.x > 150.0)); + crate::zoom_field::track_pasteboard(&ctx, pressed); + if super::compact_wrapping_value_field(ui, v, 0.0..=12800.0, "%", egui::vec2(90.0, 20.0)).accessible_name("Zoom").changed() { + *changes += 1; + } + let _ = ui.button("next"); + }, + (start, 0), + ) +} + +fn zoom_field_node<'a>(h: &'a egui_kittest::Harness<'static, (f32, u32)>) -> egui_kittest::Node<'a> { + use egui_kittest::kittest::Queryable; + h.get_by_role(egui::accesskit::Role::SpinButton) +} + +/// Clicks the field and types `text` over its selected number, one key at a time. +fn click_and_type(h: &mut egui_kittest::Harness<'static, (f32, u32)>, text: &str) { + zoom_field_node(h).click(); + h.run(); + for c in text.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } +} + +/// A button down (and up) at `pos`. +fn click_at(h: &mut egui_kittest::Harness<'static, (f32, u32)>, pos: egui::Pos2, button: egui::PointerButton) { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button, pressed, modifiers: egui::Modifiers::NONE }); + h.run(); + } +} + +/// The zoom field applies a typed number once, on Enter, however many digits it has: never the +/// number its first keys spell on the way (`1`, `12` on the way to `1200`), and always clamped. +#[test] +fn a_typed_zoom_is_applied_once_on_enter() { + // (zoom before, typed, zoom after). The range is 0..=12800. + let cases = [(100.0, "200", 200.0), (3.0, "1200", 1200.0), (800.0, "5", 5.0), (25.0, "99999", 12800.0), (7.0, "7", 7.0)]; + for (before, typed, after) in cases { + let mut h = zoom_harness(before, EscEater::Shortcuts, false); + click_and_type(&mut h, typed); + assert_eq!(*h.state(), (before, 0), "{typed} over {before}: nothing applies while typing"); + h.key_press(egui::Key::Enter); + h.run(); + let changes = u32::from(after != before); + assert_eq!(*h.state(), (after, changes), "{typed} over {before}: Enter applies it once"); + } +} + +/// Esc drops what was typed, whoever else wants the key, and the field is the zoom again. +#[test] +fn esc_drops_the_typed_zoom() { + for eater in [EscEater::Nobody, EscEater::Shortcuts, EscEater::Always] { + let mut h = zoom_harness(200.0, eater, false); + click_and_type(&mut h, "40"); + click_and_type_more(&mut h, "50"); + h.key_press(egui::Key::Escape); + h.run(); + assert_eq!(*h.state(), (200.0, 0), "Esc does not apply 40 or 50"); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "Esc leaves the field"); + // Nothing typed is left to come back with the next click and Enter. + zoom_field_node(&h).click(); + h.run(); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (200.0, 0), "the field shows the zoom, 200, again"); + } +} + +/// Types more into the field that already has the keyboard. +fn click_and_type_more(h: &mut egui_kittest::Harness<'static, (f32, u32)>, text: &str) { + // The first number is still selected only when the field was just reached: select it as + // Photoshop's user would, with Ctrl+A. + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run(); + for c in text.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } +} + +/// Tab (and ⇧Tab) in the zoom field: the alert, the text selected, the focus kept, nothing applied. +#[test] +fn tab_in_the_zoom_field_alerts_and_selects_the_text() { + use egui_kittest::kittest::Queryable; + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::SHIFT] { + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "200"); + assert!(!crate::zoom_field::alert_pending(&h.ctx), "typing is silent"); + h.key_press_modifiers(modifiers, egui::Key::Tab); + h.run_steps(2); + assert_eq!(*h.state(), (100.0, 0), "Tab leaves the zoom alone"); + assert!(zoom_field_node(&h).is_focused(), "and the focus in the field"); + assert!(!h.get_by_label("next").is_focused(), "not on the next widget"); + assert!(crate::zoom_field::alert_pending(&h.ctx), "and asks for the alert"); + // The text is selected: this replaces 200 instead of making it 2003. + for c in "3".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (3.0, 1)); + } +} + +/// A zoom field reads `1/2` as 50 % and `5/2` as 250 %, like Photoshop. +#[test] +fn a_zoom_field_reads_a_division_as_a_ratio() { + for (typed, after) in [("1/2", 50.0), ("5/2", 250.0), ("4/1", 400.0)] { + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, typed); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (after, 1), "{typed}"); + } +} + +/// Where the system's decimal separator is not known, `,` and `.` are both a decimal point in a +/// zoom field: `100,90` is `100.90`. +#[test] +fn a_zoom_field_reads_a_comma_as_a_decimal_point() { + for (typed, after) in [("100,90", 100.9), ("100.90", 100.9), ("12,5", 12.5), ("200,", 200.0), ("100/5,0", 2000.0), ("100/5.0", 2000.0)] { + let mut h = zoom_harness(50.0, EscEater::Shortcuts, false); + click_and_type(&mut h, typed); + assert!(!crate::zoom_field::alert_pending(&h.ctx), "{typed}: silent"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (after, 1), "{typed}"); + } +} + +/// The system's decimal separator is the zoom field's, as in Photoshop: on a German Windows a typed +/// `.` is refused with the alert (and not inserted) and `,` is the decimal point; on an English one +/// the other way round. Ratios follow it (`100/5,0` is 2000 % on German). +#[test] +fn a_zoom_field_takes_only_the_systems_decimal_separator() { + // (system separator, typed, alert, zoom after Enter) + let cases = [ + (',', "12,5", false, 12.5), + (',', "12.5", true, 125.0), + (',', "100/5,0", false, 2000.0), + (',', "100/5.0", true, 200.0), + ('.', "12.5", false, 12.5), + ('.', "12,5", true, 125.0), + ('.', "100/5.0", false, 2000.0), + ('.', "100/5,0", true, 200.0), + ]; + for (sep, typed, alert, after) in cases { + crate::i18n::with_decimal_separator(Some(sep), || { + let mut h = zoom_harness(50.0, EscEater::Shortcuts, false); + click_and_type(&mut h, typed); + assert_eq!(crate::zoom_field::alert_pending(&h.ctx), alert, "{sep} {typed}: alert"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (after, 1), "{sep} {typed}"); + assert_eq!(crate::zoom_field::invalid_entry_pending(&h.ctx), None, "{sep} {typed}: a valid entry"); + }); + } +} + +/// The field shows its zoom with the system's decimal separator (Photoshop's `12,5%`), so its own +/// text is one that can be typed. +#[test] +fn a_zoom_field_shows_the_systems_decimal_separator() { + use egui_kittest::kittest::Queryable; + for (sep, shown) in [(Some(','), "12,5"), (Some('.'), "12.5"), (None, "12.5")] { + crate::i18n::with_decimal_separator(sep, || { + let mut h = zoom_harness(12.5, EscEater::Shortcuts, false); + h.run(); + let text = h.get_by_role(egui::accesskit::Role::SpinButton).value(); + assert!(text.as_deref().is_some_and(|t| t.contains(shown)), "{sep:?}: {text:?}"); + }); + } +} + +/// A second separator can be typed (`80,,2`), silently. Enter asks for Photoshop's "Invalid numeric +/// entry" message, with the field's range, and applies the number before the second separator. +#[test] +fn a_second_separator_is_typed_and_enter_applies_the_number_before_it_with_the_message() { + // (system separator, typed, zoom after Enter) + let cases = [ + (Some(','), "80,,2", 80.0), + (Some(','), "1,5,2", 1.5), + (Some(','), "100/5,0,1", 2000.0), + (Some('.'), "80..2", 80.0), + (None, "1,000.5", 1.0), + (None, "12.5,", 12.5), + ]; + for (sep, typed, after) in cases { + crate::i18n::with_decimal_separator(sep, || { + let mut h = zoom_harness(50.0, EscEater::Shortcuts, false); + click_and_type(&mut h, typed); + assert!(!crate::zoom_field::alert_pending(&h.ctx), "{typed}: typed, silently"); + assert_eq!(crate::zoom_field::invalid_entry_pending(&h.ctx), None, "{typed}: no message before Enter"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (after, 1), "{typed}: the number before the second separator"); + assert_eq!(crate::zoom_field::invalid_entry_pending(&h.ctx), Some((0.0, 12800.0)), "{typed}: the message, with the range"); + }); + } + // The number before it is the zoom already: nothing changes, but the message still comes. + let mut h = zoom_harness(80.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "80,,2"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (80.0, 0)); + assert!(crate::zoom_field::invalid_entry_pending(&h.ctx).is_some()); + // No number before it at all: the zoom stays, with the message. + let mut h = zoom_harness(80.0, EscEater::Shortcuts, false); + click_and_type(&mut h, ",,5"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (80.0, 0)); + assert!(crate::zoom_field::invalid_entry_pending(&h.ctx).is_some()); +} + +/// Esc, or Tab, with a second separator typed: no message, nothing applied. +#[test] +fn esc_and_tab_with_a_second_separator_show_no_message() { + for key in [egui::Key::Escape, egui::Key::Tab] { + let mut h = zoom_harness(50.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "80,,2"); + h.key_press(key); + h.run_steps(2); + assert_eq!(*h.state(), (50.0, 0), "{key:?}"); + assert_eq!(crate::zoom_field::invalid_entry_pending(&h.ctx), None, "{key:?}"); + } +} + +/// A paste is filtered like typing: the other separator is dropped with the alert (`100/5.0` on a +/// German system is `100/50`), and a second separator stays for Enter's message. +#[test] +fn a_paste_follows_the_typing_rules() { + crate::i18n::with_decimal_separator(Some(','), || { + let mut h = zoom_harness(150.0, EscEater::Shortcuts, false); + zoom_field_node(&h).click(); + h.run(); + h.event(egui::Event::Paste("100/5.0".into())); + h.run(); + assert!(crate::zoom_field::alert_pending(&h.ctx), "the `.` asks for the alert"); + assert_eq!(*h.state(), (150.0, 0), "and the paste applies nothing yet"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (200.0, 1), "100/50"); + assert_eq!(crate::zoom_field::invalid_entry_pending(&h.ctx), None); + + let mut h = zoom_harness(150.0, EscEater::Shortcuts, false); + zoom_field_node(&h).click(); + h.run(); + h.event(egui::Event::Paste("80,,2".into())); + h.run(); + assert!(!crate::zoom_field::alert_pending(&h.ctx), "a second separator is pasted silently, as it is typed"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (80.0, 1)); + assert!(crate::zoom_field::invalid_entry_pending(&h.ctx).is_some(), "Enter's message"); + }); +} + +/// Tab writes the typed number as the field shows a zoom (`045` is `45`, `45,0` is `45`, `1/2` is +/// `50`, past the range is its end) and selects it, without applying it. +#[test] +fn tab_writes_the_typed_number_as_the_zoom_it_makes() { + use egui_kittest::kittest::Queryable; + for (typed, shown) in [("045", "45"), ("45,0", "45"), ("45.50", "45.5"), ("1/2", "50"), ("99999", "12800"), ("80,,2", "80,,2")] { + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, typed); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert_eq!(*h.state(), (100.0, 0), "{typed}: Tab applies nothing"); + let text = h.get_by_role(egui::accesskit::Role::SpinButton).value(); + assert_eq!(text.as_deref(), Some(shown), "{typed}"); + // Selected: a digit replaces it. + h.event(egui::Event::Text("7".into())); + h.run(); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (7.0, 1), "{typed}: the text was selected"); + } + // With the system's comma, the comma is kept. + crate::i18n::with_decimal_separator(Some(','), || { + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "045,50"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert_eq!(h.get_by_role(egui::accesskit::Role::SpinButton).value().as_deref(), Some("45,5")); + }); + // The copy follows Backspace: `450` with the `0` deleted is `45`. + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "0450"); + h.key_press(egui::Key::Backspace); + h.run(); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert_eq!(h.get_by_role(egui::accesskit::Role::SpinButton).value().as_deref(), Some("45")); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (45.0, 1), "Enter then applies it"); +} + +/// After a key the field's copy of the text can't follow (a word deletion, an undo), Tab only +/// selects the text: it never writes the digits typed since over the whole of it. +#[test] +fn tab_after_a_word_deletion_leaves_the_text_as_it_is() { + use egui_kittest::kittest::Queryable; + for (mods, key) in [(egui::Modifiers::COMMAND, egui::Key::Backspace), (egui::Modifiers::COMMAND, egui::Key::Z)] { + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "450"); + h.key_press(egui::Key::End); + h.run(); + h.key_press_modifiers(mods, key); + h.run(); + h.key_press(egui::Key::End); + h.run(); + for c in "05".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } + let typed = h.get_by_role(egui::accesskit::Role::SpinButton).value(); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert_eq!(h.get_by_role(egui::accesskit::Role::SpinButton).value(), typed, "{key:?}: Tab leaves {typed:?} as it is"); + assert_eq!(*h.state(), (100.0, 0), "{key:?}"); + } +} + +/// A click on the pasteboard writes the typed number like Tab does. +#[test] +fn a_click_on_the_pasteboard_writes_the_typed_number_as_the_zoom_it_makes() { + use egui_kittest::kittest::Queryable; + let mut h = zoom_harness(40.0, EscEater::Shortcuts, true); + click_and_type(&mut h, "045,0"); + click_at(&mut h, egui::pos2(250.0, 80.0), egui::PointerButton::Primary); + assert_eq!(*h.state(), (40.0, 0), "the zoom stays"); + assert_eq!(h.get_by_role(egui::accesskit::Role::SpinButton).value().as_deref(), Some("45")); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (45.0, 1)); +} + +/// Only digits, the decimal separator and `/` go into a zoom field: any other key (`%` too, which +/// is drawn beside the field) asks for the alert and leaves the text as it was. +#[test] +fn a_zoom_field_takes_digits_and_separators_only() { + let mut h = zoom_harness(100.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "2"); + assert!(!crate::zoom_field::alert_pending(&h.ctx), "a digit is silent"); + h.event(egui::Event::Text("a".into())); + h.run(); + assert!(crate::zoom_field::alert_pending(&h.ctx), "a letter asks for the alert"); + for c in "00".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (200.0, 1), "the letter was not inserted: 2, then 00"); +} + +/// A click, with either button, on the pasteboard keeps the typed zoom and selects it; a click +/// on anything else applies it, as before. +#[test] +fn a_click_on_the_pasteboard_selects_the_typed_zoom_and_applies_nothing() { + let away = egui::pos2(250.0, 80.0); + for button in [egui::PointerButton::Primary, egui::PointerButton::Secondary] { + let mut h = zoom_harness(40.0, EscEater::Shortcuts, true); + click_and_type(&mut h, "50"); + click_at(&mut h, away, button); + assert_eq!(*h.state(), (40.0, 0), "{button:?}: the zoom stays"); + assert!(zoom_field_node(&h).is_focused(), "{button:?}: the field keeps the keyboard"); + h.event(egui::Event::Text("7".into())); + h.run(); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), (7.0, 1), "{button:?}: the text was selected, 7 replaced 50"); + } + let mut h = zoom_harness(40.0, EscEater::Shortcuts, false); + click_and_type(&mut h, "50"); + click_at(&mut h, away, egui::PointerButton::Primary); + assert_eq!(*h.state(), (50.0, 1), "a click elsewhere applies the typed zoom"); +} + +/// Enter right after a click on the pasteboard, in the frames egui draws next (the release's frame, +/// one more for its events, then the Enter, with the mouse still): the typed zoom is applied. +#[test] +fn enter_right_after_a_click_on_the_pasteboard_applies_the_typed_zoom() { + let away = egui::pos2(250.0, 80.0); + let mut h = zoom_harness(40.0, EscEater::Shortcuts, true); + click_and_type(&mut h, "50"); + h.event(egui::Event::PointerMoved(away)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: away, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(1); + assert_eq!(*h.state(), (40.0, 0), "the click applied nothing"); + h.key_press(egui::Key::Enter); + h.run_steps(1); + assert_eq!(*h.state(), (50.0, 1), "Enter applies the typed 50"); +} + /// #5717: a value past the range shows as it is without reporting a change; an edit is clamped. #[test] fn value_field_keeps_a_shown_value_past_its_range_and_clamps_edits() { diff --git a/crates/ui-egui/src/widgets/text_field.rs b/crates/ui-egui/src/widgets/text_field.rs index b377fbf098..5e999c147a 100644 --- a/crates/ui-egui/src/widgets/text_field.rs +++ b/crates/ui-egui/src/widgets/text_field.rs @@ -1,5 +1,5 @@ use super::panel::surface; -use super::{Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, theme, vec2}; +use super::{Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, theme, vec2}; /// The most characters a [`text_field`] holds unless its caller sets a limit: far beyond any /// search, name or hex value, yet a pasted multi-gigabyte string is cut at the field instead of @@ -26,7 +26,8 @@ pub fn cap_text_events(events: &mut [egui::Event]) { } // The highlighted range indexes the old text; past the cut it would point nowhere. egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - if cut(text) && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) { + let truncated = cut(text); + if truncated && active_range_chars.as_ref().is_some_and(|r| r.end > INPUT_EVENT_LIMIT) { *active_range_chars = None; } } @@ -56,6 +57,24 @@ pub fn text_field(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, widt /// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say), or to /// [`TEXT_FIELD_LIMIT`] without one. pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool, char_limit: Option) -> Response { + text_field_in(ui, id, text, hint, [width, 0.0], mono, char_limit).0 +} + +/// A plain text field reserving space at its right for an embedded popup arrow. +/// Returns the editor and the full field rectangle; the arrow gets its own interaction id. +pub fn text_field_with_trailing(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, trailing: f32) -> (Response, Rect) { + text_field_in(ui, id, text, hint, [width, trailing], false, None) +} + +fn text_field_in( + ui: &mut Ui, + id: egui::Id, + text: &mut String, + hint: &str, + [width, trailing]: [f32; 2], + mono: bool, + char_limit: Option, +) -> (Response, Rect) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width.max(16.0), TEXT_FIELD_H), Sense::hover()); surface(ui, rect, t.field, false); @@ -63,7 +82,8 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; - let inner = rect.shrink2(TEXT_FIELD_PAD); + let mut inner = rect.shrink2(TEXT_FIELD_PAD); + inner.max.x = (inner.max.x - trailing).max(inner.min.x + 1.0); let limit = char_limit.unwrap_or(TEXT_FIELD_LIMIT); // A value already past the limit (restored from a state file, set by automation) is shown and // edited as its first `limit` characters, so it is never laid out whole; the caller's string @@ -96,7 +116,7 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w { *text = b; } - response + (response, rect) } pub(super) fn first_field_id() -> egui::Id { diff --git a/crates/ui-egui/src/widgets/tone.rs b/crates/ui-egui/src/widgets/tone.rs index 1c9aad9091..51cd340a21 100644 --- a/crates/ui-egui/src/widgets/tone.rs +++ b/crates/ui-egui/src/widgets/tone.rs @@ -15,9 +15,10 @@ use crate::theme::Tokens; static EMPTY: [u32; 256] = [0; 256]; -/// 256-bin histograms: the composite then one per channel of the space (RGB: the channel mean, -/// R, G, B; CMYK: composite, C, M, Y, K as channel brightness; Lab: L, a, b). `tag` identifies -/// what they were computed from (see [`HistSource`]). +/// 256-bin histograms: the composite then one per channel of the space (RGB: the channel mean, R, G, B; +/// CMYK: composite, C, M, Y, K as channel brightness; Lab: L, a, b). Native partial selections +/// ([`HistSource::Colors`]) always use combined, then native components, including Lab. +/// `tag` identifies what they were computed from (see [`HistSource`]). #[derive(Clone, Debug)] pub struct Histograms { pub tag: u64, @@ -48,6 +49,8 @@ impl std::ops::Index for Histograms { pub enum HistSource { /// The image below an adjustment layer (what the adjustment receives). BelowLayer(LayerId), + /// Native histograms of a partial colour selection, combined then individual. + Colors { layer: LayerId, colors: photocraft_engine::color_channels::ColorChannels }, /// One layer on its own (the target of a destructive Image › Adjustments command). Layer(LayerId), /// The gray plane a Curves target edits: a layer mask, a saved channel or the Quick Mask. @@ -67,6 +70,7 @@ pub const MAX_CACHE_LEVEL: u32 = 8; pub(crate) fn tag(source: HistSource, space: ToneSpace) -> u64 { let (k, id) = match source { HistSource::BelowLayer(l) => (1u64, l.0), + HistSource::Colors { layer, colors } => (8, layer.0 ^ colors.indices().fold(0u64, |m, k| m | (1u64 << k))), HistSource::Layer(l) => (2, l.0), HistSource::Channel { layer, surface } => match surface { photocraft_engine::channel_cmds::GraySurface::LayerMask => (3, layer.0), @@ -129,6 +133,24 @@ pub fn compute_histograms_at(doc: &Document, source: HistSource, space: ToneSpac } ]; match source { + HistSource::Colors { layer, colors } => { + h = vec![[0; 256]; 1 + doc.pixel_format().mode.color_channels()]; + if let Some(surface) = doc.layer(layer).and_then(Layer::surface) { + for k in colors.indices() { + { + let bins = component_bins(surface, doc.bounds(), k); + if let Some(channel) = h.get_mut(k + 1) { + *channel = bins; + } + if let Some(combined) = h.first_mut() { + for (a, b) in combined.iter_mut().zip(bins) { + *a = a.saturating_add(b); + } + } + } + } + } + } // The gray plane itself: its levels are what the curve edits, whatever the document's // colour mode (#5335). HistSource::Channel { layer, surface } => { @@ -206,7 +228,7 @@ fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, max_leve HistSource::UpTo(id) => { hide_above(&mut d.layers, id); } - HistSource::Channel { .. } => {} + HistSource::Channel { .. } | HistSource::Colors { .. } => {} } let level = cache_level(&d, max_level); let d = if level > 1 { photocraft_compose::proxy::proxy_document(&d, proxy_factor(&d, level)) } else { d }; @@ -301,6 +323,29 @@ fn count_pixels(px: &[[f32; 4]], space: ToneSpace, h: &mut [[u32; 256]]) { /// 256-bin histogram of a gray plane: its levels in 0–255, sampled like the engine's auto-tone /// histograms so a big canvas costs no more than about a megapixel here (#5335). +fn component_bins(surface: &photocraft_raster::Surface, area: photocraft_geom::Rect, component: usize) -> [u32; 256] { + let mut bins = [0u32; 256]; + let format = surface.format(); + let step = ((u64::from(area.width()) * u64::from(area.height())) / 1_000_000).max(1).isqrt().max(1) as usize; + for y in (area.y0..area.y1).step_by(step) { + for x in (area.x0..area.x1).step_by(step) { + if format.alpha { + let alpha = surface.sample_channel(x, y, format.mode.color_channels()); + if alpha.is_nan() || alpha <= 0.0 { + continue; + } + } + let v = surface.sample_channel(x, y, component); + let v = if surface.format().mode == photocraft_doc::ColorMode::Cmyk { 1.0 - v } else { v }; + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if let Some(bin) = bins.get_mut((v * 255.0).round() as usize) { + *bin = bin.saturating_add(1); + } + } + } + bins +} + fn gray_bins(surf: &photocraft_raster::Surface, area: photocraft_geom::Rect) -> [u32; 256] { let mut h = [0u32; 256]; let step = ((u64::from(area.width()) * u64::from(area.height())) / 1_000_000).max(1).isqrt().max(1) as i32; @@ -327,7 +372,7 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace) let want = tag(source, space); let layer = match source { HistSource::BelowLayer(l) | HistSource::Layer(l) | HistSource::UpTo(l) => l, - HistSource::Channel { layer, .. } => layer, + HistSource::Channel { layer, .. } | HistSource::Colors { layer, .. } => layer, }; let pixels_kept = view_only(st); if let Some((d, l, r, h)) = app.tone_hist.as_mut() @@ -416,6 +461,21 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn subset_histograms_skip_transparent_unstored_and_nan_alpha_pixels() { + let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); + let area = photocraft_geom::Rect::new(0, 0, 32, 24); + surface.fill_rect(photocraft_geom::Rect::new(0, 0, 16, 24), &[0.5, 0.4, 0.3, 1.0]); + let bins = component_bins(&surface, area, 0); + assert_eq!(bins.iter().sum::(), 16 * 24); + assert_eq!(bins[0], 0); + let nan = photocraft_raster::Surface::with_default( + photocraft_color::PixelFormat::new(photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::F32, true), + &[0.5, 0.4, 0.3, f32::NAN], + ); + assert_eq!(component_bins(&nan, area, 0), [0; 256]); + } + #[test] fn histogram_ignores_the_edited_adjustment() { let mut s = photocraft_engine::Session::new(); @@ -481,6 +541,7 @@ mod tests { HistSource::BelowLayer(layer), HistSource::Layer(layer), HistSource::UpTo(layer), + HistSource::Colors { layer, colors: photocraft_engine::color_channels::ColorChannels::from_indices([0, 2], 3).unwrap() }, HistSource::Channel { layer, surface: GraySurface::LayerMask }, HistSource::Channel { layer, surface: GraySurface::QuickMask }, HistSource::Channel { layer, surface: GraySurface::FilterMask }, diff --git a/crates/ui-egui/src/widgets/units.rs b/crates/ui-egui/src/widgets/units.rs index c4ea21dbde..d8da90b5fb 100644 --- a/crates/ui-egui/src/widgets/units.rs +++ b/crates/ui-egui/src/widgets/units.rs @@ -63,6 +63,21 @@ pub fn value_field_extent( value_field_in(ui, value, range, suffix, width, 0.0, opts, None).0 } +/// A [`value_field_extent`] showing the places [`value_field`](super::value_field) picks itself +/// (the Properties panel's Transform fields: `50%` typed into a px W is half of `extent`, #10784). +pub fn value_field_len( + ui: &mut Ui, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + width: f32, + units: FieldUnits, + extent: f64, +) -> Response { + let opts = FieldOpts { units: Some(units), extent: Some(extent), ..Default::default() }; + value_field_in(ui, value, range, suffix, width, 0.0, opts, None).0 +} + pub(super) fn field_resolution_id() -> egui::Id { egui::Id::unique("pc-field-resolution") } diff --git a/crates/ui-egui/tests/it/adjust_preview_gpu.rs b/crates/ui-egui/tests/it/adjust_preview_gpu.rs index d3880b04fd..4e8aa8552c 100644 --- a/crates/ui-egui/tests/it/adjust_preview_gpu.rs +++ b/crates/ui-egui/tests/it/adjust_preview_gpu.rs @@ -23,7 +23,9 @@ fn block_on(f: F) -> F::Output { } fn gpu() -> Option<(wgpu::Device, wgpu::Queue, Compositor)> { - let adapter = match block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())) { + let adapter = match block_on( + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()).request_adapter(&wgpu::RequestAdapterOptions::default()), + ) { Ok(a) => a, Err(e) => { eprintln!("skipping adjustment preview GPU parity: no adapter ({e})"); @@ -150,14 +152,14 @@ fn preview_layer_composites_on_the_gpu_like_the_command() { // may flip a few pixels sitting right at a step (1 % allowed). let skip = if matches!(kind, "threshold" | "posterize") && depth != SampleType::U8 { (b.width() * b.height()) as usize / 100 } else { 0 }; let on_gpu = render_to_vec(&mut comp, &device, &queue, &preview, b).unwrap_or_else(|e| panic!("{what}: {e:?}")); - let on_cpu = photocraft_compose::render(&preview, b).px; + let on_cpu = photocraft_compose::render(&preview, b).into_px(); let d = max_diff(&on_gpu, &on_cpu, skip); assert!(d <= tol_gpu, "{what}: GPU vs CPU {:.2}/255", d * 255.0); let mut s = photocraft_engine::Session::new(); s.add_document(doc.clone(), None); s.select_layer(target).unwrap(); s.execute(&format!("image.adjustments.{kind}"), params.clone()).unwrap(); - let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).px; + let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).into_px(); let d = max_diff(&on_gpu, &cmd, skip); worst = worst.max(d); assert!(d <= tol_cmd, "{what}: GPU preview vs command {:.2}/255", d * 255.0); @@ -182,7 +184,7 @@ fn proxy_preview_composites_on_the_gpu() { for kind in photocraft_ui_egui::adjust_editors::KINDS { let p = proxy_with_settings(&proxy, kind, &sample(kind)).unwrap(); let on_gpu = render_to_vec(&mut comp, &device, &queue, &p, p.bounds()).unwrap(); - let on_cpu = photocraft_compose::render(&p, p.bounds()).px; + let on_cpu = photocraft_compose::render(&p, p.bounds()).into_px(); let d = max_diff(&on_gpu, &on_cpu, 0); assert!(d <= 2.0 / 255.0, "{kind} selection {selection}: {:.2}/255", d * 255.0); } diff --git a/crates/ui-egui/tests/it/batch_export_options.rs b/crates/ui-egui/tests/it/batch_export_options.rs index 980331a91f..1890f6dd9e 100644 --- a/crates/ui-egui/tests/it/batch_export_options.rs +++ b/crates/ui-egui/tests/it/batch_export_options.rs @@ -290,7 +290,7 @@ fn batch_naming_controls_export_the_requested_names_and_reset_on_cancel() { edit_path(&mut h, "Output", &output); h.get_by_role_and_label(egui::accesskit::Role::TextInput, "File Naming").click(); h.run_steps(2); - replace_text(&mut h, "{serial}_{name}"); + replace_text(&mut h, "{serial}_{name}.{ext}"); h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Starting serial").click(); h.run_steps(2); h.event(egui::Event::Text("7".into())); diff --git a/crates/ui-egui/tests/it/camera_raw_localization.rs b/crates/ui-egui/tests/it/camera_raw_localization.rs index 7d91567591..eedaa6be93 100644 --- a/crates/ui-egui/tests/it/camera_raw_localization.rs +++ b/crates/ui-egui/tests/it/camera_raw_localization.rs @@ -28,6 +28,8 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ h.run_steps(3); let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, ThemeKind::Pro); + // Labels and retained state are under test, not the collapse animation. + h.state_mut().session.prefs.edit(|p| p.interface.enable_animations = false); h.state_mut().session.prefs.edit(|p| p.interface.language = lang.code().into()); h.run_steps(4); let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s); @@ -38,17 +40,17 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ } assert!(h.query_by_role_and_label(Role::Slider, tr("Blacks")).is_some()); h.get_by_role_and_label(Role::DisclosureTriangle, tr("Light")).click(); - h.run_steps(12); + h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color")).click(); - h.run_steps(12); + h.run_steps(2); assert!(h.query_by_role_and_label(Role::Label, tr("White Balance: As Shot")).is_some(), "{}: White Balance", lang.code()); for label in ["Temperature", "Tint", "Vibrance", "Saturation"] { assert!(h.query_by_role_and_label(Role::Slider, tr(label)).is_some(), "{}: {label}", lang.code()); } h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color")).click(); - h.run_steps(12); + h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color Mixer")).click(); - h.run_steps(12); + h.run_steps(2); for tab in ["Hue", "Saturation", "Luminance"] { assert!(h.get_by_role_and_label(Role::Button, tr(tab)).rect().right() <= 1426.0, "{}: mixer tabs overflow the panel", lang.code()); } @@ -56,7 +58,7 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ assert!(h.query_by_role_and_label(Role::Slider, tr(band)).is_some(), "{}: {band}", lang.code()); } h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color Mixer")).click(); - h.run_steps(12); + h.run_steps(2); for (section, labels) in [ ("Effects", vec!["Texture", "Clarity", "Dehaze", "Vignetting", "Grain", "Roughness"]), ("Curve", vec!["Highlights", "Lights", "Darks", "Shadows"]), @@ -64,16 +66,16 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_ ("Detail", vec!["Sharpening", "Masking", "Noise Reduction", "Luminance Detail", "Color Detail"]), ] { h.get_by_role_and_label(Role::DisclosureTriangle, tr(section)).click(); - h.run_steps(12); + h.run_steps(2); for label in labels { assert!(h.query_all_by_label(tr(label)).next().is_some(), "{}: {section}/{label}", lang.code()); } // Detail is also the sharpening slider label; target the section disclosure. h.get_by_role_and_label(Role::DisclosureTriangle, tr(section)).click(); - h.run_steps(12); + h.run_steps(2); } h.get_by_role_and_label(Role::DisclosureTriangle, tr("Color Mixer")).click(); - h.run_steps(12); + h.run_steps(2); let graph = h.get_by_label(tr("Tone Histogram")).rect(); h.event(egui::Event::PointerMoved(graph.center())); for pressed in [true, false] { diff --git a/crates/ui-egui/tests/it/camera_raw_scope.rs b/crates/ui-egui/tests/it/camera_raw_scope.rs index ba6ee7d705..7214b8bb54 100644 --- a/crates/ui-egui/tests/it/camera_raw_scope.rs +++ b/crates/ui-egui/tests/it/camera_raw_scope.rs @@ -592,10 +592,9 @@ fn curve_deletion_reentry_keyboard_limits_and_controller_replacement_share_captu assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc)); } -#[test] -fn curve_gpu_paints_the_moved_handle_in_every_theme() { +fn curve_gpu_paints_the_moved_handle_in_every_theme_part(part: usize) { let _gpu = gpu_lock(); - for theme in ThemeKind::ALL { + for theme in ThemeKind::ALL.into_iter().skip(part).step_by(4) { let mut h = fixture_render(theme, true); let graph = open_curve(&mut h); set_curve(&mut h, json!([[0, 0], [128, 128], [255, 255]])); @@ -618,6 +617,26 @@ fn curve_gpu_paints_the_moved_handle_in_every_theme() { } } +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_0() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(0); +} + +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_1() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(1); +} + +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_2() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(2); +} + +#[test] +fn curve_gpu_paints_the_moved_handle_in_every_theme_part_3() { + curve_gpu_paints_the_moved_handle_in_every_theme_part(3); +} + #[test] fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() { let mut h = fixture(ThemeKind::Pro); @@ -664,12 +683,15 @@ fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() { assert!(inspect(&h)["vectorscopeRect"].is_null()); } -#[test] -fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes() { +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(part: usize) { let _gpu = gpu_lock(); - for theme in ThemeKind::ALL { - let mut h = fixture_render(theme, true); + let mut h = fixture_render(ThemeKind::Pro, true); + for theme in ThemeKind::ALL.into_iter().skip(part).step_by(4) { let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); + settle(&mut h); + assert_eq!(h.state().ui.theme, theme); menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":true}}})).unwrap(); h.run_steps(4); let histogram = rect(&h, "scopeRect"); @@ -727,6 +749,26 @@ fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_theme } } +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_0() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(0); +} + +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_1() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(1); +} + +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_2() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(2); +} + +#[test] +fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part_3() { + pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes_part(3); +} + #[test] fn sampler_list_replacement_and_operations_cannot_silently_cancel_each_other() { let mut h = fixture(ThemeKind::Pro); diff --git a/crates/ui-egui/tests/it/checker_canvas.rs b/crates/ui-egui/tests/it/checker_canvas.rs index 513d277abf..8e453ecaac 100644 --- a/crates/ui-egui/tests/it/checker_canvas.rs +++ b/crates/ui-egui/tests/it/checker_canvas.rs @@ -304,46 +304,61 @@ fn pixel_grid_does_not_flip_colour_across_mid_tones() { } } -#[test] -fn pixel_grid_is_solid_and_visible_on_white_and_black_at_both_display_scales() { +fn pixel_grid_is_solid_and_visible_at_scale(gpu: bool, ppp: f32) { let _gpu = photocraft_testkit::gpu_lock(); use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Layer, LayerContent, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; - for gpu in [true, false] { - for ppp in [1.0, 2.0] { - let Some(mut h) = harness_with_scale(gpu, ppp) else { return }; - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - for v in [0.0, 1.0] { - let mut doc = Document::new("grid contrast", Size::new(32, 16), ColorMode::Rgb, depth); - let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); - surface.fill_rect(Rect::new(0, 0, 32, 16), &[v, v, v, 1.0]); - doc.layers.push(Layer::new("opaque", LayerContent::Raster(surface))); - let app = h.state_mut(); - app.session.add_document(doc, None); - app.sync_views(); - app.ui.view.show.pixel_grid = true; - for zoom in [7.119, 8.0, 22.825] { - control(&mut h, "ui.set", json!({"zoom": zoom, "center": [16.0, 8.0]})); - assert_eq!(h.state().perf.gpu, gpu); - let img = h.render().expect("render opaque grid"); - let center = h.state().last_canvas_rect.center() * ppp; - let x0 = center.x - 16.0 * zoom; - let y = (center.y + 3.25 * zoom).floor() as u32; - for x in 2..30 { - let edge = (x0 + x as f32 * zoom).floor() as u32; - for sy in y..y + 3 { - let inside = img.get_pixel((x0 + (x as f32 + 0.5) * zoom).floor() as u32, sy)[0]; - assert_eq!(inside, (v * 255.0) as u8); - let contrast = (edge - 1..=edge + 1).map(|sx| img.get_pixel(sx, sy)[0].abs_diff(inside)).max().expect("line"); - assert!(contrast >= 28, "{gpu:?}/{depth:?}, scale {ppp}, zoom {zoom}, fill {v}, ({x}, {sy}): invisible grid ({contrast})"); - } - } + let Some(mut h) = harness_with_scale(gpu, ppp) else { return }; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for v in [0.0, 1.0] { + let mut doc = Document::new("grid contrast", Size::new(32, 16), ColorMode::Rgb, depth); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true)); + surface.fill_rect(Rect::new(0, 0, 32, 16), &[v, v, v, 1.0]); + doc.layers.push(Layer::new("opaque", LayerContent::Raster(surface))); + let app = h.state_mut(); + app.session.add_document(doc, None); + app.sync_views(); + app.ui.view.show.pixel_grid = true; + for zoom in [7.119, 8.0, 22.825] { + control(&mut h, "ui.set", json!({"zoom": zoom, "center": [16.0, 8.0]})); + assert_eq!(h.state().perf.gpu, gpu); + let img = h.render().expect("render opaque grid"); + let center = h.state().last_canvas_rect.center() * ppp; + let x0 = center.x - 16.0 * zoom; + let y = (center.y + 3.25 * zoom).floor() as u32; + for x in 2..30 { + let edge = (x0 + x as f32 * zoom).floor() as u32; + for sy in y..y + 3 { + let inside = img.get_pixel((x0 + (x as f32 + 0.5) * zoom).floor() as u32, sy)[0]; + assert_eq!(inside, (v * 255.0) as u8); + let contrast = (edge - 1..=edge + 1).map(|sx| img.get_pixel(sx, sy)[0].abs_diff(inside)).max().expect("line"); + assert!(contrast >= 28, "{gpu:?}/{depth:?}, scale {ppp}, zoom {zoom}, fill {v}, ({x}, {sy}): invisible grid ({contrast})"); } } } } } } + +#[test] +fn pixel_grid_is_solid_and_visible_gpu_1x() { + pixel_grid_is_solid_and_visible_at_scale(true, 1.0); +} + +#[test] +fn pixel_grid_is_solid_and_visible_gpu_2x() { + pixel_grid_is_solid_and_visible_at_scale(true, 2.0); +} + +#[test] +fn pixel_grid_is_solid_and_visible_cpu_1x() { + pixel_grid_is_solid_and_visible_at_scale(false, 1.0); +} + +#[test] +fn pixel_grid_is_solid_and_visible_cpu_2x() { + pixel_grid_is_solid_and_visible_at_scale(false, 2.0); +} diff --git a/crates/ui-egui/tests/it/clipboard_svg.rs b/crates/ui-egui/tests/it/clipboard_svg.rs new file mode 100644 index 0000000000..5036eb42d5 --- /dev/null +++ b/crates/ui-egui/tests/it/clipboard_svg.rs @@ -0,0 +1,24 @@ +use photocraft_engine::Session; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::json; + +/// A normal paste must not make an unchanged external SVG replace a newer internal copy. +#[test] +fn normal_paste_preserves_newer_internal_copy_for_later_paste_as() { + const SVG: &[u8] = br##""##; + let services = + Services { clipboard_get_svg: Some(Box::new(|| Ok(Some(SVG.to_vec())))), clipboard_get_image: Some(Box::new(|| Ok(None))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.run("edit.paste", json!({"as": "pixels"})).unwrap(); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = false); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let pasted = app.run("edit.paste", json!({})).unwrap(); + assert!(pasted["layer"].as_u64().is_some(), "normal paste did not insert a layer: {pasted}"); + let pasted_as = app.run("edit.paste", json!({"as": "pixels"})).unwrap(); + assert!(pasted_as["layer"].as_u64().is_some(), "Paste As did not insert a layer: {pasted_as}"); + assert_ne!(pasted["layer"], pasted_as["layer"]); + assert_eq!(app.run("document.pixel", json!({"x": 4, "y": 3})).unwrap(), json!([0.0, 0.0, 1.0, 1.0])); +} diff --git a/crates/ui-egui/tests/it/crop_beyond_canvas.rs b/crates/ui-egui/tests/it/crop_beyond_canvas.rs index fd23d7f888..d399492406 100644 --- a/crates/ui-egui/tests/it/crop_beyond_canvas.rs +++ b/crates/ui-egui/tests/it/crop_beyond_canvas.rs @@ -110,7 +110,8 @@ fn editing_a_crop_frame_shows_the_pixels_past_the_canvas() { let app = h.state_mut(); // 300 × 120: red down the left 60 px and blue down the right 60 px of a new layer, then // cropped to (50, 0) 200 × 100 keeping the pixels: 50 px of red past the left edge, 50 of - // blue past the right, and 20 rows of the white Background below. + // blue past the right, and 20 rows of the white Background below (the crop makes it "Layer 0", + // which keeps them, #10867). app.run("file.new", json!({"width": 300, "height": 120, "background": "white"})).expect("new"); app.run("layer.new.layer", json!({})).expect("layer"); for (x, color) in [(0, "#ff0000"), (240, "#0000ff")] { @@ -173,11 +174,11 @@ fn editing_a_crop_frame_shows_the_pixels_past_the_canvas() { assert!(c[0] > 230 && c[1] < 40, "at x = {x}: {c:?}"); } - // Esc cancels: back to the canvas alone. + // Esc cancels: back to the canvas alone, with no frame until the next press (#10370). h.event(egui::Event::PointerGone); h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(3); let img = render(&mut h); - assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!(h.state().ui.crop_rect, None, "Esc hides the frame"); assert_eq!(shown(&h, &img), was, "hidden again after Esc"); } diff --git a/crates/ui-egui/tests/it/crop_ctrl_straighten.rs b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs new file mode 100644 index 0000000000..643b3ad88f --- /dev/null +++ b/crates/ui-egui/tests/it/crop_ctrl_straighten.rs @@ -0,0 +1,305 @@ +//! Crop tool: a Straighten line drawn with ⌘ (Ctrl) held turns the image but keeps the frame's +//! size (#10870), while the options-bar Straighten button keeps fitting the frame inside the +//! turned image. +//! +//! Measured in the reference application: an 800 × 500 document, the frame around the whole +//! canvas, a line rising about 10°. The button commits 633 × 396; ⌘ held commits 800 × 500 with +//! the uncovered corners in the background colour (Fill: Background). PhotoCraft fitted the frame +//! both ways. +//! +//! Driven as an agent does, through `ui.pointer` (document px), in both crop modes. + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_ui::{angle, corners as turned_corners, pending}; +use serde_json::{Value, json}; + +/// The background colour, #1050e0: what the turn uncovers is filled with it. +const BLUE: [f32; 3] = [16.0 / 255.0, 80.0 / 255.0, 224.0 / 255.0]; + +/// An 800 × 500 white document with a red 40 px square centred on (300, 200), the Crop tool +/// picked with options-bar ratio `ratio` ("" for none) in Classic Mode or the default mode, and +/// its default frame. +fn app(classic: bool, ratio: &str) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 800, "height": 500, "background": "white"})).expect("new"); + app.run("select.rect", json!({"x": 280, "y": 180, "width": 40, "height": 40})).expect("select"); + app.run("edit.fill", json!({"color": "#ff0000"})).expect("fill"); + app.run("select.deselect", json!({})).expect("deselect"); + app.run("tools.setColors", json!({"background": "#1050e0"})).expect("colors"); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.tool_options.crop_ratio = ratio.into(); + control(&mut app, &ctx, "ui.set", json!({"tool": "crop"})); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + (app, ctx) +} + +/// Runs one control-channel request, which must succeed. +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (req, _rx) = ControlRequest::new(method, params.clone()); + assert!(matches!(handle(app, ctx, &req), Outcome::Done(_)), "{method} {params}"); +} + +/// One `ui.pointer` call: `events` with ⌘ held or not. +fn pointer(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool, events: Value) { + control(app, ctx, "ui.pointer", json!({"command": command, "events": events})); +} + +/// A drag from `a` to `b` through their midpoint, `command` held or not. +fn drag(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool, a: [f64; 2], b: [f64; 2]) { + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + let events = json!([ + {"kind": "down", "x": a[0], "y": a[1]}, + {"kind": "move", "x": mid[0], "y": mid[1]}, + {"kind": "move", "x": b[0], "y": b[1]}, + {"kind": "up", "x": b[0], "y": b[1]}, + ]); + pointer(app, ctx, command, events); +} + +/// The issue's line's ends: rising 10° to the right across the canvas. +fn ends() -> ([f64; 2], [f64; 2]) { + ([100.0, 300.0], [700.0, 300.0 - 600.0 * 10.0f64.to_radians().tan()]) +} + +/// The issue's line, `command` held or not. +fn line(app: &mut PhotocraftApp, ctx: &egui::Context, command: bool) { + let (a, b) = ends(); + drag(app, ctx, command, a, b); +} + +/// ↵: commits the crop and returns the document's new size. +fn committed(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("document").doc.size; + (size.width, size.height) +} + +/// The flattened document's pixel at (`x`, `y`). +fn pixel(app: &PhotocraftApp, x: usize, y: usize) -> [f32; 4] { + let doc = &app.session.active().expect("document").doc; + let flat = photocraft_compose::flatten(doc); + flat.px[y * doc.size.width as usize + x] +} + +fn is_blue(p: [f32; 4]) -> bool { + BLUE.iter().zip(p).all(|(w, g)| (w - g).abs() < 0.02) +} + +fn is_white(p: [f32; 4]) -> bool { + p[..3].iter().all(|&v| v > 0.98) +} + +fn is_red(p: [f32; 4]) -> bool { + p[0] > 0.98 && p[1] < 0.02 && p[2] < 0.02 +} + +/// The four corner pixels of the document, in `crop_ui::corners`' order: top-left, top-right, +/// bottom-right, bottom-left. +fn corners(app: &PhotocraftApp) -> [[f32; 4]; 4] { + let size = app.session.active().expect("document").doc.size; + let (r, b) = (size.width as usize - 1, size.height as usize - 1); + [pixel(app, 0, 0), pixel(app, r, 0), pixel(app, r, b), pixel(app, 0, b)] +} + +/// The issue's row: the frame turns and keeps its size and position, and ↵ fills what the turn +/// uncovers. Also with a 1:1 ratio preset's frame and with Delete Cropped Pixels off. +#[test] +fn a_command_held_straighten_line_keeps_the_frame_size() { + for classic in [false, true] { + for (ratio, frame) in [("", [0.0, 0.0, 800.0, 500.0]), ("1:1", [150.0, 0.0, 650.0, 500.0])] { + for delete in [true, false] { + let what = format!("classic {classic}, ratio {ratio:?}, delete {delete}"); + let (mut app, ctx) = app(classic, ratio); + app.ui.tool_options.crop_delete = delete; + assert_eq!(app.ui.crop_rect, Some(frame), "{what}: the default frame"); + line(&mut app, &ctx, true); + // The frame turns (counter-clockwise: the line rises to the right) and stays put. + assert!((angle(&app) + 10.0).abs() < 1e-6, "{what}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some(frame), "{what}"); + assert!(pending(&app), "{what}: a pending edit"); + assert!(!photocraft_ui_egui::crop_straighten::mode(&app), "{what}: the line ended"); + // Where the turned frame's corners land on the canvas: the whole-canvas frame's + // all leave it; the 500 × 500 one's top-right and bottom-left leave it at the top + // and bottom, the other two stay inside the 800 px width. + let turn = angle(&app); + let outside = turned_corners(frame, turn).map(|c| !(0.0..=800.0).contains(&c[0]) || !(0.0..=500.0).contains(&c[1])); + let size = (frame[2] - frame[0], frame[3] - frame[1]); + assert_eq!(committed(&mut app), (size.0 as u32, size.1 as u32), "{what}"); + // The corners the turn uncovered take the background colour, the others and the + // middle are the image. With Delete Cropped Pixels off the Background becomes + // Layer 0 (#10867), so the uncovered corners stay transparent. + for (p, out) in corners(&app).into_iter().zip(outside) { + let uncovered = if delete { is_blue(p) } else { p[3] < 0.02 }; + assert!(if out { uncovered } else { is_white(p) }, "{what}: a corner {} the canvas is {p:?}", if out { "off" } else { "on" }); + } + assert_eq!(outside, if ratio.is_empty() { [true; 4] } else { [false, true, false, true] }, "{what}"); + let mid = pixel(&app, (size.0 / 2.0) as usize, (size.1 / 2.0) as usize); + assert!(is_white(mid), "{what}: the white image in the middle, not {mid:?}"); + // Delete Cropped Pixels off keeps the turned image outside the frame: Reveal All + // brings it back. Both frames are centred on the canvas's centre, so the revealed + // canvas is too. The point 300 px left of and 270 px above that centre (on the + // turned frame's axes) lies outside the frame and on the white image. + let q = [300.0, 270.0]; + let [on_image, ..] = turned_corners([400.0 - q[0], 250.0 - q[1], 400.0 + q[0], 250.0 + q[1]], turn); + assert!(on_image[0] > 10.0 && on_image[1] > 10.0 && on_image[0] < 270.0, "{what}: the point is on the image, at {on_image:?}"); + let revealed = app.run("image.revealAll", json!({})).expect("reveal all"); + let doc_size = app.session.active().expect("document").doc.size; + let (w, h) = (f64::from(doc_size.width), f64::from(doc_size.height)); + if delete { + assert_eq!((revealed["changed"].clone(), w, h), (json!(false), size.0, size.1), "{what}: nothing left to reveal"); + } else { + assert!(w > size.0 + 40.0 && h > size.1 + 40.0, "{what}: revealed {w} × {h}"); + let p = pixel(&app, (w / 2.0 - q[0]) as usize, (h / 2.0 - q[1]) as usize); + assert!(is_white(p), "{what}: the image outside the frame is kept, not {p:?}"); + } + } + } + } +} + +/// A frame drawn off the canvas's centre keeps its size and position through ⌘ lines, the +/// second one turning the already turned frame to its own angle. +#[test] +fn a_command_held_line_keeps_a_drawn_frame_where_it_is() { + let frame = [100.0, 50.0, 500.0, 350.0]; + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + // Auto Center Preview stays on (the default): in the default mode it pans the view to the + // drawn box, which must not move the frame on the image. + assert!(app.ui.tool_options.crop_shield.auto_center_preview); + drag(&mut app, &ctx, false, [frame[0], frame[1]], [frame[2], frame[3]]); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: the drawn frame"); + line(&mut app, &ctx, true); + assert!((angle(&app) + 10.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: after the first line"); + // A line falling 4° to the right sets the frame's angle to 4°, not 10° - 4°. + drag(&mut app, &ctx, true, [150.0, 100.0], [450.0, 100.0 + 300.0 * 4.0f64.to_radians().tan()]); + assert!((angle(&app) - 4.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some(frame), "classic {classic}: after the second line"); + assert_eq!(committed(&mut app), (400, 300), "classic {classic}"); + // The crop is centred on the red square, where the frame was (its red pixels' centroid + // is the middle, which one pixel in the 40 px square can't tell); turned 4° the frame + // stays inside the image, so nothing is filled. + let flat = photocraft_compose::flatten(&app.session.active().expect("document").doc); + let red: Vec<(f64, f64)> = (0..300usize) + .flat_map(|y| (0..400usize).map(move |x| (x, y))) + .filter(|&(x, y)| is_red(flat.px[y * 400 + x])) + .map(|(x, y)| (x as f64, y as f64)) + .collect(); + assert!(red.len() > 1000, "classic {classic}: the square is in the crop ({} red pixels)", red.len()); + let n = red.len() as f64; + let (cx, cy) = (red.iter().map(|p| p.0).sum::() / n, red.iter().map(|p| p.1).sum::() / n); + // Pixel centres sit at +0.5, so the middle of 400 × 300 is (199.5, 149.5). + assert!((cx - 199.5).abs() <= 1.0 && (cy - 149.5).abs() <= 1.0, "classic {classic}: the red centroid is ({cx:.1}, {cy:.1})"); + for p in corners(&app) { + assert!(is_white(p), "classic {classic}: a corner is {p:?}"); + } + } +} + +/// ⌘ let go before the mouse button: the line started as a ⌘ line, so it still keeps the frame. +#[test] +fn releasing_command_before_the_end_of_the_line_still_keeps_the_frame() { + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + let (a, b) = ends(); + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + pointer(&mut app, &ctx, true, json!([{"kind": "down", "x": a[0], "y": a[1]}, {"kind": "move", "x": mid[0], "y": mid[1]}])); + pointer(&mut app, &ctx, false, json!([{"kind": "move", "x": b[0], "y": b[1]}, {"kind": "up", "x": b[0], "y": b[1]}])); + assert!((angle(&app) + 10.0).abs() < 1e-6, "classic {classic}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "classic {classic}"); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + } +} + +/// Either side of 45°: a line rising 44° to the right is levelled (the frame turns -44°), one +/// rising 46° is made plumb (it leans 44° the other way, so +44°). The frame keeps its size both +/// ways, however far it turns. +#[test] +fn a_steep_command_held_line_keeps_the_frame_level_or_plumb() { + for classic in [false, true] { + for (rise, want) in [(44.0f64, -44.0), (46.0, 44.0)] { + let what = format!("classic {classic}, rising {rise}°"); + let (mut app, ctx) = app(classic, ""); + let (s, c) = rise.to_radians().sin_cos(); + drag(&mut app, &ctx, true, [400.0 - 200.0 * c, 250.0 + 200.0 * s], [400.0 + 200.0 * c, 250.0 - 200.0 * s]); + assert!((angle(&app) - want).abs() < 1e-6, "{what}: {}", app.ui.crop_angle); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "{what}"); + assert_eq!(committed(&mut app), (800, 500), "{what}"); + for p in corners(&app) { + assert!(is_blue(p), "{what}: a corner is {p:?}"); + } + } + } +} + +/// A ⌘-click, or a ⌘ line too short to measure, changes nothing: the default frame stays the +/// default, so ↵ crops nothing. +#[test] +fn a_command_held_click_or_tiny_line_changes_nothing() { + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + drag(&mut app, &ctx, true, [400.0, 250.0], [400.0, 250.0]); + drag(&mut app, &ctx, true, [400.0, 250.0], [400.001, 249.999]); + assert_eq!((app.ui.crop_rect, angle(&app)), (Some([0.0, 0.0, 800.0, 500.0]), 0.0), "classic {classic}"); + assert!(!pending(&app), "classic {classic}: still the default frame"); + let steps = app.session.active().expect("document").history.past_len(); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + assert_eq!(app.session.active().expect("document").history.past_len(), steps, "classic {classic}: no crop"); + } +} + +/// Undo steps the box back before ↵ (the frame upright, the default again) and Redo turns it +/// again; after ↵ the crop is one document step that Undo reverts. +#[test] +fn a_command_held_line_undoes_as_one_box_step_and_commits_as_one_document_step() { + for classic in [false, true] { + let (mut app, ctx) = app(classic, ""); + line(&mut app, &ctx, true); + app.run("edit.undo", json!({})).expect("undo"); + assert_eq!((app.ui.crop_rect, angle(&app)), (Some([0.0, 0.0, 800.0, 500.0]), 0.0), "classic {classic}: undone"); + assert!(!pending(&app), "classic {classic}: the default frame again"); + app.run("edit.redo", json!({})).expect("redo"); + assert!((angle(&app) + 10.0).abs() < 1e-6 && pending(&app), "classic {classic}: redone"); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "classic {classic}: redone"); + let steps = app.session.active().expect("document").history.past_len(); + assert_eq!(committed(&mut app), (800, 500), "classic {classic}"); + assert_eq!(app.session.active().expect("document").history.past_len(), steps + 1, "classic {classic}"); + assert!(is_blue(pixel(&app, 0, 0)), "classic {classic}: filled"); + app.run("edit.undo", json!({})).expect("undo the crop"); + assert!(is_white(pixel(&app, 0, 0)) && is_red(pixel(&app, 300, 200)), "classic {classic}: the image as it was"); + } +} + +/// The button's line fits the frame inside the turned image, keeping the frame's proportions +/// (the canvas's, or a 1:1 preset's), also with ⌘ held while the button is armed. +#[test] +fn the_straighten_button_still_fits_the_frame() { + let (s, c) = 10.0f64.to_radians().sin_cos(); + // The 1:1 frame's side: its turned bounds fit the canvas's height. + let square = 500.0 / (s + c); + for classic in [false, true] { + for (ratio, command, want) in [("", false, (633.0, 396.0)), ("", true, (633.0, 396.0)), ("1:1", false, (square, square))] { + let what = format!("classic {classic}, ratio {ratio:?}, command {command}"); + let (mut app, ctx) = app(classic, ratio); + photocraft_ui_egui::crop_straighten::toggle(&mut app); + line(&mut app, &ctx, command); + assert!((angle(&app) + 10.0).abs() < 1e-6, "{what}"); + assert!(!photocraft_ui_egui::crop_straighten::mode(&app), "{what}: one line per click"); + let (w, h) = committed(&mut app); + assert!((f64::from(w) - want.0).abs() <= 1.0 && (f64::from(h) - want.1).abs() <= 1.0, "{what}: {w} × {h}"); + if ratio == "1:1" { + assert_eq!(w, h, "{what}: keeps the frame's 1:1"); + } + // Fitted inside the turned image: nothing to fill. + for p in corners(&app) { + assert!(!is_blue(p), "{what}: a corner is {p:?}"); + } + } + } +} diff --git a/crates/ui-egui/tests/it/crop_default_mode.rs b/crates/ui-egui/tests/it/crop_default_mode.rs index 58d9709e99..4161631529 100644 --- a/crates/ui-egui/tests/it/crop_default_mode.rs +++ b/crates/ui-egui/tests/it/crop_default_mode.rs @@ -239,3 +239,34 @@ fn a_resize_keeps_the_box_centre_after_a_space_pan() { let b = screen_box(&h); assert!(mid(b).distance(mid(box0)) < 0.5, "still where it was: {:?} vs {:?}", mid(b), mid(box0)); } + +#[test] +fn clear_removes_the_displayed_crop_proportions_without_changing_the_frame() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = setup() else { return }; + let o = &mut h.state_mut().ui.tool_options; + o.crop_ratio = "16:9".into(); + o.crop_custom_ratio_width = 16.0; + o.crop_custom_ratio_height = 9.0; + h.run_steps(3); + assert!(h.query_all_by_value("16").next().is_some()); + assert!(h.query_all_by_value("9").next().is_some()); + let frame = h.state().ui.crop_rect; + let save = |h: &mut Harness, name: &str| { + if let Some(dir) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(name)).unwrap(); + } + }; + save(&mut h, "crop-clear-before.png"); + h.query_all_by_label("Clear").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).expect("Crop options bar Clear").click(); + h.run_steps(3); + save(&mut h, "crop-clear-after.png"); + let o = &h.state().ui.tool_options; + assert!(o.crop_ratio.is_empty()); + assert_eq!((o.crop_custom_ratio_width, o.crop_custom_ratio_height), (0.0, 0.0)); + assert!(h.query_all_by_value("16").next().is_none()); + assert!(h.query_all_by_value("9").next().is_none()); + assert_eq!(h.state().ui.crop_rect, frame); +} diff --git a/crates/ui-egui/tests/it/crop_overlay_icons.rs b/crates/ui-egui/tests/it/crop_overlay_icons.rs new file mode 100644 index 0000000000..2cbc8195e6 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_overlay_icons.rs @@ -0,0 +1,166 @@ +//! #10889: every Crop overlay has its own embedded glyph, the options bar's overlay button paints +//! the one of the overlay in use, and the overlay menu lists a glyph per entry. + +use std::collections::HashSet; + +use egui_kittest::Harness; +use photocraft_ui_egui::crop_overlay::CropOverlay; +use photocraft_ui_egui::icons; +use photocraft_ui_egui::state::{Tool, ToolOptions}; + +fn collect(shape: &egui::Shape, ids: &mut HashSet) { + match shape { + egui::Shape::Mesh(m) => { + ids.insert(m.texture_id); + } + // Images (the SVG glyphs) are rects filled with a texture in egui 0.37. + egui::Shape::Rect(r) => { + if let Some(brush) = r.brush.as_ref() + && brush.fill_texture_id != egui::TextureId::default() + { + ids.insert(brush.fill_texture_id); + } + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, ids)), + _ => {} + } +} + +fn drawn_textures(h: &Harness<'_, State>) -> HashSet { + let mut ids = HashSet::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut ids); + } + ids +} + +/// Every glyph texture and the rectangle it covers, in paint order. +fn glyph_rects(shape: &egui::Shape, out: &mut Vec<(egui::TextureId, egui::Rect)>) { + match shape { + egui::Shape::Rect(r) => { + if let Some(brush) = r.brush.as_ref() + && brush.fill_texture_id != egui::TextureId::default() + { + out.push((brush.fill_texture_id, r.rect)); + } + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| glyph_rects(shape, out)), + _ => {} + } +} + +fn harness() -> Harness<'static, ToolOptions> { + let h = Harness::builder() + .with_size(egui::vec2(320.0, 520.0)) + .with_max_steps(64) + .with_accessibility_check(false) + .build_ui_state(|ui, o: &mut ToolOptions| photocraft_ui_egui::crop_overlay::options_button(o, ui), ToolOptions::default()); + // The SVG icon loader lives in the app's context setup; without it no glyph is rasterized. + photocraft_ui_egui::PhotocraftApp::setup_context(&h.ctx, Default::default()); + h +} + +#[test] +fn every_overlay_has_its_own_embedded_icon() { + let mut names: Vec<&str> = CropOverlay::ALL.iter().map(|k| k.icon()).collect(); + for name in &names { + assert!(icons::exists(name), "{name} is not embedded"); + } + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), CropOverlay::ALL.len(), "one icon per overlay"); +} + +/// The six files are different drawings, not one picture saved under six names: a duplicate would +/// still get six texture ids and pass the texture checks above. +#[test] +fn the_overlay_glyphs_are_six_different_drawings() { + let art: [(&str, &[u8]); 6] = [ + ("crop-overlay-thirds", include_bytes!("../../../../assets/icons/crop-overlay-thirds.svg")), + ("crop-overlay-grid", include_bytes!("../../../../assets/icons/crop-overlay-grid.svg")), + ("crop-overlay-diagonal", include_bytes!("../../../../assets/icons/crop-overlay-diagonal.svg")), + ("crop-overlay-triangle", include_bytes!("../../../../assets/icons/crop-overlay-triangle.svg")), + ("crop-overlay-golden-ratio", include_bytes!("../../../../assets/icons/crop-overlay-golden-ratio.svg")), + ("crop-overlay-golden-spiral", include_bytes!("../../../../assets/icons/crop-overlay-golden-spiral.svg")), + ]; + let icons: Vec<&str> = CropOverlay::ALL.iter().map(|k| k.icon()).collect(); + assert_eq!(art.iter().map(|(name, _)| *name).collect::>(), icons, "one file per overlay, in ALL order"); + for (i, (name, bytes)) in art.iter().enumerate() { + assert!(art[..i].iter().all(|(_, other)| other != bytes), "{name} repeats another overlay's drawing"); + } +} + +#[test] +fn the_overlay_button_paints_the_current_overlays_icon() { + // One context: the SVG loader gives every new file its own texture, so the id changes as the + // overlay (and therefore the button's glyph) changes. + let mut h = Harness::builder() + .with_size(egui::vec2(60.0, 60.0)) + .with_max_steps(64) + .with_accessibility_check(false) + .build_ui_state(|ui, o: &mut ToolOptions| photocraft_ui_egui::crop_overlay::options_button(o, ui), ToolOptions::default()); + photocraft_ui_egui::PhotocraftApp::setup_context(&h.ctx, Default::default()); + let mut glyphs = Vec::new(); + for overlay in CropOverlay::ALL { + h.state_mut().crop_overlay = overlay; + h.run(); + let ids = drawn_textures(&h); + assert_eq!(ids.len(), 1, "{overlay:?}: one glyph on the button, got {ids:?}"); + glyphs.push(*ids.iter().next().expect("a glyph")); + } + let unique: HashSet<_> = glyphs.iter().copied().collect(); + assert_eq!(unique.len(), CropOverlay::ALL.len(), "the button follows the overlay: {glyphs:?}"); +} + +#[test] +fn the_overlay_menu_lists_a_glyph_per_entry() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + let mut h = harness(); + h.run(); + h.get_by_role(Role::Button).click(); + h.run(); + let ids = drawn_textures(&h); + assert!(ids.len() >= CropOverlay::ALL.len(), "one glyph per overlay entry plus the button: {ids:?}"); +} + +/// The glyph texture its row drew, found by row bounds. +fn row_glyph(h: &Harness<'_, State>, label: &str) -> egui::TextureId { + use egui_kittest::kittest::Queryable; + + let mut drawn = Vec::new(); + for shape in &h.output().shapes { + glyph_rects(&shape.shape, &mut drawn); + } + h.get_all_by_label(label) + .map(|node| node.rect()) + .find_map(|row| drawn.iter().find(|(_, rect)| row.contains_rect(*rect)).map(|(id, _)| *id)) + .unwrap_or_else(|| panic!("no {label} row draws a glyph")) +} + +/// The canvas right-click Crop menu's overlay rows carry the same glyphs (#10889), each row its +/// own: a swapped icon still adds six textures, so the count alone proves nothing. Checked per +/// row, and pairwise distinct. (Comparing against a freshly loaded 14 px glyph does not work: +/// egui frees icon textures at the end of every pass, so the reload gets a new id.) +#[test] +fn the_canvas_crop_menu_lists_a_glyph_per_overlay() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).with_accessibility_check(false).build_eframe(|cc| { + photocraft_ui_egui::PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + app.run("file.new", serde_json::json!({"name": "crop menu", "width": 400, "height": 300})).expect("new document"); + app.ui.tool = Tool::Crop; + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + // The menu opens on a crop the user has started, so nudge the untouched frame once. + photocraft_ui_egui::crop_ui::nudge(&mut app, 1.0, 0.0); + app + }); + h.run(); + let ctx = h.ctx.clone(); + let opened = photocraft_ui_egui::canvas::canvas_tool_menu::open_crop(h.state_mut(), &ctx, Tool::Crop, [600.0, 400.0]); + assert!(opened, "the crop menu opens on a pending crop"); + h.run(); + let glyphs: Vec<_> = CropOverlay::ALL.iter().map(|kind| row_glyph(&h, kind.label())).collect(); + let unique: HashSet<_> = glyphs.iter().copied().collect(); + assert_eq!(unique.len(), CropOverlay::ALL.len(), "one glyph per row: {glyphs:?}"); +} diff --git a/crates/ui-egui/tests/it/crop_shift_corner.rs b/crates/ui-egui/tests/it/crop_shift_corner.rs new file mode 100644 index 0000000000..f7452b94cf --- /dev/null +++ b/crates/ui-egui/tests/it/crop_shift_corner.rs @@ -0,0 +1,242 @@ +//! Crop tool: ⇧-dragging a frame corner keeps its proportions by following the pointer along the +//! frame's diagonal, as Photoshop does (#10871). +//! +//! Measured in Photoshop: 800 × 500 document, frame = whole canvas, free ratio, Auto Center +//! Preview off, ⇧-drag of the bottom-right corner by (−100, −50) px commits 706 × 441 (the drag +//! projected onto (800, 500) shortens the frame by 11.8 %). PhotoCraft let the larger relative +//! extent win and committed 720 × 450. +//! +//! Driven through `ui.pointer`, as an agent or the canvas sends the drag; no GPU needed. The Auto +//! Center Preview test sends screen points through the view instead, as the mouse does, since the +//! view pans with the frame during that drag. + +use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::{ToolEvent, ViewXform, tool_event}; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_ui; +use serde_json::{Value, json}; + +/// The measured ⇧ drag. +const DRAG: [f64; 2] = [-100.0, -50.0]; + +#[derive(Default)] +struct Setup { + auto_center: bool, + classic: bool, + /// Options-bar ratio preset, `""` for none. + ratio: &'static str, + snap: bool, +} + +/// An 800 × 500 document at 100 % in a canvas it fits, the Crop tool's frame up. +fn crop_app(s: Setup) -> PhotocraftApp { + let doc = Document::with_background("crop", Size::new(800, 500), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut session = photocraft_engine::Session::new(); + session.add_document(doc, None); + let mut app = PhotocraftApp::new(session, Default::default()); + app.sync_views(); + app.ui.extras.snap = s.snap; + app.ui.extras.guides = true; + app.ui.extras.rulers = false; + app.ui.view.show.smart_guides = false; + app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [400.0, 250.0], false, 0.0); + app.ui.tool_options.crop_shield.auto_center_preview = s.auto_center; + app.ui.tool_options.crop_shield.classic_mode = s.classic; + app.ui.tool_options.crop_ratio = s.ratio.into(); + pointer(&mut app, json!({"tool": "Crop", "events": []})); + crop_ui::ensure_frame(&mut app); + app +} + +fn pointer(app: &mut PhotocraftApp, params: Value) { + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new("ui.pointer", params); + let Outcome::Done(v) = handle(app, &ctx, &req) else { panic!("ui.pointer didn't finish") }; + assert_eq!(v["ok"], true, "{v}"); +} + +/// The frame's (turned) bottom-right corner, where the drags start. +fn corner(app: &PhotocraftApp) -> [f64; 2] { + crop_ui::corners(app.ui.crop_rect.expect("a frame"), crop_ui::angle(app))[2] +} + +/// Commits with ↵ and returns the document size. +fn commit(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("a document").doc.size; + (size.width, size.height) +} + +/// Drags the bottom-right corner by document vector `d` in steps through `ui.pointer`, holding +/// `mods`, and commits. +fn drag_corner_and_commit(app: &mut PhotocraftApp, d: [f64; 2], mods: Value) -> (u32, u32) { + drag_corner(app, d, mods); + commit(app) +} + +/// [`drag_corner_and_commit`] without the commit: the frame stays pending. +fn drag_corner(app: &mut PhotocraftApp, d: [f64; 2], mods: Value) { + let [x, y] = corner(app); + let mut events = vec![json!({"kind": "down", "x": x, "y": y})]; + events.extend((1..=4).map(|i| { + let t = f64::from(i) / 4.0; + json!({"kind": "move", "x": x + d[0] * t, "y": y + d[1] * t}) + })); + events.push(json!({"kind": "up", "x": x + d[0], "y": y + d[1]})); + let mut params = mods; + params["events"] = Value::Array(events); + pointer(app, params); +} + +fn assert_near(got: (u32, u32), want: (u32, u32), what: &str) { + let off = |a: u32, b: u32| a.abs_diff(b); + assert!(off(got.0, want.0) <= 1 && off(got.1, want.1) <= 1, "{what}: committed {got:?}, want {want:?} ±1 px"); +} + +#[test] +fn shift_corner_drag_follows_the_frames_diagonal() { + let mut app = crop_app(Setup::default()); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "the default frame is the whole canvas"); + let got = drag_corner_and_commit(&mut app, DRAG, json!({"shift": true})); + assert_near(got, (706, 441), "⇧ drag by (−100, −50)"); +} + +#[test] +fn shift_corner_drag_outward_past_the_canvas_follows_the_diagonal() { + let mut app = crop_app(Setup::default()); + // (100, 50) onto (800, 500) grows the frame by 11.8 %: 894.4 × 559.0, past the canvas. + let got = drag_corner_and_commit(&mut app, [100.0, 50.0], json!({"shift": true})); + assert_near(got, (894, 559), "⇧ drag by (100, 50)"); +} + +/// A turned frame: the diagonal turns with it, so the measured drag taken in the frame's own axes +/// gives the measured size, in Classic Mode (the frame turns) and the default mode (the image +/// turns). Projected onto the upright diagonal instead it gives about 714 × 446. +#[test] +fn shift_corner_drag_on_a_turned_frame_follows_its_diagonal() { + for classic in [true, false] { + let mut app = crop_app(Setup { classic, ..Setup::default() }); + app.ui.crop_angle = 30.0; + photocraft_ui_egui::crop_mode::sync_frame(&mut app); + let d = crop_ui::turn(DRAG, [0.0, 0.0], 30.0); + let got = drag_corner_and_commit(&mut app, d, json!({"shift": true})); + assert_near(got, (706, 441), &format!("⇧ drag on a frame turned 30°, Classic Mode {classic}")); + } +} + +/// Auto Center Preview (the default): the box stays centred on screen and shrinks on both sides +/// as the handle follows the pointer, so each extent changes by twice the projected drag: +/// 800 − 2 × 94.4 = 611.2 and 500 − 2 × 59.0 = 382.0. With the larger relative extent winning it +/// was 640 × 400. +#[test] +fn shift_corner_drag_follows_the_diagonal_with_auto_center_preview() { + let mut app = crop_app(Setup { auto_center: true, ..Setup::default() }); + let shift = egui::Modifiers::SHIFT; + let [x, y] = corner(&app); + let from = ViewXform::active(&app).expect("a view").to_screen(x as f32, y as f32); + let to = from + egui::vec2(DRAG[0] as f32, DRAG[1] as f32); + // Each screen point is read through the view as it is then, as the canvas does every frame. + let at = |app: &PhotocraftApp, p: egui::Pos2| ViewXform::active(app).expect("a view").to_doc(p); + let [x, y] = at(&app, from); + tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, shift); + for i in 1..=4 { + let [x, y] = at(&app, from + (to - from) * (i as f32 / 4.0)); + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, shift); + } + let [x, y] = at(&app, to); + tool_event(&mut app, ToolEvent::Up { x, y }, shift); + assert_near(commit(&mut app), (611, 382), "⇧ drag with Auto Center Preview"); +} + +/// A ratio preset keeps today's rule (not measured): the larger relative extent wins. On the +/// 500 × 500 frame 1 : 1 fits, (−100, −50) gives max(400, 450) = 450 square; along the diagonal +/// it would be 425 square. +#[test] +fn shift_corner_drag_with_a_ratio_preset_is_unchanged() { + let mut app = crop_app(Setup { ratio: "1:1", ..Setup::default() }); + assert_eq!(app.ui.crop_rect, Some([150.0, 0.0, 650.0, 500.0]), "the preset fits a 500 × 500 frame"); + assert_near(drag_corner_and_commit(&mut app, DRAG, json!({"shift": true})), (450, 450), "⇧ with 1 : 1"); +} + +#[test] +fn the_same_drag_without_shift_or_with_alt_is_unchanged() { + // No ⇧: the corner follows the pointer freely, 700 × 450 as in Photoshop. + let mut app = crop_app(Setup::default()); + assert_near(drag_corner_and_commit(&mut app, DRAG, json!({})), (700, 450), "no ⇧"); + // ⌥ ⇧ (not measured, today's rule): about the centre, 600 × 400 grows to 8 : 5 as 640 × 400. + let mut app = crop_app(Setup::default()); + assert_near(drag_corner_and_commit(&mut app, DRAG, json!({"shift": true, "alt": true})), (640, 400), "⌥ ⇧"); +} + +/// Snapping reads the handle where the ⇧ drag puts it, on the diagonal (x ≈ 705.6 for the +/// measured drag), not where the larger-extent rule would (x = 720). The tolerance is 8 px at +/// 100 %: a guide 5.6 px away catches the handle, one 8.4 px away doesn't. +#[test] +fn a_shift_corner_drag_snaps_where_its_handle_is() { + for (guide, want) in [(700.0, (700, 438)), (714.0, (706, 441))] { + let mut app = crop_app(Setup { snap: true, ..Setup::default() }); + app.run("view.newGuide", json!({"orientation": "vertical", "position": guide})).expect("guide"); + let got = drag_corner_and_commit(&mut app, DRAG, json!({"shift": true})); + assert_near(got, want, &format!("⇧ drag with a guide at x = {guide}")); + } +} + +/// On a frame turned 30° the corner slides along the turned diagonal, direction (0.89, 1.67) for +/// 8 : 5, so a vertical guide is met by sliding along it, not by moving the handle sideways. +/// +/// - ⇧, free ratio: the measured drag leaves the handle at x ≈ 569.2, 4.2 px from a guide at +/// x = 565. Sliding on to it shortens the frame by 12.7 % to 698.1 × 436.3. Moving the handle +/// sideways and projecting that back onto the diagonal stopped it at x ≈ 568.3, off the guide, +/// at 704 × 440. +/// - An 8 : 5 preset (the larger extent wins, 720 × 450) leaves it at x ≈ 577.1, 4.1 px from a +/// guide at x = 573: 712.5 × 445.3. Sideways, the larger extent grew the frame to 723 × 452, +/// away from the guide. +#[test] +fn a_shift_corner_drag_on_a_turned_frame_snaps_along_its_diagonal() { + for (ratio, guide, want) in [("", 565.0, (698, 436)), ("8:5", 573.0, (712, 445))] { + for classic in [true, false] { + let what = format!("ratio {ratio:?}, guide at x = {guide}, Classic Mode {classic}"); + let mut app = crop_app(Setup { classic, ratio, snap: true, ..Setup::default() }); + app.ui.crop_angle = 30.0; + photocraft_ui_egui::crop_mode::sync_frame(&mut app); + app.run("view.newGuide", json!({"orientation": "vertical", "position": guide})).expect("guide"); + drag_corner(&mut app, crop_ui::turn(DRAG, [0.0, 0.0], 30.0), json!({"shift": true})); + let at = corner(&app); + assert!((at[0] - guide).abs() < 1e-3, "{what}: the handle is at {at:?}, not on the guide"); + let r = app.ui.crop_rect.expect("a frame"); + assert!(((r[2] - r[0]) / (r[3] - r[1]) - 1.6).abs() < 1e-9, "{what}: 8 : 5 kept, {r:?}"); + assert_near(commit(&mut app), want, &what); + } + } +} + +/// Turned 31° the other way, the 8 : 5 frame's bottom-right diagonal runs 1° off horizontal, so it +/// crosses a horizontal guide 3 px from the handle about 170 px further along. That crossing is +/// well outside the 8 px snap tolerance: the guide doesn't catch the handle and the frame follows +/// the drag, as with no guide. Sliding to the crossing grew the frame by about 40 %. +#[test] +fn a_turned_shift_corner_ignores_a_guide_its_diagonal_meets_far_away() { + let drag = crop_ui::turn([-50.0, -25.0], [0.0, 0.0], -31.0); + for classic in [true, false] { + let what = format!("Classic Mode {classic}"); + // Off the document's centre lines, which snap too. + let setup = || { + let mut app = crop_app(Setup { classic, snap: true, ..Setup::default() }); + app.ui.crop_rect = Some([100.0, 60.0, 500.0, 310.0]); + app.ui.crop_angle = -31.0; + photocraft_ui_egui::crop_mode::sync_frame(&mut app); + app + }; + let mut free = setup(); + drag_corner(&mut free, drag, json!({"shift": true})); + let (want, at) = (free.ui.crop_rect.expect("a frame"), corner(&free)); + let mut app = setup(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": at[1] + 3.0})).expect("guide"); + drag_corner(&mut app, drag, json!({"shift": true})); + let r = app.ui.crop_rect.expect("a frame"); + assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{what}: {r:?}, want {want:?} as with no guide"); + } +} diff --git a/crates/ui-egui/tests/it/crop_turn_grid.rs b/crates/ui-egui/tests/it/crop_turn_grid.rs new file mode 100644 index 0000000000..dcbc8af511 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_turn_grid.rs @@ -0,0 +1,306 @@ +//! #10931: the default Crop turn temporarily replaces the chosen overlay with +//! centred 48-point cells; Classic Mode and Straighten keep their existing look. + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_overlay::{self, CropOverlay, OverlayShow, Polyline}; +use photocraft_ui_egui::crop_ui; +use photocraft_ui_egui::state::Tool; +use serde_json::{Value, json}; + +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (request, _rx) = ControlRequest::new(method, params.clone()); + assert!(matches!(handle(app, ctx, &request), Outcome::Done(_)), "{method} {params}"); +} + +fn app(classic: bool, show: OverlayShow) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width":412,"height":301})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Crop; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let o = &mut app.ui.tool_options; + o.crop_shield.classic_mode = classic; + o.crop_shield.auto_center_preview = false; + o.crop_overlay_show = show; + o.crop_overlay = CropOverlay::Triangle; + o.crop_overlay_orientation = 1; + crop_ui::ensure_frame(&mut app); + (app, ctx) +} + +fn turning(app: &mut PhotocraftApp, ctx: &egui::Context) -> [f64; 2] { + turning_by(app, ctx, 10.0) +} + +fn turning_by(app: &mut PhotocraftApp, ctx: &egui::Context, degrees: f64) -> [f64; 2] { + let c = crop_ui::center(app.ui.crop_rect.unwrap()); + let p = [c[0] + 500.0, c[1]]; + let a = degrees.to_radians(); + let q = [c[0] + 500.0 * a.cos(), c[1] + 500.0 * a.sin()]; + control(app, ctx, "ui.pointer", json!({"events":[{"kind":"down","x":p[0],"y":p[1]},{"kind":"move","x":q[0],"y":q[1]}]})); + assert!(crop_ui::turning(app).is_some(), "the real pointer path must start a turn"); + q +} + +fn release(app: &mut PhotocraftApp, ctx: &egui::Context, p: [f64; 2]) { + control(app, ctx, "ui.pointer", json!({"events":[{"kind":"up","x":p[0],"y":p[1]}]})); + assert!(crop_ui::turning(app).is_none()); +} + +fn positions(lines: &[Polyline], vertical: bool, len: f32) -> Vec { + lines.iter().filter(|l| (l[0][0] == l[1][0]) == vertical).map(|l| l[0][if vertical { 0 } else { 1 }] * len).collect() +} + +fn near(actual: &[f32], expected: &[f32]) { + assert_eq!(actual.len(), expected.len(), "{actual:?}"); + for (a, e) in actual.iter().zip(expected) { + assert!((a - e).abs() < 0.001, "{actual:?}, expected {expected:?}"); + } +} + +fn full_grid(w: f32, h: f32) -> Vec { + crop_overlay::turn_grid(w, h, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h))) +} + +#[test] +fn measured_frame_sizes_have_centred_48_point_cells() { + let grid = full_grid(329.0, 240.0); + near(&positions(&grid, true, 329.0), &[20.5, 68.5, 116.5, 164.5, 212.5, 260.5, 308.5]); + near(&positions(&grid, false, 240.0), &[24.0, 72.0, 120.0, 168.0, 216.0]); + let grid = full_grid(711.0, 519.0); + near(&positions(&grid, true, 711.0), &[19.5, 67.5, 115.5, 163.5, 211.5, 259.5, 307.5, 355.5, 403.5, 451.5, 499.5, 547.5, 595.5, 643.5, 691.5]); + near(&positions(&grid, false, 519.0), &[19.5, 67.5, 115.5, 163.5, 211.5, 259.5, 307.5, 355.5, 403.5, 451.5, 499.5]); +} + +#[test] +fn default_turn_replaces_the_overlay_and_release_restores_each_show_mode() { + for show in [OverlayShow::Always, OverlayShow::Auto, OverlayShow::Never] { + let (mut app, ctx) = app(false, show); + let p = turning(&mut app, &ctx); + let expected = if show == OverlayShow::Never { Vec::new() } else { full_grid(329.0, 240.0) }; + assert_eq!(crop_overlay::current_guides(&app, 329.0, 240.0), expected, "{show:?}"); + release(&mut app, &ctx, p); + let expected = if show == OverlayShow::Always { crop_overlay::guides(CropOverlay::Triangle, 1, 329.0, 240.0) } else { Vec::new() }; + assert_eq!(crop_overlay::current_guides(&app, 329.0, 240.0), expected, "{show:?} after release"); + } +} + +#[test] +fn zoom_and_hidpi_do_not_change_screen_point_spacing() { + let (mut app, ctx) = app(false, OverlayShow::Always); + ctx.set_pixels_per_point(2.0); + control(&mut app, &ctx, "ui.set", json!({"zoom":2.0})); + let p = turning(&mut app, &ctx); + let q = [egui::pos2(0.0, 0.0), egui::pos2(711.0, 0.0), egui::pos2(711.0, 519.0), egui::pos2(0.0, 519.0)]; + let lines = crop_overlay::current_guides_for_frame(&app, q, egui::Rect::from_min_max(q[0], q[2])); + let xs = positions(&lines, true, 711.0); + assert_eq!(xs.len(), 15); + for pair in xs.windows(2) { + assert!((pair[1] - pair[0] - 48.0).abs() < 0.001, "{xs:?}"); + } + release(&mut app, &ctx, p); +} + +#[test] +fn canvas_paints_48_point_cells_at_zoom_two_and_hidpi() { + fn collect(shape: &egui::Shape, clip: egui::Rect, lines: &mut Vec<[egui::Pos2; 2]>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, lines)), + egui::Shape::Path(path) if path.stroke.color == egui::epaint::ColorMode::Solid(egui::Color32::from_white_alpha(90)) => { + assert_eq!(path.points.len(), 2); + assert_eq!(path.stroke.width, 1.0); + assert!(path.points.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + let points = [path.points[0], path.points[1]]; + assert!(egui::Rect::from_two_pos(points[0], points[1]).intersects(clip)); + lines.push(points); + } + _ => {} + } + } + + // Exercise both paint call sites: the initial upright turn and a moved + // default-mode turn whose camera counter-rotates the frame. + for degrees in [0.0, 10.0] { + let (mut app, ctx) = app(false, OverlayShow::Always); + ctx.set_pixels_per_point(2.0); + control(&mut app, &ctx, "ui.set", json!({"zoom":2.0})); + let p = turning_by(&mut app, &ctx, degrees); + assert_eq!(crop_ui::angle(&app) == 0.0, degrees == 0.0); + app.ui.extras.rulers = false; + let rect = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(600.0, 480.0)); + // Deliberately narrower than the canvas and frame, to detect a caller + // passing the whole canvas instead of the painter's actual clip. + let clip = egui::Rect::from_center_size(rect.center(), egui::vec2(180.0, 130.0)); + let mut view = app.ui.views[0].clone(); + view.fit_pending = false; + view.fill_pending = false; + view.doc_size = [412, 301]; + view.center = [206.0, 150.5]; + let xf = photocraft_ui_egui::canvas::ViewXform { rect, zoom: 1.0, center: view.center, flip: false, rotation: view.rotation, aspect: 1.0 }; + let q = crop_ui::corners(app.ui.crop_rect.unwrap(), crop_ui::angle(&app)).map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + ui.set_clip_rect(clip); + let painted_view = photocraft_ui_egui::canvas::canvas_view(&mut app, ui, 0, rect, view.clone(), true); + assert_eq!(painted_view.zoom, 2.0); + }); + // Shape-only CPU inspection has no renderer to consume texture updates. + output.textures_delta.clear(); + assert_eq!(output.pixels_per_point, 2.0); + assert_eq!(app.canvas_ppp(), 2.0); + assert!(crop_ui::turning(&app).is_some()); + let mut lines = Vec::new(); + for shape in &output.shapes { + let before = lines.len(); + collect(&shape.shape, clip, &mut lines); + if lines.len() != before { + assert_eq!(shape.clip_rect, clip); + } + } + assert_eq!(lines.len(), 6, "only the three visible lines per axis: {degrees} degrees, {lines:?}"); + for vertical in [true, false] { + let mut positions = Vec::new(); + for points in &lines { + let delta = points[1] - points[0]; + if (delta.x.abs() < 0.001) == vertical { + let (coordinate, lo, hi) = if vertical { (points[0].x, q[0].y, q[3].y) } else { (points[0].y, q[0].x, q[1].x) }; + let endpoints = if vertical { [points[0].y, points[1].y] } else { [points[0].x, points[1].x] }; + near(&endpoints, &[lo, hi]); + positions.push(coordinate); + } + } + positions.sort_by(f32::total_cmp); + let center = if vertical { rect.center().x } else { rect.center().y }; + near(&positions, &[center - 48.0, center, center + 48.0]); + } + release(&mut app, &ctx, p); + } +} + +#[test] +fn classic_turn_keeps_the_selected_overlay() { + let (mut app, ctx) = app(true, OverlayShow::Always); + let p = turning(&mut app, &ctx); + assert_eq!(crop_overlay::current_guides(&app, 329.0, 240.0), crop_overlay::guides(CropOverlay::Triangle, 1, 329.0, 240.0)); + release(&mut app, &ctx, p); +} + +#[test] +fn straighten_line_keeps_the_selected_overlay() { + let (mut app, ctx) = app(false, OverlayShow::Always); + control(&mut app, &ctx, "ui.pointer", json!({"command":true,"events":[{"kind":"down","x":40,"y":80},{"kind":"move","x":300,"y":110}]})); + assert!(crop_ui::turning(&app).is_none()); + assert_eq!(crop_overlay::current_guides(&app, 329.0, 240.0), crop_overlay::guides(CropOverlay::Triangle, 1, 329.0, 240.0)); + control(&mut app, &ctx, "ui.pointer", json!({"command":true,"events":[{"kind":"up","x":300,"y":110}]})); +} + +#[test] +fn invalid_sizes_are_empty_and_huge_frames_keep_bounded_guides() { + for (w, h) in [(0.0, 240.0), (329.0, 0.0), (f32::NAN, 240.0), (329.0, f32::INFINITY)] { + assert!(full_grid(w, h).is_empty()); + } + // A panned 1000-point viewport far outside the old centre-based cap. + let visible = egui::Rect::from_min_max(egui::pos2(900_000.0, 800_000.0), egui::pos2(901_000.0, 801_000.0)); + let grid = crop_overlay::turn_grid(1e6, 1e6, visible); + assert_eq!(grid.len(), 42, "the visible extent must have actual guides, not just satisfy an upper bound"); + let xs = positions(&grid, true, 1e6); + assert!((xs[0] - 900_032.0).abs() < 0.1 && (xs[xs.len() - 1] - 900_992.0).abs() < 0.1, "{xs:?}"); + assert!(grid.iter().flatten().flatten().all(|f| f.is_finite() && (0.0..=1.0).contains(f))); + let hostile = full_grid(1e6, 1e6); + assert!(!hostile.is_empty() && hostile.len() <= 8192); +} + +#[test] +fn a_zoomed_panned_view_is_covered_without_visiting_the_whole_frame() { + let (mut app, ctx) = app(false, OverlayShow::Always); + let p = turning(&mut app, &ctx); + app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); + let c = crop_ui::corners(app.ui.crop_rect.unwrap(), crop_ui::angle(&app)); + let at = [c[0][0] + 0.8 * (c[1][0] - c[0][0]) + 0.7 * (c[3][0] - c[0][0]), c[0][1] + 0.8 * (c[1][1] - c[0][1]) + 0.7 * (c[3][1] - c[0][1])]; + control(&mut app, &ctx, "ui.set", json!({"zoom":100.0,"center":at})); + assert!(crop_ui::turning(&app).is_some()); + let xf = photocraft_ui_egui::canvas::ViewXform::active(&app).unwrap(); + let q = crop_ui::corners(app.ui.crop_rect.unwrap(), crop_ui::angle(&app)).map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); + let (u, v) = (q[1] - q[0], q[3] - q[0]); + assert!(u.y.abs() < 0.1 && v.x.abs() < 0.1, "the default-mode frame stays upright"); + assert!(u.length() > 4608.0 && v.length() > 4608.0, "exercise a frame beyond the old centre cap"); + // Model navigation's local camera being newer than the stored one: guides + // must use the same quad/clip as paint, not reconstruct the stored camera. + app.ui.views[0].center = [206.0, 150.5]; + let lines = crop_overlay::current_guides_for_frame(&app, q, xf.rect); + assert!(!lines.is_empty() && lines.len() < 50, "work is bounded by the visible viewport: {}", lines.len()); + for (vertical, lo, hi) in [(true, xf.rect.left(), xf.rect.right()), (false, xf.rect.top(), xf.rect.bottom())] { + let mut positions: Vec = lines + .iter() + .filter(|l| (l[0][0] == l[1][0]) == vertical) + .map(|l| { + let screen = q[0] + u * l[0][0] + v * l[0][1]; + if vertical { screen.x } else { screen.y } + }) + .collect(); + positions.sort_by(f32::total_cmp); + let first = *positions.first().unwrap(); + let last = *positions.last().unwrap(); + assert!(first >= lo - 0.1 && first - lo < 48.1 && last <= hi + 0.1 && hi - last < 48.1, "{positions:?}, viewport {lo}..{hi}"); + assert!(positions.windows(2).all(|p| (p[1] - p[0] - 48.0).abs() < 0.1), "{positions:?}"); + } + release(&mut app, &ctx, p); +} + +#[test] +fn rotated_and_flipped_viewports_keep_frame_axis_spacing() { + let (mut app, ctx) = app(false, OverlayShow::Always); + let p = turning(&mut app, &ctx); + let clip = egui::Rect::from_min_max(egui::pos2(100.0, 150.0), egui::pos2(260.0, 350.0)); + for flipped in [false, true] { + // The measured329x240 frame turned90deg; reflecting its horizontal + // direction exercises the inverse basis's negative determinant too. + let (x, side) = if flipped { (60.0, 300.0) } else { (300.0, 60.0) }; + let q = [egui::pos2(x, 100.0), egui::pos2(x, 429.0), egui::pos2(side, 429.0), egui::pos2(side, 100.0)]; + let lines = crop_overlay::current_guides_for_frame(&app, q, clip); + near(&positions(&lines, true, 329.0), &[68.5, 116.5, 164.5, 212.5]); + near(&positions(&lines, false, 240.0), &[72.0, 120.0, 168.0]); + for line in lines { + let at = |t: [f32; 2]| q[0] + (q[1] - q[0]) * t[0] + (q[3] - q[0]) * t[1]; + let delta = at(line[1]) - at(line[0]); + if line[0][0] == line[1][0] { + assert!(delta.y.abs() < 0.001 && (delta.x.abs() - 240.0).abs() < 0.001); + } else { + assert!(delta.x.abs() < 0.001 && (delta.y.abs() - 329.0).abs() < 0.001); + } + } + } + // A non-cardinal view turn: the square viewport projects to centre +/- + // 96*sqrt(2), so five48-point lines per frame axis intersect it. + let reach = 240.0 * std::f32::consts::SQRT_2; + let q = [egui::pos2(500.0, 500.0 - reach), egui::pos2(500.0 + reach, 500.0), egui::pos2(500.0, 500.0 + reach), egui::pos2(500.0 - reach, 500.0)]; + let clip = egui::Rect::from_min_max(egui::pos2(404.0, 404.0), egui::pos2(596.0, 596.0)); + let lines = crop_overlay::current_guides_for_frame(&app, q, clip); + near(&positions(&lines, true, 480.0), &[144.0, 192.0, 240.0, 288.0, 336.0]); + near(&positions(&lines, false, 480.0), &[144.0, 192.0, 240.0, 288.0, 336.0]); + release(&mut app, &ctx, p); + assert_eq!(crop_overlay::current_guides_for_frame(&app, q, clip), crop_overlay::guides(CropOverlay::Triangle, 1, 480.0, 480.0)); + app.ui.tool = Tool::Brush; + assert!(crop_overlay::current_guides_for_frame(&app, q, clip).is_empty()); +} + +#[test] +fn empty_clips_and_degenerate_frame_bases_have_no_guides() { + let (mut app, ctx) = app(false, OverlayShow::Always); + let p = turning(&mut app, &ctx); + let frame = [egui::pos2(0.0, 0.0), egui::pos2(329.0, 0.0), egui::pos2(329.0, 240.0), egui::pos2(0.0, 240.0)]; + assert!(crop_overlay::current_guides_for_frame(&app, frame, egui::Rect::NOTHING).is_empty()); + let clip = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(329.0, 240.0)); + assert!(crop_overlay::current_guides_for_frame(&app, [egui::Pos2::ZERO; 4], clip).is_empty()); + let collinear = [egui::Pos2::ZERO, egui::pos2(329.0, 0.0), egui::pos2(569.0, 0.0), egui::pos2(240.0, 0.0)]; + assert!(crop_overlay::current_guides_for_frame(&app, collinear, clip).is_empty()); + let mut nonfinite = frame; + nonfinite[1].x = f32::NAN; + assert!(crop_overlay::current_guides_for_frame(&app, nonfinite, clip).is_empty()); + let offscreen = egui::Rect::from_min_max(egui::pos2(400.0, 400.0), egui::pos2(600.0, 600.0)); + assert!(crop_overlay::current_guides_for_frame(&app, frame, offscreen).is_empty()); + release(&mut app, &ctx, p); +} diff --git a/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs b/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs new file mode 100644 index 0000000000..74e7fbbef9 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs @@ -0,0 +1,308 @@ +//! Crop tool, default mode: turning the image keeps the frame inside the turned image (#10869). +//! +//! Measured in the reference application on an 800 × 500 document, turning by a drag outside the +//! frame with Auto Center Preview off: the frame keeps its size where it fits the turned image, +//! else moves the shortest way into it, else shrinks about its centre keeping its proportions. +//! With Ctrl (⌘) held the frame isn't adjusted and the uncovered corners get the Fill. +//! PhotoCraft never adjusted the frame, so ↵ filled the corners with the background colour. +//! +//! Driven as an agent does, through `ui.pointer` (document px as the view showed them at the +//! press). + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_mode::kept_inside; +use photocraft_ui_egui::crop_ui::{angle, center, corners}; +use serde_json::{Value, json}; + +/// The document's size. +const W: f64 = 800.0; +const H: f64 = 500.0; +/// The whole canvas, the default frame. +const CANVAS: [f64; 4] = [0.0, 0.0, W, H]; + +/// An 800 × 500 white document with a #1050e0 background colour, the Crop tool in the default +/// mode with Auto Center Preview off, and `frame` as the pending crop frame: the default frame +/// when it is the whole canvas, else drawn from its bottom-right corner to its top-left one (a +/// press on the default frame's corner handle would resize it). +fn app(frame: [f64; 4]) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": W, "height": H, "background": "white"})).expect("new"); + app.run("tools.setColors", json!({"background": "#1050e0"})).expect("colors"); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool_options.crop_shield.auto_center_preview = false; + control(&mut app, &ctx, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [W / 2.0, H / 2.0]})); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert!(!app.ui.tool_options.crop_shield.classic_mode, "Photoshop's default mode"); + if frame != CANVAS { + let events = json!([ + {"kind": "down", "x": frame[2], "y": frame[3]}, + {"kind": "move", "x": frame[0], "y": frame[1]}, + {"kind": "up", "x": frame[0], "y": frame[1]}, + ]); + control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + } + assert_eq!(app.ui.crop_rect, Some(frame)); + (app, ctx) +} + +/// Runs one control-channel request, which must succeed. +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (req, _rx) = ControlRequest::new(method, params.clone()); + assert!(matches!(handle(app, ctx, &req), Outcome::Done(_)), "{method} {params}"); +} + +/// The point 500 px right of the frame's centre turned `deg` degrees clockwise about it: a press +/// there is outside every frame below, and moving to it from the unturned point turns the image +/// `deg` degrees clockwise. +fn around(frame: [f64; 4], deg: f64) -> [f64; 2] { + let c = center(frame); + let (s, k) = deg.to_radians().sin_cos(); + [c[0] + 500.0 * k, c[1] + 500.0 * s] +} + +/// A turn of the image: a press outside `frame`, then for each `(deg, modifiers)` step a move to +/// `deg` with those modifiers held (one `ui.pointer` call each, so a key can be pressed during +/// the drag); `each` runs after every step. Releases at the last step's point with its modifiers. +fn turn(app: &mut PhotocraftApp, ctx: &egui::Context, frame: [f64; 4], steps: &[(f64, Value)], mut each: impl FnMut(&PhotocraftApp, f64)) { + let p = around(frame, 0.0); + control(app, ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": p[0], "y": p[1]}]})); + for (deg, mods) in steps { + let p = around(frame, *deg); + control(app, ctx, "ui.pointer", json!({"modifiers": mods, "events": [{"kind": "move", "x": p[0], "y": p[1]}]})); + each(app, *deg); + } + let (deg, mods) = steps.last().expect("a step"); + let p = around(frame, *deg); + control(app, ctx, "ui.pointer", json!({"modifiers": mods, "events": [{"kind": "up", "x": p[0], "y": p[1]}]})); +} + +/// No modifier held. +fn plain() -> Value { + json!({}) +} + +/// The pending frame. +fn frame(app: &PhotocraftApp) -> [f64; 4] { + app.ui.crop_rect.expect("a frame") +} + +/// The pending frame's corners, turned with it, all lie on the canvas. +fn inside(app: &PhotocraftApp) -> bool { + corners(frame(app), angle(app)).iter().all(|c| (-1e-6..=W + 1e-6).contains(&c[0]) && (-1e-6..=H + 1e-6).contains(&c[1])) +} + +/// Where the frame's centre shows on screen. +fn on_screen(app: &PhotocraftApp) -> egui::Pos2 { + let c = center(frame(app)); + ViewXform::active(app).expect("a view").to_screen(c[0] as f32, c[1] as f32) +} + +/// ↵: commits the crop and returns the document's new size. +fn committed(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("document").doc.size; + (size.width, size.height) +} + +/// The flattened document's four corner pixels and its middle one. +fn corners_and_middle(app: &PhotocraftApp) -> [[f32; 4]; 5] { + let doc = &app.session.active().expect("document").doc; + let flat = photocraft_compose::flatten(doc); + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let px = |x: usize, y: usize| flat.px[y * w + x]; + [px(0, 0), px(w - 1, 0), px(w - 1, h - 1), px(0, h - 1), px(w / 2, h / 2)] +} + +/// The background colour #1050e0 rather than the white image: its red is 16/255. A corner that +/// the turned image covers only in part still has far more red than that. +fn background(p: [f32; 4]) -> bool { + p[0] < 0.5 && (p[2] - 224.0 / 255.0).abs() < 0.02 +} + +/// White: the image. +fn white(p: [f32; 4]) -> bool { + p[..3].iter().all(|&v| v > 0.98) +} + +/// The frame's angle is `want` degrees, up to the `f32` rounding of the view the pointer is read +/// through. +fn near_angle(deg: f64, want: f64) -> bool { + (deg - want).abs() < 1e-4 +} + +/// The size of the largest frame of the canvas's proportions that fits inside the canvas turned +/// `deg` degrees. +fn fitted_size(deg: f64) -> (f64, f64) { + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + let k = (W / (W * c + H * s)).min(H / (W * s + H * c)); + (W * k, H * k) +} + +/// The issue's first two rows: the whole-canvas frame shrinks about its centre, keeping its +/// proportions, to the largest size that fits (687 × 429 measured at 6.3°, 580 × 362 at the 15° +/// that ⇧ snaps to), live while dragging, with the box put on screen. ↵ crops to it and no corner +/// shows the background colour. +#[test] +fn turning_the_whole_canvas_frame_shrinks_it_inside_the_turned_image() { + for (deg, shift, measured) in [(6.3, false, (687.0, 429.0)), (13.0, true, (580.0, 362.0))] { + let what = format!("turned {deg}°, shift {shift}"); + let (mut app, ctx) = app(CANVAS); + let screen = on_screen(&app); + // ⇧ pressed after the press, as measured: the turn snaps to 15°. + let steps = [(deg / 2.0, plain()), (deg, json!({"shift": shift}))]; + turn(&mut app, &ctx, CANVAS, &steps, |app, step| { + assert!(inside(app), "{what}: at {step}° the frame {:?} turned {} leaves the image", frame(app), angle(app)); + assert!(on_screen(app).distance(screen) < 0.01, "{what}: at {step}° the box moved on screen to {:?}", on_screen(app)); + }); + let turned = if shift { 15.0 } else { deg }; + assert!(near_angle(angle(&app), -turned), "{what}: the frame's angle is {}", angle(&app)); + let (w, h) = fitted_size(angle(&app)); + let f = frame(&app); + let (fw, fh) = (f[2] - f[0], f[3] - f[1]); + assert!((fw - w).abs() < 1e-6 && (fh - h).abs() < 1e-6, "{what}: {fw} × {fh}, want {w} × {h}"); + assert!((fw - measured.0).abs() <= 3.0 && (fh - measured.1).abs() <= 3.0, "{what}: {fw} × {fh} is far from the reference's {measured:?}"); + let c = center(f); + assert!((c[0] - W / 2.0).abs() < 1e-6 && (c[1] - H / 2.0).abs() < 1e-6, "{what}: centred, not at {c:?}"); + let (cw, ch) = committed(&mut app); + assert!((f64::from(cw) - w).abs() <= 1.0 && (f64::from(ch) - h).abs() <= 1.0, "{what}: committed {cw} × {ch}"); + for p in corners_and_middle(&app) { + assert!(!background(p), "{what}: a corner shows the background colour: {p:?}"); + } + assert!(white(corners_and_middle(&app)[4]), "{what}: the middle is the image"); + } +} + +/// The issue's Ctrl row: with Ctrl (⌘) held the frame keeps the whole canvas and ↵ fills the +/// corners the turn uncovered. Pressed part way through the turn, Ctrl gives the press frame back +/// at once. +#[test] +fn with_command_held_the_turn_keeps_the_frame() { + for late in [false, true] { + let (mut app, ctx) = app(CANVAS); + let first = if late { plain() } else { json!({"command": true}) }; + turn(&mut app, &ctx, CANVAS, &[(3.0, first), (6.0, json!({"command": true}))], |app, step| { + if late && step == 3.0 { + assert!(frame(app) != CANVAS && inside(app), "Ctrl not held yet: the frame fits, {:?}", frame(app)); + } else { + assert_eq!(frame(app), CANVAS, "late {late}: at {step}°"); + } + }); + assert!(near_angle(angle(&app), -6.0), "late {late}: {}", angle(&app)); + assert_eq!(committed(&mut app), (800, 500), "late {late}"); + let [corner @ .., middle] = corners_and_middle(&app); + for p in corner { + assert!(background(p), "late {late}: an uncovered corner is {p:?}"); + } + assert!(white(middle), "late {late}: the middle is the image"); + } +} + +/// The issue's fourth row: a 300 × 201 frame at (100, 99) still lies inside the image turned +/// 11.3° about its centre, so it stays exactly as it was. +#[test] +fn a_frame_that_fits_the_turned_image_keeps_its_size_and_place() { + let start = [100.0, 99.0, 400.0, 300.0]; + let (mut app, ctx) = app(start); + turn(&mut app, &ctx, start, &[(5.0, plain()), (11.3, plain())], |app, step| { + assert_eq!(frame(app), start, "at {step}°"); + }); + assert!(near_angle(angle(&app), -11.3), "{}", angle(&app)); + assert_eq!(committed(&mut app), (300, 201)); + for p in corners_and_middle(&app) { + assert!(white(p), "{p:?}"); + } +} + +/// The issue's last row: a 300 × 200 frame in the top-left corner, turned 11.1°, keeps its size +/// and moves the shortest way into the turned image: its turned corners end up touching the +/// canvas's left and top edges, not further in. The box stays put on screen while the image +/// slides under it. +#[test] +fn a_frame_in_the_corner_moves_in_without_shrinking() { + let start = [0.0, 0.0, 300.0, 200.0]; + let (mut app, ctx) = app(start); + let screen = on_screen(&app); + turn(&mut app, &ctx, start, &[(5.0, plain()), (11.1, plain())], |app, step| { + assert!(inside(app), "at {step}° the frame {:?} leaves the image", frame(app)); + assert!(on_screen(app).distance(screen) < 0.01, "at {step}° the box moved on screen to {:?}", on_screen(app)); + }); + let f = frame(&app); + assert!(((f[2] - f[0]) - 300.0).abs() < 1e-9 && ((f[3] - f[1]) - 200.0).abs() < 1e-9, "the size is kept: {f:?}"); + let turned = corners(f, angle(&app)); + let left = turned.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min); + let top = turned.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min); + assert!(left.abs() < 1e-9 && top.abs() < 1e-9, "moved the shortest way: the turned frame's left {left}, top {top}"); + assert_eq!(committed(&mut app), (300, 200)); + for p in corners_and_middle(&app) { + assert!(!background(p), "a corner shows the background colour: {p:?}"); + } +} + +/// Every step of the turn starts from the frame at the press, so turning back within the same +/// drag gives the whole frame back instead of keeping the smallest size it passed through. +#[test] +fn turning_back_gives_the_frame_back() { + let (mut app, ctx) = app(CANVAS); + turn(&mut app, &ctx, CANVAS, &[(10.0, plain()), (0.0, plain())], |_, _| {}); + assert_eq!((frame(&app), angle(&app)), (CANVAS, 0.0)); +} + +/// A second turn starts from the frame the first one fitted, whose turned corners touch the +/// canvas's edges: turned further it shrinks again, turned back upright it stays as it is. +#[test] +fn a_second_turn_starts_from_the_fitted_frame() { + for second in [10.0, -6.3] { + let (mut app, ctx) = app(CANVAS); + turn(&mut app, &ctx, CANVAS, &[(6.3, plain())], |_, _| {}); + let first = frame(&app); + turn(&mut app, &ctx, first, &[(second, plain())], |_, _| {}); + let f = frame(&app); + let what = format!("then {second}°: {f:?} turned {}", angle(&app)); + assert!(near_angle(angle(&app), -(6.3 + second)), "{what}"); + assert!(inside(&app), "{what}"); + if second > 0.0 { + let want = fitted_size(angle(&app)); + assert!(((f[2] - f[0]) - want.0).abs() < 1e-6 && ((f[3] - f[1]) - want.1).abs() < 1e-6, "{what}: want {want:?}"); + } else { + assert_eq!(f, first, "{what}"); + } + } +} + +/// A frame `kept_inside` fitted is left exactly as it is at the same angle, at every tenth of a +/// degree up to 90°. The fitted frame's turned corners touch the canvas's edges, and at many +/// angles rounding puts them a hair past: without the fit's tolerance such a frame would count as +/// reaching past the canvas, and a second turn would no longer keep it inside. +#[test] +fn a_fitted_frame_fits_at_its_own_angle() { + let off_centre = [0.0, 0.0, 300.0, 200.0]; + for i in 1..900 { + let deg = f64::from(i) / 10.0; + for start in [CANVAS, off_centre] { + let f = kept_inside(start, deg, W, H).expect("a frame"); + assert_eq!(kept_inside(f, deg, W, H), Some(f), "{start:?} at {deg}°"); + } + } +} + +/// Not measured, so they keep today's behaviour: with Fill set to Content-Aware Fill, with a +/// frame already reaching past the canvas at the press, and in Classic Mode (where the frame +/// turns over the image) the frame keeps its size and place. +#[test] +fn content_aware_fill_a_frame_past_the_canvas_and_classic_mode_keep_the_frame() { + let past = [-50.0, 100.0, 300.0, 300.0]; + for (what, start) in [("content-aware", CANVAS), ("past the canvas", past), ("classic", CANVAS)] { + let (mut app, ctx) = app(start); + app.ui.tool_options.crop_content_aware = what == "content-aware"; + app.ui.tool_options.crop_shield.classic_mode = what == "classic"; + turn(&mut app, &ctx, start, &[(6.3, plain())], |_, _| {}); + assert!(angle(&app).abs() > 6.0, "{what}: turned, {}", angle(&app)); + assert_eq!(frame(&app), start, "{what}"); + } +} diff --git a/crates/ui-egui/tests/it/full_res_preview.rs b/crates/ui-egui/tests/it/full_res_preview.rs index f472dd2ad3..f2bcbf19be 100644 --- a/crates/ui-egui/tests/it/full_res_preview.rs +++ b/crates/ui-egui/tests/it/full_res_preview.rs @@ -1,7 +1,7 @@ //! Preferences › Performance › Low Resolution Previews: on (the default) live previews of a large //! document render on a reduced copy, off they render at full resolution. //! -//! A 3000 × 2000 document of 1 px black and white columns: the reduced copy (every 2nd pixel at +//! A 2502 × 2000 document of 1 px black and white columns: the reduced copy (every 2nd pixel at //! this size) is all black, so its preview shows a flat colour where the full-size one shows the //! columns at 100 % (and their mipmapped grey zoomed out). Drives the real app offscreen on wgpu; //! skips when no GPU adapter exists (like `drag_preview_canvas.rs`). @@ -15,7 +15,8 @@ use serde_json::{Value, json}; type Harness = egui_kittest::Harness<'static, PhotocraftApp>; -const W: i32 = 3000; +// Just above the 5 MP proxy threshold; keep the same factor-two reduction. +const W: i32 = 2502; const H: i32 = 2000; fn harness() -> Option { @@ -47,9 +48,17 @@ fn control(h: &mut Harness, method: &str, params: Value) { fn columns_document() -> Document { let mut doc = Document::with_background("columns", Size::new(W as u32, H as u32), ColorMode::Rgb, SampleType::U8, Color::WHITE); + assert_eq!(photocraft_ui_egui::proxy::factor(&doc), 2, "the fixture must exercise a reduced preview"); let s = doc.layers[0].surface_mut().expect("background pixels"); - for x in (0..W).step_by(2) { - s.fill_rect(Rect::new(x, 0, x + 1, H), &[0.0, 0.0, 0.0, 1.0]); + let row: Vec = (0..W) + .flat_map(|x| { + let v = if x % 2 == 0 { 0.0 } else { 1.0 }; + [v, v, v, 1.0] + }) + .collect(); + for y in (0..H).step_by(64) { + let end = (y + 64).min(H); + s.write_region(Rect::new(0, y, W, end), &row.repeat((end - y) as usize)); } doc } @@ -88,7 +97,7 @@ fn row(h: &mut Harness) -> Vec { photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect) }; // egui_kittest waits 10 s for the GPU. Windows CI renders on WARP, a software rasterizer, - // where the first full-resolution composite of this 6 MP document can take longer; the + // where the first full-resolution composite of this 5 MP document can take longer; the // submitted work still finishes, so wait for it again instead of failing. let img = (0..3).find_map(|_| h.render().ok()).expect("render"); let (x0, y) = (canvas.min.x as u32 + 20, canvas.min.y as u32 + 40); diff --git a/crates/ui-egui/tests/it/layout_perf.rs b/crates/ui-egui/tests/it/layout_perf.rs index 2b5cf73bf1..b8f43f4733 100644 --- a/crates/ui-egui/tests/it/layout_perf.rs +++ b/crates/ui-egui/tests/it/layout_perf.rs @@ -232,7 +232,9 @@ fn layout_document_composites_on_the_gpu_like_the_cpu() { std::thread::yield_now(); } } - let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())) else { + let Ok(adapter) = + block_on(wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()).request_adapter(&wgpu::RequestAdapterOptions::default())) + else { eprintln!("skipping: no GPU adapter"); return; }; diff --git a/crates/ui-egui/tests/it/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs index 58d6632ea3..a152f6678c 100644 --- a/crates/ui-egui/tests/it/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/it/live_stroke_canvas.rs @@ -118,8 +118,7 @@ fn max_diff(a: &[[u8; 4]], b: &[[u8; 4]]) -> u8 { a.iter().zip(b).flat_map(|(p, q)| (0..3).map(move |i| p[i].abs_diff(q[i]))).max().unwrap_or(0) } -#[test] -fn the_canvas_shows_the_committed_stroke_while_it_is_drawn() { +fn the_canvas_shows_the_committed_stroke_part(part: usize) { let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; h.run_steps(4); @@ -135,7 +134,7 @@ fn the_canvas_shows_the_committed_stroke_while_it_is_drawn() { cases.push(Case { zoom: 4.0, smoothing: 0.1, hardness: 0.0, depth: 8, pressure: false, flip: true }); cases.push(Case { zoom: 12.0, smoothing: 0.5, hardness: 0.0, depth: 16, pressure: true, flip: true }); let mut gpu_seen = false; - for c in cases { + for c in cases.into_iter().skip(part).step_by(4) { let (before, drawing, done, gpu) = frames(&mut h, c); gpu_seen |= gpu; // The stroke is on screen while drawing (not a blank canvas compared with a blank one)… @@ -148,3 +147,23 @@ fn the_canvas_shows_the_committed_stroke_while_it_is_drawn() { eprintln!("note: the GPU compositor fell back to the CPU for every case"); } } + +#[test] +fn the_canvas_shows_the_committed_stroke_part_0() { + the_canvas_shows_the_committed_stroke_part(0); +} + +#[test] +fn the_canvas_shows_the_committed_stroke_part_1() { + the_canvas_shows_the_committed_stroke_part(1); +} + +#[test] +fn the_canvas_shows_the_committed_stroke_part_2() { + the_canvas_shows_the_committed_stroke_part(2); +} + +#[test] +fn the_canvas_shows_the_committed_stroke_part_3() { + the_canvas_shows_the_committed_stroke_part(3); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index e8d8b334ba..b8fdb2987d 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -9,10 +9,16 @@ mod camera_raw_scope; mod camera_raw_zoom; mod canvas_16f; mod checker_canvas; +mod clipboard_svg; mod color_managed_canvas; mod crop_beyond_canvas; +mod crop_ctrl_straighten; mod crop_default_mode; +mod crop_overlay_icons; mod crop_popup_keys; +mod crop_shift_corner; +mod crop_turn_grid; +mod crop_turn_keeps_frame_inside; mod custom_print_paper; mod display_shadows; mod drag_preview_canvas; diff --git a/crates/vector/src/feather.rs b/crates/vector/src/feather.rs index 5048916d49..2e3cd10d08 100644 --- a/crates/vector/src/feather.rs +++ b/crates/vector/src/feather.rs @@ -42,6 +42,21 @@ pub fn path_area(path: &Path, sigma: f32) -> Rect { } } +/// The largest buffer (in `f32`s) the blur has allocated, for tests of its working set. +#[cfg(test)] +static LARGEST_BUFFER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Tests make [`line_buffers`] fail for lines of this many samples (0: never). +#[cfg(test)] +static FAIL_LINE_BUFFERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Notes a blur buffer of `len` floats ([`LARGEST_BUFFER`]); nothing outside tests. +fn note_buffer(len: usize) { + #[cfg(test)] + LARGEST_BUFFER.fetch_max(len, std::sync::atomic::Ordering::Relaxed); + let _ = len; +} + /// 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)] @@ -99,34 +114,77 @@ fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { /// 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]; +fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass, halo: usize, buffers: &mut (Vec, Vec)) { + let (padded, scratch) = buffers; for row in v.chunks_mut(w) { - for _ in 0..3 { - blur_line(row, &mut scratch, pass); - } + blur_padded_line(row, padded, scratch, pass, halo); } } -fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { - if w == 0 || v.is_empty() { +/// A padded line and its scratch for lines of `len` samples with `halo` more on each side; `None` +/// when they don't fit in memory. +fn line_buffers(len: usize, halo: usize) -> Option<(Vec, Vec)> { + #[cfg(test)] + if FAIL_LINE_BUFFERS.load(std::sync::atomic::Ordering::Relaxed) == len { + return None; + } + let n = halo.checked_mul(2)?.checked_add(len)?; + let (mut padded, mut scratch) = (Vec::new(), Vec::new()); + padded.try_reserve_exact(n).ok()?; + scratch.try_reserve_exact(n).ok()?; + padded.resize(n, 0.0); + scratch.resize(n, 0.0); + note_buffer(n); + Some((padded, scratch)) +} + +/// The three box passes on `line` continued `halo` samples past each end with its end values, as +/// Photoshop continues a mask past the canvas edge (#9475). Padding each line, row pass then +/// column pass, gives exactly what padding the whole plane would: the plane's padded corners +/// repeat its corner values, which is what the padded rows' own row pass leaves there. `padded` +/// and `scratch` hold `line.len() + 2 * halo` samples. +fn blur_padded_line(line: &mut [f32], padded: &mut [f32], scratch: &mut [f32], pass: BoxPass, halo: usize) { + let (Some(&a), Some(&b)) = (line.first(), line.last()) else { return }; + let n = line.len() + 2 * halo; + let (Some(padded), Some(scratch)) = (padded.get_mut(..n), scratch.get_mut(..n)) else { return }; + padded[..halo].fill(a); + padded[halo..halo + line.len()].copy_from_slice(line); + padded[halo + line.len()..].fill(b); + for _ in 0..3 { + blur_line(padded, scratch, pass); + } + line.copy_from_slice(&padded[halo..halo + line.len()]); +} + +/// How many workers blur `lines` lines of `len` samples. +fn row_workers(lines: usize, len: usize) -> usize { + if cfg!(target_arch = "wasm32") || lines.saturating_mul(len) < 1 << 16 { + 1 + } else { + std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(lines.max(1)) + } +} + +/// Line buffers for each of [`row_workers`]; `None` unless every one fits in memory. +fn worker_buffers(lines: usize, len: usize, halo: usize) -> Option, Vec)>> { + (0..row_workers(lines, len)).map(|_| line_buffers(len, halo)).collect() +} + +/// Blurs the rows of `v` on one worker per entry of `buffers` (from [`worker_buffers`]). +fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass, halo: usize, buffers: &mut [(Vec, Vec)]) { + if w == 0 || v.is_empty() || buffers.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); + if let [only] = buffers { + blur_rows_chunk(v, w, pass, halo, only); return; } - let rows_per_worker = h.div_ceil(threads); + let rows_per_worker = h.div_ceil(buffers.len()); 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)); + for (chunk, buffers) in v.chunks_mut(chunk_len).zip(buffers.iter_mut()) { + scope.spawn(move || blur_rows_chunk(chunk, w, pass, halo, buffers)); } }); } @@ -147,50 +205,62 @@ fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { } /// 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 +/// exactly `sigma²` ([`BoxPass`]). Beyond its edges the plane continues with its edge values, as +/// Photoshop feathers a mask at the canvas edge: a mask white up to the edge stays white there, +/// one black there stays black (#9475). 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. + // Each pass reads `r + 1` samples past a pixel, so three see `3(r + 1)` past the edge. Clamping + // every pass at the edge instead repeated the blurred values there, which brightened a 32 px + // feather by up to 12 levels where the canvas cut it off. + let m = pass.r.saturating_add(1).saturating_mul(3); + blur_plane(v, w, h, pass, m); +} + +/// Three box passes along each axis of the `w`×`h` plane, every line continued `halo` samples +/// past its ends ([`blur_padded_line`]). +fn blur_plane(v: &mut [f32], w: usize, h: usize, pass: BoxPass, halo: usize) { + // Reserve everything before modifying the input, so an allocation failure can use the + // sequential fallback (which also reserves first) and never leaves a half-blurred mask. let mut transposed = Vec::new(); - if transposed.try_reserve_exact(v.len()).is_err() { - gaussian_sequential(v, w, h, pass); + let buffers = if transposed.try_reserve_exact(v.len()).is_ok() { worker_buffers(h, w, halo).zip(worker_buffers(w, h, halo)) } else { None }; + let Some((mut rows, mut columns)) = buffers else { + gaussian_sequential(v, w, h, pass, halo); return; - } + }; transposed.resize(v.len(), 0.0); - blur_rows(v, w, pass); + note_buffer(v.len()); + blur_rows(v, w, pass, halo, &mut rows); transpose_into(v, &mut transposed, w, h); - blur_rows(&mut transposed, h, pass); + blur_rows(&mut transposed, h, pass, halo, &mut columns); 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) { +/// Allocation-light reference path used if parallel transpose storage cannot be reserved. It +/// reserves its lines before touching `v`: without them the plane is left as it was, never half +/// blurred. +fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass, halo: usize) { 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() { + if line.try_reserve_exact(len).is_err() { return; } line.resize(len, 0.0); - scratch.resize(len, 0.0); + let Some((mut padded, mut scratch)) = line_buffers(len, halo) else { return }; 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); - } + blur_padded_line(&mut line[..line_len], &mut padded, &mut scratch, pass, halo); for i in 0..line_len { if axis { v[i * w + k] = line[i]; @@ -254,9 +324,31 @@ mod tests { 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"); + let source = parallel.clone(); + for halo in [0, 3 * (pass.r + 1)] { + parallel.copy_from_slice(&source); + sequential.copy_from_slice(&source); + gaussian_sequential(&mut sequential, w, h, pass, halo); + blur_plane(&mut parallel, w, h, pass, halo); + assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic (halo {halo})"); + } + } + + /// Padding each line (#9493) blurs exactly as padding the whole plane with its edge values + /// and cropping it afterwards does, without the padded plane. + #[test] + fn padding_each_line_matches_padding_the_plane() { + let (w, h, sigma): (usize, usize, f32) = (90, 70, 9.0); + let source: Vec = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); + let pass = BoxPass::for_sigma(sigma).unwrap(); + let m = 3 * (pass.r + 1); + let (pw, ph) = (w + 2 * m, h + 2 * m); + let mut plane: Vec = (0..pw * ph).map(|i| source[((i / pw).clamp(m, m + h - 1) - m) * w + (i % pw).clamp(m, m + w - 1) - m]).collect(); + blur_plane(&mut plane, pw, ph, pass, 0); + let want: Vec = (0..w * h).map(|i| plane[(i / w + m) * pw + i % w + m]).collect(); + let mut got = source; + gaussian(&mut got, w, h, sigma); + assert_eq!(got, want); } #[test] @@ -269,7 +361,7 @@ mod tests { 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); + gaussian_sequential(&mut sequential, w, h, pass, 3 * (pass.r + 1)); let sequential_time = start.elapsed(); let mut parallel = source; let start = Instant::now(); @@ -278,4 +370,36 @@ mod tests { assert_eq!(parallel, sequential); eprintln!("50 MP mask blur: sequential={sequential_time:?}, parallel={parallel_time:?}"); } + + /// CodeRabbit on #9493: continuing the mask past the plane's edge must not build a padded copy + /// of the whole plane. At the 1000 px maximum feather the halo is about 3000 px a side, so a + /// padded 400 × 400 plane would be 6400 × 6400 floats; the blur needs only the plane's + /// transpose and one padded line per worker. + #[test] + fn a_maximum_feather_does_not_pad_the_whole_plane() { + let (w, h) = (400usize, 400usize); + let mut v: Vec = (0..w * h).map(|i| if (i % w) < w / 2 { 1.0 } else { 0.0 }).collect(); + gaussian(&mut v, w, h, feather_sigma(1000.0)); + let largest = LARGEST_BUFFER.load(std::sync::atomic::Ordering::Relaxed); + assert!(largest <= w * h, "the blur allocated a buffer of {largest} floats for a {w} × {h} plane"); + assert!(v.iter().all(|x| x.is_finite())); + } + + /// CodeRabbit on #9493: when a line buffer can't be had, the mask is never left half blurred + /// (rows untouched but columns blurred, or some workers' rows only): the plane is either fully + /// blurred or untouched. + #[test] + fn a_failed_line_buffer_never_leaves_a_half_blurred_mask() { + let (w, h, sigma): (usize, usize, f32) = (433, 211, 7.0); + let source: Vec = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); + let mut want = source.clone(); + gaussian(&mut want, w, h, sigma); + for fail in [w, h] { + FAIL_LINE_BUFFERS.store(fail, std::sync::atomic::Ordering::Relaxed); + let mut got = source.clone(); + gaussian(&mut got, w, h, sigma); + FAIL_LINE_BUFFERS.store(0, std::sync::atomic::Ordering::Relaxed); + assert!(got == want || got == source, "lines of {fail} failing left a partly blurred plane"); + } + } } diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index a586dfa1bf..cc50817c74 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -73,28 +73,51 @@ fn eval(seg: &[Point; 4], t: f64) -> (f64, f64) { /// Validate before interactive rendering: finite coordinates and a bounded tessellation and /// dash workload. Arc length is bounded by the cubic control polygon, without allocating pixels. pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) -> Result<(), &'static str> { + validate_workload(path, stroke, 1_000_000.0, "shape coordinates must be finite and within ±1000000 px") +} + +fn validate_workload( + path: &Path, + stroke: Option<&photocraft_doc::ShapeStroke>, + coordinate_limit: f64, + coordinate_error: &'static str, +) -> Result<(), &'static str> { let knots = path.subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.knots.len())).ok_or("too many path knots")?; if knots > 4096 || path.subpaths.len() > 4096 { - return Err("shape path exceeds 4096 knots/subpaths"); + return Err("path exceeds 4096 knots/subpaths"); } let mut points = 0usize; let mut length = 0.0; for sub in &path.subpaths { for k in &sub.knots { for p in [k.anchor, k.in_ctrl, k.out_ctrl] { - if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1_000_000.0 || p.y.abs() > 1_000_000.0 { - return Err("shape coordinates must be finite and within ±1000000 px"); + if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > coordinate_limit || p.y.abs() > coordinate_limit { + return Err(coordinate_error); } } } points = points.saturating_add(1); for seg in sub.segments() { + let mut segment_length = 0.0; + for pair in seg.windows(2) { + let (dx, dy) = (pair[1].x - pair[0].x, pair[1].y - pair[0].y); + if !dx.is_finite() || !dy.is_finite() { + return Err("path geometry arithmetic must be finite"); + } + segment_length += dx.hypot(dy); + if !segment_length.is_finite() { + return Err("path geometry arithmetic must be finite"); + } + } + length += segment_length; + if !length.is_finite() { + return Err("path geometry arithmetic must be finite"); + } points = points.saturating_add(if is_line(&seg) { 1 } else { pieces(&seg, crate::DEFAULT_TOLERANCE) }); - length += seg.windows(2).map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)).sum::(); } } if points > 100_000 { - return Err("shape tessellation exceeds 100000 points"); + return Err("path tessellation exceeds 100000 points"); } if let Some(s) = stroke { s.validate()?; @@ -109,6 +132,12 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) Ok(()) } +/// Work and saved paths may extend far beyond the canvas. Keep their coordinates usable by the +/// f32 UI while bounding the complete path's flattening workload before publishing an edit. +pub fn validate_path(path: &Path) -> Result<(), &'static str> { + validate_workload(path, None, f64::from(f32::MAX), "path coordinates must be finite and representable as f32") +} + /// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks /// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile /// curves cannot exhaust memory. @@ -203,6 +232,58 @@ mod tests { } } + #[test] + fn general_paths_bound_total_knots_subpaths_and_combined_tessellation() { + let line = Subpath::polygon(&[(0.0, 0.0)]); + let mut paths = Path { subpaths: vec![line.clone(); 4096], ..Default::default() }; + assert!(validate_path(&paths).is_ok()); + paths.subpaths.push(line); + assert_eq!(validate_path(&paths), Err("path exceeds 4096 knots/subpaths")); + let knots = Path { subpaths: vec![Subpath::polygon(&vec![(0.0, 0.0); 4097])], ..Default::default() }; + assert_eq!(validate_path(&knots), Err("path exceeds 4096 knots/subpaths")); + + let curve = shapes::ellipse(0.0, 0.0, 1000.0, 1000.0).subpaths.remove(0); + let points = 1 + curve.segments().iter().map(|s| if is_line(s) { 1 } else { pieces(s, crate::DEFAULT_TOLERANCE) }).sum::(); + let half = 100_000 / points / 2 + 1; + let mut combined = Path { subpaths: vec![curve.clone(); half], ..Default::default() }; + assert!(validate_path(&combined).is_ok()); + assert!(validate_shape(&combined, None).is_ok()); + combined.subpaths.extend(vec![curve; half]); + assert!(combined.subpaths.len() < 4096 && combined.subpaths.iter().map(|s| s.knots.len()).sum::() < 4096); + assert_eq!(validate_path(&combined), Err("path tessellation exceeds 100000 points")); + assert_eq!(validate_shape(&combined, None), Err("path tessellation exceeds 100000 points")); + } + + #[test] + fn general_paths_reject_coordinates_that_overflow_the_ui_or_segment_arithmetic() { + for extent in [f64::from(f32::MAX) * 2.0, 1.0e308, f64::INFINITY, f64::NAN] { + let path = Path { subpaths: vec![Subpath::polygon(&[(-extent, 0.0), (extent, 0.0)])], ..Default::default() }; + assert_eq!(validate_path(&path), Err("path coordinates must be finite and representable as f32")); + } + // The shared arithmetic guard also rejects overflow independently of coordinate policy. + let overflowing_difference = Path { subpaths: vec![Subpath::polygon(&[(-1.0e308, 0.0), (1.0e308, 0.0)])], ..Default::default() }; + assert_eq!(validate_workload(&overflowing_difference, None, f64::MAX, "coordinates"), Err("path geometry arithmetic must be finite")); + let overflowing_length = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (f64::MAX, f64::MAX)])], ..Default::default() }; + assert_eq!(validate_workload(&overflowing_length, None, f64::MAX, "coordinates"), Err("path geometry arithmetic must be finite")); + } + + #[test] + fn general_paths_keep_large_straight_coordinates_and_shape_limits_stay_put() { + let at_shape_limit = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (1_000_000.0, 0.0)])], ..Default::default() }; + assert!(validate_shape(&at_shape_limit, None).is_ok()); + let outside_shape = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (1_000_001.0, 0.0)])], ..Default::default() }; + assert_eq!(validate_shape(&outside_shape, None), Err("shape coordinates must be finite and within ±1000000 px")); + assert!(validate_path(&outside_shape).is_ok()); + for extent in [3.0e9, f64::from(f32::MAX)] { + let path = Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (extent, 0.0), (extent, extent)])], ..Default::default() }; + assert!(validate_path(&path).is_ok()); + let line = flatten_subpath(&path.subpaths[0], crate::DEFAULT_TOLERANCE); + assert_eq!(line.pts, [(0.0, 0.0), (extent, 0.0), (extent, extent)]); + assert!(line.pts.iter().all(|(x, y)| (*x as f32).is_finite() && (*y as f32).is_finite())); + assert!(validate_shape(&path, None).is_err()); + } + } + #[test] fn hostile_curves_flatten_within_the_point_budget() { // Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index d6c21e3ac3..7ccaa1b103 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -567,14 +567,21 @@ pub struct CompiledShape { impl CompiledShape { /// Compiles the shape at flattening tolerance `tol`. `canvas` is the frame gradients - /// without "Align with layer" are laid out in. + /// without "Align with layer" are laid out in, and the area a feather is computed over. pub fn new(shape: &ShapeLayer, tol: f64, canvas: Rect) -> Self { + Self::with_frames(shape, tol, canvas, canvas) + } + + /// [`CompiledShape::new`] with the feather computed over `coverage` while gradients still lay + /// out in `gradient_frame`: measuring a shape beyond the canvas it is drawn in. + pub fn with_frames(shape: &ShapeLayer, tol: f64, coverage: Rect, gradient_frame: Rect) -> Self { let pb = shape.path.control_bounds().unwrap_or((0.0, 0.0, 0.0, 0.0)); - let cb = (f64::from(canvas.x0), f64::from(canvas.y0), f64::from(canvas.x1), f64::from(canvas.y1)); + let g = gradient_frame; + let cb = (f64::from(g.x0), f64::from(g.y0), f64::from(g.x1), f64::from(g.y1)); let area = fill_rasterizer(&shape.path, tol); let mut bounds = None; let fill = shape.fill.as_ref().map(|f| { - let coverage = FillCoverage::new(shape, &area, canvas); + let coverage = FillCoverage::new(shape, &area, coverage); bounds = coverage.bounds(); (coverage, Paint::from_fill(f, pb, cb)) }); diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index d259d27871..0b7e65a48d 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -215,6 +215,65 @@ pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path { Path::new(vec![Subpath::polygon(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)])]) } +/// A filled vector line with optional arrowheads at its endpoints (#6408). +/// `width` and `length` measure the head in document pixels, independently of the shaft weight. +/// `concavity` is 0..100 percent: 0 has a flat rear edge, 100 a deep V-shaped notch. +/// The shaft stops at the notch, so a concave head remains concave after filling. +// The arguments correspond to the stored LiveShape::ArrowLine parameters. +#[allow(clippy::too_many_arguments)] +pub fn arrow_line(a: (f64, f64), b: (f64, f64), weight: f64, start: bool, end: bool, width: f64, length: f64, concavity: f64) -> Path { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let distance = dx.hypot(dy); + if !distance.is_finite() || !weight.is_finite() { + return Path::default(); + } + // Without arrowheads, use the ordinary line even for segments shorter than 1 px. + if !start && !end { + return line(a, b, weight); + } + if distance < 1.0 { + return Path::default(); + } + let (ux, uy) = (dx / distance, dy / distance); + let (vx, vy) = (-uy, ux); + let size = (if length.is_finite() { length.max(1.0) } else { 1.0 }).min(distance * if start && end { 0.49 } else { 0.9 }); + let half = (if width.is_finite() { width.max(1.0) } else { 1.0 }) / 2.0; + let requested_notch = concavity.clamp(0.0, 100.0) / 100.0; + // Preserve 0..50%. Beyond that compress the usable range so the head never collapses. + let max_notch = 0.75; + let notch = if requested_notch <= 0.5 { requested_notch } else { 0.5 + (requested_notch - 0.5) * (max_notch - 0.5) / 0.5 }; + // Put the shaft up to the notch, leaving the head's concave edges visible. + let inner = size * (1.0 - notch); + let shaft_a = if start { (a.0 + ux * inner, a.1 + uy * inner) } else { a }; + let shaft_b = if end { (b.0 - ux * inner, b.1 - uy * inner) } else { b }; + let hw = weight.max(0.1) / 2.0; + let mut pieces = vec![Subpath::polygon(&[ + (shaft_a.0 + vx * hw, shaft_a.1 + vy * hw), + (shaft_b.0 + vx * hw, shaft_b.1 + vy * hw), + (shaft_b.0 - vx * hw, shaft_b.1 - vy * hw), + (shaft_a.0 - vx * hw, shaft_a.1 - vy * hw), + ])]; + // The tip, both outer corners of the head and the recessed midpoint. + // Positive `sign` points towards the tail of the head (away from its tip). + let mut head = |tip: (f64, f64), sign: f64| { + let base = (tip.0 + ux * sign * size, tip.1 + uy * sign * size); + let rear_middle = (base.0 - ux * sign * size * notch, base.1 - uy * sign * size * notch); + pieces.push(Subpath::polygon(&[tip, (base.0 + vx * half, base.1 + vy * half), rear_middle, (base.0 - vx * half, base.1 - vy * half)])); + }; + if start { + head(a, 1.0); + } + if end { + head(b, -1.0); + } + // Never return non-finite arrow geometry. + // Large but valid scaled arrowheads remain unchanged. + if pieces.iter().flat_map(|sp| &sp.knots).any(|k| !k.anchor.x.is_finite() || !k.anchor.y.is_finite()) { + return Path::default(); + } + Path::new(pieces) +} + /// The path of a live shape. pub fn live_path(s: &LiveShape) -> Path { match s { @@ -224,6 +283,9 @@ pub fn live_path(s: &LiveShape) -> Path { 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), + LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => { + arrow_line((from[0], from[1]), (to[0], to[1]), *weight, *start, *end, *width, *length, *concavity) + } } } @@ -231,6 +293,33 @@ pub fn live_path(s: &LiveShape) -> Path { mod tests { use super::*; + #[test] + fn rejects_non_finite_arrowhead_coordinates() { + let path = arrow_line((1.5e308, 0.0), (1.5e308, 200.0), 8.0, false, true, f64::MAX, 50.0, 0.0); + assert!(path.subpaths.is_empty()); + } + + #[test] + fn preserves_scaled_head_dimensions_above_ui_limit() { + let path = arrow_line((0.0, 0.0), (20000.0, 0.0), 8.0, false, true, 5200.0, 5200.0, 0.0); + let head = &path.subpaths[1].knots; + assert!((head[1].anchor.x - 14800.0).abs() < 1e-6); + assert!((head[1].anchor.y - 2600.0).abs() < 1e-6); + } + + #[test] + fn live_arrow_can_be_regenerated_after_edit() { + let shape = + LiveShape::ArrowLine { from: [10.0, 10.0], to: [200.0, 10.0], weight: 4.0, start: false, end: true, width: 31.0, length: 31.0, concavity: 50.0 }; + let before = live_path(&shape); + assert_eq!(before.subpaths.len(), 2); + let mut edited = shape; + if let LiveShape::ArrowLine { width, .. } = &mut edited { + *width = 60.0; + } + let after = live_path(&edited); + assert_ne!(before, after); + } /// 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; @@ -317,3 +406,33 @@ mod tests { assert!((top.x - 5.0).abs() < 1e-9 && top.y.abs() < 1e-9); } } + +#[cfg(test)] +mod line_arrow_6408_tests { + use super::*; + + #[test] + fn short_line_without_heads_matches_regular_line() { + let a = (0.0, 0.0); + let b = (0.25, 0.0); + assert_eq!(arrow_line(a, b, 5.0, false, false, 52.0, 50.0, 0.0), line(a, b, 5.0)); + } + + #[test] + fn plain_line_has_single_subpath() { + let p = arrow_line((0.0, 0.0), (100.0, 0.0), 5.0, false, false, 52.0, 50.0, 0.0); + assert_eq!(p.subpaths.len(), 1); + } + + #[test] + fn start_end_arrowheads_add_two_vector_subpaths() { + let p = arrow_line((0.0, 0.0), (100.0, 0.0), 5.0, true, true, 52.0, 50.0, 25.0); + assert_eq!(p.subpaths.len(), 3); + } + + #[test] + fn degenerate_line_returns_empty_path() { + let p = arrow_line((2.0, 2.0), (2.0, 2.0), 5.0, true, true, 52.0, 50.0, 0.0); + assert!(p.subpaths.is_empty()); + } +} diff --git a/docs/atn.md b/docs/atn.md new file mode 100644 index 0000000000..2e57068baf --- /dev/null +++ b/docs/atn.md @@ -0,0 +1,2163 @@ +# Actions files (`.atn`) + +PhotoCraft loads and saves action sets in the reference application's `.atn` format: Load Actions and +Save Actions in the Actions panel menu, `actions.load` / `actions.save` for agents, and +`photocraft batch --actions set.atn` on the command line. Reading and writing the file lives in +`photocraft-psd` (`crates/psd/src/atn.rs`, version 16 sets, exact round trip); the mapping between the +application's events and PhotoCraft commands lives in `photocraft-engine` +(`crates/engine/src/cmd/atn_cmds/`, table `EVENTS` in `events.rs`). + +A step whose event is in the table below loads as the PhotoCraft command it names, plays, and saves back +as the same event. Any other step loads as a kept step (`actions.photoshopStep`), and Save Actions writes +it back unchanged. Playback stops at an enabled kept step with its name, so an action never silently skips +work. Kept steps with no effect on the document (tool selections and the like) are imported switched off, +so playback skips them. + +This page covers the events PhotoCraft maps today, the settings some of them still lose, how steps name +layers and documents, newer and older events and files, and a measured reference for the events it does not +map yet, with everything needed to implement them without +the reference application. + +## Action playback history + +Actions create one history entry named `Action: ` by default. Nested plays +share the outer action's entry, and a failing or Stop step keeps the completed +prefix. A play with no document change adds no entry and preserves redo. +`actions.setHistory {"singleState":false}` restores per-step history. The option +is saved as `actions.createSingleHistoryState` in preferences and defaults to true +when loading older preferences. It is separate from Playback Options' performance +modes; the Playback Options dialog can use the same setting when #5212 lands. + +Step-dialog confirmation joins the original play only while its document and +history still match the paused state. Cancel keeps the completed prefix; an +intervening edit or document switch prevents the stale dialog from applying. +Batch and droplets group each processed document independently under the action's +name and use the same saved option. +Existing non-linear History ordering and visibility options continue to apply. + +Explicit Clear History and Purge Histories steps keep their normal effects. Later +edits form a grouped entry from that new boundary, without restoring discarded +states. If an action explicitly chooses an intermediate row as the History Brush +source, its pixels are retained as a named snapshot when grouping removes that +row; the action still adds only one Undo entry. + +## Supported events + +| Code | String ID | Label | PhotoCraft command(s) | +|---|---|---|---| +| `AdNs` | addNoise | Add Noise | `filter.noise.addNoise` | +| `Algn` | align | Align | `layer.align.leftEdges`, `layer.align.horizontalCenters`, `layer.align.rightEdges`, `layer.align.topEdges`, `layer.align.verticalCenters`, `layer.align.bottomEdges` | +| `AppI` | applyImageEvent | Apply Image | `image.applyImage` | +| (string ID only) | applyComp | Apply Layer Comp | `layerComp.apply` | +| (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | +| (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | +| (string ID only) | autoColor | Auto Color | `image.autoColor` | +| `Avrg` | Avrg | Average | `filter.blur.average` | +| (string ID only) | blackAndWhite | Black & White | `image.adjustments.blackWhite` | +| `Blr ` | blur | Blur | `filter.blur.blur` | +| `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | +| `Brdr` | border | Border | `select.modify.border` | +| (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | +| `BrgC` | brightnessEvent | Brightness/Contrast | `image.adjustments.brightnessContrast` | +| (string ID only) | Adobe Camera Raw Filter | Camera Raw Filter | `filter.cameraRaw` | +| `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | +| `ChnM` | channelMixer | Channel Mixer | `image.adjustments.channelMixer` | +| (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | +| `Cls ` | close | Close | `file.close` | +| `Clds` | clouds | Clouds | `filter.render.clouds` | +| (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | +| `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | +| `ClrR` | colorRange | Color Range | `select.colorRange` | +| (string ID only) | cafWorkspace | Content-Aware Fill | `edit.contentAwareFill` | +| `Cntc` | contract | Contract | `select.modify.contract` | +| `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | +| (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | +| (string ID only) | newPlacedLayer | Convert to Smart Object | `layer.smartObjects.convertToSmartObject` | +| `copy` | copyEvent | Copy | `edit.copy` | +| (string ID only) | copyEffects | Copy Layer Style | `layer.layerStyle.copyLayerStyle` | +| `CpyM` | copyMerged | Copy Merged | `edit.copyMerged` | +| `GrpL` | groupEvent | Create Clipping Mask | `layer.createClippingMask` | +| `Crop` | crop | Crop | `image.crop` | +| `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | +| `Crvs` | curves | Curves | `image.adjustments.curves` | +| `Cstm` | custom | Custom | `filter.other.custom` | +| `cut ` | cut | Cut | `edit.cut` | +| `Dntr` | deInterlace | De-Interlace | `filter.video.deInterlace` | +| `Dfrg` | defringe | Defringe | `layer.matting.defringe` | +| `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | +| `Dstt` | desaturate | Desaturate | `image.adjustments.desaturate` | +| (string ID only) | selectNoLayers | Deselect Layers | `select.deselectLayers` | +| `Dspc` | despeckle | Despeckle | `filter.noise.despeckle` | +| `DfrC` | differenceClouds | Difference Clouds | `filter.render.differenceClouds` | +| `Dfs ` | diffuse | Diffuse | `filter.stylize.diffuse` | +| `Dplc` | duplicate | Duplicate | `layer.duplicate`, `image.duplicate` | +| `DstS` | dustAndScratches | Dust & Scratches | `filter.noise.dustAndScratches` | +| (string ID only) | placedLayerEditContents | Edit Contents | `layer.smartObjects.editContents` | +| `Embs` | emboss | Emboss | `filter.stylize.emboss` | +| `Eqlz` | equalize | Equalize | `image.adjustments.equalize` | +| `Exch` | exchange | Exchange | `tools.swapColors` | +| `Expn` | expand | Expand | `select.modify.expand` | +| `Expr` | export | Export | `file.export.saveForWebLegacy` | +| `Exps` | exposure | Exposure | `image.adjustments.exposure` | +| `Fthr` | feather | Feather | `select.modify.feather` | +| `Fbrs` | | Fibers | `filter.render.fibers` | +| `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | +| `GEfc` | filterGallery | Filter Gallery | `filter.gallery.accentedEdges`, `filter.gallery.angledStrokes`, `filter.gallery.basRelief`, `filter.gallery.chalkCharcoal`, `filter.gallery.charcoal`, `filter.gallery.chrome`, `filter.gallery.coloredPencil`, `filter.gallery.conteCrayon`, `filter.gallery.craquelure`, `filter.gallery.crosshatch`, `filter.gallery.cutout`, `filter.gallery.darkStrokes`, `filter.gallery.diffuseGlow`, `filter.gallery.dryBrush`, `filter.gallery.filmGrain`, `filter.gallery.fresco`, `filter.gallery.glass`, `filter.gallery.glowingEdges`, `filter.gallery.graphicPen`, `filter.gallery.halftonePattern`, `filter.gallery.inkOutlines`, `filter.gallery.mosaicTiles`, `filter.gallery.neonGlow`, `filter.gallery.notePaper`, `filter.gallery.oceanRipple`, `filter.gallery.paintDaubs`, `filter.gallery.paletteKnife`, `filter.gallery.patchwork`, `filter.gallery.photocopy`, `filter.gallery.plaster`, `filter.gallery.plasticWrap`, `filter.gallery.posterEdges`, `filter.gallery.reticulation`, `filter.gallery.roughPastels`, `filter.gallery.smudgeStick`, `filter.gallery.spatter`, `filter.gallery.sponge`, `filter.gallery.sprayedStrokes`, `filter.gallery.stainedGlass`, `filter.gallery.stamp`, `filter.gallery.sumiE`, `filter.gallery.texturizer`, `filter.gallery.tornEdges`, `filter.gallery.underpainting`, `filter.gallery.waterPaper`, `filter.gallery.watercolor`, `filter.gallery.grain` | +| `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | +| `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | +| (string ID only) | focusMask | Focus Area | `select.focusArea` | +| `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | +| `Grdn` | gradientClassEvent | Classic Gradient (custom RGB, centred midpoints) | `paint.gradient` | +| (string ID only) | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | +| `GrMp` | (code only) | Gradient Map (recorded classic form) | `image.adjustments.gradientMap` | +| `RplC` | replaceColor | Replace Color (Lab sample box) | `image.adjustments.replaceColor` | +| `Grow` | grow | Grow | `select.grow` | +| `Hd ` | hide | Hide | `layer.hideLayers` | +| `HghP` | highPass | High Pass | `filter.other.highPass` | +| `HsbP` | HsbP | HSB/HSL | `filter.other.hsbHsl` | +| `HStr` | hueSaturation | Hue/Saturation | `image.adjustments.hueSaturation` | +| (string ID only) | conditional | If | `actions.conditional` | +| `ImgS` | imageSize | Image Size | `image.imageSize` | +| `Invs` | inverse | Inverse | `select.inverse` | +| `Invr` | invert | Invert | `image.adjustments.invert` | +| `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | +| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | +| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | +| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | +| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `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`, `history.newSnapshot`, `type.convertToShape`, `type.createWorkPath` | +| (string ID only) | animationFramesFromLayers | Make Frames from Layers | | +| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | +| `Mxm ` | maximum | Maximum | `filter.other.maximum` | +| `Mdn ` | median | Median | `filter.noise.median` | +| `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | +| `MSpt` | mergeSpotChannel | Merge Spot Channel | `channel.mergeSpot` | +| `MrgV` | mergeVisible | Merge Visible | `layer.mergeVisible`, `layer.stampVisible` | +| `Mnm ` | minimum | Minimum | `filter.other.minimum` | +| `MtnB` | motionBlur | Motion Blur | `filter.blur.motionBlur` | +| `move` | move | Move | `layer.translate`, `layer.moveTo`, `layer.arrange.bringToFront`, `layer.arrange.sendToBack`, `layer.arrange.bringForward`, `layer.arrange.sendBackward` | +| `NTSC` | NTSC | NTSC Colors | `filter.video.ntscColors` | +| `Ofst` | offset | Offset | `filter.other.offset` | +| (string ID only) | oilPaint | Oil Paint | `filter.stylize.oilPaint` | +| `Opn ` | open | Open | `file.openAs` | +| `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | +| (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | +| (string ID only) | photoFilter | Photo Filter | `image.adjustments.photoFilter` | +| `Pnch` | pinch | Pinch | `filter.distort.pinch` | +| `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | +| `Ply ` | play | Play | `actions.play` | +| `Plr ` | polar | Polar Coordinates | `filter.distort.polarCoordinates` | +| `Pstr` | posterize | Posterize | `image.adjustments.posterize` | +| `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | +| `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | +| (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | +| `Ungr` | ungroup | Release Clipping Mask | `layer.releaseClippingMask` | +| (string ID only) | removeBackground | Remove Background | `layer.removeBackground` | +| `RmvB` | removeBlackMatte | Remove Black Matte | `layer.matting.removeBlackMatte` | +| `RmvW` | removeWhiteMatte | Remove White Matte | `layer.matting.removeWhiteMatte` | +| `Shr ` | shear | Shear | `filter.distort.shear` | +| `Rset` | reset | Reset | `tools.defaultColors` | +| `RvlA` | revealAll | Reveal All | `image.revealAll` | +| `Rvrt` | revert | Revert | `file.revert` | +| `Rple` | ripple | Ripple | `filter.distort.ripple` | +| `Rtte` | rotateEventEnum | Rotate | `image.imageRotation.90cw`, `image.imageRotation.90ccw`, `image.imageRotation.180`, `image.rotation.arbitrary`, `edit.transform.rotate90Cw`, `edit.transform.rotate90Ccw`, `edit.transform.rotate180` | +| `save` | save | Save | `file.save`, `file.saveAs`, `file.saveACopy` | +| `slct` | select | Select | `layer.select`, `layer.selectAbove`, `layer.selectBelow`, `layer.addAboveToSelection`, `layer.addBelowToSelection`, `layer.selectTop`, `layer.selectBottom`, `channel.target`, `edit.undo`, `edit.redo`, `view.fitOnScreen`, `view.actualPixels`, `view.zoomIn`, `view.zoomOut`, `document.activate` | +| (string ID only) | selectAllLayers | Select All Layers | `select.allLayers` | +| (string ID only) | refineSelectionEdge | Select and Mask | `select.refineEdge` | +| (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | +| (string ID only) | autoCutout | Select Subject | `select.subject` | +| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` | +| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | +| (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | +| `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | +| `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` | +| `ShrM` | sharpenMore | Sharpen More | `filter.sharpen.sharpenMore` | +| `Shw ` | show | Show | `layer.showLayers`, `layer.showOnly` | +| `Smlr` | similar | Similar | `select.similar` | +| (string ID only) | selectSky | Sky | `select.sky` | +| (string ID only) | scaleEffectsEvent | Scale Effects | `layer.layerStyle.scaleEffects` | +| `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | +| (string ID only) | smartSharpen | Smart Sharpen | `filter.sharpen.smartSharpen` | +| `Smth` | smoothness | Smooth | `select.modify.smooth` | +| `Sphr` | spherize | Spherize | `filter.distort.spherize` | +| `SplC` | splitChannels | Split Channels | `channel.split` | +| `Stop` | stop | Stop | `STOP_MESSAGE` | +| `Strk` | stroke | Stroke | `edit.stroke` | +| (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | +| `Thrs` | threshold | Threshold | `image.adjustments.threshold` | +| `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | +| `Trap` | trap | Trap | `image.trap` | +| (string ID only) | trim | Trim | `image.trim` | +| `Twrl` | twirl | Twirl | `filter.distort.twirl` | +| (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | +| `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | +| (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | +| (string ID only) | vibrance | Vibrance | `image.adjustments.vibrance` | +| `Wave` | wave | Wave | `filter.distort.wave` | +| `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | +| (string ID only) | reverse | Reverse | `layer.arrange.reverse` | +| (string ID only) | linkSelectedLayers | Link Layers (toggles, as measured) | `layer.linkLayers` | +| (string ID only) | unlinkSelectedLayers | Unlink Layers | `layer.linkLayers {"linked":false}` | +| (string ID only) | selectLinkedLayers | Select Linked Layers | `layer.selectLinkedLayers` | +| (string ID only) | disableLayerStyle | Clear Layer Style | `layer.layerStyle.clear` | +| (string ID only) | placedLayerMakeCopy | New Smart Object via Copy | `layer.smartObjects.newSmartObjectViaCopy` | +| (string ID only) | placedLayerConvertToLayers | Convert to Layers | `layer.smartObjects.convertToLayers` | +| (string ID only) | placedLayerConvertToEmbedded | Embed Linked | `layer.smartObjects.convertToEmbedded` | +| (string ID only) | placedLayerUpdateAllModified | Update All Modified Content | `layer.smartObjects.updateAllModifiedContent` | +| (string ID only) | rasterizeAll | Rasterize All Layers | `layer.rasterize.allLayers` | +| (string ID only) | addBlankVideoLayer | New Blank Video Layer | `layer.videoLayers.newBlankVideoLayer` | +| (string ID only) | isolateLayers | Isolate Layers | `select.isolateLayers` | +| (string ID only) | textLoadDefaultTypeStyles | Load Default Type Styles | `type.loadDefaultTypeStyles` | +| (string ID only) | textSaveDefaultTypeStyles | Save Default Type Styles | `type.saveDefaultTypeStyles` | +| (string ID only) | recordMeasurements | Record Measurements | `image.analysis.recordMeasurements` | +| (string ID only) | CropPhotosAuto0001 | Crop and Straighten Photos | `file.automate.cropAndStraightenPhotos` | +| (string ID only) | placedLayerExportContents | Export Contents | `layer.smartObjects.exportContents` | +| (string ID only) | placedLayerConvertToLinked | Convert to Linked | `layer.smartObjects.convertToLinked` | +| (string ID only) | placedLayerRelinkToFile | Relink to File | `layer.smartObjects.relinkToFile` | +| (string ID only) | placedLayerReplaceContents | Replace Contents | `layer.smartObjects.replaceContents` | +| (string ID only) | shapeBlur | Shape Blur (radius-only form) | `filter.blur.shapeBlur` | +| (string ID only) | artboardFromLayerGroupEvent | Artboard from Group | `layer.new.artboardFromGroup` | +| (string ID only) | editArtboardEvent | Artboard background color (flags 8/1) | `layer.artboard.set` | + +### Newer event mapping boundaries + +The 23 requested forms in the two **Measured** tables under +[Newer events](#newer-events-string-id-only) are mapped. Artboard from Group +retains the current-layer target, preset tag, fractional RGB, background type +and integer `changeBackground`, including absence. A flag of 1 or absence applies +the background; other integers give white. An omitted type defaults to white. +Recorded guide IDs are preserved on save and ignored on playback, as measured +in [the guide/background reference below](#newer-events-string-id-only). +Existing document guides remain unchanged. The preset does not resize the group. + +The additional `editArtboardEvent` maps background-only changes with `changeSizes` +8 and `changeBackground` 1. Its rectangle and preset remain recording metadata: +they do not move or resize the target artboard. Colors remain in the saved step +even for white, black or transparent backgrounds. Other flags, extra settings, +unknown targets and forms that cannot reconstruct exactly remain whole kept +steps. Native geometry edits without a matching wire form refuse export. + +The sixteen target-only events accept an empty descriptor or the current-layer +`null` reference (`Lyr `/`Ordn.Trgt`). Save retains which form was imported. +Unknown targets or extra keys stay complete kept steps. Unrepresented native +options are refused on export. Playback uses the native command's preconditions +and current state. Isolate Layers toggles the panel view; type defaults remain +session settings; Record Measurements uses the current measurement configuration. + +Link Layers plays as the toggle the reference application performs (played twice on the same +layers it links, then unlinks them; see the measurements below). Unlink is explicit and +idempotent: it keeps an already unlinked selection unlinked and clears singleton link groups. +Undo restores an edit. Explicit native layer lists cannot be exported as these targeted events. + +The four Smart Object file events use Open/Place's `Pth `/`txtu` path encoding. +Export Contents uses `null` for its path; Convert to Linked uses the current-layer +`null` reference and `Usng` for its path; Relink and Replace use `null` for the path. +A positive integer `LyrI` is a recording-session hint, never a PhotoCraft layer +identity, and is omitted on save under the normalization rule below. Undecodable +paths or extra keys keep the entire step. File access happens at playback; a +missing or unusable file reports the native command's error. + +Convert Type to Shape retains Make's `contentLayer` class reference and `Usng` +target `textLayer` reference; it does not export a separate `convertToShape` +event. Other Make mappings, including Solid Color and Pattern fills, are unchanged. +Shape Blur maps only the measured radius form (`Rds ` pixels, 5–1000; omitted +radius uses the native factory value 10). Later dialog forms carrying +`customShape` or `keyOriginType` remain kept. Native explicit shape choices cannot +be saved as radius-only descriptors; no custom asset lookup or new kernel is added. + +Crop and Straighten Photos executes the native scan operation once, creating one +document per photo found inside the selection and leaving the source unchanged. +It requires a selected, visible layer with no pixel or full lock, including +inherited group state, and remains unavailable for Indexed Color documents. +The separate `CropPhotos0001` helper remains a complete kept Photoshop step. +Playback stops at that enabled helper before subsequent duplicate/crop steps can +run. This covers the parent event, not complete recordings with generated helpers. + +### Content-Aware Fill recordings + +`cafWorkspace` maps Rectangular / Default color adaptation / None rotation with New Layer or +Current Layer output to `edit.contentAwareFill`, retaining all seven keys and the three booleans: +Sample All Layers, Scale and Mirror. The selection comes from the preceding selection step. +New Layer is in the measured reference; Current Layer and the enumeration type IDs are explicit +in [Stephen Marsh's published Action Manager example](https://community.adobe.com/questions-712/expand-and-content-aware-fill-functions-are-not-working-via-javascript-1172085). + +| Recorded key, in order | Supported value | Native parameter | +|---|---|---| +| `cafSamplingRegion` | `cafSamplingRegionRectangular` | `sampling: "rectangular"` | +| `cafSampleAllLayers` | boolean | `sampleAllLayers` | +| `cafColorAdaptationLevel` | `cafColorAdaptationDefault` | `colorAdaptation: "default"` | +| `cafRotationAmount` | `cafRotationAmountNone` | `rotationAdaptation: "none"` | +| `cafScale` | boolean | `scale` | +| `cafMirror` | boolean | `mirror` | +| `cafOutput` | `cafOutputToNewLayer`, `cafOutputToCurrentLayer` | `output: "new"`, `"current"` | + +Descriptor types, source identity and key order survive save while its native settings are +unchanged. Editing a supported setting exports the new values in canonical form instead of +restoring stale source values; malformed preservation data still refuses export. +Native export writes the seven keys explicitly; the known native boolean, color +adaptation, rotation and Current Layer output defaults are supported when sampling is explicitly +Rectangular. A native call using the Auto sampling default cannot yet be exported. + +The reference's ellipses do not establish additional enum forms. Auto/Custom sampling, +other adaptation levels, Duplicate Layer output, absent keys, unknown settings and +malformed or repeated fields remain complete kept steps. Native layer targets, sampling/fill +edits and nondefault seeds also refuse export rather than lose their settings. Playback uses +the existing native fill and its selection, authorization, job and Undo/Redo behavior. The +remaining CAF forms and the other recorded tools in #10710 are still separate work. + +### Layer Style menu recordings + +`scaleEffectsEvent` maps to `layer.layerStyle.scaleEffects` with the recorded `Scl ` +whole percentage in `#Prc`, from 1 through 1000. A missing recorded value is not +given a guessed default. Native export makes its effective default of 100 explicit; +fractional, out-of-range and unrepresented options refuse export. + +Create Layer(s) maps Make (`Mk ` / `make`) to `layer.layerStyle.createLayer` when +`null` is the `Lyr ` class reference and `Usng` is the `Lefx` property of the target +layer (`Prpr.Lefx`, `Lyr .Ordn.Trgt`). It has no recorded operation settings. +The existing native command creates the effect layers. The warning dialog is not +an extra action parameter. Explicit native layer IDs have no measured export form. +Malformed references stay complete kept steps, rather than creating a blank layer; +ordinary layer creation still uses its layer-options object or class-only form. + +Both mappings retain descriptor identity, order and step flags while unchanged. +A supported scale edit exports its new value canonically; damaged preservation +metadata remains refused. These two rows are the implemented subset of #10723; +the other menu recordings remain separate work. Playback reuses native history +and rendering; this does not claim Photoshop pixel equivalence. + +### Type Create Work Path recordings + +Make (`Mk ` / `make`) maps to `type.createWorkPath` when `null` is the `Path` +class reference and `From` is the target text layer (`TxLr.Ordn.Trgt`). Playback +uses the existing native glyph-outline command and leaves the text layer editable. +Undo restores the previous work path; Redo restores the new one. + +The mapping preserves the complete descriptor, including key order and reference +names. Unknown or duplicate keys, named sources, other source classes and other +reference chains remain complete kept Photoshop steps. Native export supports +the implicit active text layer only; explicit layer targets refuse export. +This is another implemented subset of #10723, not support for its remaining events. + +### Settings retained by mapped events + +- Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to + `layer.showOnly`; repeating it restores the other layers' visibility. +- Make keeps top-level group names, new-layer clipping (`Usng.Grup`) and adjustment colour labels + (`Usng.Clr `). Layer via Copy / Cut keeps names, blend modes, opacity and colour labels. +- Loading another layer's transparency keeps its source reference. Background property references + remain independent of translated names; ordinary layer-name references remain names. +- Close without `Svng` preserves the unanswered save choice. Headless playback refuses to discard a + changed document. Native `file.close` still defaults to discard; `save: "ask"` opts into this policy. +- Photo Filter converts recorded Lab colours through `photocraft-cms`. +- Direct Vibrance and Gradient Map save as the string events `vibrance` and `gradientMapEvent`. + Stroke Outside uses `StrL.Otsd`, group Pass Through uses `BlnM.passThrough`, Canvas Size extension + colours use `FrgC` and `Clr `, and Absolute Colorimetric intent uses `Inte.AClr`. +- Make adjustment layers keeps surrounding `Usng.Opct` as layer opacity, independently of the + adjustment settings. Recorded percentages use eight-bit layer-opacity storage at every document + depth: 70 % becomes 179/255. Native creation accepts an optional 0..1 `opacity` fraction. + A default adjustment with only layer opacity still saves as a default preset. +- Document offsets wrap in opening order, independently of dragged tabs. An ATN zero offset remains + a kept step; the native command retains its existing zero-offset no-op. +- Convert Mode saves `T ` as a class. Layer-retaining RGB, grayscale, CMYK and Lab conversions + read and write explicit false `Fltt` and `Rstr` flags. True or malformed flags remain kept steps; + depth, bitmap and multichannel recordings with these flags also remain kept. +- Canvas Size preserves percentage units and resolves them against the playback document. + Percentages are absolute sizes; mixed units and relative percentages remain kept steps. +- Image Size preserves points (`#Rlt`) and resolves them using the playback document's resolution. + Mixed units and unsupported physical-size/resolution combinations remain kept steps. + Without resampling, native Image Size still changes only resolution. + +Shape creation (`contentLayer`) with geometry or stroke settings remains a kept step. Save preserves +its descriptor, and enabled playback stops instead of creating a canvas-wide solid fill. Editable +shape creation is not mapped yet. + +The Vibrance event and adjustment-opacity handling incorporate [@bommerts' contribution in #8659](https://github.com/storytold/photocraft/pull/8659). + +### Command options retained by mapped events + +- Image Size preserves `scaleStyles`. Native calls default to true; an absent ATN flag means + false (the reference application plays both alike), so Save writes the key only when it is true. + Crop and resolution-only resizing retain their existing behavior. +- Convert to Profile preserves `Dthr`, `Fltt` and `rasterizePlaced`, defaulting to false. + `sdwM` is written as the reference application records it (-1); other values and malformed + flags remain kept steps. Absolute intent writes `AClr`, with `AbsC` still accepted on import. +- Content-Aware Fill preserves colour, quarter-turn rotation, scale and mirror adaptation. + Existing native defaults remain unchanged. +- Paste Layer Style preserves `allowPasteFXOnLayerSet`. False skips selected groups without + applying their styles to children; a selection with no eligible targets fails without editing. +- Paste preserves `As ` path or shape using the original bounded SVG source. Unsupported + geometry fails instead of silently rasterizing. `As Pxel` plays as an ordinary Paste (or Paste + in Place), as does omission. + +Generated fixtures verify these native options and their load/save/playback behavior. Conversion order, +SVG rasterization and placement were not compared with the reference application. + +### Render filters + +Native commands without an explicit Lens Flare center still use 0.5 / 0.5. Imported steps materialize the measured center above. A missing Fibers seed is captured once at import and preserved for later playback and export. Native color overrides and other settings without ATN fields are refused during strict export. + +## Recognized steps kept disabled + +These recognized events have no playable command. They load as kept steps switched +off, and Save Actions preserves their descriptors. They are excluded from the +supported-event count and the measured unmapped reference. + +| String ID | Label | +|---|---| +| animationFramesFromLayers | Make Frames from Layers | + +### Supported direct adjustment settings + +#### Gradient Map (direct) + +- Event: `GrMp` (no string ID) +- PhotoCraft command: `image.adjustments.gradientMap` +- Supported: classic (`Gcls`, also when omitted), 2..64 ordered custom RGB stops at midpoint 50, smoothness 4096, and the two opaque endpoint transparency stops. Fractional RGB components retain their precision and the recorded name and event survive save. +- Other interpolation methods, midpoints, transparency, unsorted stops and unsupported descriptor forms remain kept steps. This does not change the separate legacy `GdMp` event mapping. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | gradientsInterpolationMethod | enumerated | | gradientInterpolationMethodType.'Gcls' classic | | +| `Grad` | gradient | object | | obj 'Grdn' gradientClassEvent | | + +Nested values as recorded (defaults): +``` + 'Nm ' name : STRINGTYPE = "Vorder- zu Hintergrundfarbe" + 'GrdF' gradientForm : ENUMERATEDTYPE = 'GrdF' gradientForm.'CstS' customStops + 'Intr' interfaceIconFrameDimmed : DOUBLETYPE = 4096 + 'Clrs' colors : LISTTYPE = list[2] + [0] OBJECTTYPE = obj 'Clrt' colorStop + 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor + 'Rd ' red : DOUBLETYPE = 0 + 'Grn ' green : DOUBLETYPE = 0 + 'Bl ' blue : DOUBLETYPE = 0 + 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop + 'Lctn' location : INTEGERTYPE = 0 + 'Mdpn' midpoint : INTEGERTYPE = 50 + [1] OBJECTTYPE = obj 'Clrt' colorStop + 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor + 'Rd ' red : DOUBLETYPE = 255 + 'Grn ' green : DOUBLETYPE = 255 + 'Bl ' blue : DOUBLETYPE = 255 + 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop + 'Lctn' location : INTEGERTYPE = 4096 + 'Mdpn' midpoint : INTEGERTYPE = 50 + 'Trns' transparency : LISTTYPE = list[2] + [0] OBJECTTYPE = obj 'TrnS' transferSpec + 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit + 'Lctn' location : INTEGERTYPE = 0 + 'Mdpn' midpoint : INTEGERTYPE = 50 + [1] OBJECTTYPE = obj 'TrnS' transferSpec + 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit + 'Lctn' location : INTEGERTYPE = 4096 + 'Mdpn' midpoint : INTEGERTYPE = 50 +``` + +#### Replace Color + +- Event: `RplC` / `replaceColor` +- PhotoCraft command: `image.adjustments.replaceColor` +- Supported: the explicit D50 Lab minimum/maximum box and `colorModel` 0, with the four integer settings clamped to the ranges below. Matching uses original pixel channels through the document profile. Unsupported color models, malformed or missing samples and unknown keys remain kept steps. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `Fzns` | fuzziness | integer | | 40 | 0 .. 200 (out-of-range values are clamped) | +| `Mnm ` | minimum | object | | obj 'LbCl' labColor | | +| `Mxm ` | maximum | object | | obj 'LbCl' labColor | | +| `H ` | hue | integer | | 30 | -180 .. 180 (out-of-range values are clamped) | +| `Strt` | saturation | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | +| `Lght` | lightness | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | +| `` | colorModel | integer | | 0 | | + +Nested values as recorded (example values): +``` + 'Lmnc' luminance : DOUBLETYPE = 40 + 'A ' a : DOUBLETYPE = 30 + 'B ' b : DOUBLETYPE = 40 + 'Lmnc' luminance : DOUBLETYPE = 40 + 'A ' a : DOUBLETYPE = 30 + 'B ' b : DOUBLETYPE = 40 +``` + + +## Known gaps in mapped events + +A mapped event can still lose a setting. The original 117-event mapping was checked on 2026-11-01: the 5 296 steps of +the `.atn` corpus (sets the reference application recorded) and factory descriptors measured in the +reference application went through `actions.load` and `actions.save`, and each saved descriptor was compared +with the original. Then every step PhotoCraft saves for the cases in +`every_mapped_command_exports_and_imports_back` was played in the reference application: 128 of 151 ran. +Three failed on a wrong code (Vibrance, Gradient Map, Stroke Outside); the others needed a document the test +did not have (a mask, a group, a named pattern or channel). Checking every code the mapping writes found +three more (Pass Through, canvas extension colour, Absolute Colorimetric). The steps below change what a +step does. Every one of them is fixed now: the sections "Settings retained by mapped events" and +"Command options retained by mapped events" above say how each setting is kept. What remains are +differences that do not change what a step does. + +### Differences that do not change the result + +- Value types: `long` and unit values are interchangeable for the reference application (Motion Blur + `Angl`, Dust & Scratches `Rds `, Apply Image `Ofst`: same pixels either way); Apply Image's `With` + object plays the same with class `null`. +- Save writes the 4-character event code where the file had the string ID, writes `null` as the + top-level class (the application writes the event code), and writes an empty descriptor for steps that + had none. +- Ids of the recording session (`LyrI`, `DocI`, `Idnt`, `layerSectionStart`, `layerSectionEnd`, `Vrsn`, + `forceNotify`) and default flags (`MkVs` false, `Adjs` false) are not written back. +- An adjustment layer's values equal to the defaults are not written back (other values are). +- Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom". +- Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543). + +### Classic Gradient recordings + +`Grdn` / `gradientClassEvent` maps to `paint.gradient` for custom RGB gradients +with byte-valued colours and centred colour/transparency midpoints. The five +measured styles are Linear, Radial, Angle, Reflected and Diamond. Pixel endpoints, +opacity, blend mode, reverse, dither and the transparency switch are explicit; +an omitted `Dthr` is off, rather than the native command's on default. The +recorded interpolation method maps to the existing Classic, Perceptual, Linear +or Smooth implementation, and `Intr` supplies smoothness. + +Unchanged recordings retain their complete descriptor bytes, key order, ID +encoding and explicit defaults. A supported edit writes the recorded key order: +`From`, `T `, non-default `Opct` and `Md `, `Type`, non-default `Rvrs`, enabled +`Dthr` and `UsMs`, `gradientsInterpolationMethod`, then required `Grad`. Source +data is never replayed in place of edited executable settings. Native export +requires explicit stops, transparency (an empty list means opaque) and method; +it does not resolve a named or current gradient preset at save time. + +Unknown/duplicate keys, absent interpolation methods, non-centred midpoints, +fractional RGB colours, noise gradients and dynamic foreground/background stops +remain kept steps. The current preset model does not represent those forms +faithfully. Invalid units/enums or missing `Grad` also stay kept, rather than +using a different gradient. The default Make/Set gradient fill-layer recordings +are separate work. Synthetic fixtures follow the measured reference below; +no new Photoshop recording or pixel-equivalence measurement is claimed. + +## Reference forms + +How the reference application resolves a layer or document a step names, measured on 2026-11-01 by +playing `select` and `move` steps with each form (documents with and without a Background, a group, +layers above and below it). A recorded step can use any of these; PhotoCraft reads names and the target +today. + +### Layers + +The stack is counted from the bottom and includes a hidden "group end" entry below each group's +children. Example stack, bottom to top: Background, L1, L2, *group end*, Gchild, G (the group), L3. + +| Form | Meaning | Measured | +|---|---|---| +| By name (`name`) | The layer with that name (the first match). | (supported) | +| Target (`Ordn.Trgt`) | The active layer. | (supported) | +| By index (`indx`) | Position in the stack above. With a Background, the Background is 0 and L1 is 1. Without one, the bottom layer is 1, and 0 fails. The group end is a position but cannot be selected: the step leaves the selection unchanged. A number past the top fails. | with Background: 0 Background, 1 L1, 2 L2, 3 (group end: no change), 4 Gchild, 5 G, 6 L3, 7 fails | +| By id (`Idnt`) | The layer id of the document the step was recorded in (`LyrI`, never reused in that document). In another document it names an unrelated layer or none. | id 3 = L2 with a Background, L1 without | +| By offset (`rele`) | Not supported for layers: every offset fails. | -3..3 all fail | +| `Ordn.Frwr` / `Ordn.Bckw` | The next layer up / down in the stack, entering groups (group ends are skipped). | from L2: Frwr Gchild, Bckw L1 | +| `Ordn.Frnt` / `Ordn.Back` | The top layer / the bottom layer (the Background when there is one). | L3 / Background | + +**Move to an index** (`move`, `T` = layer by index `m`): counted as above on the stack before the move. +Moving down, the layer lands at index `m`; moving up, at `m - 1` (directly below the layer that had index +`m`). With a Background, `m` = 0 fails; without one, `m` = 0 sends the layer to the bottom. Moving to an +index inside a group puts the layer into that group. + +| m (with Background, moving L2 at index 2) | 0 | 1 | 2, 3 | 4 | 5 | 6 | +|---|---|---|---|---|---|---| +| Stack after | fails | Bg, L2, L1, ... | unchanged | Bg, L1, *end*, L2, Gchild, G, L3 (L2 inside G, bottom) | ... *end*, Gchild, L2, G (inside G, top) | ... G, L2, L3 | + +### Documents + +| Form | Meaning | Measured (D1, D2, D3 opened in that order, D2 active) | +|---|---|---| +| By offset (`rele`) | That many documents after (positive) or before (negative) the active one, in opening order, **wrapping around** at the ends. 0 fails. | -1 D1, +1 D3, -2 D3, +2 D1 | +| By index (`indx`) | 1-based, in opening order; 0 fails. | 1 D1, 2 D2, 3 D3 | + +PhotoCraft's `document.activate {"offset"}` refuses to wrap (#7103); the reference application wraps. + +### Layer comps + +Measured with three comps (CompA, CompB, CompC) that each show a different layer, applied with +`applyComp` and every reference form. The document's `compsList` gives each comp's `title`, `itemIndex` +(1-based), `ID` (long) and `selection`. + +| Form | Result | +|---|---| +| By name (`name`) | Applies that comp. An unknown name fails. | +| By index (`indx`) | 1-based, in panel order. 0, negative and past-the-end indexes are **accepted and change nothing**. | +| By ID (`Idnt`) | Applies the comp with that `ID`. An unknown ID fails. | +| By offset (`rele`) | Always fails, for every offset. | +| Ordinal (`Ordn`) `Trgt`, `Nxt `, `Prvs` | Applies the current (last applied) comp; `Nxt ` and `Prvs` do not move. | +| Ordinal `Frst`, `Lst `, `Al ` | Applies the **first** comp (also for `Lst `). | + +Delete (`Dlt `) by index uses the same 1-based numbering. PhotoCraft's `applyComp` import reads only the +name form today; the index, ID and ordinal forms load as kept steps. + +## Newer events (string ID only) + +The original survey covered menu events that were neither mapped nor in the reference below: +63 candidates from PhotoCraft's menu catalogue, each run in the reference +application without a dialog on a document prepared for it (layers, a selection, a smart object, a type +layer, a layer style or a group) on 2026-11-01. 27 more menu items record no event of their own (puppet +warp and warp splits, combine shapes, scripts such as Load Files into Stack and Statistics, artboard and +video export, missing-font commands): they cannot be recorded or play as a script step. + +The two Measured tables remain the recording reference. The mappings and remaining +limits added since that survey are listed under [Newer event mapping boundaries](#newer-event-mapping-boundaries). + +**Measured: no settings beyond the target.** A step with these events carries at most `null` (the target +layer reference, `Ordn.Trgt`); mapping them needs only the event name. + +| Event | Menu item | +|---|---| +| `reverse` | Layer > Arrange > Reverse (several layers selected) | +| `linkSelectedLayers` / `unlinkSelectedLayers` | Layer > Link Layers / Unlink Layers | +| `selectLinkedLayers` | Layer > Select Linked Layers | +| `disableLayerStyle` | Layer > Layer Style > Clear Layer Style | +| `placedLayerMakeCopy` | Layer > Smart Objects > New Smart Object via Copy | +| `placedLayerConvertToLayers` | Layer > Smart Objects > Convert to Layers | +| `placedLayerConvertToEmbedded` | Layer > Smart Objects > Embed Linked | +| `placedLayerUpdateAllModified` | Layer > Smart Objects > Update All Modified Content (the returned `documentID` and `layerIDs` are session ids, not settings) | +| `rasterizeAll` | Layer > Rasterize > All Layers | +| `addBlankVideoLayer` | Layer > Video Layers > New Blank Video Layer | +| `isolateLayers` | Select > Isolate Layers | +| `textLoadDefaultTypeStyles` / `textSaveDefaultTypeStyles` | Type > Load / Save Default Type Styles | +| `recordMeasurements` | Image > Analysis > Record Measurements | +| `CropPhotosAuto0001` | File > Automate > Crop and Straighten Photos | + +**Measured: settings.** + +| Event | Menu item | Keys | +|---|---|---| +| `placedLayerExportContents` | Layer > Smart Objects > Export Contents | `null`: path of the file written | +| `placedLayerConvertToLinked` | Layer > Smart Objects > Convert to Linked | `null`: target layer; `Usng`: path of the file written | +| `placedLayerRelinkToFile` | Layer > Smart Objects > Relink to File | `null`: path; `LyrI`: session layer id | +| `placedLayerReplaceContents` | Layer > Smart Objects > Replace Contents | `null`: path; `LyrI`: session layer id | +| `artboardFromLayerGroupEvent` | Layer > New > Artboard from Group | `null` target; `artboard` (object: `guideIDs` list, `artboardPresetName` text, `Clr ` RGB background colour, `artboardBackgroundType` long: 1 white, 2 black, 3 transparent, 4 other = `Clr `); `changeBackground` long. See *Artboard from Group: guides and background* below. | +| `shapeBlur` | Filter > Blur > Shape Blur | `Rds ` radius in pixels (factory 10); the shape itself comes from the dialog | +| Make `contentLayer` with `Usng` = reference to `textLayer` `Ordn.Trgt` | Type > Convert to Shape (recorded as Make, not as `convertToShape`) | `null`: class `contentLayer`; `Usng`: the type layer | + +**Recorded through the menus** (2026-11-01, steps read back from the saved Actions panel): + +| Menu | Recorded step | Keys | +|---|---|---| +| Layer > New > Frame from Layers | Make, `null` = class `framedGroupSection`, `From` = layer **by name**, `layerSectionStart`, `layerSectionEnd` long, `Nm ` text | the dialog also shows width and height, which are not recorded | +| Layer > New > Artboard from Layers | Make, `null` = class `artboardSection`, `From` = layer target, `layerSectionStart`, `layerSectionEnd`, `Nm ` | | +| Layer > New > Artboard | Make, `null` = class `artboardSection`, `layerSectionStart`, `layerSectionEnd`, `Nm `, `artboardRect` (`Top `, `Left`, `Btom`, `Rght` doubles, pixels) | | +| Properties panel > Artboard Background Color (Artboard selected) | `editArtboardEvent` | `null` target; `artboard` (object: `artboardRect` `Top `/`Left`/`Btom`/`Rght` pixels, `guideIDs` list, `artboardPresetName` text, `Clr `, `artboardBackgroundType` 1..4 as above); `changeSizes` long (8 when only the background changed), `changeBackground` 1. `Clr ` is only meaningful with type 4: with types 1..3 it holds the previously used colour | +| Layer > Smart Objects > Stack Mode > mode | `applyImageStackPluginRenderer`: `imageStackPlugin` **class** with the mode's code, `Nm ` text = the menu name (localised, except "Entropy") | codes: Range `rang`, Entropy `entr`, Kurtosis `kurt`, Maximum `maxx`, Median `medn`, Minimum `minn`, Mean `avrg`, Skewness `skew`, Standard Deviation `stdv`, Summation `summ`, Variance `vari` | +| Layer > Smart Objects > Stack Mode > None | `imageStackConvertSmartObject` | none | +| Layer > Video Layers > New Blank Video Layer, Insert Blank Frame, Delete Frame, Show / Hide Altered Video | `addBlankVideoLayer`, `insertBlankFrame`, `deleteFrame`, `showAlteredVideo` / `hideAlteredVideo` | none | +| Layer > Video Layers > Interpret Footage | `interpretFootage` | `Vrsn` long (1), `interpretAlpha` (`alphaInterpretation`: `Ignr` ignore, …), `colorConvertAlterations` bool | +| Edit > Find and Replace Text (Replace All) | `replace`, `null` = reference: property `replace` of all text layers (`TxLr` `Ordn.Al`), `Usng` = `find` text, `replace` text, `checkAll`, `Fwd `, `caseSensitive`, `wholeWord`, `ignoreAccents` bool | | +| Edit > Check Spelling | `spellCheck`, `null` = property `spellCheck` of all text layers, `Usng` = `checkAll` bool | recorded with its dialog switched on; the corrections themselves are not recorded | +| Image > Analysis > Set Measurement Scale > Custom | Set, `null` = property `measurementScale` of the document target, `T ` = `pixelLength` long, `logicalLength` double, `logicalUnits` text | | +| Image > Analysis > Place Scale Marker | a script step (`AdobeScriptAutomation Scripts`) with `jsop` = `measurementScaleMarkerLength`, `measurementScaleMarkerDisplayText`, `measurementScaleMarkerTextPositionBottom` bool, `measurementScaleMarkerColor` text (`FFFFFF` / `000000`), `measurementScaleMarkerFont`, `measurementScaleMarkerFontSize` text ("4 pt") | the dialog is a bundled script; the step stores its options | +| Image > Analysis > Record Measurements | `recordMeasurements` | none | +| File > Close All | one `close` per document: `Svng` (`YsN ` `N ` when "don't save" was chosen), `DocI`, `forceNotify` | not a `closeAll` event | + +**Not recorded:** File > Close Others, Image > Variables > Define (the variables are kept, no step). + +**Recorded with source files** (2026-11-01): + +| Menu | Recorded step | Keys | +|---|---|---| +| Image > Apply Data Set | `apply`, `null` = `dataSetClass` **by name** | | +| File > Export > Data Sets as Files | `exportDataSet`, `null` = class `dataSetClass` | `All ` bool (all data sets), `T ` alias (folder), `fileNamingRules` (`fileNamingComponents` list of 7 incl. `None`; components as in Batch plus `underscore` and `dataSetName`; `Mcnt`, `Win `, `unix`) | +| File > Import > Variable Data Sets | `importDataSets`, `null` = class `dataSetClass` | `Usng` alias (the text file), `Encd` (`dataSetEncoding`: `dataSetEncodingAuto`, …), `eraseAll` bool (replace existing), `useFirstColumn` bool (first column = data set names) | +| File > Package | `packageFile` | `T ` alias (folder); writes " Folder" (localised) with the document and a `Links` folder | +| File > Import > Video Frames to Layers | `importVideoToLayers` | `Usng` alias (video), `makeAnimation` bool, `frameSkip` long (limit to every n-th frame; absent when off) | +| File > Import > Notes (Annotations) | `importAnnots` | `Usng` alias (PDF). A file without notes shows "No annotations were imported" and records nothing | +| Layer > Video Layers > New Video Layer from File | `addVideoLayer` | `null` alias (video) | +| Layer > Video Layers > Replace Footage | `replaceFootage` | `null` alias (video) | +| Layer > Video Layers > Duplicate Frame, Restore Frame, Restore All Frames, Reload Frame | `duplicateFrame`, `restoreFrame`, `restoreAllFrames`, `reloadFrame` | none. Duplicate needs a painted frame; Restore needs an altered frame; Restore All asks for confirmation | +| File > Automate > Photomerge | the steps the script runs (open dialog, open, duplicate, `addLayerFromFile`, `align` with `layerTransformation`, `mergeAlignedLayers` …), then the script event `0f9db13f-a772-4035-9020-840f0e5e2f02` with an empty descriptor (class `cjsp`) | the layout and options are not in the step | +| File > Automate > Crop and Straighten Photos | `CropPhotosAuto0001` (no keys), then the steps it runs: `CropPhotos0001` with `Vl ` (list of 8 doubles: the four corners), duplicate, `crop` | needs a selected, visible, unlocked layer, otherwise an error message and a step without effect | +| File > Automate > Conditional Mode Change | the Convert Mode it runs, then `8cba8cd6-cb66-11d1-bc43-0060b0a13dc4` | `Msge` text, `SrcM` list of `Cndn` enums (`UGry`, `URGB`, …: the source modes), `DstM` object whose class is the target mode (`CMYM` for CMYK) | +| File > Automate > Fit Image | the Image Size it runs, then `3caa3434-cb67-11d1-bc43-0060b0a13dc4` | `Msge` text, `Hght`, `Wdth` pixels, `limit` bool (don't enlarge) | + +**Not recorded:** importing or applying inside Image > Variables > Data Sets (only the separate menu +commands record). + +**Artboard from Group: guides and background** (measured 2026-11-02, script runs and a recording through the menu): + +- **Recorded form:** the menu item opens no dialog. The step always has `guideIDs` empty, `artboardPresetName` "", `changeBackground` 1, and the + background type and colour **last used** for an artboard (the Properties panel choice is sticky: after picking Black for one artboard, the + next Artboard from Group records type 2 and makes a black artboard). Guides in the document do not change the recorded step. +- **`guideIDs`:** session metadata, not a playback input. Guide ids are a counter for the whole application session (a new document's + guides got 5, 6, 7 after an earlier one's 2, 3, 4). Playing the step with the document's own guide ids, an unknown id (99999) or an + empty list gives the same result: no error, the guides stay document guides (`kind` document), and the new artboard's `guideIDs` is empty. + An implementation can ignore the list on playback and write `[]`. +- **`changeBackground`:** 1 or absent applies `artboardBackgroundType` and `Clr `. Any other value (0, 2, 3, -1, 7 tried) ignores both and + makes a white artboard (type 1, `Clr ` 255/255/255), and the step's result then has no `changeBackground` key. + `artboardBackgroundType` absent with `changeBackground` 1 gives type 1 (white). +- The artboard takes the group's bounds; `Clr ` is stored on the artboard as given even when the type is 1..3. + +**Measured for the mapped events** (2026-11-02, script runs in the reference application): + +- **Transform reference point** (`FTcs`, type `QCSt`): `Qcs0` top-left, `Qcs1` top-right, `Qcs2` bottom-right, `Qcs3` bottom-left + corner; `Qcs4` top, `Qcs5` right, `Qcs6` bottom, `Qcs7` left edge centre; `Qcsa` the centre. `Qcs8` and unknown values also scale + about the centre (not recorded). +- **Convert to Profile** records `sdwM` = -1 (with `Inte`, `Dthr`, `Fltt`, `rasterizePlaced`). +- **Plain Edit > Paste** records `AntA` = `Annt.Anno` and `As ` = class `Pxel`. +- **Link Layers** (`linkSelectedLayers`) toggles when played: played twice on the same two layers it links, then unlinks them. +- **Split Channels** splits a document with a single layer, Background or not (a lone layer gives one more document); a document + with more than one layer is refused. +- **Load Actions** with a set whose name is already in the panel adds a second set of that name; nothing is replaced. + +- PhotoCraft preserves fractional recorded RGB values exactly. Native actions with partial settings export an empty preset and guide list when absent. + An absent native color exports as white: it matches the native custom-color fallback and is visually ignored for white, black and transparent backgrounds. + A custom background without a color uses the White variant (type 1); an explicit custom white color keeps type 4. + This is canonical export metadata, not a claim about the reference application's remembered color. Imported steps retain their recorded color instead. + +## Measured through dialogs and recordings + +Measured on 2026-11-01 in two ways: by running the event with its dialog and pressing OK (the reference +application then returns the full descriptor), and by recording actions through the menus and reading +the recorded steps back from the saved Actions panel. Ranges come from the dialog's own "a value between +… is required" message. + +| Event | Keys (recorded order) | Notes | +|---|---|---| +| Custom (`Cstm`) | `Scl ` long 1..9999, `Ofst` long -9999..9999, `Mtrx` list of 25 longs -999..999 | Without the dialog the step needs all 25 cells, or the application fails. | +| Fibers (`Fbrs`) | `Vrnc` long 1..64, `Strg` long 1..64, `RndS` long (seed) | | +| Displace (`Dspl`) | `HrzS`, `VrtS` long -999..999; `DspM` (`StrF` stretch to fit, or tile); `UndA` (`RptE` repeat edge pixels, or wrap around); `DspF` alias: path of the displacement map (.psd) | The map file is chosen after OK. | +| Color Lookup (`colorLookup`) | `lookupType` (`colorLookupType`: `3DLUT`, abstract, device link); `Nm ` text: the file's path; `Dthr` bool; `profile` raw data (ICC); `LUTFormat` (`LUTFormatType`, e.g. `LUTFormatLOOK`); `LUT3DFileData` raw data: the LUT file itself; `LUT3DFileName` text | The LUT travels inside the step. | +| Shape Blur (`shapeBlur`) | `Rds ` pixels 5..1000; `customShape` object (`Nm ` text, `Idnt` text: the shape's id); `keyOriginType` long (9) | | +| Lighting Effects (`lightFilterLightingEffects`) | `k3DLights` list of lights (`Nm `, colour `Rd `/`Grn `/`Bl ` 0..1, `hots`, `FlOf`, `shdw`, attenuation keys, `Type`, `ison`, `key3DMatrix` raw + floats, `X `/`Y `/`Z `, target `tarx`/`tary`/`tarz`, `key3DPosition`), `key3DCurrentCameraPosition`, `Glos`, `Mtrl`, `Exps`, `AmbB`, `AmbC` RGB, `BmpA`, `BmpC` (texture channel by name, localised: "Ohne" = none), `Wdth`, `Hght` | A dialog-less run returns nothing; only a recording shows the keys. | +| Blur Gallery (`blurbTransform`) | `blurbType`, `VrsM`, `VrsN`, `referenceRect` (rectangle), `blurbOpenPanel`, `blurbGeneralBlurEffectApplied`, `blurbSaveMaskChannel`, `blurbHighQuality`, `blurbWidgetList` (per widget: `blurbWidgetType` (0 = field blur), `blurbWidgetLocationX`/`Y`, `blurbWidgetSelected`, `blurbWidgetEffectEnabled`, `blurbGeneralBlurAmount`) | One event for all five Blur Gallery filters. | +| Liquify (`LqFy`) | `LqMe` raw data: the mesh | Opaque; keep the step as recorded. | +| Camera Raw Filter | `CrVe` text (version), `PrVN` long, `PrVe` long | With settings at their defaults; changed settings add keys. | +| Vanishing Point (`VnPt`) | none | The planes are not recorded. | +| Calculations | recorded as Make: `Nw ` class channel, `Usng` object (`T ` reference: source 1 channel and layer, `Clcl` blend mode, `Src2` reference) | Not a `calculations` event. | +| Image Size, size in cm or inches | `Wdth` in `#Rlt` (points), `scaleStyles`, `CnsP`, `Intr` | A physical size is recorded in points, not pixels: playback needs the document's resolution. | +| Canvas Size | only the changed side; `canvasExtensionColorType` `FrgC` for the foreground colour | | +| Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. | +| New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | +| Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | +| Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | +| Perspective Warp (`perspectiveWarpTransform`) | `null` = layer target, `Md ` (`perspectiveWarpMode`: `warp`), `referenceRect` rectangle in pixels, `vertices` list of points in pixels (the layout quad), `warpedVertices` (the same points after warping), `quads` list of `indices` (4 vertex indexes each) | Layout and warp are one step. | +| Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | +| Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. | +| Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | + +**Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, +Histories, All and Video Cache, no Undo); File > Export > Export As and Quick Export as PNG (the file +is written, no step is recorded). `vectorStrokeToFill` (Convert Stroke to Fill) exists only internally: the reference application knows +it by a localised command name ("command … is not currently available"), but it is in no menu, panel +menu, context menu, the stroke options (incl. their gear menu), the Properties panel or the Keyboard +Shortcuts summary of all commands and tools, and the event is refused even with a stroked path component +selected. It can't be triggered, so it is never recorded: no action contains it, nothing to map. + +### Batch + +Running Batch while recording records the steps the batch played, not a Batch step. The Batch +descriptor itself (`Btch`, as the dialog returns it): + +| Key | Type | Meaning | +|---|---|---| +| `null` | alias, or enum | source folder; for open documents the enum `batchSourceType` `sourceOpenFiles` | +| `OvrO` | bool | override action "Open" commands (written only when on) | +| `Fldr` | bool | include all subfolders | +| `suppressWarnings` | bool | suppress colour profile warnings | +| `suppressOpenOptions` | bool | suppress file open options dialogs | +| `Usng` | reference | the action: `Actn` by name in `ASet` by name | +| `T ` | alias | destination folder (destination Folder) | +| `SvAn` | bool | destination Save and Close (written instead of `T `) | +| `Ovrd` | bool | override action "Save As" commands (written only when on) | +| `fileNamingRules` | object | file naming (below) | +| `Log ` | alias | folder of the error log ("log errors to file") | +| `Nm ` | text | error log file name | + +`fileNamingRules`: `fileNamingComponents` (list of up to six objects; each is `component` enum +`fileNamingComponent`, or `Txt ` text for typed text), `Strt` long (starting serial number), `Mcnt`, `Win `, +`unix` bool (compatibility). + +| Component | Menu entry | +|---|---| +| `Nm ` / `lowerCase` / `upperCase` | Document Name / document name / DOCUMENT NAME | +| `oneDigit` `twoDigit` `threeDigit` `fourDigit` | 1, 2, 3, 4 digit serial number | +| `lowerCaseSerial` / `upperCaseSerial` | serial letter (a, b, c…) / (A, B, C…) | +| `mmddyy` `mmdd` `yyyymmdd` `yymmdd` `yyddmm` `ddmmyy` `ddmm` | dates | +| `lowerCaseExtension` / `upperCaseExtension` | extension / EXTENSION | + +The extension component is written **without a dot, unless it is the last component**, and nothing +else adds an extension: the name is exactly what the components spell. Measured on `a.png`: + +| Components | Saved as | +|---|---| +| Name + extension | `a.png` | +| Name + EXTENSION | `a.PNG` | +| Name + extension + 3 digit serial | `apng001` (no extension) | +| Name + 3 digit serial | `a001` (no extension) | +| Name + extension + text `x` | `apngx` | +| Name + extension + extension | `apng.png` | +| Name + text `.jpg` | `a.jpg` (still PNG data) | +| DOCUMENT NAME + `_` + serial letter + `_` + SERIAL LETTER | `A_a_A` | + +The two short date entries are `mmdd` and `ddmm` (on 1 November they give `1101` and `0111`). + +#### Batch behaviour + +Measured with a folder `in` holding `a.png`, `b.png`, `notes.txt` and `sub/a.png`, `sub/c.png`, +`sub/deeper/d.png`: + +- **Order:** the folder's files by name, then each subfolder (by name, depth first). The serial number + counts across all of them: `a_001` (`in/a`), `b_002`, `a_003` (`in/sub/a`), `c_004`, `d_005`. +- **Include All Subfolders:** results all go **flat into the destination folder**; the subfolder structure + is not rebuilt. A later file with the same output name **silently overwrites** an earlier one + (`in/a.png` and `in/sub/a.png` give one `a.png`). +- **Destination None:** every processed file stays open, changed and unsaved. **Save and Close** saves + each file over itself in its own format and closes it. **Folder** saves a copy named by the file + naming rules. An open document that was never saved asks with a Save As dialog (Photoshop format, + the document's name) even with Folder as the destination. +- **Override Open:** files are opened only by the action's own Open steps; without one the action runs + with no document and every file fails. **Override Save As:** files are saved only by the action's own + Save As steps, into the destination folder; without one nothing is saved. The dialog says both when + the box is ticked. +- **Files that are not images** (`notes.txt`) are an error for that file (code -25600, "extension not + recognised"), not skipped silently. +- **Errors, Stop For Errors** (no `Log ` key): the batch stops at the failing file; files before it are + saved, nothing after it is processed. +- **Errors, Log Errors To File:** the batch goes on, and at the end a message says an error occurred and + the details are in the log. The log file is **UTF-16 LE with BOM and CRLF**, and is written even when + nothing failed. In German the lines are (the application's own language): + + ``` + Stapelanfang + + Datei: "" + Fehler: () + + …one pair per failed file… + Stapelende + ``` +- **Cancel:** Esc during a batch stops the running command and asks "A command was cancelled or stopped + during batch processing. Continue with the next file or stop?" (Continue / Stop). Stop ends the batch + at once; the file being processed is not saved (12 of 60 written in the test). There is no progress + bar: the documents open and close in the window as they are processed. + +Batch cancellation conformance (Photoshop 2026 observation above; PhotoCraft's producer is +`file_cmds::batch_run::process_batch_outputs`, and its desktop consumer is `jobs_ui::tick`): + +| Reference step | Acceptance gate | Status | +|---|---|---| +| Plan folder order, names and serials once | Continue retains the original plan and next serial | Implemented and tested; Esc between inputs does not skip the unopened input | +| Show the opened input while its action runs | A read-only document preview appears in the canvas and closes with the input | Implemented and visually checked; deliberate deviation: worker snapshots are not live tabs or Layers panel targets, so they cannot be edited or saved by mistake | +| Interrupt the current command on Esc | The current input reaches a checkpoint before any output write | Implemented and tested before save; once the crash-safe atomic write begins, Esc is refused because publication cannot be retracted | +| Ask Continue or Stop | Continue skips the interrupted input; Stop returns prior outputs and skips the rest | Implemented and tested in engine and UI | +| Keep headless cancellation direct | `jobs.cancel` returns partial results without an interactive question | Implemented and tested, including a worker waiting for a desktop decision | +| No progress bar | The desktop Batch preview is the visible feedback | Implemented, UI tested and visually checked | +| Preserve error policy and log | Continue retains earlier errors and writes one final UTF-16 LE log | Implemented and tested with errors before and after Esc | + +- **Return value:** when any file failed, the Batch step itself reports "cancelled" (the dialog returns no + descriptor); a script run with Stop For Errors reports success even though it stopped. + +### Actions panel menu + +Button Mode; New Action, New Set, Duplicate, Delete, Play; Start Recording, Record Again, Insert Menu +Item, Insert Stop, Insert Conditional, Insert Path; Action Options, Playback Options, Allow Tool +Recording; Clear All Actions, Reset Actions, Load Actions, Replace Actions, Save Actions; the bundled sets +(Commands, Frames, Image Effects, LAB Black & White Technique, Production, Star Trails, Text Effects, +Textures, Video Actions); Close, Close Tab Group. Playback Options: Accelerated, Step by Step, Pause For +1..60 seconds. + +How two menu entries record: **Insert Menu Item** records the chosen menu command's own event with no +descriptor (Image > Image Size gives an `imageSize` step without settings; playback opens its dialog). +**Insert Path** records a `set` of the work path: `null` = reference `Path` property `WrPt`, `T ` = list +of path components (`shapeOperation`, `SbpL` list of subpaths, each with `Clsp` closed and `Pts ` list of +points with `Anch` in pixels). + +## Recording that plays on any document + +What the reference application records when a user works normally, measured on 2026-11-01 by recording +an action through the panel, mouse and keyboard on a document with layers A, B and C and a second open +document, then reading the steps back. This is the form PhotoCraft's recorder should write (AUTO-219-1); +today it copies journal entries with explicit layer ids. + +| User action | Recorded step | +|---|---| +| Click a layer in the Layers panel | `select`, `null` = layer **by name** (`"B"`), `MkVs` false, `LyrI` list of layer ids (a hint, not the reference) | +| Ctrl+J (Layer via Copy) | `copyToLayer`, no keys | +| Click an empty spot of the canvas with Auto-Select on | `selectNoLayers`, `null` = layer target | +| Arrow keys with the Move tool | **one** `move` step per run of key presses, `null` = layer target, `T ` offset in pixels: Right, Right, Right, Shift+Down gave `Hrzn` 3, `Vrtc` 10 | +| Eye icon | `hide` (or `show`), `null` = **list** of layers by name | +| Opacity typed in the Layers panel | `set` of the target layer, `T ` = `Opct` percent (pressing Enter after typing recorded the same step twice) | +| Select All, Deselect | `set` of the selection (`fsel`) to `Al ` / `None` | +| Alt+Backspace (fill with foreground) | `fill`, `Usng` `FrgC`, `Opct` 100 %, `Md ` Normal | +| Type tool: click, type "Hallo", commit | **one** `make` of a text layer (`TxLr`) with `Txt ` = the whole text; position `TxtC` in **percent of the canvas**; bounds in points | +| Ctrl+E (Merge Down) | `mergeLayersNew`, no keys | +| Ctrl+Tab / Ctrl+Shift+Tab | `select` of a document **by offset** (`rele` +1 / -1) plus `DocI` (the document id, a hint) and `forceNotify` | + +So the recorder names layers by name and acts on the target layer, never by id; it merges a run of +nudges into one relative move and a typing session into one text step; text and document positions are +relative. + +### Playing on another document + +The same recording played on two other documents (Play from step 2, skipping the setup step): + +- **Same layer names, new ids:** plays through; layers are found by name, `LyrI` and `DocI` are ignored. +- **No layer with that name** (layers X, Y, Z): each step that names a missing layer stops with a + message and Continue / Stop. Select by name: "The command 'Select' is not currently available". Hide by + name: "The object 'Layer "A"' is not currently available". Steps on the target layer act on whatever + layer is selected (Ctrl+J copied Z into "Z Kopie"). +- **After `selectNoLayers`**, steps that need a target layer (move, set opacity, fill) each stop with + "The command '…' is not currently available" and Continue / Stop. Continue skips the step; nothing is + undone. +- **Play from a step:** selecting a step in the panel and pressing Play plays from that step to the end. + +## Older files: version 12 + +Action sets saved by the reference application's version 5 era use file version 12. PhotoCraft rejects +them today; this is what reading them needs. Checked on 2026-11-01 on 325 real version 12 sets (all parse +to the end with this layout) and against AtnKit (MIT), which reads version 12 but gets two details wrong +(noted below). + +**Only the descriptor differs.** The file header, the action record and the step record are the same as +in version 16 (set and action names are UTF-16 strings with a counted NUL; events are almost always +4-character codes, so the existing event table applies). Trailing bytes after the last action occur in +22 of 325 files (stale data from an overwritten file): keep them a warning. + +The version 12 descriptor: + +- The step's descriptor has no name and no class: it starts with the item count. +- Every identifier is a bare 4-byte code, with no length prefix: keys, classes, enum types and values, + units, reference classes. No Unicode class names anywhere. +- Strings (`TEXT`, reference names) are the same UTF-16 strings as in version 16. + +```text +step descriptor := count u32, count × (key [4], type [4], value) +object := class [4], count u32, count × (key [4], type [4], value) + +bool u8 | long i32 | comp i64 | doub f64 | UntF unit [4] + f64 +UnFl unit [4] + n u32 + n × f64 +TEXT UTF-16 string | enum type [4] + value [4] +Objc / GlbO object | type / GlbC class [4] +VlLs n u32 + n × (type [4] + value) +ObAr class [4] + object count u32 + count u32 + count × (key [4], type [4], value) +obj n u32 + n × reference item +tdta / alis / Pth u32 length + bytes + +reference item := form [4] + class [4] + (prop: key [4] | Clss: nothing | Enmr: type [4] + value [4] + | rele: i32 | indx: u32 | Idnt: u32 | name: UTF-16 string) +``` + +AtnKit reads `indx` without its class code and has no case for `UnFl`, `ObAr` and `Pth `; with those +fixed, all 325 files parse. `Idnt` was not seen in a version 12 file; its layout follows `indx`. + +**Into PhotoCraft's model:** every id becomes a 4-character code; the step descriptor gets the empty name +and class `null` that version 16 files use for code events; nested classes get the empty name. Save Actions +keeps writing version 16. Version 7 files (a different layout with 8-bit strings) and sets wrapped in +MacBinary stay rejected. + +## Event reference: measured specs + +Each entry below is an event the reference application records, as measured there: its 4-character code +and string ID, the PhotoCraft command it maps to (or should map to), and every key the step carries, in the +order it is recorded, with type, unit, factory default and allowed values. The Supported events table says +which of them PhotoCraft maps today; for the others an entry has everything needed to implement them. When +an event is implemented, add it to that table in the same change. +Current support and limits of the newer events are described under [Newer event mapping boundaries](#newer-event-mapping-boundaries). + +### How these values were measured + +- **Keys, order, types, units, defaults:** each event was run in the reference application without a + dialog on an RGB 8-bit document (with a selection, the clipboard or several layers where the step + needs them), and the descriptor it returned was read back. That descriptor is what an action records. + Settings were reset first, so the defaults are factory defaults. Steps that need a prepared document + (a selection, the clipboard, several layers) were run with example values; their tables say so and show + those values instead of defaults. +- **Ranges:** every number was set far above and below its range. The application clamps instead of + refusing, so the values it returned are the exact limits. "Out-of-range values are clamped" in a table + means exactly that; implement the same clamping on import. +- **Enum values:** every known enum value was tried; only values the application accepted and returned + unchanged are listed. A value outside the list should keep the step as a kept step. +- **Names:** keys are stored by their 4-character code. The string ID column is a label; where one code + has two names in the application, the one that fits the setting is shown. +- **Filter Gallery effects:** the "PhotoCraft today" column compares each value with PhotoCraft's own + effect parameters (`crates/algo/src/artistic.rs`). All ranges and defaults already match; 26 parameters + have a different name in PhotoCraft, given in that column. + +### What an implementation needs to do + +1. **Import:** `actions.load` maps the step to the command and params listed, using every key in the + table; missing keys take the default from the table. +2. **Out-of-range values:** clamped to the allowed range, as the reference application does. Enum values + outside the allowed list keep the step as a kept step, never a silent default. +3. **Export:** `actions.save` writes the same event with all keys in the order of the table, defaults + included, with the units shown. +4. **Round trip:** load, then save, gives identical descriptors, for a step with defaults and for one + with every value changed. +5. **Tests:** add the event to `every_mapped_command_exports_and_imports_back` and test the params that + reach the command. + +### Group A: The 47 Filter Gallery effects + +Each Filter Gallery effect is recorded as its own event (one per effect), carrying a `GEfk` key with the effect id plus the effect's settings. Today only Spatter (`GEfk` = `Spt`) is mapped (to `filter.gallery.spatter`); the other effects load as kept steps that stop at playback. PhotoCraft already has every effect (`crates/algo/src/artistic.rs`) behind `filter.filterGallery`; this issue only maps them. Where our own range or default differs from the measured one, fix ours to match (marked in each table). + +#### Accented Edges + +- Event: `AccE` / `accentedEdges` +- PhotoCraft command: `filter.filterGallery effect "accentedEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `EdgW` | edgeWidth | integer | | 2 | 1 .. 14 (out-of-range values are clamped) | same | +| `EdgB` | edgeBrightness | integer | | 38 | 0 .. 50 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Angled Strokes + +- Event: `AngS` / `angledStrokes` +- PhotoCraft command: `filter.filterGallery effect "angledStrokes"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `DrcB` | directionBalance | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | same | +| `StrL` | strokeLength | integer | | 15 | 3 .. 50 (out-of-range values are clamped) | same | +| `Shrp` | sharpness | integer | | 3 | 0 .. 10 (out-of-range values are clamped) | maps to our `sharpness` (same range and default) | + +#### Bas Relief + +- Event: `BsRl` / `basRelief` +- PhotoCraft command: `filter.filterGallery effect "basRelief"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dtl ` | detail | integer | | 13 | 1 .. 15 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDBt` lightDirBottom | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | + +#### Chalk Charcoal + +- Event: `ChlC` / `chalkCharcoal` +- PhotoCraft command: `filter.filterGallery effect "chalkCharcoal"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ChrA` | charcoalArea | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | same | +| `ChlA` | chalkArea | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | same | +| `StrP` | strokePressure | integer | | 1 | 0 .. 5 (out-of-range values are clamped) | same | + +#### Charcoal + +- Event: `Chrc` / `charcoal` +- PhotoCraft command: `filter.filterGallery effect "charcoal"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ChAm` | charcoalAmount | integer | | 1 | 1 .. 7 (out-of-range values are clamped) | maps to our `charcoalThickness` (same range and default) | +| `Dtl ` | detail | integer | | 5 | 0 .. 5 (out-of-range values are clamped) | same | +| `LgDr` | lightDark | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `lightDarkBalance` (same range and default) | + +#### Chrome + +- Event: `Chrm` / `chrome` +- PhotoCraft command: `filter.filterGallery effect "chrome"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dtl ` | detail | integer | | 4 | 0 .. 10 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 7 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Colored Pencil + +- Event: `ClrP` / `coloredPencil` +- PhotoCraft command: `filter.filterGallery effect "coloredPencil"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Pncl` | pencilWidth | integer | | 4 | 1 .. 24 (out-of-range values are clamped) | maps to our `pencilWidth` (same range and default) | +| `StrP` | strokePressure | integer | | 8 | 0 .. 15 (out-of-range values are clamped) | same | +| `PprB` | paperBrightness | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | same | + +#### Conte Crayon + +- Event: `CntC` / `conteCrayon` +- PhotoCraft command: `filter.filterGallery effect "conteCrayon"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `FrgL` | foregroundLevel | integer | | 11 | 1 .. 15 (out-of-range values are clamped) | same | +| `BckL` | backgroundLevel | integer | | 7 | 1 .. 15 (out-of-range values are clamped) | maps to our `backgroundLevel` (same range and default) | +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 4 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDTp` lightDirTop | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Craquelure + +- Event: `Crql` / `craquelure` +- PhotoCraft command: `filter.filterGallery effect "craquelure"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `CrcS` | crackSpacing | integer | | 15 | 2 .. 100 (out-of-range values are clamped) | same | +| `CrcD` | crackDepth | integer | | 6 | 0 .. 10 (out-of-range values are clamped) | same | +| `CrcB` | crackBrightness | integer | | 9 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Crosshatch + +- Event: `Crsh` / `crosshatch` +- PhotoCraft command: `filter.filterGallery effect "crosshatch"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 9 | 3 .. 50 (out-of-range values are clamped) | same | +| `Shrp` | sharpness | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | maps to our `sharpness` (same range and default) | +| `Strg` | strength | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | maps to our `strength` (same range and default) | + +#### Cutout + +- Event: `Ct ` / `cutout` +- PhotoCraft command: `filter.filterGallery effect "cutout"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `NmbL` | numberOfLayers | integer | | 4 | 2 .. 8 (out-of-range values are clamped) | maps to our `numberOfLevels` (same range and default) | +| `EdgS` | edgeSimplicity | integer | | 4 | 0 .. 10 (out-of-range values are clamped) | same | +| `EdgF` | edgeFidelity | integer | | 2 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Dark Strokes + +- Event: `DrkS` / `darkStrokes` +- PhotoCraft command: `filter.filterGallery effect "darkStrokes"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Blnc` | balance | integer | | 5 | 0 .. 10 (out-of-range values are clamped) | same | +| `BlcI` | blackIntensity | integer | | 6 | 0 .. 10 (out-of-range values are clamped) | same | +| `WhtI` | whiteIntensity | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Diffuse Glow + +- Event: `DfsG` / `diffuseGlow` +- PhotoCraft command: `filter.filterGallery effect "diffuseGlow"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Grns` | graininess | integer | | 6 | 0 .. 10 (out-of-range values are clamped) | same | +| `GlwA` | glowAmount | integer | | 10 | 0 .. 20 (out-of-range values are clamped) | same | +| `ClrA` | clearAmount | integer | | 15 | 0 .. 20 (out-of-range values are clamped) | same | + +#### Dry Brush + +- Event: `DryB` / `dryBrush` +- PhotoCraft command: `filter.filterGallery effect "dryBrush"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `BrsD` | brushDetail | integer | | 8 | 0 .. 10 (out-of-range values are clamped) | same | +| `Txtr` | texture | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Film Grain + +- Event: `FlmG` / `filmGrain` +- PhotoCraft command: `filter.filterGallery effect "filmGrain"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Grn ` | grain | integer | | 4 | 0 .. 20 (out-of-range values are clamped) | same | +| `HghA` | highlightArea | integer | | 0 | 0 .. 20 (out-of-range values are clamped) | same | +| `Intn` | intensity | integer | | 10 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Fresco + +- Event: `Frsc` / `fresco` +- PhotoCraft command: `filter.filterGallery effect "fresco"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `BrsD` | brushDetail | integer | | 8 | 0 .. 10 (out-of-range values are clamped) | same | +| `Txtr` | texture | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Glass + +- Event: `Gls ` / `glass` +- PhotoCraft command: `filter.filterGallery effect "glass"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dstr` | distortion | integer | | 5 | 0 .. 20 (out-of-range values are clamped) | maps to our `distortion` (same range and default) | +| `Smth` | smoothness | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | +| `TxtT` | textureType | enumerated | | `TxFr` texTypeFrosted | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `InvT` | invertTexture | boolean | | false | | | + +#### Glowing Edges + +- Event: `GlwE` / `glowingEdges` +- PhotoCraft command: `filter.filterGallery effect "glowingEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `EdgW` | edgeWidth | integer | | 2 | 1 .. 14 (out-of-range values are clamped) | same | +| `EdgB` | edgeBrightness | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Graphic Pen + +- Event: `GraP` / `graphicPen` +- PhotoCraft command: `filter.filterGallery effect "graphicPen"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 15 | 1 .. 15 (out-of-range values are clamped) | same | +| `LgDr` | lightDark | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `lightDarkBalance` (same range and default) | +| `SDir` | strokeDirection | enumerated | | `SDRD` strokeDirRightDiag | `SDHz` strokeDirHorizontal, `SDLD` strokeDirLeftDiag, `SDRD` strokeDirRightDiag, `SDVt` strokeDirVertical | | + +#### Halftone Screen + +- Event: `HlfS` / `halftoneScreen` +- PhotoCraft command: `filter.filterGallery effect "halftonePattern"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `HlSz` | halftoneSize | integer | | 1 | 1 .. 12 (out-of-range values are clamped) | maps to our `size` (same range and default) | +| `Cntr` | contrast | integer | | 5 | 0 .. 50 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | +| `ScrT` | screenType | enumerated | | `ScrD` screenDot | `ScrC` screenCircle, `ScrD` screenDot, `ScrL` screenLine | | + +#### Ink Outlines + +- Event: `InkO` / `inkOutlines` +- PhotoCraft command: `filter.filterGallery effect "inkOutlines"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 4 | 1 .. 50 (out-of-range values are clamped) | same | +| `DrkI` | darkIntensity | integer | | 20 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghI` | lightIntensity | integer | | 10 | 0 .. 50 (out-of-range values are clamped) | same | + +#### Mosaic Tiles + +- Event: `MscT` (no string ID) +- PhotoCraft command: `filter.filterGallery effect "mosaicTiles"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `TlSz` | tileSize | integer | | 12 | 2 .. 100 (out-of-range values are clamped) | same | +| `GrtW` | groutWidth | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | +| `LghG` | lightenGrout | integer | | 9 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Neon Glow + +- Event: `NGlw` / `neonGlow` +- PhotoCraft command: `filter.filterGallery effect "neonGlow"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Sz ` | size | integer | | 5 | -24 .. 24 (out-of-range values are clamped) | maps to our `glowSize` (same range and default) | +| `Brgh` | brightness | integer | | 15 | 0 .. 50 (out-of-range values are clamped) | maps to our `glowBrightness` (same range and default) | +| `Clr ` | color | object | | obj 'RGBC' RGBColor | | | + +Nested values as recorded (defaults): +``` + 'Rd ' red : DOUBLETYPE = 0 + 'Grn ' green : DOUBLETYPE = 0 + 'Bl ' blue : DOUBLETYPE = 255 +``` + +#### Note Paper + +- Event: `NtPr` / `notePaper` +- PhotoCraft command: `filter.filterGallery effect "notePaper"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ImgB` | imageBalance | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | same | +| `Grns` | graininess | integer | | 10 | 0 .. 20 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 11 | 0 .. 25 (out-of-range values are clamped) | same | + +#### Ocean Ripple + +- Event: `OcnR` / `oceanRipple` +- PhotoCraft command: `filter.filterGallery effect "oceanRipple"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `RplS` | rippleSize | integer | | 9 | 1 .. 15 (out-of-range values are clamped) | same | +| `RplM` | rippleMagnitude | integer | | 9 | 0 .. 20 (out-of-range values are clamped) | same | + +#### Paint Daubs + +- Event: `PntD` / `paintDaubs` +- PhotoCraft command: `filter.filterGallery effect "paintDaubs"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Sz ` | size | integer | | 8 | 1 .. 50 (out-of-range values are clamped) | maps to our `brushSize` (same range and default) | +| `Shrp` | sharpness | integer | | 7 | 0 .. 40 (out-of-range values are clamped) | maps to our `sharpness` (same range and default) | +| `BrsT` | brushType | enumerated | | `BrSm` brushSimple | `BrDR` brushDarkRough, `BrSm` brushSimple, `BrSp` brushSparkle, `BrbW` brushWideBlurry, `BrsL` brushLightRough, `BrsW` brushWideSharp | | + +#### Palette Knife + +- Event: `PltK` / `paletteKnife` +- PhotoCraft command: `filter.filterGallery effect "paletteKnife"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrS` | strokeSize | integer | | 25 | 1 .. 50 (out-of-range values are clamped) | same | +| `StDt` | strokeDetail | integer | | 3 | 1 .. 3 (out-of-range values are clamped) | same | +| `Sftn` | softness | integer | | 0 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Patchwork + +- Event: `Ptch` / `patchwork` +- PhotoCraft command: `filter.filterGallery effect "patchwork"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `SqrS` | squareSize | integer | | 4 | 0 .. 10 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 8 | 0 .. 25 (out-of-range values are clamped) | same | + +#### Photocopy + +- Event: `Phtc` / `photocopy` +- PhotoCraft command: `filter.filterGallery effect "photocopy"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dtl ` | detail | integer | | 7 | 1 .. 24 (out-of-range values are clamped) | same | +| `Drkn` | darkness | integer | | 8 | 1 .. 50 (out-of-range values are clamped) | maps to our `darkness` (same range and default) | + +#### Plaster + +- Event: `Plst` / `plaster` +- PhotoCraft command: `filter.filterGallery effect "plaster"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ImgB` | imageBalance | integer | | 20 | 0 .. 50 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 2 | 1 .. 15 (out-of-range values are clamped) | same | +| `LghP` | lightPosition | enumerated | | `LPTp` lightPosTop | `LPBL` lightPosBottomLeft, `LPBr` lightPosBottomRight, `LPBt` lightPosBottom, `LPLf` lightPosLeft, `LPRg` lightPosRight, `LPTL` lightPosTopLeft, `LPTR` lightPosTopRight, `LPTp` lightPosTop | | + +#### Plastic Wrap + +- Event: `PlsW` / `plasticWrap` +- PhotoCraft command: `filter.filterGallery effect "plasticWrap"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `HghS` | highlightStrength | integer | | 15 | 0 .. 20 (out-of-range values are clamped) | same | +| `Dtl ` | detail | integer | | 9 | 1 .. 15 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 7 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Poster Edges + +- Event: `PstE` / `posterEdges` +- PhotoCraft command: `filter.filterGallery effect "posterEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `EdgT` | edgeThickness | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `EdgI` | edgeIntensity | integer | | 1 | 0 .. 10 (out-of-range values are clamped) | same | +| `Pstr` | posterization | integer | | 2 | 0 .. 6 (out-of-range values are clamped) | same | + +#### Reticulation + +- Event: `Rtcl` / `reticulation` +- PhotoCraft command: `filter.filterGallery effect "reticulation"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dnst` | density | integer | | 12 | 0 .. 50 (out-of-range values are clamped) | same | +| `BlcL` | blackLevel | integer | | 40 | 0 .. 50 (out-of-range values are clamped) | maps to our `foregroundLevel` (same range and default) | +| `WhtL` | whiteLevel | integer | | 5 | 0 .. 50 (out-of-range values are clamped) | maps to our `backgroundLevel` (same range and default) | + +#### Rough Pastels + +- Event: `RghP` / `roughPastels` +- PhotoCraft command: `filter.filterGallery effect "roughPastels"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 6 | 0 .. 40 (out-of-range values are clamped) | same | +| `StDt` | strokeDetail | integer | | 4 | 1 .. 20 (out-of-range values are clamped) | same | +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 20 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDBt` lightDirBottom | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Smudge Stick + +- Event: `SmdS` / `smudgeStick` +- PhotoCraft command: `filter.filterGallery effect "smudgeStick"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `HghA` | highlightArea | integer | | 0 | 0 .. 20 (out-of-range values are clamped) | same | +| `Intn` | intensity | integer | | 10 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Spatter + +- Event: `Spt ` / `spatter` +- PhotoCraft command: `filter.filterGallery effect "spatter"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `SprR` | sprayRadius | integer | | 10 | 0 .. 25 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Sponge + +- Event: `Spng` / `sponge` +- PhotoCraft command: `filter.filterGallery effect "sponge"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `Dfnt` | definition | integer | | 12 | 0 .. 25 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Sprayed Strokes + +- Event: `SprS` / `sprayedStrokes` +- PhotoCraft command: `filter.filterGallery effect "sprayedStrokes"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 12 | 0 .. 20 (out-of-range values are clamped) | same | +| `SprR` | sprayRadius | integer | | 7 | 0 .. 25 (out-of-range values are clamped) | same | +| `SDir` | strokeDirection | enumerated | | `SDRD` strokeDirRightDiag | `SDHz` strokeDirHorizontal, `SDLD` strokeDirLeftDiag, `SDRD` strokeDirRightDiag, `SDVt` strokeDirVertical | | + +#### Stained Glass + +- Event: `StnG` / `stainedGlass` +- PhotoCraft command: `filter.filterGallery effect "stainedGlass"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ClSz` | cellSize | integer | | 10 | 2 .. 50 (out-of-range values are clamped) | same | +| `BrdT` | borderThickness | integer | | 4 | 1 .. 20 (out-of-range values are clamped) | same | +| `LghI` | lightIntensity | integer | | 3 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Stamp + +- Event: `Stmp` / `stamp` +- PhotoCraft command: `filter.filterGallery effect "stamp"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `LgDr` | lightDark | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | maps to our `lightDarkBalance` (same range and default) | +| `Smth` | smoothness | integer | | 5 | 1 .. 50 (out-of-range values are clamped) | same | + +#### Sumie + +- Event: `Smie` / `sumie` +- PhotoCraft command: `filter.filterGallery effect "sumiE"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrW` | strokeWidth | integer | | 10 | 3 .. 15 (out-of-range values are clamped) | same | +| `StrP` | strokePressure | integer | | 2 | 0 .. 15 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 16 | 0 .. 40 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | + +#### Texturizer + +- Event: `Txtz` / `texturizer` +- PhotoCraft command: `filter.filterGallery effect "texturizer"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 4 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDTp` lightDirTop | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Torn Edges + +- Event: `TrnE` / `tornEdges` +- PhotoCraft command: `filter.filterGallery effect "tornEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ImgB` | imageBalance | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 11 | 1 .. 15 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 17 | 1 .. 25 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | + +#### Underpainting + +- Event: `Undr` / `underpainting` +- PhotoCraft command: `filter.filterGallery effect "underpainting"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 6 | 0 .. 40 (out-of-range values are clamped) | same | +| `TxtC` | textureCoverage | integer | | 16 | 0 .. 40 (out-of-range values are clamped) | maps to our `textureCoverage` (same range and default) | +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 4 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDTp` lightDirTop | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Water Paper + +- Event: `WtrP` / `waterPaper` +- PhotoCraft command: `filter.filterGallery effect "waterPaper"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `FbrL` | fiberLength | integer | | 15 | 3 .. 50 (out-of-range values are clamped) | same | +| `Brgh` | brightness | integer | | 60 | 0 .. 100 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 80 | 0 .. 100 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | + +#### Watercolor + +- Event: `Wtrc` / `watercolor` +- PhotoCraft command: `filter.filterGallery effect "watercolor"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsD` | brushDetail | integer | | 9 | 1 .. 14 (out-of-range values are clamped) | same | +| `ShdI` | shadingIntensity | integer | | 1 | 0 .. 10 (out-of-range values are clamped) | maps to our `shadowIntensity` (same range and default) | +| `Txtr` | texture | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Grain + +- Event: `Grn ` / `grain` +- PhotoCraft command: `filter.filterGallery effect "grain"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Intn` | intensity | integer | | 40 | 0 .. 100 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | +| `Grnt` | grainType | enumerated | | `GrnR` grainRegular | `GrCn` grainContrasty, `GrSf` grainSoft, `GrSp` grainSpeckle, `GrSr` grainSprinkles, `GrSt` grainStippled, `GrnC` grainClumped, `GrnE` grainEnlarged, `GrnH` grainHorizontal, `GrnR` grainRegular, `GrnV` grainVertical | | + +### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare + +All four commands exist in PhotoCraft; only the `.atn` mapping is missing. Lighting Effects is covered with the events that need a recorded step (its lights are only recorded through the dialog). + +#### Clouds + +- Event: `Clds` / `clouds` +- PhotoCraft command: `filter.render.clouds` +- Uses the current foreground and background colours; the step itself carries no colours. + +No parameters: the step carries an empty descriptor. + +#### Difference Clouds + +- Event: `DfrC` / `differenceClouds` +- PhotoCraft command: `filter.render.differenceClouds` +- Same as Clouds, blended with Difference. + +No parameters: the step carries an empty descriptor. + +#### Fibers + +- Event: `Fbrs` (no string ID) +- PhotoCraft command: `filter.render.fibers` +- `randomSeed` makes playback reproduce the recorded result; keep it. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Vrnc` | (none) | integer | | 16 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `Strg` | strengthPlugin | integer | | 4 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | + +#### Lens Flare + +- Event: `LnsF` / `lensFlare` +- PhotoCraft command: `filter.render.lensFlare` +- Lens types: `Zm ` 50-300 mm Zoom, `Nkn ` 35 mm Prime, `Nkn1` 105 mm Prime, `PnVs` Movie Prime. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Brgh` | brightness | integer | | 100 | 10 .. 300 (out-of-range values are clamped) | +| `FlrC` | flareCenter | object | | obj 'Pnt ' point | `Hrzn` / `Vrtc` doubles, 0.4 / 0.4 by default; not clamped: (5, -5) is kept as given and plays | +| `Lns ` | lens | enumerated | | `Zm ` zoom | `Nkn ` nikon, `Nkn1` nikon105, `PnVs` panaVision, `Zm ` zoom | + +Nested values as recorded (defaults): +``` + 'Hrzn' horizontal : DOUBLETYPE = 0.40000000596046 + 'Vrtc' vertical : DOUBLETYPE = 0.40000000596046 +``` + +### Group C: The Distort filters (Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave, ZigZag) + +The seven Distort events below map to PhotoCraft's `filter.distort.*` commands for their documented supported forms, including complete Shear curves. Displace needs a displacement map file and is listed separately at the end. + +#### Pinch + +- Event: `Pnch` / `pinch` +- PhotoCraft command: `filter.distort.pinch` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Amnt` | amount | integer | | 50 | -100 .. 100 (out-of-range values are clamped) | + +#### Polar Coordinates + +- Event: `Plr ` / `polar` +- PhotoCraft command: `filter.distort.polarCoordinates` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Cnvr` | convert | enumerated | | `RctP` rectToPolar | `PlrR` polarToRect, `RctP` rectToPolar | + +#### Shear + +- Event: `Shr ` / `shear` +- PhotoCraft command: `filter.distort.shear` +- `shearPoints` is a list of curve points; keep their order and count. + +Supported for complete curves: import clamps finite grid coordinates to the measured ranges below, +maps them to the native top-to-bottom position `(Vrtc - 1) / 127` and half-width offset `Hrzn / 64`, +and preserves fractional recorded coordinates exactly on save. Both edge modes are supported. +Only `ShrS` 0 and `ShrE` equal to the last point's index map; an omitted `ShrE` still defaults to 1. +The adapter accepts 2..4096 points as an execution safety budget, not a measured Photoshop format +maximum. This bounds point-vector allocation and the renderer's linear per-row curve search while +allowing fractional scripted points. Larger recorded curves stay intact as kept steps, and native +exports above that budget are refused before point conversion. No renderer performance is claimed. +The budget covers adapter mapping and playback; input parsing and generic export copies remain outside it. +Malformed settings, unknown keys, nonfinite values, and curves whose order or points the native +renderer would change remain complete kept steps. Export refuses unrepresentable native options +and mask targets. The native Shear renderer and its ordinary command behavior are unchanged. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`) from top to bottom; default two points (0, 1) and (0, 128). The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid edge clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. A script run accepts any value without clamping (e.g. `Hrzn` ±10000, fractions) and crashed on a first point with `Vrtc` < 1: clamp to the dialog ranges on import | +| `UndA` | undefinedArea | enumerated | | `WrpA` wrapAround | `RptE` repeatEdgePixels, `WrpA` wrapAround | +| `ShrS` | shearStart | integer | | 0 | index of the first curve point; the dialog always writes 0 | +| `ShrE` | shearEnd | integer | | 1 | index of the last curve point (point count - 1); the dialog writes it. Script runs with other values change the result, and far-off values (`ShrE` 100 or 1000, `ShrS` -1000) crashed the application: on import accept only `ShrS` 0 and `ShrE` = point count - 1, otherwise keep the step as a kept step | + +Nested values as recorded (defaults): +``` + [0] OBJECTTYPE = obj 'Pnt ' point + 'Hrzn' horizontal : DOUBLETYPE = 0 + 'Vrtc' vertical : DOUBLETYPE = 1 + [1] OBJECTTYPE = obj 'Pnt ' point + 'Hrzn' horizontal : DOUBLETYPE = 0 + 'Vrtc' vertical : DOUBLETYPE = 128 +``` + +#### Spherize + +- Event: `Sphr` / `spherize` +- PhotoCraft command: `filter.distort.spherize` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Amnt` | amount | integer | | 100 | -100 .. 100 (out-of-range values are clamped) | +| `SphM` | spherizeMode | enumerated | | `Nrml` normal | `HrzO` horizontalOnly, `Nrml` normal, `VrtO` verticalOnly | + +#### Twirl + +- Event: `Twrl` / `twirl` +- PhotoCraft command: `filter.distort.twirl` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Angl` | angle | integer | | 50 | -999 .. 999 (out-of-range values are clamped) | + +#### Wave + +- Event: `Wave` / `wave` +- PhotoCraft command: `filter.distort.wave` +- `randomSeed`: keep it so playback reproduces the recorded result. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Wvtp` | waveType | enumerated | | `WvSn` waveSine | `WvSn` waveSine, `WvSq` waveSquare, `WvTr` waveTriangle | +| `NmbG` | numberOfGenerators | integer | | 5 | 1 .. 999 (out-of-range values are clamped) | +| `WLMn` | wavelengthMin | integer | | 10 | 1 .. 999 (out-of-range values are clamped) | +| `WLMx` | wavelengthMax | integer | | 120 | 1 .. 999 (out-of-range values are clamped) | +| `AmMn` | amplitudeMin | integer | | 5 | 1 .. 999 (out-of-range values are clamped) | +| `AmMx` | amplitudeMax | integer | | 35 | 1 .. 999 (out-of-range values are clamped) | +| `SclH` | scaleHorizontal | integer | | 100 | 1 .. 100 (out-of-range values are clamped) | +| `SclV` | scaleVertical | integer | | 100 | 1 .. 100 (out-of-range values are clamped) | +| `UndA` | undefinedArea | enumerated | | `RptE` repeatEdgePixels | `RptE` repeatEdgePixels, `WrpA` wrapAround | +| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | + +#### ZigZag + +- Event: `ZgZg` / `zigZag` +- PhotoCraft command: `filter.distort.zigZag` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Amnt` | amount | integer | | 10 | -100 .. 100 (out-of-range values are clamped) | +| `NmbR` | numberOfRipples | integer | | 5 | 0 .. 20 (out-of-range values are clamped) | +| `ZZTy` | zigZagType | enumerated | | `PndR` pondRipples | `ArnC` aroundCenter, `OtFr` outFromCenter, `PndR` pondRipples | + +#### Displace + +Displace reads a displacement map from a file chosen in a dialog; record one step and add its keys (file path form) before implementing. + +### Group D: The Stylize and Pixelate filters + +All commands exist in PhotoCraft (`filter.stylize.*`, `filter.pixelate.*`); only the `.atn` mapping is missing. + +#### Extrude + +- Event: `Extr` / `extrude` +- PhotoCraft command: `filter.stylize.extrude` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ExtS` | extrudeSize | integer | | 30 | 2 .. 255 (out-of-range values are clamped) | +| `ExtD` | extrudeDepth | integer | | 30 | 1 .. 255 (out-of-range values are clamped) | +| `ExtF` | extrudeSolidFace | boolean | | false | | +| `ExtM` | extrudeMaskIncomplete | boolean | | false | | +| `ExtT` | extrudeType | enumerated | | `Blks` blacks | `Blks` blacks, `Pyrm` pyramids | +| `ExtR` | extrudeRandom | enumerated | | `Rndm` random | `LvlB` levelBased, `Rndm` random | + +#### Find Edges + +- Event: `FndE` / `findEdges` +- PhotoCraft command: `filter.stylize.findEdges` + +No parameters: the step carries an empty descriptor. + +#### Solarize + +- Event: `Slrz` / `solarize` +- PhotoCraft command: `filter.stylize.solarize` + +No parameters: the step carries an empty descriptor. + +#### Trace Contour + +- Event: `TrcC` / `traceContour` +- PhotoCraft command: `filter.stylize.traceContour` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Lvl ` | level | integer | | 128 | whole number 0..255 (the dialog clamps to the nearest value). A recorded step carries the real level (e.g. 77); only a script run echoes 128 back. Levels <= 0 and >= 255 give the same image. | +| `Edg ` | edge | enumerated | | `Upr ` upper | `CntE` enum: `Lwr ` lower, `Upr ` upper; recorded as set in the dialog | + +#### Wind + +- Event: `Wnd ` / `wind` +- PhotoCraft command: `filter.stylize.wind` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `WndM` | windMethod | enumerated | | `Wnd ` wind | `Blst` blast, `Stgr` stagger, `Wnd ` wind | +| `Drct` | direction | enumerated | | `Left` left | `Left` left, `Rght` right | + +#### Tiles + +- Event: `Tls ` / `tiles` +- PhotoCraft command: `filter.stylize.tiles` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `TlNm` | tileNumber | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | +| `TlOf` | tileOffset | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | +| `FlCl` | fillColor | enumerated | | `FlBc` fillBack | `FlBc` fillBack, `FlFr` fillFore, `FlIn` fillInverse, `FlSm` fillSame | + +#### Color Halftone + +- Event: `ClrH` / `colorHalftone` +- PhotoCraft command: `filter.pixelate.colorHalftone` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Rds ` | radius | integer | | 8 | 4 .. 127 (out-of-range values are clamped) | +| `Ang1` | angle1 | integer | | 108 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang2` | angle2 | integer | | 162 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang3` | angle3 | integer | | 90 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang4` | angle4 | integer | | 45 | -10000 .. 10000 (out-of-range values are clamped) | + +#### Facet + +- Event: `Fct ` / `facet` +- PhotoCraft command: `filter.pixelate.facet` + +No parameters: the step carries an empty descriptor. + +#### Fragment + +- Event: `Frgm` / `fragment` +- PhotoCraft command: `filter.pixelate.fragment` + +No parameters: the step carries an empty descriptor. + +#### Mezzotint + +- Event: `Mztn` / `mezzotint` +- PhotoCraft command: `filter.pixelate.mezzotint` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `MztT` | mezzotintType | enumerated | | `FnDt` fineDots | `CrsD` coarseDots, `FnDt` fineDots, `GrnD` grainyDots, `LngL` longLines, `LngS` longStrokes, `MdmD` mediumDots, `MdmL` mediumLines, `MdmS` mediumStrokes, `ShSt` shortStrokes, `ShrL` shortLines | + +#### Mosaic + +- Event: `Msc ` / `mosaic` +- PhotoCraft command: `filter.pixelate.mosaic` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ClSz` | cellSize | unitdouble | | 2 | 2 .. 200 pixels (out-of-range values are clamped). Write it as a unit double in `#Pxl`: the application accepts that and returns it unchanged. Read a value without a unit as pixels (the application returns its own default that way). | + +#### Pointillize + +- Event: `Pntl` / `pointillize` +- PhotoCraft command: `filter.pixelate.pointillize` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ClSz` | cellSize | integer | | 5 | 3 .. 300 (out-of-range values are clamped) | + +### Group E: Video, Other and matting filters and the direct Gradient Map / Replace Color adjustments + +All commands exist in PhotoCraft. The two direct adjustments, classic Gradient Map and Lab-range Replace Color, are supported above; the remaining events below still need their `.atn` mappings. These direct adjustments are separate from adjustment layers. + +#### De-Interlace + +- Event: `Dntr` / `deInterlace` +- PhotoCraft command: `filter.video.deInterlace` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `IntE` | interlaceEliminateType | enumerated | | `ElmO` eliminateOddFields | `ElmE` eliminateEvenFields, `ElmO` eliminateOddFields | +| `IntC` | interlaceCreateType | enumerated | | `CrtI` createInterpolation | `CrtD` createDroplet, `CrtI` createInterpolation | + +#### NTSC Colors + +- Event: `NTSC` (no string ID) +- PhotoCraft command: `filter.video.ntscColors` + +No parameters: the step carries an empty descriptor. + +#### HSB/HSL + +- Event: `HsbP` (no string ID) +- PhotoCraft command: `filter.other.hsbHsl` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Inpt` | input | enumerated | | `RGBC` RGBColor | `HSBl` HSBColorEnum, `HSLC` HSLColor, `RGBC` RGBColor | +| `Otpt` | output | enumerated | | `HSBl` HSBColorEnum | `HSBl` HSBColorEnum, `HSLC` HSLColor, `RGBC` RGBColor | + +#### Remove Black Matte + +- Event: `RmvB` / `removeBlackMatte` +- PhotoCraft command: `layer.matting.removeBlackMatte` + +No parameters: the step carries an empty descriptor. + +#### Remove White Matte + +- Event: `RmvW` / `removeWhiteMatte` +- PhotoCraft command: `layer.matting.removeWhiteMatte` + +No parameters: the step carries an empty descriptor. + +#### Average + +- Event: `Avrg` (no string ID) +- PhotoCraft command: `filter.blur.average` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +No parameters: the step carries an empty descriptor. + +#### Custom + +- Event: `Cstm` / `custom` +- PhotoCraft command: `filter.other.custom` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `Scl ` | scale | integer | | 1 | 1 .. 9999 (measured in the dialog); applied also when played without the dialog (scale 2 halves the result) | +| `Ofst` | offset | integer | | 0 | -9999 .. 9999 (measured in the dialog); applied also when played without the dialog (offset 50 adds 50) | +| `Mtrx` | matrix | list | | none: a step always carries all 25 cells | exactly 25 integers (5 x 5, row by row), each -999 .. 999; a 3 x 3 list makes the application fail | + +### Group F: Selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older merge/mask events + +Steps recorded in an ordinary editing session. The values below were measured in a document set up for each step (a selection, the clipboard, linked or several selected layers). Align, Copy/Paste Layer Style and Rename are already mapped under their current events and are not repeated here. + +#### Add to Selection (rectangle) + +- Event: `AddT` / `addTo` +- PhotoCraft command: `select.rect {"mode":"add"}` +- Shift-drag with the Rectangular Marquee. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit +``` + +#### Add to Selection (ellipse) + +- Event: `AddT` / `addTo` +- PhotoCraft command: `select.rect {"mode":"add","ellipse":true}` +- Same event with an `Elps` target and anti-aliasing. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Elps' ellipse | | +| `AntA` | antiAlias | boolean | | true | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit +``` + +#### Subtract from Selection + +- Event: `SbtF` / `subtractFrom` +- PhotoCraft command: `select.rect {"mode":"subtract"}` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 60 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 60 '#Pxl' pixelsUnit +``` + +#### Intersect with Selection + +- Event: `IntW` / `interfaceWhite` +- PhotoCraft command: `select.rect {"mode":"intersect"}` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 120 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 120 '#Pxl' pixelsUnit +``` + +#### Paste Into + +- Event: `PstI` / `pasteInto` +- PhotoCraft command: `edit.pasteSpecial.pasteInto` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | +| `As ` | as | class | | class 'Pxel' pixel | | + +#### Paste Outside + +- Event: `PstO` / `pasteOutside` +- PhotoCraft command: `edit.pasteSpecial.pasteOutside` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | +| `As ` | as | class | | class 'Pxel' pixel | | + +#### Fade + +- Event: `Fade` / `fade` +- PhotoCraft command: `edit.fade` +- Recorded right after the step it fades. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `Opct` | opacity | unitdouble | `#Prc` | 50 | 0 .. 100 (out-of-range values are clamped) | +| `Md ` | mode | enumerated | | `Mltp` multiply | not measured yet | + +#### Link Layers + +- Event: `` / `linkSelectedLayers` +- PhotoCraft command: `layer.linkLayers` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | + +#### Distribute + +- Event: `Dstr` / `distort` +- PhotoCraft command: `layer.distribute.leftEdges (one command per edge)` +- `Usng` selects the edge or centre; one enum value per Distribute menu item. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | +| `Usng` | using | enumerated | | `AdLf` ADSLefts | not measured yet | + +#### Remove Layer Mask (older files) + +- Event: `RmvL` / `removeLayerMask` +- PhotoCraft command: `layer.layerMask.delete` +- The reference application asks "Apply mask before deleting?" when this step plays without the apply choice; see the table for the key it records. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +No parameters: the step carries an empty descriptor. + +#### Define Pattern + +- Event: `DfnP` / `definePattern` +- PhotoCraft command: `edit.definePattern` + +No parameters: the step carries an empty descriptor. + +#### Merge Layers (older files) + +- Event: `MrgL` (no string ID) +- PhotoCraft command: `layer.mergeLayers` + +No parameters: the step carries an empty descriptor. + +### Group G: Split Channels, Merge Spot Channel, Trap and Match Color + +Channel and colour steps. Trap was measured in a CMYK document (it only exists there). + +#### Split Channels + +- Event: `SplC` / `splitChannels` +- PhotoCraft command: `channel.split` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `` | documentIDs | list | | list[3] | | + +Nested values as recorded (example values): +``` + [0] 182 + [1] 185 + [2] 188 +``` + +#### Merge Spot Channel + +- Event: `MSpt` / `mergeSpotChannel` +- PhotoCraft command: `channel.mergeSpot` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +No parameters: the step carries an empty descriptor. + +#### Trap + +- Event: `Trap` / `trap` +- PhotoCraft command: `image.trap` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `Wdth` | width | unitdouble | `#Pxl` | 2 | up to 32767 (clamped); the minimum was not measured (a huge negative value ran out of memory) | + +#### Match Color + +- Event: `` / `matchColor` +- PhotoCraft command: `image.adjustments.matchColor` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Lght` | lightness | integer | | 100 | | +| `ClrR` | colorRange | integer | | 100 | | +| `Fade` | fade | integer | | 0 | | +| `` | noReference | boolean | | true | | + +### Group I: Newer features (Focus Area, Remove Background, Select Sky, Oil Paint, Smart Sharpen) + +These events only have a string ID (no 4-character code). Steps recorded in newer versions use them. + +#### Select > Focus Area + +- Event: `` / `focusMask` +- PhotoCraft command: `select.focusArea` + +No parameters: the step carries an empty descriptor. + +#### Remove Background (quick action) + +- Event: `` / `removeBackground` +- PhotoCraft command: `layer.removeBackground` + +No parameters: the step carries an empty descriptor. + +#### Select > Sky + +- Event: `` / `selectSky` +- PhotoCraft command: `select.sky` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | sampleAllLayers | boolean | | false | | + +#### Filter > Stylize > Oil Paint + +- Event: `` / `oilPaint` +- PhotoCraft command: `filter.stylize.oilPaint` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | lightingOn | boolean | | true | | +| `` | stylization | double | | 4 | | +| `` | cleanliness | double | | 2.29999995231628 | | +| `` | brushScale | double | | 0.80000001192093 | | +| `` | microBrush | double | | 10 | | +| `LghD` | lightDirection | integer | | -60 | | +| `` | specularity | double | | 1.29999995231628 | | + +#### Filter > Sharpen > Smart Sharpen + +- Event: `` / `smartSharpen` +- PhotoCraft command: `filter.sharpen.smartSharpen` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | presetKind | enumerated | | presetKindType. presetKindCustom | | +| `` | useLegacy | boolean | | true | | +| `Amnt` | amount | unitdouble | `#Prc` | 200 | | +| `Rds ` | radius | unitdouble | `#Pxl` | 1 | | +| `` | moreAccurate | boolean | | false | | +| `blur` | blur | enumerated | | blurType. lensBlur | | + +### Events that need a step recorded through their dialog + +These carry settings that only exist once their dialog was used, so a dialog-less run returns no +values or ignores the ones it was given. They are listed so nobody measures them again from scratch; +their tables will be added once a recorded step was read. + +| Event | Why | +|---|---| +| Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | + +### Recorded under another event + +Some older events are no longer what the application records; PhotoCraft already maps the current one: +Clear (recorded as Delete), Rasterize Type (`rasterizeLayer`), Convert to Profile (`convertToProfile`), +Copy and Paste Layer Style (`copyEffects`, `pasteEffects`), Align (`align`), Rename Layer (Set of the +layer's name), Deselect (Set of the selection to None). Steps recorded with Backlight, Fill Flash, Color +Cast, Variations, Texture Fill and the old Snapshot events come only from very old versions and stay kept +steps. diff --git a/docs/batch-naming.md b/docs/batch-naming.md index a922803f81..02e4d10f04 100644 --- a/docs/batch-naming.md +++ b/docs/batch-naming.md @@ -1,28 +1,46 @@ # Batch output naming -Select a recorded action, then open **File > Automate > Batch**. Set the input and output -folders. **File Naming** accepts a filename stem template; the selected export format supplies -the extension. Leave it blank to keep the existing source filenames and collision reporting. +Select a recorded action, then open **File > Automate > Batch**. Source can be a folder (with +**Include All Subfolders**) or the open documents. **File Naming** accepts a template. Leave it +blank to keep source stems with the selected output extension. An explicit template spells the +whole filename; without `{ext}`, no extension is added. | Token | Value | | --- | --- | -| `{name}` | Input filename without its last extension | -| `{serial}` | Input position plus **Starting serial** (default 1), padded to at least three digits | +| `{name}`, `{NAME}`, `{Name}` | Input filename without its last extension, unchanged, uppercase, or initial capital | +| `{serial}`, `{serial:1}` … `{serial:4}` | Input position plus **Starting serial** (default 1), padded to at least 3 or the specified number of digits | +| `{letter}`, `{LETTER}` | Serial letters: a … z, aa …; uppercase with `{LETTER}` | | `{date}` | UTC date at the start of the batch, in `YYYY-MM-DD` form | +| `{date:mmddyy}`, `{date:mmdd}`, `{date:yyyymmdd}`, `{date:yymmdd}`, `{date:yyddmm}`, `{date:ddmmyy}` | Other UTC date arrangements | +| `{ext}`, `{EXT}` | Output extension, lowercase or uppercase; a dot is added when this is the final component, unless the template already supplied one | -For example, `{name}_{serial}` with starting serial 7 writes `photo_007.png` and -`photo_008.png` for two PNG inputs named `photo.png`. A serial after 999 becomes 1000; +For example, `{name}_{serial}` with starting serial 7 writes `photo_007` and +`photo_008` for two PNG inputs named `photo.png`; `{name}_{serial}{ext}` writes +`photo_007.png` and `photo_008.png`. The file bytes still use the selected output format. +A serial after 999 becomes 1000; it does not wrap. Folder inputs use the existing filename order, and an explicit input array keeps its order. Failed inputs consume their position, so later filenames do not shift. -Braces in an input filename are literal text, not additional tokens. +Braces in an input filename are literal text, not additional tokens. Batch writes subfolder +inputs flat into the output folder in input order. A later same-name result replaces an earlier +one, as measured in Photoshop 2026. **Stop On Error** leaves later inputs unprocessed; +**Error Log** writes a UTF-16 LE file with a BOM and CRLF line endings even when no file fails. +It starts with `Batch Start`, records a `File` / `Error` pair per failure, and ends with +`Batch End`. The wording is English because PhotoCraft does not have Photoshop's numeric error +codes or German message catalog. Folder batches report progress per file and +can be cancelled after the current file. A cancelled batch returns `cancelled: true`, its +completed `files`, and `skipped` inputs. Background job status is `cancelled` and retains +the partial result; a waiting control request replies with `ok: false`, `error: "cancelled"`, +and that result. If writing the error log fails, the batch still +returns its per-file results with `errorLogError` instead of `errorLog`. Unknown tokens, unmatched braces, invalid parameter types, path separators, reserved filename characters, excessive filename length (255 UTF-8 bytes), and serial overflow are rejected before export writes begin. Starting serial must be an integer from 1 to 4,294,967,295. -A custom template that resolves to an existing input file is also rejected; choose a separate -destination or another template. Ordinary per-file read, action and save failures still appear -in the batch's `errors` result. When two results share a name, including a case-only difference, -the first successful result is kept and the later collision is reported. +A custom template that resolves to an existing input file is also rejected before processing; +the same guard checks default flat output names. This prevents an early result from destroying +a later unread input. Ordinary per-file read, +action and save failures still appear in the batch's `errors` result. Image Processor and lens +correction continue to report output-name collisions. The same options are available on `file.automate.batch` through the engine command interface: @@ -37,5 +55,18 @@ The same options are available on `file.automate.batch` through the engine comma } ``` -The CLI can invoke this command with `run --cmd file.automate.batch --params ...`. -This option belongs to Batch; Image Processor and droplet options retain their existing behavior. +The CLI has matching `batch --recursive`, `--naming`, `--start-serial`, `--stop-on-error`, and +`--error-log` flags. Droplets use Batch's flat output and naming behavior. + +## Reference behavior and conformance + +The [Photoshop 2026 Batch measurements](https://github.com/davidmann4/photocraft/blob/docs/atn-event-reference/docs/atn.md#batch-behaviour) +record flat subfolder output, last-result-wins collisions, and extension components that add a +dot only at the end. `batch_include_all_subfolders`, +`batch_naming_later_result_replaces_the_earlier_one`, and +`extension_component_only_adds_a_dot_at_the_end` check those cases. PhotoCraft deliberately +rejects a plan that would replace another input file before it is read, to prevent data loss. +The desktop Batch follows the measured flow: it shows no progress in the status bar, previews each +input read-only on the canvas, and Esc asks whether to continue or stop. Headless control clients +still read the job's progress and stop it with `jobs.cancel`. The inputs are not live tabs with +Layers panel content, so AUTO-219-5 stays partial. diff --git a/docs/benchmarks/multi-channel-editing/baseline.json b/docs/benchmarks/multi-channel-editing/baseline.json new file mode 100644 index 0000000000..ef1306fdd0 --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/baseline.json @@ -0,0 +1,281 @@ +{ + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 77.039568, + "p95_ms": 79.17588, + "samples_ms": [ + 74.12240200000001, + 77.039568, + 77.12316700000001, + 79.17588, + 74.261511, + 74.401069 + ], + "target": "composite" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 249.477602, + "p95_ms": 257.652946, + "samples_ms": [ + 249.060745, + 249.477602, + 253.60113699999997, + 247.988555, + 248.645689, + 257.652946 + ], + "target": "single" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 93.98787, + "p95_ms": 98.007156, + "samples_ms": [ + 93.262145, + 98.007156, + 91.055733, + 95.894644, + 93.54998400000001, + 93.98787 + ], + "target": "composite" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 275.65841, + "p95_ms": 280.951327, + "samples_ms": [ + 280.58498799999995, + 280.951327, + 275.65841, + 265.87684, + 266.507286, + 275.524372 + ], + "target": "single" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 110.992591, + "p95_ms": 112.493579, + "samples_ms": [ + 106.41027799999999, + 110.992591, + 108.263013, + 112.493579, + 107.70536899999999, + 111.501408 + ], + "target": "composite" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 297.747324, + "p95_ms": 300.62228000000005, + "samples_ms": [ + 279.587061, + 287.268048, + 287.399617, + 300.62228000000005, + 297.747324, + 299.382141 + ], + "target": "single" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 98.552445, + "p95_ms": 103.26905599999999, + "samples_ms": [ + 98.965101, + 93.175729, + 103.26905599999999, + 92.94748, + 98.552445, + 97.644012 + ], + "target": "composite" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 271.277763, + "p95_ms": 289.669225, + "samples_ms": [ + 269.67571499999997, + 268.258747, + 264.88655500000004, + 271.277763, + 289.39890499999996, + 289.669225 + ], + "target": "single" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 211.734374, + "p95_ms": 219.24907, + "samples_ms": [ + 219.24907, + 207.864383, + 210.464073, + 209.830038, + 212.212469, + 211.734374 + ], + "target": "composite" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 323.834497, + "p95_ms": 331.500214, + "samples_ms": [ + 331.500214, + 323.834497, + 328.59429, + 300.04599199999996, + 323.69923, + 319.02554 + ], + "target": "single" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 4.667992, + "p95_ms": 4.739272, + "samples_ms": [ + 4.667992, + 4.5473930000000005, + 4.519483, + 4.593012, + 4.739272, + 4.680841 + ], + "target": "composite" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 6.7067760000000005, + "p95_ms": 6.794574, + "samples_ms": [ + 6.671266, + 6.542766, + 6.515307, + 6.794574, + 6.725835, + 6.7067760000000005 + ], + "target": "single" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 4.532363, + "p95_ms": 4.631032, + "samples_ms": [ + 4.631032, + 4.532363, + 4.532613, + 4.484143, + 4.384174, + 4.3979550000000005 + ], + "target": "composite" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 6.532437000000001, + "p95_ms": 6.646095, + "samples_ms": [ + 6.523307, + 6.591367, + 6.646095, + 6.478507, + 6.532437000000001, + 6.350338 + ], + "target": "single" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 0.332068, + "p95_ms": 0.340877, + "samples_ms": [ + 0.338697, + 0.340877, + 0.32919699999999996, + 0.332068, + 0.32726700000000003, + 0.325697 + ], + "target": "composite" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 0.33038799999999996, + "p95_ms": 0.359947, + "samples_ms": [ + 0.359947, + 0.327757, + 0.32745799999999997, + 0.332287, + 0.33038799999999996, + 0.323517 + ], + "target": "single" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 0.302687, + "p95_ms": 0.304467, + "samples_ms": [ + 0.302687, + 0.304467, + 0.301338, + 0.303658, + 0.30212700000000003, + 0.301837 + ], + "target": "composite" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 0.304278, + "p95_ms": 0.425657, + "samples_ms": [ + 0.425657, + 0.305007, + 0.303948, + 0.299278, + 0.302817, + 0.304278 + ], + "target": "single" + } + ], + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/before-fused-rerun.json b/docs/benchmarks/multi-channel-editing/before-fused-rerun.json new file mode 100644 index 0000000000..d2767e607b --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/before-fused-rerun.json @@ -0,0 +1,418 @@ +{ + "clipboard_pixel": false, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 62.881443999999995, + "p95_ms": 65.475511, + "samples_ms": [ + 65.475511, + 64.44737, + 62.881443999999995, + 58.529292, + 55.746496, + 54.667835 + ], + "target": "composite" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 63.021662000000006, + "p95_ms": 63.861135, + "samples_ms": [ + 63.861135, + 63.697696, + 62.322318, + 61.394997, + 63.021662000000006, + 60.835801000000004 + ], + "target": "single" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 126.806588, + "p95_ms": 130.083789, + "samples_ms": [ + 130.083789, + 126.04885399999999, + 125.290851, + 126.66338899999998, + 127.874028, + 126.806588 + ], + "target": "subset" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 74.659931, + "p95_ms": 84.01213, + "samples_ms": [ + 74.659931, + 73.71597, + 84.01213, + 76.81577200000001, + 74.475403, + 74.649262 + ], + "target": "composite" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 85.96262300000001, + "p95_ms": 87.072133, + "samples_ms": [ + 82.036728, + 87.072133, + 85.96262300000001, + 84.303397, + 86.32294999999999, + 82.972789 + ], + "target": "single" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 174.853611, + "p95_ms": 176.927052, + "samples_ms": [ + 168.429516, + 175.731793, + 169.690595, + 170.552488, + 174.853611, + 176.927052 + ], + "target": "subset" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 87.334311, + "p95_ms": 95.19053299999999, + "samples_ms": [ + 87.584219, + 84.990211, + 86.55707799999999, + 87.334311, + 95.19053299999999, + 85.085351 + ], + "target": "composite" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 107.347948, + "p95_ms": 111.79760800000001, + "samples_ms": [ + 106.30147600000001, + 107.347948, + 106.387346, + 108.335268, + 111.79760800000001, + 106.44169500000001 + ], + "target": "single" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 218.90678799999998, + "p95_ms": 229.549566, + "samples_ms": [ + 220.760431, + 229.549566, + 218.90678799999998, + 214.39687700000002, + 217.475171, + 216.587899 + ], + "target": "subset" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 77.17561, + "p95_ms": 81.134995, + "samples_ms": [ + 75.80635099999999, + 79.293111, + 77.17561, + 75.47901399999999, + 75.134917, + 81.134995 + ], + "target": "composite" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 85.979344, + "p95_ms": 86.884215, + "samples_ms": [ + 83.57455399999999, + 85.979344, + 86.884215, + 82.72966199999999, + 83.654884, + 86.500229 + ], + "target": "single" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 173.504073, + "p95_ms": 182.163878, + "samples_ms": [ + 173.504073, + 182.163878, + 164.070806, + 163.035464, + 169.195841, + 173.59339300000002 + ], + "target": "subset" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 186.393192, + "p95_ms": 189.207458, + "samples_ms": [ + 189.207458, + 187.644351, + 186.260563, + 186.393192, + 182.402997, + 184.54991800000002 + ], + "target": "composite" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 321.990454, + "p95_ms": 327.784444, + "samples_ms": [ + 321.990454, + 327.784444, + 322.273732, + 321.970455, + 317.807662, + 319.018472 + ], + "target": "single" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 298.245511, + "p95_ms": 303.884412, + "samples_ms": [ + 297.413057, + 303.884412, + 298.245511, + 297.146581, + 298.226372, + 298.717618 + ], + "target": "subset" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 4.61605, + "p95_ms": 4.6703600000000005, + "samples_ms": [ + 4.61229, + 4.539721, + 4.6703600000000005, + 4.61605, + 4.5865, + 4.652079 + ], + "target": "composite" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 7.048058, + "p95_ms": 7.089189, + "samples_ms": [ + 6.998659, + 7.048058, + 6.9936490000000004, + 6.957668999999999, + 7.089189, + 7.085938 + ], + "target": "single" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 6.593383, + "p95_ms": 6.655762, + "samples_ms": [ + 6.655762, + 6.593383, + 6.532503, + 6.6033029999999995, + 6.547123, + 6.540343 + ], + "target": "subset" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 4.385561, + "p95_ms": 4.435531, + "samples_ms": [ + 4.366002, + 4.380482, + 4.349623, + 4.401231999999999, + 4.435531, + 4.385561 + ], + "target": "composite" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 7.008909, + "p95_ms": 7.089888, + "samples_ms": [ + 7.000089999999999, + 7.076238, + 6.9988, + 7.089888, + 6.9605, + 7.008909 + ], + "target": "single" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 6.631562, + "p95_ms": 6.703132, + "samples_ms": [ + 6.615993, + 6.6215720000000005, + 6.631562, + 6.703132, + 6.660322, + 6.569763 + ], + "target": "subset" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 0.329107, + "p95_ms": 0.335287, + "samples_ms": [ + 0.329107, + 0.329597, + 0.335287, + 0.324407, + 0.324067, + 0.32476700000000003 + ], + "target": "composite" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 1.732215, + "p95_ms": 1.746704, + "samples_ms": [ + 1.695326, + 1.702455, + 1.732215, + 1.723015, + 1.746704, + 1.735645 + ], + "target": "single" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 1.497437, + "p95_ms": 1.528977, + "samples_ms": [ + 1.528977, + 1.478628, + 1.497437, + 1.5275670000000001, + 1.480617, + 1.4860870000000002 + ], + "target": "subset" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 0.304288, + "p95_ms": 0.311477, + "samples_ms": [ + 0.301917, + 0.311477, + 0.30126699999999995, + 0.304838, + 0.300757, + 0.304288 + ], + "target": "composite" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 1.752135, + "p95_ms": 1.7659850000000001, + "samples_ms": [ + 1.701375, + 1.7659850000000001, + 1.7215850000000001, + 1.763525, + 1.752135, + 1.739195 + ], + "target": "single" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 1.4874070000000001, + "p95_ms": 1.497487, + "samples_ms": [ + 1.4885169999999999, + 1.480837, + 1.476148, + 1.487227, + 1.497487, + 1.4874070000000001 + ], + "target": "subset" + } + ], + "selection": null, + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/candidate-before-fused-original.json b/docs/benchmarks/multi-channel-editing/candidate-before-fused-original.json new file mode 100644 index 0000000000..6a3be252db --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/candidate-before-fused-original.json @@ -0,0 +1,418 @@ +{ + "clipboard_pixel": false, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 68.079649, + "p95_ms": 75.84554, + "samples_ms": [ + 75.84554, + 69.212254, + 68.079649, + 64.603017, + 65.86191099999999, + 65.166558 + ], + "target": "composite" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 66.252773, + "p95_ms": 68.404331, + "samples_ms": [ + 66.27493299999999, + 66.252773, + 68.404331, + 66.177203, + 64.652527, + 65.187909 + ], + "target": "single" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 135.996379, + "p95_ms": 140.009044, + "samples_ms": [ + 135.996379, + 133.472169, + 132.520466, + 140.009044, + 133.914992, + 137.91493599999998 + ], + "target": "subset" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 76.321192, + "p95_ms": 79.047742, + "samples_ms": [ + 77.390676, + 75.430069, + 76.321192, + 75.915569, + 75.111297, + 79.047742 + ], + "target": "composite" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 87.988156, + "p95_ms": 94.40502000000001, + "samples_ms": [ + 91.029347, + 87.988156, + 84.02156099999999, + 83.065477, + 84.173631, + 94.40502000000001 + ], + "target": "single" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 178.837842, + "p95_ms": 186.176321, + "samples_ms": [ + 186.176321, + 173.622833, + 172.88915, + 171.898916, + 178.837842, + 185.268507 + ], + "target": "subset" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 92.30975199999999, + "p95_ms": 103.886286, + "samples_ms": [ + 86.808741, + 92.30975199999999, + 86.838092, + 103.186924, + 103.886286, + 91.54509900000001 + ], + "target": "composite" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 111.079004, + "p95_ms": 130.32487700000001, + "samples_ms": [ + 108.36332399999999, + 117.460119, + 108.041192, + 108.099822, + 130.32487700000001, + 111.079004 + ], + "target": "single" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 227.299497, + "p95_ms": 260.002012, + "samples_ms": [ + 215.826514, + 227.299497, + 230.71226000000001, + 223.567123, + 260.002012, + 219.260437 + ], + "target": "subset" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 77.51628500000001, + "p95_ms": 83.06073699999999, + "samples_ms": [ + 82.545715, + 77.41176499999999, + 83.06073699999999, + 77.51628500000001, + 75.15427700000001, + 76.46373200000001 + ], + "target": "composite" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 86.82109200000001, + "p95_ms": 87.809835, + "samples_ms": [ + 86.82109200000001, + 84.69463400000001, + 87.255302, + 87.809835, + 86.56438, + 86.200919 + ], + "target": "single" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 172.334327, + "p95_ms": 183.64984, + "samples_ms": [ + 183.64984, + 172.334327, + 178.95308300000002, + 165.786143, + 167.396128, + 167.060507 + ], + "target": "subset" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 204.830161, + "p95_ms": 214.53772800000002, + "samples_ms": [ + 202.299382, + 200.550995, + 214.53772800000002, + 197.952695, + 204.830161, + 210.525773 + ], + "target": "composite" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 339.89961600000004, + "p95_ms": 341.07395, + "samples_ms": [ + 339.89961600000004, + 341.07395, + 337.855228, + 340.77491, + 337.87565800000004, + 334.27040400000004 + ], + "target": "single" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 313.88154699999996, + "p95_ms": 325.827083, + "samples_ms": [ + 311.396168, + 325.827083, + 310.189942, + 313.88154699999996, + 321.27623500000004, + 310.929695 + ], + "target": "subset" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 4.902278000000001, + "p95_ms": 5.093019, + "samples_ms": [ + 4.902278000000001, + 4.649497, + 4.6055779999999995, + 5.010279000000001, + 5.093019, + 4.556657 + ], + "target": "composite" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 7.162267, + "p95_ms": 7.468198, + "samples_ms": [ + 7.162267, + 7.134457, + 7.468198, + 7.231518, + 6.985316, + 7.067867000000001 + ], + "target": "single" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 6.689736, + "p95_ms": 6.981197, + "samples_ms": [ + 6.647906, + 6.681775, + 6.691655, + 6.689736, + 6.591145, + 6.981197 + ], + "target": "subset" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 4.413457, + "p95_ms": 4.533498000000001, + "samples_ms": [ + 4.372346, + 4.413457, + 4.393206999999999, + 4.363787, + 4.449597, + 4.533498000000001 + ], + "target": "composite" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 7.107588, + "p95_ms": 7.224407, + "samples_ms": [ + 7.081416, + 7.081996999999999, + 7.059476999999999, + 7.107588, + 7.193017, + 7.224407 + ], + "target": "single" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 6.724545, + "p95_ms": 6.789786, + "samples_ms": [ + 6.789786, + 6.708316, + 6.620475, + 6.724545, + 6.720415, + 6.7708960000000005 + ], + "target": "subset" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 0.335841, + "p95_ms": 0.33765100000000003, + "samples_ms": [ + 0.33765100000000003, + 0.329222, + 0.335841, + 0.33032100000000003, + 0.337552, + 0.32372199999999995 + ], + "target": "composite" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 1.715687, + "p95_ms": 1.7770970000000001, + "samples_ms": [ + 1.703867, + 1.7023570000000001, + 1.747037, + 1.7770970000000001, + 1.715687, + 1.715097 + ], + "target": "single" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 1.501496, + "p95_ms": 1.517476, + "samples_ms": [ + 1.507226, + 1.517476, + 1.501496, + 1.499485, + 1.495976, + 1.498835 + ], + "target": "subset" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 0.31121099999999996, + "p95_ms": 0.316181, + "samples_ms": [ + 0.309821, + 0.31121099999999996, + 0.316181, + 0.307672, + 0.30920200000000003, + 0.31137200000000004 + ], + "target": "composite" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 1.735036, + "p95_ms": 1.772937, + "samples_ms": [ + 1.732827, + 1.696477, + 1.709337, + 1.746277, + 1.735036, + 1.772937 + ], + "target": "single" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 1.475336, + "p95_ms": 1.491745, + "samples_ms": [ + 1.449175, + 1.475336, + 1.471516, + 1.476065, + 1.4739760000000002, + 1.491745 + ], + "target": "subset" + } + ], + "selection": null, + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/candidate-clipboard-before-fused-original.json b/docs/benchmarks/multi-channel-editing/candidate-clipboard-before-fused-original.json new file mode 100644 index 0000000000..259bd4d0f2 --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/candidate-clipboard-before-fused-original.json @@ -0,0 +1,43 @@ +{ + "clipboard_pixel": true, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "edit.paste", + "p50_ms": 0.44659099999999996, + "p95_ms": 0.448761, + "samples_ms": [ + 0.44659099999999996, + 0.44063199999999997, + 0.448761, + 0.447612, + 0.438182, + 0.432592 + ], + "target": "single" + }, + { + "case": 0, + "command": "edit.paste", + "p50_ms": 0.9689730000000001, + "p95_ms": 0.9963140000000001, + "samples_ms": [ + 0.9963140000000001, + 0.991903, + 0.959443, + 0.9200630000000001, + 0.9689730000000001, + 0.896964 + ], + "target": "subset" + } + ], + "selection": null, + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/candidate-clipboard.json b/docs/benchmarks/multi-channel-editing/candidate-clipboard.json new file mode 100644 index 0000000000..7de0675003 --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/candidate-clipboard.json @@ -0,0 +1,43 @@ +{ + "clipboard_pixel": true, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "edit.paste", + "p50_ms": 0.458816, + "p95_ms": 0.478796, + "samples_ms": [ + 0.478796, + 0.458596, + 0.458816, + 0.454116, + 0.458136, + 0.46384600000000004 + ], + "target": "single" + }, + { + "case": 0, + "command": "edit.paste", + "p50_ms": 1.003902, + "p95_ms": 1.017852, + "samples_ms": [ + 0.982531, + 1.012902, + 1.017852, + 1.003902, + 0.924632, + 0.954002 + ], + "target": "subset" + } + ], + "selection": null, + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/candidate-region-before-fused-original.json b/docs/benchmarks/multi-channel-editing/candidate-region-before-fused-original.json new file mode 100644 index 0000000000..9cb9bf18f8 --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/candidate-region-before-fused-original.json @@ -0,0 +1,243 @@ +{ + "clipboard_pixel": false, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 77.201194, + "p95_ms": 82.453574, + "samples_ms": [ + 82.453574, + 77.201194, + 74.372303, + 78.28338699999999, + 74.074202, + 74.235352 + ], + "target": "composite" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 1.067503, + "p95_ms": 1.159604, + "samples_ms": [ + 0.974773, + 1.067503, + 1.0415139999999998, + 0.987094, + 1.159604, + 1.129815 + ], + "target": "single" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 1.245524, + "p95_ms": 1.378636, + "samples_ms": [ + 1.243065, + 1.245524, + 1.176115, + 1.288165, + 1.378636, + 1.200104 + ], + "target": "subset" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 95.854955, + "p95_ms": 97.876743, + "samples_ms": [ + 95.854955, + 97.876743, + 93.10746400000001, + 97.22975000000001, + 93.045934, + 94.002798 + ], + "target": "composite" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 1.064084, + "p95_ms": 1.187494, + "samples_ms": [ + 0.829623, + 1.173594, + 1.187494, + 0.799923, + 1.064084, + 1.060904 + ], + "target": "single" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 1.246965, + "p95_ms": 1.402196, + "samples_ms": [ + 1.310696, + 1.402196, + 1.085405, + 1.226375, + 1.246965, + 1.161794 + ], + "target": "subset" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 103.756235, + "p95_ms": 111.544604, + "samples_ms": [ + 101.495116, + 103.756235, + 111.544604, + 105.803453, + 102.95610199999999, + 101.978809 + ], + "target": "composite" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 1.426045, + "p95_ms": 1.610526, + "samples_ms": [ + 1.228124, + 1.4493150000000001, + 1.400706, + 1.610526, + 1.3924759999999998, + 1.426045 + ], + "target": "single" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 1.838737, + "p95_ms": 1.9343780000000002, + "samples_ms": [ + 1.838737, + 1.8892570000000002, + 1.825287, + 1.8031970000000002, + 1.9343780000000002, + 1.823447 + ], + "target": "subset" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 93.317075, + "p95_ms": 99.043097, + "samples_ms": [ + 91.604459, + 92.460962, + 99.043097, + 93.254725, + 93.317075, + 93.950557 + ], + "target": "composite" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 1.171014, + "p95_ms": 1.285904, + "samples_ms": [ + 1.100934, + 1.184594, + 1.285904, + 1.0143039999999999, + 1.171014, + 1.164024 + ], + "target": "single" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 1.4515559999999998, + "p95_ms": 1.8267870000000002, + "samples_ms": [ + 1.193784, + 1.438136, + 1.453886, + 1.246384, + 1.4515559999999998, + 1.8267870000000002 + ], + "target": "subset" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 0.597332, + "p95_ms": 0.7034619999999999, + "samples_ms": [ + 0.7034619999999999, + 0.676912, + 0.553232, + 0.597332, + 0.548492, + 0.5534319999999999 + ], + "target": "composite" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 0.977864, + "p95_ms": 1.189995, + "samples_ms": [ + 0.943864, + 0.977864, + 0.906234, + 1.189995, + 0.928453, + 1.1691049999999998 + ], + "target": "single" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 0.9821439999999999, + "p95_ms": 1.1049440000000001, + "samples_ms": [ + 0.892043, + 1.1049440000000001, + 0.8708929999999999, + 0.9821439999999999, + 0.8869140000000001, + 1.032884 + ], + "target": "subset" + } + ], + "selection": [ + 2000, + 1500, + 16, + 16 + ], + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/candidate-region.json b/docs/benchmarks/multi-channel-editing/candidate-region.json new file mode 100644 index 0000000000..3a468f7b25 --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/candidate-region.json @@ -0,0 +1,243 @@ +{ + "clipboard_pixel": false, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 72.961086, + "p95_ms": 78.093633, + "samples_ms": [ + 78.093633, + 72.367661, + 72.534419, + 72.961086, + 72.10088300000001, + 75.37319600000001 + ], + "target": "composite" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 0.947402, + "p95_ms": 1.1260109999999999, + "samples_ms": [ + 0.826373, + 0.946382, + 0.947402, + 0.816803, + 1.1260109999999999, + 1.0366609999999998 + ], + "target": "single" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 0.9814520000000001, + "p95_ms": 1.077291, + "samples_ms": [ + 0.687814, + 0.9643619999999999, + 1.077291, + 0.667484, + 1.0550309999999998, + 0.9814520000000001 + ], + "target": "subset" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 94.89197100000001, + "p95_ms": 97.00682400000001, + "samples_ms": [ + 92.866738, + 97.00682400000001, + 94.89197100000001, + 95.608316, + 89.520437, + 90.866975 + ], + "target": "composite" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 0.966832, + "p95_ms": 0.9917509999999999, + "samples_ms": [ + 0.723414, + 0.964212, + 0.9917509999999999, + 0.787343, + 0.9728920000000001, + 0.966832 + ], + "target": "single" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 0.9793320000000001, + "p95_ms": 1.08252, + "samples_ms": [ + 0.667474, + 1.08252, + 1.003442, + 0.708024, + 0.9793320000000001, + 0.902782 + ], + "target": "subset" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 115.12890200000001, + "p95_ms": 149.352494, + "samples_ms": [ + 109.92012600000001, + 113.59251400000001, + 105.83353, + 115.12890200000001, + 121.061362, + 149.352494 + ], + "target": "composite" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 1.331709, + "p95_ms": 1.634796, + "samples_ms": [ + 1.1860600000000001, + 1.307489, + 1.392789, + 1.232429, + 1.331709, + 1.634796 + ], + "target": "single" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 1.613536, + "p95_ms": 2.082383, + "samples_ms": [ + 1.399228, + 1.613536, + 1.5626870000000002, + 1.4206379999999998, + 1.9183839999999999, + 2.082383 + ], + "target": "subset" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 94.877212, + "p95_ms": 97.655499, + "samples_ms": [ + 88.713004, + 96.28551, + 97.655499, + 94.877212, + 94.027929, + 89.49229799999999 + ], + "target": "composite" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 1.11832, + "p95_ms": 1.384419, + "samples_ms": [ + 0.8631329999999999, + 1.384419, + 1.11832, + 0.892473, + 1.0668710000000001, + 1.16497 + ], + "target": "single" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 1.1515309999999999, + "p95_ms": 1.19203, + "samples_ms": [ + 0.8590829999999999, + 1.1515309999999999, + 1.16529, + 0.814253, + 1.19203, + 1.080471 + ], + "target": "subset" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 0.600195, + "p95_ms": 0.6391049999999999, + "samples_ms": [ + 0.563605, + 0.529145, + 0.584226, + 0.604845, + 0.6391049999999999, + 0.600195 + ], + "target": "composite" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 1.0301310000000001, + "p95_ms": 1.0916109999999999, + "samples_ms": [ + 1.038532, + 0.945762, + 1.0916109999999999, + 1.0047409999999999, + 1.0301310000000001, + 0.9601320000000001 + ], + "target": "single" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 0.915743, + "p95_ms": 0.9945519999999999, + "samples_ms": [ + 0.906413, + 0.8346830000000001, + 0.984532, + 0.915743, + 0.890042, + 0.9945519999999999 + ], + "target": "subset" + } + ], + "selection": [ + 2000, + 1500, + 16, + 16 + ], + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/candidate.json b/docs/benchmarks/multi-channel-editing/candidate.json new file mode 100644 index 0000000000..32474ee30a --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/candidate.json @@ -0,0 +1,418 @@ +{ + "clipboard_pixel": false, + "depth": 16, + "repeats": 6, + "rows": [ + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 75.22649700000001, + "p95_ms": 76.751695, + "samples_ms": [ + 75.22649700000001, + 75.591634, + 71.801266, + 71.019002, + 73.349893, + 76.751695 + ], + "target": "composite" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 21.782447, + "p95_ms": 22.927447, + "samples_ms": [ + 21.782447, + 21.655068, + 20.720476, + 22.303791999999998, + 22.927447, + 21.319791000000002 + ], + "target": "single" + }, + { + "case": 0, + "command": "image.adjustments.invert", + "p50_ms": 26.369258, + "p95_ms": 28.975326, + "samples_ms": [ + 25.504526, + 28.975326, + 25.888163, + 27.172911000000003, + 26.369258, + 25.960582 + ], + "target": "subset" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 94.930951, + "p95_ms": 98.948667, + "samples_ms": [ + 94.251716, + 96.28029000000001, + 94.930951, + 98.948667, + 90.132662, + 92.804759 + ], + "target": "composite" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 24.066668, + "p95_ms": 25.039439, + "samples_ms": [ + 21.078023, + 25.039439, + 24.515804, + 24.066668, + 22.716579, + 22.056214 + ], + "target": "single" + }, + { + "case": 1, + "command": "image.adjustments.threshold", + "p50_ms": 29.769648999999998, + "p95_ms": 31.033169, + "samples_ms": [ + 26.245089, + 28.550649, + 29.769648999999998, + 31.033169, + 29.939578, + 26.370188 + ], + "target": "subset" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 102.884725, + "p95_ms": 104.72050800000001, + "samples_ms": [ + 104.491921, + 100.574794, + 100.648264, + 104.72050800000001, + 100.569144, + 102.884725 + ], + "target": "composite" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 23.440443, + "p95_ms": 24.236386, + "samples_ms": [ + 23.021156, + 21.574828, + 23.440443, + 23.363483000000002, + 24.236386, + 23.8072 + ], + "target": "single" + }, + { + "case": 2, + "command": "image.adjustments.levels", + "p50_ms": 29.057096, + "p95_ms": 29.516322000000002, + "samples_ms": [ + 29.313973999999998, + 27.001372, + 28.987577, + 29.057096, + 29.516322000000002, + 28.881407000000003 + ], + "target": "subset" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 91.623449, + "p95_ms": 92.885219, + "samples_ms": [ + 91.781958, + 92.885219, + 89.899204, + 89.555856, + 88.28092699999999, + 91.623449 + ], + "target": "composite" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 22.675949, + "p95_ms": 23.770948999999998, + "samples_ms": [ + 23.770948999999998, + 22.675949, + 23.217585, + 20.509707, + 21.517367999999998, + 21.916835 + ], + "target": "single" + }, + { + "case": 3, + "command": "image.adjustments.curves", + "p50_ms": 28.806977, + "p95_ms": 32.858163000000005, + "samples_ms": [ + 28.588779000000002, + 32.858163000000005, + 29.334413, + 27.24627, + 28.806977, + 25.812552999999998 + ], + "target": "subset" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 184.70721600000002, + "p95_ms": 186.907257, + "samples_ms": [ + 186.907257, + 184.96219299999998, + 184.70721600000002, + 179.042925, + 179.55811999999997, + 182.74069300000002 + ], + "target": "composite" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 320.047598, + "p95_ms": 321.437786, + "samples_ms": [ + 313.103935, + 320.098657, + 320.047598, + 313.304264, + 318.865948, + 321.437786 + ], + "target": "single" + }, + { + "case": 4, + "command": "filter.blur.gaussianBlur", + "p50_ms": 288.818921, + "p95_ms": 293.22966299999996, + "samples_ms": [ + 287.980297, + 293.22966299999996, + 290.836293, + 285.886504, + 288.818921, + 287.239573 + ], + "target": "subset" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 4.6112210000000005, + "p95_ms": 4.6583309999999996, + "samples_ms": [ + 4.6112210000000005, + 4.610892, + 4.570152, + 4.6583309999999996, + 4.616752, + 4.522093 + ], + "target": "composite" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 7.0430600000000005, + "p95_ms": 7.091, + "samples_ms": [ + 6.999151, + 7.091, + 7.06035, + 6.942612, + 6.957071, + 7.0430600000000005 + ], + "target": "single" + }, + { + "case": 5, + "command": "paint.stroke", + "p50_ms": 6.546024999999999, + "p95_ms": 6.636444, + "samples_ms": [ + 6.465876, + 6.441466, + 6.522285, + 6.636444, + 6.582515, + 6.546024999999999 + ], + "target": "subset" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 4.362043000000001, + "p95_ms": 4.447753, + "samples_ms": [ + 4.362043000000001, + 4.344873, + 4.353173, + 4.345474, + 4.392252999999999, + 4.447753 + ], + "target": "composite" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 7.04852, + "p95_ms": 7.1616789999999995, + "samples_ms": [ + 7.09586, + 6.974471, + 7.011411000000001, + 7.04852, + 7.1616789999999995, + 7.003350999999999 + ], + "target": "single" + }, + { + "case": 6, + "command": "paint.stroke", + "p50_ms": 6.485336, + "p95_ms": 6.582695, + "samples_ms": [ + 6.582695, + 6.499136, + 6.483495, + 6.4517359999999995, + 6.485336, + 6.451656 + ], + "target": "subset" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 0.328727, + "p95_ms": 0.336797, + "samples_ms": [ + 0.334057, + 0.326868, + 0.336797, + 0.328727, + 0.327278, + 0.327437 + ], + "target": "composite" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 1.692256, + "p95_ms": 1.715116, + "samples_ms": [ + 1.715116, + 1.692256, + 1.687186, + 1.6878360000000001, + 1.672846, + 1.702096 + ], + "target": "single" + }, + { + "case": 7, + "command": "live.brush.extend", + "p50_ms": 1.408188, + "p95_ms": 1.437678, + "samples_ms": [ + 1.437678, + 1.402628, + 1.394068, + 1.408188, + 1.424778, + 1.398208 + ], + "target": "subset" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 0.304967, + "p95_ms": 0.307537, + "samples_ms": [ + 0.303247, + 0.30220699999999995, + 0.305088, + 0.30053799999999997, + 0.304967, + 0.307537 + ], + "target": "composite" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 1.697116, + "p95_ms": 1.7054259999999999, + "samples_ms": [ + 1.693115, + 1.695056, + 1.7054259999999999, + 1.697116, + 1.683406, + 1.703216 + ], + "target": "single" + }, + { + "case": 8, + "command": "live.eraser.extend", + "p50_ms": 1.410779, + "p95_ms": 1.420118, + "samples_ms": [ + 1.409278, + 1.387898, + 1.413808, + 1.410779, + 1.4054790000000001, + 1.420118 + ], + "target": "subset" + } + ], + "selection": null, + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/benchmarks/multi-channel-editing/environment.json b/docs/benchmarks/multi-channel-editing/environment.json new file mode 100644 index 0000000000..8d9c106976 --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/environment.json @@ -0,0 +1,45 @@ +{ + "date": "2026-10-25", + "cpu": "AMD Ryzen 7 9700X (8 cores / 16 threads)", + "compiler": "rustc 1.99.0", + "profile": "release (workspace defaults, thin LTO)", + "rayon_threads": 8, + "base_commit": "6719f1813e84e96129fc31676cc945d51be0e736", + "baseline_binary_sha256": "103b32f5aeeeef26e7554a1f8287a6b5bbeff32f7400280f4a7d34c3163a0f94", + "baseline_resources": "elapsed_seconds=14.81 peak_rss_kib=665344", + "candidate_binary_sha256": "c29daf0495d8e764e2c615f322bde6d8790b58a3a816f8cc6fdce57cec24bc62", + "candidate_resources": "elapsed_seconds=10.69 peak_rss_kib=665584", + "measurement_isolation": "Retained full-image, small-selection and one-pixel clipboard runs had no concurrent Rust compilation or engine/UI test processes. Interrupted runs were discarded and repeated.", + "baseline_harness_sha256": "d8503bfea031eb9da9689d2676c42f87190f201f12789e391621495ca2a1b289", + "candidate_harness_sha256": "28f1d433f20a72e97069521362899d0b6d724eed5788cb612eb70e20113d0ec7", + "candidate_region_resources": "elapsed_seconds=3.10 peak_rss_kib=438464", + "candidate_clipboard_resources": "elapsed_seconds=0.10 peak_rss_kib=214620", + "candidate_before_fused_binary_sha256": "91568e5215657c1127ab5cb330ebfadb6a1545687607b48c168370a2c5c8dc9c", + "candidate_before_fused_original_resources": "elapsed_seconds=17.22 peak_rss_kib=700760", + "candidate_source_commit": "aeff53449221e8b42a628d832abc595c9503a146", + "candidate_tone_source_sha256": "da42e686db09ed4e5bf2c19316a6b47261c97185aec83c6bba86831e29e39c6e", + "before_fused_rerun_resources": "elapsed_seconds=16.47 peak_rss_kib=690516", + "before_fused_commit": "15bf0873ba03622e54189e668df20a1403e41d56", + "liquify_preview": { + "source_content_commit": "009c4fa6128dad1e564d731656a304953d879842", + "build_git_head": "a37d21de7e1a90451d5afcad6772ae358f69e07a", + "binary_sha256": "309bc5e882403080ade9410103fba6eb73b296da897274a7816380f50e413c3a", + "files_sha256": { + "crates/ui-egui/examples/multi_channel_preview_bench.rs": "84f4e6fd4a0f79a35da706a06144401ad06509a06608ea5ed89729d394f6e0b1", + "crates/ui-egui/src/liquify_ui.rs": "1179cb7403fa189a8e0d1872212e150bd98b10844d189140e954cd0cdd279f29", + "crates/ui-egui/src/gallery_ui.rs": "118249270993f1443882df4808fd0f6e05b129a091441f3560bdcc0e4d80a4ca", + "crates/ui-egui/src/wide_angle_ui.rs": "6ebf9f1d291642dc3cc42c72980830f79e9f82c6d9b743c145e9048adddb1b36", + "crates/engine/src/color_channels.rs": "e6737a5dfb79a5324f08173d18cf271a539dddb386b9488249c150810537313e" + }, + "measured_at_utc": "2026-10-25T17:45:11.612486+00:00", + "profile": "release, thin LTO", + "rayon_threads": 8, + "measurement": "CPU field deformation plus dirty native proxy render. GPU texture upload/presentation excluded. One warmup plus six interleaved updates; p50 is upper middle; max_ms is max of six. open_ms is one sequential setup timing per mode, not a median.", + "resources": { + "elapsed_seconds": 0.72, + "peak_rss_kib": 389664 + }, + "isolation": "Measured after final tests, builds and cargo xtask perf --quick ended; no Cargo or rustc processes were running.", + "correctness_proof": "Each workspace reports a nonzero Liquify maxDisplacement after the measured updates." + } +} diff --git a/docs/benchmarks/multi-channel-editing/liquify-preview.json b/docs/benchmarks/multi-channel-editing/liquify-preview.json new file mode 100644 index 0000000000..27c2ba338e --- /dev/null +++ b/docs/benchmarks/multi-channel-editing/liquify-preview.json @@ -0,0 +1,54 @@ +{ + "depth": 16, + "operation": "Liquify 120px live move; native proxy preview", + "repeats": 6, + "rows": [ + { + "max_ms": 0.120119, + "open_ms": 22.648719, + "p50_ms": 0.110929, + "samples_ms": [ + 0.111069, + 0.097649, + 0.10152, + 0.120119, + 0.10755999999999999, + 0.110929 + ], + "target": "all" + }, + { + "max_ms": 0.36013799999999996, + "open_ms": 246.61311, + "p50_ms": 0.34547799999999995, + "samples_ms": [ + 0.310718, + 0.30684799999999995, + 0.320127, + 0.352098, + 0.34547799999999995, + 0.36013799999999996 + ], + "target": "red" + }, + { + "max_ms": 0.461107, + "open_ms": 317.70011900000003, + "p50_ms": 0.447017, + "samples_ms": [ + 0.397908, + 0.394428, + 0.416098, + 0.448617, + 0.447017, + 0.461107 + ], + "target": "red_blue" + } + ], + "size": [ + 6000, + 4000 + ], + "warmups": 1 +} diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 23d0b422c3..d2f9b3d060 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-25 · **Change:** minor (Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; 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 @@ -76,6 +76,7 @@ tablets against Photoshop side by side. | ⇧-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 | | Clear painting mode in the Brush and Pencil Mode menus | done | `paint_mode_dropdown` in `panels.rs`, `"mode": "clear"`, #3439 | +| Shift+right-click opens the painting tool's menu: Edit Brush… and its own Mode list (Brush/Pencil painting modes with Behind and Clear, stamps and History Brush blend modes, Eraser Brush/Pencil/Block, Dodge/Burn Range, Sponge mode, Healing Brush, Gradient and Paint Bucket modes); Behind and Clear greyed on the Background; Alt and Command/Ctrl keep their existing gestures | partial | #4929, `brush_mode_menu.rs`; rows set the options-bar state (`tools.setBrush` or the tool option); kittest and control regressions. Missing because the options bar lacks the setting: Behind for the stamps, History Brush, Gradient and Paint Bucket; the Blur/Sharpen/Smudge modes; the Spot Healing modes (no menu); the Healing Brush's Sampled/Pattern source and Replace; the History Brush source | | Right-click opens the Brush Preset picker | done | [`context-menu-parity.md`](context-menu-parity.md); XP-Pen right-click not (#1980), macOS issue #2360 | | Precise / normal / full-size tip cursors, crosshair in the tip | partial | Caps Lock precise cursor not audited; round Pencil cursor requested (#2662, #1057) | | Brush choice kept per tool (Brush, Eraser, Blur each their own) | partial | #2542 | @@ -130,7 +131,10 @@ tablets against Photoshop side by side. | 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 | Brush-only Shift+right-click Mode menu shares options-bar choices, including Clear; preserves existing right-click gestures and consumes outside dismissal without painting (#4929). 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-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. 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. | +| 2026-10-23 | minor | Escape also consumes a held right-button release after a rapid second press, so the Brush Mode picker stays dismissed without painting or changing history (#4929). | +| 2026-10-30 | minor | Shift+right-click works for every painting tool as in the reference application: Edit Brush… opens the Brush Preset picker, then the tool's own Mode list from its options bar; Behind and Clear greyed on the Background. Row marked partial for the settings the options bar lacks. No brush-feel estimate changed. | diff --git a/docs/color-picker.md b/docs/color-picker.md index a30311c4be..1a6a74066b 100644 --- a/docs/color-picker.md +++ b/docs/color-picker.md @@ -1,6 +1,12 @@ # Color selection and screen sampling -Click the color swatch in the Type options bar or Character panel to open the full Color Picker. It offers the color field, component strip, HSB/RGB/Lab/CMYK and hex fields, and current/new swatches. Selecting characters targets that range; with no character selection, the active text layer is targeted. **OK** applies through `type.setStyle`; **Cancel** leaves the text and history unchanged. The foreground color is independent of this dialog. +Click the color swatch in the Type options bar or Character panel to open the full Color Picker. It offers the color field, component strip, HSB/RGB/Lab/CMYK and hex fields, and current/new swatches. Selecting characters targets that range; with no character selection, the active text layer is targeted. While the picker is open, the canvas shows the target in the picked colour, as Photoshop does: the preview runs `type.setStyle` on a copy of the document, so the document, its revision and its history are untouched. **OK** applies through `type.setStyle`, exactly the previewed edit, in one history step; **Cancel** leaves the text and history unchanged. The foreground color is independent of this dialog. + +The Color Picker for a gradient fill layer's colour stop (double-click a stop in Properties or on the canvas) previews the same way, through `gradient.fill.stop`. The gradient presets editor's stop picker changes presets, not pixels, and has no canvas preview. + +## HUD Color Picker + +With a painting tool, **Shift+Alt+right-click** on the canvas opens the HUD Color Picker: a saturation/value square with a hue strip to its right, placed so the current color sits under the pointer. While the right button stays down, moving the pointer over the HUD sets the foreground color live; releasing the button keeps it. **Esc** closes the HUD and keeps the color picked so far (Undo takes it back). The whole gesture is one coalesced `tools.setColors` history step. The agents' equivalent is `ui.pointer` with `"button": "right"`, `"shift": true, "alt": true` (the down opens the HUD, the up closes it). The existing canvas eyedropper continues to sample the document while the Color Picker is open, including canvas zoom and pan. This works without operating-system screen-capture permission. diff --git a/docs/color-range-performance.md b/docs/color-range-performance.md new file mode 100644 index 0000000000..939673e1c1 --- /dev/null +++ b/docs/color-range-performance.md @@ -0,0 +1,42 @@ +# Color Range selection sampling + +`select.colorRange` in Replace mode refines an existing selection. Its sampled-colour, +hue, skin-tone and tonal queries render/read only `selection.content_bounds()` clipped +to the canvas. RGBA buffers, Lab conversion, coverage, selection clipping and replacement +use that same rectangle. Invert happens before clipping to the old coverage, including +soft edges and holes. + +Eyedropper points and subtract points still sample anywhere on the original canvas. +Localized Color Clusters retain the full canvas's range and sample positions; the +coverage query receives the sampled rectangle's origin. Without a selection, or with +Add, Subtract or Intersect, sampling covers the full canvas. Nonzero implicit selection +defaults and malformed tile coordinates also use the full canvas. An explicitly empty +selection produces an empty result while still validating query parameters. + +The Out of Gamut preset uses the existing proof gamut-mask path and is not cropped. +Interactive preview resolution and scheduling are unchanged. + +On a 6000×4000 canvas, the RGBA f32 buffer normally contains 24,000,000 pixels +(384,000,000 bytes). A 50×50 selection uses 2,500 pixels (40,000 bytes); a 600×400 +selection uses 240,000 pixels (3,840,000 bytes). Lab input/output and mask buffers +shrink by the same area ratio. These are buffer sizes, not measured peak RSS. + +## Reproduce the comparison + +On a CI runner, run: + +```sh +RAYON_NUM_THREADS=4 cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture +``` + +Or dispatch `ci.yml` with `color_range=true`. It runs the Linux release comparison, +full normal CI, and the general quick performance suite on macOS. The release fixture +is 24 MP with two colour regions and localized eyedropper sampling. It compares the +full-canvas reference with the cropped command for 50×50, 600×400, 1200×800 and +full-canvas soft selections. Each has a warm-up pair and three alternating timed +pairs; reported timings are medians. Both include the undoable selection edit, and the +cropped path includes command dispatch. Every stored coverage sample is compared over +the whole canvas outside timing. No GUI frame latency is measured. + +This optimization does not change an existing scorecard scenario or baseline. The +request's P5 label is unrelated to scorecard P5 (layer visibility). diff --git a/docs/contributing.md b/docs/contributing.md index ac4eb41e34..aa581b0a02 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,7 +2,7 @@ - **Language:** Rust only. No JavaScript or TypeScript. On the web, `wasm-bindgen` generates a small loader; never hand-write JS. - **Licence:** contributions are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). -- **Clean-room:** do not copy code, shaders, icons, ICC profiles or other assets from proprietary software (such as Photoshop). Match behaviour and look by observation and public specs. Only use assets with permissive licences, keep their license next to the asset (e.g. `assets/fonts/OFL-*.txt`, `assets/icons/LICENSE-lucide.txt`), and list every asset in `ATTRIBUTION.md`. **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: add new fonts there, and build with them through the optional `CRAFT_FONTS_DIR` build input (`docs/development.md` › Fonts; rules in `../craftrules/standards/fonts.md`, a sibling checkout — `storytold/craftrules` is not public, so ask a maintainer for the rules relevant to your change). +- **Clean-room:** do not copy code, shaders, icons, ICC profiles or other assets from proprietary software (such as Photoshop). Match behaviour and look by observation and public specs. Only use assets with permissive licences, keep their license next to the asset (e.g. `assets/fonts/OFL-*.txt`, `assets/icons/LICENSE-lucide.txt`), and list every asset in `ATTRIBUTION.md`. **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: add new fonts there, and build with them through the optional `CRAFT_FONTS_DIR` build input (`docs/development.md` › Fonts). - **Never crash:** non-test code must not panic: no `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`. Errors go through `Result` and `?`, input-derived indices and sizes are checked, and every crash fix ships with a regression test. See *Never crash* in `AGENTS.md`. - **Commands, not handlers:** new features are engine commands with tests, and the UI calls them (checklist below). - **Layering:** `cargo xtask layers` must pass. Register new crates in `xtask/src/layers.rs`. diff --git a/docs/contributors.md b/docs/contributors.md index a6e162521c..ab1df7922b 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to PhotoCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -38,20 +36,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c286693c2..7e71f2233b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,7 +46,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement; the `result` is a JSON array of command objects (see [Command list replies](#command-list-replies)) -- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`). `perf.renderPath` says why the active document is drawn on the CPU: `current` is `null` on the GPU, else `{doc, kind, detail}` (`kind` is one of `overBudget`, `canvasTooLarge`, `compositorBuild`, `deviceLost`, `gpuError`, `unsupported`, `adjustPreview`, `viewTransform`, `softwareAdapter`, `cpuMode`; `detail` is the English reason, with the working set and budget for `overBudget`), and `history` holds the last 32 changes, oldest first (`{t, doc, kind, detail}`, `kind` `null` for back on the GPU, `doc` the changed document's id, or 0 for a change to every document). An adjustment dialog's CPU proxy preview has its own slot: `current` reports it (`adjustPreview`) only when neither the window nor the canvas has a reason, it isn't recorded while the whole window is off the GPU, and its history entries always have `kind` `adjustPreview`, with `detail` `null` when the preview stops using the proxy (never read as back on the GPU). `perf.timings.gpuFallback` is `current`'s `detail` as text +- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`). `perf.renderPath` says why the active document is drawn on the CPU: `current` is `null` on the GPU, else `{doc, kind, detail}` (`kind` is one of `overBudget`, `canvasTooLarge`, `compositorBuild`, `deviceLost`, `gpuError`, `unsupported`, `adjustPreview`, `viewTransform`, `softwareAdapter`, `cpuMode`; `detail` is the English reason, with the working set and budget for `overBudget`), and `history` holds the last 32 changes, oldest first (`{t, doc, kind, detail}`, `kind` `null` for back on the GPU, `doc` the changed document's id, or 0 for a change to every document). An adjustment dialog's CPU proxy preview has its own slot: `current` reports it (`adjustPreview`) only when neither the window nor the canvas has a reason, it isn't recorded while the whole window is off the GPU, and its history entries always have `kind` `adjustPreview`, with `detail` `null` when the preview stops using the proxy (never read as back on the GPU). `perf.timings.gpuFallback` is `current`'s `detail` as text. `perf.slowFrames` lists the last 64 frames over 50 ms, oldest first, each with its stages in ms: `t` (start), `frameMs`, `inputMs` (raw input to the UI build, commands excluded), `commandMs` and `command` (the last command run since the previous frame), `damageMs` (damage tracking and GPU memory budgeting), `compositeMs`, `uploadMs`, `gpuMs` (the GPU's own composite time, or `null`), `presentMs` (the previous frame's tessellation and submission, or `null`), `refresh` and `refreshPx` (the canvas redraw's kind and pixels) and `path` (`gpu` or `cpu`). It stays empty on the web, which has no clock - `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background?, mode?, ramp?, webSafeRamp?}`, merged into the Color panel's state: `background` is whether it edits the background colour, `mode` its panel-menu display (`hueCube`, `brightnessCube`, `colorWheel`, `grayscaleSlider`, `rgbSliders`, `hsbSliders`, `cmykSliders`, `labSliders` or `webColorSliders`), `ramp` the colour ramp under the sliders (`rgbSpectrum`, `cmykSpectrum`, `grayscaleRamp` or `currentColors`) and `webSafeRamp` Make Ramp Web Safe; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again; `filterMaskTarget` targets the active smart object's filter mask (an error without smart filters), so painting, fills, filters and adjustments edit it (`"target":"filterMask"`) while Move and Edit › Transform still act on the smart object) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}`. Camera Raw and the full-window workspaces (Liquify, Content-Aware Fill, Filter Gallery, Adaptive Wide Angle) are modal: while one is open, `ui.menu.invoke` refuses every other menu item ("menu command is unavailable while a modal dialog is open") and accepts only that workspace's own `{"ui": {...}}` requests (`filter.liquify`, `edit.contentAwareFill`, `filter.filterGallery`, `filter.adaptiveWideAngle`, `filter.cameraRaw`; `params` must hold only the `ui` object), so finish it with `{"ui":{"commit":true}}` or `{"ui":{"cancel":true}}` first. Those requests are refused too while a dialog, an unsaved-changes prompt or a crop that blocks the command is open: resolve that first, then commit or cancel the workspace. `ui.menu.list` reports the same gate: an item the modal state refuses is listed with `enabled: false`. `engine.execute` is not a menu click and is not gated @@ -54,17 +54,20 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `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. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or `artboard` (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; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing. - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - 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. - 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 the Pen tools' options bar (#1397): `penMode` (`path` or `shape`) and `penPathOp`, the Path Operations menu (`newLayer`, `combine`, `subtract`, `intersect`, `exclude`): how the next Pen, Freeform Pen or Curvature Pen path joins the selected shape layer (Shape mode; `newLayer` makes a new one) or the targeted vector mask, selected path or Work Path (Path mode, where `newLayer` combines). `ui.inspect` reports them under `toolOptions` (`vector_mode`, `pen_path_op`). Merge Shape Components is the engine command `path.mergeShapeComponents` (work, saved or `layer` path) or `layer.combineShapes.mergeShapeComponents` (shape layers). - `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. - `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}` (`layer.new.group` / `layer.new.groupFromLayers` open the New Group dialog: fields `name`, `color`, `mode`, `opacity`), `newCropPreset` / `deleteCropPreset`: the Crop tool's New / Delete Crop Preset… dialogs, fields `name` and `selected: [names]`; crop presets are `crop` tool presets, so `tool.presets.new/list/edit {"tool":"crop"}` drive them too) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once - A Layer Style color field opens the shared picker with `ui.dialog.open {"kind":"colorPicker","parent":12,"effect":"fx2","field":"color"}`. `parent` is the Layer Style dialog id; `effect` is its effect instance id. `field` must be one of that instance's color parameters (`color`, or `from`/`to` for Gradient Overlay). The picker previews over the pending style without changing the document. Picker OK changes only the parent parameter; Cancel discards the preview. Confirm the parent afterwards to apply the style. Parent confirmation is refused while its picker is open; parent cancellation closes both. The binding is exposed in `ui.inspect` as `__layerStyleColor`; changed/closed targets or a document switch invalidate it. + + - Gradient Overlay color stops use `colorPicker {parent, effect, field: "stops", stop: index}`; the stop must exist. - `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. @@ -72,16 +75,19 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - 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` - Gradient Overlay exposes `params.method` (`smooth`, `perceptual`, `linear`, `classic`). `custom` denotes an existing curve without a recorded method and preserves it. Choosing a method retains all recorded source colour stops. If no source record exists (including baked PSD imports), choosing a method rebuilds from the two end colours, matching legacy command behaviour; the old curve stays unchanged until that choice. Later endpoint edits rebuild the colour curve using the recorded method. The source stops, smoothness and method survive dialog reopening, undo/redo and native `.pcraft` saves. Saving a style preset through "New Style…" may lose the multi-stop source curve: it passes only params to `style.presets.new`, and `effect_from_params` rebuilds the effect from the default gradient. PSD export continues to write the baked appearance; reopening that PSD shows Custom because editable interpolation metadata is not exported yet. Opacity interpolation and the multi-stop editor are separate work. - 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. + - Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `hideView` (true shows the image without the view, as holding X does), `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …). - 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. 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 -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. + - With a painting tool, Shift+right-click (without Alt, Ctrl or Command) opens that tool's menu, as in the reference application, even when right-click painting is set to Erase: Edit Brush… (opens the Brush Preset picker at the click) and then the tool's own Mode list from its options bar. Brush and Pencil: Edit Brush…, the painting modes with Behind and Clear; Clone Stamp, Pattern Stamp, History Brush: Edit Brush…, the blend modes; Mixer Brush, Color Replacement, Background Eraser, Blur, Sharpen, Smudge: Edit Brush… only; Eraser: Brush / Pencil / Block; Dodge and Burn: Edit Brush…, Shadows / Midtones / Highlights; Sponge: Edit Brush…, Desaturate / Saturate; Healing Brush: its blend modes; Gradient: the blend modes; Paint Bucket: the blend modes with Clear. Other tools (Spot Healing Brush, Magic Eraser, non-painting tools) keep their plain right-click route. `ui.inspect.brushModeMenu` reports its screen position, `tool`, current `mode` (the checked row's label), `choices` (the row labels, in order) and `items` (`{label, enabled, checked}` or `{separator: true}`); Behind and Clear are disabled on the Background layer. Pick a row with `ui.click`, or with `ui.context.choose {id}` where `id` is the row's English label from `choices` (an unknown or disabled row is an error that changes nothing). A row sets what the tool's options-bar control sets: a brush-based tool's blend mode goes through `tools.setBrush`; the Eraser, Range, Sponge, Gradient and Paint Bucket rows set the tool option. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing + - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default - 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). @@ -104,7 +110,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i another document becomes active before the capture, the request fails with an error rather than saving the wrong document - `ui.focus`: bring the main window to the front -- `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.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?, tiffCompression?, tiffTransparency?, tiffPyramid?, tiffLayers?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; TIFF: `tiffCompression` `none`, `lzw` or `zip`, `tiffTransparency` and `tiffPyramid` bools; automation never shows JPEG or TIFF 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 beneath the canonical or configured root are accepted, while `..` 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 @@ -114,7 +120,7 @@ Change the UI language without restarting with `prefs.set`: are documented in [UI localisation](localization.md). `prefs.get` and the existing preference store expose and persist the same setting; scripts keep using canonical command IDs. -`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled (reply shape: [Command list replies](#command-list-replies)). Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). Examples: +`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled (reply shape: [Command list replies](#command-list-replies)). Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). An unknown command id or method name fails with up to three near matches, for example ``unknown command `engine.commands`; did you mean `command.list`?``; a protocol method sent through `engine.execute`, or an engine command id sent as a method, says which of the two it is. Examples: | Command | Params | |---|---| @@ -146,7 +152,7 @@ store expose and persist the same setting; scripts keep using canonical command | `actions.list` | `{}` → `{actions:[{name, steps}], recording}` (the name being recorded, or null) | | `actions.get` | `{"action": name or index}` → `{name, steps:[[id, params], …], disabled:[step, …]}`. `steps` is the shape `file.automate.batch` and droplets take; `disabled` lists the 0-based steps playback skips | | `actions.record` | `{"name":"Red"}` starts a new action (default name `Action N`). `{"action": name or index}` appends to one that exists | -| `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted) | +| `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted). For portable playback, an active `layer` id is dropped only when it is the sole selected layer and does not supply source content (`layer.new.frame`, `select.loadSelection`, `type.createSelection`, `edit.checkSpelling`; a Check Spelling step keeps only `allLayers`, as the reference application's step does). Otherwise, including when several layers are selected, it is recorded by its unique name; `layer.select` records `{"name":"Layer","occurrence":0}`. Type tool keystrokes on a new layer fold into its `type.create` step; edits on an existing text layer retain separate replacements so styled text between edited spans survives replay. For commands other than `layer.select`, ambiguous layer names keep the id and require a unique name for portable playback | | `actions.play` | `{"action": name or index, "from": step?}`. `from` and `failed.step` are 0-based. Returns `{action, ran, failed?:{step, id, error}}` and still returns ok when a step fails, so a partial run is reported. Leaves one history step per step that ran. Supports nested action calls up to 16 levels, rejecting cycles. While recording, playing another action appends one named `actions.play` step instead of its expanded commands. On an untrusted session each step is authorized the same way as a top-level command | | `actions.move` | `{"action": name or index, "step": index?, "to": index}` moves a step within its action, or moves the whole action when `step` is omitted. `to` is the final zero-based index. Pending steps can be reordered while recording; invalid moves leave the list unchanged. | | `actions.delete` | `{"action": name or index, "step": index?}`. With a zero-based `step`: `{action, deletedStep, steps}`, also allowed while recording. Without `step`: `{deleted}`, refused while recording | @@ -155,6 +161,10 @@ store expose and persist the same setting; scripts keep using canonical command The Actions list fills the resizable dock group. +Newly recorded Layers eye gestures resolve their targets by layer name and duplicate-name +ordinal during playback rather than retaining a document-local layer id. A missing target +reports a failed step instead of changing another layer. + The Actions panel shows replayable steps immediately while recording. Click an individual step to select it, then use the trash button to remove that step; selecting the action heading instead targets the whole action. Step deletion also works during recording @@ -196,6 +206,18 @@ their usual file access. Source replacement, relinking and export commands remai double-clicking its thumbnail in Layers. Text input, Commit and Cancel use the existing Type tool editing session; non-type layers return an error without changing the tool or document. +### Selected colour channel sets + +`channel.target {"colors":[0,2]}` selects native colour components. Shift-click is +`channel.target {"channel":{"color":1},"extend":true}`: toggle that component, +starting from all colour components when the composite is selected. Masks and saved +channels cannot join a colour set. All components selected becomes the composite. +Channel inspection adds `selectedColors`; the legacy primary `target` remains. +Applicable drawing, adjustment, clipboard and filter commands follow the selected +set, or accept `"target":{"colors":[0,2]}` explicitly. Invalid or empty sets fail +without a document edit. Move/transform on partial sets and Camera Raw on a partial +set return an unsupported error. See [selected colour editing](multi-channel-editing.md). + ### Command list replies `engine.commands` (control channel and `serve`), the engine command `command.list` and the MCP @@ -228,7 +250,7 @@ The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). ### Background jobs (#210) -Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. +Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged; a folder Batch can already have written earlier output files. A cancelled Batch has `state: "cancelled"` with its partial `result` in `jobs.list`; a waiting control request receives `{"ok":false,"error":"cancelled","result":…}`, and an MCP call through the app bridge gets the error `cancelled; partial result: {…}`. A Batch already asked to stop, or past its last input, is listed with `reason: "finishing"`. A save that has started replacing its file can no longer be cancelled: `jobs.cancel` leaves it running and lists it in `notCancelled: [{job, reason: "committing"}]` (next to `cancelled`, the number of jobs it did cancel); the save then ends as done or failed like any other. Closing its document doesn't stop it either. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. ## Preferences @@ -287,6 +309,15 @@ Headless CLI/MCP sessions and the web build keep brush presets and the action li only, unless a store is attached. Gradient presets (including imported `.grd` groups) persist with the preferences. +`brush.presets.importAbr` and `gradient.presets.importGrd` never overwrite a group by default: a +file whose group name is already taken imports beside it as ` (2)`, ` (3)`…, and the +result's `group` names the one it landed in. Pass `"replace": true` to delete that group's own +presets first (built-in brushes are kept); the preset libraries are outside document history, so +this can't be undone (#10640). In the app, opening such a file (`app.open`, File › Open, +drag-and-drop, Import Brushes…) shows a prompt instead: `ui.dialog.confirm` imports as a new +group, `ui.dialog.set {"choice":"replace"}` then `ui.dialog.confirm` replaces, and +`ui.dialog.cancel` imports nothing. + Named New Document presets also persist with preferences (`presets.documents`) on desktop and web. File › New › Save Preset… saves the current form; the Saved tab selects a configuration or deletes it. Selection only fills the form: Create still runs `file.new`, and the document name @@ -409,16 +440,16 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | Method | Params | |---|---| | `engine.execute` | `{command, params?, wait?}`: any engine command (`wait: false` starts a long one as a background job: `{job, pending}`) | -| `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all. Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | +| `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all (`{cancelled: n}`, plus `notCancelled: [{job, reason}]` for jobs left running, e.g. a save already writing its file). Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | | `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`); `result` is a bare array of command objects ([Command list replies](#command-list-replies)) | | `session.list` | open documents and the active index | -| `doc.open` / `doc.new` | `{path}` / `file.new` params | -| `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path | +| `doc.open` / `doc.new` | `{path, approveHighMemory? (false)}` / `file.new` params (`approveHighMemory` is the same one-shot PSD/PSB consent as the `file.openAs` command; see [development.md](development.md) for the structured refusal fields) | +| `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), approveHighMemory? (false), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path. `approveHighMemory` is refused unless it is a boolean | | `doc.replaceSmartObject` | `{path, layer? (id or name; default the active layer), index?}` → the command's `{layer, instances}`: replaces a Smart Object's contents (one history step, transform and filters kept). `path` resolves beneath the read root, so this is the capability-scoped `layer.smartObjects.replaceContents`, which itself still refuses an ambient path (#8061) | | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | -| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tiffCompression?, tiffTransparency?, tiffPyramid?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tiffCompression` (`none`, `lzw`, `zip`), `tiffTransparency` (bool: Save Transparency; unset, kept unless the document has alpha or spot channels) and `tiffPyramid` (bool) are TIFF Options; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`; the save may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md), and the manifest's `full_composite` member names it when written). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | @@ -742,3 +773,7 @@ dock target. The panel menu and icon rail remain available in custom workspaces. `window.panel. {show:true}` always reveals, selects and expands the panel, including when another tab is selected. Workspace snapshots retain custom collapse settings and Timeline visibility. + +### Select the layer under the pointer + +Assign **Select Layer Under Cursor** in Keyboard Shortcuts to choose the topmost visible, pickable layer beneath the canvas pointer. Empty pixels and points outside the document leave the selection unchanged; dialogs, strokes and brush-resize gestures block it. The command follows zoom, pan, view rotation and horizontal flipping. Agents can invoke `ui.menu.invoke {"id":"layer.selectUnderCursor"}` after moving the screen pointer with `ui.move {"x": ..., "y": ...}`. The command has no default shortcut. diff --git a/docs/development.md b/docs/development.md index 41d74bae67..1a9258205a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,6 +36,8 @@ building locally; the downloaded app is still subject to Windows application-con ## Build and run +On Linux, PhotoCraft keeps its integrated client-side decorated title bar by default (#4439), including desktop-matched buttons. Enabling **System Title Bar** in Preferences switches to the system frame at the next launch: on GNOME/Wayland, winit provides font-free Adwaita controls; on X11, the window manager supplies the frame. Explicit saved choices are respected and Windows/macOS defaults are unchanged. + ```sh cargo run --release -p photocraft -- path/to/image.psd # desktop app cargo run --release -p photocraft -- --control 7878 --control-token-file .private/control.token \ @@ -64,6 +66,22 @@ 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`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); `PHOTOCRAFT_FULL_SWEEP=1 cargo test --workspace` stays the final check before a PR. +### Long matrix tests and CI timings + +Nextest's `SLOW` line at 60 seconds is a progress warning, not a sleep or a failed test. +The existing ten-minute hang limit and all GPU concurrency limits remain in force. +The PSD file/bytes import equivalence matrix and swept-block reference comparison each expose +four test functions so nextest can schedule their cases independently. Every formerly selected +case still runs once, with the same assertions. The extrude matrix retains the existing daily +sweep selection; splitting it does not sample fewer cases. PSD temporary directories include the +part number so ordinary `cargo test` can also run the parts concurrently in one process. + +For a like-for-like timing comparison, use the CI workflow's `full_sweep` manual input, or set +`PHOTOCRAFT_FULL_SWEEP=1` locally. Compare the complete nextest summary and runner wall time, +not only the longest individual test: partitioning adds scheduling opportunities, not less work. +Keep the same runner, feature set, fonts, and warm/cold build state. No speedup should be claimed +from changing the 60-second warning threshold or omitting tests. + ### Shortcut capture without a GPU, and native snapshots The ordinary `shortcut_capture_tests` check modifier capture, conflicts, restored bindings and @@ -97,7 +115,7 @@ Rust test and variable work on Windows with the native shell's environment-varia ## 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. +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). craft-fonts is an **optional build input**, never a Cargo dependency: @@ -137,7 +155,11 @@ The window itself draws with the same device (or, with the CPU canvas, one it wa ## 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. +Native pixel residency, RAM limits and scratch failure recovery are described in +[`scratch-disks.md`](scratch-disks.md). Control `ui.inspect` includes `scratch` counters and +the `memory` sample, effective allowances and current working reservations. + +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. A desktop start that fails before any window exists logs the error and, when no terminal, automation client or headless session can report it, shows it in a dialog naming this file (#7643). By default PhotoCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,photocraft_gpu=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`photocraft*=debug`). The logger is `apps/photocraft/src/logging.rs`; the web build logs to the browser console instead. @@ -158,7 +180,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | | `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) | | `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Linux: use the graphics adapter whose name contains this (case-insensitive); overrides `performance.gpuAdapter` for one launch | -| `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | +| `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback. Tests read it too: on Windows with an Intel GPU, run them with `WGPU_BACKEND=dx12`, since loading the Intel Vulkan driver crashes the test process with `STATUS_ACCESS_VIOLATION` (#4) | | `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`) | @@ -200,6 +222,8 @@ stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend and how frames are presented. To compare presentation settings without rebuilding, launch with `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` (see [Environment variables](#environment-variables)): with `novsync` the app no longer waits on vsync (`wait` near 0): each refresh shows the newest frame, and where the driver only offers Immediate, the screen may tear. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. +The trace has no clock of its own: it reads the per-frame stage record the app gathers every frame (`crates/ui-egui/src/frame_stages.rs`). The same record feeds the slow-frame ring: any frame over 50 ms is kept (the last 64, in `perf.slowFrames`) and logged at `info`, at most one line a second with a count of the slow frames in between, naming its input, command, damage, composite, upload, GPU and present times and the canvas redraw's kind and pixels. + ## Driving the app programmatically Start the app with a private token file. It then accepts authenticated JSON lines on `127.0.0.1:7878`: @@ -233,6 +257,17 @@ cargo run -p photocraft-cli -- commands --filter blur # the c cargo run -p photocraft-cli --example man-and-completions -- out/ # man pages + bash/zsh/fish/elvish/PowerShell completions ``` +The Linux AppImage includes the same CLI. Select it with a leading `--cli`: + +```sh +./photocraft--linux-x86_64.AppImage --cli --help +./photocraft--linux-x86_64.AppImage --cli mcp +ln -s photocraft--linux-x86_64.AppImage photocraft-cli +./photocraft-cli commands --filter blur +``` + +The `--cli` selector is removed; all remaining arguments, stdin, stdout, stderr and the exit status are passed through. CLI launches skip desktop integration. The symlink form uses the AppImage runtime's `ARGV0`; the normal AppImage name launches the GUI. + `actions.json` holds the steps of a recorded action: `[["", {…}], …]`, `[{"command": "", "params": {…}}, …]` or bare ids, as a list or wrapped in `{"actions": …}` or `{"steps": …}`; a droplet file works too. It can also be an Actions set (the Actions panel's `actions.json`, `{"actions": [{"name": …, "steps": […]}, …]}`): `--action ` picks the action to play (optional when the set holds one), and the whole set is loaded for each file, so a step that plays another action of the set works. `batch` refuses an `--out` folder that is the `--in` folder, since the results would replace the originals, unless `--in-place` is given. `run` prints one JSON line per command result. ## Native format (.pcraft) @@ -242,6 +277,7 @@ cargo run -p photocraft-cli --example man-and-completions -- out/ # man pa - `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. +- `composite/full.png`: the flattened document at the canvas size, for other apps that read `.pcraft` files. Saves write it only when asked (`ExportOptions::pcraft_full_composite`, off by default), and may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md). Check the manifest's `full_composite` member to confirm it was written. - `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). @@ -474,6 +510,18 @@ cargo run --release -p photocraft-ui-egui --example perf_scenarios -- \ 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. +Every `--bench` name must match a `[[bench]]` name in `perf/budgets.toml`; an unknown name +is rejected before any benchmark runs or any report or baseline is written. + +`perf_scenarios` rows that time a command and then the canvas refresh after it also split each +sample: `cmd_samples_ms` and `refresh_samples_ms` (`samples_ms`, what budgets judge, is their sum), +`refresh_kinds` (how many refreshes were `gpu-rect`, `gpu-full`, `rect`/`full` on the CPU +compositor, also without an adapter, `none` when nothing needed drawing, …) and `fallbacks` (why +the wgpu compositor didn't draw, `CpuReason::kind`). The console line ends with the medians, e.g. +`cmd 0.06 + refresh 1.71 ms {"gpu-rect": 7}`, so a slow row says whether the engine or the canvas +is the cost before you profile it. The bench refreshes like the app's `damage_since`: the last +edit's rect only when the canvas showed the revision before it, else everything; a row whose timed +refresh also had to draw untimed setup edits is reported on stderr. 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 @@ -581,8 +629,8 @@ so a following `ui.inspect` observes their effect. ## Photoshop Actions (.atn) -`photocraft-psd` reads and writes Photoshop Actions files (`atn`, version 16: Photoshop 6 and -later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, +`photocraft-psd` reads Photoshop Actions files (`atn`, versions 12 and 16) and writes version 16 +(Photoshop 6 and later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, `actions.save`; the Actions panel menu's Load/Save Actions…; `photocraft-cli batch --actions set.atn`). To see what a set holds (event ids, panel names, the parameter keys Photoshop recorded) when adding an event to the table: @@ -600,6 +648,34 @@ 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. +Recorded direct Gradient Map (`GrMp`) and Replace Color (`RplC`) forms are described in +[the ATN reference](atn.md#supported-direct-adjustment-settings), based on davidmann4's +measurements in #10603. Gradient Map keeps its event, fractional RGB stops and recorded name; +unsupported interpolation, midpoint and transparency settings remain preserved steps. +Replace Color accepts `labMinimum` / `labMaximum` (three D50 Lab numbers each), mutually +exclusive with RGB samples and localized settings. The new option matches native surface samples through +the document profile, retains alpha and untouched native pixels, and uses one undoable edit. +A zero tone shift validates its inputs without changing pixels. The existing RGB option keeps +its previous behavior. Explicit Lab bounds require a composite pixel-layer target. + +Artboard from Group action settings follow [the measured guide/background behavior](atn.md#newer-events-string-id-only): +guide IDs are ignored session metadata, 1 or absent `changeBackground` applies the +background, and other integer flags give white. The group's bounds are retained. +Background-only `editArtboardEvent` uses the existing Artboard edit command; its +recorded rectangle/preset are retained for saving, without resizing the target. + +`crates/psd/tests/it/atn_contract.rs` checks the parser's public API against small, independently +encoded version-16 fixtures (the version-12 ones are read by `atn_v12::`) in `crates/psd/tests/fixtures/atn/` (provenance and checksums in +its README): `cargo test -p photocraft-psd --test it atn_contract::`. +Recorded layer indexes and ordinals resolve against the full stack, including collapsed children +and hidden group-end positions. Indexed moves use the stack before the move. Recorded document +indexes are one-based opening order; a native numeric `document` parameter remains a zero-based +tab index. Layer comp references retain their name, index, ID or ordinal form when saved, including +the measured comp ordinals that reapply the current or first comp. Invalid comp indexes do nothing, +even with an empty comp list. Recording-document layer IDs and layer/comp offsets stay kept steps. +These rules follow [davidmann4's measured reference](https://github.com/storytold/photocraft/pull/10603) +(#10701). + ## Remove Tool quality `cargo run --release -p photocraft-engine --example remove_quality -- [out dir]` fills @@ -621,6 +697,156 @@ photos are public domain, not committed; download them at 1920 px wide into ` H$ are refused and cannot be overridden. +- When available RAM is unknown (an unsupported platform or a failed provider), native + automatic admission is 2 GiB, not 8 GiB: + $S = \min(2 \text{ GiB}, H)$, where $H$ is the aggregate address-space cap + (`native_platform_cap`), reduced by known commit headroom. Estimates above $S$ need the same + explicit, per-invocation approval as known-memory estimates above $S$, and approval is possible + up to $H$. Estimates above 8 GiB remain approvable; `MemoryBudget::warning_threshold` and + `MemoryBudget::requires_uncertainty_warning` let callers show the uncertain-capacity warning. + 8 GiB is a warning threshold, not a hard ceiling, because the process may still run out of + memory. +- These budgets apply to an import's **aggregate peak estimate**, not to one allocation: a peak + may sum many layer, mask, merged and workspace buffers. Each individual buffer must still fit + the target's layout limit (`isize::MAX`) and the decoder's own per-buffer allowance + (`photocraft-psd`'s `MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`), which is + enforced by fallible allocation or an explicit cap where that buffer is created. A 64-bit + native process has no measurable aggregate address-space cap, so admission relies on explicit + approval plus those per-buffer checks; 32-bit native uses a 2 GiB project cap + (`NATIVE_32BIT_PEAK_CAP`), a product choice below its 4 GiB address space. +- WebAssembly (wasm32): automatic admission is 1 GiB; estimates above it use the same explicit + approval. The architectural linear-memory limit is 4 GiB (65,536 × 64 KiB pages), shared with + the runtime, stack, allocator, resident document and history, and one Rust allocation cannot + exceed `isize::MAX` (2 GiB − 1). Admission uses a **project cap of 2 GiB** + (`WASM_MAX_PEAK_BUDGET`) for the aggregate peak, chosen below the address space to leave room + for the rest; it is not an architectural maximum, browsers often allow less, and allocation can + still fail. +- macOS reads a conservative reclaimable-memory measurement through `sysinfo`'s safe API: + $\min(\text{total} - \text{used}, \text{available})$, never below `free`. `total` is + `HW_MEMSIZE`; `used` is `internal_page_count - purgeable_count + wire_count + + compressor_page_count`; `available` is sysinfo's `available_memory` (`active + inactive + + free_count`, where XNU counts speculative pages inside `free_count`); `free` is its + `free_memory` (`free_count - speculative`). XNU documents *available memory* (active + + inactive + free + speculative) as everything not wired, compressed or stolen, which counts + anonymous working sets, while $\text{total} - \text{used}$ is the pool that can be handed out + without evicting one: free + file-backed + purgeable pages, close to the fully-reclaimable + pool jetsam monitors. Taking the smaller of the two keeps a counter disagreement + conservative, and `free` is a measured floor because those pages are allocatable by + definition. Swap is excluded. A genuine zero reading stays zero; unknown memory is reported + only when the provider query demonstrably failed (zero installed size or zero used memory, + which a live host cannot report). Linux reads `MemAvailable` from `/proc/meminfo` and + constrains it by the memory cgroup: v2 from the unified hierarchy's + `memory.max`/`memory.current`/`memory.swap.max`, using `cgroup.controllers` and + `cgroup.subtree_control` to distinguish a genuinely disabled controller (and the interface-file + exemption of the hierarchy root) from a view that is missing files it should expose; v1 from the + `memory` controller mount found in `/proc/self/mountinfo` (including dedicated mounts, bind + mounts and non-root process paths), applying every visible ancestor limit. When several mounts + match, the widest view (the shortest cgroup mount root) is used so a deep bind mount cannot hide + a constrained ancestor. Inactive page cache counts as reclaimable (`inactive_file` on v2, + `total_inactive_file` on v1). A v1 `memsw` limit also caps RAM; per ancestor its swap allowance + is `memsw limit - memory limit - swap already used`, and those allowances are combined + separately from RAM headroom. A memory cgroup whose mount, path, membership, limit or usage + cannot be read, or whose membership file is malformed or empty, fails closed (available memory + is treated as zero) rather than falling back to unrestricted host RAM. +- Applications can lower the admission threshold with `ImportOptions::psd_max_pixel_bytes`; explicit caller limits may lower admission, never bypass the hard boundary. +- Concurrent PSD decodes are serialized using a process-wide, cancellation-aware admission gate, with nested decodes sharing the enclosing allowance. +- A pending high-memory approval is bound to one source and one estimate. The engine keeps at + most 32 pending preflights and, separately, at most 1 GiB of source bytes + (`Session::MAX_PENDING_PREFLIGHT_BYTES`): only an in-memory source holds data (a path streams + from its file once opened), and an `Arc` shared by several preflights is counted once. The + retention cap is not a pixel budget or an admission limit; it keeps an unanswered prompt from + pinning unbounded memory, and is deliberately below the 2 GiB web hard cap. The charge is + computed from the queue, so answering, cancelling, refusing or clearing releases it. +- The approval verifies the file it decodes, not the path it was told about: the importer opens + the path once, reads `photocraft_io::FileIdentity` from that handle (Unix: device, inode, + length, mtime and ctime with nanoseconds), refuses with `SourceChanged` when it is not the + approved file, and re-reads the identity from the same handle when the memory preflight pauses + for approval. Windows has no stable file-index API in `std`, so its identity is the length and + modification time (a different file with both preserved is not distinguished there). The + `file.approveMemory` permission is one-shot and is not journaled or recorded in an action. A + refusal above the hard ceiling is the typed `PsdHardBudget` error (name, required, limit), not + a string, and it releases the queued source. +- A layered TIFF (Photoshop layer data in tags 37724/34377) is admitted by the same policy + before its composite is decoded. The page's tags are read into the PSD model first, without + pixels; then that model's layer/workspace estimate, the decoded composite and its + extra-sample planes, the planar big-endian merged copy, a 16-bit-float conversion's extra + copies, the decoder's scratch buffers (one strip or tile per worker, see + `codecs::TiffBands::decode_scratch_bytes`) and the decoder's copy of the tags are charged + together, with the caller limit and approval flags from `ImportOptions`. A zero caller limit + means the adaptive budget, as for a PSD. A TIFF whose layer tag cannot be read still opens + flattened with the same warning as before, and that whole-image decode and conversion are + bounded the same way. A layered TIFF opened inside another import spends the enclosing + attempt's remaining allowance instead of resolving a fresh budget, and its admission guard is + held across the composite decode and the PSD conversion, so a top-level import is charged + once and a same-file continuation is never charged again. +- Before waiting for the decode gate, an import checks admission; it checks again with a fresh + measurement once admitted, and only then decodes. This holds for the ordinary PSD and layered + TIFF paths and for the standalone legacy `psd_to_document` conversion, so an ineligible file + never waits behind an active decode. A nested import checks cancellation before consuming its + parent's remaining allowance and takes the reentrant admission guard first, so a cancelled or + refused nested import never spends the parent's budget. Hiding a large estimate behind a + destroyed composite or a malformed layer tag does not bypass the obvious bound: the + codecs' per-buffer limit (`Limits::max_alloc`) and the flat band check still apply to a page + opened without Photoshop layers. + +Headless RPC and MCP report approval-required memory errors with byte counts (`required`, +`softLimit`, `hardLimit`, and `availableRam` when known). A hard-budget refusal reports only +`required` and `hardLimit`: no approval can raise that ceiling, so there is no soft limit or +capacity to act on. This holds for `doc.open` and `doc.place` and for the MCP `doc_open` and +`doc_place` tools, including a refusal from `file.openAs`/`file.placeEmbedded` reached through a +command. GUI automation imports currently cross a service boundary that returns string errors. + +An import exceeding the hard budget returns `IoError::PsdImportBudget` with the estimated +and allowed byte counts; imports requiring confirmation return `IoError::PsdApprovalRequired` +when unapproved. Damaged-file recovery never bypasses these memory guards. The legacy +`psd_to_document` conversion APIs cannot return an error, so they return an empty document and +an explicit warning on preflight refusal. Layer and mask pixel allocation failures also fail +ordinary imports instead of opening partial content. + +User and real masks decode into tiles one tile row at a time, converting sample byte order +in place. Raw and RLE source-backed masks read only the current band (plus the RLE row index); +ZIP still inflates one whole plane before handing out bands. No full-mask interleave copy is +needed. A damaged later band discards all previously written bands of that mask. + +Pattern block headers are inspected without decoding pixels. The same allowance includes +all decoded pattern planes, padded tiles, bitmap/indexed expansion and conversion copies, +including earlier records in a block that later proves malformed. Pattern conversion reserves +pixel buffers fallibly. Multichannel conversion uses one tile-row buffer and applies alpha/ink +polarity before writing tiles, without a full-plane float buffer or alpha readback. + +The #8593 allowance covers these pixel paths, not total process +RSS: encoded input, parser metadata, smart-filter caches, embedded smart-object +contents, generated vector caches, and text preparation are outside it and retain their +existing limits. Joining these paths to a shared budget and streaming ZIP inflation are follow-up work. An accepted +estimate does not guarantee allocation success on a memory-constrained host. + +The legacy `memory::available()` helper used by flat TIFF conversion keeps its established +meaning: on Linux it reports `MemAvailable + SwapFree`, now bounded by cgroup v2 swap limits or +v1 `memsw`, and it is `None` on Windows and macOS. The adaptive budget is the only consumer of +the macOS physical-memory provider. Banded flat TIFF pages keep that helper; a layered TIFF's +whole-image work is admitted by the adaptive policy above. + ## Test corpora Real files are our best oracles, but they are large binaries, so they never go into this diff --git a/docs/embedded-vector-editing.md b/docs/embedded-vector-editing.md new file mode 100644 index 0000000000..e1fa2c33ba --- /dev/null +++ b/docs/embedded-vector-editing.md @@ -0,0 +1,147 @@ +# Embedded vector smart-object editing + +Layer > Smart Objects > Edit Contents offers VectorCraft editing for embedded SVG, +PDF-compatible AI, PDF and EPS, including payloads retained in PSD linked-layer tables. +Choose VectorCraft or a local executable. PhotoCraft creates a private working copy in the original format; +saving that file updates the original smart object when its parent is active and ready. +Each accepted save is one undo step, including all shared instances. Placement, masks, +filters and Undo/Redo remain in the existing smart-object transaction. + +For a vector inside a nested PSB smart object, save the edited PSB contents back to its +parent as well. Raster/PSD/PSB internal editing retains its existing behavior. + +## Format and platform limits + +AI, PDF and EPS save-back retains the native file name and saved native bytes. A separate, +source-hash-bound SVG is a display preview, never a replacement for the embedded native file. +It is retained in `.pcraft` and a private PSD `pcVP` layer block; other PSD readers may ignore +that block and use the layer's cached pixels. Undo restores both the original source and preview. +No original file on disk is edited. Test on a PSD copy. + +Format preservation is not a claim of lossless Illustrator editing: the editor regenerates +its supported AI/PDF/EPS content, and unsupported features or missing fonts can change appearance. +Native Adobe Photoshop/Illustrator Edit Contents acceptance remains unverified. The chooser +keeps that warning explicit. `updateSvg` only replaces SVG contents: it refuses an AI, PDF or +EPS source, which only `updateVectorSource` saves back, in its own format. + +Native editing requires VectorCraft and its adjacent vectorcraft-cli for bounded previews, +even when another interactive editor is selected. The CLI runs headless in the background, +restricted to a fresh private temporary read/write root per snapshot, separate from the +interactive working directory. Open and export must both succeed, with cancellation and a +60-second timeout. Each stable native save is validated before PhotoCraft accepts it. + +Foreign AI files ordinarily require Save As in VectorCraft to protect the source. When VectorCraft +is the editor, PhotoCraft regenerates only the private AI working copy with VectorCraft editing +data before launching it, so ordinary Save can be used without changing that general protection. +Illustrator and other editors get the original AI bytes. Merely opening the +editor never updates the embedded original. Ordinary EPS Save needs the companion VectorCraft +EPS-save support; older editors may require exporting EPS to the opened private file. + +The PSD source-pixel frame is retained when PDF points differ from stored dimensions. The +native file retains its own geometry; the display preview fits the original placed frame. +Unsupported or oversized frames fail without changing the smart object. + +Desktop integration supports an explicitly selected executable. Linux discovery checks +standard native locations and a user Flatpak deployment. On Linux, choosing VectorCraft +uses the desktop's Open With chooser to launch the regular application; +select VectorCraft there. The writable document portal shares only the private working +file. Working copies live in PhotoCraft's private cache because its isolated /tmp cannot +be exported. Ordinary VectorCraft launches retain preferences and existing-window +handoff instead of running a second payload inside PhotoCraft's sandbox. Reusing tabs +requires the installed VectorCraft build's Linux single-instance support. + +Native AI/PDF/EPS previews still need vectorcraft-cli alongside the discovered +executable. The local Flatpak runtime test granted read-only access to the test +VectorCraft build for that invocation. A sandboxed distribution must arrange access to +its compatible converter; this source change adds no broad filesystem or host-process +permissions and does not establish default Flatpak packaging acceptance. SVG editing +through the portal does not require access to the VectorCraft deployment. Explicit +Other application executables retain their direct-launch behavior. + +On macOS, VectorCraft discovery also checks `/Applications/VectorCraft.app` and +`~/Applications/VectorCraft.app` and runs the bundle's executable directly. Other application +accepts an application bundle (`.app`): its Info.plist `CFBundleExecutable` must name an +executable inside the bundle's `Contents/MacOS`. Bundles with the application identifier +`ai.storyteller.vectorcraft` run directly with the working file as an argument, matching +automatic discovery; other bundles use `/usr/bin/open -a` for open-document events. Adobe Illustrator is +offered when an `Adobe Illustrator*.app` is found in `/Applications` (at most one folder deep). +AI/PDF/EPS previews look for `vectorcraft-cli` in the same bundle's `Contents/MacOS`, then in +the other known locations; current VectorCraft macOS packages ship the CLI as a separate +download, so it must be placed there or installed beside a discovered executable. These paths +are unit-tested on Linux with synthetic bundles. Windows and macOS runtime acceptance has not +been performed. Browser and automation paths do not launch +editors; browser internal editing remains available. + +This PR does not add clipboard discovery or Paste As. It stands alone on main, without +requiring the clipboard or Shape Tools work in #3409/#3446. Its source-watching and +bounded SVG rendering build on credited open-source work from #3409; the embedded +AI/PDF/EPS extension was first submitted in Skyfuller1303/photocraft#1. + +## Safety + +Artwork never supplies a program, command line or output pathname. Editor selection is +explicit, without a shell. The interactive editor runs with its normal desktop permissions; +the restricted MCP roots apply to preview generation, not to that editor process. +Working copies reject links and nonregular files; Unix opens +are no-follow and nonblocking. Unix save settling includes device/inode identity, including equal-time atomic replacement. +Reads remain byte-bounded even if another process grows the file. A save that changes while it +is being read is not reported as an error; the watcher waits for it to settle again and reads it. + +Source input/output is limited to 16 MiB. Bounded SVG parsing permits one size-checked gzip +layer; nested gzip is rejected both at the top level and inside embedded SVG data URLs. +PSD preview validation caches shared sources and +uses a 96 million pixel-work budget across distinct previews; previews beyond that budget fall +back to the PSD cached appearance without changing the native source. The charge includes +raster decoding, eager layer caches and embedded-SVG intermediate surfaces. Raster-bearing +repeatable paint/effect resources reserve conservative repaint work; ordinary vector paths, +artboard clips and blur without image references do not multiply unrelated image decoding. +Decoded images and each canvas/cache component keep their independent 24 million pixel +limits, with separate bounds on SVG nesting. A new saved preview also has a 96 million +pixel-work validation limit. External file and URL images are not fetched. Missing system generic fonts fall back to installed families; +matching typography still requires the authored fonts. + +Memory-only layer.smartObjects.exportSvg, layer.smartObjects.updateSvg and +layer.smartObjects.updateVectorSource validate +parent document, layer, shared-content identity and source fingerprint. They never +follow a linked disk pathname or launch processes. The native update command is unavailable +to untrusted automation because a caller-supplied preview is not evidence of native appearance. +Automation authorization also checks +visible stored smart filters on every shared instance before replay, so a desktop LUT +file parameter cannot inherit filesystem access. Malformed saves, locked targets and +conflicting edits retain existing content. Saved updates wait through masks, gradients, +other active previews and dialogs instead of interrupting an edit. + +Frame recovery does not rasterize old previews or decode their embedded images just to +read their dimensions. It checks the actual native-source digest, memoizes shared immutable +preview identities and caps distinct old-preview XML at 64 MiB per update. Exhaustion +rejects the whole update without changing any instance. PSD preview import/export keeps +its separate 96 million pixel validation budget. Native and SVG source updates do not +append full artwork to the replay journal; document Undo/Redo remains available. + +## Validation evidence + +Eight contributor-owned logo PSDs were tested through their visible nested AI vectors. +A real VectorCraft edit/save reached PhotoCraft's native watcher and engine; child and +parent Undo/Redo and PSD export/reopen preserved the changed vector. Original SHA256 +hashes were unchanged. This combines runtime editor/engine acceptance and separate UI +wiring tests; it is not a claim of manual full PhotoCraft GUI acceptance. Private artwork, +and private screenshots are excluded from this repository. The opt-in acceptance harness +accepts local paths through environment variables; it contains no private artwork. + +![Embedded-vector editor chooser](images/embedded-vector-editor-en.png) + +Earlier SVG-only Linux Flatpak GUI acceptance verified an editor save through the writable document +portal reached PhotoCraft's watcher, with an ordinary document opening as a second tab +in the same VectorCraft window. Both launch orders were checked. + +![Regular and PhotoCraft vector documents in one VectorCraft window](images/embedded-vector-tabs-linux.png) + +Separate isolated VectorCraft GUI tests saved AI, PDF and EPS back to the same paths +and opened a second document as another tab. The native-format watcher/engine test +also passed inside PhotoCraft's Flatpak runtime. These tests do not establish lossless +Adobe compatibility or Windows/macOS acceptance. + +D-Bus tests cover +chooser success/cancellation/failure and reject symlinks and FIFOs before sharing. + +Offscreen dialog capture with authored bytes and mock services; no private artwork. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f49fa461de..bc3c84a524 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-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; 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: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, 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: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; 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: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, 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: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -26,12 +26,12 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | 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 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, 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`); alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.16.0, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/258. 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, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); TIFF Options on Save As and Save a Copy (Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency, Discard Layers and Save a Copy; scripts pass `tiffCompression`, `tiffTransparency`, `tiffPyramid`, `tiffLayers`; #8827), no JPEG compression, Pixel Order or Byte Order (the encoder writes interleaved samples in the machine's byte order); undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); Save Transparency on keeps the transparency as the first extra sample ahead of the channels (a layered file marks it with `MTrn`, as Photoshop does), off mattes on white; unset, it is kept only without alpha or spot channels | | 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 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) | +| 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); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). 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 | @@ -133,23 +133,51 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 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` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets and writes version 16 (exact round trip for version 16; version 12 legacy bare identifiers, reference classes and object-array values keep their meaning; trailing bytes remain a warning, #10699). `engine/src/cmd/atn_cmds/` maps the reference application's events to commands (supported event IDs listed in [atn.md](atn.md); one recognized-but-disabled event), including Apply Layer Comp by name (#7101), recorded layer indexes/ordinals and indexed moves, opening-order document indexes and comp index/ID/ordinal references (#10701), recorded classic `GrMp` with fractional RGB stops and `RplC` with explicit Lab sample bounds (#10607); other steps kept verbatim. Save As maps to `file.saveAs`/`file.saveACopy` for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported; version 12 coverage uses synthetic fixtures. | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | 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 | | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | | C2PA Content Credentials | R/W | no | #1050 | +## Embedded vectors in PSD smart objects (2026-10-22) + +Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCraft editor choice, including nested PSB contents. AI/PDF/EPS working copies retain their format on save-back; a separate source-bound SVG display preview does not replace the native payload. Placement, shared instances, masks and Undo/Redo use existing smart-object transactions, with native bytes and previews persisted through .pcraft and PSD/PSB saves. Unsupported editor features may still change; standalone EPS/DCS import and lossless Adobe round-trip compatibility remain unverified. See [workflow, safety and validation limits](embedded-vector-editing.md). No corpus floor or parity percentage is changed. + ## Revision history +The original reference row retains its source date and historical totals; current totals are in the status and Actions row above. +The original reference entry retains its source date and historical totals; current totals are in the status above. + | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | +| 2026-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | +| 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). | +| 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered | +| 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | +| 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | +| 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | +| 2026-10-31 | minor | `.atn`: De-Interlace (`Dntr`), NTSC Colors (`NTSC`), HSB/HSL (`HsbP`), Custom (`Cstm`, a 5 × 5 `Mtrx`), Average (`Avrg`) and Remove Black / White Matte (`RmvB`/`RmvW`) map to their commands with Photoshop's defaults and save back with the same keys; Replace Color and the direct Gradient Map (`GrMp`) still kept verbatim (#10607) | +| 2026-11-01 | minor | `.atn` version 12 import uses the existing descriptor model and limits, preserves trailing-byte warnings and writes version 16. Synthetic fixtures cover legacy values, references, truncations and command-import equivalence (#10699). | +| 2026-10-31 | minor | `.atn`: Oil Paint (`oilPaint`), Smart Sharpen (`smartSharpen`), Focus Area (`focusMask`), Select Sky (`selectSky`) and Remove Background (`removeBackground`) map to their commands with the keys and defaults Photoshop records (Oil Paint's and Select Sky's defaults written out where they differ from the commands'); Smart Sharpen maps the newer filter's Gaussian form, as PSD smart filters do, and keeps the legacy filter, Lens Blur and Motion Blur as Photoshop steps; out-of-range Oil Paint and Smart Sharpen settings are clamped on import (#10610) | +| 2026-11-01 | minor | `.atn`: layer indexes/ordinals include collapsed children and group ends; indexed moves use pre-move boundaries; document indexes use opening order; comp index/ID/ordinal forms retain their reference kind (#10701). | +| 2026-11-01 | minor | `.atn`: original reference snapshot, before the Group F mappings: [atn.md](atn.md) documented 117 supported events and measured keys, defaults, ranges and enum values for 101 events not mapped yet | +| 2026-10-31 | minor | `.atn` Group F: selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older mask/merge events load, play and save; 127 supported event IDs, 89 unmapped reference entries; AUTO-219-2 remains partial | +| 2026-10-31 | minor | `.atn` Group D: twelve Stylize/Pixelate events load, play and save; 128 supported and 89 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | +| 2026-10-31 | minor | `.atn`: Split Channels (`SplC`, its recorded `documentIDs` not replayed or written), Merge Spot Channel (`MSpt`), Trap (`Trap`, `Wdth` clamped to 32767 px) and Match Color without a source (`matchColor`, `noReference`) map to `channel.split`, `channel.mergeSpot`, `image.trap` and `image.adjustments.matchColor`; a Match Color step that matches another image stays a Photoshop step (#10609) | +| 2026-10-31 | minor | `.atn`: Filter Gallery (`GEfc` with the effect in `GEfk`, or the effect's own event) maps all 47 effects to `filter.gallery.` with every key in recorded order, out-of-range values clamped and missing keys given Photoshop's defaults (was Spatter only); Glass maps the Frosted and Canvas textures (#10592) | +| 2026-10-30 | patch | TIFF: YCbCr (PhotometricInterpretation 6) opens as RGB instead of failing with "colour type YCbCr(8)": JPEG-compressed with subsampled chroma (as scanners write it) through the JPEG conversion, uncompressed with ReferenceBlackWhite and YCbCrCoefficients; 8 and 16-bit. Uncompressed subsampled YCbCr and YCbCr with extra samples still fail with a clear error (#10316) | +| 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | +| 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | +| 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | | 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged | | 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. | | 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | | 2026-10-23 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-23 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-23 | minor | Amiga IFF write: Save As `.iff` stores a 24-plane ILBM (ByteRun1, alpha as a 1-bit mask plane), and 24-plane ILBMs open | +| 2026-10-16 | minor | Save for Web (Legacy) offers WebP, lossy or lossless, with its own preview and size estimate (#2415, #6391) | | 2026-10-23 | minor | #8940: Filter Gallery Halftone Pattern maps size, contrast and dot/circle/line both ways with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`); PSD/PSB round trips at 8/16/32 bits, malformed descriptors preserved verbatim. Other gallery effects remain partial; no floor or estimate changed. | | 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) | @@ -213,4 +241,5 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-11 | minor | PSD smart filters: Filter Gallery (`GEfc`) maps to `filter.filterGallery` when every effect is modelled (Chrome so far), editable from the Layers panel | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | | 2026-10-11 | minor | PDF write now covers Save As / Save a Copy / CLI (`io::pdf::export`, #3401): one flattened RGB/Gray/CMYK page at the document's print size with the embedded profile. Read (#2541), multi-page save, PDF presentation, vector/text output still open +| 2026-10-11 | minor | Sony encrypted applied WB/black and measured ILCE-6000 crop; camera calibration remains partial | | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md index 0c94561436..ef86e534cd 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-27 · **Change:** minor (G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; 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; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; 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; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **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 @@ -53,7 +53,10 @@ what an agent can't finish alone. 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; a generated 4.38 GB PSB opens, saves and re-opens - through io on Windows, io tests/it/large_files.rs, 2026-10-13). + through io on Windows, io tests/it/large_files.rs, 2026-10-13), a 117,504 × 22,464 px print TIFF + refused at 2^30 px (#8456, #8169; opens since 2026-10-22, decoded a group of rows at a time into + the layer, refused with an error when it doesn't fit in free memory; a single-strip file is still + decoded whole beside the layer; layered TIFFs over the limit and huge PSBs still decode whole). No test opens our exports in a third-party editor (FILE-216-4). Mockups whose smart object uses Transform Warp's Cylinder re-rendered flat after Replace Contents (#4112, fixed 2026-10-14; vertical cylinders and ones with perspective are still unread). @@ -73,8 +76,10 @@ what an agent can't finish alone. - **Estimate:** 60–100 h (2–9 h each, per the calibration). ### 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 +- **Missing:** Select and Mask's Refine Hair (#2671), Object Aware refine mode (#2788) and Presets + (TOOL-214-1; the workspace landed 2026-10-23, #7069, #8148: Photoshop's layout with its own + Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso and Polygonal Lasso, Hand and + Zoom, the view modes since #1754, Real-Time Refinement and High Quality Preview); 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 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 @@ -93,8 +98,11 @@ what an agent can't finish alone. and saves back into the file's `Txt2` since 2026-10-21, #7962), 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 + (TYPE-218-3), OpenType Stylistic Sets submenu and greying out features the font lacks + (TYPE-218-5; Character panel buttons 2026-10-11, #3752; Numerals submenu 2026-11-03 — + `.pcraft` keeps all figure tags; PSD EngineData only `OldStyle`/`onum` until a Photoshop-authored + oracle lands; PhotoCraft-native PSDs keep stylistic sets and other Txt2-only flags through + save/reopen since 2026-11-04, #10308; the Type › OpenType › Stylistic Sets menu is #10285 / PR #10304), text free transform (#2630), area type in PSD (TYPE-218-10). 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 @@ -106,6 +114,16 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **Partial 2026-11-02 (#4579):** Batch accepts recursive folders with flat output and + last-result-wins names, as measured in Photoshop 2026. It also accepts live open documents, + extended naming components, stop-on-error, UTF-16 LE logs, and job cancellation. The CLI + exposes the folder options. The desktop now asks Continue/Stop after Esc, preserves the + original output plan and cumulative error log, and shows each folder input as a read-only + canvas preview without a progress bar. AUTO-219-5 remains partial because worker inputs are + not live tabs with Layers panel content. No readiness percentage or estimate changed. +- **Done 2026-10-31 (#10604):** all four Render ATN events (Group B) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 120 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. +- **2026-10-31 evidence (#10608):** Group F selection, paste, Fade, Link, Distribute, pattern and older mask/merge events load, play and save through existing commands. Unsupported settings remain kept; AUTO-219-2 remains partial. +- **Done 2026-10-31 (#10606):** all twelve Stylize/Pixelate ATN events (Group D) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 128 supported and 89 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **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); @@ -115,10 +133,14 @@ what an agent can't finish alone. 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. -- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion, open-document inputs, progress/cancel and an error-log file - (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the + `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Custom output + names cannot replace inputs before they are read. AUTO-219-5 stays partial. +- **Partial 2026-11-02 (#4514):** Action recording drops an implicit active-layer id, names a + unique non-active layer, folds Type keystrokes into whole-text steps and Move-tool nudge runs + into one relative move, records keyboard document switches as opening-order offsets, and + places recorded point text by canvas percentage. Duplicate layer names remain ambiguous and + keep the id; paragraph text boxes still use absolute coordinates. AUTO-219-1 remains partial. +- **Missing:** ExtendScript/UXP compatibility. `.atn` steps outside the 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. @@ -130,10 +152,19 @@ what an agent can't finish alone. 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"}`, + offset: the previous, next or Nth open document 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. +- **2026-10-22 correction:** document offsets follow opening/creation order, independently of + dragged tab order. Closing removes an entry; reopening appends it. Absolute document indices + and command results still use tab order. Evidence is the firsthand behavior reports in + [Adobe's 2016 tab-order discussion](https://community.adobe.com/questions-712/how-select-real-next-document-1100886) + and [2021 action-order discussion](https://community.adobe.com/questions-712/how-to-change-the-order-of-open-documents-for-an-action-1129531), + corroborated by a [reported Dcmn offset test](https://community.adobe.com/questions-712/how-to-select-the-document-just-back-of-the-active-document-1102704). + This is a behavioral inference from those reports, not a new live Photoshop reproduction or + an official format guarantee. The existing out-of-range rejection is unchanged. Regression + tests cover tab reordering, close/reopen, imported document IDs and `.atn` copy/paste playback. - **Done 2026-10-17:** a recorded Save As imports with its format and destination file (`file.saveAs`, or `file.saveACopy` for As a Copy) instead of as a plain Save, which wrote over the file the action was played on (#7102). A folder destination, an unmapped format or a @@ -218,6 +249,11 @@ 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-25):** Shift-selected colour sets now constrain raster drawing, + erasers, tone adjustments and supported filters at native depths. Live previews, + transparency and unselected encoded components are protected. Move/transform on + partial sets remains open; [scope and benchmarks](multi-channel-editing.md). + - **Progress (2026-10-23, #8842):** the five hard-round built-in presets use 5% spacing. At 150 px, this removes the scalloped outline from the former 25% spacing and reduces nested dab boundaries in pressure-opacity tapers. Pixel regressions cover 8/16/32-bit @@ -268,7 +304,9 @@ what an agent can't finish alone. snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded - tools (#2672); H/V skew fields in the Free Transform options bar. + tools (#2672); H/V skew fields in the Free Transform options bar; Free Transform of several + selected layers, ⌘T and the Move tool's shared frame (#6501, #3384, #6096; Warp stays one + layer). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. @@ -286,7 +324,7 @@ what an agent can't finish alone. - **Progress (2026-10-15):** the Color panel has Photoshop's panel-menu modes (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), its colour ramp and copy commands, the same in every theme (UI-217-17, #2569, #6000; `ui-egui/src/color_panel_ui.rs`). -- **Missing:** 12 of 181 preferences do nothing (scorecard prefs audit, 2026-10-27); panel groups can't +- **Missing:** 11 of 183 preferences do nothing (scorecard prefs audit, 2026-11-01); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); 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, Pen and Crop @@ -295,6 +333,12 @@ what an agent can't finish alone. - **Estimate:** 150–250 h. ### G2. Interactive performance +- **Progress (2026-10-27):** Smooth and Feather also crop command mask reads and + replacement to the nonzero selection bounds plus their blur reach and a zero margin. + A 50×50 selection at radius 5 uses 62×62 and 82×82 samples respectively on a 36 MP + canvas. Full-canvas reference tests and remote release benchmarks cover both commands; + GUI budgets and readiness percentages are unchanged. + [Method and limits](selection-distance-performance.md#cropped-smooth-and-feather-commands-2026-10-27). - **Measured progress (2026-10-26):** Expand, Contract and Border restrict distance work to the nonzero bounds plus a radius halo. Small radii use capped distances; Contract avoids inverted float masks and Border retains one distance plane. On 36 MP, eight synthetic @@ -333,7 +377,8 @@ what an agent can't finish alone. ## File-format gaps ### G10. Missing and partial file formats -- **Missing:** JPEG Options' Matte, progressive Scans and size preview (FILE-215-19 partial), Photoshop PDF read and its vector/text/multi-page +- **Progress (2026-10-26):** Embedded SVG/PDF-compatible AI/PDF/EPS smart objects can be edited in VectorCraft, including nested PSD payloads. AI/PDF/EPS working copies retain their format with a separate source-bound SVG display preview; native bytes and previews persist through .pcraft and PSD/PSB saves. This does not add standalone EPS/DCS import or establish lossless Adobe round-trip compatibility. See [workflow and limits](embedded-vector-editing.md); estimates and parity percentages are unchanged. +- **Missing:** JPEG Options' Matte, progressive Scans and size preview (FILE-215-19 partial), TIFF Options' JPEG compression, Pixel Order and Byte Order (the rest landed 2026-11-02, #8827), 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), DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop @@ -396,8 +441,23 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | +| 2026-11-02 | minor | Action recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and places point text by canvas percentage; paragraph boxes remain absolute (AUTO-219-1, #4514); parity percentages unchanged | +| 2026-11-01 | minor | Action recording stores portable layer targets and whole-text Type steps where unambiguous (AUTO-219-1, #4514); duplicate names remain ambiguous; parity percentages unchanged | +| 2026-11-02 | minor | G11: measured Batch output is flat and later results replace earlier ones; naming spells the whole filename; UTF-16 LE logs; current progress and cancellation UI tracked as remaining parity work (#4579); AUTO-219-5 partial; readiness and estimate unchanged | +| 2026-11-01 | minor | G11: Batch recursion, open-document source, extended naming, stop/error log and folder-job progress/cancel (#4579); AUTO-219-5 done; readiness and estimate unchanged | +| 2026-10-31 | minor | G11: four Render ATN events mapped; current counts 120 supported/97 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | +| 2026-10-31 | minor | G11: Group F action steps map to existing commands; 127 supported event IDs; action-file support remains partial; estimates unchanged | +| 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 128 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | +| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | +| 2026-11-01 | minor | G9: 11 of 183 preferences do nothing (was 12): Graphics Adapter (`performance.gpuAdapter`) always worked, but startup read it by raw JSON key, which the scorecard prefs audit can't see; it now reads the typed Performance section; no estimate changed | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | +| 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | +| 2026-10-25 | minor | Selected colour channel sets: drawing, combined and per-component Levels/Curves, adjustments and supported filters; [scope, evidence and benchmarks](multi-channel-editing.md). Move/transform remains open; percentages unchanged | +| 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | +| 2026-10-23 | minor | G7: Select and Mask workspace (Photoshop's layout, its tools as `select.refineEdge` edits, Real-Time Refinement, High Quality Preview), #7069, #8148; Refine Hair, Object Aware and Presets remain; no estimate changed | +| 2026-10-22 | minor | G1: flat TIFFs up to 300,000 px a side open (was 2^30 px); the #8456 banner (117,504 × 22,464 px) opens in 3.3 s; 30,000² opens in 1.1 s at 3.86 GB peak (was 6.8 s, 6.43 GB); #8169; no estimate changed | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | | 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed | | 2026-10-23 | minor | G4: hard-round built-in presets use 5% spacing (#8842), guarded by large-stroke outline and pressure-opacity pixel regressions at 8/16/32-bit; percentages and estimates unchanged | @@ -405,13 +465,16 @@ what an agent can't finish alone. | 2026-10-23 | minor | G10: TIFF alpha and spot channels written and read as extra samples with Photoshop's channel resources, PSD output unchanged (#7415); FILE-215-7 leaves the list, FILE-215-17 narrows to RGB/CMYK spot preview and PDF Separation | | 2026-10-23 | minor | G10: Amiga IFF write landed (24-plane ILBM, Save As `.iff`) and 24-plane ILBMs open | | 2026-10-23 | minor | #8940: Filter Gallery Halftone Pattern maps size, contrast and dot/circle/line both ways with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`); PSD/PSB round trips at 8/16/32 bits, malformed descriptors preserved verbatim. Other gallery effects remain partial; no floor or estimate changed. | +| 2026-10-22 | minor | G11: document offsets use opening order despite tab reordering; lifecycle and `.atn` playback regressions; behavioral source evidence, no new live Photoshop reproduction | | 2026-10-22 | minor | G17: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; estimate unchanged | +| 2026-10-19 | minor | G5: Free Transform of several selected layers (#6501): one box moves them all; no estimate changed | | 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 | G9: adjustment clipping remains visible in compact Properties (#5231); no readiness percentage or estimate changed | | 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-21 | minor | G9: right-click on a pending crop opens the Crop tool menu (presets, Clear, overlays, Cancel, Crop; #6561); Reset still open | | 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-11-02 | minor | G10: TIFF Options dialog on Save As and Save a Copy: Image Compression, Save Image Pyramid, Save Transparency, Discard Layers (#8827); JPEG compression, Pixel Order and Byte Order not offered; no readiness percentage or estimate changed | | 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-19 | minor | G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430 | @@ -483,8 +546,11 @@ what an agent can't finish alone. | 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-11-04 | minor | G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen (#10308); Stylistic Sets menu still #10285 | +| 2026-11-03 | minor | G8: Type › OpenType › Numerals submenu landed (TYPE-218-5); `.pcraft` keeps figure tags, PSD only `OldStyle`/`onum` for now; Stylistic Sets and font-feature greying still missing | | 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 +| 2026-10-11 | minor | G14 Sony metadata/crop progress recorded without closing camera colour or tone gaps | | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/images/embedded-vector-editor-en.png b/docs/images/embedded-vector-editor-en.png new file mode 100644 index 0000000000..ce61bfd245 Binary files /dev/null and b/docs/images/embedded-vector-editor-en.png differ diff --git a/docs/images/embedded-vector-tabs-linux.png b/docs/images/embedded-vector-tabs-linux.png new file mode 100644 index 0000000000..e97f05e161 Binary files /dev/null and b/docs/images/embedded-vector-tabs-linux.png differ diff --git a/docs/images/multi-channel-editing/LICENSE-APACHE b/docs/images/multi-channel-editing/LICENSE-APACHE new file mode 100644 index 0000000000..f433b1a53f --- /dev/null +++ b/docs/images/multi-channel-editing/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/docs/images/multi-channel-editing/LICENSE-MIT b/docs/images/multi-channel-editing/LICENSE-MIT new file mode 100644 index 0000000000..68facca4cf --- /dev/null +++ b/docs/images/multi-channel-editing/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/images/multi-channel-editing/LICENSE-PUBLIC-DOMAIN.txt b/docs/images/multi-channel-editing/LICENSE-PUBLIC-DOMAIN.txt new file mode 100644 index 0000000000..befdee28ce --- /dev/null +++ b/docs/images/multi-channel-editing/LICENSE-PUBLIC-DOMAIN.txt @@ -0,0 +1,10 @@ +The Great Wave off Kanagawa, Katsushika Hokusai, c. 1830–1832. +Source: https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg +Original: https://upload.wikimedia.org/wikipedia/commons/a/a5/Tsunami_by_hokusai_19th_century.jpg +The artist died in 1849. This work was published before 1931 and is in the +public domain in the United States and countries with life-plus-100 or shorter terms. +Wikimedia Commons identifies this faithful reproduction as public domain (PD-Art). +Public Domain Mark 1.0: https://creativecommons.org/publicdomain/mark/1.0/ +The artwork is included unchanged as great-wave.jpg. PhotoCraft renders, scales +and converts it to RGB, CMYK and Lab for the adjacent screenshots. +PhotoCraft UI and screenshot scripts: MIT OR Apache-2.0 (adjacent license files). diff --git a/docs/images/multi-channel-editing/cmyk-script.json b/docs/images/multi-channel-editing/cmyk-script.json new file mode 100644 index 0000000000..fb489c1101 --- /dev/null +++ b/docs/images/multi-channel-editing/cmyk-script.json @@ -0,0 +1,47 @@ +[ + [ + "engine.execute", + { + "command": "image.imageSize", + "params": { + "width": 1000, + "resample": "bicubic" + } + } + ], + [ + "engine.execute", + { + "command": "image.mode.cmyk", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 1, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "ui.menu.invoke", + { + "id": "image.adjustments.curves" + } + ] +] diff --git a/docs/images/multi-channel-editing/cmyk.png b/docs/images/multi-channel-editing/cmyk.png new file mode 100644 index 0000000000..d196561ef1 Binary files /dev/null and b/docs/images/multi-channel-editing/cmyk.png differ diff --git a/docs/images/multi-channel-editing/curves-choices-script.json b/docs/images/multi-channel-editing/curves-choices-script.json new file mode 100644 index 0000000000..7e374dab89 --- /dev/null +++ b/docs/images/multi-channel-editing/curves-choices-script.json @@ -0,0 +1,68 @@ +[ + [ + "engine.execute", + { + "command": "file.new", + "params": { + "width": 800, + "height": 560, + "depth": 16, + "background": "white" + } + } + ], + [ + "engine.execute", + { + "command": "layer.new.layer", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "paint.gradient", + "params": { + "from": [ + 0, + 0 + ], + "to": [ + 800, + 560 + ], + "colors": [ + "#143b9b", + "#f6b552" + ] + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "ui.menu.invoke", + { + "id": "image.adjustments.curves" + } + ] +] diff --git a/docs/images/multi-channel-editing/curves-choices.png b/docs/images/multi-channel-editing/curves-choices.png new file mode 100644 index 0000000000..eca521068f Binary files /dev/null and b/docs/images/multi-channel-editing/curves-choices.png differ diff --git a/docs/images/multi-channel-editing/curves-script.json b/docs/images/multi-channel-editing/curves-script.json new file mode 100644 index 0000000000..7e374dab89 --- /dev/null +++ b/docs/images/multi-channel-editing/curves-script.json @@ -0,0 +1,68 @@ +[ + [ + "engine.execute", + { + "command": "file.new", + "params": { + "width": 800, + "height": 560, + "depth": 16, + "background": "white" + } + } + ], + [ + "engine.execute", + { + "command": "layer.new.layer", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "paint.gradient", + "params": { + "from": [ + 0, + 0 + ], + "to": [ + 800, + 560 + ], + "colors": [ + "#143b9b", + "#f6b552" + ] + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "ui.menu.invoke", + { + "id": "image.adjustments.curves" + } + ] +] diff --git a/docs/images/multi-channel-editing/curves.png b/docs/images/multi-channel-editing/curves.png new file mode 100644 index 0000000000..4d182398ba Binary files /dev/null and b/docs/images/multi-channel-editing/curves.png differ diff --git a/docs/images/multi-channel-editing/drawing-script.json b/docs/images/multi-channel-editing/drawing-script.json new file mode 100644 index 0000000000..915ac8b664 --- /dev/null +++ b/docs/images/multi-channel-editing/drawing-script.json @@ -0,0 +1,93 @@ +[ + [ + "engine.execute", + { + "command": "file.new", + "params": { + "width": 800, + "height": 560, + "depth": 16, + "background": "white" + } + } + ], + [ + "engine.execute", + { + "command": "layer.new.layer", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "paint.gradient", + "params": { + "from": [ + 0, + 0 + ], + "to": [ + 800, + 560 + ], + "colors": [ + "#143b9b", + "#f6b552" + ] + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "engine.execute", + { + "command": "paint.stroke", + "params": { + "points": [ + [ + 100, + 180 + ], + [ + 400, + 380 + ], + [ + 700, + 180 + ] + ], + "size": 64, + "color": "#ffffff", + "seed": 42 + } + } + ], + [ + "ui.set", + { + "tool": "remove" + } + ] +] diff --git a/docs/images/multi-channel-editing/drawing.png b/docs/images/multi-channel-editing/drawing.png new file mode 100644 index 0000000000..bfb95f0186 Binary files /dev/null and b/docs/images/multi-channel-editing/drawing.png differ diff --git a/docs/images/multi-channel-editing/gallery-channel-preview-after.png b/docs/images/multi-channel-editing/gallery-channel-preview-after.png new file mode 100644 index 0000000000..228dcb584c Binary files /dev/null and b/docs/images/multi-channel-editing/gallery-channel-preview-after.png differ diff --git a/docs/images/multi-channel-editing/gallery-channel-preview-before.png b/docs/images/multi-channel-editing/gallery-channel-preview-before.png new file mode 100644 index 0000000000..c4896b97ba Binary files /dev/null and b/docs/images/multi-channel-editing/gallery-channel-preview-before.png differ diff --git a/docs/images/multi-channel-editing/gallery-channel-preview-script.json b/docs/images/multi-channel-editing/gallery-channel-preview-script.json new file mode 100644 index 0000000000..ef1cb026c1 --- /dev/null +++ b/docs/images/multi-channel-editing/gallery-channel-preview-script.json @@ -0,0 +1,51 @@ +[ + [ + "engine.execute", + { + "command": "image.imageSize", + "params": { + "width": 1000, + "resample": "bicubic" + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "engine.execute", + { + "command": "channel.setVisible", + "params": { + "channel": "composite", + "visible": true + } + } + ], + [ + "ui.menu.invoke", + { + "id": "filter.filterGallery" + } + ], + [ + "ui.menu.invoke", + { + "id": "filter.filterGallery", + "params": { + "ui": { + "filter": "cutout" + } + } + } + ] +] diff --git a/docs/images/multi-channel-editing/great-wave.jpg b/docs/images/multi-channel-editing/great-wave.jpg new file mode 100644 index 0000000000..309cb5163b Binary files /dev/null and b/docs/images/multi-channel-editing/great-wave.jpg differ diff --git a/docs/images/multi-channel-editing/lab-script.json b/docs/images/multi-channel-editing/lab-script.json new file mode 100644 index 0000000000..a2fc178853 --- /dev/null +++ b/docs/images/multi-channel-editing/lab-script.json @@ -0,0 +1,46 @@ +[ + [ + "engine.execute", + { + "command": "image.imageSize", + "params": { + "width": 1000, + "resample": "bicubic" + } + } + ], + [ + "engine.execute", + { + "command": "image.mode.lab", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 1, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "ui.menu.invoke", + { + "id": "image.adjustments.curves" + } + ] +] diff --git a/docs/images/multi-channel-editing/lab.png b/docs/images/multi-channel-editing/lab.png new file mode 100644 index 0000000000..300f7c280f Binary files /dev/null and b/docs/images/multi-channel-editing/lab.png differ diff --git a/docs/images/multi-channel-editing/levels-script.json b/docs/images/multi-channel-editing/levels-script.json new file mode 100644 index 0000000000..992f21732d --- /dev/null +++ b/docs/images/multi-channel-editing/levels-script.json @@ -0,0 +1,68 @@ +[ + [ + "engine.execute", + { + "command": "file.new", + "params": { + "width": 800, + "height": 560, + "depth": 16, + "background": "white" + } + } + ], + [ + "engine.execute", + { + "command": "layer.new.layer", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "paint.gradient", + "params": { + "from": [ + 0, + 0 + ], + "to": [ + 800, + 560 + ], + "colors": [ + "#143b9b", + "#f6b552" + ] + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "ui.menu.invoke", + { + "id": "image.adjustments.levels" + } + ] +] diff --git a/docs/images/multi-channel-editing/levels.png b/docs/images/multi-channel-editing/levels.png new file mode 100644 index 0000000000..1f8074b761 Binary files /dev/null and b/docs/images/multi-channel-editing/levels.png differ diff --git a/docs/images/multi-channel-editing/mask-view-after.png b/docs/images/multi-channel-editing/mask-view-after.png new file mode 100644 index 0000000000..0f09d0f8dd Binary files /dev/null and b/docs/images/multi-channel-editing/mask-view-after.png differ diff --git a/docs/images/multi-channel-editing/mask-view-before.png b/docs/images/multi-channel-editing/mask-view-before.png new file mode 100644 index 0000000000..d062f0a241 Binary files /dev/null and b/docs/images/multi-channel-editing/mask-view-before.png differ diff --git a/docs/images/multi-channel-editing/mask-view-script.json b/docs/images/multi-channel-editing/mask-view-script.json new file mode 100644 index 0000000000..e466a1532f --- /dev/null +++ b/docs/images/multi-channel-editing/mask-view-script.json @@ -0,0 +1,128 @@ +[ + [ + "engine.execute", + { + "command": "file.new", + "params": { + "width": 800, + "height": 560, + "depth": 16, + "background": "white" + } + } + ], + [ + "engine.execute", + { + "command": "paint.gradient", + "params": { + "from": [ + 0, + 0 + ], + "to": [ + 800, + 560 + ], + "colors": [ + "#143b9b", + "#f6b552" + ] + } + } + ], + [ + "engine.execute", + { + "command": "layer.layerMask.revealAll", + "params": {} + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "engine.execute", + { + "command": "view.layerMask", + "params": { + "mode": "gray" + } + } + ], + [ + "ui.set", + { + "tool": "brush", + "activeTabs": { + "middle": "channels" + }, + "fit": true + } + ], + [ + "engine.execute", + { + "command": "tools.setColors", + "params": { + "foreground": [ + 0, + 0, + 0, + 1 + ] + } + } + ], + [ + "engine.execute", + { + "command": "tools.setBrush", + "params": { + "brush": { + "size": 70, + "hardness": 1.0, + "smoothing": { + "amount": 0.0 + } + } + } + } + ], + [ + "ui.pointer", + { + "events": [ + { + "kind": "down", + "x": 150, + "y": 200 + }, + { + "kind": "move", + "x": 400, + "y": 280 + }, + { + "kind": "move", + "x": 650, + "y": 200 + }, + { + "kind": "up", + "x": 650, + "y": 200 + } + ] + } + ] +] diff --git a/docs/images/multi-channel-editing/rgb-script.json b/docs/images/multi-channel-editing/rgb-script.json new file mode 100644 index 0000000000..1d6c107711 --- /dev/null +++ b/docs/images/multi-channel-editing/rgb-script.json @@ -0,0 +1,39 @@ +[ + [ + "engine.execute", + { + "command": "image.imageSize", + "params": { + "width": 1000, + "resample": "bicubic" + } + } + ], + [ + "engine.execute", + { + "command": "channel.target", + "params": { + "colors": [ + 0, + 2 + ] + } + } + ], + [ + "ui.set", + { + "dockTabs": { + "layers": 1 + }, + "fit": true + } + ], + [ + "ui.menu.invoke", + { + "id": "image.adjustments.curves" + } + ] +] diff --git a/docs/images/multi-channel-editing/rgb.png b/docs/images/multi-channel-editing/rgb.png new file mode 100644 index 0000000000..eb16aca38d Binary files /dev/null and b/docs/images/multi-channel-editing/rgb.png differ diff --git a/docs/integration-testing/approval-refresh.json b/docs/integration-testing/approval-refresh.json new file mode 100644 index 0000000000..9801df95f1 --- /dev/null +++ b/docs/integration-testing/approval-refresh.json @@ -0,0 +1,2602 @@ +{ + "refreshed_at": "2026-10-29T12:06:14.628928+00:00", + "frozen_count": 108, + "currently_qualifying_frozen_heads": 107, + "prs": [ + { + "number": 9703, + "url": "https://github.com/storytold/photocraft/pull/9703", + "frozen_head": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9", + "current_head": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T17:06:00Z", + "url": "https://github.com/storytold/photocraft/pull/9703#pullrequestreview-5655990760", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9" + } + } + }, + { + "number": 9651, + "url": "https://github.com/storytold/photocraft/pull/9651", + "frozen_head": "67666bdf72a58066df868868cef82e5b9cc838de", + "current_head": "67666bdf72a58066df868868cef82e5b9cc838de", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T11:38:57Z", + "url": "https://github.com/storytold/photocraft/pull/9651#pullrequestreview-5651366614", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "67666bdf72a58066df868868cef82e5b9cc838de" + } + } + }, + { + "number": 9623, + "url": "https://github.com/storytold/photocraft/pull/9623", + "frozen_head": "652075f4da7fe905ad651eebc3a6c276fe1d7a5d", + "current_head": "652075f4da7fe905ad651eebc3a6c276fe1d7a5d", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T08:29:25Z", + "url": "https://github.com/storytold/photocraft/pull/9623#pullrequestreview-5649212328", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "652075f4da7fe905ad651eebc3a6c276fe1d7a5d" + } + } + }, + { + "number": 9606, + "url": "https://github.com/storytold/photocraft/pull/9606", + "frozen_head": "6cc516a3d20120b678040050d95ccadc8d1dfd3f", + "current_head": "6cc516a3d20120b678040050d95ccadc8d1dfd3f", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T05:50:41Z", + "url": "https://github.com/storytold/photocraft/pull/9606#pullrequestreview-5647898187", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "6cc516a3d20120b678040050d95ccadc8d1dfd3f" + } + } + }, + { + "number": 9598, + "url": "https://github.com/storytold/photocraft/pull/9598", + "frozen_head": "47aea89e9220b996f0d5a0d2bb8b5938100b4ad1", + "current_head": "47aea89e9220b996f0d5a0d2bb8b5938100b4ad1", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T05:45:19Z", + "url": "https://github.com/storytold/photocraft/pull/9598#pullrequestreview-5647858443", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "47aea89e9220b996f0d5a0d2bb8b5938100b4ad1" + } + } + }, + { + "number": 9597, + "url": "https://github.com/storytold/photocraft/pull/9597", + "frozen_head": "9429df26b371c04c2afa65228a60ded739b5fbc2", + "current_head": "9429df26b371c04c2afa65228a60ded739b5fbc2", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T07:55:42Z", + "url": "https://github.com/storytold/photocraft/pull/9597#pullrequestreview-5648898287", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "9429df26b371c04c2afa65228a60ded739b5fbc2" + } + } + }, + { + "number": 9596, + "url": "https://github.com/storytold/photocraft/pull/9596", + "frozen_head": "e62013cfd652fa60470a0f7a185fbc8f3d2a2c92", + "current_head": "e62013cfd652fa60470a0f7a185fbc8f3d2a2c92", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T05:46:37Z", + "url": "https://github.com/storytold/photocraft/pull/9596#pullrequestreview-5647870259", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "e62013cfd652fa60470a0f7a185fbc8f3d2a2c92" + } + } + }, + { + "number": 9595, + "url": "https://github.com/storytold/photocraft/pull/9595", + "frozen_head": "75fa71a4f55a098061b12d91508b514565ab8dcf", + "current_head": "75fa71a4f55a098061b12d91508b514565ab8dcf", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T05:48:01Z", + "url": "https://github.com/storytold/photocraft/pull/9595#pullrequestreview-5647882992", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "75fa71a4f55a098061b12d91508b514565ab8dcf" + } + } + }, + { + "number": 9594, + "url": "https://github.com/storytold/photocraft/pull/9594", + "frozen_head": "46512521fec2b4b462c29d9fefd16398cb81212c", + "current_head": "46512521fec2b4b462c29d9fefd16398cb81212c", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T05:49:21Z", + "url": "https://github.com/storytold/photocraft/pull/9594#pullrequestreview-5647890395", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "46512521fec2b4b462c29d9fefd16398cb81212c" + } + } + }, + { + "number": 9590, + "url": "https://github.com/storytold/photocraft/pull/9590", + "frozen_head": "a9744ed44dc782613b19a97b8c159a091f73e669", + "current_head": "a9744ed44dc782613b19a97b8c159a091f73e669", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T05:54:47Z", + "url": "https://github.com/storytold/photocraft/pull/9590#pullrequestreview-5647923229", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "a9744ed44dc782613b19a97b8c159a091f73e669" + } + } + }, + { + "number": 9589, + "url": "https://github.com/storytold/photocraft/pull/9589", + "frozen_head": "da157945077190e550b53a5e14b0536f62761292", + "current_head": "da157945077190e550b53a5e14b0536f62761292", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T03:32:19Z", + "url": "https://github.com/storytold/photocraft/pull/9589#pullrequestreview-5647036365", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "da157945077190e550b53a5e14b0536f62761292" + } + } + }, + { + "number": 9584, + "url": "https://github.com/storytold/photocraft/pull/9584", + "frozen_head": "fc0f7b1132f799b5bba7be642c9047499d95a887", + "current_head": "fc0f7b1132f799b5bba7be642c9047499d95a887", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T03:41:38Z", + "url": "https://github.com/storytold/photocraft/pull/9584#pullrequestreview-5647082363", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "fc0f7b1132f799b5bba7be642c9047499d95a887" + } + } + }, + { + "number": 9523, + "url": "https://github.com/storytold/photocraft/pull/9523", + "frozen_head": "728782c595e768bdb6febc7f30e4d1456d13fd36", + "current_head": "728782c595e768bdb6febc7f30e4d1456d13fd36", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T23:36:04Z", + "url": "https://github.com/storytold/photocraft/pull/9523#pullrequestreview-5645920841", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "728782c595e768bdb6febc7f30e4d1456d13fd36" + } + } + }, + { + "number": 9517, + "url": "https://github.com/storytold/photocraft/pull/9517", + "frozen_head": "81b434c6149732eadc94e0465fea81bd075facd4", + "current_head": "81b434c6149732eadc94e0465fea81bd075facd4", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T19:39:44Z", + "url": "https://github.com/storytold/photocraft/pull/9517#pullrequestreview-5644911791", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "81b434c6149732eadc94e0465fea81bd075facd4" + } + } + }, + { + "number": 9512, + "url": "https://github.com/storytold/photocraft/pull/9512", + "frozen_head": "6c55561128caf5ac917eb3d174e8e7bb4bd48e23", + "current_head": "6c55561128caf5ac917eb3d174e8e7bb4bd48e23", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T19:23:44Z", + "url": "https://github.com/storytold/photocraft/pull/9512#pullrequestreview-5644863033", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "6c55561128caf5ac917eb3d174e8e7bb4bd48e23" + } + } + }, + { + "number": 9509, + "url": "https://github.com/storytold/photocraft/pull/9509", + "frozen_head": "d78d7493d3f0b6e760c88de969ac810e9bc5d910", + "current_head": "d78d7493d3f0b6e760c88de969ac810e9bc5d910", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T18:46:42Z", + "url": "https://github.com/storytold/photocraft/pull/9509#pullrequestreview-5644707748", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "d78d7493d3f0b6e760c88de969ac810e9bc5d910" + } + } + }, + { + "number": 9504, + "url": "https://github.com/storytold/photocraft/pull/9504", + "frozen_head": "993c9ed167c6acd9bc8f87c6cff6e2c7abba17a8", + "current_head": "993c9ed167c6acd9bc8f87c6cff6e2c7abba17a8", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T18:30:54Z", + "url": "https://github.com/storytold/photocraft/pull/9504#pullrequestreview-5644657832", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "993c9ed167c6acd9bc8f87c6cff6e2c7abba17a8" + } + } + }, + { + "number": 9493, + "url": "https://github.com/storytold/photocraft/pull/9493", + "frozen_head": "61a413d8df9e90ee545eb08e57c5615aa2b29cea", + "current_head": "61a413d8df9e90ee545eb08e57c5615aa2b29cea", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T18:35:43Z", + "url": "https://github.com/storytold/photocraft/pull/9493#pullrequestreview-5644672718", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "61a413d8df9e90ee545eb08e57c5615aa2b29cea" + } + } + }, + { + "number": 9481, + "url": "https://github.com/storytold/photocraft/pull/9481", + "frozen_head": "7937adff961027154d795b8905e821244ae4f929", + "current_head": "7937adff961027154d795b8905e821244ae4f929", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T18:03:55Z", + "url": "https://github.com/storytold/photocraft/pull/9481#pullrequestreview-5644538284", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "7937adff961027154d795b8905e821244ae4f929" + } + } + }, + { + "number": 9478, + "url": "https://github.com/storytold/photocraft/pull/9478", + "frozen_head": "cc208ee0440bbdf1858565dca49667272a490c56", + "current_head": "cc208ee0440bbdf1858565dca49667272a490c56", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T17:58:39Z", + "url": "https://github.com/storytold/photocraft/pull/9478#pullrequestreview-5644503679", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "cc208ee0440bbdf1858565dca49667272a490c56" + } + } + }, + { + "number": 9472, + "url": "https://github.com/storytold/photocraft/pull/9472", + "frozen_head": "4bab657cd4fb4a91ced44d3721e63a002ca5068c", + "current_head": "4bab657cd4fb4a91ced44d3721e63a002ca5068c", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T19:17:41Z", + "url": "https://github.com/storytold/photocraft/pull/9472#pullrequestreview-5644841088", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "4bab657cd4fb4a91ced44d3721e63a002ca5068c" + } + } + }, + { + "number": 9457, + "url": "https://github.com/storytold/photocraft/pull/9457", + "frozen_head": "2863c3281c2a22dade0ce92d42bf8900074f76c6", + "current_head": "2863c3281c2a22dade0ce92d42bf8900074f76c6", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T15:24:36Z", + "url": "https://github.com/storytold/photocraft/pull/9457#pullrequestreview-5643852329", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "2863c3281c2a22dade0ce92d42bf8900074f76c6" + } + } + }, + { + "number": 9436, + "url": "https://github.com/storytold/photocraft/pull/9436", + "frozen_head": "819f2692db4302019638c57b6ea34cd1e8f4c24b", + "current_head": "819f2692db4302019638c57b6ea34cd1e8f4c24b", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T22:16:33Z", + "url": "https://github.com/storytold/photocraft/pull/9436#pullrequestreview-5645623601", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "819f2692db4302019638c57b6ea34cd1e8f4c24b" + } + } + }, + { + "number": 9418, + "url": "https://github.com/storytold/photocraft/pull/9418", + "frozen_head": "da4b838e54f6073cda4798fa7b63b3b7214aaf93", + "current_head": "da4b838e54f6073cda4798fa7b63b3b7214aaf93", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T18:18:42Z", + "url": "https://github.com/storytold/photocraft/pull/9418#pullrequestreview-5644614398", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "da4b838e54f6073cda4798fa7b63b3b7214aaf93" + } + } + }, + { + "number": 9416, + "url": "https://github.com/storytold/photocraft/pull/9416", + "frozen_head": "742b06805284d8d76d5a29ec1821193f4835fedf", + "current_head": "742b06805284d8d76d5a29ec1821193f4835fedf", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T11:36:34Z", + "url": "https://github.com/storytold/photocraft/pull/9416#pullrequestreview-5642992245", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "742b06805284d8d76d5a29ec1821193f4835fedf" + } + } + }, + { + "number": 9401, + "url": "https://github.com/storytold/photocraft/pull/9401", + "frozen_head": "f241bba1adfb9c7ec5dd5ed9d86f95b63c7bc2c3", + "current_head": "f241bba1adfb9c7ec5dd5ed9d86f95b63c7bc2c3", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T11:13:17Z", + "url": "https://github.com/storytold/photocraft/pull/9401#pullrequestreview-5642877921", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "f241bba1adfb9c7ec5dd5ed9d86f95b63c7bc2c3" + } + } + }, + { + "number": 9354, + "url": "https://github.com/storytold/photocraft/pull/9354", + "frozen_head": "41eb1de5cff17f5f5b9c22913e48b71565b56a2a", + "current_head": "41eb1de5cff17f5f5b9c22913e48b71565b56a2a", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T06:55:52Z", + "url": "https://github.com/storytold/photocraft/pull/9354#pullrequestreview-5641946837", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "41eb1de5cff17f5f5b9c22913e48b71565b56a2a" + } + } + }, + { + "number": 9342, + "url": "https://github.com/storytold/photocraft/pull/9342", + "frozen_head": "c46a74621d36adae9707ab8f3eb39cf6b4c302b9", + "current_head": "c46a74621d36adae9707ab8f3eb39cf6b4c302b9", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T07:21:10Z", + "url": "https://github.com/storytold/photocraft/pull/9342#pullrequestreview-5642054720", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "c46a74621d36adae9707ab8f3eb39cf6b4c302b9" + } + } + }, + { + "number": 9339, + "url": "https://github.com/storytold/photocraft/pull/9339", + "frozen_head": "393645ab91460af2e37cc3ba8f6921e7551f676e", + "current_head": "393645ab91460af2e37cc3ba8f6921e7551f676e", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T07:36:38Z", + "url": "https://github.com/storytold/photocraft/pull/9339#pullrequestreview-5642090549", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "393645ab91460af2e37cc3ba8f6921e7551f676e" + } + } + }, + { + "number": 9282, + "url": "https://github.com/storytold/photocraft/pull/9282", + "frozen_head": "20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce", + "current_head": "20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-26T04:14:47Z", + "url": "https://github.com/storytold/photocraft/pull/9282#pullrequestreview-5647322262", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "20803d1f15900ed6ecc7b5c79b4c68f6c3d6dcce" + } + } + }, + { + "number": 9172, + "url": "https://github.com/storytold/photocraft/pull/9172", + "frozen_head": "8ebb04999d25790f71f9bd88f7a268a6a2754aec", + "current_head": "8ebb04999d25790f71f9bd88f7a268a6a2754aec", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T18:18:17Z", + "url": "https://github.com/storytold/photocraft/pull/9172#pullrequestreview-5639390920", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "8ebb04999d25790f71f9bd88f7a268a6a2754aec" + } + } + }, + { + "number": 9110, + "url": "https://github.com/storytold/photocraft/pull/9110", + "frozen_head": "2e716f93b2e2135944e07d821be4eb38fce13ec9", + "current_head": "2e716f93b2e2135944e07d821be4eb38fce13ec9", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:41:50Z", + "url": "https://github.com/storytold/photocraft/pull/9110#pullrequestreview-5639002103", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "2e716f93b2e2135944e07d821be4eb38fce13ec9" + } + } + }, + { + "number": 9019, + "url": "https://github.com/storytold/photocraft/pull/9019", + "frozen_head": "364c4be1c19728aefa601ee924bc7a0cd6a09789", + "current_head": "364c4be1c19728aefa601ee924bc7a0cd6a09789", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:33:36Z", + "url": "https://github.com/storytold/photocraft/pull/9019#pullrequestreview-5638973719", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "364c4be1c19728aefa601ee924bc7a0cd6a09789" + } + } + }, + { + "number": 9015, + "url": "https://github.com/storytold/photocraft/pull/9015", + "frozen_head": "afb4364a8795c42e6353762891b4890d878f9a68", + "current_head": "afb4364a8795c42e6353762891b4890d878f9a68", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:33:42Z", + "url": "https://github.com/storytold/photocraft/pull/9015#pullrequestreview-5638974295", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "afb4364a8795c42e6353762891b4890d878f9a68" + } + } + }, + { + "number": 9004, + "url": "https://github.com/storytold/photocraft/pull/9004", + "frozen_head": "39cf823cdfe486bb967cf81df725d229f7eed903", + "current_head": "39cf823cdfe486bb967cf81df725d229f7eed903", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:33:48Z", + "url": "https://github.com/storytold/photocraft/pull/9004#pullrequestreview-5638974843", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "39cf823cdfe486bb967cf81df725d229f7eed903" + } + } + }, + { + "number": 8986, + "url": "https://github.com/storytold/photocraft/pull/8986", + "frozen_head": "390dfbbcb026b33d46c52c62360b65deca00f258", + "current_head": "390dfbbcb026b33d46c52c62360b65deca00f258", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:36:01Z", + "url": "https://github.com/storytold/photocraft/pull/8986#pullrequestreview-5638981860", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "390dfbbcb026b33d46c52c62360b65deca00f258" + } + } + }, + { + "number": 8985, + "url": "https://github.com/storytold/photocraft/pull/8985", + "frozen_head": "8e695caf885b529f01f482b3f0c90cdb34df1915", + "current_head": "8e695caf885b529f01f482b3f0c90cdb34df1915", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:33:54Z", + "url": "https://github.com/storytold/photocraft/pull/8985#pullrequestreview-5638975214", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "8e695caf885b529f01f482b3f0c90cdb34df1915" + } + } + }, + { + "number": 8931, + "url": "https://github.com/storytold/photocraft/pull/8931", + "frozen_head": "d42dd4c6302c77c1f9dc46b7b53ffa5eb0047a13", + "current_head": "d42dd4c6302c77c1f9dc46b7b53ffa5eb0047a13", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:59:36Z", + "url": "https://github.com/storytold/photocraft/pull/8931#pullrequestreview-5639059166", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "d42dd4c6302c77c1f9dc46b7b53ffa5eb0047a13" + } + } + }, + { + "number": 8905, + "url": "https://github.com/storytold/photocraft/pull/8905", + "frozen_head": "0fdc6259193aab32b8feab7347ddc36196f40a19", + "current_head": "0fdc6259193aab32b8feab7347ddc36196f40a19", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:38:23Z", + "url": "https://github.com/storytold/photocraft/pull/8905#pullrequestreview-5638989196", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "0fdc6259193aab32b8feab7347ddc36196f40a19" + } + } + }, + { + "number": 8903, + "url": "https://github.com/storytold/photocraft/pull/8903", + "frozen_head": "d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88", + "current_head": "d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:38:30Z", + "url": "https://github.com/storytold/photocraft/pull/8903#pullrequestreview-5638989541", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88" + } + } + }, + { + "number": 8897, + "url": "https://github.com/storytold/photocraft/pull/8897", + "frozen_head": "eb06798f15516eb7e4fac24c98346fd9052e7f8e", + "current_head": "eb06798f15516eb7e4fac24c98346fd9052e7f8e", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:38:48Z", + "url": "https://github.com/storytold/photocraft/pull/8897#pullrequestreview-5638990476", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "eb06798f15516eb7e4fac24c98346fd9052e7f8e" + } + } + }, + { + "number": 8896, + "url": "https://github.com/storytold/photocraft/pull/8896", + "frozen_head": "19fa473039ef11deda7c3200c8fac1de41309086", + "current_head": "19fa473039ef11deda7c3200c8fac1de41309086", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-28T13:25:04Z", + "url": "https://github.com/storytold/photocraft/pull/8896#pullrequestreview-5683297464", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "19fa473039ef11deda7c3200c8fac1de41309086" + } + } + }, + { + "number": 8892, + "url": "https://github.com/storytold/photocraft/pull/8892", + "frozen_head": "9267c4fcf83a260d4bae0e441dc1af9bc48aa23b", + "current_head": "9267c4fcf83a260d4bae0e441dc1af9bc48aa23b", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-24T16:40:01Z", + "url": "https://github.com/storytold/photocraft/pull/8892#pullrequestreview-5638996509", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "9267c4fcf83a260d4bae0e441dc1af9bc48aa23b" + } + } + }, + { + "number": 8347, + "url": "https://github.com/storytold/photocraft/pull/8347", + "frozen_head": "2b1364393cfcd09380cf18a221e7cdd325fc4deb", + "current_head": "2b1364393cfcd09380cf18a221e7cdd325fc4deb", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T17:48:01Z", + "url": "https://github.com/storytold/photocraft/pull/8347#pullrequestreview-5644437741", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "2b1364393cfcd09380cf18a221e7cdd325fc4deb" + } + } + }, + { + "number": 8193, + "url": "https://github.com/storytold/photocraft/pull/8193", + "frozen_head": "864024609b872de3d869879f1632b951c896c6cd", + "current_head": "864024609b872de3d869879f1632b951c896c6cd", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T12:43:15Z", + "url": "https://github.com/storytold/photocraft/pull/8193#pullrequestreview-5643244034", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "864024609b872de3d869879f1632b951c896c6cd" + } + } + }, + { + "number": 8192, + "url": "https://github.com/storytold/photocraft/pull/8192", + "frozen_head": "412e3b7cf1f4836a5772d93391d21728dbe1f74f", + "current_head": "412e3b7cf1f4836a5772d93391d21728dbe1f74f", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T09:50:41Z", + "url": "https://github.com/storytold/photocraft/pull/8192#pullrequestreview-5642599218", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "412e3b7cf1f4836a5772d93391d21728dbe1f74f" + } + } + }, + { + "number": 8191, + "url": "https://github.com/storytold/photocraft/pull/8191", + "frozen_head": "91e4cec43525ca739c05cff614701fbd3576e389", + "current_head": "91e4cec43525ca739c05cff614701fbd3576e389", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T09:46:29Z", + "url": "https://github.com/storytold/photocraft/pull/8191#pullrequestreview-5642584382", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "91e4cec43525ca739c05cff614701fbd3576e389" + } + } + }, + { + "number": 8074, + "url": "https://github.com/storytold/photocraft/pull/8074", + "frozen_head": "290f924d0fecc3bf6bea159c0d9e86255d326a96", + "current_head": "290f924d0fecc3bf6bea159c0d9e86255d326a96", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-22T10:39:23Z", + "url": "https://github.com/storytold/photocraft/pull/8074#pullrequestreview-5612886202", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "290f924d0fecc3bf6bea159c0d9e86255d326a96" + } + } + }, + { + "number": 8039, + "url": "https://github.com/storytold/photocraft/pull/8039", + "frozen_head": "5579cf5439856fed93f41a669a3fc9b56d5813cf", + "current_head": "5579cf5439856fed93f41a669a3fc9b56d5813cf", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T22:54:54Z", + "url": "https://github.com/storytold/photocraft/pull/8039#pullrequestreview-5592809677", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "5579cf5439856fed93f41a669a3fc9b56d5813cf" + } + } + }, + { + "number": 7903, + "url": "https://github.com/storytold/photocraft/pull/7903", + "frozen_head": "5c30699087b02edcaa23397a0f7076bf503be048", + "current_head": "5c30699087b02edcaa23397a0f7076bf503be048", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T12:55:08Z", + "url": "https://github.com/storytold/photocraft/pull/7903#pullrequestreview-5643279271", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "5c30699087b02edcaa23397a0f7076bf503be048" + } + } + }, + { + "number": 7848, + "url": "https://github.com/storytold/photocraft/pull/7848", + "frozen_head": "389a1d5bd67072d5581c27989080222a7065b585", + "current_head": "389a1d5bd67072d5581c27989080222a7065b585", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T14:05:42Z", + "url": "https://github.com/storytold/photocraft/pull/7848#pullrequestreview-5586186420", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "389a1d5bd67072d5581c27989080222a7065b585" + } + } + }, + { + "number": 7743, + "url": "https://github.com/storytold/photocraft/pull/7743", + "frozen_head": "fd6fc92015fd08560362b4b32844edf97722f292", + "current_head": "fd6fc92015fd08560362b4b32844edf97722f292", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-19T17:02:06Z", + "url": "https://github.com/storytold/photocraft/pull/7743#pullrequestreview-5574964336", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "fd6fc92015fd08560362b4b32844edf97722f292" + } + } + }, + { + "number": 7742, + "url": "https://github.com/storytold/photocraft/pull/7742", + "frozen_head": "ab844f9cbcd1f1839ed7f286776331cb72e399aa", + "current_head": "ab844f9cbcd1f1839ed7f286776331cb72e399aa", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T14:12:28Z", + "url": "https://github.com/storytold/photocraft/pull/7742#pullrequestreview-5586281249", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "ab844f9cbcd1f1839ed7f286776331cb72e399aa" + } + } + }, + { + "number": 7648, + "url": "https://github.com/storytold/photocraft/pull/7648", + "frozen_head": "d24669d412f64ab079a668b0b8520ddbc607410c", + "current_head": "d24669d412f64ab079a668b0b8520ddbc607410c", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-28T17:15:54Z", + "url": "https://github.com/storytold/photocraft/pull/7648#pullrequestreview-5686770506", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "d24669d412f64ab079a668b0b8520ddbc607410c" + } + } + }, + { + "number": 7255, + "url": "https://github.com/storytold/photocraft/pull/7255", + "frozen_head": "1b810052bc3a876c1eaf1a8766870ffdba9f8a80", + "current_head": "1b810052bc3a876c1eaf1a8766870ffdba9f8a80", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-19T23:37:36Z", + "url": "https://github.com/storytold/photocraft/pull/7255#pullrequestreview-5579038009", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "1b810052bc3a876c1eaf1a8766870ffdba9f8a80" + } + } + }, + { + "number": 7153, + "url": "https://github.com/storytold/photocraft/pull/7153", + "frozen_head": "6f5d929d6172cf211a070f12cde9507aca9eb30a", + "current_head": "6f5d929d6172cf211a070f12cde9507aca9eb30a", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-27T23:20:12Z", + "url": "https://github.com/storytold/photocraft/pull/7153#pullrequestreview-5675752399", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "6f5d929d6172cf211a070f12cde9507aca9eb30a" + } + } + }, + { + "number": 6621, + "url": "https://github.com/storytold/photocraft/pull/6621", + "frozen_head": "ead45134d320cb1002724d5ef44f5c56588bbe1d", + "current_head": "ead45134d320cb1002724d5ef44f5c56588bbe1d", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T15:33:29Z", + "url": "https://github.com/storytold/photocraft/pull/6621#pullrequestreview-5643879866", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "ead45134d320cb1002724d5ef44f5c56588bbe1d" + } + } + }, + { + "number": 6510, + "url": "https://github.com/storytold/photocraft/pull/6510", + "frozen_head": "8bb0a5e67474d36ae08d565251edb1ae780dd044", + "current_head": "8bb0a5e67474d36ae08d565251edb1ae780dd044", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-27T08:54:40Z", + "url": "https://github.com/storytold/photocraft/pull/6510#pullrequestreview-5664421033", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "8bb0a5e67474d36ae08d565251edb1ae780dd044" + } + } + }, + { + "number": 6492, + "url": "https://github.com/storytold/photocraft/pull/6492", + "frozen_head": "657cfd503d4ce276df6b5e9f763cb3a5cf2deaeb", + "current_head": "657cfd503d4ce276df6b5e9f763cb3a5cf2deaeb", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T09:49:00Z", + "url": "https://github.com/storytold/photocraft/pull/6492#pullrequestreview-5583441089", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "657cfd503d4ce276df6b5e9f763cb3a5cf2deaeb" + } + } + }, + { + "number": 6449, + "url": "https://github.com/storytold/photocraft/pull/6449", + "frozen_head": "25c38a0b69b7bff31badcdbe2ef9f2ff6aba1c63", + "current_head": "25c38a0b69b7bff31badcdbe2ef9f2ff6aba1c63", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T14:07:53Z", + "url": "https://github.com/storytold/photocraft/pull/6449#pullrequestreview-5548906956", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "25c38a0b69b7bff31badcdbe2ef9f2ff6aba1c63" + } + } + }, + { + "number": 6387, + "url": "https://github.com/storytold/photocraft/pull/6387", + "frozen_head": "8bfbbf816025942b74130adf16fb07265817b691", + "current_head": "8bfbbf816025942b74130adf16fb07265817b691", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-17T00:45:37Z", + "url": "https://github.com/storytold/photocraft/pull/6387#pullrequestreview-5555146754", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "8bfbbf816025942b74130adf16fb07265817b691" + } + } + }, + { + "number": 6383, + "url": "https://github.com/storytold/photocraft/pull/6383", + "frozen_head": "74aadae69e3b3f5cd257bca1926acc1b1297b1a2", + "current_head": "74aadae69e3b3f5cd257bca1926acc1b1297b1a2", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T13:09:48Z", + "url": "https://github.com/storytold/photocraft/pull/6383#pullrequestreview-5548207780", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "74aadae69e3b3f5cd257bca1926acc1b1297b1a2" + } + } + }, + { + "number": 6236, + "url": "https://github.com/storytold/photocraft/pull/6236", + "frozen_head": "ce546dd4b5451a17b843ef1e129bdb40fbfe3160", + "current_head": "ce546dd4b5451a17b843ef1e129bdb40fbfe3160", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-25T16:15:01Z", + "url": "https://github.com/storytold/photocraft/pull/6236#pullrequestreview-5644088784", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "ce546dd4b5451a17b843ef1e129bdb40fbfe3160" + } + } + }, + { + "number": 6139, + "url": "https://github.com/storytold/photocraft/pull/6139", + "frozen_head": "e2347f8ead85d929193044f139788b850e31c06f", + "current_head": "e2347f8ead85d929193044f139788b850e31c06f", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T02:15:01Z", + "url": "https://github.com/storytold/photocraft/pull/6139#pullrequestreview-5542220545", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "e2347f8ead85d929193044f139788b850e31c06f" + } + } + }, + { + "number": 6026, + "url": "https://github.com/storytold/photocraft/pull/6026", + "frozen_head": "248ba660576f4896f69cbb6639353e2f1d295271", + "current_head": "248ba660576f4896f69cbb6639353e2f1d295271", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T09:43:20Z", + "url": "https://github.com/storytold/photocraft/pull/6026#pullrequestreview-5545897025", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "248ba660576f4896f69cbb6639353e2f1d295271" + } + } + }, + { + "number": 5939, + "url": "https://github.com/storytold/photocraft/pull/5939", + "frozen_head": "7348e060b4be7204fb69b59079c3b9d808e9aad3", + "current_head": "7348e060b4be7204fb69b59079c3b9d808e9aad3", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-17T23:23:14Z", + "url": "https://github.com/storytold/photocraft/pull/5939#pullrequestreview-5560602826", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "7348e060b4be7204fb69b59079c3b9d808e9aad3" + } + } + }, + { + "number": 5870, + "url": "https://github.com/storytold/photocraft/pull/5870", + "frozen_head": "f9de0a6abc78aa405b1ef46f5da6cba424106204", + "current_head": "f9de0a6abc78aa405b1ef46f5da6cba424106204", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T18:02:42Z", + "url": "https://github.com/storytold/photocraft/pull/5870#pullrequestreview-5551781697", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "f9de0a6abc78aa405b1ef46f5da6cba424106204" + } + } + }, + { + "number": 5744, + "url": "https://github.com/storytold/photocraft/pull/5744", + "frozen_head": "50b1f6734799cf7b5d45dd84868976afd31aa711", + "current_head": "50b1f6734799cf7b5d45dd84868976afd31aa711", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T00:22:49Z", + "url": "https://github.com/storytold/photocraft/pull/5744#pullrequestreview-5593443602", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "50b1f6734799cf7b5d45dd84868976afd31aa711" + } + } + }, + { + "number": 5650, + "url": "https://github.com/storytold/photocraft/pull/5650", + "frozen_head": "4b1bd6fb6d264f885cb9caa3b8a6578ee527bd7b", + "current_head": "4b1bd6fb6d264f885cb9caa3b8a6578ee527bd7b", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T00:12:41Z", + "url": "https://github.com/storytold/photocraft/pull/5650#pullrequestreview-5593380251", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "4b1bd6fb6d264f885cb9caa3b8a6578ee527bd7b" + } + } + }, + { + "number": 5515, + "url": "https://github.com/storytold/photocraft/pull/5515", + "frozen_head": "375d4894a119b6cd5933d53e8fe7f506d2783392", + "current_head": "375d4894a119b6cd5933d53e8fe7f506d2783392", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T00:12:20Z", + "url": "https://github.com/storytold/photocraft/pull/5515#pullrequestreview-5593377843", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "375d4894a119b6cd5933d53e8fe7f506d2783392" + } + } + }, + { + "number": 5493, + "url": "https://github.com/storytold/photocraft/pull/5493", + "frozen_head": "f97eb2849a68e1ed1c08c5b378770cf35cbcb72d", + "current_head": "67f57c3cf1e7de045aee03a305663d9560daa3e3", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": false, + "exclusions": [ + "head changed; newer head remains excluded", + "latest FredApps review is not an approval of current head" + ], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-27T12:23:56Z", + "url": "https://github.com/storytold/photocraft/pull/5493#pullrequestreview-5666969724", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "f97eb2849a68e1ed1c08c5b378770cf35cbcb72d" + } + } + }, + { + "number": 5453, + "url": "https://github.com/storytold/photocraft/pull/5453", + "frozen_head": "e22a9baedceefd97daffe84403b23c99c56d6919", + "current_head": "e22a9baedceefd97daffe84403b23c99c56d6919", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-17T20:51:16Z", + "url": "https://github.com/storytold/photocraft/pull/5453#pullrequestreview-5560030568", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "e22a9baedceefd97daffe84403b23c99c56d6919" + } + } + }, + { + "number": 5270, + "url": "https://github.com/storytold/photocraft/pull/5270", + "frozen_head": "9db1c7ae1d48ce4f31622b5f9029c70c590842f8", + "current_head": "9db1c7ae1d48ce4f31622b5f9029c70c590842f8", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T13:29:14Z", + "url": "https://github.com/storytold/photocraft/pull/5270#pullrequestreview-5585703891", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "9db1c7ae1d48ce4f31622b5f9029c70c590842f8" + } + } + }, + { + "number": 5176, + "url": "https://github.com/storytold/photocraft/pull/5176", + "frozen_head": "c96891c298151c5cb11a55928b9e1c27911f38bd", + "current_head": "c96891c298151c5cb11a55928b9e1c27911f38bd", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T13:39:04Z", + "url": "https://github.com/storytold/photocraft/pull/5176#pullrequestreview-5585830576", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "c96891c298151c5cb11a55928b9e1c27911f38bd" + } + } + }, + { + "number": 5165, + "url": "https://github.com/storytold/photocraft/pull/5165", + "frozen_head": "27bc6e4b37b701480efa69559ab13eafc52bd94c", + "current_head": "27bc6e4b37b701480efa69559ab13eafc52bd94c", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T15:13:12Z", + "url": "https://github.com/storytold/photocraft/pull/5165#pullrequestreview-5549755629", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "27bc6e4b37b701480efa69559ab13eafc52bd94c" + } + } + }, + { + "number": 5090, + "url": "https://github.com/storytold/photocraft/pull/5090", + "frozen_head": "61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206", + "current_head": "61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T13:46:28Z", + "url": "https://github.com/storytold/photocraft/pull/5090#pullrequestreview-5585928498", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "61d9e8cc1aed29ed6bea0d0fd2702fa87c4f0206" + } + } + }, + { + "number": 5082, + "url": "https://github.com/storytold/photocraft/pull/5082", + "frozen_head": "23bab3cc9204568aaeb012b9e1da70a2caacb879", + "current_head": "23bab3cc9204568aaeb012b9e1da70a2caacb879", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-18T09:23:19Z", + "url": "https://github.com/storytold/photocraft/pull/5082#pullrequestreview-5562323692", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "23bab3cc9204568aaeb012b9e1da70a2caacb879" + } + } + }, + { + "number": 5010, + "url": "https://github.com/storytold/photocraft/pull/5010", + "frozen_head": "cc865221894e21f846d92b721cce7421ea338363", + "current_head": "cc865221894e21f846d92b721cce7421ea338363", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T13:51:11Z", + "url": "https://github.com/storytold/photocraft/pull/5010#pullrequestreview-5585991159", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "cc865221894e21f846d92b721cce7421ea338363" + } + } + }, + { + "number": 4993, + "url": "https://github.com/storytold/photocraft/pull/4993", + "frozen_head": "81964dc5ef77a71a2ae2a938958cf3ed77d90719", + "current_head": "81964dc5ef77a71a2ae2a938958cf3ed77d90719", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T15:10:55Z", + "url": "https://github.com/storytold/photocraft/pull/4993#pullrequestreview-5549725641", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "81964dc5ef77a71a2ae2a938958cf3ed77d90719" + } + } + }, + { + "number": 4992, + "url": "https://github.com/storytold/photocraft/pull/4992", + "frozen_head": "194a8976cd6c14441222b287b9b4f4841a0eb2f9", + "current_head": "194a8976cd6c14441222b287b9b4f4841a0eb2f9", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T15:10:53Z", + "url": "https://github.com/storytold/photocraft/pull/4992#pullrequestreview-5549725271", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "194a8976cd6c14441222b287b9b4f4841a0eb2f9" + } + } + }, + { + "number": 4985, + "url": "https://github.com/storytold/photocraft/pull/4985", + "frozen_head": "1dc98f80f60e993f3edf20af815391b6a7e7add7", + "current_head": "1dc98f80f60e993f3edf20af815391b6a7e7add7", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-28T08:02:27Z", + "url": "https://github.com/storytold/photocraft/pull/4985#pullrequestreview-5679506261", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "1dc98f80f60e993f3edf20af815391b6a7e7add7" + } + } + }, + { + "number": 4938, + "url": "https://github.com/storytold/photocraft/pull/4938", + "frozen_head": "63b7c921634835259139e4d3e7b9d3c396998b4d", + "current_head": "63b7c921634835259139e4d3e7b9d3c396998b4d", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T13:55:39Z", + "url": "https://github.com/storytold/photocraft/pull/4938#pullrequestreview-5586050980", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "63b7c921634835259139e4d3e7b9d3c396998b4d" + } + } + }, + { + "number": 4890, + "url": "https://github.com/storytold/photocraft/pull/4890", + "frozen_head": "ab84cab7355ac999fb1be226950978bafec39605", + "current_head": "ab84cab7355ac999fb1be226950978bafec39605", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T00:37:37Z", + "url": "https://github.com/storytold/photocraft/pull/4890#pullrequestreview-5593534344", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "ab84cab7355ac999fb1be226950978bafec39605" + } + } + }, + { + "number": 4883, + "url": "https://github.com/storytold/photocraft/pull/4883", + "frozen_head": "ea500486fb40511d3b4782dfacbb89fbff846d8a", + "current_head": "ea500486fb40511d3b4782dfacbb89fbff846d8a", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:27:22Z", + "url": "https://github.com/storytold/photocraft/pull/4883#pullrequestreview-5583810948", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "ea500486fb40511d3b4782dfacbb89fbff846d8a" + } + } + }, + { + "number": 4783, + "url": "https://github.com/storytold/photocraft/pull/4783", + "frozen_head": "a9d62726bed607bc540db9d09c8dc1be4f9895aa", + "current_head": "a9d62726bed607bc540db9d09c8dc1be4f9895aa", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:26:56Z", + "url": "https://github.com/storytold/photocraft/pull/4783#pullrequestreview-5583807081", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "a9d62726bed607bc540db9d09c8dc1be4f9895aa" + } + } + }, + { + "number": 4763, + "url": "https://github.com/storytold/photocraft/pull/4763", + "frozen_head": "f2804a7cf013519d9ba8bac1da3d96458c4f656b", + "current_head": "f2804a7cf013519d9ba8bac1da3d96458c4f656b", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:17:19Z", + "url": "https://github.com/storytold/photocraft/pull/4763#pullrequestreview-5583718187", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "f2804a7cf013519d9ba8bac1da3d96458c4f656b" + } + } + }, + { + "number": 4742, + "url": "https://github.com/storytold/photocraft/pull/4742", + "frozen_head": "b7145b5a8afb85d0eef3922931d6749436966943", + "current_head": "b7145b5a8afb85d0eef3922931d6749436966943", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-16T15:12:19Z", + "url": "https://github.com/storytold/photocraft/pull/4742#pullrequestreview-5549743037", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "b7145b5a8afb85d0eef3922931d6749436966943" + } + } + }, + { + "number": 4711, + "url": "https://github.com/storytold/photocraft/pull/4711", + "frozen_head": "db546ff4822a011a7915b66af3834f94c05767db", + "current_head": "db546ff4822a011a7915b66af3834f94c05767db", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T03:44:19Z", + "url": "https://github.com/storytold/photocraft/pull/4711#pullrequestreview-5594717250", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "db546ff4822a011a7915b66af3834f94c05767db" + } + } + }, + { + "number": 4547, + "url": "https://github.com/storytold/photocraft/pull/4547", + "frozen_head": "a08f7a2295488c2692f68902835a693322017b64", + "current_head": "a08f7a2295488c2692f68902835a693322017b64", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:18:20Z", + "url": "https://github.com/storytold/photocraft/pull/4547#pullrequestreview-5583727527", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "a08f7a2295488c2692f68902835a693322017b64" + } + } + }, + { + "number": 4305, + "url": "https://github.com/storytold/photocraft/pull/4305", + "frozen_head": "1b74873991dbb2e9803d228a969291f09c6f1032", + "current_head": "1b74873991dbb2e9803d228a969291f09c6f1032", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T03:52:30Z", + "url": "https://github.com/storytold/photocraft/pull/4305#pullrequestreview-5594762253", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "1b74873991dbb2e9803d228a969291f09c6f1032" + } + } + }, + { + "number": 4042, + "url": "https://github.com/storytold/photocraft/pull/4042", + "frozen_head": "f05098562220a663d052ee3918fd36c8c3e17a51", + "current_head": "f05098562220a663d052ee3918fd36c8c3e17a51", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:20:17Z", + "url": "https://github.com/storytold/photocraft/pull/4042#pullrequestreview-5583745473", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "f05098562220a663d052ee3918fd36c8c3e17a51" + } + } + }, + { + "number": 4035, + "url": "https://github.com/storytold/photocraft/pull/4035", + "frozen_head": "15ebf8fc940031abdf772e5fdd687c8b55938639", + "current_head": "15ebf8fc940031abdf772e5fdd687c8b55938639", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T04:40:10Z", + "url": "https://github.com/storytold/photocraft/pull/4035#pullrequestreview-5595033656", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "15ebf8fc940031abdf772e5fdd687c8b55938639" + } + } + }, + { + "number": 4023, + "url": "https://github.com/storytold/photocraft/pull/4023", + "frozen_head": "303a5346fcb5961cf3a776024db58507b401e4ea", + "current_head": "303a5346fcb5961cf3a776024db58507b401e4ea", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T04:44:05Z", + "url": "https://github.com/storytold/photocraft/pull/4023#pullrequestreview-5595056228", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "303a5346fcb5961cf3a776024db58507b401e4ea" + } + } + }, + { + "number": 3967, + "url": "https://github.com/storytold/photocraft/pull/3967", + "frozen_head": "3a77519ce2cef0fb60cebd4ea796d599f68868cb", + "current_head": "3a77519ce2cef0fb60cebd4ea796d599f68868cb", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T04:46:51Z", + "url": "https://github.com/storytold/photocraft/pull/3967#pullrequestreview-5595072511", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "3a77519ce2cef0fb60cebd4ea796d599f68868cb" + } + } + }, + { + "number": 3874, + "url": "https://github.com/storytold/photocraft/pull/3874", + "frozen_head": "92488307722c3b840507827cac7ee9a88470fc8a", + "current_head": "92488307722c3b840507827cac7ee9a88470fc8a", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T04:51:17Z", + "url": "https://github.com/storytold/photocraft/pull/3874#pullrequestreview-5595098959", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "92488307722c3b840507827cac7ee9a88470fc8a" + } + } + }, + { + "number": 3748, + "url": "https://github.com/storytold/photocraft/pull/3748", + "frozen_head": "c45d01eafe84e600bb28033a6853cc19c827b7bf", + "current_head": "c45d01eafe84e600bb28033a6853cc19c827b7bf", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:26:31Z", + "url": "https://github.com/storytold/photocraft/pull/3748#pullrequestreview-5583803054", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "c45d01eafe84e600bb28033a6853cc19c827b7bf" + } + } + }, + { + "number": 3554, + "url": "https://github.com/storytold/photocraft/pull/3554", + "frozen_head": "dd21e325b515cbf67fd4a461ae5b3e1b195aa53d", + "current_head": "dd21e325b515cbf67fd4a461ae5b3e1b195aa53d", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:23:03Z", + "url": "https://github.com/storytold/photocraft/pull/3554#pullrequestreview-5583771056", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "dd21e325b515cbf67fd4a461ae5b3e1b195aa53d" + } + } + }, + { + "number": 3520, + "url": "https://github.com/storytold/photocraft/pull/3520", + "frozen_head": "d36ec9a21e9c1ed70de2e0d185efd7e084644157", + "current_head": "d36ec9a21e9c1ed70de2e0d185efd7e084644157", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:19:14Z", + "url": "https://github.com/storytold/photocraft/pull/3520#pullrequestreview-5583735883", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "d36ec9a21e9c1ed70de2e0d185efd7e084644157" + } + } + }, + { + "number": 3519, + "url": "https://github.com/storytold/photocraft/pull/3519", + "frozen_head": "807de6c043a512cb947d86b944c758b3db1277c7", + "current_head": "807de6c043a512cb947d86b944c758b3db1277c7", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-20T10:19:34Z", + "url": "https://github.com/storytold/photocraft/pull/3519#pullrequestreview-5583738841", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "807de6c043a512cb947d86b944c758b3db1277c7" + } + } + }, + { + "number": 3464, + "url": "https://github.com/storytold/photocraft/pull/3464", + "frozen_head": "1eecd00b2dff4ae4c642269cec211cd06ce1c3aa", + "current_head": "1eecd00b2dff4ae4c642269cec211cd06ce1c3aa", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T08:42:37Z", + "url": "https://github.com/storytold/photocraft/pull/3464#pullrequestreview-5597090618", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "1eecd00b2dff4ae4c642269cec211cd06ce1c3aa" + } + } + }, + { + "number": 3461, + "url": "https://github.com/storytold/photocraft/pull/3461", + "frozen_head": "5d8cb2a39ad70e31260f1f3fc2010ca132daff65", + "current_head": "5d8cb2a39ad70e31260f1f3fc2010ca132daff65", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T08:45:31Z", + "url": "https://github.com/storytold/photocraft/pull/3461#pullrequestreview-5597122170", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "5d8cb2a39ad70e31260f1f3fc2010ca132daff65" + } + } + }, + { + "number": 3455, + "url": "https://github.com/storytold/photocraft/pull/3455", + "frozen_head": "d84fa7955d260505378a1a596ec80d602036319d", + "current_head": "d84fa7955d260505378a1a596ec80d602036319d", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T08:46:06Z", + "url": "https://github.com/storytold/photocraft/pull/3455#pullrequestreview-5597131410", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "d84fa7955d260505378a1a596ec80d602036319d" + } + } + }, + { + "number": 3387, + "url": "https://github.com/storytold/photocraft/pull/3387", + "frozen_head": "58b83f42a4b39cf56751be53ec8d227efee70f68", + "current_head": "58b83f42a4b39cf56751be53ec8d227efee70f68", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T08:51:49Z", + "url": "https://github.com/storytold/photocraft/pull/3387#pullrequestreview-5597200428", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "58b83f42a4b39cf56751be53ec8d227efee70f68" + } + } + }, + { + "number": 3345, + "url": "https://github.com/storytold/photocraft/pull/3345", + "frozen_head": "bfb691f84896fe68543980426632153dc4b4ea69", + "current_head": "bfb691f84896fe68543980426632153dc4b4ea69", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T08:57:39Z", + "url": "https://github.com/storytold/photocraft/pull/3345#pullrequestreview-5597265645", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "bfb691f84896fe68543980426632153dc4b4ea69" + } + } + }, + { + "number": 3142, + "url": "https://github.com/storytold/photocraft/pull/3142", + "frozen_head": "e2ed234e654e574109ffcdb9756f8e8d1b8a00b2", + "current_head": "e2ed234e654e574109ffcdb9756f8e8d1b8a00b2", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T09:18:10Z", + "url": "https://github.com/storytold/photocraft/pull/3142#pullrequestreview-5597503674", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "e2ed234e654e574109ffcdb9756f8e8d1b8a00b2" + } + } + }, + { + "number": 3139, + "url": "https://github.com/storytold/photocraft/pull/3139", + "frozen_head": "4da79e8d16ded8b51f06c1ac64e50ccba288274e", + "current_head": "4da79e8d16ded8b51f06c1ac64e50ccba288274e", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T09:17:33Z", + "url": "https://github.com/storytold/photocraft/pull/3139#pullrequestreview-5597496876", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "4da79e8d16ded8b51f06c1ac64e50ccba288274e" + } + } + }, + { + "number": 2300, + "url": "https://github.com/storytold/photocraft/pull/2300", + "frozen_head": "ebf3777aec961d1815aa141d9987eaa645c2273f", + "current_head": "ebf3777aec961d1815aa141d9987eaa645c2273f", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-18T18:14:16Z", + "url": "https://github.com/storytold/photocraft/pull/2300#pullrequestreview-5564229610", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "ebf3777aec961d1815aa141d9987eaa645c2273f" + } + } + }, + { + "number": 1991, + "url": "https://github.com/storytold/photocraft/pull/1991", + "frozen_head": "202266b90613280e16ae285b53dd4e10d5f0c7ac", + "current_head": "202266b90613280e16ae285b53dd4e10d5f0c7ac", + "state": "OPEN", + "isDraft": false, + "current_rollup": { + "state": "SUCCESS" + }, + "currently_qualifies": true, + "exclusions": [], + "approval_receipt": { + "state": "APPROVED", + "submittedAt": "2026-10-21T09:51:24Z", + "url": "https://github.com/storytold/photocraft/pull/1991#pullrequestreview-5597895651", + "author": { + "login": "FredApps" + }, + "commit": { + "oid": "202266b90613280e16ae285b53dd4e10d5f0c7ac" + } + } + } + ] +} diff --git a/docs/integration-testing/approved-bugfix-parity-2026-10-29.md b/docs/integration-testing/approved-bugfix-parity-2026-10-29.md new file mode 100644 index 0000000000..b6643f7ca8 --- /dev/null +++ b/docs/integration-testing/approved-bugfix-parity-2026-10-29.md @@ -0,0 +1,326 @@ +> HISTORICAL 108-PR BUILD: this record describes the earlier source and artifact. The current branch has 21 requested PRs removed. See [trimmed integration](trimmed-integration-2026-10-29.md) for current scope and validation. Do not use the old portable artifact to validate these removals. + +# PhotoCraft integration test plan + +Status: TEST BUILD PREPARED; automated validation passed. Every manual case is NOT RUN. +This plan supplements the existing gates. It does not claim Photoshop parity or authorize disabling checks. + +## Provenance and release gate + +At selection freeze, GitHub showed 108 open, non-draft, unmerged PRs, each with the latest +FredApps review APPROVED at its exact current head, no unresolved inline threads, and successful +current-head check rollup. Full heads, review receipts, checks, changed files and authored regression +descriptions are in [approved-selection.json](approved-selection.json). Successful rollup can include +skipped Windows/GPU jobs; record actual job and adapter execution separately. Recheck heads before final delivery. + +Base: `5f9b4391874aabc9deb2fcb905267613e4d93ec8`. Branch: `integration/approved-bugfix-parity-2026-10-29`. +Record the final commit SHA, toolchain, OS, adapter/backend, font inputs, corpus pins, commands, +pass/fail/ignored counts, duration, output log/artifact URLs, and every skipped or unavailable stage. +Require every selected head to be an ancestor of the tested SHA. Never equate a merged-head ancestry +check with preservation of its behavior: rerun the regressions after conflict resolutions. + +## Source checkout checks (PowerShell) + +These commands require a source checkout, Rust and the repository's documented dependencies. They are separate from testing the portable ZIP. Use a dedicated PowerShell session in the checkout: + +```powershell +$env:PHOTOCRAFT_NO_PRINT = '1' +$env:LPDEST = 'photocraft-no-such-printer' +$env:PHOTOCRAFT_FULL_SWEEP = '1' +# Optional isolated build output (can require rebuilding dependencies): +$env:CARGO_TARGET_DIR = Join-Path $PWD 'target\integration-approved' +cargo test --workspace --locked +``` + +Only the Windows Intel/DX12 case (#9110) requests `$env:WGPU_BACKEND = 'dx12'`; set it in that dedicated test session and record the actual adapter/backend. Do not infer GPU execution from a green test that returned early. Portable ZIP testers do not need Cargo; use the Windows execution sheet below. + +## Automated execution + +Set `PHOTOCRAFT_NO_PRINT=1` and `LPDEST=photocraft-no-such-printer` before tests. +Use `CARGO_TARGET_DIR=target/integration-approved` for local work. Keep the checked-in lockfile and +repository Cargo configuration; no source substitutions or dependency pin changes to bypass failures. + +1. `cargo fmt --all -- --check`; `cargo metadata --locked --format-version 1`. +2. `cargo xtask ci` (the checked-in gate: formatting, strict clippy, layers, scorecard, tests, + command panic hunt, doctests, wasm and i18n reporting). Capture each constituent stage. +3. Set `PHOTOCRAFT_FULL_SWEEP=1`; run `cargo test --workspace --locked`. Daily quarter sweeps + are insufficient for the final gate. Repeat affected crates after each substantive resolution. +4. `cargo xtask test-corpus`: fetch pinned SHA256-verified corpora and execute opt-in corpus tests. + Plain workspace tests omit these. Record pin revisions and corpus floors, never lower them. +5. `cargo test -p photocraft-engine --test panic_hunt -- --ignored` for command changes; + `cargo xtask layers`; `cargo xtask wasm`; command additions require `cargo xtask parity`. +6. Run CPU and GPU parity regressions with an actual adapter. Record backend/adapter and tests that + return early without one. Include 8/16/32f, RGB/Gray/CMYK where supported, masks, clipping, + Fill versus Opacity, extreme finite HDR and malformed-input refusal. +7. Run performance examples in release on public synthetic 24–36 MP images. Record baseline and + integrated times, peak memory, dimensions, threads and output equivalence. No timing claims + from debug builds or while other builds contend for the machine. +8. Dispatch existing `ci.yml` manually on the fork branch. A branch push does not trigger this + workflow. Manual dispatch selects Windows and web. Capture Linux, Windows unit/integration, + corpus, wasm/web size, HEIF/non-HEIF and font-enabled job outcomes separately. macOS native + interaction requires separate execution; a Windows/Linux pass does not cover it. + +## Manual matrix — all NOT RUN + +For each case save inputs, steps, screenshots/output files, expected versus actual, application versions, +exact PhotoCraft SHA, tester and date. Use public-domain or synthetic images and licensed fonts. + +| Case | Steps | Expected result | Status | +|---|---|---|---| +| M01 File exchange | Open Photoshop-authored layered PSD/PSB; edit type, masks, Blend If and smart filters; save native and PSD; reopen each in PhotoCraft and PSD in Photoshop. | Structure, editable properties and appearance survive; Photoshop accepts the exported file. PhotoCraft round-trip alone does not satisfy this oracle. | NOT RUN | +| M02 Effective profile | Untagged RGB with sRGB, Display P3 and Adobe RGB working spaces; export sRGB at 8/16/32f, with embedding on/off; repeat tagged-source precedence, Gray and CMYK; inspect preview and actual ICC/pixels. | Real conversion uses captured source profile, tagged sources win, preview invalidates on working-space change; embedding off writes none; sources unchanged. | NOT RUN | +| M03 Combined adjustment regressions | Constant bounded lookup over huge positive/negative 32f backdrop at full coverage; then each special-eight mode at reduced Fill, Opacity and mask, unclipped and clipped; compare CPU/GPU and Photoshop reference. | #9509 exact full-coverage result remains; #9512 Fill changes blend while Opacity/mask control coverage, and clipping follows the intended rule. Preserve both regression suites. | NOT RUN | +| M04 Blend If | Split/rejoin handles with Alt, hard boundaries at 8/16-bit and Gray; adjust opacity on This Layer; Pass Through group with Blend If. | Correct Photoshop boundary and feathering behavior; group isolation; CPU/GPU agree within established tolerance. | NOT RUN | +| M05 Typography | Vertical upright/rotated Roman type, closed-path seam caret clicks and wrapping, hanging punctuation; copy type between 72/300 ppi then edit; save PSD and reopen in Photoshop. | Layout/caret stable, pixel size survives edits, metadata and editable text retained. Font-dependent skips are explicit. | NOT RUN | +| M06 Painting | 1 px Pencil turns; Smudge pickup; Mixer Brush Alt sampling; brush blur size and section selections; selections/masks and Undo/Redo at all depths. | Continuous intended strokes, correct sampling and bounded buffers; one expected history step, no source corruption. | NOT RUN | +| M07 Selection and geometry | Fixed-size Marquee; Polygonal Lasso edge scrolling; guide drag/rotate/delete; crop menu; saved-path Pen edits and gestures; Select and Mask; transform interpolation. | Correct target/document ownership, dimensions and modifier behavior; cancellation and Undo/Redo preserve state. | NOT RUN | +| M08 UI and modal safety | Short-window Layer Style, defaults and dependent layout changes; Color panel persistence; shortcut capture; detached document; home screen disabled controls; modal automation requests. | Controls reachable at supported scales/themes, state persists correctly, no edits behind modal dialogs, no focus/gesture leakage. | NOT RUN | +| M09 Actions and automation | Import/export JSON actions including nested references, record/replay layer visibility and fractional params; undo definition edits; invalid paths, occupied control port and plugin memory budget. | Replay targets intended named layers; malformed input refuses atomically, workspace authorization holds, failures are clear and bounded. | NOT RUN | +| M10 Platform/package | Launch native app and CLI from portable Windows zip, Linux AppImage and macOS droplet bundle; test spaced paths, exit status and stdio; check startup failures and macOS creation time. | Correct bundles/CLI routing and diagnostics, stable metadata; no print side effects. | NOT RUN | +| M11 Independent rerender | Change a smart-filter or type property after import to invalidate cached pixels; render through the engine; compare against authored Photoshop oracle. | Actual engine rerender meets the asserted oracle. Cached PSD composite tests alone are insufficient. | NOT RUN | + +## Integration risk and order + +Preserve Cutout dependency order #8191 → #8192 → #8193 → #7903 and Layer Style #4938 → #5010. +#9339 and #9342 are complementary: session-aware Export As conversion versus IO/Save for Web +profile embedding. Their shared export tests must retain both expectations. #9509/#9512 modify +`blend_back`: retain Fill-aware `blend_rgb_fill` and numerically stable `b*(1-k)+s*k` together. +Older branches predate engine/UI module splits; migrate each changed behavior and regression into +the current modules rather than resurrecting obsolete monolithic files. Substantive choices require +parent review. Verify no feature silently disappears in conflict resolution. + +## Per-PR regression inventory + +See the [executable case appendix](per-pr-regressions.md) for fixture/setup, action, expected outcome and blank result fields for every selected PR. Full reviewed-head receipts and checks are in [selection evidence](approved-selection.json). + +### Actions and automation + +- [#1991](https://github.com/storytold/photocraft/pull/1991) feat(actions): import and export reusable Actions JSON files — NOT RUN +- [#3455](https://github.com/storytold/photocraft/pull/3455) Undo and redo action definition edits from the Actions panel — NOT RUN +- [#3461](https://github.com/storytold/photocraft/pull/3461) Replay recorded layer visibility on the intended named layer — NOT RUN +- [#3748](https://github.com/storytold/photocraft/pull/3748) control: suggest near matches for unknown command ids and methods (#3674) — NOT RUN +- [#4993](https://github.com/storytold/photocraft/pull/4993) Prevent control-channel engine commands from editing behind modal dialogs — NOT RUN +- [#5090](https://github.com/storytold/photocraft/pull/5090) Actions: add editable PhotoCraft Essentials starter presets — NOT RUN +- [#5176](https://github.com/storytold/photocraft/pull/5176) Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale — NOT RUN +- [#6492](https://github.com/storytold/photocraft/pull/6492) Fail control startup promptly when its port is occupied — NOT RUN +- [#6510](https://github.com/storytold/photocraft/pull/6510) Automation: resolve configured root aliases for absolute paths (#6426) — NOT RUN +- [#7742](https://github.com/storytold/photocraft/pull/7742) Actions: a fractional setting an integer field cannot hold is refused, not rounded — NOT RUN +- [#8905](https://github.com/storytold/photocraft/pull/8905) fix(plugins): bound a run's total plug-in memory, not just each instance — NOT RUN + +### Compositing and filters + +- [#3874](https://github.com/storytold/photocraft/pull/3874) fix(layer-style): support Blend If Alt-split handle feathering and throttle live preview — NOT RUN +- [#3967](https://github.com/storytold/photocraft/pull/3967) Add move and delete actions to Smart Filter rows — NOT RUN +- [#4305](https://github.com/storytold/photocraft/pull/4305) Filter > Other > Frequency Separation (#1714) — NOT RUN +- [#4711](https://github.com/storytold/photocraft/pull/4711) Treat Dissolve as a per-pixel choice — NOT RUN +- [#4992](https://github.com/storytold/photocraft/pull/4992) Preserve Dissolve coverage when compositing clipped layers — NOT RUN +- [#7153](https://github.com/storytold/photocraft/pull/7153) Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace — NOT RUN +- [#7903](https://github.com/storytold/photocraft/pull/7903) Filter Gallery › Cutout 4/4: trace the image into flat paper shapes — NOT RUN +- [#8039](https://github.com/storytold/photocraft/pull/8039) Compose: Extend Softer outer glows past Size (#7899) — NOT RUN +- [#8191](https://github.com/storytold/photocraft/pull/8191) Filter Gallery › Cutout 1/4: colour and palette stage — NOT RUN +- [#8192](https://github.com/storytold/photocraft/pull/8192) Filter Gallery › Cutout 2/4: regions stage — NOT RUN +- [#8193](https://github.com/storytold/photocraft/pull/8193) Filter Gallery › Cutout 3/4: edges and curves stage — NOT RUN +- [#8903](https://github.com/storytold/photocraft/pull/8903) Box-filter minified patterns in the CPU and GPU samplers (#8831) — NOT RUN +- [#8931](https://github.com/storytold/photocraft/pull/8931) feat(algo): describe the Gaussian blur as data for other devices — NOT RUN +- [#9472](https://github.com/storytold/photocraft/pull/9472) fix(io): import a Displace smart filter that names its map file without embedding it (#9162) — NOT RUN +- [#9509](https://github.com/storytold/photocraft/pull/9509) fix: an edit at full coverage keeps its result over huge 32-bit values — NOT RUN +- [#9512](https://github.com/storytold/photocraft/pull/9512) fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes — NOT RUN +- [#9584](https://github.com/storytold/photocraft/pull/9584) fix(compose, gpu): Blend If hard points match Photoshop at 16 bits and for Gray — NOT RUN +- [#9589](https://github.com/storytold/photocraft/pull/9589) fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity — NOT RUN +- [#9595](https://github.com/storytold/photocraft/pull/9595) fix(compose, gpu): a Pass Through group with Blend If renders isolated — NOT RUN +- [#9651](https://github.com/storytold/photocraft/pull/9651) perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) — NOT RUN + +### File fidelity and colour + +- [#3142](https://github.com/storytold/photocraft/pull/3142) Read applied Sony RAW metadata and crop ILCE-6000 images — NOT RUN +- [#3387](https://github.com/storytold/photocraft/pull/3387) feat(codecs): add lossless JPEG XL export for 8/16-bit images — NOT RUN +- [#3519](https://github.com/storytold/photocraft/pull/3519) feat(export): optionally trim transparent margins from layer files — NOT RUN +- [#4035](https://github.com/storytold/photocraft/pull/4035) fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778) — NOT RUN +- [#5870](https://github.com/storytold/photocraft/pull/5870) fix(format): reject duplicate native layer identities — NOT RUN +- [#6387](https://github.com/storytold/photocraft/pull/6387) Video Frames to Layers: every page of a multi-page TIFF (part of #6379) — NOT RUN +- [#8897](https://github.com/storytold/photocraft/pull/8897) fix(io): read linked smart objects for PSD export as regular files only — NOT RUN +- [#9339](https://github.com/storytold/photocraft/pull/9339) fix(export): embed requested sRGB profile for untagged sources (#9325) — NOT RUN +- [#9342](https://github.com/storytold/photocraft/pull/9342) fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325) — NOT RUN +- [#9401](https://github.com/storytold/photocraft/pull/9401) fix(format): a save on macOS keeps the file's creation time (#9388) — NOT RUN +- [#9416](https://github.com/storytold/photocraft/pull/9416) Affinity reader: contiguous curve records and a cheaper object tag index — NOT RUN +- [#9418](https://github.com/storytold/photocraft/pull/9418) Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073) — NOT RUN +- [#9457](https://github.com/storytold/photocraft/pull/9457) raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes — NOT RUN +- [#9590](https://github.com/storytold/photocraft/pull/9590) fix(io): PSD layer-effect contours keep at most 16 points — NOT RUN +- [#9596](https://github.com/storytold/photocraft/pull/9596) fix(io): drop a kept Txt2 once no type layers are left — NOT RUN + +### Painting + +- [#4763](https://github.com/storytold/photocraft/pull/4763) refactor(liquify): reuse native brush resizing and anchored HUD feedback — NOT RUN +- [#4985](https://github.com/storytold/photocraft/pull/4985) feat(ui): add Shift+right-click brush mode picker — NOT RUN +- [#5082](https://github.com/storytold/photocraft/pull/5082) Mixer Brush Alt-click loads reservoir while preserving foreground — NOT RUN +- [#5165](https://github.com/storytold/photocraft/pull/5165) Fix: brush section selection — NOT RUN +- [#5939](https://github.com/storytold/photocraft/pull/5939) fix(paint): scale blur sigma with brush size (#5892) — NOT RUN +- [#6139](https://github.com/storytold/photocraft/pull/6139) Fix painting workflows, File Info and layer selection — NOT RUN +- [#6236](https://github.com/storytold/photocraft/pull/6236) Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098) — NOT RUN +- [#7848](https://github.com/storytold/photocraft/pull/7848) fix(retouch): preserve Smudge pickup with live-pixel stroke commits — NOT RUN +- [#9623](https://github.com/storytold/photocraft/pull/9623) Pencil: keep a 1 px freehand line connected where it turns — NOT RUN + +### Platform and reliability + +- [#3554](https://github.com/storytold/photocraft/pull/3554) fix(packaging): launch bundled CLI directly from AppImage — NOT RUN +- [#4742](https://github.com/storytold/photocraft/pull/4742) fix(macos): bundle photocraft-cli and resolve it from droplet scripts — NOT RUN +- [#6383](https://github.com/storytold/photocraft/pull/6383) Windows releases ship a symbols zip: the release build's PDBs for profilers (#688) — NOT RUN +- [#7743](https://github.com/storytold/photocraft/pull/7743) packaging: the portable zip holds the files, not a folder with the files in it — NOT RUN +- [#9015](https://github.com/storytold/photocraft/pull/9015) fix(xtask): reject leading zeroes in numeric prerelease identifiers — NOT RUN +- [#9019](https://github.com/storytold/photocraft/pull/9019) fix(xtask): reject unknown performance benchmark selections — NOT RUN +- [#9110](https://github.com/storytold/photocraft/pull/9110) test: GPU tests honour WGPU_BACKEND, so they run on Windows with an Intel GPU (#4) — NOT RUN +- [#9436](https://github.com/storytold/photocraft/pull/9436) Startup: a failed launch says why in a dialog instead of closing silently (#7643) — NOT RUN + +### Selection, paths and transforms + +- [#2300](https://github.com/storytold/photocraft/pull/2300) Scroll the viewport while outlining with Polygonal Lasso — NOT RUN +- [#3464](https://github.com/storytold/photocraft/pull/3464) fix(vector): target selected saved paths and own Pen gestures — NOT RUN +- [#5453](https://github.com/storytold/photocraft/pull/5453) Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1 — NOT RUN +- [#5493](https://github.com/storytold/photocraft/pull/5493) Import Photoshop custom shape sets into the Shapes panel — NOT RUN +- [#5515](https://github.com/storytold/photocraft/pull/5515) Selecting subject now automatically applies to selected layer, and us… — NOT RUN +- [#5650](https://github.com/storytold/photocraft/pull/5650) Guides: ⌥ turns a dragged guide; dropping one on either ruler deletes it (#5034) — NOT RUN +- [#6026](https://github.com/storytold/photocraft/pull/6026) Marquee: Fixed Size selects exactly the entered width and height (fixes #6015) — NOT RUN +- [#6621](https://github.com/storytold/photocraft/pull/6621) Crop: right-click on the pending crop opens the crop menu (#6561) — NOT RUN +- [#7648](https://github.com/storytold/photocraft/pull/7648) Pen: Path Operations menu, as in Photoshop (#1397) — NOT RUN +- [#8074](https://github.com/storytold/photocraft/pull/8074) Transform: remember selected interpolation (#8067) — NOT RUN +- [#8896](https://github.com/storytold/photocraft/pull/8896) Select and Mask: Photoshop's full-window workspace (#7069, #8148) — NOT RUN +- [#8986](https://github.com/storytold/photocraft/pull/8986) Path tools: edit the saved path selected in the Paths panel (#8478) — NOT RUN +- [#9493](https://github.com/storytold/photocraft/pull/9493) fix(vector): a mask feather continues the mask past the canvas edge — NOT RUN + +### Type and typography + +- [#3139](https://github.com/storytold/photocraft/pull/3139) Draw right-to-left layer names in visual word order — NOT RUN +- [#4783](https://github.com/storytold/photocraft/pull/4783) Fix/4758 type extreme finite geometry 20261013s1215 — NOT RUN +- [#8347](https://github.com/storytold/photocraft/pull/8347) Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment) — NOT RUN +- [#9478](https://github.com/storytold/photocraft/pull/9478) fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470) — NOT RUN +- [#9523](https://github.com/storytold/photocraft/pull/9523) Fix Type on a Path wrapping and caret positioning across closed-path seams — NOT RUN +- [#9594](https://github.com/storytold/photocraft/pull/9594) fix(engine): copied type keeps its size across resolutions after an edit — NOT RUN + +### UI and workflow + +- [#3345](https://github.com/storytold/photocraft/pull/3345) Add grouped gradient presets and selectable style icons — NOT RUN +- [#3520](https://github.com/storytold/photocraft/pull/3520) fix(ui): restore square color chips in Studio Dark theme — NOT RUN +- [#4023](https://github.com/storytold/photocraft/pull/4023) i18n(es): add 548 missing rows, fix five filter and video terms — NOT RUN +- [#4042](https://github.com/storytold/photocraft/pull/4042) feat(canvas): rotate view with Shift + middle-mouse drag — NOT RUN +- [#4547](https://github.com/storytold/photocraft/pull/4547) fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478) — NOT RUN +- [#4883](https://github.com/storytold/photocraft/pull/4883) feat(ui): show selection and active-layer bounds in the status bar — NOT RUN +- [#4890](https://github.com/storytold/photocraft/pull/4890) Add scrollbar visibility preferences (#3262) — NOT RUN +- [#4938](https://github.com/storytold/photocraft/pull/4938) Layer Style: one dialog size on every page, and it fits short screens — NOT RUN +- [#5010](https://github.com/storytold/photocraft/pull/5010) Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938] — NOT RUN +- [#5270](https://github.com/storytold/photocraft/pull/5270) Layer Style: Gradient Overlay takes any number of colour stops (#5177) — NOT RUN +- [#5744](https://github.com/storytold/photocraft/pull/5744) fix(ui): keep detached documents in one interactive window — NOT RUN +- [#6449](https://github.com/storytold/photocraft/pull/6449) Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop — NOT RUN +- [#7255](https://github.com/storytold/photocraft/pull/7255) fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223) — NOT RUN +- [#8892](https://github.com/storytold/photocraft/pull/8892) ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte — NOT RUN +- [#8985](https://github.com/storytold/photocraft/pull/8985) Color panel and Color Picker: clip the round cursor to the colour field — NOT RUN +- [#9004](https://github.com/storytold/photocraft/pull/9004) fix(ui): enlarge Move tool alignment icons for legibility — NOT RUN +- [#9172](https://github.com/storytold/photocraft/pull/9172) Gradient Fill layer: default to Foreground to Transparent (#8708) — NOT RUN +- [#9282](https://github.com/storytold/photocraft/pull/9282) feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer — NOT RUN +- [#9354](https://github.com/storytold/photocraft/pull/9354) fix(ui): Full Screen Mode needs a document (#9349) — NOT RUN +- [#9481](https://github.com/storytold/photocraft/pull/9481) fix(ui): new layer effects start from the Make Default values — NOT RUN +- [#9504](https://github.com/storytold/photocraft/pull/9504) fix(ui): Keyboard Shortcuts records ⌘Z instead of undoing the capture — NOT RUN +- [#9517](https://github.com/storytold/photocraft/pull/9517) fix(ui): Layer Style scrolls on a short window so OK stays on screen — NOT RUN +- [#9597](https://github.com/storytold/photocraft/pull/9597) fix(ui): the layer list doesn't move with no layer selected — NOT RUN +- [#9598](https://github.com/storytold/photocraft/pull/9598) fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon — NOT RUN +- [#9606](https://github.com/storytold/photocraft/pull/9606) fix(ui): the Color panel remembers its options across restarts — NOT RUN +- [#9703](https://github.com/storytold/photocraft/pull/9703) Fix adjustment layer name clicks targeting masks — NOT RUN + +## Frozen execution record and acceptance + +- Upstream base: `5f9b4391874aabc9deb2fcb905267613e4d93ec8`. +Validated and packaged source: `f5b3f1238bb414b43851ebe86f3a58ce83bb9b48`. Base: `5f9b4391874aabc9deb2fcb905267613e4d93ec8`. +Version: `0.17.0-integration.20261029.f5b3f123`; Windows x64; pinned fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. +[Full-sweep CI](https://github.com/FredApps/photocraft/actions/runs/40858867520); [portable build](https://github.com/FredApps/photocraft/actions/runs/40867283787); +[download artifact](https://github.com/FredApps/photocraft/actions/runs/40867283787/artifacts/12895845995). Portable ZIP: `photocraft-0.17.0-integration.20261029.f5b3f123-windows-x64-portable.zip`. +SHA256: `4125eccd5b009f30dc2d72f64bfafcf32220dc5574f765d0b3664ac4d9f992ba`. Artifact ID `12895845995`, job ID `123425396034`. +The source SHA was checked against the run and artifact metadata. The ZIP was inspected for portable +content and x64 PE executables; this is not a claim that GUI/manual cases passed. +Release workflow 390299892 was temporarily enabled for the authorized dispatch and its original +`disabled_manually` state was restored while the accepted run continued. No stable publication or installation. +This test build is **unsigned** because the fork has no Windows signing secrets. The packaging job +executed the CLI version check and printed the expected version and `f5b3f1238` source prefix. + +- PR heads and approval receipts: `approved-selection.json`; current per-head ancestry/status: `integration-status.json`. +- Dependencies: Cutout #8191 → #8192 → #8193 → #7903; Layer Style #4938 → #5010; #7848 prerequisite #7281 is merged upstream. +- Executable per-PR cases and blank result fields: [per-pr-regressions.md](per-pr-regressions.md) and [regression-results.json](regression-results.json). + +Entry: exact frozen build; all selected heads ancestors; no unresolved integration conflicts; fixtures, fonts, corpus pins and adapter available. The branch meets the ancestry and resolved-merge prerequisites. The exact portable artifact is recorded below. Manual entry still requires the specified fixtures/fonts/oracles; manual execution has not begun. +Exit: relevant automated gates PASSED; P0 manual cases PASSED; no crash/data-loss regression; residual exclusions, unsupported cases and skipped stages explicitly listed. Do not lower corpus/parity floors or tolerances. PASSED means executed with supporting output; FAILED means an assertion or expected result failed; BLOCKED means a prerequisite prevented execution; SKIPPED needs a deliberate reason; NOT RUN means no execution was attempted. + +P0 scenarios are M01 file exchange, M02 profile fidelity, M03 combined precision/Fill, M04 Blend If, M09 bounded/authorized automation and M11 real engine rerender. Also prioritize these combined flows on synthetic/public fixtures: + +- Selection → mask → transform → PNG: preserve target ownership, selection geometry, cancellation and one-step Undo/Redo across the selected path/marquee/mask fixes. +- Retouch → adjustment → export: preserve Smudge/Pencil results, mask coverage, HDR/Fill blending and captured output profile; Undo/Redo restores each step. +- Preview Cancel across layer styles, transforms and filters: source document, history and cached previews return to the pre-preview state. +- Actions replay on a second document with different IDs and duplicate layer names: intended named targets change, unrelated layers and source files remain unchanged. +- Native save/reopen and malformed inputs: keep valid work intact on refusal, duplicate layer IDs, extreme type geometry, occupied control port and invalid plugin/automation input. Recovery behavior beyond the selected fixes is an exploratory gap, not a claimed new capability. +- CPU/GPU comparison plus large-document zoom/pan/brush: record actual adapter/backend, fallback reasons, output differences, latency and peak memory; GPU early-return skips do not establish parity. +- Workspace/modal focus with repeated key/pointer input: no background command edits, stale gesture ownership or unexpected shortcut history routing. + +For Photoshop acceptance, record Photoshop version/platform, reference fixture identity, profiles and depth, rendering/export options, expected tolerance and its exact repository-document source, and actual image/diff evidence. Use the repository's established oracle thresholds for the specific path; do not invent one universal tolerance. A PhotoCraft CPU/GPU comparison is a separate test from a Photoshop oracle. Reopen exported PSD/PSB in Photoshop before claiming Photoshop acceptance. + +## Final automated execution and portable build + +Validated and packaged source: `f5b3f1238bb414b43851ebe86f3a58ce83bb9b48`. Base: `5f9b4391874aabc9deb2fcb905267613e4d93ec8`. +Version: `0.17.0-integration.20261029.f5b3f123`; Windows x64; pinned fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. +[Full-sweep CI](https://github.com/FredApps/photocraft/actions/runs/40858867520); [portable build](https://github.com/FredApps/photocraft/actions/runs/40867283787); +[download artifact](https://github.com/FredApps/photocraft/actions/runs/40867283787/artifacts/12895845995). Portable ZIP: `photocraft-0.17.0-integration.20261029.f5b3f123-windows-x64-portable.zip`. +SHA256: `4125eccd5b009f30dc2d72f64bfafcf32220dc5574f765d0b3664ac4d9f992ba`. Artifact ID `12895845995`, job ID `123425396034`. +The source SHA was checked against the run and artifact metadata. The ZIP was inspected for portable +content and x64 PE executables; this is not a claim that GUI/manual cases passed. +Release workflow 390299892 was temporarily enabled for the authorized dispatch and its original +`disabled_manually` state was restored while the accepted run continued. No stable publication or installation. +This test build is **unsigned** because the fork has no Windows signing secrets. The packaging job +executed the CLI version check and printed the expected version and `f5b3f1238` source prefix. + +| Gate | State | Evidence / limitation | +|---|---|---| +| Frozen selected-head ancestry | PASSED | 108/108 selected heads are ancestors of `f5b3f1238bb414b43851ebe86f3a58ce83bb9b48`. | +| Full Linux gate | PASSED | 12,904 passed, 97 skipped; fmt, strict Clippy, layers, scorecard, tests, panic hunt, doctests, wasm and i18n passed. | +| Command panic hunt | PASSED | One requested ignored test executed and passed; one other test skipped. | +| Windows unit | PASSED | 10,326 passed, 79 skipped; one nextest leaky-process warning. | +| Windows integration | PASSED | 2,501 passed, 19 skipped; two nextest leaky-process warnings. | +| Without HEIF | PASSED | 1,887 passed, 14 skipped. | +| Pinned corpus / actual engine rerender | PASSED | Separate corpus job passed, including 2,428 engine tests and smart-filter rerender cases; cached IO round-trip is separate coverage. | +| Morphology / quick performance / web-wasm | PASSED | All three requested jobs passed at the frozen source. No general large-image performance claim follows. | +| Packaging lint / conflict scan | PASSED | All four packaging-lint jobs passed; zero conflict-marker hits across 2,630 tracked files. Both independent packaging jobs preserved. | +| Portable archive | VERIFIED | Exact-source artifact and ZIP/PE inspection; see build record. GUI acceptance remains NOT RUN. | +| Optional fuzz | SKIPPED | Not requested for this CI dispatch. | +| Manual / Photoshop / platform acceptance | NOT RUN | Use the Windows entry sheet and per-PR appendix; unavailable required capabilities are BLOCKED, not PASSED. | + +### Remaining coverage and historical failures + +All 108 manual case records remain NOT RUN, with blank Actual/Evidence/Tester fields. Photoshop +reopening and reference diffs, macOS droplets, Unix FIFO behavior on Windows, actual Windows Intel/DX12 +hardware, same-run executable/PDB identity, and selected large-image hot paths require their specified +environments and evidence. Two Cairo-dependent local text cases returned early because the required +font was unavailable; their geometry coverage remains BLOCKED. A green runner result alone cannot +establish that a GPU/font-dependent assertion executed. + +Nextest reported leaky-process warnings for the passing Windows tests +`panels::histogram_panel::tests::cache_levels_one_still_throttles_recounts_and_an_uncached_refresh_does_not`, +`workspace_presets::custom_workspaces_restore_timeline_visibility_and_dock_after_presets`, and +`workspace_presets::open_timeline_is_saved_without_saving_playback`. Preserve those warnings for follow-up; +they were not assertion failures and were not hidden by changing thresholds. + +Earlier checkpoints failed localization, overwrite-protection fixture, style-source and ownership/UI +regressions. Their fixes preserved the assertions and protections. A broad local UI attempt at `c3d1e816c` +aborted with Windows `0xc0000005` after 3,788 completed success lines and three assertion failures; +it was not an aggregate pass. The three targeted corrections passed afterward and final process-isolated +hosted suites passed. The monolithic local abort is retained as a historical gap rather than claimed fixed. + +The packaging run's macOS universal job could not start because GitHub reported account payment/spending +restrictions for its runner. No billing, credentials, permissions or environment settings were changed. +FreeBSD aarch64 also failed VM provisioning because its configured package repositories lacked `cmake`; +that job did not compile PhotoCraft. Neither job passes its platform's acceptance requirements. +Record the overall packaging conclusion separately from the successful Windows artifact job. + +### Selection refresh + +107 of the 108 frozen selected heads still matched open, non-draft, current-head FredApps approvals +at the 2026-10-29 12:06 UTC refresh ([receipts](approval-refresh.json)). #5493 advanced without a matching approval: approved +`f97eb2849a68e1ed1c08c5b378770cf35cbcb72d` remains integrated; newer +`67f57c3cf1e7de045aee03a305663d9560daa3e3` is excluded. #5493 does not count toward the 107 current +matching approvals. The minimum of 100 remains satisfied. Immutable approval receipts are preserved. + +See [Windows portable test entry sheet](windows-portable-test-protocol.md) for practical GUI steps. diff --git a/docs/integration-testing/approved-selection.json b/docs/integration-testing/approved-selection.json new file mode 100644 index 0000000000..41268b9e96 --- /dev/null +++ b/docs/integration-testing/approved-selection.json @@ -0,0 +1,17409 @@ +{ + "source": "fresh GitHub GraphQL and REST reads", + "captured_at": "2026-10-29T02:24:09.464811+00:00", + "count": 108, + "prs": [ + { + "number": 9703, + "title": "Fix adjustment layer name clicks targeting masks", + "url": "https://github.com/storytold/photocraft/pull/9703", + "body": "Fixes #9694\n\nClicking an adjustment layer's name now selects its contents instead of its mask, including when that same layer's pixel or vector mask was previously selected. Explicit mask-thumbnail clicks and new-layer creation keep their existing behavior. Fill-layer row targeting and rasterization prompts, and ordinary raster-layer row behavior, are unchanged.\n\nRegression coverage uses actual native UI clicks at 1x and 2x, checks the selected content-thumbnail brackets, and preserves the existing #3369 Properties/creation regression. The new regression fails on the base revision and passes with this fix. An actual Fill-black regression verifies that adjustment content is preserved and only its mask changes. Pixel painting on adjustment layers retains its existing mask fallback.\n\nValidation:\n- 29 mask-thumbnail tests pass; independent code review found no blocker.\n- Strict all-target UI Clippy, formatting, layering, and WebAssembly checks pass.\n- Full UI library run: 4,013 passed, 26 failed, 18 ignored. Integration: 132 passed, 1 failed, 1 ignored. All 27 failures are missing-glyph panics in egui 0.37 with the fonts available in this local build; the full suite is not green. Doctests pass (0 tests).\n- Native macOS offscreen screenshots rendered and visually inspected. Windows/Linux UI has not been manually tested.\n\nBefore (mask thumbnail selected after clicking the name):\n\n![Before](https://raw.githubusercontent.com/vanvo/photocraft/d879000b88edd76743a7774a0517b5bcd2e63ebd/before.png)\n\nAfter (content thumbnail selected after the same click):\n\n![After](https://raw.githubusercontent.com/vanvo/photocraft/d879000b88edd76743a7774a0517b5bcd2e63ebd/after.png)\n\n[Image attribution and licenses](https://raw.githubusercontent.com/vanvo/photocraft/d879000b88edd76743a7774a0517b5bcd2e63ebd/ATTRIBUTION.md)", + "state": "OPEN", + "isDraft": false, + "merged": false, + "headRefOid": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9", + "baseRefName": "main", + "files": { + "nodes": [ + { + "path": "crates/ui-egui/src/mask_thumbs_ui/tests.rs" + }, + { + "path": "crates/ui-egui/src/panels.rs" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": { + "nodes": [ + { + "name": "select platforms", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682358085" + }, + { + "name": "sonarcloud / Plan coverage shards", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095888/job/121681757819" + }, + { + "name": "sonarcloud / Build instrumented test archive", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095888/job/121681861941" + }, + { + "name": "gate / gate (ubuntu-24.04)", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682358927" + }, + { + "name": "sonarcloud / Tests ${{ matrix.shard }}/${{ inputs.shards }}", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095888/job/121681862838" + }, + { + "name": "test (without heif)", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682358648" + }, + { + "name": "corpus tests", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682359068" + }, + { + "name": "sonarcloud / SonarCloud Scan", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095888/job/121681860253" + }, + { + "name": "fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682361404" + }, + { + "name": "test (windows-latest, unit)", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682729957" + }, + { + "name": "test (windows-latest, integration)", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682729898" + }, + { + "name": "web build and wasm size gate", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40354095607/job/121682729794" + }, + { + "context": "CodeRabbit", + "state": "SUCCESS", + "targetUrl": null + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + } + } + ] + }, + "approval": { + "state": "APPROVED", + "submittedAt": "2026-10-26T17:06:00Z", + "url": "https://github.com/storytold/photocraft/pull/9703#pullrequestreview-5655990760", + "body": "Reviewed at SHA 3605d30.\n\nFixes #9694: clicking an adjustment layer's name in the Layers panel now selects its content, not its mask (`ui.maskTarget` false), including when that layer's pixel or vector mask was selected before. In `layer_row` the old `content_less` rule (adjustment and fill rows default to the mask) is split: fills with a mask keep the mask default; adjustments take the `maskTarget false` branch (`on_thumb || adjustment || !row.primary`). Clicking a mask thumbnail still targets that mask, the vector mask thumbnail still targets the vector mask, and creating a layer keeps its existing behaviour.\n\nChecked: the whole `panels.rs` change (the branch order is unchanged, only the conditions); the edited expectation in the existing #3369 test (fill rows still target the mask, adjustments do not, no vector mask target); the new test across two adjustment kinds at 1x and 2x: mask thumbnail clicks, name click after each, bracket on the content thumbnail, another-layer-then-name, `paint_target` still \"mask\", and Fill black editing the mask and not the adjustment content.\nNote: this is a deliberate behaviour change for adjustment rows, whereas fill rows keep the old default; it is covered by the tests and matches the issue, but it is a product choice I did not check against the other app.\nNot verified: I did not run the tests; the author reports 29 mask-thumbnail tests passing and a local failure set of font-glyph panics that predates this change, which I did not reproduce.\n", + "commit": { + "oid": "3605d30c38294c4d970e52f4a02f44f97e8bc1a9" + }, + "author": { + "login": "FredApps" + } + }, + "manual_status": "NOT RUN", + "subsystem": "UI and workflow", + "new_test_names": [ + "adjustment_names_target_contents_instead_of_masks" + ], + "regression_source": "https://github.com/storytold/photocraft/pull/9703" + }, + { + "number": 9651, + "title": "perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426)", + "url": "https://github.com/storytold/photocraft/pull/9651", + "body": "Refs #5426. Follow-up to #5499.\n\n## Problem\n\n#5499 parallelized `local_blur` and the per-dab mix in `DabTool::Focus`, but `local_sharpen` (`crates/algo/src/retouch.rs`) stayed single-threaded. `protect_detail` also recomputed boundary clamping and strided lookups for the 8-neighbour window inside the per-channel loop, for every pixel in the dab. With large brushes (500–2500 px) interactive strokes lagged badly and stalled the application.\n\n## Change\n\n- `local_sharpen` processes window rows in parallel (`out.par_chunks_mut(stride)`), with a `wasm32` serial fallback like `local_blur`.\n- `protect_detail` computes the 3×3 neighbourhood offsets once, outside the channel loop.\n- Interior pixels (`x > 0 && x + 1 < w && y > 0 && y + 1 < h`) use direct stride offsets and skip boundary clamping, which covers over 99.9% of pixels in large dabs.\n- New `retouch_sharpen_exact` integration test compares against the frozen scalar oracle bit for bit across layouts, edges and channels.\n- New `bench_sharpen_tool` example, matching `bench_blur_tool`.\n\n## Timings\n\nGitHub Actions `rc-bench`, 4 vCPU AMD EPYC 9V45. Output is bit-identical at every size (fingerprint `96d1f22de93b16ec` at 8-bit, `9282e5cfc9e3ec48` at 16-bit).\n\n| Depth | Brush | Before | After | Less time |\n| --- | ---: | ---: | ---: | ---: |\n| 8-bit | 100 px | 40.75 ms | 27.37 ms | 33% |\n| 8-bit | 500 px | 214.14 ms | 153.37 ms | 28% |\n| 8-bit | 1000 px | 738.67 ms | 571.52 ms | 23% |\n| 16-bit | 100 px | 39.94 ms | 27.42 ms | 31% |\n| 16-bit | 500 px | 196.86 ms | 134.26 ms | 32% |\n| 16-bit | 1000 px | 597.21 ms | 447.68 ms | 25% |\n\n## Tests\n\n- `cargo test -p photocraft-algo --test it -- retouch_sharpen_exact` (2 passed)\n- Bit-identical fingerprints between base and this PR at every depth and size.\n- Wasm size gate and clean `cargo clippy`.", + "state": "OPEN", + "isDraft": false, + "merged": false, + "headRefOid": "67666bdf72a58066df868868cef82e5b9cc838de", + "baseRefName": "main", + "files": { + "nodes": [ + { + "path": "crates/algo/src/retouch.rs" + }, + { + "path": "crates/algo/tests/it/main.rs" + }, + { + "path": "crates/algo/tests/it/retouch_sharpen_exact.rs" + }, + { + "path": "crates/engine/examples/bench_sharpen_tool.rs" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": { + "nodes": [ + { + "name": "select platforms", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393509820" + }, + { + "name": "select platforms", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510766804" + }, + { + "name": "sonarcloud / Plan coverage shards", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685377/job/121510767326" + }, + { + "name": "gate", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393597582" + }, + { + "name": "sonarcloud / Build instrumented test archive", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685377/job/121510848043" + }, + { + "name": "gate / gate (ubuntu-24.04)", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510766978" + }, + { + "name": "test (without heif)", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393508322" + }, + { + "name": "sonarcloud / Tests ${{ matrix.shard }}/${{ inputs.shards }}", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685377/job/121510849890" + }, + { + "name": "test (without heif)", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510766972" + }, + { + "name": "corpus tests", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393560877" + }, + { + "name": "corpus tests", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510766515" + }, + { + "name": "sonarcloud / SonarCloud Scan", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685377/job/121510847640" + }, + { + "name": "fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510769284" + }, + { + "name": "fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393509224" + }, + { + "name": "selection-morphology", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393564915" + }, + { + "name": "test (windows-latest, ${{ matrix.part }})", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510958391" + }, + { + "name": "selection-morphology-quick-perf", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393558240" + }, + { + "name": "web build and wasm size gate", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40304685201/job/121510956808" + }, + { + "name": "test (windows-latest, ${{ matrix.part }})", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393511108" + }, + { + "name": "web build and wasm size gate", + "status": "COMPLETED", + "conclusion": "SKIPPED", + "detailsUrl": "https://github.com/storytold/photocraft/actions/runs/40562083384/job/122393511236" + }, + { + "context": "CodeRabbit", + "state": "SUCCESS", + "targetUrl": null + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + } + } + ] + }, + "approval": { + "state": "APPROVED", + "submittedAt": "2026-10-26T11:38:57Z", + "url": "https://github.com/storytold/photocraft/pull/9651#pullrequestreview-5651366614", + "body": "Reviewed at SHA 67666bd.\n\nWhat it does: `local_sharpen` in `algo/src/retouch.rs` is now row-parallel with rayon (a sequential `chunks_mut` on wasm32). For `protect_detail` it computes the 3x3 neighbour offsets once per pixel, using a branch-free fast path for interior pixels and the old clamped lookup at the edges. An empty window or zero channels returns an empty Vec early (#5426).\n\nChecked: each output row is a disjoint `stride = ow*ch` chunk, so the rows are independent. The interior test (`x>0 && x+10 && y+1